From c2ac27edefe84403d6977c0fef5bf90649166f16 Mon Sep 17 00:00:00 2001 From: Zero Liu Date: Tue, 19 May 2026 14:55:21 -0700 Subject: [PATCH] feat(agent-mode): introduce Agent Mode feature Squashed from 8 commits on zero/acp-test: - feat(agent-mode): introduce Agent Mode feature (squashed) - Fix eslint - hide add context button - chore: ban parent-relative imports and rewrite existing ones to @/ aliases - wip(agent-mode): skills management + slash command revamp - fix(agent-mode): initialize SkillManager before preload probes - fix(build): align svgr jsxRuntime with tsconfig classic jsx - fix(test): stub ItemView/WorkspaceLeaf in ChatSingleMessage obsidian mock --- .gitignore | 5 + AGENTS.md | 18 +- CLAUDE.md | 2 +- __mocks__/@agentclientprotocol/sdk.js | 62 + __mocks__/@anthropic-ai/claude-agent-sdk.js | 44 + __mocks__/obsidian.js | 17 +- __mocks__/svg.js | 6 + designdocs/ACP_LIMITATIONS_REPORT.md | 709 +++++ designdocs/AGENT_PROCESSING_UI.md | 274 ++ designdocs/CODE_REVIEW_GUIDE.md | 433 +++ designdocs/SETTINGS_REDESIGN_PRD.md | 433 +++ designdocs/SKILLS_MANAGEMENT.md | 1132 +++++++ designdocs/todo/ACP_DESIGN.md | 4 + designdocs/todo/AGENT_MODE_TODOS.md | 107 + .../todo/MCP_EXTERNALLY_MANAGED_SERVERS.md | 59 + esbuild.config.mjs | 56 +- eslint.config.mjs | 183 +- jest.config.js | 3 + nodeModuleShim.mjs | 6 +- package-lock.json | 2811 +++++++++++++---- package.json | 16 +- scripts/patchRendererUnsafeUnref.js | 241 ++ .../chainRunner/BaseChainRunner.ts | 2 +- src/agentMode/AGENTS.md | 172 + src/agentMode/CLAUDE.md | 1 + src/agentMode/acp/AcpBackendProcess.test.ts | 182 ++ src/agentMode/acp/AcpBackendProcess.ts | 549 ++++ src/agentMode/acp/AcpProcessManager.ts | 176 ++ src/agentMode/acp/VaultClient.test.ts | 163 + src/agentMode/acp/VaultClient.ts | 129 + src/agentMode/acp/debugTap.ts | 182 ++ src/agentMode/acp/index.ts | 4 + src/agentMode/acp/nodeShebangPath.ts | 35 + src/agentMode/acp/types.ts | 27 + src/agentMode/acp/wireTranslate.ts | 425 +++ .../backends/claude/AskUserQuestionModal.tsx | 164 + .../backends/claude/ClaudeInstallModal.tsx | 46 + .../backends/claude/ClaudeSettingsPanel.tsx | 148 + .../claude/claudeBinaryResolver.test.ts | 155 + .../backends/claude/claudeBinaryResolver.ts | 123 + src/agentMode/backends/claude/descriptor.ts | 284 ++ src/agentMode/backends/claude/index.ts | 1 + src/agentMode/backends/claude/logo.svg | 1 + .../backends/codex/CodexBackend.test.ts | 132 + src/agentMode/backends/codex/CodexBackend.ts | 77 + .../backends/codex/CodexInstallModal.tsx | 27 + .../backends/codex/CodexSettingsPanel.tsx | 26 + .../backends/codex/descriptor.test.ts | 31 + src/agentMode/backends/codex/descriptor.ts | 157 + src/agentMode/backends/codex/index.ts | 1 + src/agentMode/backends/codex/logo.svg | 1 + .../backends/opencode/OpencodeBackend.test.ts | 560 ++++ .../backends/opencode/OpencodeBackend.ts | 315 ++ .../opencode/OpencodeBinaryManager.test.ts | 317 ++ .../opencode/OpencodeBinaryManager.ts | 599 ++++ .../opencode/OpencodeInstallModal.tsx | 209 ++ .../opencode/OpencodeSettingsPanel.tsx | 151 + .../backends/opencode/descriptor.test.ts | 151 + src/agentMode/backends/opencode/descriptor.ts | 202 ++ src/agentMode/backends/opencode/index.ts | 1 + src/agentMode/backends/opencode/logo.svg | 1 + .../opencode/platformResolver.test.ts | 110 + .../backends/opencode/platformResolver.ts | 140 + src/agentMode/backends/opencode/prompts.ts | 77 + src/agentMode/backends/registry.test.ts | 42 + src/agentMode/backends/registry.ts | 32 + .../backends/shared/BinaryInstallContent.tsx | 106 + .../shared/SimpleBackendSettingsPanel.tsx | 107 + .../backends/shared/simpleBinaryBackend.ts | 52 + src/agentMode/index.ts | 136 + .../sdk/ClaudeSdkBackendProcess.test.ts | 419 +++ src/agentMode/sdk/ClaudeSdkBackendProcess.ts | 622 ++++ src/agentMode/sdk/effortOption.ts | 105 + src/agentMode/sdk/permissionBridge.test.ts | 279 ++ src/agentMode/sdk/permissionBridge.ts | 206 ++ src/agentMode/sdk/sdkDebugTap.test.ts | 117 + src/agentMode/sdk/sdkDebugTap.ts | 112 + .../sdk/sdkMessageTranslator.test.ts | 537 ++++ src/agentMode/sdk/sdkMessageTranslator.ts | 323 ++ src/agentMode/sdk/toolMeta.ts | 63 + src/agentMode/session/AgentChatBackend.ts | 76 + .../AgentChatPersistenceManager.test.ts | 182 ++ .../session/AgentChatPersistenceManager.ts | 453 +++ src/agentMode/session/AgentChatUIState.ts | 151 + .../session/AgentMessageStore.test.ts | 223 ++ src/agentMode/session/AgentMessageStore.ts | 394 +++ src/agentMode/session/AgentModelPreloader.ts | 194 ++ src/agentMode/session/AgentSession.test.ts | 1408 +++++++++ src/agentMode/session/AgentSession.ts | 1206 +++++++ .../session/AgentSessionManager.test.ts | 815 +++++ src/agentMode/session/AgentSessionManager.ts | 941 ++++++ src/agentMode/session/applyPersistedMode.ts | 32 + .../session/backendSettingsAccess.ts | 20 + src/agentMode/session/debugSink.test.ts | 101 + src/agentMode/session/debugSink.ts | 410 +++ src/agentMode/session/descriptor.ts | 191 ++ src/agentMode/session/errors.ts | 23 + .../session/expandCustomCommandPrefix.test.ts | 141 + .../session/expandCustomCommandPrefix.ts | 53 + src/agentMode/session/index.ts | 26 + src/agentMode/session/mcpResolver.test.ts | 174 + src/agentMode/session/mcpResolver.ts | 157 + src/agentMode/session/modelEnable.test.ts | 77 + src/agentMode/session/modelEnable.ts | 49 + src/agentMode/session/plan.ts | 48 + .../session/translateBackendState.test.ts | 724 +++++ .../session/translateBackendState.ts | 349 ++ src/agentMode/session/types.ts | 671 ++++ src/agentMode/skills/SkillManager.test.ts | 209 ++ src/agentMode/skills/SkillManager.ts | 775 +++++ src/agentMode/skills/agentPaths.ts | 13 + src/agentMode/skills/bulkMove.test.ts | 295 ++ src/agentMode/skills/bulkMove.ts | 222 ++ src/agentMode/skills/denyListComposer.test.ts | 97 + src/agentMode/skills/denyListComposer.ts | 46 + .../skills/discoverManagedSkills.test.ts | 219 ++ src/agentMode/skills/discoverManagedSkills.ts | 113 + src/agentMode/skills/importDetector.test.ts | 199 ++ src/agentMode/skills/importDetector.ts | 194 ++ src/agentMode/skills/index.ts | 60 + src/agentMode/skills/nodeFsAdapters.ts | 148 + src/agentMode/skills/reconcile.test.ts | 296 ++ src/agentMode/skills/reconcile.ts | 207 ++ src/agentMode/skills/renameWithRetry.ts | 29 + src/agentMode/skills/skillFormat.test.ts | 213 ++ src/agentMode/skills/skillFormat.ts | 271 ++ src/agentMode/skills/spawnDirective.test.ts | 91 + src/agentMode/skills/spawnDirective.ts | 59 + .../skills/suffixOnCollision.test.ts | 31 + src/agentMode/skills/suffixOnCollision.ts | 35 + src/agentMode/skills/symlinks.ts | 199 ++ src/agentMode/skills/toggleAgent.test.ts | 257 ++ src/agentMode/skills/toggleAgent.ts | 141 + src/agentMode/skills/types.ts | 57 + src/agentMode/skills/ui/AgentIconButton.tsx | 71 + .../skills/ui/DeleteConfirmDialog.tsx | 144 + src/agentMode/skills/ui/EmptyPlaceholder.tsx | 46 + .../skills/ui/ImportConsentDialog.tsx | 506 +++ src/agentMode/skills/ui/PropertiesDialog.tsx | 422 +++ src/agentMode/skills/ui/SkillRow.tsx | 226 ++ src/agentMode/skills/ui/SkillsSettings.tsx | 724 +++++ src/agentMode/skills/updateProperties.test.ts | 428 +++ src/agentMode/skills/updateProperties.ts | 213 ++ src/agentMode/ui/ActionCard.tsx | 89 + src/agentMode/ui/AgentChat.tsx | 546 ++++ src/agentMode/ui/AgentChatControls.tsx | 96 + src/agentMode/ui/AgentChatMessages.tsx | 159 + src/agentMode/ui/AgentMarkdownText.tsx | 35 + src/agentMode/ui/AgentModeChat.tsx | 113 + src/agentMode/ui/AgentModeStatus.tsx | 68 + src/agentMode/ui/AgentModelCatalogModal.tsx | 228 ++ src/agentMode/ui/AgentTabStrip.test.ts | 101 + src/agentMode/ui/AgentTabStrip.tsx | 522 +++ src/agentMode/ui/AgentTrailView.tsx | 208 ++ src/agentMode/ui/AggregateCard.tsx | 47 + src/agentMode/ui/CopilotAgentView.tsx | 100 + src/agentMode/ui/McpServersPanel.tsx | 271 ++ src/agentMode/ui/PermissionModal.tsx | 158 + src/agentMode/ui/PlanPreviewView.tsx | 287 ++ src/agentMode/ui/PlanProposalCard.tsx | 181 ++ src/agentMode/ui/ReasoningBlock.tsx | 71 + src/agentMode/ui/SelectedModelsList.tsx | 134 + src/agentMode/ui/SubAgentCard.tsx | 123 + .../ui/agentModePickerHelpers.test.ts | 154 + src/agentMode/ui/agentModePickerHelpers.ts | 55 + .../ui/agentModelPickerHelpers.test.ts | 387 +++ src/agentMode/ui/agentModelPickerHelpers.ts | 419 +++ src/agentMode/ui/agentTrail.test.ts | 311 ++ src/agentMode/ui/agentTrail.ts | 185 ++ src/agentMode/ui/diffRender.ts | 59 + src/agentMode/ui/index.ts | 10 + src/agentMode/ui/permissionPrompter.ts | 25 + src/agentMode/ui/planEntryStyles.ts | 11 + src/agentMode/ui/toolIcons.ts | 67 + src/agentMode/ui/toolSummaries.test.ts | 193 ++ src/agentMode/ui/toolSummaries.ts | 428 +++ src/agentMode/ui/useAgentModePicker.ts | 58 + src/agentMode/ui/useAgentModelPicker.ts | 96 + src/agentMode/ui/useBackendDescriptor.ts | 43 + src/agentMode/ui/useManagerSubscribe.ts | 39 + src/agentMode/ui/vaultPath.test.ts | 33 + src/agentMode/ui/vaultPath.ts | 55 + src/aiParams.ts | 6 +- src/commands/index.ts | 25 +- src/components/Chat.tsx | 7 +- src/components/agent/BinaryPathSetting.tsx | 108 + .../chat-components/AddContextButton.tsx | 66 + .../chat-components/AgentReasoningBlock.tsx | 143 +- .../chat-components/AtMentionTypeahead.tsx | 18 +- .../BottomLoadingIndicator.tsx | 30 + .../chat-components/ChatContextMenu.tsx | 59 +- .../chat-components/ChatHistoryPopover.tsx | 20 +- src/components/chat-components/ChatInput.tsx | 479 +-- .../chat-components/ChatMessages.tsx | 38 +- .../chat-components/ChatModeInput.tsx | 150 + .../ChatSingleMessage.test.tsx | 4 + .../chat-components/ContextControl.tsx | 8 +- .../chat-components/CopilotSpinner.tsx | 45 + .../chat-components/InlineMessageEditor.tsx | 2 +- .../chat-components/LexicalEditor.tsx | 17 +- .../attachChatViewLayoutObservers.ts | 79 + .../hooks/useAtMentionCategories.tsx | 47 +- .../hooks/useAtMentionSearch.ts | 7 +- .../chat-components/hooks/useNoteSearch.ts | 2 +- .../chat-components/hooks/useTagSearch.ts | 2 +- .../hooks/useTypeaheadPlugin.ts | 4 +- .../pills/ActiveNotePillNode.tsx | 2 +- .../pills/ActiveWebTabPillNode.tsx | 2 +- .../chat-components/pills/BasePillNode.tsx | 2 +- .../plugins/ActiveNotePillSyncPlugin.tsx | 2 +- .../plugins/AtMentionCommandPlugin.tsx | 33 +- .../plugins/FolderPillSyncPlugin.tsx | 5 +- .../plugins/KeyboardPlugin.tsx | 53 +- .../plugins/NotePillSyncPlugin.tsx | 2 +- .../chat-components/plugins/PastePlugin.tsx | 5 +- .../plugins/SlashCommandPlugin.tsx | 184 +- .../plugins/TextInsertionPlugin.tsx | 2 +- .../plugins/ToolPillSyncPlugin.tsx | 2 +- .../plugins/URLPillSyncPlugin.tsx | 2 +- .../plugins/WebTabPillSyncPlugin.tsx | 7 +- .../plugins/slashMenuItems.test.ts | 103 + .../chat-components/plugins/slashMenuItems.ts | 86 + .../chat-components/utils/lexicalTextUtils.ts | 16 +- src/components/composer/ApplyView.tsx | 4 +- src/components/modals/ReactModal.tsx | 36 + src/components/quick-ask/QuickAskInput.tsx | 6 +- src/components/ui/ModePicker.tsx | 90 + src/components/ui/ModelEffortPicker.tsx | 395 +++ src/components/ui/ModelSelector.tsx | 88 +- src/components/ui/badge.tsx | 3 +- src/components/ui/button.tsx | 3 +- src/constants.ts | 30 + src/contexts/PluginContext.tsx | 17 + src/core/ChatPersistenceManager.ts | 8 +- src/hooks/useChatScrolling.ts | 32 +- src/integration_tests/composerPrompt.test.ts | 2 +- src/lib/duration.test.ts | 34 + src/lib/duration.ts | 28 + src/main.ts | 201 +- src/search/dbOperations.ts | 2 +- src/search/v3/engines/FullTextEngine.ts | 6 +- src/search/v3/scoring/AdaptiveCutoff.test.ts | 2 +- src/search/v3/scoring/AdaptiveCutoff.ts | 4 +- .../v3/scoring/FolderBoostCalculator.test.ts | 2 +- .../v3/scoring/FolderBoostCalculator.ts | 4 +- src/search/v3/scoring/GraphBoostCalculator.ts | 4 +- src/search/v3/utils/ScoreNormalizer.test.ts | 2 +- src/search/v3/utils/ScoreNormalizer.ts | 2 +- src/settings/model.test.ts | 87 + src/settings/model.ts | 413 ++- src/settings/v2/SettingsMainV2.tsx | 118 +- .../v2/components/AdvancedSettings.tsx | 49 + src/settings/v2/components/AgentSettings.tsx | 288 ++ src/state/ChatUIState.ts | 56 +- src/styles/tailwind.css | 112 +- src/tools/allTools.validation.test.ts | 7 +- src/types/message.ts | 9 + src/utils.ts | 6 +- src/utils/chatHistoryUtils.ts | 39 + src/utils/detectBinary.ts | 63 + src/utils/errorUtils.test.ts | 36 + src/utils/errorUtils.ts | 12 + src/utils/pathUtils.test.ts | 93 + src/utils/pathUtils.ts | 54 + src/utils/rendererEventsShim.ts | 61 + src/utils/vaultAdapterUtils.ts | 25 + tailwind.config.js | 2 + typings/global.d.ts | 12 + typings/svg.d.ts | 5 + 269 files changed, 41803 insertions(+), 1466 deletions(-) create mode 100644 __mocks__/@agentclientprotocol/sdk.js create mode 100644 __mocks__/@anthropic-ai/claude-agent-sdk.js create mode 100644 __mocks__/svg.js create mode 100644 designdocs/ACP_LIMITATIONS_REPORT.md create mode 100644 designdocs/AGENT_PROCESSING_UI.md create mode 100644 designdocs/CODE_REVIEW_GUIDE.md create mode 100644 designdocs/SETTINGS_REDESIGN_PRD.md create mode 100644 designdocs/SKILLS_MANAGEMENT.md create mode 100644 designdocs/todo/AGENT_MODE_TODOS.md create mode 100644 designdocs/todo/MCP_EXTERNALLY_MANAGED_SERVERS.md create mode 100644 scripts/patchRendererUnsafeUnref.js create mode 100644 src/agentMode/AGENTS.md create mode 120000 src/agentMode/CLAUDE.md create mode 100644 src/agentMode/acp/AcpBackendProcess.test.ts create mode 100644 src/agentMode/acp/AcpBackendProcess.ts create mode 100644 src/agentMode/acp/AcpProcessManager.ts create mode 100644 src/agentMode/acp/VaultClient.test.ts create mode 100644 src/agentMode/acp/VaultClient.ts create mode 100644 src/agentMode/acp/debugTap.ts create mode 100644 src/agentMode/acp/index.ts create mode 100644 src/agentMode/acp/nodeShebangPath.ts create mode 100644 src/agentMode/acp/types.ts create mode 100644 src/agentMode/acp/wireTranslate.ts create mode 100644 src/agentMode/backends/claude/AskUserQuestionModal.tsx create mode 100644 src/agentMode/backends/claude/ClaudeInstallModal.tsx create mode 100644 src/agentMode/backends/claude/ClaudeSettingsPanel.tsx create mode 100644 src/agentMode/backends/claude/claudeBinaryResolver.test.ts create mode 100644 src/agentMode/backends/claude/claudeBinaryResolver.ts create mode 100644 src/agentMode/backends/claude/descriptor.ts create mode 100644 src/agentMode/backends/claude/index.ts create mode 100644 src/agentMode/backends/claude/logo.svg create mode 100644 src/agentMode/backends/codex/CodexBackend.test.ts create mode 100644 src/agentMode/backends/codex/CodexBackend.ts create mode 100644 src/agentMode/backends/codex/CodexInstallModal.tsx create mode 100644 src/agentMode/backends/codex/CodexSettingsPanel.tsx create mode 100644 src/agentMode/backends/codex/descriptor.test.ts create mode 100644 src/agentMode/backends/codex/descriptor.ts create mode 100644 src/agentMode/backends/codex/index.ts create mode 100644 src/agentMode/backends/codex/logo.svg create mode 100644 src/agentMode/backends/opencode/OpencodeBackend.test.ts create mode 100644 src/agentMode/backends/opencode/OpencodeBackend.ts create mode 100644 src/agentMode/backends/opencode/OpencodeBinaryManager.test.ts create mode 100644 src/agentMode/backends/opencode/OpencodeBinaryManager.ts create mode 100644 src/agentMode/backends/opencode/OpencodeInstallModal.tsx create mode 100644 src/agentMode/backends/opencode/OpencodeSettingsPanel.tsx create mode 100644 src/agentMode/backends/opencode/descriptor.test.ts create mode 100644 src/agentMode/backends/opencode/descriptor.ts create mode 100644 src/agentMode/backends/opencode/index.ts create mode 100644 src/agentMode/backends/opencode/logo.svg create mode 100644 src/agentMode/backends/opencode/platformResolver.test.ts create mode 100644 src/agentMode/backends/opencode/platformResolver.ts create mode 100644 src/agentMode/backends/opencode/prompts.ts create mode 100644 src/agentMode/backends/registry.test.ts create mode 100644 src/agentMode/backends/registry.ts create mode 100644 src/agentMode/backends/shared/BinaryInstallContent.tsx create mode 100644 src/agentMode/backends/shared/SimpleBackendSettingsPanel.tsx create mode 100644 src/agentMode/backends/shared/simpleBinaryBackend.ts create mode 100644 src/agentMode/index.ts create mode 100644 src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts create mode 100644 src/agentMode/sdk/ClaudeSdkBackendProcess.ts create mode 100644 src/agentMode/sdk/effortOption.ts create mode 100644 src/agentMode/sdk/permissionBridge.test.ts create mode 100644 src/agentMode/sdk/permissionBridge.ts create mode 100644 src/agentMode/sdk/sdkDebugTap.test.ts create mode 100644 src/agentMode/sdk/sdkDebugTap.ts create mode 100644 src/agentMode/sdk/sdkMessageTranslator.test.ts create mode 100644 src/agentMode/sdk/sdkMessageTranslator.ts create mode 100644 src/agentMode/sdk/toolMeta.ts create mode 100644 src/agentMode/session/AgentChatBackend.ts create mode 100644 src/agentMode/session/AgentChatPersistenceManager.test.ts create mode 100644 src/agentMode/session/AgentChatPersistenceManager.ts create mode 100644 src/agentMode/session/AgentChatUIState.ts create mode 100644 src/agentMode/session/AgentMessageStore.test.ts create mode 100644 src/agentMode/session/AgentMessageStore.ts create mode 100644 src/agentMode/session/AgentModelPreloader.ts create mode 100644 src/agentMode/session/AgentSession.test.ts create mode 100644 src/agentMode/session/AgentSession.ts create mode 100644 src/agentMode/session/AgentSessionManager.test.ts create mode 100644 src/agentMode/session/AgentSessionManager.ts create mode 100644 src/agentMode/session/applyPersistedMode.ts create mode 100644 src/agentMode/session/backendSettingsAccess.ts create mode 100644 src/agentMode/session/debugSink.test.ts create mode 100644 src/agentMode/session/debugSink.ts create mode 100644 src/agentMode/session/descriptor.ts create mode 100644 src/agentMode/session/errors.ts create mode 100644 src/agentMode/session/expandCustomCommandPrefix.test.ts create mode 100644 src/agentMode/session/expandCustomCommandPrefix.ts create mode 100644 src/agentMode/session/index.ts create mode 100644 src/agentMode/session/mcpResolver.test.ts create mode 100644 src/agentMode/session/mcpResolver.ts create mode 100644 src/agentMode/session/modelEnable.test.ts create mode 100644 src/agentMode/session/modelEnable.ts create mode 100644 src/agentMode/session/plan.ts create mode 100644 src/agentMode/session/translateBackendState.test.ts create mode 100644 src/agentMode/session/translateBackendState.ts create mode 100644 src/agentMode/session/types.ts create mode 100644 src/agentMode/skills/SkillManager.test.ts create mode 100644 src/agentMode/skills/SkillManager.ts create mode 100644 src/agentMode/skills/agentPaths.ts create mode 100644 src/agentMode/skills/bulkMove.test.ts create mode 100644 src/agentMode/skills/bulkMove.ts create mode 100644 src/agentMode/skills/denyListComposer.test.ts create mode 100644 src/agentMode/skills/denyListComposer.ts create mode 100644 src/agentMode/skills/discoverManagedSkills.test.ts create mode 100644 src/agentMode/skills/discoverManagedSkills.ts create mode 100644 src/agentMode/skills/importDetector.test.ts create mode 100644 src/agentMode/skills/importDetector.ts create mode 100644 src/agentMode/skills/index.ts create mode 100644 src/agentMode/skills/nodeFsAdapters.ts create mode 100644 src/agentMode/skills/reconcile.test.ts create mode 100644 src/agentMode/skills/reconcile.ts create mode 100644 src/agentMode/skills/renameWithRetry.ts create mode 100644 src/agentMode/skills/skillFormat.test.ts create mode 100644 src/agentMode/skills/skillFormat.ts create mode 100644 src/agentMode/skills/spawnDirective.test.ts create mode 100644 src/agentMode/skills/spawnDirective.ts create mode 100644 src/agentMode/skills/suffixOnCollision.test.ts create mode 100644 src/agentMode/skills/suffixOnCollision.ts create mode 100644 src/agentMode/skills/symlinks.ts create mode 100644 src/agentMode/skills/toggleAgent.test.ts create mode 100644 src/agentMode/skills/toggleAgent.ts create mode 100644 src/agentMode/skills/types.ts create mode 100644 src/agentMode/skills/ui/AgentIconButton.tsx create mode 100644 src/agentMode/skills/ui/DeleteConfirmDialog.tsx create mode 100644 src/agentMode/skills/ui/EmptyPlaceholder.tsx create mode 100644 src/agentMode/skills/ui/ImportConsentDialog.tsx create mode 100644 src/agentMode/skills/ui/PropertiesDialog.tsx create mode 100644 src/agentMode/skills/ui/SkillRow.tsx create mode 100644 src/agentMode/skills/ui/SkillsSettings.tsx create mode 100644 src/agentMode/skills/updateProperties.test.ts create mode 100644 src/agentMode/skills/updateProperties.ts create mode 100644 src/agentMode/ui/ActionCard.tsx create mode 100644 src/agentMode/ui/AgentChat.tsx create mode 100644 src/agentMode/ui/AgentChatControls.tsx create mode 100644 src/agentMode/ui/AgentChatMessages.tsx create mode 100644 src/agentMode/ui/AgentMarkdownText.tsx create mode 100644 src/agentMode/ui/AgentModeChat.tsx create mode 100644 src/agentMode/ui/AgentModeStatus.tsx create mode 100644 src/agentMode/ui/AgentModelCatalogModal.tsx create mode 100644 src/agentMode/ui/AgentTabStrip.test.ts create mode 100644 src/agentMode/ui/AgentTabStrip.tsx create mode 100644 src/agentMode/ui/AgentTrailView.tsx create mode 100644 src/agentMode/ui/AggregateCard.tsx create mode 100644 src/agentMode/ui/CopilotAgentView.tsx create mode 100644 src/agentMode/ui/McpServersPanel.tsx create mode 100644 src/agentMode/ui/PermissionModal.tsx create mode 100644 src/agentMode/ui/PlanPreviewView.tsx create mode 100644 src/agentMode/ui/PlanProposalCard.tsx create mode 100644 src/agentMode/ui/ReasoningBlock.tsx create mode 100644 src/agentMode/ui/SelectedModelsList.tsx create mode 100644 src/agentMode/ui/SubAgentCard.tsx create mode 100644 src/agentMode/ui/agentModePickerHelpers.test.ts create mode 100644 src/agentMode/ui/agentModePickerHelpers.ts create mode 100644 src/agentMode/ui/agentModelPickerHelpers.test.ts create mode 100644 src/agentMode/ui/agentModelPickerHelpers.ts create mode 100644 src/agentMode/ui/agentTrail.test.ts create mode 100644 src/agentMode/ui/agentTrail.ts create mode 100644 src/agentMode/ui/diffRender.ts create mode 100644 src/agentMode/ui/index.ts create mode 100644 src/agentMode/ui/permissionPrompter.ts create mode 100644 src/agentMode/ui/planEntryStyles.ts create mode 100644 src/agentMode/ui/toolIcons.ts create mode 100644 src/agentMode/ui/toolSummaries.test.ts create mode 100644 src/agentMode/ui/toolSummaries.ts create mode 100644 src/agentMode/ui/useAgentModePicker.ts create mode 100644 src/agentMode/ui/useAgentModelPicker.ts create mode 100644 src/agentMode/ui/useBackendDescriptor.ts create mode 100644 src/agentMode/ui/useManagerSubscribe.ts create mode 100644 src/agentMode/ui/vaultPath.test.ts create mode 100644 src/agentMode/ui/vaultPath.ts create mode 100644 src/components/agent/BinaryPathSetting.tsx create mode 100644 src/components/chat-components/AddContextButton.tsx create mode 100644 src/components/chat-components/BottomLoadingIndicator.tsx create mode 100644 src/components/chat-components/ChatModeInput.tsx create mode 100644 src/components/chat-components/CopilotSpinner.tsx create mode 100644 src/components/chat-components/attachChatViewLayoutObservers.ts create mode 100644 src/components/chat-components/plugins/slashMenuItems.test.ts create mode 100644 src/components/chat-components/plugins/slashMenuItems.ts create mode 100644 src/components/modals/ReactModal.tsx create mode 100644 src/components/ui/ModePicker.tsx create mode 100644 src/components/ui/ModelEffortPicker.tsx create mode 100644 src/contexts/PluginContext.tsx create mode 100644 src/lib/duration.test.ts create mode 100644 src/lib/duration.ts create mode 100644 src/settings/v2/components/AgentSettings.tsx create mode 100644 src/utils/detectBinary.ts create mode 100644 src/utils/errorUtils.test.ts create mode 100644 src/utils/errorUtils.ts create mode 100644 src/utils/pathUtils.test.ts create mode 100644 src/utils/pathUtils.ts create mode 100644 src/utils/rendererEventsShim.ts create mode 100644 typings/global.d.ts create mode 100644 typings/svg.d.ts diff --git a/.gitignore b/.gitignore index 75d21f2a..503f48d0 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ styles.css # obsidian data.json +data/ # Exclude macOS Finder (System Explorer) View States .DS_Store @@ -31,3 +32,7 @@ data.json # Development session tracking TODO.md + +# ACP full-frame debug log (vault sidecar, may contain prompt/tool data) +copilot/acp-frames.ndjson +copilot/acp-frames.old.ndjson diff --git a/AGENTS.md b/AGENTS.md index 5acef64b..455da12d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,12 +36,6 @@ The Obsidian desktop app includes a CLI for plugin development. Use the full pat /Applications/Obsidian.app/Contents/MacOS/obsidian ``` -**Plugin reload** (after `npm run build`): - -```bash -/Applications/Obsidian.app/Contents/MacOS/obsidian plugin:reload id=copilot -``` - **Console debugging** (requires attaching debugger first): ```bash @@ -65,28 +59,24 @@ Run `obsidian help` for the full command list. ### Core Systems 1. **LLM Provider System** (`src/LLMProviders/`) - - Provider implementations for OpenAI, Anthropic, Google, Azure, local models - `LLMProviderManager` handles provider lifecycle and switching - Stream-based responses with error handling and rate limiting - Custom model configuration support 2. **Chain Factory Pattern** (`src/chainFactory.ts`) - - Different chain types for various AI operations (chat, copilot, adhoc prompts) - LangChain integration for complex workflows - Memory management for conversation context - Tool integration (search, file operations, time queries) 3. **Vector Store & Search** (`src/search/`) - - `VectorStoreManager` manages embeddings and semantic search - `ChunkedStorage` for efficient large document handling - Event-driven index updates via `IndexManager` - Multiple embedding providers support 4. **UI Component System** (`src/components/`) - - React functional components with Radix UI primitives - Tailwind CSS with class variance authority (CVA) - Modal system for user interactions @@ -94,7 +84,6 @@ Run `obsidian help` for the full command list. - Settings UI with versioned components 5. **Message Management Architecture** (`src/core/`, `src/state/`) - - **MessageRepository** (`src/core/MessageRepository.ts`): Single source of truth for all messages - Stores each message once with both `displayText` and `processedText` - Provides computed views for UI display and LLM processing @@ -116,7 +105,6 @@ Run `obsidian help` for the full command list. - Reprocesses context when messages are edited 6. **Settings Management** - - Jotai for atomic settings state management - React contexts for feature-specific state @@ -147,14 +135,12 @@ For detailed architecture diagrams and documentation, see [`MESSAGE_ARCHITECTURE ### Core Classes and Flow 1. **MessageRepository** (`src/core/MessageRepository.ts`) - - Single source of truth for all messages - Stores `StoredMessage` objects with both `displayText` and `processedText` - Provides computed views via `getDisplayMessages()` and `getLLMMessages()` - No complex dual-array synchronization or ID matching 2. **ChatManager** (`src/core/ChatManager.ts`) - - Central business logic coordinator - Orchestrates MessageRepository, ContextManager, and LLM operations - Handles all message CRUD operations with proper error handling @@ -166,14 +152,12 @@ For detailed architecture diagrams and documentation, see [`MESSAGE_ARCHITECTURE - Creates new empty repository for each project (no message caching) 3. **ChatUIState** (`src/state/ChatUIState.ts`) - - Clean UI-only state manager - Delegates all business logic to ChatManager - Provides React integration with subscription mechanism - Replaces legacy SharedState with minimal, focused approach 4. **ContextManager** (`src/core/ContextManager.ts`) - - Handles context processing (notes, URLs, selected text) - Reprocesses context when messages are edited - Ensures fresh context for LLM processing @@ -233,6 +217,8 @@ For detailed architecture diagrams and documentation, see [`MESSAGE_ARCHITECTURE - **Tailwind classes**: Use Tailwind utility classes in components (see `tailwind.config.js` for available classes) - **Custom CSS**: Add custom styles to `src/styles/tailwind.css` after the `@import` statements - After editing CSS, always run `npm run build` to regenerate `styles.css` +- **No arbitrary font-size values**: Never use Tailwind's arbitrary-value syntax for typography (e.g. `tw-text-[10.5px]`, `tw-text-[13px]`). Stick to the configured `fontSize` tokens (`tw-text-ui-smaller`, `tw-text-ui-small`, `tw-text-xs`, `tw-text-smallest`, etc.) so type stays consistent with Obsidian's CSS variables. If none of the existing tokens fit, extend the `fontSize` scale in `tailwind.config.js` rather than hard-coding a pixel value at the call site. +- **No inline `style={{ ... }}`**: Reserve the `style` prop for values that must change dynamically at runtime (computed positions, animated transforms). Static visual changes belong in Tailwind classes or the shared component (e.g. `Button` variants/sizes) — do not zero out component chrome with inline `border: 0`, `background: "transparent"`, `padding: 0`, etc. ## Testing Guidelines diff --git a/CLAUDE.md b/CLAUDE.md index f5d76674..f6aa6c02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,3 @@ # CLAUDE.md -@AGENTS.md \ No newline at end of file +@AGENTS.md diff --git a/__mocks__/@agentclientprotocol/sdk.js b/__mocks__/@agentclientprotocol/sdk.js new file mode 100644 index 00000000..cd56f307 --- /dev/null +++ b/__mocks__/@agentclientprotocol/sdk.js @@ -0,0 +1,62 @@ +// Lightweight mock for @agentclientprotocol/sdk so unit tests can import +// modules that reference its runtime values (RequestError, ClientSideConnection, +// PROTOCOL_VERSION, ndJsonStream) without pulling the real ESM package +// through ts-jest. Tests that need behavior beyond this mock should stub +// their own. + + +class RequestError extends Error { + constructor(code, message, data) { + super(message); + this.code = code; + this.data = data; + this.name = "RequestError"; + } + static parseError(data, msg) { + return new RequestError(-32700, msg ?? "Parse error", data); + } + static invalidRequest(data, msg) { + return new RequestError(-32600, msg ?? "Invalid Request", data); + } + static methodNotFound(method) { + return new RequestError(-32601, `Method not found: ${method}`); + } + static invalidParams(data, msg) { + return new RequestError(-32602, msg ?? "Invalid params", data); + } + static internalError(data, msg) { + return new RequestError(-32603, msg ?? "Internal error", data); + } + static authRequired(data, msg) { + return new RequestError(-32000, msg ?? "Authentication required", data); + } + static resourceNotFound(uri) { + return new RequestError(-32001, `Resource not found${uri ? `: ${uri}` : ""}`); + } + toResult() { + return { error: this.toErrorResponse() }; + } + toErrorResponse() { + return { code: this.code, message: this.message, data: this.data }; + } +} + +class ClientSideConnection { + constructor(toClient, _stream) { + this._client = toClient(this); + } + initialize = jest.fn(async () => ({ protocolVersion: 1 })); + newSession = jest.fn(async () => ({ sessionId: "test-session" })); + prompt = jest.fn(async () => ({ stopReason: "end_turn" })); + cancel = jest.fn(async () => undefined); +} + +const ndJsonStream = jest.fn(() => ({})); +const PROTOCOL_VERSION = 1; + +module.exports = { + RequestError, + ClientSideConnection, + ndJsonStream, + PROTOCOL_VERSION, +}; diff --git a/__mocks__/@anthropic-ai/claude-agent-sdk.js b/__mocks__/@anthropic-ai/claude-agent-sdk.js new file mode 100644 index 00000000..a8112c20 --- /dev/null +++ b/__mocks__/@anthropic-ai/claude-agent-sdk.js @@ -0,0 +1,44 @@ +// Lightweight mock for @anthropic-ai/claude-agent-sdk so unit tests can +// import modules that reference its runtime values (`query`, +// `createSdkMcpServer`, `tool`) without pulling the real ESM package +// through ts-jest. Tests that exercise SDK behavior should stub these. + + +function query() { + // Minimal Query stub: empty async generator + control methods. Tests + // that need real behavior should mock `query` themselves. + const iter = (async function* () {})(); + return Object.assign(iter, { + interrupt: async () => {}, + setModel: async () => {}, + setPermissionMode: async () => {}, + setMaxThinkingTokens: async () => {}, + applyFlagSettings: async () => {}, + initializationResult: async () => ({}), + supportedCommands: async () => [], + supportedModels: async () => [], + supportedAgents: async () => [], + mcpServerStatus: async () => [], + getContextUsage: async () => ({}), + readFile: async () => null, + reloadPlugins: async () => ({}), + accountInfo: async () => ({}), + }); +} + +function createSdkMcpServer(options) { + return { type: "sdk", instance: { name: options?.name ?? "mock", tools: options?.tools ?? [] } }; +} + +function tool(name, description, inputSchema, handler) { + return { name, description, inputSchema, handler }; +} + +class AbortError extends Error {} + +module.exports = { + query, + createSdkMcpServer, + tool, + AbortError, +}; diff --git a/__mocks__/obsidian.js b/__mocks__/obsidian.js index f6ffe6a2..ab3b8f61 100644 --- a/__mocks__/obsidian.js +++ b/__mocks__/obsidian.js @@ -13,8 +13,6 @@ let requestUrlImpl = jest.fn().mockResolvedValue({ }); module.exports = { - // Reason: normalizePath is used by projectPaths.ts; identity function is sufficient for tests - normalizePath: jest.fn().mockImplementation((p) => p), moment: jest.requireActual("moment"), requestUrl: (...args) => requestUrlImpl(...args), __setRequestUrlImpl: (impl) => { @@ -45,7 +43,22 @@ module.exports = { }), Platform: { isDesktop: true, + isDesktopApp: true, + isMobile: false, }, + FileSystemAdapter: class FileSystemAdapter { + constructor(basePath = "/vault") { + this._basePath = basePath; + this.read = jest.fn(); + this.write = jest.fn(); + this.exists = jest.fn().mockResolvedValue(true); + this.mkdir = jest.fn().mockResolvedValue(undefined); + } + getBasePath() { + return this._basePath; + } + }, + normalizePath: (p) => String(p).replace(/\\\\/g, "/").replace(/\/+/g, "/"), parseYaml: jest.fn().mockImplementation((content) => { return parseYamlString(content); }), diff --git a/__mocks__/svg.js b/__mocks__/svg.js new file mode 100644 index 00000000..192a7346 --- /dev/null +++ b/__mocks__/svg.js @@ -0,0 +1,6 @@ + +import React from "react"; + +const SvgStub = (props) => React.createElement("svg", props); +module.exports = SvgStub; +module.exports.default = SvgStub; diff --git a/designdocs/ACP_LIMITATIONS_REPORT.md b/designdocs/ACP_LIMITATIONS_REPORT.md new file mode 100644 index 00000000..d7904455 --- /dev/null +++ b/designdocs/ACP_LIMITATIONS_REPORT.md @@ -0,0 +1,709 @@ +# ACP Protocol & `claude-agent-acp` Wrapper: Concrete Limitations Encountered During SDK Migration + +**From:** Obsidian Copilot plugin team +**Re:** Migration from `@agentclientprotocol/claude-agent-acp` → `@anthropic-ai/claude-agent-sdk` +**Status:** Feedback / RFC. We still ship `@agentclientprotocol/sdk` for our OpenCode and Codex backends; this is not a teardown. + +--- + +## TL;DR + +We ship Copilot for Obsidian, an Obsidian plugin that runs an in-vault AI agent inside an Electron renderer. We've used `@agentclientprotocol/claude-agent-acp` (v0.31.4) for our Claude backend since launch. As Claude Code / Claude Agent SDK matured, we hit a series of capability ceilings that ACP-as-a-protocol and the wrapper-as-an-implementation cannot pass through, and we've now migrated our Claude backend to call `@anthropic-ai/claude-agent-sdk` directly. The five gaps that forced the migration: + +1. **`AskUserQuestion` is hard-blocked** at the wrapper (`acp-agent.ts:1813`). Claude can never ask the user a multi-choice clarifying question through ACP. +2. **Only `PostToolUse` hooks are exposed** (`acp-agent.ts:1864-1885`). No `PreToolUse`, no `UserPromptSubmit`, no `Stop`, no `SessionStart/End`, no `SubagentStart/Stop`. A whole class of host policy/audit/UX features is unreachable. +3. **No in-process MCP server transport.** ACP forwards only `stdio | http | sse` (`acp-agent.ts:1749-1773`). For an Obsidian plugin, every vault file op needs to flow through `vault.adapter` (so sync, frontmatter, plugin events fire). Under ACP we'd have to spawn a localhost MCP subprocess that round-trips back into the renderer per call — defeats the point. +4. **Many SDK system & content events are silently dropped** (`acp-agent.ts:837-852, 1132-1136, 2738-2747`). Compaction, task progress, memory recall, rate-limit, redacted thinking, `input_json_delta`, citations — all gone. Hosts cannot show "context compacted" UI, live tool-input typing, structured citations, or backoff state. +5. **Streaming model collapses too early.** Tool inputs only arrive as completed JSON blocks (`input_json_delta` discarded); thinking surfaces only as plain text (`acp-agent.ts:2576-2585`); subagent runs surface only as flat `tool_call`s tagged with `parent_tool_use_id`. + +The rest of this doc walks each gap with code citations on both sides — so each section can be lifted into a tracked issue. A prioritized list lives in §5. We also call out what ACP got right in §4; the protocol's session-update streaming and `requestPermission` shapes are genuinely good, just under-expressive at the edges. + +All ACP-side citations refer to `@agentclientprotocol/claude-agent-acp` v0.31.4 (`~/Developer/claude-agent-acp/src/`). All host-side citations refer to obsidian-copilot branch `zero/acp-test`. + +--- + +## 2. How we use the protocol + +Our host: + +- Is an Obsidian plugin running inside an Electron renderer (no Node CLI shell, no easy access to spawn pipes from the renderer). +- Drives **multiple agent backends** behind a single UI: Claude (now via SDK), OpenCode (ACP), Codex (ACP). The multi-backend story is exactly why ACP is valuable to us — and exactly why we're writing this rather than walking away. +- Operates on **vault files**, not OS files. Every file mutation must go through `vault.adapter` / `vault.modify` to keep Obsidian's sync, frontmatter parsing, plugin event bus, and encryption-at-rest providers consistent. Direct FS writes are observable but lossy. +- Streams to a custom React UI that wants to render: live thinking deltas, live tool-input deltas, plan proposals, multi-choice elicitation modals, per-turn cost, compaction notices, and rate-limit backoff. +- Targets desktop and mobile Obsidian. Mobile has no `claude` CLI binary available; we work around this only on desktop today. + +--- + +## 3. Limitations by category + +Each subsection follows the same shape: + +> **What ACP / claude-agent-acp does today** — with `path:line` citation. +> **What we needed** — concrete UX example. +> **Workaround attempted under ACP** — or "no workaround possible." +> **What the SDK exposes natively** — with citation. +> **Suggested protocol change** — one line the ACP team can act on. + +### 3.1 No `AskUserQuestion` — agent cannot ask multi-choice clarifying questions + +**ACP today.** The wrapper hard-codes `AskUserQuestion` into a disallowed-tools list: + +```ts +// claude-agent-acp/src/acp-agent.ts:1812-1813 +// Disable this for now, not a great way to expose this over ACP at the moment +// (in progress work so we can revisit) +const disallowedTools = ["AskUserQuestion"]; +``` + +That list is then merged into the SDK options unconditionally (`acp-agent.ts:1862`). There is no `_meta` opt-in, no client capability flag, no way for a host that _does_ know how to render multi-choice prompts to receive them. + +**What we needed.** When a user types "rename the daily note", Claude should be able to ask "Which one — today's, yesterday's, or both?" with three buttons, get back the user's selection, and continue the turn without having to abandon and re-prompt. + +**Workaround under ACP.** None. The tool is gone from Claude's tool list before the prompt even starts. There is no equivalent ACP notification (`session/elicitation`, `session/ask_question`, etc.). + +**SDK native.** The SDK ships `AskUserQuestion` as a built-in tool whose dispatch flows through `canUseTool`. Our `permissionBridge.ts` intercepts: + +```ts +// obsidian-copilot/src/agentMode/sdk/permissionBridge.ts +if (toolName === "AskUserQuestion") { + const answers = await openAskUserQuestionModal(input.questions); + return { behavior: "allow", updatedInput: { questions: input.questions, answers } }; +} +``` + +The host opens the modal, the user clicks, the answers thread back through `updatedInput`, and the agent continues the same turn. No protocol-level gymnastics required. + +**Suggested protocol change.** Add a `session/elicitation` notification with structured options: + +```jsonc +{ + "method": "session/elicitation", + "params": { + "sessionId": "…", + "questions": [ + { + "question": "Which note?", + "header": "note-pick", + "options": [ + { "label": "Today", "description": "2026-05-03.md" }, + { "label": "Yesterday", "description": "2026-05-02.md" }, + ], + "multiSelect": false, + }, + ], + }, +} +``` + +Client returns answers in the response. Hosts that don't implement it can advertise so via capability and the wrapper falls back to the current "disallowed" behavior. + +--- + +### 3.2 Hooks: only `PostToolUse` is exposed + +**ACP today.** The wrapper hard-wires exactly one hook surface and uses it internally: + +```ts +// claude-agent-acp/src/acp-agent.ts:1864-1885 +hooks: { + ...userProvidedOptions?.hooks, + PostToolUse: [ + ...(userProvidedOptions?.hooks?.PostToolUse || []), + { + hooks: [ + createPostToolUseHook(this.logger, { + onEnterPlanMode: async () => { + await this.client.sessionUpdate({ + sessionId, + update: { sessionUpdate: "current_mode_update", currentModeId: "plan" }, + }); + await this.updateConfigOption(sessionId, "mode", "plan"); + }, + }), + ], + }, + ], +}, +``` + +User-provided `hooks.PreToolUse`, `hooks.UserPromptSubmit`, `hooks.Stop`, `hooks.SessionStart`, `hooks.SessionEnd`, `hooks.SubagentStart`, `hooks.SubagentStop`, `hooks.PermissionRequest` etc. are silently passed into `userProvidedOptions?.hooks` only if the _caller_ is a Node consumer that already has SDK types — which an ACP client by construction is not. Across the wire, ACP has no hook concept at all. + +The SDK-side hook lifecycle events that _do_ fire (`hook_started`, `hook_progress`, `hook_response`) are then explicitly dropped: + +```ts +// claude-agent-acp/src/acp-agent.ts:837-839 +case "hook_started": +case "hook_progress": +case "hook_response": +``` + +(Falls through to the no-op default; cf. lines 837-852.) + +**What we needed.** + +- **`PreToolUse`** to gate destructive vault writes against a per-vault audit policy _before_ the tool runs (so a denial prevents partial state). +- **`UserPromptSubmit`** to inject vault-context (current note title, selection, frontmatter) into every prompt without polluting the system prompt. +- **`Stop`** to flush a turn-end event for our chat persistence layer. +- **`SubagentStart/Stop`** to render a hierarchical run tree in the UI. + +**Workaround under ACP.** Partial. The wrapper's own `PostToolUse` handler emits `current_mode_update` for `EnterPlanMode` and forwards Edit-tool diffs (`tools.ts:771-798`), but this is a private side channel; we cannot intercept it. For `PreToolUse` we'd have to abuse `requestPermission` (which only fires for tools that require approval — read-only tools never hit it). + +**SDK native.** Full hook surface across the SDK's `Hook` lifecycle. We don't yet use all of them in obsidian-copilot, but the migration _unblocks_ them: + +```ts +// (planned) src/agentMode/sdk/ClaudeSdkBackendProcess.ts +hooks: { + PreToolUse: [{ hooks: [auditWriteHook] }], + UserPromptSubmit: [{ hooks: [vaultContextInjector] }], + Stop: [{ hooks: [persistTurnHook] }], +} +``` + +**Suggested protocol change.** Add a `session/hook_event` notification with `phase: "pre_tool_use" | "post_tool_use" | "user_prompt_submit" | "stop" | "session_start" | "session_end" | "subagent_start" | "subagent_stop" | "permission_request"` and an opaque `payload` blob. Clients that handle it can return a hook _response_ (block / modify / continue) via the response object. Wrappers stay free to map this onto whatever native hook system the underlying agent has. + +--- + +### 3.3 No in-process MCP servers + +**ACP today.** ACP's MCP forwarding accepts exactly three transport types: + +```ts +// claude-agent-acp/src/acp-agent.ts:1749-1773 +const mcpServers: Record = {}; +if (Array.isArray(params.mcpServers)) { + for (const server of params.mcpServers) { + if ("type" in server && (server.type === "http" || server.type === "sse")) { + mcpServers[server.name] = { + type: server.type, + url: server.url, + headers: server.headers ? Object.fromEntries(...) : undefined, + }; + } else { + // Stdio type (with or without explicit type field) + mcpServers[server.name] = { + type: "stdio", + command: server.command, + args: server.args, + env: server.env ? Object.fromEntries(...) : undefined, + }; + } + } +} +``` + +There is no fourth case for "host implements these tools in-process and the wrapper should call back via JSON-RPC." + +**What we needed.** Vault file ops (`vault_read`, `vault_write`, `vault_edit`, `vault_list`, `vault_glob`, `vault_grep`) MUST flow through `app.vault.adapter` and `app.vault.modify/create`. Anything else — direct FS access from a spawned subprocess, for instance — bypasses Obsidian's: + +- file-modified event bus (other plugins watching for changes break) +- frontmatter parser / metadata cache +- third-party sync drivers (Obsidian Sync, iCloud, Self-hosted LiveSync) +- mobile compatibility (mobile has no spawnable processes at all) +- vault encryption-at-rest plugins + +**Workaround under ACP.** We considered shipping a localhost stdio MCP subprocess that round-trips every read/write back into the renderer over WebSocket or named pipe. This (a) cannot work on mobile, (b) requires shipping platform-specific binaries inside an Obsidian plugin, which is not a supported distribution shape, and (c) adds a per-tool-call latency floor for what should be a memory-speed operation. + +**SDK native.** The SDK accepts an `sdk`-typed MCP server whose tools are JS callbacks the agent invokes in-process: + +```ts +// obsidian-copilot/src/agentMode/sdk/vaultMcpServer.ts +const server = createSdkMcpServer({ + name: "obsidian-vault", + tools: [ + tool("vault_read", "Read a vault file", { path: z.string() }, async ({ path }) => { + return { content: [{ type: "text", text: await app.vault.adapter.read(path) }] }; + }), + // … vault_write, vault_edit, vault_list, vault_glob, vault_grep + ], +}); + +// in query options: +mcpServers: { "obsidian-vault": { type: "sdk", instance: server } } +``` + +The agent sees the tools as normal MCP tools; the host runs them with full vault-API access. No subprocess, no IPC, works on mobile. + +**Suggested protocol change.** Add an `mcp/sdk` (or `mcp/host`) transport. The wrapper-side adapter would expose the tools as if they were stdio MCP, but route every `tools/call` back to the ACP client as a `session/host_tool_call` notification, awaiting a response. Implementation cost on both sides is moderate; the payoff is hosts can finally implement their own first-class tools without process management. + +--- + +### 3.4 Silent message drops + +**ACP today.** Two large switch statements in `acp-agent.ts` and `tools.ts` enumerate SDK events the wrapper does not translate. Falling through means the host literally never sees them. + +System messages (`acp-agent.ts:837-852`): + +```ts +case "hook_started": +case "hook_progress": +case "hook_response": +case "files_persisted": +case "task_started": +case "task_notification": +case "task_progress": +case "task_updated": +case "elicitation_complete": +case "plugin_install": +case "memory_recall": +case "notification": +case "api_retry": +case "mirror_error": +``` + +Tool-progress / auth / billing (`acp-agent.ts:1132-1136`): + +```ts +case "tool_progress": +case "tool_use_summary": +case "auth_status": +case "prompt_suggestion": +case "rate_limit_event": +``` + +Content block types (`acp-agent.ts:2738-2747`): + +```ts +case "document": +case "search_result": +case "redacted_thinking": +case "input_json_delta": +case "citations_delta": +case "signature_delta": +case "container_upload": +case "compaction": +case "compaction_delta": +case "advisor_tool_result": + break; +``` + +**What we needed (host-by-host UX impact).** + +| Dropped event | UX we wanted but cannot ship | +| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `compaction`, `compaction_delta` | Show "Context window compacted (X→Y tokens)" notice in chat history so users understand why earlier messages stop influencing the model | +| `task_started`, `task_progress`, `task_updated`, `task_notification` | Render running subagents with live progress; show their tools/output without polling | +| `memory_recall` | Surface "Agent recalled this from session 2026-04-12" as inline citation | +| `redacted_thinking` | Show a redaction marker (current behavior: thinking just silently disappears) | +| `input_json_delta` | Live "Bash command being typed" in tool-call UI (see §3.6) | +| `citations_delta` | Structured citations from `WebSearch` / `WebFetch` results — currently lost and re-derived from text | +| `signature_delta` | Cryptographic signing of thinking blocks (used for cache-eligibility) — required for cache semantics in extended-thinking turns | +| `rate_limit_event`, `api_retry` | Show "Rate-limited, retrying in 12s" UI; today the user just sees an unexplained stall | +| `auth_status` | React to mid-turn auth-token refresh failures | +| `tool_progress` | Long-running tool progress bars (Bash, WebFetch with large responses) | + +**Workaround under ACP.** None. The events do not cross the wire. + +**SDK native.** All the above events are first-class on `SDKMessage` / `stream_event`. The host can subscribe selectively. + +**Suggested protocol change.** Two-part fix: + +1. **Add explicit variants** for the high-value ones (`session/compaction`, `session/task_progress`, `session/rate_limit`, `session/citation`). +2. **Add a passthrough envelope** `session/raw_event` with `{ source: "claude_code" | "opencode" | …, eventType: string, payload: unknown }` for everything else, behind a client capability flag. Hosts that opt in get to render whatever they want; hosts that don't get current behavior. + +--- + +### 3.5 Information flattening on tool calls + +**ACP today.** The wrapper recognizes 8 named tools (Agent/Task, Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch — `tools.ts:121-411`) and collapses everything else into `kind: "other"` with the input JSON-stringified. + +```ts +// claude-agent-acp/src/tools.ts:121-145 (excerpt) +export function toolInfoFromToolUse( + toolUse: any, + supportsTerminalOutput = false, + cwd?: string +): ToolInfo { + const name = toolUse.name; + switch (name) { + case "Agent": + case "Task": { + /* kind: "think" */ + } + case "Bash": { + /* kind: "execute" */ + } + case "Read": { + /* kind: "read" */ + } + case "Write": + case "Edit": { + /* kind: "edit" */ + } + case "Glob": + case "Grep": { + /* kind: "search" */ + } + case "WebFetch": + case "WebSearch": { + /* kind: "fetch" */ + } + // … TodoWrite (special-cased to plan update), ExitPlanMode (switch_mode) + default: + return { + title: name, + kind: "other", + content: [ + { + type: "content", + content: { type: "text", text: JSON.stringify(toolUse.input, null, 2) }, + }, + ], + }; + } +} +``` + +`Edit` and `Write` results are translated to **empty** updates, with the actual diff smuggled through the `PostToolUse` hook side channel (`tools.ts:413-552`): + +```ts +case "Edit": +case "Write": + // diffs are sent via PostToolUse hook only + return {}; +``` + +Image content in tool results is downconverted to a text stub (`tools.ts:632`): + +```ts +return [{ type: "content", content: { type: "text", text: `Fetched: ${block.url ?? ""}` } }]; +``` + +**What we needed.** Any user-defined MCP tool — say a `notion_search` tool from a Notion MCP server — should render with a meaningful icon and title, not "Other: { query: 'foo' }". And any tool whose result is an image (a chart-rendering MCP tool, an OCR tool) should keep the image. + +**Workaround under ACP.** None for non-builtin tools. For the image case, only inline base64 images survive the round-trip; URL- and file-based images become text. + +**SDK native.** Tool results carry full `content` arrays through unchanged. MCP tools advertise their own metadata (description, inputSchema, annotations) at registration; hosts that consume the SDK directly can read the registry. + +**Suggested protocol change.** Add a `tool_call.metadata` field populated from MCP tool descriptors at registration time: + +```jsonc +{ + "sessionUpdate": "tool_call", + "toolCallId": "…", + "title": "notion_search", + "kind": "search", + "metadata": { + "mcpServer": "notion", + "description": "Search Notion pages", + "icon": "notion", + }, + "rawInput": { "query": "foo" }, +} +``` + +Plus: stop dropping image content blocks unconditionally — pass them through with their original `source` shape (URL / file / base64) and let the host decide. + +--- + +### 3.6 Streaming model: no partial tool inputs, no thinking deltas + +**ACP today.** Two specific deltas are silently dropped on the content-block level (`acp-agent.ts:2738-2747`, repeated for emphasis): + +- `input_json_delta` — the SDK streams tool-call arguments incrementally. ACP discards. +- `signature_delta` — used to sign thinking blocks for cache eligibility. ACP discards. + +Thinking blocks themselves _do_ survive but only as plain text (`acp-agent.ts:2576-2585`): + +```ts +// excerpt — every thinking block becomes a flat agent_thought_chunk +case "thinking": +case "thinking_delta": + update = { sessionUpdate: "agent_thought_chunk", content: { type: "text", text: block.text ?? block.delta } }; +``` + +Structured budget, signature, encrypted-thinking metadata — all lost. + +**What we needed.** + +- Live tool-input UI: as Claude types out a Bash command, show it filling in token-by-token. Not just "spinner → completed JSON dump." +- Cacheable thinking turns: keep the signature so the next turn can re-use the cache. +- Thinking metadata UI: render budget consumed / remaining as a progress bar. + +**Workaround under ACP.** The wrapper _does_ call `toAcpNotifications` recursively on a streaming `content_block_start` (`acp-agent.ts:2784-2790`), which means the _first_ time a `tool_use` block opens we get a `tool_call` notification — but its `input` field is the empty object the SDK started with, since `input_json_delta` updates between `start` and `stop` are not propagated. We can't reconstruct progressive input client-side. + +**SDK native.** Full `stream_event` access including `input_json_delta`, `signature_delta`, `text_delta`, `thinking_delta`. Our translator now reassembles per content-block index: + +```ts +// obsidian-copilot/src/agentMode/sdk/sdkMessageTranslator.ts +case "input_json_delta": { + const block = state.toolUseBlocks.get(event.index); + if (!block) break; + block.partial += event.delta.partial_json; + try { + block.parsed = parsePartialJson(block.partial); + } catch { /* still incomplete */ } + emit({ sessionUpdate: "tool_call_update", toolCallId: block.id, rawInput: block.parsed }); +} +``` + +The UI shows the Bash command typing itself in. + +**Suggested protocol change.** Add two notification variants: + +- `tool_call_input_chunk { toolCallId, partialInputJson }` — fired as each `input_json_delta` arrives. Hosts can either ignore (current behavior) or use it to drive live UI. +- `agent_thought_chunk` already exists; extend with optional `signature?: string`, `budgetTokensUsed?: number`, `budgetTokensRemaining?: number` fields. + +--- + +### 3.7 No fine-grained permission control + +**ACP today.** The wrapper exposes exactly three session config options (`acp-agent.ts:2256-2339`): `mode`, `model`, `effort`. The `mode` enum (`acp-agent.ts:1319-1324`) covers `auto | default | acceptEdits | bypassPermissions | dontAsk | plan`. There is no separate axis for `permissionMode` — the two are conflated. + +`requestPermission` outcomes are limited to: + +- `allow_once` +- `allow_always` with a label string ("all Bash", "Bash(npm test:\*)") +- `reject_once` + +**What we needed.** + +- The SDK's `canUseTool` callback can return `updatedPermissions: PermissionUpdate[]` along with the decision — a structured array of allow/deny rules to install for the rest of the session. Far richer than a label string. +- Independent `permissionMode` (gates whether tools require approval) and "agent mode" (e.g. `plan` is conceptually different from `acceptEdits`). + +**Workaround under ACP.** We parse the `allow_always` label heuristically to derive "always allow Bash with prefix `npm test`" rules. Brittle and lossy — the label is human text, not a structured rule. + +**SDK native.** + +```ts +// SDK type +type PermissionResult = + | { behavior: "allow"; updatedInput?: unknown; updatedPermissions?: PermissionUpdate[] } + | { behavior: "deny"; reason: string }; + +type PermissionUpdate = + | { type: "addRules"; rules: PermissionRule[]; behavior: "allow" | "deny" | "ask"; destination: "session" | "project" | "user" } + | { type: "removeRules"; rules: PermissionRule[]; … } + | { type: "setMode"; mode: PermissionMode; … }; +``` + +**Suggested protocol change.** Extend the `session/request_permission` response schema with an optional `updatedPermissions: PermissionUpdate[]` array using a structured rule shape rather than a free-form label. + +--- + +### 3.8 Session control plane is RPC-only, not live + +**ACP today.** Switching model or mode mid-session is a separate RPC roundtrip: + +- `session/set_session_model` +- `session/set_session_config_option` (for `mode` / `effort`) + +Plus capability probing — the wrapper detects whether the agent supports them at all (the obsidian-copilot side handles the probe in `src/agentMode/acp/AcpBackendProcess.ts:96-101`). + +**What we needed.** A user is mid-turn on Sonnet, sees Claude is going to need a long thinking pass, and wants to switch to Opus _without_ aborting and re-prompting (which would discard the partial output and lose the cache). + +**Workaround under ACP.** Abort the turn (`session/cancel`), wait for the cancel to settle, send `session/set_session_model`, re-prompt with the original user input. User sees their progress disappear and a fresh latency cost. + +**SDK native.** The SDK's `query()` exposes async `setModel(modelId)` and `setPermissionMode(mode)` methods callable mid-stream. Internally these flow through the same JSON-RPC framing, but they're applied to the _live_ generator without interrupting it. Our adapter forces streaming-input mode (`ClaudeSdkBackendProcess.ts:218`) to enable this. + +```ts +// obsidian-copilot/src/agentMode/sdk/ClaudeSdkBackendProcess.ts:330-360 +async setModel(model: string) { + await this.activeQuery?.setModel(model); +} +async setPermissionMode(mode: PermissionMode) { + await this.activeQuery?.setPermissionMode(mode); +} +``` + +**Suggested protocol change.** Define the existing `session/set_session_model` and `session/set_session_config_option` RPCs as **valid mid-turn** (not just between turns), and require wrappers to apply them to the in-flight generator without interrupting. Document the semantics: the new value applies starting from the _next_ assistant message or tool call within the current turn. + +--- + +### 3.9 No subagent control surface + +**ACP today.** The Agent / Task tool is mapped onto `kind: "think"` (`tools.ts:129-144`): + +```ts +case "Agent": +case "Task": { + const input = toolUse.input as AgentInput | BashInput | undefined; + return { title: input?.description ?? "Task", kind: "think", … }; +} +``` + +Subagent runs surface as nested `tool_call` updates tagged with `_meta.claudeCode.parentToolUseId` (`acp-agent.ts:2755-2762`). There is no first-class "subagent started / progressed / finished / errored" event family. + +**What we needed.** + +- Render running subagents with their model, allowed tools, and cumulative token use. +- Allow the user to interrupt a specific subagent without aborting the whole turn. +- Show a hierarchical run tree (parent agent → subagent → sub-subagent) instead of a flat list of `tool_call`s. + +**Workaround under ACP.** Reconstruct the tree client-side from `parent_tool_use_id`. Possible but fragile, and doesn't unlock interruption or per-subagent status. + +**SDK native.** The SDK exposes `agents: { name: AgentDefinition }` config, an `Agent` tool, and `task_*` events (`task_started`, `task_progress`, `task_notification`, `task_updated`) — all of which ACP drops (§3.4). + +**Suggested protocol change.** Add a `session/subagent_*` notification family: + +- `session/subagent_started { taskId, parentTaskId?, name, model, tools }` +- `session/subagent_progress { taskId, message? }` +- `session/subagent_finished { taskId, status: "ok" | "error" | "cancelled", usage? }` + +Plus a `session/cancel_subagent { taskId }` RPC. + +--- + +### 3.10 System prompt locked to preset + +**ACP today.** `acp-agent.ts:1775-1791` constructs the system prompt; the default and the "custom" path both keep `type: "preset"`: + +```ts +let systemPrompt: Options["systemPrompt"] = { type: "preset", preset: "claude_code" }; +if (params._meta?.systemPrompt) { + const customPrompt = params._meta.systemPrompt; + if (typeof customPrompt === "string") { + systemPrompt = customPrompt; // raw string supported via _meta + } else { + systemPrompt = { + type: "preset", + preset: "claude_code", + append: customPrompt.append, + // … + } as Options["systemPrompt"]; + } +} +``` + +So a host _can_ fully override by passing a raw string via `_meta.claudeCode.systemPrompt`, but the only documented protocol-level way to customize is append-style on top of the locked `claude_code` preset. + +**What we needed.** Insert vault-specific scaffolding ("paths are vault-relative; never use absolute FS paths; the user's current note is X; selection is Y") _without_ the Claude Code preset clobbering it. The Claude Code preset assumes a CLI environment, references `.claude/` files, and instructs about behaviors that don't apply inside Obsidian. + +**Workaround under ACP.** Use `_meta.claudeCode.systemPrompt` as a string. Works, but is an undocumented escape hatch and reaches outside the protocol contract — not portable to non-Claude ACP backends. + +**SDK native.** `systemPrompt: string` is fully supported as a first-class field on `Options`. + +**Suggested protocol change.** Promote `systemPrompt` to a documented protocol-level field on `session/new` with two shapes: + +```ts +type SystemPrompt = + | { type: "preset"; preset: string; append?: string; excludeDynamicSections?: string[] } + | { type: "custom"; text: string }; +``` + +Wrappers translate to whatever the underlying agent supports; agents that only have presets can refuse `type: "custom"` with a clear error. + +--- + +### 3.11 Slash command / skill allowlist filtering + +**ACP today.** The wrapper hard-codes a list of slash commands that are stripped before being advertised to the client (`acp-agent.ts:2379-2410`): + +```ts +const UNSUPPORTED_COMMANDS = [ + "cost", + "keybindings-help", + "login", + "logout", + "output-style:new", + "release-notes", + "todos", +]; +``` + +Plus MCP commands get renamed: `/mcp:server:cmd` → `/server:cmd (MCP)`. + +**What we needed.** Some of these we'd actually like to surface — `/cost` could populate our settings panel, `/release-notes` could show a changelog. We have a different login flow, so `/login` and `/logout` can stay filtered, but that should be a host _choice_, not a wrapper _fact_. + +**Workaround under ACP.** None. The wrapper strips them before they reach us. + +**SDK native.** `query.supportedCommands()` returns the full list with metadata; the host filters as it sees fit. + +**Suggested protocol change.** Pass through all commands by default. Let the host opt out via capability: + +```jsonc +{ + "clientCapabilities": { + "slashCommands": { "exclude": ["login", "logout"] }, + }, +} +``` + +--- + +### 3.12 Auth and CLI binary discovery (wrapper, not protocol) + +These aren't ACP-protocol issues per se, but anyone consuming `claude-agent-acp` from a non-Node-CLI environment hits them: + +- **Binary discovery.** The wrapper calls `claudeCliPath()` (`acp-agent.ts:1857`) which assumes a normal `PATH` environment. Inside Obsidian / Electron renderer, `PATH` doesn't include user shell paths; we had to ship a `claudeBinaryResolver` (in `obsidian-copilot/src/agentMode/sdk/`) that probes Volta, asdf, NVM, Homebrew, npm-global, and Windows-specific locations. +- **Electron renderer crashes.** Both the SDK's process-transport-close and the MCP SDK's stdio-close-wait call `.unref()` on a `setTimeout` handle. In the Electron renderer this is unsafe and crashes during teardown. We patch this at bundle time with `scripts/patchRendererUnsafeUnref.js`. The wrapper inherits this directly from the SDK — same patch would be needed for any host that bundles `claude-agent-acp` into a renderer. +- **Mobile.** No spawnable binaries. The wrapper has no escape hatch for "no CLI available, fall back to remote API." + +**Suggested protocol change / wrapper change.** + +1. Document the binary-discovery contract (wrapper reads `CLAUDE_CODE_EXECUTABLE`; if unset, calls `claudeCliPath()`; recommended host behavior is to set the env var explicitly). +2. Ship a renderer-safe build of the wrapper (or guard the unsafe `.unref()` sites internally). +3. (Long-term) Add a `mode: "remote" | "local"` option that routes through the Anthropic API directly when no local binary is available — useful for mobile and serverless hosts. + +--- + +## 4. What ACP got right + +It's worth stating clearly: the protocol's bones are good, and the gaps above are fixable without breaking the abstraction. + +- **Session-update streaming** (`SessionNotification` + `sessionUpdate` discriminator) is the right shape for tail-of-turn events. Adding new variants is non-breaking. +- **`requestPermission`** has the right semantics — synchronous client decision blocking the agent. The under-expressiveness (§3.7) is an extension point, not a redesign. +- **Multi-backend uniformity** is the actual reason we wrote this letter rather than walking away. Our OpenCode and Codex backends still go through ACP, and the win of "one UI, one chat-history layer, one permission modal across three different agents" is large enough that we tolerated each gap individually for a long time. We're not asking for ACP to absorb every Claude-specific feature — we're asking for the protocol to grow generic versions of the categories the SDK has shown to be valuable. + +--- + +## 5. Prioritized request list + +| Priority | Gap | Section | +| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| **P0** | `AskUserQuestion` / structured elicitation | §3.1 | +| **P0** | Hook lifecycle events (`PreToolUse`, `UserPromptSubmit`, `Stop`, `Subagent*`, `SessionStart/End`) | §3.2 | +| **P0** | In-process MCP transport (`mcp/sdk` or `mcp/host`) | §3.3 | +| **P1** | Pass through dropped events: `compaction`, `task_*`, `memory_recall`, `rate_limit_event`, `redacted_thinking`, `citations_delta`, `signature_delta` | §3.4 | +| **P1** | Streaming tool input deltas (`tool_call_input_chunk`) | §3.6 | +| **P1** | Structured `updatedPermissions` array on `requestPermission` response | §3.7 | +| **P2** | Documented custom system prompt (`type: "custom"`) | §3.10 | +| **P2** | First-class subagent notifications | §3.9 | +| **P2** | Mid-turn model / mode / config switches applied to live generator | §3.8 | +| **P3** | Slash command passthrough by default | §3.11 | +| **P3** | `tool_call.metadata` for non-builtin tools + image content preservation | §3.5 | +| **Wrapper** | Renderer-safe build, documented binary discovery, mobile/remote escape hatch | §3.12 | + +--- + +## 6. Appendix + +### 6.1 Citation index (ACP wrapper) + +- `acp-agent.ts:837-852` — system message switch dropping `hook_*`, `task_*`, `files_persisted`, `elicitation_complete`, `plugin_install`, `memory_recall`, `notification`, `api_retry`, `mirror_error` +- `acp-agent.ts:1132-1136` — `tool_progress`, `tool_use_summary`, `auth_status`, `prompt_suggestion`, `rate_limit_event` dropped +- `acp-agent.ts:1319-1324` — `mode` enum: `auto`, `default`, `acceptEdits`, `bypassPermissions`, `dontAsk`, `plan` +- `acp-agent.ts:1749-1773` — MCP transport handling (stdio / http / sse only) +- `acp-agent.ts:1775-1791` — system prompt construction (locked to preset over the wire) +- `acp-agent.ts:1812-1813` — `disallowedTools = ["AskUserQuestion"]` +- `acp-agent.ts:1864-1885` — hook config (PostToolUse only; internal `onEnterPlanMode`) +- `acp-agent.ts:2256-2339` — exposed config options (mode, model, effort) +- `acp-agent.ts:2379-2410` — `UNSUPPORTED_COMMANDS` slash-command filter +- `acp-agent.ts:2576-2585` — thinking blocks → flat `agent_thought_chunk` +- `acp-agent.ts:2738-2747` — content block types dropped +- `acp-agent.ts:2755-2762` — subagent surface via `_meta.claudeCode.parentToolUseId` +- `tools.ts:121-411` — 8-tool kind allowlist +- `tools.ts:413-552` — Edit/Write tool result transforms (returns `{}`) +- `tools.ts:632` — image content downconverted to `"Fetched: "` text stub +- `tools.ts:771-798` — internal PostToolUse hook for plan-mode and Edit-diff propagation + +### 6.2 Citation index (host adapter that demonstrates the SDK surface) + +- `obsidian-copilot/src/agentMode/sdk/ClaudeSdkBackendProcess.ts` — query lifecycle, mid-turn `setModel`/`setPermissionMode`, MCP server registration +- `obsidian-copilot/src/agentMode/sdk/sdkMessageTranslator.ts` — `input_json_delta` reassembly, thinking-delta handling, `EnterPlanMode` detection +- `obsidian-copilot/src/agentMode/sdk/vaultMcpServer.ts` — in-process MCP server using `vault.adapter` +- `obsidian-copilot/src/agentMode/sdk/permissionBridge.ts` — `canUseTool` integration, AskUserQuestion modal hookup +- `obsidian-copilot/src/agentMode/acp/AcpBackendProcess.ts` — the ACP integration we kept for OpenCode and Codex + +### 6.3 Versions tested + +- `@agentclientprotocol/claude-agent-acp` v0.31.4 +- `@anthropic-ai/claude-agent-sdk` v0.2.123 (the version `claude-agent-acp` itself depends on) +- `@agentclientprotocol/sdk` v0.21.0 + +### 6.4 Cross-reference + +Internal migration roadmap: [`designdocs/todo/CLAUDE_AGENT_SDK_MIGRATION.md`](./todo/CLAUDE_AGENT_SDK_MIGRATION.md). That document covers our implementation plan, milestones, and Electron-renderer spike findings — this document is its outward-facing protocol-feedback companion. + +--- + +_We're happy to chat through any of these in more detail, contribute fixes upstream, or test pre-release wrapper changes against our codebase. The fastest channel for follow-ups is the obsidian-copilot GitHub issues; tag them `acp-feedback`._ diff --git a/designdocs/AGENT_PROCESSING_UI.md b/designdocs/AGENT_PROCESSING_UI.md new file mode 100644 index 00000000..876aa0f3 --- /dev/null +++ b/designdocs/AGENT_PROCESSING_UI.md @@ -0,0 +1,274 @@ +# Agent Processing UI — Design Doc + +## Context + +Agent mode streams a mix of events while a turn is in flight: tool calls, tool results, model reasoning tokens, sub-agent invocations, plan proposals, and prose deltas. The UI has to make this trail **scannable, verifiable, and unobtrusive** for knowledge workers — without devolving into a debug log. + +Today the chat surface renders a flat list of tool-call cards, prose, and a (legacy) reasoning block. It works for simple turns but breaks down on three patterns we now hit regularly: + +1. **Burst tool calls** — the agent fires 5 consecutive `Edit`s and floods the message with near-identical cards. +2. **Sub-agents** — Claude Code's Task tool spawns a child agent whose tool calls and reasoning currently render as flat top-level entries, indistinguishable from the parent's work. +3. **Reasoning streams** — agent-mode `thought` parts exist in the data model but aren't hooked to the existing `AgentReasoningBlock` UI, so reasoning either disappears or renders as a static `
` block. + +This doc proposes a unified component model that handles all three, and the visual rules that make it readable. + +## Design principles + +1. **What was consulted matters more than how the model thought.** Tool calls are the primary signal. Reasoning is secondary, collapsed by default after streaming. +2. **One visual rhythm.** Every action — tool, sub-agent, reasoning — follows the same `icon · verb · target · outcome` pattern, so the eye learns to scan once. +3. **Progressive disclosure.** Default = one dense line. Expand for details. Re-expand for raw payloads. Never dump full tool results inline. +4. **Verifiable.** Every claim in the prose answer must be traceable to a card; every card must show _which note / URL / line range_ it touched. +5. **Compact > complete.** A 5-action turn = 5 lines, not 5 paragraphs. Prefer aggregation (one `Edited 5 notes` card) over enumeration when actions are homogeneous. +6. **Don't invent abstractions over the data model.** The backend already emits tool calls, reasoning, and parent-child links. The UI's job is to render them well, not to introduce a separate "Chain of Thought" or "Task" layer on top. + +## Component taxonomy + +We collapse the four Vercel SDK concepts (Reasoning, Tool, Task, Chain of Thought) into **two render primitives**: + +| Primitive | Renders | Source | +| ------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| **Action card** | A tool call, an aggregate of consecutive same-tool calls, OR a sub-agent invocation | `AgentMessagePart.tool_call` (with optional `parentToolCallId`) | +| **Reasoning block** | The model's internal thinking tokens | `AgentMessagePart.thought` | + +Sub-agents reuse the action-card primitive — they're just an action whose "result" is a nested trail of more action cards. **No separate `Task` or `Chain of Thought` component.** + +## Action card + +### Anatomy (collapsed) + +``` +┌─────────────────────────────────────────────────────┐ +│ 🔍 Searched web · "jazz piano voicings" ✓ ⌄ │ +│ 5 results · 1.2s │ +└─────────────────────────────────────────────────────┘ +``` + +- **Icon** — tool family (search, read, edit, fetch, vault, agent, time, …) +- **Verb · target** — past-tense action + the thing acted on +- **Outcome line** — small, muted: count + duration +- **Status** — `⟳` running, `✓` done, `⚠` failed, `∅` empty result +- **Affordance** — `⌄` to expand + +### Tool-aware summaries + +The collapsed line speaks the user's language per tool. Same shape, tool-specific outcome: + +``` +📄 Read music-theory.md · lines 10–48 ✓ +🗂️ Searched vault · "scales" · 12 matches ✓ +🔗 Fetched pianogroove.com/voicings ✓ +✏️ Edited practice-log.md · +12 / −0 lines ✓ +🧠 Indexed 47 notes · jazz folder ✓ +🕒 Time "what day is today" ✓ +``` + +### Expanded view + +Shows **inputs** (so the user can verify the agent searched what they'd expect) and **outputs** in cited form (clickable note paths and URLs). Full result body is _not_ dumped — there's a second-level expand for the raw payload when needed. + +``` +┌─────────────────────────────────────────────────────┐ +│ 🔍 Searched web · "jazz piano voicings" ✓ ⌃ │ +│ 5 results · 1.2s │ +│ ───────────────────────────────────────────────── │ +│ Query: jazz piano voicings │ +│ │ +│ Results: │ +│ 1. Jazz Piano Voicings — pianogroove.com ↗ │ +│ 2. Modern Jazz Voicings — open-studio.com ↗ │ +│ 3. Rootless Voicings Guide — jazzwise.com ↗ │ +│ 4. ... │ +└─────────────────────────────────────────────────────┘ +``` + +### States + +``` +⟳ Searching web · "jazz piano voicings"… (running, animated) +✓ Searched web · "jazz piano voicings" · 5 results (done) +⚠ Search failed · network error [retry] (error) +∅ Read note · daily/2024-03-15.md · empty (degenerate ok) +``` + +Empty results render explicitly — knowledge workers care about negative findings ("no, you don't have notes on this"). + +## Compaction (consecutive tool calls) + +### Rule + +At render time, fold a run of N ≥ 2 cards into a single aggregate when: + +- **Same tool type** (all `Edit`, all `Read`, all `WebSearch`) +- **Strictly consecutive in the part stream** — no intervening tool of a different type, no streamed prose, no `thought` part between them +- **Same nesting level** — never compact across a sub-agent boundary + +No fancier heuristics (no "detect read-edit cycles", no topic clustering). The backend may also pass an explicit `group_id`/`batch_id` in `_meta` to force-group; the UI honors it but doesn't depend on it. + +### Aggregate card + +The collapsed view shows a tool-aware aggregate stat, _not_ the first item: + +``` +✏️ Edited 5 notes · +47 / −12 lines ✓ ⌄ +📄 Read 3 notes · 4.5k tokens ✓ ⌄ +🔍 Searched web · 3 queries · 17 unique results ✓ ⌄ +🗂️ Searched vault · 4 queries · 28 matches ✓ ⌄ +``` + +Mixed status surfaces in the line: `✏️ Edited 5 notes · 4 ✓ · 1 ⚠`. + +### Expanded aggregate + +Per-item rows with their own micro-summary; each row is itself expandable to show the underlying card detail: + +``` +✏️ Edited 5 notes · +47 / −12 lines ✓ ⌃ + ───────────────────────────────────────────────── + ✏️ practice-log.md · +12 / −0 + ✏️ jazz/voicings.md · +18 / −8 + ✏️ jazz/scales.md · +8 / −2 + ✏️ daily/2024-03-15.md · +6 / −0 + ✏️ ⚠ archive/old.md · failed (file locked) +``` + +### What we lose + +Strict per-call timestamps in the collapsed view. Acceptable: the expanded view restores everything, and 95% of the value is the aggregate stat. + +## Sub-agent nesting + +Sub-agents use the **same action-card primitive** with a different shape: the child trail lives inside the expanded card. They are identified by `parentToolCallId` on child tool parts (already extracted in `backendMeta.ts`; needs to be propagated into `AgentMessagePart`). + +### Collapsed parent card + +``` +🤖 research-agent · "find all jazz voicings notes" ✓ ⌄ + 3 tools · 1 reasoning · 4.2s · 7 matches found +``` + +- Sub-agent identity (`research-agent`, `code-reviewer`, `explore`) is first-class — surfaced in the title. +- Outcome line summarizes both _the work_ (tool/reasoning counts, duration) and _the result_ (whatever the sub-agent returned). + +### Expanded parent card — full nested trail + +``` +🤖 research-agent · "find all jazz voicings notes" ✓ ⌃ + 3 tools · 1 reasoning · 4.2s + ┃ + ┃ 🗂️ Searched vault · "voicings" · 7 matches ✓ + ┃ 💭 Reasoning · 250 tokens ⌄ + ┃ 📄 Read voicings.md · 1.2k ✓ + ┃ 📄 Read 2024-02-piano.md · 800 ✓ + ┃ ───────────────────────────────────────────── + ┃ Returned: 7 notes referencing voicings, + ┃ primarily under jazz/ and practice/ +``` + +A vertical guide rail (`┃`) and indentation make nesting unambiguous. Child cards are the _same components_ as top-level cards, just rendered inside a parent. Compaction applies inside the nest the same way (5 sub-agent edits → one aggregate inside the parent). + +### Streaming behavior + +Default for all sub-agents: + +``` +🤖 research-agent · "find all jazz voicings notes" ⟳ + 2 tools so far… +``` + +Collapsed parent shows running counter; expanding lets the user watch live. (Auto-expand-while-running, auto-collapse-on-done is a reasonable v2 enhancement for long primary sub-agents — defer until users ask for it.) + +### Recursion + +Cap visible nesting at 2–3 levels. Beyond the cap, deeper sub-agents render as collapsed `🤖 …` cards inside the parent — still clickable to drill in, but indentation stops growing. Without a cap, deep traces become unreadable horizontal noise. + +### Sub-agent textual return values + +Some sub-agents return prose. Render it as a quoted block at the bottom of the expanded parent — keeps the parent card self-contained, no scroll-elsewhere required: + +``` +🤖 code-reviewer · "review auth changes" ✓ ⌃ + 5 tools · 2 reasoning · 12s + ┃ ...child cards... + ┃ ───────────────────────────────────────────── + ┃ > The migration is safe under concurrent + ┃ > writes. The backfill default handles… +``` + +## Reasoning block + +Renders the model's `thought` parts. Visually distinct from action cards — lower weight, no border, muted text — to signal "secondary information." + +``` +💭 Reasoning · 250 tokens · 1.8s ⌄ +``` + +### Streaming + +While streaming: a small pulse + live token counter, no auto-expand. (Vercel's auto-open-during-stream pattern is distracting in a chat UI; users can click to watch live if they want.) + +### After streaming + +Collapsed by default. Click to read. The block can show either raw thinking text or, if the backend emits structured reasoning steps, a step list — but the same component handles both. + +## Composition with the chat message + +``` +You ▸ summarize what I've written about jazz voicings + +Assistant + 🗂️ Searched vault · "voicings" · 7 matches ✓ + 💭 Reasoning · 250 tokens ⌄ + 📄 Read 2 notes · 2.0k tokens ✓ + ───────────────────────────────────────────────────── + Across your notes, you've explored three voicing + families: rootless (Bill Evans style), quartal + (McCoy Tyner), and shell voicings… + + [Sources: voicings.md, 2024-02-piano.md] +``` + +Action cards and reasoning blocks form a header strip above the prose answer. Citations at the bottom link prose claims back to specific notes — they tie the cards (process) to the prose (result). + +## Edge cases + +- **Parallel tool calls** (agent fires 3 reads in one turn): order chronologically by completion. If they're same-tool, compaction folds them naturally. +- **Mid-trail failure**: parent stays `⚠`, child cards remain visible — the partial trail is the most useful debugging artifact. +- **Empty results**: surface explicitly (`∅ Searched vault · 0 matches`). Don't collapse to "Done." +- **Permission-gated tools** (e.g., `ExitPlanMode` plan proposals): existing `PlanProposalCard` handles this — keep it as a specialized action-card variant, not a separate primitive. +- **Backend group hint**: an explicit `group_id` in `_meta` overrides the consecutive-same-tool heuristic, letting agent authors mark logically grouped operations even when interleaved. +- **Tool that produces no output** (fire-and-forget): show with `✓` and an outcome line like "no return value." + +## Current state & gaps + +References from `zero/acp-test`: + +| Area | File | Status | +| --------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | +| Streaming events | `src/agentMode/acp/AcpBackendProcess.ts`, `src/agentMode/session/AgentSession.ts` | ✅ tool_call / tool_call_update / agent_message_chunk / thought / plan all flowing | +| Tool card UI | `src/agentMode/ui/AgentToolCall.tsx` | ✅ renders `tool_call` / `thought` / `plan`, status icons, diff view, file annotations | +| Plan proposal | `src/agentMode/ui/PlanProposalCard.tsx` (commit `fa7a65d`) | ✅ specialized card, permission flow, preview view | +| Backend meta | `src/agentMode/session/backendMeta.ts`, `src/agentMode/backends/claude-code/meta.ts` | ✅ `parentToolCallId`, `vendorToolName`, `isPlanProposal` extracted | +| Reasoning UI | `src/components/chat-components/AgentReasoningBlock.tsx` | ⚠ exists for legacy chat, **not wired** to agent-mode `thought` parts | +| **Sub-agent nesting** | — | ❌ `AgentMessagePart.tool_call` lacks a `parentToolCallId` field; tool parts are flattened in the store; no UI hierarchy | +| **Tool compaction** | — | ❌ no aggregation/grouping logic; consecutive same-tool calls render as N separate cards | +| **Aggregate stats** | — | ❌ no tool-aware summary helpers (line deltas, token totals, unique-result counts) | + +### Implementation pointers (high-level — not a step-by-step plan) + +- **Data model**: add `parentToolCallId?: string` to `AgentMessagePart.tool_call` and propagate it from `backendMeta.ts` into the store. This is the foundation for both sub-agent nesting and ensuring compaction never crosses a parent/child boundary. +- **Render-time grouping**: introduce a pure function that takes the flat `AgentMessagePart[]` for an assistant turn and returns a tree of render nodes — `Card | AggregateCard | SubAgentCard | ReasoningBlock`. Compaction is a fold over consecutive same-tool peers at each level. Keep this layer purely derivational so the store stays flat. +- **Tool-aware summary registry**: a small registry mapping tool name → `(parts) => { line: string, stat: string }`. Each tool family (read, edit, search, fetch, vault-search, …) contributes one entry. Falls back to a generic summary for unknown tools. +- **Reasoning hookup**: route agent-mode `thought` parts through `AgentReasoningBlock`. The legacy block already supports streaming + collapse-on-done; the gap is wiring, not new UI. +- **Sub-agent component**: a thin variant of `AgentToolCall` that, when expanded, renders its child render tree (recursively reusing the same renderer). Indentation + a left guide rail handles the visual. + +## Verification + +- **Unit**: cover the grouping/aggregation function with cases for (a) heterogeneous runs (no grouping), (b) consecutive same-tool, (c) interleaved with reasoning (no grouping), (d) sub-agent boundary (no grouping across), (e) explicit `group_id`. +- **Visual / live**: drive each case end-to-end via agent mode in Obsidian — single tool, 5-edit burst, sub-agent invocation (e.g., `Task` from Claude Code backend), reasoning-only turn, mixed turn. Use the Obsidian CLI (`obsidian dev:debug` / `dev:console`) to confirm the underlying parts and screenshot the rendered cards. +- **Regression**: existing flat-card layouts still render correctly when no nesting / grouping applies (single tool calls, plan proposals). + +## Open questions + +- Should the reasoning block live **inline among action cards** (as in the composition mockup), or always **at the top** of the assistant turn? Inline preserves chronological truth; top is calmer visually. Recommend inline; flag for review. +- Auto-collapse threshold for sub-agents — collapse on done always, or only when child count ≥ 3? Recommend always-collapse for consistency. +- Tool icon source — Lucide set, custom set, or per-backend overrides? Decide before implementation since it touches every card. diff --git a/designdocs/CODE_REVIEW_GUIDE.md b/designdocs/CODE_REVIEW_GUIDE.md new file mode 100644 index 00000000..8f3cc072 --- /dev/null +++ b/designdocs/CODE_REVIEW_GUIDE.md @@ -0,0 +1,433 @@ +# Branch review guide — `zero/acp-test` + +## Context + +One week of vibe-coded work that adds **Agent Mode** to the plugin: a chat +surface that drives external coding agents (OpenCode, Codex, Claude). The +branch is large (160 files, +31k / −9k LOC across 42 commits) but the +architecture is clean — **six element types** with ESLint-enforced boundaries +(`src/agentMode/CLAUDE.md`, `.eslintrc` `boundaries/elements`). + +This is a **review tour by architectural concern**, not by commit +chronology. Read in this order; each group is sized for one or two +sittings. + +--- + +## Architecture in one diagram + +``` + ┌──────────────────────────────────────────┐ + │ Host plugin │ + │ (main.ts, CopilotView, ChatInput) │ + └────────────────────┬─────────────────────┘ + │ only via @/agentMode barrel + ▼ + ┌──────────────────────────────────────────┐ + │ ui/ │ GROUP 5 + │ Backend-agnostic React; reads │ + │ BackendDescriptor │ + └─────────┬────────────────────────┬───────┘ + │ runtime + types │ catalog + │ ▼ + │ ┌─────────────────────────┐ + │ │ backends/registry.ts │ GROUP 3 + │ │ (its own element type) │ + │ └────────────┬────────────┘ + │ │ + │ ▼ + │ ┌─────────────────────────┐ + │ │ backends// │ GROUP 3 + │ │ opencode, codex, claude│ + │ │ + backends/_shared/ │ + │ └────┬───────────────┬────┘ + │ │ │ + │ ┌──────────▼─────┐ ┌──────▼──────┐ + │ │ acp/ │ │ sdk/ │ GROUP 2 + │ │ JSON-RPC over │ │ In-process │ + │ │ stdio │ │ Claude SDK │ + │ └──────────┬─────┘ └──────┬──────┘ + │ │ siblings — │ + │ │ neither imports the other + ▼ ▼ ▼ + ┌──────────────────────────────────────────┐ + │ session/ │ GROUP 1 + 4 + │ The contract sink. Owns BackendProcess, │ + │ BackendDescriptor, errors, debug sink. │ + │ Also: AgentSession, message store, │ + │ multi-tab manager, persistence, │ + │ mode/effort/MCP/model adapters. │ + │ Imports nothing from agentMode. │ + └──────────────────────────────────────────┘ +``` + +Dependency direction in one sentence: **all arrows point toward `session/`**. +It's a sink — every other element imports types from it; it imports nothing +back. `ui/` reaches the backend catalog via `registry` (carved out as its own +element type) so it can stay oblivious to which specific backends exist. + +The **contract** that ties this all together (Group 1) lives in +`session/types.ts`, `session/errors.ts`, and `src/agentMode/CLAUDE.md`. Read +those first or nothing else will land cleanly. + +A **vertical slice** (Group 6 — plan mode & permissions) crosses every +layer above; it's worth reading after you've seen the layers in +isolation. + +--- + +## Group 1 — The contract (start here) + +**Why first:** every other file conforms to interfaces declared here. If +you read these in any other order, you'll re-derive them yourself as you +go. + +**Files (~700 lines total):** + +- `src/agentMode/CLAUDE.md` — layer rules, descriptor surface, debug tips + (the spec; authoritative when this guide and it disagree) +- `src/agentMode/session/types.ts` — **the central vocabulary**: the + `BackendProcess` interface, `BackendDescriptor`, `InstallState`, the event + union the session emits to the UI (532 lines) +- `src/agentMode/session/errors.ts` — `MethodUnsupportedError` and + `JSONRPC_METHOD_NOT_FOUND` (extracted out of types.ts so backends can + throw them without a circular import) +- `src/agentMode/session/debugSink.ts` — shared NDJSON frame sink fed by + both transport layers (`acp/debugTap.ts` and `sdk/sdkDebugTap.ts`) +- `src/agentMode/index.ts` — public surface (one barrel; nothing outside + agentMode is allowed to deep-import past this) +- `src/agentMode/backends/registry.ts` — the only file you edit when + adding a new backend (32 lines). Carved out as its own boundary element + so `ui/` can read the catalog without unlocking deep imports into + individual backends — this is why the element count is **six**, not five. +- `.eslintrc` — find the `boundaries/elements` and + `boundaries/dependencies` blocks; this is what enforces the layering at + lint time + +**Validate understanding:** can you describe (a) what `BackendProcess` +exposes to `session/`, (b) what `BackendDescriptor` exposes to `ui/` and +`registry`, and (c) why those two interfaces are different? + +--- + +## Group 2 — Agent transport (two runtimes, one contract) + +**Why grouped together:** ACP and the Claude Agent SDK are the same +architectural layer — both translate "an external agent's event stream" +into the session's internal event vocabulary, and both implement +`BackendProcess`. They are **siblings: neither imports the other**, and +`session/` doesn't import either — `session/` consumes the `BackendProcess` +interface only. Reading them side-by-side is the fastest way to see what +the contract actually buys you. + +**The shared shape both runtimes expose to `session/`:** the +`BackendProcess` interface — `start(prompt)`, `interrupt()`, `setModel()`, +`setPermissionMode()`, `close()`, plus a stream of `SessionNotification` +events. + +### 2a. ACP runtime — JSON-RPC subprocess + +`src/agentMode/acp/` + +- `types.ts` — ACP wire-format types +- `AcpProcessManager.ts` — subprocess spawn / lifecycle +- `AcpBackendProcess.ts` — `BackendProcess` impl: JSON-RPC frame loop + ACP + event translation (446 lines, the central file) +- `VaultClient.ts` — host-side handler for the agent's `fs/*` requests + (this is how the agent reads/writes vault files via the host, not the + OS filesystem) +- `debugTap.ts` — opt-in JSON-RPC frame tap; writes into + `session/debugSink.ts` (the standalone `frameSink.ts` from earlier + iterations is gone — the sink lives in `session/` now) +- `nodeShebangPath.ts` — CLI shebang resolution + +### 2b. Claude SDK runtime — in-process query iterator + +`src/agentMode/sdk/` + +- `ClaudeSdkBackendProcess.ts` — `BackendProcess` impl that wraps `query()` + from `@anthropic-ai/claude-agent-sdk` (636 lines, the central file) +- `sdkMessageTranslator.ts` — translates `SDKMessage` stream → the same + internal event vocabulary the ACP path uses (this is what lets + `session/` stay unchanged across both runtimes) +- `vaultMcpServer.ts` — in-process MCP server wrapping `app.vault.adapter` + so writes flow through Obsidian (file watchers, links, sync). The + SDK's built-in Read/Write/Edit are explicitly disallowed. +- `permissionBridge.ts` — translates the SDK's `canUseTool` callback into + the existing permission flow, including new `AskUserQuestion` handling +- `effortOption.ts` — resolves reasoning-effort options for the SDK + (consumed by `backends/claude/descriptor.ts`) +- `toolMeta.ts` — SDK-side tool metadata (icons / display strings) +- `sdkDebugTap.ts` — SDK-side frame tap; feeds `session/debugSink.ts` so + ACP and SDK turns appear in the same NDJSON file + +(Note: `claudeBinaryResolver.ts` used to live here but moved to +`backends/claude/` — see Group 3.) + +**Companion docs:** + +- `designdocs/todo/CLAUDE_AGENT_SDK_MIGRATION.md` — capability diff and + why the SDK path was added alongside ACP rather than replacing it. + Read §1 (capability table) and §2 (target architecture). + +**Validate:** turn on full ACP frame logging (Settings → Advanced), run a +turn on opencode and a turn on claude. The internal events emitted to +`session/` should look the same shape; only the inputs differ. + +--- + +## Group 3 — Backend catalog (which agents exist, how they install) + +**Why a separate group:** "how do we talk to an agent" (Group 2) and +"which specific agents do we ship" are different concerns. A backend +descriptor picks a transport, owns its install/binary story, exposes a +settings panel, and registers itself. + +**The pattern (per backend):** + +``` +backends// + descriptor.ts — the BackendDescriptor export (settings glue, + install state, createBackendProcess factory) + Backend.ts — subprocess track only: implements the + AcpBackend helper interface used by + simpleBinaryBackendProcess (a thin wrapper). + SDK-track backends skip this — the descriptor's + createBackendProcess constructs the + BackendProcess directly. + InstallModal.tsx — onboarding UI (BYOK key, binary path) + SettingsPanel.tsx — settings-page panel + index.ts — re-exports the descriptor +``` + +**Files:** + +- `src/agentMode/backends/registry.ts` — already read in Group 1 +- `src/agentMode/backends/_shared/` — common scaffolding three of the + three backends use: + - `simpleBinaryBackend.ts` — boilerplate for "user installs a CLI, we + detect it and pass the path" + - `BinaryInstallContent.tsx` — install-modal body + - `SimpleBackendSettingsPanel.tsx` — settings-panel body +- `src/agentMode/backends/opencode/` — the **most complete example**; + read this first. Includes: + - `OpencodeBinaryManager.ts` (612 lines) — plugin-managed download + + extract + verify (this is the only backend with a fully managed + install) + - `platformResolver.ts` — picks the right tarball per OS/arch +- `src/agentMode/backends/codex/` — minimal example (no managed binary) +- `src/agentMode/backends/claude/` — uses Group 2b (`sdk/`) instead of + ACP, so it has no `Backend.ts`. Includes: + - `AskUserQuestionModal.tsx` — Claude-only multi-choice question modal + (a capability ACP didn't have) + - `claudeBinaryResolver.ts(+test)` — locates the user-installed + `claude` CLI across Volta / asdf / NVM / Homebrew / npm-global on + macOS / Linux / Windows. Lives here (not in `sdk/`) because + cross-platform CLI resolution is Claude-specific. +- `src/utils/detectBinary.ts` — generic binary-on-PATH detection used by + the simple backend scaffold + +**Validate:** open Settings → Agents tab. Switch active backend. Each +backend should expose its own install state, settings panel, and BYOK +fields without any other code changing. + +--- + +## Group 4 — Session core (state machine + adapters) + +**Why grouped together:** the state machine and the adapters are tightly +coupled. The adapters exist _because_ the session has to present a +uniform model/mode/effort UX over backends that disagree on what those +mean. Reading the state machine in isolation makes the adapters look +incidental, and vice versa. + +### 4a. State machine + +- `src/agentMode/session/AgentSession.ts` — **1301 lines, the heart**. + Owns: turn state, message queue, plan/permission resolvers, interrupt, + model/mode/effort changes, agent-supplied title, the event handler + switch. Skim once for public methods, then read each event handler in + sequence. +- `src/agentMode/session/AgentSessionManager.ts` — multi-tab orchestration + (779 lines): per-tab session creation, active-session selection, + cross-session lifecycle, persistence wiring +- `src/agentMode/session/AgentMessageStore.ts` — append-only store +- `src/agentMode/session/AgentChatUIState.ts` — React subscription bridge +- `src/agentMode/session/AgentChatBackend.ts` — small interface the + session uses to talk to its current backend +- `src/agentMode/session/AgentChatPersistenceManager.ts` — debounced + markdown auto-save, project-scoped file naming +- `src/agentMode/session/index.ts` — public surface for the layer + +(The contract files — `types.ts`, `errors.ts`, `debugSink.ts` — are +already covered in Group 1.) + +### 4b. Cross-cutting adapters (per-backend normalizers) + +- `session/modeAdapter.ts` — canonical `default | plan | yolo` modes + mapped to per-backend strings +- `session/effortAdapter.ts` — reasoning effort levels per backend, + including the `modelId#effort` encoding +- `session/mcpResolver.ts` — user-configured MCP servers (stdio/http/sse) +- `session/AgentModelPreloader.ts` — kicks model lists at plugin load +- `session/modelEnable.ts` — per-backend model curation +- `session/backendMeta.ts`, `session/backendSettingsAccess.ts` — small + helpers +- `ui/useAgentModelPicker.ts` (445 lines) + `ui/agentModelPickerHelpers.ts` + — the UI hook that consumes the adapters; lives in `ui/` but is + conceptually part of this group +- `ui/McpServersPanel.tsx` — settings panel for `mcpResolver` + +**Validate:** start two tabs on different backends, send messages in +both, change model mid-turn (it applies on next send), set per-backend +default mode, configure an MCP server. Reload the plugin — sessions +restore from disk. + +--- + +## Group 5 — Chat surface UI (backend-agnostic) + +**Why grouped:** all of `ui/` (minus the plan/permission vertical in +Group 6) is React rendering against the session layer. No file in here +imports from `acp/`, `sdk/`, or specific backends — only from `session/` +and `backends/registry.ts`. This is exactly what `.eslintrc`'s +`from: { type: "ui" }` rule allows; if you want to verify a specific +import, check that rule. + +### 5a. Shell + +- `ui/CopilotAgentView.tsx` — Obsidian view registration +- `ui/AgentModeChat.tsx` — top-level chat surface +- `ui/AgentChat.tsx` (511 lines) — main chat container +- `ui/AgentChatControls.tsx` — header controls +- `ui/AgentTabStrip.tsx` — multi-session tabs +- `ui/AgentModeStatus.tsx` — install / connection status +- `ui/AgentChatMessages.tsx` — message list +- `ui/AgentMarkdownText.tsx` — markdown rendering helper + +### 5b. Action-card trail (most recent UI rewrite) + +The chronological trail of tool calls + reasoning + text: + +- `ui/agentTrail.ts` — chronological merge of text/tool/reasoning parts +- `ui/AgentTrailView.tsx` — renderer +- `ui/ActionCard.tsx`, `AggregateCard.tsx`, `SubAgentCard.tsx` — card + variants +- `ui/ReasoningBlock.tsx` + `components/chat-components/AgentReasoningBlock.tsx` + — reasoning timer +- `ui/toolSummaries.ts` (+ test) + `toolIcons.ts` — per-tool one-liners +- `ui/diffRender.ts` — edit-tool diff display +- `ui/vaultPath.ts` — vault-relative path display + +**Validate:** run a multi-tool turn (read + edit + bash). The trail +should show one card per tool call, reasoning blocks interleaved +chronologically, and a live timer on in-flight reasoning. + +--- + +## Group 6 — Plan mode & permissions (vertical slice across all layers) + +**Why a separate group:** this is the trickiest interaction surface in +the whole feature, and it crosses every layer. Reading it as a vertical +shows you how transport / session / UI cooperate around a single user +flow. Save it for after you understand the layers in isolation. + +**The flow:** agent emits a `plan` notification → session creates a plan +proposal → UI renders the floating card → user approves → session sends +an `ExitPlanMode` permission → plan transitions into apply. + +**Files (cross-layer):** + +Transport side (Group 2): + +- ACP: the `requestPermission` RPC handler in + `acp/AcpBackendProcess.ts` and `permissionPrompter.ts` +- SDK: `sdk/permissionBridge.ts` (translates `canUseTool` callback, + including `AskUserQuestion`) + +Session side (Group 4): + +- The plan / permission slices of `session/AgentSession.ts` (search for + `plan`, `EnterPlanMode`, `ExitPlanMode`) + +UI side: + +- `ui/permissionPrompter.ts` — routing layer; special-cases + `ExitPlanMode` switch_mode +- `ui/AcpPermissionModal.tsx` — generic permission modal +- `ui/PlanProposalCard.tsx` — inline plan card +- `ui/PlanPreviewView.tsx` — full-screen plan preview (custom Obsidian + view registered in `main.ts`) +- `ui/planEntryStyles.ts` +- `backends/claude/AskUserQuestionModal.tsx` — Claude-only + multi-choice question modal + +**Known landmines fixed in this branch:** ghost plan card on late +`tool_call_update` (`e5b1dcb`), markdown class on plan preview +(`060e7d5`). + +**Validate:** trigger a multi-step task in plan mode. Card renders → +preview opens → approve → session transitions into apply. No ghost card +on late events. + +--- + +## Group 7 — Host integration & settings (the outer seam) + +**Why last:** these are the files outside `agentMode/` that hook the +feature into the plugin. Reading them last is correct; the layered +mental model has to be in your head first or you'll mistake plumbing for +architecture. + +**Plugin entry & view registration:** + +- `src/main.ts` (193-line diff) — `createAgentSessionManager()` call, + view registration (chat + plan-preview), mobile gate, lifecycle +- `src/components/CopilotView.tsx` — agent-mode chat-surface mounting + +**Chat input integration:** + +- `src/components/chat-components/ChatInput.tsx` — agent-mode-aware input + (queue messages while a turn runs, ESC to stop, Shift-Tab to cycle + modes) +- `src/components/chat-components/ChatModeInput.tsx` — extracted + agent-mode toggle wrapper +- `src/components/chat-components/ChatMessages.tsx`, + `AgentReasoningBlock.tsx`, + `BottomLoadingIndicator.tsx`, + `attachChatViewLayoutObservers.ts` + +**Settings:** + +- `src/settings/model.ts` — new `agentMode.*` settings tree (active + backend, per-backend slices, sticky model/mode/effort, MCP servers) +- `src/settings/v2/components/AgentSettings.tsx` — dedicated Agents tab +- `src/settings/v2/components/AdvancedSettings.tsx` — frame-log toggle +- `src/settings/v2/SettingsMainV2.tsx` — tab registration +- `src/components/agent/BinaryPathSetting.tsx` — generic binary-path + picker shared by backends + +**Build / runtime shims (needed because the SDK assumes Node, but +Obsidian runs in Electron's renderer):** + +- `nodeModuleShim.mjs` +- `scripts/patchRendererUnsafeUnref.js` +- `esbuild.config.mjs` +- `__mocks__/@anthropic-ai/` +- `typings/global.d.ts` + +--- + +## Reading order at a glance + +1. **Group 1 — Contract** (must be first) +2. **Group 2 — Transport: ACP + SDK side-by-side** +3. **Group 3 — Backend catalog** +4. **Group 4 — Session core + adapters** +5. **Group 5 — Chat UI** +6. **Group 6 — Plan/permission vertical** +7. **Group 7 — Host seam** + +After Group 1 the order is mostly negotiable — Groups 2/3/4 can flex +based on what you want to validate first. But Group 6 should always come +after 2/4/5, and Group 7 should always come last. diff --git a/designdocs/SETTINGS_REDESIGN_PRD.md b/designdocs/SETTINGS_REDESIGN_PRD.md new file mode 100644 index 00000000..4abc22cf --- /dev/null +++ b/designdocs/SETTINGS_REDESIGN_PRD.md @@ -0,0 +1,433 @@ +# Settings Redesign PRD — Copilot for Obsidian + +**Status:** Draft for design handoff +**Audience:** Senior product designer (Claude Design) doing IA work +**Out of scope:** Visual restyling, color/typography choices, chat UI redesign, settings storage schema + +--- + +## 1. Context & Problem + +Copilot for Obsidian currently exposes **~150 user-configurable settings** across 7 tabs (Basic, Model, QA, Command, Plus, Advanced, Agent). The information architecture has accumulated through feature additions rather than user-journey design. The result: + +- Beginners encounter expert knobs (lexical search RAM limit, embedding batch size, partition count, auto-compact token threshold, agent backend internals) on the same page as their first decisions (default model, send shortcut). They get confused and bounce. +- Related settings live in different surfaces. API keys hide in a modal launched from Basic. Custom system prompts hide in Advanced. Plus features render conditionally inside a sub-component. Self-host/Miyo settings only appear after server-side eligibility checks pass. +- Conditional/nested settings have no discoverability. A user who isn't sure if a setting exists can't find out without trial and error. +- There's no settings search. +- Deprecated and unused settings (`autoIncludeTextSelection`, `chatNoteContextPath`, `inlineEditCommands`, `stream`) still ship in the schema. + +**This redesign happens in parallel with the ACP-Centric Revamp** (see audit at `~/Developer/zeroliu_second_brain/notes/Copilot Feature Audit for ACP - Centric Revamp.md`), which collapses the four chat modes (Chat / Vault QA / Copilot Plus / Projects) into one chat surface with a single Agent Mode toggle, retires `@`-commands, converts custom prompts and custom commands into "skills," and adds a dedicated Agent Mode configuration tab. **This PRD describes the post-revamp world**, not today's state. Settings being dropped in the revamp are listed in §6 so the designer doesn't design around them. + +### Success criteria + +1. **Beginner finishes setup in <60 seconds.** API key in, default model picked, chat works. +2. **Power user can still find every knob.** No setting goes missing; advanced surfaces are reachable in ≤2 clicks from any entry. +3. **Agent Mode is a first-class section** with a clean per-backend pattern. +4. **Settings search exists** and reduces "where is X" support questions. +5. **Locked / eligibility-gated states are explicit** — never silently hidden. + +--- + +## 2. Users & Audiences + +Four tiers, used as labels throughout this doc. Use them to drive progressive disclosure. + +| Tier | Description | What they configure | +| ---------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| **Beginner** | Just installed Copilot. Has an API key from one provider. Doesn't know what an embedding is. Wants chat to work. | API key, default model, where chat opens, send shortcut. | +| **Intermediate** | Several weeks in. Has a saved-conversations workflow. Uses vault QA. May have configured 2–3 models. | Save folders, conversation tags, filename templates, indexing strategy, embedding model, exclusions/inclusions, skills folder. | +| **Power user** | Multi-provider, custom models, self-hosts. Tunes rate limits and partition counts. Configures agent backends. | Performance tuning, custom model registration, Azure/Bedrock/proxies, agent backend binaries, MCP servers, self-host/Miyo. | +| **Developer** | Debugging the plugin or contributing. | Debug logs, raw ACP frame capture, log files, internal toggles. | + +Each setting in §5 has a tier label. + +--- + +## 3. Goals & Non-Goals + +### Goals + +- Progressive disclosure: essentials default-visible, advanced collapsed. +- A clear **quick-setup path** for first-run users. +- Settings search/filter across the entire surface. +- Distinct grouping: **credentials**, **default behavior**, **performance tuning**, **experimental**. +- Agent Mode promoted to a top-level section (per audit). +- All conditional/eligibility-gated settings have a visible "locked" state with a reason. +- A clear mobile story for desktop-only features. + +### Non-Goals + +- Changing the _meaning_ of any setting. This is IA only. +- Visual restyling beyond what IA decisions require. +- Modifying the underlying settings storage schema (`CopilotSettings` interface). +- Redesigning the chat UI, command palette, or modals. +- Adding new functionality. (Skills replace custom prompts/commands as part of the parallel ACP revamp — that work is upstream of this PRD; here we just need to know skills exist.) + +--- + +## 4. Platform & Tier Constraints + +The designer must respect these. They're not negotiable. + +### Obsidian native settings shell + +The plugin renders inside Obsidian's settings modal. Standard convention is a left-rail tab list with a scrollable right-pane content area. Redesigns can deviate, but they must still feel native to Obsidian — no full-bleed marketing pages, no animated backgrounds, no overlays that obscure the modal chrome. Width is constrained by the Obsidian modal (roughly 700–900px effective). + +### Mobile vs desktop + +- **Agent Mode is desktop-only.** External ACP backends (opencode / claude-code / codex) require local binaries; mobile cannot run them. The legacy in-process agent is the only path on mobile. +- **Vault index can be disabled on mobile** (default on) to save battery/storage. +- The settings UI must degrade gracefully on Obsidian mobile (smaller width, touch targets). Desktop-only sections should be visible on mobile but show a "desktop only" state — don't hide them silently or users will think they don't exist. + +### Plus / Believer / Self-host tiers + +- **Free** — BYOK API keys, vault QA with own embeddings, basic agent (legacy). +- **Plus** — hosted models, web search, YouTube transcripts, PDF parsing, reranker, memory. +- **Believer** (lifetime supporter) — unlocks Self-host mode + Miyo (private indexing infra). + +When a feature requires a tier the user doesn't have, **show the section with a locked state** and a one-line "what this does + how to unlock." Don't hide it silently. + +### Eligibility-gated settings + +Some panes (Self-host activation, Miyo enable) require live server-side validation. The 3-strike grace flow allows up to 3 successful validations to grant offline-permanent access. Designer needs a UI pattern for **"verified eligible"** vs **"checking…"** vs **"not eligible — here's why"** states. + +### Settings that trigger expensive work + +Some settings cause heavy side effects when changed. The IA must surface this **inline at the setting**, not in a separate "danger zone." + +- Changing the embedding model → full vault reindex. +- Changing partition count → index rebuild. +- Toggling semantic search on/off → index state changes. +- Disabling index sync → moves index to/from `.obsidian` folder. + +--- + +## 5. Settings Inventory — Post-Revamp State + +Settings are grouped by **functional purpose**, not by current tab. The designer decides where each group lives in the new IA. Each setting has audience tier, default, and notes. + +Tier shorthand: `B` Beginner · `I` Intermediate · `P` Power · `D` Developer. + +### A. Account & Credentials + +| Setting | Tier | Default | Notes | +| --------------------------------------------------------------------------- | ---- | ------- | --------------------------------------------------------------------------------------------------------- | +| Plus license key | B | empty | Validated online; success unlocks Plus features and triggers welcome modal. Drives visibility of group G. | +| OpenAI API key | B | empty | Most common; should be prominent. | +| Anthropic API key | B | empty | | +| Google (Gemini) API key | B | empty | | +| OpenRouter API key | I | empty | | +| Cohere API key | I | empty | Often used for embeddings/reranker. | +| Groq, Mistral, xAI, DeepSeek, SiliconFlow, HuggingFace API keys | I | empty | Long tail; collapse. | +| OpenAI org id | I | empty | | +| OpenAI proxy base URL | P | empty | Custom OpenAI-compatible endpoint. | +| Embedding proxy base URL | P | empty | Separate from chat proxy. | +| Azure OpenAI (api key, instance, deployment, version, embedding deployment) | P | empty | Five fields, only meaningful together. | +| AWS Bedrock (api key, region) | P | empty | Two fields, only meaningful together. | +| GitHub Copilot (access token, token, expiry) | P | empty | OAuth-driven; expiry auto-refreshed. | + +**Today** these live in a separate modal (`ApiKeyDialog`) launched from Basic. Open question: should they stay modal or move inline into the IA? + +### B. Default Chat Behavior + +| Setting | Tier | Default | Notes | +| ------------------------------- | ---- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| Default chat model | B | `google/gemini-2.5-flash\|openrouterai` | The single most important setting. Drives 90% of UX. Picker reads from enabled chat-model registry (group D). | +| Send shortcut | B | Enter | Enter vs Shift+Enter. | +| Where chat opens | B | Sidebar view | Sidebar vs main editor. | +| Auto-add active note to context | B | on | Convenience. | +| Auto-add selection to context | B | off | | +| Pass markdown images | I | on | Only meaningful with multimodal models. | +| Show suggested prompts | B | on | UI affordance toggle. | +| Show relevant notes | B | on | UI affordance toggle. | + +### C. Conversation Storage + +| Setting | Tier | Default | Notes | +| ------------------------ | ---- | ------------------------------- | -------------------------------------------------------------------- | +| Autosave chat | B | on | Saves after each turn. | +| Save folder | B | `copilot/copilot-conversations` | | +| Default conversation tag | B | `copilot-conversation` | | +| Filename template | I | `{$date}_{$time}__{$topic}` | Variables: `{$date}`, `{$time}`, `{$topic}`. Must include all three. | +| AI-generated chat titles | I | on | When off, falls back to first ~10 words. | +| Chat history sort | B | recent | recent / created / name / manual. | +| Project list sort | B | recent | Same options. | + +### D. Models — Chat & Embedding Registries + +These are **CRUD surfaces**, not single-value settings. The IA must accommodate list/table views inside settings. + +**Chat models registry** (I→P) + +- 36+ built-in models pre-populated. Enable/disable per model. Drag-reorder. Add custom model (provider, name, model id, capabilities). +- Per-model capabilities: vision, reasoning. +- Per-model overrides (where supported): temperature, max tokens, reasoning effort, verbosity. + +**Embedding models registry** (I→P) + +- 12 built-in. Same enable/disable pattern. +- Selected embedding model is the one used for QA. **Changing it triggers full reindex** — surface inline. + +**Conversation context** (cross-cutting) + +- Context turns to include in chat history (I; default 15, range 1–50). 1 turn = user message + AI reply. +- Auto-compact threshold in tokens (P; default 128000, range 64000–1000000). When context exceeds this, older turns are summarized. +- Default reasoning effort (P; for o1-class models). Values: minimal / low / medium / high. +- Default verbosity (P; for GPT-5 class). Values: low / medium / high. +- Default temperature (P; default 0.1). +- Default max tokens (P; default 6000). + +### E. Vault Search & Indexing + +| Setting | Tier | Default | Notes | +| ----------------------------- | ---- | -------------- | ----------------------------------------------------------- | +| Semantic search enabled | B | off | When off: lexical-only. Toggling requires reindex. | +| Inline citations in responses | B | on | Experimental; doesn't work with all models. | +| Indexing strategy | I | on mode switch | never / on startup / on mode switch. Cost implications. | +| Max source chunks per QA call | I | 30 | Range 1–128. More = slower + more context. | +| QA inclusions | I | empty | Folder/tag/title patterns. Empty = index everything. | +| QA exclusions | I | `copilot` | Always includes copilot folder. | +| Index sync via Obsidian Sync | I | on | When off: index goes to `.copilot/` instead of `.obsidian`. | +| Disable index on mobile | B | on | QA modes unavailable on mobile when on. | + +**Performance tuning (collapse by default, all P)** + +- Embedding requests per minute (default 60, range 10–60). +- Embedding batch size (default 16, range 1–128). +- Index partition count (default 1; values 1, 2, 4, 8, 16, 32, 40). For large vaults. Requires rebuild on change. +- Lexical search RAM limit MB (default 100, range 20–1000). +- Lexical boosts enabled (default on). Folder/graph relevance boosts. + +**Index management actions** (I) — these are buttons, not toggles. The IA needs to show actions inside a settings page gracefully. + +- Rebuild index +- Force reindex +- Clear index +- Garbage-collect index +- List indexed files +- Inspect file by path +- Clear index cache + +### F. Agent Mode (NEW dedicated top-level section per audit) + +**Desktop-only.** On mobile, show the section with a "desktop only" explainer. + +| Setting | Tier | Default | Notes | +| -------------------- | ---- | -------- | ------------------------------------------ | +| Master enable | P | off | Toggles entire agent UI. | +| Active backend | P | opencode | opencode / claude-code / codex. | +| Max agent iterations | P | 4 | Range 4–16. | +| Auto-accept edits | P | off | When on, file edits apply without preview. | +| Diff view mode | P | split | side-by-side / split. | + +**Per-backend slice** — repeat this pattern for each of the 3 backends. Designer needs a clean repeating layout pattern. + +- Binary path (string). +- Binary version (read-only, detected). +- Binary source: managed / custom (radio). +- Selected model key (dropdown from probe). +- Selected effort/reasoning level (where applicable; e.g., codex uses `gpt-5-codex/high`). +- Selected operational mode: default / plan / auto. +- Per-model enable/disable overrides (table or list — power users curate which models appear in the picker). + +**Tools allowlist** (I) — checkbox list of tools the agent may use: + +- localSearch, readNote, webSearch, pomodoro, youtubeTranscription, writeFile, editFile, updateMemory. + +**MCP servers** (P) — list/CRUD surface. Each entry has: name, command/URL, args, env vars, enabled. Designer needs a list-with-edit-form pattern. + +### G. Plus & Self-Host (gated) + +Show as locked section when license tier is insufficient. Show eligibility-pending state when validation is in flight. + +**Plus features** (require Plus license) + +- Self-host search provider (P): Firecrawl / Perplexity. +- Firecrawl API key (P). +- Perplexity API key (P). +- Supadata API key (P) — for YouTube transcripts. + +**Memory system** (I, experimental, Plus) + +- Memory folder (default `copilot/memory`). +- Recent conversations enabled (default on). +- Max recent conversations (default 30, range 10–50). +- Saved memory enabled (default on). + +**Document processor** (I, Plus) + +- Converted document output folder (string; empty = don't save converted markdown). + +**Self-host mode** (Believer-only — eligibility-gated) + +- Self-host mode enabled (P; default off). Requires Believer license + 15-day re-validation. After 3 successful validations, becomes offline-permanent. +- Self-host backend URL (P). +- Self-host API key (P). + +**Miyo (private index)** — requires self-host mode + +- Miyo enabled (P; default off). +- Miyo server URL (P; empty = local discovery). +- Miyo search-all (P; default off — when off, limited to current vault folder). + +### H. Skills & System Prompts + +Per the ACP audit, **custom prompts and custom commands collapse into "skills."** Skills are file-based — each skill is a markdown file in a configured folder. The settings UI mostly governs _where they live_ and _how they're sorted_; the skills themselves are managed in a CRUD surface. + +| Setting | Tier | Default | Notes | +| ---------------------------- | ---- | -------------------------------- | ------------------------------------------------------------------------------------------ | +| Skills folder | I | `copilot/copilot-custom-prompts` | Auto-loads `.md` files. | +| Default system prompt | P | empty | File name from custom system prompts folder. Empty = built-in. | +| Custom system prompts folder | P | `copilot/system-prompts` | | +| Skill sort | B | timestamp | timestamp / alphabetical / manual (drag). | +| Template variables enabled | I | on | When off: raw prompt text. Variables: `{activeNote}`, `{[[Note]]}`, `{#tag}`, `{folder/}`. | + +The skills _list_ itself (CRUD: create, edit, reorder, delete, toggle visibility in slash menu, toggle visibility in context menu) is the larger surface and the IA must accommodate it. + +### I. Projects (referenced, not configured here) + +Projects live in the chat sidebar, not the settings page. Mentioned because the designer should know: + +- Per-project: system prompt, vault scope (folders/tag patterns), web/YouTube sources, context cache. +- **Per-project model and parameters are dropped in the revamp** — global default applies. + +### J. System & Privacy + +| Setting | Tier | Default | Notes | +| ------------------------ | ---- | ------- | -------------------------------------------------------------------------------------- | +| Encrypt API keys at rest | P | off | Encryption-at-rest for stored credentials. | +| Debug logging | D | off | Console + log file. Performance impact. | +| ACP raw frame logging | D | off | `agentMode.debugFullFrames`. Writes NDJSON to disk; sensitive data. Surface a warning. | +| Open log file | D | action | Button. | +| User ID | D | UUID | Read-only, auto-generated. Analytics. | + +--- + +## 6. Removed / Deprecated (don't design around these) + +These existed in the current schema but go away in the post-revamp world (per audit and cleanup pass). The designer should not surface them. + +- **`defaultChainType`** — the four chat modes (Chat / Vault QA / Copilot Plus / Projects) collapse into one chat surface. +- **`@`-command syntax for tools** (`@vault`, `@web`, `@memory`) — agent calls tools directly. `@`-mention for notes/folders/tags/URLs _stays_. +- **Per-project model and parameters** — global default applies. +- **`inlineEditCommands`** — legacy command format, migrated to file-based skills. +- **`autoIncludeTextSelection`** — renamed to `autoAddSelectionToContext`. +- **`chatNoteContextPath`, `chatNoteContextTags`** — unused. +- **`stream`** — hard-coded true; not a user choice. +- **YouTube transcript modal** — dropped. +- **Agent-tool exposure of file parsing** (PDFs, images, YouTube, web URLs) — backends handle these natively. + +--- + +## 7. New (introduced by revamp) + +- **Agent Mode tab** with per-backend configuration (binary path, model, mode, model-enable overrides). See §5.F. +- **MCP server management** as a list/CRUD surface inside Agent Mode. +- **Skills** consolidate custom prompts + custom commands into one file-based system. +- **Backend session id** persisted per chat (no setting; storage detail). +- **Eligibility-gated panes** (Self-host, Miyo) with three states: not eligible, checking, verified. +- **Settings search** (new affordance — required by this redesign). + +--- + +## 8. IA Principles (asks for the design) + +1. **Quick-setup first.** New user lands on a screen that gets them to a working chat in under a minute: pick a provider, paste an API key, pick a default model. Optional: Plus license. Everything else is reachable but not in their face. +2. **Progressive disclosure.** Each section has an essentials view by default; "advanced," "performance," and "experimental" knobs collapse behind a disclosure toggle. Beginner labels never appear next to expert knobs without a separator. +3. **Settings search.** Inventory is too large to navigate by tab alone. Search/filter must work across all settings (label, description, alias). +4. **Audience tiering visible.** Power-user knobs visually distinct (subdued, badged, or grouped under "Advanced"). Beginners should see "this is normal to skip." +5. **Action surfaces inside settings.** Index rebuild, log file open, license validation, eligibility re-check are buttons, not toggles. The IA must accommodate them gracefully — don't force them into "ghost toggles." +6. **No silent hiding.** When a setting is gated by tier or eligibility, render it with a locked / not-eligible / desktop-only state. Show what unlocks it. +7. **Inline side-effect warnings.** Settings that trigger reindex / migration / restart announce that _at the setting_, not in a separate "danger zone." +8. **Mobile-aware.** Desktop-only sections (Agent Mode, parts of Self-host) need a clear story on mobile. +9. **CRUD surfaces are first-class.** Model registries, skills list, MCP servers — each is a list with rows, edit forms, and reordering. The IA pattern for "settings page that's mostly a list" should be repeatable and consistent across these three. +10. **Repeatable patterns over snowflakes.** The 3 agent backends share structure → use one component pattern. The 19 BYOK providers share structure → use one component pattern. Avoid bespoke layouts per provider/backend. + +--- + +## 9. Open Questions for the Designer + +1. **API keys: modal or inline?** Today they're behind a modal. Should they live inline in the new IA, in a dedicated "Credentials" section, or stay modal-launched from a single "Manage API keys" entry? +2. **Top-level shape:** tabbed left-rail (current), single-page-with-anchors, sidebar-tree, or hybrid? Which fits Obsidian's settings shell best given the inventory size? +3. **Model registry placement:** inside settings as a CRUD list, or in a dedicated "Models" management view reachable from settings? +4. **Skills placement:** same question — settings or dedicated management view? Skills also surface in chat (slash menu) which complicates ownership. +5. **Inline reindex warnings:** what's the right pattern? Inline banner under the setting? Confirmation modal on save? Toast after save? +6. **Plus / Self-host / Miyo grouping:** keep separate sections with their own gating, or merge into one "Cloud & self-host" group with sub-states? +7. **Locked states:** badge, disabled-with-tooltip, expandable upsell card, or something else? +8. **Search UX:** persistent search bar, command-palette-style overlay, or filter chips by audience tier? +9. **"Quick setup" path:** is it a separate screen on first run, a pinned section at the top of settings, or integrated into the chat onboarding flow? +10. **Settings density on mobile:** same layout collapsed, or a fundamentally different mobile IA? + +--- + +## 10. Briefing Prompt for Claude Design + +Paste the prompt below into a fresh Claude Design conversation alongside this document. + +--- + +> # Brief: IA Redesign for Copilot for Obsidian Settings +> +> You are a senior product designer being asked to propose an **information architecture** redesign for the settings UI of _Copilot for Obsidian_, a popular Obsidian plugin that integrates LLM chat, vault Q&A, and an agent mode. +> +> ## What you're working from +> +> The accompanying document `SETTINGS_REDESIGN_PRD.md` is the spec. It contains: +> +> - The user audiences (Beginner / Intermediate / Power / Developer) with examples. +> - A complete inventory of every setting that exists in the post-revamp world, grouped by _functional purpose_ (not current tabs), with audience tier and notes for each. +> - Platform constraints (Obsidian's settings modal, mobile vs desktop, Plus/Believer tiers, eligibility-gated settings). +> - 10 IA principles to design against. +> - 10 open questions you're expected to engage with. +> - A list of removed/deprecated settings (don't design around those). +> +> Read it carefully before proposing anything. +> +> ## What I need from you +> +> 1. **Top-level IA proposal.** What are the top-level sections? In what order? Why? +> 2. **Section-by-section breakdown.** For each top-level section, list its sub-groups and which settings (from the PRD inventory) live there. Use the PRD's setting names verbatim so I can map back. +> 3. **Progressive disclosure pattern.** Concrete pattern for how essentials show by default and how advanced/performance/experimental knobs collapse. Show the same pattern applied to two different sections so I can see it's repeatable. +> 4. **Settings search behavior.** Where it lives, what it searches, what filters it offers, what happens when a setting is in a collapsed group or a gated section. +> 5. **Locked / eligibility states.** Visual treatment pattern for: not-Plus, not-Believer, eligibility-checking, eligibility-failed, desktop-only. Show one example per state. +> 6. **CRUD surface pattern.** A single repeatable pattern for "settings page that's mostly a list" — applied consistently to the three lists (chat models registry, skills, MCP servers). Show the pattern once and note any per-list deviations. +> 7. **Per-backend pattern (Agent Mode).** A repeatable layout for the three agent backends (opencode, claude-code, codex), since their settings share structure. +> 8. **Mobile fallback.** What happens on Obsidian mobile? Specifically, how Agent Mode appears. +> 9. **Quick-setup path.** How does a brand-new user get from "just installed" to "chat works" in under 60 seconds? Where does this flow live? +> 10. **Two layout sketches.** One desktop, one mobile. ASCII or markdown is fine. Show one section at depth — preferably the busiest one (Agent Mode or Vault Search & Indexing) — so I can see how density and disclosure feel. +> +> ## Engage with the open questions +> +> The PRD has 10 open questions in §9. Pick a position on each and explain your reasoning briefly. Don't punt. +> +> ## What you can change +> +> - You may **rename** settings if a current name is unclear (note the rename and why). +> - You may **merge** settings if two are conceptually one (note what you merged). +> - You may **split** a setting if one knob is doing two jobs. +> - You may **reorder** anything in the inventory. +> - You may propose **new affordances** like inline help, contextual tooltips, examples — as long as they serve IA goals. +> +> ## What you cannot change +> +> - Don't propose new settings or remove existing ones (the inventory is fixed by the parallel ACP revamp). +> - Don't propose visual style choices (colors, typography, iconography). This is IA only. +> - Don't redesign the chat UI or any modal flow — only the settings surface. +> +> ## How to deliver +> +> One markdown response. Use headings, lists, and ASCII/markdown sketches where helpful. If you need to ask clarifying questions before committing to a structure, ask them first — I'd rather answer 3 good questions than receive a guess. +> +> Be opinionated. I want a designer's point of view, not a survey of options. Where you make a judgment call, name the trade-off you accepted. + +--- + +## Appendix: Reference files + +For implementers (not the designer): + +- [src/settings/model.ts](../src/settings/model.ts) — `CopilotSettings` interface; source of truth for what exists today. +- [src/constants.ts](../src/constants.ts) — `DEFAULT_SETTINGS` defaults. +- [src/settings/v2/SettingsMainV2.tsx](../src/settings/v2/SettingsMainV2.tsx) — current tab container. +- [src/settings/v2/components/](../src/settings/v2/components/) — current per-tab components. +- `~/Developer/zeroliu_second_brain/notes/Copilot Feature Audit for ACP - Centric Revamp.md` — source of truth for the post-revamp world. diff --git a/designdocs/SKILLS_MANAGEMENT.md b/designdocs/SKILLS_MANAGEMENT.md new file mode 100644 index 00000000..14244f07 --- /dev/null +++ b/designdocs/SKILLS_MANAGEMENT.md @@ -0,0 +1,1132 @@ +# Skills Management — Design Doc + +## Context + +Copilot for Obsidian now spawns three coding-agent backends — Claude +Code (in-process SDK), Codex (`@zed-industries/codex-acp` subprocess), +and OpenCode (subprocess) — each with its own skill discovery system +under a different directory in the vault. In parallel, Copilot has its +own legacy "Custom Commands" feature. + +Today the user has to learn three or four different skill systems, and +agent-side skills are invisible to Copilot — they can't be browsed or +toggled per agent. This proposal adds a new **Skills** tab in Settings +that becomes the central place to organize every skill the user wants +Copilot to manage. + +The design is intentionally small: + +- The Skills tab is empty until the user does something with it. +- If we detect existing skills under `.claude/skills/`, + `.agents/skills/`, or `.opencode/skills/`, we show one friendly + consent card. One click moves all of them into a canonical store + and leaves symlinks behind so the owning agents keep working. +- If we don't detect anything, the tab is just on — no opt-in screen. +- Managed skills get inline per-agent toggles (Claude / Codex / + OpenCode). Toggling on creates a symlink in that agent's folder; + toggling off removes it. The canonical file is never touched. +- The legacy **Custom Commands** tab stays exactly as it is. The two + systems coexist. After the user hits Enter, `/skill-name` and + `/command-name` look identical in the chat. +- User-scope skills (`~/.claude/skills/`, `~/.codex/skills/`, + `~/.config/opencode/skills/`) are explicitly ignored. The owning + CLI keeps using them; Copilot pretends they don't exist. + +The on-disk skill format follows the **Agent Skills specification** +() for the shared fields +(`name`, `description`, `license`, `compatibility`, `metadata`, +`allowed-tools`). Three Claude Code-only flags +(`disable-model-invocation`, `model`, `user-invocable`) are written +as top-level frontmatter keys in Claude Code's native style so +Claude's loader picks them up directly. The single Copilot-only +field — which agents have a symlink — lives inside the spec's +`metadata` escape hatch as `metadata.copilot-enabled-agents`. + +## Per-agent landscape + +| Backend | Project-level skill paths in the vault | +| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Claude | `.claude/skills//SKILL.md` | +| Codex | `.agents/skills//SKILL.md` (walks up to filesystem/git root; use `path.parse(cwd).root` as the stop sentinel — `'/'` won't terminate on Windows) | +| OpenCode | `.opencode/skills//SKILL.md`; **also** reads `.claude/skills/` and `.agents/skills/` cross-discovery, gated by `OPENCODE_DISABLE_EXTERNAL_SKILLS` / `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` env vars | + +All three CLIs converge on the same on-disk format: a directory +containing `SKILL.md` with YAML frontmatter plus optional supporting +files. We adopt the Agent Skills spec verbatim. + +## Goals + +1. **One home for managed skills.** Every Copilot-managed skill lives + at `/copilot/skills//`. The Skills tab shows them in + one list with no scope chrome. +2. **Per-agent toggles via symlinks.** The list row shows three + agent-icon toggles. Toggling on creates a symlink in that agent's + project dir; toggling off removes it. The canonical file is never + touched. +3. **Opt-in bulk import — only when there's something to import.** If + we find pre-existing agent-folder skills, we show a friendly + consent card. One click brings them all under management. If we + find nothing, the tab is just on. +4. **Storage that plays well with every loader.** Shared fields + follow the Agent Skills spec verbatim. Claude-only flags + (`disable-model-invocation`, `model`, `user-invocable`) are + written at top level in Claude's native style so Claude's loader + honors them without translation. The single Copilot-only field + lives under `metadata.copilot-enabled-agents`. +5. **Coexist with the Custom Commands tab.** The legacy tab stays + unchanged. Both surfaces appear in the slash menu; on name + collision the skill wins. + +## Non-goals + +- **User-scope skills** (`~/.claude/skills/`, `~/.codex/skills/`, + `~/.config/opencode/skills/`) — not detected, not warned, not + listed. The owning CLI uses them; Copilot pretends they don't exist. +- **Conversational skill creation** (`skill-creator`) — deferred. v1 + has no "New skill" CTA on the Skills tab; users author skills by + hand or import from agent folders. +- **A visual SKILL.md editor.** Edit opens the file in Obsidian's + editor. +- **Per-row collision resolution UI on import.** v1 auto-suffixes + (`foo-2`, `foo-3`, …). +- **Honoring Claude-only flags on OpenCode / Codex.** `model`, + `disable-model-invocation`, and `user-invocable` are Claude + Code-only in v1. OpenCode and Codex silently ignore them. +- **Cross-vault skill sync.** + +## On-disk layout + +The canonical store location is **user-configurable** via the +`agentMode.skills.folder` setting (see §Skills folder setting). The +default is `copilot/skills`, resolved relative to the vault root. +The rest of this doc uses the literal `copilot/skills` for +readability; everywhere it appears in a path (including the +lifecycle table, consent card, reconciliation rules, milestone +checkpoints, and spawn-time directives), substitute the configured +value. The placeholder `` is used in a few places +where the configurability is load-bearing for understanding. + +``` +/ + / ← canonical home for every managed skill + (default: copilot/skills) + / + SKILL.md ← spec frontmatter, Copilot state in metadata + ...support files + .claude/skills/ ← symlink/junction → absolute + // + when Claude toggle is on + .agents/skills/ ← symlink/junction (Codex) — same rule + .opencode/skills/ ← symlink/junction (OpenCode) — same rule +``` + +Discovery operates in two modes: + +- **Steady-state** (every load): walk `//` only. + Build an in-memory `Skill[]`. +- **Import detection** (on first open and on demand): walk every + per-agent project path and identify entries that are real + directories (not symlinks pointing at `//`). + These are import candidates. + +### Skills folder setting + +`agentMode.skills.folder` — string, defaults to `"copilot/skills"`. +Stored under `agentMode.skills` alongside the rest of the skills +schema in `src/settings/model.ts`. + +**Resolution**: + +- Always interpreted as a vault-root-relative POSIX path. Leading + `/` and `./` are stripped before use. `..` segments are rejected + (validation error in the settings UI). +- Empty / whitespace-only value falls back to the default. +- Forward slashes only on disk; the UI shows the value as the user + typed it. + +**Settings UI surface** (in the Skills tab, header area or top of +the settings panel — implementation choice): + +- Text input labeled "Skills folder" with the configured value. +- Inline helper text: "Where Copilot stores managed skills inside + your vault. Existing skills won't move automatically — see below." +- Save is disabled while the value fails validation (empty after + trim, contains `..`, or contains an OS-illegal segment). + +**Changing the folder** leaves canonical files alone but tears +down the agent fanout that pointed at the old location: + +1. Persist the new value to settings. +2. Do **not** move existing skills automatically — moving a + directory that is the target of live symlinks/junctions requires + per-agent retargeting and atomic-replace semantics we'd rather + not ship silently. The canonical files stay where they are. +3. **Sweep agent dirs**: for each entry under + `./skills/*`, if it's a symlink/junction whose absolute + target resolves into the **previous** configured folder, remove + it. Real directories and symlinks pointing anywhere else are + left untouched (user-owned). The canonical SKILL.md files are + the source of truth — `copilot-enabled-agents` lets us rebuild + the fanout later if the user flips the setting back. +4. Re-run discovery against the new path on the next Skills-tab + open. The grid reflects whatever lives at the new location + (likely empty on first switch). +5. Surface a one-time notice in the Skills tab body when the new + folder is empty but the old folder still contains skills: + + > Your skills folder changed to ``. The agent symlinks + > that pointed at `` have been removed. Move those skills + > into `` (drag in Obsidian or in your file explorer) to + > relink them, or switch the setting back to restore the fanout. + + Users who need migration can change the setting back (fanout + rebuilds automatically from `copilot-enabled-agents`), move the + files by hand, then flip the setting forward again. + +6. Reconciliation only ever operates against the **currently + configured** folder. Flipping back to a previously configured + folder rebuilds the symlinks from each canonical SKILL.md's + `copilot-enabled-agents` — nothing is lost. + +**Spawn-time directive** (see §Decisions captured — "Spawn-time +system prompt steers skill creation into the managed folder") +must template the **currently configured** folder, not a hardcoded +`copilot/skills`. Each backend's `descriptor.ts` reads the live +setting at spawn time. + +### Frontmatter + +Shared fields follow the agentskills.io spec verbatim. Claude +Code-only flags are written at top level in Claude's native style. +The single Copilot-only field — agent symlink fanout — lives inside +`metadata`. + +```yaml +--- +name: + review-prose # spec: required, 1–64 chars, lowercase a-z/0-9/-, + # no leading/trailing/consecutive hyphens, + # must match parent directory name +description: Critique writing for clarity, voice, and rhythm. # spec: required, ≤1024 chars +allowed-tools: Read Grep WebSearch # spec experimental + Claude native; space-separated +model: claude-opus-4-7 # Claude Code native; omitted = agent default +disable-model-invocation: false # Claude Code native; default false +user-invocable: true # Claude Code style (kebab-case top-level); default true +metadata: + copilot-enabled-agents: "claude,opencode" +--- + +``` + +Rules: + +- **Shared fields** (`name`, `description`, `license`, + `compatibility`, `allowed-tools`, `metadata`) — written exactly as + the agentskills.io spec defines them. +- **Claude Code-only flags** — top-level keys in Claude's native + kebab-case style. Claude's loader picks them up directly via the + symlink at `.claude/skills//`, which means we don't need to + synthesize deny rules at spawn time for them. + - `model` — model key passed to Claude. Omitted = Claude default. + - `disable-model-invocation` — boolean. When `true`, Claude cannot + auto-invoke the skill from its own reasoning. The user can still + call it via the chat slash menu. Default `false`. + - `user-invocable` — boolean (Claude-style kebab-case; not a + native Claude field but follows the convention). When `false`, + the skill is hidden from the chat slash menu. Enforced + Copilot-side. Default `true`. (No palette / right-click surface + for managed skills in v1 — see §M7.) +- **`metadata.copilot-enabled-agents`** — comma-separated list of + `claude`, `codex`, `opencode`. Source of truth for which agents + have a symlink in their project dir. Empty string = none. + Lives in `metadata` because it has no Claude-native analog. +- Unknown top-level fields the spec doesn't define are tolerated on + read (OpenCode and Codex are permissive); we only emit the keys + listed above. Unknown `metadata` keys (e.g. `author`, `version`) + are preserved on round-trip but never read or written by Copilot. +- The on-disk symlinks are derived from `copilot-enabled-agents`; on + startup we reconcile (create missing symlinks, remove orphans). + +There is no `data.json` enable map and no separate skip-list. +Per-skill state travels with the skill file. + +### Windows compatibility + +- **Directory junction instead of symlink.** `fs.symlink()` on Windows + requires admin privileges or Developer Mode (Settings → Privacy & + security → For developers); a stock Obsidian process gets `EPERM`. + Use `fs.symlink(absoluteTarget, linkPath, 'junction')` on + `process.platform === 'win32'`. Junctions are directory-only (✓), + require **absolute** targets (resolve before passing), and are + same-volume only (✓). +- **Privilege fallback.** On `EPERM`, surface a one-time notice in + the Skills tab ("Multi-agent fanout requires Developer Mode on + Windows; until then enabled per-agent toggles will be no-ops"). The + canonical SKILL.md is still authoritative; the user can fix it later + by enabling Developer Mode and re-toggling. +- **Rename-with-retry.** Bulk-move (M2) and atomic-replace on toggle + flip (M3) hit `EBUSY` / `EPERM` whenever Obsidian's vault watcher, + OneDrive / Dropbox, or AV holds an open handle. Reuse the existing + helper at + `src/agentMode/backends/opencode/OpencodeBinaryManager.ts:401-413`. +- **Sync-folder caveat.** When the vault lives inside OneDrive / + iCloud Drive / Dropbox on Windows, sync clients sometimes replace + junctions with shortcuts or skip them entirely. Detect via substring + match on the absolute vault path and render a one-line warning in + the Skills tab. + +## Skill lifecycle + +| Action | Effect | +| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Toggle agent X on | Create symlink/junction at `/./skills/` → `/copilot/skills/` (absolute target). Append `X` to `metadata.copilot-enabled-agents`. | +| Toggle agent X off | Remove that symlink. Canonical copy untouched. Remove `X` from `metadata.copilot-enabled-agents`. | +| Edit SKILL.md | Open `/copilot/skills//SKILL.md` in Obsidian's editor. Body and frontmatter both editable; reconciliation picks up changes on next focus. | +| Edit settings | Open per-skill modal (name, description, allowed-tools, Claude-only flags). See §Edit settings modal. Renames are an atomic dir-rename + per-agent symlink swap (delete old `` link, create new `` link). | +| Delete | Remove the canonical directory and every symlink under each agent's project dir. Confirmation dialog lists the concrete paths (see §States). Vault sync / git is the rollback path. | + +Toggle ops are atomic-replace-friendly: if the target path already +exists (e.g. a leftover real dir from an aborted move), rename it to +`..replacing`, create the link with an absolute target, then +delete the `.replacing` directory. + +## The consent card (first-run import) + +When the Skills tab opens and discovery finds ≥1 importable skill (a +real directory under `./skills//` that is not a symlink +to `/copilot/skills/`), the tab body shows a single card: + +``` +You already have some skills in this vault + +We spotted skills tucked inside your agent folders. +Copilot can bring them together in one place so it's easier +to see them, share them across agents, and tweak them. + +▸ skill-a, skill-b, skill-c (from Claude) +▸ research-helper (from Codex) +▸ writing-coach, daily-note (from OpenCode) + +Your agents will keep working exactly the same — we just +leave shortcuts behind so nothing breaks. If two skills +share a name we'll add a small suffix (foo-2, foo-3, …). + +[ Not now ] [ Bring them together ] +``` + +The preview list is read-only — no per-row toggles, no collision UI. + +- **Bring them together** runs the bulk move. Each skill lands with + `metadata.copilot-enabled-agents` set to its source agent. +- **Not now** leaves everything in place. The tab then shows the + quiet placeholder. No persistent skip-list; a `Find existing skills` + header action re-runs detection on demand. + +If discovery returns zero candidates AND zero managed skills exist, +the tab body is just one short line: "Skills you create or import +will show up here." Skills is on by default — there's nothing to +opt into. + +### Bulk-move per skill (atomic) + +1. **Move** `/./skills//` → + `/copilot/skills//`. Use the rename-with-retry helper. + On name collision in the canonical store, append the smallest + suffix (`-2`, `-3`, …) that keeps the name spec-valid. +2. **Verify** the canonical copy parses (SKILL.md frontmatter is + valid per spec). On failure, move back and abort this row with a + one-line notice. +3. **Stamp `metadata`**: set `copilot-enabled-agents` to the source + agent. Preserve every other frontmatter field byte-for-byte + (within the spec's allowed shape). +4. **Atomic-replace** the original path with a symlink/junction to + the canonical copy, absolute target. On Windows without privilege, + skip the link and surface the one-time EPERM notice. +5. An interrupted run can leave the canonical copy without the + symlink; reconciliation on next load walks + `copilot-enabled-agents` and recreates any missing links. + +## Filtering enabled skills per backend + +Default for every imported skill: `copilot-enabled-agents` includes +the source agent only. + +The per-spawn deny list is rebuilt every time a backend launches a +session, computed as `cross_discovered − enabled_for_`, +where: + +- `cross_discovered` = the set of managed skills the backend would + otherwise see via cross-discovery (OpenCode reads `.claude/skills/` + and `.agents/skills/` in addition to its own). +- `enabled_for_` = managed skills whose + `copilot-enabled-agents` includes ``. + +**Claude (SDK)**: Claude's loader honors top-level +`disable-model-invocation` and `model` natively via the symlink at +`.claude/skills//`. We don't synthesize spawn-time deny rules +for them. Claude has no cross-discovery to worry about, so its deny +list is usually empty. (We still wire the deny mechanism for +defense-in-depth and for any future cross-discovery surface.) + +**OpenCode**: extend `OPENCODE_CONFIG_CONTENT` (already injected at +spawn by `OpencodeBackend.buildSpawnDescriptor`, +`src/agentMode/backends/opencode/OpencodeBackend.ts:82-86`) with +`permission.skill: { "": "deny" }` per skill in +`cross_discovered − enabled`. The Claude-only flags +(`disable-model-invocation`, `user-invocable`, `model`) are unknown +to OpenCode and are silently ignored by its frontmatter loader; we +don't translate them. + +**Codex**: managed skills are governed by symlink presence only. If +the Codex toggle is off, there is no symlink at +`.agents/skills/` and Codex cannot see the skill. The +Claude-only flags are unknown to Codex and ignored. + +### Reconciliation — keeping the fanout in sync + +The managed folder and the per-agent symlink dirs stay aligned via +a single idempotent reconciliation pass owned by `SkillManager`. +**`metadata.copilot-enabled-agents` is the source of truth; the +agent-dir symlinks are a derived view.** Whenever the two disagree, +the filesystem is reshaped to match the metadata — never the +reverse. + +#### What constitutes a "managed" entry + +For reconciliation purposes, an entry at `./skills//` +is **managed** (and therefore reconciliation's to touch) iff it's a +symlink/junction whose absolute target resolves into the **current +or any previously configured** skills folder. Everything else — +real directories, symlinks pointing elsewhere, broken symlinks +pointing into directories we never owned — is **user-owned** and +reconciliation never modifies it. The "previously configured" +allowance covers the brief window between a folder change and the +sweep landing; outside that window the same rule lets us clean up +state left by an aborted sweep. + +#### Triggers + +A pass runs (debounced 250ms, single-flight — see Concurrency): + +1. **Plugin load**, once, after `app.vault.onLayoutReady`. +2. **Skills-tab open** and on Skills-tab focus regained. +3. **App / window focus regained** — defensive; catches external + changes (git pull, file-manager edits, sync clients) made while + the plugin wasn't observing. +4. **Vault-watcher events** scoped to: + - `/**` — `create` / `modify` / `delete` / + `rename` on any `SKILL.md` or the parent dir. + - `.claude/skills/**`, `.agents/skills/**`, + `.opencode/skills/**` — link/dir mutations from outside + Copilot. +5. **Immediately after every Copilot-initiated write** — toggle, + rename, delete, bulk import, folder change. The UI doesn't wait + for the watcher; the action runs reconciliation locally so the + grid reflects the new state on the same tick. + +Obsidian's `app.vault.on(...)` only fires for vault-indexed paths; +symlink dirs and files outside the markdown tree can fall through. +Use `fs.watch` (or `chokidar` if multi-platform reliability proves +flaky) on the four watched roots above, scoped to the vault root, +and tear the watchers down in `onunload`. + +#### The pass (idempotent) + +1. **Walk the canonical store.** Read every + `//SKILL.md`, validate against the spec, + build `Skill[]`. Files that fail validation are skipped with a + one-line warning surfaced in the Skills tab (no symlink work is + done for an invalid skill — its symlinks, if any, fall through + to step 3 and are treated as orphans). +2. **Forward sync** — for each skill, for each agent in + `metadata.copilot-enabled-agents`: + - Link missing → create it (absolute target). + - Link present, target resolves to current canonical → no-op. + - Link present, target resolves elsewhere or is broken → + atomic-replace via the §Skill lifecycle helper. + - A **real directory** sits at the path → log a one-line + warning and skip. Reconciliation never deletes real + directories. (This is the "import never completed" or + "user dropped a folder here" case; the user resolves it + by running the consent card or moving the dir.) +3. **Reverse sync (orphan removal)** — for each agent path, list + entries and remove any link that meets all of: + - Is a symlink/junction (never a real dir). + - Resolves into the current or previously configured skills + folder. + - Its basename has no matching entry in step 1's `Skill[]`, + or its target's `` no longer matches the link's + basename. +4. **Emit state.** Publish the new `Skill[]` through `SkillManager`'s + subscription so `SkillsSettings.tsx` re-renders. + +#### Concurrency + +- **Single-flight.** `SkillManager` holds an `inFlight: Promise | null`. + Triggers that fire while a pass is running coalesce to one + trailing pass scheduled when the current one settles. +- **Debounce.** Watcher-driven triggers are debounced 250 ms so a + multi-file save or git checkout fires one pass, not N. +- **UI writes are awaited.** Toggle / rename / delete handlers + `await` reconciliation before resolving so the grid never paints + a stale state. + +#### Failure handling + +- **Windows EPERM** on `fs.symlink(..., 'junction')` → per-skill + warning surfaced in the Skills tab, pass continues for the + remaining skills, metadata is **not** rolled back. The skill + re-attempts its fanout on every subsequent pass; the user enables + Developer Mode and the next trigger heals it. +- **Parse errors** on `SKILL.md` → that skill is skipped with a + one-line warning; orphan removal in step 3 will clean its stale + symlinks since it's absent from `Skill[]`. +- **Mid-pass `ENOENT` / `EBUSY`** (sync client or AV grabbed the + file) → use the existing rename-with-retry helper for writes; + swallow ENOENT on reads (the file was deleted out from under us; + the next pass picks up the new reality). +- **All errors are non-fatal to the pass** — a partial reconcile is + better than a thrown promise that never re-emits state. + +## UI — V1 Tidy list row + +The Skills tab follows the **V1 Tidy list row** variant from the +attached Claude design exploration (the `#v1` section in +`Skills Tab v2.html`). The plugin matches the visual contract — not +the literal HTML/CSS, which is a prototype. + +### Row anatomy + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ skill-name [C][X][O] ⋯ │ +│ One-line description from frontmatter. │ +└──────────────────────────────────────────────────────────────────┘ +``` + +- **Left**: skill name (bold) + one-line description (muted). +- **Right**: three agent icons (Claude / Codex / OpenCode) acting as + toggles. Brand-colored when enabled (Claude coral, Codex charcoal, + OpenCode green); dashed-outline + muted glyph when disabled. Tap to + flip — immediate effect, no confirmation. +- **Far right**: ⋯ overflow menu (`Edit SKILL.md · Edit settings · Delete`). + +### States + +- **Consent-needed** — the consent card body (see §The consent card). +- **Empty placeholder** — one short line ("Skills you create or + import will show up here.") shown when there are no managed skills + and no detectable candidates. No CTA in v1. +- **Steady-state grid** — list of V1 rows. +- **Edit settings modal** — per-skill modal; fields below. +- **Delete confirmation** — modal body lists the concrete paths + that will be removed so the user can verify before confirming: + + ``` + Delete ? + + This will remove: + • /copilot/skills// + (canonical SKILL.md and supporting files) + • /.claude/skills/ (if linked) + • /.agents/skills/ (if linked) + • /.opencode/skills/ (if linked) + + Vault sync / git is the only rollback path. + + [ Cancel ] [ Delete skill ] + ``` + + Only the agent-symlink lines whose agent appears in + `metadata.copilot-enabled-agents` are rendered. The action button + uses the destructive variant. + +### Header action + +Top-right: **Find existing skills** — re-runs import detection and +reopens the consent card. + +There is no **New skill** button in v1. + +### Visual language + +Use Obsidian's existing settings density and typography (Tailwind +`text-normal`, `text-muted`, `border-border`). Do not replicate the +prototype's Kalam / Caveat / JetBrains-Mono aesthetic — that is +prototype chrome, not product chrome. + +### Edit settings modal + +Opened from the row's ⋯ menu. The single metadata surface for a +managed skill — Markdown body editing is reserved for **Edit +SKILL.md** (Obsidian's editor). Fields, top to bottom: + +- **Name** _(all agents)_ — text input mapped to the top-level + `name` field. Validated against the spec + (`^[a-z0-9]+(-[a-z0-9]+)*$`, 1–64 chars). Changing the value + triggers the rename mechanics below. +- **Description** _(all agents)_ — text input mapped to the + top-level `description` field. 1–1024 chars, non-empty per spec. + Plain frontmatter rewrite; no symlink work. +- **Allowed tools** _(all agents)_ — text input mapped to the + top-level `allowed-tools` field (space-separated string, spec + experimental + Claude native). No per-agent translation — the + value is the literal frontmatter string. Example: + `Read Grep Bash(git:*)`. +- **Model override** _(Claude Code only)_ — text input mapped to + the top-level `model` field. UI label carries "(Claude Code only)". + Honored by Claude's loader directly; unknown to OpenCode / Codex. +- **Don't let Claude invoke this on its own** _(Claude Code only)_ + — checkbox mapped to the top-level `disable-model-invocation` + field. Honored by Claude's loader directly; unknown to OpenCode / + Codex. +- **Show in slash menu** _(Claude Code only)_ — + checkbox mapped to the top-level `user-invocable` field (default + on). Enforced Copilot-side by hiding the skill from the chat + slash menu. Claude / OpenCode / Codex don't have a first-party + equivalent; we label it Claude-only to avoid implying parity. + (No palette / right-click surface in v1 — see §M7.) + +#### Rename mechanics + +A rename via this modal is the only interactive code path that +mutates a skill's identity. The save action runs atomically: + +1. Validate the new `name` against the spec. Surface inline + validation errors and keep the dialog open. +2. If another managed skill already owns the new name under + `copilot/skills/`, show an inline collision error and don't save. + **No auto-suffix in interactive edits** — auto-suffix is reserved + for bulk import where the user has no chance to intervene. +3. Rename `copilot/skills//` → `copilot/skills//` via the + rename-with-retry helper (`OpencodeBinaryManager.ts:401-413`). +4. For each agent in `metadata.copilot-enabled-agents`: **remove** + the old symlink at `./skills//` and **create** a + fresh symlink at `./skills//` pointing at the + renamed canonical (absolute target). The two paths are different, + so this is delete-old + create-new, not a same-path retarget; + use the atomic-replace strategy from §Skill lifecycle if a stale + entry from an aborted prior run already sits at the new path. + Old links must be gone before the operation reports success — + leaving them would leave the agent resolving `/old-name` to the + renamed skill. +5. Rewrite the `name:` value inside SKILL.md so the spec's + parent-directory-match rule (see §Frontmatter) holds. +6. On Windows-EPERM symlink failures, surface the same one-time + notice as toggle ops. The canonical rename still succeeds; the + user can re-toggle once Developer Mode is on. + +## Slash command + +Managed skills are invokable from the chat slash menu only. The +command palette and editor right-click context menu are **out of +scope for v1** for managed skills — see §Milestones / §M7 below. +Legacy custom commands continue to surface on the palette and +right-click as they always have, via the existing +`CustomCommandRegister`; that path is untouched. + +### Chat slash menu + +- The slash menu lists managed skills currently enabled for the + active backend (i.e., `metadata.copilot-enabled-agents` includes + the active agent) **plus** every legacy custom command not hidden + by collision. +- Skills with top-level `user-invocable: false` are hidden. +- **Managed skill** → Copilot does nothing special. The user's + message reaches the agent containing the `/skill-name` literal; + the agent's native skill resolver picks it up via the symlink in + `./skills//`. Args go to the agent verbatim. +- **Legacy custom command** → the command body is passed to the + active agent at runtime (not pasted into the input). +- **Visual identical**: after Enter, the chat bubble shows + `/skill-name` or `/command-name` and the agent streams its + response. The user doesn't need to know which mechanism is firing. +- **Name collision**: managed skill wins on the slash menu. The + legacy custom command stays available on the palette / right-click + via its own registration. + +### Plain-LLM fallback + +When no agent backend is configured, route through +`useStreamingChatSession` (`src/hooks/use-streaming-chat-session.ts`) +with the skill body or command body as the prompt, args appended +naively. Keeps Skills usable without Agent Mode. + +## Critical files + +- `src/agentMode/backends/{claude,codex,opencode}/descriptor.ts` — + spawn descriptor surface; OpenCode already injects + `OPENCODE_CONFIG_CONTENT` (we extend it to add per-name deny + entries and the read-only profile). +- `src/agentMode/backends/registry.ts` — add per-backend skill path + map. +- `src/commands/{type,state,customCommandRegister,contextMenu,CustomCommandChatModal,customCommandUtils}.ts` + — legacy command surface preserved; updated to route content to + the agent at runtime and to hide commands shadowed by managed + skills. +- `src/components/chat-components/plugins/SlashCommandPlugin.tsx` — + rewire to managed skills + legacy commands; runtime injection + instead of paste; skill wins on name collision. +- `src/settings/v2/components/SkillsSettings.tsx` (new) — V1 Tidy + list row grid, consent card, empty placeholder, Edit settings + modal, Delete confirmation. +- `src/settings/v2/components/CommandSettings.tsx` — untouched. +- `src/settings/model.ts` — extend `agentMode.skills` schema with + `folder: string` (default `"copilot/skills"`). No `skippedImports` + field; per-skill state lives in SKILL.md. +- `src/agentMode/skills/SkillManager.ts` (new) — canonical-store + discovery, bulk-move, symlink lifecycle, reconciliation. +- `src/agentMode/skills/skillFormat.ts` (new) — SKILL.md + parse/serialize strictly against the agentskills.io spec. Validates + `name` constraints + parent-directory match. Preserves unknown + `metadata` keys on round-trip. +- `src/agentMode/sdk/skillDenyList.ts` (new) and per-backend + equivalents — emit per-name deny snippets for cross-discovery only. + Claude's loader handles `disable-model-invocation` natively via + the symlink, so we don't synthesize deny rules for it. +- `eslint.config.mjs` + `src/agentMode/CLAUDE.md` — register + `skills/` as a new Agent Mode layer with restricted cross-imports. + See §ESLint boundary updates below. + +## ESLint boundary updates + +Placing skills under `src/agentMode/skills/` makes it a new Agent +Mode layer in the sense `src/agentMode/CLAUDE.md` describes +("Adding a new layer"). Two follow-ups need to land in the same PR +as M1 so the discipline isn't silently broken. + +### Boundary plugin status + +`src/agentMode/CLAUDE.md` references `eslint-plugin-boundaries` and a +`boundaries/elements` / `boundaries/dependencies` discipline, but +`eslint.config.mjs` does not currently register the plugin — +`no-restricted-imports` is the only import-restriction rule named, +and it is `off`. Before the rules below can be enforced, either: + +1. Wire `eslint-plugin-boundaries` into `eslint.config.mjs` with + `elements` covering every existing Agent Mode layer (`session`, + `acp`, `sdk`, `backends`, `ui`) plus the new `skills` element. +2. Or, if pulling in the plugin in this PR is out of scope, encode + the same intent via `no-restricted-imports` with `patterns` + (zone-style) as an interim. Same rules, simpler surface. + +Whichever path is chosen, it should also retrofit the existing +layers — leaving boundaries off everywhere except `skills/` would +let the rest of Agent Mode drift further. + +### `skills/` element + dependency rules + +Add `skills` matching `src/agentMode/skills/**` with the following +direction rules: + +- **Skills may import**: + - `src/agentMode/session/**` — types only (`SessionEvent`, + `BackendId`, descriptor surface). + - Shared utilities outside agent-mode that don't reach into + backend internals (`src/logger`, generic vault helpers). + - `node:fs`, `node:path` — already permitted by the + `src/agentMode/**` override at `eslint.config.mjs:240-248`, + which the new path inherits automatically. Confirm the inherited + `import/no-nodejs-modules: off` still matches once any new + boundaries config lands; if the glob narrows, re-add the + inheritance explicitly. +- **Skills must NOT import**: + - `src/agentMode/acp/**` — ACP wire types are confined there. + - `src/agentMode/sdk/**` — sibling in-process driver layer. + - `src/agentMode/backends/**` — backend-specific internals. + Spawn descriptors consume skills, not the other way around. + - `src/agentMode/ui/**` — UI is a downstream consumer. +- **Inbound (who may import `skills/`)**: + - `src/agentMode/backends/**` (spawn-time deny list composition; + `OPENCODE_CONFIG_CONTENT` extension). + - `src/agentMode/sdk/**` and `src/agentMode/ui/**` for the same + reason — they read managed-skill state. + - `src/settings/v2/components/SkillsSettings.tsx` — owns the + consent card, grid, and modals and calls into `SkillManager`. + - `src/main.ts` for plugin-level registration. + +### Doc updates that ride along + +- `src/agentMode/CLAUDE.md`: add `skills/` to the numbered layer list + with a one-line description ("canonical-store discovery, symlink + lifecycle, reconciliation; no agent-wire awareness"). Add `skills/` + to the "What lives where (cheatsheet)" section too. +- This design doc — already reflects the new path under + `src/agentMode/skills/`. + +### M1 checkpoint add-on + +`npm run lint` passes after the new boundary rules land — confirms +`skills/` cannot reach into `acp/`, `sdk/`, `backends/`, or `ui/`, +and that consumers (`backends//descriptor.ts`, +`SkillsSettings.tsx`, `main.ts`) can still import it. + +## Decisions captured + +- **One canonical home, no scopes.** Every managed skill lives at + `///`, where `` is the + user-configurable `agentMode.skills.folder` (default + `copilot/skills`). No scope badges, no Promote action, no + project / user / managed distinction. +- **Skills folder is user-configurable.** `agentMode.skills.folder` + lets users pick the vault-relative folder where managed skills + live. Default `copilot/skills`. Changing the value doesn't + auto-migrate existing skills — see §Skills folder setting. +- **Consent only when there's something to consent to.** Friendly + card with one-click bulk move; quiet placeholder when there's + nothing detected. +- **Shared frontmatter follows the agentskills.io spec.** + Claude-only flags (`model`, `disable-model-invocation`, + `user-invocable`) sit at top level in Claude's native kebab-case + style so Claude's loader honors them directly. The single + Copilot-only field lives under `metadata.copilot-enabled-agents`. +- **Three Claude Code-only fields.** `model`, + `disable-model-invocation`, `user-invocable` are labeled + "(Claude Code only)" in the UI. Claude's loader enforces the + first two; `user-invocable` is enforced Copilot-side by hiding + the skill from the chat slash menu (managed skills have no + palette / right-click surface in v1 — see §M7). OpenCode and + Codex silently ignore all three. +- **Slash unified.** Both legacy commands and managed skills go to + the agent at runtime; nothing pastes into the input. Skill wins + on name collision. +- **User-scope skills ignored entirely.** Not detected, not warned, + not listed. +- **No skill-creator in v1.** Conversational skill creation deferred. +- **No Add CTA in v1.** Skills enter the managed store via the + consent card, by hand-authoring SKILL.md, or by an agent writing + one on the user's behalf (see next bullet). The Skills tab has no + "New skill" button and the empty state is just one line of copy. +- **Edit settings is the single metadata surface.** The row's + ⋯ menu offers `Edit SKILL.md · Edit settings · Delete`. Body + editing goes through Obsidian's Markdown editor; frontmatter + editing (name, description, allowed-tools, three Claude-only + flags) goes through the modal. Renames are an atomic dir-rename + - symlink-retarget op. +- **Delete confirmation lists concrete paths.** The modal body + enumerates `copilot/skills//` and every agent symlink + currently in `copilot-enabled-agents` so users can verify the + blast radius before confirming. +- **Spawn-time system prompt steers skill creation into the + managed folder.** Every backend's spawn descriptor injects a + one-line directive: when the user asks the agent to create a + skill, write `///SKILL.md` with + spec-valid frontmatter and `metadata.copilot-enabled-agents` + pre-set to the authoring agent. `` is templated + from `agentMode.skills.folder` at spawn time (default + `copilot/skills`). Never write into the agent-specific paths — + those are symlink-fanout locations Copilot reconciles + automatically. +- **Custom Commands tab unchanged.** No migration UI; the two + systems coexist permanently as far as v1 is concerned. + +## Milestones + +Each milestone is independently shippable and verifiable. Checkpoints +are concrete pass/fail steps the user can run by hand. + +### M1 — Canonical-store discovery + read-only V1 grid + empty placeholder + +**Goal**: every skill in `/copilot/skills/` shows up in the +Skills tab. Empty placeholder when none. Nothing is mutated. + +**Scope**: + +- `agentMode.skills.folder` setting wired in `src/settings/model.ts` + with default `"copilot/skills"`. Settings UI exposes a "Skills + folder" input with validation (no `..`, no empty, no leading `/`) + and the one-time notice for old-folder symlinks. +- `src/agentMode/skills/SkillManager.ts` reads the configured folder + via the settings singleton (top-level orchestration only — inner + helpers receive the resolved absolute path as a parameter, per the + "Avoiding Deep Dependency Chains in Tests" rule in AGENTS.md) and + walks `//` only. +- `src/agentMode/skills/skillFormat.ts` parses + validates SKILL.md against + the Agent Skills spec. Validates `name` (1–64 chars, lowercase + a–z/0–9/hyphens, no leading/trailing/consecutive hyphens, must + match parent dir) and `description` (1–1024 chars, non-empty). +- `SkillsSettings.tsx`: V1 Tidy list row grid. Toggles rendered but + inert in this milestone. +- Empty placeholder when discovery returns zero skills. +- Legacy `CommandSettings.tsx` left in place. + +**Checkpoints**: + +1. Fresh vault, no managed skills → Skills tab shows the empty + placeholder. +2. Hand-create `/copilot/skills/managedfoo/SKILL.md` with + valid spec frontmatter → reload → row "managedfoo" appears. +3. Hand-create a SKILL.md with an invalid `name` (uppercase, + leading hyphen, consecutive hyphens, mismatched parent dir) → + the row is skipped with a one-line warning in the tab. +4. SKILL.md with extra `metadata` keys (e.g. `author`, `version`) + → round-trip unit test confirms unknown `metadata` keys are + preserved byte-equal. +5. Default setting → discovery walks `/copilot/skills/`. + Change `agentMode.skills.folder` to `team-skills` → reload → + discovery walks `/team-skills/`; the original + `copilot/skills/` folder is ignored. +6. Validation: set the folder to `../escape` or `/abs/path` → the + settings UI rejects the value and Save stays disabled. Empty + string trims to the default `copilot/skills` on save. +7. Switching the folder while skills already exist in the old + location surfaces the one-time notice in the Skills tab body, + leaves the canonical files in the old folder untouched, and + **removes every agent symlink whose target resolves into the + old folder**. Flipping the setting back rebuilds those symlinks + from each canonical SKILL.md's `copilot-enabled-agents`. + +### M2 — Consent card + bulk move + +**Goal**: existing skills under `.claude/skills/`, +`.agents/skills/`, `.opencode/skills/` can be imported into the +canonical store via the consent card. + +**Scope**: + +- Import detection walker scans every per-agent project path and + emits candidates that are real directories (not symlinks pointing + at `/copilot/skills/`). +- Consent card UI shown only when ≥1 candidate exists. + **Bring them together** runs the bulk move; **Not now** dismisses + the card and shows the placeholder / grid. +- `Find existing skills` header action re-runs detection. +- Bulk move per skill: + - Move source dir → `copilot/skills//` via + rename-with-retry. On name collision, smallest suffix + `-2`, `-3`, …. + - Verify SKILL.md parses; on failure, move back and surface a + one-line error. + - Stamp `metadata.copilot-enabled-agents` with the source agent. + - Create symlink/junction at the original agent path → canonical + (absolute target). On Windows EPERM, surface the one-time + notice and proceed without the link. + +**Checkpoints**: + +1. Fresh vault with `.claude/skills/foo/`, `.agents/skills/bar/`, + `.opencode/skills/baz/` (all real dirs) → open Skills tab → + consent card lists all 3 grouped by source. +2. Click **Bring them together** → + `/copilot/skills/{foo,bar,baz}/` exist with original + contents; each agent folder has a symlink to the canonical; + each SKILL.md has `metadata.copilot-enabled-agents` set to its + source. +3. Click **Not now** on a separate fixture → nothing moves; + placeholder / existing grid shown. +4. Click `Find existing skills` header action → consent card + re-opens with current candidates. +5. Name collision (managed `foo` already exists, candidate `foo` + under `.claude/skills/`) → imported as `foo-2`; both rows + present in the grid. + +### M3 — Per-agent toggles for managed skills (symlinks) + +**Goal**: managed skills can be made visible to any subset of agents +via per-agent toggles. Edit and Delete actions wired up. + +**Scope**: + +- Toggle on for an agent → create a symlink (POSIX) / directory + junction (Windows) at `/./skills/` → + absolute path of `/copilot/skills/`. + Atomic-replace if the path already exists. Append the agent to + `metadata.copilot-enabled-agents`. +- Toggle off → remove the link. Canonical copy untouched. Remove + the agent from `metadata.copilot-enabled-agents`. +- On Windows without privilege: surface the one-time notice; the + on-disk fanout is a no-op until Developer Mode is enabled. +- Edit action opens `/copilot/skills//SKILL.md` in the + Obsidian editor. +- Delete action removes the canonical dir + every symlink (confirm + dialog). +- Reconciliation pass on Skills-tab load and on relevant + vault-watch events. + +**Checkpoints**: + +1. Managed card "managedfoo" with all three toggles off → no + symlinks exist under any agent's project dir. +2. Toggle Claude on → `/.claude/skills/managedfoo` is a + symlink to `/copilot/skills/managedfoo`; + `metadata.copilot-enabled-agents` includes `claude`. +3. Toggle Claude off → symlink removed; canonical copy intact; + `metadata.copilot-enabled-agents` no longer includes `claude`. +4. Toggle all three on → three symlinks (Claude / Codex / OpenCode). +5. Edit the canonical SKILL.md body → invoke `/managedfoo` from + any enabled agent's session → updated body runs. +6. Click ⋯ → **Delete** → confirmation modal lists + `/copilot/skills/managedfoo/` plus each agent's symlink + currently in `copilot-enabled-agents`. Confirm → all listed + paths removed; the canonical dir is gone; no orphan symlinks + remain. +7. Manually `rm` a symlink that should exist per + `copilot-enabled-agents` → reload Skills tab → reconciliation + recreates it. + +### M3.5 — Agent-authored skills land in the managed folder + +**Goal**: when a user asks the active agent to create a skill, the +agent writes it under `/copilot/skills//` (not into an +agent-specific path), and the Skills tab picks it up on the next +reconciliation pass. Depends on M3's reconciliation. + +**Scope**: + +- Extend each backend's spawn-time system prompt with a one-line + directive (templated with the authoring agent's kebab-case name + so the skill comes out pre-enabled for it): + + > When the user asks you to create a skill, write + > `///SKILL.md` with valid Agent + > Skills spec frontmatter — at minimum `name`, `description`, + > and `metadata.copilot-enabled-agents: ""` where + > `` is `claude` / `codex` / `opencode` for this + > session, and `` is the value of + > `agentMode.skills.folder` interpolated at spawn time (default + > `copilot/skills`). Do not write into `.claude/skills/`, + > `.agents/skills/`, or `.opencode/skills/` — those are symlink + > locations managed by Copilot; the symlink for this agent will + > be created automatically on the next Skills-tab reconciliation. + +- Wire the directive in + `src/agentMode/backends/{claude,codex,opencode}/descriptor.ts` + alongside existing spawn-prompt assembly. +- Reconciliation already lives in M3 — no changes needed here. If + the agent forgets the `copilot-enabled-agents` metadata, the row + appears with all toggles off and the user can flip them by hand. + +**Checkpoints**: + +1. In a Claude session, ask "create a skill that critiques prose" → + `/copilot/skills//SKILL.md` exists with spec-valid + frontmatter and `metadata.copilot-enabled-agents: "claude"`. + Reload Skills tab → row appears with Claude toggle on, others + off. The Claude symlink at `.claude/skills/` exists. +2. Repeat in an OpenCode session → same flow, OpenCode toggle on + by default and `.opencode/skills/` symlink created. +3. In a Claude session, explicitly say "save it under + `.claude/skills/`" → the agent should still steer the user back + to `copilot/skills/` (verifies directive strength). If it + complies with the user instead, the file is still picked up by + reconciliation only after the next Skills-tab import detection, + not automatically — which is the expected fallback behavior. + +### M4 — Edit settings modal + +**Goal**: per-skill name, description, and advanced options exposed +via the row's ⋯ menu under **Edit settings**. + +**Scope**: + +- Modal with six fields: `name`, `description`, `allowed-tools`, + `model`, `disable-model-invocation`, `user-invocable`. All six + are top-level frontmatter keys. The three Claude-only fields + carry the "(Claude Code only)" label. +- Name edit triggers the rename mechanics (atomic dir-rename + + symlink retarget per agent in `copilot-enabled-agents`). +- Collision on rename → inline error, modal stays open, no + filesystem mutation. +- Round-trip preserves unknown top-level keys and unknown + `metadata` keys. + +**Checkpoints**: + +1. Open Edit settings on `foo`, set `allowed-tools: "Read Grep"`, + `model: "claude-opus-4-7"`, `disable-model-invocation: true` → + reload → all values round-trip as top-level keys. +2. Inspect SKILL.md against Claude's native loader → Claude reads + `model` and `disable-model-invocation` directly. +3. Manually add a `metadata.author: "alice"` key → edit settings → + save → the foreign key is preserved byte-for-byte. +4. Change `description` only → file rewritten with new + description; no symlink work performed. +5. Rename `foo` → `bar` (with Claude and OpenCode toggles on) → + `copilot/skills/bar/` exists, `copilot/skills/foo/` gone, + `.claude/skills/bar` and `.opencode/skills/bar` are symlinks to + the renamed canonical, old symlinks gone, SKILL.md `name:` is + `bar`. Atomic — no in-between state where the symlinks dangle. +6. Rename `foo` to an existing name `baz` → inline collision error + in the dialog; nothing on disk changes. +7. Rename `foo` to an invalid name (uppercase, leading hyphen, etc.) + → inline validation error; nothing on disk changes. + +### M5 — Spawn-time deny (cross-discovery only) + +**Goal**: cross-discovery deny emits correctly per backend. +Claude-only flags need no deny synthesis — Claude's loader honors +them natively. + +**Scope**: + +- OpenCode `permission.skill` per-name deny for every managed skill + in `cross_discovered_for_opencode − enabled_for_opencode`. +- Claude (SDK) deny mechanism wired for symmetry; the deny set is + usually empty (Claude has no cross-discovery surface). Useful as + defense-in-depth. +- Codex: no per-skill deny; managed via symlink presence only. + +**Checkpoints**: + +1. Managed skill `foo` with `copilot-enabled-agents: "claude"` → + Claude session sees `foo`; OpenCode session has `foo` denied + via `permission.skill: { foo: "deny" }` in injected config + (verify by inspecting the spawn descriptor). +2. Set `disable-model-invocation: true` on `foo` at the top level + → Claude session auto-invocation of `foo` is blocked (verify + via Claude's native loader behavior, not via our deny rules). + User-invocation surfaces unaffected. +3. Toggle OpenCode on for `foo` → next OpenCode spawn no longer + denies it; `foo` runs normally. + +### M6 — Slash-command runtime unification + +**Goal**: both managed skills and legacy custom commands run via +the active agent at runtime. UX is visually identical. + +**Scope**: + +- `SlashCommandPlugin.tsx`: replace today's paste-into-input + handler with a send that delegates to the active agent session. + The slash menu lists managed skills enabled for the active agent + plus legacy commands not hidden by collision. +- Legacy custom command path: pass the command body to the agent + at runtime (not pasted into the input). +- Managed skill path: do nothing special — the user message + contains `/skill-name` and the agent's native resolver picks it + up via the symlink. +- Plain-LLM fallback when no agent backend is configured. + +**Checkpoints**: + +1. With Claude backend live and a managed skill `summarize` + (Claude toggle on), type `/summarize text` and hit Enter → + chat shows `/summarize text`; agent loads SKILL.md via the + symlink and runs it. Nothing was pasted into the input. +2. Repeat with a legacy `/oldcmd` → identical UX: chat shows + `/oldcmd`; agent receives the command body at runtime. +3. Slash-name collision: managed `dup` exists and legacy `dup` + exists → only the managed skill is offered in the slash menu; + the command still resolves via its palette entry. +4. Disable Claude toggle on `summarize` → slash menu no longer + lists it for the active Claude session. +5. With Agent Mode disabled and no backend → `/summarize text` + works via plain-LLM fallback (verify network call goes to the + user's configured LLM provider). + +### M7 — Quick-command surface (palette + right-click) — **out of scope for v1** + +A previous iteration of this design proposed surfacing every +enabled managed skill (and every non-shadowed legacy command) on +the Obsidian command palette and the editor right-click context +menu, with a read-only spawn profile. That surface is **explicitly +out of scope for v1** — not deferred, not a follow-up. Managed +skills are reachable only via the chat slash menu (M6); legacy +custom commands continue to surface on the palette and right-click +via their existing `CustomCommandRegister` wiring, unchanged. + +Rationale: the slash menu already covers the user-invocable case, +and a duplicate palette / right-click surface introduced collision +rules and a read-only-profile dependency without a clear win over +the chat surface. If we revisit this later it will be a fresh +design pass, not a resurrection of the prior plan. diff --git a/designdocs/todo/ACP_DESIGN.md b/designdocs/todo/ACP_DESIGN.md index 6ea3a322..40d0cf75 100644 --- a/designdocs/todo/ACP_DESIGN.md +++ b/designdocs/todo/ACP_DESIGN.md @@ -572,6 +572,10 @@ The following items are intentionally deferred from MVP and should be revisited - Decide vault-relative only skills folders vs external/absolute path support. +9. Advanced UI polish (end-of-project nits) + +- Improve the advanced-section UI in agent mode settings. The current button styling looks rough; revisit visual treatment, spacing, and affordance once functionality is locked in. Bundle with other end-of-project polish passes. + ## 14. Acceptance Criteria - Agent mode can run Claude Code, Codex, OpenCode. diff --git a/designdocs/todo/AGENT_MODE_TODOS.md b/designdocs/todo/AGENT_MODE_TODOS.md new file mode 100644 index 00000000..93e45e7f --- /dev/null +++ b/designdocs/todo/AGENT_MODE_TODOS.md @@ -0,0 +1,107 @@ +# ACP Agent Mode TODOs + +- P0: Chat history + - [x] Load chat history from agents + - [x] Save chat history to notes +- [x] P0: Thoroughly assess whether migrating to Anthropic agent SDK is worth it. +- [ ] P0: [Bug] effort is not passed to claude code correctly on first load if not making any changes +- [ ] P0: Test Windows devices +- [ ] P0: Make sure bash command shows what command it runs (visibility) +- [ ] P0: Provide copilot specific system prompt + - [ ] Allow users to share system prompt + - [ ] Do a quick spike on the concrete behavior + - [ ] Investigate opencode provider specific prompt +- [ ] P0: Skills + - [x] Check out cc-switch to understand how to make skills compatible cross other agents https://github.com/farion1231/cc-switch +- [ ] P0: Permission management + - [ ] Permission UI improvement + - [ ] Permission "always allow" doesn't seem to persist +- [ ] P0: How to design the settings to configure the provider? + - [ ] Redesign the model settings - discussed on May 7 group meeting + - [ ] support self host model + - [ ] remove built-in models +- [ ] P0: Fix broken legacy agent mode + - [ ] Can we only support basic chat - not now but eventually yes +- [ ] P0: Auto-save chat history controls +- [ ] P0: Support image context +- [ ] P1: [BUG] Check active note path. Sometimes the agent will start from a path that does not exist +- [ ] P1: MCP + - Basic functionality is ready + - [ ] P1: Surface externally-managed MCP servers (claude.ai remote, plugin-provided) — see [MCP_EXTERNALLY_MANAGED_SERVERS.md](./MCP_EXTERNALLY_MANAGED_SERVERS.md) + - [ ] P1: Support oauth for MCP servers (the one example that I tested didn't work) + - [ ] P1: Support setting MCP by copy pasting JSON blobs +- [ ] P1: Content type support (image, audio) - https://agentclientprotocol.com/protocol/content +- [ ] P1: Edit diff UI - https://agentclientprotocol.com/protocol/tool-calls#diffs + - The edit diff should be based on well rendered markdown, not raw markdown file. For example, table is impossible to understand the diff with the raw format +- [ ] P1: Thoroughly test opencode, codex, and claude code with different test cases + - [ ] Create sample vaults for test cases. +- [ ] P1: Fix session title for claude code agent +- [ ] P1: Agent upgrade detection and helper UI in settings +- [ ] P1: Forward web-source context to the agent + - The agent should be able to access the content of the rendered tab + - Right-click "Add to Copilot context" excerpts from web tabs and the + "include active web tab" toggle currently surface a Notice and are + dropped before the prompt is built. Wire them into the + `` envelope (e.g. a `Web excerpts:` section with + `title (url): content`) so the agent can actually read them. +- [ ] P1: Token counter + - To know how many context is left in the current session + - Nice-to-have: Cost estimate +- [ ] P1: Integrate copilot plus tool calls (convert them to skills) + - [ ] vault search (make it work with Miyo) + - may want to rename + - challenge - how to enable it in an agent + - challenge - agentic search often is better than RAG, how does the agent know when to trigger it + - we don't need index-free search + - [ ] web search (paid feature only) + - [ ] ~~edit~~ + - [ ] youtube transcription (paid feature only) + - [ ] obsidian CLI +- [ ] P1: Opencode plan mode fine-tuning +- [ ] P1: compaction + - [ ] manual trigger + - [ ] configure when to auto compact +- [ ] P1: Project mode +- [ ] P2: Claude vscode plugin add comment to plan capability + - It makes iterating on plan a lot easier +- [ ] P2: [UX] Make mode more obvious + - idea: consider change the chat border color for different modes +- [ ] P2: [UX] fix the brief moment of "Read Read" tool call message +- [ ] P2: Edit previous user message +- [ ] P2: New agent command (/new, /usage) +- [ ] P2: Claude code / Codex authentication +- [ ] P2: Steering conversation (instead of queue) +- [ ] P2: Rollback everything to the state of previous message +- [ ] P3: Rerun agent response +- [ ] P3: Agent todo list + - [ ] make sure in case it renders, it won't be buggy +- [x] P1: Queue messages +- [x] P1: Only include the provided models + - hide openrouter models behind a modal selector +- [x] P2: [UX] Thought for x second shows 0 second +- [x] P2: Subagent nested tool calls +- [x] P2: Keyboard shortcut +- [x] P1: Model, effort, and mode is not persisted across sessions +- [x] P2: Rebuild agent session tabs and right click context menu +- [x] P2: Add agent brand indicator in chat + - so user can know which agent harness they are interacting with +- [x] P2: Rebrand chat send button + - Make it a send icon to save space and get rid of "chat" label which no longer applies +- [x] P1: Merge copilot models with opencode models +- [x] P1: Agent effort selector +- [x] P1: Codex support +- [x] P1: Cancel chat +- [x] P1: Clean up opencode model list (maybe it's related to the "effort" feature) +- [x] P1: Clicking new chat should reset the tab label +- [x] P2: Agent survey (asking for user input) + - ~~Not possible with ACP, need more digging~~ now possible after migrating to agent SDK + - Need to convert the UI to inline card in chat +- [x] P1: Agent message is not rendered in the correct order with the tool calls +- [x] P1: Plan mode preview display +- [x] P1: Support note context input + - [[note]], the "+ Note" picker, "include active note", and right-click + "Add to Copilot context" now forward vault-relative paths / inlined + excerpts in a `` envelope so the agent's Read tool + can fetch them via `VaultClient.readTextFile`. +- [x] P1: Agent mode selector (yolo, plan, safe) - https://agentclientprotocol.com/protocol/session-config-options + - [x] P1: Basic functionality is added but doesn't work well yet. Need thorough test. diff --git a/designdocs/todo/MCP_EXTERNALLY_MANAGED_SERVERS.md b/designdocs/todo/MCP_EXTERNALLY_MANAGED_SERVERS.md new file mode 100644 index 00000000..89815aa6 --- /dev/null +++ b/designdocs/todo/MCP_EXTERNALLY_MANAGED_SERVERS.md @@ -0,0 +1,59 @@ +# MCP servers managed outside the local config + +## Problem + +When the Claude Code backend is active, the running `claude` binary connects to MCP servers from at least three sources we do not control through `~/.claude.json` or `.mcp.json`: + +1. **claude.ai-provisioned remote MCPs** — Gmail, Google Calendar, Google Drive, Slack, etc. Provisioned server-side per claude.ai account; the binary connects to them at startup over HTTPS. +2. **Plugin-provided MCPs** — e.g. `plugin:context7:context7`. Bundled by Claude Code plugins, not declared in any user-editable file. +3. **(Already covered by the main sync plan)** Local config MCPs in `~/.claude.json` and `.mcp.json`. + +`claude mcp list` enumerates all three at runtime. `~/.claude.json` only contains category 3. The only local hint of category 1 is a `claudeAiMcpEverConnected: string[]` field, which is informational ("ever connected", not "currently active") and does not include category 2 at all. + +### Why this is bad + +If the obsidian-copilot MCP panel only shows local-config entries, a user who already has `claude.ai Gmail` connected may add their own `gmail` MCP through the panel. Both register tools under similar/overlapping namespaces; the agent calls become ambiguous, and the user has no signal that the conflict exists. + +We've already locked down "extra registration via ACP" by passing `mcpServers: []` when the backend owns runtime registration (see `MCP_BACKEND_SYNC` plan). That stops obsidian-copilot from causing duplicates itself, but it does not prevent the user from creating a duplicate **inside** the local config that collides with a remote claude.ai MCP. + +## What we cannot do + +- **Disable claude.ai MCPs locally.** No CLI flag, no env var, no config key. Only off-switch is at claude.ai/settings → Connectors (web) or signing the connector out. +- **Disable plugin MCPs without disabling the plugin.** No per-MCP toggle. +- **Get a structured (JSON) list from `claude mcp list`.** Output is human-formatted; parsing is brittle and version-coupled. + +Treating these as user-editable is therefore not on the table. The only available shape is **read-only awareness**. + +## Options + +| Option | Effect | Cost | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| **A. Surface via `claude mcp list`** | Shell out, parse output, render claude.ai/_ and plugin/_ entries as read-only "Managed externally" rows alongside editable local ones. Solves "I don't see it". | Brittle parsing; spawns `claude` (~500ms–1s) on panel mount. Output format may change between Claude Code versions. | +| **B. Static informational note** | Show "Claude Code may connect to remote claude.ai MCPs (Gmail, Calendar, etc.) that aren't editable here. Manage them at claude.ai/settings → Connectors." Just text, no enumeration. | Cheap. Doesn't tell the user _which_ servers, so they can still duplicate by name. | +| **C. Hybrid: warn on name conflict** | Pre-fill known claude.ai MCP names (gmail, calendar, slack, drive — small static list) and warn if user adds a server matching a known remote. | Heuristic; misses unknown remote MCPs and any future additions. | +| **D. Do nothing** | Status quo. User experiences conflict, debugs, learns. | Bad UX. | + +### Recommendation + +**A + B combined.** Shell out to `claude mcp list` once when the panel mounts, render claude.ai/_ and plugin/_ entries as read-only rows tagged "Managed by claude.ai" / "Managed by plugin", with a static note pointing to claude.ai/settings → Connectors. This is exactly what the user already sees in the terminal, lifted into Obsidian. Cache the result for the lifetime of the panel mount; add a manual "Refresh" button to reread. + +Risks to mitigate before implementation: + +- Parser must tolerate format changes — fail closed (omit external rows + log warning), never crash the panel. +- The shell-out must be debounced and cancellable; don't block initial render. +- If `claude` is not on PATH at panel mount (binary configured via custom path only), fall back to invoking via the configured `binaryPath` if the same binary supports `mcp list` (it does — same binary). + +## Open questions + +1. **Render claude.ai/plugin entries inline, or behind a "show external MCPs" disclosure toggle?** Inline is more discoverable; toggle keeps the panel uncluttered for users who never touch external MCPs. +2. **Refresh cadence:** mount-only + manual button, or also re-fetch on `subscribe()` callback from the local-config file watch (since external state can change without local file changes)? +3. **Backend coverage:** does the same problem exist for OpenCode / future Codex backend? If so, the read-only surface should live on `McpStorageAdapter` (`listExternal()` method) rather than be Claude-Code specific. + +## Out of scope + +- Any attempt to disable, mute, or hide claude.ai MCPs at runtime. The user must do that through claude.ai web settings; we can only point them there. +- Forking or patching `claude-agent-acp`. + +## Related + +- Main sync plan: see [`AGENT_MODE_TODOS.md`](./AGENT_MODE_TODOS.md) — _P1: Syncing and managing MCP registered in the backends_. This doc covers the externally-managed sub-case that the main sync plan deliberately does not address. diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 5e926170..961afe67 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -1,9 +1,35 @@ import esbuild from "esbuild"; -import svgPlugin from "esbuild-plugin-svg"; +import { transform as svgrTransform } from "@svgr/core"; +import jsxPlugin from "@svgr/plugin-jsx"; +import { readFile } from "node:fs/promises"; import process from "process"; +import { createRequire } from "module"; import wasmPlugin from "./wasmPlugin.mjs"; import nodeModuleShim from "./nodeModuleShim.mjs"; +// Inline SVGR plugin: each `import Foo from "./foo.svg"` resolves to a React +// component (`React.FC>`) instead of a raw string. +// Source SVGs use `fill="currentColor"`, so theme color follows automatically. +const svgrPlugin = { + name: "svgr", + setup(build) { + build.onLoad({ filter: /\.svg$/ }, async (args) => { + const svg = await readFile(args.path, "utf8"); + const contents = await svgrTransform( + svg, + { jsxRuntime: "classic", typescript: false, plugins: [jsxPlugin] }, + { filePath: args.path, caller: { name: "esbuild-plugin-inline-svgr" } } + ); + return { contents, loader: "jsx" }; + }); + }, +}; + +// CommonJS plugin loaded via createRequire — pure JS, no ESM export needed. +const patchRendererUnsafeUnref = createRequire(import.meta.url)( + "./scripts/patchRendererUnsafeUnref.js" +); + const banner = `/* THIS IS A GENERATED/BUNDLED FILE BY ESBUILD if you want to view the source, please visit the github repository of this plugin @@ -39,14 +65,38 @@ const context = await esbuild.context({ "@lezer/common", "@lezer/highlight", "@lezer/lr", - // Node.js built-in modules (available in Electron) - except node:module which we shim + // Node.js built-in modules (available in Electron) - except `module` / + // `node:module` which we shim. Both prefixed (`node:foo`) and bare + // (`foo`) forms are listed because @anthropic-ai/claude-agent-sdk and + // its transitive deps mix the two styles. "node:fs", + "node:fs/promises", "node:path", + "node:os", "node:url", "node:buffer", "node:stream", "node:crypto", + "node:events", "node:async_hooks", + "node:child_process", + "node:http", + "node:https", + "node:util", + "node:readline", + "node:process", + "async_hooks", + "child_process", + "crypto", + "events", + "fs", + "fs/promises", + "os", + "path", + "process", + "readline", + "url", + "util", ], format: "cjs", target: "es2020", @@ -54,7 +104,7 @@ const context = await esbuild.context({ sourcemap: prod ? false : "inline", treeShaking: true, outfile: "main.js", - plugins: [nodeModuleShim, svgPlugin(), wasmPlugin], + plugins: [nodeModuleShim, svgrPlugin, wasmPlugin, patchRendererUnsafeUnref], define: { global: "window", "process.env.NODE_ENV": prod ? '"production"' : '"development"', diff --git a/eslint.config.mjs b/eslint.config.mjs index 69aa77f4..53032372 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,18 +2,12 @@ import obsidianmd from "eslint-plugin-obsidianmd"; import eslintReact from "@eslint-react/eslint-plugin"; import reactHooks from "eslint-plugin-react-hooks"; import tailwind from "eslint-plugin-tailwindcss"; +import boundaries from "eslint-plugin-boundaries"; import globals from "globals"; export default [ { - ignores: [ - "node_modules/**", - "main.js", - "styles.css", - "data.json", - "designdocs/**", - "docs/**", - ], + ignores: ["node_modules/**", "main.js", "styles.css", "data.json", "designdocs/**", "docs/**"], }, // obsidianmd recommended brings: @@ -119,16 +113,35 @@ export default [ }, }, - // Guardrail: every standalone React root in the plugin must go through - // `createPluginRoot` so descendants can rely on `useApp()` unconditionally - // (the bug class fixed in PR #2466). Forbid importing `createRoot` from - // `react-dom/client` anywhere except the helper itself. + // Two AST-level import bans, combined in one block: + // + // 1. Parent-relative imports (`../foo`, `..`) — use the `@/` path alias + // instead. Survives file moves, keeps grep unambiguous, avoids long + // `../../../` chains. Same-directory `./foo` remains allowed. + // + // 2. `createRoot` from `react-dom/client` outside `createPluginRoot` — + // every standalone React root must go through that helper so descendants + // can rely on `useApp()` unconditionally (bug class fixed in PR #2466). + // + // Both selectors must live in the same block: flat config replaces (does + // not merge) rule values when the same rule key appears in multiple + // matching blocks, so splitting them would silently disable the earlier + // ban on every file the later block also matches. + // + // `createPluginRoot.tsx` is exempted via `ignores` — it owns `createRoot`, + // and has no parent imports today. { files: ["src/**/*.{ts,tsx}"], ignores: ["src/utils/react/createPluginRoot.tsx"], rules: { "no-restricted-syntax": [ "error", + { + selector: + "ImportDeclaration[source.value=/^\\.\\.($|\\u002f)/], ImportExpression[source.value=/^\\.\\.($|\\u002f)/]", + message: + "Parent-relative imports (`../foo`) are banned. Use the `@/` path alias (e.g. `@/components/Foo`) instead.", + }, { selector: "ImportDeclaration[source.value='react-dom/client'] ImportSpecifier[imported.name='createRoot']", @@ -153,6 +166,10 @@ export default [ // Tests use intentional `any` mocks; disable type-safety rules that flood // the test suite without adding signal. "@typescript-eslint/no-unsafe-member-access": "off", + // Tests freely reach across layers and import ACP wire types directly to + // build fixtures; the layer enforcement only applies to production code. + "boundaries/dependencies": "off", + "no-restricted-imports": "off", }, }, @@ -196,6 +213,130 @@ export default [ }, }, + // Agent Mode: backends spawn subprocesses (ACP) and the in-process Claude + // SDK uses node:async_hooks. The plugin runs in Electron renderer where + // these modules are available; the desktop-only Agent Mode is also gated by + // `Platform.isMobile` at runtime in main.ts. + // detectBinary / rendererEventsShim are sibling utilities pulled in by + // agent-mode wiring and share the same Electron-renderer assumptions. + { + files: ["src/agentMode/**", "src/utils/detectBinary.ts", "src/utils/rendererEventsShim.ts"], + rules: { + "import/no-nodejs-modules": "off", + }, + }, + + // Element types (order matters — first match wins; files before folders): + // registry src/agentMode/backends/registry.ts (file) + // barrel src/agentMode/index.ts (file) + // session src/agentMode/session + // acp src/agentMode/acp + // sdk src/agentMode/sdk + // backend src/agentMode/backends/ + // ui src/agentMode/ui + // skills src/agentMode/skills + // host src/** (everything else under src/) + { + files: ["src/**/*.{ts,tsx,js,jsx}"], + plugins: { boundaries }, + settings: { + // Required so `eslint-plugin-boundaries` can resolve `@/*` path aliases + // to their `src/*` targets. + "import/resolver": { + typescript: { project: "./tsconfig.json" }, + node: true, + }, + "boundaries/include": ["src/**/*"], + "boundaries/elements": [ + { type: "registry", pattern: "src/agentMode/backends/registry.ts", mode: "file" }, + { type: "barrel", pattern: "src/agentMode/index.ts", mode: "file" }, + { type: "session", pattern: "src/agentMode/session" }, + { type: "acp", pattern: "src/agentMode/acp" }, + { type: "sdk", pattern: "src/agentMode/sdk" }, + { type: "backend", pattern: "src/agentMode/backends/*", capture: ["name"] }, + { type: "ui", pattern: "src/agentMode/ui" }, + { type: "skills", pattern: "src/agentMode/skills" }, + { type: "host", pattern: "src/**" }, + ], + }, + rules: { + "boundaries/dependencies": [ + "error", + { + default: "disallow", + rules: [ + { from: { type: "session" }, allow: { to: { type: ["session", "host"] } } }, + { from: { type: "acp" }, allow: { to: { type: ["acp", "session", "host"] } } }, + { from: { type: "sdk" }, allow: { to: { type: ["sdk", "session", "host"] } } }, + { + from: { type: "backend" }, + allow: [ + { to: { type: ["acp", "sdk", "session", "skills", "host"] } }, + { to: { type: "backend", captured: { name: "{{from.captured.name}}" } } }, + { to: { type: "backend", captured: { name: "shared" } } }, + ], + }, + { from: { type: "registry" }, allow: { to: { type: ["backend", "session", "host"] } } }, + { + from: { type: "ui" }, + allow: { + to: { type: ["ui", "session", "registry", "skills", "host"] }, + }, + }, + { + from: { type: "skills" }, + allow: { to: { type: ["skills", "session", "host", "registry"] } }, + }, + { + from: { type: "barrel" }, + allow: { + to: { + type: ["acp", "session", "sdk", "backend", "registry", "ui", "skills", "host"], + }, + }, + }, + { from: { type: "host" }, allow: { to: { type: ["host", "barrel"] } } }, + ], + }, + ], + }, + }, + + // Re-disable boundaries/dependencies for tests — the block above otherwise + // re-enables the rule for test files via the broader `src/**` pattern. + { + files: ["**/*.test.{js,jsx,ts,tsx}", "jest.setup.js", "__mocks__/**"], + rules: { + "boundaries/dependencies": "off", + }, + }, + + // Only acp/ may import `@agentclientprotocol/sdk`. Tests are exempted via + // the test block; acp/ itself is exempted below. + { + files: ["src/**/*.{ts,tsx}"], + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "@agentclientprotocol/sdk", + message: + "ACP wire types are confined to src/agentMode/acp/. session/, sdk/, ui/, backends/, and skills/ should depend on the session-domain types in @/agentMode/session/types instead. See src/agentMode/AGENTS.md.", + }, + ], + }, + ], + }, + }, + { + files: ["src/agentMode/acp/**/*.{ts,tsx}"], + rules: { + "no-restricted-imports": "off", + }, + }, + // TypeScript-specific overrides (the @typescript-eslint plugin is registered // by obsidianmd's recommended config only for .ts/.tsx files). { @@ -227,6 +368,20 @@ export default [ }, }, + // Agent Mode tests use heavy `any` mocking for backend / SDK / ACP wire + // types whose real shapes are vendor-controlled and inconvenient to model + // in test scaffolding. Loosen the test-only unsafe rules for the + // agent-mode subtree only; production code stays enforced. Placed after the + // general TS block so it actually overrides. + { + files: ["src/agentMode/**/*.test.{ts,tsx}"], + rules: { + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-unsafe-return": "off", + }, + }, + // Non-TS files aren't in tsconfig.json — disable type-aware rules that // obsidianmd's recommended config enables globally. Most typed obsidianmd // rules are already gated to **/*.ts(x); only no-plugin-as-component leaks @@ -249,7 +404,9 @@ export default [ "error", { presets: ["native", "microutilities", "preferred"], - allowed: [], + // dotenv is used only by integration tests to load .env.test; + // the native --env-file flag doesn't work in jest. + allowed: ["dotenv"], }, ], }, diff --git a/jest.config.js b/jest.config.js index 14e71630..dc847068 100644 --- a/jest.config.js +++ b/jest.config.js @@ -6,12 +6,15 @@ module.exports = { "^.+\\.(js|jsx|ts|tsx)$": "ts-jest", }, moduleNameMapper: { + "\\.svg$": "/__mocks__/svg.js", "^@/(.*)$": "/src/$1", "^obsidian$": "/__mocks__/obsidian.js", // The yaml package's "exports" field defaults to a browser ESM entry under // jsdom; Jest can't parse ESM without extra config, so point at the CJS // build it ships under dist/. "^yaml$": "/node_modules/yaml/dist/index.js", + "^@agentclientprotocol/sdk$": "/__mocks__/@agentclientprotocol/sdk.js", + "^@anthropic-ai/claude-agent-sdk$": "/__mocks__/@anthropic-ai/claude-agent-sdk.js", }, testRegex: ".*\\.test\\.(jsx?|tsx?)$", moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], diff --git a/nodeModuleShim.mjs b/nodeModuleShim.mjs index ae76e18d..234268e4 100644 --- a/nodeModuleShim.mjs +++ b/nodeModuleShim.mjs @@ -2,8 +2,10 @@ const nodeModuleShim = { name: "node-module-shim", setup(build) { - // Intercept node:module imports and provide a shim - build.onResolve({ filter: /^node:module$/ }, (args) => { + // Intercept node:module / module imports and provide a shim. Both prefixed + // and bare forms are matched — @anthropic-ai/claude-agent-sdk imports the + // bare form, while @langchain/community uses node:module. + build.onResolve({ filter: /^(node:)?module$/ }, (args) => { return { path: args.path, namespace: "node-module-shim", diff --git a/package-lock.json b/package-lock.json index 74844c31..4b6b7e9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "3.3.3", "license": "AGPL-3.0", "dependencies": { + "@agentclientprotocol/sdk": "^0.20.0", + "@anthropic-ai/claude-agent-sdk": "^0.2.126", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -42,20 +44,21 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "diff": "^7.0.0", + "dotenv": "^17.4.2", "fuzzysort": "^3.1.0", "jotai": "^2.10.3", "lexical": "^0.34.0", "lucide-react": "^0.462.0", "luxon": "^3.5.0", "minisearch": "^7.2.0", - "openai": "^6.10.0", + "openai": "^6.34.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-resizable-panels": "^3.0.2", "tailwind-merge": "^2.5.5", "turndown": "^7.2.2", "uuid": "^11.1.0", - "zod": "^3.25.76" + "zod": "^4.4.2" }, "devDependencies": { "@codemirror/state": "^6.5.2", @@ -64,6 +67,8 @@ "@google/generative-ai": "^0.24.0", "@jest/globals": "^29.7.0", "@langchain/ollama": "^1.2.2", + "@svgr/core": "^8.1.0", + "@svgr/plugin-jsx": "^8.1.0", "@tailwindcss/container-queries": "^0.1.1", "@testing-library/react": "^14.0.0", "@types/diff": "^7.0.1", @@ -78,6 +83,8 @@ "esbuild": "^0.25.0", "esbuild-plugin-svg": "^0.1.0", "eslint": "^9.18.0", + "eslint-import-resolver-typescript": "^4.4.4", + "eslint-plugin-boundaries": "^6.0.2", "eslint-plugin-obsidianmd": "^0.3.0", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-tailwindcss": "^3.18.0", @@ -88,7 +95,7 @@ "nano-staged": "^0.8.0", "npm-run-all2": "^7.0.2", "obsidian": "^1.2.5", - "prettier": "^3.3.3", + "prettier": "3.8.3", "tailwindcss": "^3.4.15", "tailwindcss-animate": "^1.0.7", "ts-jest": "^29.1.0", @@ -98,6 +105,15 @@ "yaml": "^2.6.1" } }, + "node_modules/@agentclientprotocol/sdk": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.20.0.tgz", + "integrity": "sha512-BxEHyE4MvwyOsdyVPub1vEtyrq8E0JSdjC+ckXWimY1VabFCTXdPyXv2y2Omz1j+iod7Z8oBJDXFCJptM0GBqQ==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -123,10 +139,152 @@ "node": ">=6.0.0" } }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.91.1", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", - "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.2.141", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.141.tgz", + "integrity": "sha512-AIBacMWGcZIUcXlUoObqjwJ6pmJI3BayAqPAFXuvSq3DHJXdiuZVs7l/zTB5l3nRhRv5cqSrI2XbiDeHgZWizw==", + "license": "SEE LICENSE IN README.md", + "dependencies": { + "@anthropic-ai/sdk": "^0.93.0", + "@modelcontextprotocol/sdk": "^1.29.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.141", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.141", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.141", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.141", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.141", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.141", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.141", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.141" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { + "version": "0.2.141", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.2.141.tgz", + "integrity": "sha512-9HZ0ot6+FwOfQ1aeMqQLH4IJGMm/DcP08SysDxscVjBm6l2JjqleHohxi3zid0DurfGweqT+4x9GScJffwg55g==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { + "version": "0.2.141", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.2.141.tgz", + "integrity": "sha512-4iAdarJaQ+2R58s6QJswZCzUdz2WQmL5lYG7Y+FLzWbRSROFfcH0QYpmOqSaPXd2KRQhIJwEacqecDZd/Q1XKQ==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { + "version": "0.2.141", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.2.141.tgz", + "integrity": "sha512-Jdf0ZEwJzOP8sE6rPqdJN+SxMb0/L8sxJg4twCv/7S+Qzk0hJtls+wxSi+0Tjh6EEMaNxJqEGc7S3fx99Wi99Q==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { + "version": "0.2.141", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.2.141.tgz", + "integrity": "sha512-6H1AJ/AVaWNnV22kubUPkOTRzZFH0+qP9k7WlhriHMN9gtgZcVAsITMddDeGjQsQJMCAdhXFd6sgi7TM1LdeOQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { + "version": "0.2.141", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.2.141.tgz", + "integrity": "sha512-DVjp72f3HmrRYpbneWZZWIqkUht5kTZXS7wXGFiwzLz6eNYEgjjh+GcsnhIi8UOwZUtNiKUrjZnoP38ovFqV8A==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { + "version": "0.2.141", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.2.141.tgz", + "integrity": "sha512-fTI1YuM4cxOa4nSgsyMAdB5ELizkWp+w5Ispo4JnnYtcczMAL4D9GBNjWPW0sUzKvjsJOUVim68SmWLWhUOpXQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { + "version": "0.2.141", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.2.141.tgz", + "integrity": "sha512-Wm10J6kfbufbPGFELokiJ/7Y5Oqug4Uag3HXFsV8g7TWCpaItx/oqVaJoiGptuAtXQB7xGLQVTuk082wER+Y5w==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { + "version": "0.2.141", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.2.141.tgz", + "integrity": "sha512-IXuP29YJuWbR5Q6xOHrjFVGG54V2s1FC61UVNwEN5fpxL09MwPnbwtQL6fqgzt/U1MP7vWAwpXZriYAklkH/mg==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/sdk": { + "version": "0.93.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.93.0.tgz", + "integrity": "sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA==", "license": "MIT", "dependencies": { "json-schema-to-ts": "^3.1.1" @@ -658,10 +816,66 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@boundaries/elements": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@boundaries/elements/-/elements-2.0.1.tgz", + "integrity": "sha512-sAWO3D8PFP6pBXdxxW93SQi/KQqqhE2AAHo3AgWfdtJXwO6bfK6/wUN81XnOZk0qRC6vHzUEKhjwVD9dtDWvxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-import-resolver-node": "0.3.9", + "eslint-module-utils": "2.12.1", + "handlebars": "4.7.9", + "is-core-module": "2.16.1", + "micromatch": "4.0.8" + }, + "engines": { + "node": ">=18.18" + } + }, + "node_modules/@boundaries/elements/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/@boundaries/elements/node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/@boundaries/elements/node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/@cfworker/json-schema": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.0.3.tgz", - "integrity": "sha512-ZykIcDTVv5UNmKWSTLAs3VukO6NDJkkSKxrgUTDPBkAlORVT3H9n5DbRjRl8xIotklscHdbLIa0b9+y3mQq73g==" + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", + "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", + "license": "MIT" }, "node_modules/@codemirror/state": { "version": "6.5.2", @@ -769,6 +983,40 @@ "semver": "bin/semver.js" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", @@ -1327,16 +1575,6 @@ "node": ">=18.18.0" } }, - "node_modules/@eslint-react/kit/node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/@eslint-react/shared": { "version": "1.53.1", "resolved": "https://registry.npmjs.org/@eslint-react/shared/-/shared-1.53.1.tgz", @@ -1354,16 +1592,6 @@ "node": ">=18.18.0" } }, - "node_modules/@eslint-react/shared/node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/@eslint-react/var": { "version": "1.53.1", "resolved": "https://registry.npmjs.org/@eslint-react/var/-/var-1.53.1.tgz", @@ -1602,6 +1830,18 @@ "node": ">=18.0.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1899,22 +2139,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/console/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@jest/core": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", @@ -1962,22 +2186,6 @@ } } }, - "node_modules/@jest/core/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@jest/core/node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -2125,22 +2333,6 @@ } } }, - "node_modules/@jest/reporters/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@jest/reporters/node_modules/istanbul-lib-instrument": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.1.tgz", @@ -2239,22 +2431,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/transform/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@jest/types": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", @@ -2272,22 +2448,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/types/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", @@ -2358,6 +2518,26 @@ "@langchain/core": "^1.1.45" } }, + "node_modules/@langchain/anthropic/node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@langchain/classic": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@langchain/classic/-/classic-1.0.9.tgz", @@ -2828,6 +3008,81 @@ "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", "license": "BSD-2-Clause" }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -6150,6 +6405,244 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", + "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", + "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", + "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", + "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", + "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", + "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", + "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", + "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", + "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", + "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", + "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", + "@svgr/babel-plugin-transform-svg-component": "8.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/core": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", + "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^8.1.3", + "snake-case": "^3.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/core/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", + "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.21.3", + "entities": "^4.4.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", + "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "@svgr/hast-util-to-babel-ast": "8.0.0", + "svg-parser": "^2.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "*" + } + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -6191,22 +6684,6 @@ "node": ">=14" } }, - "node_modules/@testing-library/dom/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@testing-library/react": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.0.0.tgz", @@ -6234,13 +6711,15 @@ "node": ">= 10" } }, - "node_modules/@trysound/sax": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", - "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, - "engines": { - "node": ">=10.13.0" + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@types/aria-query": { @@ -6636,31 +7115,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/parser/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/parser/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/@typescript-eslint/project-service": { "version": "8.59.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", @@ -6683,31 +7137,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/project-service/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/project-service/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/@typescript-eslint/scope-manager": { "version": "8.59.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", @@ -6768,31 +7197,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/type-utils/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/type-utils/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/@typescript-eslint/types": { "version": "8.59.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", @@ -6858,24 +7262,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -6892,13 +7278,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/@typescript-eslint/utils": { "version": "8.59.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", @@ -6954,6 +7333,299 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -7052,6 +7724,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -7362,22 +8073,6 @@ "@babel/core": "^7.8.0" } }, - "node_modules/babel-jest/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/babel-plugin-istanbul": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", @@ -7489,11 +8184,146 @@ "dev": true, "license": "(MIT OR Apache-2.0)" }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/body-parser/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/body-parser/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/boolean": { "version": "3.2.0", @@ -7613,6 +8443,15 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cacheable-lookup": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", @@ -7691,7 +8530,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -7751,6 +8589,22 @@ } ] }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -7921,6 +8775,7 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", "dev": true, + "license": "MIT", "engines": { "node": ">= 10" } @@ -7947,12 +8802,82 @@ "simple-wcswidth": "^1.1.2" } }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -7974,22 +8899,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/create-jest/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -8001,7 +8910,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -8016,6 +8924,7 @@ "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.0.1", @@ -8032,6 +8941,7 @@ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", "dev": true, + "license": "MIT", "dependencies": { "mdn-data": "2.0.14", "source-map": "^0.6.1" @@ -8041,10 +8951,11 @@ } }, "node_modules/css-what": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", - "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">= 6" }, @@ -8069,6 +8980,7 @@ "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", "integrity": "sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==", "dev": true, + "license": "MIT", "dependencies": { "css-tree": "^1.1.2" }, @@ -8180,12 +9092,12 @@ "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==" }, "node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", - "dev": true, + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -8337,6 +9249,15 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -8399,6 +9320,7 @@ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", "dev": true, + "license": "MIT", "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", @@ -8413,6 +9335,7 @@ "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", "dev": true, + "license": "BSD-2-Clause", "funding": { "url": "https://github.com/fb55/entities?sponsor=1" } @@ -8427,7 +9350,8 @@ "type": "github", "url": "https://github.com/sponsors/fb55" } - ] + ], + "license": "BSD-2-Clause" }, "node_modules/domexception": { "version": "4.0.0", @@ -8446,6 +9370,7 @@ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.2.0" }, @@ -8461,6 +9386,7 @@ "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^1.0.1", "domelementtype": "^2.2.0", @@ -8470,6 +9396,29 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -8490,6 +9439,11 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, "node_modules/electron": { "version": "27.3.11", "resolved": "https://registry.npmjs.org/electron/-/electron-27.3.11.tgz", @@ -8849,6 +9803,7 @@ "resolved": "https://registry.npmjs.org/esbuild-plugin-svg/-/esbuild-plugin-svg-0.1.0.tgz", "integrity": "sha512-/9ZhvIpl+Ovl6glVK3BedvIwrOwSQnECw4Fy6ZwysWib3Ns7UkX6WNGjMOWtvQ1Cnm0uc7sptiKGm0BthKCAJA==", "dev": true, + "license": "ISC", "dependencies": { "svgo": "^2.2.2" } @@ -8862,6 +9817,11 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -8875,15 +9835,15 @@ } }, "node_modules/escodegen": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", - "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", - "esutils": "^2.0.2", - "optionator": "^0.8.1" + "esutils": "^2.0.2" }, "bin": { "escodegen": "bin/escodegen.js", @@ -8896,57 +9856,6 @@ "source-map": "~0.6.1" } }, - "node_modules/escodegen/node_modules/levn": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", - "dev": true, - "dependencies": { - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/escodegen/node_modules/optionator": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", - "dev": true, - "dependencies": { - "deep-is": "~0.1.3", - "fast-levenshtein": "~2.0.6", - "levn": "~0.3.0", - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2", - "word-wrap": "~1.2.3" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/escodegen/node_modules/prelude-ls": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/escodegen/node_modules/type-check": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", - "dev": true, - "dependencies": { - "prelude-ls": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/eslint": { "version": "9.39.4", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", @@ -9023,6 +9932,31 @@ "eslint": ">=6.0.0" } }, + "node_modules/eslint-import-context": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", + "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" + }, + "peerDependencies": { + "unrs-resolver": "^1.0.0" + }, + "peerDependenciesMeta": { + "unrs-resolver": { + "optional": true + } + } + }, "node_modules/eslint-import-resolver-node": { "version": "0.3.10", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", @@ -9069,6 +10003,41 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/eslint-import-resolver-typescript": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.4.tgz", + "integrity": "sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw==", + "dev": true, + "license": "ISC", + "dependencies": { + "debug": "^4.4.1", + "eslint-import-context": "^0.1.8", + "get-tsconfig": "^4.10.1", + "is-bun-module": "^2.0.0", + "stable-hash-x": "^0.2.0", + "tinyglobby": "^0.2.14", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^16.17.0 || >=18.6.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, "node_modules/eslint-module-utils": { "version": "2.12.1", "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", @@ -9097,6 +10066,49 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-plugin-boundaries": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-boundaries/-/eslint-plugin-boundaries-6.0.2.tgz", + "integrity": "sha512-wSHgiYeMEbziP91lH0UQ9oslgF2djG1x+LV9z/qO19ggMKZaCB8pKIGePHAY91eLF4EAgpsxQk8MRSFGRPfPzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@boundaries/elements": "2.0.1", + "chalk": "4.1.2", + "eslint-import-resolver-node": "0.3.9", + "eslint-module-utils": "2.12.1", + "handlebars": "4.7.9", + "micromatch": "4.0.8" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, + "node_modules/eslint-plugin-boundaries/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-boundaries/node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, "node_modules/eslint-plugin-depend": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/eslint-plugin-depend/-/eslint-plugin-depend-1.3.1.tgz", @@ -9868,22 +10880,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", @@ -9988,6 +10984,15 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -10001,6 +11006,27 @@ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -10049,6 +11075,214 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -10087,8 +11321,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { "version": "3.3.2", @@ -10134,7 +11367,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "dev": true, "funding": [ { "type": "github", @@ -10199,6 +11431,45 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -10321,6 +11592,15 @@ "node": ">= 14" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -10669,9 +11949,9 @@ } }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "license": "MIT", "dependencies": { "minimist": "^1.2.5", @@ -10772,6 +12052,15 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", @@ -10967,8 +12256,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/internal-slot": { "version": "1.1.0", @@ -10985,6 +12273,24 @@ "node": ">= 0.4" } }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-arguments": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", @@ -11089,6 +12395,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -11295,6 +12611,12 @@ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -11458,13 +12780,13 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/isomorphic.js": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", "peer": true, "funding": { "type": "GitHub Sponsors ❤", @@ -11650,22 +12972,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-circus/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-circus/node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -11731,22 +13037,6 @@ } } }, - "node_modules/jest-cli/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-config": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", @@ -11792,22 +13082,6 @@ } } }, - "node_modules/jest-config/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-config/node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -11855,22 +13129,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-diff/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-diff/node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -11931,22 +13189,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-each/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-each/node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -12117,22 +13359,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-matcher-utils/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-matcher-utils/node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -12185,22 +13411,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-message-util/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-message-util/node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -12306,22 +13516,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-resolve/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-runner": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", @@ -12354,22 +13548,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-runner/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-runtime": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", @@ -12403,22 +13581,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-runtime/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-snapshot": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", @@ -12450,22 +13612,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-snapshot/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-snapshot/node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -12515,22 +13661,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-util/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-validate": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", @@ -12560,22 +13690,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-validate/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-validate/node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -12627,22 +13741,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-watcher/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-worker": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", @@ -12682,6 +13780,15 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/jotai": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.10.3.tgz", @@ -12849,6 +13956,12 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -12979,22 +14092,6 @@ } } }, - "node_modules/langsmith/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/langsmith/node_modules/p-queue": { "version": "6.6.2", "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", @@ -13064,9 +14161,10 @@ "integrity": "sha512-oqQ87i49oGRt8u9qIXow+fWeqN6luRMtYAa1GAx1l9AaR3A8Tfrgnmj2+13ej6CMPK6JunKaUXbd+7FDy+s8XQ==" }, "node_modules/lib0": { - "version": "0.2.114", - "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.114.tgz", - "integrity": "sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==", + "version": "0.2.117", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", + "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", + "license": "MIT", "peer": true, "dependencies": { "isomorphic.js": "^0.2.4" @@ -13140,6 +14238,16 @@ "loose-envify": "cli.js" } }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", @@ -13239,7 +14347,8 @@ "version": "2.0.14", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", - "dev": true + "dev": true, + "license": "CC0-1.0" }, "node_modules/memorystream": { "version": "0.3.1", @@ -13250,6 +14359,18 @@ "node": ">= 0.10.0" } }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -13369,9 +14490,10 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, "node_modules/mustache": { "version": "4.2.0", @@ -13426,6 +14548,22 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -13438,6 +14576,17 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "license": "MIT" }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -13676,6 +14825,7 @@ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0" }, @@ -13693,7 +14843,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -13711,7 +14860,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -13859,11 +15007,21 @@ "whatwg-fetch": "^3.6.20" } }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "dependencies": { "wrappy": "1" } @@ -14051,6 +15209,14 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -14073,7 +15239,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "engines": { "node": ">=8" } @@ -14106,6 +15271,16 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -14160,6 +15335,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -14386,10 +15570,11 @@ } }, "node_modules/prettier": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", - "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, + "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" }, @@ -14474,6 +15659,19 @@ "react-is": "^16.13.1" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -14515,6 +15713,21 @@ } ] }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -14553,6 +15766,75 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/react": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", @@ -14793,7 +16075,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -14935,6 +16216,32 @@ "dev": true, "optional": true }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -15046,8 +16353,17 @@ "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } }, "node_modules/saxes": { "version": "6.0.0", @@ -15088,6 +16404,104 @@ "dev": true, "optional": true }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/serialize-error": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", @@ -15117,6 +16531,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -15164,11 +16606,15 @@ "node": ">= 0.4" } }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -15180,7 +16626,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "engines": { "node": ">=8" } @@ -15201,7 +16646,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -15221,7 +16665,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -15238,7 +16681,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -15257,7 +16699,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -15300,6 +16741,17 @@ "node": ">=8" } }, + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -15338,7 +16790,18 @@ "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/stable-hash-x": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", + "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } }, "node_modules/stack-utils": { "version": "2.0.6", @@ -15695,18 +17158,26 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/svgo": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz", - "integrity": "sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==", + "node_modules/svg-parser": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", "dev": true, + "license": "MIT" + }, + "node_modules/svgo": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.2.tgz", + "integrity": "sha512-TyzE4NVGLUFy+H/Uy4N6c3G0HEeprsVfge6Lmq+0FdQQ/zqoVYB62IsBZORsiL+o96s6ff/V6/3UQo/C0cgCAA==", + "dev": true, + "license": "MIT", "dependencies": { - "@trysound/sax": "0.2.0", "commander": "^7.2.0", "css-select": "^4.1.3", "css-tree": "^1.1.3", "csso": "^4.2.0", "picocolors": "^1.0.0", + "sax": "^1.5.0", "stable": "^0.1.8" }, "bin": { @@ -15958,6 +17429,14 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, "node_modules/toml-eslint-parser": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/toml-eslint-parser/-/toml-eslint-parser-0.9.3.tgz", @@ -16357,6 +17836,50 @@ "node": ">= 4.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, "node_modules/update-browserslist-db": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz", @@ -16482,6 +18005,14 @@ "node": ">=10.12.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/w3c-keyname": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.6.tgz", @@ -16572,7 +18103,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -16724,8 +18254,7 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/write-file-atomic": { "version": "4.0.2", @@ -16861,9 +18390,10 @@ } }, "node_modules/yjs": { - "version": "13.6.27", - "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz", - "integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==", + "version": "13.6.30", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.30.tgz", + "integrity": "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==", + "license": "MIT", "peer": true, "dependencies": { "lib0": "^0.2.99" @@ -16890,13 +18420,22 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index bf4df993..a12902fc 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "@google/generative-ai": "^0.24.0", "@jest/globals": "^29.7.0", "@langchain/ollama": "^1.2.2", + "@svgr/core": "^8.1.0", + "@svgr/plugin-jsx": "^8.1.0", "@tailwindcss/container-queries": "^0.1.1", "@testing-library/react": "^14.0.0", "@types/diff": "^7.0.1", @@ -54,6 +56,8 @@ "esbuild": "^0.25.0", "esbuild-plugin-svg": "^0.1.0", "eslint": "^9.18.0", + "eslint-import-resolver-typescript": "^4.4.4", + "eslint-plugin-boundaries": "^6.0.2", "eslint-plugin-obsidianmd": "^0.3.0", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-tailwindcss": "^3.18.0", @@ -64,7 +68,7 @@ "nano-staged": "^0.8.0", "npm-run-all2": "^7.0.2", "obsidian": "^1.2.5", - "prettier": "^3.3.3", + "prettier": "3.8.3", "tailwindcss": "^3.4.15", "tailwindcss-animate": "^1.0.7", "ts-jest": "^29.1.0", @@ -74,6 +78,8 @@ "yaml": "^2.6.1" }, "dependencies": { + "@agentclientprotocol/sdk": "^0.20.0", + "@anthropic-ai/claude-agent-sdk": "^0.2.126", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -107,19 +113,23 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "diff": "^7.0.0", + "dotenv": "^17.4.2", "fuzzysort": "^3.1.0", "jotai": "^2.10.3", "lexical": "^0.34.0", "lucide-react": "^0.462.0", "luxon": "^3.5.0", "minisearch": "^7.2.0", - "openai": "^6.10.0", + "openai": "^6.34.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-resizable-panels": "^3.0.2", "tailwind-merge": "^2.5.5", "turndown": "^7.2.2", "uuid": "^11.1.0", - "zod": "^3.25.76" + "zod": "^4.4.2" + }, + "overrides": { + "@browserbasehq/stagehand": "3.2.1" } } diff --git a/scripts/patchRendererUnsafeUnref.js b/scripts/patchRendererUnsafeUnref.js new file mode 100644 index 00000000..4f2e2ef3 --- /dev/null +++ b/scripts/patchRendererUnsafeUnref.js @@ -0,0 +1,241 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +"use strict"; + +const fs = require("fs"); + +/** + * Esbuild plugin: rewrite every `setTimeout(...).unref()` site in the bundled + * output to `(t=>{t&&t.unref&&t.unref()})(setTimeout(...))`. + * + * Why: the `@anthropic-ai/claude-agent-sdk` process-transport-close path and + * the transitive `@modelcontextprotocol/sdk` stdio-close-wait both call + * `.unref()` on a `setTimeout` handle. In Electron's renderer process the + * timer object lacks `.unref()`, and calling it crashes the renderer at + * teardown. The wrapper preserves Node-side semantics and is a safe no-op + * in the renderer. + * + * Runs as an `onEnd` plugin so it sees the final, post-minify, on-disk + * output. After rewriting it re-scans the file and throws if any + * unmatched site remains, failing the build. + */ +const patchRendererUnsafeUnref = { + name: "patch-renderer-unsafe-unref", + setup(build) { + build.onEnd((result) => { + // Skip when the build itself failed — esbuild has already surfaced errors. + if (result.errors && result.errors.length > 0) return; + + const outfile = build.initialOptions.outfile; + if (!outfile) { + throw new Error( + "[patch-renderer-unsafe-unref] expected build.initialOptions.outfile to be set" + ); + } + + let source; + try { + source = fs.readFileSync(outfile, "utf8"); + } catch (err) { + throw new Error(`[patch-renderer-unsafe-unref] failed to read ${outfile}: ${err.message}`); + } + + // The SDKs whose unsafe `.unref()` sites motivate this patch + // (@anthropic-ai/claude-agent-sdk, @modelcontextprotocol/sdk) only get + // bundled once host code actually imports them. Until that happens the + // bundle has no `setTimeout(...).unref()` sites — that's fine, the + // patch becomes a no-op. The marker probe below distinguishes that + // benign case from "SDK is bundled but the matcher failed." + const sdkBundled = source.includes("@anthropic-ai/claude-agent-sdk"); + + // Iterate: each pass finds all top-level `setTimeout(...).unref()` + // sites, rewrites them right-to-left, and re-scans. Nested sites + // (e.g. an inner `setTimeout(...).unref()` living inside an outer + // setTimeout's argument list) are shadowed by their outer site on + // any single pass, so without iteration the verifier complains they + // remain. Cap at a few passes so a matcher bug can't infinite-loop. + let out = source; + let totalRewritten = 0; + const MAX_PASSES = 8; + for (let pass = 0; pass < MAX_PASSES; pass++) { + const sites = findUnsafeSites(out); + if (sites.length === 0) break; + for (let i = sites.length - 1; i >= 0; i--) { + const { setTimeoutStart, setTimeoutEnd, fullEnd } = sites[i]; + const setTimeoutCall = out.slice(setTimeoutStart, setTimeoutEnd); + const wrapped = `(t=>{t&&t.unref&&t.unref()})(${setTimeoutCall})`; + out = out.slice(0, setTimeoutStart) + wrapped + out.slice(fullEnd); + } + totalRewritten += sites.length; + } + + if (totalRewritten === 0) { + if (sdkBundled) { + throw new Error( + "[patch-renderer-unsafe-unref] @anthropic-ai/claude-agent-sdk " + + "appears bundled but no `setTimeout(...).unref()` sites were " + + "found. The matcher likely missed them after a minifier change." + ); + } + + console.log("[patch-renderer-unsafe-unref] no sites to rewrite (SDK not bundled yet)"); + return; + } + + // Verify: re-scan the final rewritten output. Any remaining site means + // the scanner missed something or iteration didn't converge — fail + // loud. + const remaining = findUnsafeSites(out); + if (remaining.length > 0) { + throw new Error( + `[patch-renderer-unsafe-unref] verifier found ${remaining.length} ` + + "unsafe `setTimeout(...).unref()` site(s) still present after " + + "rewrite. First site near offset " + + remaining[0].setTimeoutStart + + "." + ); + } + + fs.writeFileSync(outfile, out, "utf8"); + + console.log(`[patch-renderer-unsafe-unref] rewrote ${totalRewritten} site(s)`); + }); + }, +}; + +/** + * Find every `setTimeout()\.unref\(\)` site in the source. + * Returns an array sorted by offset. + * + * The scanner is string-literal-aware (single, double, backtick, and + * line/block comments) so parentheses inside string contents don't + * unbalance the count. Regex literals are intentionally not handled — + * minified output rarely emits regex literals adjacent to `(` of a call, + * and the SDK call sites are simple. + */ +function findUnsafeSites(source) { + const NEEDLE = "setTimeout("; + const sites = []; + let i = 0; + while (i <= source.length - NEEDLE.length) { + const at = source.indexOf(NEEDLE, i); + if (at === -1) break; + // Reject identifier-prefixed matches like `mySetTimeout(` — the char before + // must not be an identifier-continuation character or `.`. + if (at > 0) { + const prev = source.charCodeAt(at - 1); + const isIdentChar = + (prev >= 0x30 && prev <= 0x39) || // 0-9 + (prev >= 0x41 && prev <= 0x5a) || // A-Z + (prev >= 0x61 && prev <= 0x7a) || // a-z + prev === 0x24 || // $ + prev === 0x5f || // _ + prev === 0x2e; // . + if (isIdentChar) { + i = at + 1; + continue; + } + } + const setTimeoutStart = at; + const argStart = at + NEEDLE.length; + const closeIdx = findMatchingParen(source, argStart); + if (closeIdx === -1) { + i = at + 1; + continue; + } + const setTimeoutEnd = closeIdx + 1; // one past the `)` + if (source.startsWith(".unref()", setTimeoutEnd)) { + sites.push({ + setTimeoutStart, + setTimeoutEnd, + fullEnd: setTimeoutEnd + ".unref()".length, + }); + i = setTimeoutEnd + ".unref()".length; + } else { + i = at + 1; + } + } + return sites; +} + +/** + * Given a position immediately after an opening `(`, return the index of + * its matching `)`, accounting for nested parens, string literals + * (`'`, `"`, `` ` ``), and `// ` / `/* ... *\/` comments. Returns -1 if + * unbalanced. + */ +function findMatchingParen(source, start) { + let depth = 1; + let i = start; + while (i < source.length) { + const ch = source[i]; + if (ch === "(") { + depth++; + i++; + continue; + } + if (ch === ")") { + depth--; + if (depth === 0) return i; + i++; + continue; + } + if (ch === "'" || ch === '"' || ch === "`") { + i = skipString(source, i, ch); + continue; + } + if (ch === "/" && i + 1 < source.length) { + const next = source[i + 1]; + if (next === "/") { + i = source.indexOf("\n", i + 2); + if (i === -1) return -1; + continue; + } + if (next === "*") { + const end = source.indexOf("*/", i + 2); + if (end === -1) return -1; + i = end + 2; + continue; + } + } + i++; + } + return -1; +} + +/** + * Skip a string literal starting at `i` whose opening quote is `quote`. + * Returns the index just past the closing quote. Handles backslash escapes. + * For template literals, recursively skips `${...}` expressions. + */ +function skipString(source, i, quote) { + i++; // past opening quote + while (i < source.length) { + const ch = source[i]; + if (ch === "\\") { + i += 2; + continue; + } + if (ch === quote) return i + 1; + if (quote === "`" && ch === "$" && source[i + 1] === "{") { + // Skip the `${ ... }` interpolation, balancing inner braces. + let depth = 1; + let j = i + 2; + while (j < source.length && depth > 0) { + const c = source[j]; + if (c === "{") depth++; + else if (c === "}") depth--; + else if (c === "'" || c === '"' || c === "`") { + j = skipString(source, j, c); + continue; + } + j++; + } + i = j; + continue; + } + i++; + } + return i; +} + +module.exports = patchRendererUnsafeUnref; diff --git a/src/LLMProviders/chainRunner/BaseChainRunner.ts b/src/LLMProviders/chainRunner/BaseChainRunner.ts index 8f58b987..b5ca8f40 100644 --- a/src/LLMProviders/chainRunner/BaseChainRunner.ts +++ b/src/LLMProviders/chainRunner/BaseChainRunner.ts @@ -2,7 +2,7 @@ import { ABORT_REASON, AI_SENDER } from "@/constants"; import { logError, logInfo } from "@/logger"; import { ChatMessage, ResponseMetadata } from "@/types/message"; import { err2String, formatDateTime } from "@/utils"; -import ChainManager from "../chainManager"; +import ChainManager from "@/LLMProviders/chainManager"; export interface ChainRunner { run( diff --git a/src/agentMode/AGENTS.md b/src/agentMode/AGENTS.md new file mode 100644 index 00000000..f8da0aa9 --- /dev/null +++ b/src/agentMode/AGENTS.md @@ -0,0 +1,172 @@ +# Agent Mode — layer rules + +Six element types, strict imports. Enforced by `eslint-plugin-boundaries` +(see root `eslint.config.mjs`). The list below mirrors `boundaries/elements` and +`boundaries/dependencies` exactly — when in doubt, the lint config wins. + +1. **`session/`** — **the contract layer.** Hosts the + backend-agnostic session, message store, UI-state bridge, + persistence manager. +2. **`acp/`** — the generic ACP runtime (subprocess, JSON-RPC connection, + vault MCP client, JSON-RPC stream tap) — + the only place that touches `@agentclientprotocol/sdk`. **All ACP knowledge is confined here.** +3. **`sdk/`** — in-process SDK adapters that implement + `BackendProcess` directly. Today + this hosts the Claude Agent SDK driver. **Does not import `acp/` or `@agentclientprotocol/sdk`** — + `acp/` and `sdk/` are siblings, and the SDK adapter speaks the + session domain natively. +4. **`backends//`** — one backend per folder. Exports a + `BackendDescriptor` whose `createBackendProcess` returns a finished + `BackendProcess`. +5. **`backends/registry.ts`** — the only place that names every + backend. +6. **`ui/`** — backend-agnostic React UI. — permission modals, model pickers, and + trail rendering all read session-domain types; +7. **`skills/`** — canonical-store discovery, symlink lifecycle, reconciliation, + and the Skills settings UI. + +## Why two adapters under one session + +The contract lives in `session/types.ts` (`BackendProcess`, +`BackendDescriptor`, the full session-domain type set) and +`session/errors.ts` (`MethodUnsupportedError`). `acp/AcpBackendProcess` +wraps a JSON-RPC subprocess and translates ACP wire ↔ session-domain +at its public boundary (via `acp/wireTranslate.ts`); +`sdk/ClaudeSdkBackendProcess` wraps an in-process async generator and +emits session-domain events natively. Both produce the same +`SessionEvent` stream and `BackendState`, so `AgentSession` stays +oblivious. Crucially, **neither adapter imports the other**, +`session/` doesn't import either, and `@agentclientprotocol/sdk` +imports outside `acp/` are blocked by lint +(`no-restricted-imports`). + +## Adding a new backend + +Pick a track based on what the agent gives you: + +- **Subprocess track** (codex, opencode) — the agent speaks ACP + over stdio. Implement `AcpBackend` in `Backend.ts` and have the + descriptor's `createBackendProcess(args)` call + `simpleBinaryBackendProcess(args, new Backend())` from + `backends/shared/simpleBinaryBackend.ts`. The helper wraps the + spawn descriptor in `AcpBackendProcess` for you. +- **In-process / SDK track** (claude) — the agent ships an + in-process SDK. Put the `BackendProcess` implementation in `sdk/` + if any logic is reusable (translator, debug tap, MCP shim) and + have the descriptor's `createBackendProcess(args)` construct it + directly. + +Then in either case: + +1. Create `backends//` with: + - `descriptor.ts` — `export const BackendDescriptor: BackendDescriptor = {…}` + - `index.ts` — re-exports the descriptor + - any backend-specific UI (install modal, settings panel, + permission modal) co-located here + - `Backend.ts` (subprocess track only) +2. Add the entry to `backends/registry.ts`. +3. Settings: store backend-specific config under `agentMode.backends.` + (extend `CopilotSettings.agentMode.backends` in `src/settings/model.ts`). +4. Done. **No edits to `acp/`, `session/`, `sdk/`, or `ui/` should be + required.** If you need one, the boundary is leaking — extend the + descriptor surface instead. + +## Adding a new layer + +1. Create `src/agentMode//`. +2. Add an entry under `boundaries/elements` in root `eslint.config.mjs` and a + corresponding rule in `boundaries/dependencies`. +3. Re-export from `src/agentMode/index.ts` if it should be visible to + plugin host code. +4. Update this doc. + +## What lives where (cheatsheet) + +- "Backend-agnostic contract — `BackendProcess`, `BackendDescriptor`, + the session-domain types, `MethodUnsupportedError`, debug sink, + `translateBackendState` helper" → `session/` +- "ACP wire types, JSON-RPC subprocess plumbing, vault MCP client, + ACP↔domain translators" → `acp/` (the **only** layer that imports + `@agentclientprotocol/sdk`) +- "In-process driver for an SDK that produces session-domain events + natively" → `sdk/` +- "Helper used by more than one backend" → `backends/shared/` +- "Knows about a specific binary, install path, BYOK keys, vendor models" → `backends//` +- "React component or modal" → `ui/` (generic) or `backends//` + (backend-specific) +- "Canonical-store skill discovery, symlink lifecycle, SKILL.md + parser/serializer, Skills settings tab (reads `backends/registry.ts` + for the brand list)" → `skills/` +- "Plugin-level wiring" → `index.ts` only + +## Modals and dialogs + +**Always prefer Obsidian's native `Modal`** (from `obsidian`) over the +Radix-based `Dialog` primitive in `@/components/ui/dialog`. The native +modal gives us correct popout-window behavior, native header chrome, +ESC handling, and visual consistency with the rest of the plugin. + +The standard pattern (see `src/components/modals/ConfirmModal.tsx` and +`src/agentMode/skills/ui/DeleteConfirmDialog.tsx`): + +Only reach for the Radix `Dialog` when the surface needs to live +_inside_ an existing React tree (e.g. nested inside another modal) +and spawning a separate Obsidian `Modal` would break the focus or +layout flow. + +## BackendDescriptor surface + +The descriptor is the contract `session/`, `sdk/`, and `ui/` rely on. + +If a UI component needs something the descriptor doesn't expose, **add +it to the descriptor** — don't reach into a specific backend. The +descriptor will keep growing; that's by design. + +## Debugging tips + +### Inspect full Agent Mode frames + +The default debug log truncates each frame's payload to 400 chars. For +diagnostic frame logs with larger payload summaries, turn on +**Settings → Advanced → Log Full Agent Mode Frames**. + +When enabled, every parsed frame is appended as one NDJSON line outside the +vault, under the OS temp directory: + +```text +/obsidian-copilot/acp-frames//acp-frames.ndjson +``` + +Each line is a `FrameRecord` (`src/agentMode/session/debugSink.ts`): + +```ts +{ ts, dir: "→" | "←", tag, kind: "request" | "notif" | "result" | "error" | "raw", + method, id, payload } +``` + +`dir` is from the plugin's perspective: `→` = sent to the agent, +`←` = received from the agent. `tag` is the backend id (e.g. `claude-sdk`, +`opencode`, `codex`). The ACP runtime (`acp/debugTap`) and the Claude SDK +adapter (`sdk/sdkDebugTap`) both feed the shared sink, so JSON-RPC and +SDK turns appear in the same file. + +Useful queries: + +```bash +LOG="" + +# count frames by method +jq -r .method "$LOG" | sort | uniq -c | sort -rn + +# inspect every session/update payload +jq -c 'select(.method=="session/update") | .payload' "$LOG" + +# only frames for one backend +jq -c 'select(.tag=="claude-sdk")' "$LOG" +``` + +The file is append-only and bounded. Oversized individual frames are replaced +with a `__truncated` summary, and at 50 MB the file rotates to +`acp-frames.old.ndjson` (overwriting any prior `.old`) in the same temp +folder. Use the **Open** / **Clear** buttons in the same settings section, or +delete the files directly. Disable the toggle when not actively debugging. diff --git a/src/agentMode/CLAUDE.md b/src/agentMode/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/src/agentMode/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/src/agentMode/acp/AcpBackendProcess.test.ts b/src/agentMode/acp/AcpBackendProcess.test.ts new file mode 100644 index 00000000..a4acde1d --- /dev/null +++ b/src/agentMode/acp/AcpBackendProcess.test.ts @@ -0,0 +1,182 @@ +import { FileSystemAdapter, App } from "obsidian"; +import type { BackendDescriptor } from "@/agentMode/session/types"; +import { AcpBackendProcess } from "./AcpBackendProcess"; +import type { AcpBackend } from "./types"; +import type { VaultClient } from "./VaultClient"; + +jest.mock("@/logger", () => ({ + logInfo: jest.fn(), + logWarn: jest.fn(), + logError: jest.fn(), +})); + +const exitListeners = new Set<() => void>(); +let mockProcessIsRunning = true; + +jest.mock("./AcpProcessManager", () => ({ + AcpProcessManager: jest.fn().mockImplementation(() => ({ + start: () => ({ + stdin: new WritableStream(), + stdout: new ReadableStream(), + }), + onExit: (fn: () => void) => { + exitListeners.add(fn); + return () => exitListeners.delete(fn); + }, + isRunning: () => mockProcessIsRunning, + shutdown: jest.fn().mockResolvedValue(undefined), + })), +})); + +function buildApp(basePath = "/vault"): App { + const adapter = new (FileSystemAdapter as unknown as new (basePath: string) => unknown)(basePath); + return { vault: { adapter } } as unknown as App; +} + +function buildStubBackend(): AcpBackend { + return { + id: "opencode", + displayName: "opencode", + buildSpawnDescriptor: jest.fn().mockResolvedValue({ + command: "/bin/true", + args: [], + env: {}, + }), + }; +} + +function buildStubDescriptor(): BackendDescriptor { + return { + id: "opencode", + displayName: "opencode", + } as unknown as BackendDescriptor; +} + +/** + * Pull the VaultClient that AcpBackendProcess wires into the mock + * ClientSideConnection. The mock stores the `toClient(this)` result on + * `_client`, which lets tests trigger routing/permission paths the same way + * the agent backend would. + */ +function getVaultClient(backend: AcpBackendProcess): VaultClient { + const connection = (backend as unknown as { connection: { _client: VaultClient } }).connection; + return connection._client; +} + +describe("AcpBackendProcess", () => { + beforeEach(() => { + exitListeners.clear(); + mockProcessIsRunning = true; + }); + + it("routes session updates to the matching session handler and drops unknown ones", async () => { + const backend = new AcpBackendProcess( + buildApp(), + buildStubBackend(), + "1.0.0", + buildStubDescriptor() + ); + await backend.start(); + + const handler = jest.fn(); + backend.registerSessionHandler("session-known", handler); + + const client = getVaultClient(backend); + const knownUpdate = { + sessionId: "session-known", + update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "ok" } }, + } as unknown as Parameters[0]; + await client.sessionUpdate(knownUpdate); + // Handler is called with a SessionEvent (translated from the wire shape). + expect(handler).toHaveBeenCalledTimes(1); + const got = handler.mock.calls[0][0]; + expect(got.sessionId).toBe("session-known"); + expect(got.update.sessionUpdate).toBe("agent_message_chunk"); + + const strayUpdate = { + sessionId: "session-unknown", + update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "x" } }, + } as unknown as Parameters[0]; + await expect(client.sessionUpdate(strayUpdate)).resolves.toBeUndefined(); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("returns cancelled outcome when permission is requested but no prompter is registered", async () => { + const backend = new AcpBackendProcess( + buildApp(), + buildStubBackend(), + "1.0.0", + buildStubDescriptor() + ); + await backend.start(); + + const client = getVaultClient(backend); + const response = await client.requestPermission({ + sessionId: "s1", + toolCall: { + toolCallId: "tc1", + title: "Run dangerous thing", + }, + options: [{ optionId: "ok", name: "Allow", kind: "allow_once" }], + } as unknown as Parameters[0]); + expect(response).toEqual({ outcome: { outcome: "cancelled" } }); + }); + + it("delegates to the registered prompter and forwards the response", async () => { + const backend = new AcpBackendProcess( + buildApp(), + buildStubBackend(), + "1.0.0", + buildStubDescriptor() + ); + await backend.start(); + + const prompter = jest + .fn() + .mockResolvedValue({ outcome: { outcome: "selected", optionId: "ok" } }); + backend.setPermissionPrompter(prompter); + + const client = getVaultClient(backend); + const req = { + sessionId: "s1", + toolCall: { toolCallId: "tc1", title: "Read" }, + options: [{ optionId: "ok", name: "Allow", kind: "allow_once" }], + } as unknown as Parameters[0]; + const response = await client.requestPermission(req); + expect(prompter).toHaveBeenCalledTimes(1); + // Prompter receives a session-domain `PermissionPrompt`. + const prompt = prompter.mock.calls[0][0]; + expect(prompt.sessionId).toBe("s1"); + expect(prompt.toolCall.toolCallId).toBe("tc1"); + expect(response).toEqual({ outcome: { outcome: "selected", optionId: "ok" } }); + }); + + it("clears connection state on subprocess exit so subsequent ops fail with a clear error", async () => { + const backend = new AcpBackendProcess( + buildApp(), + buildStubBackend(), + "1.0.0", + buildStubDescriptor() + ); + await backend.start(); + const handler = jest.fn(); + backend.registerSessionHandler("s1", handler); + + // Simulate the subprocess dying. + mockProcessIsRunning = false; + for (const fn of exitListeners) fn(); + + await expect(backend.prompt({ sessionId: "s1", prompt: [] })).rejects.toThrow(/has exited/); + expect(backend.isRunning()).toBe(false); + }); + + it("throws if start() was never called", async () => { + const backend = new AcpBackendProcess( + buildApp(), + buildStubBackend(), + "1.0.0", + buildStubDescriptor() + ); + await expect(backend.prompt({ sessionId: "s1", prompt: [] })).rejects.toThrow(/start\(\)/); + }); +}); diff --git a/src/agentMode/acp/AcpBackendProcess.ts b/src/agentMode/acp/AcpBackendProcess.ts new file mode 100644 index 00000000..7547e82b --- /dev/null +++ b/src/agentMode/acp/AcpBackendProcess.ts @@ -0,0 +1,549 @@ +import { logError, logInfo, logWarn } from "@/logger"; +import { + ClientSideConnection, + PROTOCOL_VERSION, + RequestError, + ndJsonStream, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionConfigOption, + type SessionId as AcpSessionId, + type SessionModeState, + type SessionModelState, + type SessionNotification, +} from "@agentclientprotocol/sdk"; +import { App, FileSystemAdapter } from "obsidian"; +import { AcpProcessManager, AcpProcessManagerOptions } from "./AcpProcessManager"; +import { VaultClient } from "./VaultClient"; +import { JSONRPC_METHOD_NOT_FOUND, MethodUnsupportedError } from "@/agentMode/session/errors"; +import type { + BackendDescriptor, + BackendProcess, + BackendState, + CancelInput, + ListSessionsInput, + ListSessionsOutput, + LoadSessionInput, + LoadSessionOutput, + OpenSessionInput, + OpenSessionOutput, + PermissionDecision, + PermissionPrompt, + PromptInput, + PromptOutput, + ResumeSessionInput, + ResumeSessionOutput, + SessionId, + SessionUpdateHandler as DomainSessionUpdateHandler, +} from "@/agentMode/session/types"; +import { wrapStreamsForDebug } from "./debugTap"; +import { AcpBackend } from "./types"; +import { + acpNotificationToEvent, + acpPermissionRequestToPrompt, + acpStateToBackendState, + cancelInputToAcp, + decisionToAcpResponse, + listedSessionFromAcp, + mcpServerSpecToAcp, + promptContentToAcp, + sessionIdFromAcp, + sessionIdToAcp, + stopReasonFromAcp, +} from "./wireTranslate"; + +/** + * Capabilities the agent may or may not implement. Tracked in a single Set so + * adding a new capability is one constant + one branch in the unsupported + * handler instead of touching reset / probe / getter sites. + */ +export type AcpCapability = + | "session/list" + | "session/resume" + | "session/load" + | "session/set_model" + | "session/set_mode" + | "session/set_config_option" + | "mcp/http" + | "mcp/sse"; + +/** + * Detect a JSON-RPC -32601 (method not found) error from the ACP SDK. The SDK + * surfaces these as `RequestError` instances; we also tolerate plain objects + * shaped like `{ code: number }` defensively. + */ +function isMethodNotFoundError(err: unknown): boolean { + if (err instanceof RequestError) return err.code === JSONRPC_METHOD_NOT_FOUND; + if (typeof err === "object" && err !== null && "code" in err) { + return err.code === JSONRPC_METHOD_NOT_FOUND; + } + return false; +} + +const COPILOT_CLIENT_NAME = "obsidian-copilot"; + +/** + * Per-session bookkeeping for the latest known wire-shaped catalogs. We keep + * these so that mid-session `current_mode_update` / `config_option_update` + * notifications and per-dimension `setSession*` calls can produce a fresh + * `BackendState` without having to refetch from the agent. + */ +interface SessionWireState { + models: SessionModelState | null; + modes: SessionModeState | null; + configOptions: SessionConfigOption[] | null; +} + +/** + * One-per-vault wrapper around an ACP-speaking subprocess. Owns the + * `ClientSideConnection`, the `AcpProcessManager`, and the demultiplexer + * that fans `session/update` notifications out to the right `AgentSession`. + * + * Lifecycle: `start()` exactly once, then any number of `newSession`/`prompt` + * calls, finally `shutdown()`. All sessions on this backend share the + * subprocess and die together if it exits. + */ +export class AcpBackendProcess implements BackendProcess { + private process: AcpProcessManager | null = null; + private connection: ClientSideConnection | null = null; + private readonly domainHandlers = new Map(); + /** + * Per-session FIFO of `session/update` notifications that arrived before a + * handler was registered. Buffers the wire-shaped notification so we + * translate at replay time (the destination handler is domain-typed). + */ + private readonly pendingUpdates = new Map(); + private static readonly PENDING_UPDATE_LIMIT = 32; + private permissionPrompter: ((req: PermissionPrompt) => Promise) | null = + null; + private exitListeners = new Set<() => void>(); + private capabilities = new Map(); + private readonly sessionWireState = new Map(); + + constructor( + private readonly app: App, + private readonly backend: AcpBackend, + private readonly clientVersion: string, + private readonly descriptor: BackendDescriptor + ) {} + + /** + * Spawn the subprocess and complete the ACP `initialize` handshake. + * Idempotent: a second call while an existing connection is live is a + * no-op. + */ + async start(): Promise { + if (this.connection) return; + const adapter = this.app.vault.adapter; + if (!(adapter instanceof FileSystemAdapter)) { + throw new Error("Agent Mode requires desktop Obsidian (FileSystemAdapter)."); + } + const descriptor = await this.backend.buildSpawnDescriptor({ + vaultBasePath: adapter.getBasePath(), + }); + + const procOpts: AcpProcessManagerOptions = { + command: descriptor.command, + args: descriptor.args, + env: descriptor.env, + logTag: this.backend.id, + }; + const proc = new AcpProcessManager(procOpts); + this.process = proc; + const raw = proc.start(); + const { stdin, stdout } = wrapStreamsForDebug(raw.stdin, raw.stdout, this.backend.id); + + proc.onExit(() => { + logWarn(`[AgentMode] backend ${this.backend.id} exited`); + this.connection = null; + this.domainHandlers.clear(); + this.pendingUpdates.clear(); + this.sessionWireState.clear(); + this.permissionPrompter = null; + this.capabilities.clear(); + for (const fn of this.exitListeners) { + try { + fn(); + } catch (e) { + logWarn("[AgentMode] exit listener threw", e); + } + } + }); + + const stream = ndJsonStream(stdin, stdout); + const client = new VaultClient(this.app, { + onSessionUpdate: (sessionId, update) => this.routeSessionUpdate(sessionId, update), + requestPermission: (req) => this.handlePermission(req), + }); + this.connection = new ClientSideConnection(() => client, stream); + + try { + const init = await this.connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + }, + clientInfo: { + name: COPILOT_CLIENT_NAME, + version: this.clientVersion, + }, + }); + if (init.agentCapabilities?.sessionCapabilities?.list != null) { + this.capabilities.set("session/list", true); + } + if (init.agentCapabilities?.sessionCapabilities?.resume != null) { + this.capabilities.set("session/resume", true); + } + if (init.agentCapabilities?.loadSession === true) { + this.capabilities.set("session/load", true); + } + if (init.agentCapabilities?.mcpCapabilities?.http === true) { + this.capabilities.set("mcp/http", true); + } + if (init.agentCapabilities?.mcpCapabilities?.sse === true) { + this.capabilities.set("mcp/sse", true); + } + logInfo( + `[AgentMode] initialized backend ${this.backend.id} (negotiated protocol v${init.protocolVersion}, listSessions=${this.hasCapability("session/list")}, resumeSession=${this.hasCapability("session/resume")}, loadSession=${this.hasCapability("session/load")}, mcp.http=${this.hasCapability("mcp/http")}, mcp.sse=${this.hasCapability("mcp/sse")})` + ); + } catch (err) { + logError( + `[AgentMode] initialize failed for ${this.backend.id}; tearing down subprocess`, + err + ); + this.connection = null; + try { + await proc.shutdown(); + } catch (e) { + logError("[AgentMode] shutdown after failed initialize threw", e); + } + this.process = null; + throw err; + } + } + + isRunning(): boolean { + return this.process?.isRunning() ?? false; + } + + onExit(listener: () => void): () => void { + this.exitListeners.add(listener); + return () => this.exitListeners.delete(listener); + } + + setPermissionPrompter(fn: (req: PermissionPrompt) => Promise): void { + this.permissionPrompter = fn; + } + + registerSessionHandler(sessionId: SessionId, handler: DomainSessionUpdateHandler): () => void { + this.domainHandlers.set(sessionId, handler); + const buffered = this.pendingUpdates.get(sessionId); + if (buffered) { + this.pendingUpdates.delete(sessionId); + for (const wire of buffered) { + try { + handler(acpNotificationToEvent(wire)); + } catch (e) { + logWarn(`[AgentMode] replay of buffered session/update threw for ${sessionId}`, e); + } + } + } + return () => { + if (this.domainHandlers.get(sessionId) === handler) { + this.domainHandlers.delete(sessionId); + } + }; + } + + async newSession(params: OpenSessionInput): Promise { + const wireResp = await this.requireConnection().newSession({ + cwd: params.cwd, + mcpServers: params.mcpServers.map(mcpServerSpecToAcp), + }); + this.recordWireState(wireResp.sessionId, { + models: wireResp.models ?? null, + modes: wireResp.modes ?? null, + configOptions: wireResp.configOptions ?? null, + }); + return { + sessionId: sessionIdFromAcp(wireResp.sessionId), + state: this.computeState(wireResp.sessionId), + }; + } + + async prompt(params: PromptInput): Promise { + const resp = await this.requireConnection().prompt({ + sessionId: sessionIdToAcp(params.sessionId), + prompt: promptContentToAcp(params.prompt), + }); + return { stopReason: stopReasonFromAcp(resp.stopReason) }; + } + + async cancel(params: CancelInput): Promise { + return this.requireConnection().cancel(cancelInputToAcp(params)); + } + + hasCapability(cap: AcpCapability): boolean { + return this.capabilities.get(cap) === true; + } + + supportsMcpTransport(transport: "http" | "sse"): boolean { + return this.hasCapability(transport === "http" ? "mcp/http" : "mcp/sse"); + } + + async setSessionModel(params: { sessionId: SessionId; modelId: string }): Promise { + await this.dispatchCapability("session/set_model", (c) => + c.unstable_setSessionModel({ + sessionId: sessionIdToAcp(params.sessionId), + modelId: params.modelId, + }) + ); + const wire = this.sessionWireState.get(params.sessionId); + if (wire) { + const current = wire.models; + wire.models = current + ? { ...current, currentModelId: params.modelId } + : { availableModels: [], currentModelId: params.modelId }; + } + return this.computeState(params.sessionId); + } + + isSetSessionModelSupported(): boolean | null { + return this.capabilitySupported("session/set_model"); + } + + async setSessionMode(params: { sessionId: SessionId; modeId: string }): Promise { + await this.dispatchCapability("session/set_mode", (c) => + c.setSessionMode({ + sessionId: sessionIdToAcp(params.sessionId), + modeId: params.modeId, + }) + ); + const wire = this.sessionWireState.get(params.sessionId); + if (wire) { + const seed: SessionModeState = wire.modes ?? { availableModes: [], currentModeId: "" }; + wire.modes = { ...seed, currentModeId: params.modeId }; + } + return this.computeState(params.sessionId); + } + + isSetSessionModeSupported(): boolean | null { + return this.capabilitySupported("session/set_mode"); + } + + async setSessionConfigOption(params: { + sessionId: SessionId; + configId: string; + value: string; + }): Promise { + const resp = await this.dispatchCapability("session/set_config_option", (c) => + c.setSessionConfigOption({ + sessionId: sessionIdToAcp(params.sessionId), + configId: params.configId, + value: params.value, + }) + ); + const wire = this.sessionWireState.get(params.sessionId); + if (wire) { + wire.configOptions = resp.configOptions; + } + return this.computeState(params.sessionId); + } + + isSetSessionConfigOptionSupported(): boolean | null { + return this.capabilitySupported("session/set_config_option"); + } + + /** + * Run an RPC gated by capability. Throws `MethodUnsupportedError` if the + * capability is known unsupported (advertised off, or a previous -32601). + * On a fresh -32601 reply, cache the negative result and rethrow. + */ + private async dispatchCapability( + capability: AcpCapability, + run: (c: ClientSideConnection) => Promise, + opts: { mustBeAdvertised?: boolean } = {} + ): Promise { + const known = this.capabilities.get(capability); + if (known === false || (opts.mustBeAdvertised && known !== true)) { + throw new MethodUnsupportedError(capability); + } + try { + const resp = await run(this.requireConnection()); + this.capabilities.set(capability, true); + return resp; + } catch (err) { + if (isMethodNotFoundError(err)) { + this.capabilities.set(capability, false); + throw new MethodUnsupportedError(capability); + } + throw err; + } + } + + private capabilitySupported(capability: AcpCapability): boolean | null { + return this.capabilities.has(capability) ? this.capabilities.get(capability)! : null; + } + + async listSessions(params: ListSessionsInput): Promise { + const resp = await this.dispatchCapability( + "session/list", + (c) => c.listSessions(params.cwd ? { cwd: params.cwd } : {}), + { mustBeAdvertised: true } + ); + return { + sessions: resp.sessions.map((s) => + listedSessionFromAcp({ + sessionId: s.sessionId, + cwd: s.cwd, + title: (s as { title?: string | null }).title ?? null, + updatedAt: (s as { updatedAt?: string | null }).updatedAt ?? null, + }) + ), + }; + } + + async resumeSession(params: ResumeSessionInput): Promise { + const wireResp = await this.dispatchCapability( + "session/resume", + (c) => + c.resumeSession({ + sessionId: sessionIdToAcp(params.sessionId), + cwd: params.cwd, + mcpServers: params.mcpServers.map(mcpServerSpecToAcp), + }), + { mustBeAdvertised: true } + ); + this.recordWireState(sessionIdToAcp(params.sessionId), { + models: wireResp.models ?? null, + modes: wireResp.modes ?? null, + configOptions: wireResp.configOptions ?? null, + }); + return { + sessionId: params.sessionId, + state: this.computeState(sessionIdToAcp(params.sessionId)), + }; + } + + async loadSession(params: LoadSessionInput): Promise { + const wireResp = await this.dispatchCapability( + "session/load", + (c) => + c.loadSession({ + sessionId: sessionIdToAcp(params.sessionId), + cwd: params.cwd, + mcpServers: params.mcpServers.map(mcpServerSpecToAcp), + }), + { mustBeAdvertised: true } + ); + this.recordWireState(sessionIdToAcp(params.sessionId), { + models: wireResp.models ?? null, + modes: wireResp.modes ?? null, + configOptions: wireResp.configOptions ?? null, + }); + return { + sessionId: params.sessionId, + state: this.computeState(sessionIdToAcp(params.sessionId)), + }; + } + + async shutdown(): Promise { + this.connection = null; + this.domainHandlers.clear(); + this.pendingUpdates.clear(); + this.sessionWireState.clear(); + this.permissionPrompter = null; + this.capabilities.clear(); + if (this.process) { + try { + await this.process.shutdown(); + } catch (e) { + logError("[AgentMode] backend shutdown failed", e); + } + this.process = null; + } + } + + private requireConnection(): ClientSideConnection { + if (!this.connection) { + throw new Error( + this.process + ? "AcpBackendProcess subprocess has exited" + : "AcpBackendProcess.start() not called" + ); + } + return this.connection; + } + + private recordWireState(sessionId: AcpSessionId, wire: SessionWireState): void { + this.sessionWireState.set(sessionIdFromAcp(sessionId), wire); + } + + private computeState(sessionId: AcpSessionId): BackendState { + const wire = this.sessionWireState.get(sessionIdFromAcp(sessionId)) ?? { + models: null, + modes: null, + configOptions: null, + }; + return acpStateToBackendState(wire.models, wire.modes, wire.configOptions, this.descriptor); + } + + private routeSessionUpdate(acpSessionId: AcpSessionId, update: SessionNotification): void { + const sessionId = sessionIdFromAcp(acpSessionId); + // Mirror per-dimension wire updates into our cache so subsequent + // setSession* calls (and the next `state_changed` event) reflect reality. + const wire = this.sessionWireState.get(sessionId); + if (wire) { + const u = update.update; + if (u.sessionUpdate === "current_mode_update") { + const seed = wire.modes ?? { availableModes: [], currentModeId: "" }; + wire.modes = { ...seed, currentModeId: u.currentModeId }; + } else if (u.sessionUpdate === "config_option_update") { + wire.configOptions = u.configOptions; + } + } + + const handler = this.domainHandlers.get(sessionId); + if (!handler) { + let queue = this.pendingUpdates.get(sessionId); + if (!queue) { + queue = []; + this.pendingUpdates.set(sessionId, queue); + } + if (queue.length >= AcpBackendProcess.PENDING_UPDATE_LIMIT) { + const kind = update.update.sessionUpdate ?? "unknown"; + logWarn( + `[AgentMode] dropping session/update for ${sessionId}: pending buffer full (${queue.length}, kind=${kind})` + ); + return; + } + queue.push(update); + return; + } + + // Per-dimension wire updates already mutated `wire` above; AgentSession + // ignores them and waits for the synthesized `state_changed` we publish + // below. Skip the original to avoid a wasted translation + dispatch. + const sub = update.update.sessionUpdate; + if (sub === "current_mode_update" || sub === "config_option_update") { + handler({ + sessionId, + update: { sessionUpdate: "state_changed", state: this.computeState(sessionId) }, + }); + return; + } + + handler(acpNotificationToEvent(update)); + } + + private async handlePermission( + req: RequestPermissionRequest + ): Promise { + if (!this.permissionPrompter) { + logWarn(`[AgentMode] permission requested but no prompter is registered; auto-cancelling`); + return { outcome: { outcome: "cancelled" } }; + } + const decision = await this.permissionPrompter(acpPermissionRequestToPrompt(req)); + return decisionToAcpResponse(decision); + } +} diff --git a/src/agentMode/acp/AcpProcessManager.ts b/src/agentMode/acp/AcpProcessManager.ts new file mode 100644 index 00000000..1940f13f --- /dev/null +++ b/src/agentMode/acp/AcpProcessManager.ts @@ -0,0 +1,176 @@ +import { logError, logInfo, logWarn } from "@/logger"; +import { ChildProcessByStdio, spawn } from "node:child_process"; +import { Readable, Writable } from "node:stream"; + +const SIGTERM_GRACE_MS = 3_000; + +export interface AcpProcessManagerOptions { + /** Absolute path to the agent backend binary (e.g. opencode). */ + command: string; + /** Process arguments. */ + args: string[]; + /** Environment for the child. Pass through `process.env` plus any overrides. */ + env: NodeJS.ProcessEnv; + /** Tag used in stderr/log lines so multiple agents can be distinguished. */ + logTag?: string; +} + +/** + * Spawns and supervises the ACP-speaking agent backend subprocess. Exposes + * the child's stdin/stdout as Web Streams (suitable for + * `@agentclientprotocol/sdk`'s `ndJsonStream`) and pipes stderr line-by-line + * into the Copilot logger. + * + * Single-shot: `start()` may only be called once. Use `shutdown()` for a + * graceful SIGTERM→SIGKILL teardown. + */ +export class AcpProcessManager { + private child: ChildProcessByStdio | null = null; + private exitListeners = new Set<(code: number | null, signal: NodeJS.Signals | null) => void>(); + private hasExited = false; + private exitCode: number | null = null; + private exitSignal: NodeJS.Signals | null = null; + + constructor(private readonly opts: AcpProcessManagerOptions) {} + + /** + * Spawn the child and return its stdin/stdout as Web Streams. Stderr is + * consumed internally and routed to the logger; callers don't see it. + */ + start(): { stdin: WritableStream; stdout: ReadableStream } { + if (this.child) { + throw new Error("AcpProcessManager already started"); + } + const tag = this.opts.logTag ?? "acp"; + logInfo(`[AgentMode] spawning ${this.opts.command} ${this.opts.args.join(" ")} (tag=${tag})`); + const child = spawn(this.opts.command, this.opts.args, { + env: this.opts.env, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + this.child = child; + + child.on("error", (err) => { + logError(`[AgentMode] subprocess error (${tag})`, err); + }); + child.on("exit", (code, signal) => { + this.hasExited = true; + this.exitCode = code; + this.exitSignal = signal; + logInfo(`[AgentMode] subprocess exit (${tag}) code=${code} signal=${signal}`); + for (const fn of this.exitListeners) { + try { + fn(code, signal); + } catch (e) { + logWarn(`[AgentMode] exit listener threw`, e); + } + } + }); + + pipeStderrToLogger(child.stderr, tag); + + // Bridge Node streams → Web Streams for `@agentclientprotocol/sdk`'s + // `ndJsonStream`. `toWeb` is Node ≥17 (Electron 27 ships Node 18), but + // missing from the older `@types/node` this project pins, hence the + // cast. + const writableToWeb = ( + Writable as unknown as { + toWeb: (s: NodeJS.WritableStream) => WritableStream; + } + ).toWeb; + const readableToWeb = ( + Readable as unknown as { + toWeb: (s: NodeJS.ReadableStream) => ReadableStream; + } + ).toWeb; + return { + stdin: writableToWeb(child.stdin), + stdout: readableToWeb(child.stdout), + }; + } + + onExit(listener: (code: number | null, signal: NodeJS.Signals | null) => void): () => void { + if (this.hasExited) { + // Fire synchronously so callers don't miss the event when subscribing + // after exit (e.g. crash before they wired up). + try { + listener(this.exitCode, this.exitSignal); + } catch (e) { + logWarn(`[AgentMode] exit listener threw`, e); + } + return () => {}; + } + this.exitListeners.add(listener); + return () => this.exitListeners.delete(listener); + } + + isRunning(): boolean { + return this.child !== null && !this.hasExited; + } + + /** + * Send SIGTERM, wait up to SIGTERM_GRACE_MS, then escalate to SIGKILL if + * the child is still alive. Resolves once the child has exited (or + * immediately if it already had). + */ + async shutdown(): Promise { + if (!this.child || this.hasExited) return; + const child = this.child; + const tag = this.opts.logTag ?? "acp"; + + const exited = new Promise((resolve) => { + this.onExit(() => resolve()); + }); + + try { + child.kill("SIGTERM"); + } catch (e) { + logWarn(`[AgentMode] SIGTERM failed (${tag})`, e); + } + + const timeout = new Promise<"timeout">((resolve) => + window.setTimeout(() => resolve("timeout"), SIGTERM_GRACE_MS) + ); + const winner = await Promise.race([exited.then(() => "exited" as const), timeout]); + if (winner === "timeout" && !this.hasExited) { + logWarn(`[AgentMode] subprocess (${tag}) did not exit within ${SIGTERM_GRACE_MS}ms; SIGKILL`); + try { + child.kill("SIGKILL"); + } catch (e) { + logWarn(`[AgentMode] SIGKILL failed (${tag})`, e); + } + await exited; + } + } +} + +function pipeStderrToLogger(stderr: Readable, tag: string): void { + let buffer = ""; + stderr.setEncoding("utf-8"); + stderr.on("data", (chunk: string) => { + buffer += chunk; + let nlIdx: number; + while ((nlIdx = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, nlIdx).trimEnd(); + buffer = buffer.slice(nlIdx + 1); + if (line) emitStderrLine(line, tag); + } + }); + stderr.on("end", () => { + if (buffer.trim()) emitStderrLine(buffer.trim(), tag); + }); +} + +function emitStderrLine(line: string, tag: string): void { + // Heuristic: lines starting with "error" / "ERROR" / "fatal" go to error, + // everything else stays at info. We don't want to drown the user log in + // opencode's noisy debug output, so warnings stay warnings. + const lower = line.toLowerCase(); + if (lower.startsWith("error") || lower.startsWith("fatal")) { + logError(`[AgentMode][${tag}] ${line}`); + } else if (lower.startsWith("warn")) { + logWarn(`[AgentMode][${tag}] ${line}`); + } else { + logInfo(`[AgentMode][${tag}] ${line}`); + } +} diff --git a/src/agentMode/acp/VaultClient.test.ts b/src/agentMode/acp/VaultClient.test.ts new file mode 100644 index 00000000..1ffcdd47 --- /dev/null +++ b/src/agentMode/acp/VaultClient.test.ts @@ -0,0 +1,163 @@ +import { FileSystemAdapter, App } from "obsidian"; +import { sliceLines, VaultClient } from "./VaultClient"; + +jest.mock("@/logger", () => ({ + logInfo: jest.fn(), + logWarn: jest.fn(), + logError: jest.fn(), +})); + +describe("sliceLines", () => { + const text = "a\nb\nc\nd\ne"; + + it("returns full content when both line and limit are null", () => { + expect(sliceLines(text, null, null)).toBe(text); + }); + + it("returns from line N (1-based) to end when limit is null", () => { + expect(sliceLines(text, 3, null)).toBe("c\nd\ne"); + }); + + it("returns first N lines when line is null", () => { + expect(sliceLines(text, null, 2)).toBe("a\nb"); + }); + + it("respects both line and limit", () => { + expect(sliceLines(text, 2, 2)).toBe("b\nc"); + }); + + it("returns empty string when line is past end", () => { + expect(sliceLines(text, 99, 5)).toBe(""); + }); + + it("clamps limit when it overruns", () => { + expect(sliceLines(text, 4, 100)).toBe("d\ne"); + }); +}); + +describe("VaultClient", () => { + function buildApp(basePath = "/vault"): App { + // The mocked FileSystemAdapter takes the basePath via constructor; the + // real Obsidian type has a no-arg constructor, hence the cast. + const adapter = new (FileSystemAdapter as unknown as new (basePath: string) => unknown)( + basePath + ); + return { vault: { adapter } } as unknown as App; + } + + it("readTextFile reads via adapter and applies line/limit", async () => { + const app = buildApp(); + (app.vault.adapter as unknown as { read: jest.Mock }).read.mockResolvedValue( + "one\ntwo\nthree\nfour" + ); + const client = new VaultClient(app, { + onSessionUpdate: () => {}, + requestPermission: () => Promise.resolve({ outcome: { outcome: "cancelled" } }), + }); + const resp = await client.readTextFile({ + sessionId: "s1", + path: "notes/foo.md", + line: 2, + limit: 2, + }); + expect(resp.content).toBe("two\nthree"); + expect((app.vault.adapter as unknown as { read: jest.Mock }).read).toHaveBeenCalledWith( + "notes/foo.md" + ); + }); + + it("rejects out-of-vault relative paths with .. traversal", async () => { + const app = buildApp("/Users/me/vault"); + const client = new VaultClient(app, { + onSessionUpdate: () => {}, + requestPermission: () => Promise.resolve({ outcome: { outcome: "cancelled" } }), + }); + await expect(client.readTextFile({ sessionId: "s1", path: "../outside.md" })).rejects.toThrow( + /outside the vault/ + ); + }); + + // eslint-disable-next-line obsidianmd/hardcoded-config-path -- test fixture for hidden-dir guard + it("rejects dotfile paths under the vault (e.g. .obsidian config)", async () => { + const app = buildApp("/Users/me/vault"); + const client = new VaultClient(app, { + onSessionUpdate: () => {}, + requestPermission: () => Promise.resolve({ outcome: { outcome: "cancelled" } }), + }); + await expect( + // eslint-disable-next-line obsidianmd/hardcoded-config-path -- test fixture + client.readTextFile({ sessionId: "s1", path: ".obsidian/plugins/copilot/data.json" }) + ).rejects.toThrow(/hidden directory/); + await expect( + client.writeTextFile({ sessionId: "s1", path: ".git/config", content: "x" }) + ).rejects.toThrow(/hidden directory/); + }); + + it("rejects absolute paths outside the vault base", async () => { + const app = buildApp("/Users/me/vault"); + const client = new VaultClient(app, { + onSessionUpdate: () => {}, + requestPermission: () => Promise.resolve({ outcome: { outcome: "cancelled" } }), + }); + await expect(client.readTextFile({ sessionId: "s1", path: "/etc/passwd" })).rejects.toThrow( + /outside the vault/ + ); + }); + + it("accepts absolute paths inside the vault base and routes to adapter as relative", async () => { + const app = buildApp("/Users/me/vault"); + (app.vault.adapter as unknown as { read: jest.Mock }).read.mockResolvedValue("hello"); + const client = new VaultClient(app, { + onSessionUpdate: () => {}, + requestPermission: () => Promise.resolve({ outcome: { outcome: "cancelled" } }), + }); + const resp = await client.readTextFile({ + sessionId: "s1", + path: "/Users/me/vault/notes/foo.md", + }); + expect(resp.content).toBe("hello"); + expect((app.vault.adapter as unknown as { read: jest.Mock }).read).toHaveBeenCalledWith( + "notes/foo.md" + ); + }); + + it("writeTextFile creates parent dir if missing", async () => { + const app = buildApp(); + const adapter = app.vault.adapter as unknown as { + exists: jest.Mock; + mkdir: jest.Mock; + write: jest.Mock; + }; + adapter.exists.mockResolvedValueOnce(false); + adapter.write.mockResolvedValue(undefined); + const client = new VaultClient(app, { + onSessionUpdate: () => {}, + requestPermission: () => Promise.resolve({ outcome: { outcome: "cancelled" } }), + }); + await client.writeTextFile({ + sessionId: "s1", + path: "Inbox/note.md", + content: "hi", + }); + expect(adapter.mkdir).toHaveBeenCalledWith("Inbox"); + expect(adapter.write).toHaveBeenCalledWith("Inbox/note.md", "hi"); + }); + + it("sessionUpdate forwards to handler", async () => { + const app = buildApp(); + const onSessionUpdate = jest.fn(); + const client = new VaultClient(app, { + onSessionUpdate, + requestPermission: () => Promise.resolve({ outcome: { outcome: "cancelled" } }), + }); + const update = { + sessionId: "s1", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "hi" }, + }, + } as unknown as Parameters[0]; + await client.sessionUpdate(update); + expect(onSessionUpdate).toHaveBeenCalledWith("s1", update); + }); +}); diff --git a/src/agentMode/acp/VaultClient.ts b/src/agentMode/acp/VaultClient.ts new file mode 100644 index 00000000..ea99b783 --- /dev/null +++ b/src/agentMode/acp/VaultClient.ts @@ -0,0 +1,129 @@ +import type { + Client, + ReadTextFileRequest, + ReadTextFileResponse, + RequestPermissionRequest, + RequestPermissionResponse, + SessionId, + SessionNotification, + WriteTextFileRequest, + WriteTextFileResponse, +} from "@agentclientprotocol/sdk"; +import { RequestError } from "@agentclientprotocol/sdk"; +import { logInfo, logWarn } from "@/logger"; +import { App, FileSystemAdapter, normalizePath } from "obsidian"; +import * as path from "node:path"; + +export interface PermissionPrompter { + (req: RequestPermissionRequest): Promise; +} + +export interface VaultClientHandlers { + /** Routes a session/update to the right AgentSession. */ + onSessionUpdate: (sessionId: SessionId, update: SessionNotification) => void; + /** Opens the permission UI; resolves with the user's choice. */ + requestPermission: PermissionPrompter; +} + +/** + * Implements the ACP `Client` interface against an Obsidian vault. + * + * - `readTextFile`/`writeTextFile` route through `app.vault.adapter`, with + * strict vault-relative path resolution. Out-of-vault paths are rejected + * with `invalidParams` so the agent gets a clear error. + * - `sessionUpdate` is demultiplexed via `handlers.onSessionUpdate`. + * - `requestPermission` defers to `handlers.requestPermission` which opens + * the modal UI. + * + * Terminal capabilities are deliberately *not* implemented — we don't + * advertise the capability, and opencode falls back to its internal PTY. + */ +export class VaultClient implements Client { + constructor( + private readonly app: App, + private readonly handlers: VaultClientHandlers + ) {} + + async sessionUpdate(params: SessionNotification): Promise { + try { + this.handlers.onSessionUpdate(params.sessionId, params); + } catch (e) { + logWarn(`[AgentMode] sessionUpdate handler threw`, e); + } + } + + async requestPermission(params: RequestPermissionRequest): Promise { + return this.handlers.requestPermission(params); + } + + async readTextFile(params: ReadTextFileRequest): Promise { + const rel = this.resolveVaultRelative(params.path); + const full = await this.app.vault.adapter.read(rel); + const content = sliceLines(full, params.line ?? null, params.limit ?? null); + logInfo(`[AgentMode] readTextFile ${rel} (${content.length} chars)`); + return { content }; + } + + async writeTextFile(params: WriteTextFileRequest): Promise { + const rel = this.resolveVaultRelative(params.path); + const adapter = this.app.vault.adapter; + const dir = path.posix.dirname(rel); + if (dir && dir !== "." && dir !== "/" && !(await adapter.exists(dir))) { + await adapter.mkdir(dir); + } + await adapter.write(rel, params.content); + logInfo(`[AgentMode] writeTextFile ${rel} (${params.content.length} chars)`); + return {}; + } + + /** + * Resolve `p` against the vault root. Returns a vault-relative, + * forward-slashed path for `app.vault.adapter`. Throws + * `RequestError.invalidParams` if the path escapes the vault. + */ + private resolveVaultRelative(p: string): string { + const adapter = this.app.vault.adapter; + if (!(adapter instanceof FileSystemAdapter)) { + throw RequestError.invalidParams( + undefined, + "Agent Mode requires a FileSystemAdapter (desktop)." + ); + } + const vaultBase = path.resolve(adapter.getBasePath()); + const resolved = path.isAbsolute(p) ? path.resolve(p) : path.resolve(vaultBase, p); + const rel = path.relative(vaultBase, resolved); + if (rel.startsWith("..") || path.isAbsolute(rel)) { + throw RequestError.invalidParams( + { path: p }, + `Path "${p}" is outside the vault and cannot be accessed by Agent Mode.` + ); + } + const normalized = normalizePath(rel.split(path.sep).join("/")); + // Block dotfile dirs/files at the vault root (`.obsidian/`, `.copilot/`, + // `.git/`, etc.). They contain plugin settings — including encrypted-at- + // rest API keys, hotkey config, vault metadata — that the agent has no + // business reading or writing without an explicit user-facing flow. + const firstSegment = normalized.split("/")[0] ?? ""; + if (firstSegment.startsWith(".")) { + throw RequestError.invalidParams( + { path: p }, + `Path "${p}" is in a hidden directory (${firstSegment}) and is not accessible to Agent Mode.` + ); + } + return normalized; + } +} + +/** + * Extract a 1-based line slice with an optional limit. Mirrors ACP's + * `ReadTextFileRequest.{line, limit}` semantics: read N lines starting from + * line `line`. Out-of-range starts return empty. + */ +export function sliceLines(content: string, line: number | null, limit: number | null): string { + if (line == null && limit == null) return content; + const lines = content.split("\n"); + const start = line != null ? Math.max(0, line - 1) : 0; + const end = limit != null ? Math.min(lines.length, start + limit) : lines.length; + if (start >= lines.length) return ""; + return lines.slice(start, end).join("\n"); +} diff --git a/src/agentMode/acp/debugTap.ts b/src/agentMode/acp/debugTap.ts new file mode 100644 index 00000000..4dc3693a --- /dev/null +++ b/src/agentMode/acp/debugTap.ts @@ -0,0 +1,182 @@ +import { logInfo } from "@/logger"; +import { getSettings } from "@/settings/model"; +import { formatPayload, frameSink, type FrameRecord } from "@/agentMode/session/debugSink"; + +interface JsonRpcFrame { + jsonrpc?: string; + id?: number | string; + method?: string; + params?: unknown; + result?: unknown; + error?: { code?: number; message?: string; data?: unknown }; +} + +/** + * Wrap the subprocess stdin/stdout streams so every NDJSON-framed + * JSON-RPC message is logged in both directions. Outbound is what + * `ClientSideConnection` writes to stdin; inbound is what we read from + * stdout. The taps are passthroughs — bytes flow through unchanged. + * + * Method names are remembered per request id so that responses (which + * carry only id + result/error) can be labeled with the method they + * answered. + */ +export function wrapStreamsForDebug( + stdin: WritableStream, + stdout: ReadableStream, + tag: string +): { stdin: WritableStream; stdout: ReadableStream } { + const outboundPending = new Map(); + const inboundPending = new Map(); + + return { + stdin: tapWritable(stdin, (line) => logFrame("→", line, tag, outboundPending, inboundPending)), + stdout: tapReadable(stdout, (line) => + logFrame("←", line, tag, inboundPending, outboundPending) + ), + }; +} + +function tapWritable( + inner: WritableStream, + onLine: (line: string) => void +): WritableStream { + // Avoid `TransformStream` / `pipeTo` because the inner stream comes from + // Node's `Writable.toWeb()` and is branded against `node:internal/ + // webstreams`; mixing it with global-realm streams throws + // `ERR_INVALID_ARG_TYPE`. A hand-rolled WritableStream that delegates to + // the inner writer side-steps the realm check entirely. + const writer = inner.getWriter(); + const splitter = new NdjsonLineSplitter(onLine); + return new WritableStream({ + async write(chunk) { + splitter.push(chunk); + await writer.write(chunk); + }, + async close() { + splitter.flush(); + await writer.close(); + }, + async abort(reason) { + splitter.flush(); + await writer.abort(reason); + }, + }); +} + +function tapReadable( + inner: ReadableStream, + onLine: (line: string) => void +): ReadableStream { + // `tee()` is a same-realm method (no class mismatch), so we get two + // branded-equivalent ReadableStreams: one for the SDK to consume, one we + // drain ourselves for logging. + const [forConsumer, forLogging] = inner.tee(); + const splitter = new NdjsonLineSplitter(onLine); + void (async () => { + const reader = forLogging.getReader(); + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + if (value) splitter.push(value); + } + splitter.flush(); + } catch { + // Stream closed/aborted; nothing for the tap to do. + } + })(); + return forConsumer; +} + +class NdjsonLineSplitter { + private buffer = ""; + private decoder = new TextDecoder(); + + constructor(private readonly onLine: (line: string) => void) {} + + push(chunk: Uint8Array): void { + this.buffer += this.decoder.decode(chunk, { stream: true }); + let nl: number; + while ((nl = this.buffer.indexOf("\n")) !== -1) { + const line = this.buffer.slice(0, nl).trim(); + this.buffer = this.buffer.slice(nl + 1); + if (line) this.emit(line); + } + } + + flush(): void { + const tail = this.buffer.trim(); + this.buffer = ""; + if (tail) this.emit(tail); + } + + private emit(line: string): void { + try { + this.onLine(line); + } catch { + // Logging must never break the protocol stream. + } + } +} + +function logFrame( + arrow: "→" | "←", + line: string, + tag: string, + /** Pending requests originated by *our* side of this stream. */ + ownPending: Map, + /** Pending requests originated by the *other* side of this stream. */ + peerPending: Map +): void { + // Read once per frame so the hot path doesn't allocate a record + timestamp + // when the toggle is off. + const fullFramesOn = !!getSettings().agentMode?.debugFullFrames; + const emit = fullFramesOn + ? (kind: FrameRecord["kind"], method: string, id: string | null, payload: unknown) => + frameSink.append({ + ts: new Date().toISOString(), + dir: arrow, + tag, + kind, + method, + id, + payload, + }) + : null; + + let frame: JsonRpcFrame; + try { + frame = JSON.parse(line); + } catch { + logInfo(`[ACP ${arrow}][${tag}] (unparsed) ${formatPayload(line)}`); + emit?.("raw", "(unparsed)", null, { raw: line }); + return; + } + + const idStr = frame.id !== undefined ? String(frame.id) : null; + + if (frame.method) { + // Request or notification. + const method = frame.method; + const idLabel = idStr !== null ? `#${idStr}` : "(notif)"; + if (idStr !== null) ownPending.set(idStr, method); + logInfo(`[ACP ${arrow}][${tag}] ${method} ${idLabel} ${formatPayload(frame.params)}`); + emit?.(idStr !== null ? "request" : "notif", method, idStr, frame.params); + return; + } + + // Response (result or error). Method name comes from the side that + // originated the request — that's `peerPending` from this stream's + // perspective. + const method = idStr !== null ? (peerPending.get(idStr) ?? "(unknown)") : "(unknown)"; + if (idStr !== null) peerPending.delete(idStr); + const idLabel = idStr !== null ? `#${idStr}` : "(no-id)"; + if (frame.error) { + logInfo(`[ACP ${arrow}][${tag}] (error) ${method} ${idLabel} ${formatPayload(frame.error)}`); + emit?.("error", method, idStr, frame.error); + } else { + logInfo(`[ACP ${arrow}][${tag}] ${method} ${idLabel} ${formatPayload(frame.result)}`); + emit?.("result", method, idStr, frame.result); + } +} diff --git a/src/agentMode/acp/index.ts b/src/agentMode/acp/index.ts new file mode 100644 index 00000000..53eb2743 --- /dev/null +++ b/src/agentMode/acp/index.ts @@ -0,0 +1,4 @@ +export { AcpBackendProcess } from "./AcpBackendProcess"; +export { AcpProcessManager, type AcpProcessManagerOptions } from "./AcpProcessManager"; +export { VaultClient } from "./VaultClient"; +export type { AcpBackend, AcpSpawnDescriptor } from "./types"; diff --git a/src/agentMode/acp/nodeShebangPath.ts b/src/agentMode/acp/nodeShebangPath.ts new file mode 100644 index 00000000..e04ecc95 --- /dev/null +++ b/src/agentMode/acp/nodeShebangPath.ts @@ -0,0 +1,35 @@ +import * as path from "node:path"; + +/** + * macOS GUI apps (Obsidian) inherit a minimal PATH that omits Homebrew and + * common Node installer locations. Adapters that ship as `#!/usr/bin/env + * node` launchers fail to spawn with `env: node: No such file or directory` + * unless we put `node` on PATH ourselves. + * + * Prepend the directory containing the binary (npm globals install the + * launcher script next to `node`) plus the well-known Homebrew / system + * prefixes, then keep the inherited PATH for everything else. + */ +export function augmentPathForNodeShebang( + binaryPath: string, + inherited: string | undefined +): string { + const sep = process.platform === "win32" ? ";" : ":"; + const candidates = [ + path.dirname(binaryPath), + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + ]; + const inheritedParts = (inherited ?? "").split(sep).filter(Boolean); + const seen = new Set(); + const merged: string[] = []; + for (const p of [...candidates, ...inheritedParts]) { + if (!seen.has(p)) { + seen.add(p); + merged.push(p); + } + } + return merged.join(sep); +} diff --git a/src/agentMode/acp/types.ts b/src/agentMode/acp/types.ts new file mode 100644 index 00000000..6fa1f9d9 --- /dev/null +++ b/src/agentMode/acp/types.ts @@ -0,0 +1,27 @@ +import type { BackendId } from "@/agentMode/session/types"; + +/** + * Spawn descriptor for an ACP-speaking agent backend. Backends produce these + * lazily because they may need to read settings (BYOK keys, MCP config) at + * spawn time. + */ +export interface AcpSpawnDescriptor { + command: string; + args: string[]; + env: NodeJS.ProcessEnv; +} + +/** + * One ACP-track agent backend. Implementers (OpencodeBackend, CodexBackend, + * etc.) own the spawn-time contract. The rest of Agent Mode — + * `AcpBackendProcess`, `AgentSession`, `VaultClient` — stays + * backend-agnostic. + */ +export interface AcpBackend { + /** Stable identifier, used for logging and settings selection. */ + readonly id: BackendId; + /** Human-readable name surfaced in the UI. */ + readonly displayName: string; + /** Build the spawn descriptor (BYOK keys decrypted, env composed). */ + buildSpawnDescriptor(ctx: { vaultBasePath: string }): Promise; +} diff --git a/src/agentMode/acp/wireTranslate.ts b/src/agentMode/acp/wireTranslate.ts new file mode 100644 index 00000000..468cc1c4 --- /dev/null +++ b/src/agentMode/acp/wireTranslate.ts @@ -0,0 +1,425 @@ +/** + * Pure translators between ACP wire types and the session-domain types + * defined in `session/types.ts`. The shapes mostly mirror each other (we + * intentionally modelled the session domain on ACP's vocabulary), so most + * translators are structural identity casts. Keeping them in one file + * isolates the seam: when ACP evolves, this is the only place that needs + * updating. + */ +import type { + CancelNotification, + ContentBlock, + McpServer as AcpMcpServer, + ModelInfo as AcpModelInfo, + PermissionOption as AcpPermissionOption, + Plan as AcpPlan, + RequestPermissionRequest, + RequestPermissionResponse, + SessionConfigOption, + SessionId as AcpSessionId, + SessionModeState, + SessionModelState, + SessionNotification, + StopReason as AcpStopReason, + ToolCall, + ToolCallContent as AcpToolCallContent, + ToolCallUpdate, + ToolKind as AcpToolKind, +} from "@agentclientprotocol/sdk"; +import type { + AgentToolKind, + AgentToolStatus, + BackendConfigOption, + BackendDescriptor, + RawModeState, + RawModelState, + BackendState, + CancelInput, + ListedSessionInfo, + McpServerSpec, + PermissionDecision, + PermissionOption, + PermissionPrompt, + PromptContent, + SessionEvent, + SessionId, + SessionUpdate, + StopReason, + ToolCallContent, + ToolCallDelta, + ToolCallSnapshot, +} from "@/agentMode/session/types"; +import { PERMISSION_OPTION_KINDS } from "@/agentMode/session/types"; +import { translateBackendState } from "@/agentMode/session/translateBackendState"; + +// ---- Catalog wire → neutral (pass-through, structural alias) ----------- + +export function modelStateFromAcp( + state: SessionModelState | null | undefined +): RawModelState | null { + if (!state) return null; + return { + currentModelId: state.currentModelId, + availableModels: state.availableModels.map((m) => ({ + modelId: m.modelId, + name: m.name, + description: m.description ?? undefined, + })), + }; +} + +export function modeStateFromAcp(state: SessionModeState | null | undefined): RawModeState | null { + if (!state) return null; + return { + currentModeId: state.currentModeId, + availableModes: state.availableModes.map((m) => ({ + id: m.id, + name: m.name, + description: m.description ?? undefined, + })), + }; +} + +export function configOptionsFromAcp( + options: SessionConfigOption[] | null | undefined +): BackendConfigOption[] | null { + if (!options) return null; + return options.map(configOptionFromAcp); +} + +function configOptionFromAcp(opt: SessionConfigOption): BackendConfigOption { + if (opt.type === "select") { + return { + id: opt.id, + type: "select", + name: opt.name, + description: opt.description ?? undefined, + category: opt.category ?? null, + currentValue: String(opt.currentValue), + options: opt.options.map((entry) => { + if ("options" in entry) { + return { + name: entry.name, + options: entry.options.map((inner) => ({ + value: inner.value, + name: inner.name, + description: inner.description ?? undefined, + })), + }; + } + return { + value: entry.value, + name: entry.name, + description: entry.description ?? undefined, + }; + }), + }; + } + return { + id: opt.id, + type: "boolean", + name: opt.name, + description: opt.description ?? undefined, + category: opt.category ?? null, + currentValue: Boolean((opt as { currentValue: unknown }).currentValue), + }; +} + +export function configOptionToAcp(opt: BackendConfigOption): SessionConfigOption { + // SDK's SessionConfigOption shape mirrors our neutral shape; cast through. + return opt as unknown as SessionConfigOption; +} + +export function modelInfoFromAcp(model: AcpModelInfo): { + modelId: string; + name: string; + description?: string; +} { + return { + modelId: model.modelId, + name: model.name, + description: model.description ?? undefined, + }; +} + +export function acpStateToBackendState( + models: SessionModelState | null | undefined, + modes: SessionModeState | null | undefined, + configOptions: SessionConfigOption[] | null | undefined, + descriptor: BackendDescriptor +): BackendState { + return translateBackendState( + { + models: modelStateFromAcp(models), + modes: modeStateFromAcp(modes), + configOptions: configOptionsFromAcp(configOptions), + }, + descriptor + ); +} + +// ---- StopReason -------------------------------------------------------- + +export function stopReasonFromAcp(reason: AcpStopReason): StopReason { + switch (reason) { + case "end_turn": + case "cancelled": + case "refusal": + case "max_tokens": + case "max_turn_requests": + return reason; + default: + return "end_turn"; + } +} + +// ---- Tool kind / status (ACP enum subsets) ----------------------------- + +export function toolKindFromAcp(kind: AcpToolKind | undefined): AgentToolKind | undefined { + if (kind == null) return undefined; + return kind; +} + +function toolStatusFromAcp(status: string | undefined): AgentToolStatus | undefined { + if (!status) return undefined; + return status as AgentToolStatus; +} + +// ---- Content blocks ---------------------------------------------------- + +export function promptContentToAcp(blocks: PromptContent[]): ContentBlock[] { + return blocks.map((b): ContentBlock => { + if (b.type === "text") return { type: "text", text: b.text }; + if (b.type === "image") return { type: "image", mimeType: b.mimeType, data: b.data }; + return { type: "resource_link", uri: b.uri, name: b.name ?? b.uri }; + }); +} + +export function promptContentFromAcp(block: ContentBlock): PromptContent | null { + if (block.type === "text") return { type: "text", text: block.text }; + if (block.type === "image") return { type: "image", mimeType: block.mimeType, data: block.data }; + if (block.type === "resource_link") + return { type: "resource_link", uri: block.uri, name: block.name ?? undefined }; + return null; +} + +function toolCallContentFromAcp( + content: AcpToolCallContent[] | null | undefined +): ToolCallContent[] | undefined { + if (!content) return undefined; + const out: ToolCallContent[] = []; + for (const item of content) { + if (item.type === "content" && item.content.type === "text") { + out.push({ type: "content", content: { type: "text", text: item.content.text } }); + } else if (item.type === "diff") { + out.push({ + type: "diff", + path: item.path, + oldText: item.oldText ?? null, + newText: item.newText, + }); + } + } + return out.length > 0 ? out : undefined; +} + +function toolCallSnapshotFromAcp( + call: ToolCall & { sessionUpdate?: "tool_call" } +): ToolCallSnapshot { + return { + toolCallId: call.toolCallId, + title: call.title, + kind: toolKindFromAcp(call.kind), + status: toolStatusFromAcp(call.status), + rawInput: call.rawInput, + content: toolCallContentFromAcp(call.content), + locations: call.locations?.map((l) => ({ path: l.path, line: l.line ?? undefined })), + }; +} + +function mapNullable(value: T | null | undefined, fn: (value: T) => R): R | null | undefined { + if (value === null) return null; + if (value === undefined) return undefined; + return fn(value); +} + +function toolCallDeltaFromAcp( + upd: ToolCallUpdate & { sessionUpdate?: "tool_call_update" } +): ToolCallDelta { + return { + toolCallId: upd.toolCallId, + title: upd.title ?? undefined, + kind: toolKindFromAcp(upd.kind ?? undefined), + status: toolStatusFromAcp(upd.status as string | undefined), + rawInput: upd.rawInput, + content: mapNullable(upd.content, toolCallContentFromAcp), + locations: mapNullable(upd.locations, (locs) => + locs.map((l) => ({ path: l.path, line: l.line ?? undefined })) + ), + }; +} + +// ---- Notification → SessionEvent -------------------------------------- + +export function acpNotificationToEvent(n: SessionNotification): SessionEvent { + return { + sessionId: sessionIdFromAcp(n.sessionId), + update: acpUpdateToSessionUpdate(n.update), + }; +} + +function acpUpdateToSessionUpdate(update: SessionNotification["update"]): SessionUpdate { + switch (update.sessionUpdate) { + case "agent_message_chunk": + return { + sessionUpdate: "agent_message_chunk", + content: promptContentFromAcp(update.content) ?? { type: "text", text: "" }, + }; + case "agent_thought_chunk": + return { + sessionUpdate: "agent_thought_chunk", + content: promptContentFromAcp(update.content) ?? { type: "text", text: "" }, + }; + case "tool_call": + return { + sessionUpdate: "tool_call", + ...toolCallSnapshotFromAcp(update), + }; + case "tool_call_update": + return { + sessionUpdate: "tool_call_update", + ...toolCallDeltaFromAcp(update), + }; + case "plan": + return { + sessionUpdate: "plan", + entries: update.entries.map((e: AcpPlan["entries"][number]) => ({ + content: e.content, + priority: e.priority, + status: e.status, + })), + }; + case "session_info_update": + return { + sessionUpdate: "session_info_update", + title: (update as { title?: string | null }).title ?? null, + }; + case "current_mode_update": + return { + sessionUpdate: "current_mode_update", + currentModeId: update.currentModeId, + }; + case "config_option_update": + return { + sessionUpdate: "config_option_update", + configOptions: configOptionsFromAcp(update.configOptions) ?? [], + }; + default: + // Unknown discriminant — fall back to a benign session_info_update with no title. + return { sessionUpdate: "session_info_update", title: null }; + } +} + +// ---- Permission prompt / decision ------------------------------------- + +export function acpPermissionRequestToPrompt(req: RequestPermissionRequest): PermissionPrompt { + const call = req.toolCall; + return { + sessionId: sessionIdFromAcp(req.sessionId), + toolCall: { + toolCallId: call.toolCallId, + title: call.title ?? "Tool call", + kind: toolKindFromAcp(call.kind ?? undefined), + status: toolStatusFromAcp(call.status as string | undefined) ?? "pending", + rawInput: call.rawInput, + content: toolCallContentFromAcp(call.content), + locations: call.locations?.map((l) => ({ path: l.path, line: l.line ?? undefined })), + }, + options: req.options.map(permissionOptionFromAcp), + }; +} + +function permissionOptionFromAcp(opt: AcpPermissionOption): PermissionOption { + return { + optionId: opt.optionId, + name: opt.name, + kind: (PERMISSION_OPTION_KINDS as readonly string[]).includes(opt.kind) + ? opt.kind + : "reject_once", + }; +} + +export function permissionPromptToAcp(prompt: PermissionPrompt): RequestPermissionRequest { + return { + sessionId: prompt.sessionId, + toolCall: { + toolCallId: prompt.toolCall.toolCallId, + title: prompt.toolCall.title, + kind: prompt.toolCall.kind, + status: prompt.toolCall.status ?? "pending", + rawInput: prompt.toolCall.rawInput, + }, + options: prompt.options.map((o) => ({ + optionId: o.optionId, + name: o.name, + kind: o.kind, + })), + }; +} + +export function acpDecisionFromResponse(resp: RequestPermissionResponse): PermissionDecision { + if (resp.outcome.outcome === "cancelled") { + return { outcome: { outcome: "cancelled" } }; + } + return { outcome: { outcome: "selected", optionId: resp.outcome.optionId } }; +} + +export function decisionToAcpResponse(decision: PermissionDecision): RequestPermissionResponse { + return decision; +} + +// ---- MCP server -------------------------------------------------------- + +export function mcpServerSpecToAcp(spec: McpServerSpec): AcpMcpServer { + if ("type" in spec && spec.type === "http") { + return { type: "http", name: spec.name, url: spec.url, headers: spec.headers }; + } + if ("type" in spec && spec.type === "sse") { + return { type: "sse", name: spec.name, url: spec.url, headers: spec.headers }; + } + // stdio + return { + name: spec.name, + command: spec.command, + args: spec.args, + env: spec.env, + }; +} + +// ---- SessionId / Cancel ----------------------------------------------- + +export function sessionIdFromAcp(id: AcpSessionId): SessionId { + return id; +} + +export function sessionIdToAcp(id: SessionId): AcpSessionId { + return id; +} + +export function cancelInputToAcp(input: CancelInput): CancelNotification { + return { sessionId: sessionIdToAcp(input.sessionId) }; +} + +export function listedSessionFromAcp(s: { + sessionId: AcpSessionId; + cwd: string; + title?: string | null; + updatedAt?: string | null; +}): ListedSessionInfo { + return { + sessionId: sessionIdFromAcp(s.sessionId), + cwd: s.cwd, + title: s.title ?? null, + updatedAt: s.updatedAt ?? null, + }; +} diff --git a/src/agentMode/backends/claude/AskUserQuestionModal.tsx b/src/agentMode/backends/claude/AskUserQuestionModal.tsx new file mode 100644 index 00000000..87f01ebe --- /dev/null +++ b/src/agentMode/backends/claude/AskUserQuestionModal.tsx @@ -0,0 +1,164 @@ +import { Button } from "@/components/ui/button"; +import { createPluginRoot } from "@/utils/react/createPluginRoot"; +import { App, Modal } from "obsidian"; +import React from "react"; +import { Root } from "react-dom/client"; +import type { AskUserQuestionInput } from "@/agentMode/sdk/permissionBridge"; + +type Questions = AskUserQuestionInput["questions"]; +type Answers = { [questionText: string]: string }; + +interface ContentProps { + questions: Questions; + onSubmit: (answers: Answers) => void; + onCancel: () => void; +} + +/** + * Modal that renders the SDK's `AskUserQuestion` payload as a series of + * single- or multi-choice question blocks. Resolves with `{ questionText: + * "label" }` (single-select) or `{ questionText: "label1, label2" }` + * (multi-select). Closing without submitting resolves with `{}` — the + * permission bridge treats an empty answer map as a cancellation. + */ +const AskUserQuestionContent: React.FC = ({ questions, onSubmit, onCancel }) => { + // Per-question selection: a single label for radio, a Set of labels for checkbox. + const [selections, setSelections] = React.useState>>({}); + + const canSubmit = questions.every((q, idx) => { + if (q.multiSelect) return true; + return typeof selections[idx] === "string" && selections[idx] !== ""; + }); + + const submit = (): void => { + const answers: Answers = {}; + for (let i = 0; i < questions.length; i++) { + const q = questions[i]; + const sel = selections[i]; + if (q.multiSelect) { + answers[q.question] = sel instanceof Set ? Array.from(sel).join(", ") : ""; + } else { + answers[q.question] = typeof sel === "string" ? sel : ""; + } + } + onSubmit(answers); + }; + + return ( +
+ {questions.map((q, idx) => ( +
+ {q.header && ( +
{q.header}
+ )} +
{q.question}
+
+ {q.options.map((opt) => { + const sel = selections[idx]; + const checked = q.multiSelect + ? sel instanceof Set && sel.has(opt.label) + : sel === opt.label; + return ( + + ); + })} +
+
+ ))} + +
+ + +
+
+ ); +}; + +/** + * Open the AskUserQuestion modal for one Claude SDK `canUseTool` invocation. + * Resolves with the answers map (or `{}` on cancel — the bridge maps empty + * to a deny-with-cancelled message). + */ +export function openAskUserQuestionModal(app: App, questions: Questions): Promise { + return new Promise((resolve) => { + const modal = new AskUserQuestionModal(app, questions, resolve); + modal.open(); + }); +} + +class AskUserQuestionModal extends Modal { + private root: Root | null = null; + private settled = false; + + constructor( + app: App, + private readonly questions: Questions, + private readonly onSettle: (answers: Answers) => void + ) { + super(app); + this.titleEl.setText("Agent Mode — Question from Claude"); + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + this.root = createPluginRoot(contentEl, this.app); + this.root.render( + { + this.settled = true; + this.onSettle(answers); + this.close(); + }} + onCancel={() => { + this.settled = true; + this.onSettle({}); + this.close(); + }} + /> + ); + } + + onClose(): void { + this.root?.unmount(); + this.root = null; + this.contentEl.empty(); + if (!this.settled) { + this.settled = true; + this.onSettle({}); + } + } +} diff --git a/src/agentMode/backends/claude/ClaudeInstallModal.tsx b/src/agentMode/backends/claude/ClaudeInstallModal.tsx new file mode 100644 index 00000000..36e0e750 --- /dev/null +++ b/src/agentMode/backends/claude/ClaudeInstallModal.tsx @@ -0,0 +1,46 @@ +import { App, Modal, Setting } from "obsidian"; +import { CLAUDE_INSTALL_COMMAND } from "./descriptor"; + +/** + * Onboarding modal shown when the `claude` CLI cannot be located. Tells + * the user the exact install command and offers a "Re-detect" button (the + * resolver runs each time the descriptor's `getInstallState` is queried). + */ +export class ClaudeInstallModal extends Modal { + constructor(app: App) { + super(app); + } + + onOpen(): void { + const { contentEl } = this; + contentEl.createEl("h2", { text: "Install Claude CLI" }); + contentEl.createEl("p", { + text: + "The Claude (SDK) backend requires the official `claude` CLI installed on your system. " + + "Run this command in a terminal:", + }); + const code = contentEl.createEl("pre"); + code.createEl("code", { text: CLAUDE_INSTALL_COMMAND }); + contentEl.createEl("p", { + text: + "The Claude Agent SDK is bundled with this plugin; the `claude` CLI provides " + + "authentication and runs the model — that's why you install `@anthropic-ai/claude-code`.", + }); + contentEl.createEl("p", { + text: + "Then return to Obsidian and click 'Re-detect' in Agent Mode advanced settings. " + + "Authentication is inherited from the CLI's login state — run `claude` once to sign in if " + + "you haven't already, or set ANTHROPIC_API_KEY in your shell environment.", + }); + new Setting(contentEl).addButton((btn) => + btn + .setButtonText("Close") + .setCta() + .onClick(() => this.close()) + ); + } + + onClose(): void { + this.contentEl.empty(); + } +} diff --git a/src/agentMode/backends/claude/ClaudeSettingsPanel.tsx b/src/agentMode/backends/claude/ClaudeSettingsPanel.tsx new file mode 100644 index 00000000..be05688c --- /dev/null +++ b/src/agentMode/backends/claude/ClaudeSettingsPanel.tsx @@ -0,0 +1,148 @@ +import { BinaryPathSetting } from "@/components/agent/BinaryPathSetting"; +import { Button } from "@/components/ui/button"; +import { SettingItem } from "@/components/ui/setting-item"; +import type CopilotPlugin from "@/main"; +import { setSettings, useSettingsValue } from "@/settings/model"; +import { validateExecutableFile } from "@/utils/detectBinary"; +import type { App } from "obsidian"; +import { Notice } from "obsidian"; +import React from "react"; +import { CLAUDE_INSTALL_COMMAND, resolveClaudeCliPath, updateClaudeFields } from "./descriptor"; + +interface Props { + plugin: CopilotPlugin; + app: App; +} + +interface AuthEnvSummary { + bedrock: boolean; + vertex: boolean; + apiKey: boolean; +} + +/** + * Read Claude SDK auth-related env vars once. The SDK resolves credentials + * through the spawned `claude` CLI, so any of these may be unset and the + * agent still works (the CLI's saved login covers it). + */ +function readAuthEnv(): AuthEnvSummary { + return { + apiKey: Boolean(process.env.ANTHROPIC_API_KEY), + bedrock: Boolean(process.env.CLAUDE_CODE_USE_BEDROCK), + vertex: Boolean(process.env.CLAUDE_CODE_USE_VERTEX), + }; +} + +function renderAuthDescription(env: AuthEnvSummary): React.ReactNode { + const parts: string[] = []; + if (env.apiKey) parts.push("Anthropic API key set"); + if (env.bedrock) parts.push("Bedrock configured"); + if (env.vertex) parts.push("Vertex configured"); + if (parts.length === 0) { + return ( + + No auth env vars set — credentials inherit from the claude CLI login state. + + ); + } + return
{parts.join(" · ")}
; +} + +/** + * Settings panel for the Claude (Agent SDK) backend. Shows the resolved + * `claude` CLI status, lets users force a re-detect, override the path, and + * inspects auth-relevant env vars. There is no managed install for this + * backend — the user must install the `claude` CLI themselves. + */ +export const ClaudeSettingsPanel: React.FC = () => { + const settings = useSettingsValue(); + const [, force] = React.useReducer((x: number) => x + 1, 0); + + const overridePath = settings.agentMode?.claudeCli?.path ?? ""; + // Each render re-walks fs.existsSync via the resolver — pressing + // "Re-detect" simply forces a new render via `force`. + const resolvedPath = resolveClaudeCliPath(settings); + const isCustom = Boolean(overridePath); + + // Env doesn't change without an Obsidian restart; read once on mount. + const authEnv = React.useMemo(readAuthEnv, []); + + const statusDescription = resolvedPath ? ( + <> +
+ Ready — claude + {isCustom && (custom)} +
+
{resolvedPath}
+ + ) : ( + + Setup required — Claude CLI not found. Install with {CLAUDE_INSTALL_COMMAND}. + + ); + + const onSaveCustomPath = React.useCallback(async (path: string): Promise => { + const err = await validateExecutableFile(path); + if (err) return err; + setSettings((cur) => ({ + agentMode: { ...cur.agentMode, claudeCli: { path } }, + })); + new Notice("Claude CLI path saved."); + return null; + }, []); + + const clearCustomPath = (): void => { + setSettings((cur) => ({ + agentMode: { ...cur.agentMode, claudeCli: undefined }, + })); + new Notice("Claude CLI override cleared. Auto-detection will be used."); + }; + + return ( + <> + +
+ + {isCustom && ( + + )} +
+
+ + + + + + + + + + updateClaudeFields({ enableThinking: checked })} + /> + + ); +}; diff --git a/src/agentMode/backends/claude/claudeBinaryResolver.test.ts b/src/agentMode/backends/claude/claudeBinaryResolver.test.ts new file mode 100644 index 00000000..9ce64c20 --- /dev/null +++ b/src/agentMode/backends/claude/claudeBinaryResolver.test.ts @@ -0,0 +1,155 @@ +import { + resolveClaudeBinary, + type ClaudeBinaryResolverFs, + type ClaudeBinaryResolverInput, +} from "./claudeBinaryResolver"; + +function makeFs( + paths: Iterable, + contents: Record = {} +): ClaudeBinaryResolverFs { + const set = new Set(paths); + return { + existsSync: (p: string) => set.has(p), + readFileSync: (p: string) => { + if (p in contents) return contents[p]; + const err: NodeJS.ErrnoException = new Error(`ENOENT: ${p}`); + err.code = "ENOENT"; + throw err; + }, + }; +} + +function unixInput( + fs: ClaudeBinaryResolverFs, + overrides: Partial = {} +): ClaudeBinaryResolverInput { + return { + homeDir: "/home/me", + platform: "linux", + env: {}, + fs, + ...overrides, + }; +} + +function winInput( + fs: ClaudeBinaryResolverFs, + overrides: Partial = {} +): ClaudeBinaryResolverInput { + return { + homeDir: "C:\\Users\\me", + platform: "win32", + env: { APPDATA: "C:\\Users\\me\\AppData\\Roaming" }, + fs, + ...overrides, + }; +} + +describe("resolveClaudeBinary — Unix", () => { + it("returns the override when it exists", () => { + const fs = makeFs(["/custom/claude"]); + const path = resolveClaudeBinary(unixInput(fs, { override: "/custom/claude" })); + expect(path).toBe("/custom/claude"); + }); + + it("falls through to the default search when the override is missing", () => { + const fs = makeFs(["/home/me/.claude/local/claude"]); + const path = resolveClaudeBinary(unixInput(fs, { override: "/missing" })); + expect(path).toBe("/home/me/.claude/local/claude"); + }); + + it("prefers ~/.claude/local/claude over later candidates", () => { + const fs = makeFs([ + "/home/me/.claude/local/claude", + "/usr/local/bin/claude", + "/opt/homebrew/bin/claude", + ]); + expect(resolveClaudeBinary(unixInput(fs))).toBe("/home/me/.claude/local/claude"); + }); + + it("finds /opt/homebrew/bin/claude when no earlier candidate exists", () => { + const fs = makeFs(["/opt/homebrew/bin/claude"]); + expect(resolveClaudeBinary(unixInput(fs))).toBe("/opt/homebrew/bin/claude"); + }); + + it("finds ~/.volta/bin/claude", () => { + const fs = makeFs(["/home/me/.volta/bin/claude"]); + expect(resolveClaudeBinary(unixInput(fs))).toBe("/home/me/.volta/bin/claude"); + }); + + it("finds ~/.asdf/shims/claude", () => { + const fs = makeFs(["/home/me/.asdf/shims/claude"]); + expect(resolveClaudeBinary(unixInput(fs))).toBe("/home/me/.asdf/shims/claude"); + }); + + it("uses npm_config_prefix when set", () => { + const fs = makeFs(["/opt/myprefix/bin/claude"]); + const path = resolveClaudeBinary( + unixInput(fs, { env: { npm_config_prefix: "/opt/myprefix" } }) + ); + expect(path).toBe("/opt/myprefix/bin/claude"); + }); + + it("falls back to NVM default alias when no other candidate exists", () => { + const aliasPath = "/home/me/.nvm/alias/default"; + const claudePath = "/home/me/.nvm/versions/node/v20.11.0/bin/claude"; + const fs = makeFs([aliasPath, claudePath], { [aliasPath]: "v20.11.0\n" }); + expect(resolveClaudeBinary(unixInput(fs))).toBe(claudePath); + }); + + it("accepts NVM alias contents without the leading 'v'", () => { + const aliasPath = "/home/me/.nvm/alias/default"; + const claudePath = "/home/me/.nvm/versions/node/v18.19.0/bin/claude"; + const fs = makeFs([aliasPath, claudePath], { [aliasPath]: "18.19.0" }); + expect(resolveClaudeBinary(unixInput(fs))).toBe(claudePath); + }); + + it("returns null when NVM alias is unparseable (e.g. 'lts/*')", () => { + const aliasPath = "/home/me/.nvm/alias/default"; + const fs = makeFs([aliasPath], { [aliasPath]: "lts/*" }); + expect(resolveClaudeBinary(unixInput(fs))).toBeNull(); + }); + + it("falls back to cli.js under the npm-global lib root", () => { + const fs = makeFs(["/home/me/.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js"]); + expect(resolveClaudeBinary(unixInput(fs))).toBe( + "/home/me/.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js" + ); + }); + + it("returns null when nothing is found", () => { + const fs = makeFs([]); + expect(resolveClaudeBinary(unixInput(fs))).toBeNull(); + }); +}); + +describe("resolveClaudeBinary — Windows", () => { + it("prefers claude.exe over claude.cmd in the same dir", () => { + const fs = makeFs([ + "C:\\Users\\me\\AppData\\Roaming\\npm\\claude.exe", + "C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd", + ]); + expect(resolveClaudeBinary(winInput(fs))).toBe( + "C:\\Users\\me\\AppData\\Roaming\\npm\\claude.exe" + ); + }); + + it("never picks claude.cmd even when it is the only file present", () => { + const fs = makeFs(["C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd"]); + expect(resolveClaudeBinary(winInput(fs))).toBeNull(); + }); + + it("falls back to cli.js under node_modules\\@anthropic-ai\\claude-code", () => { + const cliJs = + "C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@anthropic-ai\\claude-code\\cli.js"; + const fs = makeFs([cliJs]); + expect(resolveClaudeBinary(winInput(fs))).toBe(cliJs); + }); + + it("respects the override on Windows", () => { + const override = "C:\\tools\\claude.exe"; + const fs = makeFs([override]); + expect(resolveClaudeBinary(winInput(fs, { override }))).toBe(override); + }); +}); diff --git a/src/agentMode/backends/claude/claudeBinaryResolver.ts b/src/agentMode/backends/claude/claudeBinaryResolver.ts new file mode 100644 index 00000000..ec10ceba --- /dev/null +++ b/src/agentMode/backends/claude/claudeBinaryResolver.ts @@ -0,0 +1,123 @@ +/** + * Locate the user-installed `claude` CLI to pass as + * `pathToClaudeCodeExecutable`. The SDK's auto-discovery walks + * `import.meta.url`, which fails inside Obsidian's bundled `main.js`. + * + * Pure leaf: callers inject `homeDir`, `platform`, `env`, and `fs` so tests + * don't touch real disk. + */ +import * as path from "node:path"; + +export interface ClaudeBinaryResolverFs { + existsSync: (path: string) => boolean; + readFileSync: (path: string, encoding: "utf8") => string; +} + +export interface ClaudeBinaryResolverInput { + /** User-configured override path. If set and exists, returned as-is. */ + override?: string; + homeDir: string; + platform: NodeJS.Platform; + env: { NVM_BIN?: string; npm_config_prefix?: string; APPDATA?: string }; + fs: ClaudeBinaryResolverFs; +} + +export function resolveClaudeBinary(input: ClaudeBinaryResolverInput): string | null { + const { override, fs } = input; + + if (override && fs.existsSync(override)) { + return override; + } + + const candidates = input.platform === "win32" ? windowsCandidates(input) : unixCandidates(input); + + for (const candidate of candidates) { + if (candidate && fs.existsSync(candidate)) { + return candidate; + } + } + + return null; +} + +const posix = path.posix; +const win = path.win32; + +function unixCandidates(input: ClaudeBinaryResolverInput): Array { + const { homeDir, env, fs } = input; + return [ + posix.join(homeDir, ".claude", "local", "claude"), + posix.join(homeDir, ".local", "bin", "claude"), + posix.join(homeDir, ".volta", "bin", "claude"), + posix.join(homeDir, ".asdf", "shims", "claude"), + posix.join(homeDir, ".asdf", "bin", "claude"), + "/usr/local/bin/claude", + "/opt/homebrew/bin/claude", + posix.join(homeDir, ".npm-global", "bin", "claude"), + env.npm_config_prefix ? posix.join(env.npm_config_prefix, "bin", "claude") : null, + env.NVM_BIN ? posix.join(env.NVM_BIN, "claude") : null, + resolveNvmDefaultClaude(homeDir, fs), + posix.join( + homeDir, + ".npm-global", + "lib", + "node_modules", + "@anthropic-ai", + "claude-code", + "cli.js" + ), + env.npm_config_prefix + ? posix.join( + env.npm_config_prefix, + "lib", + "node_modules", + "@anthropic-ai", + "claude-code", + "cli.js" + ) + : null, + "/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js", + "/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js", + ]; +} + +function windowsCandidates(input: ClaudeBinaryResolverInput): Array { + const { homeDir, env } = input; + // Per-dir, prefer `claude.exe`, then `cli.js` under that dir's + // node_modules. Never pick `claude.cmd` — it requires `shell: true` and + // breaks SDK stdio streaming. + const dirs = [ + env.APPDATA ? win.join(env.APPDATA, "npm") : null, + env.npm_config_prefix ?? null, + win.join(homeDir, "AppData", "Roaming", "npm"), + ]; + const out: Array = []; + for (const dir of dirs) { + if (!dir) continue; + out.push(win.join(dir, "claude.exe")); + out.push(win.join(dir, "node_modules", "@anthropic-ai", "claude-code", "cli.js")); + } + return out; +} + +/** + * NVM doesn't export `NVM_BIN` to GUI applications on macOS, so reading + * `~/.nvm/alias/default` is the most reliable way to find the user's default + * Node install. The file may contain `vX.Y.Z`, `X.Y.Z`, or an unresolvable + * alias like `lts/*` — the latter we silently skip. + */ +function resolveNvmDefaultClaude(homeDir: string, fs: ClaudeBinaryResolverFs): string | null { + const aliasPath = posix.join(homeDir, ".nvm", "alias", "default"); + let raw: string; + try { + raw = fs.readFileSync(aliasPath, "utf8"); + } catch { + return null; + } + const trimmed = raw.trim(); + if (!trimmed) return null; + const versionPattern = /^v?\d+\.\d+\.\d+/; + if (!versionPattern.test(trimmed)) return null; + const version = trimmed.startsWith("v") ? trimmed : `v${trimmed}`; + return posix.join(homeDir, ".nvm", "versions", "node", version, "bin", "claude"); +} diff --git a/src/agentMode/backends/claude/descriptor.ts b/src/agentMode/backends/claude/descriptor.ts new file mode 100644 index 00000000..601b4e18 --- /dev/null +++ b/src/agentMode/backends/claude/descriptor.ts @@ -0,0 +1,284 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { logWarn } from "@/logger"; +import type CopilotPlugin from "@/main"; +import { + getSettings, + subscribeToSettingsChange, + updateAgentModeBackendFields, + type ClaudeBackendSettings, + type CopilotSettings, +} from "@/settings/model"; +import type { AgentSession } from "@/agentMode/session/AgentSession"; +import { applyPersistedMode } from "@/agentMode/session/applyPersistedMode"; +import { MethodUnsupportedError } from "@/agentMode/session/errors"; +import { resolveClaudeBinary } from "./claudeBinaryResolver"; +import { ClaudeSdkBackendProcess } from "@/agentMode/sdk/ClaudeSdkBackendProcess"; +import { getCachedSdkCatalog, synthesizeEffortConfigOption } from "@/agentMode/sdk/effortOption"; +import { + buildSkillCreationDirective, + DEFAULT_SKILLS_FOLDER, + SkillManager, +} from "@/agentMode/skills"; +import type { + BackendConfigOption, + CopilotMode, + ModeMapping, + ModelSelection, + ModelWireCodec, +} from "@/agentMode/session/types"; +import type { BackendDescriptor, BackendProcess, InstallState } from "@/agentMode/session/types"; +import { ClaudeInstallModal } from "./ClaudeInstallModal"; +import ClaudeLogo from "./logo.svg"; +import { ClaudeSettingsPanel } from "./ClaudeSettingsPanel"; +import { openAskUserQuestionModal } from "./AskUserQuestionModal"; + +export const CLAUDE_INSTALL_COMMAND = "npm install -g @anthropic-ai/claude-code"; + +export function updateClaudeFields(partial: Partial): void { + updateAgentModeBackendFields("claude", partial); +} + +/** + * Wire-format codec for Claude — bare base id only. Effort is dispatched + * via `setSessionConfigOption`, not encoded in the model id, so `encode` + * drops `effort` and `effortConfigFor` provides the config option spec. + */ +const claudeWire: ModelWireCodec = { + encode: (selection: ModelSelection) => selection.baseModelId, + decode: (wireId: string) => ({ + selection: { baseModelId: wireId, effort: null }, + provider: "anthropic", + }), + effortConfigFor: (baseModelId: string): BackendConfigOption | null => { + const catalog = getCachedSdkCatalog(); + if (!catalog) return null; + const modelInfo = catalog.find((m) => m.value === baseModelId); + if (!modelInfo) return null; + return synthesizeEffortConfigOption(modelInfo, undefined); + }, +}; + +/** + * Resolve the `claude` CLI path from settings + auto-detection. Mirrors the + * `getInstallState` logic: explicit override wins, otherwise the resolver + * walks Volta/asdf/NVM/Homebrew/npm-global. + */ +export function resolveClaudeCliPath(settings: CopilotSettings): string | null { + const override = settings.agentMode?.claudeCli?.path; + return resolveClaudeBinary({ + override, + homeDir: os.homedir(), + platform: process.platform, + env: { + NVM_BIN: process.env.NVM_BIN, + npm_config_prefix: process.env.npm_config_prefix, + APPDATA: process.env.APPDATA, + }, + fs: { + existsSync: (p) => fs.existsSync(p), + readFileSync: (p, encoding) => fs.readFileSync(p, encoding), + }, + }); +} + +/** + * Plan mode writes its proposal to `/plans/.md` + * (typically `~/.claude/plans/`, but `CLAUDE_CONFIG_DIR` / `XDG_CONFIG_HOME` + * can relocate it). We suffix-match on `.claude/plans` rather than prefix- + * matching `os.homedir()` so the predicate stays correct under those env + * overrides and across platforms — `path.dirname` + `path.join` produce + * native separators on macOS/Linux/Windows. + */ +function isClaudePlanModePlanFilePath(absolutePath: string): boolean { + if (!path.isAbsolute(absolutePath)) return false; + if (!absolutePath.endsWith(".md")) return false; + const dir = path.dirname(absolutePath); + return dir.endsWith(path.join(".claude", "plans")); +} + +/** + * Claude backend backed by the official `@anthropic-ai/claude-agent-sdk`. + * Replaces the legacy `claude-code-acp` shim. Auth is inherited from the + * user-installed `claude` CLI's login state (or `ANTHROPIC_API_KEY` / + * Bedrock / Vertex env if configured) — the SDK handles credential + * resolution through the spawned CLI; we never see or pass the secret. + */ +export const ClaudeBackendDescriptor: BackendDescriptor = { + id: "claude", + displayName: "Claude", + Icon: ClaudeLogo, + skillsProjectDir: ".claude/skills", + crossDiscoveredAgents: [], + restartOnManagedSkillsChange: false, + wire: claudeWire, + + getInstallState(settings: CopilotSettings): InstallState { + const path = resolveClaudeCliPath(settings); + if (!path) return { kind: "absent" }; + return { + kind: "ready", + source: settings.agentMode?.claudeCli?.path ? "custom" : "managed", + }; + }, + + subscribeInstallState(_plugin: CopilotPlugin, cb: () => void): () => void { + return subscribeToSettingsChange((prev, next) => { + if (prev.agentMode?.claudeCli?.path !== next.agentMode?.claudeCli?.path) { + cb(); + } + }); + }, + + openInstallUI(plugin: CopilotPlugin): void { + new ClaudeInstallModal(plugin.app).open(); + }, + + isPlanModePlanFilePath(absolutePath: string): boolean { + return isClaudePlanModePlanFilePath(absolutePath); + }, + + async applySelection(session: AgentSession, selection: ModelSelection): Promise { + // Claude's wire id is just the baseModelId — effort travels through + // `setConfigOption`, not the model id. Skip the model round-trip when + // the base hasn't changed, otherwise effort-only ticks would fire a + // pointless `setSessionModel` on every slider drag. + const currentBase = session.getState()?.model?.current.baseModelId; + if (currentBase !== selection.baseModelId) { + await session.setModel(claudeWire.encode(selection)); + } + if (selection.effort === null) return; + const cfgOpt = claudeWire.effortConfigFor?.(selection.baseModelId); + if (!cfgOpt) return; + try { + await session.setConfigOption(cfgOpt.id, selection.effort); + } catch (e) { + if (!(e instanceof MethodUnsupportedError)) throw e; + } + }, + + createBackendProcess(args): BackendProcess { + const claudePath = resolveClaudeCliPath(getSettings()); + if (!claudePath) { + throw new Error( + `Claude CLI not found. Install with: ${CLAUDE_INSTALL_COMMAND}, ` + + `or set agentMode.claudeCli.path in settings.` + ); + } + return new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: claudePath, + app: args.app, + clientVersion: args.clientVersion, + descriptor: args.descriptor, + askUserQuestion: (questions) => openAskUserQuestionModal(args.app, questions), + getEnableThinking: () => Boolean(getSettings().agentMode?.backends?.claude?.enableThinking), + isPlanModePlanFilePath: isClaudePlanModePlanFilePath, + getDefaultModelId: () => getSettings().agentMode?.backends?.claude?.defaultModel?.baseModelId, + // Spawn-time skill-creation directive: read the current + // `agentMode.skills.folder` so the directive templates the live value + // on every new session. See the Skills Management spec. + // + // Claude has no cross-discovery surface — it only loads + // `.claude/skills/`, and the symlink fanout already enforces + // visibility (no link = not seen). If the Claude Agent SDK ever + // grows a per-skill deny hook, wire `composeDenyList(getManagedSkills(), + // "claude")` in here. + getSkillCreationDirective: () => { + const folder = getSettings().agentMode?.skills?.folder ?? DEFAULT_SKILLS_FOLDER; + const dirs = Object.values(SkillManager.getInstance().getAgentDirsProjectRel()); + return buildSkillCreationDirective("claude", folder, dirs); + }, + }); + }, + + SettingsPanel: ClaudeSettingsPanel, + + /** + * Map Copilot's canonical modes onto the SDK's `PermissionMode` strings. + * `acceptEdits` / `dontAsk` exist upstream but stay hidden — the picker is + * a 3-mode UI (default / plan / auto). The session adapter normalizes + * unknown ids to `default`. + */ + getModeMapping(): ModeMapping { + return { + kind: "setMode", + canonical: { + default: "default", + plan: "plan", + auto: "bypassPermissions", + }, + }; + }, + + async persistModeSelection(value: CopilotMode, _plugin: CopilotPlugin): Promise { + updateClaudeFields({ selectedMode: value }); + }, + + /** + * Replay persisted mode + effort on a freshly created session. The + * Claude SDK adapter probes the model catalog asynchronously, so the + * effort `SessionConfigOption` may not be present yet when this runs; + * `replayPersistedEffort` subscribes to the session and applies once the + * option arrives (with a timeout guard to avoid leaking listeners on + * agents that never report effort). + */ + async applyInitialSessionConfig(session: AgentSession, settings: CopilotSettings): Promise { + const claudeSettings = settings.agentMode?.backends?.claude; + const persistedEffort = claudeSettings?.defaultModel?.effort ?? null; + await Promise.all([ + applyPersistedMode(session, claudeSettings?.selectedMode ?? "default"), + replayPersistedEffort(session, persistedEffort ?? undefined), + ]); + }, +}; + +async function replayPersistedEffort( + session: AgentSession, + persistedEffort: string | undefined +): Promise { + if (!persistedEffort) return; + const tryApply = async (): Promise => { + const state = session.getState(); + const current = state?.model?.current; + if (!current) return false; + if (current.effort === persistedEffort) return true; + const entry = state?.model?.availableModels.find((e) => e.baseModelId === current.baseModelId); + if (!entry?.effortOptions.some((o) => o.value === persistedEffort)) return true; + const cfgOpt = ClaudeBackendDescriptor.wire.effortConfigFor?.(current.baseModelId); + if (!cfgOpt) return true; + try { + await session.setConfigOption(cfgOpt.id, persistedEffort); + } catch (e) { + if (e instanceof MethodUnsupportedError) return true; + logWarn(`[AgentMode] could not apply default effort ${persistedEffort}`, e); + } + return true; + }; + + if (await tryApply()) return; + + // Effort hasn't been advertised yet — wait for the first + // config_option_update. Bound the wait so we don't keep a listener alive + // on agents that never emit an effort option. + await new Promise((resolve) => { + let settled = false; + const finish = (): void => { + if (settled) return; + settled = true; + unsub(); + window.clearTimeout(timer); + resolve(); + }; + const unsub = session.subscribe({ + onMessagesChanged: () => {}, + onStatusChanged: () => {}, + onModelChanged: () => { + void tryApply().then((applied) => { + if (applied) finish(); + }); + }, + }); + const timer = window.setTimeout(finish, 10_000); + }); +} diff --git a/src/agentMode/backends/claude/index.ts b/src/agentMode/backends/claude/index.ts new file mode 100644 index 00000000..155ca429 --- /dev/null +++ b/src/agentMode/backends/claude/index.ts @@ -0,0 +1 @@ +export { ClaudeBackendDescriptor } from "./descriptor"; diff --git a/src/agentMode/backends/claude/logo.svg b/src/agentMode/backends/claude/logo.svg new file mode 100644 index 00000000..f290abbf --- /dev/null +++ b/src/agentMode/backends/claude/logo.svg @@ -0,0 +1 @@ + diff --git a/src/agentMode/backends/codex/CodexBackend.test.ts b/src/agentMode/backends/codex/CodexBackend.test.ts new file mode 100644 index 00000000..bd1fddea --- /dev/null +++ b/src/agentMode/backends/codex/CodexBackend.test.ts @@ -0,0 +1,132 @@ +import { resetSettings, setSettings } from "@/settings/model"; +import { CodexBackend, toTomlBasicString } from "./CodexBackend"; + +jest.mock("@/logger", () => ({ + logInfo: jest.fn(), + logWarn: jest.fn(), + logError: jest.fn(), +})); + +jest.mock("@/agentMode/skills", () => { + const actual = jest.requireActual("@/agentMode/skills"); + return { + ...actual, + SkillManager: { + hasInstance: () => true, + getInstance: () => ({ + getAgentDirsProjectRel: () => ({ + claude: ".claude/skills", + codex: ".agents/skills", + opencode: ".opencode/skills", + }), + }), + }, + }; +}); + +describe("CodexBackend.buildSpawnDescriptor", () => { + beforeEach(() => { + resetSettings(); + setSettings({ + agentMode: { + enabled: true, + byok: {}, + mcpServers: [], + activeBackend: "codex", + debugFullFrames: false, + skills: { folder: "copilot/skills" }, + backends: { + codex: { binaryPath: "/usr/local/bin/codex-acp" }, + }, + }, + }); + }); + + it("injects the skill-creation directive via -c developer_instructions", async () => { + const backend = new CodexBackend(); + const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" }); + expect(desc.command).toBe("/usr/local/bin/codex-acp"); + const cIdx = desc.args.indexOf("-c"); + expect(cIdx).toBeGreaterThanOrEqual(0); + const value = desc.args[cIdx + 1]; + expect(value.startsWith("developer_instructions=")).toBe(true); + // The TOML value carries the directive text (newlines escaped as \n). + expect(value).toContain('metadata.copilot-enabled-agents: \\"codex\\"'); + expect(value).toContain("copilot/skills//SKILL.md"); + expect(value).toContain(".claude/skills/"); + expect(value).toContain(".agents/skills/"); + expect(value).toContain(".opencode/skills/"); + }); + + it("templates a custom skills folder at spawn time", async () => { + setSettings({ + agentMode: { + enabled: true, + byok: {}, + mcpServers: [], + activeBackend: "codex", + debugFullFrames: false, + skills: { folder: "team-skills" }, + backends: { codex: { binaryPath: "/usr/local/bin/codex-acp" } }, + }, + }); + const backend = new CodexBackend(); + const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" }); + const cIdx = desc.args.indexOf("-c"); + const value = desc.args[cIdx + 1]; + expect(value).toContain("team-skills//SKILL.md"); + expect(value).not.toContain("copilot/skills"); + }); + + it("escapes embedded double quotes and backslashes for TOML safety", async () => { + // Folders can't contain quotes in practice (validateSkillsFolder + // strips them), but the escape logic should still be airtight — the + // resulting -c value is consumed by a TOML parser, so an unescaped + // quote would terminate the basic-string literal and break + // codex-acp's startup. + const backend = new CodexBackend(); + const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" }); + const cIdx = desc.args.indexOf("-c"); + const value = desc.args[cIdx + 1]; + // The value is wrapped in unescaped outer quotes; any inner double + // quote must be `\"` and every newline `\n` (no raw newlines, which + // would also break TOML basic strings). + expect(value).not.toMatch(/\n/); + // Confirm the outer literal is well-formed: starts with `key="…` and + // ends with `…"` (the closing quote of the TOML string). + expect(value.startsWith('developer_instructions="')).toBe(true); + expect(value.endsWith('"')).toBe(true); + }); + + it("escapes the full TOML basic-string control set", () => { + // Named escapes per the TOML 1.0 spec. + expect(toTomlBasicString("a\bb\tc\nd\fe\rf")).toBe('"a\\bb\\tc\\nd\\fe\\rf"'); + // Backslash + double-quote. + expect(toTomlBasicString('back\\slash"quote')).toBe('"back\\\\slash\\"quote"'); + // Other controls fall through as \\uXXXX. Build the input from char + // codes so the source file stays plain ASCII (and copies/pastes cleanly). + const controls = + String.fromCharCode(0x01) + String.fromCharCode(0x1f) + String.fromCharCode(0x7f); + expect(toTomlBasicString(controls)).toBe('"\\u0001\\u001f\\u007f"'); + // Non-ASCII passes through unescaped. + expect(toTomlBasicString("über — café")).toBe('"über — café"'); + }); + + it("throws when the codex binary path is unset", async () => { + setSettings({ + agentMode: { + enabled: true, + byok: {}, + mcpServers: [], + activeBackend: "codex", + debugFullFrames: false, + skills: { folder: "copilot/skills" }, + backends: {}, + }, + }); + const backend = new CodexBackend(); + await expect(backend.buildSpawnDescriptor({ vaultBasePath: "/vault" })).rejects.toThrow( + /Codex binary path not configured/ + ); + }); +}); diff --git a/src/agentMode/backends/codex/CodexBackend.ts b/src/agentMode/backends/codex/CodexBackend.ts new file mode 100644 index 00000000..089095ab --- /dev/null +++ b/src/agentMode/backends/codex/CodexBackend.ts @@ -0,0 +1,77 @@ +import { getSettings } from "@/settings/model"; +import { AcpBackend, AcpSpawnDescriptor } from "@/agentMode/acp/types"; +import { buildSimpleSpawnDescriptor } from "@/agentMode/backends/shared/simpleBinaryBackend"; +import { + buildSkillCreationDirective, + DEFAULT_SKILLS_FOLDER, + SkillManager, +} from "@/agentMode/skills"; + +/** + * Spawns the user-provided `codex-acp` binary + * (`@zed-industries/codex-acp`). The package wraps the local `codex` CLI + * and exposes it as an ACP server over stdio. Authentication is inherited + * from the user's existing `codex login` (`~/.codex/auth.json`) or + * `OPENAI_API_KEY` / `CODEX_API_KEY` exported in the user's shell — we + * deliberately do not inject keys so ChatGPT-login subscriptions work + * transparently. + */ +export class CodexBackend implements AcpBackend { + readonly id = "codex" as const; + readonly displayName = "Codex"; + + async buildSpawnDescriptor(_ctx: { vaultBasePath: string }): Promise { + const descriptor = buildSimpleSpawnDescriptor( + getSettings().agentMode?.backends?.codex?.binaryPath, + "Codex binary path not configured. Open Agent Mode settings and set the path to codex-acp." + ); + // Spawn-time skill-creation directive: forwarded into codex's + // `developer_instructions` config field via codex-acp's `-c key=value` + // override (see codex's `core/src/config/mod.rs`: "Developer + // instructions override injected as a separate message"). The value + // is wrapped in a TOML 1.0 basic string — the `-c` parser runs through + // TOML, so we escape per the spec rules (`\`, `"`, the named escapes + // `\b \t \n \f \r`, and remaining controls as `\uXXXX`). Folder is + // read live from settings so a setting change applies on the next + // session. See the Skills Management spec. + const skillsFolder = getSettings().agentMode?.skills?.folder ?? DEFAULT_SKILLS_FOLDER; + const dirs = Object.values(SkillManager.getInstance().getAgentDirsProjectRel()); + const directive = buildSkillCreationDirective("codex", skillsFolder, dirs); + descriptor.args = [ + ...descriptor.args, + "-c", + `developer_instructions=${toTomlBasicString(directive)}`, + ]; + return descriptor; + } +} + +/** + * Encode `value` as a TOML 1.0 basic string (double-quoted). Escapes: + * - `\` and `"` + * - named escapes `\b \t \n \f \r` + * - any other byte in 0x00–0x1F and 0x7F as `\uXXXX` + * + * Non-ASCII characters above 0x7F are valid in basic strings and pass + * through unescaped. Exported for unit testing. + */ +export function toTomlBasicString(value: string): string { + let out = '"'; + for (let i = 0; i < value.length; i++) { + const ch = value.charCodeAt(i); + if (ch === 0x5c) out += "\\\\"; + else if (ch === 0x22) out += '\\"'; + else if (ch === 0x08) out += "\\b"; + else if (ch === 0x09) out += "\\t"; + else if (ch === 0x0a) out += "\\n"; + else if (ch === 0x0c) out += "\\f"; + else if (ch === 0x0d) out += "\\r"; + else if (ch < 0x20 || ch === 0x7f) { + out += "\\u" + ch.toString(16).padStart(4, "0"); + } else { + out += value[i]; + } + } + out += '"'; + return out; +} diff --git a/src/agentMode/backends/codex/CodexInstallModal.tsx b/src/agentMode/backends/codex/CodexInstallModal.tsx new file mode 100644 index 00000000..66b74dae --- /dev/null +++ b/src/agentMode/backends/codex/CodexInstallModal.tsx @@ -0,0 +1,27 @@ +import { getSettings } from "@/settings/model"; +import { App } from "obsidian"; +import React from "react"; +import { BinaryInstallModal } from "@/agentMode/backends/shared/BinaryInstallContent"; +import { CODEX_BINARY_NAME, CODEX_INSTALL_COMMAND, updateCodexFields } from "./descriptor"; + +export class CodexInstallModal extends BinaryInstallModal { + constructor(app: App) { + super(app, { + modalTitle: "Configure Codex (Agent backend)", + binaryDisplayName: "Codex", + binaryName: CODEX_BINARY_NAME, + installCommand: CODEX_INSTALL_COMMAND, + pathPlaceholder: "/absolute/path/to/codex-acp", + initialPath: getSettings().agentMode?.backends?.codex?.binaryPath ?? "", + description: ( + <> + Codex uses the official @zed-industries/codex-acp adapter, which wraps the + local codex CLI. It inherits your existing codex login{" "} + credentials — or set OPENAI_API_KEY / CODEX_API_KEY in your + shell if you prefer API-key auth. + + ), + onPersist: (path) => updateCodexFields({ binaryPath: path }), + }); + } +} diff --git a/src/agentMode/backends/codex/CodexSettingsPanel.tsx b/src/agentMode/backends/codex/CodexSettingsPanel.tsx new file mode 100644 index 00000000..1468a78c --- /dev/null +++ b/src/agentMode/backends/codex/CodexSettingsPanel.tsx @@ -0,0 +1,26 @@ +import type CopilotPlugin from "@/main"; +import { getSettings } from "@/settings/model"; +import type { App } from "obsidian"; +import React from "react"; +import { SimpleBackendSettingsPanel } from "@/agentMode/backends/shared/SimpleBackendSettingsPanel"; +import { CodexInstallModal } from "./CodexInstallModal"; +import { CODEX_BINARY_NAME, CODEX_INSTALL_COMMAND, updateCodexFields } from "./descriptor"; + +interface Props { + plugin: CopilotPlugin; + app: App; +} + +export const CodexSettingsPanel: React.FC = ({ app }) => ( + getSettings().agentMode?.backends?.codex?.binaryPath ?? ""} + persistPath={(path) => updateCodexFields({ binaryPath: path })} + openInstallModal={() => new CodexInstallModal(app).open()} + /> +); diff --git a/src/agentMode/backends/codex/descriptor.test.ts b/src/agentMode/backends/codex/descriptor.test.ts new file mode 100644 index 00000000..e7815add --- /dev/null +++ b/src/agentMode/backends/codex/descriptor.test.ts @@ -0,0 +1,31 @@ +import { CodexBackendDescriptor } from "./descriptor"; + +jest.mock("@/logger", () => ({ + logInfo: jest.fn(), + logWarn: jest.fn(), + logError: jest.fn(), +})); + +describe("CodexBackendDescriptor.isModelEnabledByDefault", () => { + const fn = CodexBackendDescriptor.isModelEnabledByDefault!; + + it("matches gpt-5.5 family by modelId", () => { + expect(fn({ modelId: "gpt-5.5", name: "GPT-5.5" })).toBe(true); + expect(fn({ modelId: "gpt-5.5/high", name: "GPT-5.5 (high)" })).toBe(true); + }); + + it("matches gpt-5.5 family by display name", () => { + expect(fn({ modelId: "some-internal-id", name: "GPT-5.5" })).toBe(true); + }); + + it("does not match version numbers that merely contain 5.5", () => { + expect(fn({ modelId: "model-15.5-x", name: "Model 15.5x" })).toBe(false); + expect(fn({ modelId: "model-5.50", name: "Model 5.50" })).toBe(false); + }); + + it("rejects unrelated models", () => { + expect(fn({ modelId: "gpt-5", name: "GPT-5" })).toBe(false); + expect(fn({ modelId: "gpt-5-codex/high", name: "GPT-5 Codex (high)" })).toBe(false); + expect(fn({ modelId: "o3", name: "o3" })).toBe(false); + }); +}); diff --git a/src/agentMode/backends/codex/descriptor.ts b/src/agentMode/backends/codex/descriptor.ts new file mode 100644 index 00000000..88238fde --- /dev/null +++ b/src/agentMode/backends/codex/descriptor.ts @@ -0,0 +1,157 @@ +import type CopilotPlugin from "@/main"; +import { + subscribeToSettingsChange, + updateAgentModeBackendFields, + type CodexBackendSettings, + type CopilotSettings, +} from "@/settings/model"; +import { CodexBackend } from "./CodexBackend"; +import { CodexInstallModal } from "./CodexInstallModal"; +import CodexLogo from "./logo.svg"; +import { CodexSettingsPanel } from "./CodexSettingsPanel"; +import type { AgentSession } from "@/agentMode/session/AgentSession"; +import { applyPersistedMode } from "@/agentMode/session/applyPersistedMode"; +import { + binaryPathInstallState, + simpleBinaryBackendProcess, +} from "@/agentMode/backends/shared/simpleBinaryBackend"; +import type { + CopilotMode, + ModeMapping, + ModelSelection, + ModelWireCodec, +} from "@/agentMode/session/types"; +import type { BackendDescriptor, BackendProcess, InstallState } from "@/agentMode/session/types"; + +export const CODEX_BINARY_NAME = "codex-acp"; +export const CODEX_INSTALL_COMMAND = "npm install -g @zed-industries/codex-acp"; + +/** + * Vocabulary mirrors codex-acp's advertised efforts. `minimal` is included + * for forward-compat — codex CLI accepts it as a reasoning level even though + * codex-acp doesn't currently advertise it. + */ +const KNOWN_CODEX_EFFORTS = new Set(["minimal", "low", "medium", "high", "xhigh"]); + +export function updateCodexFields(partial: Partial): void { + updateAgentModeBackendFields("codex", partial); +} + +/** + * Wire-format codec for Codex — `[/]`. No provider segment + * (Codex's catalog isn't routed through Copilot BYOK keys, so + * `decode().provider` stays `null`). + */ +const codexWire: ModelWireCodec = { + encode: (selection: ModelSelection) => + selection.effort ? `${selection.baseModelId}/${selection.effort}` : selection.baseModelId, + decode: (wireId: string) => { + if (!wireId) return { selection: { baseModelId: wireId, effort: null }, provider: null }; + const segments = wireId.split("/"); + if (segments.length === 1) { + return { selection: { baseModelId: wireId, effort: null }, provider: null }; + } + if (segments.length === 2 && KNOWN_CODEX_EFFORTS.has(segments[1])) { + return { + selection: { baseModelId: segments[0], effort: segments[1] }, + provider: null, + }; + } + return { selection: { baseModelId: wireId, effort: null }, provider: null }; + }, +}; + +/** + * Codex backend — wraps `@zed-industries/codex-acp`, which inherits auth + * from the local `codex` CLI login. Independent of Copilot's + * `activeModels` / BYOK keys, so the picker is fed entirely by live + * `availableModels` (active session or preloader cache). + * + * Effort is surfaced via opencode-style model-id parsing — codex-acp + * advertises one model per (base × effort) combination, and we collapse + * them into a single picker row plus a sibling effort dropdown. + */ +export const CodexBackendDescriptor: BackendDescriptor = { + id: "codex", + displayName: "Codex", + Icon: CodexLogo, + skillsProjectDir: ".agents/skills", + crossDiscoveredAgents: [], + restartOnManagedSkillsChange: false, + wire: codexWire, + + getInstallState(settings: CopilotSettings): InstallState { + return binaryPathInstallState(settings.agentMode?.backends?.codex?.binaryPath); + }, + + subscribeInstallState(_plugin: CopilotPlugin, cb: () => void): () => void { + return subscribeToSettingsChange((prev, next) => { + if ( + prev.agentMode?.backends?.codex?.binaryPath !== next.agentMode?.backends?.codex?.binaryPath + ) { + cb(); + } + }); + }, + + openInstallUI(plugin: CopilotPlugin): void { + new CodexInstallModal(plugin.app).open(); + }, + + async applySelection(session: AgentSession, selection: ModelSelection): Promise { + await session.setModel(codexWire.encode(selection)); + }, + + createBackendProcess(args): BackendProcess { + // Codex sees managed skills only via the `.agents/skills/` + // symlink. The per-agent toggle drives whether the symlink exists; no + // deny synthesis is needed because Codex does not cross-discover from + // `.claude/skills/` or `.opencode/skills/`. + return simpleBinaryBackendProcess(args, new CodexBackend()); + }, + + SettingsPanel: CodexSettingsPanel, + + isModelEnabledByDefault(model) { + // Default-enable only gpt-5.5; digit-boundary on each side avoids + // matching `15.5` or `5.50`. Users widen via the Agents tab toggles. + const re = /(^|[^0-9])5\.5([^0-9]|$)/; + return re.test(model.name) || re.test(model.modelId); + }, + + /** + * Codex exposes sandbox/approval presets via ACP setMode: `read-only`, + * `auto`, and `full-access`. We surface all three: + * - build → "auto" (workspace-write, on-request approvals) + * - plan → "read-only" (no writes, no exec; closest ACP analog) + * - auto-build → "full-access" (no sandbox, no approvals) + * + * Note: this is a sandbox restriction, not Codex CLI's real `ModeKind::Plan` + * (which would draft a plan artifact). That mode lives behind the app-server + * `turn/start.collaborationMode` field, which `@zed-industries/codex-acp` + * does not forward — it translates ACP modes to + * `Op::OverrideTurnContext { approval_policy, sandbox_policy }` only. + * Read-only is the closest available analog: the agent can read and reason + * but cannot mutate the vault, which matches user intent for "Plan". + */ + getModeMapping(): ModeMapping { + return { + kind: "setMode", + canonical: { default: "auto", plan: "read-only", auto: "full-access" }, + }; + }, + + async persistModeSelection(value: CopilotMode, _plugin: CopilotPlugin): Promise { + updateCodexFields({ selectedMode: value }); + }, + + /** + * Replay the persisted mode on a freshly created session. Skipped when + * the agent doesn't advertise modes, or when the persisted mode's native + * id isn't currently in `availableModes` (filtered out by `getModeMapping`). + */ + async applyInitialSessionConfig(session: AgentSession, settings: CopilotSettings): Promise { + const persistedMode = settings.agentMode?.backends?.codex?.selectedMode ?? "default"; + await applyPersistedMode(session, persistedMode); + }, +}; diff --git a/src/agentMode/backends/codex/index.ts b/src/agentMode/backends/codex/index.ts new file mode 100644 index 00000000..6a3bb514 --- /dev/null +++ b/src/agentMode/backends/codex/index.ts @@ -0,0 +1 @@ +export { CodexBackendDescriptor } from "./descriptor"; diff --git a/src/agentMode/backends/codex/logo.svg b/src/agentMode/backends/codex/logo.svg new file mode 100644 index 00000000..4b543976 --- /dev/null +++ b/src/agentMode/backends/codex/logo.svg @@ -0,0 +1 @@ + diff --git a/src/agentMode/backends/opencode/OpencodeBackend.test.ts b/src/agentMode/backends/opencode/OpencodeBackend.test.ts new file mode 100644 index 00000000..4a26ec0e --- /dev/null +++ b/src/agentMode/backends/opencode/OpencodeBackend.test.ts @@ -0,0 +1,560 @@ +import { ChatModelProviders } from "@/constants"; +import { resetSettings, setSettings, updateSetting } from "@/settings/model"; +import type { Skill } from "@/agentMode/skills"; +import { buildOpencodeConfig, OPENCODE_PROVIDER_MAP, OpencodeBackend } from "./OpencodeBackend"; +import { COPILOT_PROMPT_BASE, selectCopilotPrompt } from "./prompts"; + +jest.mock("@/logger", () => ({ + logInfo: jest.fn(), + logWarn: jest.fn(), + logError: jest.fn(), +})); + +// Mock the skills package so we can drive the deny-list synthesis path +// without booting the real jotai store / Obsidian App singleton. +let mockSkills: Skill[] = []; +let mockSkillManagerReady = false; + +jest.mock("@/agentMode/skills", () => { + const actual = jest.requireActual("@/agentMode/skills"); + return { + ...actual, + getManagedSkills: () => mockSkills, + SkillManager: { + hasInstance: () => mockSkillManagerReady, + getInstance: () => { + if (!mockSkillManagerReady) { + throw new Error("SkillManager.getInstance called before initialize"); + } + return { getAgentDirsProjectRel: () => ({}) } as unknown; + }, + }, + }; +}); + +function makeSkill(name: string, enabledAgents: Skill["enabledAgents"]): Skill { + return { + name, + description: `${name} skill`, + filePath: `/x/${name}/SKILL.md`, + dirPath: `/x/${name}`, + body: "", + enabledAgents, + }; +} + +function seedSkills(skills: Skill[]): void { + mockSkills = skills; + mockSkillManagerReady = skills.length > 0; +} + +/** + * Most tests below disable the built-in `activeModels` so injection is a + * blank slate. The few that exercise injection set their own active models + * explicitly. + */ +function clearActiveModels() { + setSettings({ activeModels: [] }); +} + +describe("buildOpencodeConfig", () => { + beforeEach(() => { + resetSettings(); + clearActiveModels(); + seedSkills([]); + }); + + it("emits provider entries only for non-empty keys", async () => { + updateSetting("anthropicApiKey", "anth-123"); + updateSetting("openAIApiKey", ""); + updateSetting("googleApiKey", "g-456"); + const cfg = (await buildOpencodeConfig()) as { provider: Record }; + expect(Object.keys(cfg.provider).sort()).toEqual(["anthropic", "google"]); + expect(cfg.provider.anthropic).toEqual({ options: { apiKey: "anth-123" } }); + expect(cfg.provider.google).toEqual({ options: { apiKey: "g-456" } }); + }); + + it("returns empty provider map when no keys are set", async () => { + const cfg = (await buildOpencodeConfig()) as { provider: Record }; + expect(cfg.provider).toEqual({}); + }); + + it("injects enabled active models under their provider's `models` map", async () => { + updateSetting("anthropicApiKey", "anth-123"); + setSettings({ + activeModels: [ + { + name: "claude-sonnet-4-6", + provider: ChatModelProviders.ANTHROPIC, + enabled: true, + }, + { + name: "claude-haiku", + provider: ChatModelProviders.ANTHROPIC, + enabled: false, // disabled — should NOT inject + }, + ], + }); + const cfg = (await buildOpencodeConfig()) as { + provider: Record }>; + }; + expect(cfg.provider.anthropic.models).toEqual({ "claude-sonnet-4-6": {} }); + }); + + it("does not inject a model when neither top-level nor per-model key is available", async () => { + setSettings({ + activeModels: [ + { + name: "gpt-5", + provider: ChatModelProviders.OPENAI, + enabled: true, + }, + ], + }); + // Neither openAIApiKey nor model.apiKey configured + const cfg = (await buildOpencodeConfig()) as { provider: Record }; + expect(cfg.provider).toEqual({}); + }); + + it("falls back to per-model apiKey when top-level provider key is missing", async () => { + setSettings({ + activeModels: [ + { + name: "gpt-5", + provider: ChatModelProviders.OPENAI, + enabled: true, + apiKey: "per-model-key", + }, + ], + }); + // No openAIApiKey configured globally, but the model carries its own key. + const cfg = (await buildOpencodeConfig()) as { + provider: Record }>; + }; + expect(cfg.provider.openai.options).toEqual({ apiKey: "per-model-key" }); + expect(cfg.provider.openai.models).toEqual({ "gpt-5": {} }); + }); + + it("prefers the top-level provider key when both are present", async () => { + updateSetting("openAIApiKey", "global-key"); + setSettings({ + activeModels: [ + { + name: "gpt-5", + provider: ChatModelProviders.OPENAI, + enabled: true, + apiKey: "per-model-key", + }, + ], + }); + const cfg = (await buildOpencodeConfig()) as { + provider: Record; + }; + // Top-level wins because the provider entry is built before the + // per-model fallback runs — keeps the historical behaviour. + expect(cfg.provider.openai.options).toEqual({ apiKey: "global-key" }); + }); + + it("does not inject models for providers OpenCode cannot route", async () => { + setSettings({ + activeModels: [ + { + name: "claude-via-bedrock", + provider: ChatModelProviders.AMAZON_BEDROCK, + enabled: true, + }, + { + name: "llama", + provider: ChatModelProviders.OLLAMA, + enabled: true, + }, + ], + }); + const cfg = (await buildOpencodeConfig()) as { provider: Record }; + expect(cfg.provider).toEqual({}); + }); + + it("skips embedding models", async () => { + updateSetting("openAIApiKey", "key"); + setSettings({ + activeModels: [ + { + name: "text-embedding-3-large", + provider: ChatModelProviders.OPENAI, + enabled: true, + isEmbeddingModel: true, + }, + { + name: "gpt-test", + provider: ChatModelProviders.OPENAI, + enabled: true, + }, + ], + }); + const cfg = (await buildOpencodeConfig()) as { + provider: Record }>; + }; + // gpt-test is injected; the embedding model is not, even though both have + // the same provider and enabled flag. + expect(cfg.provider.openai.models).toHaveProperty("gpt-test"); + expect(cfg.provider.openai.models).not.toHaveProperty("text-embedding-3-large"); + }); + + it("sets top-level model from the persisted defaultModel.baseModelId", async () => { + updateSetting("anthropicApiKey", "anth-123"); + setSettings({ + agentMode: { + enabled: true, + byok: {}, + mcpServers: [], + activeBackend: "opencode", + debugFullFrames: false, + skills: { folder: "copilot/skills" }, + backends: { + opencode: { + binaryPath: "/x", + defaultModel: { baseModelId: "anthropic/claude-sonnet-4-6", effort: null }, + }, + }, + }, + }); + const cfg = (await buildOpencodeConfig()) as { model?: string }; + expect(cfg.model).toBe("anthropic/claude-sonnet-4-6"); + }); + + it("appends effort suffix when defaultModel.effort is set", async () => { + updateSetting("anthropicApiKey", "anth-123"); + setSettings({ + agentMode: { + enabled: true, + byok: {}, + mcpServers: [], + activeBackend: "opencode", + debugFullFrames: false, + skills: { folder: "copilot/skills" }, + backends: { + opencode: { + binaryPath: "/x", + defaultModel: { baseModelId: "anthropic/claude-sonnet-4-6", effort: "high" }, + }, + }, + }, + }); + const cfg = (await buildOpencodeConfig()) as { model?: string }; + expect(cfg.model).toBe("anthropic/claude-sonnet-4-6/high"); + }); + + it("registers a custom copilot-plus provider when plusLicenseKey is set", async () => { + updateSetting("plusLicenseKey", "plus-token-123"); + setSettings({ + activeModels: [ + { + name: "copilot-plus-flash", + provider: ChatModelProviders.COPILOT_PLUS, + enabled: true, + }, + ], + }); + const cfg = (await buildOpencodeConfig()) as { + provider: Record< + string, + { + npm?: string; + name?: string; + options?: { baseURL?: string; apiKey?: string }; + models?: Record; + } + >; + }; + const cp = cfg.provider["copilot-plus"]; + expect(cp.npm).toBe("@ai-sdk/openai-compatible"); + expect(cp.name).toBe("Copilot Plus"); + expect(cp.options?.baseURL).toBe("https://models.brevilabs.com/v1"); + expect(cp.options?.apiKey).toBe("plus-token-123"); + expect(cp.models).toEqual({ "copilot-plus-flash": {} }); + }); + + it("does not register copilot-plus provider when plusLicenseKey is empty", async () => { + setSettings({ + activeModels: [ + { + name: "copilot-plus-flash", + provider: ChatModelProviders.COPILOT_PLUS, + enabled: true, + }, + ], + }); + const cfg = (await buildOpencodeConfig()) as { provider: Record }; + expect(cfg.provider["copilot-plus"]).toBeUndefined(); + }); + + it("uses a Copilot-Plus-shaped defaultModel.baseModelId verbatim", async () => { + updateSetting("plusLicenseKey", "plus-token-123"); + setSettings({ + activeModels: [ + { + name: "copilot-plus-flash", + provider: ChatModelProviders.COPILOT_PLUS, + enabled: true, + }, + ], + agentMode: { + enabled: true, + byok: {}, + mcpServers: [], + activeBackend: "opencode", + debugFullFrames: false, + skills: { folder: "copilot/skills" }, + backends: { + opencode: { + binaryPath: "/x", + defaultModel: { baseModelId: "copilot-plus/copilot-plus-flash", effort: null }, + }, + }, + }, + }); + const cfg = (await buildOpencodeConfig()) as { model?: string }; + expect(cfg.model).toBe("copilot-plus/copilot-plus-flash"); + }); + + it("sets default_agent from persisted selectedMode (canonical → native id)", async () => { + setSettings({ + agentMode: { + enabled: true, + byok: {}, + mcpServers: [], + activeBackend: "opencode", + debugFullFrames: false, + skills: { folder: "copilot/skills" }, + backends: { + opencode: { + selectedMode: "default", + }, + }, + }, + }); + const defaultCfg = (await buildOpencodeConfig()) as { default_agent?: string }; + expect(defaultCfg.default_agent).toBe("copilot-build"); + + setSettings({ + agentMode: { + enabled: true, + byok: {}, + mcpServers: [], + activeBackend: "opencode", + debugFullFrames: false, + skills: { folder: "copilot/skills" }, + backends: { opencode: { selectedMode: "auto" } }, + }, + }); + const autoCfg = (await buildOpencodeConfig()) as { default_agent?: string }; + expect(autoCfg.default_agent).toBe("build"); + }); + + it("falls back to canonical default (copilot-build) when no mode is persisted", async () => { + const cfg = (await buildOpencodeConfig()) as { default_agent?: string }; + expect(cfg.default_agent).toBe("copilot-build"); + }); + + it("overrides system prompt on both build and copilot-build agents", async () => { + const cfg = (await buildOpencodeConfig()) as { + agent: Record; + }; + // Prompt now starts with the COPILOT_PROMPT_BASE and ends with the + // spawn-time skill-creation directive (see the Skills Management + // spec). Assert the base is the prefix so future directive changes + // don't break this test. + expect(cfg.agent["copilot-build"].prompt?.startsWith(COPILOT_PROMPT_BASE)).toBe(true); + expect(cfg.agent.build.prompt?.startsWith(COPILOT_PROMPT_BASE)).toBe(true); + expect(cfg.agent["copilot-build"].prompt).toContain( + 'metadata.copilot-enabled-agents: "opencode"' + ); + expect(cfg.agent.build.prompt).toContain('metadata.copilot-enabled-agents: "opencode"'); + // Regression guard: the copilot-build permission block must survive + // alongside the new prompt field — opencode's field-wise merge depends + // on us not stomping native fields. + expect(cfg.agent["copilot-build"].permission).toEqual({ bash: "ask", edit: "ask" }); + expect(cfg.agent["copilot-build"].mode).toBe("primary"); + }); + + it("templates a custom skills folder into the opencode directive", async () => { + setSettings({ + agentMode: { + enabled: true, + byok: {}, + mcpServers: [], + activeBackend: "opencode", + debugFullFrames: false, + skills: { folder: "team-skills" }, + backends: {}, + }, + }); + const cfg = (await buildOpencodeConfig()) as { + agent: Record; + }; + expect(cfg.agent["copilot-build"].prompt).toContain("/team-skills//SKILL.md"); + expect(cfg.agent.build.prompt).toContain("/team-skills//SKILL.md"); + }); + + it("denies a skill enabled for Claude only (cross-discovered, not enabled for opencode)", async () => { + seedSkills([makeSkill("foo", ["claude"])]); + const cfg = (await buildOpencodeConfig()) as { + permission?: { skill?: Record }; + }; + expect(cfg.permission?.skill?.foo).toBe("deny"); + }); + + it("does not deny a skill enabled for both Claude and OpenCode", async () => { + seedSkills([makeSkill("foo", ["claude", "opencode"])]); + const cfg = (await buildOpencodeConfig()) as { + permission?: { skill?: Record }; + }; + expect(cfg.permission?.skill?.foo).toBeUndefined(); + }); + + it("does not emit a permission.skill block when no skills need denying", async () => { + seedSkills([makeSkill("foo", ["opencode"])]); + const cfg = (await buildOpencodeConfig()) as { + permission?: { skill?: Record }; + }; + expect(cfg.permission).toBeUndefined(); + }); + + it("does not emit a permission.skill block when there are no skills at all", async () => { + seedSkills([]); + const cfg = (await buildOpencodeConfig()) as { permission?: unknown }; + expect(cfg.permission).toBeUndefined(); + }); + + it("synthesises deny rules for a mix of skills (only cross-discovered + not-enabled wins)", async () => { + seedSkills([ + makeSkill("a", ["claude"]), + makeSkill("b", ["claude", "opencode"]), + makeSkill("c", []), + makeSkill("d", ["opencode"]), + makeSkill("e", ["codex"]), + ]); + const cfg = (await buildOpencodeConfig()) as { + permission?: { skill?: Record }; + }; + // a is claude-only → denied. e is codex-only → denied (codex also + // populates the cross-discovered `.agents/skills/` path). b/c/d not denied. + expect(cfg.permission?.skill).toEqual({ a: "deny", e: "deny" }); + }); + + it("skips deny synthesis when SkillManager has not initialised yet", async () => { + // Place a skill in the snapshot, but mark the singleton as not ready. + mockSkills = [makeSkill("foo", ["claude"])]; + mockSkillManagerReady = false; + const cfg = (await buildOpencodeConfig()) as { permission?: unknown }; + expect(cfg.permission).toBeUndefined(); + }); + + it("omits cfg.model when no defaultModel is set", async () => { + updateSetting("anthropicApiKey", "anth-123"); + setSettings({ + activeModels: [], + agentMode: { + enabled: true, + byok: {}, + mcpServers: [], + activeBackend: "opencode", + debugFullFrames: false, + skills: { folder: "copilot/skills" }, + backends: { + opencode: { binaryPath: "/x" }, + }, + }, + }); + const cfg = (await buildOpencodeConfig()) as { model?: string }; + expect(cfg.model).toBeUndefined(); + }); +}); + +describe("OpencodeBackend.buildSpawnDescriptor", () => { + beforeEach(() => { + resetSettings(); + clearActiveModels(); + }); + + it("throws if no binary is installed", async () => { + const backend = new OpencodeBackend(); + await expect(backend.buildSpawnDescriptor({ vaultBasePath: "/vault" })).rejects.toThrow( + /binary not installed/ + ); + }); + + it("uses agentMode.backends.opencode.binaryPath as command and passes cwd in args", async () => { + updateSetting("agentMode", { + enabled: true, + byok: {}, + mcpServers: [], + activeBackend: "opencode", + debugFullFrames: false, + skills: { folder: "copilot/skills" }, + backends: { + opencode: { + binaryPath: "/path/to/opencode", + binaryVersion: "1.3.17", + binarySource: "managed", + }, + }, + }); + updateSetting("anthropicApiKey", "anth-xyz"); + const backend = new OpencodeBackend(); + const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault/abs" }); + expect(desc.command).toBe("/path/to/opencode"); + expect(desc.args).toEqual(["acp", "--cwd", "/vault/abs"]); + expect(desc.env.OPENCODE_CONFIG_CONTENT).toBeDefined(); + const cfg = JSON.parse(desc.env.OPENCODE_CONFIG_CONTENT as string); + expect(cfg.provider.anthropic.options).toEqual({ apiKey: "anth-xyz" }); + }); +}); + +describe("selectCopilotPrompt", () => { + it("returns COPILOT_PROMPT_BASE for any model id (no per-provider variants yet)", () => { + expect(selectCopilotPrompt(undefined)).toBe(COPILOT_PROMPT_BASE); + expect(selectCopilotPrompt("copilot-plus-flash")).toBe(COPILOT_PROMPT_BASE); + expect(selectCopilotPrompt("copilot-plus/copilot-plus-flash")).toBe(COPILOT_PROMPT_BASE); + expect(selectCopilotPrompt("anthropic/claude-sonnet-4-6")).toBe(COPILOT_PROMPT_BASE); + expect(selectCopilotPrompt("google/gemini-2.5-flash")).toBe(COPILOT_PROMPT_BASE); + }); +}); + +describe("COPILOT_PROMPT_BASE", () => { + it("establishes Obsidian Copilot identity, not a CLI/coding agent", () => { + expect(COPILOT_PROMPT_BASE).toMatch(/Obsidian Copilot/); + expect(COPILOT_PROMPT_BASE).toMatch(/NOT a software-engineering agent or CLI coding tool/); + }); + + it("does not carry chat-mode-only baggage that misfires in tool-driven agents", () => { + // @vault and getCurrentTime/getTimeRangeMs are chat-mode injections that + // do not exist in opencode. YouTube auto-transcription is also chat-only. + expect(COPILOT_PROMPT_BASE).not.toMatch(/@vault/); + expect(COPILOT_PROMPT_BASE).not.toMatch(/getCurrentTime/); + expect(COPILOT_PROMPT_BASE).not.toMatch(/getTimeRangeMs/); + expect(COPILOT_PROMPT_BASE).not.toMatch(/YouTube/); + }); + + it("ports AGENT_LOOP_GUIDANCE behavior bullets", () => { + expect(COPILOT_PROMPT_BASE).toMatch(/NEVER search for the same/); + }); +}); + +describe("OPENCODE_PROVIDER_MAP", () => { + it("includes the eight BYOK-mapped providers plus Copilot Plus", () => { + expect(Object.keys(OPENCODE_PROVIDER_MAP).sort()).toEqual( + [ + ChatModelProviders.ANTHROPIC, + ChatModelProviders.COPILOT_PLUS, + ChatModelProviders.DEEPSEEK, + ChatModelProviders.GOOGLE, + ChatModelProviders.GROQ, + ChatModelProviders.MISTRAL, + ChatModelProviders.OPENAI, + ChatModelProviders.OPENROUTERAI, + ChatModelProviders.XAI, + ].sort() + ); + }); +}); diff --git a/src/agentMode/backends/opencode/OpencodeBackend.ts b/src/agentMode/backends/opencode/OpencodeBackend.ts new file mode 100644 index 00000000..af77355c --- /dev/null +++ b/src/agentMode/backends/opencode/OpencodeBackend.ts @@ -0,0 +1,315 @@ +import { BREVILABS_MODELS_BASE_URL, ChatModelProviders } from "@/constants"; +import { getDecryptedKey } from "@/encryptionService"; +import { logInfo, logWarn } from "@/logger"; +import { getSettings } from "@/settings/model"; +import { AcpBackend, AcpSpawnDescriptor } from "@/agentMode/acp/types"; +import type { CopilotMode } from "@/agentMode/session/types"; +import { + buildSkillCreationDirective, + composeDenyList, + DEFAULT_SKILLS_FOLDER, + getManagedSkills, + SkillManager, +} from "@/agentMode/skills"; +import { OpencodeBackendDescriptor } from "./descriptor"; +import { selectCopilotPrompt } from "./prompts"; + +/** + * Map from Copilot's `ChatModelProviders` enum value (as stored in + * `CustomModel.provider`) to OpenCode's provider id (as it appears in + * OpenCode's `availableModels` and config). Only providers in this map are + * routable through OpenCode; everything else (Azure, Bedrock, Ollama, + * LM Studio, GitHub Copilot, etc.) is filtered out of the picker. + * + * Copilot Plus is handled separately because it isn't a built-in OpenCode + * provider — we register it as a custom `@ai-sdk/openai-compatible` entry + * pointing at brevilabs and authed via the user's `plusLicenseKey`. + */ +export const OPENCODE_PROVIDER_MAP: Partial> = { + [ChatModelProviders.ANTHROPIC]: "anthropic", + [ChatModelProviders.OPENAI]: "openai", + [ChatModelProviders.GOOGLE]: "google", + [ChatModelProviders.GROQ]: "groq", + [ChatModelProviders.MISTRAL]: "mistral", + [ChatModelProviders.DEEPSEEK]: "deepseek", + [ChatModelProviders.OPENROUTERAI]: "openrouter", + [ChatModelProviders.XAI]: "xai", + [ChatModelProviders.COPILOT_PLUS]: "copilot-plus", +}; + +/** OpenCode provider id reserved for Copilot Plus's brevilabs proxy. */ +const COPILOT_PLUS_PROVIDER_ID = "copilot-plus"; + +/** + * Custom OpenCode agent id provisioned via `OPENCODE_CONFIG_CONTENT`. Maps + * to Copilot's canonical `default` mode (writes/exec allowed, but the user + * approves each request). The built-in `build` agent doesn't ask. + */ +export const OPENCODE_COPILOT_BUILD_AGENT_ID = "copilot-build"; + +/** OpenCode's built-in build agent id (full perms, no permission asks). */ +export const OPENCODE_BUILTIN_BUILD_AGENT_ID = "build"; + +/** + * Shared canonical→native agent-id mapping for OpenCode. Used both at spawn + * time (`buildOpencodeConfig` sets `default_agent`) and at runtime (the + * descriptor's `getModeMapping` for `session/set_config_option`). Keeping + * one source of truth so the spawn-time default and the runtime picker + * never disagree. Plan mode is intentionally absent — opencode's plan + * agent has no ACP-visible finalization tool, so we don't expose it. + */ +export const OPENCODE_CANONICAL_MODE_AGENT_IDS: Partial> = { + default: OPENCODE_COPILOT_BUILD_AGENT_ID, + auto: OPENCODE_BUILTIN_BUILD_AGENT_ID, +}; + +/** + * Spawns `opencode acp --cwd ` with `OPENCODE_CONFIG_CONTENT` + * containing decrypted BYOK keys pulled from the existing Copilot settings. + * + * Reuses Copilot's top-level `*ApiKey` fields so users don't have to re-enter + * them in an Agent Mode-specific settings panel. + */ +export class OpencodeBackend implements AcpBackend { + readonly id = "opencode" as const; + readonly displayName = "opencode"; + + async buildSpawnDescriptor(ctx: { vaultBasePath: string }): Promise { + const binaryPath = getSettings().agentMode?.backends?.opencode?.binaryPath; + if (!binaryPath) { + throw new Error( + "opencode binary not installed. Open Agent Mode settings and install it before starting a session." + ); + } + + const config = await buildOpencodeConfig(); + + return { + command: binaryPath, + args: ["acp", "--cwd", ctx.vaultBasePath], + env: { + ...process.env, + OPENCODE_CONFIG_CONTENT: JSON.stringify(config), + }, + }; + } +} + +/** + * Build the `OPENCODE_CONFIG_CONTENT` payload from current Copilot settings. + * + * - Per-provider `options.apiKey` is set for any BYOK key configured in + * Copilot, decrypted in-process. + * - Each enabled `activeModel` whose provider is in `OPENCODE_PROVIDER_MAP` + * is registered under `provider..models.` so OpenCode + * reports it in `NewSessionResponse.models.availableModels`. Built-in + * providers (anthropic, openai, …) carry their own models.dev snapshot + * so this is largely additive there; for the custom Copilot Plus + * provider — and for OpenRouter models the snapshot doesn't cover — + * the registration is what makes the model visible at all. The + * Agents-tab catalog modal then curates from opencode's reported + * `availableModels`. + * - The top-level `model` field carries the user's sticky preference so + * a fresh session boots with the right default, even before + * `unstable_setSessionModel` is called. + * + * Exported for unit tests. + */ +export async function buildOpencodeConfig(): Promise> { + const s = getSettings(); + + type Mapping = { providerId: string; settingsKey: keyof typeof s }; + const mappings: Mapping[] = [ + { providerId: "anthropic", settingsKey: "anthropicApiKey" }, + { providerId: "openai", settingsKey: "openAIApiKey" }, + { providerId: "google", settingsKey: "googleApiKey" }, + { providerId: "groq", settingsKey: "groqApiKey" }, + { providerId: "mistral", settingsKey: "mistralApiKey" }, + { providerId: "deepseek", settingsKey: "deepseekApiKey" }, + { providerId: "openrouter", settingsKey: "openRouterAiApiKey" }, + { providerId: "xai", settingsKey: "xaiApiKey" }, + ]; + + const decrypted = await Promise.all( + mappings.map(async (m) => { + const raw = s[m.settingsKey]; + if (typeof raw !== "string" || !raw) return null; + const apiKey = await getDecryptedKey(raw); + if (!apiKey) return null; + return { providerId: m.providerId, apiKey }; + }) + ); + + type ProviderConfig = { + npm?: string; + name?: string; + options?: { apiKey?: string; baseURL?: string; headers?: Record }; + models?: Record>; + }; + const provider: Record = {}; + for (const entry of decrypted) { + if (entry) provider[entry.providerId] = { options: { apiKey: entry.apiKey } }; + } + + // Copilot Plus speaks OpenAI's wire format but isn't a built-in OpenCode + // provider. Register it as a custom `@ai-sdk/openai-compatible` entry + // pointing at brevilabs and authed via the user's `plusLicenseKey`. + if (typeof s.plusLicenseKey === "string" && s.plusLicenseKey) { + const licenseKey = await getDecryptedKey(s.plusLicenseKey); + if (licenseKey) { + provider[COPILOT_PLUS_PROVIDER_ID] = { + npm: "@ai-sdk/openai-compatible", + name: "Copilot Plus", + options: { baseURL: BREVILABS_MODELS_BASE_URL, apiKey: licenseKey }, + }; + } + } + + // Register Copilot-configured models under their respective providers so + // OpenCode treats them as known when reporting `availableModels`. When the + // top-level provider key is absent, fall back to the per-model `apiKey` so + // models the user configured with a model-specific key still reach the + // agent. Without this fallback any such model would be silently dropped. + const injected: string[] = []; + for (const model of s.activeModels ?? []) { + if (!model.enabled) continue; + if (model.isEmbeddingModel) continue; + const providerId = OPENCODE_PROVIDER_MAP[model.provider as ChatModelProviders]; + if (!providerId) continue; + + if (!provider[providerId]) { + const perModel = model.apiKey ? await getDecryptedKey(model.apiKey) : null; + if (!perModel) { + logWarn( + `[AgentMode] skipping ${model.provider}/${model.name}: no API key (set the provider key in Copilot settings or on the model itself)` + ); + continue; + } + provider[providerId] = { options: { apiKey: perModel } }; + } + + if (!provider[providerId].models) provider[providerId].models = {}; + provider[providerId].models[model.name] = {}; + injected.push(`${providerId}/${model.name}`); + } + + if (injected.length > 0) { + logInfo( + `[AgentMode] injected ${injected.length} model(s) into opencode config: ${injected.join(", ")}` + ); + } else if (Object.keys(provider).length === 0) { + logInfo( + "[AgentMode] no BYOK keys found; opencode will rely on its own auth. Set provider keys in Copilot settings to use Agent Mode end-to-end." + ); + } + + const config: Record = { provider }; + + // Inject a managed `copilot-build` agent so the mode picker can offer the + // canonical "default" semantic — let the agent edit, but ask first. The + // built-in `build` agent never asks (used as our `auto` mode); it doesn't + // cover ask-before-write, hence the custom agent. + // + // Both agents also carry a Copilot-authored `prompt` that overrides + // opencode's provider-default prompt picker (`session/system.ts`). Without + // this override, Copilot Plus model names (e.g. `copilot-plus-flash`) miss + // every substring branch and fall through to the generic `default.txt` + // CLI-coding-agent prompt — wrong domain for an Obsidian vault assistant. + // opencode's `cfg.agent.` merge is field-wise, so adding `prompt` to + // the built-in `build` agent leaves its native permissions intact. + const basePrompt = selectCopilotPrompt( + s.agentMode?.backends?.opencode?.defaultModel?.baseModelId + ); + // Append the spawn-time skill-creation directive so agent-authored skills + // land in the canonical managed folder instead of `.opencode/skills/`. + // Folder is read live from settings on each spawn — see the Skills + // Management spec. + const skillsFolder = s.agentMode?.skills?.folder ?? DEFAULT_SKILLS_FOLDER; + const skillManagerReady = SkillManager.hasInstance(); + const skillsDirs = skillManagerReady + ? Object.values(SkillManager.getInstance().getAgentDirsProjectRel()) + : []; + const prompt = `${basePrompt}\n\n${buildSkillCreationDirective("opencode", skillsFolder, skillsDirs)}`; + config.agent = { + [OPENCODE_BUILTIN_BUILD_AGENT_ID]: { + prompt, + }, + [OPENCODE_COPILOT_BUILD_AGENT_ID]: { + mode: "primary", + permission: { bash: "ask", edit: "ask" }, + prompt, + }, + }; + + // Apply sticky model preference at spawn so the very first turn (before + // `unstable_setSessionModel` lands) uses the user's pick. The persisted + // shape is `{ baseModelId, effort }` where `baseModelId` is opencode's + // `/` form — append the effort suffix when present. + const defaultModel = s.agentMode?.backends?.opencode?.defaultModel; + if (defaultModel?.baseModelId) { + config.model = defaultModel.effort + ? `${defaultModel.baseModelId}/${defaultModel.effort}` + : defaultModel.baseModelId; + } + + // Apply sticky mode preference at spawn via OpenCode's `default_agent` so + // the first turn already runs in the user's chosen agent — closes the + // cold-start gap where the `mode` configOption isn't yet registered. + // Falls back to canonical `default` (ask-before-write `copilot-build`); + // otherwise OpenCode would land on its no-ask built-in `build` agent. + const selectedMode = s.agentMode?.backends?.opencode?.selectedMode ?? "default"; + config.default_agent = + OPENCODE_CANONICAL_MODE_AGENT_IDS[selectedMode] ?? OPENCODE_COPILOT_BUILD_AGENT_ID; + + // Synthesize deny rules for managed skills that OpenCode would + // cross-discover (via `.claude/skills/` and `.agents/skills/`) but are + // not enabled for OpenCode in their `metadata.copilot-enabled-agents`. + // Read SkillManager live at spawn time — same pattern as the + // skill-creation directive. If SkillManager isn't initialised yet (plugin + // still booting; OpenCode session spawned before the Skills tab has + // hydrated), fall back to an empty deny list — the next reconciliation + // pass + session restart closes the eventual-consistency window. + // + // Note: we intentionally do NOT set `OPENCODE_DISABLE_EXTERNAL_SKILLS` or + // `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS`. We want OpenCode to walk the + // cross-discovery paths so the per-name `permission.skill. = "deny"` + // entries below can take effect. + if (!skillManagerReady) { + // SkillManager initialises asynchronously from `main.ts onload`. If + // OpenCode spawns before that finishes, we ship an empty deny list + // for this session — the next OpenCode spawn (after SkillManager + // hydrates) gets the correct one. + logInfo( + "[AgentMode] SkillManager not yet initialised at OpenCode spawn — shipping empty deny list; next session will pick it up." + ); + } + const managedSkills = skillManagerReady ? getManagedSkills() : []; + const denyNames = composeDenyList( + managedSkills, + OpencodeBackendDescriptor.id, + OpencodeBackendDescriptor.crossDiscoveredAgents + ); + if (denyNames.length > 0) { + // Be additive: opencode's config schema allows `permission` as a + // top-level key with sub-fields (`skill`, `tool`, `write`, …). Preserve + // any existing `permission.*` settings the user may have provided + // through other surfaces. + const existingPermission = config.permission as Record | undefined; + const existingSkillMap = existingPermission?.skill as Record | undefined; + const mergedSkillMap: Record = { ...(existingSkillMap ?? {}) }; + for (const name of denyNames) { + // User-provided entries win — if the user explicitly allowed a skill + // we'd otherwise deny, respect their override. + if (!(name in mergedSkillMap)) mergedSkillMap[name] = "deny"; + } + config.permission = { + ...(existingPermission ?? {}), + skill: mergedSkillMap, + }; + logInfo( + `[AgentMode] opencode deny list: ${denyNames.length} cross-discovered skill(s) denied (${denyNames.join(", ")})` + ); + } + + return config; +} diff --git a/src/agentMode/backends/opencode/OpencodeBinaryManager.test.ts b/src/agentMode/backends/opencode/OpencodeBinaryManager.test.ts new file mode 100644 index 00000000..c5a17a32 --- /dev/null +++ b/src/agentMode/backends/opencode/OpencodeBinaryManager.test.ts @@ -0,0 +1,317 @@ +jest.mock("obsidian", () => ({ + // pickMatchingAsset is pure; FileSystemAdapter and requestUrl are + // referenced by the manager class but not by these tests. + FileSystemAdapter: class {}, + requestUrl: jest.fn(), +})); + +jest.mock("@/logger", () => ({ + logInfo: jest.fn(), + logWarn: jest.fn(), + logError: jest.fn(), +})); + +// In-memory settings store for the manager's getSettings/setSettings calls. +// Defined inside jest.mock so it's hoisted alongside the factory. +jest.mock("@/settings/model", () => { + type OpencodeSlice = { + binaryPath?: string; + binaryVersion?: string; + binarySource?: "managed" | "custom"; + }; + type AgentMode = { + enabled?: boolean; + activeBackend?: string; + backends?: { opencode?: OpencodeSlice }; + }; + type Store = { agentMode: AgentMode }; + let store: Store = { + agentMode: { backends: { opencode: {} } }, + }; + return { + __esModule: true, + __reset: (initial: OpencodeSlice = {}) => { + store = { agentMode: { backends: { opencode: { ...initial } } } }; + }, + __get: () => store.agentMode.backends?.opencode ?? {}, + getSettings: () => store, + setSettings: (settings: Partial | ((current: Store) => Partial)) => { + const partial = typeof settings === "function" ? settings(store) : settings; + store = { ...store, ...partial }; + }, + }; +}); + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + computeInstallState, + OpencodeBinaryManager, + parseVersionFromStdout, + pickMatchingAsset, + verifyOpencodeBinary, +} from "./OpencodeBinaryManager"; + +// Pull the mock helpers off the mocked module without TS complaints. +const settingsMock = jest.requireMock("@/settings/model"); + +// Minimal CopilotPlugin stand-in. Only `manifest.id` and `app.vault.adapter` +// are touched by the methods under test, and only via getDataDir() (which we +// don't exercise here). +const fakePlugin = { + app: { vault: { adapter: {} } }, + manifest: { id: "copilot-test" }, +} as never; + +describe("pickMatchingAsset", () => { + const release = { + tag_name: "v1.14.24", + assets: [ + { + name: "opencode-darwin-arm64.zip", + size: 100, + browser_download_url: "https://example.com/opencode-darwin-arm64.zip", + }, + { + name: "opencode-linux-x64.tar.gz", + size: 100, + browser_download_url: "https://example.com/opencode-linux-x64.tar.gz", + }, + { + name: "opencode-linux-x64-musl.tar.gz", + size: 100, + browser_download_url: "https://example.com/opencode-linux-x64-musl.tar.gz", + }, + { + name: "opencode-windows-x64.zip", + size: 100, + browser_download_url: "https://example.com/opencode-windows-x64.zip", + }, + ], + }; + + it("picks the first matching candidate stem", () => { + const asset = pickMatchingAsset(release, ["opencode-darwin-arm64"]); + expect(asset.name).toBe("opencode-darwin-arm64.zip"); + }); + + it("falls back to the next candidate when the preferred one is missing", () => { + const asset = pickMatchingAsset(release, [ + "opencode-linux-x64-musl-baseline", + "opencode-linux-x64-musl", + "opencode-linux-x64", + ]); + expect(asset.name).toBe("opencode-linux-x64-musl.tar.gz"); + }); + + it("strips .tar.gz before matching stems", () => { + const asset = pickMatchingAsset(release, ["opencode-linux-x64"]); + expect(asset.name).toBe("opencode-linux-x64.tar.gz"); + }); + + it("throws when no candidate matches", () => { + expect(() => pickMatchingAsset(release, ["opencode-windows-arm64"])).toThrow( + /No matching opencode release asset/ + ); + }); +}); + +describe("verifyOpencodeBinary", () => { + // We use the running Node binary as a stand-in for any executable that + // accepts `--version` and exits 0. This exercises the success path without + // requiring a real opencode binary on disk. + it("resolves when the binary returns 0 to --version", async () => { + const result = await verifyOpencodeBinary(process.execPath); + expect(result.stdout).toMatch(/^v\d+\./); + }); + + it("throws ENOENT-style error for a non-existent path", async () => { + await expect(verifyOpencodeBinary("/definitely/not/a/real/path/opencode")).rejects.toThrow( + /No file at/ + ); + }); +}); + +describe("computeInstallState", () => { + it("absent when no path is set", () => { + expect(computeInstallState({})).toEqual({ kind: "absent" }); + expect(computeInstallState(undefined)).toEqual({ kind: "absent" }); + }); + + it("absent when path is set but version is missing", () => { + // We no longer surface a path-only state — without a version we can't + // tell what binary the user is pointing at, so the manager forces + // install/setCustomBinaryPath to populate both fields together. + expect(computeInstallState({ binaryPath: "/p" })).toEqual({ kind: "absent" }); + }); + + it("installed (managed) when source is missing — legacy data defaults to managed", () => { + expect(computeInstallState({ binaryPath: "/p", binaryVersion: "1.2.3" })).toEqual({ + kind: "installed", + version: "1.2.3", + path: "/p", + source: "managed", + }); + }); + + it("installed with explicit source preserves the value", () => { + expect( + computeInstallState({ binaryPath: "/p", binaryVersion: "1.2.3", binarySource: "custom" }) + ).toEqual({ kind: "installed", version: "1.2.3", path: "/p", source: "custom" }); + expect( + computeInstallState({ binaryPath: "/p", binaryVersion: "1.2.3", binarySource: "managed" }) + ).toEqual({ kind: "installed", version: "1.2.3", path: "/p", source: "managed" }); + }); +}); + +describe("parseVersionFromStdout", () => { + it.each([ + ["1.14.24", "1.14.24"], + ["v1.14.24", "1.14.24"], + ["opencode 1.14.24", "1.14.24"], + ["opencode\nversion: 1.14.24\n", "1.14.24"], + ["1.14.24-rc.1", "1.14.24-rc.1"], + ["1.14.24+build.5", "1.14.24+build.5"], + ])("parses %j → %s", (input, expected) => { + expect(parseVersionFromStdout(input)).toBe(expected); + }); + + it("returns undefined when no semver-shaped token is present", () => { + expect(parseVersionFromStdout("not a version")).toBeUndefined(); + expect(parseVersionFromStdout("")).toBeUndefined(); + expect(parseVersionFromStdout("1.2")).toBeUndefined(); + }); +}); + +describe("OpencodeBinaryManager.refreshInstallState", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "opencode-mgr-")); + }); + + afterEach(async () => { + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + }); + + it("no-op for absent state", async () => { + settingsMock.__reset({}); + const mgr = new OpencodeBinaryManager(fakePlugin); + await mgr.refreshInstallState(); + expect(settingsMock.__get()).toEqual({}); + }); + + it("no-op for custom-source installs even if the file is missing", async () => { + // Custom paths are validated at config time and shouldn't be re-checked + // on every plugin load — a transient mount issue shouldn't wipe user state. + const ghost = path.join(tmpDir, "does-not-exist", "opencode"); + settingsMock.__reset({ + binaryPath: ghost, + binaryVersion: "1.14.24", + binarySource: "custom", + }); + const mgr = new OpencodeBinaryManager(fakePlugin); + await mgr.refreshInstallState(); + expect(settingsMock.__get()).toEqual({ + binaryPath: ghost, + binaryVersion: "1.14.24", + binarySource: "custom", + }); + }); + + it("clears settings when persisted managed binary is missing on disk", async () => { + const ghost = path.join(tmpDir, "does-not-exist", "opencode"); + settingsMock.__reset({ + binaryPath: ghost, + binaryVersion: "1.14.24", + binarySource: "managed", + }); + const mgr = new OpencodeBinaryManager(fakePlugin); + await mgr.refreshInstallState(); + expect(settingsMock.__get()).toEqual({ + binaryPath: undefined, + binaryVersion: undefined, + binarySource: undefined, + }); + }); + + it("leaves settings intact when persisted managed binary is present", async () => { + const realFile = path.join(tmpDir, "opencode"); + await fs.promises.writeFile(realFile, ""); + settingsMock.__reset({ + binaryPath: realFile, + binaryVersion: "1.14.24", + binarySource: "managed", + }); + const mgr = new OpencodeBinaryManager(fakePlugin); + await mgr.refreshInstallState(); + expect(settingsMock.__get()).toEqual({ + binaryPath: realFile, + binaryVersion: "1.14.24", + binarySource: "managed", + }); + }); +}); + +describe("OpencodeBinaryManager.setCustomBinaryPath", () => { + let tmpDir: string; + + beforeEach(async () => { + settingsMock.__reset({}); + tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "opencode-custom-")); + }); + + afterEach(async () => { + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + }); + + it("rejects when the file does not exist", async () => { + const mgr = new OpencodeBinaryManager(fakePlugin); + await expect(mgr.setCustomBinaryPath(path.join(tmpDir, "nope"))).rejects.toThrow(/No file at/); + expect(settingsMock.__get()).toEqual({}); + }); + + it("rejects when the path is a directory, not a file", async () => { + const mgr = new OpencodeBinaryManager(fakePlugin); + await expect(mgr.setCustomBinaryPath(tmpDir)).rejects.toThrow(/No file at/); + expect(settingsMock.__get()).toEqual({}); + }); + + it("rejects non-executable files on POSIX", async () => { + if (process.platform === "win32") return; // skip — XOK semantics differ on Windows + const file = path.join(tmpDir, "not-exec"); + await fs.promises.writeFile(file, ""); + await fs.promises.chmod(file, 0o644); + const mgr = new OpencodeBinaryManager(fakePlugin); + await expect(mgr.setCustomBinaryPath(file)).rejects.toThrow(/not executable/); + expect(settingsMock.__get()).toEqual({}); + }); + + it("clearing (null) wipes all binary fields and does not touch disk", async () => { + settingsMock.__reset({ + binaryPath: "/p", + binaryVersion: "1.0.0", + binarySource: "managed", + }); + const mgr = new OpencodeBinaryManager(fakePlugin); + await mgr.setCustomBinaryPath(null); + expect(settingsMock.__get()).toEqual({ + binaryPath: undefined, + binaryVersion: undefined, + binarySource: undefined, + }); + }); + + it("accepting a real binary captures version from --version and tags source as custom", async () => { + // Use the running node binary as a stand-in: it exists, is executable, + // and `--version` exits 0 — the same shape verifyOpencodeBinary expects. + // Node prints `v22.x.y`; the parser strips the `v` and gives us a semver. + const mgr = new OpencodeBinaryManager(fakePlugin); + await mgr.setCustomBinaryPath(process.execPath); + const stored = settingsMock.__get(); + expect(stored.binaryPath).toBe(process.execPath); + expect(stored.binarySource).toBe("custom"); + expect(stored.binaryVersion).toMatch(/^\d+\.\d+\.\d+/); + }); +}); diff --git a/src/agentMode/backends/opencode/OpencodeBinaryManager.ts b/src/agentMode/backends/opencode/OpencodeBinaryManager.ts new file mode 100644 index 00000000..238aeb91 --- /dev/null +++ b/src/agentMode/backends/opencode/OpencodeBinaryManager.ts @@ -0,0 +1,599 @@ +import { OPENCODE_PINNED_VERSION, OPENCODE_RELEASE_API_URL_TEMPLATE } from "@/constants"; +import { logError, logInfo, logWarn } from "@/logger"; +import type CopilotPlugin from "@/main"; +import { getSettings, setSettings, type OpencodeBackendSettings } from "@/settings/model"; +import { execFile, spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import * as fs from "node:fs"; +import * as https from "node:https"; +import { IncomingMessage } from "node:http"; +import { FileSystemAdapter, requestUrl } from "obsidian"; +import * as path from "node:path"; +import { promisify } from "node:util"; +import { renameWithRetry } from "@/agentMode/skills/renameWithRetry"; +import { expectedBinaryName, resolveOpencodeTarget } from "./platformResolver"; + +const execFileAsync = promisify(execFile); +const DOWNLOAD_INACTIVITY_TIMEOUT_MS = 30_000; +// Generous: first-run on Windows (Defender real-time scan) and macOS +// (Gatekeeper translocation) can add a few seconds before the binary responds. +const VERIFY_BINARY_TIMEOUT_MS = 8_000; + +export type ProgressEvent = + | { phase: "resolve"; message: string } + | { phase: "download"; received: number; total?: number; assetName: string } + | { phase: "extract"; message: string } + | { phase: "done"; version: string; path: string }; + +export interface InstallOptions { + onProgress?: (e: ProgressEvent) => void; + signal?: AbortSignal; + /** Override pinned version. Defaults to OPENCODE_PINNED_VERSION. */ + version?: string; +} + +export type InstallState = + | { kind: "absent" } + | { kind: "installed"; version: string; path: string; source: "managed" | "custom" }; + +interface GithubAsset { + name: string; + size: number; + browser_download_url: string; +} + +interface GithubRelease { + tag_name: string; + assets: GithubAsset[]; +} + +interface InstallManifest { + version: string; + assetName: string; + installedAt: string; +} + +export class AbortError extends Error { + constructor() { + super("Aborted"); + this.name = "AbortError"; + } +} + +export function pickMatchingAsset(release: GithubRelease, candidates: string[]): GithubAsset { + const ARCHIVE_EXTS = [".zip", ".tar.gz", ".tar.xz", ".tgz"]; + const stemOf = (name: string): string => { + for (const ext of ARCHIVE_EXTS) { + if (name.endsWith(ext)) return name.slice(0, -ext.length); + } + return name; + }; + + const byStem = new Map(); + for (const a of release.assets) { + byStem.set(stemOf(a.name), a); + } + + for (const stem of candidates) { + const asset = byStem.get(stem); + if (asset) return asset; + } + + throw new Error( + `No matching opencode release asset found. Tried: ${candidates.join(", ")}. ` + + `Available: ${release.assets.map((a) => a.name).join(", ")}` + ); +} + +/** Derive the install state from the persisted `agentMode.backends.opencode` slice. */ +export function computeInstallState(opencode: OpencodeBackendSettings | undefined): InstallState { + const s = opencode ?? {}; + if (s.binaryPath && s.binaryVersion) { + return { + kind: "installed", + version: s.binaryVersion, + path: s.binaryPath, + source: s.binarySource ?? "managed", + }; + } + return { kind: "absent" }; +} + +/** Read the OpenCode-specific settings slice from current settings. */ +export function readOpencodeSettings(): OpencodeBackendSettings { + return getSettings().agentMode?.backends?.opencode ?? {}; +} + +function updateOpencodeFields(partial: Partial): void { + setSettings((cur) => ({ + agentMode: { + ...cur.agentMode, + backends: { + ...cur.agentMode.backends, + opencode: { ...(cur.agentMode.backends?.opencode ?? {}), ...partial }, + }, + }, + })); +} + +function clearOpencodeBinary(): void { + updateOpencodeFields({ + binaryVersion: undefined, + binaryPath: undefined, + binarySource: undefined, + }); +} + +/** + * Manages the lifecycle of the opencode binary on disk: platform-aware + * download from GitHub releases, extraction into the plugin data dir, and + * persistence of the install location into `settings.agentMode`. Desktop-only. + */ +export class OpencodeBinaryManager { + constructor(private readonly plugin: CopilotPlugin) {} + + getInstallState(): InstallState { + return computeInstallState(readOpencodeSettings()); + } + + /** + * Reconcile persisted install state with what's actually on disk. If we + * believe a managed install exists but the binary is gone (user deleted it, + * restored a vault from backup, etc.), demote to `absent`. Skipped for + * custom-source installs — re-checking on every plugin load would punish + * users for transient filesystem hiccups (network mounts, etc.). + */ + async refreshInstallState(): Promise { + const state = this.getInstallState(); + if (state.kind !== "installed") return; + if (state.source !== "managed") return; + if (await fileExists(state.path)) return; + logWarn(`[AgentMode] persisted opencode binary missing at ${state.path}; clearing settings.`); + clearOpencodeBinary(); + } + + /** Absolute path to `/.obsidian/plugins//data/opencode`. */ + getDataDir(): string { + const adapter = this.plugin.app.vault.adapter; + if (!(adapter instanceof FileSystemAdapter)) { + throw new Error("Agent Mode requires desktop Obsidian (FileSystemAdapter)."); + } + const base = adapter.getBasePath(); + const id = this.plugin.manifest.id; + return path.join(base, this.plugin.app.vault.configDir, "plugins", id, "data", "opencode"); + } + + getPinnedVersion(): string { + return OPENCODE_PINNED_VERSION; + } + + /** + * Full install pipeline: resolve target → fetch release metadata → + * download → extract → atomic rename → persist settings. Idempotent when + * an existing install matches the pinned manifest. + */ + async install(opts: InstallOptions = {}): Promise<{ version: string; path: string }> { + const version = opts.version ?? OPENCODE_PINNED_VERSION; + const dataDir = this.getDataDir(); + const versionDir = path.join(dataDir, version); + + opts.onProgress?.({ phase: "resolve", message: "Resolving platform asset…" }); + const { target, candidates } = await resolveOpencodeTarget(); + this.throwIfAborted(opts.signal); + + const release = await this.fetchReleaseMetadata(version); + this.throwIfAborted(opts.signal); + + const asset = pickMatchingAsset(release, candidates); + + const binName = expectedBinaryName(target.platform); + const finalBinPath = path.join(versionDir, "bin", binName); + + // Idempotency: if the existing manifest matches and the binary is in place, no-op. + const existing = await readManifest(path.join(versionDir, "install-manifest.json")); + if (existing && existing.assetName === asset.name && (await fileExists(finalBinPath))) { + logInfo(`[AgentMode] opencode ${version} already installed at ${finalBinPath}`); + // Skip the write when settings already match — avoids spuriously waking + // every settings subscriber on healthy plugin loads. + const cur = readOpencodeSettings(); + if ( + cur.binaryVersion !== version || + cur.binaryPath !== finalBinPath || + cur.binarySource !== "managed" + ) { + updateOpencodeFields({ + binaryVersion: version, + binaryPath: finalBinPath, + binarySource: "managed", + }); + } + opts.onProgress?.({ phase: "done", version, path: finalBinPath }); + return { version, path: finalBinPath }; + } + + await fs.promises.mkdir(dataDir, { recursive: true }); + const tmpDir = path.join(dataDir, `.tmp-${version}-${randomBytes(4).toString("hex")}`); + await fs.promises.mkdir(tmpDir, { recursive: true }); + + try { + const archivePath = path.join(tmpDir, asset.name); + await downloadToFile(asset.browser_download_url, archivePath, asset.size, opts); + this.throwIfAborted(opts.signal); + + opts.onProgress?.({ phase: "extract", message: "Extracting archive…" }); + const extractDir = path.join(tmpDir, "extract"); + await fs.promises.mkdir(extractDir, { recursive: true }); + await extractArchive(archivePath, extractDir); + this.throwIfAborted(opts.signal); + + const extractedBin = await locateFile(extractDir, binName); + if (target.platform !== "windows") { + await fs.promises.chmod(extractedBin, 0o755); + } + + // Stage the final layout under tmpDir, then atomically rename into place. + const stageDir = path.join(tmpDir, "stage"); + const stageBinDir = path.join(stageDir, "bin"); + await fs.promises.mkdir(stageBinDir, { recursive: true }); + await fs.promises.rename(extractedBin, path.join(stageBinDir, binName)); + + const manifest: InstallManifest = { + version, + assetName: asset.name, + installedAt: new Date().toISOString(), + }; + await fs.promises.writeFile( + path.join(stageDir, "install-manifest.json"), + JSON.stringify(manifest, null, 2) + ); + + // Rename-aside-then-rename: move any existing versionDir out of the way, + // promote the staged dir into place, and only then delete the old one. + // If the second rename fails, we restore the original so the user keeps + // a working install instead of a half-deleted one. + let asideDir: string | null = null; + if (await fileExists(versionDir)) { + asideDir = `${versionDir}.old-${randomBytes(4).toString("hex")}`; + await renameWithRetry(versionDir, asideDir); + } + try { + await renameWithRetry(stageDir, versionDir); + } catch (e) { + if (asideDir) { + await renameWithRetry(asideDir, versionDir).catch((restoreErr) => + logError("[AgentMode] failed to restore previous opencode install", restoreErr) + ); + } + throw e; + } + if (asideDir) { + await removeDir(asideDir).catch((rmErr) => + logWarn(`[AgentMode] failed to remove ${asideDir}: ${rmErr}`) + ); + } + + // Smoke-test the installed binary. Catches corrupt extracts and + // platform/libc mismatches before the user hits them at ACP boot — + // failures here surface in the install Modal where a Retry is one click + // away. + await verifyOpencodeBinary(finalBinPath); + this.throwIfAborted(opts.signal); + + updateOpencodeFields({ + binaryVersion: version, + binaryPath: finalBinPath, + binarySource: "managed", + }); + opts.onProgress?.({ phase: "done", version, path: finalBinPath }); + logInfo(`[AgentMode] opencode ${version} installed at ${finalBinPath}`); + return { version, path: finalBinPath }; + } catch (err) { + logError("[AgentMode] opencode install failed", err); + throw err; + } finally { + await removeDir(tmpDir).catch(() => {}); + } + } + + /** + * Remove the active managed version dir and clear settings. For a custom + * binary this only clears settings — the binary on disk belongs to the user + * and we don't touch it. Other version dirs are kept either way. + */ + async uninstall(): Promise { + const s = readOpencodeSettings(); + if (s.binarySource === "managed" && s.binaryVersion) { + const versionDir = path.join(this.getDataDir(), s.binaryVersion); + await removeDir(versionDir).catch((e) => + logError(`[AgentMode] failed to remove ${versionDir}`, e) + ); + } + clearOpencodeBinary(); + } + + /** + * Point Agent Mode at a user-supplied opencode binary. Pass `null` to + * clear the override (without removing any managed install on disk). + * Performs filesystem checks plus a `--version` smoke test so misconfigured + * paths are caught at config time rather than later when ACP tries to boot. + */ + async setCustomBinaryPath(p: string | null): Promise { + if (p === null) { + clearOpencodeBinary(); + return; + } + const stat = await fs.promises.stat(p).catch(() => null); + if (!stat || !stat.isFile()) { + throw new Error(`No file at ${p}`); + } + if (process.platform !== "win32") { + try { + await fs.promises.access(p, fs.constants.X_OK); + } catch { + throw new Error(`${p} is not executable. chmod +x and try again.`); + } + } + const { stdout } = await verifyOpencodeBinary(p); + const version = parseVersionFromStdout(stdout); + if (!version) { + throw new Error( + `${p} --version output didn't include a version number. Is this an opencode binary?` + ); + } + updateOpencodeFields({ binaryVersion: version, binaryPath: p, binarySource: "custom" }); + } + + private async fetchReleaseMetadata(version: string): Promise { + const url = OPENCODE_RELEASE_API_URL_TEMPLATE.replace("{version}", version); + const res = await requestUrl({ + url, + method: "GET", + headers: { Accept: "application/vnd.github+json" }, + throw: false, + }); + if (res.status === 403) { + throw new Error( + "GitHub API rate-limited (60/hour for unauthenticated requests). Set GITHUB_TOKEN or retry later." + ); + } + if (res.status === 404) { + throw new Error(`opencode release v${version} not found on GitHub.`); + } + if (res.status < 200 || res.status >= 300) { + throw new Error(`GitHub release fetch failed with status ${res.status}`); + } + return res.json as GithubRelease; + } + + private throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) throw new AbortError(); + } +} + +// Tolerant of leading `v`, build metadata, etc. — keeps the parser working +// across opencode releases that decorate the version string differently. +export function parseVersionFromStdout(stdout: string): string | undefined { + const match = stdout.match(/\d+\.\d+\.\d+(?:[-+][\w.]+)?/); + return match?.[0]; +} + +async function fileExists(p: string): Promise { + try { + await fs.promises.access(p); + return true; + } catch { + return false; + } +} + +async function readManifest(p: string): Promise { + try { + const raw = await fs.promises.readFile(p, "utf-8"); + return JSON.parse(raw) as InstallManifest; + } catch { + return null; + } +} + +async function removeDir(p: string): Promise { + await fs.promises.rm(p, { recursive: true, force: true }); +} + +/** + * Issue a GET against `url`, following up to `maxRedirects` 3xx hops. Resolves + * with the response stream on a 2xx or rejects on any other terminal status. + * Honors the supplied AbortSignal at every step. + */ +function httpsGetWithRedirects( + url: string, + signal: AbortSignal | undefined, + maxRedirects = 5 +): Promise { + return new Promise((resolve, reject) => { + const request = (currentUrl: string, redirectsLeft: number): void => { + let onAbort: (() => void) | null = null; + const detachAbort = (): void => { + if (onAbort) signal?.removeEventListener("abort", onAbort); + onAbort = null; + }; + const req = https.get(currentUrl, (res) => { + const status = res.statusCode ?? 0; + if (status >= 300 && status < 400 && res.headers.location) { + detachAbort(); + if (redirectsLeft <= 0) { + res.resume(); + reject(new Error(`Too many redirects fetching ${url}`)); + return; + } + res.resume(); + const next = new URL(res.headers.location, currentUrl).toString(); + request(next, redirectsLeft - 1); + return; + } + if (status !== 200) { + detachAbort(); + res.resume(); + reject(new Error(`HTTP ${status} fetching ${currentUrl}`)); + return; + } + // Hand the response off without detaching: the consumer may still + // need to abort mid-stream. Cleanup happens on res 'close'/'end'. + res.on("close", detachAbort); + resolve(res); + }); + req.on("error", (e) => { + detachAbort(); + reject(e); + }); + if (signal) { + if (signal.aborted) { + req.destroy(new AbortError()); + } else { + onAbort = (): void => { + req.destroy(new AbortError()); + }; + signal.addEventListener("abort", onAbort, { once: true }); + } + } + }; + request(url, maxRedirects); + }); +} + +/** + * Stream a remote asset to `dest`, emitting progress events and aborting if + * no bytes arrive for `DOWNLOAD_INACTIVITY_TIMEOUT_MS`. Stalled connections + * surface a clear "download stalled" error instead of hanging forever. + */ +async function downloadToFile( + url: string, + dest: string, + expectedSize: number | undefined, + opts: InstallOptions +): Promise { + const assetName = path.basename(dest); + const res = await httpsGetWithRedirects(url, opts.signal); + const total = + expectedSize ?? + (res.headers["content-length"] ? Number(res.headers["content-length"]) : undefined); + + let received = 0; + let stalled = false; + let inactivityTimer: number | null = null; + const out = fs.createWriteStream(dest); + await new Promise((resolve, reject) => { + const clearInactivity = (): void => { + if (inactivityTimer) { + window.clearTimeout(inactivityTimer); + inactivityTimer = null; + } + }; + const armInactivity = (): void => { + clearInactivity(); + inactivityTimer = window.setTimeout(() => { + stalled = true; + res.destroy( + new Error( + `Download stalled — no bytes received for ${Math.round(DOWNLOAD_INACTIVITY_TIMEOUT_MS / 1000)}s. Check your network and retry.` + ) + ); + }, DOWNLOAD_INACTIVITY_TIMEOUT_MS); + }; + armInactivity(); + res.on("data", (chunk: Uint8Array) => { + received += chunk.length; + armInactivity(); + opts.onProgress?.({ phase: "download", received, total, assetName }); + }); + const fail = (e: Error): void => { + clearInactivity(); + reject(e); + }; + res.on("error", fail); + out.on("error", fail); + out.on("finish", () => { + clearInactivity(); + if (stalled) return; // already rejected via res.destroy() + resolve(); + }); + res.pipe(out); + }); +} + +/** + * Extract `archivePath` into `destDir` by shelling out to the system `tar` + * (bsdtar on Windows 10 1803+). Distinguishes "tar not found" from + * non-zero exits so the user gets actionable error text. + * + * Path-traversal note: both GNU tar and bsdtar strip leading `/` and refuse + * to follow `..` outside the extraction root by default, so a malicious + * archive cannot escape `destDir`. We rely on that default rather than + * re-implementing extraction in JS. + */ +async function extractArchive(archivePath: string, destDir: string): Promise { + // Cross-platform: macOS and Linux ship `tar`; Windows 10 1803+ ships `tar.exe` + // built in (`bsdtar`), which handles .zip / .tar.gz / .tar.xz transparently. + await new Promise((resolve, reject) => { + const proc = spawn("tar", ["-xf", archivePath, "-C", destDir], { + stdio: ["ignore", "pipe", "pipe"], + }); + let stderr = ""; + proc.stderr.on("data", (d: Uint8Array) => { + stderr += Buffer.from(d).toString(); + }); + proc.on("error", (e: NodeJS.ErrnoException) => { + if (e.code === "ENOENT") { + reject( + new Error( + "`tar` was not found on PATH. macOS/Linux ship it by default; on Windows you need 10 1803+ (which ships `tar.exe`/bsdtar) or to install bsdtar manually." + ) + ); + } else { + reject(new Error(`Failed to launch tar: ${e instanceof Error ? e.message : String(e)}`)); + } + }); + proc.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`tar exited with code ${code}: ${stderr.slice(0, 500)}`)); + }); + }); +} + +// BFS because the binary's depth inside the upstream archive varies across +// opencode releases — first match wins. +async function locateFile(root: string, name: string): Promise { + const queue: string[] = [root]; + while (queue.length > 0) { + const dir = queue.shift() as string; + const entries = await fs.promises.readdir(dir, { withFileTypes: true }); + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) queue.push(full); + else if (e.isFile() && e.name === name) return full; + } + } + throw new Error(`File "${name}" not found anywhere under ${root}`); +} + +export async function verifyOpencodeBinary(p: string): Promise<{ stdout: string }> { + try { + const { stdout } = await execFileAsync(p, ["--version"], { + timeout: VERIFY_BINARY_TIMEOUT_MS, + windowsHide: true, + }); + return { stdout: stdout.toString().trim() }; + } catch (e) { + const err = e as NodeJS.ErrnoException & { signal?: string; code?: string | number }; + if (err.code === "ENOENT") { + throw new Error(`No file at ${p}`); + } + if (err.signal === "SIGTERM") { + throw new Error( + `${p} did not respond to --version within ${VERIFY_BINARY_TIMEOUT_MS}ms. Is this an opencode binary?` + ); + } + throw new Error( + `${p} --version failed: ${err.message ?? String(err)}. Is this an opencode binary?` + ); + } +} diff --git a/src/agentMode/backends/opencode/OpencodeInstallModal.tsx b/src/agentMode/backends/opencode/OpencodeInstallModal.tsx new file mode 100644 index 00000000..fa235632 --- /dev/null +++ b/src/agentMode/backends/opencode/OpencodeInstallModal.tsx @@ -0,0 +1,209 @@ +import { ReactModal } from "@/components/modals/ReactModal"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { + AbortError, + InstallOptions, + ProgressEvent, +} from "@/agentMode/backends/opencode/OpencodeBinaryManager"; +import type { OpencodeBinaryManager } from "@/agentMode/backends/opencode/OpencodeBinaryManager"; +import { OPENCODE_PINNED_VERSION } from "@/constants"; +import { App } from "obsidian"; +import React from "react"; + +type ModalState = + | { kind: "confirm" } + | { kind: "running"; progress: ProgressEvent | null } + | { kind: "success"; version: string; path: string } + | { kind: "error"; message: string }; + +interface ContentProps { + manager: OpencodeBinaryManager; + hostPlatform: string; + hostArch: string; + pinnedVersion: string; + destinationDir: string; + onClose: () => void; +} + +const formatBytes = (bytes: number): string => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +}; + +const phaseLabel = (e: ProgressEvent | null): string => { + if (!e) return "Starting…"; + switch (e.phase) { + case "resolve": + return e.message; + case "download": + if (e.total) { + const pct = Math.floor((e.received / e.total) * 100); + return `Downloading ${e.assetName} — ${formatBytes(e.received)} / ${formatBytes(e.total)} (${pct}%)`; + } + return `Downloading ${e.assetName} — ${formatBytes(e.received)}`; + case "extract": + return e.message; + case "done": + return "Done"; + } +}; + +const phaseProgress = (e: ProgressEvent | null): number | undefined => { + if (!e) return undefined; + if (e.phase === "download" && e.total) { + return Math.min(100, Math.floor((e.received / e.total) * 100)); + } + if (e.phase === "extract") return 98; + if (e.phase === "done") return 100; + return undefined; +}; + +const OpencodeInstallContent: React.FC = ({ + manager, + hostPlatform, + hostArch, + pinnedVersion, + destinationDir, + onClose, +}) => { + const [state, setState] = React.useState({ kind: "confirm" }); + const abortRef = React.useRef(null); + + const startInstall = React.useCallback(() => { + const controller = new AbortController(); + abortRef.current = controller; + setState({ kind: "running", progress: null }); + + const opts: InstallOptions = { + signal: controller.signal, + onProgress: (e) => setState({ kind: "running", progress: e }), + }; + + manager + .install(opts) + .then(({ version, path }) => setState({ kind: "success", version, path })) + .catch((err: unknown) => { + if (err instanceof AbortError || (err as Error)?.name === "AbortError") { + // User cancelled — go back to confirm so they can retry. + setState({ kind: "confirm" }); + return; + } + const message = err instanceof Error ? err.message : String(err); + setState({ kind: "error", message }); + }); + }, [manager]); + + const cancelInstall = React.useCallback(() => { + abortRef.current?.abort(); + }, []); + + React.useEffect(() => { + return () => { + abortRef.current?.abort(); + }; + }, []); + + if (state.kind === "confirm") { + return ( +
+

+ opencode runs locally on your machine. The official binary will be downloaded from{" "} + github.com/sst/opencode/releases over HTTPS and smoke-tested with{" "} + --version before being activated. +

+
+
Platform
+
+ {hostPlatform}-{hostArch} +
+
Version
+
v{pinnedVersion} (pinned)
+
Destination
+
{destinationDir}
+
+
+ + +
+
+ ); + } + + if (state.kind === "running") { + const pct = phaseProgress(state.progress); + return ( +
+

{phaseLabel(state.progress)}

+ +
+ +
+
+ ); + } + + if (state.kind === "success") { + return ( +
+

+ opencode v{state.version} installed successfully. +

+

{state.path}

+
+ +
+
+ ); + } + + // error + return ( +
+

Install failed.

+
+        {state.message}
+      
+
+ + +
+
+ ); +}; + +export class OpencodeInstallModal extends ReactModal { + constructor( + app: App, + private readonly manager: OpencodeBinaryManager, + private readonly hostInfo: { platform: string; arch: string } + ) { + super(app, "Install opencode (BYOK Agent backend)"); + } + + protected renderContent(close: () => void): React.ReactElement { + return ( + + ); + } +} diff --git a/src/agentMode/backends/opencode/OpencodeSettingsPanel.tsx b/src/agentMode/backends/opencode/OpencodeSettingsPanel.tsx new file mode 100644 index 00000000..99558b94 --- /dev/null +++ b/src/agentMode/backends/opencode/OpencodeSettingsPanel.tsx @@ -0,0 +1,151 @@ +import { OpencodeInstallModal } from "@/agentMode/backends/opencode/OpencodeInstallModal"; +import { BinaryPathSetting } from "@/components/agent/BinaryPathSetting"; +import { ConfirmModal } from "@/components/modals/ConfirmModal"; +import { Button } from "@/components/ui/button"; +import { SettingItem } from "@/components/ui/setting-item"; +import { logError } from "@/logger"; +import type CopilotPlugin from "@/main"; +import { useSettingsValue } from "@/settings/model"; +import type { App } from "obsidian"; +import { Notice } from "obsidian"; +import React from "react"; +import { computeInstallState, type InstallState } from "./OpencodeBinaryManager"; +import { getOpencodeBinaryManager } from "./descriptor"; +import { mapNodeArch, mapNodePlatform } from "./platformResolver"; + +interface Props { + plugin: CopilotPlugin; + app: App; +} + +function renderStatusDescription(state: InstallState): React.ReactNode { + if (state.kind === "absent") { + return Setup required — opencode is not installed.; + } + return ( + <> +
+ Ready — opencode v{state.version} + {state.source === "custom" && (custom)} +
+
{state.path}
+ + ); +} + +/** + * OpenCode-specific settings panel. Lives in the OpenCode backend folder so + * the generic Agent Mode settings tab stays backend-agnostic — it just + * renders `descriptor.SettingsPanel`. + */ +export const OpencodeSettingsPanel: React.FC = ({ plugin, app }) => { + const settings = useSettingsValue(); + const manager = getOpencodeBinaryManager(plugin); + const [showAdvanced, setShowAdvanced] = React.useState(false); + + const installState = computeInstallState(settings.agentMode?.backends?.opencode); + + const openInstallModal = (): void => { + new OpencodeInstallModal(app, manager, { + platform: mapNodePlatform(process.platform) ?? process.platform, + arch: mapNodeArch(process.arch) ?? process.arch, + }).open(); + }; + + const handleUninstall = (): void => { + new ConfirmModal( + app, + async () => { + try { + await manager.uninstall(); + new Notice("opencode uninstalled."); + } catch (e) { + logError("[AgentMode] uninstall failed", e); + new Notice(`Uninstall failed: ${e instanceof Error ? e.message : String(e)}`); + } + }, + "Remove the installed opencode binary? Your BYOK keys and MCP config will be kept.", + "Uninstall opencode", + "Uninstall" + ).open(); + }; + + const onSaveCustomPath = React.useCallback( + async (path: string): Promise => { + try { + await manager.setCustomBinaryPath(path); + } catch (e) { + return e instanceof Error ? e.message : String(e); + } + new Notice("Custom opencode binary path saved."); + return null; + }, + [manager] + ); + + const clearCustomPath = async (): Promise => { + await manager.setCustomBinaryPath(null); + }; + + return ( + <> + +
+ {installState.kind === "absent" && ( + + )} + {installState.kind === "installed" && installState.source === "managed" && ( + <> + + + + )} + {installState.kind === "installed" && installState.source === "custom" && ( + + )} +
+
+ + {!(installState.kind === "installed" && installState.source === "custom") && ( +
+ + {showAdvanced && ( + + + + )} +
+ )} + + ); +}; diff --git a/src/agentMode/backends/opencode/descriptor.test.ts b/src/agentMode/backends/opencode/descriptor.test.ts new file mode 100644 index 00000000..cbaa75ed --- /dev/null +++ b/src/agentMode/backends/opencode/descriptor.test.ts @@ -0,0 +1,151 @@ +import { OpencodeBackendDescriptor } from "./descriptor"; + +jest.mock("@/logger", () => ({ + logInfo: jest.fn(), + logWarn: jest.fn(), + logError: jest.fn(), +})); + +describe("OpencodeBackendDescriptor.wire.decode", () => { + const decode = OpencodeBackendDescriptor.wire.decode; + + it("parses 2-segment ids as bare/default with provider mapped to Copilot", () => { + expect(decode("anthropic/claude-sonnet-4-5")).toEqual({ + selection: { baseModelId: "anthropic/claude-sonnet-4-5", effort: null }, + provider: "anthropic", + }); + }); + + it("parses 3-segment ids as variants when the suffix is a known effort", () => { + expect(decode("anthropic/claude-sonnet-4-5/medium")).toEqual({ + selection: { baseModelId: "anthropic/claude-sonnet-4-5", effort: "medium" }, + provider: "anthropic", + }); + expect(decode("openai/gpt-5/minimal")).toEqual({ + selection: { baseModelId: "openai/gpt-5", effort: "minimal" }, + provider: "openai", + }); + }); + + it("recognizes opencode's full effort vocabulary (none/minimal/low/medium/high/xhigh/max)", () => { + // Opencode advertises Anthropic models with `/max` and `/xhigh` and + // OpenRouter reasoning models with `/none`. Each must collapse onto + // its bare base. + for (const effort of ["none", "minimal", "low", "medium", "high", "xhigh", "max"]) { + expect(decode(`anthropic/claude-opus-4-7/${effort}`)).toEqual({ + selection: { baseModelId: "anthropic/claude-opus-4-7", effort }, + provider: "anthropic", + }); + } + }); + + it("returns no-effort representation for 3-segment ids whose suffix isn't a known effort", () => { + // OpenRouter-style 3-segment ids without an effort suffix — the + // trailing segment is part of the model name. The whole id is the + // baseModelId; provider is still attributed from the leading segment. + expect(decode("openrouter/anthropic/claude-sonnet-4-5")).toEqual({ + selection: { baseModelId: "openrouter/anthropic/claude-sonnet-4-5", effort: null }, + provider: "openrouterai", + }); + expect(decode("openrouter/anthropic/claude-3.5-haiku")).toEqual({ + selection: { baseModelId: "openrouter/anthropic/claude-3.5-haiku", effort: null }, + provider: "openrouterai", + }); + }); + + it("parses 4-segment umbrella ids as variants when the last segment is a known effort", () => { + // OpenRouter wraps native ids under `openrouter/`, so its variants + // are 4-segment: `openrouter///`. Without this + // case the picker would render seven duplicate rows per OpenRouter + // reasoning model. + expect(decode("openrouter/anthropic/claude-sonnet-4.5/high")).toEqual({ + selection: { baseModelId: "openrouter/anthropic/claude-sonnet-4.5", effort: "high" }, + provider: "openrouterai", + }); + expect(decode("openrouter/anthropic/claude-sonnet-4.5/none")).toEqual({ + selection: { baseModelId: "openrouter/anthropic/claude-sonnet-4.5", effort: "none" }, + provider: "openrouterai", + }); + expect(decode("openrouter/openai/gpt-5/xhigh")).toEqual({ + selection: { baseModelId: "openrouter/openai/gpt-5", effort: "xhigh" }, + provider: "openrouterai", + }); + // OpenRouter route variants like `:exacto` live inside the model + // segment — the effort suffix still attaches at the trailing slash. + expect(decode("openrouter/openai/gpt-oss-120b:exacto/none")).toEqual({ + selection: { baseModelId: "openrouter/openai/gpt-oss-120b:exacto", effort: "none" }, + provider: "openrouterai", + }); + }); + + it("returns no-effort representation for unparseable shapes (1 segment or unknown trailing segment)", () => { + // 1-segment ids have no provider segment to attribute. + expect(decode("just-a-name")).toEqual({ + selection: { baseModelId: "just-a-name", effort: null }, + provider: null, + }); + // 4+ segment ids whose trailing segment isn't a known effort fall + // through to a no-effort representation. The leading segment still + // attributes a provider when it maps. + expect(decode("anthropic/foo/bar/baz")).toEqual({ + selection: { baseModelId: "anthropic/foo/bar/baz", effort: null }, + provider: "anthropic", + }); + expect(decode("a/b/c/d")).toEqual({ + selection: { baseModelId: "a/b/c/d", effort: null }, + provider: null, + }); + }); +}); + +describe("OpencodeBackendDescriptor.wire.encode", () => { + const encode = OpencodeBackendDescriptor.wire.encode; + + it("returns the bare baseModelId when effort is null", () => { + expect(encode({ baseModelId: "anthropic/claude-sonnet-4-5", effort: null })).toBe( + "anthropic/claude-sonnet-4-5" + ); + }); + + it("appends the variant when effort is set", () => { + expect(encode({ baseModelId: "anthropic/claude-sonnet-4-5", effort: "high" })).toBe( + "anthropic/claude-sonnet-4-5/high" + ); + }); + + it("round-trips via wire.decode", () => { + const ids = [ + "anthropic/claude-sonnet-4-5", + "anthropic/claude-sonnet-4-5/low", + "openai/gpt-5/high", + "anthropic/claude-opus-4-7/max", + "openrouter/anthropic/claude-sonnet-4.5", + "openrouter/anthropic/claude-sonnet-4.5/none", + "openrouter/anthropic/claude-sonnet-4.5/high", + ]; + for (const id of ids) { + const decoded = OpencodeBackendDescriptor.wire.decode(id); + expect(encode(decoded.selection)).toBe(id); + } + }); +}); + +describe("OpencodeBackendDescriptor.isModelEnabledByDefault", () => { + const fn = OpencodeBackendDescriptor.isModelEnabledByDefault!; + + it("matches 'Big Pickle' in name", () => { + expect(fn({ modelId: "anthropic/foo", name: "Big Pickle" })).toBe(true); + expect(fn({ modelId: "anthropic/foo", name: "BIG PICKLE" })).toBe(true); + expect(fn({ modelId: "anthropic/foo", name: "big-pickle" })).toBe(true); + }); + + it("matches 'big-pickle' in modelId", () => { + expect(fn({ modelId: "openai/big-pickle", name: "Some Display" })).toBe(true); + expect(fn({ modelId: "openai/big_pickle", name: "Some Display" })).toBe(true); + }); + + it("returns false for unrelated models", () => { + expect(fn({ modelId: "anthropic/claude-sonnet-4-5", name: "Claude Sonnet 4.5" })).toBe(false); + expect(fn({ modelId: "openai/gpt-5", name: "GPT-5" })).toBe(false); + }); +}); diff --git a/src/agentMode/backends/opencode/descriptor.ts b/src/agentMode/backends/opencode/descriptor.ts new file mode 100644 index 00000000..cb762080 --- /dev/null +++ b/src/agentMode/backends/opencode/descriptor.ts @@ -0,0 +1,202 @@ +import { OpencodeInstallModal } from "@/agentMode/backends/opencode/OpencodeInstallModal"; +import OpencodeLogo from "@/agentMode/backends/opencode/logo.svg"; +import type CopilotPlugin from "@/main"; +import { + subscribeToSettingsChange, + updateAgentModeBackendFields, + type CopilotSettings, +} from "@/settings/model"; +import { + OPENCODE_CANONICAL_MODE_AGENT_IDS, + OpencodeBackend, + OPENCODE_PROVIDER_MAP, +} from "./OpencodeBackend"; +import { computeInstallState, OpencodeBinaryManager } from "./OpencodeBinaryManager"; +import { OpencodeSettingsPanel } from "./OpencodeSettingsPanel"; +import { mapNodeArch, mapNodePlatform } from "./platformResolver"; +import type { AgentSession } from "@/agentMode/session/AgentSession"; +import { applyPersistedMode } from "@/agentMode/session/applyPersistedMode"; +import { simpleBinaryBackendProcess } from "@/agentMode/backends/shared/simpleBinaryBackend"; +import type { + CopilotMode, + ModeMapping, + ModelSelection, + ModelWireCodec, +} from "@/agentMode/session/types"; +import type { BackendDescriptor, BackendProcess, InstallState } from "@/agentMode/session/types"; + +/** Config option id OpenCode uses to switch the active agent at runtime. */ +const OPENCODE_MODE_CONFIG_OPTION_ID = "mode"; + +// Lazy-created singleton manager. The first plugin to ask for it wins; in a +// running Obsidian instance there's exactly one CopilotPlugin so this is safe. +let managerRef: OpencodeBinaryManager | null = null; + +/** + * Effort suffixes opencode appends to model ids. Used to disambiguate + * genuine effort variants from ids whose trailing segment is part of + * the model name (e.g. `openrouter/anthropic/claude-3.5-haiku` — the + * last segment `claude-3.5-haiku` is the model, not an effort). + */ +const KNOWN_OPENCODE_EFFORTS = new Set([ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +]); + +/** + * Wire-format codec for Opencode. Native providers emit + * `/[/]` (3 segments with effort); umbrella + * providers like OpenRouter emit `//[/]` + * (4 segments with effort). The leading segment is always the opencode + * provider id, mapped onto a Copilot `ChatModelProviders` value via + * `OPENCODE_PROVIDER_MAP` for picker section grouping. We classify the + * trailing segment as effort iff it's in the known effort vocabulary — + * that gates out 3-seg umbrella ids whose last segment is part of the + * model name (e.g. `openrouter/anthropic/claude-3.5-haiku`). + */ +const opencodeWire: ModelWireCodec = { + encode: (selection: ModelSelection) => + selection.effort ? `${selection.baseModelId}/${selection.effort}` : selection.baseModelId, + decode: (wireId: string) => { + if (!wireId) return { selection: { baseModelId: wireId, effort: null }, provider: null }; + const segments = wireId.split("/"); + const provider = segments.length >= 2 ? opencodeProviderToCopilot(segments[0]) : null; + const last = segments[segments.length - 1]; + if (segments.length >= 3 && KNOWN_OPENCODE_EFFORTS.has(last)) { + return { + selection: { baseModelId: segments.slice(0, -1).join("/"), effort: last }, + provider, + }; + } + return { selection: { baseModelId: wireId, effort: null }, provider }; + }, +}; + +/** + * Resolve the lazy `OpencodeBinaryManager` instance owned by this descriptor. + * The plugin no longer holds a top-level reference — ownership lives next to + * the backend that uses it. + */ +export function getOpencodeBinaryManager(plugin: CopilotPlugin): OpencodeBinaryManager { + if (!managerRef) managerRef = new OpencodeBinaryManager(plugin); + return managerRef; +} + +/** + * Descriptor for the OpenCode backend. This is the contract `session/` and + * `ui/` consume — the rest of Agent Mode never imports `OpencodeBackend`, + * `OpencodeBinaryManager`, or `OpencodeInstallModal` directly. + */ +export const OpencodeBackendDescriptor: BackendDescriptor = { + id: "opencode", + displayName: "opencode", + Icon: OpencodeLogo, + skillsProjectDir: ".opencode/skills", + crossDiscoveredAgents: ["claude", "codex"], + restartOnManagedSkillsChange: true, + wire: opencodeWire, + + getInstallState(settings: CopilotSettings): InstallState { + const raw = computeInstallState(settings.agentMode?.backends?.opencode); + if (raw.kind === "absent") return { kind: "absent" }; + return { kind: "ready", source: raw.source }; + }, + + subscribeInstallState(_plugin: CopilotPlugin, cb: () => void): () => void { + return subscribeToSettingsChange((prev, next) => { + if (prev.agentMode?.backends?.opencode !== next.agentMode?.backends?.opencode) { + cb(); + } + }); + }, + + openInstallUI(plugin: CopilotPlugin): void { + new OpencodeInstallModal(plugin.app, getOpencodeBinaryManager(plugin), { + platform: mapNodePlatform(process.platform) ?? process.platform, + arch: mapNodeArch(process.arch) ?? process.arch, + }).open(); + }, + + async applySelection(session: AgentSession, selection: ModelSelection): Promise { + await session.setModel(opencodeWire.encode(selection)); + }, + + createBackendProcess(args): BackendProcess { + return simpleBinaryBackendProcess(args, new OpencodeBackend()); + }, + + SettingsPanel: OpencodeSettingsPanel, + + async onPluginLoad(plugin: CopilotPlugin): Promise { + await getOpencodeBinaryManager(plugin).refreshInstallState(); + }, + + isModelEnabledByDefault(model) { + // Default-enable only "Big Pickle"; users widen the catalog via the + // per-model toggles in the Agents tab. + const re = /big[\s_-]*pickle/i; + return re.test(model.name) || re.test(model.modelId); + }, + + getProbeSessionId(settings: CopilotSettings): string | undefined { + const id = settings.agentMode?.backends?.opencode?.probeSessionId; + return id && id.length > 0 ? id : undefined; + }, + + async persistProbeSessionId(sessionId: string, _plugin: CopilotPlugin): Promise { + updateAgentModeBackendFields("opencode", { probeSessionId: sessionId }); + }, + + /** + * OpenCode doesn't use ACP `availableModes` — its "modes" are agents, + * switched at runtime via `session/set_config_option` with `configId: + * "mode"`. The `copilot-build` agent is provisioned in the spawn-time + * config (see `OpencodeBackend.buildOpencodeConfig`); `build` is the + * OpenCode built-in we surface as canonical `auto`. Plan mode is not + * exposed for opencode (no ACP-visible plan finalization tool). + */ + getModeMapping(_modeState, configOptions): ModeMapping | null { + if (!configOptions) return null; + const opt = configOptions.find((o) => o.id === OPENCODE_MODE_CONFIG_OPTION_ID); + if (!opt) return null; + return { + kind: "configOption", + configId: OPENCODE_MODE_CONFIG_OPTION_ID, + canonical: { ...OPENCODE_CANONICAL_MODE_AGENT_IDS }, + }; + }, + + async persistModeSelection(value: CopilotMode, _plugin: CopilotPlugin): Promise { + updateAgentModeBackendFields("opencode", { selectedMode: value }); + }, + + /** + * Defense-in-depth replay of the persisted mode on a freshly created + * session. The primary path is `default_agent` baked into the spawn-time + * `OPENCODE_CONFIG_CONTENT` (see `OpencodeBackend.buildOpencodeConfig`), + * which guarantees the very first turn runs in the right agent. This + * runtime call only fires if the spawn-time default didn't take and the + * `mode` configOption is already registered. + */ + async applyInitialSessionConfig(session: AgentSession, settings: CopilotSettings): Promise { + const persistedMode = settings.agentMode?.backends?.opencode?.selectedMode ?? "default"; + await applyPersistedMode(session, persistedMode); + }, +}; + +/** + * Map an OpenCode provider id (the leading segment of a wire-form modelId) + * back to its Copilot `ChatModelProviders` value, or `null` for OpenCode- + * native providers that don't correspond to any Copilot provider. + */ +function opencodeProviderToCopilot(opencodeProviderId: string): string | null { + for (const [copilotProvider, oId] of Object.entries(OPENCODE_PROVIDER_MAP)) { + if (oId === opencodeProviderId) return copilotProvider; + } + return null; +} diff --git a/src/agentMode/backends/opencode/index.ts b/src/agentMode/backends/opencode/index.ts new file mode 100644 index 00000000..cfb6141d --- /dev/null +++ b/src/agentMode/backends/opencode/index.ts @@ -0,0 +1 @@ +export { OpencodeBackendDescriptor } from "./descriptor"; diff --git a/src/agentMode/backends/opencode/logo.svg b/src/agentMode/backends/opencode/logo.svg new file mode 100644 index 00000000..78339890 --- /dev/null +++ b/src/agentMode/backends/opencode/logo.svg @@ -0,0 +1 @@ + diff --git a/src/agentMode/backends/opencode/platformResolver.test.ts b/src/agentMode/backends/opencode/platformResolver.test.ts new file mode 100644 index 00000000..7a0029c4 --- /dev/null +++ b/src/agentMode/backends/opencode/platformResolver.test.ts @@ -0,0 +1,110 @@ +import { + buildAssetCandidates, + expectedBinaryName, + mapNodeArch, + mapNodePlatform, +} from "./platformResolver"; + +describe("buildAssetCandidates", () => { + it("darwin arm64 → single candidate", () => { + expect(buildAssetCandidates({ platform: "darwin", arch: "arm64" })).toEqual([ + "opencode-darwin-arm64", + ]); + }); + + it("darwin x64 with avx2 → single candidate (no baseline)", () => { + expect(buildAssetCandidates({ platform: "darwin", arch: "x64", hasAvx2: true })).toEqual([ + "opencode-darwin-x64", + ]); + }); + + it("darwin x64 without avx2 → baseline first, regular as fallback", () => { + expect(buildAssetCandidates({ platform: "darwin", arch: "x64", hasAvx2: false })).toEqual([ + "opencode-darwin-x64-baseline", + "opencode-darwin-x64", + ]); + }); + + it("linux x64 glibc with avx2 → single candidate", () => { + expect( + buildAssetCandidates({ platform: "linux", arch: "x64", libc: "glibc", hasAvx2: true }) + ).toEqual(["opencode-linux-x64"]); + }); + + it("linux x64 glibc without avx2 → baseline first, regular as fallback", () => { + expect( + buildAssetCandidates({ platform: "linux", arch: "x64", libc: "glibc", hasAvx2: false }) + ).toEqual(["opencode-linux-x64-baseline", "opencode-linux-x64"]); + }); + + it("linux x64 musl with avx2 → musl first, regular as fallback", () => { + expect( + buildAssetCandidates({ platform: "linux", arch: "x64", libc: "musl", hasAvx2: true }) + ).toEqual(["opencode-linux-x64-musl", "opencode-linux-x64"]); + }); + + it("linux x64 musl without avx2 → musl, baseline, regular", () => { + expect( + buildAssetCandidates({ platform: "linux", arch: "x64", libc: "musl", hasAvx2: false }) + ).toEqual(["opencode-linux-x64-musl", "opencode-linux-x64-baseline", "opencode-linux-x64"]); + }); + + it("linux arm64 → single candidate", () => { + expect(buildAssetCandidates({ platform: "linux", arch: "arm64" })).toEqual([ + "opencode-linux-arm64", + ]); + }); + + it("windows x64 → single candidate", () => { + expect(buildAssetCandidates({ platform: "windows", arch: "x64", hasAvx2: true })).toEqual([ + "opencode-windows-x64", + ]); + }); + + it("does not duplicate candidates", () => { + const candidates = buildAssetCandidates({ + platform: "linux", + arch: "arm64", + libc: "glibc", + }); + expect(candidates.length).toBe(new Set(candidates).size); + }); +}); + +describe("mapNodePlatform", () => { + it.each([ + ["darwin", "darwin"], + ["linux", "linux"], + ["win32", "windows"], + ])("%s → %s", (input, expected) => { + expect(mapNodePlatform(input as NodeJS.Platform)).toBe(expected); + }); + + it("returns undefined for unsupported platforms", () => { + expect(mapNodePlatform("freebsd")).toBeUndefined(); + }); +}); + +describe("mapNodeArch", () => { + it.each([ + ["x64", "x64"], + ["arm64", "arm64"], + ["arm", "arm"], + ])("%s → %s", (input, expected) => { + expect(mapNodeArch(input)).toBe(expected); + }); + + it("returns undefined for unsupported archs", () => { + expect(mapNodeArch("ia32")).toBeUndefined(); + }); +}); + +describe("expectedBinaryName", () => { + it("appends .exe on windows", () => { + expect(expectedBinaryName("windows")).toBe("opencode.exe"); + }); + it("plain on darwin/linux", () => { + expect(expectedBinaryName("darwin")).toBe("opencode"); + expect(expectedBinaryName("linux")).toBe("opencode"); + }); +}); diff --git a/src/agentMode/backends/opencode/platformResolver.ts b/src/agentMode/backends/opencode/platformResolver.ts new file mode 100644 index 00000000..c0733541 --- /dev/null +++ b/src/agentMode/backends/opencode/platformResolver.ts @@ -0,0 +1,140 @@ +import { execFile as execFileCb } from "node:child_process"; +import * as fs from "node:fs"; +import { promisify } from "node:util"; + +const execFile = promisify(execFileCb); + +export type OpencodePlatform = "darwin" | "linux" | "windows"; +export type OpencodeArch = "x64" | "arm64" | "arm"; +export type OpencodeLibc = "glibc" | "musl"; + +export interface AssetTarget { + platform: OpencodePlatform; + arch: OpencodeArch; + /** Only set on linux. */ + libc?: OpencodeLibc; + /** Only set on x64. `undefined` ⇒ assume modern (AVX2 present). */ + hasAvx2?: boolean; +} + +/** + * Build the prioritized list of opencode release asset stems (no extension) + * for the given target. The first match wins; later entries are fallbacks + * when the preferred variant is not published for a release. + * + * Mirrors the fallback order in opencode's own launcher script + * (`bin/opencode` in sst/opencode). + */ +export function buildAssetCandidates(target: AssetTarget): string[] { + const base = `opencode-${target.platform}-${target.arch}`; + const out: string[] = []; + + if (target.platform === "linux" && target.libc === "musl") { + out.push(`${base}-musl`); + } + + if (target.arch === "x64" && target.hasAvx2 === false) { + out.push(`${base}-baseline`); + } + + out.push(base); + + return [...new Set(out)]; +} + +export function mapNodePlatform(nodePlatform: NodeJS.Platform): OpencodePlatform | undefined { + if (nodePlatform === "darwin") return "darwin"; + if (nodePlatform === "linux") return "linux"; + if (nodePlatform === "win32") return "windows"; + return undefined; +} + +export function mapNodeArch(nodeArch: string): OpencodeArch | undefined { + if (nodeArch === "x64") return "x64"; + if (nodeArch === "arm64") return "arm64"; + if (nodeArch === "arm") return "arm"; + return undefined; +} + +/** + * Best-effort musl libc detection. Linux only; returns false on other OSes. + * Falls back to false if probes fail (glibc is the safer default). + */ +export async function detectMusl(): Promise { + if (process.platform !== "linux") return false; + try { + await fs.promises.access("/etc/alpine-release"); + return true; + } catch { + // not alpine; try ldd + } + try { + const { stdout, stderr } = await execFile("ldd", ["--version"]); + return /musl/i.test(`${stdout}\n${stderr}`); + } catch { + return false; + } +} + +/** + * Best-effort AVX2 detection on x64 hosts. Returns `true` when the probe + * fails — modern hardware is the safer default and the manager already + * falls back to the non-baseline asset if the baseline asset is missing. + */ +export async function detectAvx2(): Promise { + if (process.arch !== "x64") return false; + try { + if (process.platform === "darwin") { + const { stdout } = await execFile("sysctl", ["-n", "hw.optional.avx2_0"]); + return stdout.trim() === "1"; + } + if (process.platform === "linux") { + const cpuinfo = await fs.promises.readFile("/proc/cpuinfo", "utf-8"); + return /\bavx2\b/.test(cpuinfo); + } + if (process.platform === "win32") { + const { stdout } = await execFile("powershell.exe", [ + "-NoProfile", + "-Command", + "[System.Runtime.Intrinsics.X86.Avx2]::IsSupported", + ]); + return /true/i.test(stdout); + } + } catch { + // probe failed → assume modern + } + return true; +} + +export interface ResolvedTarget { + target: AssetTarget; + candidates: string[]; +} + +/** + * Resolve the current host's opencode asset target by probing the system, + * and return the prioritized asset-stem candidate list. + */ +export async function resolveOpencodeTarget(): Promise { + const platform = mapNodePlatform(process.platform); + const arch = mapNodeArch(process.arch); + if (!platform || !arch) { + throw new Error( + `Unsupported platform/arch: ${process.platform}/${process.arch}. Agent Mode requires darwin/linux/windows on x64/arm64.` + ); + } + const [muslIsh, avx2] = await Promise.all([ + platform === "linux" ? detectMusl() : Promise.resolve(false), + arch === "x64" ? detectAvx2() : Promise.resolve(undefined), + ]); + const libc: OpencodeLibc | undefined = + platform === "linux" ? (muslIsh ? "musl" : "glibc") : undefined; + const hasAvx2 = arch === "x64" ? avx2 : undefined; + const target: AssetTarget = { platform, arch, libc, hasAvx2 }; + return { target, candidates: buildAssetCandidates(target) }; +} + +/** Expected binary file name inside the extracted archive. */ +export function expectedBinaryName(platform: OpencodePlatform): string { + return platform === "windows" ? "opencode.exe" : "opencode"; +} diff --git a/src/agentMode/backends/opencode/prompts.ts b/src/agentMode/backends/opencode/prompts.ts new file mode 100644 index 00000000..b4a6550f --- /dev/null +++ b/src/agentMode/backends/opencode/prompts.ts @@ -0,0 +1,77 @@ +/** + * Copilot Agent Mode system prompts for opencode. + * + * Why this exists: opencode picks a provider-default system prompt by + * substring-matching `model.api.id` (see `opencode/packages/opencode/src/ + * session/system.ts:19-33`). For Copilot Plus models — registered under + * names like `copilot-plus-flash` — that match falls through every branch + * to `default.txt`, opencode's terse CLI-coding-agent prompt. The + * resulting "you are a CLI software engineering tool" framing is wrong + * for an Obsidian vault assistant and degrades behavior badly on smaller + * fast models like Gemini 2.5 Flash. + * + * The fix: inject our own prompt via `cfg.agent..prompt` at spawn + * time so opencode's substring picker is bypassed regardless of model. + * + * Content is curated, not invented. Two existing Copilot prompts cover + * most of what's needed: + * + * - `DEFAULT_SYSTEM_PROMPT` (`src/constants.ts`) — the chat-mode + * identity and formatting rules. Most rules port directly; chat-only + * hooks (`@vault`, `getCurrentTime`, YouTube auto-transcribe) are + * dropped because that infrastructure does not exist in opencode. + * - `AGENT_LOOP_GUIDANCE` (`src/LLMProviders/chainRunner/ + * AutonomousAgentChainRunner.ts`) — the in-process autonomous + * agent's loop bullets. Ported verbatim — the agent shape is the + * same. + */ + +export const COPILOT_PROMPT_BASE = `You are Obsidian Copilot, an AI assistant that helps users work with their Obsidian vault — markdown notes for knowledge management, writing, and research. You are NOT a software-engineering agent or CLI coding tool. The working directory is the user's Obsidian vault: a collection of markdown notes, not a code repository. Disregard any framing in environment metadata that suggests otherwise. + +## Grounding +- The user's vault contains markdown notes. When the user says "note", they mean an Obsidian note in this vault. +- When the user mentions "tags", they usually mean tags in Obsidian note properties. +- Never claim you do not have access to something. Rely on the user's provided context and the tools available to you. +- If you are unsure, say so and ask for more context — don't guess. +- Always respond in the language of the user's query. + +## Tool Behavior +- Prefer evidence from \`read\`, \`grep\`, and \`glob\` over assumption. Don't infer what a note contains from its title — read it. +- NEVER search for the same or very similar query twice. If results were insufficient, try substantially different terms. +- After 1-2 searches, synthesize an answer from the results you have. Do not keep searching unless the results are clearly insufficient. +- If you have enough information to answer, respond directly without calling any more tools. + +## Response Style +- Respond at length appropriate to note-taking and knowledge work. Do NOT default to 1-3 line CLI cadence — give the user enough context to understand and act on your answer. +- Be direct and concrete. Don't pad with preamble or postamble. + +## Markdown Formatting +- Use \`$...$\` for LaTeX equations, never \`\\[...\\]\` or \`\\(...\\)\`. +- For markdown lists, always use \`- \` (hyphen followed by exactly one space) for bullet points. Never use \`*\` for bullets. +- For tables, use GitHub-flavored markdown. +- When referring to an Obsidian note in your written reply, use \`[[title]]\` format (no backticks around it). To actually read or modify a note, call the \`read\` or \`edit\` tool — don't infer note contents from a wikilink title alone. +- For Obsidian-internal image links, use \`![[link]]\` format. For web image links, use \`![alt](url)\` format.`; + +/** + * Pick the Copilot system prompt for a given model. + * + * Today returns `COPILOT_PROMPT_BASE` for every model. The function exists + * so per-provider variants — for prompt-style differences across model + * families (Anthropic vs Gemini vs GPT, larger vs smaller) — can be added + * by branching on `modelApiId`, mirroring opencode's own substring picker + * at `session/system.ts:19-33` but with our own content. + * + * Limitation worth knowing for future per-provider work: opencode reads + * `cfg.agent..prompt` at spawn time, so the chosen prompt is fixed + * for the session lifetime. Per-model prompts that follow mid-session + * model switches would require either spawn-time recycling or an + * opencode `experimental.chat.system.transform` plugin. For v1 the + * `modelApiId` argument carries the user's sticky default at spawn. + */ +export function selectCopilotPrompt(modelApiId: string | undefined): string { + // Future per-provider branching goes here, e.g.: + // if (modelApiId?.includes("gemini")) return COPILOT_PROMPT_GEMINI; + // if (modelApiId?.includes("claude")) return COPILOT_PROMPT_ANTHROPIC; + void modelApiId; + return COPILOT_PROMPT_BASE; +} diff --git a/src/agentMode/backends/registry.test.ts b/src/agentMode/backends/registry.test.ts new file mode 100644 index 00000000..4375f3e2 --- /dev/null +++ b/src/agentMode/backends/registry.test.ts @@ -0,0 +1,42 @@ +import type { CopilotSettings } from "@/settings/model"; +import { getActiveBackendDescriptor, listBackendDescriptors } from "./registry"; +import { OpencodeBackendDescriptor } from "./opencode/descriptor"; + +jest.mock("@/agentMode/backends/opencode/OpencodeInstallModal", () => ({ + OpencodeInstallModal: class {}, +})); +jest.mock("@/components/modals/ConfirmModal", () => ({ ConfirmModal: class {} })); +jest.mock("@/components/ui/setting-item", () => ({ SettingItem: () => null })); +jest.mock("@/components/ui/button", () => ({ Button: () => null })); +jest.mock("@/components/ui/input", () => ({ Input: () => null })); +jest.mock("@/logger", () => ({ logInfo: jest.fn(), logWarn: jest.fn(), logError: jest.fn() })); +jest.mock("obsidian", () => ({ + Modal: class {}, + Notice: class {}, + Platform: { isMobile: false }, +})); + +describe("backendRegistry", () => { + const baseSettings = (activeBackend?: string): CopilotSettings => + ({ + agentMode: { + enabled: true, + byok: {}, + mcpServers: [], + activeBackend: activeBackend ?? "opencode", + backends: { opencode: {} }, + }, + }) as unknown as CopilotSettings; + + it("returns the OpenCode descriptor by default", () => { + expect(getActiveBackendDescriptor(baseSettings())).toBe(OpencodeBackendDescriptor); + }); + + it("falls back to OpenCode when an unknown backend is selected", () => { + expect(getActiveBackendDescriptor(baseSettings("nonexistent"))).toBe(OpencodeBackendDescriptor); + }); + + it("listBackendDescriptors includes OpenCode", () => { + expect(listBackendDescriptors()).toContain(OpencodeBackendDescriptor); + }); +}); diff --git a/src/agentMode/backends/registry.ts b/src/agentMode/backends/registry.ts new file mode 100644 index 00000000..8f3768b0 --- /dev/null +++ b/src/agentMode/backends/registry.ts @@ -0,0 +1,32 @@ +import type { CopilotSettings } from "@/settings/model"; +import { ClaudeBackendDescriptor } from "./claude"; +import { CodexBackendDescriptor } from "./codex/descriptor"; +import { OpencodeBackendDescriptor } from "./opencode/descriptor"; +import type { BackendDescriptor, BackendId } from "@/agentMode/session/types"; + +/** + * Registry of all known backends. Adding a new backend is exactly: + * + * 1. Implement `AcpBackend` and export a `BackendDescriptor` from + * `backends//`. + * 2. Add the entry below. + * 3. Persist the backend's per-id slice in `agentMode.backends.`. + * + * No edits to `acp/`, `session/`, or `ui/` should be required. + */ +export const backendRegistry: Record = { + opencode: OpencodeBackendDescriptor, + claude: ClaudeBackendDescriptor, + codex: CodexBackendDescriptor, +}; + +/** Resolve the active backend descriptor from settings. Falls back to `opencode`. */ +export function getActiveBackendDescriptor(settings: CopilotSettings): BackendDescriptor { + const id = settings.agentMode?.activeBackend ?? "opencode"; + return backendRegistry[id] ?? OpencodeBackendDescriptor; +} + +/** List all registered backend descriptors (e.g. for a backend picker). */ +export function listBackendDescriptors(): BackendDescriptor[] { + return Object.values(backendRegistry); +} diff --git a/src/agentMode/backends/shared/BinaryInstallContent.tsx b/src/agentMode/backends/shared/BinaryInstallContent.tsx new file mode 100644 index 00000000..7e7d9299 --- /dev/null +++ b/src/agentMode/backends/shared/BinaryInstallContent.tsx @@ -0,0 +1,106 @@ +import { ReactModal } from "@/components/modals/ReactModal"; +import { BinaryPathSetting } from "@/components/agent/BinaryPathSetting"; +import { Button } from "@/components/ui/button"; +import { logError } from "@/logger"; +import { validateExecutableFile } from "@/utils/detectBinary"; +import { App, Notice } from "obsidian"; +import React from "react"; + +export interface BinaryInstallContentProps { + /** Modal title (e.g. "Configure Claude Code (Agent backend)"). */ + modalTitle: string; + /** Lower-case display label for save toast (e.g. "Claude Code"). */ + binaryDisplayName: string; + /** Binary lookup name (e.g. "claude-agent-acp"). */ + binaryName: string; + /** Shell command to install the binary. */ + installCommand: string; + /** Placeholder shown in the binary-path input. */ + pathPlaceholder: string; + /** Initial path read from settings. */ + initialPath: string; + /** Body paragraph above the install command. */ + description: React.ReactNode; + /** Persist the validated path back to settings. */ + onPersist: (path: string) => void; + onClose: () => void; +} + +const BinaryInstallContent: React.FC = ({ + binaryDisplayName, + binaryName, + installCommand, + pathPlaceholder, + initialPath, + description, + onPersist, + onClose, +}) => { + const onSave = React.useCallback( + async (path: string): Promise => { + const err = await validateExecutableFile(path); + if (err) return err; + onPersist(path); + new Notice(`${binaryDisplayName} binary path saved.`); + onClose(); + return null; + }, + [binaryDisplayName, onPersist, onClose] + ); + + const copy = React.useCallback((): void => { + navigator.clipboard.writeText(installCommand).catch((e) => { + logError("[AgentMode] copy install command failed", e); + }); + new Notice("Copied to clipboard."); + }, [installCommand]); + + return ( +
+

{description}

+
+ Install command +
+ + {installCommand} + + +
+
+
+ Binary path + +
+
+ +
+
+ ); +}; + +/** + * Generic install modal for backends whose configuration is "point us at a + * binary." Backends provide identifying strings + a persist callback. + */ +export class BinaryInstallModal extends ReactModal { + constructor( + app: App, + private readonly props: Omit + ) { + super(app, props.modalTitle); + } + + protected renderContent(close: () => void): React.ReactElement { + return ; + } +} diff --git a/src/agentMode/backends/shared/SimpleBackendSettingsPanel.tsx b/src/agentMode/backends/shared/SimpleBackendSettingsPanel.tsx new file mode 100644 index 00000000..a7382938 --- /dev/null +++ b/src/agentMode/backends/shared/SimpleBackendSettingsPanel.tsx @@ -0,0 +1,107 @@ +import { BinaryPathSetting } from "@/components/agent/BinaryPathSetting"; +import { Button } from "@/components/ui/button"; +import { SettingItem } from "@/components/ui/setting-item"; +import { useSettingsValue } from "@/settings/model"; +import { validateExecutableFile } from "@/utils/detectBinary"; +import { Notice } from "obsidian"; +import React from "react"; + +export interface SimpleBackendSettingsPanelProps { + /** Lower-case display name (e.g. "Claude Code"). */ + displayName: string; + /** Binary lookup name (e.g. "claude-agent-acp"). */ + binaryName: string; + /** Shell command users can run to install the binary. */ + installCommand: string; + /** Placeholder for the binary-path input. */ + pathPlaceholder: string; + /** Title of the custom-path SettingItem. */ + customPathTitle: string; + /** Description of the custom-path SettingItem. */ + customPathDescription: string; + /** Read the configured binary path from current settings. */ + readStoredPath: () => string; + /** Persist `path` (or clear when `undefined`) back to settings. */ + persistPath: (path: string | undefined) => void; + /** Open the matching install modal. */ + openInstallModal: () => void; +} + +/** + * Settings panel for backends whose configuration is a single binary path + * (Claude Code, Codex). Manages the "Configure / Clear path" SettingItem + * plus a custom-path entry. + */ +export const SimpleBackendSettingsPanel: React.FC = ({ + displayName, + binaryName, + installCommand, + pathPlaceholder, + customPathTitle, + customPathDescription, + readStoredPath, + persistPath, + openInstallModal, +}) => { + // Re-render whenever settings change. + useSettingsValue(); + const stored = readStoredPath(); + + const onSave = React.useCallback( + async (path: string): Promise => { + const err = await validateExecutableFile(path); + if (err) return err; + persistPath(path); + new Notice(`${displayName} binary path saved.`); + return null; + }, + [displayName, persistPath] + ); + + const clear = React.useCallback((): void => { + persistPath(undefined); + }, [persistPath]); + + const description = stored ? ( + <> +
+ Ready — {binaryName} (custom path) +
+
{stored}
+ + ) : ( + + Setup required — {displayName} binary path not configured. + + ); + + return ( + <> + +
+ {!stored && ( + + )} + {stored && ( + + )} +
+
+ + + + + + ); +}; diff --git a/src/agentMode/backends/shared/simpleBinaryBackend.ts b/src/agentMode/backends/shared/simpleBinaryBackend.ts new file mode 100644 index 00000000..1af804eb --- /dev/null +++ b/src/agentMode/backends/shared/simpleBinaryBackend.ts @@ -0,0 +1,52 @@ +import type { App } from "obsidian"; +import type CopilotPlugin from "@/main"; +import { AcpBackendProcess } from "@/agentMode/acp/AcpBackendProcess"; +import type { AcpBackend, AcpSpawnDescriptor } from "@/agentMode/acp/types"; +import { augmentPathForNodeShebang } from "@/agentMode/acp/nodeShebangPath"; +import type { BackendDescriptor, BackendProcess, InstallState } from "@/agentMode/session/types"; + +/** + * Build a spawn descriptor for a backend whose only configuration is a + * user-provided binary path (no managed install, no extra args). Auth is + * inherited from the user's environment / login state — no API key + * injection. + */ +export function buildSimpleSpawnDescriptor( + binaryPath: string | undefined, + configErrorMessage: string +): AcpSpawnDescriptor { + if (!binaryPath) throw new Error(configErrorMessage); + return { + command: binaryPath, + args: [], + env: { + ...process.env, + PATH: augmentPathForNodeShebang(binaryPath, process.env.PATH), + }, + }; +} + +/** + * `InstallState` for the same shape: a binaryPath either is set + * (`ready/custom`) or it is not (`absent`). + */ +export function binaryPathInstallState(binaryPath: string | undefined): InstallState { + return binaryPath ? { kind: "ready", source: "custom" } : { kind: "absent" }; +} + +/** + * Wrap an `AcpBackend` in `AcpBackendProcess` to satisfy the descriptor's + * `createBackendProcess` factory. Centralizes the "ACP-track plumbing" so + * subprocess backends (codex, opencode) don't repeat the construction. + */ +export function simpleBinaryBackendProcess( + args: { + plugin: CopilotPlugin; + app: App; + clientVersion: string; + descriptor: BackendDescriptor; + }, + backend: AcpBackend +): BackendProcess { + return new AcpBackendProcess(args.app, backend, args.clientVersion, args.descriptor); +} diff --git a/src/agentMode/index.ts b/src/agentMode/index.ts new file mode 100644 index 00000000..3e964bc0 --- /dev/null +++ b/src/agentMode/index.ts @@ -0,0 +1,136 @@ +import type { App } from "obsidian"; +import type CopilotPlugin from "@/main"; +import { logError } from "@/logger"; +import { getSettings } from "@/settings/model"; +import { backendRegistry, listBackendDescriptors } from "./backends/registry"; +import { AgentChatPersistenceManager } from "./session/AgentChatPersistenceManager"; +import { AgentModelPreloader } from "./session/AgentModelPreloader"; +import { AgentSessionManager } from "./session/AgentSessionManager"; +import { SkillManager } from "./skills"; +import { createDefaultPermissionPrompter } from "./ui/permissionPrompter"; + +export { AGENT_CHAT_MODE } from "./session/AgentChatPersistenceManager"; +export { AgentModeChat } from "./ui/AgentModeChat"; +export { default as CopilotAgentView } from "./ui/CopilotAgentView"; +export { + useActiveBackendDescriptor, + useBackendInstallState, + useSessionBackendDescriptor, +} from "./ui/useBackendDescriptor"; +export { useAgentModelPicker } from "./ui/useAgentModelPicker"; +export type { AgentModelPickerOverride } from "./ui/useAgentModelPicker"; +export { useAgentModePicker } from "./ui/useAgentModePicker"; +export type { AgentModePickerOverride } from "./ui/useAgentModePicker"; +export type { AgentSessionManager } from "./session/AgentSessionManager"; +export type { AgentBrand, BackendDescriptor, BackendId, InstallState } from "./session/types"; +export { isAgentModelEnabled, writeAgentModelOverride } from "./session/modelEnable"; +export { getBackendModelOverrides } from "./session/backendSettingsAccess"; +export type { + BackendState, + CopilotMode, + EffortOption, + ModelEntry, + ModelSelection, + ModelState, +} from "./session/types"; +export type { StoredMcpServer, McpTransport } from "./session/mcpResolver"; +export { sanitizeStoredMcpServers } from "./session/mcpResolver"; +export { McpServersPanel } from "./ui/McpServersPanel"; +export { SelectedModelsList } from "./ui/SelectedModelsList"; +export { PlanPreviewView, PLAN_PREVIEW_VIEW_TYPE } from "./ui/PlanPreviewView"; +export type { PlanPreviewViewState } from "./ui/PlanPreviewView"; +export { getActiveBackendDescriptor, listBackendDescriptors } from "./backends/registry"; +export { frameSink as acpFrameSink } from "./session/debugSink"; +export { SkillManager, SkillsSettings, useManagedSkills } from "./skills"; +export type { Skill } from "./skills"; + +/** + * Collect each registered backend's project-relative skills directory into + * a `BackendId → path` map. The skills layer is forbidden by + * `boundaries/dependencies` from importing the registry, so this lives in + * the host-side barrel and is injected into `SkillManager.initialize`. + */ +function collectAgentSkillsDirsProjectRel(): Record { + const out: Record = {}; + for (const descriptor of listBackendDescriptors()) { + out[descriptor.id] = descriptor.skillsProjectDir; + } + return out; +} + +/** + * Single seam between the plugin host (`main.ts`) and Agent Mode. Initialises + * the SkillManager singleton, wires the default permission prompter into a + * fresh `AgentSessionManager`, kicks off every registered backend + * descriptor's load-time reconcile (e.g. clear stale managed install), and + * starts the model-catalog preload probes. The manager itself is + * backend-agnostic — backends are spawned lazily on first session creation. + * + * SkillManager must be initialised before the preload probes fire: the + * Claude descriptor's `getSkillCreationDirective` reads + * `SkillManager.getInstance()` synchronously inside `newSession()`, which + * the probe calls. Doing it in this function (rather than from `main.ts` + * via a separate call) keeps the dependency order obvious and prevents the + * preload race that would otherwise throw "called before initialize". + * + * `main.ts` calls this once on plugin load. To swap prompters, shut down + * the existing manager and call this again. + */ +export function createAgentSessionManager(app: App, plugin: CopilotPlugin): AgentSessionManager { + const skillManager = SkillManager.initialize(app, collectAgentSkillsDirsProjectRel()); + const preloader = new AgentModelPreloader(app, plugin, (id) => backendRegistry[id]); + const persistenceManager = new AgentChatPersistenceManager(app); + // Mutable ref breaks the construction cycle: the prompter needs the + // manager, but handlers only fire after a session exists, which can't + // happen before assignment below. + let managerRef: AgentSessionManager | null = null; + const prompter = createDefaultPermissionPrompter( + app, + (id) => managerRef?.getSessionByBackendId(id) ?? null + ); + const manager = new AgentSessionManager(app, plugin, { + permissionPrompter: prompter, + resolveDescriptor: (id) => backendRegistry[id], + modelPreloader: preloader, + persistenceManager, + }); + managerRef = manager; + // Skill-set changes restart the affected backend when its descriptor + // opts in via `restartOnManagedSkillsChange`, so native skill command + // caches stay fresh. + skillManager.subscribeToSkillSetChange((backendId) => { + const descriptor = backendRegistry[backendId]; + if (!descriptor?.restartOnManagedSkillsChange) return; + void manager + .restartBackend(backendId, "managed skills changed") + .catch((error) => + logError(`[Skills] Failed to refresh backend after skill change: ${backendId}`, error) + ); + }); + void skillManager.refresh().catch((error) => { + logError("[Skills] Initial discovery pass failed", error); + }); + // Non-blocking — plugin load should not wait on disk reconcile. + for (const descriptor of listBackendDescriptors()) { + descriptor + .onPluginLoad?.(plugin) + .catch((e) => logError(`[AgentMode] backend ${descriptor.id} onPluginLoad failed`, e)); + } + + const settings = getSettings(); + if (!settings.agentMode?.enabled) return manager; + const preloads: Promise[] = []; + for (const descriptor of listBackendDescriptors()) { + if (descriptor.getInstallState(settings).kind !== "ready") continue; + preloads.push( + manager + .preloadModels(descriptor.id) + .catch((e) => logError(`[AgentMode] preload ${descriptor.id} failed`, e)) + ); + } + // Aggregate so the chat UI can gate its first render until every + // backend's catalog has settled — the model picker should never flash + // empty before the cache populates. + manager.setPreloadPromise(Promise.allSettled(preloads).then(() => undefined)); + return manager; +} diff --git a/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts b/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts new file mode 100644 index 00000000..45565a69 --- /dev/null +++ b/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts @@ -0,0 +1,419 @@ +import type { ModelInfo, SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import type { BackendDescriptor, SessionEvent } from "@/agentMode/session/types"; + +const queryMock = jest.fn(); +const createSdkMcpServerMock = jest.fn((opts: unknown) => ({ type: "sdk", instance: opts })); + +jest.mock("@anthropic-ai/claude-agent-sdk", () => ({ + query: (...args: unknown[]) => queryMock(...args), + createSdkMcpServer: (opts: unknown) => createSdkMcpServerMock(opts), + tool: (name: string, description: string, inputSchema: unknown, handler: unknown) => ({ + name, + description, + inputSchema, + handler, + }), +})); + +const FAKE_CATALOG: ModelInfo[] = [ + { + value: "claude-fake-pro", + displayName: "Claude Fake Pro", + description: "test", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + }, + { + value: "claude-fake-mini", + displayName: "Claude Fake Mini", + description: "test", + supportsEffort: false, + }, +]; + +jest.mock("@/logger", () => ({ + logInfo: jest.fn(), + logWarn: jest.fn(), + logError: jest.fn(), +})); + +jest.mock("@/settings/model", () => ({ + getSettings: () => ({ agentMode: { debugFullFrames: false } }), +})); + +jest.mock("@/agentMode/session/debugSink", () => ({ + frameSink: { append: jest.fn() }, + formatPayload: () => "", +})); + +jest.mock("./effortOption", () => ({ + ...jest.requireActual("./effortOption"), + getCachedSdkCatalog: jest.fn(), +})); + +import { ClaudeSdkBackendProcess } from "./ClaudeSdkBackendProcess"; +import { getCachedSdkCatalog } from "./effortOption"; + +beforeEach(() => { + (getCachedSdkCatalog as jest.Mock).mockReturnValue(FAKE_CATALOG); +}); + +function fakeDescriptor(): BackendDescriptor { + return { + id: "claude", + displayName: "Claude", + wire: { + encode: (sel: { baseModelId: string; effort: string | null }) => sel.baseModelId, + decode: (id: string) => ({ + selection: { baseModelId: id, effort: null }, + provider: "anthropic", + }), + effortConfigFor: (baseModelId: string) => { + const m = FAKE_CATALOG.find((x) => x.value === baseModelId); + if (!m?.supportsEffort) return null; + const levels = m.supportedEffortLevels ?? []; + if (levels.length === 0) return null; + return { + id: "effort", + type: "select", + category: "thought_level", + name: "Effort", + currentValue: levels[0], + options: levels.map((v) => ({ value: v, name: v })), + }; + }, + }, + } as unknown as BackendDescriptor; +} + +function makeQuery(messages: SDKMessage[]) { + const iter = (async function* () { + for (const m of messages) yield m; + })(); + return Object.assign(iter, { + interrupt: jest.fn().mockResolvedValue(undefined), + setModel: jest.fn().mockResolvedValue(undefined), + setPermissionMode: jest.fn().mockResolvedValue(undefined), + }); +} + +function streamEvent(event: object): SDKMessage { + return { + type: "stream_event", + event, + parent_tool_use_id: null, + uuid: "uuid-x" as `${string}-${string}-${string}-${string}-${string}`, + session_id: "irrelevant", + } as SDKMessage; +} + +function resultMessage(): SDKMessage { + return { + type: "result", + subtype: "success", + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + num_turns: 1, + result: "ok", + stop_reason: "end_turn", + total_cost_usd: 0, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + usage: {} as any, + modelUsage: {}, + permission_denials: [], + uuid: "uuid-r" as `${string}-${string}-${string}-${string}-${string}`, + session_id: "irrelevant", + }; +} + +function getPromptQueryCalls(): unknown[][] { + return queryMock.mock.calls.filter((c) => { + const opts = (c[0] as { options?: { cwd?: unknown } } | undefined)?.options; + return opts?.cwd !== undefined; + }); +} + +describe("ClaudeSdkBackendProcess.prompt happy path", () => { + beforeEach(() => { + queryMock.mockReset(); + createSdkMcpServerMock.mockClear(); + }); + + it("translates SDK text deltas to agent_message_chunk and resolves with end_turn", async () => { + queryMock.mockImplementation(() => + makeQuery([ + streamEvent({ type: "message_start", message: {} }), + streamEvent({ + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "hello" }, + }), + resultMessage(), + ]) + ); + + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + }); + + const { sessionId, state } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + expect(sessionId).toBeTruthy(); + expect(state.model?.current.baseModelId).toBe("claude-fake-pro"); + + const events: SessionEvent[] = []; + proc.registerSessionHandler(sessionId, (e) => events.push(e)); + + const resp = await proc.prompt({ + sessionId, + prompt: [{ type: "text", text: "hi" }], + }); + expect(resp.stopReason).toBe("end_turn"); + + const chunks = events.filter((u) => u.update.sessionUpdate === "agent_message_chunk"); + expect(chunks).toHaveLength(1); + const chunk = chunks[0].update; + if (chunk.sessionUpdate === "agent_message_chunk" && chunk.content.type === "text") { + expect(chunk.content.text).toBe("hello"); + } else { + throw new Error("expected agent_message_chunk text update"); + } + + const promptCalls = getPromptQueryCalls(); + expect(promptCalls).toHaveLength(1); + const call = promptCalls[0][0] as { options: Record }; + expect(call.options.pathToClaudeCodeExecutable).toBe("/usr/local/bin/claude"); + expect(Object.keys(call.options.mcpServers as object)).not.toContain("obsidian-vault"); + expect(call.options.allowedTools).toEqual(["Read", "Write", "Edit", "Glob", "Grep", "LS"]); + expect(call.options.disallowedTools).toBeUndefined(); + // First turn → sessionId is seeded, no resume. + expect(call.options.sessionId).toBe(sessionId); + expect(call.options.resume).toBeUndefined(); + // No skill-creation directive opt passed → no systemPrompt override. + expect(call.options.systemPrompt).toBeUndefined(); + }); + + it("forwards the spawn-time skill-creation directive via systemPrompt append", async () => { + queryMock.mockImplementation(() => + makeQuery([streamEvent({ type: "message_start", message: {} }), resultMessage()]) + ); + + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + getSkillCreationDirective: () => "DO THIS THING WITH SKILLS", + }); + + const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); + + const calls = getPromptQueryCalls(); + const opts = (calls[0][0] as { options: Record }).options; + expect(opts.systemPrompt).toEqual({ + type: "preset", + preset: "claude_code", + append: "DO THIS THING WITH SKILLS", + }); + }); + + it("captures the directive at newSession time and ignores later setting changes mid-session", async () => { + queryMock.mockImplementation(() => + makeQuery([streamEvent({ type: "message_start", message: {} }), resultMessage()]) + ); + + let current = "FIRST DIRECTIVE"; + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + getSkillCreationDirective: () => current, + }); + + const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + // Mutate the "setting" after newSession → the session's first turn must + // still use the original directive, proving capture-at-spawn semantics. + current = "SECOND DIRECTIVE"; + await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); + + const opts = (getPromptQueryCalls()[0][0] as { options: Record }).options; + expect(opts.systemPrompt).toEqual({ + type: "preset", + preset: "claude_code", + append: "FIRST DIRECTIVE", + }); + }); + + it("buffers events emitted before a session handler is registered and replays them", async () => { + queryMock.mockImplementation(() => + makeQuery([ + streamEvent({ type: "message_start", message: {} }), + streamEvent({ + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "buffered" }, + }), + resultMessage(), + ]) + ); + + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + }); + + const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + // Kick off prompt without a handler — events are buffered. + const promptPromise = proc.prompt({ + sessionId, + prompt: [{ type: "text", text: "hi" }], + }); + + const seen: SessionEvent[] = []; + proc.registerSessionHandler(sessionId, (e) => seen.push(e)); + await promptPromise; + + const chunks = seen.filter((u) => u.update.sessionUpdate === "agent_message_chunk"); + expect(chunks.length).toBeGreaterThan(0); + }); + + it("passes resume on the second prompt for the same session", async () => { + queryMock.mockImplementation(() => makeQuery([resultMessage()])); + + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + }); + + const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + proc.registerSessionHandler(sessionId, () => {}); + + await proc.prompt({ sessionId, prompt: [{ type: "text", text: "1" }] }); + await proc.prompt({ sessionId, prompt: [{ type: "text", text: "2" }] }); + + const promptCalls = getPromptQueryCalls(); + expect(promptCalls).toHaveLength(2); + const second = promptCalls[1][0] as { options: Record }; + expect(second.options.resume).toBe(sessionId); + expect(second.options.sessionId).toBeUndefined(); + }); +}); + +describe("ClaudeSdkBackendProcess.newSession dynamic catalog", () => { + beforeEach(() => { + queryMock.mockReset(); + createSdkMcpServerMock.mockClear(); + }); + + it("returns BackendState with current model + effort options from the cached catalog", async () => { + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + }); + + const resp = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + expect(resp.state.model?.current.baseModelId).toBe("claude-fake-pro"); + const ids = resp.state.model?.availableModels.map((m) => m.baseModelId); + expect(ids).toContain("claude-fake-pro"); + expect(ids).toContain("claude-fake-mini"); + const proEffort = resp.state.model?.availableModels + .find((m) => m.baseModelId === "claude-fake-pro") + ?.effortOptions.map((o) => o.value); + expect(proEffort).toEqual(["low", "medium", "high"]); + }); + + it("honors persisted default model when it appears in the catalog", async () => { + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + getDefaultModelId: () => "claude-fake-mini", + }); + + const resp = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + expect(resp.state.model?.current.baseModelId).toBe("claude-fake-mini"); + const miniEffort = resp.state.model?.availableModels.find( + (m) => m.baseModelId === "claude-fake-mini" + )?.effortOptions; + expect(miniEffort).toEqual([]); + }); + + it("falls back to catalog default when the default model is gone", async () => { + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + getDefaultModelId: () => "claude-removed-by-cli-upgrade", + }); + + const resp = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + expect(resp.state.model?.current.baseModelId).toBe("claude-fake-pro"); + }); + + it("seeds session.model so prompt() sends options.model", async () => { + queryMock.mockImplementation(() => makeQuery([resultMessage()])); + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + }); + + const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + proc.registerSessionHandler(sessionId, () => {}); + await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); + + const promptCalls = getPromptQueryCalls(); + expect(promptCalls).toHaveLength(1); + const call = promptCalls[0][0] as { options: { model?: string } }; + expect(call.options.model).toBe("claude-fake-pro"); + }); + + it("setSessionConfigOption('effort', …) clamps + persists the level on the session", async () => { + queryMock.mockImplementation(() => makeQuery([resultMessage()])); + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + }); + + const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + proc.registerSessionHandler(sessionId, () => {}); + const stateAfter = await proc.setSessionConfigOption({ + sessionId, + configId: "effort", + value: "high", + }); + expect(stateAfter.model?.current.effort).toBe("high"); + + await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); + const promptCalls = getPromptQueryCalls(); + expect(promptCalls).toHaveLength(1); + const call = promptCalls[0][0] as { options: { effort?: string } }; + expect(call.options.effort).toBe("high"); + }); +}); diff --git a/src/agentMode/sdk/ClaudeSdkBackendProcess.ts b/src/agentMode/sdk/ClaudeSdkBackendProcess.ts new file mode 100644 index 00000000..8b65d419 --- /dev/null +++ b/src/agentMode/sdk/ClaudeSdkBackendProcess.ts @@ -0,0 +1,622 @@ +/** + * In-process driver for the Claude Agent SDK that implements `BackendProcess`, + * the same interface `AgentSession` consumes for ACP backends. Every SDK + * message is translated to a session-domain `SessionEvent` and dispatched to + * the per-session handler. From `AgentSession`'s perspective there's no + * difference between this adapter and `AcpBackendProcess`. + * + * Lifecycle differs from ACP: there's no long-lived subprocess. Each + * `prompt()` call starts a fresh `query()` (with `resume: ` after + * the first turn so the SDK loads prior conversation state). + */ +import { logError, logInfo, logWarn } from "@/logger"; +import { err2String } from "@/utils"; +import { + query, + type EffortLevel, + type McpServerConfig, + type ModelInfo, + type Options, + type PermissionMode, + type Query, + type SDKUserMessage, +} from "@anthropic-ai/claude-agent-sdk"; +import { App } from "obsidian"; +import { v4 as uuidv4 } from "uuid"; +import { translateBackendState } from "@/agentMode/session/translateBackendState"; +import type { + BackendConfigOption, + BackendDescriptor, + BackendProcess, + RawModelState, + RawModeState, + BackendState, + CancelInput, + ListSessionsInput, + ListSessionsOutput, + LoadSessionInput, + LoadSessionOutput, + McpServerSpec, + OpenSessionInput, + OpenSessionOutput, + PermissionDecision, + PermissionPrompt, + PromptInput, + PromptOutput, + ResumeSessionInput, + ResumeSessionOutput, + SessionEvent, + SessionId, + SessionUpdateHandler, + StopReason, +} from "@/agentMode/session/types"; +import { MethodUnsupportedError } from "@/agentMode/session/errors"; +import { createTranslatorState, mapStopReason, translateSdkMessage } from "./sdkMessageTranslator"; +import { PermissionBridge, type AskUserQuestionHandler } from "./permissionBridge"; +import { + getCachedSdkCatalog, + probeClaudeSdkCatalog, + resolveSeedModelId, + synthesizeEffortConfigOption, +} from "./effortOption"; +import { + describeSdkMessage, + logSdkError, + logSdkInbound, + logSdkOutbound, + logSdkOutboundResult, +} from "./sdkDebugTap"; + +interface SessionState { + cwd: string | null; + /** + * Drives whether the next `query()` passes `resume: ` (continue + * the persisted conversation) or `sessionId: ` (mint a new SDK-side + * session with our pre-allocated id). + */ + firstPromptStarted: boolean; + model?: string; + permissionMode?: PermissionMode; + /** + * Effort tier passed to `query()`'s `options.effort` on the next prompt. + * The vocabulary is per-model — the runtime catalog + * (`ModelInfo.supportedEffortLevels`) is the source of truth and is + * pulled via `ensureModelCatalog()`. + */ + effort?: EffortLevel; + mcpServers: Record; + active?: Query; + /** + * Snapshot of the skill-creation system-prompt directive captured at + * `newSession()` time so the setting change takes effect on the next + * session rather than mid-conversation. Empty string = no directive. + * Appended to Claude's default `claude_code` preset via + * `options.systemPrompt.append`. + */ + systemPromptAppend: string; +} + +export interface ClaudeSdkBackendProcessOptions { + pathToClaudeCodeExecutable: string; + app: App; + clientVersion: string; + descriptor: BackendDescriptor; + askUserQuestion?: AskUserQuestionHandler; + /** + * Read at the start of every `prompt()` so a settings change live-applies on + * the next turn. + */ + getEnableThinking?: () => boolean; + /** + * Predicate identifying plan-mode plan files (e.g. `~/.claude/plans/*.md`). + * When set, `Write` calls targeting these paths are auto-allowed via + * `canUseTool`; every other `Write` is routed through the permission + * prompter like any other tool. + */ + isPlanModePlanFilePath?: (absolutePath: string) => boolean; + /** + * Returns the user's persisted model preference. Read at session start + * to seed `session.model` from the live catalog (so the SDK uses what + * the picker shows, instead of falling back to its own internal default). + */ + getDefaultModelId?: () => string | undefined; + /** + * Returns the spawn-time skill-creation directive to append to Claude's + * default `claude_code` system prompt. Read once per `newSession()` so + * a settings change applies to the next session rather than mid-turn. + * Empty string / undefined disables the append. + */ + getSkillCreationDirective?: () => string | undefined; +} + +/** + * Static mode catalog for the Claude SDK. `acceptEdits` and `dontAsk` + * are intentionally excluded from the picker. + */ +const STATIC_SDK_MODES: RawModeState = { + currentModeId: "default", + availableModes: [ + { id: "default", name: "Default" }, + { id: "plan", name: "Plan" }, + { id: "bypassPermissions", name: "Auto" }, + ], +}; + +export class ClaudeSdkBackendProcess implements BackendProcess { + private readonly sessionHandlers = new Map(); + private readonly pendingUpdates = new Map(); + private static readonly PENDING_UPDATE_LIMIT = 32; + private readonly sessions = new Map(); + private permissionPrompter: ((req: PermissionPrompt) => Promise) | null = + null; + private exitListeners = new Set<() => void>(); + private shuttingDown = false; + private readonly bridge: PermissionBridge; + /** + * Process-scoped cache of the SDK's model catalog. Populated lazily by + * `ensureModelCatalog()` so we only spawn one extra `claude` subprocess + * per backend lifetime. + */ + private cachedModels: ModelInfo[] | null = null; + private cachedModelsProbe: Promise | null = null; + + constructor(private readonly opts: ClaudeSdkBackendProcessOptions) { + this.bridge = new PermissionBridge({ + getPrompter: () => this.permissionPrompter, + askUserQuestion: opts.askUserQuestion, + isPlanModePlanFilePath: opts.isPlanModePlanFilePath, + }); + logInfo( + `[AgentMode] ClaudeSdkBackendProcess constructed (claude=${opts.pathToClaudeCodeExecutable})` + ); + } + + isRunning(): boolean { + return !this.shuttingDown; + } + + onExit(listener: () => void): () => void { + this.exitListeners.add(listener); + return () => this.exitListeners.delete(listener); + } + + setPermissionPrompter(fn: (req: PermissionPrompt) => Promise): void { + this.permissionPrompter = fn; + } + + registerSessionHandler(sessionId: SessionId, handler: SessionUpdateHandler): () => void { + this.sessionHandlers.set(sessionId, handler); + const buffered = this.pendingUpdates.get(sessionId); + if (buffered) { + this.pendingUpdates.delete(sessionId); + for (const event of buffered) { + try { + handler(event); + } catch (e) { + logWarn(`[AgentMode] replay of buffered SDK event threw for ${sessionId}`, e); + } + } + } + return () => { + if (this.sessionHandlers.get(sessionId) === handler) { + this.sessionHandlers.delete(sessionId); + } + }; + } + + async newSession(params: OpenSessionInput): Promise { + logSdkOutbound("newSession", { cwd: params.cwd, mcpServers: params.mcpServers }); + const sessionId = uuidv4(); + const cwd = params.cwd ?? null; + const mcp: Record = {}; + for (const server of params.mcpServers ?? []) { + const cfg = mcpServerSpecToSdkConfig(server); + if (cfg) mcp[server.name] = cfg; + } + // Resolve the catalog before returning so the picker never sees an + // empty model list. On a probe miss, at most one subprocess is + // spawned (deduped via cachedModelsProbe). + const catalog = await this.ensureModelCatalog(); + const defaultId = this.opts.getDefaultModelId?.(); + const seedModelId = resolveSeedModelId(catalog, defaultId); + + this.sessions.set(sessionId, { + cwd, + firstPromptStarted: false, + mcpServers: mcp, + model: seedModelId, + systemPromptAppend: this.opts.getSkillCreationDirective?.() ?? "", + }); + + const state = this.computeState(sessionId); + logSdkOutboundResult( + "newSession", + { sessionId, currentModelId: seedModelId ?? null, hasEffort: state.model !== null }, + sessionId + ); + return { sessionId, state }; + } + + async prompt(params: PromptInput): Promise { + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Unknown session ${params.sessionId}`); + } + + // Streaming-input mode (AsyncIterable) is required to expose + // interrupt/setModel/setPermissionMode on the returned Query — without it + // those control calls reject with "only available in streaming input mode". + const promptText = extractPromptText(params); + const promptStream = makePromptStream(promptText, params.sessionId); + + this.bridge.setSessionContext(params.sessionId); + + const options: Options = { + pathToClaudeCodeExecutable: this.opts.pathToClaudeCodeExecutable, + cwd: session.cwd ?? undefined, + includePartialMessages: true, + mcpServers: session.mcpServers, + allowedTools: ["Read", "Write", "Edit", "Glob", "Grep", "LS"], + canUseTool: this.bridge.canUseTool, + }; + // Append the skill-creation directive (captured at newSession time) to + // Claude's default `claude_code` preset. The SDK's preset+append form + // preserves the full default system prompt; we only add the directive. + if (session.systemPromptAppend) { + options.systemPrompt = { + type: "preset", + preset: "claude_code", + append: session.systemPromptAppend, + }; + } + if (session.firstPromptStarted) { + options.resume = params.sessionId; + } else { + // First turn: tell the SDK to use *our* pre-allocated session id so + // future `resume` calls match. + options.sessionId = params.sessionId; + } + if (session.model) options.model = session.model; + if (session.permissionMode) options.permissionMode = session.permissionMode; + if (session.effort) options.effort = session.effort; + if (this.opts.getEnableThinking?.()) { + options.thinking = { type: "adaptive" }; + } + + logSdkOutbound( + "prompt", + { + prompt: promptText, + resume: options.resume ?? null, + sessionIdSeed: options.sessionId ?? null, + model: options.model ?? null, + permissionMode: options.permissionMode ?? null, + effort: options.effort ?? null, + mcpServers: Object.keys(options.mcpServers ?? {}), + allowedTools: options.allowedTools, + }, + params.sessionId + ); + + const q = query({ prompt: promptStream, options }); + session.active = q; + session.firstPromptStarted = true; + + const translatorState = createTranslatorState(); + let stopReason: StopReason = "end_turn"; + let resultErrorMessage: string | null = null; + try { + for await (const sdkMsg of q) { + if (this.shuttingDown) break; + logSdkInbound(describeSdkMessage(sdkMsg), sdkMsg, params.sessionId); + const events = translateSdkMessage(sdkMsg, params.sessionId, translatorState); + for (const e of events) this.dispatchEvent(e); + if (sdkMsg.type === "result") { + stopReason = mapStopReason(sdkMsg); + if (stopReason !== "end_turn" && sdkMsg.subtype !== "success") { + const errs = "errors" in sdkMsg ? sdkMsg.errors : undefined; + if (errs && errs.length > 0) { + resultErrorMessage = errs.join("; "); + } + } + break; + } + } + } finally { + if (session.active === q) session.active = undefined; + this.bridge.clearSessionContext(); + } + + if (resultErrorMessage) { + logSdkError("→", "prompt", { error: resultErrorMessage }, params.sessionId); + throw new Error(resultErrorMessage); + } + logSdkOutboundResult("prompt", { stopReason }, params.sessionId); + return { stopReason }; + } + + async cancel(params: CancelInput): Promise { + logSdkOutbound("cancel", {}, params.sessionId); + const session = this.sessions.get(params.sessionId); + if (!session?.active) return; + try { + await session.active.interrupt(); + } catch (e) { + logWarn("[AgentMode] SDK query.interrupt() threw", e); + logSdkError("→", "interrupt", { error: err2String(e) }, params.sessionId); + } + } + + async setSessionModel(params: { sessionId: SessionId; modelId: string }): Promise { + logSdkOutbound("setSessionModel", { modelId: params.modelId }, params.sessionId); + const session = this.sessions.get(params.sessionId); + if (!session) throw new Error(`Unknown session ${params.sessionId}`); + session.model = params.modelId; + if (this.cachedModels && session.effort) { + const info = this.cachedModels.find((m) => m.value === params.modelId); + const levels = info?.supportedEffortLevels ?? []; + if (!levels.includes(session.effort)) { + session.effort = levels[0]; + } + } + if (session.active) { + try { + await session.active.setModel(params.modelId); + } catch (e) { + logWarn("[AgentMode] SDK query.setModel() threw (will apply on next turn)", e); + logSdkError("→", "setModel", { error: err2String(e) }, params.sessionId); + } + } + const state = this.computeState(params.sessionId); + this.dispatchStateChanged(params.sessionId, state); + return state; + } + + isSetSessionModelSupported(): boolean | null { + return true; + } + + async setSessionMode(params: { sessionId: SessionId; modeId: string }): Promise { + logSdkOutbound("setSessionMode", { modeId: params.modeId }, params.sessionId); + const session = this.sessions.get(params.sessionId); + if (!session) throw new Error(`Unknown session ${params.sessionId}`); + const mode = canonicalModeToSdk(params.modeId); + if (!mode) { + throw new Error(`Unsupported mode ${params.modeId}`); + } + session.permissionMode = mode; + if (session.active) { + try { + await session.active.setPermissionMode(mode); + } catch (e) { + logWarn("[AgentMode] SDK query.setPermissionMode() threw (will apply on next turn)", e); + logSdkError("→", "setPermissionMode", { error: err2String(e) }, params.sessionId); + } + } + const state = this.computeState(params.sessionId); + this.dispatchStateChanged(params.sessionId, state); + return state; + } + + isSetSessionModeSupported(): boolean | null { + return true; + } + + /** + * Only `effort` is exposed as a session config option for this backend. + * We synthesize the option from the SDK's per-model + * `ModelInfo.supportedEffortLevels`, store the pick on the session, and + * apply it as `options.effort` on the next `query()` — the SDK has no + * runtime RPC for changing effort mid-turn. + */ + async setSessionConfigOption(params: { + sessionId: SessionId; + configId: string; + value: string; + }): Promise { + logSdkOutbound( + "setSessionConfigOption", + { configId: params.configId, value: params.value }, + params.sessionId + ); + if (params.configId !== "effort") { + throw new MethodUnsupportedError("session/set_config_option"); + } + const session = this.sessions.get(params.sessionId); + if (!session) throw new Error(`Unknown session ${params.sessionId}`); + const models = await this.ensureModelCatalog(); + const modelInfo = models.find((m) => m.value === session.model); + const levels = modelInfo?.supportedEffortLevels ?? []; + if (!levels.includes(params.value as EffortLevel)) { + throw new Error( + `Effort '${params.value}' not supported by ${session.model ?? "default model"}` + ); + } + session.effort = params.value as EffortLevel; + const state = this.computeState(params.sessionId); + this.dispatchStateChanged(params.sessionId, state); + return state; + } + + isSetSessionConfigOptionSupported(): boolean | null { + return true; + } + + async listSessions(_params: ListSessionsInput): Promise { + throw new MethodUnsupportedError("session/list"); + } + + async resumeSession(_params: ResumeSessionInput): Promise { + throw new MethodUnsupportedError("session/resume"); + } + + async loadSession(_params: LoadSessionInput): Promise { + throw new MethodUnsupportedError("session/load"); + } + + supportsMcpTransport(_transport: "http" | "sse"): boolean { + return true; + } + + async shutdown(): Promise { + this.shuttingDown = true; + for (const session of this.sessions.values()) { + const q = session.active; + if (!q) continue; + try { + await q.interrupt(); + } catch (e) { + logWarn("[AgentMode] interrupt during shutdown threw", e); + } + } + this.sessions.clear(); + this.sessionHandlers.clear(); + this.pendingUpdates.clear(); + for (const fn of this.exitListeners) { + try { + fn(); + } catch (e) { + logWarn("[AgentMode] SDK exit listener threw", e); + } + } + this.exitListeners.clear(); + } + + /** + * Resolve the SDK's model catalog. Falls back to an on-demand probe + * only when the shared cache is cold; at most one subprocess is + * spawned per backend lifetime (deduped via `cachedModelsProbe`). + * Failures resolve to `[]` so callers degrade gracefully. + */ + private ensureModelCatalog(): Promise { + if (this.cachedModels) return Promise.resolve(this.cachedModels); + const fromCache = getCachedSdkCatalog(); + if (fromCache && fromCache.length > 0) { + this.cachedModels = fromCache; + return Promise.resolve(fromCache); + } + if (this.cachedModelsProbe) return this.cachedModelsProbe; + const probePromise = probeClaudeSdkCatalog(this.opts.pathToClaudeCodeExecutable).then( + (models) => { + if (models.length > 0) this.cachedModels = models; + else this.cachedModelsProbe = null; + return models; + } + ); + this.cachedModelsProbe = probePromise; + return probePromise; + } + + private computeState(sessionId: SessionId): BackendState { + const session = this.sessions.get(sessionId); + const catalog = this.cachedModels ?? []; + const seedModel = session?.model; + const models: RawModelState | null = + catalog.length > 0 && seedModel + ? { + currentModelId: seedModel, + availableModels: catalog.map((m) => ({ modelId: m.value, name: m.displayName })), + } + : null; + const modes: RawModeState = { + ...STATIC_SDK_MODES, + currentModeId: session?.permissionMode ?? STATIC_SDK_MODES.currentModeId, + availableModes: [...STATIC_SDK_MODES.availableModes], + }; + const modelInfo = seedModel ? catalog.find((m) => m.value === seedModel) : undefined; + const effortOpt = synthesizeEffortConfigOption(modelInfo, session?.effort); + const configOptions: BackendConfigOption[] | null = effortOpt ? [effortOpt] : null; + return translateBackendState({ models, modes, configOptions }, this.opts.descriptor); + } + + private dispatchStateChanged(sessionId: SessionId, state: BackendState): void { + this.dispatchEvent({ + sessionId, + update: { sessionUpdate: "state_changed", state }, + }); + } + + private dispatchEvent(event: SessionEvent): void { + const handler = this.sessionHandlers.get(event.sessionId); + if (!handler) { + let queue = this.pendingUpdates.get(event.sessionId); + if (!queue) { + queue = []; + this.pendingUpdates.set(event.sessionId, queue); + } + if (queue.length >= ClaudeSdkBackendProcess.PENDING_UPDATE_LIMIT) { + const kind = event.update.sessionUpdate; + logWarn( + `[AgentMode] dropping SDK event for ${event.sessionId}: pending buffer full (${queue.length}, kind=${kind})` + ); + return; + } + queue.push(event); + return; + } + try { + handler(event); + } catch (e) { + logError(`[AgentMode] SDK event handler threw for ${event.sessionId}`, e); + } + } +} + +function extractPromptText(req: PromptInput): string { + const parts: string[] = []; + for (const block of req.prompt) { + if (block.type === "text" && block.text.length > 0) parts.push(block.text); + } + return parts.join("\n"); +} + +async function* makePromptStream( + text: string, + sessionId: SessionId +): AsyncIterable { + yield { + type: "user", + message: { role: "user", content: text }, + parent_tool_use_id: null, + session_id: sessionId, + }; +} + +function mcpServerSpecToSdkConfig(server: McpServerSpec): McpServerConfig | null { + if ("type" in server && server.type === "http") { + return { type: "http", url: server.url, headers: kvListToRecord(server.headers) }; + } + if ("type" in server && server.type === "sse") { + return { type: "sse", url: server.url, headers: kvListToRecord(server.headers) }; + } + if ("command" in server) { + return { + type: "stdio", + command: server.command, + args: server.args ?? [], + env: kvListToRecord(server.env), + }; + } + return null; +} + +function kvListToRecord( + list: Array<{ name: string; value: string }> | undefined +): Record | undefined { + if (!list || list.length === 0) return undefined; + const out: Record = {}; + for (const { name, value } of list) out[name] = value; + return out; +} + +function canonicalModeToSdk(modeId: string): PermissionMode | null { + switch (modeId) { + case "default": + case "acceptEdits": + case "bypassPermissions": + case "plan": + return modeId; + default: + return null; + } +} diff --git a/src/agentMode/sdk/effortOption.ts b/src/agentMode/sdk/effortOption.ts new file mode 100644 index 00000000..c7803b11 --- /dev/null +++ b/src/agentMode/sdk/effortOption.ts @@ -0,0 +1,105 @@ +import type { BackendConfigOption } from "@/agentMode/session/types"; +import { + query, + type EffortLevel, + type ModelInfo, + type SDKUserMessage, +} from "@anthropic-ai/claude-agent-sdk"; +import { logWarn } from "@/logger"; + +/** + * Build a single-select effort `BackendConfigOption` from a model's + * advertised `supportedEffortLevels`. Returns `null` when the model + * doesn't support effort or the SDK reports an empty list. + * + * The category is `"thought_level"` (the spec-conformant + * `SessionConfigOptionCategory` reserved name) so future ACP-aware UIs + * recognize it. + */ +export function synthesizeEffortConfigOption( + modelInfo: ModelInfo | undefined, + currentEffort: EffortLevel | undefined +): BackendConfigOption | null { + const levels = modelInfo?.supportsEffort ? (modelInfo.supportedEffortLevels ?? []) : []; + if (levels.length === 0) return null; + const value = currentEffort && levels.includes(currentEffort) ? currentEffort : levels[0]; + return { + id: "effort", + type: "select", + category: "thought_level", + name: "Effort", + currentValue: value, + options: levels.map((v) => ({ value: v, name: v })), + }; +} + +/** + * Pick the model id to seed a session with. Honors a persisted preference + * when it still appears in the live catalog (CLI revs can drop/rename + * models); falls back to the first catalog entry. Returns `undefined` when + * the catalog is empty — callers then send no `options.model` and the SDK + * uses its default. + */ +export function resolveSeedModelId( + catalog: ModelInfo[], + defaultId: string | undefined +): string | undefined { + if (defaultId && catalog.some((m) => m.value === defaultId)) return defaultId; + if (defaultId) { + logWarn( + `[AgentMode] persisted Claude model "${defaultId}" not in live catalog; falling back to default` + ); + } + return catalog[0]?.value; +} + +/** + * Plugin-lifetime cache of the SDK's model catalog, shared across every + * `ClaudeSdkBackendProcess` instance so opening a chat doesn't re-spawn + * the `claude` CLI to read the model list. + */ +let cachedSdkCatalog: ModelInfo[] | null = null; + +export function getCachedSdkCatalog(): ModelInfo[] | null { + return cachedSdkCatalog; +} + +/** + * Spawn a one-shot SDK `query()` solely to read its initialization + * handshake — which carries the catalog of models the bundled `claude` + * CLI advertises (per-model `supportsEffort` + `supportedEffortLevels`). + * + * The SDK requires streaming-input mode to expose `initializationResult()`, + * so we feed it a generator that never yields and tear the query down + * via `interrupt()` once the handshake completes. Failures resolve to + * an empty array (logged) so callers can degrade gracefully. + * + * Successful, non-empty probes update the module-level cache so a later + * `getCachedSdkCatalog()` returns hot data without re-probing. + */ +export async function probeClaudeSdkCatalog( + pathToClaudeCodeExecutable: string +): Promise { + // eslint-disable-next-line require-yield + const noopPrompt = (async function* (): AsyncIterable { + await new Promise(() => {}); + })(); + const probe = query({ + prompt: noopPrompt, + options: { pathToClaudeCodeExecutable }, + }); + try { + const init = await probe.initializationResult(); + if (init.models.length > 0) cachedSdkCatalog = init.models; + return init.models; + } catch (e) { + logWarn("[AgentMode] Claude SDK init probe failed", e); + return []; + } finally { + try { + await probe.interrupt(); + } catch { + // Probe is being torn down; swallow. + } + } +} diff --git a/src/agentMode/sdk/permissionBridge.test.ts b/src/agentMode/sdk/permissionBridge.test.ts new file mode 100644 index 00000000..e1db29d5 --- /dev/null +++ b/src/agentMode/sdk/permissionBridge.test.ts @@ -0,0 +1,279 @@ +import type { PermissionDecision, PermissionPrompt } from "@/agentMode/session/types"; +import { PermissionBridge } from "./permissionBridge"; + +describe("PermissionBridge.canUseTool", () => { + function makeBridge( + prompter: ((req: PermissionPrompt) => Promise) | null, + askUserQuestion?: ( + questions: Array<{ question: string; options: Array<{ label: string }> }> + ) => Promise<{ [q: string]: string }> + ) { + const bridge = new PermissionBridge({ + getPrompter: () => prompter, + + askUserQuestion: askUserQuestion, + }); + bridge.setSessionContext("session-1"); + return bridge; + } + + const ctx = { + signal: new AbortController().signal, + toolUseID: "toolu_test_id", + } as unknown as Parameters[2]; + + it("denies when no prompter is registered", async () => { + const bridge = new PermissionBridge({ getPrompter: () => null }); + bridge.setSessionContext("session-1"); + const result = await bridge.canUseTool("Edit", { file_path: "a.md" }, ctx); + expect(result.behavior).toBe("deny"); + }); + + it("synthesizes a PermissionPrompt with kind from toolName", async () => { + let captured: PermissionPrompt | null = null; + const bridge = makeBridge(async (req) => { + captured = req; + return { outcome: { outcome: "selected", optionId: "allow_once" } }; + }); + await bridge.canUseTool("Edit", { file_path: "a.md" }, ctx); + expect(captured).not.toBeNull(); + expect(captured!.toolCall.kind).toBe("edit"); + expect(captured!.toolCall.rawInput).toEqual({ file_path: "a.md" }); + expect(captured!.options.map((o) => o.kind)).toEqual([ + "allow_once", + "allow_always", + "reject_once", + "reject_always", + ]); + }); + + it("propagates ctx.toolUseID as PermissionPrompt.toolCall.toolCallId", async () => { + // The trail UI pairs each permission prompt with the corresponding + // `tool_call` notification by id. If the bridge mints a fresh uuid + // here instead of reusing the SDK's `tool_use_id`, the prompt and the + // notification disagree and the action card cannot be resolved. + let captured: PermissionPrompt | null = null; + const bridge = makeBridge(async (req) => { + captured = req; + return { outcome: { outcome: "selected", optionId: "reject_once" } }; + }); + const ctxWithToolUse = { + signal: new AbortController().signal, + toolUseID: "toolu_abc123", + } as unknown as Parameters[2]; + await bridge.canUseTool("Edit", { file_path: "a.md" }, ctxWithToolUse); + expect(captured).not.toBeNull(); + expect(captured!.toolCall.toolCallId).toBe("toolu_abc123"); + }); + + it("maps allow_once to allow with updatedInput echoing the original input", async () => { + const bridge = makeBridge(async () => ({ + outcome: { outcome: "selected", optionId: "allow_once" }, + })); + const result = await bridge.canUseTool("Bash", { command: "ls" }, ctx); + expect(result).toEqual({ behavior: "allow", updatedInput: { command: "ls" } }); + }); + + it("maps allow_always with suggestions to allow + updatedInput + updatedPermissions", async () => { + const bridge = makeBridge(async () => ({ + outcome: { outcome: "selected", optionId: "allow_always" }, + })); + const ctxWithSuggestions = { + signal: new AbortController().signal, + suggestions: [ + { + type: "addRules", + rules: [{ toolName: "Bash" }], + behavior: "allow", + destination: "session", + } as unknown, + ], + } as unknown as Parameters[2]; + const result = await bridge.canUseTool("Bash", { command: "ls" }, ctxWithSuggestions); + expect(result.behavior).toBe("allow"); + if (result.behavior === "allow") { + expect(result.updatedInput).toEqual({ command: "ls" }); + expect(result.updatedPermissions).toHaveLength(1); + } + }); + + it("maps reject_once to deny with a message", async () => { + const bridge = makeBridge(async () => ({ + outcome: { outcome: "selected", optionId: "reject_once" }, + })); + const result = await bridge.canUseTool("Bash", { command: "ls" }, ctx); + expect(result.behavior).toBe("deny"); + if (result.behavior === "deny") expect(result.message).toContain("declined"); + }); + + it("forwards decision.denyMessage as the deny message on reject", async () => { + const bridge = makeBridge(async () => ({ + outcome: { outcome: "selected", optionId: "reject_once" }, + denyMessage: "Please drop the second step and only do step 1.", + })); + const result = await bridge.canUseTool("ExitPlanMode", { plan: "# x" }, ctx); + expect(result.behavior).toBe("deny"); + if (result.behavior === "deny") { + expect(result.message).toBe("Please drop the second step and only do step 1."); + } + }); + + it("ignores denyMessage when the decision is allow", async () => { + const bridge = makeBridge(async () => ({ + outcome: { outcome: "selected", optionId: "allow_once" }, + denyMessage: "this should be ignored", + })); + const result = await bridge.canUseTool("Bash", { command: "ls" }, ctx); + expect(result.behavior).toBe("allow"); + }); + + it("maps cancelled outcome to deny", async () => { + const bridge = makeBridge(async () => ({ outcome: { outcome: "cancelled" } })); + const result = await bridge.canUseTool("Bash", {}, ctx); + expect(result.behavior).toBe("deny"); + }); + + it("routes AskUserQuestion to the dedicated handler with answers", async () => { + const handler = jest.fn(async () => ({ "What's your favorite color?": "Blue" })); + const bridge = makeBridge(null, handler); + const result = await bridge.canUseTool( + "AskUserQuestion", + { + questions: [{ question: "What's your favorite color?", options: [{ label: "Blue" }] }], + }, + ctx + ); + expect(handler).toHaveBeenCalled(); + expect(result.behavior).toBe("allow"); + if (result.behavior === "allow") { + expect(result.updatedInput).toMatchObject({ + answers: { "What's your favorite color?": "Blue" }, + }); + } + }); + + it("denies AskUserQuestion when no handler is configured", async () => { + const bridge = makeBridge(async () => ({ outcome: { outcome: "cancelled" } })); + const result = await bridge.canUseTool( + "AskUserQuestion", + { questions: [{ question: "Q", options: [{ label: "A" }] }] }, + ctx + ); + expect(result.behavior).toBe("deny"); + }); + + it("treats empty AskUserQuestion answers as cancelled", async () => { + const handler = jest.fn(async () => ({})); + const bridge = makeBridge(null, handler); + const result = await bridge.canUseTool( + "AskUserQuestion", + { questions: [{ question: "Q", options: [{ label: "A" }] }] }, + ctx + ); + expect(result.behavior).toBe("deny"); + }); + + describe("Write tool gating", () => { + function makeBridgeWithPlanMatcher( + isPlanModePlanFilePath: (p: string) => boolean, + prompter: ((req: PermissionPrompt) => Promise) | null = null + ) { + const bridge = new PermissionBridge({ + getPrompter: () => prompter, + isPlanModePlanFilePath, + }); + bridge.setSessionContext("session-1"); + return bridge; + } + + it("auto-allows Write when file_path matches the plan-mode predicate", async () => { + const prompter = jest.fn(); + const bridge = makeBridgeWithPlanMatcher( + (p) => p.endsWith("/.claude/plans/foo.md"), + prompter + ); + const result = await bridge.canUseTool( + "Write", + { file_path: "/Users/x/.claude/plans/foo.md", content: "# plan" }, + ctx + ); + expect(result.behavior).toBe("allow"); + if (result.behavior === "allow") { + expect(result.updatedInput).toEqual({ + file_path: "/Users/x/.claude/plans/foo.md", + content: "# plan", + }); + } + expect(prompter).not.toHaveBeenCalled(); + }); + + it("routes non-plan Write through the permission prompter", async () => { + let captured: PermissionPrompt | null = null; + const bridge = makeBridgeWithPlanMatcher( + () => false, + async (req) => { + captured = req; + return { outcome: { outcome: "selected", optionId: "allow_once" } }; + } + ); + const result = await bridge.canUseTool( + "Write", + { file_path: "/tmp/foo.md", content: "x" }, + ctx + ); + expect(captured).not.toBeNull(); + expect(captured!.toolCall.kind).toBe("edit"); + expect(captured!.toolCall.vendorToolName).toBe("Write"); + expect(result).toEqual({ + behavior: "allow", + updatedInput: { file_path: "/tmp/foo.md", content: "x" }, + }); + }); + + it("routes Write through the prompter even with no plan predicate configured", async () => { + const prompter = jest.fn(async () => ({ + outcome: { outcome: "selected" as const, optionId: "reject_once" as const }, + })); + const bridge = new PermissionBridge({ getPrompter: () => prompter }); + bridge.setSessionContext("session-1"); + const result = await bridge.canUseTool( + "Write", + { file_path: "/Users/x/.claude/plans/foo.md", content: "x" }, + ctx + ); + expect(prompter).toHaveBeenCalled(); + expect(result.behavior).toBe("deny"); + }); + }); + + describe("ExitPlanMode handling", () => { + it("synthesizes a prompt with switch_mode kind and isPlanProposal=true", async () => { + let captured: PermissionPrompt | null = null; + const bridge = makeBridge(async (req) => { + captured = req; + return { outcome: { outcome: "selected", optionId: "allow_once" } }; + }); + const ctxWithToolUse = { + signal: new AbortController().signal, + toolUseID: "toolu_plan_xyz", + } as unknown as Parameters[2]; + await bridge.canUseTool("ExitPlanMode", { plan: "# do thing" }, ctxWithToolUse); + expect(captured).not.toBeNull(); + expect(captured!.toolCall.kind).toBe("switch_mode"); + expect(captured!.toolCall.toolCallId).toBe("toolu_plan_xyz"); + expect(captured!.toolCall.rawInput).toEqual({ plan: "# do thing" }); + expect(captured!.toolCall.vendorToolName).toBe("ExitPlanMode"); + expect(captured!.toolCall.isPlanProposal).toBe(true); + }); + + it("does not set isPlanProposal for non-ExitPlanMode tools", async () => { + let captured: PermissionPrompt | null = null; + const bridge = makeBridge(async (req) => { + captured = req; + return { outcome: { outcome: "selected", optionId: "allow_once" } }; + }); + await bridge.canUseTool("Bash", { command: "ls" }, ctx); + expect(captured!.toolCall.isPlanProposal).toBeUndefined(); + }); + }); +}); diff --git a/src/agentMode/sdk/permissionBridge.ts b/src/agentMode/sdk/permissionBridge.ts new file mode 100644 index 00000000..7adfd7ba --- /dev/null +++ b/src/agentMode/sdk/permissionBridge.ts @@ -0,0 +1,206 @@ +/** + * Bridge between the Claude SDK's `canUseTool` callback and Agent Mode's + * session-domain permission prompter. Each `canUseTool` invocation is + * translated to a `PermissionPrompt`, dispatched through the prompter, then + * translated back to a SDK `PermissionResult`. AskUserQuestion gets a + * separate branch that opens a dedicated multi-choice modal. + */ +import type { + CanUseTool, + PermissionResult, + PermissionUpdate, +} from "@anthropic-ai/claude-agent-sdk"; +import type { + PermissionDecision, + PermissionOption, + PermissionOptionKind, + PermissionPrompt, + SessionId, +} from "@/agentMode/session/types"; +import { PERMISSION_OPTION_KINDS } from "@/agentMode/session/types"; +import { err2String } from "@/utils"; +import { logSdkInbound, logSdkOutbound } from "./sdkDebugTap"; +import { deriveToolKind, deriveToolTitle, vendorMetaFields } from "./toolMeta"; + +export type Prompter = (req: PermissionPrompt) => Promise; + +export type AskUserQuestionHandler = ( + questions: AskUserQuestionInput["questions"] +) => Promise<{ [questionText: string]: string }>; + +export interface AskUserQuestionInput { + questions: Array<{ + question: string; + header?: string; + options: Array<{ label: string; description?: string }>; + multiSelect?: boolean; + }>; +} + +export interface PermissionBridgeOptions { + getPrompter: () => Prompter | null; + askUserQuestion?: AskUserQuestionHandler; + /** + * Predicate identifying plan-mode plan files. When provided, the bridge + * auto-allows `Write` calls whose `file_path` satisfies the predicate so + * plan mode can finalize its proposal at `~/.claude/plans/*.md` without + * a prompt. Every other `Write` is routed through the permission + * prompter like any other tool. + */ + isPlanModePlanFilePath?: (absolutePath: string) => boolean; +} + +export class PermissionBridge { + constructor(private readonly opts: PermissionBridgeOptions) {} + + /** + * Single-field rather than keyed-by-toolCallId because each backend has + * exactly one in-flight `query()` at a time. If we ever support concurrent + * prompts on the same backend instance, key this by toolCallId. + */ + private currentSessionId: SessionId | null = null; + + setSessionContext(sessionId: SessionId): void { + this.currentSessionId = sessionId; + } + + clearSessionContext(): void { + this.currentSessionId = null; + } + + canUseTool: CanUseTool = async (toolName, input, ctx) => { + if (toolName === "AskUserQuestion") { + return this.handleAskUserQuestion(input as unknown as AskUserQuestionInput); + } + + const sessionId = this.currentSessionId; + logSdkInbound( + `canUseTool:request`, + { toolName, input, suggestions: ctx.suggestions }, + sessionId + ); + + if (toolName === "Write") { + const filePath = typeof input.file_path === "string" ? input.file_path : null; + if (filePath && this.opts.isPlanModePlanFilePath?.(filePath)) { + const result: PermissionResult = { behavior: "allow", updatedInput: input }; + logSdkOutbound("canUseTool:response:auto-allow-plan", result, sessionId); + return result; + } + } + + const prompter = this.opts.getPrompter(); + if (!prompter) { + return this.deny("canUseTool:response", "No permission prompter available", sessionId); + } + if (!sessionId) { + return this.deny("canUseTool:response", "Permission requested outside a session", sessionId); + } + + const prompt = synthesizePermissionPrompt(toolName, input, sessionId, ctx); + const decision = await prompter(prompt); + const result = mapDecisionToSdk(decision, ctx.suggestions, input); + logSdkOutbound("canUseTool:response", result, sessionId); + return result; + }; + + private async handleAskUserQuestion(input: AskUserQuestionInput): Promise { + const sessionId = this.currentSessionId; + logSdkInbound("askUserQuestion:request", input, sessionId); + if (!this.opts.askUserQuestion) { + return this.deny( + "askUserQuestion:response", + "AskUserQuestion is not yet supported", + sessionId + ); + } + try { + const answers = await this.opts.askUserQuestion(input.questions); + if (Object.keys(answers).length === 0) { + return this.deny("askUserQuestion:response", "User cancelled the question", sessionId); + } + const result: PermissionResult = { + behavior: "allow", + updatedInput: { questions: input.questions, answers }, + }; + logSdkOutbound("askUserQuestion:response", result, sessionId); + return result; + } catch (e) { + return this.deny( + "askUserQuestion:response", + `AskUserQuestion failed: ${err2String(e)}`, + sessionId + ); + } + } + + private deny(method: string, message: string, sessionId: SessionId | null): PermissionResult { + const result: PermissionResult = { behavior: "deny", message }; + logSdkOutbound(method, result, sessionId); + return result; + } +} + +const STANDARD_OPTION_NAMES: Record = { + allow_once: "Allow once", + allow_always: "Allow always", + reject_once: "Deny once", + reject_always: "Deny always", +}; +const STANDARD_OPTIONS: PermissionOption[] = PERMISSION_OPTION_KINDS.map((kind) => ({ + optionId: kind, + name: STANDARD_OPTION_NAMES[kind], + kind, +})); +const STANDARD_OPTION_IDS = new Set(PERMISSION_OPTION_KINDS); + +function synthesizePermissionPrompt( + toolName: string, + input: Record, + sessionId: SessionId, + ctx: Parameters[2] +): PermissionPrompt { + return { + sessionId, + toolCall: { + // Reuse the SDK's `tool_use_id` so prompt and `tool_call` notification + // share an id — the trail UI and plan-card resolver pair them by id. + toolCallId: ctx.toolUseID, + kind: deriveToolKind(toolName), + status: "pending", + title: deriveToolTitle( + toolName, + input, + typeof ctx.title === "string" ? ctx.title : undefined + ), + rawInput: input, + ...vendorMetaFields(toolName), + }, + options: STANDARD_OPTIONS, + }; +} + +function mapDecisionToSdk( + decision: PermissionDecision, + suggestions: PermissionUpdate[] | undefined, + input: Record +): PermissionResult { + if (decision.outcome.outcome === "cancelled") { + return { behavior: "deny", message: "User cancelled" }; + } + // Defensive default: unknown ids collapse to deny so they don't silently allow. + const optionKind = STANDARD_OPTION_IDS.has(decision.outcome.optionId) + ? (decision.outcome.optionId as PermissionOptionKind) + : "reject_once"; + switch (optionKind) { + case "allow_once": + // SDK runtime schema requires `updatedInput` even though the type marks + // it optional. Echo the original — we don't modify tool args from the prompt. + return { behavior: "allow", updatedInput: input }; + case "allow_always": + return { behavior: "allow", updatedInput: input, updatedPermissions: suggestions ?? [] }; + case "reject_once": + case "reject_always": + return { behavior: "deny", message: decision.denyMessage ?? "User declined" }; + } +} diff --git a/src/agentMode/sdk/sdkDebugTap.test.ts b/src/agentMode/sdk/sdkDebugTap.test.ts new file mode 100644 index 00000000..85ff95f4 --- /dev/null +++ b/src/agentMode/sdk/sdkDebugTap.test.ts @@ -0,0 +1,117 @@ +import { + describeSdkMessage, + logSdkInbound, + logSdkOutbound, + SDK_FRAME_TAG, + type SdkFrameSinkLike, +} from "./sdkDebugTap"; + +const mockLogInfo = jest.fn(); +jest.mock("@/logger", () => ({ + logInfo: (...args: unknown[]) => mockLogInfo(...args), +})); + +let debugFullFrames = false; +jest.mock("@/settings/model", () => ({ + getSettings: () => ({ agentMode: { debugFullFrames } }), +})); + +function fakeSink(): { sink: SdkFrameSinkLike; records: unknown[] } { + const records: unknown[] = []; + return { + records, + sink: { append: (r) => records.push(r) }, + }; +} + +beforeEach(() => { + mockLogInfo.mockClear(); + debugFullFrames = false; +}); + +describe("sdkDebugTap", () => { + it("always emits a console line with the claude-sdk tag", () => { + const { sink, records } = fakeSink(); + logSdkOutbound("prompt", { hi: "there" }, "session-1", sink); + expect(mockLogInfo).toHaveBeenCalledTimes(1); + const line = mockLogInfo.mock.calls[0][0] as string; + expect(line).toContain(`[ACP →][${SDK_FRAME_TAG}]`); + expect(line).toContain("prompt"); + expect(line).toContain("#session-1"); + expect(line).toContain('{"hi":"there"}'); + expect(records).toHaveLength(0); + }); + + it("does not write to disk when debugFullFrames is off", () => { + const { sink, records } = fakeSink(); + debugFullFrames = false; + logSdkInbound("stream_event:content_block_delta", { x: 1 }, "s", sink); + expect(records).toHaveLength(0); + }); + + it("writes a full FrameRecord to disk when debugFullFrames is on", () => { + const { sink, records } = fakeSink(); + debugFullFrames = true; + logSdkInbound("stream_event:content_block_delta", { x: 1 }, "s", sink); + expect(records).toHaveLength(1); + const rec = records[0] as Record; + expect(rec.dir).toBe("←"); + expect(rec.tag).toBe(SDK_FRAME_TAG); + expect(rec.kind).toBe("notif"); + expect(rec.method).toBe("stream_event:content_block_delta"); + expect(rec.id).toBe("s"); + expect(rec.payload).toEqual({ x: 1 }); + expect(typeof rec.ts).toBe("string"); + }); + + it("truncates the console payload past the 400-char limit", () => { + const { sink } = fakeSink(); + const big = "x".repeat(800); + logSdkOutbound("prompt", { big }, "s", sink); + const line = mockLogInfo.mock.calls[0][0] as string; + expect(line).toContain("…(+"); + expect(line.length).toBeLessThan(800); + }); + + it("survives unserializable payloads on the console path", () => { + const { sink } = fakeSink(); + const cyclic: Record = {}; + cyclic.self = cyclic; + expect(() => logSdkOutbound("prompt", cyclic, null, sink)).not.toThrow(); + expect(mockLogInfo).toHaveBeenCalledTimes(1); + }); + + it("uses (no-id) label when id is omitted on outbound, (notif) on inbound", () => { + const { sink } = fakeSink(); + logSdkOutbound("cancel", {}, null, sink); + logSdkInbound("acp_notify:plan", {}, null, sink); + const lines = mockLogInfo.mock.calls.map((c) => c[0] as string); + expect(lines[0]).toContain("(no-id)"); + expect(lines[1]).toContain("(notif)"); + }); +}); + +describe("describeSdkMessage", () => { + it("annotates stream_event with inner event type", () => { + expect( + describeSdkMessage({ + type: "stream_event", + event: { type: "content_block_delta" }, + }) + ).toBe("stream_event:content_block_delta"); + }); + + it("annotates result with subtype", () => { + expect(describeSdkMessage({ type: "result", subtype: "success" })).toBe("result:success"); + }); + + it("returns the bare type for assistant/user messages", () => { + expect(describeSdkMessage({ type: "assistant" })).toBe("assistant"); + expect(describeSdkMessage({ type: "user" })).toBe("user"); + }); + + it("returns (unknown) for malformed inputs", () => { + expect(describeSdkMessage(null)).toBe("(unknown)"); + expect(describeSdkMessage({})).toBe("(unknown)"); + }); +}); diff --git a/src/agentMode/sdk/sdkDebugTap.ts b/src/agentMode/sdk/sdkDebugTap.ts new file mode 100644 index 00000000..d30d7429 --- /dev/null +++ b/src/agentMode/sdk/sdkDebugTap.ts @@ -0,0 +1,112 @@ +/** + * Debug tap for the Claude Agent SDK adapter. Each call logs one frame to the + * console (truncated) and, when `agentMode.debugFullFrames` is on, appends + * the full payload to the shared `acp-frames.ndjson` sink. Frames carry + * `tag: "claude-sdk"` to distinguish from ACP frames in the same log. + * @see ../acp/debugTap.ts for the JSON-RPC stream variant. + */ +import { logInfo } from "@/logger"; +import { getSettings } from "@/settings/model"; +import { formatPayload, frameSink, type FrameRecord } from "@/agentMode/session/debugSink"; + +export const SDK_FRAME_TAG = "claude-sdk"; + +export type SdkFrameKind = FrameRecord["kind"]; +export type SdkFrameDir = "→" | "←"; + +export interface SdkFrameSinkLike { + append(record: FrameRecord): void; +} + +export interface LogSdkFrameArgs { + dir: SdkFrameDir; + method: string; + /** SDK session id, request id, or any correlator. Null/undefined for un-keyed frames. */ + id?: string | null; + payload?: unknown; + /** Defaults to "request" for outbound, "notif" for inbound. */ + kind?: SdkFrameKind; +} + +/** + * Internal entry point. Exposed for tests; production callers should use + * the convenience wrappers below. + */ +export function logSdkFrame(args: LogSdkFrameArgs, sink: SdkFrameSinkLike = frameSink): void { + const id = args.id ?? null; + const idLabel = id !== null ? `#${id}` : args.kind === "notif" ? "(notif)" : "(no-id)"; + logInfo( + `[ACP ${args.dir}][${SDK_FRAME_TAG}] ${args.method} ${idLabel} ${formatPayload(args.payload)}` + ); + + if (!getSettings().agentMode?.debugFullFrames) return; + sink.append({ + ts: new Date().toISOString(), + dir: args.dir, + tag: SDK_FRAME_TAG, + kind: args.kind ?? (args.dir === "→" ? "request" : "notif"), + method: args.method, + id, + payload: args.payload, + }); +} + +/** Outbound RPC or control call (we → SDK). */ +export function logSdkOutbound( + method: string, + payload: unknown, + id?: string | null, + sink?: SdkFrameSinkLike +): void { + logSdkFrame({ dir: "→", method, id, payload, kind: "request" }, sink); +} + +/** Outbound RPC result (we → caller / response we are about to return). */ +export function logSdkOutboundResult( + method: string, + payload: unknown, + id?: string | null, + sink?: SdkFrameSinkLike +): void { + logSdkFrame({ dir: "→", method, id, payload, kind: "result" }, sink); +} + +/** Inbound SDK message or translated ACP notification (SDK → us). */ +export function logSdkInbound( + method: string, + payload: unknown, + id?: string | null, + sink?: SdkFrameSinkLike +): void { + logSdkFrame({ dir: "←", method, id, payload, kind: "notif" }, sink); +} + +/** Inbound or outbound error frame. */ +export function logSdkError( + dir: SdkFrameDir, + method: string, + payload: unknown, + id?: string | null, + sink?: SdkFrameSinkLike +): void { + logSdkFrame({ dir, method, id, payload, kind: "error" }, sink); +} + +/** + * Synthesize a stable "method" label for an SDK message so the trace reads + * similarly to ACP JSON-RPC method names. `stream_event` carries the inner + * event type to make the high-frequency stream readable. + */ +export function describeSdkMessage(msg: unknown): string { + const m = msg as { type?: unknown; event?: { type?: unknown }; subtype?: unknown }; + if (!m || typeof m.type !== "string") return "(unknown)"; + if (m.type === "stream_event") { + const ev = m.event && typeof m.event === "object" ? m.event : null; + const evType = ev && typeof ev.type === "string" ? ev.type : "?"; + return `stream_event:${evType}`; + } + if (m.type === "result" && typeof m.subtype === "string") { + return `result:${m.subtype}`; + } + return m.type; +} diff --git a/src/agentMode/sdk/sdkMessageTranslator.test.ts b/src/agentMode/sdk/sdkMessageTranslator.test.ts new file mode 100644 index 00000000..efd4ea7a --- /dev/null +++ b/src/agentMode/sdk/sdkMessageTranslator.test.ts @@ -0,0 +1,537 @@ +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import { createTranslatorState, mapStopReason, translateSdkMessage } from "./sdkMessageTranslator"; + +const SESSION_ID = "session-test-1"; + +function streamEvent(event: object): SDKMessage { + return { + type: "stream_event", + event, + parent_tool_use_id: null, + uuid: "uuid-1" as `${string}-${string}-${string}-${string}-${string}`, + session_id: SESSION_ID, + } as SDKMessage; +} + +describe("translateSdkMessage", () => { + it("emits agent_message_chunk for text deltas", () => { + const state = createTranslatorState(); + const out = translateSdkMessage( + streamEvent({ + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "Hello, world" }, + }), + SESSION_ID, + state + ); + expect(out).toEqual([ + { + sessionId: SESSION_ID, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Hello, world" }, + }, + }, + ]); + }); + + it("emits agent_thought_chunk for thinking deltas", () => { + const state = createTranslatorState(); + const out = translateSdkMessage( + streamEvent({ + type: "content_block_delta", + index: 0, + delta: { type: "thinking_delta", thinking: "Let me think..." }, + }), + SESSION_ID, + state + ); + expect(out).toEqual([ + { + sessionId: SESSION_ID, + update: { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "Let me think..." }, + }, + }, + ]); + }); + + it("ignores non-text deltas (input_json, signature, citations)", () => { + const state = createTranslatorState(); + expect( + translateSdkMessage( + streamEvent({ + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: '{"a":1}' }, + }), + SESSION_ID, + state + ) + ).toEqual([]); + expect( + translateSdkMessage( + streamEvent({ + type: "content_block_delta", + index: 0, + delta: { type: "signature_delta", signature: "sig" }, + }), + SESSION_ID, + state + ) + ).toEqual([]); + }); + + it("emits nothing for message_start/stop and content_block_start/stop in Chunk 1", () => { + const state = createTranslatorState(); + expect( + translateSdkMessage(streamEvent({ type: "message_start", message: {} }), SESSION_ID, state) + ).toEqual([]); + expect(translateSdkMessage(streamEvent({ type: "message_stop" }), SESSION_ID, state)).toEqual( + [] + ); + expect( + translateSdkMessage( + streamEvent({ + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + }), + SESSION_ID, + state + ) + ).toEqual([]); + expect( + translateSdkMessage(streamEvent({ type: "content_block_stop", index: 0 }), SESSION_ID, state) + ).toEqual([]); + }); + + it("clears toolUseBlocks state on message_start", () => { + const state = createTranslatorState(); + state.toolUseBlocks.set(0, { + id: "t1", + name: "Tool", + inputJsonAcc: "", + lastParsedInput: {}, + emittedToolCall: false, + }); + translateSdkMessage(streamEvent({ type: "message_start", message: {} }), SESSION_ID, state); + expect(state.toolUseBlocks.size).toBe(0); + }); + + it("returns [] for `result` (caller resolves the prompt promise separately)", () => { + const state = createTranslatorState(); + const out = translateSdkMessage( + { + type: "result", + subtype: "success", + duration_ms: 0, + duration_api_ms: 0, + is_error: false, + num_turns: 1, + result: "ok", + stop_reason: "end_turn", + total_cost_usd: 0, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + usage: {} as any, + modelUsage: {}, + permission_denials: [], + uuid: "uuid-2" as `${string}-${string}-${string}-${string}-${string}`, + session_id: SESSION_ID, + }, + SESSION_ID, + state + ); + expect(out).toEqual([]); + }); + + it("ignores assistant messages whose tool_use blocks were already streamed", () => { + const state = createTranslatorState(); + // Pretend the streaming path already saw this tool_use. + state.emittedToolUseIds.add("tool-1"); + expect( + translateSdkMessage( + { + type: "assistant", + + message: { + content: [ + { type: "tool_use", id: "tool-1", name: "Read", input: { file_path: "a.md" } }, + ], + } as never, + parent_tool_use_id: null, + uuid: "uuid-a" as `${string}-${string}-${string}-${string}-${string}`, + session_id: SESSION_ID, + }, + SESSION_ID, + state + ) + ).toEqual([]); + }); + + it("emits tool_call on content_block_start for tool_use blocks", () => { + const state = createTranslatorState(); + const out = translateSdkMessage( + streamEvent({ + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "tu-1", name: "Read", input: {} }, + }), + SESSION_ID, + state + ); + expect(out).toHaveLength(1); + expect(out[0].update).toMatchObject({ + sessionUpdate: "tool_call", + toolCallId: "tu-1", + kind: "read", + vendorToolName: "Read", + }); + }); + + it("emits tool_call_update with parsed rawInput on input_json_delta", () => { + const state = createTranslatorState(); + translateSdkMessage( + streamEvent({ + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "tu-2", name: "Read", input: {} }, + }), + SESSION_ID, + state + ); + const out = translateSdkMessage( + streamEvent({ + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: '{"file_path":"a.md"}' }, + }), + SESSION_ID, + state + ); + expect(out[0].update).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "tu-2", + rawInput: { file_path: "a.md" }, + }); + }); + + it("emits tool_call_update with status in_progress on content_block_stop", () => { + const state = createTranslatorState(); + translateSdkMessage( + streamEvent({ + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "tu-3", name: "ExitPlanMode", input: {} }, + }), + SESSION_ID, + state + ); + translateSdkMessage( + streamEvent({ + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: '{"plan":"Step 1"}' }, + }), + SESSION_ID, + state + ); + const out = translateSdkMessage( + streamEvent({ type: "content_block_stop", index: 0 }), + SESSION_ID, + state + ); + expect(out[0].update).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "tu-3", + rawInput: { plan: "Step 1" }, + status: "in_progress", + }); + }); + + it("emits tool_call_update with status completed for tool_result (success)", () => { + const state = createTranslatorState(); + const out = translateSdkMessage( + { + type: "user", + + message: { + content: [ + { + type: "tool_result", + tool_use_id: "tu-4", + content: "vault contents", + is_error: false, + }, + ], + } as never, + parent_tool_use_id: null, + session_id: SESSION_ID, + }, + SESSION_ID, + state + ); + expect(out).toHaveLength(1); + expect(out[0].update).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "tu-4", + status: "completed", + }); + }); + + it("emits tool_call_update with status failed when tool_result.is_error is true", () => { + const state = createTranslatorState(); + const out = translateSdkMessage( + { + type: "user", + + message: { + content: [{ type: "tool_result", tool_use_id: "tu-5", content: "boom", is_error: true }], + } as never, + parent_tool_use_id: null, + session_id: SESSION_ID, + }, + SESSION_ID, + state + ); + expect(out[0].update).toMatchObject({ status: "failed" }); + }); + + it("synthesizes current_mode_update on EnterPlanMode tool_use", () => { + const state = createTranslatorState(); + const out = translateSdkMessage( + streamEvent({ + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "tu-plan", name: "EnterPlanMode", input: {} }, + }), + SESSION_ID, + state + ); + expect(out).toHaveLength(2); + expect(out[0].update).toMatchObject({ sessionUpdate: "tool_call", toolCallId: "tu-plan" }); + expect(out[1].update).toMatchObject({ + sessionUpdate: "current_mode_update", + currentModeId: "plan", + }); + }); + + it("strips the mcp____ prefix when the server name itself contains underscores", () => { + const state = createTranslatorState(); + const out = translateSdkMessage( + streamEvent({ + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "tu-mcp-underscored", + name: "mcp__my_server__do_thing", + input: {}, + }, + }), + SESSION_ID, + state + ); + expect(out).toHaveLength(1); + expect(out[0].update).toMatchObject({ + sessionUpdate: "tool_call", + toolCallId: "tu-mcp-underscored", + vendorToolName: "do_thing", + }); + }); + + it("strips the mcp____ prefix from MCP tool names so kind/title/meta see the bare name", () => { + const state = createTranslatorState(); + const out = translateSdkMessage( + streamEvent({ + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "tu-mcp", + name: "mcp__custom-server__do_thing", + input: { path: "Daily/2026-05-01.md" }, + }, + }), + SESSION_ID, + state + ); + expect(out).toHaveLength(1); + expect(out[0].update).toMatchObject({ + sessionUpdate: "tool_call", + toolCallId: "tu-mcp", + title: "do_thing Daily/2026-05-01.md", + vendorToolName: "do_thing", + }); + }); + + it("emits ExitPlanMode tool_call with kind=switch_mode (routes through plan-proposal flow)", () => { + const state = createTranslatorState(); + const out = translateSdkMessage( + streamEvent({ + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "tu-exit", name: "ExitPlanMode", input: {} }, + }), + SESSION_ID, + state + ); + expect(out).toHaveLength(1); + expect(out[0].update).toMatchObject({ + sessionUpdate: "tool_call", + toolCallId: "tu-exit", + kind: "switch_mode", + vendorToolName: "ExitPlanMode", + isPlanProposal: true, + }); + }); + + it("threads parent_tool_use_id into parentToolCallId on streamed tool_use", () => { + const state = createTranslatorState(); + const out = translateSdkMessage( + { + type: "stream_event", + event: { + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "child-1", name: "Read", input: {} }, + }, + parent_tool_use_id: "task-parent-1", + uuid: "uuid-p" as `${string}-${string}-${string}-${string}-${string}`, + session_id: SESSION_ID, + }, + SESSION_ID, + state + ); + expect(out).toHaveLength(1); + expect(out[0].update).toMatchObject({ + sessionUpdate: "tool_call", + toolCallId: "child-1", + vendorToolName: "Read", + parentToolCallId: "task-parent-1", + }); + }); + + it("threads parent_tool_use_id through tool_call_update on input_json_delta", () => { + const state = createTranslatorState(); + translateSdkMessage( + { + type: "stream_event", + event: { + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "child-2", name: "Read", input: {} }, + }, + parent_tool_use_id: "task-parent-2", + uuid: "uuid-p2" as `${string}-${string}-${string}-${string}-${string}`, + session_id: SESSION_ID, + }, + SESSION_ID, + state + ); + const out = translateSdkMessage( + { + type: "stream_event", + event: { + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: '{"path":"a.md"}' }, + }, + parent_tool_use_id: "task-parent-2", + uuid: "uuid-p3" as `${string}-${string}-${string}-${string}-${string}`, + session_id: SESSION_ID, + }, + SESSION_ID, + state + ); + expect(out[0].update).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "child-2", + vendorToolName: "Read", + parentToolCallId: "task-parent-2", + }); + }); + + it("omits parentToolCallId when parent_tool_use_id is null", () => { + const state = createTranslatorState(); + const out = translateSdkMessage( + streamEvent({ + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "tu-top", name: "Read", input: {} }, + }), + SESSION_ID, + state + ); + expect(out[0].update).toMatchObject({ + sessionUpdate: "tool_call", + vendorToolName: "Read", + }); + expect(out[0].update).not.toHaveProperty("parentToolCallId"); + }); + + it("threads parent_tool_use_id on assistant-message fallback path", () => { + const state = createTranslatorState(); + const out = translateSdkMessage( + { + type: "assistant", + + message: { + content: [{ type: "tool_use", id: "child-3", name: "Read", input: { path: "a.md" } }], + } as never, + parent_tool_use_id: "task-parent-3", + uuid: "uuid-p4" as `${string}-${string}-${string}-${string}-${string}`, + session_id: SESSION_ID, + }, + SESSION_ID, + state + ); + expect(out).toHaveLength(1); + expect(out[0].update).toMatchObject({ + sessionUpdate: "tool_call", + toolCallId: "child-3", + vendorToolName: "Read", + parentToolCallId: "task-parent-3", + }); + }); + + it("ignores partial input_json that doesn't parse yet", () => { + const state = createTranslatorState(); + translateSdkMessage( + streamEvent({ + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "tu-6", name: "Read", input: {} }, + }), + SESSION_ID, + state + ); + const out = translateSdkMessage( + streamEvent({ + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: '{"path":"a.md' }, + }), + SESSION_ID, + state + ); + expect(out).toEqual([]); + }); +}); + +describe("mapStopReason", () => { + it("maps success → end_turn", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(mapStopReason({ type: "result", subtype: "success" } as any)).toBe("end_turn"); + }); + it("maps error variants → cancelled", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(mapStopReason({ type: "result", subtype: "error_during_execution" } as any)).toBe( + "cancelled" + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(mapStopReason({ type: "result", subtype: "error_max_turns" } as any)).toBe("cancelled"); + }); +}); diff --git a/src/agentMode/sdk/sdkMessageTranslator.ts b/src/agentMode/sdk/sdkMessageTranslator.ts new file mode 100644 index 00000000..8a0dcbe4 --- /dev/null +++ b/src/agentMode/sdk/sdkMessageTranslator.ts @@ -0,0 +1,323 @@ +/** Pure translator: Claude Agent SDK `SDKMessage` → session-domain `SessionUpdate`. */ +import type { + SDKAssistantMessage, + SDKMessage, + SDKPartialAssistantMessage, + SDKResultMessage, + SDKUserMessage, +} from "@anthropic-ai/claude-agent-sdk"; +import type { + AgentToolStatus, + SessionEvent, + SessionId, + SessionUpdate, + ToolCallContent, +} from "@/agentMode/session/types"; +import { deriveToolKind, deriveToolTitle, vendorMetaFields } from "./toolMeta"; + +/** + * Mutable per-query translator state. One instance lives for the duration of + * a single `query()` call; reset whenever a new turn starts. + */ +export interface TranslatorState { + toolUseBlocks: Map< + number, + { + id: string; + name: string; + inputJsonAcc: string; + lastParsedInput: unknown; + emittedToolCall: boolean; + } + >; + /** Tool-use ids already emitted in this turn — used to dedupe in the assistant-message fallback path. */ + emittedToolUseIds: Set; +} + +export function createTranslatorState(): TranslatorState { + return { toolUseBlocks: new Map(), emittedToolUseIds: new Set() }; +} + +function event(sessionId: SessionId, update: SessionUpdate): SessionEvent { + return { sessionId, update }; +} + +/** + * Translate one SDK message to zero or more session-domain events. Returning + * an array (rather than firing a callback) keeps the function pure and + * trivially testable; the caller decides what to do with the events and when + * to terminate the prompt promise. + */ +export function translateSdkMessage( + msg: SDKMessage, + sessionId: SessionId, + state: TranslatorState +): SessionEvent[] { + switch (msg.type) { + case "stream_event": + return translateStreamEvent(msg, sessionId, state); + case "assistant": + return translateAssistantMessage(msg, sessionId, state); + case "user": + return translateUserMessage(msg, sessionId, state); + case "result": + default: + return []; + } +} + +export function mapStopReason(msg: SDKResultMessage): "end_turn" | "cancelled" | "refusal" { + if (msg.subtype === "success") return "end_turn"; + return "cancelled"; +} + +function translateStreamEvent( + msg: SDKPartialAssistantMessage, + sessionId: SessionId, + state: TranslatorState +): SessionEvent[] { + const parentToolUseId = msg.parent_tool_use_id ?? undefined; + const sdkEvent = msg.event as + | { type: "message_start"; message?: unknown } + | { type: "message_stop" } + | { type: "message_delta"; delta?: unknown; usage?: unknown } + | { + type: "content_block_start"; + index: number; + content_block: + | { type: "text"; text: string } + | { type: "tool_use"; id: string; name: string; input: unknown } + | { type: "thinking"; thinking: string } + | { type: "redacted_thinking" }; + } + | { + type: "content_block_delta"; + index: number; + delta: + | { type: "text_delta"; text: string } + | { type: "thinking_delta"; thinking: string } + | { type: "input_json_delta"; partial_json: string } + | { type: "signature_delta"; signature: string } + | { type: "citations_delta"; citation: unknown }; + } + | { type: "content_block_stop"; index: number }; + + switch (sdkEvent.type) { + case "message_start": + state.toolUseBlocks.clear(); + return []; + case "content_block_start": { + const block = sdkEvent.content_block; + if (block.type === "tool_use") { + const name = normalizeToolName(block.name); + state.toolUseBlocks.set(sdkEvent.index, { + id: block.id, + name, + inputJsonAcc: "", + lastParsedInput: block.input ?? {}, + emittedToolCall: true, + }); + state.emittedToolUseIds.add(block.id); + const out: SessionEvent[] = [ + event(sessionId, makeToolCallUpdate(block.id, name, block.input ?? {}, parentToolUseId)), + ]; + if (name === "EnterPlanMode") { + out.push( + event(sessionId, { + sessionUpdate: "current_mode_update", + currentModeId: "plan", + }) + ); + } + return out; + } + return []; + } + case "content_block_delta": { + const delta = sdkEvent.delta; + if (delta.type === "text_delta") { + return [ + event(sessionId, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: delta.text }, + }), + ]; + } + if (delta.type === "thinking_delta") { + return [ + event(sessionId, { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: delta.thinking }, + }), + ]; + } + if (delta.type === "input_json_delta") { + const block = state.toolUseBlocks.get(sdkEvent.index); + if (!block) return []; + block.inputJsonAcc += delta.partial_json; + // Cheap pre-check: a complete JSON value's last non-whitespace byte + // is `}`, `]`, `"`, a digit, or one of the literals' last letters. + // Skipping JSON.parse on obviously-incomplete buffers (mid-key, + // mid-string) avoids O(N) work per delta when a large tool input + // streams across many small chunks. + if (!couldBeCompleteJson(block.inputJsonAcc)) return []; + const parsed = tryParseJson(block.inputJsonAcc); + if (!parsed.ok) return []; + block.lastParsedInput = parsed.value; + return [ + event(sessionId, { + sessionUpdate: "tool_call_update", + toolCallId: block.id, + rawInput: parsed.value, + ...vendorMetaFields(block.name, parentToolUseId), + }), + ]; + } + return []; + } + case "content_block_stop": { + const block = state.toolUseBlocks.get(sdkEvent.index); + if (!block) return []; + const parsed = tryParseJson(block.inputJsonAcc); + const finalInput = parsed.ok ? parsed.value : block.lastParsedInput; + block.lastParsedInput = finalInput; + return [ + event(sessionId, { + sessionUpdate: "tool_call_update", + toolCallId: block.id, + rawInput: finalInput, + status: "in_progress" as AgentToolStatus, + ...vendorMetaFields(block.name, parentToolUseId), + }), + ]; + } + case "message_delta": + case "message_stop": + default: + return []; + } +} + +function translateAssistantMessage( + msg: SDKAssistantMessage, + sessionId: SessionId, + state: TranslatorState +): SessionEvent[] { + const out: SessionEvent[] = []; + const content = (msg.message as { content?: unknown }).content; + if (!Array.isArray(content)) return out; + const parentToolUseId = msg.parent_tool_use_id ?? undefined; + for (const block of content) { + const b = block as { type?: string; id?: string; name?: string; input?: unknown }; + if (b.type !== "tool_use" || !b.id || !b.name) continue; + if (state.emittedToolUseIds.has(b.id)) continue; + state.emittedToolUseIds.add(b.id); + out.push(event(sessionId, makeToolCallUpdate(b.id, b.name, b.input ?? {}, parentToolUseId))); + } + return out; +} + +function translateUserMessage( + msg: SDKUserMessage, + sessionId: SessionId, + _state: TranslatorState +): SessionEvent[] { + const content = (msg.message as { content?: unknown }).content; + if (!Array.isArray(content)) return []; + const out: SessionEvent[] = []; + for (const block of content) { + const b = block as { + type?: string; + tool_use_id?: string; + content?: unknown; + is_error?: boolean; + }; + if (b.type !== "tool_result" || !b.tool_use_id) continue; + const status: AgentToolStatus = b.is_error ? "failed" : "completed"; + const outputs = toolResultContent(b.content); + out.push( + event(sessionId, { + sessionUpdate: "tool_call_update", + toolCallId: b.tool_use_id, + status, + content: outputs, + }) + ); + } + return out; +} + +function makeToolCallUpdate( + toolCallId: string, + normalizedName: string, + rawInput: unknown, + parentToolUseId?: string +): SessionUpdate { + return { + sessionUpdate: "tool_call", + toolCallId, + title: deriveToolTitle(normalizedName, rawInput), + kind: deriveToolKind(normalizedName), + status: "in_progress" as AgentToolStatus, + rawInput, + ...vendorMetaFields(normalizedName, parentToolUseId), + }; +} + +/** + * Strip the SDK's `mcp____` prefix on MCP tool names so downstream UI + * mapping (kind / title / vendorToolName) sees the bare tool name. The + * non-greedy middle segment tolerates server names containing underscores. + */ +function normalizeToolName(name: string): string { + const m = /^mcp__.+?__(.+)$/.exec(name); + return m ? m[1] : name; +} + +function toolResultContent(content: unknown): ToolCallContent[] | undefined { + if (typeof content === "string") { + return [{ type: "content", content: { type: "text", text: content } }]; + } + if (!Array.isArray(content)) return undefined; + const out: ToolCallContent[] = []; + for (const block of content) { + const b = block as { type?: string; text?: unknown }; + if (b.type === "text" && typeof b.text === "string") { + out.push({ type: "content", content: { type: "text", text: b.text } }); + } + } + return out.length > 0 ? out : undefined; +} + +type ParseResult = { ok: true; value: unknown } | { ok: false }; + +function tryParseJson(raw: string): ParseResult { + if (raw.trim().length === 0) return { ok: true, value: {} }; + try { + return { ok: true, value: JSON.parse(raw) }; + } catch { + return { ok: false }; + } +} + +function couldBeCompleteJson(raw: string): boolean { + let i = raw.length - 1; + while (i >= 0) { + const c = raw.charCodeAt(i); + // Skip ASCII whitespace. + if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) { + i--; + continue; + } + // }, ], ", e (true/false), l (null), or any digit can end a JSON value. + return ( + c === 0x7d || // } + c === 0x5d || // ] + c === 0x22 || // " + c === 0x65 || // e + c === 0x6c || // l + (c >= 0x30 && c <= 0x39) // 0-9 + ); + } + return false; +} diff --git a/src/agentMode/sdk/toolMeta.ts b/src/agentMode/sdk/toolMeta.ts new file mode 100644 index 00000000..4bd9fc60 --- /dev/null +++ b/src/agentMode/sdk/toolMeta.ts @@ -0,0 +1,63 @@ +import type { AgentToolKind } from "@/agentMode/session/types"; + +export interface VendorMetaFields { + vendorToolName: string; + parentToolCallId?: string; + isPlanProposal?: boolean; +} + +/** + * Caller passes the normalized tool name (any `mcp__server__` prefix + * already stripped). `isPlanProposal` is omitted unless true so the flag + * doesn't leak onto unrelated tool calls. + */ +export function vendorMetaFields( + normalizedName: string, + parentToolCallId?: string +): VendorMetaFields { + const fields: VendorMetaFields = { vendorToolName: normalizedName }; + if (parentToolCallId) fields.parentToolCallId = parentToolCallId; + if (normalizedName === "ExitPlanMode") fields.isPlanProposal = true; + return fields; +} + +export function deriveToolKind(toolName: string): AgentToolKind { + if (toolName === "ExitPlanMode" || toolName === "EnterPlanMode") return "switch_mode"; + const lower = toolName.toLowerCase(); + if (lower === "read" || lower === "glob" || lower === "grep" || lower === "ls") { + return "read"; + } + if (lower === "write" || lower === "edit" || lower === "multiedit") { + return "edit"; + } + if (lower === "bash") return "execute"; + if (lower === "websearch" || lower === "webfetch") return "fetch"; + if (lower === "todowrite" || lower === "task" || lower === "agent") return "think"; + return "other"; +} + +/** + * Build a one-line "what is the agent doing" title surfaced on the action + * card. `titleOverride` short-circuits when the SDK already supplied one + * (e.g. via `canUseTool` ctx). + */ +export function deriveToolTitle( + toolName: string, + rawInput: unknown, + titleOverride?: string +): string { + if (typeof titleOverride === "string" && titleOverride.length > 0) return titleOverride; + const input = rawInput as Record | null | undefined; + if (input && typeof input === "object") { + if (typeof input.path === "string") return `${toolName} ${input.path}`; + if (typeof input.file_path === "string") return `${toolName} ${input.file_path}`; + if (typeof input.command === "string") return `${toolName}: ${truncate(input.command, 60)}`; + if (typeof input.pattern === "string") return `${toolName} ${truncate(input.pattern, 60)}`; + if (typeof input.url === "string") return `${toolName} ${input.url}`; + } + return toolName; +} + +export function truncate(s: string, n: number): string { + return s.length > n ? `${s.slice(0, n - 1)}…` : s; +} diff --git a/src/agentMode/session/AgentChatBackend.ts b/src/agentMode/session/AgentChatBackend.ts new file mode 100644 index 00000000..e30359b7 --- /dev/null +++ b/src/agentMode/session/AgentChatBackend.ts @@ -0,0 +1,76 @@ +import type { MessageContext } from "@/types/message"; +import type { AgentChatMessage, BackendState, CurrentPlan, PlanDecisionAction } from "./types"; + +/** + * Narrow interface the Agent Mode UI tree consumes. Implemented by + * `AgentChatUIState`. Distinct from the legacy `ChatUIState` because Agent + * Mode has no edit/regenerate/persistence flow and no chain-type or + * include-active-note plumbing — ACP owns those concerns server-side. + * + * `sendMessage` returns `{ id, turn }` so the caller can synchronously read + * the new user message id (for input history) and separately await the full + * turn for loading-state management. + */ +export interface AgentChatBackend { + subscribe(listener: () => void): () => void; + sendMessage( + text: string, + context?: MessageContext, + content?: unknown[] + ): { id: string; turn: Promise }; + cancel(): Promise; + deleteMessage(id: string): Promise; + clearMessages(): void; + getMessages(): AgentChatMessage[]; + + /** True while ACP `session/new` is still in flight. Send is gated on this. */ + isStarting(): boolean; + + /** Latest unified picker state, or `null` while the backend session is still starting. */ + getBackendState(): BackendState | null; + /** + * Intent-level capability probes. Tri-state: null = not yet probed, + * true/false = result. The session encapsulates wire routing + * (descriptor-style vs suffix-style effort, `setMode` vs + * `setConfigOption` mode dispatch) — UI consumers ask intent only. + */ + canSwitchModel(): boolean | null; + canSwitchEffort(): boolean | null; + canSwitchMode(): boolean | null; + + /** + * Resolve the current plan proposal the user has decided on. Branches on + * `currentPlan.permissionGated`: + * - gated (Claude Code ExitPlanMode): resolves the underlying ACP + * permission as allow/deny. Approve auto-continues the agent's turn; + * Reject ends the turn; Feedback denies with `feedbackText` as the + * agent-visible deny reason. + * - non-gated (OpenCode end-of-turn, or backends whose plan-exit signal + * carries no permission): Approve switches to canonical `build` mode + * (when the descriptor advertises one) and sends a `Proceed with the + * plan.` follow-up; Reject is informational; Feedback sends + * `feedbackText` as the next user turn (mode stays in plan). + * + * `proposalId` must match the current `getCurrentPlan().id` — stale + * resolutions (the user clicked a card that has since been replaced) + * are silently ignored. + */ + resolvePlanProposal( + proposalId: string, + decision: PlanDecisionAction, + feedbackText?: string + ): Promise; + + /** + * Singleton plan-mode review state, or `null` when there's nothing to + * surface. The floating plan card and the editor preview tab read this. + */ + getCurrentPlan(): CurrentPlan | null; + + /** + * True when an ExitPlanMode permission is currently pending. The chat input + * disables itself while one is outstanding so the user is funneled to the + * proposal card's actions. + */ + hasPendingPlanPermission(): boolean; +} diff --git a/src/agentMode/session/AgentChatPersistenceManager.test.ts b/src/agentMode/session/AgentChatPersistenceManager.test.ts new file mode 100644 index 00000000..54b9eed3 --- /dev/null +++ b/src/agentMode/session/AgentChatPersistenceManager.test.ts @@ -0,0 +1,182 @@ +/* eslint-disable obsidianmd/no-tfile-tfolder-cast -- test fixtures; not real TFiles */ +import { AI_SENDER, USER_SENDER } from "@/constants"; +import { AgentChatPersistenceManager } from "./AgentChatPersistenceManager"; +import type { AgentChatMessage } from "./types"; +import type { App, TFile } from "obsidian"; + +jest.mock("obsidian", () => ({ + Notice: jest.fn(), + TFile: jest.fn(), +})); +jest.mock("@/logger"); +jest.mock("@/settings/model", () => ({ + getSettings: jest.fn().mockReturnValue({ + defaultSaveFolder: "test-folder", + defaultConversationTag: "copilot-conversation", + defaultConversationNoteName: "{$date}_{$time}__{$topic}", + }), +})); +jest.mock("@/utils", () => ({ + ensureFolderExists: jest.fn(async () => {}), + formatDateTime: jest.fn(() => ({ + fileName: "20260101_120000", + display: "2026/01/01 12:00:00", + })), + getUtf8ByteLength: jest.fn((s: string) => new TextEncoder().encode(s).length), + truncateToByteLimit: jest.fn((s: string, n: number) => { + const bytes = new TextEncoder().encode(s); + if (bytes.length <= n) return s; + return new TextDecoder().decode(bytes.slice(0, n)); + }), +})); +jest.mock("@/utils/vaultAdapterUtils", () => ({ + isInVaultCache: jest.fn(() => false), + listMarkdownFiles: jest.fn().mockResolvedValue([]), + readFrontmatterViaAdapter: jest.fn().mockResolvedValue(null), +})); + +interface FakeFile { + path: string; + basename: string; + contents?: string; +} + +/** + * Build a minimal in-memory `app` mock that records files written via + * `vault.create` / `vault.adapter.write` so a round-trip save/load test can + * read what the previous step wrote without wiring real disk I/O. + */ +function makeApp() { + const files = new Map(); + return { + files, + vault: { + getAbstractFileByPath: jest.fn((path: string) => files.get(path) ?? null), + create: jest.fn(async (path: string, content: string) => { + const basename = path.split("/").pop()!.replace(/\.md$/, ""); + const file = { path, basename, contents: content }; + files.set(path, file); + return file; + }), + modify: jest.fn(async (file: FakeFile, content: string) => { + file.contents = content; + }), + read: jest.fn(async (file: FakeFile) => file.contents ?? ""), + delete: jest.fn(async (file: FakeFile) => { + files.delete(file.path); + }), + adapter: { + exists: jest.fn(async (path: string) => files.has(path)), + read: jest.fn(async (path: string) => files.get(path)?.contents ?? ""), + write: jest.fn(async (path: string, content: string) => { + const existing = files.get(path); + if (existing) { + existing.contents = content; + } else { + const basename = path.split("/").pop()!.replace(/\.md$/, ""); + files.set(path, { path, basename, contents: content }); + } + }), + remove: jest.fn(async (path: string) => { + files.delete(path); + }), + }, + }, + metadataCache: { + getFileCache: jest.fn(() => undefined), + }, + fileManager: { + processFrontMatter: jest.fn(), + }, + }; +} + +function makeMessage(sender: string, message: string, epoch = 1735732800000): AgentChatMessage { + return { + id: `msg-${epoch}`, + sender, + message, + isVisible: true, + timestamp: { epoch, display: "2026/01/01 12:00:00", fileName: "20260101_120000" }, + }; +} + +describe("AgentChatPersistenceManager", () => { + let app: ReturnType; + let manager: AgentChatPersistenceManager; + + beforeEach(() => { + app = makeApp(); + manager = new AgentChatPersistenceManager(app as unknown as App); + }); + + it("round-trips messages, backendId, and label", async () => { + const messages = [makeMessage(USER_SENDER, "hello world"), makeMessage(AI_SENDER, "hi back")]; + const saved = await manager.saveSession(messages, "claude-code", { label: "My chat" }); + expect(saved).not.toBeNull(); + + const file = app.files.get(saved!.path)!; + const loaded = await manager.loadFile(file as unknown as TFile); + expect(loaded.backendId).toBe("claude-code"); + expect(loaded.label).toBe("My chat"); + expect(loaded.messages).toHaveLength(2); + expect(loaded.messages[0].sender).toBe(USER_SENDER); + expect(loaded.messages[0].message).toBe("hello world"); + expect(loaded.messages[1].sender).toBe(AI_SENDER); + expect(loaded.messages[1].message).toBe("hi back"); + }); + + it("escapes and round-trips a label containing quotes and backslashes", async () => { + const tricky = 'has "quotes" and \\backslashes\\'; + const messages = [makeMessage(USER_SENDER, "hi")]; + const saved = await manager.saveSession(messages, "opencode", { label: tricky }); + expect(saved).not.toBeNull(); + const loaded = await manager.loadFile(app.files.get(saved!.path) as unknown as TFile); + expect(loaded.label).toBe(tricky); + }); + + it("strips control characters from labels so they can't break frontmatter", async () => { + const messages = [makeMessage(USER_SENDER, "hi")]; + const saved = await manager.saveSession(messages, "opencode", { + label: "first\nsecond\rthird", + }); + const raw = app.files.get(saved!.path)!.contents!; + // The label line must remain a single key:value entry. + const labelLines = raw.split("\n").filter((l) => l.startsWith("agentLabel:")); + expect(labelLines).toHaveLength(1); + const loaded = await manager.loadFile(app.files.get(saved!.path) as unknown as TFile); + expect(loaded.label).toBe("first second third"); + }); + + it("throws on missing backendId instead of silently defaulting", async () => { + const path = "test-folder/agent__broken.md"; + await app.vault.adapter.write( + path, + ["---", "epoch: 1735732800000", "mode: agent", "---", "", "**user**: hi"].join("\n") + ); + await expect( + manager.loadFile({ path, basename: "agent__broken" } as unknown as TFile) + ).rejects.toThrow(/Missing backendId/); + }); + + it("assigns deterministic ids that depend only on message timestamp", async () => { + const messages = [ + makeMessage(USER_SENDER, "first", 1700000000000), + makeMessage(AI_SENDER, "second", 1700000000001), + ]; + const saved = await manager.saveSession(messages, "claude-code"); + const file = app.files.get(saved!.path)!; + + const loadedA = await manager.loadFile(file as unknown as TFile); + const loadedB = await manager.loadFile(file as unknown as TFile); + // The key contract: same file + same content → same ids across reloads. + expect(loadedA.messages.map((m) => m.id)).toEqual(loadedB.messages.map((m) => m.id)); + expect(loadedA.messages[0].id.startsWith("loaded-0-")).toBe(true); + }); + + it("returns null when given zero messages instead of writing an empty file", async () => { + const result = await manager.saveSession([], "opencode"); + expect(result).toBeNull(); + expect(app.files.size).toBe(0); + }); +}); diff --git a/src/agentMode/session/AgentChatPersistenceManager.ts b/src/agentMode/session/AgentChatPersistenceManager.ts new file mode 100644 index 00000000..0ac20657 --- /dev/null +++ b/src/agentMode/session/AgentChatPersistenceManager.ts @@ -0,0 +1,453 @@ +import { AI_SENDER, USER_SENDER } from "@/constants"; +import { logError, logInfo, logWarn } from "@/logger"; +import { getSettings } from "@/settings/model"; +import { FormattedDateTime } from "@/types/message"; +import { + ensureFolderExists, + formatDateTime, + getUtf8ByteLength, + truncateToByteLimit, +} from "@/utils"; +import { + isFileAlreadyExistsError, + isInVaultCache, + isNameTooLongError, + listMarkdownFiles, + patchFrontmatter, + readFrontmatterViaAdapter, + trashFile, +} from "@/utils/vaultAdapterUtils"; +import { TFile, type App } from "obsidian"; +import { Notice } from "obsidian"; +import type { AgentChatMessage, BackendId } from "./types"; + +const SAFE_FILENAME_BYTE_LIMIT = 100; +export const AGENT_FILENAME_PREFIX = "agent__"; +export const AGENT_CHAT_MODE = "agent"; + +/** + * Result of `loadFile` — restores display-only Agent Mode messages plus + * routing info needed to spawn the right backend session. + */ +export interface LoadedAgentChat { + messages: AgentChatMessage[]; + backendId: BackendId; + topic?: string; + label?: string; +} + +interface ExistingMeta { + topic?: string; + label?: string; + lastAccessedAt?: number; +} + +/** + * Escape a string for safe YAML double-quoted string value. Strips control + * chars (including newlines) up front — a stray `\n` in the user's topic + * would otherwise terminate the line and corrupt the rest of the frontmatter. + */ +function escapeYamlString(str: string): string { + return ( + str + // eslint-disable-next-line no-control-regex + .replace(/[\x00-\x1F\x7F]/g, " ") + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + ); +} + +/** + * Inverse of `escapeYamlString` for the values our hand-rolled frontmatter + * parser extracts. Only handles the two escapes we emit (`\\` and `\"`). + */ +function unescapeYamlString(str: string): string { + let out = ""; + for (let i = 0; i < str.length; i++) { + const c = str[i]; + if (c === "\\" && i + 1 < str.length) { + const next = str[i + 1]; + if (next === "\\" || next === '"') { + out += next; + i++; + continue; + } + } + out += c; + } + return out; +} + +/** + * Backend-agnostic on-disk persistence for Agent Mode sessions. Mirrors the + * legacy `ChatPersistenceManager` shape so a single `ChatHistoryPopover` can + * render both lists, but with three differences: + * + * 1. Files are prefixed with `agent__` so they never collide with legacy + * project-prefixed (`{projectId}__`) or unprefixed chats. + * 2. Frontmatter records `mode: agent` and `backendId: ` so the loader + * can route a history click to the right backend. + * 3. No project / AI-topic generation — Agent Mode has no project concept + * and no chain manager to generate titles with. + * + * Sessions with zero visible messages are never written. + */ +export class AgentChatPersistenceManager { + constructor(private readonly app: App) {} + + /** + * Save the supplied messages to disk. Returns the resulting file (or the + * existing path on hidden-directory writes), or `null` when the session has + * nothing user-visible to persist. + * + * `existingPath` lets the caller pin updates to a previously-saved file so + * they're applied even if the messages list shrinks below the original + * `firstMessageEpoch`. When omitted, the file is matched by epoch. + */ + async saveSession( + messages: AgentChatMessage[], + backendId: BackendId, + options?: { label?: string | null; modelKey?: string; existingPath?: string } + ): Promise<{ path: string } | null> { + if (messages.length === 0) return null; + + try { + const settings = getSettings(); + const chatContent = this.formatChatContent(messages); + const firstMessageEpoch = messages[0].timestamp?.epoch ?? Date.now(); + + await ensureFolderExists(settings.defaultSaveFolder); + + const existingFile = options?.existingPath + ? this.resolveExistingFile(options.existingPath) + : null; + const existingMeta = existingFile ? await this.readExistingMeta(existingFile) : {}; + + const preferredFileName = existingFile + ? existingFile.path + : this.generateFileName(messages, firstMessageEpoch, existingMeta.topic); + + const noteContent = this.generateNoteContent({ + chatContent, + firstMessageEpoch, + backendId, + topic: existingMeta.topic, + label: options?.label ?? existingMeta.label, + modelKey: options?.modelKey, + lastAccessedAt: existingMeta.lastAccessedAt, + }); + + if (existingFile && isInVaultCache(this.app, existingFile.path)) { + await this.app.vault.modify(existingFile, noteContent); + return { path: existingFile.path }; + } + + if ( + !isInVaultCache(this.app, preferredFileName) && + (await this.app.vault.adapter.exists(preferredFileName)) + ) { + await this.app.vault.adapter.write(preferredFileName, noteContent); + return { path: preferredFileName }; + } + + try { + const created = await this.app.vault.create(preferredFileName, noteContent); + return { path: created.path }; + } catch (err) { + if (isFileAlreadyExistsError(err)) { + await this.app.vault.adapter.write(preferredFileName, noteContent); + return { path: preferredFileName }; + } + if (isNameTooLongError(err)) { + logWarn("[AgentChatPersistenceManager] Filename too long, falling back to minimal name"); + const fallback = `${settings.defaultSaveFolder}/${AGENT_FILENAME_PREFIX}chat-${firstMessageEpoch}.md`; + try { + const created = await this.app.vault.create(fallback, noteContent); + return { path: created.path }; + } catch (fallbackErr) { + if (isFileAlreadyExistsError(fallbackErr)) { + await this.app.vault.adapter.write(fallback, noteContent); + return { path: fallback }; + } + throw fallbackErr; + } + } + throw err; + } + } catch (error) { + logError("[AgentChatPersistenceManager] Error saving session:", error); + return null; + } + } + + /** + * Parse a saved agent chat file back into `AgentChatMessage`s and routing + * info. Tool/plan/thought parts are not restored — the markdown format only + * preserves sender + text (display-only history, mirroring legacy mode). + */ + async loadFile(file: TFile): Promise { + let content: string; + try { + content = await this.app.vault.read(file); + } catch { + content = await this.app.vault.adapter.read(file.path); + } + + const { frontmatter, body } = this.splitFrontmatter(content); + const backendId = (frontmatter.backendId ?? "").trim(); + if (!backendId) { + throw new Error(`Missing backendId in agent chat frontmatter: ${file.path}`); + } + const topic = frontmatter.topic?.trim() || undefined; + const label = frontmatter.agentLabel?.trim() || undefined; + const messages = this.parseChatBody(body); + + logInfo( + `[AgentChatPersistenceManager] Loaded ${messages.length} messages from ${file.path} (backend=${backendId})` + ); + return { messages, backendId, topic, label }; + } + + /** + * List every persisted Agent Mode chat file (across all backends). Filters + * by the `agent__` filename prefix so the result is backend-agnostic and + * never collides with legacy or project chats. + */ + async getAgentChatHistoryFiles(): Promise { + const settings = getSettings(); + const files = await listMarkdownFiles(this.app, settings.defaultSaveFolder); + return files.filter((file) => file.basename.startsWith(AGENT_FILENAME_PREFIX)); + } + + /** Update the user-visible topic in frontmatter. */ + async updateTopic(fileId: string, newTopic: string): Promise { + await patchFrontmatter(this.app, fileId, { topic: newTopic.trim() }); + } + + async deleteFile(fileId: string): Promise { + const file = this.app.vault.getAbstractFileByPath(fileId); + if (file) { + await trashFile(this.app, file); + new Notice("Chat moved to trash."); + return; + } + if (await this.app.vault.adapter.exists(fileId)) { + await this.app.vault.adapter.remove(fileId); + new Notice("Chat deleted."); + return; + } + throw new Error("Chat file not found."); + } + + private resolveExistingFile(path: string): TFile | null { + const file = this.app.vault.getAbstractFileByPath(path); + return file instanceof TFile ? file : null; + } + + private async readExistingMeta(file: TFile): Promise { + const cached = this.app.metadataCache.getFileCache(file)?.frontmatter; + if (cached) { + return { + topic: cached.topic, + label: cached.agentLabel, + lastAccessedAt: + typeof cached.lastAccessedAt === "number" ? cached.lastAccessedAt : undefined, + }; + } + try { + const fm = await readFrontmatterViaAdapter(this.app, file.path); + if (!fm) return {}; + const lastAccessed = fm.lastAccessedAt ? Number(fm.lastAccessedAt) : undefined; + return { + topic: fm.topic, + label: fm.agentLabel, + lastAccessedAt: lastAccessed && Number.isFinite(lastAccessed) ? lastAccessed : undefined, + }; + } catch { + return {}; + } + } + + private formatChatContent(messages: AgentChatMessage[]): string { + return messages + .map((m) => { + const ts = m.timestamp ? m.timestamp.display : "Unknown time"; + return `**${m.sender}**: ${m.message}\n[Timestamp: ${ts}]`; + }) + .join("\n\n"); + } + + private parseChatBody(body: string): AgentChatMessage[] { + const messages: AgentChatMessage[] = []; + const pattern = /\*\*(user|ai)\*\*: ([\s\S]*?)(?=(?:\n\*\*(?:user|ai)\*\*: )|$)/g; + + let match: RegExpExecArray | null; + while ((match = pattern.exec(body)) !== null) { + const sender = match[1] === "user" ? USER_SENDER : AI_SENDER; + const fullContent = match[2].trim(); + + const lines = fullContent.split("\n"); + let endIndex = lines.length; + let timestampStr = "Unknown time"; + + if (lines[endIndex - 1]?.startsWith("[Timestamp: ")) { + const tsMatch = lines[endIndex - 1].match(/\[Timestamp: (.*?)\]/); + if (tsMatch) { + timestampStr = tsMatch[1]; + endIndex--; + } + } + + const messageText = lines.slice(0, endIndex).join("\n").trim(); + + let timestamp: FormattedDateTime | null = null; + if (timestampStr !== "Unknown time") { + const date = new Date(timestampStr); + if (!isNaN(date.getTime())) { + timestamp = { + epoch: date.getTime(), + display: timestampStr, + fileName: "", + }; + } + } + + // Deterministic id: stable across reloads so React keeps message + // identity when the UI re-renders the same loaded chat. Uses the + // message's own epoch when present, falling back to the index. + const id = timestamp + ? `loaded-${messages.length}-${timestamp.epoch}` + : `loaded-${messages.length}`; + messages.push({ + id, + message: messageText, + sender, + isVisible: true, + timestamp, + }); + } + return messages; + } + + private splitFrontmatter(content: string): { + frontmatter: Record; + body: string; + } { + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) return { frontmatter: {}, body: content }; + const frontmatter: Record = {}; + for (const line of match[1].split("\n")) { + const m = line.match(/^(\w+):\s*(.+)/); + if (!m) continue; + const raw = m[2].trim(); + // Unquote and unescape: only double-quoted values were escaped on save, + // so single-quoted / unquoted values are returned verbatim. + let value: string; + if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) { + value = unescapeYamlString(raw.slice(1, -1)); + } else if (raw.startsWith("'") && raw.endsWith("'") && raw.length >= 2) { + value = raw.slice(1, -1); + } else { + value = raw; + } + frontmatter[m[1]] = value; + } + return { frontmatter, body: content.slice(match[0].length).trim() }; + } + + private generateFileName( + messages: AgentChatMessage[], + firstMessageEpoch: number, + topic?: string + ): string { + const settings = getSettings(); + const formatted = formatDateTime(new Date(firstMessageEpoch)); + const timestampFileName = formatted.fileName; + + let topicForFilename: string; + if (topic) { + topicForFilename = topic; + } else { + const firstUser = messages.find((m) => m.sender === USER_SENDER); + topicForFilename = firstUser + ? firstUser.message + .replace(/\[\[([^\]]+)\]\]/g, "$1") + .replace(/[{}[\]]/g, "") + .split(/\s+/) + .slice(0, 10) + .join(" ") + // eslint-disable-next-line no-control-regex + .replace(/[\\/:*?"<>|\x00-\x1F]/g, "") + .trim() || "Untitled Agent Chat" + : "Untitled Agent Chat"; + } + + let customFileName = settings.defaultConversationNoteName || "{$date}_{$time}__{$topic}"; + const filePrefix = AGENT_FILENAME_PREFIX; + + const extensionBytes = getUtf8ByteLength(".md"); + const filePrefixBytes = getUtf8ByteLength(filePrefix); + + const formatOverhead = customFileName + .replace("{$topic}", "") + .replace("{$date}", timestampFileName.split("_")[0]) + .replace("{$time}", timestampFileName.split("_")[1]); + const formatOverheadBytes = getUtf8ByteLength(formatOverhead); + + const topicByteBudget = Math.max( + 20, + SAFE_FILENAME_BYTE_LIMIT - extensionBytes - filePrefixBytes - formatOverheadBytes + ); + + const topicWithUnderscores = topicForFilename.replace(/\s+/g, "_"); + const truncatedTopic = truncateToByteLimit(topicWithUnderscores, topicByteBudget); + + customFileName = customFileName + .replace("{$topic}", truncatedTopic) + .replace("{$date}", timestampFileName.split("_")[0]) + .replace("{$time}", timestampFileName.split("_")[1]); + + const sanitizedFileName = customFileName + .replace(/\[\[([^\]]+)\]\]/g, "$1") + .replace(/[{}[\]]/g, "_") + // eslint-disable-next-line no-control-regex + .replace(/[\\/:*?"<>|\x00-\x1F]/g, "_"); + + const baseNameWithPrefix = `${filePrefix}${sanitizedFileName}.md`; + if (getUtf8ByteLength(baseNameWithPrefix) > SAFE_FILENAME_BYTE_LIMIT) { + const availableForBasename = SAFE_FILENAME_BYTE_LIMIT - extensionBytes - filePrefixBytes; + const truncatedBasename = truncateToByteLimit(sanitizedFileName, availableForBasename); + return `${settings.defaultSaveFolder}/${filePrefix}${truncatedBasename}.md`; + } + + return `${settings.defaultSaveFolder}/${baseNameWithPrefix}`; + } + + private generateNoteContent(args: { + chatContent: string; + firstMessageEpoch: number; + backendId: BackendId; + topic?: string; + label?: string | null; + modelKey?: string; + lastAccessedAt?: number; + }): string { + const settings = getSettings(); + const lines: string[] = [ + "---", + `epoch: ${args.firstMessageEpoch}`, + `mode: ${AGENT_CHAT_MODE}`, + `backendId: ${args.backendId}`, + ]; + if (args.topic) lines.push(`topic: "${escapeYamlString(args.topic)}"`); + if (args.label) lines.push(`agentLabel: "${escapeYamlString(args.label)}"`); + if (args.modelKey) lines.push(`modelKey: "${escapeYamlString(args.modelKey)}"`); + if (args.lastAccessedAt) lines.push(`lastAccessedAt: ${args.lastAccessedAt}`); + lines.push("tags:"); + lines.push(` - ${settings.defaultConversationTag}`); + lines.push("---"); + lines.push(""); + lines.push(args.chatContent); + return lines.join("\n"); + } +} diff --git a/src/agentMode/session/AgentChatUIState.ts b/src/agentMode/session/AgentChatUIState.ts new file mode 100644 index 00000000..3dcb1ed8 --- /dev/null +++ b/src/agentMode/session/AgentChatUIState.ts @@ -0,0 +1,151 @@ +import { logError, logWarn } from "@/logger"; +import type { AgentChatBackend } from "@/agentMode/session/AgentChatBackend"; +import type { AgentSession } from "@/agentMode/session/AgentSession"; +import type { + AgentChatMessage, + BackendState, + CurrentPlan, + PlanDecisionAction, +} from "@/agentMode/session/types"; +import type { MessageContext } from "@/types/message"; + +/** + * `AgentChatBackend` implementation backed by an `AgentSession`. The Agent + * Mode UI tree consumes this exclusively — it knows nothing about the legacy + * `ChatUIState` / `ChatManager` stack. + * + * Edit, regenerate, and persistence operations are intentionally absent — + * they don't have ACP semantics and Agent Mode chat persistence is deferred. + */ +export class AgentChatUIState implements AgentChatBackend { + private listeners = new Set<() => void>(); + + constructor(private readonly session: AgentSession) { + // Forward message, status, and model changes. The chat UI gates the + // send button on `isStarting()`, so it needs to re-render when status + // transitions out of `"starting"`. + this.session.subscribe({ + onMessagesChanged: () => this.notifyListeners(), + onStatusChanged: () => this.notifyListeners(), + onModelChanged: () => this.notifyListeners(), + onCurrentPlanChanged: () => this.notifyListeners(), + }); + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notifyListeners(): void { + for (const l of this.listeners) { + try { + l(); + } catch (e) { + logWarn("[AgentChatUIState] listener threw", e); + } + } + } + + /** + * Append a user message and kick off the ACP turn. Returns the new user + * message id synchronously plus a `turn` promise the caller can await for + * loading-state lifecycle (Stop button, input lock). + */ + sendMessage( + text: string, + context?: MessageContext, + content?: unknown[] + ): { id: string; turn: Promise } { + const { userMessageId, turn } = this.session.sendPrompt(text, context, content); + this.notifyListeners(); + const wrapped = turn.then( + () => undefined, + (err) => { + logError("[AgentMode] turn failed", err); + } + ); + return { id: userMessageId, turn: wrapped }; + } + + async cancel(): Promise { + await this.session.cancel(); + } + + async deleteMessage(id: string): Promise { + // Refuse delete during an in-flight turn: the placeholder assistant + // message is what streaming notifications target, and removing it would + // leave the session writing into a vanished id. + const status = this.session.getStatus(); + if (status === "running" || status === "awaiting_permission") { + logWarn("[AgentChatUIState] delete refused while turn is in flight"); + return false; + } + const ok = this.session.store.deleteMessage(id); + if (ok) this.notifyListeners(); + return ok; + } + + clearMessages(): void { + this.session.store.clear(); + this.notifyListeners(); + } + + getMessages(): AgentChatMessage[] { + return this.session.store.getDisplayMessages(); + } + + isStarting(): boolean { + return this.session.getStatus() === "starting"; + } + + getBackendState(): BackendState | null { + return this.session.getState(); + } + + canSwitchModel(): boolean | null { + return this.session.canSwitchModel(); + } + + canSwitchEffort(): boolean | null { + return this.session.canSwitchEffort(); + } + + canSwitchMode(): boolean | null { + return this.session.canSwitchMode(); + } + + hasPendingPlanPermission(): boolean { + return this.session.hasPendingPlanPermission(); + } + + getCurrentPlan(): CurrentPlan | null { + return this.session.getCurrentPlan(); + } + + async resolvePlanProposal( + proposalId: string, + decision: PlanDecisionAction, + feedbackText?: string + ): Promise { + const plan = this.session.getCurrentPlan(); + if (!plan || plan.id !== proposalId || plan.decision !== "pending") return; + if (!plan.permissionGated || !plan.pendingToolCallId) { + logWarn("[AgentChatUIState] non-gated plan card has no resolution path"); + return; + } + const trimmedFeedback = decision === "feedback" ? feedbackText?.trim() : undefined; + // Resolve the underlying ACP permission. Approve unblocks the agent + // and continues the same turn; reject denies with `"User declined"`; + // feedback rides the typed text through the same deny `message` so + // the agent revises in-turn instead of receiving a separate + // follow-up prompt. + this.session.resolvePlanProposalPermission( + plan.pendingToolCallId, + decision === "approve", + trimmedFeedback + ); + this.session.finalizePlanDecision(plan.id); + this.notifyListeners(); + } +} diff --git a/src/agentMode/session/AgentMessageStore.test.ts b/src/agentMode/session/AgentMessageStore.test.ts new file mode 100644 index 00000000..f532e03d --- /dev/null +++ b/src/agentMode/session/AgentMessageStore.test.ts @@ -0,0 +1,223 @@ +import { AI_SENDER } from "@/constants"; +import { AgentMessagePart } from "@/agentMode/session/types"; +import { formatDateTime } from "@/utils"; +import { AgentMessageStore } from "./AgentMessageStore"; + +jest.mock("@/logger", () => ({ + logInfo: jest.fn(), + logWarn: jest.fn(), + logError: jest.fn(), +})); + +describe("AgentMessageStore", () => { + const placeholder = () => ({ + message: "", + sender: AI_SENDER, + timestamp: formatDateTime(new Date()), + isVisible: true as const, + parts: [] as AgentMessagePart[], + }); + + it("appendDisplayText accumulates streaming chunks", () => { + const store = new AgentMessageStore(); + const id = store.addMessage(placeholder()); + store.appendDisplayText(id, "Hello, "); + store.appendDisplayText(id, "world."); + expect(store.getMessage(id)?.message).toBe("Hello, world."); + }); + + it("appendDisplayText returns false for unknown message", () => { + const store = new AgentMessageStore(); + expect(store.appendDisplayText("missing", "x")).toBe(false); + }); + + it("appendAgentText folds successive chunks into one trailing text part", () => { + const store = new AgentMessageStore(); + const id = store.addMessage(placeholder()); + store.appendAgentText(id, "Hello, "); + store.appendAgentText(id, "world."); + const msg = store.getMessage(id); + const parts = msg?.parts ?? []; + expect(parts).toHaveLength(1); + expect(parts[0]).toEqual({ kind: "text", text: "Hello, world." }); + // Flat body stays in sync for persistence / search / error append. + expect(msg?.message).toBe("Hello, world."); + }); + + it("appendAgentText starts a new text part when interrupted by a tool call", () => { + const store = new AgentMessageStore(); + const id = store.addMessage(placeholder()); + store.appendAgentText(id, "before"); + store.upsertAgentPart(id, { + kind: "tool_call", + id: "tc1", + title: "Read README", + status: "completed", + }); + store.appendAgentText(id, "after"); + const parts = store.getMessage(id)?.parts ?? []; + expect(parts.map((p) => p.kind)).toEqual(["text", "tool_call", "text"]); + expect(parts[0]).toEqual({ kind: "text", text: "before" }); + expect(parts[2]).toEqual({ kind: "text", text: "after" }); + }); + + it("appendAgentText returns false for unknown message", () => { + const store = new AgentMessageStore(); + expect(store.appendAgentText("missing", "x")).toBe(false); + }); + + it("appendAgentThought folds successive chunks into one part", () => { + const store = new AgentMessageStore(); + const id = store.addMessage(placeholder()); + store.appendAgentThought(id, "Thinking"); + store.appendAgentThought(id, " harder"); + const parts = store.getMessage(id)?.parts ?? []; + expect(parts).toHaveLength(1); + expect(parts[0]).toEqual({ kind: "thought", text: "Thinking harder" }); + }); + + it("upsertAgentPart appends new tool_call by toolCallId", () => { + const store = new AgentMessageStore(); + const id = store.addMessage(placeholder()); + store.upsertAgentPart(id, { + kind: "tool_call", + id: "tc1", + title: "Read README", + status: "pending", + }); + const parts = store.getMessage(id)?.parts ?? []; + expect(parts).toHaveLength(1); + expect(parts[0]).toMatchObject({ kind: "tool_call", id: "tc1", title: "Read README" }); + }); + + it("upsertAgentPart replaces existing tool_call when ids match", () => { + const store = new AgentMessageStore(); + const id = store.addMessage(placeholder()); + store.upsertAgentPart(id, { + kind: "tool_call", + id: "tc1", + title: "Read README", + status: "pending", + }); + store.upsertAgentPart(id, { + kind: "tool_call", + id: "tc1", + title: "Read README", + status: "completed", + output: [{ type: "text", text: "ok" }], + }); + const parts = store.getMessage(id)?.parts ?? []; + expect(parts).toHaveLength(1); + expect(parts[0]).toMatchObject({ + kind: "tool_call", + id: "tc1", + status: "completed", + output: [{ type: "text", text: "ok" }], + }); + }); + + it("upsertAgentPart returns false when re-applying an identical snapshot", () => { + const store = new AgentMessageStore(); + const id = store.addMessage(placeholder()); + const part: AgentMessagePart = { + kind: "tool_call", + id: "tc1", + title: "Read README", + status: "pending", + }; + expect(store.upsertAgentPart(id, part)).toBe(true); + expect(store.upsertAgentPart(id, { ...part })).toBe(false); + }); + + it("upsertAgentPart compares large repeated tool outputs without duplicating", () => { + const store = new AgentMessageStore(); + const id = store.addMessage(placeholder()); + const part: AgentMessagePart = { + kind: "tool_call", + id: "tc1", + title: "Search", + status: "completed", + input: { query: "communication drill", nested: { text: "x".repeat(20_000) } }, + output: [ + { + type: "text", + text: + "a".repeat(12_000) + + "\n\n[Tool output truncated in Copilot UI: 8,000 characters omitted.]", + truncated: true, + originalLength: 20_000, + omittedLength: 8_000, + }, + ], + }; + + expect(store.upsertAgentPart(id, part)).toBe(true); + expect( + store.upsertAgentPart(id, { ...part, output: part.output?.map((o) => ({ ...o })) }) + ).toBe(false); + expect(store.getMessage(id)?.parts).toHaveLength(1); + }); + + it("upsertAgentPart treats plan as singleton (replace, not duplicate)", () => { + const store = new AgentMessageStore(); + const id = store.addMessage(placeholder()); + store.upsertAgentPart(id, { + kind: "plan", + entries: [{ content: "step 1", priority: "high", status: "pending" }], + }); + store.upsertAgentPart(id, { + kind: "plan", + entries: [ + { content: "step 1", priority: "high", status: "completed" }, + { content: "step 2", priority: "medium", status: "pending" }, + ], + }); + const parts = store.getMessage(id)?.parts ?? []; + const planParts = parts.filter((p) => p.kind === "plan"); + expect(planParts).toHaveLength(1); + expect(planParts[0]).toMatchObject({ + kind: "plan", + entries: expect.arrayContaining([ + expect.objectContaining({ content: "step 1", status: "completed" }), + expect.objectContaining({ content: "step 2" }), + ]), + }); + }); + + it("getDisplayMessages includes parts", () => { + const store = new AgentMessageStore(); + const id = store.addMessage(placeholder()); + store.upsertAgentPart(id, { + kind: "tool_call", + id: "tc1", + title: "x", + status: "pending", + }); + const msg = store.getDisplayMessages().find((m) => m.id === id); + expect(msg?.parts).toHaveLength(1); + }); + + it("markMessageError flags the message and appends formatted error text", () => { + const store = new AgentMessageStore(); + const id = store.addMessage({ + message: "partial reply", + sender: AI_SENDER, + timestamp: formatDateTime(new Date()), + isVisible: true, + }); + store.markMessageError(id, "boom"); + const msg = store.getMessage(id); + expect(msg?.isErrorMessage).toBe(true); + expect(msg?.message).toContain("partial reply"); + expect(msg?.message).toContain("**Error:** boom"); + }); + + it("truncateAfterMessageId drops everything after the target", () => { + const store = new AgentMessageStore(); + const a = store.addMessage(placeholder()); + store.addMessage(placeholder()); + store.addMessage(placeholder()); + store.truncateAfterMessageId(a); + expect(store.getDisplayMessages()).toHaveLength(1); + }); +}); diff --git a/src/agentMode/session/AgentMessageStore.ts b/src/agentMode/session/AgentMessageStore.ts new file mode 100644 index 00000000..6b029eb9 --- /dev/null +++ b/src/agentMode/session/AgentMessageStore.ts @@ -0,0 +1,394 @@ +import { logInfo } from "@/logger"; +import { + AgentChatMessage, + AgentMessagePart, + AgentToolCallOutput, + NewAgentChatMessage, + StopReason, +} from "@/agentMode/session/types"; +import { FormattedDateTime, MessageContext } from "@/types/message"; +import { formatDateTime } from "@/utils"; + +/** + * Internal storage shape for one Agent Mode message. Mirrors `AgentChatMessage` + * but uses `displayText` internally to keep the streaming append APIs explicit + * and to leave room for future fields without changing the public type. + */ +interface StoredAgentMessage { + id: string; + displayText: string; + sender: string; + timestamp: FormattedDateTime; + isVisible: boolean; + isErrorMessage?: boolean; + parts?: AgentMessagePart[]; + context?: MessageContext; + content?: unknown[]; + turnStopReason?: StopReason; + turnDurationMs?: number; +} + +const MAX_COMPARE_JSON_CHARS = 8_000; +const MAX_COMPARE_EDGE_CHARS = 512; +const MAX_COMPARE_TEXT_EDGE_CHARS = 128; + +/** + * Stable identity for an agent part. Tool calls key on `tool:` so + * a `tool_call_update` notification can find and replace the right entry. + * `plan` parts are singletons per message so they key on the literal `"plan"`. + * Thoughts have no key — they're folded by `appendAgentThought`. + */ +function agentPartId(part: AgentMessagePart): string | undefined { + if (part.kind === "tool_call") return `tool:${part.id}`; + if (part.kind === "plan") return "plan"; + return undefined; +} + +/** + * Structural equality for agent parts. Lets `upsertAgentPart` skip a + * notify/re-render when the ACP transport resends a tool-call snapshot whose + * fields haven't actually changed. + */ +function partsEqual(a: AgentMessagePart, b: AgentMessagePart): boolean { + if (a === b) return true; + if (a.kind !== b.kind) return false; + + switch (a.kind) { + case "text": + if (b.kind !== "text") return false; + return a.text === b.text; + case "thought": + if (b.kind !== "thought") return false; + return a.text === b.text; + case "plan": + if (b.kind !== "plan") return false; + return planEntriesEqual(a.entries, b.entries); + case "tool_call": + if (b.kind !== "tool_call") return false; + return ( + a.id === b.id && + a.title === b.title && + a.toolKind === b.toolKind && + a.status === b.status && + a.vendorToolName === b.vendorToolName && + a.parentToolCallId === b.parentToolCallId && + boundedValueEqual(a.input, b.input) && + locationsEqual(a.locations, b.locations) && + toolOutputsEqual(a.output, b.output) + ); + } +} + +/** Compare plan entries without stringifying the whole part object. */ +function planEntriesEqual( + a: Extract["entries"], + b: Extract["entries"] +): boolean { + if (a === b) return true; + if (a.length !== b.length) return false; + return a.every( + (entry, index) => + entry.content === b[index].content && + entry.priority === b[index].priority && + entry.status === b[index].status + ); +} + +/** Compare tool locations by their scalar fields. */ +function locationsEqual( + a: Extract["locations"], + b: Extract["locations"] +): boolean { + if (a === b) return true; + if (!a || !b) return a === b; + if (a.length !== b.length) return false; + return a.every((loc, index) => loc.path === b[index].path && loc.line === b[index].line); +} + +/** Compare rendered tool outputs with bounded string work. */ +function toolOutputsEqual( + a: AgentToolCallOutput[] | undefined, + b: AgentToolCallOutput[] | undefined +): boolean { + if (a === b) return true; + if (!a || !b) return a === b; + if (a.length !== b.length) return false; + + return a.every((output, index) => { + const other = b[index]; + if (output.type !== other.type) return false; + if (output.type === "diff" && other.type === "diff") { + return ( + output.path === other.path && + output.oldText === other.oldText && + output.newText === other.newText + ); + } + if (output.type === "text" && other.type === "text") { + return ( + output.truncated === other.truncated && + output.originalLength === other.originalLength && + output.omittedLength === other.omittedLength && + textFingerprint(output.text) === textFingerprint(other.text) + ); + } + return false; + }); +} + +/** Compare arbitrary tool input with bounded stringify work. */ +function boundedValueEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + return valueFingerprint(a) === valueFingerprint(b); +} + +/** Create a stable, bounded comparison key for arbitrary JSON-like values. */ +function valueFingerprint(value: unknown): string { + let json: string; + try { + json = JSON.stringify(value); + } catch { + json = String(value); + } + if (json.length <= MAX_COMPARE_JSON_CHARS) return json; + return `${json.length}:${json.slice(0, MAX_COMPARE_EDGE_CHARS)}:${json.slice( + -MAX_COMPARE_EDGE_CHARS + )}`; +} + +/** Create a bounded comparison key for long text outputs. */ +function textFingerprint(text: string): string { + if (text.length <= MAX_COMPARE_TEXT_EDGE_CHARS * 2) return text; + return `${text.length}:${text.slice(0, MAX_COMPARE_TEXT_EDGE_CHARS)}:${text.slice( + -MAX_COMPARE_TEXT_EDGE_CHARS + )}`; +} + +/** + * Single source of truth for one Agent Mode chat session. The UI subscribes + * via the surrounding `AgentSession`, the session writes streamed updates + * here, and computed views feed React. + * + * Distinct from the legacy `MessageRepository` because Agent Mode messages + * have structured `parts` (tool calls, thoughts, plans) and Agent Mode has no + * concept of `processedText` / `contextEnvelope` — ACP owns the model's view + * of the conversation. + */ +export class AgentMessageStore { + private messages: StoredAgentMessage[] = []; + + private generateId(): string { + return `msg-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + } + + /** Add a message; returns its assigned id. */ + addMessage(message: NewAgentChatMessage): string { + const id = message.id || this.generateId(); + const timestamp = message.timestamp || formatDateTime(new Date()); + this.messages.push({ + id, + displayText: message.message, + sender: message.sender, + timestamp, + context: message.context, + isVisible: message.isVisible !== false, + isErrorMessage: message.isErrorMessage, + content: message.content, + parts: message.parts, + turnStopReason: message.turnStopReason, + turnDurationMs: message.turnDurationMs, + }); + return id; + } + + /** + * Stamp a finished turn's `stopReason` and frozen `durationMs` onto its + * placeholder assistant message. Returns false if the message is missing or + * already marked complete — the latter lets callers skip notifying. + */ + markTurnComplete(id: string, stopReason: StopReason, durationMs: number): boolean { + const msg = this.messages.find((m) => m.id === id); + if (!msg) return false; + if (msg.turnStopReason !== undefined) return false; + msg.turnStopReason = stopReason; + msg.turnDurationMs = durationMs; + return true; + } + + /** + * Append text to a message body. Used to stream `agent_message_chunk` + * updates into the placeholder assistant message. Returns false if the + * target message is missing (the session was likely reset mid-turn). + */ + appendDisplayText(id: string, chunk: string): boolean { + const msg = this.messages.find((m) => m.id === id); + if (!msg) return false; + msg.displayText += chunk; + return true; + } + + /** + * Append assistant prose to the trailing `text` part, creating one if the + * last part is a different kind. Mirrors `appendAgentThought` so streamed + * `agent_message_chunk`s interleave chronologically with tool calls and + * thoughts inside `parts[]`. Also keeps `displayText` in sync so callers + * that read the flattened body (persistence, search, error append) stay + * correct. + */ + appendAgentText(id: string, text: string): boolean { + const msg = this.messages.find((m) => m.id === id); + if (!msg) return false; + msg.displayText += text; + if (!msg.parts) msg.parts = []; + const last = msg.parts[msg.parts.length - 1]; + if (last && last.kind === "text") { + last.text += text; + } else { + msg.parts.push({ kind: "text", text }); + } + return true; + } + + /** + * Append text to the trailing `thought` part, creating one if absent. Folds + * multiple `agent_thought_chunk` updates into a single collapsible block + * instead of one block per chunk. + */ + appendAgentThought(id: string, text: string): boolean { + const msg = this.messages.find((m) => m.id === id); + if (!msg) return false; + if (!msg.parts) msg.parts = []; + const last = msg.parts[msg.parts.length - 1]; + if (last && last.kind === "thought") { + last.text += text; + } else { + msg.parts.push({ kind: "thought", text }); + } + return true; + } + + /** + * Replace an agent part by its stable identity (see `agentPartId`). Appends + * if no existing part matches. Returns false when the message is missing OR + * when the new part is structurally identical to the existing one — callers + * use this to skip redundant React notifications. + */ + upsertAgentPart(id: string, part: AgentMessagePart): boolean { + const msg = this.messages.find((m) => m.id === id); + if (!msg) return false; + if (!msg.parts) msg.parts = []; + const partId = agentPartId(part); + if (partId !== undefined) { + const idx = msg.parts.findIndex((p) => agentPartId(p) === partId); + if (idx !== -1) { + if (partsEqual(msg.parts[idx], part)) return false; + msg.parts[idx] = part; + return true; + } + } + msg.parts.push(part); + return true; + } + + /** + * Mark a message as an error and append the error text to its display body. + * Used when a turn rejects mid-stream so the partial placeholder gets a + * visible error instead of looking like a normal truncated reply. + */ + markMessageError(id: string, errorText: string): boolean { + const msg = this.messages.find((m) => m.id === id); + if (!msg) return false; + msg.isErrorMessage = true; + const suffix = msg.displayText.length > 0 ? "\n\n" : ""; + msg.displayText += `${suffix}**Error:** ${errorText}`; + return true; + } + + /** + * Whether an assistant placeholder has emitted any user-visible activity. + * Used to distinguish a legitimate completed turn from a blank backend + * response that would otherwise render as an empty assistant block. + */ + hasAssistantActivity(id: string): boolean { + const msg = this.messages.find((m) => m.id === id); + if (!msg) return false; + if (msg.displayText.trim().length > 0) return true; + return (msg.parts ?? []).some((part) => { + if (part.kind === "text" || part.kind === "thought") { + return part.text.trim().length > 0; + } + return true; + }); + } + + deleteMessage(id: string): boolean { + const idx = this.messages.findIndex((m) => m.id === id); + if (idx === -1) return false; + this.messages.splice(idx, 1); + return true; + } + + clear(): void { + this.messages = []; + } + + truncateAfterMessageId(messageId: string): void { + const idx = this.messages.findIndex((m) => m.id === messageId); + if (idx !== -1) { + this.messages = this.messages.slice(0, idx + 1); + } + } + + /** Visible messages, shaped for the UI. */ + getDisplayMessages(): AgentChatMessage[] { + return this.messages.filter((m) => m.isVisible).map((m) => this.toAgentChatMessage(m)); + } + + getMessage(id: string): AgentChatMessage | undefined { + const msg = this.messages.find((m) => m.id === id); + return msg ? this.toAgentChatMessage(msg) : undefined; + } + + loadMessages(messages: AgentChatMessage[]): void { + this.clear(); + for (const msg of messages) { + this.messages.push({ + id: msg.id || this.generateId(), + displayText: msg.message, + sender: msg.sender, + timestamp: msg.timestamp || formatDateTime(new Date()), + context: msg.context, + isVisible: msg.isVisible !== false, + isErrorMessage: msg.isErrorMessage, + content: msg.content, + parts: msg.parts, + turnStopReason: msg.turnStopReason, + turnDurationMs: msg.turnDurationMs, + }); + } + logInfo(`[AgentMessageStore] Loaded ${messages.length} messages`); + } + + getDebugInfo() { + return { + totalMessages: this.messages.length, + visibleMessages: this.messages.filter((m) => m.isVisible).length, + }; + } + + private toAgentChatMessage(m: StoredAgentMessage): AgentChatMessage { + return { + id: m.id, + message: m.displayText, + sender: m.sender, + timestamp: m.timestamp, + isVisible: m.isVisible, + context: m.context, + isErrorMessage: m.isErrorMessage, + content: m.content, + parts: m.parts, + turnStopReason: m.turnStopReason, + turnDurationMs: m.turnDurationMs, + }; + } +} diff --git a/src/agentMode/session/AgentModelPreloader.ts b/src/agentMode/session/AgentModelPreloader.ts new file mode 100644 index 00000000..34793a66 --- /dev/null +++ b/src/agentMode/session/AgentModelPreloader.ts @@ -0,0 +1,194 @@ +import { logError, logInfo, logWarn } from "@/logger"; +import type CopilotPlugin from "@/main"; +import { getSettings } from "@/settings/model"; +import { App, FileSystemAdapter, Platform } from "obsidian"; +import { MethodUnsupportedError } from "./errors"; +import { backendStateSignature } from "./translateBackendState"; +import type { BackendDescriptor, BackendId, BackendProcess, BackendState } from "./types"; + +/** + * Plugin-lifetime cache of per-backend session state. Backends expose + * `BackendState` only as a side-effect of session creation / resume / load, + * so without this preload the picker would show no entries for non-active + * backends and would blink empty during the round-trip on a fresh session. + * + * Probes once per backend at startup: prefer resume of a persisted probe + * sessionId, fall back to load, then to new (and persist the new id so the + * next reload can reuse it — keeps the agent-side session store at one stale + * entry per machine instead of growing with each reload). + */ +export class AgentModelPreloader { + private readonly cache = new Map(); + private readonly inflight = new Map>(); + private readonly listeners = new Set<() => void>(); + private disposed = false; + + constructor( + private readonly app: App, + private readonly plugin: CopilotPlugin, + private readonly resolveDescriptor: (id: BackendId) => BackendDescriptor | undefined + ) {} + + getCachedBackendState(backendId: BackendId): BackendState | null { + return this.cache.get(backendId) ?? null; + } + + /** + * Replace the cached entry for `backendId`. No-op when the signature + * is unchanged, to avoid spurious picker rebuilds. + */ + setCached(backendId: BackendId, state: BackendState): void { + if (this.disposed) return; + const prev = this.cache.get(backendId) ?? null; + if (backendStateSignature(prev) === backendStateSignature(state)) return; + this.cache.set(backendId, state); + this.notify(); + } + + /** Remove the cached entry for `backendId` after its backend is restarted. */ + clearCached(backendId: BackendId): void { + if (this.disposed) return; + if (!this.cache.delete(backendId)) return; + this.notify(); + } + + /** Best-effort probe; failures are logged and swallowed. Dedupes per backend. */ + preload(backendId: BackendId): Promise { + if (this.disposed) return Promise.resolve(); + const existing = this.inflight.get(backendId); + if (existing) return existing; + const promise = this.runProbe(backendId).finally(() => { + this.inflight.delete(backendId); + }); + this.inflight.set(backendId, promise); + return promise; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + shutdown(): void { + this.disposed = true; + this.cache.clear(); + this.inflight.clear(); + this.listeners.clear(); + } + + private notify(): void { + for (const l of this.listeners) { + try { + l(); + } catch (e) { + logWarn("[AgentMode] preload listener threw", e); + } + } + } + + private async runProbe(backendId: BackendId): Promise { + if (Platform.isMobile) return; + const adapter = this.app.vault.adapter; + if (!(adapter instanceof FileSystemAdapter)) return; + const cwd = adapter.getBasePath(); + + const descriptor = this.resolveDescriptor(backendId); + if (!descriptor) { + logWarn(`[AgentMode] preload skipped: unknown backend ${backendId}`); + return; + } + if (descriptor.getInstallState(getSettings()).kind !== "ready") return; + + const proc = descriptor.createBackendProcess({ + plugin: this.plugin, + app: this.app, + clientVersion: this.plugin.manifest.version, + descriptor, + }); + + try { + await proc.start?.(); + const storedId = descriptor.getProbeSessionId?.(getSettings()); + const state = await this.fetchInitialState(proc, descriptor, backendId, storedId, cwd); + if (this.disposed) return; + if (state.model || state.mode) { + this.setCached(backendId, state); + logProbeResult(backendId, "session probe", state); + } else { + logInfo(`[AgentMode] preload ${backendId}: agent did not report any initial state`); + } + } catch (err) { + logError(`[AgentMode] preload ${backendId} failed`, err); + } finally { + try { + await proc.shutdown(); + } catch (e) { + logWarn(`[AgentMode] preload ${backendId}: shutdown failed`, e); + } + } + } + + private async fetchInitialState( + proc: BackendProcess, + descriptor: BackendDescriptor, + backendId: BackendId, + storedId: string | undefined, + cwd: string + ): Promise { + type Strategy = { + label: string; + sessionId: string; + run: () => Promise<{ sessionId: string; state: BackendState }>; + }; + const strategies: Strategy[] = []; + if (storedId) { + strategies.push({ + label: `resumed probe session ${storedId}`, + sessionId: storedId, + run: () => proc.resumeSession({ sessionId: storedId, cwd, mcpServers: [] }), + }); + strategies.push({ + label: `loaded probe session ${storedId}`, + sessionId: storedId, + run: () => proc.loadSession({ sessionId: storedId, cwd, mcpServers: [] }), + }); + } + + for (const { label, sessionId, run } of strategies) { + try { + proc.registerSessionHandler(sessionId, () => {}); + const resp = await run(); + logInfo(`[AgentMode] preload ${backendId}: ${label}`); + return resp.state; + } catch (err) { + if (!(err instanceof MethodUnsupportedError)) { + logWarn(`[AgentMode] preload ${backendId}: ${label} failed (will fall back)`, err); + } + } + } + + const resp = await proc.newSession({ cwd, mcpServers: [] }); + proc.registerSessionHandler(resp.sessionId, () => {}); + logInfo(`[AgentMode] preload ${backendId}: created probe session ${resp.sessionId}`); + if (descriptor.persistProbeSessionId) { + try { + await descriptor.persistProbeSessionId(resp.sessionId, this.plugin); + } catch (e) { + logWarn(`[AgentMode] preload ${backendId}: persistProbeSessionId failed`, e); + } + } + return resp.state; + } +} + +function logProbeResult(backendId: BackendId, label: string, state: BackendState): void { + const ids = state.model?.availableModels.map((m) => m.baseModelId).join(", ") ?? ""; + const modeOpts = state.mode?.options.map((o) => o.value).join(", ") ?? ""; + const currentBaseId = state.model?.current.baseModelId ?? "-"; + const currentEntry = state.model?.availableModels.find((e) => e.baseModelId === currentBaseId); + const effortOpts = currentEntry?.effortOptions.map((o) => o.value ?? "default").join(", ") ?? ""; + logInfo( + `[AgentMode] preload ${backendId} (${label}): models=[${ids}] (current=${currentBaseId}), ` + + `mode=[${modeOpts}] effort=[${effortOpts}]` + ); +} diff --git a/src/agentMode/session/AgentSession.test.ts b/src/agentMode/session/AgentSession.test.ts new file mode 100644 index 00000000..39a40aaa --- /dev/null +++ b/src/agentMode/session/AgentSession.test.ts @@ -0,0 +1,1408 @@ +import { AI_SENDER, USER_SENDER } from "@/constants"; +import type { TFile } from "obsidian"; +import { AgentSession, buildPromptBlocks, tryReadExitPlanModeCall } from "./AgentSession"; +import { MethodUnsupportedError } from "./errors"; +import type { + BackendDescriptor, + BackendProcess, + BackendState, + SessionEvent, + SessionUpdateHandler, +} from "./types"; + +jest.mock("@/logger", () => ({ + logInfo: jest.fn(), + logWarn: jest.fn(), + logError: jest.fn(), +})); +jest.mock("@/settings/model", () => ({ + getSettings: jest.fn().mockReturnValue({ agentMode: { mcpServers: [] } }), +})); + +interface MockBackend { + asBackend: BackendProcess; + registerHandler: jest.Mock; + emit: (event: SessionEvent) => void; + prompt: jest.Mock; + cancel: jest.Mock; + newSession: jest.Mock; + setSessionModel: jest.Mock; + setSessionConfigOption: jest.Mock; + setSessionMode: jest.Mock; + listSessions: jest.Mock; +} + +function emptyState(): BackendState { + return { model: null, mode: null }; +} + +function makeMockBackend(): MockBackend { + let handler: SessionUpdateHandler | null = null; + const registerHandler = jest.fn((_id: string, h: SessionUpdateHandler) => { + handler = h; + return () => { + handler = null; + }; + }); + const prompt = jest.fn(async () => ({ stopReason: "end_turn" as const })); + const cancel = jest.fn(async () => undefined); + const newSession = jest.fn(async () => ({ sessionId: "acp-1", state: emptyState() })); + const setSessionModel = jest.fn(async () => emptyState()); + const setSessionConfigOption = jest.fn(async () => emptyState()); + const setSessionMode = jest.fn(async () => emptyState()); + const listSessions = jest.fn(async () => ({ sessions: [] })); + const backend: BackendProcess = { + isRunning: () => true, + onExit: () => () => {}, + setPermissionPrompter: () => {}, + registerSessionHandler: registerHandler, + newSession: newSession, + prompt: prompt, + cancel: cancel, + setSessionModel: setSessionModel, + isSetSessionModelSupported: () => true, + setSessionMode: setSessionMode, + isSetSessionModeSupported: () => true, + setSessionConfigOption: setSessionConfigOption, + isSetSessionConfigOptionSupported: () => true, + listSessions: listSessions, + resumeSession: () => Promise.reject(new MethodUnsupportedError("resume")), + loadSession: () => Promise.reject(new MethodUnsupportedError("load")), + supportsMcpTransport: () => false, + shutdown: async () => {}, + }; + return { + asBackend: backend, + registerHandler, + prompt, + cancel, + newSession, + setSessionModel, + setSessionConfigOption, + setSessionMode, + listSessions, + emit: (event) => handler?.(event), + }; +} + +describe("buildPromptBlocks", () => { + // eslint-disable-next-line obsidianmd/no-tfile-tfolder-cast -- test fixture; not a real TFile + const makeFile = (path: string) => ({ path }) as unknown as TFile; + + it("returns plain text when no context is attached", () => { + expect(buildPromptBlocks("hello")).toEqual([{ type: "text", text: "hello" }]); + }); + + it("returns plain text when context has no notes or excerpts", () => { + const blocks = buildPromptBlocks("hello", { notes: [], urls: [] }); + expect(blocks).toEqual([{ type: "text", text: "hello" }]); + }); + + it("wraps the message with note paths when contextNotes are attached", () => { + const blocks = buildPromptBlocks("summarize them", { + notes: [makeFile("daily/2026-04-28.md"), makeFile("projects/copilot.md")], + urls: [], + }); + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("text"); + const text = (blocks[0] as { type: "text"; text: string }).text; + expect(text).toContain(""); + expect(text).toContain("- daily/2026-04-28.md"); + expect(text).toContain("- projects/copilot.md"); + expect(text).toContain(""); + expect(text).toContain("\nsummarize them\n"); + }); + + it("inlines selected text excerpts with path and line range", () => { + const blocks = buildPromptBlocks("explain", { + notes: [], + urls: [], + selectedTextContexts: [ + { + id: "s1", + sourceType: "note", + notePath: "projects/copilot.md", + noteTitle: "copilot", + startLine: 12, + endLine: 18, + content: "line one\nline two", + }, + ], + }); + const text = (blocks[0] as { type: "text"; text: string }).text; + expect(text).toContain("Selected excerpts"); + expect(text).toContain("- projects/copilot.md (lines 12-18):"); + expect(text).toContain(" line one"); + expect(text).toContain(" line two"); + }); + + it("ignores web-source selected text excerpts", () => { + const blocks = buildPromptBlocks("explain", { + notes: [], + urls: [], + selectedTextContexts: [ + { + id: "w1", + sourceType: "web", + title: "Example", + url: "https://example.com", + content: "web snippet", + }, + ], + }); + expect(blocks).toEqual([{ type: "text", text: "explain" }]); + }); +}); + +describe("AgentSession.sendPrompt", () => { + it("appends user + placeholder synchronously and resolves on stopReason", async () => { + const mock = makeMockBackend(); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "opencode", + }); + const { userMessageId, turn } = session.sendPrompt("Hi there"); + + const messages = session.store.getDisplayMessages(); + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ + id: userMessageId, + sender: USER_SENDER, + message: "Hi there", + }); + expect(messages[1]).toMatchObject({ sender: AI_SENDER, message: "" }); + expect(session.getStatus()).toBe("running"); + + const stopReason = await turn; + expect(stopReason).toBe("end_turn"); + expect(session.getStatus()).toBe("idle"); + expect(mock.prompt).toHaveBeenCalledWith({ + sessionId: "acp-1", + prompt: [{ type: "text", text: "Hi there" }], + }); + }); + + it("rejects if a turn is already in flight", () => { + const mock = makeMockBackend(); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "opencode", + }); + session.sendPrompt("first"); + expect(() => session.sendPrompt("second")).toThrow(/in flight/); + }); + + it("marks an empty completed turn as a visible error message", async () => { + const mock = makeMockBackend(); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "opencode", + }); + + await session.sendPrompt("hi").turn; + + const placeholder = session.store.getDisplayMessages().find((m) => m.sender === AI_SENDER); + expect(placeholder?.isErrorMessage).toBe(true); + expect(placeholder?.message).toMatch(/without returning any assistant text or tool activity/); + }); + + it("includes nested provider errors when a prompt rejects", async () => { + const mock = makeMockBackend(); + const error = new Error("stream error"); + (error as { cause?: unknown }).cause = { + data: { + error: { + type: "FreeUsageLimitError", + message: "Rate limit exceeded. Please try again later.", + }, + }, + }; + mock.prompt.mockRejectedValueOnce(error); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "opencode", + }); + + await expect(session.sendPrompt("hi").turn).rejects.toThrow("stream error"); + + const placeholder = session.store.getDisplayMessages().find((m) => m.sender === AI_SENDER); + expect(placeholder?.isErrorMessage).toBe(true); + expect(placeholder?.message).toContain("FreeUsageLimitError"); + expect(placeholder?.message).toContain("Rate limit exceeded"); + }); + + it("agent_message_chunk is appended to placeholder displayText", async () => { + const mock = makeMockBackend(); + let resolvePrompt: ((v: { stopReason: "end_turn" }) => void) | null = null; + mock.prompt.mockImplementation( + () => new Promise((resolve) => (resolvePrompt = resolve as typeof resolvePrompt)) + ); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "opencode", + }); + const { turn } = session.sendPrompt("hi"); + + mock.emit({ + sessionId: "acp-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Hello" }, + }, + }); + mock.emit({ + sessionId: "acp-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: ", world." }, + }, + }); + + const placeholder = session.store.getDisplayMessages().find((m) => m.sender === AI_SENDER); + expect(placeholder?.message).toBe("Hello, world."); + + resolvePrompt!({ stopReason: "end_turn" }); + await turn; + }); + + it("tool_call followed by tool_call_update merges into a single part", async () => { + const mock = makeMockBackend(); + let resolvePrompt: ((v: { stopReason: "end_turn" }) => void) | null = null; + mock.prompt.mockImplementation( + () => new Promise((resolve) => (resolvePrompt = resolve as typeof resolvePrompt)) + ); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "opencode", + }); + const { turn } = session.sendPrompt("hi"); + + mock.emit({ + sessionId: "acp-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tc1", + title: "Read README", + kind: "read", + status: "pending", + rawInput: { path: "README.md" }, + }, + }); + mock.emit({ + sessionId: "acp-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tc1", + status: "completed", + content: [{ type: "content", content: { type: "text", text: "file contents" } }], + }, + }); + + const placeholder = session.store.getDisplayMessages().find((m) => m.sender === AI_SENDER); + expect(placeholder?.parts).toHaveLength(1); + expect(placeholder?.parts?.[0]).toMatchObject({ + kind: "tool_call", + id: "tc1", + title: "Read README", + status: "completed", + output: [{ type: "text", text: "file contents" }], + }); + + resolvePrompt!({ stopReason: "end_turn" }); + await turn; + }); + + it("truncates large text tool outputs before storing them in UI state", async () => { + const mock = makeMockBackend(); + let resolvePrompt: ((v: { stopReason: "end_turn" }) => void) | null = null; + mock.prompt.mockImplementation( + () => new Promise((resolve) => (resolvePrompt = resolve as typeof resolvePrompt)) + ); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "codex", + }); + const { turn } = session.sendPrompt("hi"); + const hugeOutput = "x".repeat(20_000); + + mock.emit({ + sessionId: "acp-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tc1", + status: "completed", + content: [{ type: "content", content: { type: "text", text: hugeOutput } }], + }, + }); + + const placeholder = session.store.getDisplayMessages().find((m) => m.sender === AI_SENDER); + const part = placeholder?.parts?.[0]; + expect(part).toMatchObject({ kind: "tool_call", id: "tc1" }); + if (part?.kind !== "tool_call") throw new Error("expected tool_call part"); + const output = part.output?.[0]; + expect(output).toMatchObject({ + type: "text", + truncated: true, + originalLength: hugeOutput.length, + omittedLength: 8_000, + }); + expect(output?.type === "text" ? output.text.length : 0).toBeLessThan(13_000); + expect(output?.type === "text" ? output.text : "").toContain( + "Tool output truncated in Copilot UI" + ); + + resolvePrompt!({ stopReason: "end_turn" }); + await turn; + }); + + it("cancel() sends cancel and aborts local controller", async () => { + const mock = makeMockBackend(); + let resolvePrompt: ((v: { stopReason: "cancelled" }) => void) | null = null; + mock.prompt.mockImplementation( + () => new Promise((resolve) => (resolvePrompt = resolve as typeof resolvePrompt)) + ); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "opencode", + }); + const { turn } = session.sendPrompt("hi"); + await session.cancel(); + expect(mock.cancel).toHaveBeenCalledWith({ sessionId: "acp-1" }); + resolvePrompt!({ stopReason: "cancelled" }); + expect(await turn).toBe("cancelled"); + }); +}); + +describe("AgentSession.create (via start)", () => { + it("captures `state.model` from newSession and exposes via getState", async () => { + const mock = makeMockBackend(); + const stateWithModel: BackendState = { + model: { + current: { baseModelId: "anthropic/sonnet", effort: null }, + availableModels: [ + { + baseModelId: "anthropic/sonnet", + name: "Claude Sonnet", + provider: "anthropic", + effortOptions: [], + }, + { baseModelId: "openai/gpt-5", name: "GPT-5", provider: "openai", effortOptions: [] }, + ], + }, + mode: null, + }; + mock.newSession.mockResolvedValueOnce({ sessionId: "acp-1", state: stateWithModel }); + const session = AgentSession.start({ + backend: mock.asBackend, + cwd: "/vault", + internalId: "internal-1", + backendId: "opencode", + }); + await session.ready; + expect(session.getState()?.model?.current.baseModelId).toBe("anthropic/sonnet"); + expect(session.getState()?.model?.availableModels).toHaveLength(2); + }); + + it("getState returns null-model when the agent doesn't report models", async () => { + const mock = makeMockBackend(); + mock.newSession.mockResolvedValueOnce({ sessionId: "acp-1", state: emptyState() }); + const session = AgentSession.start({ + backend: mock.asBackend, + cwd: "/vault", + internalId: "internal-1", + backendId: "opencode", + }); + await session.ready; + expect(session.getState()?.model).toBeNull(); + }); + + it("attempts setModel when defaultModelId is set", async () => { + const mock = makeMockBackend(); + mock.newSession.mockResolvedValueOnce({ sessionId: "acp-1", state: emptyState() }); + const session = AgentSession.start({ + backend: mock.asBackend, + cwd: "/vault", + internalId: "internal-1", + backendId: "opencode", + defaultModelId: "openai/gpt-5", + }); + await session.ready; + expect(mock.setSessionModel).toHaveBeenCalledWith({ + sessionId: "acp-1", + modelId: "openai/gpt-5", + }); + }); + + it("survives a MethodUnsupportedError from default-model application", async () => { + const mock = makeMockBackend(); + mock.newSession.mockResolvedValueOnce({ sessionId: "acp-1", state: emptyState() }); + mock.setSessionModel.mockRejectedValueOnce(new MethodUnsupportedError("session/set_model")); + const session = AgentSession.start({ + backend: mock.asBackend, + cwd: "/vault", + internalId: "internal-1", + backendId: "opencode", + defaultModelId: "openai/gpt-5", + }); + await session.ready; + expect(session.getStatus()).toBe("idle"); + }); +}); + +describe("AgentSession.setModel", () => { + it("calls backend.setSessionModel and replaces the cached state on success", async () => { + const mock = makeMockBackend(); + const newState: BackendState = { + model: { + current: { baseModelId: "x/y", effort: null }, + availableModels: [{ baseModelId: "x/y", name: "X Y", provider: null, effortOptions: [] }], + }, + mode: null, + }; + mock.setSessionModel.mockResolvedValueOnce(newState); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "opencode", + }); + await session.setModel("x/y"); + expect(mock.setSessionModel).toHaveBeenCalledWith({ sessionId: "acp-1", modelId: "x/y" }); + expect(session.getState()?.model?.current.baseModelId).toBe("x/y"); + }); + + it("rethrows MethodUnsupportedError without mutating local state", async () => { + const mock = makeMockBackend(); + mock.setSessionModel.mockRejectedValueOnce(new MethodUnsupportedError("session/set_model")); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "opencode", + }); + await expect(session.setModel("x/y")).rejects.toBeInstanceOf(MethodUnsupportedError); + expect(session.getState()).toBeNull(); + }); + + it("notifies onModelChanged listeners after successful switch", async () => { + const mock = makeMockBackend(); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "opencode", + }); + const onModelChanged = jest.fn(); + session.subscribe({ + onMessagesChanged: jest.fn(), + onStatusChanged: jest.fn(), + onModelChanged, + }); + await session.setModel("x/y"); + expect(onModelChanged).toHaveBeenCalledTimes(1); + }); +}); + +describe("AgentSession.setConfigOption", () => { + it("forwards to backend and replaces state from response", async () => { + const mock = makeMockBackend(); + mock.setSessionConfigOption.mockResolvedValueOnce(emptyState()); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "claude-code", + }); + await session.setConfigOption("effort", "high"); + expect(mock.setSessionConfigOption).toHaveBeenCalledWith({ + sessionId: "acp-1", + configId: "effort", + value: "high", + }); + }); + + it("notifies onModelChanged subscribers on success", async () => { + const mock = makeMockBackend(); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "claude-code", + }); + const onModelChanged = jest.fn(); + session.subscribe({ + onMessagesChanged: jest.fn(), + onStatusChanged: jest.fn(), + onModelChanged, + }); + await session.setConfigOption("effort", "low"); + expect(onModelChanged).toHaveBeenCalledTimes(1); + }); + + it("rethrows MethodUnsupportedError without notifying", async () => { + const mock = makeMockBackend(); + mock.setSessionConfigOption.mockRejectedValueOnce( + new MethodUnsupportedError("session/set_config_option") + ); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "claude-code", + }); + const onModelChanged = jest.fn(); + session.subscribe({ + onMessagesChanged: jest.fn(), + onStatusChanged: jest.fn(), + onModelChanged, + }); + await expect(session.setConfigOption("effort", "high")).rejects.toBeInstanceOf( + MethodUnsupportedError + ); + expect(onModelChanged).not.toHaveBeenCalled(); + }); +}); + +describe("AgentSession.setMode", () => { + it("calls backend.setSessionMode and replaces state on success", async () => { + const mock = makeMockBackend(); + mock.setSessionMode.mockResolvedValueOnce(emptyState()); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "claude-code", + }); + await session.setMode("plan"); + expect(mock.setSessionMode).toHaveBeenCalledWith({ sessionId: "acp-1", modeId: "plan" }); + }); + + it("rethrows MethodUnsupportedError without mutating local state", async () => { + const mock = makeMockBackend(); + mock.setSessionMode.mockRejectedValueOnce(new MethodUnsupportedError("session/set_mode")); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "claude-code", + }); + await expect(session.setMode("plan")).rejects.toBeInstanceOf(MethodUnsupportedError); + }); + + it("notifies onModelChanged listeners after successful switch", async () => { + const mock = makeMockBackend(); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "claude-code", + }); + const onModelChanged = jest.fn(); + session.subscribe({ + onMessagesChanged: jest.fn(), + onStatusChanged: jest.fn(), + onModelChanged, + }); + await session.setMode("plan"); + expect(onModelChanged).toHaveBeenCalledTimes(1); + }); +}); + +describe("AgentSession state_changed event", () => { + it("swaps cached state and notifies onModelChanged", () => { + const mock = makeMockBackend(); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "claude-code", + }); + const onModelChanged = jest.fn(); + session.subscribe({ + onMessagesChanged: jest.fn(), + onStatusChanged: jest.fn(), + onModelChanged, + }); + const newState: BackendState = { + model: null, + mode: { current: "plan", options: [{ value: "plan", label: "Plan" }], apply: {} }, + }; + mock.emit({ + sessionId: "acp-1", + update: { sessionUpdate: "state_changed", state: newState }, + }); + expect(session.getState()).toBe(newState); + expect(onModelChanged).toHaveBeenCalledTimes(1); + }); +}); + +describe("AgentSession intent capabilities", () => { + function makeDescriptor(opts: { + descriptorStyleEffort?: boolean; + }): () => BackendDescriptor | undefined { + const descriptor = { + id: "test-backend", + displayName: "Test", + wire: { + encode: () => "", + decode: () => ({ selection: { baseModelId: "", effort: null }, provider: null }), + ...(opts.descriptorStyleEffort + ? { + effortConfigFor: () => ({ + id: "reasoning_effort", + label: "Effort", + values: [], + }), + } + : {}), + }, + } as unknown as BackendDescriptor; + return () => descriptor; + } + + function sessionWith(opts: { + isModelSwitchSupported: boolean | null; + isSetSessionConfigOptionSupported: boolean | null; + isSetModeSupported: boolean | null; + descriptorStyleEffort?: boolean; + initialState?: BackendState; + }): AgentSession { + const mock = makeMockBackend(); + mock.asBackend.isSetSessionModelSupported = () => opts.isModelSwitchSupported; + mock.asBackend.isSetSessionConfigOptionSupported = () => opts.isSetSessionConfigOptionSupported; + mock.asBackend.isSetSessionModeSupported = () => opts.isSetModeSupported; + return new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "test-backend", + initialState: opts.initialState ?? null, + getDescriptor: makeDescriptor({ descriptorStyleEffort: opts.descriptorStyleEffort }), + }); + } + + it("canSwitchModel mirrors the underlying model-switch probe", () => { + expect( + sessionWith({ + isModelSwitchSupported: true, + isSetSessionConfigOptionSupported: false, + isSetModeSupported: false, + }).canSwitchModel() + ).toBe(true); + expect( + sessionWith({ + isModelSwitchSupported: false, + isSetSessionConfigOptionSupported: true, + isSetModeSupported: true, + }).canSwitchModel() + ).toBe(false); + expect( + sessionWith({ + isModelSwitchSupported: null, + isSetSessionConfigOptionSupported: true, + isSetModeSupported: true, + }).canSwitchModel() + ).toBeNull(); + }); + + it("canSwitchEffort returns the setConfigOption probe for descriptor-style backends", () => { + expect( + sessionWith({ + isModelSwitchSupported: false, + isSetSessionConfigOptionSupported: true, + isSetModeSupported: false, + descriptorStyleEffort: true, + }).canSwitchEffort() + ).toBe(true); + }); + + it("canSwitchEffort returns the model-switch probe for suffix-style backends", () => { + expect( + sessionWith({ + isModelSwitchSupported: true, + isSetSessionConfigOptionSupported: false, + isSetModeSupported: false, + descriptorStyleEffort: false, + }).canSwitchEffort() + ).toBe(true); + }); + + it("canSwitchMode returns null when no mode state is reported", () => { + expect( + sessionWith({ + isModelSwitchSupported: false, + isSetSessionConfigOptionSupported: true, + isSetModeSupported: true, + }).canSwitchMode() + ).toBeNull(); + }); + + it("canSwitchMode samples the first option's apply spec — setConfigOption", () => { + const state: BackendState = { + model: null, + mode: { + current: "plan", + options: [{ value: "plan", label: "Plan" }], + apply: { plan: { kind: "setConfigOption", configId: "mode", value: "plan" } }, + }, + }; + expect( + sessionWith({ + isModelSwitchSupported: false, + isSetSessionConfigOptionSupported: true, + isSetModeSupported: false, + initialState: state, + }).canSwitchMode() + ).toBe(true); + }); + + it("canSwitchMode samples the first option's apply spec — setMode", () => { + const state: BackendState = { + model: null, + mode: { + current: "plan", + options: [{ value: "plan", label: "Plan" }], + apply: { plan: { kind: "setMode", nativeId: "plan" } }, + }, + }; + expect( + sessionWith({ + isModelSwitchSupported: false, + isSetSessionConfigOptionSupported: false, + isSetModeSupported: true, + initialState: state, + }).canSwitchMode() + ).toBe(true); + }); +}); + +describe("AgentSession.setLabel", () => { + it("stores trimmed label and notifies onLabelChanged", () => { + const mock = makeMockBackend(); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "opencode", + }); + const onLabelChanged = jest.fn(); + session.subscribe({ + onMessagesChanged: jest.fn(), + onStatusChanged: jest.fn(), + onLabelChanged, + }); + + session.setLabel(" My session "); + expect(session.getLabel()).toBe("My session"); + expect(onLabelChanged).toHaveBeenCalledTimes(1); + + session.setLabel(" "); + expect(session.getLabel()).toBeNull(); + expect(onLabelChanged).toHaveBeenCalledTimes(2); + + session.setLabel(null); + expect(onLabelChanged).toHaveBeenCalledTimes(2); + }); +}); + +describe("AgentSession needsAttention flag", () => { + it("starts cleared and flips on mark / clear with one notification each", () => { + const mock = makeMockBackend(); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "opencode", + }); + const onNeedsAttentionChanged = jest.fn(); + session.subscribe({ + onMessagesChanged: jest.fn(), + onStatusChanged: jest.fn(), + onNeedsAttentionChanged, + }); + + expect(session.getNeedsAttention()).toBe(false); + + session.markNeedsAttention(); + expect(session.getNeedsAttention()).toBe(true); + expect(onNeedsAttentionChanged).toHaveBeenCalledTimes(1); + expect(onNeedsAttentionChanged).toHaveBeenLastCalledWith(true); + + // No-op: already true. + session.markNeedsAttention(); + expect(onNeedsAttentionChanged).toHaveBeenCalledTimes(1); + + session.clearNeedsAttention(); + expect(session.getNeedsAttention()).toBe(false); + expect(onNeedsAttentionChanged).toHaveBeenCalledTimes(2); + expect(onNeedsAttentionChanged).toHaveBeenLastCalledWith(false); + + // No-op: already false. + session.clearNeedsAttention(); + expect(onNeedsAttentionChanged).toHaveBeenCalledTimes(2); + }); +}); + +describe("AgentSession session_info_update", () => { + it("adopts the title pushed by the agent and notifies listeners", () => { + const mock = makeMockBackend(); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "opencode", + }); + const onLabelChanged = jest.fn(); + session.subscribe({ + onMessagesChanged: jest.fn(), + onStatusChanged: jest.fn(), + onLabelChanged, + }); + + mock.emit({ + sessionId: "acp-1", + update: { sessionUpdate: "session_info_update", title: "Refactor auth" }, + }); + + expect(session.getLabel()).toBe("Refactor auth"); + expect(onLabelChanged).toHaveBeenCalledTimes(1); + }); + + it("ignores agent-pushed titles after the user has renamed the session", () => { + const mock = makeMockBackend(); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "opencode", + }); + session.setLabel("My label"); + + mock.emit({ + sessionId: "acp-1", + update: { sessionUpdate: "session_info_update", title: "Agent-chosen title" }, + }); + + expect(session.getLabel()).toBe("My label"); + }); + + it("does not require an active turn placeholder", () => { + const mock = makeMockBackend(); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "opencode", + }); + mock.emit({ + sessionId: "acp-1", + update: { sessionUpdate: "session_info_update", title: "Idle title" }, + }); + expect(session.getLabel()).toBe("Idle title"); + }); + + it("a null/empty agent title clears the label and re-opens it for future agent updates", () => { + const mock = makeMockBackend(); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "opencode", + }); + mock.emit({ + sessionId: "acp-1", + update: { sessionUpdate: "session_info_update", title: "First" }, + }); + expect(session.getLabel()).toBe("First"); + + mock.emit({ + sessionId: "acp-1", + update: { sessionUpdate: "session_info_update", title: null }, + }); + expect(session.getLabel()).toBeNull(); + + mock.emit({ + sessionId: "acp-1", + update: { sessionUpdate: "session_info_update", title: "Second" }, + }); + expect(session.getLabel()).toBe("Second"); + }); +}); + +describe("AgentSession title poll after turn", () => { + async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + } + + it("pulls the title via listSessions and applies it after end_turn", async () => { + const mock = makeMockBackend(); + mock.listSessions.mockResolvedValueOnce({ + sessions: [ + { sessionId: "acp-1", cwd: "/vault", title: "Refactor auth", updatedAt: null }, + { sessionId: "acp-other", cwd: "/vault", title: "Different session", updatedAt: null }, + ], + }); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "opencode", + cwd: "/vault", + }); + const { turn } = session.sendPrompt("hi"); + await turn; + await flushMicrotasks(); + + expect(mock.listSessions).toHaveBeenCalledWith({ cwd: "/vault" }); + expect(session.getLabel()).toBe("Refactor auth"); + }); + + it("ignores opencode's default 'New session - …' placeholder titles", async () => { + const mock = makeMockBackend(); + mock.listSessions.mockResolvedValueOnce({ + sessions: [ + { + sessionId: "acp-1", + cwd: "/vault", + title: "New session - 2026-04-26T01:24:54.221Z", + updatedAt: null, + }, + ], + }); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "opencode", + cwd: "/vault", + }); + await session.sendPrompt("hi").turn; + await flushMicrotasks(); + expect(session.getLabel()).toBeNull(); + }); + + it("does not poll when the user has already renamed the session", async () => { + const mock = makeMockBackend(); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "opencode", + cwd: "/vault", + }); + session.setLabel("My label"); + await session.sendPrompt("hi").turn; + await flushMicrotasks(); + expect(mock.listSessions).not.toHaveBeenCalled(); + expect(session.getLabel()).toBe("My label"); + }); + + it("does not poll on cancelled turns", async () => { + const mock = makeMockBackend(); + mock.prompt.mockResolvedValueOnce({ stopReason: "cancelled" }); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "opencode", + cwd: "/vault", + }); + await session.sendPrompt("hi").turn; + await flushMicrotasks(); + expect(mock.listSessions).not.toHaveBeenCalled(); + }); + + it("silently no-ops when the agent doesn't support listSessions", async () => { + const mock = makeMockBackend(); + mock.listSessions.mockRejectedValueOnce(new MethodUnsupportedError("session/list")); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "opencode", + cwd: "/vault", + }); + await session.sendPrompt("hi").turn; + await flushMicrotasks(); + expect(session.getLabel()).toBeNull(); + }); + + it("omits cwd filter when the session has no cwd recorded", async () => { + const mock = makeMockBackend(); + mock.listSessions.mockResolvedValueOnce({ + sessions: [{ sessionId: "acp-1", cwd: "/vault", title: "Found me", updatedAt: null }], + }); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "opencode", + }); + await session.sendPrompt("hi").turn; + await flushMicrotasks(); + expect(mock.listSessions).toHaveBeenCalledWith({}); + expect(session.getLabel()).toBe("Found me"); + }); +}); + +describe("AgentSession plan proposal lifecycle", () => { + it("does not resurrect the plan card when a late tool_call_update arrives for a finalized proposal", async () => { + const mock = makeMockBackend(); + let resolvePrompt: ((v: { stopReason: "end_turn" }) => void) | null = null; + mock.prompt.mockImplementation( + () => new Promise((resolve) => (resolvePrompt = resolve as typeof resolvePrompt)) + ); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "claude-code", + }); + const { turn } = session.sendPrompt("plan something"); + + mock.emit({ + sessionId: "acp-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tc-plan-1", + title: "ExitPlanMode", + kind: "other", + status: "pending", + rawInput: { plan: "# proposed plan body" }, + vendorToolName: "ExitPlanMode", + isPlanProposal: true, + }, + }); + const initialPlan = session.getCurrentPlan(); + expect(initialPlan).not.toBeNull(); + expect(initialPlan?.decision).toBe("pending"); + expect(initialPlan?.pendingToolCallId).toBe("tc-plan-1"); + + expect(session.finalizePlanDecision(initialPlan!.id)).toBe(true); + expect(session.getCurrentPlan()).toBeNull(); + + mock.emit({ + sessionId: "acp-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tc-plan-1", + status: "completed", + rawInput: { plan: "# proposed plan body" }, + vendorToolName: "ExitPlanMode", + isPlanProposal: true, + }, + }); + expect(session.getCurrentPlan()).toBeNull(); + + resolvePrompt!({ stopReason: "end_turn" }); + await turn; + }); + + it("propagates a body-identical setCurrentPlan when the gating state changes", async () => { + // Regression: body-identical plan publications still need to propagate + // control metadata such as pendingToolCallId. Otherwise a repeated + // ExitPlanMode call can leave the UI resolving the wrong permission. + const mock = makeMockBackend(); + let resolvePrompt: ((v: { stopReason: "end_turn" }) => void) | null = null; + mock.prompt.mockImplementation( + () => new Promise((resolve) => (resolvePrompt = resolve as typeof resolvePrompt)) + ); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "claude-code", + }); + const { turn } = session.sendPrompt("plan something"); + + const planBody = "# proposed plan body"; + mock.emit({ + sessionId: "acp-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tc-plan-A", + title: "ExitPlanMode", + kind: "other", + status: "pending", + rawInput: { plan: planBody }, + vendorToolName: "ExitPlanMode", + isPlanProposal: true, + }, + }); + const first = session.getCurrentPlan(); + expect(first?.pendingToolCallId).toBe("tc-plan-A"); + expect(first?.permissionGated).toBe(true); + expect(first?.revision).toBe(1); + + mock.emit({ + sessionId: "acp-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tc-plan-B", + title: "ExitPlanMode", + kind: "other", + status: "pending", + rawInput: { plan: planBody }, + vendorToolName: "ExitPlanMode", + isPlanProposal: true, + }, + }); + const second = session.getCurrentPlan(); + expect(second?.pendingToolCallId).toBe("tc-plan-B"); + expect(second?.permissionGated).toBe(true); + // Body is byte-identical, so revision must NOT bump — the per-tab + // `decided` reset effect in PlanPreviewView keys on revision and would + // misfire if we treated this as an in-place content revision. + expect(second?.revision).toBe(1); + + resolvePrompt!({ stopReason: "end_turn" }); + await turn; + }); + + it("does not promote completed plan-file writes into proposal cards", async () => { + const mock = makeMockBackend(); + let resolvePrompt: ((v: { stopReason: "end_turn" }) => void) | null = null; + mock.prompt.mockImplementation( + () => new Promise((resolve) => (resolvePrompt = resolve as typeof resolvePrompt)) + ); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "claude-code", + initialState: { + model: null, + mode: { + current: "plan", + options: [{ value: "plan", label: "Plan" }], + apply: { plan: { kind: "setMode", nativeId: "plan" } }, + }, + }, + getDescriptor: () => + ({ + isPlanModePlanFilePath: (absolutePath: string) => + absolutePath === "/Users/test/.claude/plans/plan.md", + }) as unknown as BackendDescriptor, + }); + const { turn } = session.sendPrompt("plan something"); + + mock.emit({ + sessionId: "acp-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tc-plan-write", + title: "Write", + kind: "edit", + status: "completed", + rawInput: { + file_path: "/Users/test/.claude/plans/plan.md", + content: "# proposed plan body", + }, + }, + }); + + expect(session.getCurrentPlan()).toBeNull(); + + resolvePrompt!({ stopReason: "end_turn" }); + await turn; + }); + + it("publishes a gated plan from an ExitPlanMode permission request", async () => { + const mock = makeMockBackend(); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "claude-code", + }); + + const decisionPromise = session.handlePlanProposalPermission({ + sessionId: "acp-1", + toolCall: { + toolCallId: "tc-plan-from-permission", + kind: "switch_mode", + status: "pending", + title: "ExitPlanMode", + rawInput: { + plan: "# permission plan body", + planFilePath: "/Users/test/.claude/plans/plan.md", + }, + vendorToolName: "ExitPlanMode", + isPlanProposal: true, + }, + options: [ + { optionId: "allow_once", name: "Allow once", kind: "allow_once" }, + { optionId: "reject_once", name: "Deny once", kind: "reject_once" }, + ], + }); + + const plan = session.getCurrentPlan(); + expect(plan?.body).toBe("# permission plan body"); + expect(plan?.permissionGated).toBe(true); + expect(plan?.pendingToolCallId).toBe("tc-plan-from-permission"); + expect(plan?.sourceFilePath).toBe("/Users/test/.claude/plans/plan.md"); + + session.resolvePlanProposalPermission("tc-plan-from-permission", false); + await decisionPromise; + }); + + it("forwards the optional denyMessage on resolvePlanProposalPermission to the resolved decision", async () => { + const mock = makeMockBackend(); + let resolvePrompt: ((v: { stopReason: "end_turn" }) => void) | null = null; + mock.prompt.mockImplementation( + () => new Promise((resolve) => (resolvePrompt = resolve as typeof resolvePrompt)) + ); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "claude-code", + }); + const { turn } = session.sendPrompt("plan something"); + + const decisionPromise = session.handlePlanProposalPermission({ + sessionId: "acp-1", + toolCall: { + toolCallId: "tc-plan-deny-msg", + kind: "switch_mode", + status: "pending", + title: "ExitPlanMode", + rawInput: { plan: "# x" }, + vendorToolName: "ExitPlanMode", + isPlanProposal: true, + }, + options: [ + { optionId: "allow_once", name: "Allow once", kind: "allow_once" }, + { optionId: "reject_once", name: "Deny once", kind: "reject_once" }, + ], + }); + + session.resolvePlanProposalPermission("tc-plan-deny-msg", false, "please drop step 2"); + const decision = await decisionPromise; + + expect(decision.outcome).toEqual({ outcome: "selected", optionId: "reject_once" }); + expect(decision.denyMessage).toBe("please drop step 2"); + + resolvePrompt!({ stopReason: "end_turn" }); + await turn; + }); + + it("does not attach denyMessage when allowing", async () => { + const mock = makeMockBackend(); + let resolvePrompt: ((v: { stopReason: "end_turn" }) => void) | null = null; + mock.prompt.mockImplementation( + () => new Promise((resolve) => (resolvePrompt = resolve as typeof resolvePrompt)) + ); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "claude-code", + }); + const { turn } = session.sendPrompt("plan something"); + + const decisionPromise = session.handlePlanProposalPermission({ + sessionId: "acp-1", + toolCall: { + toolCallId: "tc-plan-allow", + kind: "switch_mode", + status: "pending", + title: "ExitPlanMode", + rawInput: { plan: "# x" }, + vendorToolName: "ExitPlanMode", + isPlanProposal: true, + }, + options: [ + { optionId: "allow_once", name: "Allow once", kind: "allow_once" }, + { optionId: "reject_once", name: "Deny once", kind: "reject_once" }, + ], + }); + + session.resolvePlanProposalPermission("tc-plan-allow", true, "should be ignored"); + const decision = await decisionPromise; + + expect(decision.outcome).toEqual({ outcome: "selected", optionId: "allow_once" }); + expect(decision.denyMessage).toBeUndefined(); + + resolvePrompt!({ stopReason: "end_turn" }); + await turn; + }); +}); + +describe("tryReadExitPlanModeCall", () => { + it("returns the plan body when isPlanProposal is true", () => { + const out = tryReadExitPlanModeCall({ + kind: "other", + rawInput: { plan: "# do the thing" }, + isPlanProposal: true, + }); + expect(out).toEqual({ plan: "# do the thing", planFilePath: undefined }); + }); + + it("falls back to ACP kind=switch_mode when isPlanProposal is unset", () => { + const out = tryReadExitPlanModeCall({ + kind: "switch_mode", + rawInput: { plan: "## plan body", planFilePath: "/abs/plan.md" }, + }); + expect(out).toEqual({ plan: "## plan body", planFilePath: "/abs/plan.md" }); + }); + + it("returns null when rawInput.plan is missing — content gate is load-bearing", () => { + expect( + tryReadExitPlanModeCall({ + kind: "switch_mode", + rawInput: { planFilePath: "/abs/plan.md" }, + isPlanProposal: true, + }) + ).toBeNull(); + }); + + it("returns null when rawInput.plan is not a string", () => { + expect( + tryReadExitPlanModeCall({ + kind: "switch_mode", + rawInput: { plan: 42 }, + }) + ).toBeNull(); + }); + + it("returns null when neither isPlanProposal nor switch_mode kind matches", () => { + expect( + tryReadExitPlanModeCall({ + kind: "edit", + rawInput: { plan: "looks like a plan but isn't tagged as one" }, + }) + ).toBeNull(); + }); + + it("ignores planFilePath when it isn't a string", () => { + const out = tryReadExitPlanModeCall({ + kind: "switch_mode", + rawInput: { plan: "body", planFilePath: 12 }, + }); + expect(out).toEqual({ plan: "body", planFilePath: undefined }); + }); + + it("handles null/undefined rawInput without throwing", () => { + expect(tryReadExitPlanModeCall({ kind: "switch_mode", rawInput: null })).toBeNull(); + expect(tryReadExitPlanModeCall({ kind: "switch_mode", rawInput: undefined })).toBeNull(); + }); +}); diff --git a/src/agentMode/session/AgentSession.ts b/src/agentMode/session/AgentSession.ts new file mode 100644 index 00000000..cb3b320f --- /dev/null +++ b/src/agentMode/session/AgentSession.ts @@ -0,0 +1,1206 @@ +import { AI_SENDER, USER_SENDER } from "@/constants"; +import { logInfo, logWarn } from "@/logger"; +import { AgentMessageStore } from "@/agentMode/session/AgentMessageStore"; +import { + AgentMessagePart, + AgentToolCallOutput, + BackendDescriptor, + BackendId, + BackendProcess, + BackendState, + CurrentPlan, + NewAgentChatMessage, + PERMISSION_ALLOW_KINDS, + PERMISSION_REJECT_KINDS, + PermissionDecision, + PermissionOptionKind, + PermissionPrompt, + PlanSummary, + PromptContent, + PromptInput, + SessionEvent, + SessionId, + StopReason, + ToolCallContent, + ToolCallDelta, + ToolCallSnapshot, +} from "@/agentMode/session/types"; +import { isNoteSelectedTextContext, MessageContext } from "@/types/message"; +import { err2String, formatDateTime } from "@/utils"; +import { MethodUnsupportedError } from "@/agentMode/session/errors"; +import { resolveMcpServers } from "@/agentMode/session/mcpResolver"; +import { getSettings } from "@/settings/model"; + +/** + * Prefix opencode uses for placeholder titles before its title-summarizer + * agent runs. Treating these as "no title" prevents the tab from briefly + * showing "New session - 2026-…" before the LLM-generated label arrives. + */ +const DEFAULT_TITLE_PREFIX = "New session"; +const MAX_TOOL_OUTPUT_TEXT_CHARS = 12_000; + +export type AgentSessionStatus = + | "starting" + | "idle" + | "running" + | "awaiting_permission" + | "error" + | "closed"; + +/** + * Statuses that demand the user's eye when reached from `running` on a + * backgrounded tab. The manager uses this to decide when to flag + * `needsAttention`. Co-located with the union so adding a new status forces + * a deliberate decision about whether it belongs here. + */ +export const ATTENTION_TRIGGER_STATUSES: ReadonlySet = new Set([ + "idle", + "error", + "awaiting_permission", +]); + +export interface AgentSessionListener { + onMessagesChanged(): void; + onStatusChanged(status: AgentSessionStatus): void; + /** + * Optional: fired when model, configOption, or mode state changes. The + * picker treats all three as one notification channel — they all cause it + * to rebuild and there's no value in fanning out separate callbacks. + */ + onModelChanged?(): void; + /** Optional: fired when the user-visible label changes. */ + onLabelChanged?(): void; + /** + * Optional: fired when the singleton `currentPlan` changes (created, + * revised in place, or cleared). The floating plan card / preview tab + * subscribe to this channel. + */ + onCurrentPlanChanged?(): void; + /** + * Optional: fired when the "needs attention" flag flips. The tab strip + * subscribes to render an accent dot on the brand icon for backgrounded + * sessions that finished, errored, or paused for permission while the + * user was looking at a different tab. + */ + onNeedsAttentionChanged?(needsAttention: boolean): void; +} + +export interface AgentSessionStartOptions { + backend: BackendProcess; + cwd: string; + internalId: string; + backendId: BackendId; + defaultModelId?: string; + /** + * Optional descriptor accessor. The session uses it to resolve mode mappings + * without coupling to specific backends. Manager-supplied; tests omit it. + */ + getDescriptor?: () => BackendDescriptor | undefined; +} + +/** + * Pre-resolved state used by tests and by `loadSessionFromHistory` to + * construct a session that bypasses the async backend startup. + */ +export interface AgentSessionStateOptions { + backend: BackendProcess; + backendSessionId: SessionId; + internalId: string; + backendId: BackendId; + initialState?: BackendState | null; + cwd?: string | null; + getDescriptor?: () => BackendDescriptor | undefined; +} + +/** + * Per-chat Agent Mode session. Owns its `AgentMessageStore`, the lifecycle + * of one backend session id, and the `AbortController` that cancels in-flight + * turns. + * + * Construction is split: `AgentSession.start()` returns synchronously with + * status `"starting"` so the UI can swap to the new (empty) chat immediately. + * The backend `newSession` runs in the background; once it resolves the + * session transitions to `"idle"` and `sendPrompt` becomes usable. While + * starting, `sendPrompt` throws — the chat UI gates the send button on + * `getStatus() === "starting"`. + */ +export class AgentSession { + readonly store = new AgentMessageStore(); + readonly internalId: string; + readonly backendId: BackendId; + /** Resolves when `newSession` succeeds; rejects when it fails. */ + readonly ready: Promise; + private backendSessionId: SessionId | null = null; + private readonly backend: BackendProcess; + private readonly cwd: string | null; + private readonly getDescriptor: (() => BackendDescriptor | undefined) | null; + private status: AgentSessionStatus = "starting"; + private placeholderId: string | null = null; + private abortController: AbortController | null = null; + private listeners = new Set(); + private unregisterSessionHandler: (() => void) | null = null; + /** + * Cached normalized state — produced by the backend at session start / + * resume / load and refreshed via `state_changed` events or per-dimension + * `setSession*` responses. + */ + private currentState: BackendState | null = null; + private label: string | null = null; + // Tracks who set the current label so an agent-pushed `session_info_update` + // can't clobber a label the user explicitly chose via Rename. + private labelSource: "user" | "agent" | null = null; + private disposed = false; + // Pending permission resolvers keyed by toolCallId. Populated when an + // ExitPlanMode permission request arrives (via the wrapped prompter); the + // chat card resolves them through `resolvePlanProposalPermission`. + private pendingPlanResolvers = new Map< + string, + { + request: PermissionPrompt; + resolve: (resp: PermissionDecision) => void; + } + >(); + // Singleton "current plan" for the floating card. At most one per session + // while in canonical plan mode and a plan has been proposed; cleared on a + // terminal user decision or when the canonical mode flips out of plan. + private currentPlan: CurrentPlan | null = null; + // Monotonic counter for `currentPlan.id` so the React tree can detect a + // *new* plan-mode review (vs. an in-place revision that bumps `revision`). + private planSeq = 0; + // Tool-call ids the user has already finalized a decision on. + private decidedPlanToolCallIds = new Set(); + // True when something happened (turn ended, error, permission prompt) while + // this session was not the active tab. The manager owns the policy for + // setting this; the session just exposes the flag and notification channel. + private needsAttention = false; + // Streaming backends emit `agent_message_chunk`/`agent_thought_chunk` and + // (for codex) `tool_call_update` at up to ~160 fps. We update the store + // immediately but coalesce the React-facing `onMessagesChanged` callback + // to one fire per animation frame so the trail doesn't rerender 200×/sec. + // Non-streaming callers use `notifyMessages()` directly for immediate + // flush on turn end / error / permission prompts. + private notifyScheduled = false; + private notifyHandle: ReturnType | number | null = null; + + /** Prefer `AgentSession.start(...)` in production so backend startup runs async. */ + constructor(opts: AgentSessionStateOptions | AgentSessionStartOptions) { + this.backend = opts.backend; + this.internalId = opts.internalId; + this.backendId = opts.backendId; + this.cwd = opts.cwd ?? null; + this.getDescriptor = opts.getDescriptor ?? null; + if ("backendSessionId" in opts) { + this.backendSessionId = opts.backendSessionId; + this.currentState = opts.initialState ?? null; + this.unregisterSessionHandler = this.backend.registerSessionHandler( + opts.backendSessionId, + (event) => this.handleSessionEvent(event) + ); + this.status = "idle"; + this.ready = Promise.resolve(); + } else { + this.status = "starting"; + this.ready = this.initialize(opts); + } + } + + /** + * Construct an `AgentSession` synchronously and kick off backend + * initialization in the background. The returned session is immediately + * registerable with the manager and renderable in the UI; `sendPrompt` + * is gated until `ready` resolves. + */ + static start(opts: AgentSessionStartOptions): AgentSession { + return new AgentSession(opts); + } + + /** The backend session id, or null while still starting. */ + getBackendSessionId(): SessionId | null { + return this.backendSessionId; + } + + private async initialize(opts: AgentSessionStartOptions): Promise { + const { backend, cwd, defaultModelId } = opts; + try { + const resp = await backend.newSession({ + cwd, + mcpServers: resolveMcpServers(backend, getSettings().agentMode?.mcpServers), + }); + if (this.disposed) return; + const modelLog = resp.state.model + ? `model=${resp.state.model.current.baseModelId} (available: ${resp.state.model.availableModels + .map((m) => m.baseModelId) + .join(", ")})` + : "agent did not report model state"; + logInfo(`[AgentMode] session ${resp.sessionId} ${modelLog}`); + this.backendSessionId = resp.sessionId; + this.currentState = resp.state; + this.unregisterSessionHandler = this.backend.registerSessionHandler(resp.sessionId, (event) => + this.handleSessionEvent(event) + ); + // dispose() may have run between newSession resolving and now. + if (this.disposed) { + this.unregisterSessionHandler(); + this.unregisterSessionHandler = null; + return; + } + this.setStatus("idle"); + this.notifyModelChanged(); + + // Apply sticky preference. Best-effort — failures leave the session + // usable with whatever the agent picked by default. + if (defaultModelId) { + try { + await this.setModel(defaultModelId); + } catch (e) { + logWarn(`[AgentMode] could not apply default model ${defaultModelId}`, e); + } + } + } catch (err) { + if (this.disposed) return; + logWarn(`[AgentMode] session/new failed for ${this.internalId}`, err); + this.setStatus("error"); + throw err instanceof Error ? err : new Error(err2String(err)); + } + } + + /** + * Latest known unified picker state for this session — model catalog, + * canonical mode, canonical effort. `null` while the session is still + * starting and the agent hasn't reported anything yet. + */ + getState(): BackendState | null { + return this.currentState; + } + + /** + * Switch the active model on this session. On success, replaces the + * cached `BackendState` with the freshly-translated one returned by the + * backend and notifies `onModelChanged` listeners. + * + * Throws `MethodUnsupportedError` if the backend does not support model + * switching. Callers should treat that as "model switching is not + * available" and degrade the UI accordingly. + */ + async setModel(modelId: string): Promise { + if (this.status === "closed") throw new Error("Session is closed"); + if (!this.backendSessionId) throw new Error("Session is still starting"); + const next = await this.backend.setSessionModel({ + sessionId: this.backendSessionId, + modelId, + }); + this.currentState = next; + this.notifyModelChanged(); + } + + /** + * Set a session configuration option (e.g. effort). Reuses + * `notifyModelChanged` because the picker treats model and configOption + * changes as one channel. + */ + async setConfigOption(configId: string, value: string): Promise { + if (this.status === "closed") throw new Error("Session is closed"); + if (!this.backendSessionId) throw new Error("Session is still starting"); + const next = await this.backend.setSessionConfigOption({ + sessionId: this.backendSessionId, + configId, + value, + }); + this.currentState = next; + this.notifyModelChanged(); + this.clearCurrentPlanIfModeLeft(); + } + + /** + * Switch the active session mode (claude-code permission mode, codex + * sandbox preset, etc.). On success, replaces the cached state and + * notifies `onModelChanged` listeners. + * + * Throws `MethodUnsupportedError` when the backend doesn't support mode + * switching. + */ + async setMode(modeId: string): Promise { + if (this.status === "closed") throw new Error("Session is closed"); + if (!this.backendSessionId) throw new Error("Session is still starting"); + const next = await this.backend.setSessionMode({ + sessionId: this.backendSessionId, + modeId, + }); + this.currentState = next; + this.notifyModelChanged(); + this.clearCurrentPlanIfModeLeft(); + } + + /** Whether the user can swap the active model on this session. */ + canSwitchModel(): boolean | null { + return this.backend.isSetSessionModelSupported(); + } + + /** + * Whether the user can swap effort. Descriptor-style backends route + * effort via `setConfigOption`; suffix-style via `setModel`. The wire + * routing is encapsulated here — UI consumers ask intent only. + */ + canSwitchEffort(): boolean | null { + const descriptor = this.getDescriptor?.(); + if (!descriptor) return null; + return descriptor.wire.effortConfigFor + ? this.backend.isSetSessionConfigOptionSupported() + : this.backend.isSetSessionModelSupported(); + } + + /** + * Whether the user can swap modes. Each mode option carries its own + * apply spec (`setMode` or `setConfigOption`); within a single backend + * the dispatch path is consistent, so we sample the first option. + */ + canSwitchMode(): boolean | null { + const mode = this.currentState?.mode; + if (!mode) return null; + const sample = mode.options[0]; + if (!sample) return null; + const spec = mode.apply[sample.value]; + if (!spec) return null; + return spec.kind === "setConfigOption" + ? this.backend.isSetSessionConfigOptionSupported() + : this.backend.isSetSessionModeSupported(); + } + + getStatus(): AgentSessionStatus { + return this.status; + } + + getNeedsAttention(): boolean { + return this.needsAttention; + } + + markNeedsAttention(): void { + if (this.needsAttention) return; + this.needsAttention = true; + this.notifyNeedsAttentionChanged(); + } + + clearNeedsAttention(): void { + if (!this.needsAttention) return; + this.needsAttention = false; + this.notifyNeedsAttentionChanged(); + } + + private notifyNeedsAttentionChanged(): void { + for (const l of this.listeners) { + try { + l.onNeedsAttentionChanged?.(this.needsAttention); + } catch (e) { + logWarn(`[AgentMode] needs-attention listener threw`, e); + } + } + } + + /** + * Whether this session has at least one user-visible message. The model + * picker uses this to decide whether non-active backend entries should be + * hidden (mid-conversation) or shown (empty new tab). + */ + hasUserVisibleMessages(): boolean { + return this.store.getDisplayMessages().length > 0; + } + + /** + * User-supplied label for this session (shown in the tab strip). `null` + * means "no label" — the UI falls back to a positional default like + * "Session N". + */ + getLabel(): string | null { + return this.label; + } + + setLabel(label: string | null): void { + const next = label?.trim() ? label.trim() : null; + if (next === this.label) return; + this.label = next; + this.labelSource = next ? "user" : null; + this.notifyLabelChanged(); + } + + /** + * Apply a label pushed by the backend agent (via `session_info_update`). + * No-op when the user has already renamed this session — Rename wins so + * later agent-side title revisions don't blow away the user's choice. + */ + private applyAgentLabel(label: string | null | undefined): void { + if (this.labelSource === "user") return; + const next = label?.trim() ? label.trim() : null; + if (next === this.label) return; + this.label = next; + this.labelSource = next ? "agent" : null; + this.notifyLabelChanged(); + } + + private notifyLabelChanged(): void { + for (const l of this.listeners) { + try { + l.onLabelChanged?.(); + } catch (e) { + logWarn(`[AgentMode] label listener threw`, e); + } + } + } + + subscribe(listener: AgentSessionListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + /** + * Submit a user prompt. Synchronously appends the user message + an empty + * assistant placeholder to the store and kicks off the backend prompt. + * Streaming session events mutate the placeholder in place. Returns: + * - `userMessageId`: id of the appended user message. + * - `turn`: promise that resolves with `StopReason` when the turn + * completes, or rejects on transport errors. + */ + sendPrompt( + displayText: string, + context?: MessageContext, + content?: unknown[] + ): { userMessageId: string; turn: Promise } { + if (this.status === "starting") { + throw new Error("Session is still starting"); + } + if (this.status === "running" || this.status === "awaiting_permission") { + throw new Error("Session already has a turn in flight"); + } + if (this.status === "closed") { + throw new Error("Session is closed"); + } + + const userMessage: NewAgentChatMessage = { + message: displayText, + sender: USER_SENDER, + timestamp: formatDateTime(new Date()), + isVisible: true, + context, + content, + }; + const userMessageId = this.store.addMessage(userMessage); + + const placeholder: NewAgentChatMessage = { + message: "", + sender: AI_SENDER, + timestamp: formatDateTime(new Date()), + isVisible: true, + parts: [], + }; + this.placeholderId = this.store.addMessage(placeholder); + this.notifyMessages(); + + this.abortController = new AbortController(); + this.setStatus("running"); + + const turn = this.runTurn(displayText, context, content); + return { userMessageId, turn }; + } + + private async runTurn( + displayText: string, + context: MessageContext | undefined, + content?: unknown[] + ): Promise { + const placeholderId = this.placeholderId; + const sessionId = this.backendSessionId!; + const turnStartedAt = Date.now(); + try { + const promptBlocks = buildPromptBlocks(displayText, context, content); + const req: PromptInput = { + sessionId, + prompt: promptBlocks, + }; + const resp = await this.backend.prompt(req); + if ( + placeholderId && + resp.stopReason !== "cancelled" && + !this.store.hasAssistantActivity(placeholderId) + ) { + const message = buildEmptyTurnMessage(this.backendId, resp.stopReason); + logWarn( + `[AgentMode] ${this.backendId} completed a turn without assistant text or tool activity (stopReason=${resp.stopReason})` + ); + this.store.markMessageError(placeholderId, message); + } + if ( + placeholderId && + this.store.markTurnComplete(placeholderId, resp.stopReason, Date.now() - turnStartedAt) + ) { + this.notifyMessages(); + } + this.setStatus("idle"); + if (this.placeholderId === placeholderId) this.placeholderId = null; + if (resp.stopReason === "end_turn") void this.pollSessionTitle(); + return resp.stopReason; + } catch (err) { + logWarn(`[AgentMode] prompt failed`, err); + if (placeholderId) { + this.store.markMessageError(placeholderId, formatPromptFailure(err)); + this.notifyMessages(); + } + this.setStatus("error"); + if (this.placeholderId === placeholderId) this.placeholderId = null; + throw err; + } finally { + this.abortController = null; + } + } + + /** + * Cancel any in-flight turn. The backend may still emit a few trailing + * session events before the prompt promise resolves with + * `stopReason: "cancelled"` — that's expected. + */ + async cancel(): Promise { + if (this.status !== "running" && this.status !== "awaiting_permission") return; + if (!this.backendSessionId) return; + try { + await this.backend.cancel({ sessionId: this.backendSessionId }); + } catch (e) { + logWarn(`[AgentMode] cancel notification failed`, e); + } + this.abortController?.abort(); + } + + /** Detach from the backend. Does not cancel — call `cancel()` first. */ + async dispose(): Promise { + this.disposed = true; + this.unregisterSessionHandler?.(); + this.unregisterSessionHandler = null; + for (const { request, resolve } of this.pendingPlanResolvers.values()) { + resolve(decisionFor(request, PERMISSION_REJECT_KINDS)); + } + this.pendingPlanResolvers.clear(); + this.decidedPlanToolCallIds.clear(); + this.currentPlan = null; + this.setStatus("closed"); + this.cancelScheduledNotify(); + this.listeners.clear(); + } + + /** + * Called by the wrapped permission prompter when an ExitPlanMode permission + * request arrives. Returns a promise the backend will await for the + * outcome; resolved by the chat card via `resolvePlanProposalPermission`. + */ + handlePlanProposalPermission(request: PermissionPrompt): Promise { + const toolCallId = request.toolCall.toolCallId; + const exitPlan = tryReadExitPlanModeCall({ + kind: request.toolCall.kind, + rawInput: request.toolCall.rawInput, + isPlanProposal: request.toolCall.isPlanProposal, + }); + if (exitPlan) { + this.publishGatedPlan(toolCallId, exitPlan); + } + return new Promise((resolve) => { + this.pendingPlanResolvers.set(toolCallId, { request, resolve }); + this.notifyMessages(); + }); + } + + /** + * Resolve a pending ExitPlanMode permission. `allow: true` selects the + * first allow_once option; `false` selects the first reject option (or + * cancels when no reject option is offered). When denying, an optional + * `denyMessage` is forwarded as the agent-visible deny reason — used by + * the plan card to ride user feedback through the same `canUseTool` deny + * instead of as a separate follow-up turn. No-op when no permission is + * pending for the given id (e.g. non-gated OpenCode proposals). + */ + resolvePlanProposalPermission(toolCallId: string, allow: boolean, denyMessage?: string): void { + const entry = this.pendingPlanResolvers.get(toolCallId); + if (!entry) return; + this.pendingPlanResolvers.delete(toolCallId); + const base = decisionFor( + entry.request, + allow ? PERMISSION_ALLOW_KINDS : PERMISSION_REJECT_KINDS + ); + const decision: PermissionDecision = !allow && denyMessage ? { ...base, denyMessage } : base; + entry.resolve(decision); + this.notifyMessages(); + } + + /** Snapshot of the singleton plan, or `null` if there's nothing to review. */ + getCurrentPlan(): CurrentPlan | null { + return this.currentPlan; + } + + /** + * Drop the current plan once the user has decided. The UI gates the card + * render on `decision === "pending"`, so a terminal state is never visible + * to the user — clearing synchronously is fine. + */ + finalizePlanDecision(proposalId: string): boolean { + if (!this.currentPlan || this.currentPlan.id !== proposalId) return false; + if (this.currentPlan.pendingToolCallId) { + this.decidedPlanToolCallIds.add(this.currentPlan.pendingToolCallId); + } + this.currentPlan = null; + this.notifyCurrentPlanChanged(); + return true; + } + + private setCurrentPlan(next: Omit): void { + if (!this.currentPlan) { + this.planSeq += 1; + this.currentPlan = { + ...next, + id: `plan-${this.internalId}-${this.planSeq}`, + revision: 1, + decision: "pending", + }; + this.notifyCurrentPlanChanged(); + return; + } + // Bump revision only when the body actually changed. Gating / + // pendingToolCallId / sourceFilePath flips are control-flow signals, + // not "the plan was revised in place" — they must propagate without + // resetting the per-tab `decided` state in `PlanPreviewView`, which + // keys on revision. MarkdownRenderer perf for body-identical + // republishes is already handled by React's primitive equality on + // the `planMarkdown` string in the consumer's effect deps. + const prev = this.currentPlan; + const bodyChanged = prev.body !== next.body; + const changed = + bodyChanged || + prev.title !== next.title || + prev.sourceFilePath !== next.sourceFilePath || + prev.permissionGated !== next.permissionGated || + prev.pendingToolCallId !== next.pendingToolCallId || + prev.decision !== "pending"; + if (!changed) return; + this.currentPlan = { + ...prev, + ...next, + revision: bodyChanged ? prev.revision + 1 : prev.revision, + decision: "pending", + }; + this.notifyCurrentPlanChanged(); + } + + /** Public — used by mode-picker plumbing to clear the card on mode switch. */ + clearCurrentPlanIfModeLeft(): void { + if (!this.currentPlan) return; + const descriptor = this.getDescriptor?.(); + if (!descriptor) return; + if (!descriptor.getModeMapping) return; + if (this.isCurrentlyInPlanMode()) return; + const pending = this.currentPlan.pendingToolCallId; + if (pending) { + this.resolvePlanProposalPermission(pending, false); + this.decidedPlanToolCallIds.add(pending); + } + this.currentPlan = null; + this.notifyCurrentPlanChanged(); + } + + /** + * Resolve once the session reaches a terminal state for the current turn + * (`idle`, `error`, or `closed`). Used by the UI orchestrator to await + * completion of a permission-resolution-then-followup sequence. + */ + waitForIdle(): Promise { + const terminal = (s: AgentSessionStatus) => s === "idle" || s === "error" || s === "closed"; + if (this.disposed || terminal(this.status)) return Promise.resolve(); + return new Promise((resolve) => { + const unsub = this.subscribe({ + onMessagesChanged: () => {}, + onStatusChanged: (s) => { + if (terminal(s)) { + unsub(); + resolve(); + } + }, + }); + }); + } + + /** + * Whether this session has any pending ExitPlanMode permission. The chat + * input disables itself while one is outstanding so the user is funneled + * toward the proposal card's actions. + */ + hasPendingPlanPermission(): boolean { + return this.pendingPlanResolvers.size > 0; + } + + private handleSessionEvent(event: SessionEvent): void { + const update = event.update; + + // Session-scoped updates aren't tied to a turn placeholder. + if (update.sessionUpdate === "session_info_update") { + this.applyAgentLabel(update.title); + return; + } + if (update.sessionUpdate === "state_changed") { + this.currentState = update.state; + this.notifyModelChanged(); + this.clearCurrentPlanIfModeLeft(); + return; + } + if (update.sessionUpdate === "current_mode_update") { + // The backend will follow this with a `state_changed` event carrying + // the recomputed BackendState; nothing to do here. + return; + } + if (update.sessionUpdate === "config_option_update") { + // Same — the `state_changed` follow-up carries the recomputed state. + return; + } + + const placeholderId = this.placeholderId; + if (!placeholderId) { + logWarn(`[AgentMode] dropping session/update — no placeholder for ${this.internalId}`); + return; + } + + switch (update.sessionUpdate) { + case "agent_message_chunk": { + const text = extractText(update.content); + if (!text) return; + if (this.store.appendAgentText(placeholderId, text)) { + this.scheduleNotifyMessages(); + } + return; + } + case "agent_thought_chunk": { + const text = extractText(update.content); + if (!text) return; + if (this.store.appendAgentThought(placeholderId, text)) { + this.scheduleNotifyMessages(); + } + return; + } + case "tool_call": { + const exitPlan = tryReadExitPlanModeCall({ + kind: update.kind, + rawInput: update.rawInput, + isPlanProposal: update.isPlanProposal, + }); + if (exitPlan) { + this.publishGatedPlan(update.toolCallId, exitPlan); + if (this.store.upsertAgentPart(placeholderId, toolCallToPart(update))) { + this.scheduleNotifyMessages(); + } + return; + } + if (this.store.upsertAgentPart(placeholderId, toolCallToPart(update))) { + this.scheduleNotifyMessages(); + } + return; + } + case "tool_call_update": { + const existing = this.findToolCallPart(placeholderId, update.toolCallId); + const merged = mergeToolCallUpdate(existing, update); + if (merged.kind === "tool_call") { + const exitPlan = tryReadExitPlanModeCall({ + kind: update.kind ?? merged.toolKind, + rawInput: merged.input, + isPlanProposal: update.isPlanProposal, + }); + if (exitPlan) { + this.publishGatedPlan(merged.id, exitPlan); + } + } + if (this.store.upsertAgentPart(placeholderId, merged)) { + this.scheduleNotifyMessages(); + } + return; + } + case "plan": { + if (this.store.upsertAgentPart(placeholderId, planToPart(update))) { + this.scheduleNotifyMessages(); + } + return; + } + default: + logInfo( + `[AgentMode] ignoring session/update kind=${(update as { sessionUpdate: string }).sessionUpdate}` + ); + return; + } + } + + private findToolCallPart(messageId: string, toolCallId: string): AgentMessagePart | undefined { + const msg = this.store.getMessage(messageId); + return msg?.parts?.find((p) => p.kind === "tool_call" && p.id === toolCallId); + } + + /** + * Handle a fresh `ExitPlanMode` tool_call: open or revise the floating + * plan card with the new body and route the gated permission resolver + * id at it. + */ + private publishGatedPlan( + toolCallId: string, + info: { plan: string; planFilePath?: string } + ): void { + if (this.decidedPlanToolCallIds.has(toolCallId)) return; + const stale = this.currentPlan?.pendingToolCallId; + if (stale && stale !== toolCallId) this.resolvePlanProposalPermission(stale, false); + const title = derivePlanTitleFromMarkdown(info.plan); + this.setCurrentPlan({ + body: info.plan, + title, + sourceFilePath: info.planFilePath, + permissionGated: true, + pendingToolCallId: toolCallId, + }); + } + + /** Whether the session is currently in canonical plan mode. */ + private isCurrentlyInPlanMode(): boolean { + return this.currentState?.mode?.current === "plan"; + } + + private setStatus(next: AgentSessionStatus): void { + if (this.status === next) return; + this.status = next; + for (const l of this.listeners) { + try { + l.onStatusChanged(next); + } catch (e) { + logWarn(`[AgentMode] status listener threw`, e); + } + } + } + + private notifyMessages(): void { + this.cancelScheduledNotify(); + for (const l of this.listeners) { + try { + l.onMessagesChanged(); + } catch (e) { + logWarn(`[AgentMode] messages listener threw`, e); + } + } + } + + /** + * Coalesced variant for streaming hot paths. Multiple calls within a + * single animation frame collapse to one `onMessagesChanged` fire. Store + * mutations have already happened synchronously, so subscribers see the + * latest state on their next render — they just see fewer renders. + */ + private scheduleNotifyMessages(): void { + if (this.notifyScheduled) return; + this.notifyScheduled = true; + const fire = (): void => { + this.notifyHandle = null; + this.notifyScheduled = false; + this.notifyMessages(); + }; + if (typeof requestAnimationFrame !== "undefined") { + this.notifyHandle = window.requestAnimationFrame(fire); + } else { + this.notifyHandle = window.setTimeout(fire, 16); + } + } + + private cancelScheduledNotify(): void { + if (!this.notifyScheduled) return; + this.notifyScheduled = false; + if (this.notifyHandle !== null) { + if (typeof cancelAnimationFrame !== "undefined" && typeof this.notifyHandle === "number") { + cancelAnimationFrame(this.notifyHandle); + } else { + window.clearTimeout(this.notifyHandle as ReturnType); + } + this.notifyHandle = null; + } + } + + /** + * Pull the agent-generated title for this session via `listSessions` and + * apply it as the tab label. Best-effort: silently no-ops when the agent + * doesn't support listing or when the title is still the default. + */ + private async pollSessionTitle(): Promise { + if (this.labelSource === "user") return; + try { + const resp = await this.backend.listSessions(this.cwd ? { cwd: this.cwd } : {}); + const entry = resp.sessions.find((s) => s.sessionId === this.backendSessionId); + const title = entry?.title?.trim(); + if (!title) return; + if (title.startsWith(DEFAULT_TITLE_PREFIX)) return; + this.applyAgentLabel(title); + } catch (err) { + if (err instanceof MethodUnsupportedError) return; + logWarn(`[AgentMode] session/list title poll failed for ${this.internalId}`, err); + } + } + + private notifyModelChanged(): void { + for (const l of this.listeners) { + try { + l.onModelChanged?.(); + } catch (e) { + logWarn(`[AgentMode] model listener threw`, e); + } + } + } + + private notifyCurrentPlanChanged(): void { + for (const l of this.listeners) { + try { + l.onCurrentPlanChanged?.(); + } catch (e) { + logWarn(`[AgentMode] plan listener threw`, e); + } + } + } +} + +/** Build the visible message for a completed turn that produced no UI activity. */ +function buildEmptyTurnMessage(backendId: BackendId, stopReason: StopReason): string { + return `${backendId} finished the turn without returning any assistant text or tool activity (stop reason: ${stopReason}). Try again, or switch models if this repeats.`; +} + +/** Format prompt failures with provider error details when available. */ +function formatPromptFailure(err: unknown): string { + const base = err2String(err); + const providerMessage = extractProviderErrorMessage(err); + if (!providerMessage || base.includes(providerMessage)) return base; + return `${base}\n${providerMessage}`; +} + +/** Extract concise model/provider errors nested inside AI SDK error objects. */ +function extractProviderErrorMessage(err: unknown): string | null { + const found = findProviderErrorPayload(err, new Set()); + if (!found) return null; + const type = typeof found.type === "string" ? found.type : "ProviderError"; + const message = typeof found.message === "string" ? found.message : null; + if (!message) return type; + return `${type}: ${message}`; +} + +/** Recursively search common AI SDK error shapes for provider error payloads. */ +function findProviderErrorPayload( + value: unknown, + seen: Set +): { type?: unknown; message?: unknown } | null { + if (value === null || typeof value !== "object") return null; + if (seen.has(value)) return null; + seen.add(value); + const record = value as Record; + const directError = record.error; + if (directError && typeof directError === "object") { + const errorRecord = directError as Record; + if (typeof errorRecord.message === "string" || typeof errorRecord.type === "string") { + return { type: errorRecord.type, message: errorRecord.message }; + } + } + if (record.data) { + const nested = findProviderErrorPayload(record.data, seen); + if (nested) return nested; + } + if (Array.isArray(record.errors)) { + for (const item of record.errors) { + const nested = findProviderErrorPayload(item, seen); + if (nested) return nested; + } + } + const cause = record.cause; + if (cause) return findProviderErrorPayload(cause, seen); + return null; +} + +export function buildPromptBlocks( + displayText: string, + context?: MessageContext, + content?: unknown[] +): PromptContent[] { + // TODO(agent-mode): map `content` (image_url / etc.) to PromptContent + // image/resource entries so attachments aren't silently dropped. Today + // `AgentChat` strips images before calling sendMessage and surfaces a Notice. + void content; + const envelope = buildContextEnvelope(context); + if (!envelope) return [{ type: "text", text: displayText }]; + return [{ type: "text", text: `${envelope}\n\n\n${displayText}\n` }]; +} + +/** + * Build the `` envelope listing attached vault paths and + * inlining note excerpts. Returns `null` when there's nothing to attach. + */ +function buildContextEnvelope(context: MessageContext | undefined): string | null { + if (!context) return null; + const notePaths = (context.notes ?? []).map((n) => n.path).filter(Boolean); + const excerpts = (context.selectedTextContexts ?? []).filter(isNoteSelectedTextContext); + if (notePaths.length === 0 && excerpts.length === 0) return null; + + const lines: string[] = [ + "", + "The user attached the following vault items. The vault is your current working directory; use the Read tool to inspect them when relevant.", + ]; + if (notePaths.length > 0) { + lines.push("", "Notes:"); + for (const p of notePaths) lines.push(`- ${p}`); + } + if (excerpts.length > 0) { + lines.push("", "Selected excerpts (already inlined; no need to re-read):"); + for (const e of excerpts) { + lines.push(`- ${e.notePath} (lines ${e.startLine}-${e.endLine}):`); + for (const l of e.content.split("\n")) lines.push(` ${l}`); + } + } + lines.push(""); + return lines.join("\n"); +} + +function extractText(content: PromptContent): string { + if (content.type === "text") return content.text; + return ""; +} + +function toolCallToPart( + call: ToolCallSnapshot & { sessionUpdate?: "tool_call" } +): AgentMessagePart { + return { + kind: "tool_call", + id: call.toolCallId, + title: call.title, + toolKind: call.kind, + status: call.status ?? "pending", + input: call.rawInput, + output: extractToolCallOutputs(call.content), + locations: call.locations?.map((l) => ({ path: l.path, line: l.line ?? undefined })), + vendorToolName: call.vendorToolName, + parentToolCallId: call.parentToolCallId, + }; +} + +/** + * Detect whether a tool_call / tool_call_update represents the agent's + * plan-finalization signal. Returns the parsed payload (`plan`, optional + * `planFilePath`) when so, or `null` otherwise. + */ +export function tryReadExitPlanModeCall(args: { + kind?: string; + rawInput: unknown; + isPlanProposal?: boolean; +}): { plan: string; planFilePath?: string } | null { + const raw = args.rawInput as { plan?: unknown; planFilePath?: unknown } | null | undefined; + const plan = raw?.plan; + if (typeof plan !== "string") return null; + if (!args.isPlanProposal && args.kind !== "switch_mode") return null; + const planFilePath = typeof raw?.planFilePath === "string" ? raw.planFilePath : undefined; + return { plan, planFilePath }; +} + +function derivePlanTitleFromMarkdown(md: string): string { + for (const line of md.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + return trimmed.replace(/^#+\s*/, "").slice(0, 80); + } + return "Plan proposal"; +} + +function mergeToolCallUpdate( + existing: AgentMessagePart | undefined, + upd: ToolCallDelta & { sessionUpdate?: "tool_call_update" } +): AgentMessagePart { + const base: AgentMessagePart = + existing && existing.kind === "tool_call" + ? existing + : { + kind: "tool_call", + id: upd.toolCallId, + title: upd.title ?? "Tool call", + status: "pending", + }; + if (base.kind !== "tool_call") return base; + return { + ...base, + title: upd.title ?? base.title, + toolKind: upd.kind ?? base.toolKind, + status: upd.status ?? base.status, + input: upd.rawInput !== undefined ? upd.rawInput : base.input, + output: + upd.content !== undefined && upd.content !== null + ? extractToolCallOutputs(upd.content) + : base.output, + locations: + upd.locations !== undefined && upd.locations !== null + ? upd.locations.map((l) => ({ path: l.path, line: l.line ?? undefined })) + : base.locations, + vendorToolName: upd.vendorToolName ?? base.vendorToolName, + parentToolCallId: upd.parentToolCallId ?? base.parentToolCallId, + }; +} + +function extractToolCallOutputs( + content: ToolCallContent[] | null | undefined +): AgentToolCallOutput[] | undefined { + if (!content) return undefined; + const outputs: AgentToolCallOutput[] = []; + for (const item of content) { + if (item.type === "content" && item.content.type === "text") { + outputs.push(truncateToolOutputText(item.content.text)); + } else if (item.type === "diff") { + outputs.push({ + type: "diff", + path: item.path, + oldText: item.oldText ?? null, + newText: item.newText, + }); + } + } + return outputs.length > 0 ? outputs : undefined; +} + +/** + * Keep large command/search results out of long-lived React state. The full + * frame log still captures diagnostic summaries; the UI only needs enough + * text to identify the result and avoid runaway memory growth. + */ +function truncateToolOutputText(text: string): AgentToolCallOutput { + if (text.length <= MAX_TOOL_OUTPUT_TEXT_CHARS) { + return { type: "text", text }; + } + + const omittedLength = text.length - MAX_TOOL_OUTPUT_TEXT_CHARS; + return { + type: "text", + text: + text.slice(0, MAX_TOOL_OUTPUT_TEXT_CHARS) + + `\n\n[Tool output truncated in Copilot UI: ${omittedLength.toLocaleString()} characters omitted.]`, + truncated: true, + originalLength: text.length, + omittedLength, + }; +} + +/** + * Pick the first option matching one of the given kinds (in order) and return + * a `selected` decision. Falls back to `cancelled` (spec-safe no-decision) + * when the agent offers no matching option. + */ +function decisionFor( + req: PermissionPrompt, + kinds: ReadonlyArray +): PermissionDecision { + for (const k of kinds) { + const opt = req.options.find((o) => o.kind === k); + if (opt) return { outcome: { outcome: "selected", optionId: opt.optionId } }; + } + return { outcome: { outcome: "cancelled" } }; +} + +function planToPart(plan: PlanSummary & { sessionUpdate?: "plan" }): AgentMessagePart { + return { + kind: "plan", + entries: plan.entries.map((e) => ({ + content: e.content, + priority: e.priority, + status: e.status, + })), + }; +} diff --git a/src/agentMode/session/AgentSessionManager.test.ts b/src/agentMode/session/AgentSessionManager.test.ts new file mode 100644 index 00000000..11a5ac15 --- /dev/null +++ b/src/agentMode/session/AgentSessionManager.test.ts @@ -0,0 +1,815 @@ +/** + * Pool-semantics tests for AgentSessionManager. The shared backend + * subprocess and the AgentSession factory are mocked so we can exercise + * session-pool invariants without touching ACP or spawning a child process. + */ +import { FileSystemAdapter, App } from "obsidian"; +import { AgentSession } from "./AgentSession"; +import { AgentSessionManager } from "./AgentSessionManager"; +import { setSettings as mockedSetSettings } from "@/settings/model"; +import type { BackendDescriptor } from "./types"; + +jest.mock("@/logger", () => ({ + logInfo: jest.fn(), + logWarn: jest.fn(), + logError: jest.fn(), +})); + +jest.mock("@/settings/model", () => ({ + getSettings: jest.fn(() => ({ + agentMode: { activeBackend: "opencode", backends: {} }, + })), + setSettings: jest.fn(), +})); + +let mockBackendIsRunning = true; +const mockBackendShutdown = jest.fn(async () => undefined); +const mockBackendStart = jest.fn(async () => undefined); +const mockBackendExitListeners = new Set<() => void>(); +const mockSetPermissionPrompter = jest.fn(); + +function makeMockBackendProcess() { + return { + start: mockBackendStart, + setPermissionPrompter: mockSetPermissionPrompter, + onExit: (fn: () => void) => { + mockBackendExitListeners.add(fn); + return () => mockBackendExitListeners.delete(fn); + }, + isRunning: () => mockBackendIsRunning, + shutdown: mockBackendShutdown, + }; +} + +const mockSessionDispose = jest.fn(async () => undefined); +const mockSessionCancel = jest.fn(async () => undefined); +let nextBackendSessionId = 1; + +interface MockSessionTestHandle { + /** Drive the mock session's status the way the real session does. */ + setStatus( + status: "starting" | "idle" | "running" | "awaiting_permission" | "error" | "closed" + ): void; +} + +const sessionTestHandles = new Map(); + +function getSessionTestHandle(session: AgentSession): MockSessionTestHandle { + const handle = sessionTestHandles.get(session.internalId); + if (!handle) throw new Error(`No test handle for ${session.internalId}`); + return handle; +} + +function makeMockSession(overrides: { + internalId: string; + backendSessionId?: string; + backendId: string; + ready?: Promise; +}): AgentSession { + const sessionId = overrides.backendSessionId ?? `backend-${nextBackendSessionId++}`; + let status: "starting" | "idle" | "running" | "awaiting_permission" | "error" | "closed" = "idle"; + let needsAttention = false; + const listeners = new Set<{ + onStatusChanged?: (s: typeof status) => void; + onNeedsAttentionChanged?: (v: boolean) => void; + }>(); + const session = { + internalId: overrides.internalId, + backendId: overrides.backendId, + ready: overrides.ready ?? Promise.resolve(), + getBackendSessionId: () => sessionId, + getStatus: () => status, + cancel: mockSessionCancel, + dispose: mockSessionDispose, + setModel: jest.fn(), + setMode: jest.fn(), + setConfigOption: jest.fn(), + getLabel: () => null, + setLabel: jest.fn(), + subscribe: (l: Parameters[0]) => { + listeners.add(l); + return () => listeners.delete(l); + }, + hasUserVisibleMessages: () => false, + getState: () => null, + getRawSnapshot: () => ({ models: null, modes: null, configOptions: null }), + getNeedsAttention: () => needsAttention, + markNeedsAttention: () => { + if (needsAttention) return; + needsAttention = true; + for (const l of listeners) l.onNeedsAttentionChanged?.(true); + }, + clearNeedsAttention: () => { + if (!needsAttention) return; + needsAttention = false; + for (const l of listeners) l.onNeedsAttentionChanged?.(false); + }, + } as unknown as AgentSession; + sessionTestHandles.set(overrides.internalId, { + setStatus: (next) => { + if (status === next) return; + status = next; + for (const l of listeners) l.onStatusChanged?.(next); + }, + }); + return session; +} + +const sessionCreateSpy = jest + .spyOn(AgentSession, "start") + .mockImplementation((opts) => + makeMockSession({ internalId: opts.internalId, backendId: opts.backendId }) + ); + +function buildApp(basePath = "/vault"): App { + const adapter = new (FileSystemAdapter as unknown as new (basePath: string) => unknown)(basePath); + return { vault: { adapter } } as unknown as App; +} + +function buildPlugin(): { manifest: { version: string } } { + return { manifest: { version: "1.0.0" } }; +} + +function buildDescriptor(): BackendDescriptor { + return { + id: "opencode", + displayName: "opencode", + getInstallState: jest.fn(), + subscribeInstallState: jest.fn(), + openInstallUI: jest.fn(), + createBackendProcess: jest.fn(() => makeMockBackendProcess()), + } as unknown as BackendDescriptor; +} + +function buildManager(): AgentSessionManager { + const descriptor = buildDescriptor(); + const modelPreloader = { + getCachedBackendState: jest.fn(() => null), + preload: jest.fn(async () => undefined), + subscribe: jest.fn(() => () => {}), + shutdown: jest.fn(), + setCached: jest.fn(), + clearCached: jest.fn(), + }; + return new AgentSessionManager( + buildApp(), + buildPlugin() as unknown as ConstructorParameters[1], + { + permissionPrompter: jest.fn(), + resolveDescriptor: (id) => (id === descriptor.id ? descriptor : undefined), + modelPreloader: modelPreloader as unknown as ConstructorParameters< + typeof AgentSessionManager + >[2]["modelPreloader"], + } + ); +} + +beforeEach(() => { + mockBackendIsRunning = true; + mockBackendStart.mockClear(); + mockBackendShutdown.mockClear(); + mockSetPermissionPrompter.mockClear(); + mockBackendExitListeners.clear(); + mockSessionCancel.mockClear(); + mockSessionDispose.mockClear(); + sessionCreateSpy.mockClear(); + nextBackendSessionId = 1; +}); + +describe("AgentSessionManager.createSession", () => { + it("creates a session and sets it as the active one", async () => { + const mgr = buildManager(); + const session = await mgr.createSession(); + expect(mgr.getSessions()).toEqual([session]); + expect(mgr.getActiveSession()).toBe(session); + expect(mgr.getActiveChatUIState()).not.toBeNull(); + expect(mgr.getChatUIState(session.internalId)).toBe(mgr.getActiveChatUIState()); + }); + + it("creating a second session sets it as active but keeps the first in the pool", async () => { + const mgr = buildManager(); + const a = await mgr.createSession(); + const b = await mgr.createSession(); + expect(mgr.getSessions()).toEqual([a, b]); + expect(mgr.getActiveSession()).toBe(b); + }); + + it("two concurrent createSession calls each spawn their own session", async () => { + const mgr = buildManager(); + const [a, b] = await Promise.all([mgr.createSession(), mgr.createSession()]); + expect(a).not.toBe(b); + expect(sessionCreateSpy).toHaveBeenCalledTimes(2); + expect(mgr.getSessions()).toHaveLength(2); + }); + + it("only spawns the backend once across multiple createSession calls", async () => { + const mgr = buildManager(); + await mgr.createSession(); + await mgr.createSession(); + await mgr.createSession(); + expect(mockBackendStart).toHaveBeenCalledTimes(1); + }); + + it("mirrors the new session's unified state into the preloader cache", async () => { + const cache = new Map(); + const modelPreloader = { + getCachedBackendState: jest.fn((id: string) => cache.get(id) ?? null), + preload: jest.fn(async () => undefined), + subscribe: jest.fn(() => () => {}), + shutdown: jest.fn(), + setCached: jest.fn((id: string, state: unknown) => { + cache.set(id, state); + }), + clearCached: jest.fn((id: string) => { + cache.delete(id); + }), + }; + const descriptor = buildDescriptor(); + const mgr = new AgentSessionManager( + buildApp(), + buildPlugin() as unknown as ConstructorParameters[1], + { + permissionPrompter: jest.fn(), + resolveDescriptor: (id) => (id === descriptor.id ? descriptor : undefined), + modelPreloader: modelPreloader as unknown as ConstructorParameters< + typeof AgentSessionManager + >[2]["modelPreloader"], + } + ); + + const modelEntry = { + baseModelId: "anthropic/sonnet", + name: "Claude Sonnet", + provider: "anthropic", + effortOptions: [], + }; + const unified = { + model: { current: { model: modelEntry, effort: null }, availableModels: [modelEntry] }, + mode: null, + }; + sessionCreateSpy.mockImplementationOnce((opts) => { + const s = makeMockSession({ internalId: opts.internalId, backendId: opts.backendId }); + (s as unknown as { getState: () => unknown }).getState = () => unified; + return s; + }); + + await mgr.createSession(); + expect(modelPreloader.setCached).toHaveBeenCalledWith("opencode", unified); + expect(mgr.getCachedBackendState("opencode")).toBe(unified); + + // Spawning a second session before its session/new resolves must not + // overwrite the cached state with nulls. + sessionCreateSpy.mockImplementationOnce((opts) => + makeMockSession({ internalId: opts.internalId, backendId: opts.backendId }) + ); + await mgr.createSession(); + expect(mgr.getCachedBackendState("opencode")).toBe(unified); + }); + + it("a concurrent create that succeeds does not wipe a sibling create's lastError", async () => { + const mgr = buildManager(); + // First call fails. Second call starts before first settles, so the + // pre-fix code would have cleared `lastError` at the second call's start + // and the failure surfaced by the first would be lost. + sessionCreateSpy + .mockImplementationOnce((opts) => + makeMockSession({ + internalId: opts.internalId, + backendId: opts.backendId, + // Failing session: ready rejects after a microtask. The second + // create's ready resolves immediately; with concurrent flushing, + // we still want the first failure to win in lastError. + ready: (async () => { + await Promise.resolve(); + await Promise.resolve(); + throw new Error("boom"); + })(), + }) + ) + .mockImplementationOnce((opts) => + makeMockSession({ + internalId: opts.internalId, + backendSessionId: "backend-ok", + backendId: opts.backendId, + }) + ); + + const failingSession = await mgr.createSession(); + const succeedingSession = await mgr.createSession(); + // Drain the ready continuations so lastError is populated. + await failingSession.ready.catch(() => undefined); + await succeedingSession.ready; + // Allow the manager's `.finally` continuation to run. + await Promise.resolve(); + await Promise.resolve(); + + expect(mgr.getLastError()).toMatch(/boom/); + }); +}); + +describe("AgentSessionManager.getOrCreateActiveSession", () => { + it("dedupes concurrent auto-spawn callers into a single session", async () => { + const mgr = buildManager(); + const [a, b] = await Promise.all([ + mgr.getOrCreateActiveSession(), + mgr.getOrCreateActiveSession(), + ]); + expect(a).toBe(b); + expect(sessionCreateSpy).toHaveBeenCalledTimes(1); + expect(mgr.getSessions()).toHaveLength(1); + }); + + it("returns the existing active session on subsequent calls", async () => { + const mgr = buildManager(); + const a = await mgr.getOrCreateActiveSession(); + const again = await mgr.getOrCreateActiveSession(); + expect(again).toBe(a); + expect(sessionCreateSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe("AgentSessionManager.closeSession", () => { + it("removes the session from the pool and cancels + disposes it", async () => { + const mgr = buildManager(); + const a = await mgr.createSession(); + await mgr.closeSession(a.internalId); + expect(mgr.getSessions()).toEqual([]); + expect(mgr.getActiveSession()).toBeNull(); + expect(mockSessionCancel).toHaveBeenCalled(); + expect(mockSessionDispose).toHaveBeenCalled(); + }); + + it("when the active session is closed, picks the right neighbor as active", async () => { + const mgr = buildManager(); + const a = await mgr.createSession(); + const b = await mgr.createSession(); + const c = await mgr.createSession(); + mgr.setActiveSession(b.internalId); + await mgr.closeSession(b.internalId); + // [a, b, c] -> close b (idx 1) -> remaining [a, c] -> idx 1 -> c + expect(mgr.getActiveSession()).toBe(c); + expect(mgr.getSessions()).toEqual([a, c]); + }); + + it("when the rightmost active session is closed, falls back to the new last", async () => { + const mgr = buildManager(); + const a = await mgr.createSession(); + const b = await mgr.createSession(); + expect(mgr.getActiveSession()).toBe(b); + await mgr.closeSession(b.internalId); + // [a, b] -> close b (idx 1) -> remaining [a] -> idx min(1, 0) = 0 -> a + expect(mgr.getActiveSession()).toBe(a); + }); + + it("closing a non-active session leaves the active pointer alone", async () => { + const mgr = buildManager(); + const a = await mgr.createSession(); + const b = await mgr.createSession(); + await mgr.closeSession(a.internalId); + expect(mgr.getActiveSession()).toBe(b); + expect(mgr.getSessions()).toEqual([b]); + }); + + it("is a no-op for unknown ids", async () => { + const mgr = buildManager(); + await mgr.closeSession("does-not-exist"); + expect(mgr.getSessions()).toEqual([]); + }); +}); + +describe("AgentSessionManager.setActiveSession", () => { + it("moves the active pointer to the given id", async () => { + const mgr = buildManager(); + const a = await mgr.createSession(); + const b = await mgr.createSession(); + expect(mgr.getActiveSession()).toBe(b); + mgr.setActiveSession(a.internalId); + expect(mgr.getActiveSession()).toBe(a); + }); + + it("is a silent no-op on unknown id", async () => { + const mgr = buildManager(); + const a = await mgr.createSession(); + expect(() => mgr.setActiveSession("nope")).not.toThrow(); + expect(mgr.getActiveSession()).toBe(a); + }); +}); + +describe("AgentSessionManager.restartBackend", () => { + it("returns false when the backend has not been started", async () => { + const mgr = buildManager(); + + await expect(mgr.restartBackend("opencode", "skills changed")).resolves.toBe(false); + expect(mockBackendShutdown).not.toHaveBeenCalled(); + }); + + it("restarts an idle backend and replaces the active affected session", async () => { + const mgr = buildManager(); + const first = await mgr.createSession(); + + await expect(mgr.restartBackend("opencode", "skills changed")).resolves.toBe(true); + + expect(mockSessionCancel).toHaveBeenCalledWith(); + expect(mockSessionDispose).toHaveBeenCalledWith(); + expect(mockBackendShutdown).toHaveBeenCalledTimes(1); + expect(mgr.getSessions()).toHaveLength(1); + expect(mgr.getActiveSession()).not.toBe(first); + expect(mgr.getActiveSession()?.backendId).toBe("opencode"); + }); + + it("defers restart until an active turn leaves running", async () => { + const mgr = buildManager(); + const first = await mgr.createSession(); + getSessionTestHandle(first).setStatus("running"); + + await expect(mgr.restartBackend("opencode", "skills changed")).resolves.toBe(true); + expect(mockBackendShutdown).not.toHaveBeenCalled(); + + getSessionTestHandle(first).setStatus("idle"); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + + expect(mockBackendShutdown).toHaveBeenCalledTimes(1); + expect(mgr.getActiveSession()).not.toBe(first); + expect(mgr.getActiveSession()?.backendId).toBe("opencode"); + }); +}); + +describe("AgentSessionManager attention tracking", () => { + it("flags a backgrounded session that finishes a turn", async () => { + const mgr = buildManager(); + const a = await mgr.createSession(); + const b = await mgr.createSession(); + // b is active by default; switch to a so b runs in the background. + mgr.setActiveSession(a.internalId); + const bHandle = getSessionTestHandle(b); + bHandle.setStatus("running"); + bHandle.setStatus("idle"); + expect(b.getNeedsAttention()).toBe(true); + expect(a.getNeedsAttention()).toBe(false); + }); + + it("flags a backgrounded session that errors out", async () => { + const mgr = buildManager(); + const a = await mgr.createSession(); + const b = await mgr.createSession(); + mgr.setActiveSession(a.internalId); + const bHandle = getSessionTestHandle(b); + bHandle.setStatus("running"); + bHandle.setStatus("error"); + expect(b.getNeedsAttention()).toBe(true); + }); + + it("flags a backgrounded session that pauses for permission", async () => { + const mgr = buildManager(); + const a = await mgr.createSession(); + const b = await mgr.createSession(); + mgr.setActiveSession(a.internalId); + const bHandle = getSessionTestHandle(b); + bHandle.setStatus("running"); + bHandle.setStatus("awaiting_permission"); + expect(b.getNeedsAttention()).toBe(true); + }); + + it("does not flag the active session", async () => { + const mgr = buildManager(); + const a = await mgr.createSession(); + expect(mgr.getActiveSession()).toBe(a); + const aHandle = getSessionTestHandle(a); + aHandle.setStatus("running"); + aHandle.setStatus("idle"); + expect(a.getNeedsAttention()).toBe(false); + }); + + it("does not flag the starting → idle transition", async () => { + const mgr = buildManager(); + const a = await mgr.createSession(); + const b = await mgr.createSession(); + mgr.setActiveSession(a.internalId); + const bHandle = getSessionTestHandle(b); + // simulate a fresh boot (mock starts at idle, force a starting → idle). + bHandle.setStatus("starting"); + bHandle.setStatus("idle"); + expect(b.getNeedsAttention()).toBe(false); + }); + + it("clears the flag when the user activates the tab", async () => { + const mgr = buildManager(); + const a = await mgr.createSession(); + const b = await mgr.createSession(); + mgr.setActiveSession(a.internalId); + const bHandle = getSessionTestHandle(b); + bHandle.setStatus("running"); + bHandle.setStatus("idle"); + expect(b.getNeedsAttention()).toBe(true); + mgr.setActiveSession(b.internalId); + expect(b.getNeedsAttention()).toBe(false); + }); +}); + +describe("AgentSessionManager.replaceSessionInPlace", () => { + // Drains the fire-and-forget `closeSession` chain that + // replaceSessionInPlace kicks off, so assertions about pool removal + // and dispose can run synchronously after. + async function flushBackgroundClose(): Promise { + for (let i = 0; i < 5; i++) await Promise.resolve(); + } + + it("inserts the replacement at the old session's tab-strip index", async () => { + const mgr = buildManager(); + const a = await mgr.createSession(); + const b = await mgr.createSession(); + const c = await mgr.createSession(); + // Replace the middle tab — the regression case is "new chat hijacks + // a sibling slot" because the new session is appended at the end. + const replacement = await mgr.replaceSessionInPlace(b.internalId); + await flushBackgroundClose(); + expect(mgr.getSessions()).toEqual([a, replacement, c]); + expect(mgr.getActiveSession()).toBe(replacement); + }); + + it("preserves the leftmost slot when replacing the first tab", async () => { + const mgr = buildManager(); + const a = await mgr.createSession(); + const b = await mgr.createSession(); + mgr.setActiveSession(a.internalId); + const replacement = await mgr.replaceSessionInPlace(a.internalId); + await flushBackgroundClose(); + expect(mgr.getSessions()).toEqual([replacement, b]); + expect(mgr.getActiveSession()).toBe(replacement); + }); + + it("closes the old session in the background", async () => { + const mgr = buildManager(); + const a = await mgr.createSession(); + await mgr.replaceSessionInPlace(a.internalId); + await flushBackgroundClose(); + expect(mockSessionCancel).toHaveBeenCalled(); + expect(mockSessionDispose).toHaveBeenCalled(); + expect(mgr.getSessions().some((s) => s.internalId === a.internalId)).toBe(false); + }); + + it("forwards the explicit backendId to createSession", async () => { + const mgr = buildManager(); + const a = await mgr.createSession(); + await mgr.replaceSessionInPlace(a.internalId, "opencode"); + // The mocked AgentSession.start records the backendId on the session, + // so we can assert it landed on the replacement. + const replacement = mgr.getActiveSession(); + expect(replacement?.backendId).toBe("opencode"); + }); + + it("falls back to plain create when the old id is unknown", async () => { + const mgr = buildManager(); + const replacement = await mgr.replaceSessionInPlace("does-not-exist"); + expect(mgr.getSessions()).toEqual([replacement]); + expect(mgr.getActiveSession()).toBe(replacement); + }); + + it("the replacement also takes the chatUIState slot at the same index", async () => { + // The chatUIStates map is parallel to sessions — if it isn't reordered + // alongside, getActiveChatUIState would point at the wrong session. + const mgr = buildManager(); + const a = await mgr.createSession(); + const b = await mgr.createSession(); + mgr.setActiveSession(a.internalId); + const replacement = await mgr.replaceSessionInPlace(a.internalId); + await flushBackgroundClose(); + expect(mgr.getActiveChatUIState()).toBe(mgr.getChatUIState(replacement.internalId)); + expect(mgr.getChatUIState(b.internalId)).not.toBeNull(); + }); +}); + +describe("AgentSessionManager.subscribe / shutdown", () => { + it("notifies subscribers on session create / close / activate", async () => { + const mgr = buildManager(); + const listener = jest.fn(); + mgr.subscribe(listener); + + const a = await mgr.createSession(); + const b = await mgr.createSession(); + mgr.setActiveSession(a.internalId); + await mgr.closeSession(b.internalId); + + expect(listener.mock.calls.length).toBeGreaterThanOrEqual(4); + }); + + it("shutdown cancels and disposes every session and clears state", async () => { + const mgr = buildManager(); + await mgr.createSession(); + await mgr.createSession(); + expect(mgr.getSessions()).toHaveLength(2); + + await mgr.shutdown(); + expect(mgr.getSessions()).toEqual([]); + expect(mgr.getActiveSession()).toBeNull(); + expect(mockSessionCancel).toHaveBeenCalledTimes(2); + expect(mockSessionDispose).toHaveBeenCalledTimes(2); + expect(mockBackendShutdown).toHaveBeenCalledTimes(1); + }); + + it("backend exit drops every session and surfaces lastError", async () => { + const mgr = buildManager(); + const listener = jest.fn(); + mgr.subscribe(listener); + await mgr.createSession(); + await mgr.createSession(); + + // Simulate the subprocess exiting. + for (const fn of mockBackendExitListeners) fn(); + + expect(mgr.getSessions()).toEqual([]); + expect(mgr.getActiveSession()).toBeNull(); + expect(mgr.getLastError()).toMatch(/exited unexpectedly/); + expect(listener).toHaveBeenCalled(); + }); +}); + +describe("AgentSessionManager.applySelection", () => { + it("no-ops when no session is active", async () => { + const mgr = buildManager(); + await expect( + mgr.applySelection({ effort: "high" }, { expectBackendId: "opencode" }) + ).resolves.toBeUndefined(); + }); + + it("no-ops when the active session is on a different backend", async () => { + const mgr = buildManager(); + await mgr.createSession(); + // Active session is on `opencode`; asking for a different backend + // must refuse so a stray cross-backend apply can't slip through. + await expect( + mgr.applySelection({ effort: "high" }, { expectBackendId: "claude-code" }) + ).resolves.toBeUndefined(); + }); + + it("delegates dispatch to descriptor.applySelection with the resolved selection", async () => { + const applySelectionMock = jest.fn(async () => {}); + const descriptor = { + id: "opencode", + displayName: "opencode", + getInstallState: jest.fn(), + subscribeInstallState: jest.fn(), + openInstallUI: jest.fn(), + createBackendProcess: jest.fn(() => makeMockBackendProcess()), + wire: { + encode: ({ baseModelId, effort }: { baseModelId: string; effort: string | null }) => + effort ? `${baseModelId}/${effort}` : baseModelId, + decode: (id: string) => ({ + selection: { baseModelId: id, effort: null }, + provider: null, + }), + }, + applySelection: applySelectionMock, + } as unknown as BackendDescriptor; + const modelPreloader = { + getCachedBackendState: jest.fn(() => null), + preload: jest.fn(async () => undefined), + subscribe: jest.fn(() => () => {}), + shutdown: jest.fn(), + setCached: jest.fn(), + clearCached: jest.fn(), + }; + const mgr = new AgentSessionManager( + buildApp(), + buildPlugin() as unknown as ConstructorParameters[1], + { + permissionPrompter: jest.fn(), + resolveDescriptor: (id) => (id === descriptor.id ? descriptor : undefined), + modelPreloader: modelPreloader as unknown as ConstructorParameters< + typeof AgentSessionManager + >[2]["modelPreloader"], + } + ); + const entry = { + baseModelId: "anthropic/sonnet", + name: "Sonnet", + provider: "anthropic", + effortOptions: [ + { value: null, label: "Default" }, + { value: "high", label: "High" }, + ], + }; + sessionCreateSpy.mockImplementationOnce((opts) => { + const s = makeMockSession({ internalId: opts.internalId, backendId: opts.backendId }); + (s as unknown as { getState: () => unknown }).getState = () => ({ + model: { + current: { baseModelId: entry.baseModelId, effort: null }, + availableModels: [entry], + }, + mode: null, + }); + return s; + }); + const session = await mgr.createSession(); + + // Effort-only patch: baseModelId resolves from current state. + (mockedSetSettings as jest.Mock).mockClear(); + await mgr.applySelection({ effort: "high" }, { expectBackendId: "opencode" }); + expect(applySelectionMock).toHaveBeenCalledWith(session, { + baseModelId: "anthropic/sonnet", + effort: "high", + }); + // Resolved selection is also persisted to settings. + const persistedAfterEffort = readPersistedDefault(mockedSetSettings as jest.Mock, "opencode"); + expect(persistedAfterEffort).toEqual({ + baseModelId: "anthropic/sonnet", + effort: "high", + }); + + // Full patch: both fields land verbatim. + applySelectionMock.mockClear(); + (mockedSetSettings as jest.Mock).mockClear(); + await mgr.applySelection({ baseModelId: "anthropic/opus", effort: null }); + expect(applySelectionMock).toHaveBeenCalledWith(session, { + baseModelId: "anthropic/opus", + effort: null, + }); + const persistedAfterFull = readPersistedDefault(mockedSetSettings as jest.Mock, "opencode"); + expect(persistedAfterFull).toEqual({ + baseModelId: "anthropic/opus", + effort: null, + }); + }); + + it("does not persist when the descriptor's applySelection throws", async () => { + const applySelectionMock = jest.fn(async () => { + throw new Error("nope"); + }); + const descriptor = { + id: "opencode", + displayName: "opencode", + getInstallState: jest.fn(), + subscribeInstallState: jest.fn(), + openInstallUI: jest.fn(), + createBackendProcess: jest.fn(() => makeMockBackendProcess()), + wire: { + encode: ({ baseModelId }: { baseModelId: string }) => baseModelId, + decode: (id: string) => ({ + selection: { baseModelId: id, effort: null }, + provider: null, + }), + }, + applySelection: applySelectionMock, + } as unknown as BackendDescriptor; + const modelPreloader = { + getCachedBackendState: jest.fn(() => null), + preload: jest.fn(async () => undefined), + subscribe: jest.fn(() => () => {}), + shutdown: jest.fn(), + setCached: jest.fn(), + clearCached: jest.fn(), + }; + const mgr = new AgentSessionManager( + buildApp(), + buildPlugin() as unknown as ConstructorParameters[1], + { + permissionPrompter: jest.fn(), + resolveDescriptor: (id) => (id === descriptor.id ? descriptor : undefined), + modelPreloader: modelPreloader as unknown as ConstructorParameters< + typeof AgentSessionManager + >[2]["modelPreloader"], + } + ); + sessionCreateSpy.mockImplementationOnce((opts) => { + const s = makeMockSession({ internalId: opts.internalId, backendId: opts.backendId }); + (s as unknown as { getState: () => unknown }).getState = () => ({ + model: { + current: { baseModelId: "anthropic/sonnet", effort: null }, + availableModels: [ + { baseModelId: "anthropic/sonnet", name: "Sonnet", provider: null, effortOptions: [] }, + ], + }, + mode: null, + }); + return s; + }); + await mgr.createSession(); + (mockedSetSettings as jest.Mock).mockClear(); + await expect( + mgr.applySelection({ baseModelId: "anthropic/opus", effort: "high" }) + ).rejects.toThrow("nope"); + // No persistence after a failed apply. + expect(readPersistedDefault(mockedSetSettings as jest.Mock, "opencode")).toBeUndefined(); + }); +}); + +/** + * Walk through `setSettings` calls (each carries an updater function) and + * return the most recent `defaultModel` written for `backendId`. + */ +function readPersistedDefault( + setSettings: jest.Mock, + backendId: string +): { baseModelId: string; effort: string | null } | undefined { + let backends: Record = + {}; + for (const call of setSettings.mock.calls) { + const updater = call[0]; + if (typeof updater !== "function") continue; + const patch = updater({ agentMode: { backends } }); + if (patch?.agentMode?.backends) { + backends = { ...backends, ...patch.agentMode.backends }; + } + } + return backends[backendId]?.defaultModel; +} diff --git a/src/agentMode/session/AgentSessionManager.ts b/src/agentMode/session/AgentSessionManager.ts new file mode 100644 index 00000000..3ebc2a75 --- /dev/null +++ b/src/agentMode/session/AgentSessionManager.ts @@ -0,0 +1,941 @@ +import { logError, logInfo, logWarn } from "@/logger"; +import type CopilotPlugin from "@/main"; +import { AgentChatUIState } from "@/agentMode/session/AgentChatUIState"; +import { getSettings, setSettings } from "@/settings/model"; +import { err2String } from "@/utils"; +import type { ChatHistoryItem } from "@/components/chat-components/ChatHistoryPopover"; +import { fileToHistoryItem } from "@/utils/chatHistoryUtils"; +import { App, FileSystemAdapter, Notice, Platform, TFile } from "obsidian"; +import { v4 as uuidv4 } from "uuid"; +import { AgentSession, ATTENTION_TRIGGER_STATUSES } from "./AgentSession"; +import type { AgentChatPersistenceManager } from "./AgentChatPersistenceManager"; +import type { AgentModelPreloader } from "./AgentModelPreloader"; +import type { + BackendDescriptor, + BackendId, + BackendProcess, + BackendState, + CopilotMode, + ModeApplySpec, + ModelSelection, + PermissionDecision, + PermissionPrompt, +} from "./types"; + +const AUTOSAVE_DEBOUNCE_MS = 500; + +export type PermissionPrompter = (req: PermissionPrompt) => Promise; + +// Injected by the barrel so `session/` doesn't have to import +// `backends/registry` directly (would breach the layer boundary). +export type DescriptorResolver = (id: BackendId) => BackendDescriptor | undefined; + +export interface AgentSessionManagerOptions { + permissionPrompter: PermissionPrompter; + resolveDescriptor: DescriptorResolver; + modelPreloader: AgentModelPreloader; + /** + * Persistence layer for Agent Mode chats. Optional only so legacy callers + * (tests) can omit it; production wiring always supplies one via the + * barrel in `agentMode/index.ts`. + */ + persistenceManager?: AgentChatPersistenceManager; +} + +/** + * Plugin-scoped coordinator for Agent Mode. Owns one `AcpBackendProcess` per + * registered backend (lazy-spawned on first `createSession(backendId)`) and a + * pool of `AgentSession`s, each tagged with the backend it was created on. + * Tears every backend down on plugin unload via `shutdown()`. + * + * Backend pluggability is handled via `BackendDescriptor`: the manager + * resolves descriptors from `backendRegistry` and calls + * `descriptor.createBackend(plugin)` to construct each `AcpBackend` — it + * never imports a specific backend class. The permission prompter is + * injected so this file stays out of the UI layer. + */ +export class AgentSessionManager { + private backends = new Map(); + private starting = new Map>(); + private sessions = new Map(); + private chatUIStates = new Map(); + private activeSessionId: string | null = null; + // Dedupe only the auto-spawn path. Direct `createSession()` calls (e.g. `+` + // clicks) are independent — concurrent ones each spawn their own session. + private firstSessionPromise: Promise | null = null; + private pendingCreates = 0; + private listeners = new Set<() => void>(); + private disposed = false; + private startingBackendId: BackendId | null = null; + private lastError: string | null = null; + private readonly pendingBackendRestarts = new Map(); + private readonly restartingBackends = new Set(); + private readonly preloader: AgentModelPreloader; + /** + * Resolves once the plugin-load preload phase has settled (regardless of + * per-backend success). Lets the chat UI gate its first render so the + * picker never flashes empty before the cache populates. + */ + private preloadPromise: Promise = Promise.resolve(); + private preloadReady = true; + // Per-session bookkeeping, all keyed by `internalId`. Mixes persistence + // bookkeeping with subscription teardowns — the unifying property is + // "must be cleaned up when the session is detached": + // - `path`: persisted file (set after first successful save) + // - `timer`: pending debounce timer + // - `unsub`: tear-down for the auto-save `session.subscribe()` + // - `signature`: last serialized snapshot, for no-op skipping + // - `modelCacheUnsub`: tear-down for the model-cache mirror subscription + // - `attentionUnsub`: tear-down for the needs-attention status watcher + private readonly sessionState = new Map< + string, + { + path?: string; + timer?: number; + unsub?: () => void; + signature?: string; + modelCacheUnsub?: () => void; + attentionUnsub?: () => void; + } + >(); + + private getSessionState(internalId: string) { + let entry = this.sessionState.get(internalId); + if (!entry) { + entry = {}; + this.sessionState.set(internalId, entry); + } + return entry; + } + + constructor( + private readonly app: App, + private readonly plugin: CopilotPlugin, + private readonly opts: AgentSessionManagerOptions + ) { + if (Platform.isMobile) { + throw new Error("AgentSessionManager is desktop only"); + } + this.preloader = opts.modelPreloader; + } + + /** + * List every persisted Agent Mode chat as a `ChatHistoryItem` ranked using + * the plugin's shared in-memory `lastAccessedAt` tracker. Returns `[]` when + * persistence isn't configured. + */ + async getChatHistoryItems(): Promise { + const persistence = this.opts.persistenceManager; + if (!persistence) return []; + const files = await persistence.getAgentChatHistoryFiles(); + const tracker = this.plugin.getChatHistoryLastAccessedAtManager(); + return files.map((file) => fileToHistoryItem(file, tracker)); + } + + /** Update the user-visible title (frontmatter `topic`) of a saved chat. */ + async updateChatTitle(fileId: string, newTitle: string): Promise { + const persistence = this.opts.persistenceManager; + if (!persistence) throw new Error("Agent chat persistence is not configured."); + await persistence.updateTopic(fileId, newTitle); + } + + /** Delete a saved chat by file path. */ + async deleteChatHistory(fileId: string): Promise { + const persistence = this.opts.persistenceManager; + if (!persistence) throw new Error("Agent chat persistence is not configured."); + await persistence.deleteFile(fileId); + } + + /** + * Return the active `AgentSession` if one exists, otherwise create one. + * Used by the router to lazily seed the first session on chain switch. + * Subsequent `+` clicks should call `createSession()` directly. + */ + async getOrCreateActiveSession(): Promise { + if (this.disposed) { + throw new Error("AgentSessionManager has been shut down"); + } + const active = this.getActiveSession(); + if (active && active.getStatus() !== "closed") return active; + // Dedupe rapid auto-spawn callers (e.g. the router effect re-running + // before the first create has populated the pool) so we don't seed two + // sessions when one was asked for. + if (this.firstSessionPromise) return this.firstSessionPromise; + this.firstSessionPromise = this.createSession(); + try { + return await this.firstSessionPromise; + } finally { + this.firstSessionPromise = null; + } + } + + /** + * Spawn a fresh `AgentSession`. Lazily starts the requested backend on its + * first call. The new session becomes the active one. `backendId` defaults + * to `settings.agentMode.activeBackend` (the model-picker keeps that in + * sync with the user's most recently selected default model). + * + * The new session's initial (model, effort) is read from the persisted + * default for `backendId` via `getDefaultSelection`. Picker call sites that + * want a specific selection on a new backend should call + * `persistDefaultSelection` first. + */ + async createSession(backendId?: BackendId): Promise { + if (this.disposed) { + throw new Error("AgentSessionManager has been shut down"); + } + + const adapter = this.app.vault.adapter; + if (!(adapter instanceof FileSystemAdapter)) { + throw new Error("Agent Mode requires desktop Obsidian (FileSystemAdapter)."); + } + const vaultBasePath = adapter.getBasePath(); + + const resolvedId = backendId ?? getSettings().agentMode?.activeBackend ?? "opencode"; + const descriptor = this.resolveDescriptor(resolvedId); + + this.pendingCreates++; + this.startingBackendId = resolvedId; + this.notify(); + + let backend: BackendProcess; + try { + backend = await this.ensureBackend(resolvedId, descriptor); + } catch (err) { + this.lastError = err2String(err); + this.finishPendingCreate(); + throw err; + } + + if (this.disposed) { + this.finishPendingCreate(); + throw new Error("AgentSessionManager was shut down during session creation"); + } + + const seedSelection = this.getDefaultSelection(resolvedId); + const defaultModelId = seedSelection ? descriptor.wire.encode(seedSelection) : undefined; + const session = AgentSession.start({ + backend, + cwd: vaultBasePath, + internalId: uuidv4(), + backendId: resolvedId, + defaultModelId, + getDescriptor: () => this.opts.resolveDescriptor(resolvedId), + }); + this.sessions.set(session.internalId, session); + this.chatUIStates.set(session.internalId, new AgentChatUIState(session)); + this.activeSessionId = session.internalId; + this.attachAutoSave(session); + this.attachModelCacheSync(session); + this.attachAttentionTracking(session); + this.notify(); + + // Once the ACP session is ready, apply backend-specific persisted state + // (claude-code's effort, future config-option preferences) and clear the + // "starting" pill. On failure, capture into `lastError` so the status + // surface and retry handler can react. The session itself transitions to + // status "error" inside its own `initialize`. + void session.ready + .then(async () => { + if (descriptor.applyInitialSessionConfig) { + try { + await descriptor.applyInitialSessionConfig(session, getSettings()); + } catch (e) { + logWarn( + `[AgentMode] applyInitialSessionConfig failed for ${resolvedId}; continuing`, + e + ); + } + } + this.lastError = null; + logInfo( + `[AgentMode] session ready (internal=${session.internalId} backend-id=${session.getBackendSessionId()} backend=${resolvedId}); pool size=${this.sessions.size}` + ); + }) + .catch((err) => { + this.lastError = err2String(err); + }) + .finally(() => this.finishPendingCreate()); + + return session; + } + + private finishPendingCreate(): void { + this.pendingCreates--; + if (this.pendingCreates === 0) this.startingBackendId = null; + this.notify(); + } + + private resolveDescriptor(backendId: BackendId): BackendDescriptor { + const descriptor = this.opts.resolveDescriptor(backendId); + if (!descriptor) { + throw new Error(`Unknown backend "${backendId}". Did you forget to register it?`); + } + return descriptor; + } + + setDefaultBackend(backendId: BackendId): void { + if (getSettings().agentMode?.activeBackend === backendId) return; + setSettings((cur) => ({ + agentMode: { ...cur.agentMode, activeBackend: backendId }, + })); + this.notify(); + } + + /** Read the user's sticky model preference for `backendId`, or `null` if none. */ + getDefaultSelection(backendId: BackendId): ModelSelection | null { + const backends = getSettings().agentMode?.backends as + | Record + | undefined; + return backends?.[backendId]?.defaultModel ?? null; + } + + /** Persist a sticky model preference for `backendId`. Pass `null` to clear. */ + async persistDefaultSelection( + backendId: BackendId, + selection: ModelSelection | null + ): Promise { + setSettings((cur) => { + const existing = (cur.agentMode.backends as Record | undefined)?.[ + backendId + ] as Record | undefined; + return { + agentMode: { + ...cur.agentMode, + backends: { + ...cur.agentMode.backends, + [backendId]: { ...(existing ?? {}), defaultModel: selection }, + }, + }, + }; + }); + } + + /** Persist a sticky mode preference for `backendId`. No-op if the descriptor doesn't opt in. */ + async persistModeFor(backendId: BackendId, value: CopilotMode): Promise { + const descriptor = this.resolveDescriptor(backendId); + if (!descriptor.persistModeSelection) return; + await descriptor.persistModeSelection(value, this.plugin); + } + + /** + * Apply a (baseModelId, effort) selection to the active session. Both + * fields are optional patches against the current selection: + * - `baseModelId` omitted → keep current + * - `effort` omitted → keep current + * - `effort: null` → explicit "default" (no-op for descriptor-style + * backends, encoded as the bare model id for suffix-style backends) + * + * `opts.expectBackendId`, when provided, makes this a silent no-op if + * the active session is on a different backend. Used by the effort + * sibling, which captures the backend id at picker-build time and + * might fire after a session swap. + * + * After a successful descriptor apply, the resolved selection is also + * written to the persisted default for the active backend — symmetric + * with `applyMode`. If the descriptor throws, no persistence occurs. + */ + async applySelection( + patch: { baseModelId?: string; effort?: string | null }, + opts?: { expectBackendId?: BackendId } + ): Promise { + const session = this.getActiveSession(); + if (!session) return; + if (opts?.expectBackendId && session.backendId !== opts.expectBackendId) return; + const current = session.getState()?.model?.current; + if (!current) return; + const descriptor = this.resolveDescriptor(session.backendId); + const resolved: ModelSelection = { + baseModelId: patch.baseModelId ?? current.baseModelId, + effort: patch.effort !== undefined ? patch.effort : current.effort, + }; + await descriptor.applySelection(session, resolved); + await this.persistDefaultSelection(session.backendId, resolved); + } + + /** + * Apply a canonical mode change against the active session. `value` is + * the canonical id (used for persistence); `spec` carries the native + * dispatch info (which ACP RPC + payload). + */ + async applyMode(backendId: BackendId, value: CopilotMode, spec: ModeApplySpec): Promise { + const session = this.getActiveSession(); + if (!session || session.backendId !== backendId) return; + if (spec.kind === "setMode") { + await session.setMode(spec.nativeId); + } else { + await session.setConfigOption(spec.configId, spec.value); + } + await this.persistModeFor(backendId, value); + } + + getBackendProcess(backendId: BackendId): BackendProcess | null { + return this.backends.get(backendId) ?? null; + } + + /** + * Restart a backend process so spawn-time configuration, including native + * skill discovery and deny rules, is rebuilt from current settings. If a + * session on that backend is busy, the restart is deferred until it is idle. + * + * Returns `true` when a running backend was restarted or a restart was + * scheduled; `false` when no backend process exists yet. + */ + async restartBackend(backendId: BackendId, reason: string): Promise { + if (this.disposed) return false; + const inflight = this.starting.get(backendId); + if (inflight) { + await inflight.catch(() => undefined); + } + const backend = this.backends.get(backendId); + if (!backend) return false; + if (this.hasBusySession(backendId)) { + const prev = this.pendingBackendRestarts.get(backendId); + this.pendingBackendRestarts.set(backendId, prev ? `${prev}; ${reason}` : reason); + logInfo(`[AgentMode] deferred ${backendId} backend restart: ${reason}`); + return true; + } + await this.restartBackendNow(backendId, reason); + return true; + } + + /** Cached unified backend state for `backendId`, populated by the model preloader. */ + getCachedBackendState(backendId: BackendId): BackendState | null { + return this.preloader.getCachedBackendState(backendId); + } + + /** + * The agent's catalog-declared default base model id for `backendId`. + * Trusts `availableModels` ordering (agents put their recommended model + * first). Returns `null` when the catalog hasn't been probed yet. + */ + getDefaultBaseModelId(backendId: BackendId): string | null { + const state = this.preloader.getCachedBackendState(backendId); + return state?.model?.availableModels[0]?.baseModelId ?? null; + } + + /** Subscribe to preloader cache updates. Used by the picker hook. */ + subscribeModelCache(listener: () => void): () => void { + return this.preloader.subscribe(listener); + } + + /** Kick off a (best-effort) model probe for `backendId`. */ + preloadModels(backendId: BackendId): Promise { + return this.preloader.preload(backendId); + } + + /** + * Register the aggregate preload promise — typically the `Promise.allSettled` + * of every backend's `preloadModels` call from plugin bring-up. While it + * pends, `isPreloadReady()` returns `false`; the chat UI uses that flag to + * render a "Loading…" placeholder instead of an empty picker. + */ + setPreloadPromise(promise: Promise): void { + this.preloadReady = false; + this.preloadPromise = promise; + void promise.then(() => { + this.preloadReady = true; + this.notify(); + }); + this.notify(); + } + + /** Resolves once `setPreloadPromise`'s promise settles. */ + whenPreloadReady(): Promise { + return this.preloadPromise; + } + + /** Synchronous check, suitable for React render gates. */ + isPreloadReady(): boolean { + return this.preloadReady; + } + + /** + * Cancel any in-flight turn, dispose the session, and remove it from the + * pool. If the closed session was active, picks the right neighbor (or the + * last remaining session) as the new active — `null` when none remain. + * Backend stays up. + */ + async closeSession(id: string): Promise { + const session = this.sessions.get(id); + if (!session) return; + // Capture the closed tab's index BEFORE delete so we can pick the + // neighbor that currently sits to its right. + const idsBefore = Array.from(this.sessions.keys()); + const closedIdx = idsBefore.indexOf(id); + try { + await session.cancel(); + } catch (e) { + logWarn(`[AgentMode] cancel during closeSession failed`, e); + } + // Drain any pending debounced auto-save before tearing the session + // down — otherwise the last few tokens of a fast turn never reach disk. + await this.drainAutoSave(session); + try { + await session.dispose(); + } catch (e) { + logWarn(`[AgentMode] dispose during closeSession failed`, e); + } + this.detachAutoSave(id); + this.sessions.delete(id); + this.chatUIStates.delete(id); + if (this.activeSessionId === id) { + const remaining = Array.from(this.sessions.keys()); + this.activeSessionId = + remaining.length === 0 ? null : remaining[Math.min(closedIdx, remaining.length - 1)]; + } + this.notify(); + } + + /** Move the active pointer to `id`. No-op if `id` is unknown. */ + setActiveSession(id: string): void { + const session = this.sessions.get(id); + if (!session) return; + if (this.activeSessionId === id) return; + this.activeSessionId = id; + session.clearNeedsAttention(); + this.notify(); + } + + /** + * Spawn a fresh session at `oldId`'s tab-strip position and close `oldId` + * in the background. Used by the in-tab "New Chat" button so the + * replacement chat takes the same slot the user was looking at instead of + * appearing at the end of the strip (which made it look like focus had + * jumped to a sibling tab). `backendId` defaults to the same fallback as + * `createSession`. + */ + async replaceSessionInPlace(oldId: string, backendId?: BackendId): Promise { + const oldIdx = Array.from(this.sessions.keys()).indexOf(oldId); + const created = await this.createSession(backendId); + if (oldIdx >= 0) { + this.moveMapEntry(this.sessions, created.internalId, oldIdx); + this.moveMapEntry(this.chatUIStates, created.internalId, oldIdx); + this.notify(); + } + void this.closeSession(oldId).catch((e) => + logWarn(`[AgentMode] closeSession during replaceSessionInPlace failed`, e) + ); + return created; + } + + // Maps preserve insertion order, so reordering means rebuilding the map. + // Used to land a freshly-created session at a specific tab-strip index. + private moveMapEntry(map: Map, key: string, targetIdx: number): void { + if (!map.has(key)) return; + const entries = Array.from(map.entries()); + const fromIdx = entries.findIndex(([k]) => k === key); + if (fromIdx === -1 || fromIdx === targetIdx) return; + const [entry] = entries.splice(fromIdx, 1); + entries.splice(targetIdx, 0, entry); + map.clear(); + for (const [k, v] of entries) map.set(k, v); + } + + /** Update a session's user-visible label. No-op if `id` is unknown. */ + renameSession(id: string, label: string | null): void { + const session = this.sessions.get(id); + if (!session) return; + session.setLabel(label); + this.notify(); + } + + getIsStarting(): boolean { + return this.startingBackendId !== null; + } + + /** Backend id currently being booted, or null when no create is in flight. */ + getStartingBackendId(): BackendId | null { + return this.startingBackendId; + } + + getLastError(): string | null { + return this.lastError; + } + + getSession(id: string): AgentSession | null { + return this.sessions.get(id) ?? null; + } + + /** + * Find a session by its backend `sessionId` (the agent-side identifier + * embedded in `requestPermission` / `session/update` notifications). + * Distinct from the internal id keying our own pool. Returns null while + * the session is still starting (no backend id yet) or when no session + * matches. + */ + getSessionByBackendId(backendSessionId: string): AgentSession | null { + for (const session of this.sessions.values()) { + if (session.getBackendSessionId() === backendSessionId) return session; + } + return null; + } + + getChatUIState(id: string): AgentChatUIState | null { + return this.chatUIStates.get(id) ?? null; + } + + getActiveSession(): AgentSession | null { + return this.activeSessionId ? (this.sessions.get(this.activeSessionId) ?? null) : null; + } + + getActiveChatUIState(): AgentChatUIState | null { + return this.activeSessionId ? (this.chatUIStates.get(this.activeSessionId) ?? null) : null; + } + + /** All sessions in creation order (Map iteration order). */ + getSessions(): AgentSession[] { + return Array.from(this.sessions.values()); + } + + /** + * Subscribe to lifecycle changes (session created/closed/active changed/ + * label changed, backend exit, isStarting/lastError flips). Returns an + * unsubscribe function. Listeners must not throw. + */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(): void { + for (const l of this.listeners) { + try { + l(); + } catch (e) { + logError("[AgentMode] manager listener threw", e); + } + } + } + + /** Cancel any in-flight turn on the active session. Backend stays up. */ + async cancel(): Promise { + await this.getActiveSession()?.cancel(); + } + + /** + * Tear down every session and every spawned backend subprocess. Safe to + * call when nothing was started; safe to call multiple times. + */ + async shutdown(): Promise { + if (this.disposed) return; + this.disposed = true; + logInfo( + `[AgentMode] shutdown (pool size=${this.sessions.size}, backends=${this.backends.size})` + ); + + const allSessions = Array.from(this.sessions.values()); + // Drain pending auto-saves for every session before disposing — same + // reasoning as `closeSession`. Done before the per-session unsubscribe so + // the timers don't fire with a half-disposed session. + await Promise.allSettled(allSessions.map((s) => this.drainAutoSave(s))); + for (const id of Array.from(this.sessionState.keys())) { + this.detachAutoSave(id); + } + + await Promise.allSettled( + allSessions.map(async (session) => { + try { + await session.cancel(); + } catch (e) { + logError("[AgentMode] cancel during shutdown failed", e); + } + try { + await session.dispose(); + } catch (e) { + logError("[AgentMode] dispose during shutdown failed", e); + } + }) + ); + this.sessions.clear(); + this.chatUIStates.clear(); + this.activeSessionId = null; + + const allBackends = Array.from(this.backends.values()); + await Promise.allSettled( + allBackends.map(async (proc) => { + try { + await proc.shutdown(); + } catch (e) { + logError("[AgentMode] backend shutdown failed", e); + } + }) + ); + this.backends.clear(); + this.starting.clear(); + this.startingBackendId = null; + this.listeners.clear(); + this.preloader.shutdown(); + } + + /** + * Open a previously-saved Agent Mode chat. If a live session is already + * bound to that file (because the user opened it earlier this run), focus + * its tab instead of spawning a duplicate. Otherwise, spin up a fresh + * session on the saved backend, seed its store with the persisted display + * messages, and pin the persisted-path map so subsequent turns update the + * same file. + */ + async loadSessionFromHistory(file: TFile): Promise { + if (!this.opts.persistenceManager) { + throw new Error("Agent chat persistence is not configured."); + } + if (this.disposed) { + throw new Error("AgentSessionManager has been shut down"); + } + + for (const [internalId, state] of this.sessionState.entries()) { + if (state.path !== file.path) continue; + const existing = this.sessions.get(internalId); + if (existing && existing.getStatus() !== "closed") { + this.setActiveSession(internalId); + return existing; + } + state.path = undefined; + } + + const loaded = await this.opts.persistenceManager.loadFile(file); + const session = await this.createSession(loaded.backendId); + session.store.loadMessages(loaded.messages); + if (loaded.label) session.setLabel(loaded.label); + this.getSessionState(session.internalId).path = file.path; + this.notify(); + return session; + } + + private attachAutoSave(session: AgentSession): void { + const persistence = this.opts.persistenceManager; + if (!persistence) return; + + const trigger = () => this.scheduleAutoSave(session); + const unsubscribe = session.subscribe({ + onMessagesChanged: trigger, + onStatusChanged: () => {}, + onLabelChanged: trigger, + }); + this.getSessionState(session.internalId).unsub = unsubscribe; + } + + private scheduleAutoSave(session: AgentSession): void { + const state = this.getSessionState(session.internalId); + if (state.timer) window.clearTimeout(state.timer); + state.timer = window.setTimeout(() => { + state.timer = undefined; + this.flushAutoSave(session).catch((e) => + logWarn(`[AgentMode] auto-save failed for ${session.internalId}`, e) + ); + }, AUTOSAVE_DEBOUNCE_MS); + } + + private async flushAutoSave(session: AgentSession): Promise { + const persistence = this.opts.persistenceManager; + if (!persistence) return; + if (!this.sessions.has(session.internalId)) return; + + const messages = session.store.getDisplayMessages(); + if (messages.length === 0) return; + + const label = session.getLabel(); + // Skip the write when nothing user-visible has changed since the last + // save. Streaming token updates and idempotent label notifications + // otherwise rewrite the entire file on every debounce tick. + const signature = `${label ?? ""}-${messages.length}-${ + messages[messages.length - 1]?.message ?? "" + }`; + const state = this.getSessionState(session.internalId); + if (state.signature === signature) return; + + const result = await persistence.saveSession(messages, session.backendId, { + label, + existingPath: state.path, + }); + if (result) { + state.path = result.path; + state.signature = signature; + } + } + + /** + * Cancel any pending debounced auto-save for `session` and run it + * synchronously, so the on-disk file reflects the final state before the + * session is disposed. Safe to call when no save is pending. + */ + private async drainAutoSave(session: AgentSession): Promise { + const state = this.sessionState.get(session.internalId); + if (!state?.timer) return; + window.clearTimeout(state.timer); + state.timer = undefined; + try { + await this.flushAutoSave(session); + } catch (e) { + logWarn(`[AgentMode] drain auto-save failed for ${session.internalId}`, e); + } + } + + private detachAutoSave(internalId: string): void { + const state = this.sessionState.get(internalId); + if (!state) return; + if (state.timer) window.clearTimeout(state.timer); + state.unsub?.(); + state.modelCacheUnsub?.(); + state.attentionUnsub?.(); + this.sessionState.delete(internalId); + } + + /** + * Mirror this session's unified `BackendState` into the preloader cache + * so the picker reflects current state. Skips when the session has no + * usable state yet (during the `"starting"` window) — a naive sync + * would clobber the previous session's cached entries with an empty + * snapshot. + */ + private attachModelCacheSync(session: AgentSession): void { + const sync = (): void => { + const state = session.getState(); + if (!state) return; + if (!state.model && !state.mode) return; + this.preloader.setCached(session.backendId, state); + }; + sync(); + const unsubscribe = session.subscribe({ + onMessagesChanged: () => {}, + onStatusChanged: () => {}, + onModelChanged: () => sync(), + }); + this.getSessionState(session.internalId).modelCacheUnsub = unsubscribe; + } + + /** + * Watch this session's status transitions and flag `needsAttention` when + * it transitions out of `running` into a state that demands the user's + * eye (turn ended, errored, or paused for permission) while a *different* + * tab is active. The flag is cleared in `setActiveSession` when the user + * clicks back to this tab. + */ + private attachAttentionTracking(session: AgentSession): void { + let prev = session.getStatus(); + const unsubscribe = session.subscribe({ + onMessagesChanged: () => {}, + onStatusChanged: (next) => { + const wasRunning = prev === "running"; + prev = next; + void this.flushDeferredBackendRestartIfReady(session.backendId); + if (!wasRunning) return; + if (!ATTENTION_TRIGGER_STATUSES.has(next)) return; + if (this.activeSessionId === session.internalId) return; + session.markNeedsAttention(); + }, + }); + this.getSessionState(session.internalId).attentionUnsub = unsubscribe; + } + + private async ensureBackend( + backendId: BackendId, + descriptor: BackendDescriptor + ): Promise { + const existing = this.backends.get(backendId); + if (existing && existing.isRunning()) return existing; + const inflight = this.starting.get(backendId); + if (inflight) return inflight; + + const proc = descriptor.createBackendProcess({ + plugin: this.plugin, + app: this.app, + clientVersion: this.plugin.manifest.version, + descriptor, + }); + const startPromise = (async () => { + // ACP backends declare `start()` to spawn the subprocess and run the + // initialize handshake. In-process adapters (Claude SDK) omit it. + if (proc.start) await proc.start(); + proc.setPermissionPrompter(this.opts.permissionPrompter); + proc.onExit(() => { + // Backend died unexpectedly. Sessions belonging to *this* backend + // are now unusable (their backend session ids are dead) — but other + // backends keep running. Preserving message history across crashes + // is M5. + if (this.backends.get(backendId) === proc) this.backends.delete(backendId); + const dead = Array.from(this.sessions.values()).filter((s) => s.backendId === backendId); + if (dead.length === 0) return; + for (const s of dead) { + this.detachAutoSave(s.internalId); + this.sessions.delete(s.internalId); + this.chatUIStates.delete(s.internalId); + s.cancel().catch(() => {}); + s.dispose().catch(() => {}); + } + if (this.activeSessionId && !this.sessions.has(this.activeSessionId)) { + const remaining = Array.from(this.sessions.keys()); + this.activeSessionId = remaining[0] ?? null; + } + // Surface the crash so the empty-state pill shows it and the + // router's auto-spawn effect (which bails on lastError) doesn't + // immediately respawn behind the user's back. The next explicit + // create call clears it. + this.lastError = `${descriptor.displayName} backend exited unexpectedly.`; + this.notify(); + }); + this.backends.set(backendId, proc); + return proc; + })(); + this.starting.set(backendId, startPromise); + try { + return await startPromise; + } finally { + this.starting.delete(backendId); + } + } + + /** Whether any session for `backendId` is not safe to dispose yet. */ + private hasBusySession(backendId: BackendId): boolean { + return Array.from(this.sessions.values()).some((session) => { + if (session.backendId !== backendId) return false; + const status = session.getStatus(); + return status === "starting" || status === "running" || status === "awaiting_permission"; + }); + } + + /** Execute a pending backend restart once every session for that backend is idle. */ + private async flushDeferredBackendRestartIfReady(backendId: BackendId): Promise { + const reason = this.pendingBackendRestarts.get(backendId); + if (!reason) return; + if (this.hasBusySession(backendId)) return; + this.pendingBackendRestarts.delete(backendId); + try { + await this.restartBackendNow(backendId, reason); + } catch (err) { + this.lastError = err2String(err); + logError(`[AgentMode] deferred ${backendId} backend restart failed`, err); + this.notify(); + } + } + + /** Immediately tear down `backendId` and replace the active affected tab. */ + private async restartBackendNow(backendId: BackendId, reason: string): Promise { + if (this.restartingBackends.has(backendId)) return; + const proc = this.backends.get(backendId); + if (!proc) return; + this.restartingBackends.add(backendId); + logInfo(`[AgentMode] restarting ${backendId} backend: ${reason}`); + try { + const affected = Array.from(this.sessions.values()).filter((s) => s.backendId === backendId); + const shouldCreateReplacement = + affected.length > 0 && affected.some((s) => s.internalId === this.activeSessionId); + for (const session of affected) { + await this.closeSession(session.internalId); + } + await proc.shutdown(); + if (this.backends.get(backendId) === proc) { + this.backends.delete(backendId); + } + this.preloader.clearCached(backendId); + new Notice(`${this.resolveDescriptor(backendId).displayName} refreshed after skill changes.`); + if (shouldCreateReplacement && !this.disposed) { + await this.createSession(backendId); + } + this.notify(); + } finally { + this.restartingBackends.delete(backendId); + } + } +} diff --git a/src/agentMode/session/applyPersistedMode.ts b/src/agentMode/session/applyPersistedMode.ts new file mode 100644 index 00000000..5825d536 --- /dev/null +++ b/src/agentMode/session/applyPersistedMode.ts @@ -0,0 +1,32 @@ +import { logWarn } from "@/logger"; +import type { AgentSession } from "@/agentMode/session/AgentSession"; +import { MethodUnsupportedError } from "@/agentMode/session/errors"; +import type { CopilotMode } from "@/agentMode/session/types"; + +/** + * Replay a persisted mode on a freshly created session. Skipped when the + * agent doesn't advertise modes or when the persisted mode isn't currently + * mappable (filtered out by the descriptor's `getModeMapping`). Dispatches + * on the apply-spec kind so backends with `setMode` channels and backends + * with `configOption`-driven modes share one implementation. + */ +export async function applyPersistedMode( + session: AgentSession, + persistedMode: CopilotMode +): Promise { + const state = session.getState(); + if (!state?.mode) return; + if (state.mode.current === persistedMode) return; + const spec = state.mode.apply[persistedMode]; + if (!spec) return; + try { + if (spec.kind === "setMode") { + await session.setMode(spec.nativeId); + } else { + await session.setConfigOption(spec.configId, spec.value); + } + } catch (e) { + if (e instanceof MethodUnsupportedError) return; + logWarn(`[AgentMode] could not apply default mode ${persistedMode}`, e); + } +} diff --git a/src/agentMode/session/backendSettingsAccess.ts b/src/agentMode/session/backendSettingsAccess.ts new file mode 100644 index 00000000..34ce708d --- /dev/null +++ b/src/agentMode/session/backendSettingsAccess.ts @@ -0,0 +1,20 @@ +import type { CopilotSettings } from "@/settings/model"; +import type { BackendId } from "./types"; + +interface BackendSliceWithOverrides { + modelEnabledOverrides?: Record; +} + +/** + * `undefined` means "no overrides written" — callers should fall back to the + * descriptor's default policy. + */ +export function getBackendModelOverrides( + settings: CopilotSettings, + backendId: BackendId +): Record | undefined { + const backends = settings.agentMode?.backends as + | Record + | undefined; + return backends?.[backendId]?.modelEnabledOverrides; +} diff --git a/src/agentMode/session/debugSink.test.ts b/src/agentMode/session/debugSink.test.ts new file mode 100644 index 00000000..6c9041e7 --- /dev/null +++ b/src/agentMode/session/debugSink.test.ts @@ -0,0 +1,101 @@ +import { FrameSink, getFrameLogPaths, NodeRuntime } from "./debugSink"; + +interface FakeRuntime extends NodeRuntime { + files: Map; + removedPaths: string[]; +} + +/** Create an in-memory runtime for exercising the frame sink without disk IO. */ +function makeRuntime(tmpDir = "/tmp"): FakeRuntime { + const files = new Map(); + const removedPaths: string[] = []; + const join = (...parts: string[]) => parts.join("/").replace(/\/+/g, "/"); + return { + files, + removedPaths, + tmpdir: () => tmpDir, + join, + dirname: (path) => path.slice(0, path.lastIndexOf("/")) || "/", + mkdir: jest.fn(async () => undefined), + appendFile: jest.fn(async (path, data) => { + files.set(path, (files.get(path) ?? "") + data); + }), + writeFile: jest.fn(async (path, data) => { + files.set(path, data); + }), + rm: jest.fn(async (path) => { + removedPaths.push(path); + files.delete(path); + }), + stat: jest.fn(async (path) => { + const data = files.get(path); + if (data === undefined) throw new Error("ENOENT"); + return { size: data.length }; + }), + rename: jest.fn(async (oldPath, newPath) => { + const data = files.get(oldPath); + if (data === undefined) throw new Error("ENOENT"); + files.set(newPath, data); + files.delete(oldPath); + }), + openPath: jest.fn(async () => ""), + }; +} + +describe("FrameSink", () => { + it("stores frame logs in a per-vault temp directory", () => { + const runtime = makeRuntime("C:/Users/zero/AppData/Local/Temp"); + const first = getFrameLogPaths("C:/Users/zero/Vault", runtime); + const second = getFrameLogPaths("C:/Users/zero/OtherVault", runtime); + + expect(first.logPath).toContain("/obsidian-copilot/acp-frames/"); + expect(first.logPath).toMatch(/\/acp-frames\.ndjson$/); + expect(first.rotatedPath).toMatch(/\/acp-frames\.old\.ndjson$/); + expect(first.dirPath).not.toBe(second.dirPath); + }); + + it("summarizes oversized frames before appending", async () => { + const runtime = makeRuntime(); + const sink = new FrameSink({ vaultBasePath: "/vault", runtime }); + const paths = getFrameLogPaths("/vault", runtime); + + sink.append({ + ts: "2026-05-12T00:00:00.000Z", + dir: "←", + tag: "codex", + kind: "notif", + method: "session/update", + id: null, + payload: { + update: { + sessionUpdate: "tool_call_update", + toolCallId: "call-1", + }, + content: "x".repeat(100_000), + }, + }); + await sink.flush(); + + const log = runtime.files.get(paths.logPath) ?? ""; + expect(log.length).toBeLessThan(5_000); + expect(log).toContain('"__truncated":true'); + expect(log).toContain("sessionUpdate=tool_call_update"); + expect(log).toContain("toolCallId=call-1"); + }); + + it("clears active and rotated log files", async () => { + const runtime = makeRuntime(); + const sink = new FrameSink({ vaultBasePath: "/vault", runtime }); + const paths = getFrameLogPaths("/vault", runtime); + runtime.files.set(paths.logPath, "active"); + runtime.files.set(paths.rotatedPath, "old"); + + await sink.clear(); + + expect(runtime.files.has(paths.logPath)).toBe(false); + expect(runtime.files.has(paths.rotatedPath)).toBe(false); + expect(runtime.removedPaths).toEqual( + expect.arrayContaining([paths.logPath, paths.rotatedPath]) + ); + }); +}); diff --git a/src/agentMode/session/debugSink.ts b/src/agentMode/session/debugSink.ts new file mode 100644 index 00000000..27bdaa69 --- /dev/null +++ b/src/agentMode/session/debugSink.ts @@ -0,0 +1,410 @@ +import { FileSystemAdapter } from "obsidian"; + +/** + * Sidecar logger and payload formatter shared by every backend's debug tap. + * The ACP runtime (`acp/debugTap.ts`) and the SDK adapter + * (`sdk/sdkDebugTap.ts`) both feed `frameSink` so JSON-RPC and SDK turns + * land in the same NDJSON file. `tag` distinguishes the source. + */ + +export interface FrameRecord { + ts: string; + dir: "→" | "←"; + tag: string; + kind: "request" | "notif" | "result" | "error" | "raw"; + method: string; + id: string | null; + payload: unknown; +} + +const LOG_FILE_NAME = "acp-frames.ndjson"; +const ROTATED_FILE_NAME = "acp-frames.old.ndjson"; +const DESKTOP_UNAVAILABLE_PATH = "(Agent Mode frame logs are desktop-only)"; +const LOG_DIR_PREFIX = ["obsidian-copilot", "acp-frames"] as const; +const ROTATE_BYTES = 50 * 1024 * 1024; +// Per-frame cap. Some backends (notably codex) re-emit the full cumulative +// tool output on every `tool_call_update`, so a single frame can exceed 1 MB. +// We replace the payload with a `__truncated` stub above this threshold. +const MAX_LINE_BYTES = 64 * 1024; +// Bound the in-flight write queue. Without this, a 160 fps frame storm pins +// hundreds of MB of stringified lines as closures in `writeChain`. +const MAX_QUEUE_FRAMES = 32; +const MAX_QUEUE_BYTES = 8 * 1024 * 1024; +// Stat-based rotation check every N writes. With MAX_LINE_BYTES capped at +// 64 KB, the worst-case overshoot per check window is ~1.6 MB — well under +// any reasonable disk budget. +const ROTATE_CHECK_EVERY = 25; +const MAX_PAYLOAD_CHARS = 400; + +export interface FrameLogPaths { + dirPath: string; + logPath: string; + rotatedPath: string; +} + +export interface NodeRuntime { + tmpdir: () => string; + join: (...parts: string[]) => string; + dirname: (path: string) => string; + mkdir: (path: string, opts: { recursive: boolean }) => Promise; + appendFile: (path: string, data: string, encoding: "utf8") => Promise; + writeFile: (path: string, data: string, encoding: "utf8") => Promise; + rm: (path: string, opts: { force: boolean; recursive?: boolean }) => Promise; + stat: (path: string) => Promise<{ size: number }>; + rename: (oldPath: string, newPath: string) => Promise; + openPath?: (path: string) => Promise; + showItemInFolder?: (path: string) => void; +} + +export interface FrameSinkOptions { + vaultBasePath?: string | null; + runtime?: NodeRuntime | null; +} + +/** + * Sidecar logger for full backend frames. Writes are append-only NDJSON to + * keep the file grep/jq-friendly. Writes are serialized through a single + * promise chain so concurrent calls don't interleave partial lines. + * + * Rotation: every ROTATE_CHECK_EVERY writes, stat the file; if it exceeds + * ROTATE_BYTES, rename to `.old.ndjson` (overwriting any prior `.old`) and + * start a fresh file. Bounds disk use without losing the most recent session. + */ +export class FrameSink { + private writeChain: Promise = Promise.resolve(); + private ensuredDirPath: string | null = null; + private writeCount = 0; + private pendingFrames = 0; + private pendingBytes = 0; + private droppedSinceLastWrite = 0; + + constructor(private readonly options: FrameSinkOptions = {}) {} + + /** Return the current NDJSON log path, or a desktop-unavailable placeholder. */ + getPath(): string { + return this.resolvePaths()?.logPath ?? DESKTOP_UNAVAILABLE_PATH; + } + + /** Schedule a write. Returns immediately; failures are swallowed. */ + append(record: FrameRecord): void { + const paths = this.resolvePaths(); + if (!paths) return; + + const line = this.toLine(record); + + // Backpressure: drop new frames when the queue is saturated. Without + // this, bursty backends (codex emitting cumulative content at 160 fps) + // pin hundreds of MB of stringified lines while the vault adapter + // catches up. + if ( + this.pendingFrames >= MAX_QUEUE_FRAMES || + this.pendingBytes + line.length > MAX_QUEUE_BYTES + ) { + this.droppedSinceLastWrite++; + return; + } + + const lineBytes = line.length; + this.pendingFrames++; + this.pendingBytes += lineBytes; + + this.writeChain = this.writeChain + .then(() => this.doAppend(paths, line)) + .then( + () => { + this.pendingFrames--; + this.pendingBytes -= lineBytes; + }, + () => { + this.pendingFrames--; + this.pendingBytes -= lineBytes; + } + ); + } + + /** Delete the active and rotated log files after queued writes finish. */ + async clear(): Promise { + const task = this.writeChain.then(async () => { + const paths = this.resolvePaths(); + if (!paths) return; + const runtime = this.getRuntime(); + if (!runtime) return; + await removeIfExists(runtime, paths.logPath); + await removeIfExists(runtime, paths.rotatedPath); + }); + this.writeChain = task.catch(() => {}); + return task; + } + + /** Ensure the log exists and open it with the desktop file handler. */ + async open(): Promise { + const task = this.writeChain.then(async () => { + const paths = this.resolvePaths(); + if (!paths) return; + const runtime = this.getRuntime(); + if (!runtime) return; + await this.ensureFolder(runtime, paths.dirPath); + await ensureFileExists(runtime, paths.logPath); + }); + this.writeChain = task.catch(() => {}); + await task; + const paths = this.resolvePaths(); + if (!paths) return; + const runtime = this.getRuntime(); + if (!runtime) return; + if (runtime.openPath) { + const errorMessage = await runtime.openPath(paths.logPath); + if (typeof errorMessage === "string" && errorMessage.length > 0) { + throw new Error(errorMessage); + } + return; + } + if (runtime.showItemInFolder) { + runtime.showItemInFolder(paths.logPath); + return; + } + throw new Error("No OS file opener is available."); + } + + /** Wait for all queued writes to settle. Intended for tests and tooling. */ + async flush(): Promise { + await this.writeChain; + } + + private resolvePaths(): FrameLogPaths | null { + const runtime = this.getRuntime(); + if (!runtime) return null; + const vaultBasePath = this.options.vaultBasePath ?? getVaultBasePath(); + if (!vaultBasePath) return null; + return getFrameLogPaths(vaultBasePath, runtime); + } + + private getRuntime(): NodeRuntime | null { + return this.options.runtime ?? getNodeRuntime(); + } + + private async ensureFolder(runtime: NodeRuntime, dirPath: string): Promise { + if (this.ensuredDirPath === dirPath) return; + await runtime.mkdir(dirPath, { recursive: true }); + this.ensuredDirPath = dirPath; + } + + /** + * Serialize a record to a single NDJSON line, replacing payloads that + * exceed MAX_LINE_BYTES with a `__truncated` stub so a single huge frame + * can't dominate the queue or the on-disk file. + */ + private toLine(record: FrameRecord): string { + let line: string; + try { + line = JSON.stringify(record) + "\n"; + } catch { + // Payload not serializable (e.g. circular). Fall back to a stub so the + // frame still shows up in the log. + return ( + JSON.stringify({ + ...record, + payload: { __unserializable: true }, + }) + "\n" + ); + } + + if (line.length <= MAX_LINE_BYTES) return line; + + let payloadBytes = 0; + try { + payloadBytes = JSON.stringify(record.payload).length; + } catch { + payloadBytes = 0; + } + return ( + JSON.stringify({ + ...record, + payload: { + __truncated: true, + originalBytes: payloadBytes, + summary: summarizePayload(record.payload), + }, + }) + "\n" + ); + } + + private async doAppend(paths: FrameLogPaths, line: string): Promise { + const runtime = this.getRuntime(); + if (!runtime) return; + + // Surface dropped-frame counts inline so debugging-the-debugger is + // possible without code reading. Reset BEFORE writing so concurrent + // drops accumulate into the next note. + let payload = line; + if (this.droppedSinceLastWrite > 0) { + const dropped = this.droppedSinceLastWrite; + this.droppedSinceLastWrite = 0; + const note = + JSON.stringify({ + ts: new Date().toISOString(), + dir: "→", + tag: "frameSink", + kind: "raw", + method: "frameSink.dropped", + id: null, + payload: { dropped }, + }) + "\n"; + payload = note + line; + } + + try { + await this.ensureFolder(runtime, paths.dirPath); + await runtime.appendFile(paths.logPath, payload, "utf8"); + } catch { + // appendFile can fail if the directory was removed while a write was + // queued; recreate the folder and write the frame as a fresh file. + try { + await runtime.mkdir(runtime.dirname(paths.logPath), { recursive: true }); + await runtime.writeFile(paths.logPath, payload, "utf8"); + } catch { + return; + } + } + + this.writeCount++; + if (this.writeCount % ROTATE_CHECK_EVERY === 0) { + await this.maybeRotate(runtime, paths); + } + } + + private async maybeRotate(runtime: NodeRuntime, paths: FrameLogPaths): Promise { + try { + const stat = await runtime.stat(paths.logPath); + if (stat.size < ROTATE_BYTES) return; + await removeIfExists(runtime, paths.rotatedPath); + await runtime.rename(paths.logPath, paths.rotatedPath); + } catch { + // ignore + } + } +} + +/** Build the per-vault temp NDJSON paths used by the full-frame sink. */ +export function getFrameLogPaths(vaultBasePath: string, runtime: NodeRuntime): FrameLogPaths { + const vaultHash = stableHash(vaultBasePath); + const dirPath = runtime.join(runtime.tmpdir(), ...LOG_DIR_PREFIX, vaultHash); + return { + dirPath, + logPath: runtime.join(dirPath, LOG_FILE_NAME), + rotatedPath: runtime.join(dirPath, ROTATED_FILE_NAME), + }; +} + +function getVaultBasePath(): string | null { + if (typeof app === "undefined") return null; + const adapter = app.vault?.adapter; + if (!(adapter instanceof FileSystemAdapter)) return null; + return adapter.getBasePath(); +} + +function getNodeRuntime(): NodeRuntime | null { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const fs = require("node:fs/promises") as typeof import("node:fs/promises"); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const os = require("node:os") as typeof import("node:os"); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const path = require("node:path") as typeof import("node:path"); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const electron = require("electron") as { + shell?: { + openPath?: (path: string) => Promise; + showItemInFolder?: (path: string) => void; + }; + remote?: { + shell?: { + openPath?: (path: string) => Promise; + showItemInFolder?: (path: string) => void; + }; + }; + }; + const shell = electron.shell ?? electron.remote?.shell; + return { + tmpdir: () => os.tmpdir(), + join: (...segs: string[]) => path.join(...segs), + dirname: (p: string) => path.dirname(p), + mkdir: async (dirPath, opts) => { + await fs.mkdir(dirPath, opts); + }, + appendFile: fs.appendFile, + writeFile: fs.writeFile, + rm: fs.rm, + stat: fs.stat, + rename: fs.rename, + openPath: shell?.openPath?.bind(shell), + showItemInFolder: shell?.showItemInFolder?.bind(shell), + }; + } catch { + return null; + } +} + +async function ensureFileExists(runtime: NodeRuntime, path: string): Promise { + try { + await runtime.stat(path); + } catch { + await runtime.writeFile(path, "", "utf8"); + } +} + +async function removeIfExists(runtime: NodeRuntime, path: string): Promise { + try { + await runtime.rm(path, { force: true }); + } catch { + // ignore — file already gone or adapter unavailable + } +} + +function stableHash(value: string): string { + let hash = 0x811c9dc5; + for (let i = 0; i < value.length; i++) { + hash ^= value.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +/** + * Best-effort one-line summary for a truncated payload. Keeps the most + * useful identifying fields (`sessionUpdate`, `toolCallId`, `method`) so a + * truncated frame still tells the reader which call it belonged to. + */ +function summarizePayload(payload: unknown): string { + if (!payload || typeof payload !== "object") return String(payload); + const obj = payload as Record; + const update = obj.update as Record | undefined; + const parts: string[] = []; + if (typeof obj.method === "string") parts.push(`method=${obj.method}`); + if (update && typeof update.sessionUpdate === "string") { + parts.push(`sessionUpdate=${update.sessionUpdate}`); + if (typeof update.toolCallId === "string") parts.push(`toolCallId=${update.toolCallId}`); + } + return parts.join(" ") || ""; +} + +export const frameSink = new FrameSink(); + +/** + * Stringify a payload for the truncated console log. Returns "" for + * undefined so the log line stays compact. + */ +export function formatPayload(value: unknown): string { + if (value === undefined) return ""; + let s: string; + try { + s = JSON.stringify(value); + } catch { + s = + typeof value === "string" || typeof value === "number" || typeof value === "boolean" + ? String(value) + : Object.prototype.toString.call(value); + } + if (s.length <= MAX_PAYLOAD_CHARS) return s; + return s.slice(0, MAX_PAYLOAD_CHARS) + `…(+${s.length - MAX_PAYLOAD_CHARS})`; +} diff --git a/src/agentMode/session/descriptor.ts b/src/agentMode/session/descriptor.ts new file mode 100644 index 00000000..6f4da552 --- /dev/null +++ b/src/agentMode/session/descriptor.ts @@ -0,0 +1,191 @@ +import type { App } from "obsidian"; +import type React from "react"; +import type CopilotPlugin from "@/main"; +import type { CopilotSettings } from "@/settings/model"; +import type { AgentSession } from "@/agentMode/session/AgentSession"; +import type { + BackendConfigOption, + BackendId, + BackendModelInfo, + BackendProcess, + CopilotMode, + ModelSelection, + ModelWireCodec, + ModeMapping, + RawModeState, +} from "./types"; + +/** UI-facing install/setup state for a backend. */ +export type InstallState = + | { kind: "absent" } + | { kind: "ready"; source: "managed" | "custom" } + | { kind: "error"; message: string }; + +/** + * Backend-agnostic descriptor consumed by `session/` and `ui/`. Each backend + * exports one of these from its own folder; the registry maps `BackendId → + * BackendDescriptor`. Adding a new backend is exactly: implement + * `createBackendProcess`, export a `BackendDescriptor`, register it. No + * edits to session or UI. + */ +export interface BackendDescriptor { + readonly id: BackendId; + readonly displayName: string; + + /** + * Brand icon component for this backend. Rendered in the session tab strip + * and anywhere else the UI surfaces backend identity. Should accept a + * `className` for sizing/coloring and use `currentColor` for fill so it + * adopts the surrounding theme color. + */ + readonly Icon: React.ComponentType<{ className?: string }>; + + /** + * Project-relative POSIX path of the directory this backend reads skills + * from. No leading slash. The symlink fanout writes + * `//` for every enabled skill. + */ + readonly skillsProjectDir: string; + + /** + * Other backends whose skill directories this backend also loads skills + * from at spawn time, beyond its own `skillsProjectDir`. Drives the deny + * list for cross-discovered managed skills (see + * `skills/denyListComposer.ts`). + * + * Required (not optional) so a new backend must make an explicit decision. + * `[]` is the right answer when there is no cross-discovery surface. + */ + readonly crossDiscoveredAgents: ReadonlyArray; + + /** + * When true, the host restarts this backend whenever the effective managed + * skill set changes. Set for backends (opencode) whose native skill-command + * cache is built at spawn and won't otherwise pick up symlink fanout changes. + * + * Required (not optional) so a new backend must make an explicit decision. + */ + readonly restartOnManagedSkillsChange: boolean; + + /** Sync read of install/setup state from settings + last-known disk reconcile. */ + getInstallState(settings: CopilotSettings): InstallState; + + /** Subscribe to settings/disk changes affecting install state. Returns unsubscribe. */ + subscribeInstallState(plugin: CopilotPlugin, cb: () => void): () => void; + + /** Open backend-specific install/setup modal. */ + openInstallUI(plugin: CopilotPlugin): void; + + /** + * Construct the backend process the session manager will drive. ACP-style + * backends typically delegate to `simpleBinaryBackendProcess` from + * `backends/shared/`, which wraps `AcpBackendProcess` around an + * `AcpBackend` spawn descriptor. In-process adapters (e.g. the Claude + * Agent SDK) construct their own `BackendProcess` implementation directly. + * + * `descriptor` is the descriptor itself — passed back so the backend + * process can call dispatch hooks (`getModeMapping`, `wire.decode`, + * `wire.encode`, `wire.effortConfigFor`) when producing `BackendState` + * from its native catalogs. + */ + createBackendProcess(args: { + plugin: CopilotPlugin; + app: App; + clientVersion: string; + descriptor: BackendDescriptor; + }): BackendProcess; + + /** Optional: backend-specific settings panel. Rendered inside the Agent Mode tab. */ + SettingsPanel?: React.FC<{ plugin: CopilotPlugin; app: App }>; + + /** Optional: reconcile install state on plugin load (e.g. clear stale managed install). */ + onPluginLoad?(plugin: CopilotPlugin): Promise; + + /** + * Wire-format codec for this backend's model ids. The single point of + * truth for "how does this backend pack model+effort into one + * `RawModelState.availableModels[].modelId` string." Used at the + * agent boundary by the translator (decode incoming catalog) and the + * session manager (encode outgoing `setSessionModel`); never invoked + * by the application layer. + */ + readonly wire: ModelWireCodec; + + /** + * Apply a (baseModelId, effort) selection to a live session. The descriptor + * decides whether effort travels in the wire model id (suffix-style + * backends: codex, opencode) or via a separate `setConfigOption` call + * (descriptor-style: Claude SDK). + * + * `effort: null` means "default" — descriptor-style backends typically + * no-op the effort dispatch on null (no "clear to default" config call + * exists); suffix-style backends encode the null and re-emit the bare + * model id. + * + * Implementations are expected to swallow `MethodUnsupportedError` from + * the underlying `session.setConfigOption` call (the backend may simply + * lack the capability) and propagate everything else. + */ + applySelection(session: AgentSession, selection: ModelSelection): Promise; + + /** + * Optional: return the canonical → native mode mapping for this backend + * given the current session state. Returning `null` hides the mode picker + * for this backend. The mode adapter dispatches on `mapping.kind` to pick + * between "set mode" and "set config option" channels. + */ + getModeMapping?( + modeState: RawModeState | null, + configOptions: BackendConfigOption[] | null + ): ModeMapping | null; + + /** + * Optional: persist the user's chosen mode so the next session can replay + * it. Called by `AgentSessionManager.persistModeFor`. + */ + persistModeSelection?(value: CopilotMode, plugin: CopilotPlugin): Promise; + + /** + * Optional: replay persisted state on a freshly created session. Runs + * once after `createSession` resolves. + */ + applyInitialSessionConfig?(session: AgentSession, settings: CopilotSettings): Promise; + + /** + * Optional: identify the backend's own plan-mode plan files. Used by the + * Claude SDK permission bridge to auto-allow `Write` calls that target + * backend-owned plan markdown (`~/.claude/plans/*.md`) while rejecting + * arbitrary built-in writes. No other consumer today. + * + * `cwd` is the session's working directory; pass `null` when unknown + * (the matcher should still recognize absolute data-dir paths). + */ + isPlanModePlanFilePath?(absolutePath: string, cwd: string | null | undefined): boolean; + + /** + * Optional: default enable/disable policy for an agent-reported model when + * the user has no explicit `modelEnabledOverrides` entry. Returning `true` + * surfaces the model in the chat picker and the settings tab; `false` + * hides it. Omit to default-enable every agent-reported model. + * + * Used as a no-config curation knob — Codex and Opencode advertise large + * catalogs and we ship with one-model defaults; Claude Code defaults to + * showing all reported models. + */ + isModelEnabledByDefault?(model: BackendModelInfo): boolean; + + /** + * Optional: previously-stored sessionId of the backend's dedicated + * "probe session", used by `AgentModelPreloader` to enumerate live models + * across plugin reloads without accumulating one fresh agent-side session + * record per startup. Returns `undefined` when no probe has run yet. + */ + getProbeSessionId?(settings: CopilotSettings): string | undefined; + + /** + * Optional: persist the probe sessionId returned by a successful + * `session/new` probe so the next plugin load can reuse it via + * `resumeSession` or `loadSession`. Only called by `AgentModelPreloader`. + */ + persistProbeSessionId?(sessionId: string, plugin: CopilotPlugin): Promise; +} diff --git a/src/agentMode/session/errors.ts b/src/agentMode/session/errors.ts new file mode 100644 index 00000000..1fbfad2e --- /dev/null +++ b/src/agentMode/session/errors.ts @@ -0,0 +1,23 @@ +/** + * Cross-layer error types raised by the `BackendProcess` contract. Both the + * ACP runtime (`acp/AcpBackendProcess`) and the in-process SDK adapter + * (`sdk/ClaudeSdkBackendProcess`) raise `MethodUnsupportedError` when the + * backend doesn't implement an optional capability; callers in `session/`, + * `backends/*`, and `ui/` catch it and degrade gracefully. + */ + +/** + * Thrown when a backend does not implement an optional `BackendProcess` + * method (e.g. `setSessionModel`, `resumeSession`, `loadSession`). Callers + * should catch this and degrade gracefully (e.g. disable the model picker, + * fall through to the next preloader strategy). + */ +export class MethodUnsupportedError extends Error { + constructor(method: string) { + super(`Agent does not implement ${method}`); + this.name = "MethodUnsupportedError"; + } +} + +/** JSON-RPC standard "Method not found" error code. */ +export const JSONRPC_METHOD_NOT_FOUND = -32601; diff --git a/src/agentMode/session/expandCustomCommandPrefix.test.ts b/src/agentMode/session/expandCustomCommandPrefix.test.ts new file mode 100644 index 00000000..dd901629 --- /dev/null +++ b/src/agentMode/session/expandCustomCommandPrefix.test.ts @@ -0,0 +1,141 @@ +import { mockTFile } from "@/__tests__/mockObsidian"; +import { expandCustomCommandPrefix } from "@/agentMode/session/expandCustomCommandPrefix"; +import { CustomCommand } from "@/commands/type"; +import { extractTemplateNoteFiles } from "@/utils"; +import { Vault } from "obsidian"; + +jest.mock("obsidian", () => ({ + Notice: jest.fn(), + TFile: jest.fn(), + Vault: jest.fn(), +})); + +jest.mock("@/logger", () => ({ + logWarn: jest.fn(), + logInfo: jest.fn(), + logError: jest.fn(), +})); + +jest.mock("@/utils", () => { + const actual = jest.requireActual<{ stripFrontmatter: unknown }>("@/utils"); + return { + extractTemplateNoteFiles: jest.fn().mockReturnValue([]), + getFileContent: jest.fn(), + getFileName: jest.fn(), + getNotesFromPath: jest.fn(), + getNotesFromTags: jest.fn(), + processVariableNameForNotePath: jest.fn(), + stripFrontmatter: actual.stripFrontmatter, + }; +}); + +const makeCommand = (overrides: Partial): CustomCommand => ({ + title: "test", + content: "", + showInContextMenu: false, + showInSlashMenu: true, + order: 0, + modelKey: "", + lastUsedMs: 0, + ...overrides, +}); + +describe("expandCustomCommandPrefix", () => { + let vault: Vault; + + beforeEach(() => { + jest.clearAllMocks(); + (extractTemplateNoteFiles as jest.Mock).mockReturnValue([]); + vault = { + adapter: { + stat: jest.fn().mockResolvedValue({ ctime: Date.now(), mtime: Date.now() }), + }, + } as unknown as Vault; + }); + + it("returns input unchanged when text does not start with slash", async () => { + const result = await expandCustomCommandPrefix("hello world", [], vault, "", null); + expect(result).toEqual({ text: "hello world" }); + }); + + it("returns input unchanged when no command matches", async () => { + const cmds = [makeCommand({ title: "foo", content: "FOO BODY" })]; + const result = await expandCustomCommandPrefix("/unknown", cmds, vault, "", null); + expect(result).toEqual({ text: "/unknown" }); + }); + + it("returns input unchanged for a lone slash", async () => { + const cmds = [makeCommand({ title: "foo", content: "FOO BODY" })]; + const result = await expandCustomCommandPrefix("/", cmds, vault, "", null); + expect(result).toEqual({ text: "/" }); + }); + + it("returns input unchanged when an empty commands list is given (skill collision case)", async () => { + // Mirrors composeSlashMenuItems: if a skill shadows the command title, + // the command never reaches this expander, so an empty list = pass-through. + const result = await expandCustomCommandPrefix("/foo", [], vault, "", null); + expect(result).toEqual({ text: "/foo" }); + }); + + it("expands `/foo` exactly to the command body (no args)", async () => { + const cmd = makeCommand({ title: "foo", content: "FOO BODY" }); + const result = await expandCustomCommandPrefix("/foo", [cmd], vault, "", null); + expect(result.matched).toBe(cmd); + expect(result.text).toBe("FOO BODY\n\n"); + }); + + it("is case-insensitive on the command title", async () => { + const cmd = makeCommand({ title: "Random-Hello", content: "Say hi" }); + const result = await expandCustomCommandPrefix("/random-hello", [cmd], vault, "", null); + expect(result.matched).toBe(cmd); + expect(result.text).toBe("Say hi\n\n"); + }); + + it("appends trailing args to the body before processing", async () => { + const cmd = makeCommand({ title: "foo", content: "FOO BODY" }); + const result = await expandCustomCommandPrefix("/foo bar baz", [cmd], vault, "", null); + expect(result.matched).toBe(cmd); + expect(result.text).toBe("FOO BODY\n\nbar baz\n\n"); + }); + + it("prefers the longest matching title", async () => { + const fooBar = makeCommand({ title: "foo-bar", content: "LONG" }); + const foo = makeCommand({ title: "foo", content: "SHORT" }); + const result = await expandCustomCommandPrefix("/foo-bar args", [foo, fooBar], vault, "", null); + expect(result.matched).toBe(fooBar); + expect(result.text).toBe("LONG\n\nargs\n\n"); + }); + + it("does not match when the title is a prefix but not followed by whitespace", async () => { + const foo = makeCommand({ title: "foo", content: "FOO" }); + const result = await expandCustomCommandPrefix("/foobar", [foo], vault, "", null); + expect(result).toEqual({ text: "/foobar" }); + }); + + it("expands `{}` against selected text", async () => { + const cmd = makeCommand({ title: "rewrite", content: "Rewrite this: {}" }); + const result = await expandCustomCommandPrefix( + "/rewrite", + [cmd], + vault, + "the selected paragraph", + null + ); + expect(result.matched).toBe(cmd); + expect(result.text).toBe( + "Rewrite this: {selected_text}\n\n\nthe selected paragraph\n" + ); + }); + + it("treats activeNote as the {} target when no selection is present", async () => { + const cmd = makeCommand({ title: "summarize", content: "Summarize: {}" }); + const activeNote = mockTFile({ path: "notes/today.md", basename: "today" }); + const utils = jest.requireMock<{ getFileContent: jest.Mock }>("@/utils"); + utils.getFileContent.mockResolvedValue("note body"); + + const result = await expandCustomCommandPrefix("/summarize", [cmd], vault, "", activeNote); + expect(result.matched).toBe(cmd); + expect(result.text).toContain(''); + expect(result.text).toContain("note body"); + }); +}); diff --git a/src/agentMode/session/expandCustomCommandPrefix.ts b/src/agentMode/session/expandCustomCommandPrefix.ts new file mode 100644 index 00000000..91c61515 --- /dev/null +++ b/src/agentMode/session/expandCustomCommandPrefix.ts @@ -0,0 +1,53 @@ +import { processPrompt } from "@/commands/customCommandUtils"; +import type { CustomCommand } from "@/commands/type"; +import type { TFile, Vault } from "obsidian"; + +export interface ExpandCustomCommandResult { + /** Final text to send to the backend. Equal to input when no command matched. */ + text: string; + /** The matched command, if `input` started with `/`. */ + matched?: CustomCommand; +} + +/** + * If `input` starts with `/` (optionally followed by + * whitespace + args), substitute the command's body and return the + * processed prompt. Otherwise return `input` unchanged. + * + * Args typed after the command name are appended to the command body + * (separated by a blank line) so `processPrompt` can resolve `{}` / + * `{selection}` against either selected text or the trailing args. + * + * Matching is case-insensitive on `title`. When multiple titles share a + * prefix (e.g. `foo` and `foo-bar`), the longest match wins. The match + * must be followed by whitespace or end-of-string so `/foobar` does not + * match a `foo` command. + */ +export async function expandCustomCommandPrefix( + input: string, + commands: readonly CustomCommand[], + vault: Vault, + selectedText: string, + activeNote: TFile | null +): Promise { + if (!input.startsWith("/") || input.length < 2) return { text: input }; + + const afterSlash = input.slice(1); + const lowerAfterSlash = afterSlash.toLowerCase(); + + // Longest-first so `/foo-bar` wins over `/foo`. + const candidates = [...commands].sort((a, b) => b.title.length - a.title.length); + const matched = candidates.find((cmd) => { + const title = cmd.title.toLowerCase(); + if (!lowerAfterSlash.startsWith(title)) return false; + const next = afterSlash.charAt(title.length); + return next === "" || /\s/.test(next); + }); + if (!matched) return { text: input }; + + const args = afterSlash.slice(matched.title.length).trim(); + const body = args ? `${matched.content}\n\n${args}` : matched.content; + + const result = await processPrompt(body, selectedText, vault, activeNote, false); + return { text: result.processedPrompt, matched }; +} diff --git a/src/agentMode/session/index.ts b/src/agentMode/session/index.ts new file mode 100644 index 00000000..0825d940 --- /dev/null +++ b/src/agentMode/session/index.ts @@ -0,0 +1,26 @@ +export { + AgentSessionManager, + type AgentSessionManagerOptions, + type PermissionPrompter, +} from "./AgentSessionManager"; +export { AgentSession, type AgentSessionStatus, type AgentSessionListener } from "./AgentSession"; +export { AgentChatUIState } from "./AgentChatUIState"; +export type { AgentChatBackend } from "./AgentChatBackend"; +export { AgentMessageStore } from "./AgentMessageStore"; +export type { + BackendId, + BackendProcess, + AgentChatMessage, + AgentMessagePart, + AgentToolCallOutput, + AgentToolKind, + AgentToolStatus, + AgentPlanEntry, + NewAgentChatMessage, + PermissionDecision, + PermissionPrompt, + SessionEvent, + SessionUpdate, +} from "./types"; +export type { BackendDescriptor, InstallState } from "./descriptor"; +export { MethodUnsupportedError, JSONRPC_METHOD_NOT_FOUND } from "./errors"; diff --git a/src/agentMode/session/mcpResolver.test.ts b/src/agentMode/session/mcpResolver.test.ts new file mode 100644 index 00000000..fca47afc --- /dev/null +++ b/src/agentMode/session/mcpResolver.test.ts @@ -0,0 +1,174 @@ +import { logWarn } from "@/logger"; +import { + resolveMcpServers, + sanitizeStoredMcpServers, + toMcpServerSpec, + type StoredMcpServer, +} from "./mcpResolver"; + +jest.mock("@/logger", () => ({ + logInfo: jest.fn(), + logWarn: jest.fn(), + logError: jest.fn(), +})); + +function fakeProc(supports: { http?: boolean; sse?: boolean }) { + return { + supportsMcpTransport: (transport: "http" | "sse") => { + if (transport === "http") return supports.http === true; + if (transport === "sse") return supports.sse === true; + return false; + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; +} + +describe("toMcpServerSpec", () => { + it("maps a stdio entry without a `type` field (per ACP schema)", () => { + const stored: StoredMcpServer = { + id: "1", + enabled: true, + name: "fs", + transport: "stdio", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem"], + env: [{ name: "FOO", value: "bar" }], + }; + expect(toMcpServerSpec(stored)).toEqual({ + name: "fs", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem"], + env: [{ name: "FOO", value: "bar" }], + }); + }); + + it("maps an http entry with the `type: http` discriminant", () => { + const stored: StoredMcpServer = { + id: "2", + enabled: true, + name: "remote", + transport: "http", + url: "https://example.com/mcp", + headers: [{ name: "Authorization", value: "Bearer x" }], + }; + expect(toMcpServerSpec(stored)).toEqual({ + type: "http", + name: "remote", + url: "https://example.com/mcp", + headers: [{ name: "Authorization", value: "Bearer x" }], + }); + }); + + it("returns null for stdio missing a command", () => { + expect(toMcpServerSpec({ id: "x", enabled: true, name: "n", transport: "stdio" })).toBeNull(); + }); + + it("returns null for http missing a url", () => { + expect(toMcpServerSpec({ id: "x", enabled: true, name: "n", transport: "http" })).toBeNull(); + }); + + it("returns null for empty name", () => { + expect( + toMcpServerSpec({ id: "x", enabled: true, name: " ", transport: "stdio", command: "ls" }) + ).toBeNull(); + }); +}); + +describe("sanitizeStoredMcpServers", () => { + it("returns [] for non-array input (legacy unknown[] placeholder)", () => { + expect(sanitizeStoredMcpServers(undefined)).toEqual([]); + expect(sanitizeStoredMcpServers(null)).toEqual([]); + expect(sanitizeStoredMcpServers({})).toEqual([]); + }); + + it("drops items with an unknown transport", () => { + expect(sanitizeStoredMcpServers([{ name: "x", transport: "websocket" }])).toEqual([]); + }); + + it("backfills a fresh id when missing", () => { + const out = sanitizeStoredMcpServers([{ name: "x", transport: "stdio", command: "ls" }]); + expect(out).toHaveLength(1); + expect(out[0].id).toMatch(/.+/); + expect(out[0].enabled).toBe(true); + }); + + it("treats enabled=false as disabled, anything else as enabled", () => { + const out = sanitizeStoredMcpServers([ + { id: "a", name: "x", transport: "stdio", enabled: false, command: "ls" }, + { id: "b", name: "y", transport: "stdio", command: "ls" }, + ]); + expect(out.map((s) => [s.id, s.enabled])).toEqual([ + ["a", false], + ["b", true], + ]); + }); +}); + +describe("resolveMcpServers", () => { + beforeEach(() => jest.clearAllMocks()); + + it("returns [] when the raw value is missing", () => { + expect(resolveMcpServers(fakeProc({}), undefined)).toEqual([]); + expect(resolveMcpServers(fakeProc({}), null)).toEqual([]); + }); + + it("skips disabled entries silently", () => { + const out = resolveMcpServers(fakeProc({}), [ + { id: "a", name: "x", transport: "stdio", enabled: false, command: "ls" }, + ]); + expect(out).toEqual([]); + expect(logWarn).not.toHaveBeenCalled(); + }); + + it("filters http servers when the agent does not advertise mcp/http", () => { + const out = resolveMcpServers(fakeProc({ http: false }), [ + { + id: "a", + enabled: true, + name: "remote", + transport: "http", + url: "https://example.com", + headers: [], + }, + ]); + expect(out).toEqual([]); + expect(logWarn).toHaveBeenCalledTimes(1); + }); + + it("passes http servers when the agent advertises mcp/http", () => { + const out = resolveMcpServers(fakeProc({ http: true }), [ + { + id: "a", + enabled: true, + name: "remote", + transport: "http", + url: "https://example.com", + headers: [], + }, + ]); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ type: "http", url: "https://example.com" }); + }); + + it("drops malformed entries (no command) and warns about the missing field", () => { + const out = resolveMcpServers(fakeProc({}), [ + { id: "a", enabled: true, name: "x", transport: "stdio" }, + ]); + expect(out).toEqual([]); + expect(logWarn).toHaveBeenCalledWith( + expect.stringContaining('skipping MCP server "x": missing required field "command"') + ); + }); + + it("warns about a missing field even when the agent lacks the matching capability", () => { + // Validation must run before the capability gate so the user gets a + // precise diagnostic instead of a misleading "transport not supported". + const out = resolveMcpServers(fakeProc({ http: false }), [ + { id: "a", enabled: true, name: "broken", transport: "http" }, + ]); + expect(out).toEqual([]); + expect(logWarn).toHaveBeenCalledWith( + expect.stringContaining('skipping MCP server "broken": missing required field "url"') + ); + }); +}); diff --git a/src/agentMode/session/mcpResolver.ts b/src/agentMode/session/mcpResolver.ts new file mode 100644 index 00000000..4b32a427 --- /dev/null +++ b/src/agentMode/session/mcpResolver.ts @@ -0,0 +1,157 @@ +import { logInfo, logWarn } from "@/logger"; +import { v4 as uuidv4 } from "uuid"; +import type { BackendProcess, McpServerSpec } from "@/agentMode/session/types"; + +/** Transport mechanisms supported by the MCP integration. */ +export type McpTransport = "stdio" | "http" | "sse"; + +/** + * Plugin-side storage shape for an MCP server. Includes UI-only fields + * (`id`, `enabled`) that are stripped before sending to the agent. Stdio + * servers populate `command`/`args`/`env`; http/sse populate `url`/`headers`. + */ +export interface StoredMcpServer { + id: string; + enabled: boolean; + name: string; + transport: McpTransport; + command?: string; + args?: string[]; + env?: Array<{ name: string; value: string }>; + url?: string; + headers?: Array<{ name: string; value: string }>; +} + +/** + * Convert a stored entry to the neutral `McpServerSpec` shape, or `null` if + * the entry is malformed (missing required fields for its transport, or + * empty name). Backends translate this to their wire shape at the boundary. + */ +export function toMcpServerSpec(s: StoredMcpServer): McpServerSpec | null { + const name = s.name.trim(); + if (!name) return null; + if (s.transport === "stdio") { + const command = s.command?.trim(); + if (!command) return null; + return { + name, + command, + args: s.args ?? [], + env: s.env ?? [], + }; + } + const url = s.url?.trim(); + if (!url) return null; + const headers = s.headers ?? []; + if (s.transport === "http") { + return { type: "http", name, url, headers }; + } + return { type: "sse", name, url, headers }; +} + +/** + * Pure sanitizer used by the settings layer: drop entries that are not + * plain objects, coerce missing fields to safe defaults, and skip anything + * we cannot recognize as a transport. Tolerates the legacy `unknown[]` + * placeholder shape. Logs a warning when entries are dropped so legacy or + * malformed data does not vanish silently. + */ +export function sanitizeStoredMcpServers(raw: unknown): StoredMcpServer[] { + if (!Array.isArray(raw)) return []; + const out: StoredMcpServer[] = []; + let dropped = 0; + for (const item of raw) { + if (!item || typeof item !== "object") { + dropped++; + continue; + } + const r = item as Record; + const transport = r.transport; + if (transport !== "stdio" && transport !== "http" && transport !== "sse") { + dropped++; + continue; + } + const id = typeof r.id === "string" && r.id ? r.id : uuidv4(); + const name = typeof r.name === "string" ? r.name : ""; + const enabled = r.enabled !== false; + const entry: StoredMcpServer = { id, enabled, name, transport }; + if (transport === "stdio") { + entry.command = typeof r.command === "string" ? r.command : ""; + entry.args = Array.isArray(r.args) + ? r.args.filter((a): a is string => typeof a === "string") + : []; + entry.env = sanitizeKvList(r.env); + } else { + entry.url = typeof r.url === "string" ? r.url : ""; + entry.headers = sanitizeKvList(r.headers); + } + out.push(entry); + } + if (dropped > 0) { + logWarn( + `[AgentMode] dropped ${dropped} unrecognized MCP server entr${dropped === 1 ? "y" : "ies"} from settings (unknown transport or non-object)` + ); + } + return out; +} + +function sanitizeKvList(raw: unknown): Array<{ name: string; value: string }> { + if (!Array.isArray(raw)) return []; + const out: Array<{ name: string; value: string }> = []; + for (const item of raw) { + if (!item || typeof item !== "object") continue; + const r = item as Record; + if (typeof r.name !== "string" || typeof r.value !== "string") continue; + out.push({ name: r.name, value: r.value }); + } + return out; +} + +/** + * Resolve the MCP servers to send to the agent. Filters out disabled and + * malformed entries, plus any http/sse servers when the agent did not + * advertise the matching capability via `supportsMcpTransport`. + * + * Accepts the raw `mcpServers` value (rather than the full settings object) + * to keep this module independent of `CopilotSettings` and avoid a + * settings ⇆ agentMode-barrel type cycle. + */ +export function resolveMcpServers(proc: BackendProcess, rawMcpServers: unknown): McpServerSpec[] { + const stored = sanitizeStoredMcpServers(rawMcpServers); + const out: McpServerSpec[] = []; + for (const s of stored) { + if (!s.enabled) continue; + const wire = toMcpServerSpec(s); + if (!wire) { + const missing = + s.transport === "stdio" + ? !s.name.trim() + ? "name" + : "command" + : !s.name.trim() + ? "name" + : "url"; + logWarn( + `[AgentMode] skipping MCP server "${s.name || "(unnamed)"}": missing required field "${missing}"` + ); + continue; + } + if (s.transport === "http" && !proc.supportsMcpTransport("http")) { + logWarn(`[AgentMode] skipping MCP server "${s.name}": agent does not support http transport`); + continue; + } + if (s.transport === "sse" && !proc.supportsMcpTransport("sse")) { + logWarn(`[AgentMode] skipping MCP server "${s.name}": agent does not support sse transport`); + continue; + } + out.push(wire); + } + const enabledCount = stored.filter((s) => s.enabled).length; + if (enabledCount > 0) { + const summary = out + .map((s) => `${s.name}/${"type" in s && s.type ? s.type : "stdio"}`) + .join(", "); + logInfo(`[AgentMode] resolved ${out.length}/${enabledCount} MCP servers: [${summary}]`); + } + return out; +} diff --git a/src/agentMode/session/modelEnable.test.ts b/src/agentMode/session/modelEnable.test.ts new file mode 100644 index 00000000..0dc67241 --- /dev/null +++ b/src/agentMode/session/modelEnable.test.ts @@ -0,0 +1,77 @@ +import { isAgentModelEnabled, isAgentModelEnabledOrKept } from "./modelEnable"; +import type { BackendDescriptor, BackendModelInfo } from "./types"; + +const baseDescriptor = { + id: "test", + displayName: "Test", +} as unknown as BackendDescriptor; + +const model = (modelId: string, name = modelId): BackendModelInfo => ({ modelId, name }); + +describe("isAgentModelEnabled", () => { + it("returns true when no override and no descriptor policy", () => { + expect(isAgentModelEnabled(baseDescriptor, model("foo"), undefined)).toBe(true); + }); + + it("respects an explicit override of true", () => { + expect(isAgentModelEnabled(baseDescriptor, model("foo"), { foo: true })).toBe(true); + }); + + it("respects an explicit override of false", () => { + const descriptor = { + ...baseDescriptor, + isModelEnabledByDefault: () => true, + } as BackendDescriptor; + expect(isAgentModelEnabled(descriptor, model("foo"), { foo: false })).toBe(false); + }); + + it("falls back to descriptor policy when no override exists", () => { + const descriptor = { + ...baseDescriptor, + isModelEnabledByDefault: (m: BackendModelInfo) => m.modelId === "wanted", + } as BackendDescriptor; + expect(isAgentModelEnabled(descriptor, model("wanted"), undefined)).toBe(true); + expect(isAgentModelEnabled(descriptor, model("other"), undefined)).toBe(false); + }); + + it("override wins even when descriptor policy disagrees", () => { + const descriptor = { + ...baseDescriptor, + isModelEnabledByDefault: () => false, + } as BackendDescriptor; + expect(isAgentModelEnabled(descriptor, model("foo"), { foo: true })).toBe(true); + }); + + it("treats missing override key as 'no override'", () => { + const descriptor = { + ...baseDescriptor, + isModelEnabledByDefault: () => false, + } as BackendDescriptor; + expect(isAgentModelEnabled(descriptor, model("foo"), { bar: true })).toBe(false); + }); +}); + +describe("isAgentModelEnabledOrKept", () => { + const restrictive = { + ...baseDescriptor, + isModelEnabledByDefault: () => false, + } as BackendDescriptor; + + it("force-enables the kept model even when policy disables it", () => { + expect(isAgentModelEnabledOrKept(restrictive, model("kept"), undefined, "kept")).toBe(true); + }); + + it("force-enables the kept model even when an override disables it", () => { + expect(isAgentModelEnabledOrKept(restrictive, model("kept"), { kept: false }, "kept")).toBe( + true + ); + }); + + it("falls through to normal resolution for non-kept models", () => { + expect(isAgentModelEnabledOrKept(restrictive, model("other"), undefined, "kept")).toBe(false); + }); + + it("treats null keepModelId as 'no carve-out'", () => { + expect(isAgentModelEnabledOrKept(restrictive, model("foo"), undefined, null)).toBe(false); + }); +}); diff --git a/src/agentMode/session/modelEnable.ts b/src/agentMode/session/modelEnable.ts new file mode 100644 index 00000000..56781598 --- /dev/null +++ b/src/agentMode/session/modelEnable.ts @@ -0,0 +1,49 @@ +import { getBackendModelOverrides } from "@/agentMode/session/backendSettingsAccess"; +import type { BackendDescriptor, BackendId, BackendModelInfo } from "@/agentMode/session/types"; +import { getSettings, updateAgentModeBackendFields, type CopilotSettings } from "@/settings/model"; + +/** + * User overrides win; absent overrides fall through to the descriptor's + * default policy; absent policy = default-enabled. + */ +export function isAgentModelEnabled( + descriptor: BackendDescriptor, + model: BackendModelInfo, + overrides: Record | undefined +): boolean { + const override = overrides?.[model.modelId]; + if (typeof override === "boolean") return override; + return descriptor.isModelEnabledByDefault?.(model) ?? true; +} + +/** + * `keepModelId` carves out the user's current selection so curation never + * strands it. + */ +export function isAgentModelEnabledOrKept( + descriptor: BackendDescriptor, + model: BackendModelInfo, + overrides: Record | undefined, + keepModelId: string | null +): boolean { + if (keepModelId && model.modelId === keepModelId) return true; + return isAgentModelEnabled(descriptor, model, overrides); +} + +/** + * Atomic per-backend slice update for a single model toggle. Composes the + * next `modelEnabledOverrides` map by reading the previous one off current + * settings; `updateAgentModeBackendFields` merges it back in. Shared by + * the Agents-tab selected list and the catalog modal so both surfaces + * agree on the persistence path. + */ +export function writeAgentModelOverride( + backendId: BackendId, + baseModelId: string, + enabled: boolean +): void { + const prev = getBackendModelOverrides(getSettings(), backendId) ?? {}; + const next: Record = { ...prev, [baseModelId]: enabled }; + type BackendKey = keyof CopilotSettings["agentMode"]["backends"]; + updateAgentModeBackendFields(backendId as BackendKey, { modelEnabledOverrides: next }); +} diff --git a/src/agentMode/session/plan.ts b/src/agentMode/session/plan.ts new file mode 100644 index 00000000..10f5244a --- /dev/null +++ b/src/agentMode/session/plan.ts @@ -0,0 +1,48 @@ +/** + * Decision state surfaced on the floating plan card. `pending` shows the + * action row; the three terminal states collapse it (the card itself is + * cleared shortly after by the session, so terminal states are a brief + * transition, not a persistent display state). + */ +export type PlanProposalDecision = "pending" | "approved" | "rejected" | "rejected_with_feedback"; + +/** UI action the user invokes from the plan card; resolves into a `PlanProposalDecision`. */ +export type PlanDecisionAction = "approve" | "reject" | "feedback"; + +/** + * Session-level "current plan" singleton. There is at most one of these per + * session; while the session is in canonical plan mode and a plan exists, + * the UI renders one floating card pinned to the chat bottom. Updates + * (Claude re-issuing ExitPlanMode) bump `revision` in place rather than + * spawning additional cards. + */ +export interface CurrentPlan { + /** + * Stable id for the plan-mode "review session". Held constant across + * in-place revisions so React state on the card and preview tab stays + * mounted. Reset only when the user resolves the plan or leaves plan + * mode. + */ + id: string; + /** Bumped on every body refresh — used by UI consumers to reset transient state. */ + revision: number; + /** Markdown body shown in the card teaser and the preview tab. */ + body: string; + /** Best-effort title (heading or first line of the body). */ + title: string; + /** + * Path of the plan markdown file the agent owns (Claude Code populates this + * via `ExitPlanMode.rawInput.planFilePath`). Stored as metadata only. + */ + sourceFilePath?: string; + /** + * `true` while a live `ExitPlanMode` permission is awaiting the user's + * decision. Approve resolves with `allow_once`, Reject with `reject_once`, + * Feedback rejects + queues the typed message as a follow-up turn. + */ + permissionGated: boolean; + /** ToolCallId of the live permission, when `permissionGated` is true. */ + pendingToolCallId?: string; + /** Transient state — typically `pending`; the session clears the plan after a terminal decision. */ + decision: PlanProposalDecision; +} diff --git a/src/agentMode/session/translateBackendState.test.ts b/src/agentMode/session/translateBackendState.test.ts new file mode 100644 index 00000000..272beeab --- /dev/null +++ b/src/agentMode/session/translateBackendState.test.ts @@ -0,0 +1,724 @@ +import { + backendStateSignature, + findModelEntry, + modelStateSignature, + modeStateSignature, + translateBackendState, +} from "./translateBackendState"; +import type { BackendState } from "./types"; +import type { + BackendConfigOption, + BackendDescriptor, + RawModeState, + RawModelState, + ModeMapping, + ModelWireCodec, +} from "./types"; + +/** Default codec: no provider, no effort. Treats wire id as the bare baseModelId. */ +const passthroughWire: ModelWireCodec = { + encode: (sel) => sel.baseModelId, + decode: (id) => ({ selection: { baseModelId: id, effort: null }, provider: null }), +}; + +function descriptor(opts: Partial = {}): BackendDescriptor { + return { + id: "test", + displayName: "Test", + wire: passthroughWire, + getInstallState: () => ({ kind: "ready", source: "managed" }), + subscribeInstallState: () => () => {}, + openInstallUI: () => undefined, + createBackendProcess: () => + ({}) as unknown as ReturnType, + ...opts, + } as unknown as BackendDescriptor; +} + +/** Suffix-style codec: `/[/]`. */ +const suffixWire: ModelWireCodec = { + encode: (sel) => (sel.effort ? `${sel.baseModelId}/${sel.effort}` : sel.baseModelId), + decode: (id) => { + if (!id) return { selection: { baseModelId: id, effort: null }, provider: null }; + const segments = id.split("/"); + if (segments.length === 1) { + return { selection: { baseModelId: id, effort: null }, provider: null }; + } + if (segments.length === 2) { + return { selection: { baseModelId: id, effort: null }, provider: segments[0] }; + } + if (segments.length === 3) { + return { + selection: { baseModelId: `${segments[0]}/${segments[1]}`, effort: segments[2] }, + provider: segments[0], + }; + } + return { selection: { baseModelId: id, effort: null }, provider: null }; + }, +}; + +function suffixDescriptor(extra: Partial = {}): BackendDescriptor { + return descriptor({ wire: suffixWire, ...extra }); +} + +function selectOption( + id: string, + values: { value: string; name?: string }[], + current?: string +): BackendConfigOption { + return { + id, + type: "select", + category: null, + name: id, + currentValue: current ?? values[0]?.value, + options: values.map((v) => ({ value: v.value, name: v.name ?? v.value })), + }; +} + +describe("translateBackendState — model: null cases", () => { + it("returns model: null when raw.models is null (case 1)", () => { + const state = translateBackendState( + { models: null, modes: null, configOptions: null }, + descriptor() + ); + expect(state.model).toBeNull(); + }); +}); + +describe("translateBackendState — suffix-style backends", () => { + it("collapses gpt-5 + variants into one entry with effort options (case 2)", () => { + const models: RawModelState = { + currentModelId: "openai/gpt-5/low", + availableModels: [ + { modelId: "openai/gpt-5", name: "GPT-5" }, + { modelId: "openai/gpt-5/low", name: "GPT-5 (low)" }, + { modelId: "openai/gpt-5/medium", name: "GPT-5 (medium)" }, + ], + }; + const state = translateBackendState( + { models, modes: null, configOptions: null }, + suffixDescriptor() + ); + expect(state.model).not.toBeNull(); + expect(state.model!.availableModels).toHaveLength(1); + const entry = state.model!.availableModels[0]; + expect(entry.baseModelId).toBe("openai/gpt-5"); + expect(entry.provider).toBe("openai"); + expect(entry.effortOptions.map((o) => o.value)).toEqual([null, "low", "medium"]); + expect(state.model!.current.baseModelId).toBe(entry.baseModelId); + expect(state.model!.current.effort).toBe("low"); + }); + + it("multi-provider catalog produces one entry per base with own provider (case 3)", () => { + const models: RawModelState = { + currentModelId: "openai/gpt-5/low", + availableModels: [ + { modelId: "openai/gpt-5/low", name: "GPT-5 (low)" }, + { modelId: "anthropic/claude-sonnet-4-5/low", name: "Sonnet (low)" }, + ], + }; + const state = translateBackendState( + { models, modes: null, configOptions: null }, + suffixDescriptor() + ); + const entries = state.model!.availableModels; + expect(entries).toHaveLength(2); + expect(entries[0]).toMatchObject({ baseModelId: "openai/gpt-5", provider: "openai" }); + expect(entries[1]).toMatchObject({ + baseModelId: "anthropic/claude-sonnet-4-5", + provider: "anthropic", + }); + }); + + it("single-variant base produces empty effortOptions (case 4)", () => { + const models: RawModelState = { + currentModelId: "openai/gpt-5", + availableModels: [{ modelId: "openai/gpt-5", name: "GPT-5" }], + }; + const state = translateBackendState( + { models, modes: null, configOptions: null }, + suffixDescriptor() + ); + expect(state.model!.availableModels[0].effortOptions).toEqual([]); + expect(state.model!.current.effort).toBeNull(); + }); + + it("mixed catalog: some bases have variants, some don't (case 5)", () => { + const models: RawModelState = { + currentModelId: "openai/gpt-5/medium", + availableModels: [ + { modelId: "openai/gpt-5", name: "GPT-5" }, + { modelId: "openai/gpt-5/medium", name: "GPT-5 (medium)" }, + { modelId: "anthropic/sonnet", name: "Sonnet" }, + ], + }; + const state = translateBackendState( + { models, modes: null, configOptions: null }, + suffixDescriptor() + ); + const entries = state.model!.availableModels; + const gpt = entries.find((e) => e.baseModelId === "openai/gpt-5")!; + const sonnet = entries.find((e) => e.baseModelId === "anthropic/sonnet")!; + expect(gpt.effortOptions.map((o) => o.value)).toEqual([null, "medium"]); + expect(sonnet.effortOptions).toEqual([]); + }); + + it("current selection with effort suffix is reachable in availableModels (case 9)", () => { + const models: RawModelState = { + currentModelId: "openai/gpt-5/low", + availableModels: [ + { modelId: "openai/gpt-5", name: "GPT-5" }, + { modelId: "openai/gpt-5/low", name: "GPT-5 (low)" }, + ], + }; + const state = translateBackendState( + { models, modes: null, configOptions: null }, + suffixDescriptor() + ); + expect(state.model!.current.baseModelId).toBe("openai/gpt-5"); + expect(state.model!.current.effort).toBe("low"); + expect(findModelEntry(state.model, state.model!.current.baseModelId)).toBeDefined(); + }); + + it("strips trailing effort suffix from grouped name when ≥2 variants", () => { + const models: RawModelState = { + currentModelId: "openai/gpt-5/low", + availableModels: [ + { modelId: "openai/gpt-5/low", name: "GPT-5 (low)" }, + { modelId: "openai/gpt-5/medium", name: "GPT-5 (medium)" }, + ], + }; + const state = translateBackendState( + { models, modes: null, configOptions: null }, + suffixDescriptor() + ); + expect(state.model!.availableModels[0].name).toBe("GPT-5"); + }); + + it("leaves single-variant names untouched even if they look like effort suffixes", () => { + const models: RawModelState = { + currentModelId: "x/some-model", + availableModels: [{ modelId: "x/some-model", name: "Some Model (medium)" }], + }; + const state = translateBackendState( + { models, modes: null, configOptions: null }, + suffixDescriptor() + ); + expect(state.model!.availableModels[0].name).toBe("Some Model (medium)"); + }); +}); + +describe("translateBackendState — descriptor-style backends", () => { + function effortDescriptor(map: Record): BackendDescriptor { + return descriptor({ + wire: { + encode: passthroughWire.encode, + decode: passthroughWire.decode, + effortConfigFor: (baseModelId: string) => map[baseModelId] ?? null, + }, + }); + } + + it("populates effortOptions for every model with a configOption (case 6)", () => { + const opt = selectOption("effort", [{ value: "low" }, { value: "high" }]); + const models: RawModelState = { + currentModelId: "claude-sonnet", + availableModels: [ + { modelId: "claude-sonnet", name: "Sonnet" }, + { modelId: "claude-opus", name: "Opus" }, + ], + }; + const state = translateBackendState( + { models, modes: null, configOptions: null }, + effortDescriptor({ "claude-sonnet": opt, "claude-opus": opt }) + ); + expect(state.model!.availableModels[0].effortOptions.map((o) => o.value)).toEqual([ + "low", + "high", + ]); + expect(state.model!.availableModels[1].effortOptions.map((o) => o.value)).toEqual([ + "low", + "high", + ]); + }); + + it("Haiku-style model with no effort returns empty effortOptions (case 7)", () => { + const sonnetOpt = selectOption("effort", [{ value: "low" }, { value: "high" }]); + const models: RawModelState = { + currentModelId: "claude-haiku", + availableModels: [ + { modelId: "claude-sonnet", name: "Sonnet" }, + { modelId: "claude-haiku", name: "Haiku" }, + ], + }; + const state = translateBackendState( + { models, modes: null, configOptions: null }, + effortDescriptor({ "claude-sonnet": sonnetOpt, "claude-haiku": null }) + ); + const haiku = state.model!.availableModels.find((e) => e.baseModelId === "claude-haiku")!; + expect(haiku.effortOptions).toEqual([]); + expect(state.model!.current.baseModelId).toBe(haiku.baseModelId); + expect(state.model!.current.effort).toBeNull(); + }); + + it("descriptor-style current effort uses live configOptions when present (case 10)", () => { + const spec = selectOption( + "effort", + [{ value: "low" }, { value: "medium" }, { value: "high" }], + "low" + ); + const liveOpts: BackendConfigOption[] = [ + selectOption("effort", [{ value: "low" }, { value: "medium" }, { value: "high" }], "high"), + ]; + const models: RawModelState = { + currentModelId: "claude-sonnet", + availableModels: [{ modelId: "claude-sonnet", name: "Sonnet" }], + }; + const state = translateBackendState( + { models, modes: null, configOptions: liveOpts }, + effortDescriptor({ "claude-sonnet": spec }) + ); + expect(state.model!.current.effort).toBe("high"); + }); + + it("descriptor-style current effort falls back to spec.currentValue without live opts (case 10)", () => { + const spec = selectOption( + "effort", + [{ value: "low" }, { value: "medium" }, { value: "high" }], + "medium" + ); + const models: RawModelState = { + currentModelId: "claude-sonnet", + availableModels: [{ modelId: "claude-sonnet", name: "Sonnet" }], + }; + const state = translateBackendState( + { models, modes: null, configOptions: null }, + effortDescriptor({ "claude-sonnet": spec }) + ); + expect(state.model!.current.effort).toBe("medium"); + }); + + it("Haiku has no effort dimension — current.effort: null (case 11)", () => { + const models: RawModelState = { + currentModelId: "claude-haiku", + availableModels: [{ modelId: "claude-haiku", name: "Haiku" }], + }; + const state = translateBackendState( + { models, modes: null, configOptions: null }, + effortDescriptor({ "claude-haiku": null }) + ); + expect(state.model!.current.effort).toBeNull(); + expect(findModelEntry(state.model, state.model!.current.baseModelId)!.effortOptions).toEqual( + [] + ); + }); +}); + +describe("translateBackendState — provider/parsing edge cases", () => { + it("provider precompute — entries preserve per-id provider (case 8)", () => { + const models: RawModelState = { + currentModelId: "openai/gpt-5", + availableModels: [ + { modelId: "openai/gpt-5", name: "GPT-5" }, + { modelId: "free-form-id", name: "FF" }, + ], + }; + const state = translateBackendState( + { models, modes: null, configOptions: null }, + suffixDescriptor() + ); + const gpt = state.model!.availableModels.find((e) => e.baseModelId === "openai/gpt-5")!; + const ff = state.model!.availableModels.find((e) => e.baseModelId === "free-form-id")!; + expect(gpt.provider).toBe("openai"); + expect(ff.provider).toBeNull(); + }); + + it("currentModelId not in availableModels — translator synthesizes entry (case 12)", () => { + const models: RawModelState = { + currentModelId: "openai/missing", + availableModels: [{ modelId: "openai/gpt-5", name: "GPT-5" }], + }; + const state = translateBackendState( + { models, modes: null, configOptions: null }, + suffixDescriptor() + ); + expect(state.model!.availableModels).toHaveLength(2); + expect(state.model!.current.baseModelId).toBe("openai/missing"); + expect(findModelEntry(state.model, state.model!.current.baseModelId)).toBeDefined(); + }); + + it("backend with passthrough codec and no descriptor effort hook (case 13)", () => { + const models: RawModelState = { + currentModelId: "x/y", + availableModels: [ + { modelId: "x/y", name: "x/y" }, + { modelId: "p/q", name: "p/q" }, + ], + }; + const state = translateBackendState({ models, modes: null, configOptions: null }, descriptor()); + for (const e of state.model!.availableModels) { + expect(e.effortOptions).toEqual([]); + expect(e.provider).toBeNull(); + } + expect(state.model!.current.effort).toBeNull(); + }); + + it("description present / absent round-trips (case 14)", () => { + const models: RawModelState = { + currentModelId: "claude-sonnet", + availableModels: [ + { modelId: "claude-sonnet", name: "Sonnet", description: "Smart and balanced" }, + { modelId: "claude-haiku", name: "Haiku" }, + ], + }; + const state = translateBackendState({ models, modes: null, configOptions: null }, descriptor()); + expect(state.model!.availableModels[0].description).toBe("Smart and balanced"); + expect(state.model!.availableModels[1].description).toBeUndefined(); + }); + + it("EffortOption shape from suffix grouping ≡ shape from effortConfigFor (case 15)", () => { + // Suffix path: gpt-5 with low/medium/high + const suffixModels: RawModelState = { + currentModelId: "openai/gpt-5/medium", + availableModels: [ + { modelId: "openai/gpt-5/low", name: "GPT-5 (low)" }, + { modelId: "openai/gpt-5/medium", name: "GPT-5 (medium)" }, + { modelId: "openai/gpt-5/high", name: "GPT-5 (high)" }, + ], + }; + const suffixState = translateBackendState( + { models: suffixModels, modes: null, configOptions: null }, + suffixDescriptor() + ); + const suffixOpts = suffixState.model!.availableModels[0].effortOptions; + + // Descriptor path: same effort levels via effortConfigFor + const cfgOpt = selectOption("effort", [ + { value: "low" }, + { value: "medium" }, + { value: "high" }, + ]); + const descrModels: RawModelState = { + currentModelId: "claude-sonnet", + availableModels: [{ modelId: "claude-sonnet", name: "Sonnet" }], + }; + const descrState = translateBackendState( + { models: descrModels, modes: null, configOptions: null }, + descriptor({ + wire: { + encode: passthroughWire.encode, + decode: passthroughWire.decode, + effortConfigFor: () => cfgOpt, + }, + }) + ); + const descrOpts = descrState.model!.availableModels[0].effortOptions; + + // Both produce {value, label} shape — labels differ (Default vs from + // configOption name) but value vocabulary aligns. + expect(suffixOpts.every((o) => "value" in o && "label" in o)).toBe(true); + expect(descrOpts.every((o) => "value" in o && "label" in o)).toBe(true); + // Values: suffix path adds null when bare exists, but here it doesn't. + expect(suffixOpts.map((o) => o.value)).toEqual(["low", "medium", "high"]); + expect(descrOpts.map((o) => o.value)).toEqual(["low", "medium", "high"]); + }); + + it("wire.decode → wire.encode round-trip identity (case 16)", () => { + const wireIds = ["openai/gpt-5", "openai/gpt-5/low", "anthropic/sonnet/high"]; + for (const id of wireIds) { + const decoded = suffixWire.decode(id); + expect(suffixWire.encode(decoded.selection)).toBe(id); + } + }); +}); + +describe("translateBackendState — mode (setMode style)", () => { + const mapping: ModeMapping = { + kind: "setMode", + canonical: { default: "default", plan: "plan", auto: "bypassPermissions" }, + }; + + it("filters canonical options to those advertised in availableModes", () => { + const modes: RawModeState = { + currentModeId: "default", + availableModes: [ + { id: "default", name: "Default" }, + { id: "plan", name: "Plan" }, + ], + }; + const state = translateBackendState( + { models: null, modes, configOptions: null }, + descriptor({ getModeMapping: () => mapping }) + ); + expect(state.mode).not.toBeNull(); + expect(state.mode!.options.map((o) => o.value)).toEqual(["default", "plan"]); + expect(state.mode!.current).toBe("default"); + expect(state.mode!.apply.default).toEqual({ kind: "setMode", nativeId: "default" }); + expect(state.mode!.apply.plan).toEqual({ kind: "setMode", nativeId: "plan" }); + }); + + it("returns null mode when descriptor exposes no mapping", () => { + const modes: RawModeState = { + currentModeId: "default", + availableModes: [{ id: "default", name: "Default" }], + }; + const state = translateBackendState({ models: null, modes, configOptions: null }, descriptor()); + expect(state.mode).toBeNull(); + }); + + it("reverse-projects unmapped native modes to null current", () => { + const modes: RawModeState = { + currentModeId: "acceptEdits", + availableModes: [ + { id: "default", name: "Default" }, + { id: "plan", name: "Plan" }, + { id: "acceptEdits", name: "Accept Edits" }, + ], + }; + const state = translateBackendState( + { models: null, modes, configOptions: null }, + descriptor({ getModeMapping: () => mapping }) + ); + expect(state.mode!.current).toBeNull(); + }); +}); + +describe("translateBackendState — mode (configOption style)", () => { + it("builds canonical options from a select configOption's enum values", () => { + const configOptions: BackendConfigOption[] = [ + { + id: "agent", + type: "select", + category: null, + name: "Agent", + currentValue: "build", + options: [ + { value: "build", name: "Build" }, + { value: "plan", name: "Plan" }, + ], + }, + ]; + const mapping: ModeMapping = { + kind: "configOption", + configId: "agent", + canonical: { default: "build", plan: "plan" }, + }; + const state = translateBackendState( + { models: null, modes: null, configOptions }, + descriptor({ getModeMapping: () => mapping }) + ); + expect(state.mode!.options.map((o) => o.value)).toEqual(["default", "plan"]); + expect(state.mode!.current).toBe("default"); + expect(state.mode!.apply.default).toEqual({ + kind: "setConfigOption", + configId: "agent", + value: "build", + }); + }); +}); + +describe("translateBackendState — invariants", () => { + it("current.baseModelId matches one of availableModels (case 20)", () => { + const models: RawModelState = { + currentModelId: "openai/gpt-5/low", + availableModels: [ + { modelId: "openai/gpt-5", name: "GPT-5" }, + { modelId: "openai/gpt-5/low", name: "GPT-5 low" }, + ], + }; + const state = translateBackendState( + { models, modes: null, configOptions: null }, + suffixDescriptor() + ); + expect(findModelEntry(state.model, state.model!.current.baseModelId)).toBeDefined(); + }); + + it("current.effort is null or matches a value in the corresponding entry's effortOptions (case 21)", () => { + const models: RawModelState = { + currentModelId: "openai/gpt-5/low", + availableModels: [ + { modelId: "openai/gpt-5", name: "GPT-5" }, + { modelId: "openai/gpt-5/low", name: "GPT-5 low" }, + ], + }; + const state = translateBackendState( + { models, modes: null, configOptions: null }, + suffixDescriptor() + ); + const cur = state.model!.current; + const entry = findModelEntry(state.model, cur.baseModelId)!; + expect(cur.effort === null || entry.effortOptions.some((o) => o.value === cur.effort)).toBe( + true + ); + }); +}); + +describe("backendStateSignature", () => { + it("is stable across structurally identical raws (case 17)", () => { + const models: RawModelState = { + currentModelId: "x/y", + availableModels: [{ modelId: "x/y", name: "x/y" }], + }; + const a = translateBackendState({ models, modes: null, configOptions: null }, descriptor()); + const b = translateBackendState({ models, modes: null, configOptions: null }, descriptor()); + expect(backendStateSignature(a)).toBe(backendStateSignature(b)); + }); + + it("differs when current flips (case 18)", () => { + const before = translateBackendState( + { + models: { + currentModelId: "a", + availableModels: [ + { modelId: "a", name: "A" }, + { modelId: "b", name: "B" }, + ], + }, + modes: null, + configOptions: null, + }, + descriptor() + ); + const after = translateBackendState( + { + models: { + currentModelId: "b", + availableModels: [ + { modelId: "a", name: "A" }, + { modelId: "b", name: "B" }, + ], + }, + modes: null, + configOptions: null, + }, + descriptor() + ); + expect(backendStateSignature(before)).not.toBe(backendStateSignature(after)); + }); + + it("differs when effortOptions changes (case 19)", () => { + const baseModels: RawModelState = { + currentModelId: "openai/gpt-5", + availableModels: [{ modelId: "openai/gpt-5", name: "GPT-5" }], + }; + const before = translateBackendState( + { models: baseModels, modes: null, configOptions: null }, + suffixDescriptor() + ); + const after = translateBackendState( + { + models: { + currentModelId: "openai/gpt-5", + availableModels: [ + { modelId: "openai/gpt-5", name: "GPT-5" }, + { modelId: "openai/gpt-5/low", name: "GPT-5 low" }, + ], + }, + modes: null, + configOptions: null, + }, + suffixDescriptor() + ); + expect(backendStateSignature(before)).not.toBe(backendStateSignature(after)); + }); +}); + +describe("modelStateSignature", () => { + it("returns empty string when model is null", () => { + expect(modelStateSignature(null)).toBe(""); + expect(modelStateSignature({ model: null, mode: null })).toBe(""); + }); + + it("is identical for equivalent model slices regardless of mode", () => { + const sharedModel: BackendState["model"] = { + current: { baseModelId: "x", effort: null }, + availableModels: [{ baseModelId: "x", name: "X", provider: null, effortOptions: [] }], + }; + const a: BackendState = { model: sharedModel, mode: null }; + const b: BackendState = { + model: sharedModel, + mode: { current: "plan", options: [{ value: "plan", label: "Plan" }], apply: {} }, + }; + expect(modelStateSignature(a)).toBe(modelStateSignature(b)); + }); + + it("differs when current model flips", () => { + const a: BackendState = { + model: { + current: { baseModelId: "x", effort: null }, + availableModels: [ + { baseModelId: "x", name: "X", provider: null, effortOptions: [] }, + { baseModelId: "y", name: "Y", provider: null, effortOptions: [] }, + ], + }, + mode: null, + }; + const b: BackendState = { + ...a, + model: { ...a.model!, current: { baseModelId: "y", effort: null } }, + }; + expect(modelStateSignature(a)).not.toBe(modelStateSignature(b)); + }); +}); + +describe("modeStateSignature", () => { + it("returns empty string when mode is null", () => { + expect(modeStateSignature(null)).toBe(""); + expect(modeStateSignature({ model: null, mode: null })).toBe(""); + }); + + it("is identical for equivalent mode slices regardless of model", () => { + const sharedMode: BackendState["mode"] = { + current: "plan", + options: [{ value: "plan", label: "Plan" }], + apply: { plan: { kind: "setMode", nativeId: "plan" } }, + }; + const a: BackendState = { model: null, mode: sharedMode }; + const b: BackendState = { + model: { + current: { baseModelId: "x", effort: null }, + availableModels: [{ baseModelId: "x", name: "X", provider: null, effortOptions: [] }], + }, + mode: sharedMode, + }; + expect(modeStateSignature(a)).toBe(modeStateSignature(b)); + }); + + it("differs when current mode flips", () => { + const opts = [ + { value: "plan" as const, label: "Plan" }, + { value: "default" as const, label: "Default" }, + ]; + const a: BackendState = { + model: null, + mode: { current: "plan", options: opts, apply: {} }, + }; + const b: BackendState = { + model: null, + mode: { current: "default", options: opts, apply: {} }, + }; + expect(modeStateSignature(a)).not.toBe(modeStateSignature(b)); + }); + + it("differs when an option's apply-spec kind flips", () => { + const opts = [{ value: "plan" as const, label: "Plan" }]; + const a: BackendState = { + model: null, + mode: { + current: "plan", + options: opts, + apply: { plan: { kind: "setMode", nativeId: "plan" } }, + }, + }; + const b: BackendState = { + model: null, + mode: { + current: "plan", + options: opts, + apply: { plan: { kind: "setConfigOption", configId: "mode", value: "plan" } }, + }, + }; + expect(modeStateSignature(a)).not.toBe(modeStateSignature(b)); + }); +}); diff --git a/src/agentMode/session/translateBackendState.ts b/src/agentMode/session/translateBackendState.ts new file mode 100644 index 00000000..71558206 --- /dev/null +++ b/src/agentMode/session/translateBackendState.ts @@ -0,0 +1,349 @@ +import type { + BackendConfigOption, + BackendDescriptor, + RawModelState, + RawModeState, + BackendState, + CopilotMode, + EffortOption, + ModeApplySpec, + ModeMapping, + ModeOption, + ModelEntry, + ModelSelection, + ModelState, +} from "@/agentMode/session/types"; + +const CANONICAL_ORDER: CopilotMode[] = ["default", "plan", "auto"]; +const CANONICAL_LABELS: Record = { + default: "Default", + plan: "Plan", + auto: "Auto", +}; + +/** + * Backend-supplied raw catalogs — what a backend has after `session/new` + * (or after a wire-format → neutral conversion). Backends call + * `translateBackendState` with this and the descriptor to produce the + * normalized `BackendState` consumers see. + */ +export interface BackendStateInputs { + models: RawModelState | null; + modes: RawModeState | null; + configOptions: BackendConfigOption[] | null; +} + +/** + * Project a backend's neutral catalogs onto the unified `BackendState` + * consumers use. Pure function — depends only on the inputs and the + * descriptor's wire codec + mode mapping. Re-running on every state + * mutation is cheap. + */ +export function translateBackendState( + inputs: BackendStateInputs, + descriptor: BackendDescriptor +): BackendState { + return { + model: translateModel(inputs, descriptor), + mode: translateMode(inputs, descriptor), + }; +} + +/** + * Look up the rich `ModelEntry` for a given `baseModelId` in a + * `ModelState`. Provided as a tiny session-layer helper so consumers + * that need name/description/effortOptions for the current selection + * don't open-code the lookup. + */ +export function findModelEntry( + state: ModelState | null | undefined, + baseModelId: string +): ModelEntry | undefined { + return state?.availableModels.find((e) => e.baseModelId === baseModelId); +} + +function translateModel( + inputs: BackendStateInputs, + descriptor: BackendDescriptor +): ModelState | null { + const modelState = inputs.models; + if (!modelState) return null; + + // Group advertised wire ids by baseModelId, preserving first-seen order. + type Group = { + baseModelId: string; + provider: string | null; + name: string; + description?: string; + /** Per-effort entries for suffix-style backends (decoded from wire ids). */ + variants: { effort: string | null; wireId: string }[]; + }; + const groups: Group[] = []; + const groupByBase = new Map(); + + for (const m of modelState.availableModels) { + const decoded = descriptor.wire.decode(m.modelId); + const baseId = decoded.selection.baseModelId; + const existing = groupByBase.get(baseId); + const group: Group = + existing ?? + (() => { + const fresh: Group = { + baseModelId: baseId, + provider: decoded.provider, + name: m.name, + description: m.description ?? undefined, + variants: [], + }; + groupByBase.set(baseId, fresh); + groups.push(fresh); + return fresh; + })(); + if (!group.variants.some((v) => v.effort === decoded.selection.effort)) { + group.variants.push({ effort: decoded.selection.effort, wireId: m.modelId }); + } + } + + const availableModels: ModelEntry[] = groups.map((g) => ({ + baseModelId: g.baseModelId, + // Strip a trailing `()` only when the group has multiple + // variants — those rows render an effort dropdown, so the suffix + // becomes redundant. The recognized vocabulary comes from the + // variants themselves (decoded by the descriptor's wire codec), + // so backends own their effort tokens — we don't duplicate them. + name: g.variants.length >= 2 ? stripEffortSuffix(g.name, g.variants) : g.name, + description: g.description, + provider: g.provider, + effortOptions: deriveEffortOptions(g, descriptor), + })); + + // Build current. The agent's currentModelId may decompose into a + // baseModelId not present in availableModels (rare; stale probe state). + // In that case, synthesize an entry and append so the current selection + // always has a corresponding entry in `availableModels`. + const decodedCurrent = descriptor.wire.decode(modelState.currentModelId); + const currentBaseId = decodedCurrent.selection.baseModelId; + let currentEntry = availableModels.find((e) => e.baseModelId === currentBaseId); + if (!currentEntry) { + const synthEffortOptions = descriptor.wire.effortConfigFor + ? optionsFromConfigOption(descriptor.wire.effortConfigFor(currentBaseId)) + : []; + currentEntry = { + baseModelId: currentBaseId, + name: currentBaseId, + provider: decodedCurrent.provider, + effortOptions: synthEffortOptions, + }; + availableModels.push(currentEntry); + } + + const current: ModelSelection = { + baseModelId: currentEntry.baseModelId, + effort: resolveCurrentEffort( + decodedCurrent.selection.effort, + currentEntry, + descriptor, + inputs.configOptions + ), + }; + + return { current, availableModels }; +} + +function deriveEffortOptions( + group: { variants: { effort: string | null; wireId: string }[]; baseModelId: string }, + descriptor: BackendDescriptor +): EffortOption[] { + // Suffix-style: ≥2 variants for the same base means we have an effort + // dimension encoded in the wire id. + if (group.variants.length >= 2) { + const options: EffortOption[] = []; + const hasBare = group.variants.some((v) => v.effort === null); + if (hasBare) options.push({ value: null, label: "default" }); + for (const v of group.variants) { + if (v.effort === null) continue; + options.push({ value: v.effort, label: v.effort.toLowerCase() }); + } + return options; + } + // Descriptor-style: ask the codec for a per-model effort option. + if (descriptor.wire.effortConfigFor) { + const opt = descriptor.wire.effortConfigFor(group.baseModelId); + return optionsFromConfigOption(opt); + } + return []; +} + +function optionsFromConfigOption(opt: BackendConfigOption | null): EffortOption[] { + if (!opt || opt.type !== "select") return []; + const flat: { value: string; name: string }[] = []; + for (const entry of opt.options) { + if ("options" in entry) { + for (const inner of entry.options) flat.push({ value: inner.value, name: inner.name }); + } else { + flat.push({ value: entry.value, name: entry.name }); + } + } + return flat.map((o) => ({ value: o.value, label: (o.name || o.value).toLowerCase() })); +} + +/** + * Resolve `current.effort` for the active selection. For suffix-style + * (effort encoded in wire id), the decoded value wins. For descriptor- + * style, prefer the live `currentValue` from `inputs.configOptions` + * (matched by the spec's id) so user effort changes round-trip; fall + * back to the spec's default when the agent hasn't reported one yet. + * The result is snapped to an option that exists in + * `currentEntry.effortOptions`; otherwise null. + */ +function resolveCurrentEffort( + decodedEffort: string | null, + currentEntry: ModelEntry, + descriptor: BackendDescriptor, + configOptions: BackendConfigOption[] | null +): string | null { + let candidate: string | null = decodedEffort; + if (candidate === null && descriptor.wire.effortConfigFor) { + const spec = descriptor.wire.effortConfigFor(currentEntry.baseModelId); + if (spec && spec.type === "select") { + const live = configOptions?.find((c) => c.id === spec.id); + const liveValue = live && live.type === "select" ? String(live.currentValue) : null; + candidate = liveValue ?? (spec.currentValue != null ? String(spec.currentValue) : null); + } + } + if (candidate === null) { + return currentEntry.effortOptions.some((o) => o.value === null) ? null : null; + } + if (currentEntry.effortOptions.some((o) => o.value === candidate)) return candidate; + return null; +} + +function translateMode( + inputs: BackendStateInputs, + descriptor: BackendDescriptor +): BackendState["mode"] { + const mapping = descriptor.getModeMapping?.(inputs.modes, inputs.configOptions); + if (!mapping) return null; + if (mapping.kind === "setMode") return translateSetModeMapping(mapping, inputs.modes); + return translateConfigOptionModeMapping(mapping, inputs.configOptions); +} + +function translateSetModeMapping( + mapping: ModeMapping, + modeState: RawModeState | null +): BackendState["mode"] { + if (!modeState) return null; + const advertised = new Set(modeState.availableModes.map((m) => m.id)); + const options: ModeOption[] = []; + const apply: Partial> = {}; + for (const value of CANONICAL_ORDER) { + const native = mapping.canonical[value]; + if (!native || !advertised.has(native)) continue; + options.push({ value, label: CANONICAL_LABELS[value] }); + apply[value] = { kind: "setMode", nativeId: native }; + } + if (options.length === 0) return null; + const current = reverseProjectMode(mapping.canonical, modeState.currentModeId, options); + return { current, options, apply }; +} + +function translateConfigOptionModeMapping( + mapping: ModeMapping, + configOptions: BackendConfigOption[] | null +): BackendState["mode"] { + if (!mapping.configId || !configOptions) return null; + const opt = configOptions.find((o) => o.id === mapping.configId); + if (!opt || opt.type !== "select") return null; + + const flatValues = new Set(); + for (const entry of opt.options) { + if ("options" in entry) { + for (const inner of entry.options) flatValues.add(inner.value); + } else { + flatValues.add(entry.value); + } + } + + const options: ModeOption[] = []; + const apply: Partial> = {}; + for (const value of CANONICAL_ORDER) { + const native = mapping.canonical[value]; + if (!native || !flatValues.has(native)) continue; + options.push({ value, label: CANONICAL_LABELS[value] }); + apply[value] = { kind: "setConfigOption", configId: opt.id, value: native }; + } + if (options.length === 0) return null; + + const current = reverseProjectMode(mapping.canonical, String(opt.currentValue), options); + return { current, options, apply }; +} + +/** + * Reverse-project a native mode id back to a canonical Copilot mode. + * Returns `null` when the agent is sitting in a mode the descriptor + * doesn't map (e.g. Claude's `acceptEdits` — intentionally hidden). + */ +function reverseProjectMode( + canonical: ModeMapping["canonical"], + nativeId: string, + visibleOptions: ModeOption[] +): CopilotMode | null { + const visible = new Set(visibleOptions.map((o) => o.value)); + for (const opt of CANONICAL_ORDER) { + if (!visible.has(opt)) continue; + if (canonical[opt] === nativeId) return opt; + } + return null; +} + +function stripEffortSuffix(name: string, variants: { effort: string | null }[]): string { + const m = name.match(/^(.*?)\s*\(([^()]+)\)\s*$/); + if (!m) return name; + const efforts = new Set(variants.flatMap((v) => (v.effort ? [v.effort.toLowerCase()] : []))); + if (!efforts.has(m[2].toLowerCase())) return name; + return m[1].trim(); +} + +/** + * Stable signature of the model slice of a `BackendState`. Used by the + * model+effort picker hook to invalidate its memo only on model-relevant + * changes. + */ +export function modelStateSignature(state: BackendState | null): string { + const m = state?.model; + if (!m) return ""; + return [ + m.current.baseModelId, + m.current.effort ?? "", + m.availableModels + .map( + (e) => + `${e.baseModelId}:${e.provider ?? ""}:${e.effortOptions + .map((o) => o.value ?? "_") + .join("|")}` + ) + .join(","), + ].join("/"); +} + +/** + * Stable signature of the mode slice of a `BackendState`. Used by the mode + * picker hook to invalidate its memo only on mode-relevant changes. Includes + * each option's apply-spec kind so capability flips (`setMode` ↔ + * `setConfigOption`) propagate. + */ +export function modeStateSignature(state: BackendState | null): string { + const md = state?.mode; + if (!md) return ""; + const apply = md.options.map((o) => `${o.value}:${md.apply[o.value]?.kind ?? ""}`).join(","); + return `${md.current ?? ""}|${apply}`; +} + +/** + * Stable signature of a normalized `BackendState`. Used by the preloader + * to skip notifying listeners on no-op updates. + */ +export function backendStateSignature(state: BackendState | null): string { + if (!state) return ""; + return `${modelStateSignature(state)}#${modeStateSignature(state)}`; +} diff --git a/src/agentMode/session/types.ts b/src/agentMode/session/types.ts new file mode 100644 index 00000000..47a301a1 --- /dev/null +++ b/src/agentMode/session/types.ts @@ -0,0 +1,671 @@ +import type React from "react"; +import type { FormattedDateTime, MessageContext } from "@/types/message"; + +export type { BackendDescriptor, InstallState } from "./descriptor"; +export type { CurrentPlan, PlanDecisionAction, PlanProposalDecision } from "./plan"; + +/** Stable identifier for a registered backend. New backends extend the registry; the type stays open. */ +export type BackendId = string; + +/** + * Slim projection of a backend descriptor for UI surfaces that only need to + * render an agent's identity (label + brand glyph). The shape is decoupled + * from the full descriptor so consumers depend on just id/label/icon and + * adding a new backend never requires touching them. + */ +export interface AgentBrand { + readonly id: BackendId; + readonly displayName: string; + readonly Icon: React.ComponentType<{ className?: string }>; +} + +/** + * Opaque identifier for an agent-side session. Backends mint these (ACP + * agents return one from `session/new`; in-process adapters generate UUIDs); + * the session layer stores and routes events by it. + */ +export type SessionId = string; + +/** + * Copilot's canonical operational modes for Agent Mode. Each backend's + * `getModeMapping` projects these onto its own native mode/agent ids. + * + * - `default` — balanced; agent may write/exec but the user must approve + * each permission request. Picked when the user hasn't + * explicitly selected a mode. + * - `plan` — agent drafts a plan; no writes. + * - `auto` — same as default, but bypass all permission prompts. + */ +export type CopilotMode = "default" | "plan" | "auto"; + +/** + * Per-backend mapping from canonical Copilot modes to native ids the agent + * understands. Returned by descriptors via `getModeMapping(...)`. + * + * - `kind: "setMode"` — apply via the backend's "set mode" channel. + * `canonical` values are matched against + * `RawModeState.availableModes[].id`. + * - `kind: "configOption"`— apply via the backend's "set config option" + * channel. `configId` names the option; `canonical` values are matched + * against that select option's enum values. + */ +export interface ModeMapping { + kind: "setMode" | "configOption"; + /** Required when `kind === "configOption"`. Ignored for `setMode`. */ + configId?: string; + canonical: Partial>; +} + +/** One option in the mode picker — a Copilot-canonical mode the backend supports. */ +export interface ModeOption { + value: CopilotMode; + label: string; +} + +/** + * One option in the effort picker. `value: null` is the bare/"Default" + * variant — it always renders as "Default" and selects the unsuffixed + * modelId (or the bare config-option value, when the backend uses one). + */ +export interface EffortOption { + value: string | null; + label: string; +} + +/** + * One entry in the picker's deduped catalog. One entry per base model id; + * suffix-style variants (codex/opencode) collapse into one entry whose + * `effortOptions` enumerates the variants. + */ +export interface ModelEntry { + /** + * Pure base model id, no provider prefix conventions stripped — for + * suffix-style backends, this is the wire form minus the trailing + * effort suffix (e.g. `"openai/gpt-5"`); for descriptor-style + * (Claude SDK), this is the bare model id (e.g. `"claude-sonnet-4-5"`). + */ + baseModelId: string; + /** Human-readable display name. */ + name: string; + /** Optional one-liner for row subtitle. */ + description?: string; + /** + * Normalized Copilot provider id (e.g. `"openai"`, `"anthropic"`). `null` + * when no provider mapping exists. Pre-computed by the translator. + */ + provider: string | null; + /** + * Effort options for this model. Empty array when the model has no + * effort dimension (e.g. Claude Haiku, or a suffix-style base with only + * one variant). `value: null` entries denote the bare/"Default" variant. + */ + effortOptions: EffortOption[]; +} + +/** + * Normalized model selection — the single shape used by both runtime + * state (`BackendState.model.current`) and persisted preferences + * (`agentMode.backends..defaultModel`). `baseModelId` is what + * `BackendDescriptor.wire.decode(wireId).baseModelId` produces for + * this backend; round-trips through `wire.encode` for the same backend + * but is meaningless cross-backend (opencode's includes a `provider/` + * prefix; codex's doesn't). Always read in the context of its + * backend's slice. `effort` is `null` for "unset" / "default variant" + * — the translator guarantees it matches one of the corresponding + * `ModelEntry.effortOptions[].value`. + */ +export interface ModelSelection { + baseModelId: string; + effort: string | null; +} + +/** + * Picker-ready model catalog plus the active selection. Both fields are + * populated together by the translator so they never drift. Consumers + * that need rich entry data for the current selection look it up via + * `availableModels.find(e => e.baseModelId === current.baseModelId)` + * (or the `findModelEntry` helper in `translateBackendState`). + */ +export interface ModelState { + current: ModelSelection; + availableModels: ModelEntry[]; +} + +/** + * Wire-format codec for a backend's model ids — the only place that + * knows how a given backend packs `(baseModelId, effort)` into a single + * `RawModelState.availableModels[].modelId` string. + * + * For descriptor-style backends (Claude SDK), effort lives outside the + * wire id and `effortConfigFor` exposes the `BackendConfigOption` + * dispatched via `setSessionConfigOption`. For suffix-style backends + * (codex, opencode), effort is encoded into the wire id and + * `effortConfigFor` is omitted. + */ +export interface ModelWireCodec { + /** + * Encode a normalized selection into the agent's wire-form id. Pure. + * For descriptor-style backends, effort is ignored here (returned id + * is the bare `baseModelId`). + */ + encode(selection: ModelSelection): string; + + /** + * Decode a wire-form id into a normalized selection plus the Copilot + * provider attribution used for picker section grouping (`null` when + * no Copilot provider maps). Pure. + */ + decode(wireId: string): { selection: ModelSelection; provider: string | null }; + + /** + * Per-model effort source for descriptor-style backends. Returns + * `null` when the model has no effort dimension (e.g. Claude Haiku) + * or the backend uses wire-encoded effort instead. + */ + effortConfigFor?(baseModelId: string): BackendConfigOption | null; +} + +/** + * Apply spec for a mode change. Tells the session layer which RPC the backend + * should issue. `value` is what the spec carries to the backend; the + * canonical `CopilotMode` is passed alongside for persistence. + */ +export type ModeApplySpec = + | { kind: "setMode"; nativeId: string } + | { kind: "setConfigOption"; configId: string; value: string }; + +/** + * Normalized, consumer-facing slice of session state. Produced by + * `translateBackendState` from neutral catalog inputs plus the descriptor's + * dispatch hooks. The chat picker, settings UI, and model cache all read + * from this shape — wire-form modelIds, configOption ids, and ACP-specific + * quirks (effort in modelId variants vs. configOption; mode in `modes` vs. + * configOption) never leak across this boundary. + */ +export interface BackendState { + /** + * Picker-ready model catalog plus current selection. `null` when the + * backend doesn't expose runtime model selection. + */ + model: ModelState | null; + + /** + * Canonical mode picker, regardless of backend mechanism. `null` when the + * backend doesn't support modes (or hides them — e.g. Codex). + */ + mode: { + /** Canonical projection of the agent's current mode, or `null` if unmapped. */ + current: CopilotMode | null; + options: ModeOption[]; + /** Per-option apply spec. Picker dispatches `apply[value]` to change the mode. */ + apply: Partial>; + } | null; +} + +// ---- Neutral, descriptor-input shapes ---------------------------------- + +/** + * One model entry as reported by a backend. Mirrors ACP `ModelInfo` shape + * but is owned by `session/` so backends and descriptors share a single + * vocabulary. Used by `isModelEnabledByDefault`. + */ +export interface BackendModelInfo { + modelId: string; + name: string; + description?: string; +} + +/** + * Raw model state a backend reports at session creation / resume / load — + * the active modelId plus the list of advertised models. Mirrors ACP + * `SessionModelState` structurally. Distinct from the picker-ready + * `ModelState` (the translator output) — this is the pre-translation form. + */ +export interface RawModelState { + currentModelId: string; + availableModels: BackendModelInfo[]; +} + +/** One mode entry as reported by a backend. */ +export interface BackendModeInfo { + id: string; + name: string; + description?: string; +} + +/** + * Raw mode state a backend reports — the active modeId plus the list of + * advertised modes. Mirrors ACP `SessionModeState` structurally; passed to + * `descriptor.getModeMapping`. + */ +export interface RawModeState { + currentModeId: string; + availableModes: BackendModeInfo[]; +} + +/** + * One configuration option a backend exposes (e.g. claude-code's "effort" + * select). Structurally mirrors ACP `SessionConfigOption`. Used as input + * to `descriptor.getModeMapping` (configOption-mode style) and produced + * by `ModelWireCodec.effortConfigFor` for descriptor-style effort. + */ +export type BackendConfigOption = + | { + id: string; + type: "select"; + name?: string; + description?: string; + category?: string | null; + currentValue: string; + options: Array< + | { value: string; name: string; description?: string } + | { + name: string; + description?: string; + options: Array<{ value: string; name: string; description?: string }>; + } + >; + } + | { + id: string; + type: "boolean"; + name?: string; + description?: string; + category?: string | null; + currentValue: boolean; + }; + +// ---- Session domain (transport-agnostic) ------------------------------- + +/** + * Transport-agnostic content block carried in a user prompt. Mirrors the + * subset of ACP `ContentBlock` we currently emit; backends translate this + * to their wire shape at the boundary. + */ +export type PromptContent = + | { type: "text"; text: string } + | { type: "image"; mimeType: string; data: string } + | { type: "resource_link"; uri: string; name?: string }; + +/** + * The high-level reason a turn ended. Backends translate from their native + * stop-reason shape to one of these. Mirrors ACP `StopReason`. + */ +export type StopReason = "end_turn" | "cancelled" | "refusal" | "max_tokens" | "max_turn_requests"; + +/** Tool kind, narrowed to the categories the UI styles. Mirrors ACP `ToolKind`. */ +export type AgentToolKind = + | "read" + | "edit" + | "delete" + | "move" + | "search" + | "execute" + | "think" + | "fetch" + | "switch_mode" + | "other"; + +export type AgentToolStatus = "pending" | "in_progress" | "completed" | "failed"; + +/** + * Structured output produced by an Agent Mode tool call. + * Mirrors a subset of ACP's `ToolCallContent` we render inline. + */ +export type AgentToolCallOutput = + | { + type: "text"; + text: string; + truncated?: boolean; + originalLength?: number; + omittedLength?: number; + } + | { type: "diff"; path: string; oldText: string | null; newText: string }; + +/** + * One entry of an Agent Mode plan list. Mirrors ACP `PlanEntry`. + */ +export interface AgentPlanEntry { + content: string; + priority: "high" | "medium" | "low"; + status: "pending" | "in_progress" | "completed"; +} + +/** + * Initial / updated state for an in-flight tool call. Mirrors ACP `ToolCall` + * (initial form) — emitted by backends in the `tool_call` `SessionUpdate`. + */ +export interface ToolCallSnapshot { + toolCallId: string; + title: string; + kind?: AgentToolKind; + status?: AgentToolStatus; + rawInput?: unknown; + content?: ToolCallContent[]; + locations?: Array<{ path: string; line?: number | null }>; + /** Vendor-original tool identity (e.g. "Read", "Edit", "ExitPlanMode"). */ + vendorToolName?: string; + /** Parent tool-call id, for nested tools (e.g. Claude's Task subagents). */ + parentToolCallId?: string; + /** True iff this tool call is the agent's plan-finalization signal. */ + isPlanProposal?: boolean; +} + +/** + * Update to an in-flight tool call. Mirrors ACP `ToolCallUpdate`. + */ +export interface ToolCallDelta { + toolCallId: string; + title?: string; + kind?: AgentToolKind; + status?: AgentToolStatus; + rawInput?: unknown; + content?: ToolCallContent[] | null; + locations?: Array<{ path: string; line?: number | null }> | null; + vendorToolName?: string; + parentToolCallId?: string; + isPlanProposal?: boolean; +} + +/** + * One entry in a tool call's structured `content`. Mirrors a subset of ACP + * `ToolCallContent` we render inline. + */ +export type ToolCallContent = + | { type: "content"; content: { type: "text"; text: string } } + | { type: "diff"; path: string; oldText?: string | null; newText: string }; + +/** + * Plan summary surfaced via the `plan` `SessionUpdate`. Mirrors ACP `Plan`. + */ +export interface PlanSummary { + entries: AgentPlanEntry[]; +} + +/** + * Tagged-union of streaming session updates emitted by a backend mid-turn + * (and outside turns for session-info changes). Mirrors ACP's + * `SessionNotification.update` discriminants. The `state_changed` variant + * is new — it carries an updated `BackendState` for unsolicited + * model/mode/configOption deltas, so consumers don't see raw catalog shapes. + */ +export type SessionUpdate = + | { sessionUpdate: "agent_message_chunk"; content: PromptContent } + | { sessionUpdate: "agent_thought_chunk"; content: PromptContent } + | ({ sessionUpdate: "tool_call" } & ToolCallSnapshot) + | ({ sessionUpdate: "tool_call_update" } & ToolCallDelta) + | { sessionUpdate: "plan"; entries: AgentPlanEntry[] } + | { sessionUpdate: "session_info_update"; title?: string | null } + | { sessionUpdate: "current_mode_update"; currentModeId: string } + | { sessionUpdate: "config_option_update"; configOptions: BackendConfigOption[] } + | { sessionUpdate: "state_changed"; state: BackendState }; + +/** A SessionEvent is the demuxed pair `(sessionId, update)` consumed by handlers. */ +export interface SessionEvent { + sessionId: SessionId; + update: SessionUpdate; +} + +/** Permission option kinds the agent may offer when requesting a decision. */ +export type PermissionOptionKind = "allow_once" | "allow_always" | "reject_once" | "reject_always"; + +/** Canonical ordering for `PermissionOptionKind` — used by translators and the modal. */ +export const PERMISSION_OPTION_KINDS: readonly PermissionOptionKind[] = [ + "allow_once", + "allow_always", + "reject_once", + "reject_always", +]; + +export const PERMISSION_ALLOW_KINDS: readonly PermissionOptionKind[] = [ + "allow_once", + "allow_always", +]; + +export const PERMISSION_REJECT_KINDS: readonly PermissionOptionKind[] = [ + "reject_once", + "reject_always", +]; + +/** Single option carried in a `PermissionPrompt`. */ +export interface PermissionOption { + optionId: string; + name: string; + kind: PermissionOptionKind; +} + +/** + * A request from the backend asking the user to decide on a tool call. + * Mirrors ACP `RequestPermissionRequest`. + */ +export interface PermissionPrompt { + sessionId: SessionId; + toolCall: ToolCallSnapshot; + options: PermissionOption[]; +} + +/** The user's outcome on a `PermissionPrompt`. Mirrors ACP `RequestPermissionResponse`. */ +export interface PermissionDecision { + outcome: { outcome: "selected"; optionId: string } | { outcome: "cancelled" }; + /** + * Optional override for the deny `message` surfaced to the agent. Honored + * only when the selected option resolves to a reject kind on Claude SDK's + * `canUseTool`. Ignored on allow / cancelled, and silently dropped by ACP + * backends (the wire schema has no slot for it). + */ + denyMessage?: string; +} + +// ---- MCP server spec (neutral) ----------------------------------------- + +/** + * MCP server descriptor passed to backends in `OpenSessionInput.mcpServers`. + * Mirrors ACP `McpServer` structurally; backends translate to their wire + * shape at the boundary. + */ +export type McpServerSpec = + | { + name: string; + command: string; + args: string[]; + env: Array<{ name: string; value: string }>; + } + | { + type: "http"; + name: string; + url: string; + headers: Array<{ name: string; value: string }>; + } + | { + type: "sse"; + name: string; + url: string; + headers: Array<{ name: string; value: string }>; + }; + +// ---- Session-creation I/O shapes --------------------------------------- + +export interface OpenSessionInput { + cwd: string; + mcpServers: McpServerSpec[]; +} + +export interface OpenSessionOutput { + sessionId: SessionId; + state: BackendState; +} + +export interface ResumeSessionInput { + sessionId: SessionId; + cwd: string; + mcpServers: McpServerSpec[]; +} + +export type ResumeSessionOutput = OpenSessionOutput; + +export interface LoadSessionInput { + sessionId: SessionId; + cwd: string; + mcpServers: McpServerSpec[]; +} + +export type LoadSessionOutput = OpenSessionOutput; + +export interface PromptInput { + sessionId: SessionId; + prompt: PromptContent[]; +} + +export interface PromptOutput { + stopReason: StopReason; +} + +export interface ListSessionsInput { + cwd?: string; +} + +export interface ListedSessionInfo { + sessionId: SessionId; + cwd: string; + title?: string | null; + updatedAt?: string | null; +} + +export interface ListSessionsOutput { + sessions: ListedSessionInfo[]; +} + +export interface CancelInput { + sessionId: SessionId; +} + +// ---- Backend process surface ------------------------------------------- + +/** + * Generic backend-process surface consumed by `AgentSession`. The ACP runtime + * (`AcpBackendProcess`) is one implementation; the Claude SDK adapter + * (`ClaudeSdkBackendProcess`) is another. Keeping this in `session/types.ts` + * lets `AgentSession` stay backend-agnostic — it depends on the interface, + * never on a concrete class. All session-domain types are defined above; + * backends translate to/from their wire format at the boundary. + */ +export type SessionUpdateHandler = (event: SessionEvent) => void; + +export interface BackendProcess { + /** + * Optional bring-up step. ACP backends spawn the subprocess and run the + * `initialize` handshake here; in-process adapters (Claude SDK) leave this + * undefined because they have no async startup phase — their first + * `newSession`/`prompt` does the bring-up lazily. + */ + start?(): Promise; + isRunning(): boolean; + onExit(listener: () => void): () => void; + setPermissionPrompter(fn: (req: PermissionPrompt) => Promise): void; + registerSessionHandler(sessionId: SessionId, handler: SessionUpdateHandler): () => void; + newSession(params: OpenSessionInput): Promise; + prompt(params: PromptInput): Promise; + cancel(params: CancelInput): Promise; + setSessionModel(params: { sessionId: SessionId; modelId: string }): Promise; + isSetSessionModelSupported(): boolean | null; + setSessionMode(params: { sessionId: SessionId; modeId: string }): Promise; + isSetSessionModeSupported(): boolean | null; + setSessionConfigOption(params: { + sessionId: SessionId; + configId: string; + value: string; + }): Promise; + isSetSessionConfigOptionSupported(): boolean | null; + listSessions(params: ListSessionsInput): Promise; + resumeSession(params: ResumeSessionInput): Promise; + loadSession(params: LoadSessionInput): Promise; + /** + * Whether the backend can route MCP servers of the given transport. + * ACP runtime probes this from the agent's advertised capabilities; the + * Claude SDK adapter accepts http/sse natively. + */ + supportsMcpTransport(transport: "http" | "sse"): boolean; + shutdown(): Promise; +} + +/** + * One structured part inside an Agent Mode assistant message. Used for + * display only — never serialized into LLM context. + */ +export type AgentMessagePart = + | { + kind: "tool_call"; + id: string; // toolCallId + title: string; + toolKind?: AgentToolKind; + status: AgentToolStatus; + input?: unknown; + output?: AgentToolCallOutput[]; + locations?: { path: string; line?: number }[]; + /** + * Vendor tool identity (e.g. "Read", "Edit", "Task", "ExitPlanMode") + * supplied by the backend adapter. Used by the trail UI to pick a + * richer tool-aware summary than the ACP `toolKind` enum affords. + * Absent for backends that surface no vendor identity (opencode, codex). + */ + vendorToolName?: string; + /** + * Parent tool-call id for sub-agent children. Today only Claude + * Code emits this (Task → child tool calls). Absent → the part is + * top-level. + */ + parentToolCallId?: string; + } + | { + kind: "thought"; + text: string; + } + | { + // Streamed assistant prose. Each interruption by a tool_call or thought + // closes the current text part and opens a new one, so `parts[]` keeps + // chronological interleaving instead of collapsing prose into one block. + kind: "text"; + text: string; + } + | { + kind: "plan"; + entries: AgentPlanEntry[]; + }; + +/** + * Display message shape for the Agent Mode UI stack. Distinct from the + * legacy `ChatMessage` because Agent Mode does not feed messages through a + * LangChain prompt — the agent owns the model's view of the conversation — + * so we drop `processedText`, `contextEnvelope`, `sources`, `responseMetadata`. + */ +export interface AgentChatMessage { + id: string; + sender: string; + timestamp: FormattedDateTime | null; + isVisible: boolean; + isErrorMessage?: boolean; + /** Display text (assistant body or user input). */ + message: string; + /** Assistant-only structured parts (tool calls, thoughts, plans). */ + parts?: AgentMessagePart[]; + /** User messages may carry context (notes, urls, etc.). */ + context?: MessageContext; + /** Images / rich content for user messages. */ + content?: unknown[]; + /** + * Backend `stopReason` once the turn finishes. Absent while streaming, + * set when `prompt()` resolves. Only `end_turn` triggers the + * collapse-research-into-"Worked for X" UI; cancelled / refusal / etc. + * leave the trail uncollapsed so the user sees what happened. + */ + turnStopReason?: StopReason; + /** + * Wall-clock ms the turn took, frozen at `prompt()` resolution. Stored + * so re-renders don't shift the "Worked for X" label. Absent until the + * turn ends. + */ + turnDurationMs?: number; +} + +/** Creation shape — id is assigned by the store if absent. */ +export type NewAgentChatMessage = Omit & { id?: string }; diff --git a/src/agentMode/skills/SkillManager.test.ts b/src/agentMode/skills/SkillManager.test.ts new file mode 100644 index 00000000..718bcbd8 --- /dev/null +++ b/src/agentMode/skills/SkillManager.test.ts @@ -0,0 +1,209 @@ +import { FileSystemAdapter, type App, type EventRef } from "obsidian"; +import { discoverManagedSkills } from "./discoverManagedSkills"; +import { computeSkillSetSignature, SkillManager, type RefreshResult } from "./SkillManager"; +import { runRenameSkill } from "./updateProperties"; +import type { Skill } from "./types"; + +jest.mock("@/logger", () => ({ + logInfo: jest.fn(), + logWarn: jest.fn(), + logError: jest.fn(), +})); + +let skillsFolder = "copilot/skills"; + +jest.mock("@/settings/model", () => ({ + getSettings: () => ({ + agentMode: { + skills: { + folder: skillsFolder, + importSkipList: [], + }, + }, + }), + updateSetting: jest.fn(), +})); + +jest.mock("./discoverManagedSkills", () => ({ + discoverManagedSkills: jest.fn(), +})); + +jest.mock("./reconcile", () => ({ + reconcile: jest.fn(async () => ({ created: [], removedOrphans: [], errors: [] })), +})); + +jest.mock("./nodeFsAdapters", () => ({ + createNodeBulkMoveFs: jest.fn(() => ({})), + createNodeImportDetectorFs: jest.fn(() => ({})), + createNodeReconcileFs: jest.fn(() => ({})), +})); + +jest.mock("./toggleAgent", () => ({ + runDeleteSkill: jest.fn(), + runToggleAgent: jest.fn(), +})); + +jest.mock("./updateProperties", () => ({ + runRenameSkill: jest.fn(), + runUpdateProperties: jest.fn(), +})); + +const mockedDiscoverManagedSkills = discoverManagedSkills as jest.MockedFunction< + typeof discoverManagedSkills +>; +const mockedRunRenameSkill = runRenameSkill as jest.MockedFunction; + +describe("SkillManager orchestration", () => { + beforeEach(() => { + skillsFolder = "copilot/skills"; + mockedDiscoverManagedSkills.mockReset(); + mockedRunRenameSkill.mockReset(); + SkillManager.resetForTesting(); + jest.useRealTimers(); + }); + + afterEach(() => { + SkillManager.resetForTesting(); + jest.useRealTimers(); + }); + + it("queues one follow-up refresh when the configured folder changes during an in-flight pass", async () => { + const app = makeApp(); + const manager = SkillManager.initialize(app, { claude: ".claude/skills" }); + let releaseFirst = (): void => {}; + + mockedDiscoverManagedSkills.mockImplementationOnce(async () => { + skillsFolder = "team/skills"; + void manager.refresh(); + await new Promise((resolve) => { + releaseFirst = resolve; + }); + return []; + }); + mockedDiscoverManagedSkills.mockResolvedValueOnce([]); + + const resultPromise = manager.refresh(); + await Promise.resolve(); + releaseFirst(); + const result = await resultPromise; + + expect(mockedDiscoverManagedSkills).toHaveBeenCalledTimes(2); + expect(mockedDiscoverManagedSkills.mock.calls[0][0].skillsFolderRelPath).toBe("copilot/skills"); + expect(mockedDiscoverManagedSkills.mock.calls[1][0].skillsFolderRelPath).toBe("team/skills"); + expect(result.folder).toBe("team/skills"); + }); + + it("schedules reconciliation when a rename moves a watched old path elsewhere", () => { + jest.useFakeTimers(); + const app = makeApp(); + const manager = SkillManager.initialize(app, { claude: ".claude/skills" }); + const refreshResult: RefreshResult = { + ok: true, + folder: "copilot/skills", + skillCount: 0, + reconcileErrorCount: 0, + }; + const refreshSpy = jest.spyOn(manager, "refresh").mockResolvedValue(refreshResult); + const renameHandler = app.vault.on.mock.calls.find(([event]) => event === "rename")?.[1]; + + expect(renameHandler).toBeDefined(); + renameHandler?.({ path: "elsewhere/foo/SKILL.md" }, "copilot/skills/foo/SKILL.md"); + jest.advanceTimersByTime(250); + + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); + + it("refreshes after a rename failure that already mutated the canonical directory", async () => { + const app = makeApp(); + const manager = SkillManager.initialize(app, { claude: ".claude/skills" }); + mockedRunRenameSkill.mockResolvedValueOnce({ + ok: false, + reason: "Could not rewrite SKILL.md", + mutated: true, + }); + const refreshResult: RefreshResult = { + ok: true, + folder: "copilot/skills", + skillCount: 0, + reconcileErrorCount: 0, + }; + const refreshSpy = jest.spyOn(manager, "refresh").mockResolvedValue(refreshResult); + + const result = await manager.renameSkill(makeSkill(), "bar"); + + expect(result).toEqual({ + ok: false, + code: "fs-error", + message: "Could not rewrite SKILL.md", + }); + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); + + it("notifies when a backend-visible skill signature changes", async () => { + const app = makeApp(); + const manager = SkillManager.initialize(app, { opencode: ".opencode/skills" }); + const listener = jest.fn(); + manager.subscribeToSkillSetChange(listener); + mockedDiscoverManagedSkills.mockResolvedValueOnce([makeSkill({ enabledAgents: ["opencode"] })]); + mockedDiscoverManagedSkills.mockResolvedValueOnce([ + makeSkill({ body: "updated", enabledAgents: ["opencode"] }), + ]); + + await manager.refresh(); + await manager.refresh(); + + expect(listener).toHaveBeenCalledTimes(2); + expect(listener.mock.calls.map((call) => call[0])).toEqual(["opencode", "opencode"]); + }); + + it("computes different signatures for body and enabled-agent changes", () => { + const base = makeSkill({ enabledAgents: ["claude"] }); + const bodyChanged = makeSkill({ body: "new body", enabledAgents: ["claude"] }); + const enabledChanged = makeSkill({ enabledAgents: ["opencode"] }); + + expect(computeSkillSetSignature([base], "opencode")).not.toBe( + computeSkillSetSignature([bodyChanged], "opencode") + ); + expect(computeSkillSetSignature([base], "opencode")).not.toBe( + computeSkillSetSignature([enabledChanged], "opencode") + ); + }); +}); + +function makeApp(): App & { + vault: App["vault"] & { + on: jest.Mock void]>; + offref: jest.Mock; + }; +} { + const adapter = new (FileSystemAdapter as unknown as new (basePath: string) => FileSystemAdapter)( + "/vault" + ); + adapter.exists = jest.fn().mockResolvedValue(true); + adapter.list = jest.fn().mockResolvedValue({ files: [], folders: [] }); + adapter.read = jest.fn().mockResolvedValue(""); + return { + vault: { + adapter, + on: jest.fn((event: string, handler: (...args: unknown[]) => void) => ({ event, handler })), + offref: jest.fn(), + }, + } as unknown as App & { + vault: App["vault"] & { + on: jest.Mock void]>; + offref: jest.Mock; + }; + }; +} + +function makeSkill(overrides: Partial = {}): Skill { + return { + name: "foo", + description: "A skill.", + filePath: "/vault/copilot/skills/foo/SKILL.md", + dirPath: "/vault/copilot/skills/foo", + body: "body", + enabledAgents: ["claude"], + ...overrides, + }; +} diff --git a/src/agentMode/skills/SkillManager.ts b/src/agentMode/skills/SkillManager.ts new file mode 100644 index 00000000..f02e4f72 --- /dev/null +++ b/src/agentMode/skills/SkillManager.ts @@ -0,0 +1,775 @@ +import { logError, logInfo, logWarn } from "@/logger"; +import { getSettings, updateSetting } from "@/settings/model"; +import { atom, createStore, useAtomValue } from "jotai"; +import { FileSystemAdapter, type App, type EventRef, type TAbstractFile } from "obsidian"; +import { agentSkillsDirAbs, DEFAULT_SKILLS_FOLDER } from "./agentPaths"; +import { runBulkMove, type BulkMoveResult } from "./bulkMove"; +import { discoverManagedSkills, type SkillsFsAdapter } from "./discoverManagedSkills"; +import { + createEmptyImportDetectorResult, + detectImportCandidates, + totalCandidates, + type ImportDetectorResult, +} from "./importDetector"; +import { + createNodeBulkMoveFs, + createNodeImportDetectorFs, + createNodeReconcileFs, +} from "./nodeFsAdapters"; +import { reconcile, type ReconcileReport } from "./reconcile"; +import type { SkillFrontmatterPatch } from "./skillFormat"; +import { runDeleteSkill, runToggleAgent } from "./toggleAgent"; +import type { BackendId, ImportCandidate, Skill } from "./types"; +import { runRenameSkill, runUpdateProperties } from "./updateProperties"; + +/** Debounce window for vault-watch-driven reconciliation, per spec. */ +const RECONCILE_DEBOUNCE_MS = 250; + +const skillManagerStore = createStore(); +const skillsAtom = atom([]); +const lastScannedFolderAtom = atom(DEFAULT_SKILLS_FOLDER); +const epermSeenAtom = atom(false); + +export type SkillOperationFailureCode = + | "no-vault-path" + | "unknown-agent" + | "invalid" + | "collision" + | "eperm" + | "fs-error"; + +export type SkillOperationResult< + TCode extends SkillOperationFailureCode = SkillOperationFailureCode, +> = { ok: true } | { ok: false; code: TCode; message: string }; + +/** Result of a per-agent toggle. */ +export type ToggleAgentResult = SkillOperationResult< + "no-vault-path" | "unknown-agent" | "eperm" | "fs-error" +>; + +/** Result of {@link SkillManager.deleteSkill}. */ +export type DeleteSkillResult = SkillOperationResult<"no-vault-path" | "fs-error">; + +/** Result of {@link SkillManager.updateProperties}. */ +export type UpdatePropertiesResult = SkillOperationResult<"fs-error">; + +/** Result of {@link SkillManager.renameSkill}. */ +export type RenameSkillResult = SkillOperationResult< + "no-vault-path" | "invalid" | "collision" | "eperm" | "fs-error" +>; + +/** Summary of a refresh pass. Reconciliation errors do not make discovery fail. */ +export interface RefreshResult { + ok: boolean; + folder: string; + skillCount: number; + reconcileErrorCount: number; + discoveryError?: string; + reconcileError?: string; +} + +/** Listener fired when the managed skill set relevant to any backend changes. */ +export type SkillSetChangeListener = (backendId: BackendId, signature: string) => void; + +/** + * Orchestrator for canonical-store skill discovery + symlink fanout. + * Handles per-agent toggle, delete, and reconciliation (forward + reverse). + * + * Top-level only: reads `getSettings()` to resolve the configured folder. + * Inner helpers receive concrete resolved paths and an FS adapter — see + * AGENTS.md "Avoiding Deep Dependency Chains in Tests". + */ +export class SkillManager { + private static instance: SkillManager | null = null; + private inFlight: Promise | null = null; + private inFlightFolder: string | null = null; + private queuedRefresh = false; + + /** Vault watcher event refs; torn down in {@link dispose}. */ + private vaultEventRefs: EventRef[] = []; + /** Trailing-edge debounce handle for vault-watch-triggered passes. */ + private reconcileDebounceTimer: number | null = null; + /** Last published per-backend skill signatures. */ + private readonly skillSetSignatures = new Map(); + /** Subscribers interested in backend-visible skill-set changes. */ + private readonly skillSetListeners = new Set(); + /** Pre-normalized agent dir set used by the vault-watcher hot path. */ + private readonly normalizedAgentDirs: ReadonlyArray; + + /** App handle is captured at the plugin edge; inner helpers stay pure. */ + private constructor( + private readonly app: App, + private readonly agentDirsProjectRel: Readonly> + ) { + this.normalizedAgentDirs = Object.values(agentDirsProjectRel).map(normalizeRelPath); + } + + /** + * @param agentDirsProjectRel project-relative skills directory for each + * registered backend, collected from `BackendDescriptor.skillsProjectDir`. + */ + static initialize( + app: App, + agentDirsProjectRel: Readonly> + ): SkillManager { + if (SkillManager.instance === null) { + SkillManager.instance = new SkillManager(app, agentDirsProjectRel); + SkillManager.instance.subscribeToVaultEvents(); + } + return SkillManager.instance; + } + + /** + * Project-relative skills directory for each registered backend. Exposed + * for UI components (delete confirm, import consent dialog) and the + * skill-creation spawn directive. + */ + getAgentDirsProjectRel(): Readonly> { + return this.agentDirsProjectRel; + } + + /** + * Returns the live singleton. Throws if {@link initialize} hasn't run yet — + * callers that may execute before plugin boot (tests, settings preview) + * must guard with {@link hasInstance}. + */ + static getInstance(): SkillManager { + if (SkillManager.instance === null) { + throw new Error("SkillManager.getInstance called before initialize"); + } + return SkillManager.instance; + } + + /** Whether {@link initialize} has run. Use to gate pre-boot UI surfaces. */ + static hasInstance(): boolean { + return SkillManager.instance !== null; + } + + /** Reset the singleton — test-only. */ + static resetForTesting(): void { + if (SkillManager.instance !== null) { + SkillManager.instance.dispose(); + } + SkillManager.instance = null; + skillManagerStore.set(skillsAtom, []); + skillManagerStore.set(lastScannedFolderAtom, DEFAULT_SKILLS_FOLDER); + skillManagerStore.set(epermSeenAtom, false); + } + + /** + * Tear down vault watchers + pending timers. Called from `main.ts` + * `onunload` and from {@link resetForTesting}. + * + * Also clears the singleton so a fresh `initialize()` (e.g. after plugin + * reload in tests, or a hot-reload during dev) actually rewires watchers + * rather than handing back a dead instance. + */ + dispose(): void { + for (const ref of this.vaultEventRefs) { + this.app.vault.offref(ref); + } + this.vaultEventRefs = []; + if (this.reconcileDebounceTimer !== null) { + window.clearTimeout(this.reconcileDebounceTimer); + this.reconcileDebounceTimer = null; + } + this.skillSetListeners.clear(); + this.skillSetSignatures.clear(); + this.inFlight = null; + this.inFlightFolder = null; + this.queuedRefresh = false; + if (SkillManager.instance === this) { + SkillManager.instance = null; + } + } + + /** + * Run discovery + reconciliation against the currently configured folder + * and publish the results into the store. Same-folder callers coalesce onto + * the in-flight pass; a folder change queues one follow-up pass so the final + * published state matches current settings. + */ + async refresh(): Promise { + const folder = resolveSkillsFolder(); + if (this.inFlight !== null) { + if (this.inFlightFolder !== folder) { + this.queuedRefresh = true; + } + return this.inFlight; + } + this.inFlight = Promise.resolve() + .then(() => this.runRefreshLoop(folder)) + .finally(() => { + this.inFlight = null; + this.inFlightFolder = null; + this.queuedRefresh = false; + }); + return this.inFlight; + } + + /** + * Subscribe to backend-visible skill-set changes. The listener receives + * the backend id whose effective managed skill signature changed. + */ + subscribeToSkillSetChange(listener: SkillSetChangeListener): () => void { + this.skillSetListeners.add(listener); + return () => this.skillSetListeners.delete(listener); + } + + /** + * Compute a stable signature for the current managed skill set as seen by + * `backendId`. Includes all managed skills, because OpenCode's config + * depends on both allowed skills and deny rules for cross-discovered skills. + */ + computeSkillSetSignature(backendId: BackendId): string { + return computeSkillSetSignature(getManagedSkills(), backendId); + } + + /** Run one or more refresh passes until no folder-change pass is queued. */ + private async runRefreshLoop(initialFolder: string): Promise { + let folder = initialFolder; + let result: RefreshResult; + do { + this.queuedRefresh = false; + this.inFlightFolder = folder; + result = await this.runOnce(folder); + if (this.queuedRefresh) { + folder = resolveSkillsFolder(); + } + } while (this.queuedRefresh); + return result; + } + + /** + * One pass: discover canonical skills, run reconciliation (forward + + * reverse) against the per-agent dirs, publish the list. Errors are logged + * and summarized in the returned result instead of thrown. + */ + private async runOnce(folder: string): Promise { + const absRoot = resolveAbsolutePath(this.app, folder); + const adapter = createFsAdapter(this.app); + + try { + const skills = await discoverManagedSkills({ + skillsFolderRelPath: folder, + skillsFolderAbsPath: absRoot, + adapter, + }); + + // Reconcile against the agent dirs if we have an on-disk vault. + const vaultRoot = resolveVaultRootAbs(this.app); + let reconcileErrorCount = 0; + let reconcileError: string | undefined; + if (vaultRoot !== null && absRoot !== null) { + try { + const report = await reconcile({ + skills, + canonicalAbsRoot: absRoot, + agentDirsAbs: this.resolveAgentDirsAbs(vaultRoot), + fs: createNodeReconcileFs(), + }); + reconcileErrorCount = report.errors.length; + recordReconcileReport(report); + } catch (err) { + reconcileError = err instanceof Error ? err.message : String(err); + reconcileErrorCount = 1; + logWarn(`[skills] Reconciliation pass failed: ${reconcileError}`); + } + } + + skillManagerStore.set(skillsAtom, skills); + skillManagerStore.set(lastScannedFolderAtom, folder); + this.publishSkillSetChanges(skills); + logInfo(`[skills] Discovered ${skills.length} managed skill(s) under "${folder}"`); + return { + ok: true, + folder, + skillCount: skills.length, + reconcileErrorCount, + ...(reconcileError !== undefined ? { reconcileError } : {}), + }; + } catch (err) { + logError("[skills] Discovery pass failed", err); + return { + ok: false, + folder, + skillCount: 0, + reconcileErrorCount: 0, + discoveryError: err instanceof Error ? err.message : String(err), + }; + } + } + + /** + * Toggle a single agent on/off for the given skill. Idempotent: + * + * 1. Write the canonical SKILL.md first with the new + * `metadata.copilot-enabled-agents` list — frontmatter is the source + * of truth, so on-disk state stays consistent even when the symlink + * op fails downstream. + * 2. Create or remove the symlink at `/./skills/`. + * On Windows EPERM the frontmatter is **not** rolled back — + * reconciliation reattempts the link on every subsequent pass once + * the user enables Developer Mode. + * 3. Refresh the in-memory skill list so the grid reflects the new state. + */ + async toggleAgent(skill: Skill, agent: BackendId, enabled: boolean): Promise { + const vaultRoot = resolveVaultRootAbs(this.app); + if (vaultRoot === null) { + return noVaultPathFailure(); + } + const folder = resolveSkillsFolder(); + if (resolveAbsolutePath(this.app, folder) === null) { + return noVaultPathFailure(); + } + + const fs = createNodeReconcileFs(); + const agentDir = this.resolveAgentDirAbs(vaultRoot, agent); + if (agentDir === null) { + return { ok: false, code: "unknown-agent", message: `Unknown agent: ${agent}` }; + } + const result = await runToggleAgent({ + skill, + agent, + enabled, + agentDirAbs: agentDir, + fs, + }); + + if (!result.ok && result.reason === "eperm") { + skillManagerStore.set(epermSeenAtom, true); + } + + await this.refresh(); + return result.ok ? { ok: true } : failureFromReason(result.reason); + } + + /** + * Delete a managed skill end-to-end: remove every enabled agent's + * symlink, then remove the canonical directory recursively, then + * refresh the in-memory list. The action is irreversible — the UI + * gates this behind a confirmation modal. + */ + async deleteSkill(skill: Skill): Promise { + const vaultRoot = resolveVaultRootAbs(this.app); + if (vaultRoot === null) { + return noVaultPathFailure(); + } + const fs = createNodeReconcileFs(); + const result = await runDeleteSkill({ + skill, + agentDirsAbs: this.resolveAgentDirsAbs(vaultRoot), + fs, + }); + await this.refresh(); + return result.ok ? { ok: true } : fsFailure(result.reason ?? "Unknown filesystem error."); + } + + /** + * Rewrite the canonical SKILL.md frontmatter with the given patch. No + * symlink work is performed — callers that need a rename should call + * {@link renameSkill} first. + * + * Preserves every unknown top-level key and unknown `metadata.*` key + * byte-for-byte (delegated to `serializeSkillFile`). On success, runs a + * refresh so the in-memory grid reflects the new state. + */ + async updateProperties( + skill: Skill, + patch: Omit + ): Promise { + const fs = createNodeReconcileFs(); + const result = await runUpdateProperties({ skill, patch, fs }); + if (result.ok) { + await this.refresh(); + } + return result.ok ? { ok: true } : fsFailure(result.reason); + } + + /** + * Rename a managed skill: dir-rename + per-agent symlink retarget + + * frontmatter `name:` rewrite. See `runRenameSkill` for the full + * lifecycle. The canonical rename and symlink retargets are processed + * even when one agent's link hits EPERM — `metadata.copilot-enabled-agents` + * remains the source of truth and reconciliation heals the missing link + * on the next pass once Developer Mode is on. + */ + async renameSkill(skill: Skill, newName: string): Promise { + const vaultRoot = resolveVaultRootAbs(this.app); + if (vaultRoot === null) { + return noVaultPathFailure(); + } + const folder = resolveSkillsFolder(); + const canonical = resolveAbsolutePath(this.app, folder); + if (canonical === null) { + return noVaultPathFailure(); + } + + const fs = createNodeReconcileFs(); + const result = await runRenameSkill({ + skill, + newName, + canonicalAbsRoot: canonical, + agentDirsAbs: this.resolveAgentDirsAbs(vaultRoot), + fs, + }); + + if (!result.ok && result.reason === "eperm") { + skillManagerStore.set(epermSeenAtom, true); + } + + // Refresh after any mutation beyond validation/collision, so the grid + // reflects the canonical filesystem even when a late step failed. + if (result.ok || (!result.ok && result.mutated === true)) { + await this.refresh(); + } + if (result.ok) return { ok: true }; + if (result.reason === "invalid") { + return { ok: false, code: "invalid", message: "Skill name is invalid." }; + } + if (result.reason === "collision") { + return { ok: false, code: "collision", message: "A skill with that name already exists." }; + } + return failureFromReason(result.reason); + } + + /** + * Walk every per-agent project path under the vault and return the + * import candidates grouped by source agent. Returns empty buckets if + * the host has no `FileSystemAdapter` (mobile / test environments). + * + * Candidates whose `sourcePath` appears in + * `agentMode.skills.importSkipList` are filtered out — those are + * sources from prior bulk-import attempts that failed to move and that + * the user has not explicitly asked us to retry. The "Find existing + * skills" rescan button clears the skip-list to retry them. + */ + async detectImports(): Promise { + const vaultRoot = resolveVaultRootAbs(this.app); + if (vaultRoot === null) { + return createEmptyImportDetectorResult(this.agentDirsProjectRel); + } + const folder = resolveSkillsFolder(); + const canonical = resolveAbsolutePath(this.app, folder); + if (canonical === null) { + return createEmptyImportDetectorResult(this.agentDirsProjectRel); + } + try { + const raw = await detectImportCandidates({ + vaultRootAbsPath: vaultRoot, + canonicalAbsPath: canonical, + agentDirsProjectRel: this.agentDirsProjectRel, + fs: createNodeImportDetectorFs(), + }); + const skip = new Set(getSettings().agentMode?.skills?.importSkipList ?? []); + if (skip.size === 0) return raw; + const filtered = createEmptyImportDetectorResult(this.agentDirsProjectRel); + for (const [agent, candidates] of Object.entries(raw)) { + filtered[agent] = candidates.filter((c) => !skip.has(c.sourcePath)); + } + return filtered; + } catch (err) { + logError("[skills] Import detection failed", err); + return createEmptyImportDetectorResult(this.agentDirsProjectRel); + } + } + + /** + * Run the bulk-move state machine over the supplied candidates. Returns + * one result row per candidate. After running, triggers a discovery + * refresh so the canonical grid reflects the newly moved skills. + * + * Names already taken in the current canonical grid are passed in so + * the auto-suffix logic can avoid colliding with them. + */ + async runImport(candidates: ImportCandidate[]): Promise { + const folder = resolveSkillsFolder(); + const canonical = resolveAbsolutePath(this.app, folder); + if (canonical === null) { + // No on-disk vault — bail with all rows marked as rolled back. + return { + results: candidates.map((c) => ({ + candidate: c, + targetName: c.name, + status: "rolledBack", + reason: "Vault has no on-disk path on this platform.", + })), + }; + } + const preTaken = skillManagerStore.get(skillsAtom).map((s) => s.name); + const result = await runBulkMove({ + candidates, + canonicalAbsRoot: canonical, + preTaken, + fs: createNodeBulkMoveFs(), + }); + // Persist source paths that did not land as a managed skill — the + // detector skips them on subsequent passes so the consent dialog + // doesn't re-prompt for the same failed sources. The "Find existing + // skills" rescan button clears this list to retry. + const failedPaths = result.results + .filter((row) => row.status !== "moved") + .map((row) => row.candidate.sourcePath); + if (failedPaths.length > 0) { + // Single read so we spread one consistent snapshot, not two. + const agentMode = getSettings().agentMode; + const merged = Array.from( + new Set([...(agentMode?.skills?.importSkipList ?? []), ...failedPaths]) + ); + updateSetting("agentMode", { + ...agentMode, + skills: { ...agentMode.skills, importSkipList: merged }, + }); + } + // refresh() runs reconciliation as part of its pass, so any leftover + // links from an aborted run get cleaned up here. + await this.refresh(); + return result; + } + + /** + * Clear the import skip-list so the next `detectImports` pass returns + * every candidate again. Called by the "Find existing skills" button. + */ + clearImportSkipList(): void { + const agentMode = getSettings().agentMode; + if ((agentMode?.skills?.importSkipList ?? []).length === 0) return; + updateSetting("agentMode", { + ...agentMode, + skills: { ...agentMode.skills, importSkipList: [] }, + }); + } + + /** + * Subscribe to vault file events that touch the canonical skills folder + * or any registered agent skill directory. Mutations there can + * desync the symlink fanout — we debounce by 250ms so a bulk rename + * fires one pass rather than dozens. + */ + private subscribeToVaultEvents(): void { + const handler = (file: TAbstractFile): void => { + if (!this.isWatchedPath(file.path)) return; + this.scheduleReconcile(); + }; + + // Rename includes the previous path; schedule if either side of the move + // was watched so moves out of a skills folder still reconcile. + this.vaultEventRefs.push(this.app.vault.on("create", handler)); + this.vaultEventRefs.push(this.app.vault.on("delete", handler)); + this.vaultEventRefs.push(this.app.vault.on("modify", handler)); + this.vaultEventRefs.push( + this.app.vault.on("rename", (file: TAbstractFile, oldPath: string) => { + if (this.isWatchedPath(file.path) || this.isWatchedPath(oldPath)) { + this.scheduleReconcile(); + } + }) + ); + } + + /** Is this vault-relative path inside one of the watched roots? */ + private isWatchedPath(relPath: string): boolean { + const path = normalizeRelPath(relPath); + const folder = normalizeRelPath(resolveSkillsFolder()); + if (path === folder || path.startsWith(`${folder}/`)) return true; + return this.normalizedAgentDirs.some((root) => path === root || path.startsWith(`${root}/`)); + } + + /** Absolute path for a single agent's skills directory, or null if unknown. */ + private resolveAgentDirAbs(vaultRootAbs: string, agent: BackendId): string | null { + const rel = this.agentDirsProjectRel[agent]; + if (rel === undefined) return null; + return agentSkillsDirAbs(vaultRootAbs, rel); + } + + /** Build the absolute `Record` map from the project-rel map. */ + private resolveAgentDirsAbs(vaultRootAbs: string): Record { + const out: Record = {}; + for (const [agent, rel] of Object.entries(this.agentDirsProjectRel)) { + out[agent] = agentSkillsDirAbs(vaultRootAbs, rel); + } + return out; + } + + /** Trailing-edge debounce wrapper around {@link refresh}. */ + private scheduleReconcile(): void { + if (this.reconcileDebounceTimer !== null) { + window.clearTimeout(this.reconcileDebounceTimer); + } + this.reconcileDebounceTimer = window.setTimeout(() => { + this.reconcileDebounceTimer = null; + void this.refresh(); + }, RECONCILE_DEBOUNCE_MS); + } + + /** Notify listeners for every backend whose effective skill signature changed. */ + private publishSkillSetChanges(skills: Skill[]): void { + for (const backendId of Object.keys(this.agentDirsProjectRel)) { + const signature = computeSkillSetSignature(skills, backendId); + const prev = this.skillSetSignatures.get(backendId); + if (prev === signature) continue; + this.skillSetSignatures.set(backendId, signature); + if (prev === undefined && signature === EMPTY_SKILL_SET_SIGNATURE) continue; + logInfo(`[skills] Skill set changed for ${backendId}; signature=${signature}`); + for (const listener of this.skillSetListeners) { + try { + listener(backendId, signature); + } catch (err) { + logError(`[skills] Skill-set listener failed for ${backendId}`, err); + } + } + } + } +} + +/** Total candidate count helper — re-exported for UI callers. */ +export { totalCandidates }; + +/** + * Best-effort absolute path resolution for the vault root. Returns `null` + * on platforms where the vault has no on-disk `FileSystemAdapter`. + */ +function resolveVaultRootAbs(app: App): string | null { + const adapter = app.vault.adapter; + if (!(adapter instanceof FileSystemAdapter)) return null; + return adapter.getBasePath().replace(/[/\\]+$/, ""); +} + +/** + * Hook: subscribe to the live managed-skills list. Re-renders the caller + * whenever {@link SkillManager.refresh} publishes a new list. + */ +export function useManagedSkills(): Skill[] { + return useAtomValue(skillsAtom, { store: skillManagerStore }); +} + +/** + * Hook: subscribe to the session-local EPERM banner flag. Once any + * symlink op has tripped EPERM, the banner stays up for the rest of the + * session unless dismissed via {@link dismissEpermBanner}. + */ +export function useEpermSeen(): boolean { + return useAtomValue(epermSeenAtom, { store: skillManagerStore }); +} + +/** Imperative setter — for the banner's dismiss button. */ +export function dismissEpermBanner(): void { + skillManagerStore.set(epermSeenAtom, false); +} + +/** Synchronous getter — useful from non-React code (e.g. spawn descriptors). */ +export function getManagedSkills(): Skill[] { + return skillManagerStore.get(skillsAtom); +} + +const EMPTY_SKILL_SET_SIGNATURE = "skills:v1:0"; + +/** Compute a deterministic signature for a backend's managed-skill view. */ +export function computeSkillSetSignature(skills: readonly Skill[], backendId: BackendId): string { + if (skills.length === 0) return EMPTY_SKILL_SET_SIGNATURE; + const rows = skills + .map((skill) => { + const enabled = skill.enabledAgents.includes(backendId) ? "1" : "0"; + return [ + skill.name, + enabled, + skill.description, + skill.allowedTools ?? "", + skill.model ?? "", + String(skill.disableModelInvocation ?? false), + String(skill.userInvocable ?? true), + stableHash(skill.body), + ].join("\u001f"); + }) + .sort(); + return `skills:v1:${stableHash(rows.join("\u001e"))}`; +} + +/** Small deterministic hash used only for change detection signatures. */ +function stableHash(value: string): string { + let hash = 0x811c9dc5; + for (let i = 0; i < value.length; i++) { + hash ^= value.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +/** + * Resolve `agentMode.skills.folder` defensively. Settings validation + * normally guarantees a well-formed value, but the UI may render before + * settings hydration finishes; fall back to the default in that window. + */ +function resolveSkillsFolder(): string { + const raw = getSettings().agentMode?.skills?.folder; + if (typeof raw !== "string" || raw.trim().length === 0) { + return DEFAULT_SKILLS_FOLDER; + } + return raw; +} + +/** + * Best-effort absolute path resolution. Returns `null` on platforms where + * the vault has no on-disk `FileSystemAdapter` (mobile, in-memory tests). + * Discovery still works against vault-relative paths in that case. + */ +function resolveAbsolutePath(app: App, relFolder: string): string | null { + const adapter = app.vault.adapter; + if (!(adapter instanceof FileSystemAdapter)) return null; + const base = adapter.getBasePath().replace(/[/\\]+$/, ""); + return `${base}/${relFolder}`; +} + +/** Normalize vault-relative watched paths before prefix comparison. */ +function normalizeRelPath(path: string): string { + return path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); +} + +/** + * Wrap Obsidian's `Vault.adapter` in the smaller {@link SkillsFsAdapter} + * surface. Keeps the discovery walker free of Obsidian-specific imports. + */ +function createFsAdapter(app: App): SkillsFsAdapter { + const adapter = app.vault.adapter; + return { + exists: (rel) => adapter.exists(rel), + list: (rel) => adapter.list(rel), + read: (rel) => adapter.read(rel), + }; +} + +/** Standard failure for desktop-only filesystem operations on unsupported vaults. */ +function noVaultPathFailure(): { ok: false; code: "no-vault-path"; message: string } { + return { + ok: false, + code: "no-vault-path", + message: "Vault has no on-disk path on this platform.", + }; +} + +/** Wrap an unexpected filesystem failure in the manager's public result shape. */ +function fsFailure(message: string): { ok: false; code: "fs-error"; message: string } { + return { ok: false, code: "fs-error", message }; +} + +/** Convert helper-layer reason strings into manager-layer failure codes. */ +function failureFromReason(reason: string): { + ok: false; + code: "eperm" | "fs-error"; + message: string; +} { + if (reason === "eperm") { + return { ok: false, code: "eperm", message: "Permission denied while creating a skill link." }; + } + return fsFailure(reason); +} + +/** + * Update the EPERM-seen flag based on a reconciliation report. We never + * un-set the flag here — it's session-local and only clears via the + * banner's dismiss button or {@link dismissEpermBanner}. + */ +function recordReconcileReport(report: ReconcileReport): void { + if (report.errors.some((e) => e.reason === "eperm")) { + skillManagerStore.set(epermSeenAtom, true); + } +} diff --git a/src/agentMode/skills/agentPaths.ts b/src/agentMode/skills/agentPaths.ts new file mode 100644 index 00000000..d600d73e --- /dev/null +++ b/src/agentMode/skills/agentPaths.ts @@ -0,0 +1,13 @@ +export { DEFAULT_SKILLS_FOLDER } from "@/constants"; + +/** + * POSIX-join an absolute vault root with a project-relative subdirectory + * (e.g. `.claude/skills`). Trailing/leading slashes on the inputs are + * normalized out. Callers must pass an absolute root; this helper does no + * resolution of its own. + */ +export function agentSkillsDirAbs(vaultRootAbs: string, projectRelDir: string): string { + const left = vaultRootAbs.replace(/[/\\]+$/, ""); + const right = projectRelDir.replace(/^[/\\]+/, ""); + return `${left}/${right}`; +} diff --git a/src/agentMode/skills/bulkMove.test.ts b/src/agentMode/skills/bulkMove.test.ts new file mode 100644 index 00000000..d7b39937 --- /dev/null +++ b/src/agentMode/skills/bulkMove.test.ts @@ -0,0 +1,295 @@ +import { runBulkMove } from "./bulkMove"; +import type { BulkMoveFs } from "./bulkMove"; +import type { ImportCandidate } from "./types"; + +jest.mock("@/logger", () => ({ + logInfo: jest.fn(), + logWarn: jest.fn(), + logError: jest.fn(), +})); + +/** + * Build a tiny in-memory FS that's just rich enough for the bulk-move + * orchestration. Entries are stored as either text (files) or as a + * marker (`__dir__`). Symlinks are stored with the prefix `__link__:` + * followed by the absolute target. + */ +type Node = + | { kind: "file"; content: string } + | { kind: "dir" } + | { kind: "symlink"; target: string }; + +function mkFs(initial: Record = {}): BulkMoveFs & { + __dump(): Record; + __setSymlinkBlocked(blocked: boolean): void; +} { + const map = new Map(Object.entries(initial)); + // Synthesize directory entries for every ancestor. + for (const fullPath of Array.from(map.keys())) { + const parts = fullPath.split("/"); + for (let i = 1; i < parts.length; i++) { + const ancestor = parts.slice(0, i).join("/"); + if (ancestor.length === 0) continue; + if (!map.has(ancestor)) map.set(ancestor, { kind: "dir" }); + } + } + + let symlinkBlocked = false; + + /** + * Move a subtree atomically — every entry whose path starts with + * `from + "/"` or equals `from` is rewritten to start with `to`. + */ + function rename(from: string, to: string): void { + if (!map.has(from)) throw Object.assign(new Error(`ENOENT: ${from}`), { code: "ENOENT" }); + if (map.has(to)) throw Object.assign(new Error(`EEXIST: ${to}`), { code: "EEXIST" }); + const prefix = from + "/"; + const moves: Array<[string, string]> = []; + for (const k of map.keys()) { + if (k === from) moves.push([k, to]); + else if (k.startsWith(prefix)) moves.push([k, to + k.slice(from.length)]); + } + for (const [oldKey, newKey] of moves) { + const v = map.get(oldKey)!; + map.delete(oldKey); + map.set(newKey, v); + } + // Ensure ancestors of `to` exist. + const parts = to.split("/"); + for (let i = 1; i < parts.length; i++) { + const ancestor = parts.slice(0, i).join("/"); + if (ancestor.length === 0) continue; + if (!map.has(ancestor)) map.set(ancestor, { kind: "dir" }); + } + } + + const fs: BulkMoveFs & { + __dump(): Record; + __setSymlinkBlocked(blocked: boolean): void; + } = { + async exists(p) { + return map.has(p); + }, + async isDirectory(p) { + const e = map.get(p); + return e !== undefined && e.kind === "dir"; + }, + async isSymlink(p) { + const e = map.get(p); + return e !== undefined && e.kind === "symlink"; + }, + async symlink(target, linkPath) { + if (symlinkBlocked) { + throw Object.assign(new Error("EPERM: operation not permitted"), { code: "EPERM" }); + } + if (map.has(linkPath)) { + throw Object.assign(new Error(`EEXIST: ${linkPath}`), { code: "EEXIST" }); + } + map.set(linkPath, { kind: "symlink", target }); + }, + async unlink(p) { + const e = map.get(p); + if (e === undefined) return; + if (e.kind !== "symlink") return; // mimics our helper guard upstream + map.delete(p); + }, + async rmRecursive(p) { + const prefix = p + "/"; + for (const k of Array.from(map.keys())) { + if (k === p || k.startsWith(prefix)) map.delete(k); + } + }, + async readFile(p) { + const e = map.get(p); + if (e === undefined || e.kind !== "file") { + throw Object.assign(new Error(`ENOENT: ${p}`), { code: "ENOENT" }); + } + return e.content; + }, + async writeFile(p, content) { + map.set(p, { kind: "file", content }); + }, + async mkdirRecursive(p) { + const parts = p.split("/"); + for (let i = 1; i <= parts.length; i++) { + const ancestor = parts.slice(0, i).join("/"); + if (ancestor.length === 0) continue; + if (!map.has(ancestor)) map.set(ancestor, { kind: "dir" }); + } + }, + async list(p) { + const prefix = p.replace(/\/+$/, "") + "/"; + const out = new Set(); + for (const k of map.keys()) { + if (!k.startsWith(prefix)) continue; + const rest = k.slice(prefix.length); + if (rest.length === 0) continue; + out.add(rest.split("/")[0]); + } + return Array.from(out); + }, + __dump() { + return Object.fromEntries(map); + }, + __setSymlinkBlocked(blocked) { + symlinkBlocked = blocked; + }, + }; + + // Patch rename helper into the FS so renameWithRetry-style tests work. + // We monkey-patch onto an internal — bulkMove uses `renameWithRetry` from + // `node:fs` indirectly. To keep tests pure, override `fs.promises.rename` + // via the helper module. We achieve this by re-routing through this FS + // when both source and dest are known to our map. See the rename mock + // below. + Object.defineProperty(fs, "__rename", { value: rename, enumerable: false }); + return fs; +} + +// `runBulkMove` calls `renameWithRetry`, which lives in its own module and +// uses `node:fs`. To keep this an in-memory test we replace that module. +let currentRename: ((from: string, to: string) => void) | null = null; +jest.mock("./renameWithRetry", () => ({ + __esModule: true, + renameWithRetry: jest.fn(async (from: string, to: string) => { + if (currentRename === null) throw new Error("rename not bound"); + currentRename(from, to); + }), +})); + +const CANONICAL = "/vault/copilot/skills"; + +const VALID_SKILL_MD = (name: string): string => + `---\nname: ${name}\ndescription: A short skill.\n---\nbody`; + +/** Helper: build a candidate with sensible defaults. */ +function candidate(overrides: Partial): ImportCandidate { + return { + name: "foo", + sourceAgent: "claude", + sourcePath: "/vault/.claude/skills/foo", + fileCount: 1, + totalBytes: 80, + ...overrides, + }; +} + +describe("runBulkMove", () => { + beforeEach(() => { + currentRename = null; + }); + + it("moves, stamps metadata, and links a single candidate", async () => { + const fs = mkFs({ + "/vault/.claude/skills/foo/SKILL.md": { kind: "file", content: VALID_SKILL_MD("foo") }, + }); + currentRename = (from, to) => + (fs as unknown as { __rename: (a: string, b: string) => void }).__rename(from, to); + + const result = await runBulkMove({ + candidates: [candidate({})], + canonicalAbsRoot: CANONICAL, + fs, + }); + + expect(result.results).toHaveLength(1); + expect(result.results[0].status).toBe("moved"); + expect(result.results[0].targetName).toBe("foo"); + expect(result.results[0].failingSkillMdAbsPath).toBeUndefined(); + + const dump = fs.__dump(); + // Canonical dir + SKILL.md present: + expect(dump["/vault/copilot/skills/foo"].kind).toBe("dir"); + expect(dump["/vault/copilot/skills/foo/SKILL.md"].kind).toBe("file"); + // Metadata stamped: + const md = (dump["/vault/copilot/skills/foo/SKILL.md"] as { kind: "file"; content: string }) + .content; + expect(md).toContain("copilot-enabled-agents"); + expect(md).toContain("claude"); + // Source path was replaced with a symlink pointing back at the canonical copy. + const linkEntry = dump["/vault/.claude/skills/foo"]; + expect(linkEntry.kind).toBe("symlink"); + expect((linkEntry as { kind: "symlink"; target: string }).target).toBe( + "/vault/copilot/skills/foo" + ); + }); + + it("auto-suffixes on name collision", async () => { + const fs = mkFs({ + "/vault/.claude/skills/foo/SKILL.md": { kind: "file", content: VALID_SKILL_MD("foo") }, + "/vault/copilot/skills/foo/SKILL.md": { kind: "file", content: VALID_SKILL_MD("foo") }, + }); + currentRename = (from, to) => + (fs as unknown as { __rename: (a: string, b: string) => void }).__rename(from, to); + + const result = await runBulkMove({ + candidates: [candidate({})], + canonicalAbsRoot: CANONICAL, + preTaken: ["foo"], + fs, + }); + + expect(result.results[0].status).toBe("moved"); + expect(result.results[0].targetName).toBe("foo-2"); + + const dump = fs.__dump(); + expect(dump["/vault/copilot/skills/foo-2"].kind).toBe("dir"); + // The auto-suffix rewrites `name:` inside SKILL.md so the spec's + // parent-directory-match rule holds for the canonical copy. + const md = (dump["/vault/copilot/skills/foo-2/SKILL.md"] as { kind: "file"; content: string }) + .content; + expect(md).toContain("name: foo-2"); + expect(md).toContain("copilot-enabled-agents"); + }); + + it("rolls back when SKILL.md fails to parse", async () => { + const fs = mkFs({ + "/vault/.claude/skills/foo/SKILL.md": { kind: "file", content: "not yaml at all" }, + }); + currentRename = (from, to) => + (fs as unknown as { __rename: (a: string, b: string) => void }).__rename(from, to); + + const result = await runBulkMove({ + candidates: [candidate({})], + canonicalAbsRoot: CANONICAL, + fs, + }); + + expect(result.results[0].status).toBe("rolledBack"); + expect(result.results[0].reason).toBeDefined(); + // Points at the restored source SKILL.md so the UI can offer "Edit SKILL.md". + expect(result.results[0].failingSkillMdAbsPath).toBe("/vault/.claude/skills/foo/SKILL.md"); + + const dump = fs.__dump(); + // Source restored: + expect(dump["/vault/.claude/skills/foo/SKILL.md"].kind).toBe("file"); + // No canonical entry: + expect(dump["/vault/copilot/skills/foo"]).toBeUndefined(); + }); + + it("returns epermNoLink when symlink creation fails", async () => { + const fs = mkFs({ + "/vault/.claude/skills/foo/SKILL.md": { kind: "file", content: VALID_SKILL_MD("foo") }, + }); + currentRename = (from, to) => + (fs as unknown as { __rename: (a: string, b: string) => void }).__rename(from, to); + fs.__setSymlinkBlocked(true); + + const result = await runBulkMove({ + candidates: [candidate({})], + canonicalAbsRoot: CANONICAL, + fs, + }); + + expect(result.results[0].status).toBe("epermNoLink"); + // Canonical copy survives — UI offers an editor link to that file. + expect(result.results[0].failingSkillMdAbsPath).toBe("/vault/copilot/skills/foo/SKILL.md"); + + const dump = fs.__dump(); + // Canonical preserved: + expect(dump["/vault/copilot/skills/foo"].kind).toBe("dir"); + expect(dump["/vault/copilot/skills/foo/SKILL.md"].kind).toBe("file"); + // No symlink at the original path: + expect(dump["/vault/.claude/skills/foo"]).toBeUndefined(); + }); +}); diff --git a/src/agentMode/skills/bulkMove.ts b/src/agentMode/skills/bulkMove.ts new file mode 100644 index 00000000..7cc9c260 --- /dev/null +++ b/src/agentMode/skills/bulkMove.ts @@ -0,0 +1,222 @@ +import { logError } from "@/logger"; +import { joinPosix, parentDir } from "@/utils/pathUtils"; +import { renameWithRetry } from "./renameWithRetry"; +import { parseSkillFile, serializeSkillFile, SkillFormatError } from "./skillFormat"; +import { suffixOnCollision } from "./suffixOnCollision"; +import { replaceAgentLink, type SymlinksFs } from "./symlinks"; +import type { ImportCandidate } from "./types"; + +/** + * Subset of `node:fs` (plus a read/write helper for SKILL.md) used by the + * bulk-move state machine. Injected so the orchestration can be tested + * without touching disk or Obsidian. + * + * Implements both the directory-shaping ops and the file IO we need to + * stamp `metadata.copilot-enabled-agents` on the moved SKILL.md. + */ +export interface BulkMoveFs extends SymlinksFs { + /** Read a UTF-8 file. */ + readFile(absPath: string): Promise; + /** Write a UTF-8 file (overwriting). */ + writeFile(absPath: string, content: string): Promise; + /** Recursively create a directory (mkdir -p). */ + mkdirRecursive(absPath: string): Promise; + /** List immediate entries under a directory. */ + list(absPath: string): Promise; +} + +/** + * Outcome of moving a single candidate. + * + * - `moved` — canonical copy live, metadata stamped, symlink created. + * - `rolledBack` — parse failure (or another recoverable error) after the + * move; we moved the dir back and made no canonical entry. + * - `epermNoLink` — canonical copy kept and stamped, but the symlink at + * the source path could not be created (Windows Developer Mode off). + * The source path no longer exists; the user still has a working + * canonical SKILL.md and can flip the symlink on later. + */ +export type BulkMoveStatus = "moved" | "rolledBack" | "epermNoLink"; + +export interface BulkMoveRow { + candidate: ImportCandidate; + /** Final canonical name (after auto-suffix). Always set, even on rollback. */ + targetName: string; + status: BulkMoveStatus; + /** Human-readable reason for `rolledBack` / `epermNoLink`. */ + reason?: string; + /** + * Absolute path to the SKILL.md a user can open to fix this row. Set on + * any non-`moved` status: the restored source SKILL.md on `rolledBack` + * (so the user can fix e.g. a name/parent-dir mismatch) and the + * surviving canonical SKILL.md on `epermNoLink`. + */ + failingSkillMdAbsPath?: string; +} + +export interface BulkMoveResult { + results: BulkMoveRow[]; +} + +export interface BulkMoveOptions { + /** Candidates to move, in any order — processed sequentially. */ + candidates: ImportCandidate[]; + /** Absolute path to `//`. */ + canonicalAbsRoot: string; + /** + * Names already taken in the canonical store (from a discovery pass). + * Used as the initial collision set for auto-suffixing. + */ + preTaken?: ReadonlyArray; + /** Injected FS. */ + fs: BulkMoveFs; +} + +/** + * Move each candidate's source directory into the canonical store, stamp + * `metadata.copilot-enabled-agents` with the source agent, and replace the + * source path with a symlink/junction to the canonical copy. Each row is + * atomic per the spec — on parse failure the source is restored byte-equal + * and no canonical entry remains. + * + * The function never throws for a single row — every failure is captured + * in {@link BulkMoveRow.status}/`reason`. Catastrophic FS errors (e.g. the + * canonical root itself can't be created) bubble up. + */ +export async function runBulkMove(options: BulkMoveOptions): Promise { + const { candidates, canonicalAbsRoot, fs, preTaken = [] } = options; + + // Ensure the canonical root exists. If this fails the whole run is dead + // in the water — let the error bubble so the caller can surface it. + await fs.mkdirRecursive(canonicalAbsRoot); + + const taken = new Set(preTaken); + const rows: BulkMoveRow[] = []; + + for (const candidate of candidates) { + const row = await moveOne(candidate, canonicalAbsRoot, taken, fs); + rows.push(row); + if (row.status !== "rolledBack") { + taken.add(row.targetName); + } + } + + return { results: rows }; +} + +/** + * Single-row state machine. Splits into clear phases so the rollback path + * is straightforward. + */ +async function moveOne( + candidate: ImportCandidate, + canonicalAbsRoot: string, + taken: Set, + fs: BulkMoveFs +): Promise { + const targetName = suffixOnCollision(candidate.name, taken); + const targetDir = joinPosix(canonicalAbsRoot, targetName); + const targetSkillMd = joinPosix(targetDir, "SKILL.md"); + const sourceSkillMd = joinPosix(candidate.sourcePath, "SKILL.md"); + + // 1. Move source → canonical// + try { + await renameWithRetry(candidate.sourcePath, targetDir); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logError(`[skills] Failed to move ${candidate.sourcePath} → ${targetDir}: ${message}`); + return { + candidate, + targetName, + status: "rolledBack", + reason: `Could not move directory: ${message}`, + failingSkillMdAbsPath: sourceSkillMd, + }; + } + + // 2. Verify SKILL.md parses, then 3. stamp metadata. + // + // Parse against the *original* name so the candidate's own SKILL.md + // (which still has `name: `) doesn't fail the + // parent-directory-match check just because we suffixed the folder. + // We then rewrite `name:` to the suffixed value during serialize so + // the spec invariant holds on the canonical copy. + let stampedContent: string; + try { + const raw = await fs.readFile(targetSkillMd); + const parsed = parseSkillFile(raw, candidate.name); + stampedContent = serializeSkillFile(parsed, { + // If we auto-suffixed, rewrite the top-level `name:` to match the + // new parent directory so re-parsing the canonical copy succeeds. + ...(targetName !== candidate.name ? { name: targetName } : {}), + // Stamp with the source agent only — imports are always single-source. + enabledAgents: [candidate.sourceAgent], + }); + } catch (err) { + const reason = + err instanceof SkillFormatError + ? err.message + : err instanceof Error + ? err.message + : String(err); + // Roll back the move so we leave no trace. + try { + await renameWithRetry(targetDir, candidate.sourcePath); + } catch (restoreErr) { + logError( + `[skills] Could not roll back ${targetDir} → ${candidate.sourcePath}: ${ + restoreErr instanceof Error ? restoreErr.message : String(restoreErr) + }` + ); + } + return { + candidate, + targetName, + status: "rolledBack", + reason, + failingSkillMdAbsPath: sourceSkillMd, + }; + } + + try { + await fs.writeFile(targetSkillMd, stampedContent); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + // Best-effort rollback. If writing failed, the file may be partial; + // we still try to restore source to its original location. + try { + await renameWithRetry(targetDir, candidate.sourcePath); + } catch (restoreErr) { + logError( + `[skills] Could not roll back after stamp failure: ${ + restoreErr instanceof Error ? restoreErr.message : String(restoreErr) + }` + ); + } + return { + candidate, + targetName, + status: "rolledBack", + reason: `Could not stamp metadata: ${message}`, + failingSkillMdAbsPath: sourceSkillMd, + }; + } + + // 4. Replace the source path with a symlink to the canonical copy. + const agentDir = parentDir(candidate.sourcePath); + const symlinkResult = await replaceAgentLink(fs, agentDir, candidate.name, targetDir); + + if (!symlinkResult.ok) { + // EPERM: canonical stays, source path is gone (the rename consumed + // it), no symlink. The user can re-fanout once Developer Mode is on. + return { + candidate, + targetName, + status: "epermNoLink", + reason: symlinkResult.message, + failingSkillMdAbsPath: targetSkillMd, + }; + } + + return { candidate, targetName, status: "moved" }; +} diff --git a/src/agentMode/skills/denyListComposer.test.ts b/src/agentMode/skills/denyListComposer.test.ts new file mode 100644 index 00000000..e632a2a7 --- /dev/null +++ b/src/agentMode/skills/denyListComposer.test.ts @@ -0,0 +1,97 @@ +import { composeDenyList } from "./denyListComposer"; +import type { BackendId, Skill } from "./types"; + +/** + * Build a minimal Skill with only the fields composeDenyList consults. + * Keeps the test cases dense — composer is pure, so the other fields don't + * matter. + */ +function skill(name: string, enabledAgents: BackendId[]): Skill { + return { + name, + description: `${name} skill`, + filePath: `/x/${name}/SKILL.md`, + dirPath: `/x/${name}`, + body: "", + enabledAgents, + }; +} + +/** + * Mirror of each backend's `BackendDescriptor.crossDiscoveredAgents` for + * test-only convenience — the production data lives on the descriptors + * themselves; this is a local fixture so we don't repeat the list in every + * assertion. + */ +const CROSS: Record = { + opencode: ["claude", "codex"], + claude: [], + codex: [], +}; + +const deny = (skills: Skill[], b: BackendId) => composeDenyList(skills, b, CROSS[b]); + +describe("composeDenyList", () => { + it("denies a Claude-only skill in OpenCode (cross-discovered via .claude/skills/)", () => { + const a = skill("a", ["claude"]); + expect(deny([a], "opencode")).toEqual(["a"]); + expect(deny([a], "claude")).toEqual([]); + expect(deny([a], "codex")).toEqual([]); + }); + + it("does not deny a skill that is enabled for OpenCode (and also Claude)", () => { + const b = skill("b", ["claude", "opencode"]); + expect(deny([b], "opencode")).toEqual([]); + expect(deny([b], "claude")).toEqual([]); + expect(deny([b], "codex")).toEqual([]); + }); + + it("does not deny a skill that is enabled for nothing", () => { + // Unreachable in normal usage (no symlinks are created), but if a stale + // canonical doc has no enabledAgents we must not emit a deny entry. + const c = skill("c", []); + expect(deny([c], "opencode")).toEqual([]); + expect(deny([c], "claude")).toEqual([]); + expect(deny([c], "codex")).toEqual([]); + }); + + it("does not deny an OpenCode-only skill in OpenCode (not cross-discovered for itself)", () => { + const d = skill("d", ["opencode"]); + expect(deny([d], "opencode")).toEqual([]); + expect(deny([d], "claude")).toEqual([]); + expect(deny([d], "codex")).toEqual([]); + }); + + it("denies a Codex-only skill in OpenCode (cross-discovered via .agents/skills/)", () => { + const e = skill("e", ["codex"]); + expect(deny([e], "opencode")).toEqual(["e"]); + expect(deny([e], "claude")).toEqual([]); + expect(deny([e], "codex")).toEqual([]); + }); + + it("returns a sorted, de-duplicated list for mixed skills (A/B/C/D)", () => { + const a = skill("a", ["claude"]); + const b = skill("b", ["claude", "opencode"]); + const c = skill("c", []); + const d = skill("d", ["opencode"]); + // intentional ordering: unsorted input, ensure sorted output. + const all = [d, a, c, b]; + expect(deny(all, "opencode")).toEqual(["a"]); + expect(deny(all, "claude")).toEqual([]); + expect(deny(all, "codex")).toEqual([]); + }); + + it("sorts deterministically when multiple skills are denied", () => { + const z = skill("z-task", ["claude"]); + const m = skill("m-task", ["codex"]); + const a = skill("a-task", ["claude"]); + const all = [z, m, a]; + expect(deny(all, "opencode")).toEqual(["a-task", "m-task", "z-task"]); + }); + + it("is empty when no skills exist", () => { + expect(deny([], "opencode")).toEqual([]); + expect(deny([], "claude")).toEqual([]); + expect(deny([], "codex")).toEqual([]); + }); +}); diff --git a/src/agentMode/skills/denyListComposer.ts b/src/agentMode/skills/denyListComposer.ts new file mode 100644 index 00000000..3efe739e --- /dev/null +++ b/src/agentMode/skills/denyListComposer.ts @@ -0,0 +1,46 @@ +import type { BackendId, Skill } from "./types"; + +/** + * Compose the per-backend skill deny list for cross-discovered managed + * skills. See spec §Filtering enabled skills per backend. + * + * Cross-discovery model: a backend may walk skill directories owned by + * other backends in addition to its own. Today only OpenCode does this + * (`.claude/skills/` and `.agents/skills/` in addition to + * `.opencode/skills/`). Each backend declares its cross-discovery surface + * on its `BackendDescriptor.crossDiscoveredAgents` — this function only + * consumes that data, it does not own it. + * + * The deny list is `cross_discovered − enabled_for_`: skill names + * that the backend would otherwise see via cross-discovery but are not + * enabled for it. Pure leaf helper — no Obsidian / fs / singleton / backend + * imports — so callers can drive it with plain `Skill[]` arrays from tests. + * + * @param allSkills All managed skills discovered in the canonical store. + * @param backend The backend the caller is composing a config for. + * @param crossDiscoveredAgents Other backends whose skills `backend` also + * loads at spawn time (i.e. + * `BackendDescriptor.crossDiscoveredAgents`). + * @returns Sorted (lexicographic) array of skill names to deny, with no + * duplicates. Empty array when no skills need denying. The sort + * is for log-line stability — the OpenCode config consumer is a + * `Record` so it doesn't care, but a stable + * ordering makes the `joined` log message diff-friendly when + * comparing two sessions. + */ +export function composeDenyList( + allSkills: Skill[], + backend: BackendId, + crossDiscoveredAgents: ReadonlyArray +): string[] { + if (crossDiscoveredAgents.length === 0) return []; + + const deny = new Set(); + for (const skill of allSkills) { + if (skill.enabledAgents.includes(backend)) continue; + if (skill.enabledAgents.some((a) => crossDiscoveredAgents.includes(a))) { + deny.add(skill.name); + } + } + return Array.from(deny).sort(); +} diff --git a/src/agentMode/skills/discoverManagedSkills.test.ts b/src/agentMode/skills/discoverManagedSkills.test.ts new file mode 100644 index 00000000..39386635 --- /dev/null +++ b/src/agentMode/skills/discoverManagedSkills.test.ts @@ -0,0 +1,219 @@ +import { logWarn } from "@/logger"; +import { discoverManagedSkills, type SkillsFsAdapter } from "./discoverManagedSkills"; + +jest.mock("@/logger", () => ({ + logInfo: jest.fn(), + logWarn: jest.fn(), + logError: jest.fn(), +})); + +const mockedLogWarn = logWarn as jest.MockedFunction; + +/** + * Build a minimal in-memory FS adapter where the only paths that exist are + * those given in `files` (keys are full vault-relative paths). Folder + * structure is inferred from the parent directories of each file. + */ +function makeAdapter(files: Record): SkillsFsAdapter { + const fileSet = new Map(Object.entries(files)); + const folders = new Set(); + for (const fullPath of fileSet.keys()) { + const parts = fullPath.split("/"); + for (let i = 1; i < parts.length; i++) { + folders.add(parts.slice(0, i).join("/")); + } + } + + return { + async exists(rel) { + return fileSet.has(rel) || folders.has(rel); + }, + async list(rel) { + const prefix = rel.endsWith("/") ? rel : `${rel}/`; + const filesOut: string[] = []; + const foldersOut: string[] = []; + for (const p of fileSet.keys()) { + if (!p.startsWith(prefix)) continue; + const remainder = p.slice(prefix.length); + if (remainder.includes("/")) { + foldersOut.push(`${rel}/${remainder.split("/")[0]}`); + } else { + filesOut.push(p); + } + } + for (const f of folders) { + if (!f.startsWith(prefix)) continue; + const remainder = f.slice(prefix.length); + if (!remainder.includes("/")) { + foldersOut.push(f); + } + } + return { + files: [...new Set(filesOut)], + folders: [...new Set(foldersOut)], + }; + }, + async read(rel) { + const content = fileSet.get(rel); + if (content === undefined) { + throw new Error(`ENOENT: ${rel}`); + } + return content; + }, + }; +} + +const SKILLS_ROOT = "copilot/skills"; + +const validSkillMd = (overrides: Record = {}) => { + const fm = { + name: "review-prose", + description: "Critique writing for clarity, voice, and rhythm.", + ...overrides, + }; + const yaml = Object.entries(fm) + .map(([k, v]) => `${k}: ${v}`) + .join("\n"); + return `---\n${yaml}\n---\nbody`; +}; + +describe("discoverManagedSkills", () => { + beforeEach(() => { + mockedLogWarn.mockClear(); + }); + + it("returns an empty array when the skills folder does not exist", async () => { + const adapter = makeAdapter({}); + const skills = await discoverManagedSkills({ + skillsFolderRelPath: SKILLS_ROOT, + skillsFolderAbsPath: null, + adapter, + }); + expect(skills).toEqual([]); + expect(mockedLogWarn).not.toHaveBeenCalled(); + }); + + it("returns a single Skill for one valid SKILL.md", async () => { + const adapter = makeAdapter({ + [`${SKILLS_ROOT}/review-prose/SKILL.md`]: validSkillMd({ name: "review-prose" }), + }); + const skills = await discoverManagedSkills({ + skillsFolderRelPath: SKILLS_ROOT, + skillsFolderAbsPath: null, + adapter, + }); + expect(skills).toHaveLength(1); + expect(skills[0].name).toBe("review-prose"); + expect(skills[0].description).toBe("Critique writing for clarity, voice, and rhythm."); + expect(skills[0].enabledAgents).toEqual([]); + expect(skills[0].body).toBe("body"); + }); + + it("skips a SKILL.md with invalid frontmatter and emits one logWarn", async () => { + // Uppercase name fails the spec regex. + const adapter = makeAdapter({ + [`${SKILLS_ROOT}/Bad/SKILL.md`]: validSkillMd({ name: "Bad" }), + }); + const skills = await discoverManagedSkills({ + skillsFolderRelPath: SKILLS_ROOT, + skillsFolderAbsPath: null, + adapter, + }); + expect(skills).toEqual([]); + expect(mockedLogWarn).toHaveBeenCalledTimes(1); + expect(mockedLogWarn.mock.calls[0][0]).toMatch(/Skipping .*Bad\/SKILL\.md/); + }); + + it("returns only the valid skills when invalid SKILL.md files are mixed in", async () => { + const adapter = makeAdapter({ + [`${SKILLS_ROOT}/alpha/SKILL.md`]: validSkillMd({ name: "alpha" }), + [`${SKILLS_ROOT}/beta/SKILL.md`]: validSkillMd({ name: "beta" }), + [`${SKILLS_ROOT}/gamma/SKILL.md`]: validSkillMd({ name: "gamma" }), + [`${SKILLS_ROOT}/Bad1/SKILL.md`]: validSkillMd({ name: "Bad1" }), + // Wrong parent-dir match: file claims name "wrong" but folder is "x-y". + [`${SKILLS_ROOT}/x-y/SKILL.md`]: validSkillMd({ name: "wrong" }), + }); + const skills = await discoverManagedSkills({ + skillsFolderRelPath: SKILLS_ROOT, + skillsFolderAbsPath: null, + adapter, + }); + expect(skills.map((s) => s.name).sort()).toEqual(["alpha", "beta", "gamma"]); + expect(mockedLogWarn).toHaveBeenCalledTimes(2); + }); + + it("parses metadata.copilot-enabled-agents into BackendId[]", async () => { + const content = [ + "---", + "name: multi", + "description: A skill", + "metadata:", + ' copilot-enabled-agents: "claude,opencode"', + "---", + "body", + ].join("\n"); + const adapter = makeAdapter({ + [`${SKILLS_ROOT}/multi/SKILL.md`]: content, + }); + const skills = await discoverManagedSkills({ + skillsFolderRelPath: SKILLS_ROOT, + skillsFolderAbsPath: null, + adapter, + }); + expect(skills).toHaveLength(1); + expect(skills[0].enabledAgents).toEqual(["claude", "opencode"]); + }); + + it("populates absolute paths from skillsFolderAbsPath when provided", async () => { + const adapter = makeAdapter({ + [`${SKILLS_ROOT}/foo/SKILL.md`]: validSkillMd({ name: "foo" }), + }); + const skills = await discoverManagedSkills({ + skillsFolderRelPath: SKILLS_ROOT, + skillsFolderAbsPath: "/abs/vault/copilot/skills", + adapter, + }); + expect(skills).toHaveLength(1); + expect(skills[0].dirPath).toBe("/abs/vault/copilot/skills/foo"); + expect(skills[0].filePath).toBe("/abs/vault/copilot/skills/foo/SKILL.md"); + }); + + it("ignores subdirectories without a SKILL.md", async () => { + const adapter = makeAdapter({ + [`${SKILLS_ROOT}/has-skill/SKILL.md`]: validSkillMd({ name: "has-skill" }), + [`${SKILLS_ROOT}/no-skill/README.md`]: "not a skill", + }); + const skills = await discoverManagedSkills({ + skillsFolderRelPath: SKILLS_ROOT, + skillsFolderAbsPath: null, + adapter, + }); + expect(skills.map((s) => s.name)).toEqual(["has-skill"]); + expect(mockedLogWarn).not.toHaveBeenCalled(); + }); + + it("preserves unknown metadata keys on parsed skills", async () => { + const content = [ + "---", + "name: foo", + "description: A skill", + "metadata:", + " author: alice", + ' copilot-enabled-agents: "claude"', + "---", + "body", + ].join("\n"); + const adapter = makeAdapter({ + [`${SKILLS_ROOT}/foo/SKILL.md`]: content, + }); + const skills = await discoverManagedSkills({ + skillsFolderRelPath: SKILLS_ROOT, + skillsFolderAbsPath: null, + adapter, + }); + // Round-trip preservation is exercised in skillFormat.test.ts; here we + // just verify that an extra metadata key doesn't break discovery. + expect(skills).toHaveLength(1); + expect(skills[0].enabledAgents).toEqual(["claude"]); + }); +}); diff --git a/src/agentMode/skills/discoverManagedSkills.ts b/src/agentMode/skills/discoverManagedSkills.ts new file mode 100644 index 00000000..24510c25 --- /dev/null +++ b/src/agentMode/skills/discoverManagedSkills.ts @@ -0,0 +1,113 @@ +import { logWarn } from "@/logger"; +import { basename, joinPosix } from "@/utils/pathUtils"; +import { parseSkillFile, SkillFormatError } from "./skillFormat"; +import type { Skill } from "./types"; + +/** + * Minimal adapter the discovery walker depends on. Modelled after + * `Vault.adapter`'s shape but reduced to the surface this leaf module + * actually uses, so unit tests can supply a plain object without mocking + * the entire Obsidian surface (see AGENTS.md — "Avoiding Deep Dependency + * Chains in Tests"). + */ +export interface SkillsFsAdapter { + /** Whether the path exists. Vault-relative POSIX paths. */ + exists(relPath: string): Promise; + /** List `{files, folders}` at the given vault-relative POSIX directory. */ + list(relPath: string): Promise<{ files: string[]; folders: string[] }>; + /** Read a UTF-8 file at the given vault-relative POSIX path. */ + read(relPath: string): Promise; +} + +/** + * Options for {@link discoverManagedSkills}. Receives concrete values rather + * than reaching into Obsidian globals so it stays trivially testable. + */ +export interface DiscoverManagedSkillsOptions { + /** Vault-relative POSIX path of the configured skills folder. */ + skillsFolderRelPath: string; + /** + * Absolute path to the same folder on disk. Used to populate + * {@link Skill.dirPath} / {@link Skill.filePath}. Pass `null` when the + * caller has no `FileSystemAdapter` (jsdom test environment, mobile); + * absolute fields fall back to vault-relative paths. + */ + skillsFolderAbsPath: string | null; + /** FS adapter used to walk the folder. */ + adapter: SkillsFsAdapter; +} + +/** + * Walk `//` once and return every SKILL.md that + * parses against the Agent Skills spec. + * + * Subdirectories without a `SKILL.md` are silently ignored — they may be + * staging dirs or supporting-asset folders. Parse failures emit a one-line + * `logWarn` with the path and reason but never throw. + */ +export async function discoverManagedSkills( + options: DiscoverManagedSkillsOptions +): Promise { + const { skillsFolderRelPath, skillsFolderAbsPath, adapter } = options; + + if (!(await adapter.exists(skillsFolderRelPath))) { + return []; + } + + const listing = await adapter.list(skillsFolderRelPath); + const skills: Skill[] = []; + + // Subdirectory paths come back as full vault-relative paths from + // `adapter.list` (e.g. `copilot/skills/foo`). Sort for stable ordering + // so the UI doesn't reshuffle on every reload. + for (const folderPath of [...listing.folders].sort()) { + const dirName = basename(folderPath); + const skillMdRelPath = joinPosix(folderPath, "SKILL.md"); + + let content: string; + try { + content = await adapter.read(skillMdRelPath); + } catch { + // Missing SKILL.md is expected — many subdirs are staging or asset + // folders. Read failure on an existing file is the same surface from + // Obsidian's adapter, so we can't distinguish; either way, skip. + continue; + } + + let parsed; + try { + parsed = parseSkillFile(content, dirName); + } catch (err) { + const reason = + err instanceof SkillFormatError + ? err.message + : err instanceof Error + ? err.message + : String(err); + logWarn(`[skills] Skipping ${skillMdRelPath}: ${reason}`); + continue; + } + + const fm = parsed.frontmatter; + const absDir = + skillsFolderAbsPath !== null ? joinPosix(skillsFolderAbsPath, dirName) : folderPath; + const absFile = joinPosix(absDir, "SKILL.md"); + + skills.push({ + name: fm.name, + description: fm.description, + filePath: absFile, + dirPath: absDir, + body: parsed.body, + license: fm.license, + compatibility: fm.compatibility, + allowedTools: fm.allowedTools, + model: fm.model, + disableModelInvocation: fm.disableModelInvocation, + userInvocable: fm.userInvocable, + enabledAgents: fm.enabledAgents, + }); + } + + return skills; +} diff --git a/src/agentMode/skills/importDetector.test.ts b/src/agentMode/skills/importDetector.test.ts new file mode 100644 index 00000000..7a4ff520 --- /dev/null +++ b/src/agentMode/skills/importDetector.test.ts @@ -0,0 +1,199 @@ +import { detectImportCandidates, type ImportDetectorFs } from "./importDetector"; + +jest.mock("@/logger", () => ({ + logInfo: jest.fn(), + logWarn: jest.fn(), + logError: jest.fn(), +})); + +/** + * Type tag for entries in the in-memory FS. Files carry size; directories + * are containers; symlinks resolve to another absolute path. + */ +type Entry = { kind: "file"; size: number } | { kind: "dir" } | { kind: "symlink"; target: string }; + +/** + * Build an `ImportDetectorFs` over a flat map of `{ absPath → entry }`. + * Parent directories are inferred from each file/dir entry so tests only + * have to declare leaves. + */ +function makeFs(entries: Record): ImportDetectorFs { + const map = new Map(Object.entries(entries)); + // Synthesize directory entries for every ancestor of every path. + for (const fullPath of Array.from(map.keys())) { + const parts = fullPath.split("/"); + for (let i = 1; i < parts.length; i++) { + const ancestor = parts.slice(0, i).join("/"); + if (ancestor.length === 0) continue; + if (!map.has(ancestor)) { + map.set(ancestor, { kind: "dir" }); + } + } + } + + return { + async exists(p) { + return map.has(p); + }, + async isDirectory(p) { + const e = map.get(p); + return e !== undefined && e.kind === "dir"; + }, + async isSymlink(p) { + const e = map.get(p); + return e !== undefined && e.kind === "symlink"; + }, + async readlinkAbs(p) { + const e = map.get(p); + return e !== undefined && e.kind === "symlink" ? e.target : null; + }, + async list(p) { + const prefix = p.replace(/\/+$/, "") + "/"; + const out = new Set(); + for (const k of map.keys()) { + if (!k.startsWith(prefix)) continue; + const rest = k.slice(prefix.length); + if (rest.length === 0) continue; + const first = rest.split("/")[0]; + out.add(first); + } + return Array.from(out); + }, + async statSize(p) { + const e = map.get(p); + return e !== undefined && e.kind === "file" ? e.size : 0; + }, + }; +} + +const VAULT = "/vault"; +const CANONICAL = "/vault/copilot/skills"; +const AGENT_DIRS = { + claude: ".claude/skills", + codex: ".agents/skills", + opencode: ".opencode/skills", +} as const; + +describe("detectImportCandidates", () => { + it("returns empty buckets when no agent dirs exist", async () => { + const fs = makeFs({}); + const result = await detectImportCandidates({ + vaultRootAbsPath: VAULT, + canonicalAbsPath: CANONICAL, + agentDirsProjectRel: AGENT_DIRS, + fs, + }); + expect(result.claude).toEqual([]); + expect(result.codex).toEqual([]); + expect(result.opencode).toEqual([]); + }); + + it("classifies real dirs with SKILL.md as candidates, grouped by agent", async () => { + const fs = makeFs({ + "/vault/.claude/skills/foo/SKILL.md": { kind: "file", size: 100 }, + "/vault/.claude/skills/foo/extra.md": { kind: "file", size: 50 }, + "/vault/.agents/skills/bar/SKILL.md": { kind: "file", size: 200 }, + "/vault/.opencode/skills/baz/SKILL.md": { kind: "file", size: 300 }, + }); + + const result = await detectImportCandidates({ + vaultRootAbsPath: VAULT, + canonicalAbsPath: CANONICAL, + agentDirsProjectRel: AGENT_DIRS, + fs, + }); + + expect(result.claude.map((c) => c.name)).toEqual(["foo"]); + expect(result.claude[0].sourceAgent).toBe("claude"); + expect(result.claude[0].sourcePath).toBe("/vault/.claude/skills/foo"); + expect(result.claude[0].fileCount).toBe(2); + expect(result.claude[0].totalBytes).toBe(150); + + expect(result.codex.map((c) => c.name)).toEqual(["bar"]); + expect(result.codex[0].sourceAgent).toBe("codex"); + + expect(result.opencode.map((c) => c.name)).toEqual(["baz"]); + expect(result.opencode[0].sourceAgent).toBe("opencode"); + }); + + it("creates buckets for dynamically registered backends", async () => { + const fs = makeFs({ + "/vault/.custom/skills/alpha/SKILL.md": { kind: "file", size: 42 }, + }); + + const result = await detectImportCandidates({ + vaultRootAbsPath: VAULT, + canonicalAbsPath: CANONICAL, + agentDirsProjectRel: { ...AGENT_DIRS, custom: ".custom/skills" }, + fs, + }); + + expect(result.custom.map((c) => c.name)).toEqual(["alpha"]); + expect(result.custom[0].sourceAgent).toBe("custom"); + }); + + it("skips dirs without a SKILL.md", async () => { + const fs = makeFs({ + "/vault/.claude/skills/staging/notes.md": { kind: "file", size: 10 }, + }); + const result = await detectImportCandidates({ + vaultRootAbsPath: VAULT, + canonicalAbsPath: CANONICAL, + agentDirsProjectRel: AGENT_DIRS, + fs, + }); + expect(result.claude).toEqual([]); + }); + + it("skips symlinks pointing into the canonical folder", async () => { + const fs = makeFs({ + "/vault/.claude/skills/managed": { + kind: "symlink", + target: "/vault/copilot/skills/managed", + }, + "/vault/copilot/skills/managed/SKILL.md": { kind: "file", size: 10 }, + }); + const result = await detectImportCandidates({ + vaultRootAbsPath: VAULT, + canonicalAbsPath: CANONICAL, + agentDirsProjectRel: AGENT_DIRS, + fs, + }); + expect(result.claude).toEqual([]); + }); + + it("skips symlinks pointing somewhere unrelated", async () => { + const fs = makeFs({ + "/vault/.claude/skills/external": { + kind: "symlink", + target: "/home/user/elsewhere/skill", + }, + }); + const result = await detectImportCandidates({ + vaultRootAbsPath: VAULT, + canonicalAbsPath: CANONICAL, + agentDirsProjectRel: AGENT_DIRS, + fs, + }); + expect(result.claude).toEqual([]); + }); + + it("returns a sorted, well-formed candidate list across mixed inputs", async () => { + const fs = makeFs({ + "/vault/.claude/skills/zeta/SKILL.md": { kind: "file", size: 5 }, + "/vault/.claude/skills/alpha/SKILL.md": { kind: "file", size: 7 }, + "/vault/.claude/skills/managed": { + kind: "symlink", + target: "/vault/copilot/skills/managed", + }, + "/vault/.claude/skills/no-skill-md/something.txt": { kind: "file", size: 9 }, + }); + const result = await detectImportCandidates({ + vaultRootAbsPath: VAULT, + canonicalAbsPath: CANONICAL, + agentDirsProjectRel: AGENT_DIRS, + fs, + }); + expect(result.claude.map((c) => c.name)).toEqual(["alpha", "zeta"]); + }); +}); diff --git a/src/agentMode/skills/importDetector.ts b/src/agentMode/skills/importDetector.ts new file mode 100644 index 00000000..593a622b --- /dev/null +++ b/src/agentMode/skills/importDetector.ts @@ -0,0 +1,194 @@ +import { logWarn } from "@/logger"; +import { joinPosix, resolvesInto } from "@/utils/pathUtils"; +import type { BackendId, ImportCandidate } from "./types"; + +/** + * Subset of `node:fs` the import detector needs. Modeled as a leaf adapter + * so tests can supply an in-memory FS without touching disk or pulling in + * Obsidian (see AGENTS.md — "Avoiding Deep Dependency Chains in Tests"). + * + * Paths are absolute throughout. The detector never resolves paths itself. + */ +export interface ImportDetectorFs { + /** Whether the path exists (any kind). */ + exists(absPath: string): Promise; + /** Whether the path is a directory (real or junction; not a symlink to a file). */ + isDirectory(absPath: string): Promise; + /** Whether the path itself is a symlink/junction (does not follow). */ + isSymlink(absPath: string): Promise; + /** + * Resolve a symlink to an absolute target path. Caller has already + * verified `absPath` is a symlink. Returns `null` if resolution fails. + */ + readlinkAbs(absPath: string): Promise; + /** List immediate entries (files + dirs + symlinks) under `absPath`. */ + list(absPath: string): Promise; + /** Byte size of a single regular file. Returns 0 if it can't be stat'd. */ + statSize(absPath: string): Promise; +} + +/** + * Candidates grouped by source backend id. Keys come from + * `agentDirsProjectRel` — the detector never enumerates backends itself, so + * adding a new agent flows through without edits here. Empty buckets are + * pre-seeded for every passed-in id; callers can iterate + * `Object.entries(result)` without nullish checks. + */ +export type ImportDetectorResult = Record; + +export interface DetectImportsOptions { + /** Absolute path to the vault root. */ + vaultRootAbsPath: string; + /** Absolute path to the configured canonical skills folder. */ + canonicalAbsPath: string; + /** + * Per-agent project-relative skills directory. Built by the host from + * each `BackendDescriptor.skillsProjectDir`; see `SkillManager`. + */ + agentDirsProjectRel: Readonly>; + /** Injected FS adapter (production wires `nodeImportDetectorFs`). */ + fs: ImportDetectorFs; +} + +/** + * Walk each registered backend's skills directory under the vault root + * (paths supplied via `agentDirsProjectRel`, sourced from + * `BackendDescriptor.skillsProjectDir`) and return every immediate + * subdirectory that: + * + * - Is **not** a symlink/junction whose target resolves into the canonical + * skills folder (those are already managed). + * - Contains a `SKILL.md` file (skills only — random folders are ignored). + * + * The walker tolerates missing agent dirs and per-entry IO failures: each + * failure is logged once and skipped so a half-broken vault still produces + * a useful candidate list for the consent card. + */ +export async function detectImportCandidates( + options: DetectImportsOptions +): Promise { + const { vaultRootAbsPath, canonicalAbsPath, agentDirsProjectRel, fs } = options; + + const result = createEmptyImportDetectorResult(agentDirsProjectRel); + + await Promise.all( + Object.entries(agentDirsProjectRel).map(async ([agent, relPath]) => { + const agentDirAbs = joinPosix(vaultRootAbsPath, relPath); + + if (!(await safeExists(fs, agentDirAbs))) return; + if (!(await safeIsDirectory(fs, agentDirAbs))) return; + + let entries: string[]; + try { + entries = await fs.list(agentDirAbs); + } catch (err) { + logWarn( + `[skills] Could not list ${agentDirAbs}: ${err instanceof Error ? err.message : String(err)}` + ); + return; + } + + for (const name of entries.sort()) { + const entryAbs = joinPosix(agentDirAbs, name); + + // Skip symlinks pointing back into the canonical folder — these + // are the fanout links and shouldn't be re-imported. + let isLink = false; + try { + isLink = await fs.isSymlink(entryAbs); + } catch { + // Treat unreadable lstat as "not a link" and let isDirectory decide. + } + if (isLink) { + const target = await fs.readlinkAbs(entryAbs).catch(() => null); + if (target !== null && resolvesInto(target, canonicalAbsPath)) { + continue; + } + // Symlink pointing elsewhere — also skip; reconciliation + // handles the user-owned-symlink case. + continue; + } + + if (!(await safeIsDirectory(fs, entryAbs))) continue; + + const skillMd = joinPosix(entryAbs, "SKILL.md"); + if (!(await safeExists(fs, skillMd))) continue; + + const { fileCount, totalBytes } = await summariseDir(fs, entryAbs); + + result[agent].push({ + name, + sourceAgent: agent, + sourcePath: entryAbs, + fileCount, + totalBytes, + }); + } + }) + ); + + return result; +} + +/** Total candidate count across every registered backend bucket. */ +export function totalCandidates(result: ImportDetectorResult): number { + return Object.values(result).reduce((sum, candidates) => sum + candidates.length, 0); +} + +/** Build an empty detector result with a bucket for every registered backend. */ +export function createEmptyImportDetectorResult( + agentDirsProjectRel: Readonly> +): ImportDetectorResult { + const result: ImportDetectorResult = {}; + for (const agent of Object.keys(agentDirsProjectRel)) { + result[agent] = []; + } + return result; +} + +async function safeExists(fs: ImportDetectorFs, abs: string): Promise { + try { + return await fs.exists(abs); + } catch { + return false; + } +} + +async function safeIsDirectory(fs: ImportDetectorFs, abs: string): Promise { + try { + return await fs.isDirectory(abs); + } catch { + return false; + } +} + +/** + * Shallow walk of `dirAbs`: counts immediate files (not subdirs) and + * accumulates their byte sizes. Errors on individual entries are swallowed + * — the meta line is decorative; partial counts are better than no meta. + */ +async function summariseDir( + fs: ImportDetectorFs, + dirAbs: string +): Promise<{ fileCount: number; totalBytes: number }> { + let fileCount = 0; + let totalBytes = 0; + let entries: string[]; + try { + entries = await fs.list(dirAbs); + } catch { + return { fileCount, totalBytes }; + } + for (const name of entries) { + const entryAbs = joinPosix(dirAbs, name); + const isDir = await safeIsDirectory(fs, entryAbs); + if (isDir) continue; + fileCount += 1; + try { + totalBytes += await fs.statSize(entryAbs); + } catch { + // ignore size failure — keep the count + } + } + return { fileCount, totalBytes }; +} diff --git a/src/agentMode/skills/index.ts b/src/agentMode/skills/index.ts new file mode 100644 index 00000000..793606d8 --- /dev/null +++ b/src/agentMode/skills/index.ts @@ -0,0 +1,60 @@ +export type { Skill, ImportCandidate, BackendId } from "./types"; +export { + parseSkillFile, + serializeSkillFile, + validateName, + validateDescription, + SkillFormatError, +} from "./skillFormat"; +export type { ParsedSkillFile, SkillFrontmatter, SkillFrontmatterPatch } from "./skillFormat"; +export { + SkillManager, + useManagedSkills, + getManagedSkills, + totalCandidates, + useEpermSeen, + dismissEpermBanner, +} from "./SkillManager"; +export type { + DeleteSkillResult, + RefreshResult, + RenameSkillResult, + SkillOperationFailureCode, + SkillOperationResult, + ToggleAgentResult, + UpdatePropertiesResult, +} from "./SkillManager"; +export { reconcile, getAgentDirs } from "./reconcile"; +export type { ReconcileFs, ReconcileOptions, ReconcileReport } from "./reconcile"; +export { agentSkillsDirAbs, DEFAULT_SKILLS_FOLDER } from "./agentPaths"; +export { buildSkillCreationDirective } from "./spawnDirective"; +export { composeDenyList } from "./denyListComposer"; +export { DeleteConfirmModal } from "./ui/DeleteConfirmDialog"; +export { PropertiesModal } from "./ui/PropertiesDialog"; +export type { + PropertiesFormValues, + PropertiesSaveRequest, + PropertiesSaveOutcome, +} from "./ui/PropertiesDialog"; +export { discoverManagedSkills } from "./discoverManagedSkills"; +export { createEmptyImportDetectorResult, detectImportCandidates } from "./importDetector"; +export type { ImportDetectorFs, ImportDetectorResult } from "./importDetector"; +export { runBulkMove } from "./bulkMove"; +export type { + BulkMoveFs, + BulkMoveResult, + BulkMoveRow, + BulkMoveStatus, + BulkMoveOptions, +} from "./bulkMove"; +export { createAgentLink, removeAgentLink, replaceAgentLink } from "./symlinks"; +export type { SymlinksFs, SymlinkResult } from "./symlinks"; +export { suffixOnCollision } from "./suffixOnCollision"; +export { renameWithRetry } from "./renameWithRetry"; +export type { SkillsFsAdapter, DiscoverManagedSkillsOptions } from "./discoverManagedSkills"; +export { AgentIconButton } from "./ui/AgentIconButton"; +export { SkillRow } from "./ui/SkillRow"; +export { EmptyPlaceholder } from "./ui/EmptyPlaceholder"; +export { ImportConsentDialog } from "./ui/ImportConsentDialog"; +export type { ImportPhase } from "./ui/ImportConsentDialog"; +export { SkillsSettings } from "./ui/SkillsSettings"; diff --git a/src/agentMode/skills/nodeFsAdapters.ts b/src/agentMode/skills/nodeFsAdapters.ts new file mode 100644 index 00000000..f15d7bf9 --- /dev/null +++ b/src/agentMode/skills/nodeFsAdapters.ts @@ -0,0 +1,148 @@ +import { logWarn } from "@/logger"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { BulkMoveFs } from "./bulkMove"; +import type { ImportDetectorFs } from "./importDetector"; +import { errCode } from "@/utils/errorUtils"; +import type { ReconcileFs } from "./reconcile"; +import type { SymlinksFs } from "./symlinks"; + +/** + * Production `node:fs`-backed adapter for the bulk-move / symlinks helpers. + * Lives here so the leaf modules stay pure (no `node:fs` import) and the + * orchestrator (`SkillManager`) wires this in at the edge. + * + * All paths must be **absolute**. Symlinks on Windows are created as + * directory junctions (`'junction'`) — `fs.symlink` plain mode requires + * admin/Developer Mode privileges; junctions work for stock users and are + * directory-only (which is exactly what skills need). + */ +export function createNodeBulkMoveFs(): BulkMoveFs { + return { + ...createNodeSymlinksFs(), + async readFile(p) { + return fs.promises.readFile(p, "utf-8"); + }, + async writeFile(p, content) { + await fs.promises.writeFile(p, content, "utf-8"); + }, + async mkdirRecursive(p) { + await fs.promises.mkdir(p, { recursive: true }); + }, + list: (p) => nodeList(p, true), + }; +} + +async function nodeList(p: string, warn = false): Promise { + try { + return await fs.promises.readdir(p); + } catch (err) { + if (warn) { + logWarn(`[skills] readdir ${p} failed: ${err instanceof Error ? err.message : String(err)}`); + } + return []; + } +} + +async function nodeReadlinkAbs(p: string): Promise { + try { + const target = await fs.promises.readlink(p); + return path.isAbsolute(target) ? target : path.resolve(path.dirname(p), target); + } catch { + return null; + } +} + +/** + * Subset of {@link createNodeBulkMoveFs} that satisfies the `SymlinksFs` + * surface. Reused both by bulk-move and by toggle logic. + */ +export function createNodeSymlinksFs(): SymlinksFs { + return { + async exists(p) { + try { + await fs.promises.lstat(p); + return true; + } catch { + return false; + } + }, + async isDirectory(p) { + try { + const st = await fs.promises.stat(p); + return st.isDirectory(); + } catch { + return false; + } + }, + async isSymlink(p) { + try { + const st = await fs.promises.lstat(p); + return st.isSymbolicLink(); + } catch { + return false; + } + }, + async symlink(target, linkPath) { + const type = process.platform === "win32" ? "junction" : "dir"; + // Junctions require an absolute target — caller is contracted to + // pass an absolute path. Resolve defensively anyway. + const absTarget = path.isAbsolute(target) ? target : path.resolve(target); + // Per-agent skill dirs (e.g. `/.codex/skills/`) may not exist + // yet on a fresh vault — `symlink` would fail with ENOENT. mkdir is + // a no-op when the dir already exists. + await fs.promises.mkdir(path.dirname(linkPath), { recursive: true }); + await fs.promises.symlink(absTarget, linkPath, type); + }, + async unlink(p) { + try { + await fs.promises.unlink(p); + } catch (err) { + if (errCode(err) === "ENOENT") return; + throw err; + } + }, + async rmRecursive(p) { + await fs.promises.rm(p, { recursive: true, force: true }); + }, + }; +} + +/** + * Production adapter for {@link detectImportCandidates}. Uses `lstat` to + * detect symlinks portably (Windows junctions still report as symbolic + * links via lstat). + */ +export function createNodeImportDetectorFs(): ImportDetectorFs { + return { + ...createNodeSymlinksFs(), + readlinkAbs: nodeReadlinkAbs, + list: (p) => nodeList(p), + async statSize(p) { + try { + const st = await fs.promises.stat(p); + return st.size; + } catch { + return 0; + } + }, + }; +} + +/** + * Production adapter for {@link reconcile}. Combines the SymlinksFs surface + * with shallow listing + readlink resolution used for orphan sweep. + */ +export function createNodeReconcileFs(): ReconcileFs { + return { + ...createNodeSymlinksFs(), + list: (p) => nodeList(p), + readlinkAbs: nodeReadlinkAbs, + async readFile(p) { + return fs.promises.readFile(p, "utf-8"); + }, + async writeFile(p, content) { + await fs.promises.writeFile(p, content, "utf-8"); + }, + }; +} diff --git a/src/agentMode/skills/reconcile.test.ts b/src/agentMode/skills/reconcile.test.ts new file mode 100644 index 00000000..8519d529 --- /dev/null +++ b/src/agentMode/skills/reconcile.test.ts @@ -0,0 +1,296 @@ +import { reconcile, type ReconcileFs } from "./reconcile"; +import type { Skill } from "./types"; + +jest.mock("@/logger", () => ({ + logInfo: jest.fn(), + logWarn: jest.fn(), + logError: jest.fn(), +})); + +/** + * In-memory FS tailored to the reconcile pass. Stores three kinds of nodes: + * + * - `dir` — real directory. + * - `file` — regular file (only used as filler). + * - `link` — symlink/junction with an absolute target. + * + * Provides the {@link ReconcileFs} surface plus debug accessors. Ancestor + * directories are auto-synthesized on insert. + */ +type Node = { kind: "dir" } | { kind: "file" } | { kind: "link"; target: string }; + +interface TestFs extends ReconcileFs { + __dump(): Record; + __set(path: string, node: Node): void; + __setSymlinkBlocked(blocked: boolean): void; +} + +function mkFs(initial: Record = {}): TestFs { + const map = new Map(); + let symlinkBlocked = false; + + const ensureAncestors = (p: string) => { + const parts = p.split("/"); + for (let i = 1; i < parts.length; i++) { + const a = parts.slice(0, i).join("/"); + if (a.length === 0) continue; + if (!map.has(a)) map.set(a, { kind: "dir" }); + } + }; + + for (const [p, n] of Object.entries(initial)) { + ensureAncestors(p); + map.set(p, n); + } + + const fs: TestFs = { + async exists(p) { + return map.has(p); + }, + async isDirectory(p) { + return map.get(p)?.kind === "dir"; + }, + async isSymlink(p) { + return map.get(p)?.kind === "link"; + }, + async readlinkAbs(p) { + const n = map.get(p); + return n !== undefined && n.kind === "link" ? n.target : null; + }, + async list(p) { + const prefix = p.replace(/\/+$/, "") + "/"; + const out = new Set(); + for (const k of map.keys()) { + if (!k.startsWith(prefix)) continue; + const rest = k.slice(prefix.length); + if (rest.length === 0) continue; + out.add(rest.split("/")[0]); + } + return Array.from(out); + }, + async symlink(target, linkPath) { + if (symlinkBlocked) { + throw Object.assign(new Error("EPERM"), { code: "EPERM" }); + } + if (map.has(linkPath)) { + throw Object.assign(new Error(`EEXIST: ${linkPath}`), { code: "EEXIST" }); + } + ensureAncestors(linkPath); + map.set(linkPath, { kind: "link", target }); + }, + async unlink(p) { + const n = map.get(p); + if (n === undefined) return; + if (n.kind !== "link") return; + map.delete(p); + }, + async rmRecursive(p) { + const prefix = p + "/"; + for (const k of Array.from(map.keys())) { + if (k === p || k.startsWith(prefix)) map.delete(k); + } + }, + async readFile(p) { + const n = map.get(p); + if (n === undefined || n.kind !== "file") { + throw Object.assign(new Error(`ENOENT: ${p}`), { code: "ENOENT" }); + } + // Test fixtures store files as `{ kind: "file" }` markers without + // content — return an empty string. None of the reconcile cases + // need real file contents (it's symlink-only). + return ""; + }, + async writeFile(p, _content) { + ensureAncestors(p); + map.set(p, { kind: "file" }); + }, + __dump() { + return Object.fromEntries(map); + }, + __set(p, n) { + ensureAncestors(p); + map.set(p, n); + }, + __setSymlinkBlocked(blocked) { + symlinkBlocked = blocked; + }, + }; + return fs; +} + +const VAULT = "/vault"; +const CANONICAL = "/vault/copilot/skills"; +const AGENT_DIRS_ABS = { + claude: `${VAULT}/.claude/skills`, + codex: `${VAULT}/.agents/skills`, + opencode: `${VAULT}/.opencode/skills`, +} as const; + +function mkSkill(name: string, enabledAgents: Skill["enabledAgents"] = []): Skill { + return { + name, + description: "A short skill", + filePath: `${CANONICAL}/${name}/SKILL.md`, + dirPath: `${CANONICAL}/${name}`, + body: "body", + enabledAgents, + }; +} + +describe("reconcile", () => { + it("creates a missing symlink for an enabled agent", async () => { + const fs = mkFs({ + [`${CANONICAL}/foo`]: { kind: "dir" }, + [`${CANONICAL}/foo/SKILL.md`]: { kind: "file" }, + }); + const skills = [mkSkill("foo", ["claude"])]; + + const report = await reconcile({ + skills, + canonicalAbsRoot: CANONICAL, + agentDirsAbs: AGENT_DIRS_ABS, + fs, + }); + + expect(report.created).toContain("/vault/.claude/skills/foo"); + expect(report.errors).toEqual([]); + const link = fs.__dump()["/vault/.claude/skills/foo"]; + expect(link).toEqual({ kind: "link", target: `${CANONICAL}/foo` }); + }); + + it("repairs a symlink pointing at the wrong target", async () => { + const fs = mkFs({ + [`${CANONICAL}/foo`]: { kind: "dir" }, + [`${CANONICAL}/foo/SKILL.md`]: { kind: "file" }, + "/vault/.claude/skills/foo": { kind: "link", target: "/somewhere/else" }, + }); + const skills = [mkSkill("foo", ["claude"])]; + + const report = await reconcile({ + skills, + canonicalAbsRoot: CANONICAL, + agentDirsAbs: AGENT_DIRS_ABS, + fs, + }); + + expect(report.errors).toEqual([]); + const link = fs.__dump()["/vault/.claude/skills/foo"]; + expect(link).toEqual({ kind: "link", target: `${CANONICAL}/foo` }); + expect(report.created).toContain("/vault/.claude/skills/foo"); + }); + + it("removes an orphan link pointing into the canonical store", async () => { + const fs = mkFs({ + [`${CANONICAL}/alive`]: { kind: "dir" }, + [`${CANONICAL}/alive/SKILL.md`]: { kind: "file" }, + "/vault/.claude/skills/alive": { kind: "link", target: `${CANONICAL}/alive` }, + // Orphan: link basename has no matching managed skill. + "/vault/.claude/skills/orphan": { kind: "link", target: `${CANONICAL}/orphan` }, + }); + const skills = [mkSkill("alive", ["claude"])]; + + const report = await reconcile({ + skills, + canonicalAbsRoot: CANONICAL, + agentDirsAbs: AGENT_DIRS_ABS, + fs, + }); + + expect(report.removedOrphans).toContain("/vault/.claude/skills/orphan"); + expect(fs.__dump()["/vault/.claude/skills/orphan"]).toBeUndefined(); + // The alive link is left alone. + expect(fs.__dump()["/vault/.claude/skills/alive"]).toEqual({ + kind: "link", + target: `${CANONICAL}/alive`, + }); + }); + + it("never touches a real directory sitting in an agent path", async () => { + const fs = mkFs({ + [`${CANONICAL}/foo`]: { kind: "dir" }, + [`${CANONICAL}/foo/SKILL.md`]: { kind: "file" }, + "/vault/.claude/skills/bar": { kind: "dir" }, + "/vault/.claude/skills/bar/SKILL.md": { kind: "file" }, + }); + const skills = [mkSkill("foo", ["claude"])]; + + const report = await reconcile({ + skills, + canonicalAbsRoot: CANONICAL, + agentDirsAbs: AGENT_DIRS_ABS, + fs, + }); + + // The real dir is untouched. + expect(fs.__dump()["/vault/.claude/skills/bar"]).toEqual({ kind: "dir" }); + expect(fs.__dump()["/vault/.claude/skills/bar/SKILL.md"]).toEqual({ kind: "file" }); + expect(report.removedOrphans).not.toContain("/vault/.claude/skills/bar"); + }); + + it("reports EPERM on creation as an error without crashing", async () => { + const fs = mkFs({ + [`${CANONICAL}/foo`]: { kind: "dir" }, + [`${CANONICAL}/foo/SKILL.md`]: { kind: "file" }, + }); + fs.__setSymlinkBlocked(true); + const skills = [mkSkill("foo", ["claude"])]; + + const report = await reconcile({ + skills, + canonicalAbsRoot: CANONICAL, + agentDirsAbs: AGENT_DIRS_ABS, + fs, + }); + + expect(report.created).toEqual([]); + expect(report.errors).toHaveLength(1); + expect(report.errors[0].path).toBe("/vault/.claude/skills/foo"); + expect(report.errors[0].reason).toBe("eperm"); + // No link landed. + expect(fs.__dump()["/vault/.claude/skills/foo"]).toBeUndefined(); + }); + + it("leaves links pointing outside the canonical store alone", async () => { + const fs = mkFs({ + [`${CANONICAL}/foo`]: { kind: "dir" }, + [`${CANONICAL}/foo/SKILL.md`]: { kind: "file" }, + "/vault/.claude/skills/foo": { kind: "link", target: `${CANONICAL}/foo` }, + // User-owned link to somewhere else — reconciliation must not touch it. + "/vault/.claude/skills/userOwned": { kind: "link", target: "/elsewhere/x" }, + }); + const skills = [mkSkill("foo", ["claude"])]; + + const report = await reconcile({ + skills, + canonicalAbsRoot: CANONICAL, + agentDirsAbs: AGENT_DIRS_ABS, + fs, + }); + + expect(report.removedOrphans).not.toContain("/vault/.claude/skills/userOwned"); + expect(fs.__dump()["/vault/.claude/skills/userOwned"]).toEqual({ + kind: "link", + target: "/elsewhere/x", + }); + }); + + it("handles a missing agent directory by skipping the reverse sweep for that agent", async () => { + const fs = mkFs({ + [`${CANONICAL}/foo`]: { kind: "dir" }, + [`${CANONICAL}/foo/SKILL.md`]: { kind: "file" }, + }); + // No `.claude/skills` directory at all. + const skills = [mkSkill("foo", [])]; + + const report = await reconcile({ + skills, + canonicalAbsRoot: CANONICAL, + agentDirsAbs: AGENT_DIRS_ABS, + fs, + }); + + expect(report.created).toEqual([]); + expect(report.removedOrphans).toEqual([]); + expect(report.errors).toEqual([]); + }); +}); diff --git a/src/agentMode/skills/reconcile.ts b/src/agentMode/skills/reconcile.ts new file mode 100644 index 00000000..96e79992 --- /dev/null +++ b/src/agentMode/skills/reconcile.ts @@ -0,0 +1,207 @@ +import { logWarn } from "@/logger"; +import { basename, joinPosix, normalizeAbsPath, resolvesInto } from "@/utils/pathUtils"; +import { createAgentLink, removeAgentLink, replaceAgentLink, type SymlinksFs } from "./symlinks"; +import type { BackendId, Skill } from "./types"; + +/** + * FS surface for reconciliation. Extends {@link SymlinksFs} with the bare + * directory-listing + symlink-target reading needed to find orphan links. + * + * Modeled as a leaf adapter so tests can pass an in-memory FS without + * touching disk (see AGENTS.md "Avoiding Deep Dependency Chains in Tests"). + */ +export interface ReconcileFs extends SymlinksFs { + /** List immediate entries (files + dirs + symlinks) under `absPath`. */ + list(absPath: string): Promise; + /** + * Resolve a symlink at `absPath` to an absolute target. Caller has + * already verified `absPath` is a symlink. Returns `null` on failure + * (broken link, permission, missing target). + */ + readlinkAbs(absPath: string): Promise; + /** Read a UTF-8 file. Required for toggle/delete paths that share this adapter. */ + readFile(absPath: string): Promise; + /** Write a UTF-8 file (overwriting). */ + writeFile(absPath: string, content: string): Promise; +} + +/** + * Outcome of one reconciliation pass. Lists are absolute paths; `errors` + * each carry a human-readable reason. EPERM surfaces here as a + * `reason: "eperm"` entry (callers render the durable banner from that + * signal). + */ +export interface ReconcileReport { + /** Symlinks newly created or repaired by the pass. */ + created: string[]; + /** Symlinks removed as orphans (no managed skill matches). */ + removedOrphans: string[]; + /** Per-path failures. Never thrown — collected and returned. */ + errors: Array<{ path: string; reason: string }>; +} + +/** Options bag for {@link reconcile}. All paths must be absolute. */ +export interface ReconcileOptions { + /** Managed skills as resolved by the latest discovery pass. */ + skills: Skill[]; + /** Absolute path of the canonical skills folder (e.g. `/copilot/skills`). */ + canonicalAbsRoot: string; + /** + * Absolute path of each registered backend's project-relative skills + * directory under the vault. Built by the host from each + * `BackendDescriptor.skillsProjectDir`; see `SkillManager`. + */ + agentDirsAbs: Readonly>; + /** Injected FS adapter (production wires the node fs adapter). */ + fs: ReconcileFs; +} + +/** + * Single idempotent reconciliation pass. Two phases: + * + * 1. **Forward sync** — for each skill, for each agent in + * `enabledAgents`, ensure the symlink at + * `/./skills/` exists and points at the canonical + * dir. Missing → create. Pointing elsewhere → repair via + * {@link replaceAgentLink}. + * 2. **Reverse sync (orphan removal)** — for each agent path, list + * entries and remove symlinks pointing into `canonicalAbsRoot` whose + * basename is not in the managed skill list. **Real directories are + * never touched** — they're user-owned. + * + * EPERM and other write errors are non-fatal: the pass continues for + * remaining skills and surfaces the failure in {@link ReconcileReport.errors}. + */ +export async function reconcile(options: ReconcileOptions): Promise { + const { skills, canonicalAbsRoot, agentDirsAbs, fs } = options; + + const report: ReconcileReport = { + created: [], + removedOrphans: [], + errors: [], + }; + + // Pre-index managed skills by name for the orphan-removal phase. + const managedNames = new Set(skills.map((s) => s.name)); + + // -- Phase 1: forward sync ---------------------------------------------- + // Each (skill, agent) pair is independent: launch in parallel and merge + // results so a vault with N skills × M agents costs ~max(per-pair latency) + // rather than the sum. + const forwardOps = skills.flatMap((skill) => + skill.enabledAgents.flatMap((agent) => { + const agentDir = agentDirsAbs[agent]; + if (agentDir === undefined) return []; + return [syncOneLink(fs, agentDir, skill)]; + }) + ); + for (const entry of await Promise.all(forwardOps)) { + if (entry.created !== undefined) report.created.push(entry.created); + if (entry.error !== undefined) report.errors.push(entry.error); + } + + // -- Phase 2: reverse sync (orphan removal) ----------------------------- + // List each agent dir in parallel — they're independent. + const agentEntries = await Promise.all( + Object.entries(agentDirsAbs).map(async ([agent, agentDir]) => { + try { + return { agent, agentDir, entries: await fs.list(agentDir) }; + } catch { + return { agent, agentDir, entries: [] as string[] }; + } + }) + ); + + for (const { agent, agentDir, entries } of agentEntries) { + for (const name of entries) { + // Skip aside-during-rename markers used by replaceAgentLink. + if (name.endsWith(".replacing")) continue; + const linkPath = joinPosix(agentDir, name); + + let isLink = false; + try { + isLink = await fs.isSymlink(linkPath); + } catch { + // Treat unreadable lstat as non-link — never delete real dirs. + continue; + } + if (!isLink) continue; + + const target = await fs.readlinkAbs(linkPath); + if (target === null) continue; + + // Only touch links pointing into the canonical store. Anything else + // is user-owned per the design spec. + if (!resolvesInto(target, canonicalAbsRoot)) continue; + + // Determine the expected managed name from the link's basename. Orphan + // = (a) basename has no matching managed skill, or (b) the link's + // basename doesn't match its target's parent dir basename. + const targetBase = basename(target); + const basenameMatches = targetBase === name; + const managed = managedNames.has(name); + // Also ensure the matched managed skill actually enables this agent. + const skill = skills.find((s) => s.name === name); + const agentEnabled = skill !== undefined && skill.enabledAgents.includes(agent); + + if (managed && basenameMatches && agentEnabled) continue; + + try { + await removeAgentLink(fs, agentDir, name); + report.removedOrphans.push(linkPath); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + report.errors.push({ path: linkPath, reason }); + } + } + } + + return report; +} + +/** Identifies agent paths during orphan sweep — exported for tests / docs. */ +export function getAgentDirs( + agentDirsAbs: Readonly> +): Array<{ agent: BackendId; dir: string }> { + return Object.entries(agentDirsAbs).map(([agent, dir]) => ({ agent, dir })); +} + +interface ForwardSyncEntry { + created?: string; + error?: { path: string; reason: string }; +} + +/** Reconcile a single (skill, agent) slot. Returns the report deltas. */ +async function syncOneLink( + fs: ReconcileFs, + agentDir: string, + skill: Skill +): Promise { + const linkPath = joinPosix(agentDir, skill.name); + try { + const exists = await fs.exists(linkPath); + if (!exists) { + const result = await createAgentLink(fs, agentDir, skill.name, skill.dirPath); + return result.ok + ? { created: linkPath } + : { error: { path: linkPath, reason: result.reason } }; + } + + const isLink = await fs.isSymlink(linkPath); + if (!isLink) { + logWarn(`[skills] Refusing to replace real directory at ${linkPath} during reconcile`); + return {}; + } + + const target = await fs.readlinkAbs(linkPath); + if (target !== null && normalizeAbsPath(target) === normalizeAbsPath(skill.dirPath)) { + return {}; + } + + const result = await replaceAgentLink(fs, agentDir, skill.name, skill.dirPath); + return result.ok ? { created: linkPath } : { error: { path: linkPath, reason: result.reason } }; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + return { error: { path: linkPath, reason } }; + } +} diff --git a/src/agentMode/skills/renameWithRetry.ts b/src/agentMode/skills/renameWithRetry.ts new file mode 100644 index 00000000..50940388 --- /dev/null +++ b/src/agentMode/skills/renameWithRetry.ts @@ -0,0 +1,29 @@ +import * as fs from "node:fs"; + +/** + * Retry-aware `fs.rename`. Windows commonly fails the first attempt when + * Obsidian's vault watcher, OneDrive / Dropbox / iCloud, or AV hold an + * open handle on either side; a brief wait + retry usually clears it. + * + * Lives under `skills/` (not `backends/opencode/`) so the skills layer can + * reuse it without violating the import-direction rules: backends are + * allowed to import skills, but not vice versa. The Opencode binary + * installer imports this from its new home. + * + * @param from Source absolute path. + * @param to Destination absolute path. + * @param attempts Total attempts (default 3). + */ +export async function renameWithRetry(from: string, to: string, attempts = 3): Promise { + let lastErr: unknown; + for (let i = 0; i < attempts; i++) { + try { + await fs.promises.rename(from, to); + return; + } catch (e) { + lastErr = e; + await new Promise((r) => window.setTimeout(r, 200)); + } + } + throw lastErr; +} diff --git a/src/agentMode/skills/skillFormat.test.ts b/src/agentMode/skills/skillFormat.test.ts new file mode 100644 index 00000000..f034fce5 --- /dev/null +++ b/src/agentMode/skills/skillFormat.test.ts @@ -0,0 +1,213 @@ +import { + parseSkillFile, + serializeSkillFile, + SkillFormatError, + validateDescription, + validateName, +} from "./skillFormat"; + +const minimalSkill = (overrides: Record = {}) => { + const fm = { + name: "review-prose", + description: "Critique writing for clarity, voice, and rhythm.", + ...overrides, + }; + const yaml = Object.entries(fm) + .map(([k, v]) => `${k}: ${v}`) + .join("\n"); + return `---\n${yaml}\n---\nbody text`; +}; + +describe("parseSkillFile — happy path", () => { + it("parses a valid SKILL.md", () => { + const parsed = parseSkillFile(minimalSkill(), "review-prose"); + expect(parsed.frontmatter.name).toBe("review-prose"); + expect(parsed.frontmatter.description).toBe("Critique writing for clarity, voice, and rhythm."); + expect(parsed.frontmatter.enabledAgents).toEqual([]); + expect(parsed.body).toBe("body text"); + }); + + it("reads Claude-only top-level flags", () => { + const content = [ + "---", + "name: foo", + "description: A skill", + "model: claude-opus-4-7", + "disable-model-invocation: true", + "user-invocable: false", + "allowed-tools: Read Grep", + "---", + "body", + ].join("\n"); + const parsed = parseSkillFile(content, "foo"); + expect(parsed.frontmatter.model).toBe("claude-opus-4-7"); + expect(parsed.frontmatter.disableModelInvocation).toBe(true); + expect(parsed.frontmatter.userInvocable).toBe(false); + expect(parsed.frontmatter.allowedTools).toBe("Read Grep"); + }); + + it("reads metadata.copilot-enabled-agents as a comma-separated list", () => { + const content = [ + "---", + "name: foo", + "description: A skill", + "metadata:", + ' copilot-enabled-agents: "claude,opencode"', + "---", + "body", + ].join("\n"); + const parsed = parseSkillFile(content, "foo"); + expect(parsed.frontmatter.enabledAgents).toEqual(["claude", "opencode"]); + }); + + it("treats an empty copilot-enabled-agents value as empty", () => { + const content = [ + "---", + "name: foo", + "description: A skill", + "metadata:", + ' copilot-enabled-agents: ""', + "---", + "", + ].join("\n"); + const parsed = parseSkillFile(content, "foo"); + expect(parsed.frontmatter.enabledAgents).toEqual([]); + }); +}); + +describe("parseSkillFile — validation errors", () => { + it("rejects uppercase names", () => { + expect(() => parseSkillFile(minimalSkill({ name: "ReviewProse" }), "ReviewProse")).toThrow( + /lowercase/ + ); + }); + + it("rejects leading hyphen", () => { + expect(() => parseSkillFile(minimalSkill({ name: "-foo" }), "-foo")).toThrow(/leading/); + }); + + it("rejects trailing hyphen", () => { + expect(() => parseSkillFile(minimalSkill({ name: "foo-" }), "foo-")).toThrow(/trailing/); + }); + + it("rejects consecutive hyphens", () => { + expect(() => parseSkillFile(minimalSkill({ name: "foo--bar" }), "foo--bar")).toThrow( + /consecutive/ + ); + }); + + it("rejects parent-dir mismatch", () => { + expect(() => parseSkillFile(minimalSkill({ name: "foo" }), "bar")).toThrow( + /match the parent directory name/ + ); + }); + + it("rejects names longer than 64 characters", () => { + const longName = "a".repeat(65); + expect(() => parseSkillFile(minimalSkill({ name: longName }), longName)).toThrow(/64/); + }); + + it("rejects descriptions longer than 1024 characters", () => { + const longDesc = "x".repeat(1025); + expect(() => parseSkillFile(minimalSkill({ description: longDesc }), "review-prose")).toThrow( + /1024/ + ); + }); + + it("rejects an empty description", () => { + // build by hand to avoid YAML interpreting the empty value as null + const content = ["---", "name: foo", 'description: ""', "---", ""].join("\n"); + expect(() => parseSkillFile(content, "foo")).toThrow(/non-empty/); + }); + + it("rejects a missing frontmatter block", () => { + expect(() => parseSkillFile("no frontmatter here", "foo")).toThrow(/frontmatter/); + }); + + it("rejects missing name", () => { + const content = ["---", "description: A skill", "---", ""].join("\n"); + expect(() => parseSkillFile(content, "foo")).toThrow(/missing required field `name`/); + }); +}); + +describe("serializeSkillFile — round-trip preservation", () => { + it("preserves unknown top-level keys byte-equal", () => { + const content = [ + "---", + "name: foo", + "description: A skill", + "custom-top-level: keep me", + "another-foreign: 42", + "---", + "body", + ].join("\n"); + const parsed = parseSkillFile(content, "foo"); + const out = serializeSkillFile(parsed); + expect(out).toContain("custom-top-level: keep me"); + expect(out).toContain("another-foreign: 42"); + }); + + it("preserves unknown metadata.* keys byte-equal", () => { + const content = [ + "---", + "name: foo", + "description: A skill", + "metadata:", + " author: alice", + " version: 2", + ' copilot-enabled-agents: "claude"', + "---", + "body", + ].join("\n"); + const parsed = parseSkillFile(content, "foo"); + const out = serializeSkillFile(parsed); + expect(out).toContain("author: alice"); + expect(out).toContain("version: 2"); + expect(out).toContain('copilot-enabled-agents: "claude"'); + }); + + it("updates enabledAgents while preserving foreign metadata keys", () => { + const content = [ + "---", + "name: foo", + "description: A skill", + "metadata:", + " author: alice", + ' copilot-enabled-agents: "claude"', + "---", + "body", + ].join("\n"); + const parsed = parseSkillFile(content, "foo"); + const out = serializeSkillFile(parsed, { enabledAgents: ["claude", "opencode"] }); + expect(out).toContain("author: alice"); + expect(out).toContain("copilot-enabled-agents:"); + expect(out).toContain("claude,opencode"); + }); + + it("round-trips with no changes produces identical frontmatter shape", () => { + const parsed = parseSkillFile(minimalSkill(), "review-prose"); + const out = serializeSkillFile(parsed); + const reparsed = parseSkillFile(out, "review-prose"); + expect(reparsed.frontmatter.name).toBe("review-prose"); + expect(reparsed.frontmatter.description).toBe( + "Critique writing for clarity, voice, and rhythm." + ); + expect(reparsed.body).toBe("body text"); + }); +}); + +describe("validateName + validateDescription unit-level", () => { + it("validateName accepts valid spec names", () => { + expect(() => validateName("review-prose", "review-prose")).not.toThrow(); + expect(() => validateName("a", "a")).not.toThrow(); + expect(() => validateName("a-b-c", "a-b-c")).not.toThrow(); + }); + + it("validateDescription accepts a normal description", () => { + expect(() => validateDescription("A short description.")).not.toThrow(); + }); + + it("validateDescription rejects empty", () => { + expect(() => validateDescription("")).toThrow(SkillFormatError); + }); +}); diff --git a/src/agentMode/skills/skillFormat.ts b/src/agentMode/skills/skillFormat.ts new file mode 100644 index 00000000..691303de --- /dev/null +++ b/src/agentMode/skills/skillFormat.ts @@ -0,0 +1,271 @@ +import { Document, parseDocument, YAMLMap, isMap, isScalar, Scalar } from "yaml"; +import type { BackendId } from "./types"; + +/** + * Strict frontmatter regex used by `splitFrontmatter`. Requires the file + * to begin with `---` on its own line and end the frontmatter with `---` + * on its own line. CRLF tolerated. Body may be empty. + */ +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/; + +/** Spec rule: lowercase a–z / 0–9 / hyphens; no leading/trailing/consecutive hyphens; 1–64 chars. */ +export const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/; +export const NAME_MAX = 64; +export const DESCRIPTION_MAX = 1024; + +/** + * Result of parsing a SKILL.md file. The Document is preserved so + * `serializeSkillFile` can round-trip unknown keys byte-equal. + */ +export interface ParsedSkillFile { + /** The validated and conveniently-typed frontmatter view. */ + frontmatter: SkillFrontmatter; + /** Markdown body after the closing `---`. */ + body: string; + /** + * The underlying YAML Document. Carries comments, ordering, and + * unknown keys (top-level and inside `metadata`). Mutate via + * `applyFrontmatterChanges` if you need to edit; otherwise leave it + * alone and re-serialize as-is. + */ + doc: Document.Parsed; +} + +/** Typed view over the SKILL.md frontmatter. Optional fields stay `undefined` when absent. */ +export interface SkillFrontmatter { + name: string; + description: string; + license?: string; + compatibility?: string; + /** Space-separated string as it appears in frontmatter. Not split. */ + allowedTools?: string; + /** Claude Code-only top-level field. */ + model?: string; + /** Claude Code-only top-level field. */ + disableModelInvocation?: boolean; + /** Claude Code-only top-level field (kebab-case `user-invocable`). */ + userInvocable?: boolean; + /** Sourced from `metadata.copilot-enabled-agents`. Empty string in the file → empty array. */ + enabledAgents: BackendId[]; +} + +/** + * Split `---\n…\n---\n` into the YAML chunk and the body. + * Throws when the frontmatter block is missing or malformed. + */ +function splitFrontmatter(content: string): { yaml: string; body: string } { + const m = FRONTMATTER_RE.exec(content); + if (!m) { + throw new SkillFormatError( + "SKILL.md must begin with a YAML frontmatter block delimited by ---" + ); + } + return { yaml: m[1] ?? "", body: m[2] ?? "" }; +} + +/** Domain error type — carries a human-readable message suitable for surfacing in the Skills tab. */ +export class SkillFormatError extends Error { + constructor(message: string) { + super(message); + this.name = "SkillFormatError"; + } +} + +/** Read a top-level string scalar from a YAML Document, or return undefined. */ +function readString(doc: Document.Parsed, key: string): string | undefined { + const v = doc.get(key); + return typeof v === "string" ? v : undefined; +} + +/** Read a top-level boolean scalar from a YAML Document, or return undefined. */ +function readBoolean(doc: Document.Parsed, key: string): boolean | undefined { + const v = doc.get(key); + return typeof v === "boolean" ? v : undefined; +} + +/** Read `metadata.copilot-enabled-agents` as a comma-separated string → trimmed BackendId list. */ +function readEnabledAgents(doc: Document.Parsed): BackendId[] { + const metadata = doc.get("metadata"); + if (!isMap(metadata)) return []; + const raw = metadata.get("copilot-enabled-agents"); + if (typeof raw !== "string" || raw.trim().length === 0) return []; + return raw + .split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0); +} + +/** + * Parse a SKILL.md file's text content and validate against the spec. + * + * @param content Full file contents (frontmatter + body). + * @param parentDirName The on-disk parent directory's basename. The spec + * requires this to match `frontmatter.name` exactly — pass it in + * rather than re-reading the FS so this function stays a pure leaf + * module (see AGENTS.md "Avoiding Deep Dependency Chains in Tests"). + * @returns Parsed + validated representation suitable for round-tripping. + * @throws SkillFormatError when validation fails. + */ +export function parseSkillFile(content: string, parentDirName: string): ParsedSkillFile { + const { yaml, body } = splitFrontmatter(content); + const doc = parseDocument(yaml, { keepSourceTokens: true }); + + if (doc.errors.length > 0) { + throw new SkillFormatError(`SKILL.md frontmatter YAML is invalid: ${doc.errors[0].message}`); + } + if (!isMap(doc.contents)) { + throw new SkillFormatError("SKILL.md frontmatter must be a YAML mapping"); + } + + const name = readString(doc, "name"); + if (!name) { + throw new SkillFormatError("SKILL.md frontmatter is missing required field `name`"); + } + validateName(name, parentDirName); + + const description = readString(doc, "description"); + if (description === undefined) { + throw new SkillFormatError("SKILL.md frontmatter is missing required field `description`"); + } + validateDescription(description); + + const frontmatter: SkillFrontmatter = { + name, + description, + license: readString(doc, "license"), + compatibility: readString(doc, "compatibility"), + allowedTools: readString(doc, "allowed-tools"), + model: readString(doc, "model"), + disableModelInvocation: readBoolean(doc, "disable-model-invocation"), + userInvocable: readBoolean(doc, "user-invocable"), + enabledAgents: readEnabledAgents(doc), + }; + + return { frontmatter, body, doc }; +} + +/** + * Validate a skill `name` against the agentskills.io spec rules plus + * the parent-directory-match requirement. + * + * @throws SkillFormatError with a descriptive message. + */ +export function validateName(name: string, parentDirName: string): void { + if (typeof name !== "string" || name.length === 0) { + throw new SkillFormatError("Skill `name` must be a non-empty string"); + } + if (name.length > NAME_MAX) { + throw new SkillFormatError( + `Skill \`name\` must be at most ${NAME_MAX} characters (got ${name.length})` + ); + } + if (!NAME_RE.test(name)) { + throw new SkillFormatError( + `Skill \`name\` must be lowercase a–z, 0–9, and hyphens with no leading, trailing, or consecutive hyphens (got "${name}")` + ); + } + if (name !== parentDirName) { + throw new SkillFormatError( + `Skill \`name\` ("${name}") must match the parent directory name ("${parentDirName}")` + ); + } +} + +/** + * Validate a skill `description` against the spec — non-empty and + * ≤1024 characters. + * + * @throws SkillFormatError when invalid. + */ +export function validateDescription(description: string): void { + if (typeof description !== "string" || description.length === 0) { + throw new SkillFormatError("Skill `description` must be a non-empty string"); + } + if (description.length > DESCRIPTION_MAX) { + throw new SkillFormatError( + `Skill \`description\` must be at most ${DESCRIPTION_MAX} characters (got ${description.length})` + ); + } +} + +/** + * Partial frontmatter mutation for `serializeSkillFile`. Pass only the + * keys you want to update; unspecified keys keep their existing on-disk + * value (and ordering). `enabledAgents`, if provided, replaces the + * value at `metadata.copilot-enabled-agents`; other `metadata.*` keys + * are left untouched. + */ +export interface SkillFrontmatterPatch { + name?: string; + description?: string; + license?: string; + compatibility?: string; + allowedTools?: string; + model?: string; + disableModelInvocation?: boolean; + userInvocable?: boolean; + enabledAgents?: BackendId[]; +} + +/** Set a value on the doc, or remove the key if the value is `undefined`. */ +function setOrDelete(doc: Document.Parsed, key: string, value: string | boolean | undefined): void { + if (value === undefined) { + if (doc.has(key)) doc.delete(key); + } else { + doc.set(key, value); + } +} + +/** + * Re-serialize a parsed SKILL.md back to disk text, optionally applying + * a patch. Unknown top-level keys and unknown `metadata.*` keys are + * preserved byte-equal (the YAML Document carries them); known keys + * are updated in place to preserve ordering. + */ +export function serializeSkillFile( + parsed: ParsedSkillFile, + patch: SkillFrontmatterPatch = {} +): string { + const { doc, body } = parsed; + + if ("name" in patch) setOrDelete(doc, "name", patch.name); + if ("description" in patch) setOrDelete(doc, "description", patch.description); + if ("license" in patch) setOrDelete(doc, "license", patch.license); + if ("compatibility" in patch) setOrDelete(doc, "compatibility", patch.compatibility); + if ("allowedTools" in patch) setOrDelete(doc, "allowed-tools", patch.allowedTools); + if ("model" in patch) setOrDelete(doc, "model", patch.model); + if ("disableModelInvocation" in patch) + setOrDelete(doc, "disable-model-invocation", patch.disableModelInvocation); + if ("userInvocable" in patch) setOrDelete(doc, "user-invocable", patch.userInvocable); + + if (patch.enabledAgents !== undefined) { + setEnabledAgents(doc, patch.enabledAgents); + } + + const yamlText = doc.toString().replace(/\n+$/, ""); + return `---\n${yamlText}\n---\n${body}`; +} + +/** + * Update `metadata.copilot-enabled-agents` to the given list, creating + * the `metadata` map if absent. Preserves every other `metadata.*` key. + */ +function setEnabledAgents(doc: Document.Parsed, agents: BackendId[]): void { + let metadata = doc.get("metadata"); + if (!isMap(metadata)) { + metadata = new YAMLMap(); + doc.set("metadata", metadata); + } + const map = metadata as YAMLMap; + const value = agents.join(","); + // Use a string scalar so empty values still emit `""` rather than `null`. + const scalar = new Scalar(value); + // Preserve existing quoting style when present. + const existing = map.get("copilot-enabled-agents", true); + if (isScalar(existing)) { + scalar.type = existing.type ?? scalar.type; + } else if (value.length === 0) { + scalar.type = "QUOTE_DOUBLE"; + } + map.set("copilot-enabled-agents", scalar); +} diff --git a/src/agentMode/skills/spawnDirective.test.ts b/src/agentMode/skills/spawnDirective.test.ts new file mode 100644 index 00000000..eb50d57d --- /dev/null +++ b/src/agentMode/skills/spawnDirective.test.ts @@ -0,0 +1,91 @@ +import { buildSkillCreationDirective } from "./spawnDirective"; + +const AGENT_DIRS = [".claude/skills", ".agents/skills", ".opencode/skills"]; + +describe("buildSkillCreationDirective", () => { + it("templates the claude agent + default folder", () => { + const directive = buildSkillCreationDirective("claude", "copilot/skills", AGENT_DIRS); + expect(directive).toContain("/copilot/skills//SKILL.md"); + expect(directive).toContain('metadata.copilot-enabled-agents: "claude"'); + expect(directive).toContain("Do not write"); + expect(directive).toContain(".claude/skills/"); + expect(directive).toContain(".agents/skills/"); + expect(directive).toContain(".opencode/skills/"); + expect(directive).toMatch(/Skills-tab\s+reconciliation/); + }); + + it("templates the codex agent", () => { + const directive = buildSkillCreationDirective("codex", "copilot/skills", AGENT_DIRS); + expect(directive).toContain('metadata.copilot-enabled-agents: "codex"'); + expect(directive).toContain("/copilot/skills//SKILL.md"); + }); + + it("templates the opencode agent", () => { + const directive = buildSkillCreationDirective("opencode", "copilot/skills", AGENT_DIRS); + expect(directive).toContain('metadata.copilot-enabled-agents: "opencode"'); + expect(directive).toContain("/copilot/skills//SKILL.md"); + }); + + it("templates a non-default skills folder", () => { + const directive = buildSkillCreationDirective("claude", "team-skills", AGENT_DIRS); + expect(directive).toContain("/team-skills//SKILL.md"); + // Default folder must not leak when the caller supplies a custom one. + expect(directive).not.toContain("copilot/skills"); + }); + + it("templates nested folders", () => { + const directive = buildSkillCreationDirective("opencode", "shared/team-skills", AGENT_DIRS); + expect(directive).toContain("/shared/team-skills//SKILL.md"); + }); + + it("has no leading or trailing whitespace", () => { + const directive = buildSkillCreationDirective("claude", "copilot/skills", AGENT_DIRS); + expect(directive).toBe(directive.trim()); + }); + + it("warns against writing into the symlink locations", () => { + // Verbatim from the spec — the directive must explicitly tell the agent + // that the agent-specific paths are managed by Copilot. + const directive = buildSkillCreationDirective("claude", "copilot/skills", AGENT_DIRS); + expect(directive).toMatch( + /Do not write[\s\S]*\.claude\/skills\/[\s\S]*\.agents\/skills\/[\s\S]*\.opencode\/skills\// + ); + }); + + it("reflects the injected agent-dir list (no hard-coded paths)", () => { + const directive = buildSkillCreationDirective("claude", "copilot/skills", [ + ".custom-a/skills", + ".custom-b/skills", + ]); + expect(directive).toContain(".custom-a/skills/"); + expect(directive).toContain(".custom-b/skills/"); + expect(directive).not.toContain(".claude/skills/"); + }); + + // Regression: the previous directive said "at minimum `name`, `description`, + // and `metadata.copilot-enabled-agents: \"\"`", which Claude + // interpreted as a minimum *value set* and proceeded to copy + // `"claude,opencode"` from a sibling skill verbatim. The new wording must + // describe the value as exact and single, and forbid copying. + describe("locks copilot-enabled-agents to the authoring agent", () => { + it("asserts the value is exactly the authoring agent", () => { + const directive = buildSkillCreationDirective("claude", "copilot/skills", AGENT_DIRS); + expect(directive).toMatch(/MUST be exactly[\s\S]*"claude"/); + expect(directive).toMatch(/only the agent creating the skill/i); + expect(directive).toMatch(/do not add other agents/i); + }); + + it("forbids preserving the prior value when copying an existing skill", () => { + const directive = buildSkillCreationDirective("opencode", "copilot/skills", AGENT_DIRS); + expect(directive).toMatch(/copying or adapting an existing[\s\S]*overwrite/i); + expect(directive).toMatch(/do not preserve the[\s\S]*prior value/i); + }); + + it('does not use the ambiguous phrase "at minimum" near the enabled-agents field', () => { + const directive = buildSkillCreationDirective("claude", "copilot/skills", AGENT_DIRS); + // "at minimum" was the load-bearing loophole — guard against it + // creeping back in anywhere in the directive. + expect(directive.toLowerCase()).not.toContain("at minimum"); + }); + }); +}); diff --git a/src/agentMode/skills/spawnDirective.ts b/src/agentMode/skills/spawnDirective.ts new file mode 100644 index 00000000..aa2e1e04 --- /dev/null +++ b/src/agentMode/skills/spawnDirective.ts @@ -0,0 +1,59 @@ +import { withTrailingSlash } from "@/utils/pathUtils"; +import type { BackendId } from "./types"; + +/** + * Build the one-line spawn-time directive that steers each backend to write + * agent-authored skills into the managed canonical folder (instead of an + * agent-specific path like `.claude/skills/`). Composed into whatever + * existing system-prompt / instructions surface each backend supports at + * spawn time — see spec §Decisions captured + * ("Spawn-time system prompt steers skill creation into the managed folder"). + * + * The folder is templated from `agentMode.skills.folder` and must be read + * live at spawn time so a settings change takes effect on the next session. + * Pure leaf module — no Obsidian imports, no singletons, suitable for unit + * testing with plain arguments. + * + * @param agent Authoring agent's kebab-case backend id. The + * resulting skill is pre-enabled for **only** + * this agent via + * `metadata.copilot-enabled-agents: ""` + * — an exact single-item value, never additive, + * even when adapting an existing skill that + * lists multiple agents. + * @param skillsFolder Vault-relative POSIX path of the canonical + * skills folder (e.g. `"copilot/skills"`). + * @param agentSkillsDirs Project-relative skills directory of every + * registered backend (e.g. `.claude/skills`, + * `.agents/skills`, `.opencode/skills`). Listed in + * the directive so the agent knows which managed + * symlink locations to avoid writing into. + * @returns The directive string, ready to append to a system prompt / + * instructions field. No leading or trailing newlines. + */ +export function buildSkillCreationDirective( + agent: BackendId, + skillsFolder: string, + agentSkillsDirs: readonly string[] +): string { + const managedList = agentSkillsDirs.map((d) => `\`${withTrailingSlash(d)}\``).join(", "); + return ( + `When the user asks you to create a skill, write\n` + + `/${skillsFolder}//SKILL.md with valid Agent\n` + + `Skills spec frontmatter — required fields: \`name\`, \`description\`,\n` + + `and \`metadata.copilot-enabled-agents: "${agent}"\`.\n` + + `\n` + + `The \`metadata.copilot-enabled-agents\` value MUST be exactly\n` + + `"${agent}" — only the agent creating the skill, and nothing else.\n` + + `Do not add other agents. If you are copying or adapting an existing\n` + + `skill, overwrite this field with "${agent}"; do not preserve the\n` + + `prior value, even if the source skill lists multiple agents.\n` + + `Enabling additional agents is a user action performed later in the\n` + + `Skills tab.\n` + + `\n` + + `Do not write into ${managedList} —\n` + + `those are symlink locations managed by Copilot; the symlink for\n` + + `this agent will be created automatically on the next Skills-tab\n` + + `reconciliation.` + ); +} diff --git a/src/agentMode/skills/suffixOnCollision.test.ts b/src/agentMode/skills/suffixOnCollision.test.ts new file mode 100644 index 00000000..1783fab0 --- /dev/null +++ b/src/agentMode/skills/suffixOnCollision.test.ts @@ -0,0 +1,31 @@ +import { suffixOnCollision } from "./suffixOnCollision"; + +describe("suffixOnCollision", () => { + it("returns the original name when there is no collision", () => { + expect(suffixOnCollision("foo", new Set())).toBe("foo"); + expect(suffixOnCollision("foo", new Set(["bar"]))).toBe("foo"); + }); + + it("appends -2 on the first collision", () => { + expect(suffixOnCollision("foo", new Set(["foo"]))).toBe("foo-2"); + }); + + it("keeps incrementing until a free name is found", () => { + expect(suffixOnCollision("foo", new Set(["foo", "foo-2", "foo-3"]))).toBe("foo-4"); + }); + + it("returns a spec-valid name for any suffix index", () => { + const name = suffixOnCollision("review-prose", new Set(["review-prose"])); + expect(name).toBe("review-prose-2"); + expect(/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)).toBe(true); + }); + + it("appends a new -N suffix even when the source already ends in -N", () => { + expect(suffixOnCollision("foo-2", new Set(["foo-2"]))).toBe("foo-2-2"); + }); + + it("throws when no suffix fits within the 64-char cap", () => { + const longName = "a".repeat(63); // "a-2" would already be 65 chars + expect(() => suffixOnCollision(longName, new Set([longName]))).toThrow(); + }); +}); diff --git a/src/agentMode/skills/suffixOnCollision.ts b/src/agentMode/skills/suffixOnCollision.ts new file mode 100644 index 00000000..0809a536 --- /dev/null +++ b/src/agentMode/skills/suffixOnCollision.ts @@ -0,0 +1,35 @@ +/** + * Spec rule: lowercase a–z / 0–9 / hyphens; no leading/trailing/consecutive + * hyphens; 1–64 chars. Mirrors the validator in `skillFormat.ts` so a + * suffixed name is guaranteed to satisfy `validateName`. + */ +const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/; +const NAME_MAX = 64; + +/** + * Compute the smallest spec-valid name not present in `taken`. Returns + * `name` if free; otherwise `name-2`, `name-3`, … until a free one is + * found. The original name is appended-to verbatim — we do NOT try to + * detect existing `-N` suffixes and increment them (e.g. `foo-2` colliding + * becomes `foo-2-2`, not `foo-3`). That keeps the rule mechanical and + * predictable; the spec doesn't distinguish. + * + * Throws if no spec-valid suffix fits within the 64-char cap, which only + * happens for pathologically long source names. + */ +export function suffixOnCollision(name: string, taken: Set): string { + if (!taken.has(name)) return name; + for (let i = 2; i < 1_000_000; i++) { + const candidate = `${name}-${i}`; + if (candidate.length > NAME_MAX) { + throw new Error(`Cannot suffix "${name}" without exceeding the ${NAME_MAX}-char name cap.`); + } + if (!NAME_RE.test(candidate)) { + // Defensive: should be unreachable for any spec-valid `name`, but + // guard anyway in case a malformed `name` slipped through. + continue; + } + if (!taken.has(candidate)) return candidate; + } + throw new Error(`Could not find a free suffix for "${name}".`); +} diff --git a/src/agentMode/skills/symlinks.ts b/src/agentMode/skills/symlinks.ts new file mode 100644 index 00000000..3fc92b0c --- /dev/null +++ b/src/agentMode/skills/symlinks.ts @@ -0,0 +1,199 @@ +import { logWarn } from "@/logger"; +import { errCode } from "@/utils/errorUtils"; +import { joinPosix } from "@/utils/pathUtils"; +import { renameWithRetry } from "./renameWithRetry"; + +/** + * Subset of `node:fs` used by the symlink helpers. Modeled as a small + * adapter so tests can pass an in-memory FS without touching disk. + * + * The Windows EPERM fallback lives at this seam: implementations should + * throw an Error whose `code` is `"EPERM"` when `fs.symlink()` fails for + * lack of privilege, and the helpers translate that into the typed + * `{ ok: false, reason: 'eperm' }` result. + */ +export interface SymlinksFs { + /** Whether the path exists (any kind). */ + exists(absPath: string): Promise; + /** Whether the path is a directory (real or junction). */ + isDirectory(absPath: string): Promise; + /** Whether the path itself is a symlink/junction. */ + isSymlink(absPath: string): Promise; + /** + * Create a directory symlink (POSIX) or junction (Windows) at `linkPath` + * pointing at the **absolute** `target`. May throw with `code: "EPERM"`. + */ + symlink(target: string, linkPath: string): Promise; + /** Remove a symlink/junction at `absPath`. No-op if it doesn't exist. */ + unlink(absPath: string): Promise; + /** Recursively remove a real directory at `absPath`. */ + rmRecursive(absPath: string): Promise; +} + +/** + * Result discriminator for symlink ops. The Windows-EPERM-without-Developer-Mode + * path is surfaced as a typed value rather than a raw throw so callers can + * render the one-time banner without try/catch boilerplate. + */ +export type SymlinkResult = { ok: true } | { ok: false; reason: "eperm"; message: string }; + +/** + * Create a symlink at `/` pointing at `canonicalAbs` (must + * be absolute). If the path already exists, the caller wants + * {@link replaceAgentLink} instead — this helper assumes the slot is empty. + */ +export async function createAgentLink( + fs: SymlinksFs, + agentDir: string, + name: string, + canonicalAbs: string +): Promise { + const linkPath = joinPosix(agentDir, name); + try { + await fs.symlink(canonicalAbs, linkPath); + return { ok: true }; + } catch (err) { + const code = errCode(err); + if (code === "EPERM" || code === "EACCES") { + const message = err instanceof Error ? err.message : String(err); + logWarn(`[skills] symlink EPERM at ${linkPath}: ${message}`); + return { ok: false, reason: "eperm", message }; + } + throw err; + } +} + +/** + * Remove the symlink at `/`. If the entry is a real + * directory (not a symlink/junction), this is a no-op — we never delete + * user-owned directories. Missing entry is also a no-op. + */ +export async function removeAgentLink( + fs: SymlinksFs, + agentDir: string, + name: string +): Promise { + const linkPath = joinPosix(agentDir, name); + if (!(await fs.exists(linkPath))) return; + + let isLink = false; + try { + isLink = await fs.isSymlink(linkPath); + } catch { + return; + } + + if (!isLink) { + // Real directory — don't touch it. + logWarn(`[skills] Refusing to remove real directory at ${linkPath}`); + return; + } + + try { + await fs.unlink(linkPath); + } catch (err) { + logWarn( + `[skills] Failed to unlink ${linkPath}: ${err instanceof Error ? err.message : String(err)}` + ); + } +} + +/** + * Atomic-replace the entry at `/` with a symlink pointing + * at `canonicalAbs`. + * + * - If nothing sits there, create the link directly. + * - If a symlink/junction sits there, unlink it and recreate. (Some + * platforms allow direct overwrite; doing it in two steps is portable.) + * - If a **real directory** sits there (e.g. a partial move from an + * aborted run), rename it aside to `..replacing`, create the + * link, then delete the aside dir. If link creation fails, the aside + * dir is renamed back so the user never loses data. + * + * Returns `{ ok: false, reason: 'eperm' }` when symlink creation fails + * for lack of Windows privilege. In that case the original directory is + * restored if we'd moved it aside. + */ +export async function replaceAgentLink( + fs: SymlinksFs, + agentDir: string, + name: string, + canonicalAbs: string +): Promise { + const linkPath = joinPosix(agentDir, name); + + if (!(await fs.exists(linkPath))) { + return createAgentLink(fs, agentDir, name, canonicalAbs); + } + + let isLink = false; + try { + isLink = await fs.isSymlink(linkPath); + } catch { + isLink = false; + } + + if (isLink) { + try { + await fs.unlink(linkPath); + } catch (err) { + logWarn( + `[skills] Failed to unlink stale entry at ${linkPath}: ${err instanceof Error ? err.message : String(err)}` + ); + } + return createAgentLink(fs, agentDir, name, canonicalAbs); + } + + // Real directory case — move aside, link, delete aside. + const asidePath = joinPosix(agentDir, `.${name}.replacing`); + + // If a previous aborted run left an aside dir behind, clear it first + // so the rename doesn't EEXIST. + if (await fs.exists(asidePath)) { + try { + await fs.rmRecursive(asidePath); + } catch (err) { + logWarn( + `[skills] Could not remove stale aside ${asidePath}: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + + try { + await renameWithRetry(linkPath, asidePath); + } catch (err) { + logWarn( + `[skills] Could not move ${linkPath} aside: ${err instanceof Error ? err.message : String(err)}` + ); + throw err; + } + + const linkResult = await createAgentLink(fs, agentDir, name, canonicalAbs); + if (!linkResult.ok) { + // Restore the original so we leave no half-finished state behind. + try { + await renameWithRetry(asidePath, linkPath); + } catch (restoreErr) { + logWarn( + `[skills] Failed to restore ${linkPath} from aside after EPERM: ${ + restoreErr instanceof Error ? restoreErr.message : String(restoreErr) + }` + ); + } + return linkResult; + } + + // Link is in place — delete the moved-aside real dir. + try { + await fs.rmRecursive(asidePath); + } catch (err) { + // The link is live; leftover aside dir is cosmetic. Log and move on. + logWarn( + `[skills] Failed to clean aside ${asidePath} after relink: ${ + err instanceof Error ? err.message : String(err) + }` + ); + } + + return { ok: true }; +} diff --git a/src/agentMode/skills/toggleAgent.test.ts b/src/agentMode/skills/toggleAgent.test.ts new file mode 100644 index 00000000..9dbd2266 --- /dev/null +++ b/src/agentMode/skills/toggleAgent.test.ts @@ -0,0 +1,257 @@ +import { runDeleteSkill, runToggleAgent, type ToggleAgentFs } from "./toggleAgent"; +import type { Skill } from "./types"; + +jest.mock("@/logger", () => ({ + logInfo: jest.fn(), + logWarn: jest.fn(), + logError: jest.fn(), +})); + +// `replaceAgentLink` uses `renameWithRetry` internally on the real-dir +// branch. None of these tests hit that branch (no real dir at the link +// slot), so the helper isn't exercised. Stub it anyway so we don't pull +// in `node:fs` from inside Jest. +jest.mock("./renameWithRetry", () => ({ + __esModule: true, + renameWithRetry: jest.fn(async () => { + throw new Error("renameWithRetry was not expected in toggleAgent tests"); + }), +})); + +type Node = { kind: "dir" } | { kind: "file"; content: string } | { kind: "link"; target: string }; + +function mkFs(initial: Record = {}): ToggleAgentFs & { + __dump(): Record; + __setSymlinkBlocked(blocked: boolean): void; +} { + const map = new Map(); + let symlinkBlocked = false; + + const ensureAncestors = (p: string) => { + const parts = p.split("/"); + for (let i = 1; i < parts.length; i++) { + const a = parts.slice(0, i).join("/"); + if (a.length === 0) continue; + if (!map.has(a)) map.set(a, { kind: "dir" }); + } + }; + + for (const [p, n] of Object.entries(initial)) { + ensureAncestors(p); + map.set(p, n); + } + + return { + async exists(p) { + return map.has(p); + }, + async isDirectory(p) { + return map.get(p)?.kind === "dir"; + }, + async isSymlink(p) { + return map.get(p)?.kind === "link"; + }, + async symlink(target, linkPath) { + if (symlinkBlocked) { + throw Object.assign(new Error("EPERM: operation not permitted"), { code: "EPERM" }); + } + if (map.has(linkPath)) { + throw Object.assign(new Error(`EEXIST: ${linkPath}`), { code: "EEXIST" }); + } + ensureAncestors(linkPath); + map.set(linkPath, { kind: "link", target }); + }, + async unlink(p) { + const n = map.get(p); + if (n === undefined) return; + if (n.kind !== "link") return; + map.delete(p); + }, + async rmRecursive(p) { + const prefix = p + "/"; + for (const k of Array.from(map.keys())) { + if (k === p || k.startsWith(prefix)) map.delete(k); + } + }, + async readFile(p) { + const n = map.get(p); + if (n === undefined || n.kind !== "file") { + throw Object.assign(new Error(`ENOENT: ${p}`), { code: "ENOENT" }); + } + return n.content; + }, + async writeFile(p, content) { + ensureAncestors(p); + map.set(p, { kind: "file", content }); + }, + __dump() { + return Object.fromEntries(map); + }, + __setSymlinkBlocked(blocked) { + symlinkBlocked = blocked; + }, + }; +} + +const VAULT = "/vault"; +const CANON = "/vault/copilot/skills"; +const CLAUDE_DIR = `${VAULT}/.claude/skills`; +const CODEX_DIR = `${VAULT}/.agents/skills`; +const OPENCODE_DIR = `${VAULT}/.opencode/skills`; +const AGENT_DIRS_ABS = { + claude: CLAUDE_DIR, + codex: CODEX_DIR, + opencode: OPENCODE_DIR, +} as const; + +const SKILL_MD = (name: string, agents = ""): string => + [ + "---", + `name: ${name}`, + "description: A short skill.", + "metadata:", + ` copilot-enabled-agents: "${agents}"`, + "---", + "body", + ].join("\n"); + +function mkSkill(name: string, enabledAgents: Skill["enabledAgents"] = []): Skill { + return { + name, + description: "A short skill.", + filePath: `${CANON}/${name}/SKILL.md`, + dirPath: `${CANON}/${name}`, + body: "body", + enabledAgents, + }; +} + +describe("runToggleAgent", () => { + it("turns Claude on: stamps frontmatter and creates the symlink", async () => { + const fs = mkFs({ + [`${CANON}/foo/SKILL.md`]: { kind: "file", content: SKILL_MD("foo") }, + }); + + const result = await runToggleAgent({ + skill: mkSkill("foo", []), + agent: "claude", + enabled: true, + agentDirAbs: CLAUDE_DIR, + fs, + }); + + expect(result).toEqual({ ok: true }); + const skillMd = fs.__dump()[`${CANON}/foo/SKILL.md`]; + expect(skillMd.kind).toBe("file"); + expect((skillMd as { kind: "file"; content: string }).content).toMatch( + /copilot-enabled-agents:\s*"?claude"?/ + ); + expect(fs.__dump()["/vault/.claude/skills/foo"]).toEqual({ + kind: "link", + target: `${CANON}/foo`, + }); + }); + + it("turns Claude off: removes the link and updates frontmatter", async () => { + const fs = mkFs({ + [`${CANON}/foo/SKILL.md`]: { kind: "file", content: SKILL_MD("foo", "claude") }, + "/vault/.claude/skills/foo": { kind: "link", target: `${CANON}/foo` }, + }); + + const result = await runToggleAgent({ + skill: mkSkill("foo", ["claude"]), + agent: "claude", + enabled: false, + agentDirAbs: CLAUDE_DIR, + fs, + }); + + expect(result).toEqual({ ok: true }); + expect(fs.__dump()["/vault/.claude/skills/foo"]).toBeUndefined(); + const skillMd = fs.__dump()[`${CANON}/foo/SKILL.md`]; + expect((skillMd as { kind: "file"; content: string }).content).not.toMatch(/claude/); + }); + + it("EPERM on symlink: returns eperm, frontmatter still updated, no link created", async () => { + const fs = mkFs({ + [`${CANON}/foo/SKILL.md`]: { kind: "file", content: SKILL_MD("foo") }, + }); + fs.__setSymlinkBlocked(true); + + const result = await runToggleAgent({ + skill: mkSkill("foo", []), + agent: "claude", + enabled: true, + agentDirAbs: CLAUDE_DIR, + fs, + }); + + expect(result.ok).toBe(false); + if (result.ok === false) expect(result.reason).toBe("eperm"); + // Frontmatter still reflects the new agent — reconciliation will heal later. + const skillMd = fs.__dump()[`${CANON}/foo/SKILL.md`]; + expect((skillMd as { kind: "file"; content: string }).content).toMatch( + /copilot-enabled-agents:\s*"?claude"?/ + ); + // No link was created. + expect(fs.__dump()["/vault/.claude/skills/foo"]).toBeUndefined(); + }); + + it("re-enabling an already enabled agent is idempotent", async () => { + const fs = mkFs({ + [`${CANON}/foo/SKILL.md`]: { kind: "file", content: SKILL_MD("foo", "claude") }, + "/vault/.claude/skills/foo": { kind: "link", target: `${CANON}/foo` }, + }); + + const result = await runToggleAgent({ + skill: mkSkill("foo", ["claude"]), + agent: "claude", + enabled: true, + agentDirAbs: CLAUDE_DIR, + fs, + }); + + expect(result).toEqual({ ok: true }); + expect(fs.__dump()["/vault/.claude/skills/foo"]).toEqual({ + kind: "link", + target: `${CANON}/foo`, + }); + }); +}); + +describe("runDeleteSkill", () => { + it("removes the canonical dir and every enabled agent's link", async () => { + const fs = mkFs({ + [`${CANON}/foo/SKILL.md`]: { kind: "file", content: SKILL_MD("foo", "claude,opencode") }, + "/vault/.claude/skills/foo": { kind: "link", target: `${CANON}/foo` }, + "/vault/.opencode/skills/foo": { kind: "link", target: `${CANON}/foo` }, + }); + + const result = await runDeleteSkill({ + skill: mkSkill("foo", ["claude", "opencode"]), + agentDirsAbs: AGENT_DIRS_ABS, + fs, + }); + + expect(result).toEqual({ ok: true }); + expect(fs.__dump()[`${CANON}/foo`]).toBeUndefined(); + expect(fs.__dump()[`${CANON}/foo/SKILL.md`]).toBeUndefined(); + expect(fs.__dump()["/vault/.claude/skills/foo"]).toBeUndefined(); + expect(fs.__dump()["/vault/.opencode/skills/foo"]).toBeUndefined(); + }); + + it("removes only the canonical dir when no agents are enabled", async () => { + const fs = mkFs({ + [`${CANON}/foo/SKILL.md`]: { kind: "file", content: SKILL_MD("foo") }, + }); + + const result = await runDeleteSkill({ + skill: mkSkill("foo", []), + agentDirsAbs: AGENT_DIRS_ABS, + fs, + }); + + expect(result).toEqual({ ok: true }); + expect(fs.__dump()[`${CANON}/foo`]).toBeUndefined(); + }); +}); diff --git a/src/agentMode/skills/toggleAgent.ts b/src/agentMode/skills/toggleAgent.ts new file mode 100644 index 00000000..599aea6c --- /dev/null +++ b/src/agentMode/skills/toggleAgent.ts @@ -0,0 +1,141 @@ +import { logError, logWarn } from "@/logger"; +import { parseSkillFile, serializeSkillFile } from "./skillFormat"; +import { removeAgentLink, replaceAgentLink, type SymlinkResult } from "./symlinks"; +import type { BackendId, Skill } from "./types"; + +/** + * FS surface for {@link runToggleAgent}. Mirrors the read/write subset of + * {@link ReconcileFs} that's actually needed for a toggle pass — kept + * separate so tests can construct a tiny in-memory adapter without + * pulling in the full reconcile fixture. + */ +export interface ToggleAgentFs { + /** Does the path exist (any kind)? */ + exists(absPath: string): Promise; + /** Is the path a directory (real or junction)? */ + isDirectory(absPath: string): Promise; + /** Is the path itself a symlink/junction? */ + isSymlink(absPath: string): Promise; + /** Create a directory symlink/junction. May throw with code "EPERM". */ + symlink(target: string, linkPath: string): Promise; + /** Remove a symlink/junction. No-op when missing. */ + unlink(absPath: string): Promise; + /** Recursively remove a real directory. */ + rmRecursive(absPath: string): Promise; + /** Read a UTF-8 file. */ + readFile(absPath: string): Promise; + /** Write a UTF-8 file (overwriting). */ + writeFile(absPath: string, content: string): Promise; +} + +/** Discriminated result identical to {@link SymlinkResult}, re-exported for callers. */ +export type ToggleAgentResult = { ok: true } | { ok: false; reason: string }; + +/** Options for {@link runToggleAgent}. */ +export interface ToggleAgentOptions { + skill: Skill; + agent: BackendId; + enabled: boolean; + /** Absolute path of this agent's project skills directory. */ + agentDirAbs: string; + fs: ToggleAgentFs; +} + +/** + * Pure-fs core of `SkillManager.toggleAgent`. Two steps: + * + * 1. Rewrite `metadata.copilot-enabled-agents` in the canonical SKILL.md. + * On failure, return early — the source of truth never goes stale. + * 2. Create or remove the agent's symlink. EPERM bubbles up as + * `{ ok: false, reason: 'eperm' }`; the frontmatter is **not** rolled + * back so reconciliation can heal the link on a future pass once the + * user enables Windows Developer Mode. + */ +export async function runToggleAgent(options: ToggleAgentOptions): Promise { + const { skill, agent, enabled, agentDirAbs: agentDir, fs } = options; + + const nextAgents = computeNextAgents(skill.enabledAgents, agent, enabled); + + // Step 1 — update SKILL.md (source of truth). + try { + const raw = await fs.readFile(skill.filePath); + const parsed = parseSkillFile(raw, skill.name); + const stamped = serializeSkillFile(parsed, { enabledAgents: nextAgents }); + await fs.writeFile(skill.filePath, stamped); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + logError(`[skills] toggleAgent: failed to rewrite ${skill.filePath}`, err); + return { ok: false, reason }; + } + + // Step 2 — create / remove the symlink. + if (enabled) { + const result: SymlinkResult = await replaceAgentLink(fs, agentDir, skill.name, skill.dirPath); + if (!result.ok) return { ok: false, reason: result.reason }; + return { ok: true }; + } + + try { + await removeAgentLink(fs, agentDir, skill.name); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + logWarn(`[skills] toggleAgent: failed to remove link: ${reason}`); + return { ok: false, reason }; + } + return { ok: true }; +} + +/** Options for {@link runDeleteSkill}. */ +export interface DeleteSkillOptions { + skill: Skill; + /** Absolute path of each registered agent's project skills directory. */ + agentDirsAbs: Readonly>; + fs: ToggleAgentFs; +} + +/** + * Pure-fs core of `SkillManager.deleteSkill`. Removes every enabled + * agent's symlink first (so any orphan-sweep race sees no dangling + * links), then rms the canonical directory recursively. Symlink-removal + * failures are non-fatal — they're logged but don't block the rm. + */ +export async function runDeleteSkill( + options: DeleteSkillOptions +): Promise<{ ok: boolean; reason?: string }> { + const { skill, agentDirsAbs, fs } = options; + + await Promise.all( + skill.enabledAgents.map(async (agent) => { + const agentDir = agentDirsAbs[agent]; + if (agentDir === undefined) return; + try { + await removeAgentLink(fs, agentDir, skill.name); + } catch (err) { + logWarn( + `[skills] deleteSkill: failed to remove link for ${agent}: ${err instanceof Error ? err.message : String(err)}` + ); + } + }) + ); + + try { + await fs.rmRecursive(skill.dirPath); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + logError(`[skills] deleteSkill: failed to remove ${skill.dirPath}`, err); + return { ok: false, reason }; + } + return { ok: true }; +} + +/** + * Compute the new `enabledAgents` list when toggling `agent`. Preserves + * the order of unaffected agents so the canonical SKILL.md rewrite stays + * diff-friendly. + */ +function computeNextAgents(current: BackendId[], agent: BackendId, enabled: boolean): BackendId[] { + const has = current.includes(agent); + if (enabled && !has) return [...current, agent]; + if (!enabled && has) return current.filter((a) => a !== agent); + return current; +} diff --git a/src/agentMode/skills/types.ts b/src/agentMode/skills/types.ts new file mode 100644 index 00000000..60c7e624 --- /dev/null +++ b/src/agentMode/skills/types.ts @@ -0,0 +1,57 @@ +import type { BackendId } from "@/agentMode/session/types"; + +export type { BackendId }; + +/** + * Canonical in-memory shape of a managed skill, derived from a + * `SKILL.md` file's frontmatter plus on-disk location. + * + * The shape is intentionally close to the agentskills.io spec; the + * Copilot-specific fanout (`enabledAgents`) is sourced from + * `metadata.copilot-enabled-agents` on the file and is the source of + * truth for which agent project dirs should hold a symlink. + */ +export interface Skill { + /** Spec-validated skill name (matches parent dir; 1–64 chars; `^[a-z0-9]+(-[a-z0-9]+)*$`). */ + name: string; + /** Spec-required description, 1–1024 chars. */ + description: string; + /** Absolute path to the canonical SKILL.md file. */ + filePath: string; + /** Absolute path to the canonical skill directory (parent of SKILL.md). */ + dirPath: string; + /** Body of SKILL.md after the frontmatter block. */ + body: string; + /** Optional spec field. */ + license?: string; + /** Optional spec field. */ + compatibility?: string; + /** Spec experimental + Claude-native; space-separated list as the literal string from frontmatter. */ + allowedTools?: string; + /** Claude Code-only: model override. Honored by Claude's loader. */ + model?: string; + /** Claude Code-only: when true, Claude cannot auto-invoke the skill. Defaults to false. */ + disableModelInvocation?: boolean; + /** Claude Code-only (kebab-case top-level): when false, Copilot hides the skill from invocation surfaces. */ + userInvocable?: boolean; + /** Source of truth for symlink fanout — agents whose project dir should hold a link. */ + enabledAgents: BackendId[]; +} + +/** + * A candidate for bulk import — a real directory living under + * `./skills//` that is not yet a symlink into the + * canonical store. Populated by the import-detection walker. + */ +export interface ImportCandidate { + /** Original folder name (also the proposed canonical name before suffixing). */ + name: string; + /** Source agent — drives both the consent-card grouping and the initial `enabledAgents`. */ + sourceAgent: BackendId; + /** Absolute path to the source directory (the one that will be moved). */ + sourcePath: string; + /** Number of files in the source directory (shallow walk, includes SKILL.md). */ + fileCount: number; + /** Total bytes across those files. Powers the "3 files · 4.2 KB" meta. */ + totalBytes: number; +} diff --git a/src/agentMode/skills/ui/AgentIconButton.tsx b/src/agentMode/skills/ui/AgentIconButton.tsx new file mode 100644 index 00000000..912c2bd3 --- /dev/null +++ b/src/agentMode/skills/ui/AgentIconButton.tsx @@ -0,0 +1,71 @@ +import { cn } from "@/lib/utils"; +import React from "react"; + +interface AgentIconButtonProps { + /** + * Brand glyph for this agent. Sourced from the backend descriptor + * (`BackendDescriptor.Icon`) and threaded down through the host so the + * skills layer never names specific agents. + */ + Icon: React.ComponentType<{ className?: string }>; + /** Backend id — used to derive the default aria-label. */ + agentId: string; + /** Backend display name — preferred for the title tooltip when none is provided. */ + agentName?: string; + /** Toggled-on state — filled brand colour vs. dashed outline. */ + enabled: boolean; + /** + * Hard-disabled — used when a skill carries a Claude-only flag that the + * agent silently ignores (e.g. Codex on a `disable-model-invocation` + * skill). Click is suppressed and the button is dimmed. + */ + disabled?: boolean; + onClick?: () => void; + title?: string; + size?: "sm" | "md"; +} + +/** Brand-coloured agent toggle button. */ +export const AgentIconButton: React.FC = ({ + Icon, + agentId, + agentName, + enabled, + disabled = false, + onClick, + title, + size = "md", +}) => { + const handleClick = () => { + if (!disabled) onClick?.(); + }; + const label = agentName ?? agentId; + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleClick(); + } + }} + title={title} + aria-pressed={enabled} + aria-disabled={disabled} + aria-label={title ?? `${enabled ? "Disable" : "Enable"} ${label}`} + className={cn( + "tw-flex tw-items-center tw-justify-center tw-transition-transform", + "hover:tw--translate-y-px", + size === "md" ? "tw-size-[26px] tw-rounded-[7px]" : "tw-size-5 tw-rounded-[5px]", + enabled + ? "tw-bg-interactive-accent tw-text-on-accent" + : "tw-border tw-border-dashed tw-border-[var(--text-faint)] tw-bg-primary tw-text-faint tw-opacity-60 hover:tw-opacity-100", + disabled && "tw-pointer-events-none tw-cursor-not-allowed tw-opacity-50" + )} + > + +
+ ); +}; diff --git a/src/agentMode/skills/ui/DeleteConfirmDialog.tsx b/src/agentMode/skills/ui/DeleteConfirmDialog.tsx new file mode 100644 index 00000000..702bd717 --- /dev/null +++ b/src/agentMode/skills/ui/DeleteConfirmDialog.tsx @@ -0,0 +1,144 @@ +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { logError } from "@/logger"; +import { createPluginRoot } from "@/utils/react/createPluginRoot"; +import { AlertTriangle } from "lucide-react"; +import { App, Modal } from "obsidian"; +import React from "react"; +import { Root } from "react-dom/client"; +import type { BackendId, Skill } from "@/agentMode/skills/types"; + +/** + * Body of the delete confirmation modal. Enumerates every concrete path + * that will be removed (canonical dir + each agent symlink currently in + * `copilot-enabled-agents`) so the user can verify the blast radius + * before confirming. Mirrors wireframe state G. + */ +const DeleteConfirmBody: React.FC<{ + skill: Skill; + skillsFolderRel: string; + agentDirsProjectRel: Readonly>; + onCancel: () => void; + onConfirm: () => void; +}> = ({ skill, skillsFolderRel, agentDirsProjectRel, onCancel, onConfirm }) => { + const folder = skillsFolderRel.replace(/\/+$/, ""); + const paths = collectDeletePaths(skill, folder, agentDirsProjectRel); + + return ( +
+
+
+ +
+
Will remove:
+
    + {paths.map(({ path, note }) => ( +
  • + + + {path} + {note !== null && ( + + {note} + + )} + +
  • + ))} +
+
+ +
+ + +
+
+ ); +}; + +/** + * Native Obsidian delete confirmation modal for a managed skill. Built on + * Obsidian's `Modal` for popout-window safety, native header chrome, and + * ESC handling — consistent with the rest of the plugin's confirm flows + * (see `src/components/modals/ConfirmModal.tsx`). + */ +export class DeleteConfirmModal extends Modal { + private root: Root | null = null; + + constructor( + app: App, + private readonly skill: Skill, + private readonly skillsFolderRel: string, + private readonly agentDirsProjectRel: Readonly>, + private readonly onConfirm: () => void | Promise + ) { + super(app); + // https://docs.obsidian.md/Reference/TypeScript+API/Modal/setTitle + // @ts-ignore + this.setTitle(`Delete ${skill.name}?`); + } + + onOpen() { + this.root = createPluginRoot(this.contentEl, this.app); + this.root.render( + this.close()} + onConfirm={() => { + const result = this.onConfirm(); + if (result instanceof Promise) { + result.catch((err) => logError("DeleteConfirmModal onConfirm failed", err)); + } + this.close(); + }} + /> + ); + } + + onClose() { + this.root?.unmount(); + this.root = null; + } +} + +/** + * Build the bullet list shown in the body — canonical dir first, then + * one line per enabled agent's symlink. Lines are intentionally limited + * to the paths that will actually be removed by `SkillManager.deleteSkill`. + */ +function collectDeletePaths( + skill: Skill, + folder: string, + agentDirsProjectRel: Readonly> +): Array<{ path: string; note: string | null }> { + const out: Array<{ path: string; note: string | null }> = []; + out.push({ + path: `${folder}/${skill.name}/`, + note: "canonical SKILL.md and supporting files", + }); + for (const agent of skill.enabledAgents) { + const dir = agentDirsProjectRel[agent]; + if (dir === undefined) continue; + out.push({ path: `${dir}/${skill.name}`, note: "symlink" }); + } + return out; +} diff --git a/src/agentMode/skills/ui/EmptyPlaceholder.tsx b/src/agentMode/skills/ui/EmptyPlaceholder.tsx new file mode 100644 index 00000000..ca39188e --- /dev/null +++ b/src/agentMode/skills/ui/EmptyPlaceholder.tsx @@ -0,0 +1,46 @@ +import { cn } from "@/lib/utils"; +import { LayoutGrid } from "lucide-react"; +import React from "react"; + +interface EmptyPlaceholderProps { + /** + * The currently configured skills folder, vault-relative. Rendered in + * the hint line so the user can verify exactly where Copilot is looking. + */ + folder: string; +} + +/** + * The empty state for the Skills tab — shown when discovery returns zero + * managed skills. Mirrors §A of `Skills Tab Flows.html`. + * + * There is no "New skill" CTA on purpose; users hand-author skills or import + * them via the consent card. + */ +export const EmptyPlaceholder: React.FC = ({ folder }) => { + return ( +
+
+
+
No skills yet
+
+ Skills you create or import will show up here. +
+
+ canonical home · <vault>/{folder}/ +
+
+ ); +}; diff --git a/src/agentMode/skills/ui/ImportConsentDialog.tsx b/src/agentMode/skills/ui/ImportConsentDialog.tsx new file mode 100644 index 00000000..e6521c1e --- /dev/null +++ b/src/agentMode/skills/ui/ImportConsentDialog.tsx @@ -0,0 +1,506 @@ +import { AgentIconButton } from "./AgentIconButton"; +import type { BulkMoveResult, BulkMoveRow } from "@/agentMode/skills/bulkMove"; +import { totalCandidates, type ImportDetectorResult } from "@/agentMode/skills/importDetector"; +import type { ImportCandidate } from "@/agentMode/skills/types"; +import type { AgentBrand, BackendId } from "@/agentMode/session/types"; +import { Button } from "@/components/ui/button"; +import { withTrailingSlash } from "@/utils/pathUtils"; +import { createPluginRoot } from "@/utils/react/createPluginRoot"; +import { Loader2 } from "lucide-react"; +import { App, Modal } from "obsidian"; +import React, { useEffect, useRef } from "react"; +import { Root } from "react-dom/client"; + +/** + * Phase of the import consent flow. Mirrors wireframe states B (consent) + * and C (results) plus a running spinner between them. + */ +export type ImportPhase = "consent" | "running" | "results"; + +interface ImportConsentDialogProps { + /** Controls the modal open state. */ + open: boolean; + /** Called when the modal wants to close (ESC, overlay click, X). */ + onOpenChange: (open: boolean) => void; + /** Current dialog phase. */ + phase: ImportPhase; + /** + * Brand projection of every registered backend, supplied by the host. + * Drives the per-agent groupings and the brand glyph rendered in each + * group header. The dialog never enumerates backends itself. + */ + agents: ReadonlyArray; + /** Candidates from the detector, grouped by source agent. */ + candidates: ImportDetectorResult; + /** Filled in when phase === "results". */ + results: BulkMoveResult | null; + /** Configured canonical folder — interpolated into the results headline. */ + folder: string; + /** + * Project-relative skills directory per registered backend, sourced from + * each `BackendDescriptor.skillsProjectDir`. Used to label source groups + * and the failure footnote so the dialog matches the live registry rather + * than hard-coded paths. + */ + agentDirsProjectRel: Readonly>; + /** Primary action — runs the bulk move. */ + onConfirm: () => void; + /** Secondary action — closes without moving. */ + onDismiss: () => void; + /** Done action on the results screen. */ + onDone: () => void; + /** + * Open a SKILL.md (absolute path) in Obsidian's editor — wired to the + * same opener as the skills grid so a failed-import row can offer an + * "Edit SKILL.md" affordance. + */ + onEditSkillMd: (absPath: string) => void; +} + +type PhaseRenderProps = Omit; + +/** + * Consent + results modal for the bulk import flow. Matches the + * wireframe B (consent) and C (results) states in + * `tmp/skills-design/copilot-skills-settings/project/Skills Tab Flows.html`. + * + * Rendered on Obsidian's native {@link Modal} per the layer rules in + * `src/agentMode/CLAUDE.md`: popout-window safety, native header chrome, + * and ESC handling come for free. The single modal instance is kept open + * across phase transitions so the surface feels like it's evolving. + */ +export const ImportConsentDialog: React.FC = (props) => { + const { open } = props; + const modalRef = useRef(null); + + // Latest props for the modal's close callback — captured via ref so we + // don't tear down and reopen the modal whenever a handler identity + // changes. + const propsRef = useRef(props); + propsRef.current = props; + + // Mount / unmount the underlying Obsidian modal in response to `open`. + useEffect(() => { + if (open && modalRef.current === null) { + const modal = new ImportConsentObsidianModal(app, () => { + propsRef.current.onOpenChange(false); + }); + modalRef.current = modal; + modal.open(); + modal.renderPhase(propsRef.current); + } else if (!open && modalRef.current !== null) { + const modal = modalRef.current; + modalRef.current = null; + modal.closeWithoutCallback(); + } + }, [open]); + + // Re-render content (and re-title) on prop change while the modal is open. + useEffect(() => { + if (modalRef.current !== null) { + modalRef.current.renderPhase(props); + } + }); + + // Close the modal on unmount so a remounted tab doesn't leak the surface. + useEffect(() => { + return () => { + if (modalRef.current !== null) { + const modal = modalRef.current; + modalRef.current = null; + modal.closeWithoutCallback(); + } + }; + }, []); + + return null; +}; + +/** + * Native Obsidian modal shell for the import flow. Owns its own React + * root and exposes `renderPhase` so the React wrapper can drive title + + * body updates without rebuilding the modal across phase transitions. + */ +class ImportConsentObsidianModal extends Modal { + private root: Root | null = null; + private suppressRequestClose = false; + + constructor( + app: App, + private readonly requestClose: () => void + ) { + super(app); + } + + onOpen(): void { + this.root = createPluginRoot(this.contentEl, this.app); + } + + onClose(): void { + this.root?.unmount(); + this.root = null; + if (!this.suppressRequestClose) { + this.requestClose(); + } + } + + /** + * Close the modal without firing the `requestClose` callback. Used when + * the React controller is closing the modal in response to its own + * `open` prop flipping false — the parent already knows about the state + * change, so re-notifying would re-enter `handleOpenChange` and call + * the dismiss handler after a successful `Done`. + */ + closeWithoutCallback(): void { + this.suppressRequestClose = true; + this.close(); + } + + renderPhase(props: PhaseRenderProps): void { + if (this.root === null) return; + const title = computeTitle(props); + // @ts-expect-error — setTitle exists on Modal but is missing from @types/obsidian. + this.setTitle(title); + + if (props.phase === "consent") { + this.root.render( + + ); + return; + } + if (props.phase === "running") { + this.root.render(); + return; + } + if (props.results !== null) { + this.root.render( + + ); + } + } +} + +function computeTitle(props: PhaseRenderProps): string { + if (props.phase === "consent") { + return "You already have some skills in this vault"; + } + if (props.phase === "running") { + const total = totalCandidates(props.candidates); + return `Moving ${total} ${total === 1 ? "skill" : "skills"}…`; + } + const moved = + props.results === null ? 0 : props.results.results.filter((r) => r.status === "moved").length; + const folder = props.folder.replace(/\/+$/, ""); + return `Moved ${moved} ${moved === 1 ? "skill" : "skills"} into ${folder}/`; +} + +/* --- Consent (state B) ---------------------------------------------- */ + +interface ConsentBodyProps { + agents: ReadonlyArray; + candidates: ImportDetectorResult; + total: number; + agentDirsProjectRel: Readonly>; + onConfirm: () => void; + onDismiss: () => void; +} + +const ConsentBody: React.FC = ({ + agents, + candidates, + total, + agentDirsProjectRel, + onConfirm, + onDismiss, +}) => { + return ( +
+

+ We spotted {total} {total === 1 ? "skill" : "skills"} tucked inside your agent folders. + Copilot can bring them together in one place so it's easier to see them, share them + across agents, and tweak them. +

+ +
+ {agents.map((agent) => { + const items = candidates[agent.id] ?? []; + if (items.length === 0) return null; + return ( + + ); + })} +
+ +

+ Your agents will keep working exactly the same — we just leave shortcuts behind so nothing + breaks. If two skills share a name we'll add a small suffix ( + foo-2, foo-3, + …). +

+ +
+ + +
+
+ ); +}; + +interface SourceGroupProps { + agent: AgentBrand; + path: string; + items: ImportCandidate[]; +} + +const SourceGroup: React.FC = ({ agent, path, items }) => { + return ( +
+
+ + From {agent.displayName} + + {path} +
+
    + {items.map((item) => ( +
  • + {item.name} + + + {item.fileCount} {item.fileCount === 1 ? "file" : "files"} ·{" "} + {formatBytes(item.totalBytes)} + +
  • + ))} +
+
+ ); +}; + +/* --- Running ---------------------------------------------------------- */ + +const RunningBody: React.FC = () => { + return ( +
+ + Working… +
+ ); +}; + +/* --- Results (state C) ---------------------------------------------- */ + +interface ResultsBodyProps { + agents: ReadonlyArray; + results: BulkMoveResult; + agentDirsProjectRel: Readonly>; + onDone: () => void; + onEditSkillMd: (absPath: string) => void; +} + +const ResultsBody: React.FC = ({ + agents, + results, + agentDirsProjectRel, + onDone, + onEditSkillMd, +}) => { + const failedRows = results.results.filter((r) => r.status !== "moved"); + const byAgent = groupRowsByAgent(results.results); + + // Construct the failure-summary micro footnote dynamically. Only renders + // when at least one row didn't make it. + const failureSummary = buildFailureSummary(failedRows, agentDirsProjectRel); + + return ( +
+
+ {agents.map((agent) => { + const rows = byAgent[agent.id] ?? []; + if (rows.length === 0) return null; + return ( + + ); + })} +
+ + {failureSummary !== null && ( +

{failureSummary}

+ )} + +
+ +
+
+ ); +}; + +interface ResultGroupProps { + agent: AgentBrand; + rows: BulkMoveRow[]; + onEditSkillMd: (absPath: string) => void; +} + +const ResultGroup: React.FC = ({ agent, rows, onEditSkillMd }) => { + return ( +
+
+ + From {agent.displayName} +
+
    + {rows.map((row) => ( +
  • + {row.targetName} + + +
  • + ))} +
+
+ ); +}; + +const ResultBadge: React.FC<{ + row: BulkMoveRow; + onEditSkillMd: (absPath: string) => void; +}> = ({ row, onEditSkillMd }) => { + if (row.status === "moved") { + return ✓ moved; + } + + const editLink = + row.failingSkillMdAbsPath !== undefined ? ( + + ) : null; + + if (row.status === "epermNoLink") { + return ( + + ! moved · no link + {editLink} + + ); + } + return ( + + + ! rolled back + {row.reason !== undefined && row.reason.length > 0 ? ` · ${truncate(row.reason, 60)}` : ""} + + {editLink} + + ); +}; + +const EditSkillMdLink: React.FC<{ + absPath: string; + onEditSkillMd: (absPath: string) => void; +}> = ({ absPath, onEditSkillMd }) => { + return ( + + ); +}; + +/* --- Pure helpers ---------------------------------------------------- */ + +/** + * Bucket bulk-move rows by source agent. Mirrors the consent card's + * grouping so the results dialog preserves visual continuity. + */ +function groupRowsByAgent(rows: BulkMoveRow[]): Record { + const out: Record = {}; + for (const row of rows) { + const agent = row.candidate.sourceAgent; + (out[agent] ??= []).push(row); + } + return out; +} + +/** + * Build the micro footnote shown beneath the results list. Returns `null` + * if everything moved cleanly so callers can skip the empty paragraph. + */ +function buildFailureSummary( + failed: BulkMoveRow[], + agentDirsProjectRel: Readonly> +): string | null { + if (failed.length === 0) return null; + const n = failed.length; + const first = failed[0]; + const where = sourceFolderLabel(first.candidate.sourceAgent, agentDirsProjectRel); + if (n === 1) { + return `1 skill couldn't be imported. ${first.candidate.name} ${first.reason !== undefined ? `(${first.reason}) ` : ""}— it's still in ${where}${first.candidate.name}/. Fix the issue and run Find existing skills to retry.`; + } + return `${n} skills couldn't be imported. Fix the issues and run Find existing skills to retry.`; +} + +function sourceFolderLabel( + agent: BackendId, + agentDirsProjectRel: Readonly> +): string { + return withTrailingSlash(agentDirsProjectRel[agent] ?? ""); +} + +function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +function truncate(s: string, max: number): string { + if (s.length <= max) return s; + return `${s.slice(0, max - 1)}…`; +} diff --git a/src/agentMode/skills/ui/PropertiesDialog.tsx b/src/agentMode/skills/ui/PropertiesDialog.tsx new file mode 100644 index 00000000..5f7260f7 --- /dev/null +++ b/src/agentMode/skills/ui/PropertiesDialog.tsx @@ -0,0 +1,422 @@ +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import { logError } from "@/logger"; +import { createPluginRoot } from "@/utils/react/createPluginRoot"; +import { App, Modal } from "obsidian"; +import React from "react"; +import { Root } from "react-dom/client"; +import { DESCRIPTION_MAX, NAME_MAX, NAME_RE } from "@/agentMode/skills/skillFormat"; +import type { Skill } from "@/agentMode/skills/types"; + +/** + * Form state captured from the user. Strings are kept as-is for inline + * editing; we only normalize at save time. Booleans here mirror the + * checkbox semantics (NOT the frontmatter semantics — see the field map + * in `SKILLS_MANAGEMENT.md`). + */ +export interface PropertiesFormValues { + name: string; + description: string; + allowedTools: string; + model: string; + /** + * Checkbox: "Don't let Claude invoke this on its own". On the wire this + * is `disable-model-invocation: ` — same polarity (checkbox ON = + * Claude can't auto-invoke). + */ + disableAutoInvocation: boolean; + /** + * Checkbox: "Hide from slash menu". On the wire this is the inverse — + * `user-invocable: false` means hidden. Checkbox ON = hidden = wire false. + */ + hideFromSlashMenu: boolean; +} + +/** Result the dialog hands the caller when the user clicks Save. */ +export interface PropertiesSaveRequest { + /** True when the name changed and a rename needs to run before the patch. */ + nameChanged: boolean; + /** The new name (only meaningful when `nameChanged` is true). */ + newName: string; + /** Patch of non-name fields (always applied via `updateProperties`). */ + patch: { + description: string; + allowedTools: string | undefined; + model: string | undefined; + disableModelInvocation: boolean | undefined; + userInvocable: boolean | undefined; + }; +} + +/** + * Outcome the caller hands back after attempting to persist the form: + * - `close` — save succeeded; the modal should dismiss + * - `stay` — save failed for a reason the caller already surfaced (e.g. a Notice) + * - `collision` — the new name clashed with an existing skill; the modal stays + * open and shows an inline name-collision error + */ +export type PropertiesSaveOutcome = "close" | "stay" | "collision"; + +interface PropertiesModalBodyProps { + skill: Skill; + skillsFolderRel: string; + collisionError: boolean; + saving: boolean; + onCancel: () => void; + onSave: (req: PropertiesSaveRequest) => void; +} + +/** + * Form body for {@link PropertiesModal}. Mounted only when a skill is set + * and re-mounted (via `key={skill.dirPath}`) when the target skill changes, + * so the form always boots from the current frontmatter without an explicit + * reset effect. + */ +const PropertiesModalBody: React.FC = ({ + skill, + skillsFolderRel, + collisionError, + saving, + onCancel, + onSave, +}) => { + const [values, setValues] = React.useState(() => + computeInitialFormValues(skill) + ); + + const folder = skillsFolderRel.replace(/\/+$/, ""); + const nameError = validateNameField(values.name); + const descriptionError = validateDescriptionField(values.description); + + // The Save button is gated on inline validation. Collision errors come + // from the save attempt itself; we don't pre-check the canonical store + // here because that's a filesystem check the caller already runs. + const hasError = nameError !== null || descriptionError !== null; + const canSave = !hasError && !saving; + + const handleSave = (): void => { + if (!canSave) return; + const nameChanged = values.name !== skill.name; + const allowedToolsTrim = values.allowedTools.trim(); + const modelTrim = values.model.trim(); + onSave({ + nameChanged, + newName: values.name, + patch: { + description: values.description, + allowedTools: allowedToolsTrim.length > 0 ? allowedToolsTrim : undefined, + model: modelTrim.length > 0 ? modelTrim : undefined, + // Only emit when ON, to match Claude's default-false semantics — + // an explicit `false` is also valid but redundant chrome. + disableModelInvocation: values.disableAutoInvocation ? true : undefined, + // The wire field defaults to `true` (visible). Emit `false` only + // when the user actively hides the skill. + userInvocable: values.hideFromSlashMenu ? false : undefined, + }, + }); + }; + + return ( +
+
+ Writes to{" "} + + {folder}/{skill.name}/SKILL.md + +
+ +
+
+ {/* Name */} + + Name + setValues((v) => ({ ...v, name: e.target.value }))} + aria-invalid={nameError !== null || collisionError} + autoComplete="off" + spellCheck={false} + /> + {nameError !== null ? ( + {nameError} + ) : collisionError ? ( + A skill named “{values.name}” already exists. + ) : ( + + Lowercase, hyphenated. This is what users type after{" "} + / in chat. + + )} + + + {/* Description */} + + Description +