diff --git a/package.json b/package.json index cbaf13bd..d8d9ef8e 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "@lingui/react": "6.4.0", "@modelcontextprotocol/sdk": "^1.29.0", "@monaco-editor/react": "^4.7.0", - "@opencode-ai/sdk": "^1.17.13", + "@opencode-ai/sdk": "^1.18.3", "@poracode/activity-bridge": "file:native/activity-bridge", "@poracode/agents-usage": "workspace:*", "@poracode/ssh-bridge": "file:native/ssh-bridge", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 711bc24b..da0da364 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,8 +100,8 @@ importers: specifier: ^4.7.0 version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@opencode-ai/sdk': - specifier: ^1.17.13 - version: 1.17.13 + specifier: ^1.18.3 + version: 1.18.3 '@poracode/activity-bridge': specifier: file:native/activity-bridge version: file:native/activity-bridge(@capacitor/core@8.4.1) @@ -1481,8 +1481,8 @@ packages: resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} engines: {node: '>= 20.19.0'} - '@opencode-ai/sdk@1.17.13': - resolution: {integrity: sha512-VItOGjMzRQx3zypwmeFLNhCiIx32kxS7FqzIJvVZLfyNGCifs3rfGC9qzNKWcxQo4SjNvAw++v4gWWU6Inv+JQ==} + '@opencode-ai/sdk@1.18.3': + resolution: {integrity: sha512-Mevo4e6kQwbvto9E+42KSIVMhp+JBu+SwQhC5AomAvrV6Xkio3U249T+xDILDCXhl5Z/Hi/DlAuVLzpGnuh0gg==} '@opentelemetry/api-logs@0.214.0': resolution: {integrity: sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==} @@ -8346,7 +8346,7 @@ snapshots: '@noble/hashes@2.2.0': {} - '@opencode-ai/sdk@1.17.13': + '@opencode-ai/sdk@1.18.3': dependencies: cross-spawn: 7.0.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 19e25a9c..53031bf0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -49,6 +49,8 @@ publicHoistPattern: nodeLinker: hoisted minimumReleaseAgeExclude: + # OpenCode v2 runtime-pooling integration requires the current official SDK. + - "@opencode-ai/sdk@1.18.3" # LegendList 3.3.3 contains the web anchoring and batched measurement fixes # required by the chat transcript virtualizer migration. - "@legendapp/list@3.3.3" diff --git a/src/renderer/components/composer/composerMcpServers.ts b/src/renderer/components/composer/composerMcpServers.ts index 44b0718b..3d2a80c4 100644 --- a/src/renderer/components/composer/composerMcpServers.ts +++ b/src/renderer/components/composer/composerMcpServers.ts @@ -41,6 +41,19 @@ export function resolveMcpScope( return scopes?.terminal ?? "none"; } +/** + * Providers that declare `mcpConfigSource: "agentSettings"` configure MCP on + * their settings page instead of the composer: the "+" menu shows no MCP rows + * at all for their threads (built-ins are hidden by their `"none"` scopes; + * callers use this to suppress the custom-server rows and read-only fallbacks + * too). + */ +export function providerOwnsMcpConfig( + capabilities: Pick, +): boolean { + return capabilities.mcpConfigSource === "agentSettings"; +} + export interface ComposerMcpServerDescriptor { id: "browser" | "crossagents" | "chrome"; configKey: ComposerMcpConfigKey; diff --git a/src/renderer/components/thread/ChatPane/chatPaneSelectors.test.ts b/src/renderer/components/thread/ChatPane/chatPaneSelectors.test.ts index e1b6fe39..9e76ec23 100644 --- a/src/renderer/components/thread/ChatPane/chatPaneSelectors.test.ts +++ b/src/renderer/components/thread/ChatPane/chatPaneSelectors.test.ts @@ -671,7 +671,7 @@ describe("chatPaneSelectors", () => { "agent-running": { ...items["agent-running"], state: "completed", - payload: { ...items["agent-running"].payload, status: "success" }, + payload: { ...items["agent-running"].payload }, }, }, }, diff --git a/src/renderer/components/thread/ThreadComposerSection.tsx b/src/renderer/components/thread/ThreadComposerSection.tsx index 690cd456..a1791c1d 100644 --- a/src/renderer/components/thread/ThreadComposerSection.tsx +++ b/src/renderer/components/thread/ThreadComposerSection.tsx @@ -18,7 +18,11 @@ import { modelVisibilityKey } from "@/renderer/components/common/ProviderModelMe import { AttachmentBar } from "../composer/AttachmentBar"; import { ComposerAddMenu } from "../composer/ComposerAddMenu"; import { ComposerVoiceInput } from "../composer/ComposerVoiceInput"; -import { composerMcpServers, COMPUTER_USE_MCP_ID } from "../composer/composerMcpServers"; +import { + composerMcpServers, + COMPUTER_USE_MCP_ID, + providerOwnsMcpConfig, +} from "../composer/composerMcpServers"; import { openAttachmentLightbox } from "../composer/ImageLightbox"; import { openPdfPreview } from "../pdf/openPdfPreview"; import { @@ -186,20 +190,27 @@ function ThreadComposerSectionInner(props: ThreadComposerSectionProps & { thread // and Computer Use. Users change servers in the draft composer or settings // before launching a new thread. // Bindings are display-only for an active session; toggles are no-ops. + // Providers with `mcpConfigSource: "agentSettings"` configure MCP on their + // settings page instead of per-thread, so their composer carries no MCP + // display at all — no built-in rows, no custom servers, and no read-only + // "none for this run" fallback. + const providerOwnsMcp = agentStatus ? providerOwnsMcpConfig(agentStatus.capabilities) : false; const mcpServers = composerMcpServers.map((descriptor) => ({ descriptor, enabled: thread.config[descriptor.configKey] === true, - visible: thread.config[descriptor.configKey] === true, + visible: !providerOwnsMcp && thread.config[descriptor.configKey] === true, onToggle: () => {}, })); const launchCustomMcpNames = useAppStore( (s) => s.mcpLaunchCustomServerNamesByThreadId[thread.id], ); - const customMcpServers = (launchCustomMcpNames ?? []).map((name) => ({ - id: name, - name, - enabled: true, - })); + const customMcpServers = providerOwnsMcp + ? [] + : (launchCustomMcpNames ?? []).map((name) => ({ + id: name, + name, + enabled: true, + })); const mcpMentions: McpMentionItem[] = [ ...composerMcpServers .filter((descriptor) => thread.config[descriptor.configKey] === true) @@ -775,10 +786,10 @@ function ThreadComposerSectionInner(props: ThreadComposerSectionProps & { thread {}, }} showFileOption={!isRemote} diff --git a/src/renderer/components/thread/ThreadDraftComposerArea.tsx b/src/renderer/components/thread/ThreadDraftComposerArea.tsx index 13843d7d..ff0995ef 100644 --- a/src/renderer/components/thread/ThreadDraftComposerArea.tsx +++ b/src/renderer/components/thread/ThreadDraftComposerArea.tsx @@ -31,6 +31,7 @@ import { composerMcpServers, COMPUTER_USE_MCP_ID, mcpTogglePatch, + providerOwnsMcpConfig, } from "@/renderer/components/composer/composerMcpServers"; import { openAttachmentLightbox } from "@/renderer/components/composer/ImageLightbox"; import { openPdfPreview } from "@/renderer/components/pdf/openPdfPreview"; @@ -386,13 +387,15 @@ export function ThreadDraftComposerArea(props: { // the MCP Servers settings page — because custom servers bind at launch from // settings, not from per-thread config. The launch-time merge helper decides // which workspace entries override global ones, so the menu can't drift from - // what actually launches. + // what actually launches. Providers whose MCP set lives on their settings + // page show no rows here at all. + const hideCustomMcpRows = providerOwnsMcpConfig(props.selectedAgent.capabilities); const projectCustomMcpServers = props.project.mcpServers ?? []; const projectCustomMcpIds = new Set(projectCustomMcpServers.map((server) => server.id)); - const customMcpServers: ComposerCustomMcpItem[] = mergeMcpServers( - userCustomMcpServers, - projectCustomMcpServers, - ).map((server) => { + const mergedCustomMcpServers = hideCustomMcpRows + ? [] + : mergeMcpServers(userCustomMcpServers, projectCustomMcpServers); + const customMcpServers: ComposerCustomMcpItem[] = mergedCustomMcpServers.map((server) => { const isProject = projectCustomMcpIds.has(server.id); const scopedServers = isProject ? projectCustomMcpServers : userCustomMcpServers; return { diff --git a/src/renderer/locales/de/messages.po b/src/renderer/locales/de/messages.po index 5b24fb38..34c05a3a 100644 --- a/src/renderer/locales/de/messages.po +++ b/src/renderer/locales/de/messages.po @@ -1469,6 +1469,7 @@ msgstr "Durchsuchen Sie Ihre GitHub-Repositorys oder fügen Sie eine Klon-URL ei #: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx #: src/renderer/views/MainView/parts/SidebarHeaderControls.tsx #: src/renderer/views/SettingsOverlay/parts/BrowserSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx msgid "Browser" msgstr "Browser" @@ -1840,6 +1841,7 @@ msgstr "Worktree-Basisordner auswählen" #: src/renderer/components/composer/composerMcpServers.ts #: src/renderer/components/mcp/McpServersManager.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Chrome" msgstr "Chrome" @@ -2357,6 +2359,7 @@ msgstr "Composer-Steuerung" #: src/renderer/components/mcp/McpServersManager.tsx #: src/renderer/components/thread/ThreadComposerSection.tsx #: src/renderer/components/thread/ThreadDraftComposerArea.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Computer Use" msgstr "Computernutzung" @@ -2547,6 +2550,14 @@ msgstr "In einem anderen Anbieter fortfahren" msgid "Continue in..." msgstr "Weiter in..." +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Control an external Chrome browser." +msgstr "Steuert einen externen Chrome-Browser." + +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Control the desktop." +msgstr "Steuert den Desktop." + #: src/renderer/views/SettingsOverlay/parts/SearchSettings.tsx msgid "Control which files appear in the @file mention search. Per-project overrides live in each project's settings." msgstr "Steuern Sie, welche Dateien in der @file-Erwähnungssuche angezeigt werden. Projektspezifische Überschreibungen sind in den Einstellungen jedes Projekts verfügbar." @@ -2910,6 +2921,7 @@ msgstr "Crossagent: {subagent}" #: src/renderer/components/mcp/McpServersManager.tsx #: src/renderer/components/thread/ChatPane/parts/items/ActiveSubAgentTile.tsx #: src/renderer/views/SettingsOverlay/parts/McpServersSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Crossagents" msgstr "Crossagents" @@ -3031,6 +3043,10 @@ msgstr "Standardberechtigungen" msgid "Default thread removal" msgstr "Standardmäßiges Entfernen von Threads" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Delegate work to other AI agents." +msgstr "Delegieren Sie Aufgaben an andere KI-Agenten." + #. Thread remove action: delete permanently #: src/mobile/views/FilesView.tsx #: src/renderer/components/common/BranchSelector/BranchSelector.tsx @@ -5370,6 +5386,7 @@ msgid "MCP server URL" msgstr "MCP-Server-URL" #: src/renderer/components/composer/ComposerAddMenu.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/ProfileSettings.tsx msgid "MCP servers" msgstr "MCP-Server" @@ -5382,6 +5399,10 @@ msgstr "MCP-Server" msgid "MCP Servers" msgstr "MCP-Server" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "MCP servers updated." +msgstr "MCP-Server aktualisiert." + #: src/renderer/views/ProfileOverlay/parts/ActivityInsights.tsx msgid "MCP tool calls" msgstr "MCP-Toolaufrufe" @@ -7052,6 +7073,10 @@ msgstr "Poracode installiert seinen Server auf dem SSH-Host oder verwendet ihn e msgid "Poracode will restart, back up its current data, and replace it with a complete copy of your Lightcode data." msgstr "Poracode wird neu gestartet, sichert seine aktuellen Daten und ersetzt sie durch eine vollständige Kopie Ihrer Lightcode-Daten." +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Poracode's built-in browser tools." +msgstr "Die integrierten Browser-Tools von Poracode." + #: src/mobile/views/DesktopsView.tsx #: src/mobile/views/PortsView.tsx msgid "Port" @@ -8186,6 +8211,7 @@ msgstr "Laufzeitoptionen" #: src/renderer/views/FileEditorOverlay/parts/FileEditorPane/parts/EditorToolbar.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Save" msgstr "Speichern" @@ -8207,6 +8233,10 @@ msgstr "Claude-Profil speichern" msgid "Save File" msgstr "Datei speichern" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Save MCP servers" +msgstr "MCP-Server speichern" + #: src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx msgid "Save shortcut" msgstr "Tastenkürzel speichern" @@ -9694,6 +9724,10 @@ msgstr "Theme" msgid "Theme and chat font size" msgstr "Theme und Chat-Schriftgröße" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "These built-in MCP servers are shared by OpenCode threads. Saving updates running GUI projects; removing a server can interrupt an active turn. Terminal threads pick up changes on their next launch." +msgstr "Diese integrierten MCP-Server werden von OpenCode-Threads gemeinsam genutzt. Beim Speichern werden laufende GUI-Projekte aktualisiert; das Entfernen eines Servers kann einen aktiven Durchlauf unterbrechen. Terminal-Threads übernehmen Änderungen beim nächsten Start." + #: src/renderer/components/common/EffortContextMenu/EffortContextMenu.tsx #: src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx #: src/renderer/components/thread/ChatPane/parts/items/ReasoningInline.tsx diff --git a/src/renderer/locales/en/messages.po b/src/renderer/locales/en/messages.po index b6f33328..35c72e20 100644 --- a/src/renderer/locales/en/messages.po +++ b/src/renderer/locales/en/messages.po @@ -1469,6 +1469,7 @@ msgstr "Browse your GitHub repositories or paste a clone URL." #: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx #: src/renderer/views/MainView/parts/SidebarHeaderControls.tsx #: src/renderer/views/SettingsOverlay/parts/BrowserSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx msgid "Browser" msgstr "Browser" @@ -1840,6 +1841,7 @@ msgstr "Choose worktree base folder" #: src/renderer/components/composer/composerMcpServers.ts #: src/renderer/components/mcp/McpServersManager.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Chrome" msgstr "Chrome" @@ -2357,6 +2359,7 @@ msgstr "Composer controls" #: src/renderer/components/mcp/McpServersManager.tsx #: src/renderer/components/thread/ThreadComposerSection.tsx #: src/renderer/components/thread/ThreadDraftComposerArea.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Computer Use" msgstr "Computer Use" @@ -2547,6 +2550,14 @@ msgstr "Continue in another provider" msgid "Continue in..." msgstr "Continue in..." +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Control an external Chrome browser." +msgstr "Control an external Chrome browser." + +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Control the desktop." +msgstr "Control the desktop." + #: src/renderer/views/SettingsOverlay/parts/SearchSettings.tsx msgid "Control which files appear in the @file mention search. Per-project overrides live in each project's settings." msgstr "Control which files appear in the @file mention search. Per-project overrides live in each project's settings." @@ -2910,6 +2921,7 @@ msgstr "Crossagent: {subagent}" #: src/renderer/components/mcp/McpServersManager.tsx #: src/renderer/components/thread/ChatPane/parts/items/ActiveSubAgentTile.tsx #: src/renderer/views/SettingsOverlay/parts/McpServersSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Crossagents" msgstr "Crossagents" @@ -3031,6 +3043,10 @@ msgstr "Default permissions" msgid "Default thread removal" msgstr "Default thread removal" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Delegate work to other AI agents." +msgstr "Delegate work to other AI agents." + #. Thread remove action: delete permanently #: src/mobile/views/FilesView.tsx #: src/renderer/components/common/BranchSelector/BranchSelector.tsx @@ -5370,6 +5386,7 @@ msgid "MCP server URL" msgstr "MCP server URL" #: src/renderer/components/composer/ComposerAddMenu.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/ProfileSettings.tsx msgid "MCP servers" msgstr "MCP servers" @@ -5382,6 +5399,10 @@ msgstr "MCP servers" msgid "MCP Servers" msgstr "MCP Servers" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "MCP servers updated." +msgstr "MCP servers updated." + #: src/renderer/views/ProfileOverlay/parts/ActivityInsights.tsx msgid "MCP tool calls" msgstr "MCP tool calls" @@ -7052,6 +7073,10 @@ msgstr "Poracode will install or reuse its server on the SSH host and keep crede msgid "Poracode will restart, back up its current data, and replace it with a complete copy of your Lightcode data." msgstr "Poracode will restart, back up its current data, and replace it with a complete copy of your Lightcode data." +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Poracode's built-in browser tools." +msgstr "Poracode's built-in browser tools." + #: src/mobile/views/DesktopsView.tsx #: src/mobile/views/PortsView.tsx msgid "Port" @@ -8186,6 +8211,7 @@ msgstr "Runtime options" #: src/renderer/views/FileEditorOverlay/parts/FileEditorPane/parts/EditorToolbar.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Save" msgstr "Save" @@ -8207,6 +8233,10 @@ msgstr "Save Claude profile" msgid "Save File" msgstr "Save File" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Save MCP servers" +msgstr "Save MCP servers" + #: src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx msgid "Save shortcut" msgstr "Save shortcut" @@ -9694,6 +9724,10 @@ msgstr "Theme" msgid "Theme and chat font size" msgstr "Theme and chat font size" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "These built-in MCP servers are shared by OpenCode threads. Saving updates running GUI projects; removing a server can interrupt an active turn. Terminal threads pick up changes on their next launch." +msgstr "These built-in MCP servers are shared by OpenCode threads. Saving updates running GUI projects; removing a server can interrupt an active turn. Terminal threads pick up changes on their next launch." + #: src/renderer/components/common/EffortContextMenu/EffortContextMenu.tsx #: src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx #: src/renderer/components/thread/ChatPane/parts/items/ReasoningInline.tsx diff --git a/src/renderer/locales/es/messages.po b/src/renderer/locales/es/messages.po index 28095ca7..c57deb26 100644 --- a/src/renderer/locales/es/messages.po +++ b/src/renderer/locales/es/messages.po @@ -1469,6 +1469,7 @@ msgstr "Explora tus repositorios de GitHub o pega una URL de clonación." #: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx #: src/renderer/views/MainView/parts/SidebarHeaderControls.tsx #: src/renderer/views/SettingsOverlay/parts/BrowserSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx msgid "Browser" msgstr "Navegador" @@ -1840,6 +1841,7 @@ msgstr "Elegir la carpeta base de worktrees" #: src/renderer/components/composer/composerMcpServers.ts #: src/renderer/components/mcp/McpServersManager.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Chrome" msgstr "Chrome" @@ -2357,6 +2359,7 @@ msgstr "Controles del Redactor" #: src/renderer/components/mcp/McpServersManager.tsx #: src/renderer/components/thread/ThreadComposerSection.tsx #: src/renderer/components/thread/ThreadDraftComposerArea.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Computer Use" msgstr "Uso del ordenador" @@ -2547,6 +2550,14 @@ msgstr "Continuar en otro proveedor" msgid "Continue in..." msgstr "Continuar en..." +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Control an external Chrome browser." +msgstr "Controla un navegador Chrome externo." + +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Control the desktop." +msgstr "Controla el escritorio." + #: src/renderer/views/SettingsOverlay/parts/SearchSettings.tsx msgid "Control which files appear in the @file mention search. Per-project overrides live in each project's settings." msgstr "Controla qué archivos aparecen en la búsqueda de menciones @file. Las anulaciones por proyecto están en la configuración de cada proyecto." @@ -2910,6 +2921,7 @@ msgstr "Crossagent: {subagent}" #: src/renderer/components/mcp/McpServersManager.tsx #: src/renderer/components/thread/ChatPane/parts/items/ActiveSubAgentTile.tsx #: src/renderer/views/SettingsOverlay/parts/McpServersSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Crossagents" msgstr "Crossagents" @@ -3031,6 +3043,10 @@ msgstr "Permisos predeterminados" msgid "Default thread removal" msgstr "Eliminación de hilos predeterminada" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Delegate work to other AI agents." +msgstr "Delega trabajo a otros agentes de IA." + #. Thread remove action: delete permanently #: src/mobile/views/FilesView.tsx #: src/renderer/components/common/BranchSelector/BranchSelector.tsx @@ -5370,6 +5386,7 @@ msgid "MCP server URL" msgstr "URL del servidor MCP" #: src/renderer/components/composer/ComposerAddMenu.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/ProfileSettings.tsx msgid "MCP servers" msgstr "Servidores MCP" @@ -5382,6 +5399,10 @@ msgstr "Servidores MCP" msgid "MCP Servers" msgstr "Servidores MCP" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "MCP servers updated." +msgstr "Servidores MCP actualizados." + #: src/renderer/views/ProfileOverlay/parts/ActivityInsights.tsx msgid "MCP tool calls" msgstr "Llamadas a herramientas MCP" @@ -7052,6 +7073,10 @@ msgstr "Poracode instalará o reutilizará su servidor en el host SSH y guardar msgid "Poracode will restart, back up its current data, and replace it with a complete copy of your Lightcode data." msgstr "Poracode se reiniciará, hará una copia de seguridad de sus datos actuales y los reemplazará por una copia completa de tus datos de Lightcode." +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Poracode's built-in browser tools." +msgstr "Las herramientas de navegador integradas de Poracode." + #: src/mobile/views/DesktopsView.tsx #: src/mobile/views/PortsView.tsx msgid "Port" @@ -8186,6 +8211,7 @@ msgstr "Opciones de runtime" #: src/renderer/views/FileEditorOverlay/parts/FileEditorPane/parts/EditorToolbar.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Save" msgstr "Guardar" @@ -8207,6 +8233,10 @@ msgstr "Guardar perfil de Claude" msgid "Save File" msgstr "Guardar archivo" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Save MCP servers" +msgstr "Guardar servidores MCP" + #: src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx msgid "Save shortcut" msgstr "Guardar atajo" @@ -9694,6 +9724,10 @@ msgstr "Tema" msgid "Theme and chat font size" msgstr "Tema y tamaño de fuente del chat" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "These built-in MCP servers are shared by OpenCode threads. Saving updates running GUI projects; removing a server can interrupt an active turn. Terminal threads pick up changes on their next launch." +msgstr "Estos servidores MCP integrados se comparten entre los hilos de OpenCode. Al guardar se actualizan los proyectos de la interfaz gráfica en ejecución; quitar un servidor puede interrumpir un turno activo. Los hilos de terminal reciben los cambios en su próximo inicio." + #: src/renderer/components/common/EffortContextMenu/EffortContextMenu.tsx #: src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx #: src/renderer/components/thread/ChatPane/parts/items/ReasoningInline.tsx diff --git a/src/renderer/locales/fr/messages.po b/src/renderer/locales/fr/messages.po index 7e0a4659..4d524796 100644 --- a/src/renderer/locales/fr/messages.po +++ b/src/renderer/locales/fr/messages.po @@ -1469,6 +1469,7 @@ msgstr "Parcourez vos référentiels GitHub ou collez une URL de clonage." #: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx #: src/renderer/views/MainView/parts/SidebarHeaderControls.tsx #: src/renderer/views/SettingsOverlay/parts/BrowserSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx msgid "Browser" msgstr "Navigateur" @@ -1840,6 +1841,7 @@ msgstr "Choisir le dossier de base des arbres de travail" #: src/renderer/components/composer/composerMcpServers.ts #: src/renderer/components/mcp/McpServersManager.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Chrome" msgstr "Chrome" @@ -2357,6 +2359,7 @@ msgstr "Commandes du compositeur" #: src/renderer/components/mcp/McpServersManager.tsx #: src/renderer/components/thread/ThreadComposerSection.tsx #: src/renderer/components/thread/ThreadDraftComposerArea.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Computer Use" msgstr "Utilisation de l'ordinateur" @@ -2547,6 +2550,14 @@ msgstr "Continuer dans un autre fournisseur" msgid "Continue in..." msgstr "Continuez dans..." +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Control an external Chrome browser." +msgstr "Contrôle un navigateur Chrome externe." + +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Control the desktop." +msgstr "Contrôle le bureau." + #: src/renderer/views/SettingsOverlay/parts/SearchSettings.tsx msgid "Control which files appear in the @file mention search. Per-project overrides live in each project's settings." msgstr "Contrôlez quels fichiers apparaissent dans la recherche de mention @file. Les remplacements par projet sont disponibles dans les paramètres de chaque projet." @@ -2910,6 +2921,7 @@ msgstr "Crossagent : {subagent}" #: src/renderer/components/mcp/McpServersManager.tsx #: src/renderer/components/thread/ChatPane/parts/items/ActiveSubAgentTile.tsx #: src/renderer/views/SettingsOverlay/parts/McpServersSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Crossagents" msgstr "Crossagents" @@ -3031,6 +3043,10 @@ msgstr "Autorisations par défaut" msgid "Default thread removal" msgstr "Suppression du fil par défaut" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Delegate work to other AI agents." +msgstr "Déléguez le travail à d’autres agents IA." + #. Thread remove action: delete permanently #: src/mobile/views/FilesView.tsx #: src/renderer/components/common/BranchSelector/BranchSelector.tsx @@ -5370,6 +5386,7 @@ msgid "MCP server URL" msgstr "URL du serveur MCP" #: src/renderer/components/composer/ComposerAddMenu.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/ProfileSettings.tsx msgid "MCP servers" msgstr "Serveurs MCP" @@ -5382,6 +5399,10 @@ msgstr "Serveurs MCP" msgid "MCP Servers" msgstr "Serveurs MCP" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "MCP servers updated." +msgstr "Serveurs MCP mis à jour." + #: src/renderer/views/ProfileOverlay/parts/ActivityInsights.tsx msgid "MCP tool calls" msgstr "Appels d’outils MCP" @@ -7051,6 +7072,10 @@ msgstr "Poracode installera ou réutilisera son serveur sur l’hôte SSH et con msgid "Poracode will restart, back up its current data, and replace it with a complete copy of your Lightcode data." msgstr "Poracode va redémarrer, sauvegarder ses données actuelles et les remplacer par une copie complète de vos données Lightcode." +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Poracode's built-in browser tools." +msgstr "Les outils de navigateur intégrés de Poracode." + #: src/mobile/views/DesktopsView.tsx #: src/mobile/views/PortsView.tsx msgid "Port" @@ -8185,6 +8210,7 @@ msgstr "Options d'exécution" #: src/renderer/views/FileEditorOverlay/parts/FileEditorPane/parts/EditorToolbar.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Save" msgstr "Enregistrer" @@ -8206,6 +8232,10 @@ msgstr "Enregistrer le profil de Claude" msgid "Save File" msgstr "Enregistrer le fichier" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Save MCP servers" +msgstr "Enregistrer les serveurs MCP" + #: src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx msgid "Save shortcut" msgstr "Enregistrer le raccourci" @@ -9693,6 +9723,10 @@ msgstr "Thème" msgid "Theme and chat font size" msgstr "Thème et taille de police du chat" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "These built-in MCP servers are shared by OpenCode threads. Saving updates running GUI projects; removing a server can interrupt an active turn. Terminal threads pick up changes on their next launch." +msgstr "Ces serveurs MCP intégrés sont partagés entre les fils OpenCode. L'enregistrement met à jour les projets GUI en cours ; la suppression d'un serveur peut interrompre un tour actif. Les fils de terminal récupèrent les modifications à leur prochain lancement." + #: src/renderer/components/common/EffortContextMenu/EffortContextMenu.tsx #: src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx #: src/renderer/components/thread/ChatPane/parts/items/ReasoningInline.tsx diff --git a/src/renderer/locales/ja/messages.po b/src/renderer/locales/ja/messages.po index 8c8befb3..6ffed5af 100644 --- a/src/renderer/locales/ja/messages.po +++ b/src/renderer/locales/ja/messages.po @@ -1468,6 +1468,7 @@ msgstr "GitHub リポジトリを参照するか、クローン URL を貼り付 #: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx #: src/renderer/views/MainView/parts/SidebarHeaderControls.tsx #: src/renderer/views/SettingsOverlay/parts/BrowserSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx msgid "Browser" msgstr "ブラウザ" @@ -1839,6 +1840,7 @@ msgstr "ワークツリーのベースフォルダーを選択" #: src/renderer/components/composer/composerMcpServers.ts #: src/renderer/components/mcp/McpServersManager.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Chrome" msgstr "Chrome" @@ -2356,6 +2358,7 @@ msgstr "コンポーザーの操作" #: src/renderer/components/mcp/McpServersManager.tsx #: src/renderer/components/thread/ThreadComposerSection.tsx #: src/renderer/components/thread/ThreadDraftComposerArea.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Computer Use" msgstr "コンピュータ操作" @@ -2546,6 +2549,14 @@ msgstr "別のプロバイダーで続行する" msgid "Continue in..." msgstr "...で続行" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Control an external Chrome browser." +msgstr "外部の Chrome ブラウザを操作します。" + +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Control the desktop." +msgstr "デスクトップを操作します。" + #: src/renderer/views/SettingsOverlay/parts/SearchSettings.tsx msgid "Control which files appear in the @file mention search. Per-project overrides live in each project's settings." msgstr "@file メンション検索に表示されるファイルを制御します。プロジェクトごとのオーバーライドは、各プロジェクトの設定に反映されます。" @@ -2909,6 +2920,7 @@ msgstr "Crossagent:{subagent}" #: src/renderer/components/mcp/McpServersManager.tsx #: src/renderer/components/thread/ChatPane/parts/items/ActiveSubAgentTile.tsx #: src/renderer/views/SettingsOverlay/parts/McpServersSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Crossagents" msgstr "Crossagents" @@ -3030,6 +3042,10 @@ msgstr "デフォルトの権限" msgid "Default thread removal" msgstr "デフォルトのスレッドの削除" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Delegate work to other AI agents." +msgstr "他の AI エージェントに作業を委任します。" + #. Thread remove action: delete permanently #: src/mobile/views/FilesView.tsx #: src/renderer/components/common/BranchSelector/BranchSelector.tsx @@ -5369,6 +5385,7 @@ msgid "MCP server URL" msgstr "MCPサーバーのURL" #: src/renderer/components/composer/ComposerAddMenu.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/ProfileSettings.tsx msgid "MCP servers" msgstr "MCPサーバー" @@ -5381,6 +5398,10 @@ msgstr "MCPサーバー" msgid "MCP Servers" msgstr "MCPサーバー" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "MCP servers updated." +msgstr "MCPサーバーを更新しました。" + #: src/renderer/views/ProfileOverlay/parts/ActivityInsights.tsx msgid "MCP tool calls" msgstr "MCP ツール呼び出し" @@ -7050,6 +7071,10 @@ msgstr "Poracode は SSH ホストにサーバーをインストールまたは msgid "Poracode will restart, back up its current data, and replace it with a complete copy of your Lightcode data." msgstr "Poracode は再起動し、現在のデータをバックアップしてから、Lightcode データの完全なコピーに置き換えます。" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Poracode's built-in browser tools." +msgstr "Poracode の組み込みブラウザツール。" + #: src/mobile/views/DesktopsView.tsx #: src/mobile/views/PortsView.tsx msgid "Port" @@ -8184,6 +8209,7 @@ msgstr "実行時オプション" #: src/renderer/views/FileEditorOverlay/parts/FileEditorPane/parts/EditorToolbar.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Save" msgstr "保存" @@ -8205,6 +8231,10 @@ msgstr "Claude プロフィールを保存" msgid "Save File" msgstr "ファイルの保存" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Save MCP servers" +msgstr "MCPサーバーを保存" + #: src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx msgid "Save shortcut" msgstr "ショートカットを保存" @@ -9692,6 +9722,10 @@ msgstr "テーマ" msgid "Theme and chat font size" msgstr "テーマとチャットのフォントサイズ" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "These built-in MCP servers are shared by OpenCode threads. Saving updates running GUI projects; removing a server can interrupt an active turn. Terminal threads pick up changes on their next launch." +msgstr "これらの組み込みMCPサーバーはOpenCodeスレッド間で共有されます。保存すると実行中のGUIプロジェクトが更新されますが、サーバーを削除すると進行中のターンが中断される場合があります。ターミナルスレッドには次回起動時に変更が反映されます。" + #: src/renderer/components/common/EffortContextMenu/EffortContextMenu.tsx #: src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx #: src/renderer/components/thread/ChatPane/parts/items/ReasoningInline.tsx diff --git a/src/renderer/locales/ko/messages.po b/src/renderer/locales/ko/messages.po index 2b88f905..1b7527da 100644 --- a/src/renderer/locales/ko/messages.po +++ b/src/renderer/locales/ko/messages.po @@ -1469,6 +1469,7 @@ msgstr "GitHub 리포지토리를 찾아보거나 복제 URL을 붙여넣으세 #: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx #: src/renderer/views/MainView/parts/SidebarHeaderControls.tsx #: src/renderer/views/SettingsOverlay/parts/BrowserSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx msgid "Browser" msgstr "브라우저" @@ -1840,6 +1841,7 @@ msgstr "작업 트리 기본 폴더 선택" #: src/renderer/components/composer/composerMcpServers.ts #: src/renderer/components/mcp/McpServersManager.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Chrome" msgstr "Chrome" @@ -2357,6 +2359,7 @@ msgstr "작성기 컨트롤" #: src/renderer/components/mcp/McpServersManager.tsx #: src/renderer/components/thread/ThreadComposerSection.tsx #: src/renderer/components/thread/ThreadDraftComposerArea.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Computer Use" msgstr "컴퓨터 사용" @@ -2547,6 +2550,14 @@ msgstr "다른 제공업체에서 계속" msgid "Continue in..." msgstr "다음에서 계속..." +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Control an external Chrome browser." +msgstr "외부 Chrome 브라우저를 제어합니다." + +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Control the desktop." +msgstr "데스크톱을 제어합니다." + #: src/renderer/views/SettingsOverlay/parts/SearchSettings.tsx msgid "Control which files appear in the @file mention search. Per-project overrides live in each project's settings." msgstr "@file 멘션 검색에 표시되는 파일을 제어합니다. 프로젝트별 재정의는 각 프로젝트의 설정에 적용됩니다." @@ -2910,6 +2921,7 @@ msgstr "Crossagent: {subagent}" #: src/renderer/components/mcp/McpServersManager.tsx #: src/renderer/components/thread/ChatPane/parts/items/ActiveSubAgentTile.tsx #: src/renderer/views/SettingsOverlay/parts/McpServersSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Crossagents" msgstr "Crossagents" @@ -3031,6 +3043,10 @@ msgstr "기본 권한" msgid "Default thread removal" msgstr "기본 스레드 제거" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Delegate work to other AI agents." +msgstr "다른 AI 에이전트에게 작업을 위임합니다." + #. Thread remove action: delete permanently #: src/mobile/views/FilesView.tsx #: src/renderer/components/common/BranchSelector/BranchSelector.tsx @@ -5370,6 +5386,7 @@ msgid "MCP server URL" msgstr "MCP 서버 URL" #: src/renderer/components/composer/ComposerAddMenu.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/ProfileSettings.tsx msgid "MCP servers" msgstr "MCP 서버" @@ -5382,6 +5399,10 @@ msgstr "MCP 서버" msgid "MCP Servers" msgstr "MCP 서버" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "MCP servers updated." +msgstr "MCP 서버가 업데이트되었습니다." + #: src/renderer/views/ProfileOverlay/parts/ActivityInsights.tsx msgid "MCP tool calls" msgstr "MCP 도구 호출" @@ -7052,6 +7073,10 @@ msgstr "Poracode는 SSH 호스트에 서버를 설치하거나 기존 서버를 msgid "Poracode will restart, back up its current data, and replace it with a complete copy of your Lightcode data." msgstr "Poracode가 다시 시작되어 현재 데이터를 백업한 다음 Lightcode 데이터의 전체 복사본으로 교체합니다." +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Poracode's built-in browser tools." +msgstr "Poracode의 기본 제공 브라우저 도구." + #: src/mobile/views/DesktopsView.tsx #: src/mobile/views/PortsView.tsx msgid "Port" @@ -8186,6 +8211,7 @@ msgstr "런타임 옵션" #: src/renderer/views/FileEditorOverlay/parts/FileEditorPane/parts/EditorToolbar.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Save" msgstr "저장" @@ -8207,6 +8233,10 @@ msgstr "Claude 프로필 저장" msgid "Save File" msgstr "파일 저장" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Save MCP servers" +msgstr "MCP 서버 저장" + #: src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx msgid "Save shortcut" msgstr "단축키 저장" @@ -9694,6 +9724,10 @@ msgstr "테마" msgid "Theme and chat font size" msgstr "테마 및 채팅 글꼴 크기" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "These built-in MCP servers are shared by OpenCode threads. Saving updates running GUI projects; removing a server can interrupt an active turn. Terminal threads pick up changes on their next launch." +msgstr "이 기본 제공 MCP 서버는 OpenCode 스레드 간에 공유됩니다. 저장하면 실행 중인 GUI 프로젝트가 업데이트되며, 서버를 제거하면 진행 중인 턴이 중단될 수 있습니다. 터미널 스레드는 다음 실행 시 변경 사항을 반영합니다." + #: src/renderer/components/common/EffortContextMenu/EffortContextMenu.tsx #: src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx #: src/renderer/components/thread/ChatPane/parts/items/ReasoningInline.tsx diff --git a/src/renderer/locales/pl/messages.po b/src/renderer/locales/pl/messages.po index a8ff5d36..f17c6bf2 100644 --- a/src/renderer/locales/pl/messages.po +++ b/src/renderer/locales/pl/messages.po @@ -1469,6 +1469,7 @@ msgstr "Przeglądaj swoje repozytoria GitHub lub wklej adres URL do klonowania." #: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx #: src/renderer/views/MainView/parts/SidebarHeaderControls.tsx #: src/renderer/views/SettingsOverlay/parts/BrowserSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx msgid "Browser" msgstr "Przeglądarka" @@ -1840,6 +1841,7 @@ msgstr "Wybierz folder bazowy drzew roboczych" #: src/renderer/components/composer/composerMcpServers.ts #: src/renderer/components/mcp/McpServersManager.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Chrome" msgstr "Chrome" @@ -2357,6 +2359,7 @@ msgstr "Sterowanie kompozytorem" #: src/renderer/components/mcp/McpServersManager.tsx #: src/renderer/components/thread/ThreadComposerSection.tsx #: src/renderer/components/thread/ThreadDraftComposerArea.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Computer Use" msgstr "Obsługa komputera" @@ -2547,6 +2550,14 @@ msgstr "Kontynuuj u innego dostawcy" msgid "Continue in..." msgstr "Kontynuuj w..." +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Control an external Chrome browser." +msgstr "Steruje zewnętrzną przeglądarką Chrome." + +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Control the desktop." +msgstr "Steruje pulpitem." + #: src/renderer/views/SettingsOverlay/parts/SearchSettings.tsx msgid "Control which files appear in the @file mention search. Per-project overrides live in each project's settings." msgstr "Kontroluj, które pliki pojawiają się w wyszukiwaniu wzmianek @plik. Zastąpienia poszczególnych projektów są dostępne w ustawieniach każdego projektu." @@ -2910,6 +2921,7 @@ msgstr "Crossagent: {subagent}" #: src/renderer/components/mcp/McpServersManager.tsx #: src/renderer/components/thread/ChatPane/parts/items/ActiveSubAgentTile.tsx #: src/renderer/views/SettingsOverlay/parts/McpServersSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Crossagents" msgstr "Crossagents" @@ -3031,6 +3043,10 @@ msgstr "Domyślne uprawnienia" msgid "Default thread removal" msgstr "Domyślne usuwanie wątku" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Delegate work to other AI agents." +msgstr "Deleguj zadania innym agentom AI." + #. Thread remove action: delete permanently #: src/mobile/views/FilesView.tsx #: src/renderer/components/common/BranchSelector/BranchSelector.tsx @@ -5370,6 +5386,7 @@ msgid "MCP server URL" msgstr "Adres URL serwera MCP" #: src/renderer/components/composer/ComposerAddMenu.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/ProfileSettings.tsx msgid "MCP servers" msgstr "Serwery MCP" @@ -5382,6 +5399,10 @@ msgstr "Serwery MCP" msgid "MCP Servers" msgstr "Serwery MCP" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "MCP servers updated." +msgstr "Zaktualizowano serwery MCP." + #: src/renderer/views/ProfileOverlay/parts/ActivityInsights.tsx msgid "MCP tool calls" msgstr "Wywołania narzędzi MCP" @@ -7052,6 +7073,10 @@ msgstr "Poracode zainstaluje lub ponownie wykorzysta swój serwer na hoście SSH msgid "Poracode will restart, back up its current data, and replace it with a complete copy of your Lightcode data." msgstr "Poracode uruchomi się ponownie, utworzy kopię zapasową bieżących danych i zastąpi je pełną kopią danych Lightcode." +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Poracode's built-in browser tools." +msgstr "Wbudowane narzędzia przeglądarki Poracode." + #: src/mobile/views/DesktopsView.tsx #: src/mobile/views/PortsView.tsx msgid "Port" @@ -8186,6 +8211,7 @@ msgstr "Opcje środowiska wykonawczego" #: src/renderer/views/FileEditorOverlay/parts/FileEditorPane/parts/EditorToolbar.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Save" msgstr "Zapisz" @@ -8207,6 +8233,10 @@ msgstr "Zapisz profil Claude'a" msgid "Save File" msgstr "Zapisz plik" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Save MCP servers" +msgstr "Zapisz serwery MCP" + #: src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx msgid "Save shortcut" msgstr "Zapisz skrót" @@ -9694,6 +9724,10 @@ msgstr "Motyw" msgid "Theme and chat font size" msgstr "Motyw i rozmiar czcionki czatu" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "These built-in MCP servers are shared by OpenCode threads. Saving updates running GUI projects; removing a server can interrupt an active turn. Terminal threads pick up changes on their next launch." +msgstr "Te wbudowane serwery MCP są współdzielone przez wątki OpenCode. Zapisanie aktualizuje działające projekty GUI; usunięcie serwera może przerwać aktywną turę. Wątki terminala pobiorą zmiany przy następnym uruchomieniu." + #: src/renderer/components/common/EffortContextMenu/EffortContextMenu.tsx #: src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx #: src/renderer/components/thread/ChatPane/parts/items/ReasoningInline.tsx diff --git a/src/renderer/locales/pt-BR/messages.po b/src/renderer/locales/pt-BR/messages.po index eb2419fa..216d908e 100644 --- a/src/renderer/locales/pt-BR/messages.po +++ b/src/renderer/locales/pt-BR/messages.po @@ -1469,6 +1469,7 @@ msgstr "Navegue pelos seus repositórios do GitHub ou cole uma URL de clonagem." #: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx #: src/renderer/views/MainView/parts/SidebarHeaderControls.tsx #: src/renderer/views/SettingsOverlay/parts/BrowserSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx msgid "Browser" msgstr "Navegador" @@ -1840,6 +1841,7 @@ msgstr "Escolher pasta base da árvore de trabalho" #: src/renderer/components/composer/composerMcpServers.ts #: src/renderer/components/mcp/McpServersManager.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Chrome" msgstr "Chrome" @@ -2357,6 +2359,7 @@ msgstr "Controles do Composer" #: src/renderer/components/mcp/McpServersManager.tsx #: src/renderer/components/thread/ThreadComposerSection.tsx #: src/renderer/components/thread/ThreadDraftComposerArea.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Computer Use" msgstr "Uso do computador" @@ -2547,6 +2550,14 @@ msgstr "Continuar em outro provedor" msgid "Continue in..." msgstr "Continuar em..." +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Control an external Chrome browser." +msgstr "Controla um navegador Chrome externo." + +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Control the desktop." +msgstr "Controla a área de trabalho." + #: src/renderer/views/SettingsOverlay/parts/SearchSettings.tsx msgid "Control which files appear in the @file mention search. Per-project overrides live in each project's settings." msgstr "Controle quais arquivos aparecem na pesquisa de menção @file. As substituições por projeto estão disponíveis nas configurações de cada projeto." @@ -2910,6 +2921,7 @@ msgstr "Crossagent: {subagent}" #: src/renderer/components/mcp/McpServersManager.tsx #: src/renderer/components/thread/ChatPane/parts/items/ActiveSubAgentTile.tsx #: src/renderer/views/SettingsOverlay/parts/McpServersSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Crossagents" msgstr "Crossagents" @@ -3031,6 +3043,10 @@ msgstr "Permissões padrão" msgid "Default thread removal" msgstr "Remoção de thread padrão" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Delegate work to other AI agents." +msgstr "Delegue o trabalho a outros agentes de IA." + #. Thread remove action: delete permanently #: src/mobile/views/FilesView.tsx #: src/renderer/components/common/BranchSelector/BranchSelector.tsx @@ -5370,6 +5386,7 @@ msgid "MCP server URL" msgstr "URL do servidor MCP" #: src/renderer/components/composer/ComposerAddMenu.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/ProfileSettings.tsx msgid "MCP servers" msgstr "Servidores MCP" @@ -5382,6 +5399,10 @@ msgstr "Servidores MCP" msgid "MCP Servers" msgstr "Servidores MCP" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "MCP servers updated." +msgstr "Servidores MCP atualizados." + #: src/renderer/views/ProfileOverlay/parts/ActivityInsights.tsx msgid "MCP tool calls" msgstr "Chamadas de ferramentas MCP" @@ -7052,6 +7073,10 @@ msgstr "Poracode instalará ou reutilizará o servidor no host SSH e manterá as msgid "Poracode will restart, back up its current data, and replace it with a complete copy of your Lightcode data." msgstr "O Poracode será reiniciado, fará backup dos dados atuais e os substituirá por uma cópia completa dos dados do Lightcode." +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Poracode's built-in browser tools." +msgstr "As ferramentas de navegador integradas do Poracode." + #: src/mobile/views/DesktopsView.tsx #: src/mobile/views/PortsView.tsx msgid "Port" @@ -8186,6 +8211,7 @@ msgstr "Opções de tempo de execução" #: src/renderer/views/FileEditorOverlay/parts/FileEditorPane/parts/EditorToolbar.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Save" msgstr "Salvar" @@ -8207,6 +8233,10 @@ msgstr "Salvar perfil do Claude" msgid "Save File" msgstr "Salvar arquivo" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Save MCP servers" +msgstr "Salvar servidores MCP" + #: src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx msgid "Save shortcut" msgstr "Salvar atalho" @@ -9694,6 +9724,10 @@ msgstr "Tema" msgid "Theme and chat font size" msgstr "Tema e tamanho da fonte do chat" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "These built-in MCP servers are shared by OpenCode threads. Saving updates running GUI projects; removing a server can interrupt an active turn. Terminal threads pick up changes on their next launch." +msgstr "Esses servidores MCP integrados são compartilhados entre as conversas do OpenCode. Salvar atualiza os projetos da interface gráfica em execução; remover um servidor pode interromper uma execução ativa. As conversas do terminal recebem as alterações na próxima inicialização." + #: src/renderer/components/common/EffortContextMenu/EffortContextMenu.tsx #: src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx #: src/renderer/components/thread/ChatPane/parts/items/ReasoningInline.tsx diff --git a/src/renderer/locales/ru/messages.po b/src/renderer/locales/ru/messages.po index 8b6d43bf..62b1a95d 100644 --- a/src/renderer/locales/ru/messages.po +++ b/src/renderer/locales/ru/messages.po @@ -1469,6 +1469,7 @@ msgstr "Просмотрите свои репозитории GitHub или в #: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx #: src/renderer/views/MainView/parts/SidebarHeaderControls.tsx #: src/renderer/views/SettingsOverlay/parts/BrowserSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx msgid "Browser" msgstr "Браузер" @@ -1840,6 +1841,7 @@ msgstr "Выбрать базовую папку worktree" #: src/renderer/components/composer/composerMcpServers.ts #: src/renderer/components/mcp/McpServersManager.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Chrome" msgstr "Chrome" @@ -2357,6 +2359,7 @@ msgstr "Управление полем ввода" #: src/renderer/components/mcp/McpServersManager.tsx #: src/renderer/components/thread/ThreadComposerSection.tsx #: src/renderer/components/thread/ThreadDraftComposerArea.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Computer Use" msgstr "Управление компьютером" @@ -2547,6 +2550,14 @@ msgstr "Продолжить в другом провайдере" msgid "Continue in..." msgstr "Продолжить в..." +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Control an external Chrome browser." +msgstr "Управляет внешним браузером Chrome." + +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Control the desktop." +msgstr "Управляет рабочим столом." + #: src/renderer/views/SettingsOverlay/parts/SearchSettings.tsx msgid "Control which files appear in the @file mention search. Per-project overrides live in each project's settings." msgstr "Управляйте тем, какие файлы появляются в поиске по упоминанию @file. Переопределения для каждого проекта находятся в настройках соответствующего проекта." @@ -2910,6 +2921,7 @@ msgstr "Crossagent: {subagent}" #: src/renderer/components/mcp/McpServersManager.tsx #: src/renderer/components/thread/ChatPane/parts/items/ActiveSubAgentTile.tsx #: src/renderer/views/SettingsOverlay/parts/McpServersSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Crossagents" msgstr "Crossagents" @@ -3031,6 +3043,10 @@ msgstr "Разрешения по умолчанию" msgid "Default thread removal" msgstr "Удаление тредов по умолчанию" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Delegate work to other AI agents." +msgstr "Делегируйте работу другим ИИ-агентам." + #. Thread remove action: delete permanently #: src/mobile/views/FilesView.tsx #: src/renderer/components/common/BranchSelector/BranchSelector.tsx @@ -5370,6 +5386,7 @@ msgid "MCP server URL" msgstr "URL сервера MCP" #: src/renderer/components/composer/ComposerAddMenu.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/ProfileSettings.tsx msgid "MCP servers" msgstr "Серверы MCP" @@ -5382,6 +5399,10 @@ msgstr "Серверы MCP" msgid "MCP Servers" msgstr "Серверы MCP" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "MCP servers updated." +msgstr "Серверы MCP обновлены." + #: src/renderer/views/ProfileOverlay/parts/ActivityInsights.tsx msgid "MCP tool calls" msgstr "Вызовы инструментов MCP" @@ -7052,6 +7073,10 @@ msgstr "Poracode установит или повторно использует msgid "Poracode will restart, back up its current data, and replace it with a complete copy of your Lightcode data." msgstr "Poracode перезапустится, создаст резервную копию текущих данных и заменит их полной копией данных Lightcode." +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Poracode's built-in browser tools." +msgstr "Встроенные инструменты браузера Poracode." + #: src/mobile/views/DesktopsView.tsx #: src/mobile/views/PortsView.tsx msgid "Port" @@ -8186,6 +8211,7 @@ msgstr "Параметры среды выполнения" #: src/renderer/views/FileEditorOverlay/parts/FileEditorPane/parts/EditorToolbar.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Save" msgstr "Сохранить" @@ -8207,6 +8233,10 @@ msgstr "Сохранить профиль Claude" msgid "Save File" msgstr "Сохранить файл" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Save MCP servers" +msgstr "Сохранить серверы MCP" + #: src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx msgid "Save shortcut" msgstr "Сохранить сочетание клавиш" @@ -9694,6 +9724,10 @@ msgstr "Тема" msgid "Theme and chat font size" msgstr "Тема и размер шрифта чата" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "These built-in MCP servers are shared by OpenCode threads. Saving updates running GUI projects; removing a server can interrupt an active turn. Terminal threads pick up changes on their next launch." +msgstr "Эти встроенные серверы MCP используются совместно потоками OpenCode. При сохранении обновляются запущенные GUI-проекты; удаление сервера может прервать активный ход. Потоки терминала получат изменения при следующем запуске." + #: src/renderer/components/common/EffortContextMenu/EffortContextMenu.tsx #: src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx #: src/renderer/components/thread/ChatPane/parts/items/ReasoningInline.tsx diff --git a/src/renderer/locales/tr/messages.po b/src/renderer/locales/tr/messages.po index 7d00a47e..422daf8b 100644 --- a/src/renderer/locales/tr/messages.po +++ b/src/renderer/locales/tr/messages.po @@ -1469,6 +1469,7 @@ msgstr "GitHub depolarınıza göz atın veya bir klon URL'si yapıştırın." #: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx #: src/renderer/views/MainView/parts/SidebarHeaderControls.tsx #: src/renderer/views/SettingsOverlay/parts/BrowserSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx msgid "Browser" msgstr "Tarayıcı" @@ -1840,6 +1841,7 @@ msgstr "Çalışma ağacı temel klasörünü seçin" #: src/renderer/components/composer/composerMcpServers.ts #: src/renderer/components/mcp/McpServersManager.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Chrome" msgstr "Chrome" @@ -2357,6 +2359,7 @@ msgstr "Composer denetimleri" #: src/renderer/components/mcp/McpServersManager.tsx #: src/renderer/components/thread/ThreadComposerSection.tsx #: src/renderer/components/thread/ThreadDraftComposerArea.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Computer Use" msgstr "Bilgisayar Kullanımı" @@ -2547,6 +2550,14 @@ msgstr "Başka bir sağlayıcıda devam et" msgid "Continue in..." msgstr "Şurada devam et..." +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Control an external Chrome browser." +msgstr "Harici bir Chrome tarayıcısını kontrol eder." + +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Control the desktop." +msgstr "Masaüstünü kontrol eder." + #: src/renderer/views/SettingsOverlay/parts/SearchSettings.tsx msgid "Control which files appear in the @file mention search. Per-project overrides live in each project's settings." msgstr "@file bahsetme aramasında hangi dosyaların görüneceğini kontrol edin. Projeye özel geçersiz kılmalar her projenin ayarlarında bulunur." @@ -2910,6 +2921,7 @@ msgstr "Crossagent: {subagent}" #: src/renderer/components/mcp/McpServersManager.tsx #: src/renderer/components/thread/ChatPane/parts/items/ActiveSubAgentTile.tsx #: src/renderer/views/SettingsOverlay/parts/McpServersSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Crossagents" msgstr "Crossagents" @@ -3031,6 +3043,10 @@ msgstr "Varsayılan izinler" msgid "Default thread removal" msgstr "Varsayılan konu kaldırma" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Delegate work to other AI agents." +msgstr "İşi diğer yapay zeka aracılarına devredin." + #. Thread remove action: delete permanently #: src/mobile/views/FilesView.tsx #: src/renderer/components/common/BranchSelector/BranchSelector.tsx @@ -5370,6 +5386,7 @@ msgid "MCP server URL" msgstr "MCP sunucusu URL'si" #: src/renderer/components/composer/ComposerAddMenu.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/ProfileSettings.tsx msgid "MCP servers" msgstr "MCP sunucuları" @@ -5382,6 +5399,10 @@ msgstr "MCP sunucuları" msgid "MCP Servers" msgstr "MCP sunucuları" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "MCP servers updated." +msgstr "MCP sunucuları güncellendi." + #: src/renderer/views/ProfileOverlay/parts/ActivityInsights.tsx msgid "MCP tool calls" msgstr "MCP araç çağrıları" @@ -7052,6 +7073,10 @@ msgstr "Poracode, sunucusunu SSH ana makinesine yükler veya mevcut sunucuyu yen msgid "Poracode will restart, back up its current data, and replace it with a complete copy of your Lightcode data." msgstr "Poracode yeniden başlatılacak, mevcut verilerini yedekleyecek ve bunları Lightcode verilerinizin tam bir kopyasıyla değiştirecek." +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Poracode's built-in browser tools." +msgstr "Poracode'un yerleşik tarayıcı araçları." + #: src/mobile/views/DesktopsView.tsx #: src/mobile/views/PortsView.tsx msgid "Port" @@ -8186,6 +8211,7 @@ msgstr "Çalışma zamanı seçenekleri" #: src/renderer/views/FileEditorOverlay/parts/FileEditorPane/parts/EditorToolbar.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Save" msgstr "Kaydet" @@ -8207,6 +8233,10 @@ msgstr "Claude profilini kaydet" msgid "Save File" msgstr "Dosyayı Kaydet" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Save MCP servers" +msgstr "MCP sunucularını kaydet" + #: src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx msgid "Save shortcut" msgstr "Kısayolu kaydet" @@ -9694,6 +9724,10 @@ msgstr "Tema" msgid "Theme and chat font size" msgstr "Tema ve sohbet yazı tipi boyutu" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "These built-in MCP servers are shared by OpenCode threads. Saving updates running GUI projects; removing a server can interrupt an active turn. Terminal threads pick up changes on their next launch." +msgstr "Bu yerleşik MCP sunucuları OpenCode konuları arasında paylaşılır. Kaydetme, çalışan GUI projelerini günceller; bir sunucuyu kaldırmak etkin bir turu kesintiye uğratabilir. Terminal konuları değişiklikleri bir sonraki başlatmada alır." + #: src/renderer/components/common/EffortContextMenu/EffortContextMenu.tsx #: src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx #: src/renderer/components/thread/ChatPane/parts/items/ReasoningInline.tsx diff --git a/src/renderer/locales/uk/messages.po b/src/renderer/locales/uk/messages.po index e13c89eb..bfae5c74 100644 --- a/src/renderer/locales/uk/messages.po +++ b/src/renderer/locales/uk/messages.po @@ -1469,6 +1469,7 @@ msgstr "Перегляньте свої репозиторії GitHub або в #: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx #: src/renderer/views/MainView/parts/SidebarHeaderControls.tsx #: src/renderer/views/SettingsOverlay/parts/BrowserSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx msgid "Browser" msgstr "Браузер" @@ -1840,6 +1841,7 @@ msgstr "Виберіть базову папку для worktree" #: src/renderer/components/composer/composerMcpServers.ts #: src/renderer/components/mcp/McpServersManager.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Chrome" msgstr "Chrome" @@ -2357,6 +2359,7 @@ msgstr "Керування полем вводу" #: src/renderer/components/mcp/McpServersManager.tsx #: src/renderer/components/thread/ThreadComposerSection.tsx #: src/renderer/components/thread/ThreadDraftComposerArea.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Computer Use" msgstr "Керування комп'ютером" @@ -2547,6 +2550,14 @@ msgstr "Продовжити в іншому провайдері" msgid "Continue in..." msgstr "Продовжити в..." +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Control an external Chrome browser." +msgstr "Керує зовнішнім браузером Chrome." + +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Control the desktop." +msgstr "Керує робочим столом." + #: src/renderer/views/SettingsOverlay/parts/SearchSettings.tsx msgid "Control which files appear in the @file mention search. Per-project overrides live in each project's settings." msgstr "Керуйте тим, які файли з'являються у пошуку згадок @file. Перевизначення для окремих проєктів зберігаються в налаштуваннях кожного проєкту." @@ -2910,6 +2921,7 @@ msgstr "Crossagent: {subagent}" #: src/renderer/components/mcp/McpServersManager.tsx #: src/renderer/components/thread/ChatPane/parts/items/ActiveSubAgentTile.tsx #: src/renderer/views/SettingsOverlay/parts/McpServersSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Crossagents" msgstr "Crossagents" @@ -3031,6 +3043,10 @@ msgstr "Дозволи за замовчуванням" msgid "Default thread removal" msgstr "Типове видалення треду" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Delegate work to other AI agents." +msgstr "Делегуйте роботу іншим ШІ-агентам." + #. Thread remove action: delete permanently #: src/mobile/views/FilesView.tsx #: src/renderer/components/common/BranchSelector/BranchSelector.tsx @@ -5370,6 +5386,7 @@ msgid "MCP server URL" msgstr "URL сервера MCP" #: src/renderer/components/composer/ComposerAddMenu.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/ProfileSettings.tsx msgid "MCP servers" msgstr "Сервери MCP" @@ -5382,6 +5399,10 @@ msgstr "Сервери MCP" msgid "MCP Servers" msgstr "Сервери MCP" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "MCP servers updated." +msgstr "Сервери MCP оновлено." + #: src/renderer/views/ProfileOverlay/parts/ActivityInsights.tsx msgid "MCP tool calls" msgstr "Виклики інструментів MCP" @@ -7052,6 +7073,10 @@ msgstr "Poracode встановить або повторно використа msgid "Poracode will restart, back up its current data, and replace it with a complete copy of your Lightcode data." msgstr "Poracode перезапуститься, створить резервну копію поточних даних і замінить їх повною копією даних Lightcode." +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Poracode's built-in browser tools." +msgstr "Вбудовані інструменти браузера Poracode." + #: src/mobile/views/DesktopsView.tsx #: src/mobile/views/PortsView.tsx msgid "Port" @@ -8186,6 +8211,7 @@ msgstr "Параметри середовища виконання" #: src/renderer/views/FileEditorOverlay/parts/FileEditorPane/parts/EditorToolbar.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Save" msgstr "Зберегти" @@ -8207,6 +8233,10 @@ msgstr "Зберегти профіль Claude" msgid "Save File" msgstr "Зберегти файл" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Save MCP servers" +msgstr "Зберегти сервери MCP" + #: src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx msgid "Save shortcut" msgstr "Зберегти комбінацію клавіш" @@ -9694,6 +9724,10 @@ msgstr "Тема" msgid "Theme and chat font size" msgstr "Тема и размер шрифта чата" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "These built-in MCP servers are shared by OpenCode threads. Saving updates running GUI projects; removing a server can interrupt an active turn. Terminal threads pick up changes on their next launch." +msgstr "Ці вбудовані сервери MCP спільно використовуються потоками OpenCode. Збереження оновлює запущені GUI-проєкти; видалення сервера може перервати активний хід. Потоки термінала отримають зміни під час наступного запуску." + #: src/renderer/components/common/EffortContextMenu/EffortContextMenu.tsx #: src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx #: src/renderer/components/thread/ChatPane/parts/items/ReasoningInline.tsx diff --git a/src/renderer/locales/vi/messages.po b/src/renderer/locales/vi/messages.po index ebdb6c48..76e9b74d 100644 --- a/src/renderer/locales/vi/messages.po +++ b/src/renderer/locales/vi/messages.po @@ -1469,6 +1469,7 @@ msgstr "Duyệt qua kho GitHub của bạn hoặc dán URL nhân bản." #: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx #: src/renderer/views/MainView/parts/SidebarHeaderControls.tsx #: src/renderer/views/SettingsOverlay/parts/BrowserSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx msgid "Browser" msgstr "Trình duyệt" @@ -1840,6 +1841,7 @@ msgstr "Chọn thư mục gốc của cây làm việc" #: src/renderer/components/composer/composerMcpServers.ts #: src/renderer/components/mcp/McpServersManager.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Chrome" msgstr "Chrome" @@ -2357,6 +2359,7 @@ msgstr "Điều khiển trình soạn thảo" #: src/renderer/components/mcp/McpServersManager.tsx #: src/renderer/components/thread/ThreadComposerSection.tsx #: src/renderer/components/thread/ThreadDraftComposerArea.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Computer Use" msgstr "Sử dụng máy tính" @@ -2547,6 +2550,14 @@ msgstr "Tiếp tục ở nhà cung cấp khác" msgid "Continue in..." msgstr "Tiếp tục trong..." +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Control an external Chrome browser." +msgstr "Điều khiển trình duyệt Chrome bên ngoài." + +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Control the desktop." +msgstr "Điều khiển máy tính." + #: src/renderer/views/SettingsOverlay/parts/SearchSettings.tsx msgid "Control which files appear in the @file mention search. Per-project overrides live in each project's settings." msgstr "Kiểm soát những tập tin nào xuất hiện trong tìm kiếm đề cập đến @file. Ghi đè cho mỗi dự án trực tiếp trong cài đặt của từng dự án." @@ -2910,6 +2921,7 @@ msgstr "Crossagent: {subagent}" #: src/renderer/components/mcp/McpServersManager.tsx #: src/renderer/components/thread/ChatPane/parts/items/ActiveSubAgentTile.tsx #: src/renderer/views/SettingsOverlay/parts/McpServersSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Crossagents" msgstr "Crossagents" @@ -3031,6 +3043,10 @@ msgstr "Quyền mặc định" msgid "Default thread removal" msgstr "Hành vi xóa chủ đề mặc định" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Delegate work to other AI agents." +msgstr "Giao việc cho các tác nhân AI khác." + #. Thread remove action: delete permanently #: src/mobile/views/FilesView.tsx #: src/renderer/components/common/BranchSelector/BranchSelector.tsx @@ -5370,6 +5386,7 @@ msgid "MCP server URL" msgstr "URL của máy chủ MCP" #: src/renderer/components/composer/ComposerAddMenu.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/ProfileSettings.tsx msgid "MCP servers" msgstr "Máy chủ MCP" @@ -5382,6 +5399,10 @@ msgstr "Máy chủ MCP" msgid "MCP Servers" msgstr "Máy chủ MCP" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "MCP servers updated." +msgstr "Đã cập nhật máy chủ MCP." + #: src/renderer/views/ProfileOverlay/parts/ActivityInsights.tsx msgid "MCP tool calls" msgstr "Lượt gọi công cụ MCP" @@ -7052,6 +7073,10 @@ msgstr "Poracode sẽ cài đặt hoặc tái sử dụng máy chủ trên máy msgid "Poracode will restart, back up its current data, and replace it with a complete copy of your Lightcode data." msgstr "Poracode sẽ khởi động lại, sao lưu dữ liệu hiện tại và thay thế bằng bản sao đầy đủ của dữ liệu Lightcode." +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Poracode's built-in browser tools." +msgstr "Công cụ trình duyệt tích hợp sẵn của Poracode." + #: src/mobile/views/DesktopsView.tsx #: src/mobile/views/PortsView.tsx msgid "Port" @@ -8186,6 +8211,7 @@ msgstr "Tùy chọn thời gian chạy" #: src/renderer/views/FileEditorOverlay/parts/FileEditorPane/parts/EditorToolbar.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Save" msgstr "Lưu" @@ -8207,6 +8233,10 @@ msgstr "Lưu hồ sơ Claude" msgid "Save File" msgstr "Lưu tập tin" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Save MCP servers" +msgstr "Lưu máy chủ MCP" + #: src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx msgid "Save shortcut" msgstr "Lưu phím tắt" @@ -9694,6 +9724,10 @@ msgstr "Chủ đề" msgid "Theme and chat font size" msgstr "Giao diện và cỡ chữ chat" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "These built-in MCP servers are shared by OpenCode threads. Saving updates running GUI projects; removing a server can interrupt an active turn. Terminal threads pick up changes on their next launch." +msgstr "Các máy chủ MCP tích hợp sẵn này được chia sẻ giữa các luồng OpenCode. Việc lưu sẽ cập nhật các dự án GUI đang chạy; xóa một máy chủ có thể làm gián đoạn lượt đang hoạt động. Các luồng terminal sẽ nhận thay đổi ở lần khởi chạy tiếp theo." + #: src/renderer/components/common/EffortContextMenu/EffortContextMenu.tsx #: src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx #: src/renderer/components/thread/ChatPane/parts/items/ReasoningInline.tsx diff --git a/src/renderer/locales/zh-CN/messages.po b/src/renderer/locales/zh-CN/messages.po index 1ea115f4..fde2efff 100644 --- a/src/renderer/locales/zh-CN/messages.po +++ b/src/renderer/locales/zh-CN/messages.po @@ -1469,6 +1469,7 @@ msgstr "浏览您的 GitHub 存储库或粘贴克隆 URL。" #: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx #: src/renderer/views/MainView/parts/SidebarHeaderControls.tsx #: src/renderer/views/SettingsOverlay/parts/BrowserSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx msgid "Browser" msgstr "浏览器" @@ -1840,6 +1841,7 @@ msgstr "选择工作树基础文件夹" #: src/renderer/components/composer/composerMcpServers.ts #: src/renderer/components/mcp/McpServersManager.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Chrome" msgstr "Chrome" @@ -2357,6 +2359,7 @@ msgstr "Composer 控件" #: src/renderer/components/mcp/McpServersManager.tsx #: src/renderer/components/thread/ThreadComposerSection.tsx #: src/renderer/components/thread/ThreadDraftComposerArea.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Computer Use" msgstr "计算机操作" @@ -2547,6 +2550,14 @@ msgstr "继续使用另一个提供商" msgid "Continue in..." msgstr "在其他工具中继续..." +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Control an external Chrome browser." +msgstr "控制外部 Chrome 浏览器。" + +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Control the desktop." +msgstr "控制桌面。" + #: src/renderer/views/SettingsOverlay/parts/SearchSettings.tsx msgid "Control which files appear in the @file mention search. Per-project overrides live in each project's settings." msgstr "控制哪些文件出现在 @file 提及搜索中。可在各项目的设置中进行项目级覆盖。" @@ -2910,6 +2921,7 @@ msgstr "Crossagent:{subagent}" #: src/renderer/components/mcp/McpServersManager.tsx #: src/renderer/components/thread/ChatPane/parts/items/ActiveSubAgentTile.tsx #: src/renderer/views/SettingsOverlay/parts/McpServersSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx msgid "Crossagents" msgstr "Crossagents" @@ -3031,6 +3043,10 @@ msgstr "默认权限" msgid "Default thread removal" msgstr "默认线程删除" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Delegate work to other AI agents." +msgstr "将工作委派给其他 AI 智能体。" + #. Thread remove action: delete permanently #: src/mobile/views/FilesView.tsx #: src/renderer/components/common/BranchSelector/BranchSelector.tsx @@ -5370,6 +5386,7 @@ msgid "MCP server URL" msgstr "MCP 服务器 URL" #: src/renderer/components/composer/ComposerAddMenu.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/ProfileSettings.tsx msgid "MCP servers" msgstr "MCP服务器" @@ -5382,6 +5399,10 @@ msgstr "MCP服务器" msgid "MCP Servers" msgstr "MCP 服务器" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "MCP servers updated." +msgstr "MCP 服务器已更新。" + #: src/renderer/views/ProfileOverlay/parts/ActivityInsights.tsx msgid "MCP tool calls" msgstr "MCP 工具调用" @@ -7051,6 +7072,10 @@ msgstr "Poracode 将在 SSH 主机上安装或复用其服务器,并将凭据 msgid "Poracode will restart, back up its current data, and replace it with a complete copy of your Lightcode data." msgstr "Poracode 将重新启动,备份当前数据,并用 Lightcode 数据的完整副本替换当前数据。" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Poracode's built-in browser tools." +msgstr "Poracode 内置的浏览器工具。" + #: src/mobile/views/DesktopsView.tsx #: src/mobile/views/PortsView.tsx msgid "Port" @@ -8185,6 +8210,7 @@ msgstr "运行时选项" #: src/renderer/views/FileEditorOverlay/parts/FileEditorPane/parts/EditorToolbar.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Save" msgstr "保存" @@ -8206,6 +8232,10 @@ msgstr "保存 Claude 配置文件" msgid "Save File" msgstr "保存文件" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "Save MCP servers" +msgstr "保存 MCP 服务器" + #: src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx msgid "Save shortcut" msgstr "保存快捷键" @@ -9693,6 +9723,10 @@ msgstr "主题" msgid "Theme and chat font size" msgstr "主题和聊天字号" +#: src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.tsx +msgid "These built-in MCP servers are shared by OpenCode threads. Saving updates running GUI projects; removing a server can interrupt an active turn. Terminal threads pick up changes on their next launch." +msgstr "这些内置 MCP 服务器由 OpenCode 线程共享。保存后会更新正在运行的 GUI 项目;移除服务器可能会中断进行中的轮次。终端线程将在下次启动时获取更改。" + #: src/renderer/components/common/EffortContextMenu/EffortContextMenu.tsx #: src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx #: src/renderer/components/thread/ChatPane/parts/items/ReasoningInline.tsx diff --git a/src/renderer/state/sharedSettingsStore.ts b/src/renderer/state/sharedSettingsStore.ts index d8f18880..751f1cf3 100644 --- a/src/renderer/state/sharedSettingsStore.ts +++ b/src/renderer/state/sharedSettingsStore.ts @@ -194,6 +194,20 @@ function persistSettings(settings: SharedSettingsInput): void { } } +/** + * Awaitable counterpart to `persistSettings`'s fire-and-forget bridge write. + * Callers that need a guarantee the settings file reflects the latest store + * state before triggering IPC that re-reads it from disk (e.g. reloading a + * provider's live MCP servers) should `await` this first — otherwise a fast + * follow-up call can race the write above and observe stale settings. + */ +export async function flushSharedSettings(): Promise { + if (!hasBridge() || !initialLoadDone) { + return; + } + await readBridge().setSharedSettings(selectSharedSettings(useSharedSettings.getState())); +} + function cacheSettingsSnapshot(settings: SharedSettingsInput): void { if (typeof window === "undefined") { return; diff --git a/src/renderer/state/subAgentSelectors.ts b/src/renderer/state/subAgentSelectors.ts index c7025920..fb16e543 100644 --- a/src/renderer/state/subAgentSelectors.ts +++ b/src/renderer/state/subAgentSelectors.ts @@ -18,7 +18,7 @@ function classifyActiveSubAgent(item: RuntimeChatItem): "workflow" | "native" | if (payload.status === "error" || item.observedLive !== true) return null; return "workflow"; } - if (item.state === "completed" && payload.status !== "running") return null; + if (item.state === "completed") return null; return "native"; } diff --git a/src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.test.tsx b/src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.test.tsx index 5ff834e6..4b538653 100644 --- a/src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.test.tsx +++ b/src/renderer/views/SettingsOverlay/parts/OpenCodeProviderSettings.test.tsx @@ -4,6 +4,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentStatus } from "@/shared/contracts"; import { renderWithI18n as render } from "@/renderer/testUtils/i18n"; +const toastMock = vi.hoisted(() => ({ + danger: vi.fn<(message: string) => void>(), + success: vi.fn<(message: string) => void>(), +})); + vi.mock("@heroui/react", () => ({ Button: (props: { children?: ReactNode; @@ -21,6 +26,7 @@ vi.mock("@heroui/react", () => ({ {props.children} ), + toast: toastMock, })); const runAgentLoginCommandMock = vi.hoisted(() => @@ -37,20 +43,48 @@ vi.mock("@/renderer/actions/agentLoginActions", () => ({ runAgentLoginCommand: runAgentLoginCommandMock, })); -const refreshAgentStatusesMock = vi.hoisted(() => vi.fn<() => Promise>()); +const bridgeMock = vi.hoisted(() => ({ + platform: "win32" as "linux" | "win32" | "darwin", + refreshAgentStatuses: vi.fn<() => Promise>(), + reloadAgentMcpServers: vi.fn<(input: { agentKind: string }) => Promise>(), +})); + +const flushSharedSettingsMock = vi.hoisted(() => vi.fn<() => Promise>()); +const setAgentSettingMock = vi.hoisted(() => + vi.fn<(agentKind: string, key: string, value: boolean | string) => void>(), +); vi.mock("@/renderer/bridge", () => ({ - readBridge: () => ({ refreshAgentStatuses: refreshAgentStatusesMock }), + readBridge: () => bridgeMock, })); vi.mock("@/renderer/components/common", () => ({ PixelLoader: () => , + ToggleSwitch: (props: { + "aria-label"?: string; + isSelected: boolean; + onChange: (value: boolean) => void; + }) => ( + - - - {providers.length === 0 ? ( -

- No providers connected yet. -

- ) : ( -
- {providers.map((provider, index) => { - const key = providerActionKey(provider, index); - return ( -
-
- - - {provider.label} - - {provider.detail ? ( - - {provider.detail} + {providers.length === 0 ? ( +

+ No providers connected yet. +

+ ) : ( +
+ {providers.map((provider, index) => { + const key = providerActionKey(provider, index); + return ( +
+
+ + + {provider.label} - ) : null} + {provider.detail ? ( + + {provider.detail} + + ) : null} +
+
- -
- ); - })} + ); + })} +
+ )} +
+ +
+
+
+

+ MCP servers +

+

+ + These built-in MCP servers are shared by OpenCode threads. Saving updates running + GUI projects; removing a server can interrupt an active turn. Terminal threads pick + up changes on their next launch. + +

+
+ +
+ +
+ Poracode's built-in browser tools.} + isSelected={draftMcp.browserMcp} + onChange={(value) => setDraftMcp((current) => ({ ...current, browserMcp: value }))} + /> + Delegate work to other AI agents.} + isSelected={draftMcp.crossagentMcp} + onChange={(value) => setDraftMcp((current) => ({ ...current, crossagentMcp: value }))} + /> + Control an external Chrome browser.} + isSelected={draftMcp.chromeMcp} + onChange={(value) => setDraftMcp((current) => ({ ...current, chromeMcp: value }))} + /> + {showComputerUse ? ( + Control the desktop.} + isSelected={draftMcp.computerUse} + onChange={(value) => setDraftMcp((current) => ({ ...current, computerUse: value }))} + /> + ) : null}
- )} -
+
+ + ); +} + +function McpToggleRow(props: { + title: string; + description: ReactNode; + isSelected: boolean; + onChange: (value: boolean) => void; +}) { + return ( + + + ); } diff --git a/src/shared/contracts/agent.ts b/src/shared/contracts/agent.ts index f1b3533e..a1977d58 100644 --- a/src/shared/contracts/agent.ts +++ b/src/shared/contracts/agent.ts @@ -269,6 +269,28 @@ export const agentCapabilitySchema = z.object({ bypassPermissions: bypassPermissionsSchema.optional(), /** Composer MCP toggle gating for every server. */ mcpScope: composerMcpScopesSchema.optional(), + /** + * Where the provider's MCP launch flags come from: + * + * - absent / "thread": per-thread `ThreadConfig` flags set by the composer + * MCP controls (the default for every provider). + * - "agentSettings": provider-level — flags are read from + * `sharedSettings.agentSettings[kind]` at launch, the composer shows no + * MCP controls at all, and the MCP set is identical across the provider's + * threads (letting pooled servers stay stable). Configured from the + * provider's settings page. + */ + mcpConfigSource: z.enum(["thread", "agentSettings"]).optional(), + /** + * How Crossagents identifies the calling parent thread: + * + * - absent / "thread-token": the MCP bearer token maps directly to one + * Poracode thread (the default for provider processes launched per thread). + * - "provider-session": one provider-runtime credential is shared, and a + * trusted provider hook adds the native session id to every tool call so + * the supervisor can resolve the live parent thread at call time. + */ + crossagentMcpRouting: z.enum(["thread-token", "provider-session"]).optional(), settingDefs: z.array(agentSettingDefSchema).default([]), /** Populated when the Claude Agent SDK init probe succeeds (install detection). */ slashCommands: z.array(agentSlashCommandSchema).optional(), diff --git a/src/shared/contracts/mcpServer.ts b/src/shared/contracts/mcpServer.ts index a18f87a6..9f446fe7 100644 --- a/src/shared/contracts/mcpServer.ts +++ b/src/shared/contracts/mcpServer.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { projectLocationSchema } from "./common"; +import { agentKindSchema, projectLocationSchema } from "./common"; export const DEFAULT_MCP_SERVER_TIMEOUT_MS = 30_000; @@ -313,6 +313,16 @@ export const mcpOauthStatusResultSchema = z.object({ }); export type McpOauthStatusResult = z.infer; +/** + * Re-resolve and apply the MCP set of a provider's live sessions after its + * provider-level MCP settings changed (`mcpConfigSource: "agentSettings"`). + * Sessions without a live update hook pick the new set up on next launch. + */ +export const reloadAgentMcpServersPayloadSchema = z.object({ + agentKind: agentKindSchema, +}); +export type ReloadAgentMcpServersPayload = z.infer; + export const mcpProbePayloadSchema = z.object({ server: mcpServerSchema, /** diff --git a/src/shared/ipc/procedures/mcp.ts b/src/shared/ipc/procedures/mcp.ts index c1a9162a..951831f4 100644 --- a/src/shared/ipc/procedures/mcp.ts +++ b/src/shared/ipc/procedures/mcp.ts @@ -14,6 +14,8 @@ import { mcpProbePayloadSchema, type McpProbePayload, type McpProbeResult, + reloadAgentMcpServersPayloadSchema, + type ReloadAgentMcpServersPayload, } from "../../contracts"; import { defineNoArgProcedure, definePayloadProcedure } from "../core"; @@ -28,6 +30,11 @@ export const mcpProcedures = { "supervisor", mcpProbePayloadSchema, ), + reloadAgentMcpServers: definePayloadProcedure( + "reloadAgentMcpServers", + "supervisor", + reloadAgentMcpServersPayloadSchema, + ), beginMcpServerOauth: definePayloadProcedure< McpOauthBeginPayload, McpOauthBeginResult, diff --git a/src/supervisor/agents/base/types.ts b/src/supervisor/agents/base/types.ts index 45bcabc3..22274330 100644 --- a/src/supervisor/agents/base/types.ts +++ b/src/supervisor/agents/base/types.ts @@ -96,6 +96,8 @@ export interface ThreadHistory { export interface StructuredSessionHandle { launchOptions: AgentLaunchOptions; + /** Whether a provider-native root or child session belongs to this thread. */ + ownsProviderSession?(providerSessionId: string): boolean; activate?(): Promise; openThread?(config: ThreadConfig, sessionRef?: SessionRef): Promise; ensureResumeArtifacts?(): Promise; @@ -122,6 +124,12 @@ export interface StructuredSessionHandle { ): Promise; interruptTurn?(): Promise; resolveServerRequest?(requestId: ThreadServerRequestId, response: unknown): Promise; + /** + * Swap the live session onto a new resolved MCP set (provider-level MCP + * settings changed). Implementations restart or re-sync their backing + * server; sessions without this hook pick the new set up on next launch. + */ + updateMcpServers?(mcpServers: readonly ResolvedMcpServer[]): Promise; readThread?(): Promise; rollbackThread?(numTurns: number): Promise; setListener(listener: StructuredSessionListener): void; diff --git a/src/supervisor/agents/codex/codex.test.ts b/src/supervisor/agents/codex/codex.test.ts index abd72bf4..ac0513e2 100644 --- a/src/supervisor/agents/codex/codex.test.ts +++ b/src/supervisor/agents/codex/codex.test.ts @@ -551,6 +551,29 @@ describe("CodexSubAgentRouter", () => { itemId: "parent-item", payload: { status: "success", result: "Child result" }, }); + + const lateEvents = router.routeChildNotification( + "item/started", + { + threadId: "child-thread", + turnId: "late-child-turn", + item: { id: "late-child-message", type: "agentMessage", text: "Late output" }, + }, + "provider-thread", + ); + expect(lateEvents).toContainEqual( + expect.objectContaining({ + type: "item.updated", + itemId: "parent-item", + payload: expect.objectContaining({ status: "success", result: "Child result" }), + }), + ); + expect(lateEvents).not.toContainEqual( + expect.objectContaining({ + itemId: "parent-item", + payload: expect.objectContaining({ status: "running" }), + }), + ); }); it("suppresses notifications from unrelated app-server threads", () => { diff --git a/src/supervisor/agents/codex/subAgentRouting.ts b/src/supervisor/agents/codex/subAgentRouting.ts index 867f395c..7c1c9e72 100644 --- a/src/supervisor/agents/codex/subAgentRouting.ts +++ b/src/supervisor/agents/codex/subAgentRouting.ts @@ -33,6 +33,7 @@ export class CodexSubAgentRouter { private mainThreadId: string | undefined; private readonly children = new Map(); private readonly parentPayloads = new Map(); + private readonly completedParentItemIds = new Set(); private readonly collabItems = new Map(); private readonly activityItems = new Map(); private readonly pendingChildNotifications = new Map< @@ -128,15 +129,10 @@ export class CodexSubAgentRouter { const result = this.readParentResult(child); return [ ...childCompletionEvents, - { - type: "item.completed", - threadId: this.localThreadId, - itemId: child.parentItemId, - payload: { - status: this.hasFailedSiblingOrSelf(child) ? "error" : "success", - ...(result ? { result } : {}), - }, - }, + this.completeParent(child, { + status: this.hasFailedSiblingOrSelf(child) ? "error" : "success", + ...(result ? { result } : {}), + }), ]; } @@ -145,15 +141,10 @@ export class CodexSubAgentRouter { child.failed = true; if (this.hasActiveSibling(child)) return []; return [ - { - type: "item.completed", - threadId: this.localThreadId, - itemId: child.parentItemId, - payload: { - status: "error", - result: readCodexErrorMessage(params) ?? "Codex child thread error", - }, - }, + this.completeParent(child, { + status: "error", + result: readCodexErrorMessage(params) ?? "Codex child thread error", + }), ]; } @@ -253,15 +244,9 @@ export class CodexSubAgentRouter { child.active = false; return this.hasActiveSibling(child) ? [] - : [ - { - type: "item.completed", - threadId: this.localThreadId, - itemId: child.parentItemId, - payload: { status: "error" }, - }, - ]; + : [this.completeParent(child, { status: "error" })]; } + if (this.completedParentItemIds.has(child.parentItemId)) return []; child.active = true; return [this.updateParent(child, { status: "running" })]; } @@ -323,6 +308,13 @@ export class CodexSubAgentRouter { } } if (!receiverThreadIds.some((threadId) => this.children.get(threadId)?.active)) { + const parentCompletion = events.find( + (event): event is Extract => + event.type === "item.completed" && event.itemId === parentItemId, + ); + if (parentCompletion) { + this.recordParentCompletion(parentCompletion.itemId, parentCompletion.payload); + } return [...routed, ...events]; } @@ -422,7 +414,12 @@ export class CodexSubAgentRouter { status: "running", isSubAgent: true, }; - const payload = mergeParentPayload(current, patch); + const payload = mergeParentPayload(current, { + ...(!this.completedParentItemIds.has(child.parentItemId) && patch.status + ? { status: patch.status } + : {}), + ...(patch.progress ? { progress: patch.progress } : {}), + }); this.parentPayloads.set(child.parentItemId, payload); return { type: "item.updated", @@ -431,6 +428,33 @@ export class CodexSubAgentRouter { payload, }; } + + private completeParent( + child: CodexChildThread, + payload: Pick & { result?: unknown }, + ): Extract { + this.recordParentCompletion(child.parentItemId, payload); + return { + type: "item.completed", + threadId: this.localThreadId, + itemId: child.parentItemId, + payload, + }; + } + + private recordParentCompletion(itemId: string, payload: unknown): void { + const current = this.parentPayloads.get(itemId) ?? { + name: "spawnAgent", + status: "running", + isSubAgent: true, + }; + const completedPayload = + payload && typeof payload === "object" + ? ({ ...current, ...(payload as Partial) } as ToolCallPayload) + : current; + this.parentPayloads.set(itemId, completedPayload); + this.completedParentItemIds.add(itemId); + } } function readNotificationThreadId(params: Record | undefined): string | undefined { diff --git a/src/supervisor/agents/crossagentMcp/index.ts b/src/supervisor/agents/crossagentMcp/index.ts index 39ed3eed..88a63f45 100644 --- a/src/supervisor/agents/crossagentMcp/index.ts +++ b/src/supervisor/agents/crossagentMcp/index.ts @@ -2,8 +2,10 @@ * Shared helper for injecting the Poracode Crossagents MCP server * into agent runtimes. The supervisor hosts a single in-process Streamable-HTTP * MCP endpoint (`CrossagentMcpIngress`); each thread that opts in receives a URL + - * per-thread bearer token at launch so the agent can discover and spawn the - * other connected agents as subagents. + * bearer credential at launch so the agent can discover and spawn the other + * connected agents as subagents. Most providers receive a per-thread token; + * pooled runtimes can share one credential and route calls by a trusted native + * session id. * * Mirrors the `browserMcp` module shape. The runtime resolves this endpoint * into the same provider-neutral MCP descriptor used for every server. @@ -28,10 +30,12 @@ import type { WslHostAccessResolver } from "@/supervisor/wsl/hostAccess"; export interface CrossagentMcpHttpConfig { /** MCP endpoint URL (already suffixed with `/mcp`). */ url: string; - /** Per-thread authorization bearer token. */ + /** In-memory authorization bearer token. */ token: string; /** Headers map (always includes `Authorization`). */ headers: Record; + /** Per-thread discovery/permission filter retained outside provider config. */ + disabledTools?: string[]; } /** Minimal shape needed to pick native-vs-WSL — accepts a `ProjectLocation` or diff --git a/src/supervisor/agents/opencode/argv.ts b/src/supervisor/agents/opencode/argv.ts index 4df7f31e..3abc021f 100644 --- a/src/supervisor/agents/opencode/argv.ts +++ b/src/supervisor/agents/opencode/argv.ts @@ -1,3 +1,4 @@ +import { homedir } from "node:os"; import { dirname as posixDirname } from "node:path/posix"; import type { ProjectLocation, ThreadConfig } from "@/shared/contracts"; import { buildAgentCommand, DEFAULT_WSL_EXEC_PATH, getWslCommand, type CommandSpec } from "../base"; @@ -39,11 +40,14 @@ export function buildOpenCodeArgs( // so we mirror Codex's `buildCodexAppServerCommand`: bypass `bash -l -i` and // invoke the binary under `/usr/bin/env PATH=` instead. The TUI // launch keeps its login-shell wrapping (via `buildAgentCommand` in the -// adapter's `buildLaunchArgv`). Native Windows may also pass an already-resolved -// absolute executable path so packaged apps are not hostage to an ambient PATH. +// adapter's `buildLaunchArgv`). The pooled server starts from the runtime home, +// never the first acquired project; directory-scoped SDK requests select the +// actual project. Native Windows may also pass an already-resolved absolute +// executable path so packaged apps are not hostage to an ambient PATH. export function buildOpenCodeServerCommand( location: ProjectLocation, resolvedExecPath?: string, + env: Record = {}, ): CommandSpec { const args = ["serve", "--hostname=127.0.0.1", "--port=0", "--print-logs"]; if (location.kind === "wsl") { @@ -57,14 +61,16 @@ export function buildOpenCodeServerCommand( "-d", location.distro, "--cd", - location.linuxPath, + "~", "--", "/usr/bin/env", `PATH=${pathSegments.join(":")}`, + ...Object.entries(env).map(([key, value]) => `${key}=${value}`), resolvedExecPath ?? "opencode", ...args, ], }; } - return buildAgentCommand(location, "opencode", args, resolvedExecPath); + const runtimeLocation = { ...location, path: homedir() }; + return buildAgentCommand(runtimeLocation, "opencode", args, resolvedExecPath, env); } diff --git a/src/supervisor/agents/opencode/detection.ts b/src/supervisor/agents/opencode/detection.ts index 36cb5e72..ebf448a2 100644 --- a/src/supervisor/agents/opencode/detection.ts +++ b/src/supervisor/agents/opencode/detection.ts @@ -59,9 +59,17 @@ export const opencodeDefaultCapabilities: AgentCapability = { presentationModes: ["terminal", "gui"], defaultApprovalPolicy: "yolo", bypassPermissions: { approvalPolicy: "yolo" }, - // Browser MCP: no per-thread gating point in either presentation — the TUI - // Both presentations receive the same launch-resolved MCP collection. - mcpScope: { terminal: "launch", gui: "launch" }, + // MCP is provider-level for OpenCode: the composer shows no MCP controls; + // built-in server flags come from the OpenCode settings page + // (`agentSettings.opencode`) at launch. OpenCode applies that set to each + // project directory inside the shared runtime server instead of hosting + // per-thread MCP credentials. + mcpScope: { terminal: "none", gui: "none" }, + mcpConfigSource: "agentSettings", + // The installed OpenCode plugin injects the trusted provider session id + // into Crossagents calls, allowing every directory/session in the pooled + // server to share one MCP credential without losing parent-thread routing. + crossagentMcpRouting: "provider-session", settingDefs: [], }; @@ -424,16 +432,14 @@ export const opencodeDetectionSpec: DetectionSpec = { // `opencode serve`). If the server fails to come up — corporate firewall, // sandboxed binary, missing libc — fall back to the CLI `models --verbose` // parser so the user still sees a usable model list. - const sdkInventory = await probeOpenCodeInventoryViaSdk(ctx.location, ctx.executablePath).catch( - (cause) => { - console.warn( - `[opencode] SDK capabilities probe failed, falling back to CLI parser: ${ - cause instanceof Error ? cause.message : String(cause) - }`, - ); - return undefined; - }, - ); + const sdkInventory = await probeOpenCodeInventoryViaSdk(ctx.location).catch((cause) => { + console.warn( + `[opencode] SDK capabilities probe failed, falling back to CLI parser: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + ); + return undefined; + }); if (sdkInventory) { return buildCapabilityPartialFromSdkInventory(sdkInventory); diff --git a/src/supervisor/agents/opencode/index.ts b/src/supervisor/agents/opencode/index.ts index 83e4db0c..e626c0b4 100644 --- a/src/supervisor/agents/opencode/index.ts +++ b/src/supervisor/agents/opencode/index.ts @@ -52,7 +52,13 @@ function buildOpenCodeMcpEnv( ): Record | undefined { if (mcpServers.length === 0) return undefined; const launch = buildOpenCodeMcpLaunchConfig(mcpServers); - return { ...launch.env, OPENCODE_CONFIG_CONTENT: launch.configContent }; + return { + ...launch.env, + OPENCODE_CONFIG_CONTENT: launch.configContent, + ...(mcpServers.some((server) => server.id === "crossagents") + ? { PORACODE_OPENCODE_SESSION_ROUTING: "1" } + : {}), + }; } export function createOpenCodeAdapter(): AgentAdapter { @@ -145,10 +151,10 @@ export function createOpenCodeAdapter(): AgentAdapter { // ── Launch / resume ────────────────────────────────────────────────── // // Session ID allocation: see `createStructuredSession` below. On a fresh - // launch the runtime spins up `opencode serve`, calls `session.create`, - // captures the resulting `ses_xxx` id, sets `launchOptions.resumeThreadId`, - // and then disposes the SDK connection (because `liveInputMode === - // "terminal"`). + // launch the runtime acquires the shared `opencode serve`, calls + // `session.create`, captures the resulting `ses_xxx` id, sets + // `launchOptions.resumeThreadId`, and then disposes the SDK connection + // (because `liveInputMode === "terminal"`). // The TUI process below picks up the pre-allocated id via `--session `, // so the supervisor knows the providerSessionId synchronously instead of // polling `opencode session list` after spawn. @@ -181,11 +187,11 @@ export function createOpenCodeAdapter(): AgentAdapter { // // Terminal mode (default): runtime calls `activate()` + `openThread()`, // captures the returned session id into `launchOptions.resumeThreadId`, - // then disposes immediately because `liveInputMode === "terminal"`. The - // ephemeral `opencode serve` process exits; the TUI launches with - // `--session ` and resumes from SQLite. Same observable behaviour as - // the previous `opencode acp` allocation, just over HTTP+SDK so we share - // infrastructure with the GUI flow. + // then releases its SDK acquisition because `liveInputMode === "terminal"`. + // The shared runtime server stays warm; the TUI launches with `--session + // ` and resumes from SQLite. Same observable behaviour as the previous + // `opencode acp` allocation, just over HTTP+SDK so GUI projects share the + // runtime sidecar. // // GUI mode: same handle stays alive for the thread's lifetime; SSE // events stream through `sdkCanonicalMapping` into chat items. @@ -250,9 +256,8 @@ export function createOpenCodeAdapter(): AgentAdapter { // // Two paths: // - `runOneShot` (preferred): goes through `opencode serve` over SDK, - // reusing the per-project server pool with a 30s idle TTL. Mirrors - // t3code's text-generation flow and avoids one CLI cold-start per - // generated commit/title/PR. + // reusing the app-lifetime runtime server pool. Avoids one CLI + // cold-start per generated commit/title/PR. // - `buildOneShotCommand` (fallback): legacy `opencode run --format // json` path. Kept so orchestrators that haven't migrated to the // SDK-first runner still work, and so we have a CLI fallback for diff --git a/src/supervisor/agents/opencode/opencode.test.ts b/src/supervisor/agents/opencode/opencode.test.ts index d26c5e3f..5e8a7409 100644 --- a/src/supervisor/agents/opencode/opencode.test.ts +++ b/src/supervisor/agents/opencode/opencode.test.ts @@ -379,6 +379,7 @@ describe("createOpenCodeAdapter", () => { expect(adapter.label).toBe("OpenCode"); expect(adapter.pluginId).toBe("poracode-status@opencode"); expect(adapter.minProtocolVersion).toBe(1); + expect(adapter.capabilities.crossagentMcpRouting).toBe("provider-session"); }); it("returns no extra args/env from pluginLaunchExtras (in-process plugin)", async () => { @@ -520,6 +521,32 @@ describe("createOpenCodeAdapter", () => { expect(Object.values(argv.env ?? {})).toContain("Bearer secret"); }); + it("enables trusted session routing when a terminal launch hosts Crossagents", () => { + const adapter = createOpenCodeAdapter(); + const argv = adapter.buildLaunchArgv( + { kind: "windows", path: "C:\\repo" }, + { model: "" }, + "", + undefined, + { + mcpServers: [ + { + id: "crossagents", + name: "crossagents", + timeoutMs: 300_000, + transport: { + type: "http", + url: "http://127.0.0.1:43123/mcp", + headers: { Authorization: "Bearer shared" }, + }, + }, + ], + }, + ); + + expect(argv.env?.PORACODE_OPENCODE_SESSION_ROUTING).toBe("1"); + }); + it("does not override OpenCode config when no custom MCP is selected", () => { const adapter = createOpenCodeAdapter(); const argv = adapter.buildResumeArgv( diff --git a/src/supervisor/agents/opencode/plugin/install.test.ts b/src/supervisor/agents/opencode/plugin/install.test.ts index 6584e93a..42735f0a 100644 --- a/src/supervisor/agents/opencode/plugin/install.test.ts +++ b/src/supervisor/agents/opencode/plugin/install.test.ts @@ -9,6 +9,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { getOpenCodePluginPaths, @@ -95,10 +96,52 @@ describe("installOpenCodePlugin", () => { expect(isOpenCodePluginInstalled({ envKind: "posix", baseDir })).toMatchObject({ installed: true, - version: "1.7.1", + version: "1.8.0", }); }); + it("overwrites private Crossagents routing metadata from the trusted tool context", async () => { + const baseDir = makeBaseDir(); + const opencodeDir = makeBaseDir(); + process.env.OPENCODE_CONFIG_DIR = opencodeDir; + const result = installOpenCodePlugin({ envKind: "posix", baseDir }); + expect(result.ok).toBe(true); + if (!result.ok) return; + + const pluginUrl = `${pathToFileURL(result.paths.opencodePluginFile).href}?routing-test=${Date.now()}`; + const pluginModule = (await import(pluginUrl)) as { + default: { + server(): Promise<{ + "tool.execute.before"?: ( + input: { tool: string; sessionID: string }, + output: { args: Record }, + ) => Promise; + }>; + }; + }; + const hooks = await pluginModule.default.server(); + const originalRouting = process.env.PORACODE_OPENCODE_SESSION_ROUTING; + process.env.PORACODE_OPENCODE_SESSION_ROUTING = "1"; + try { + const output = { args: { __poracode_provider_session_id: "forged", prompt: "review" } }; + await hooks["tool.execute.before"]?.( + { tool: "crossagents_run_agent", sessionID: "session-trusted" }, + output, + ); + expect(output.args.__poracode_provider_session_id).toBe("session-trusted"); + + const unrelated = { args: { prompt: "leave alone" } }; + await hooks["tool.execute.before"]?.( + { tool: "custom_run_agent", sessionID: "session-trusted" }, + unrelated, + ); + expect(unrelated.args).toEqual({ prompt: "leave alone" }); + } finally { + if (originalRouting === undefined) delete process.env.PORACODE_OPENCODE_SESSION_ROUTING; + else process.env.PORACODE_OPENCODE_SESSION_ROUTING = originalRouting; + } + }); + it("scrubs a stale `file://` plugin entry that older builds left in opencode.json", () => { const baseDir = makeBaseDir(); const opencodeDir = makeBaseDir(); @@ -145,6 +188,55 @@ describe("installOpenCodePlugin", () => { expect(config.plugin).toBeUndefined(); }); + it("preserves user MCP entries whose names match Poracode built-ins", () => { + const baseDir = makeBaseDir(); + const opencodeDir = makeBaseDir(); + process.env.OPENCODE_CONFIG_DIR = opencodeDir; + const configPath = join(opencodeDir, "opencode.json"); + const userMcp = Object.fromEntries( + ["browser", "crossagents", "chrome", "computer_use", "poracode"].map((name) => [ + name, + { type: "local", command: [`user-${name}`] }, + ]), + ); + writeFileSync(configPath, JSON.stringify({ mcp: userMcp }), "utf8"); + + expect(installOpenCodePlugin({ envKind: "posix", baseDir }).ok).toBe(true); + + const config = JSON.parse(readFileSync(configPath, "utf8")) as { mcp: unknown }; + expect(config.mcp).toEqual(userMcp); + }); + + it("restores entries shadowed by the retired MCP config projection", () => { + const baseDir = makeBaseDir(); + const opencodeDir = makeBaseDir(); + process.env.OPENCODE_CONFIG_DIR = opencodeDir; + const configPath = join(opencodeDir, "opencode.json"); + const sidecarPath = join(opencodeDir, ".poracode-managed-mcp.json"); + const originalBrowser = { type: "local", command: ["user-browser"] }; + writeFileSync( + configPath, + JSON.stringify({ + mcp: { + browser: { type: "remote", url: "http://poracode/browser" }, + poracode_only: { type: "remote", url: "http://poracode/only" }, + }, + }), + "utf8", + ); + writeFileSync( + sidecarPath, + JSON.stringify({ browser: originalBrowser, poracode_only: null }), + "utf8", + ); + + expect(installOpenCodePlugin({ envKind: "posix", baseDir }).ok).toBe(true); + + const config = JSON.parse(readFileSync(configPath, "utf8")) as { mcp: unknown }; + expect(config.mcp).toEqual({ browser: originalBrowser }); + expect(existsSync(sidecarPath)).toBe(false); + }); + it("is idempotent — restaging produces the same end state", () => { const baseDir = makeBaseDir(); process.env.OPENCODE_CONFIG_DIR = makeBaseDir(); @@ -238,6 +330,7 @@ describe("uninstallOpenCodePlugin", () => { "@user/other-plugin", `file://${result.paths.pluginDir.replace(/\\/g, "/")}/poracode-status.mjs`, ], + mcp: { browser: { type: "local", command: ["user-browser"] } }, }), "utf8", ); @@ -250,5 +343,6 @@ describe("uninstallOpenCodePlugin", () => { const config = JSON.parse(readFileSync(configPath, "utf8")) as Record; expect(config.plugin).toEqual(["@user/other-plugin"]); + expect(config.mcp).toEqual({ browser: { type: "local", command: ["user-browser"] } }); }); }); diff --git a/src/supervisor/agents/opencode/plugin/install.ts b/src/supervisor/agents/opencode/plugin/install.ts index b72f7e81..f558c716 100644 --- a/src/supervisor/agents/opencode/plugin/install.ts +++ b/src/supervisor/agents/opencode/plugin/install.ts @@ -10,7 +10,6 @@ import { import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { BUILT_IN_MCP_SERVER_NAMES } from "@/shared/contracts"; import { toWslUncPath } from "@/shared/wsl"; import { getCachedWslHomeDirectory, type AgentEnvContext } from "../../base"; import { @@ -98,12 +97,7 @@ const OPENCODE_LEGACY_DROP_FILES = [ const PORACODE_PLUGIN_SPEC_MARKER = "agent-plugins/opencode/"; const OPENCODE_CONFIG_FILE_NAME = "opencode.json"; - -/** - * All `mcp` server keys Poracode owns in `opencode.json`. Scrubbed together at - * install/uninstall so no stale poracode-managed entry survives a reinstall. - */ -const PORACODE_MANAGED_MCP_KEYS = Object.values(BUILT_IN_MCP_SERVER_NAMES); +const LEGACY_MANAGED_MCP_FILE_NAME = ".poracode-managed-mcp.json"; export interface OpenCodePluginPaths { pluginDir: string; @@ -242,11 +236,16 @@ export function installOpenCodePlugin( }; } - // Best-effort: scrub the dead `file://...` entry older poracode versions - // wrote into opencode.json. Browser MCP is synced at OpenCode launch time so - // it can honor the user's provider setting. - const nativeConfigPath = join(resolveOpenCodeNativeConfigDir(), OPENCODE_CONFIG_FILE_NAME); - updateOpenCodeConfigFile(nativeConfigPath, { remove: PORACODE_MANAGED_MCP_KEYS }); + // Best-effort: restore any user MCP entries shadowed by the retired config + // projection, then scrub the dead `file://...` plugin entry. Runtime MCP is + // now injected at launch, so entries without a legacy sidecar are user-owned. + const nativeConfigDir = resolveOpenCodeNativeConfigDir(); + const nativeConfigPath = join(nativeConfigDir, OPENCODE_CONFIG_FILE_NAME); + scrubLegacyOpenCodeMcpProjection( + nativeConfigPath, + join(nativeConfigDir, LEGACY_MANAGED_MCP_FILE_NAME), + ); + updateOpenCodeConfigFile(nativeConfigPath, { remove: [] }); console.log( `[supervisor] OpenCode hook plugin staged v${manifest.version} at ${pluginDir} ` + @@ -300,7 +299,11 @@ function installOpenCodePluginWsl( const cfgDir = resolveOpenCodeWslConfigDir(distro); if (cfgDir) { const wslConfigPath = `${cfgDir.uncDir}\\${OPENCODE_CONFIG_FILE_NAME}`; - updateOpenCodeConfigFile(wslConfigPath, { remove: PORACODE_MANAGED_MCP_KEYS }); + scrubLegacyOpenCodeMcpProjection( + wslConfigPath, + `${cfgDir.uncDir}\\${LEGACY_MANAGED_MCP_FILE_NAME}`, + ); + updateOpenCodeConfigFile(wslConfigPath, { remove: [] }); } console.log( @@ -484,6 +487,38 @@ function updateOpenCodeConfigFile(configPath: string, update: OpenCodeMcpConfigU } } +function readManagedMcpOriginals(path: string): Record { + const read = readJsonFileOrEmpty(path); + if (!read.ok) return {}; + if (Array.isArray(read.value)) { + return Object.fromEntries( + read.value + .filter((name): name is string => typeof name === "string") + .map((name) => [name, null]), + ); + } + return read.value && typeof read.value === "object" + ? (read.value as Record) + : {}; +} + +function scrubLegacyOpenCodeMcpProjection(configPath: string, managedNamesPath: string): void { + const originals = readManagedMcpOriginals(managedNamesPath); + const names = Object.keys(originals); + const restored = Object.fromEntries( + Object.entries(originals).filter( + (entry): entry is [string, Exclude] => entry[1] !== null, + ), + ); + if (names.length > 0) { + updateOpenCodeConfigFile(configPath, { + remove: names, + ...(Object.keys(restored).length > 0 ? { add: restored } : {}), + }); + } + removeIfPresent(managedNamesPath); +} + /** * Removes the dropped plugin file from OpenCode's plugins/ directory and any * legacy drops, plus scrubs the poracode entry from opencode.json. Staging @@ -500,9 +535,12 @@ export function uninstallOpenCodePlugin(ctx?: AgentEnvContext): void { } const cfgDir = resolveOpenCodeWslConfigDir(ctx.wslDistro); if (cfgDir) { - updateOpenCodeConfigFile(`${cfgDir.uncDir}\\${OPENCODE_CONFIG_FILE_NAME}`, { - remove: PORACODE_MANAGED_MCP_KEYS, - }); + const configPath = `${cfgDir.uncDir}\\${OPENCODE_CONFIG_FILE_NAME}`; + scrubLegacyOpenCodeMcpProjection( + configPath, + `${cfgDir.uncDir}\\${LEGACY_MANAGED_MCP_FILE_NAME}`, + ); + updateOpenCodeConfigFile(configPath, { remove: [] }); } removeStagedPluginDir("opencode", ctx); return; @@ -511,9 +549,10 @@ export function uninstallOpenCodePlugin(ctx?: AgentEnvContext): void { removeIfPresent(join(pluginsDir, OPENCODE_PLUGIN_DROP_FILE_NAME)); removeIfPresent(join(pluginsDir, OPENCODE_PLUGIN_DROP_MANIFEST_NAME)); cleanupLegacyDrops(pluginsDir); - updateOpenCodeConfigFile(join(resolveOpenCodeNativeConfigDir(), OPENCODE_CONFIG_FILE_NAME), { - remove: PORACODE_MANAGED_MCP_KEYS, - }); + const configDir = resolveOpenCodeNativeConfigDir(); + const configPath = join(configDir, OPENCODE_CONFIG_FILE_NAME); + scrubLegacyOpenCodeMcpProjection(configPath, join(configDir, LEGACY_MANAGED_MCP_FILE_NAME)); + updateOpenCodeConfigFile(configPath, { remove: [] }); removeStagedPluginDir("opencode", ctx); } diff --git a/src/supervisor/agents/opencode/plugin/plugin.json b/src/supervisor/agents/opencode/plugin/plugin.json index 5b3e4644..e481ee35 100644 --- a/src/supervisor/agents/opencode/plugin/plugin.json +++ b/src/supervisor/agents/opencode/plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "poracode-status-opencode", - "version": "1.7.1", + "version": "1.8.0", "description": "OpenCode plugin forwarder for Poracode thread status" } diff --git a/src/supervisor/agents/opencode/plugin/poracode-status.mjs b/src/supervisor/agents/opencode/plugin/poracode-status.mjs index 2258f3bb..0fea0d4e 100644 --- a/src/supervisor/agents/opencode/plugin/poracode-status.mjs +++ b/src/supervisor/agents/opencode/plugin/poracode-status.mjs @@ -76,6 +76,18 @@ function readPluginVersionFromManifest() { const PLUGIN_VERSION = readPluginVersionFromManifest(); const PROTOCOL_VERSION = 1; +const CROSSAGENT_SESSION_ID_ARG = "__poracode_provider_session_id"; + +function injectCrossagentSessionId(input, output) { + if (process.env.PORACODE_OPENCODE_SESSION_ROUTING !== "1") return; + if (typeof input?.tool !== "string" || !input.tool.startsWith("crossagents_")) return; + if (typeof input.sessionID !== "string" || input.sessionID.length === 0) return; + if (!output?.args || typeof output.args !== "object" || Array.isArray(output.args)) return; + + // Always overwrite the private field after model-argument validation. The + // value comes from OpenCode's trusted tool context, never from model input. + output.args[CROSSAGENT_SESSION_ID_ARG] = input.sessionID; +} function hookDebugEnabled() { const v = process.env.PORACODE_HOOK_DEBUG; @@ -242,8 +254,9 @@ export default { // Also maps to session.turn_started so a tool resuming after a permission // approval re-marks the agent as working. Duplicate turn_started events // (paired with chat.message) are idempotent in the state machine. - "tool.execute.before": async (input) => { + "tool.execute.before": async (input, output) => { try { + injectCrossagentSessionId(input, output); const sessionId = typeof input?.sessionID === "string" && input.sessionID.length > 0 ? input.sessionID diff --git a/src/supervisor/agents/opencode/sdkClient.test.ts b/src/supervisor/agents/opencode/sdkClient.test.ts index 48891dfe..99cb4f82 100644 --- a/src/supervisor/agents/opencode/sdkClient.test.ts +++ b/src/supervisor/agents/opencode/sdkClient.test.ts @@ -1,17 +1,32 @@ import { EventEmitter } from "node:events"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ChildProcess } from "node:child_process"; +import type { ProjectLocation } from "@/shared/contracts"; import type { CommandSpec } from "../base"; import type { OpenCodeServerHandle } from "./sdkServer"; const mocks = vi.hoisted(() => ({ - buildOpenCodeServerCommand: vi.fn<() => CommandSpec>(), + buildOpenCodeServerCommand: + vi.fn< + ( + location: ProjectLocation, + resolvedExecPath?: string, + env?: Record, + ) => CommandSpec + >(), createOpencodeClient: vi.fn<() => unknown>(), resolveAgentBinaryPath: vi.fn<() => string>(), + resolveWslHomeDirectoryAsync: vi.fn<() => Promise>(), + installOpenCodePlugin: vi.fn<() => { ok: true; version: string }>(), spawnOpenCodeServer: vi.fn<() => OpenCodeServerHandle>(), disposeSpawnedOpenCodeServerHandles: vi.fn<() => void>(), })); +vi.mock("../base", async (importOriginal) => ({ + ...(await importOriginal()), + resolveWslHomeDirectoryAsync: mocks.resolveWslHomeDirectoryAsync, +})); + vi.mock("../binaryResolver", () => ({ resolveAgentBinaryPath: mocks.resolveAgentBinaryPath, })); @@ -25,6 +40,10 @@ vi.mock("./sdkServer", () => ({ disposeSpawnedOpenCodeServerHandles: mocks.disposeSpawnedOpenCodeServerHandles, })); +vi.mock("./plugin/install", () => ({ + installOpenCodePlugin: mocks.installOpenCodePlugin, +})); + vi.mock("@opencode-ai/sdk/v2/client", () => ({ createOpencodeClient: mocks.createOpencodeClient, })); @@ -58,14 +77,38 @@ describe("acquireOpenCodeServer", () => { cwd: "/repo", env: {}, }); - mocks.createOpencodeClient.mockReset(); + mocks.createOpencodeClient.mockReset().mockImplementation(() => makeSubagentClient()); mocks.resolveAgentBinaryPath.mockReset().mockReturnValue("opencode"); + mocks.resolveWslHomeDirectoryAsync.mockReset().mockResolvedValue("/home/test"); + mocks.installOpenCodePlugin.mockReset().mockReturnValue({ ok: true, version: "1.0.0" }); mocks.spawnOpenCodeServer.mockReset(); + mocks.disposeSpawnedOpenCodeServerHandles.mockReset(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 404 }))); process.env.PORACODE_BROWSER_MCP_URL = "http://127.0.0.1:9321"; process.env.PORACODE_BROWSER_MCP_TOKEN = "test-token"; }); - afterEach(() => { + it("installs the routing plugin before starting the shared native server", async () => { + const handle = makeHandle("http://127.0.0.1:4096"); + mocks.spawnOpenCodeServer.mockReturnValue(handle); + + const { acquireOpenCodeServer } = await import("./sdkClient"); + await acquireOpenCodeServer({ + projectLocation: { kind: "windows", path: "C:\\repo" }, + }); + + expect(mocks.installOpenCodePlugin).toHaveBeenCalledWith( + expect.objectContaining({ envKind: "windows" }), + ); + expect(mocks.installOpenCodePlugin.mock.invocationCallOrder[0]).toBeLessThan( + mocks.spawnOpenCodeServer.mock.invocationCallOrder[0]!, + ); + }); + + afterEach(async () => { + const { shutdownSpawnedOpenCodeServers } = await import("./sdkClient"); + shutdownSpawnedOpenCodeServers(); + vi.unstubAllGlobals(); if (oldBrowserMcpUrl === undefined) { delete process.env.PORACODE_BROWSER_MCP_URL; } else { @@ -83,20 +126,20 @@ describe("acquireOpenCodeServer", () => { const secondHandle = makeHandle("http://127.0.0.1:4097"); const firstAdd = vi.fn<() => Promise>().mockRejectedValue(new Error("fetch failed")); const secondAdd = vi.fn<() => Promise>().mockResolvedValue(undefined); - const secondConnect = vi.fn<() => Promise>().mockResolvedValue(undefined); mocks.spawnOpenCodeServer.mockReturnValueOnce(firstHandle).mockReturnValueOnce(secondHandle); mocks.createOpencodeClient + .mockReturnValueOnce({}) .mockReturnValueOnce({ mcp: { add: firstAdd, connect: vi.fn<() => Promise>().mockResolvedValue(undefined), }, }) + .mockReturnValueOnce({}) .mockReturnValueOnce({ mcp: { add: secondAdd, - connect: secondConnect, }, }); @@ -114,11 +157,10 @@ describe("acquireOpenCodeServer", () => { expect(firstAdd).toHaveBeenCalledTimes(1); expect(firstHandle.dispose).toHaveBeenCalledTimes(1); expect(secondAdd).toHaveBeenCalledTimes(1); - expect(secondConnect).toHaveBeenCalledTimes(1); expect(acquired.baseUrl).toBe("http://127.0.0.1:4097"); await acquired.dispose(); - expect(secondHandle.dispose).toHaveBeenCalledTimes(1); + expect(secondHandle.dispose).not.toHaveBeenCalled(); }); function makeSubagentClient() { @@ -128,15 +170,12 @@ describe("acquireOpenCodeServer", () => { connect: vi.fn<() => Promise>().mockResolvedValue(undefined), disconnect: vi.fn<() => Promise>().mockResolvedValue(undefined), }, + instance: { + dispose: vi.fn<() => Promise>().mockResolvedValue(undefined), + }, }; } - const crossagentMcp = { - url: "http://127.0.0.1:9400/mcp", - token: "parent-thread-token", - headers: { Authorization: "Bearer parent-thread-token" }, - }; - const chromeMcp = { url: "http://127.0.0.1:9401/mcp", token: "chrome-token", @@ -166,143 +205,235 @@ describe("acquireOpenCodeServer", () => { timeout: 30_000, }, }); - expect(client.mcp.connect).toHaveBeenCalledWith({ - directory: "/repo-chrome", - name: "chrome", - }); + expect(client.mcp.connect).not.toHaveBeenCalled(); await acquired.dispose(); - expect(handle.dispose).toHaveBeenCalledTimes(1); + expect(handle.dispose).not.toHaveBeenCalled(); }); - it("dedicates a per-thread server and registers the Crossagents MCP via mcp.add", async () => { - const handle = makeHandle("http://127.0.0.1:4200"); + it("shares an authenticated native sidecar across directories", async () => { + const handle = makeHandle("http://127.0.0.1:4300"); + const eventClient = makeSubagentClient(); + const firstClient = makeSubagentClient(); + const secondClient = makeSubagentClient(); mocks.spawnOpenCodeServer.mockReturnValue(handle); - const client = makeSubagentClient(); - mocks.createOpencodeClient.mockReturnValue(client); + mocks.createOpencodeClient + .mockReturnValueOnce(eventClient) + .mockReturnValueOnce(firstClient) + .mockReturnValueOnce(secondClient); const { acquireOpenCodeServer } = await import("./sdkClient"); - const acquired = await acquireOpenCodeServer({ - projectLocation: { kind: "posix", path: "/repo-dedicated" }, - mcpServers: [remoteMcp("crossagents", crossagentMcp.url, crossagentMcp.headers, 300_000)], - dedicatedKey: "thread-parent", + const first = await acquireOpenCodeServer({ + projectLocation: { kind: "posix", path: "/repo-a" }, }); - - expect(client.mcp.add).toHaveBeenCalledTimes(1); - expect(client.mcp.add).toHaveBeenCalledWith({ - directory: "/repo-dedicated", - name: "crossagents", - config: { - type: "remote", - url: crossagentMcp.url, - headers: crossagentMcp.headers, - enabled: true, - timeout: 300_000, - }, + const second = await acquireOpenCodeServer({ + projectLocation: { kind: "posix", path: "/repo-b" }, }); - expect(client.mcp.connect).toHaveBeenCalledWith({ - directory: "/repo-dedicated", - name: "crossagents", + + expect(mocks.spawnOpenCodeServer).toHaveBeenCalledTimes(1); + expect(first.client).toBe(firstClient); + expect(second.client).toBe(secondClient); + expect(first.eventClient).toBe(eventClient); + expect(second.eventClient).toBe(eventClient); + + const credentials = mocks.buildOpenCodeServerCommand.mock.calls[0]?.[2] as Record< + string, + string + >; + expect(credentials.OPENCODE_SERVER_USERNAME).toBe("opencode"); + expect(credentials.OPENCODE_SERVER_PASSWORD).toEqual(expect.any(String)); + expect(credentials.PORACODE_OPENCODE_SESSION_ROUTING).toBe("1"); + const authorization = `Basic ${Buffer.from( + `opencode:${credentials.OPENCODE_SERVER_PASSWORD}`, + ).toString("base64")}`; + expect(mocks.createOpencodeClient).toHaveBeenNthCalledWith(1, { + baseUrl: "http://127.0.0.1:4300", + headers: { Authorization: authorization }, + throwOnError: true, }); + expect(mocks.createOpencodeClient).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ directory: "/repo-a" }), + ); + expect(mocks.createOpencodeClient).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ directory: "/repo-b" }), + ); - // Dedicated entry has a single tenant → disposes with the thread. - await acquired.dispose(); - expect(handle.dispose).toHaveBeenCalledTimes(1); + await first.dispose(); + await second.dispose(); + expect(handle.dispose).not.toHaveBeenCalled(); }); - it("keys the dedicated server distinctly from the shared per-project pool", async () => { - const sharedHandle = makeHandle("http://127.0.0.1:4300"); - const dedicatedHandle = makeHandle("http://127.0.0.1:4301"); - mocks.spawnOpenCodeServer - .mockReturnValueOnce(sharedHandle) - .mockReturnValueOnce(dedicatedHandle); - mocks.createOpencodeClient.mockReturnValueOnce({}).mockReturnValueOnce(makeSubagentClient()); + it("pools WSL directories by distro", async () => { + const ubuntuHandle = makeHandle("http://127.0.0.1:4400"); + const debianHandle = makeHandle("http://127.0.0.1:4401"); + mocks.spawnOpenCodeServer.mockReturnValueOnce(ubuntuHandle).mockReturnValueOnce(debianHandle); const { acquireOpenCodeServer } = await import("./sdkClient"); - const location = { kind: "posix", path: "/repo-split" } as const; - - // A plain (child / non-hosting) acquire joins the shared pool … - const shared = await acquireOpenCodeServer({ projectLocation: location }); - // … while a hosting thread on the SAME project gets its own server. - const dedicated = await acquireOpenCodeServer({ - projectLocation: location, - mcpServers: [remoteMcp("crossagents", crossagentMcp.url, crossagentMcp.headers, 300_000)], - dedicatedKey: "thread-host", + const ubuntuA = await acquireOpenCodeServer({ + projectLocation: { + kind: "wsl", + distro: "Ubuntu", + linuxPath: "/repo-a", + uncPath: "\\\\wsl.localhost\\Ubuntu\\repo-a", + }, + }); + const ubuntuB = await acquireOpenCodeServer({ + projectLocation: { + kind: "wsl", + distro: "Ubuntu", + linuxPath: "/repo-b", + uncPath: "\\\\wsl.localhost\\Ubuntu\\repo-b", + }, + }); + const debian = await acquireOpenCodeServer({ + projectLocation: { + kind: "wsl", + distro: "Debian", + linuxPath: "/repo-a", + uncPath: "\\\\wsl.localhost\\Debian\\repo-a", + }, }); expect(mocks.spawnOpenCodeServer).toHaveBeenCalledTimes(2); - expect(shared.baseUrl).toBe("http://127.0.0.1:4300"); - expect(dedicated.baseUrl).toBe("http://127.0.0.1:4301"); - - // Tearing down the dedicated server must not touch the shared one. - await dedicated.dispose(); - expect(dedicatedHandle.dispose).toHaveBeenCalledTimes(1); - expect(sharedHandle.dispose).not.toHaveBeenCalled(); - await shared.dispose(); - expect(sharedHandle.dispose).toHaveBeenCalledTimes(1); + expect(ubuntuA.baseUrl).toBe(ubuntuB.baseUrl); + expect(debian.baseUrl).not.toBe(ubuntuA.baseUrl); + + await ubuntuA.dispose(); + await ubuntuB.dispose(); + await debian.dispose(); + expect(ubuntuHandle.dispose).not.toHaveBeenCalled(); + expect(debianHandle.dispose).not.toHaveBeenCalled(); }); - it("recycles a dedicated server when its managed custom MCP set changes", async () => { - const firstHandle = makeHandle("http://127.0.0.1:4350"); - const secondHandle = makeHandle("http://127.0.0.1:4351"); - const firstClient = makeSubagentClient(); - const secondClient = makeSubagentClient(); - mocks.spawnOpenCodeServer.mockReturnValueOnce(firstHandle).mockReturnValueOnce(secondHandle); - mocks.createOpencodeClient.mockReturnValueOnce(firstClient).mockReturnValueOnce(secondClient); - const location = { kind: "posix", path: "/repo-custom" } as const; - const customMcp = { - id: "memory-id", - name: "memory", - description: "", - enabled: true, - timeoutMs: 30_000, - transport: { type: "stdio" as const, command: "memory-server", args: [], env: {} }, - }; + it("rebuilds MCP state for only the changed directory", async () => { + const handle = makeHandle("http://127.0.0.1:4500"); + const eventClient = makeSubagentClient(); + const firstAClient = makeSubagentClient(); + const firstBClient = makeSubagentClient(); + mocks.spawnOpenCodeServer.mockReturnValue(handle); + mocks.createOpencodeClient + .mockReturnValueOnce(eventClient) + .mockReturnValueOnce(firstAClient) + .mockReturnValueOnce(firstBClient); const { acquireOpenCodeServer } = await import("./sdkClient"); - const first = await acquireOpenCodeServer({ - projectLocation: location, - dedicatedKey: "thread-custom", - mcpServers: [customMcp], + const firstA = await acquireOpenCodeServer({ + projectLocation: { kind: "posix", path: "/repo-a" }, + mcpServers: [remoteMcp("memory", "http://127.0.0.1:9500/mcp")], }); - const second = await acquireOpenCodeServer({ - projectLocation: location, - dedicatedKey: "thread-custom", - mcpServers: [], + const firstB = await acquireOpenCodeServer({ + projectLocation: { kind: "posix", path: "/repo-b" }, + mcpServers: [remoteMcp("chrome", chromeMcp.url, chromeMcp.headers)], }); + await firstA.updateMcpServers([]); - expect(firstClient.mcp.disconnect).toHaveBeenCalledWith({ - directory: "/repo-custom", + expect(mocks.spawnOpenCodeServer).toHaveBeenCalledTimes(1); + expect(firstAClient.mcp.disconnect).toHaveBeenCalledWith({ + directory: "/repo-a", name: "memory", }); - expect(mocks.spawnOpenCodeServer).toHaveBeenCalledTimes(2); - expect(second.baseUrl).toBe("http://127.0.0.1:4351"); - expect(firstHandle.dispose).not.toHaveBeenCalled(); + expect(firstAClient.instance.dispose).toHaveBeenCalledWith({ directory: "/repo-a" }); + expect(firstBClient.instance.dispose).not.toHaveBeenCalled(); + expect(handle.dispose).not.toHaveBeenCalled(); - await first.dispose(); - expect(firstHandle.dispose).toHaveBeenCalledTimes(1); - await second.dispose(); - expect(secondHandle.dispose).toHaveBeenCalledTimes(1); + await firstA.dispose(); + await firstB.dispose(); }); - it("shares one server for two child acquires that do not host the Crossagents MCP", async () => { - const handle = makeHandle("http://127.0.0.1:4400"); + it("updates an existing MCP config without disposing the directory", async () => { + const handle = makeHandle("http://127.0.0.1:4550"); + const eventClient = makeSubagentClient(); + const client = makeSubagentClient(); mocks.spawnOpenCodeServer.mockReturnValue(handle); - mocks.createOpencodeClient.mockReturnValue({}); + mocks.createOpencodeClient.mockReturnValueOnce(eventClient).mockReturnValueOnce(client); const { acquireOpenCodeServer } = await import("./sdkClient"); - const location = { kind: "posix", path: "/repo-shared" } as const; + const acquired = await acquireOpenCodeServer({ + projectLocation: { kind: "posix", path: "/repo" }, + mcpServers: [remoteMcp("memory", "http://127.0.0.1:9500/mcp")], + }); + await acquired.updateMcpServers([remoteMcp("memory", "http://127.0.0.1:9501/mcp")]); - const first = await acquireOpenCodeServer({ projectLocation: location }); - const second = await acquireOpenCodeServer({ projectLocation: location }); + expect(client.mcp.add).toHaveBeenCalledTimes(2); + expect(client.mcp.disconnect).not.toHaveBeenCalled(); + expect(client.instance.dispose).not.toHaveBeenCalled(); - expect(mocks.spawnOpenCodeServer).toHaveBeenCalledTimes(1); - expect(first.baseUrl).toBe(second.baseUrl); + await acquired.dispose(); + }); + + it("leaves managed MCP state unchanged when a caller omits mcpServers", async () => { + const handle = makeHandle("http://127.0.0.1:4600"); + const eventClient = makeSubagentClient(); + const settingsClient = makeSubagentClient(); + const oneShotClient = makeSubagentClient(); + mocks.spawnOpenCodeServer.mockReturnValue(handle); + mocks.createOpencodeClient + .mockReturnValueOnce(eventClient) + .mockReturnValueOnce(settingsClient) + .mockReturnValueOnce(oneShotClient); + + const { acquireOpenCodeServer } = await import("./sdkClient"); + const settings = await acquireOpenCodeServer({ + projectLocation: { kind: "posix", path: "/repo" }, + mcpServers: [remoteMcp("chrome", chromeMcp.url, chromeMcp.headers)], + }); + const oneShot = await acquireOpenCodeServer({ + projectLocation: { kind: "posix", path: "/repo" }, + }); + + expect(oneShotClient.mcp.add).not.toHaveBeenCalled(); + expect(oneShotClient.mcp.disconnect).not.toHaveBeenCalled(); + expect(oneShotClient.instance.dispose).not.toHaveBeenCalled(); + + await settings.dispose(); + await oneShot.dispose(); + }); + + it("serializes concurrent MCP updates for the same directory", async () => { + const handle = makeHandle("http://127.0.0.1:4700"); + const eventClient = makeSubagentClient(); + const firstClient = makeSubagentClient(); + const secondClient = makeSubagentClient(); + let markStarted!: () => void; + let releaseAdd!: () => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const blockedAdd = new Promise((resolve) => { + releaseAdd = resolve; + }); + firstClient.mcp.add.mockImplementation(async () => { + markStarted(); + await blockedAdd; + }); + mocks.spawnOpenCodeServer.mockReturnValue(handle); + mocks.createOpencodeClient + .mockReturnValueOnce(eventClient) + .mockReturnValueOnce(firstClient) + .mockReturnValueOnce(secondClient); + + const { acquireOpenCodeServer } = await import("./sdkClient"); + const firstPromise = acquireOpenCodeServer({ + projectLocation: { kind: "posix", path: "/repo" }, + mcpServers: [remoteMcp("chrome", chromeMcp.url, chromeMcp.headers)], + }); + await started; + const secondPromise = acquireOpenCodeServer({ + projectLocation: { kind: "posix", path: "/repo" }, + mcpServers: [], + }); + await Promise.resolve(); + + expect(secondClient.instance.dispose).not.toHaveBeenCalled(); + releaseAdd(); + const first = await firstPromise; + const second = await secondPromise; + expect(secondClient.instance.dispose).toHaveBeenCalledWith({ directory: "/repo" }); await first.dispose(); - expect(handle.dispose).not.toHaveBeenCalled(); // second still holds a ref await second.dispose(); - expect(handle.dispose).toHaveBeenCalledTimes(1); }); it("shutdownSpawnedOpenCodeServers clears pool bookkeeping and disposes tracked spawns only", async () => { diff --git a/src/supervisor/agents/opencode/sdkClient.ts b/src/supervisor/agents/opencode/sdkClient.ts index 9a711c41..84f19634 100644 --- a/src/supervisor/agents/opencode/sdkClient.ts +++ b/src/supervisor/agents/opencode/sdkClient.ts @@ -1,9 +1,14 @@ +import { randomUUID } from "node:crypto"; +import { resolve as resolvePosixPath } from "node:path/posix"; +import { resolve as resolveWindowsPath } from "node:path/win32"; import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"; import type { ProjectLocation, ResolvedMcpServer } from "@/shared/contracts"; +import { resolveWslHomeDirectoryAsync, type AgentEnvContext } from "../base"; import { resolveAgentBinaryPath } from "../binaryResolver"; import { buildOpenCodeServerCommand } from "./argv"; import { buildOpenCodeMcp } from "../userMcp"; import { classifyOpenCodeError, isOpenCodeConnectionLoss } from "./opencodeErrors"; +import { installOpenCodePlugin } from "./plugin/install"; import { disposeSpawnedOpenCodeServerHandles, spawnOpenCodeServer, @@ -14,71 +19,83 @@ import { export function resolveOpenCodeSessionDirectory(location: ProjectLocation): string { switch (location.kind) { case "windows": - return location.path; + return resolveWindowsPath(location.path); case "wsl": - return location.linuxPath; + return resolvePosixPath(location.linuxPath); case "posix": - return location.path; + return resolvePosixPath(location.path); } } -function poolKey(location: ProjectLocation, dedicatedKey?: string): string { - const base = ((): string => { - switch (location.kind) { - case "windows": - return `windows:${location.path}`; - case "wsl": - return `wsl:${location.distro}:${location.linuxPath}`; - case "posix": - return `posix:${location.path}`; - } - })(); - // A `dedicatedKey` (the thread id) carves this acquisition out of the shared - // per-project pool into its own single-tenant entry. Used when a thread hosts - // the per-thread Crossagents MCP: its bearer token identifies exactly one parent - // thread, so it must not be registered on a server shared by sibling threads - // (which would misattribute their spawns). The entry still refcounts and tears - // down through the same machinery — with one acquirer it dies when that thread - // disposes. - return dedicatedKey ? `${base}::dedicated:${dedicatedKey}` : base; +function poolKey(location: ProjectLocation): string { + switch (location.kind) { + case "windows": + return "windows"; + case "wsl": + return `wsl:${location.distro}`; + case "posix": + return "posix"; + } } export interface AcquiredOpenCodeServer { + /** Directory-scoped SDK client for this acquisition's project. */ client: OpencodeClient; + /** Shared directoryless SDK client for the runtime-wide global event stream. */ + eventClient: OpencodeClient; baseUrl: string; handle: OpenCodeServerHandle; + onServerExit?(callback: () => void): () => void; + updateMcpServers(servers: readonly ResolvedMcpServer[]): Promise; dispose(): Promise; } interface ServerSnapshot { - client: OpencodeClient; + eventClient: OpencodeClient; baseUrl: string; handle: OpenCodeServerHandle; + authorization: string; +} + +interface DirectoryMcpState { + managedMcpServers?: ReturnType; + managedMcpFingerprint?: string; + sync: Promise; } interface PoolEntry { - key: string; ready: Promise; - refCount: number; - /** - * When set, the entry is currently waiting out an idle-grace window. A new - * `acquire` cancels the timer and reuses the entry; if it fires, the server - * is torn down. Mirrors t3code's `scheduleIdleClose`. - */ - idleCloseTimer?: NodeJS.Timeout | undefined; - /** Caller-requested idle TTL on the current grace timer (informational). */ - pendingIdleTtlMs?: number | undefined; - managedMcpNames: Set; - managedMcpFingerprint?: string; - /** Replaced by a fresh generation after its dynamic MCP config changed. */ - retired?: boolean; + /** Dynamic MCP state is isolated by OpenCode directory instance. */ + directoryMcp: Map; } -// Per-project pool. The OpenCode HTTP server can host any number of sessions -// in the same SQLite store, so multiple GUI threads in the same project share -// one `opencode serve` process. Refcounted: the last release tears the server -// down; if a release races with a fresh acquire, the in-flight `ready` promise -// is reused. +async function installSharedServerPlugin(projectLocation: ProjectLocation): Promise { + try { + const baseDir = process.env.PORACODE_DATA_DIR?.trim(); + const ctx: AgentEnvContext = + projectLocation.kind === "wsl" + ? { + envKind: "wsl", + wslDistro: projectLocation.distro, + ...(baseDir ? { baseDir } : {}), + } + : { envKind: projectLocation.kind, ...(baseDir ? { baseDir } : {}) }; + if (projectLocation.kind === "wsl") { + await resolveWslHomeDirectoryAsync(projectLocation.distro); + } + const result = installOpenCodePlugin(ctx); + if (!result.ok) { + console.warn(`[opencode] failed to install shared-server plugin: ${result.reason}`); + } + } catch (error) { + console.warn("[opencode] failed to install shared-server plugin:", error); + } +} + +// One app-lifetime server per execution runtime: one native process for the +// host platform, plus one process per WSL distro. OpenCode routes each SDK +// request to a lazily-created directory instance via x-opencode-directory, +// matching the v2 desktop app. const pool = new Map(); // Total budget for confirming the server is reachable once it has announced @@ -100,13 +117,14 @@ const REACHABLE_ATTEMPT_TIMEOUT_MS = 1_000; * We poll the root route — any HTTP response, including a 404, proves the * round-trip works — backing off until it answers or the budget expires. */ -async function waitForOpenCodeReachable(baseUrl: string): Promise { +async function waitForOpenCodeReachable(baseUrl: string, authorization: string): Promise { const deadline = Date.now() + REACHABLE_TIMEOUT_MS; let backoffMs = 50; for (;;) { try { await fetch(baseUrl, { method: "GET", + headers: { Authorization: authorization }, signal: AbortSignal.timeout(REACHABLE_ATTEMPT_TIMEOUT_MS), }); return; @@ -127,78 +145,127 @@ async function waitForOpenCodeReachable(baseUrl: string): Promise { } } +async function createSdkClient( + baseUrl: string, + authorization: string, + directory?: string, +): Promise { + const { createOpencodeClient } = await import("@opencode-ai/sdk/v2/client"); + return createOpencodeClient({ + baseUrl, + headers: { Authorization: authorization }, + throwOnError: true, + ...(directory !== undefined ? { directory } : {}), + }); +} + async function spawnAndWire(projectLocation: ProjectLocation): Promise { + // The shared process must load the Poracode plugin before it starts. Besides + // lifecycle hooks for terminal launches, the plugin injects the trusted + // provider session id used to route Crossagents calls from pooled GUI + // sessions. Installing after `opencode serve` starts is too late because its + // plugin set is fixed for the lifetime of the shared process. + await installSharedServerPlugin(projectLocation); const resolvedExecPath = resolveAgentBinaryPath(projectLocation, "opencode"); - const command = buildOpenCodeServerCommand(projectLocation, resolvedExecPath); + const username = "opencode"; + const password = randomUUID(); + const authorization = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; + const command = buildOpenCodeServerCommand(projectLocation, resolvedExecPath, { + OPENCODE_SERVER_USERNAME: username, + OPENCODE_SERVER_PASSWORD: password, + PORACODE_OPENCODE_SESSION_ROUTING: "1", + }); const handle = spawnOpenCodeServer(command); - let baseUrl: string; try { - baseUrl = await handle.baseUrl; + const baseUrl = await handle.baseUrl; if (projectLocation.kind === "wsl") { - await waitForOpenCodeReachable(baseUrl); + await waitForOpenCodeReachable(baseUrl, authorization); } + + const eventClient = await createSdkClient(baseUrl, authorization); + return { eventClient, baseUrl, handle, authorization }; } catch (err) { await handle.dispose(); throw err; } - - const { createOpencodeClient } = await import("@opencode-ai/sdk/v2/client"); - const client = createOpencodeClient({ - baseUrl, - directory: resolveOpenCodeSessionDirectory(projectLocation), - throwOnError: true, - }); - - return { client, baseUrl, handle }; } /** - * Spawn (or reuse) an `opencode serve` for the given project, wait for the - * ready URL, and return a wired-up SDK client. The local loopback server is - * unauthenticated by design (no `OPENCODE_SERVER_PASSWORD` is set), matching - * OpenCode's local app-server usage. + * Spawn (or reuse) an authenticated `opencode serve` for the given execution + * runtime, wait for the ready URL, and return a project-directory SDK client. * - * Disposal is per-acquisition: each acquire returns its own `dispose()` that - * decrements the refcount. The underlying server stays alive until the last - * acquirer releases it. TUI flow calls `dispose()` immediately after - * `session.create`; GUI flow keeps its acquisition for the thread's lifetime. + * Each acquisition keeps the existing `dispose()` contract, but normal + * releases leave the app-lifetime sidecar warm. Only a crash, a connection-loss + * replacement, or supervisor shutdown tears it down. */ export interface AcquireOpenCodeServerInput { projectLocation: ProjectLocation; mcpServers?: readonly ResolvedMcpServer[]; - /** - * When set, this acquisition gets its own single-tenant pool entry keyed by - * this value (the thread id) instead of joining the shared per-project pool. - * Callers that need per-thread MCP isolation pass their thread id here. - */ - dedicatedKey?: string; - /** - * If set, the server stays alive for this many milliseconds after the last - * release before being torn down. A re-acquire within the window reuses the - * same server and cancels the pending teardown. Callers that want - * immediate teardown (the TUI and GUI thread flows) leave this unset. - */ - idleCloseDelayMs?: number; } -// No disconnect diffing is needed here: any change to the MCP set -// changes the fingerprint, which retires the server before this runs. -async function syncMcpServers( - input: Pick, +async function addMcpServers( + directory: string, servers: ReturnType, client: OpencodeClient, -): Promise> { - const directory = resolveOpenCodeSessionDirectory(input.projectLocation); +): Promise { await Promise.all( - Object.entries(servers).map(async ([name, config]) => { - await client.mcp.add({ directory, name, config }).catch((error) => { - if (isOpenCodeConnectionLoss(error)) throw error; - }); - await client.mcp.connect({ directory, name }); - }), + Object.entries(servers).map(([name, config]) => client.mcp.add({ directory, name, config })), ); - return new Set(Object.keys(servers)); +} + +async function syncDirectoryMcpServers( + entry: PoolEntry, + directory: string, + servers: ReturnType, + client: OpencodeClient, +): Promise { + const existing = entry.directoryMcp.get(directory); + const state = existing ?? { sync: Promise.resolve() }; + if (!existing) entry.directoryMcp.set(directory, state); + + const nextFingerprint = JSON.stringify( + Object.entries(servers).sort(([left], [right]) => left.localeCompare(right)), + ); + const nextNames = new Set(Object.keys(servers)); + const operation = state.sync.then(async () => { + if (state.managedMcpFingerprint === nextFingerprint) return; + + let serversToAdd = servers; + if (state.managedMcpFingerprint !== undefined) { + const previousServers = state.managedMcpServers ?? {}; + const removedNames = Object.keys(previousServers).filter((name) => !nextNames.has(name)); + if (removedNames.length > 0) { + await Promise.all( + removedNames.map((name) => + client.mcp.disconnect({ directory, name }).catch((error) => { + if (isOpenCodeConnectionLoss(error)) throw error; + }), + ), + ); + + // The v2 SDK has no dynamic mcp.remove endpoint. Dispose only this + // directory instance to clear removed runtime config, then rebuild + // the current set. Other project instances and the server stay live. + await client.instance.dispose({ directory }); + } else { + serversToAdd = Object.fromEntries( + Object.entries(servers).filter( + ([name, config]) => JSON.stringify(previousServers[name]) !== JSON.stringify(config), + ), + ); + } + } + + await addMcpServers(directory, serversToAdd, client); + state.managedMcpServers = servers; + state.managedMcpFingerprint = nextFingerprint; + }); + + // Serialize concurrent settings reloads for this directory without leaving + // a rejected promise that would poison later retries. + state.sync = operation.catch(() => undefined); + await operation; } export async function acquireOpenCodeServer( @@ -211,12 +278,12 @@ async function acquireOpenCodeServerInner( input: AcquireOpenCodeServerInput, retryMcpConnectionLoss: boolean, ): Promise { - const key = poolKey(input.projectLocation, input.dedicatedKey); + const key = poolKey(input.projectLocation); let entry = pool.get(key); if (!entry) { const ready = spawnAndWire(input.projectLocation); - entry = { key, ready, refCount: 0, managedMcpNames: new Set() }; + entry = { ready, directoryMcp: new Map() }; pool.set(key, entry); // If spawn fails, evict so the next acquire respawns instead of resolving @@ -236,99 +303,47 @@ async function acquireOpenCodeServerInner( } const acquiringEntry = entry; - acquiringEntry.refCount += 1; - // Cancel any pending idle-teardown: this acquire reuses the same server. - if (acquiringEntry.idleCloseTimer) { - clearTimeout(acquiringEntry.idleCloseTimer); - acquiringEntry.idleCloseTimer = undefined; - acquiringEntry.pendingIdleTtlMs = undefined; - } - - let snapshot: ServerSnapshot; - try { - snapshot = await acquiringEntry.ready; - } catch (err) { - acquiringEntry.refCount -= 1; - throw err; - } + const snapshot = await acquiringEntry.ready; - let released = false; - const idleCloseDelayMs = input.idleCloseDelayMs; + const directory = resolveOpenCodeSessionDirectory(input.projectLocation); + const client = await createSdkClient(snapshot.baseUrl, snapshot.authorization, directory); try { - const nextMcp = buildOpenCodeMcp(input.mcpServers ?? []); - const nextMcpFingerprint = JSON.stringify(nextMcp); - if ( - acquiringEntry.managedMcpFingerprint !== undefined && - acquiringEntry.managedMcpFingerprint !== nextMcpFingerprint - ) { - const directory = resolveOpenCodeSessionDirectory(input.projectLocation); - await Promise.all( - [...acquiringEntry.managedMcpNames].map((name) => - snapshot.client.mcp.disconnect({ directory, name }).catch(() => undefined), - ), - ); - released = true; - acquiringEntry.refCount -= 1; - acquiringEntry.retired = true; - if (pool.get(key) === acquiringEntry) pool.delete(key); - if (acquiringEntry.refCount === 0) { - await snapshot.handle.dispose().catch((error) => { - console.warn("[opencode] failed to dispose retired MCP server:", error); - }); - } - return acquireOpenCodeServerInner(input, retryMcpConnectionLoss); + // Omitted MCP config means this caller (notably one-shot generation) does + // not own provider settings and must leave the directory instance alone. + // An explicit empty array is the settings-level request to clear the set. + if (input.mcpServers !== undefined) { + const nextMcp = buildOpenCodeMcp(input.mcpServers); + await syncDirectoryMcpServers(acquiringEntry, directory, nextMcp, client); } - - acquiringEntry.managedMcpNames = await syncMcpServers(input, nextMcp, snapshot.client); - acquiringEntry.managedMcpFingerprint = nextMcpFingerprint; } catch (error) { if (!retryMcpConnectionLoss || !isOpenCodeConnectionLoss(error)) { - console.warn("[opencode] failed to sync managed MCP servers:", error); - } else { - released = true; - acquiringEntry.refCount -= 1; - if (pool.get(key) === acquiringEntry) pool.delete(key); - await snapshot.handle.dispose().catch((disposeErr) => { - console.warn("[opencode] failed to dispose handle during retry:", disposeErr); - }); - return acquireOpenCodeServerInner(input, false); + throw error; } + if (pool.get(key) === acquiringEntry) pool.delete(key); + await snapshot.handle.dispose().catch((disposeErr) => { + console.warn("[opencode] failed to dispose handle during retry:", disposeErr); + }); + return acquireOpenCodeServerInner(input, false); } return { - client: snapshot.client, + client, + eventClient: snapshot.eventClient, baseUrl: snapshot.baseUrl, handle: snapshot.handle, - dispose: async () => { - if (released) return; - released = true; - acquiringEntry.refCount -= 1; - if (acquiringEntry.refCount > 0) return; - - // No idle window requested → tear down immediately, matching prior - // behaviour for the TUI/GUI flows. - if (acquiringEntry.retired || idleCloseDelayMs === undefined || idleCloseDelayMs <= 0) { - if (pool.get(key) === acquiringEntry) pool.delete(key); - await snapshot.handle.dispose(); - return; - } - - // Hold the entry open for `idleCloseDelayMs`. The next `acquire` - // cancels the timer above; if no acquire comes, the timer tears the - // server down and evicts the entry. - acquiringEntry.pendingIdleTtlMs = idleCloseDelayMs; - acquiringEntry.idleCloseTimer = setTimeout(() => { - if (acquiringEntry.refCount > 0) return; - if (pool.get(key) !== acquiringEntry) return; - pool.delete(key); - void snapshot.handle.dispose(); - }, idleCloseDelayMs); - // Don't keep the process alive just to honour an idle-close timer. - if (typeof acquiringEntry.idleCloseTimer.unref === "function") { - acquiringEntry.idleCloseTimer.unref(); + onServerExit: (callback) => { + if (snapshot.handle.child.exitCode !== null) { + queueMicrotask(callback); + return () => {}; } + snapshot.handle.child.once("exit", callback); + return () => snapshot.handle.child.off("exit", callback); + }, + updateMcpServers: async (servers) => { + await syncDirectoryMcpServers(acquiringEntry, directory, buildOpenCodeMcp(servers), client); }, + dispose: () => Promise.resolve(), }; } @@ -339,12 +354,6 @@ async function acquireOpenCodeServerInner( * `opencode.exe` processes the user started outside the app. */ export function shutdownSpawnedOpenCodeServers(): void { - for (const entry of pool.values()) { - if (entry.idleCloseTimer) { - clearTimeout(entry.idleCloseTimer); - entry.idleCloseTimer = undefined; - } - } pool.clear(); disposeSpawnedOpenCodeServerHandles(); } diff --git a/src/supervisor/agents/opencode/sdkEventHub.ts b/src/supervisor/agents/opencode/sdkEventHub.ts new file mode 100644 index 00000000..5ed775d6 --- /dev/null +++ b/src/supervisor/agents/opencode/sdkEventHub.ts @@ -0,0 +1,248 @@ +import type { Event } from "@opencode-ai/sdk/v2"; +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"; + +interface OpenCodeEventSubscriber { + directory: string; + onEvent(event: Event): void; +} + +interface QueuedOpenCodeEvent { + directory: string; + payload: Event; +} + +interface SubscribeOpenCodeServerEventsInput extends OpenCodeEventSubscriber { + eventClient: OpencodeClient; +} + +const hubs = new WeakMap(); + +const FLUSH_FRAME_MS = 16; +const STREAM_YIELD_MS = 8; +const RECONNECT_DELAY_MS = 250; +const HEARTBEAT_TIMEOUT_MS = 15_000; + +function isAbortError(error: unknown): boolean { + return ( + error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" + ); +} + +function coalescedKey(event: QueuedOpenCodeEvent): string | undefined { + if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`; + if (event.payload.type === "message.part.updated") { + const part = event.payload.properties.part; + return `message.part.updated:${event.directory}:${part.messageID}:${part.id}`; + } + return undefined; +} + +function enqueueOpenCodeEvent(queue: QueuedOpenCodeEvent[], event: QueuedOpenCodeEvent): boolean { + const key = coalescedKey(event); + const previous = queue[queue.length - 1]; + if (key && previous && coalescedKey(previous) === key) { + queue[queue.length - 1] = event; + return false; + } + queue.push(event); + return true; +} + +function coalesceOpenCodeEvents(events: QueuedOpenCodeEvent[]): QueuedOpenCodeEvent[] { + const output: QueuedOpenCodeEvent[] = []; + for (const event of events) { + if (event.payload.type !== "message.part.delta") { + output.push(event); + continue; + } + + const previous = output[output.length - 1]; + if ( + !previous || + previous.payload.type !== "message.part.delta" || + previous.directory !== event.directory || + previous.payload.properties.messageID !== event.payload.properties.messageID || + previous.payload.properties.partID !== event.payload.properties.partID || + previous.payload.properties.field !== event.payload.properties.field + ) { + output.push({ + directory: event.directory, + payload: { ...event.payload, properties: { ...event.payload.properties } }, + }); + continue; + } + + output[output.length - 1] = { + directory: event.directory, + payload: { + ...event.payload, + properties: { + ...event.payload.properties, + delta: previous.payload.properties.delta + event.payload.properties.delta, + }, + }, + }; + } + return output; +} + +function unwrapGlobalOpenCodeEvent(raw: unknown): QueuedOpenCodeEvent | undefined { + if (!raw || typeof raw !== "object") return undefined; + const envelope = raw as { directory?: unknown; payload?: unknown }; + // Server-wide `server.connected` and `server.heartbeat` envelopes have no + // directory. They keep the stream alive but are not routed to sessions. + if (typeof envelope.directory !== "string") return undefined; + if (!envelope.payload || typeof envelope.payload !== "object") return undefined; + const type = (envelope.payload as { type?: unknown }).type; + if (typeof type !== "string" || type === "sync") return undefined; + return { directory: envelope.directory, payload: envelope.payload as Event }; +} + +class OpenCodeEventHub { + private readonly eventClient: OpencodeClient; + private readonly subscribersByDirectory = new Map>(); + private queue: QueuedOpenCodeEvent[] = []; + private flushTimer: NodeJS.Timeout | undefined; + private streamAbort: AbortController | undefined; + private heartbeatTimer: NodeJS.Timeout | undefined; + private running = false; + private generation = 0; + private lastFlushAt = 0; + private streamErrorLogged = false; + + constructor(eventClient: OpencodeClient) { + this.eventClient = eventClient; + } + + subscribe(subscriber: OpenCodeEventSubscriber): () => void { + let subscribers = this.subscribersByDirectory.get(subscriber.directory); + if (!subscribers) { + subscribers = new Set(); + this.subscribersByDirectory.set(subscriber.directory, subscribers); + } + subscribers.add(subscriber); + if (!this.running) this.start(); + + let subscribed = true; + return () => { + if (!subscribed) return; + subscribed = false; + subscribers.delete(subscriber); + if (subscribers.size === 0) this.subscribersByDirectory.delete(subscriber.directory); + if (this.subscribersByDirectory.size === 0) this.stop(); + }; + } + + private start(): void { + this.running = true; + const generation = ++this.generation; + void this.consume(generation); + } + + private stop(): void { + this.running = false; + this.generation += 1; + this.streamAbort?.abort(); + this.streamAbort = undefined; + this.clearHeartbeat(); + if (this.flushTimer) clearTimeout(this.flushTimer); + this.flushTimer = undefined; + this.queue = []; + if (hubs.get(this.eventClient) === this) hubs.delete(this.eventClient); + } + + private async consume(generation: number): Promise { + while (this.running && this.generation === generation) { + const streamAbort = new AbortController(); + this.streamAbort = streamAbort; + try { + const events = await this.eventClient.global.event({ + signal: streamAbort.signal, + onSseError: (error) => this.logStreamError(error, streamAbort.signal), + }); + let yieldedAt = Date.now(); + this.resetHeartbeat(streamAbort); + for await (const raw of events.stream) { + // OpenCode's 1.18.3 global route emits an untyped server.heartbeat + // envelope every 10 seconds. Reset before filtering because that + // server-wide envelope intentionally has no directory. + this.resetHeartbeat(streamAbort); + this.streamErrorLogged = false; + const event = unwrapGlobalOpenCodeEvent(raw); + if (event && enqueueOpenCodeEvent(this.queue, event)) this.scheduleFlush(); + + if (Date.now() - yieldedAt < STREAM_YIELD_MS) continue; + yieldedAt = Date.now(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } catch (error) { + this.logStreamError(error, streamAbort.signal); + } finally { + if (this.streamAbort === streamAbort) this.streamAbort = undefined; + this.clearHeartbeat(); + } + + if (!this.running || this.generation !== generation) return; + await new Promise((resolve) => setTimeout(resolve, RECONNECT_DELAY_MS)); + } + } + + private logStreamError(error: unknown, signal: AbortSignal): void { + if (isAbortError(error) || signal.aborted || this.streamErrorLogged) return; + this.streamErrorLogged = true; + console.error("[opencode] global event stream failed:", error); + } + + private resetHeartbeat(streamAbort: AbortController): void { + if (this.heartbeatTimer) { + this.heartbeatTimer.refresh(); + return; + } + this.heartbeatTimer = setTimeout(() => streamAbort.abort(), HEARTBEAT_TIMEOUT_MS); + this.heartbeatTimer.unref?.(); + } + + private clearHeartbeat(): void { + if (!this.heartbeatTimer) return; + clearTimeout(this.heartbeatTimer); + this.heartbeatTimer = undefined; + } + + private scheduleFlush(): void { + if (this.flushTimer) return; + const elapsed = Date.now() - this.lastFlushAt; + this.flushTimer = setTimeout(() => this.flush(), Math.max(0, FLUSH_FRAME_MS - elapsed)); + } + + private flush(): void { + if (this.flushTimer) clearTimeout(this.flushTimer); + this.flushTimer = undefined; + if (this.queue.length === 0) return; + + const events = coalesceOpenCodeEvents(this.queue); + this.queue = []; + this.lastFlushAt = Date.now(); + for (const event of events) { + const subscribers = this.subscribersByDirectory.get(event.directory); + if (!subscribers) continue; + for (const subscriber of subscribers) { + try { + subscriber.onEvent(event.payload); + } catch (error) { + console.error("[opencode] event subscriber failed:", error); + } + } + } + } +} + +export function subscribeOpenCodeServerEvents( + input: SubscribeOpenCodeServerEventsInput, +): () => void { + let hub = hubs.get(input.eventClient); + if (!hub) { + hub = new OpenCodeEventHub(input.eventClient); + hubs.set(input.eventClient, hub); + } + return hub.subscribe({ directory: input.directory, onEvent: input.onEvent }); +} diff --git a/src/supervisor/agents/opencode/sdkOneShot.ts b/src/supervisor/agents/opencode/sdkOneShot.ts index b8080132..9bd687a7 100644 --- a/src/supervisor/agents/opencode/sdkOneShot.ts +++ b/src/supervisor/agents/opencode/sdkOneShot.ts @@ -1,10 +1,9 @@ /** * SDK-backed one-shot text generation (commit / PR / title gen). * - * Reuses the per-project `opencode serve` pool from `sdkClient.ts`, but - * acquires with an idle TTL so consecutive calls (commit-msg then PR title, - * etc.) share the same warm server. After the TTL elapses with no callers, - * the server is torn down by the pool. + * Reuses the app-lifetime runtime `opencode serve` pool from `sdkClient.ts`, + * so consecutive calls (commit-msg then PR title, etc.) share the same warm + * server with interactive project sessions. * * Each call creates a throwaway session with a deny-all permission ruleset * (the prompt is informational; we never want one-shot generation to touch @@ -17,14 +16,6 @@ import type { RunOneShotInput } from "../base"; import { classifyOpenCodeError } from "./opencodeErrors"; import { acquireOpenCodeServer, type AcquiredOpenCodeServer } from "./sdkClient"; -/** - * Keep the shared server warm for 30s after the last one-shot completes, - * matching t3code's TTL. Long enough to cover the chain of `commit-msg → - * branch-name → PR title` calls a typical commit triggers; short enough that - * the server doesn't linger if the user closes the app. - */ -const ONE_SHOT_IDLE_TTL_MS = 30_000; - const DENY_ALL_PERMISSIONS = [{ permission: "*", pattern: "*", action: "deny" }] as const; const READ_ONLY_WORKSPACE_PERMISSIONS = [ ...DENY_ALL_PERMISSIONS, @@ -41,7 +32,6 @@ interface AcquireInput { async function acquireOneShotServer(input: AcquireInput): Promise { return acquireOpenCodeServer({ projectLocation: input.location, - idleCloseDelayMs: ONE_SHOT_IDLE_TTL_MS, }); } @@ -172,7 +162,7 @@ export async function runOpenCodeOneShot(input: RunOneShotInput): Promise { - /* idle close + dispose are best-effort; the pool tears down on next acquire if needed */ + /* acquisition release is best-effort; supervisor shutdown owns the sidecar */ }); } } diff --git a/src/supervisor/agents/opencode/sdkProbe.ts b/src/supervisor/agents/opencode/sdkProbe.ts index 51deb0ec..6dd53a91 100644 --- a/src/supervisor/agents/opencode/sdkProbe.ts +++ b/src/supervisor/agents/opencode/sdkProbe.ts @@ -3,9 +3,9 @@ * * The CLI parser (`opencode models --verbose`) was the only way to enumerate * providers/models/variants before we started shipping the SDK runtime. Now - * that every project ends up spawning `opencode serve` for the GUI / TUI - * session allocation anyway, we may as well also use it for the one-time - * inventory call. The SDK returns: + * that the SDK runtime keeps one shared `opencode serve` per execution + * environment, the one-time inventory call reuses that same sidecar. The SDK + * returns: * * - per-provider model `name` (we no longer need slug-titleization heuristics * for everything the API knows) @@ -22,10 +22,7 @@ */ import type { ProjectLocation } from "@/shared/contracts"; -import { resolveAgentBinaryPath } from "../binaryResolver"; -import { buildOpenCodeServerCommand } from "./argv"; -import { resolveOpenCodeSessionDirectory } from "./sdkClient"; -import { spawnOpenCodeServer } from "./sdkServer"; +import { acquireOpenCodeServer } from "./sdkClient"; /** Per-model entry returned by the SDK provider list, normalised. */ export interface OpenCodeSdkModel { @@ -59,8 +56,6 @@ export interface OpenCodeSdkInventory { agents: OpenCodeSdkAgent[]; } -const PROBE_READY_GRACE_MS = 15_000; - interface ProviderListPayloadModel { id?: unknown; name?: unknown; @@ -154,41 +149,17 @@ function normalizeAgentsResponse(raw: unknown): OpenCodeSdkAgent[] { } /** - * Spawn an ephemeral `opencode serve`, call `provider.list()` + `app.agents()`, - * and tear the server back down. The returned promise rejects on any failure; - * callers are expected to fall back to the CLI parser. + * Acquire the runtime sidecar and call `provider.list()` + `app.agents()` in + * this directory. The returned promise rejects on any failure; callers are + * expected to fall back to the CLI parser. */ export async function probeOpenCodeInventoryViaSdk( location: ProjectLocation, - executablePath: string, ): Promise { - const resolvedExecPath = resolveAgentBinaryPath(location, executablePath); - const command = buildOpenCodeServerCommand(location, resolvedExecPath); - const handle = spawnOpenCodeServer(command); - - let baseUrl: string; - try { - baseUrl = await Promise.race([ - handle.baseUrl, - new Promise((_, reject) => - setTimeout( - () => reject(new Error(`opencode serve did not start within ${PROBE_READY_GRACE_MS}ms`)), - PROBE_READY_GRACE_MS, - ), - ), - ]); - } catch (err) { - await handle.dispose(); - throw err; - } + const acquired = await acquireOpenCodeServer({ projectLocation: location }); try { - const { createOpencodeClient } = await import("@opencode-ai/sdk/v2/client"); - const client = createOpencodeClient({ - baseUrl, - directory: resolveOpenCodeSessionDirectory(location), - throwOnError: true, - }); + const client = acquired.client; const [providerListResult, agentsResult] = await Promise.all([ client.provider.list().catch((err: unknown) => { @@ -215,6 +186,6 @@ export async function probeOpenCodeInventoryViaSdk( const agents = normalizeAgentsResponse(agentsPayload); return { providers, connected, agents }; } finally { - await handle.dispose(); + await acquired.dispose(); } } diff --git a/src/supervisor/agents/opencode/sdkSession.test.ts b/src/supervisor/agents/opencode/sdkSession.test.ts index 87aff219..ad35b29d 100644 --- a/src/supervisor/agents/opencode/sdkSession.test.ts +++ b/src/supervisor/agents/opencode/sdkSession.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Event } from "@opencode-ai/sdk/v2"; import type { ProjectLocation, RuntimeEvent, ThreadConfig } from "@/shared/contracts"; import type { StructuredSessionUpdate } from "../base"; +import { subscribeOpenCodeServerEvents } from "./sdkEventHub"; import { OpencodeSdkSession, parseOpenCodeQuestionAnswers } from "./sdkSession"; const mocks = vi.hoisted(() => ({ @@ -32,6 +33,16 @@ function serverConnectedEvent(): Event { }; } +function emptyEventClient() { + return { + global: { + event: vi.fn<() => Promise<{ stream: AsyncGenerator }>>().mockResolvedValue({ + stream: streamOf(), + }), + }, + }; +} + describe("OpencodeSdkSession", () => { const projectLocation: ProjectLocation = { kind: "posix", path: "/repo" }; const config: ThreadConfig = { model: "opencode/big-pickle" }; @@ -42,15 +53,15 @@ describe("OpencodeSdkSession", () => { it("starts the GUI event stream on activation", async () => { const globalEvent = vi - .fn<() => Promise<{ stream: AsyncGenerator }>>() + .fn<() => Promise<{ stream: AsyncGenerator }>>() .mockResolvedValue({ - stream: streamOf(serverConnectedEvent()), + stream: streamOf({ payload: serverConnectedEvent() }), }); const dispose = vi.fn<() => Promise>().mockResolvedValue(undefined); mocks.acquireOpenCodeServer.mockResolvedValue({ + eventClient: { global: { event: globalEvent } }, client: { - global: { event: globalEvent }, command: { list: vi.fn<() => Promise<{ data: [] }>>().mockResolvedValue({ data: [] }) }, session: { create: vi @@ -82,17 +93,148 @@ describe("OpencodeSdkSession", () => { await session.openThread(config); expect(globalEvent).toHaveBeenCalledTimes(1); - expect(globalEvent).toHaveBeenCalledWith({ signal: expect.any(AbortSignal) }); + expect(globalEvent).toHaveBeenCalledWith({ + signal: expect.any(AbortSignal), + onSseError: expect.any(Function), + }); await session.dispose(); expect(dispose).toHaveBeenCalledTimes(1); }); - it("requests a dedicated server and forwards the Crossagents MCP when hosting", async () => { + it("shares one server event stream across GUI sessions", async () => { + const globalEvent = vi + .fn<() => Promise<{ stream: AsyncGenerator }>>() + .mockResolvedValue({ stream: streamOf() }); + const eventClient = { global: { event: globalEvent } }; + const handle = {}; + mocks.acquireOpenCodeServer.mockImplementation(() => + Promise.resolve({ + eventClient, + client: {}, + baseUrl: "http://127.0.0.1:0", + handle, + dispose: vi.fn<() => Promise>().mockResolvedValue(undefined), + }), + ); + + const first = await OpencodeSdkSession.create({ + threadId: "thread-first", + projectLocation, + config, + presentationMode: "gui", + }); + const second = await OpencodeSdkSession.create({ + threadId: "thread-second", + projectLocation, + config, + presentationMode: "gui", + }); + + await first.activate(); + await second.activate(); + + expect(globalEvent).toHaveBeenCalledTimes(1); + + await first.dispose(); + await second.dispose(); + }); + + it("isolates a throwing event subscriber from sibling sessions", async () => { + const eventClient = { + global: { + event: vi.fn<() => Promise<{ stream: AsyncGenerator }>>().mockResolvedValue({ + stream: streamOf({ directory: "/repo", payload: serverConnectedEvent() }), + }), + }, + }; + const sibling = vi.fn<(event: Event) => void>(); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const unsubscribeThrowing = subscribeOpenCodeServerEvents({ + eventClient: eventClient as never, + directory: "/repo", + onEvent: () => { + throw new Error("listener failed"); + }, + }); + const unsubscribeSibling = subscribeOpenCodeServerEvents({ + eventClient: eventClient as never, + directory: "/repo", + onEvent: sibling, + }); + + await vi.waitFor(() => expect(sibling).toHaveBeenCalledWith(serverConnectedEvent())); + expect(error).toHaveBeenCalledWith( + "[opencode] event subscriber failed:", + expect.objectContaining({ message: "listener failed" }), + ); + + unsubscribeThrowing(); + unsubscribeSibling(); + error.mockRestore(); + }); + + it("reacquires the shared server when its process exits", async () => { + let notifyServerExit!: () => void; + const firstDispose = vi.fn<() => Promise>().mockResolvedValue(undefined); + const secondDispose = vi.fn<() => Promise>().mockResolvedValue(undefined); + const firstEvent = emptyEventClient(); + const secondEvent = emptyEventClient(); + const promptAsync = vi.fn<() => Promise>().mockResolvedValue(undefined); + mocks.acquireOpenCodeServer + .mockResolvedValueOnce({ + eventClient: firstEvent, + client: { + command: { list: vi.fn<() => Promise<{ data: [] }>>().mockResolvedValue({ data: [] }) }, + session: { + create: vi + .fn<() => Promise<{ data: { id: string } }>>() + .mockResolvedValue({ data: { id: "ses_recover" } }), + }, + }, + baseUrl: "http://127.0.0.1:1", + handle: {}, + onServerExit: (callback: () => void) => { + notifyServerExit = callback; + return vi.fn<() => void>(); + }, + dispose: firstDispose, + }) + .mockResolvedValueOnce({ + eventClient: secondEvent, + client: { session: { promptAsync } }, + baseUrl: "http://127.0.0.1:2", + handle: {}, + onServerExit: () => vi.fn<() => void>(), + dispose: secondDispose, + }); + + const session = await OpencodeSdkSession.create({ + threadId: "thread-recover", + projectLocation, + config, + presentationMode: "gui", + }); + + await session.activate(); + await session.openThread(config); + notifyServerExit(); + await vi.waitFor(() => expect(mocks.acquireOpenCodeServer).toHaveBeenCalledTimes(2)); + + expect(firstDispose).toHaveBeenCalledTimes(1); + expect(secondEvent.global.event).toHaveBeenCalledTimes(1); + await session.startTurn("after restart", config); + expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({ sessionID: "ses_recover" })); + + await session.dispose(); + expect(secondDispose).toHaveBeenCalledTimes(1); + }); + + it("joins the shared pool and forwards the MCP set", async () => { const dispose = vi.fn<() => Promise>().mockResolvedValue(undefined); mocks.acquireOpenCodeServer.mockResolvedValue({ + eventClient: emptyEventClient(), client: { - global: { event: vi.fn<() => Promise<{ stream: AsyncGenerator }>>() }, command: { list: vi.fn<() => Promise<{ data: [] }>>().mockResolvedValue({ data: [] }) }, session: { create: vi @@ -105,44 +247,40 @@ describe("OpencodeSdkSession", () => { dispose, }); - const crossagentMcp = { - url: "http://127.0.0.1:9500/mcp", - token: "tok-parent", - headers: { Authorization: "Bearer tok-parent" }, - }; + const mcpServers = [ + { + id: "browser", + name: "browser", + timeoutMs: 30_000, + transport: { + type: "http" as const, + url: "http://127.0.0.1:9500/mcp", + headers: {}, + }, + }, + ]; const session = await OpencodeSdkSession.create({ threadId: "thread-host-42", projectLocation, config, presentationMode: "gui", - mcpServers: [ - { - id: "crossagents", - name: "crossagents", - timeoutMs: 300_000, - transport: { - type: "http", - url: crossagentMcp.url, - headers: crossagentMcp.headers, - }, - }, - ], + mcpServers, }); await session.activate(); expect(mocks.acquireOpenCodeServer).toHaveBeenCalledWith( - expect.objectContaining({ dedicatedKey: "thread-host-42" }), + expect.objectContaining({ projectLocation, mcpServers }), ); await session.dispose(); }); - it("does not request a dedicated server when not hosting the Crossagents MCP", async () => { + it("joins the shared pool with no MCP set when none is provided", async () => { const dispose = vi.fn<() => Promise>().mockResolvedValue(undefined); mocks.acquireOpenCodeServer.mockResolvedValue({ + eventClient: emptyEventClient(), client: { - global: { event: vi.fn<() => Promise<{ stream: AsyncGenerator }>>() }, command: { list: vi.fn<() => Promise<{ data: [] }>>().mockResolvedValue({ data: [] }) }, session: { create: vi @@ -165,12 +303,89 @@ describe("OpencodeSdkSession", () => { await session.activate(); const input = mocks.acquireOpenCodeServer.mock.calls[0]?.[0] as Record; - expect(input.dedicatedKey).toBeUndefined(); - expect(input.crossagentMcp).toBeUndefined(); + expect(input.mcpServers).toBeUndefined(); await session.dispose(); }); + it("stores a new MCP set via updateMcpServers without acquiring before activation", async () => { + const session = await OpencodeSdkSession.create({ + threadId: "thread-lazy-mcp", + projectLocation, + config, + presentationMode: "gui", + }); + + const newMcpServers = [ + { + id: "browser", + name: "browser", + timeoutMs: 300_000, + transport: { type: "http" as const, url: "http://127.0.0.1:9501/mcp", headers: {} }, + }, + ]; + + await session.updateMcpServers(newMcpServers); + + expect(mocks.acquireOpenCodeServer).not.toHaveBeenCalled(); + }); + + it("updates the active directory MCP set without re-acquiring", async () => { + const dispose = vi.fn<() => Promise>().mockResolvedValue(undefined); + const updateMcpServers = vi.fn<() => Promise>().mockResolvedValue(undefined); + mocks.acquireOpenCodeServer.mockResolvedValue({ + eventClient: emptyEventClient(), + client: { + command: { list: vi.fn<() => Promise<{ data: [] }>>().mockResolvedValue({ data: [] }) }, + session: { + create: vi + .fn<() => Promise<{ data: { id: string } }>>() + .mockResolvedValue({ data: { id: "ses_first" } }), + }, + }, + baseUrl: "http://127.0.0.1:1", + handle: {}, + updateMcpServers, + dispose, + }); + + const initialMcpServers = [ + { + id: "browser", + name: "browser", + timeoutMs: 300_000, + transport: { type: "http" as const, url: "http://127.0.0.1:9501/mcp", headers: {} }, + }, + ]; + const session = await OpencodeSdkSession.create({ + threadId: "thread-reacquire-mcp", + projectLocation, + config, + presentationMode: "gui", + mcpServers: initialMcpServers, + }); + + await session.activate(); + expect(mocks.acquireOpenCodeServer).toHaveBeenCalledTimes(1); + + const newMcpServers = [ + { + id: "chrome", + name: "chrome", + timeoutMs: 300_000, + transport: { type: "http" as const, url: "http://127.0.0.1:9502/mcp", headers: {} }, + }, + ]; + await session.updateMcpServers(newMcpServers); + + expect(updateMcpServers).toHaveBeenCalledWith(newMcpServers); + expect(mocks.acquireOpenCodeServer).toHaveBeenCalledTimes(1); + expect(dispose).not.toHaveBeenCalled(); + + await session.dispose(); + expect(dispose).toHaveBeenCalledTimes(1); + }); + it("stores the created session id in launch options for terminal TUI handoff", async () => { const dispose = vi.fn<() => Promise>().mockResolvedValue(undefined); mocks.acquireOpenCodeServer.mockResolvedValue({ @@ -261,12 +476,28 @@ describe("OpencodeSdkSession", () => { expect(secondDispose).toHaveBeenCalledTimes(1); }); - it("unwraps global payload events and ignores sync duplicates", async () => { + it("routes directory events and drops server-wide and sync envelopes", async () => { const updates: StructuredSessionUpdate[] = []; const runtimeEvents: RuntimeEvent[] = []; const wrappedEvents = [ { payload: serverConnectedEvent() }, { + payload: { + id: "evt-heartbeat", + type: "server.heartbeat", + properties: {}, + }, + }, + { + directory: "/other-repo", + payload: { + id: "evt-wrong-directory", + type: "session.status", + properties: { sessionID: "ses_test", status: { type: "busy" } }, + }, + }, + { + directory: "/repo", payload: { id: "evt-other", type: "session.status", @@ -274,6 +505,7 @@ describe("OpencodeSdkSession", () => { }, }, { + directory: "/repo", payload: { id: "evt-busy", type: "session.status", @@ -281,6 +513,7 @@ describe("OpencodeSdkSession", () => { }, }, { + directory: "/repo", payload: { type: "sync", syncEvent: { @@ -315,6 +548,7 @@ describe("OpencodeSdkSession", () => { }, }, { + directory: "/repo", payload: { id: "evt-msg", type: "message.updated", @@ -343,6 +577,7 @@ describe("OpencodeSdkSession", () => { }, }, { + directory: "/repo", payload: { id: "evt-part", type: "message.part.updated", @@ -360,6 +595,7 @@ describe("OpencodeSdkSession", () => { }, }, { + directory: "/repo", payload: { id: "evt-idle", type: "session.idle", @@ -374,8 +610,8 @@ describe("OpencodeSdkSession", () => { }); mocks.acquireOpenCodeServer.mockResolvedValue({ + eventClient: { global: { event: globalEvent } }, client: { - global: { event: globalEvent }, command: { list: vi.fn<() => Promise<{ data: [] }>>().mockResolvedValue({ data: [] }) }, session: { create: vi @@ -501,32 +737,35 @@ describe("OpencodeSdkSession", () => { releaseQuestionEvent = resolve; }); const globalEvent = vi - .fn<() => Promise<{ stream: AsyncGenerator }>>() + .fn<() => Promise<{ stream: AsyncGenerator }>>() .mockResolvedValue({ stream: (async function* () { - yield serverConnectedEvent(); + yield { payload: serverConnectedEvent() }; await waitForSession; yield { - id: "evt-question", - type: "question.asked", - properties: { - id: "req1", - sessionID: "ses_test", - questions: [ - { - header: "Scope", - question: "Which scope should I use?", - options: [{ label: "Scope A" }, { label: "Scope B" }], - }, - ], + directory: "/repo", + payload: { + id: "evt-question", + type: "question.asked", + properties: { + id: "req1", + sessionID: "ses_test", + questions: [ + { + header: "Scope", + question: "Which scope should I use?", + options: [{ label: "Scope A" }, { label: "Scope B" }], + }, + ], + }, }, - } as Event; + }; })(), }); mocks.acquireOpenCodeServer.mockResolvedValue({ + eventClient: { global: { event: globalEvent } }, client: { - global: { event: globalEvent }, command: { list: vi.fn<() => Promise<{ data: [] }>>().mockResolvedValue({ data: [] }) }, question: { reply: questionReply, @@ -590,6 +829,8 @@ describe("OpencodeSdkSession", () => { }, }); expect(updates.at(-1)).toMatchObject({ status: "working", attention: "working" }); + + await session.dispose(); }); it("omits a session permission override in supervised mode", async () => { @@ -703,6 +944,61 @@ describe("OpencodeSdkSession", () => { ); }); + it("keeps disabled Crossagents tools denied in full access mode", async () => { + const update = vi + .fn<(input: unknown) => Promise<{ data: unknown }>>() + .mockResolvedValue({ data: {} }); + const promptAsync = vi + .fn<(input: unknown) => Promise<{ data: unknown }>>() + .mockResolvedValue({ data: {} }); + + mocks.acquireOpenCodeServer.mockResolvedValue({ + client: { + command: { list: vi.fn<() => Promise<{ data: [] }>>().mockResolvedValue({ data: [] }) }, + session: { + create: vi + .fn<(input: unknown) => Promise<{ data: { id: string } }>>() + .mockResolvedValue({ data: { id: "ses_test" } }), + update, + promptAsync, + }, + }, + baseUrl: "http://127.0.0.1:0", + handle: {}, + dispose: vi.fn<() => Promise>().mockResolvedValue(undefined), + }); + + const session = await OpencodeSdkSession.create({ + threadId: "thread-opencode", + projectLocation, + config: { ...config, approvalPolicy: "yolo" }, + presentationMode: "terminal", + mcpServers: [ + { + id: "crossagents", + name: "crossagents", + timeoutMs: 30_000, + disabledTools: ["spawn_agent"], + transport: { type: "http", url: "http://127.0.0.1:4321/mcp", headers: {} }, + }, + ], + }); + + await session.activate(); + await session.openThread({ ...config, approvalPolicy: "yolo" }); + await session.startTurn("ship it", { ...config, approvalPolicy: "yolo" }); + + expect(update).toHaveBeenCalledWith({ + directory: "/repo", + sessionID: "ses_test", + permission: [ + { permission: "*", pattern: "*", action: "allow" }, + { permission: "crossagents_spawn_agent", pattern: "*", action: "deny" }, + ], + }); + expect(promptAsync).toHaveBeenCalledTimes(1); + }); + it("resolves WSL relative file mentions to Linux file URLs", async () => { const promptAsync = vi .fn<(input: unknown) => Promise<{ data: unknown }>>() diff --git a/src/supervisor/agents/opencode/sdkSession.ts b/src/supervisor/agents/opencode/sdkSession.ts index 06877e29..cbe616dc 100644 --- a/src/supervisor/agents/opencode/sdkSession.ts +++ b/src/supervisor/agents/opencode/sdkSession.ts @@ -18,6 +18,7 @@ import type { Event, PermissionRule } from "@opencode-ai/sdk/v2"; import type { AgentSlashCommand, PromptSegment, + ResolvedMcpServer, RuntimeEvent, SessionRef, ThreadAttention, @@ -50,6 +51,7 @@ import { type AcquiredOpenCodeServer, type AcquireOpenCodeServerInput, } from "./sdkClient"; +import { subscribeOpenCodeServerEvents } from "./sdkEventHub"; import { closeOpenItems, createOpenCodeMapperState, @@ -114,15 +116,6 @@ function mapStatusUpdate(properties: { sessionID: string; status: { type: string } } -function unwrapGlobalOpenCodeEvent(raw: unknown): Event | undefined { - if (!raw || typeof raw !== "object") return undefined; - const payload = (raw as { payload?: unknown }).payload; - const event = payload && typeof payload === "object" ? payload : raw; - const type = (event as { type?: unknown }).type; - if (typeof type !== "string" || type === "sync") return undefined; - return event as Event; -} - export class OpencodeSdkSession implements StructuredSessionHandle { launchOptions: AgentLaunchOptions; @@ -133,7 +126,9 @@ export class OpencodeSdkSession implements StructuredSessionHandle { private listener: StructuredSessionListener | undefined; private acquired: AcquiredOpenCodeServer | undefined; private sessionId: string | undefined; - private sseAbort: AbortController | undefined; + private unsubscribeEvents: (() => void) | undefined; + private unsubscribeServerExit: (() => void) | undefined; + private reacquirePromise: Promise | undefined; private mapperState: OpenCodeMapperState | undefined; private bufferedRuntimeEvents: RuntimeEvent[] = []; private currentConfig: ThreadConfig | undefined; @@ -143,6 +138,8 @@ export class OpencodeSdkSession implements StructuredSessionHandle { private disposed = false; private pendingRequests = new Map(); private currentSlashCommands: AgentSlashCommand[] | undefined; + /** Live MCP set; starts from the launch input, replaced by settings saves. */ + private mcpServers: readonly ResolvedMcpServer[] | undefined; private constructor(input: CreateStructuredSessionInput) { this.input = input; @@ -150,6 +147,7 @@ export class OpencodeSdkSession implements StructuredSessionHandle { this.isGui = input.presentationMode === "gui"; this.sdkDirectory = resolveOpenCodeSessionDirectory(input.projectLocation); this.currentConfig = input.config; + this.mcpServers = input.mcpServers; this.launchOptions = { suppressResumeConfigOverrides: true }; } @@ -157,6 +155,13 @@ export class OpencodeSdkSession implements StructuredSessionHandle { return Promise.resolve(new OpencodeSdkSession(input)); } + ownsProviderSession(providerSessionId: string): boolean { + return ( + providerSessionId === this.sessionId || + this.mapperState?.subAgentSessions.has(providerSessionId) === true + ); + } + private rememberSessionId(id: string): void { this.sessionId = id; this.launchOptions = { ...this.launchOptions, resumeThreadId: id }; @@ -210,6 +215,7 @@ export class OpencodeSdkSession implements StructuredSessionHandle { if (this.isGui) { this.mapperState = createOpenCodeMapperState(this.threadId); this.startEventStream(); + this.startServerExitRecovery(); } } @@ -240,13 +246,15 @@ export class OpencodeSdkSession implements StructuredSessionHandle { } const permission = buildOpenCodePermissionRules(config.approvalPolicy); - let created: Awaited>; - try { - created = await acquired.client.session.create({ + const createSession = (server: typeof acquired) => + server.client.session.create({ directory: this.sdkDirectory, title: `poracode/${this.threadId.slice(0, 8)}`, ...(permission ? { permission } : {}), }); + let created: Awaited>; + try { + created = await createSession(acquired); } catch (cause) { if (!isOpenCodeConnectionLoss(cause)) { throw new Error(this.classifyOpenCodeError(cause, "session.create"), { cause }); @@ -254,11 +262,7 @@ export class OpencodeSdkSession implements StructuredSessionHandle { await this.reacquireOpenCodeServer(); const retryAcquired = this.requireAcquired(); try { - created = await retryAcquired.client.session.create({ - directory: this.sdkDirectory, - title: `poracode/${this.threadId.slice(0, 8)}`, - ...(permission ? { permission } : {}), - }); + created = await createSession(retryAcquired); } catch (retryCause) { throw new Error(this.classifyOpenCodeError(retryCause, "session.create"), { cause: retryCause, @@ -269,7 +273,10 @@ export class OpencodeSdkSession implements StructuredSessionHandle { if (!id) throw new Error("opencode session.create returned no id"); this.rememberSessionId(id); this.sessionHasPermissionOverride = permission !== undefined; - this.appliedPermissionSyncKey = this.permissionSyncKey(config); + this.appliedPermissionSyncKey = + this.crossagentDeniedPermissionRules().length === 0 + ? this.permissionSyncKey(config) + : undefined; if (this.mapperState) setOpenCodeMainSessionId(this.mapperState, id); await this.refreshSlashCommands(); return id; @@ -492,8 +499,10 @@ export class OpencodeSdkSession implements StructuredSessionHandle { if (this.disposed) return; this.disposed = true; - this.sseAbort?.abort(); - this.sseAbort = undefined; + this.unsubscribeEvents?.(); + this.unsubscribeEvents = undefined; + this.unsubscribeServerExit?.(); + this.unsubscribeServerExit = undefined; if (this.mapperState && this.listener?.onRuntimeEvent) { const closing = closeOpenItems(this.mapperState); @@ -529,8 +538,24 @@ export class OpencodeSdkSession implements StructuredSessionHandle { private permissionSyncKey(config: ThreadConfig): string { const permission = buildOpenCodePermissionRules(config.approvalPolicy); - if (permission) return "full-access"; - return `supervised:${config.mode === "plan" ? "plan" : "build"}`; + const base = permission + ? "full-access" + : `supervised:${config.mode === "plan" ? "plan" : "build"}`; + const deniedCrossagentTools = this.crossagentDeniedPermissionRules() + .map((rule) => rule.permission) + .sort() + .join(","); + return `${base}:crossagents-denied=${deniedCrossagentTools}`; + } + + private crossagentDeniedPermissionRules(): PermissionRule[] { + const server = this.mcpServers?.find((candidate) => candidate.id === "crossagents"); + if (!server?.disabledTools || server.disabledTools.length === 0) return []; + return server.disabledTools.map((toolName) => ({ + permission: `${server.name}_${toolName}`, + pattern: "*", + action: "deny", + })); } private async syncSessionPermissions(config: ThreadConfig): Promise { @@ -540,15 +565,19 @@ export class OpencodeSdkSession implements StructuredSessionHandle { const acquired = this.requireAcquired(); const sessionID = this.requireSessionId(); const fullAccessPermission = buildOpenCodePermissionRules(config.approvalPolicy); + const deniedCrossagentTools = this.crossagentDeniedPermissionRules(); if (fullAccessPermission) { - await this.updateSessionPermission(sessionID, fullAccessPermission); + await this.updateSessionPermission(sessionID, [ + ...fullAccessPermission, + ...deniedCrossagentTools, + ]); this.sessionHasPermissionOverride = true; this.appliedPermissionSyncKey = syncKey; return; } - if (!this.sessionHasPermissionOverride) { + if (!this.sessionHasPermissionOverride && deniedCrossagentTools.length === 0) { // Fresh supervised sessions have no session-level permission override; // OpenCode already resolves permissions from its loaded config stack. this.appliedPermissionSyncKey = syncKey; @@ -567,7 +596,7 @@ export class OpencodeSdkSession implements StructuredSessionHandle { throw new Error(classifyOpenCodeError({ cause, operation: "app.agents" }), { cause }); } - await this.updateSessionPermission(sessionID, rules); + await this.updateSessionPermission(sessionID, [...rules, ...deniedCrossagentTools]); this.sessionHasPermissionOverride = true; this.appliedPermissionSyncKey = syncKey; } @@ -646,52 +675,90 @@ export class OpencodeSdkSession implements StructuredSessionHandle { private startEventStream(): void { const acquired = this.requireAcquired(); - const ctrl = new AbortController(); - this.sseAbort = ctrl; + this.unsubscribeEvents = subscribeOpenCodeServerEvents({ + eventClient: acquired.eventClient, + directory: this.sdkDirectory, + onEvent: (event) => { + if (!this.disposed) this.handleSseEvent(event); + }, + }); + } - void (async () => { - try { - const sub = await acquired.client.global.event({ - signal: ctrl.signal, - }); - for await (const ev of sub.stream) { - if (this.disposed) break; - const event = unwrapGlobalOpenCodeEvent(ev); - if (event) this.handleSseEvent(event); - } - } catch (err) { + private startServerExitRecovery(): void { + const acquired = this.requireAcquired(); + this.unsubscribeServerExit = acquired.onServerExit?.(() => { + if (this.disposed || this.acquired !== acquired) return; + void this.reacquireOpenCodeServer().catch((cause) => { if (this.disposed) return; - const message = err instanceof Error ? err.message : String(err); - this.emitRuntimeEvents([ - { type: "error", threadId: this.threadId, message: `event stream: ${message}` }, - ]); - } - })(); + const message = classifyOpenCodeError({ cause, operation: "restart opencode serve" }); + this.emitRuntimeEvents([{ type: "error", threadId: this.threadId, message }]); + }); + }); } - private async reacquireOpenCodeServer(): Promise { + private reacquireOpenCodeServer(): Promise { + if (this.reacquirePromise) return this.reacquirePromise; + let tracked: Promise; + tracked = this.replaceOpenCodeServer().finally(() => { + if (this.reacquirePromise === tracked) this.reacquirePromise = undefined; + }); + this.reacquirePromise = tracked; + return tracked; + } + + private async replaceOpenCodeServer(): Promise { const previous = this.acquired; - this.sseAbort?.abort(); - this.sseAbort = undefined; + this.unsubscribeEvents?.(); + this.unsubscribeEvents = undefined; + this.unsubscribeServerExit?.(); + this.unsubscribeServerExit = undefined; this.acquired = undefined; await previous?.dispose().catch((error) => { console.warn("[opencode] failed to dispose previous session:", error); }); - this.acquired = await acquireOpenCodeServer(this.buildAcquireInput()); - if (this.isGui) this.startEventStream(); + if (this.disposed) return; + + const acquired = await acquireOpenCodeServer(this.buildAcquireInput()); + if (this.disposed) { + await acquired.dispose(); + return; + } + this.acquired = acquired; + if (this.isGui) { + this.startEventStream(); + this.startServerExitRecovery(); + } } - /** Assemble a provider-neutral MCP acquisition for this session. */ + /** + * Assemble a provider-neutral acquisition for this session. OpenCode routes + * the directory-scoped client and MCP state inside the shared runtime server. + */ private buildAcquireInput(): AcquireOpenCodeServerInput { - const { mcpServers } = this.input; - const needsDedicatedServer = (mcpServers?.length ?? 0) > 0; + const { mcpServers } = this; return { projectLocation: this.input.projectLocation, ...(mcpServers !== undefined ? { mcpServers } : {}), - ...(needsDedicatedServer ? { dedicatedKey: this.threadId } : {}), }; } + /** + * Apply a new provider-level MCP set to this directory instance (OpenCode + * settings "Save") without replacing the acquisition or shared SSE stream. + * No-op before activation — the launch path already uses the latest set. + */ + async updateMcpServers(mcpServers: readonly ResolvedMcpServer[]): Promise { + this.mcpServers = mcpServers; + this.appliedPermissionSyncKey = undefined; + if (!this.activated || this.disposed || !this.acquired) return; + try { + await this.acquired.updateMcpServers(mcpServers); + } catch (cause) { + if (!isOpenCodeConnectionLoss(cause)) throw cause; + await this.reacquireOpenCodeServer(); + } + } + private classifyOpenCodeError(cause: unknown, operation: string): string { const message = classifyOpenCodeError({ cause, diff --git a/src/supervisor/crossagentMcp/CrossagentMcpIngress.test.ts b/src/supervisor/crossagentMcp/CrossagentMcpIngress.test.ts index e1b19466..9d268706 100644 --- a/src/supervisor/crossagentMcp/CrossagentMcpIngress.test.ts +++ b/src/supervisor/crossagentMcp/CrossagentMcpIngress.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { OrchestratorThreadManager } from "./OrchestratorThreadManager"; -import { CrossagentMcpIngress } from "./CrossagentMcpIngress"; +import { CROSSAGENT_PROVIDER_SESSION_ID_ARG, CrossagentMcpIngress } from "./CrossagentMcpIngress"; import type { SubagentRunManager } from "./SubagentRunManager"; import { CROSSAGENT_MCP_INSTRUCTIONS_BASE } from "./toolRegistry"; import type { SpawnableAgent, SpawnAgentRequest } from "./types"; @@ -50,6 +50,12 @@ const AGENTS: SpawnableAgent[] = [ }, ]; +const PROVIDER_SESSION_THREADS: Record = { + "session-1": "thread-1", + "session-2": "thread-2", + "child-1": "thread-1", +}; + function makeRunManager(): { runManager: SubagentRunManager; spawned: Array<{ parentThreadId: string } & SpawnAgentRequest>; @@ -72,6 +78,8 @@ describe("CrossagentMcpIngress", () => { let ingress: CrossagentMcpIngress; let token: string; let mcpUrl: string; + let providerToken: string; + let providerMcpUrl: string; let spawned: Array<{ parentThreadId: string } & SpawnAgentRequest>; beforeEach(async () => { @@ -81,6 +89,7 @@ describe("CrossagentMcpIngress", () => { runManager: rm.runManager, orchestrator: makeInertOrchestrator(), getSpawnableAgents: async () => AGENTS, + resolveProviderSessionThreadId: (sessionId) => PROVIDER_SESSION_THREADS[sessionId], getRoutingGuide: () => "PREFER codex for search.", }); await ingress.start(); @@ -88,6 +97,10 @@ describe("CrossagentMcpIngress", () => { if (!config) throw new Error("registerThread returned undefined"); token = config.token; mcpUrl = config.url; + const providerConfig = ingress.registerProviderSessionThread("thread-1"); + if (!providerConfig) throw new Error("registerProviderSessionThread returned undefined"); + providerToken = providerConfig.token; + providerMcpUrl = providerConfig.url; }); afterEach(() => { @@ -102,6 +115,17 @@ describe("CrossagentMcpIngress", () => { }); } + async function providerRpc(method: string, params?: unknown): Promise { + return await fetch(providerMcpUrl, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${providerToken}`, + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, ...(params ? { params } : {}) }), + }); + } + it("mints a per-thread token and a /mcp endpoint url", () => { expect(mcpUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/mcp$/); expect(token).toHaveLength(64); @@ -114,6 +138,116 @@ describe("CrossagentMcpIngress", () => { expect(res.status).toBe(401); }); + it("shares one provider credential while routing concurrent sessions independently", async () => { + const secondConfig = ingress.registerProviderSessionThread("thread-2"); + expect(secondConfig?.token).toBe(providerToken); + expect(providerToken).not.toBe(token); + + await Promise.all([ + providerRpc("tools/call", { + name: "spawn_agent", + arguments: { + provider: "codex", + prompt: "first", + [CROSSAGENT_PROVIDER_SESSION_ID_ARG]: "session-1", + }, + }), + providerRpc("tools/call", { + name: "spawn_agent", + arguments: { + provider: "codex", + prompt: "second", + [CROSSAGENT_PROVIDER_SESSION_ID_ARG]: "session-2", + }, + }), + ]); + + expect(spawned).toEqual( + expect.arrayContaining([ + expect.objectContaining({ parentThreadId: "thread-1", prompt: "first" }), + expect.objectContaining({ parentThreadId: "thread-2", prompt: "second" }), + ]), + ); + }); + + it("invalidates the shared provider credential when the ingress restarts", async () => { + const rm = makeRunManager(); + const restarted = new CrossagentMcpIngress({ + runManager: rm.runManager, + orchestrator: makeInertOrchestrator(), + getSpawnableAgents: async () => AGENTS, + resolveProviderSessionThreadId: (sessionId) => PROVIDER_SESSION_THREADS[sessionId], + }); + await restarted.start(); + try { + const restartedConfig = restarted.registerProviderSessionThread("thread-1"); + expect(restartedConfig?.token).not.toBe(providerToken); + } finally { + restarted.dispose(); + } + }); + + it("fails closed when provider-session routing metadata is missing or stale", async () => { + const missing = await providerRpc("tools/call", { + name: "list_agents", + arguments: {}, + }); + expect((await missing.json()).result).toMatchObject({ isError: true }); + + const stale = await providerRpc("tools/call", { + name: "list_agents", + arguments: { [CROSSAGENT_PROVIDER_SESSION_ID_ARG]: "unknown" }, + }); + expect((await stale.json()).result).toMatchObject({ isError: true }); + }); + + it("maps child provider sessions to the registered parent thread", async () => { + const response = await providerRpc("tools/call", { + name: "spawn_agent", + arguments: { + provider: "codex", + prompt: "from child", + [CROSSAGENT_PROVIDER_SESSION_ID_ARG]: "child-1", + }, + }); + expect((await response.json()).result.isError).not.toBe(true); + expect(spawned.at(-1)?.parentThreadId).toBe("thread-1"); + }); + + it("enforces disabled tools per resolved provider session", async () => { + ingress.registerProviderSessionThread("thread-1", ["spawn_agent"]); + ingress.registerProviderSessionThread("thread-2"); + + const blocked = await providerRpc("tools/call", { + name: "spawn_agent", + arguments: { + provider: "codex", + prompt: "blocked", + [CROSSAGENT_PROVIDER_SESSION_ID_ARG]: "session-1", + }, + }); + expect((await blocked.json()).result).toMatchObject({ isError: true }); + + const allowed = await providerRpc("tools/call", { + name: "spawn_agent", + arguments: { + provider: "codex", + prompt: "allowed", + [CROSSAGENT_PROVIDER_SESSION_ID_ARG]: "session-2", + }, + }); + expect((await allowed.json()).result.isError).not.toBe(true); + }); + + it("rejects provider-session calls after the parent thread unregisters", async () => { + ingress.unregisterThread("thread-1"); + const response = await providerRpc("tools/call", { + name: "list_agents", + arguments: { [CROSSAGENT_PROVIDER_SESSION_ID_ARG]: "session-1" }, + }); + expect((await response.json()).result).toMatchObject({ isError: true }); + }); + it("returns instructions with the routing guide on initialize", async () => { const res = await rpc("initialize"); const body = await res.json(); diff --git a/src/supervisor/crossagentMcp/CrossagentMcpIngress.ts b/src/supervisor/crossagentMcp/CrossagentMcpIngress.ts index ddadd4ef..e7236ba4 100644 --- a/src/supervisor/crossagentMcp/CrossagentMcpIngress.ts +++ b/src/supervisor/crossagentMcp/CrossagentMcpIngress.ts @@ -18,12 +18,15 @@ export interface CrossagentMcpIngressDeps { orchestrator: OrchestratorThreadManager; /** Catalog of installed + authenticated agents the caller may spawn. */ getSpawnableAgents: () => Promise; + /** Resolve a trusted provider-native session id to its live Poracode parent. */ + resolveProviderSessionThreadId?: (sessionId: string) => string | undefined; /** Optional user-provided routing guide appended to the MCP instructions (phase 3). */ getRoutingGuide?: () => string | undefined; } const MAX_BODY = 1024 * 1024; const MCP_PROTOCOL_VERSION = "2025-03-26"; +export const CROSSAGENT_PROVIDER_SESSION_ID_ARG = "__poracode_provider_session_id"; interface JsonRpcRequest { jsonrpc: "2.0"; @@ -46,20 +49,25 @@ interface JsonRpcResponseErr { type JsonRpcResponse = JsonRpcResponseOk | JsonRpcResponseErr; +type CrossagentAuthContext = { mode: "thread"; threadId: string } | { mode: "provider-session" }; + /** * Single in-process MCP server hosted in the supervisor. Speaks Streamable-HTTP * MCP at `POST /mcp` (JSON-RPC body, single JSON response). Unlike the browser - * ingress, auth is PER-THREAD: each thread that opts into Crossagents mints its - * own 256-bit bearer token, and `tools/call` resolves the caller's parent - * thread from that token so spawned children attach to the right parent. + * ingress, direct provider processes use a per-thread 256-bit bearer token, so + * `tools/call` can resolve the caller's parent from the credential alone. + * Pooled provider runtimes instead share one in-memory credential and attach a + * trusted provider session id to each call. The ingress resolves that id back + * to a registered live thread at call time, so concurrent sessions never rely + * on shared mutable "active thread" state. * * Bind address: `127.0.0.1` everywhere except Windows, where we bind `0.0.0.0` * so an agent launched inside a WSL distro can reach this host ingress over the * WSL2 NAT gateway IP (a distro's loopback can't hit the host's `127.0.0.1`). * This mirrors `BrowserMcpIngress`'s posture. Tradeoff: on Windows the port is - * reachable from any interface, so the per-thread 256-bit bearer token is the - * security boundary — every request must present a token that maps to a - * registered parent thread (see `resolveThreadId`). We keep the tighter + * reachable from any interface, so the 256-bit bearer credential is the + * security boundary. Provider-session calls additionally require a trusted + * session id that resolves to a registered live thread. We keep the tighter * loopback bind on macOS/Linux since WSL only exists on Windows. */ export class CrossagentMcpIngress { @@ -67,6 +75,8 @@ export class CrossagentMcpIngress { private info: CrossagentMcpIngressInfo | null = null; private readonly tokenToThread = new Map(); private readonly threadToToken = new Map(); + private readonly providerSessionToken = randomBytes(32).toString("hex"); + private readonly providerSessionThreads = new Set(); private readonly disabledToolsByThread = new Map>(); constructor(private readonly deps: CrossagentMcpIngressDeps) {} @@ -116,6 +126,28 @@ export class CrossagentMcpIngress { url: `${this.info.url.replace(/\/$/, "")}/mcp`, token, headers: { Authorization: `Bearer ${token}` }, + disabledTools: [...disabledTools], + }; + } + + /** + * Register a thread whose provider runtime shares one MCP connection. + * Authentication proves the caller is the Poracode-launched provider + * process; the trusted provider session id on each tools/call selects the + * parent thread. The token is memory-only and shared by every such thread. + */ + registerProviderSessionThread( + threadId: string, + disabledTools: readonly string[] = [], + ): CrossagentMcpHttpConfig | undefined { + if (!this.info) return undefined; + this.providerSessionThreads.add(threadId); + this.disabledToolsByThread.set(threadId, new Set(disabledTools)); + return { + url: `${this.info.url.replace(/\/$/, "")}/mcp`, + token: this.providerSessionToken, + headers: { Authorization: `Bearer ${this.providerSessionToken}` }, + disabledTools: [...disabledTools], }; } @@ -123,12 +155,14 @@ export class CrossagentMcpIngress { const token = this.threadToToken.get(threadId); if (token) this.tokenToThread.delete(token); this.threadToToken.delete(threadId); + this.providerSessionThreads.delete(threadId); this.disabledToolsByThread.delete(threadId); } dispose(): void { this.tokenToThread.clear(); this.threadToToken.clear(); + this.providerSessionThreads.clear(); this.disabledToolsByThread.clear(); try { this.server?.closeAllConnections?.(); @@ -139,7 +173,7 @@ export class CrossagentMcpIngress { this.server = null; } - private resolveThreadId(req: IncomingMessage): string | null { + private resolveAuth(req: IncomingMessage): CrossagentAuthContext | null { const auth = req.headers.authorization; let token: string | undefined; if (auth && auth.startsWith("Bearer ")) { @@ -149,7 +183,19 @@ export class CrossagentMcpIngress { if (typeof xToken === "string") token = xToken; } if (!token) return null; - return this.tokenToThread.get(token) ?? null; + const threadId = this.tokenToThread.get(token); + if (threadId) return { mode: "thread", threadId }; + if (token === this.providerSessionToken) return { mode: "provider-session" }; + return null; + } + + private resolveProviderSessionThreadId(args: Record): string | undefined { + const sessionId = args[CROSSAGENT_PROVIDER_SESSION_ID_ARG]; + delete args[CROSSAGENT_PROVIDER_SESSION_ID_ARG]; + if (typeof sessionId !== "string" || sessionId.length === 0) return undefined; + const threadId = this.deps.resolveProviderSessionThreadId?.(sessionId); + if (!threadId || !this.providerSessionThreads.has(threadId)) return undefined; + return threadId; } private async readBody(req: IncomingMessage): Promise { @@ -191,8 +237,8 @@ export class CrossagentMcpIngress { return; } - const threadId = this.resolveThreadId(req); - if (!threadId) { + const auth = this.resolveAuth(req); + if (!auth) { this.sendJson(res, 401, { error: "unauthorized" }); return; } @@ -204,7 +250,7 @@ export class CrossagentMcpIngress { res.end(); return; } - await this.handleMcp(req, res, threadId); + await this.handleMcp(req, res, auth); return; } @@ -217,7 +263,7 @@ export class CrossagentMcpIngress { private async handleMcp( req: IncomingMessage, res: ServerResponse, - threadId: string, + auth: CrossagentAuthContext, ): Promise { const raw = await this.readBody(req); let body: unknown; @@ -240,13 +286,13 @@ export class CrossagentMcpIngress { if (Array.isArray(body)) { const out: JsonRpcResponse[] = []; for (const message of body) { - const reply = await this.handleSingle(message, threadId); + const reply = await this.handleSingle(message, auth); if (reply) out.push(reply); } this.sendJson(res, 200, out); return; } - const reply = await this.handleSingle(body, threadId); + const reply = await this.handleSingle(body, auth); if (!reply) { res.statusCode = 202; res.end(); @@ -255,7 +301,10 @@ export class CrossagentMcpIngress { this.sendJson(res, 200, reply); } - private async handleSingle(message: unknown, threadId: string): Promise { + private async handleSingle( + message: unknown, + auth: CrossagentAuthContext, + ): Promise { if (!isJsonRpcRequest(message)) return null; const { id = null, method, params } = message; try { @@ -278,7 +327,14 @@ export class CrossagentMcpIngress { return { jsonrpc: "2.0", id, result: {} }; } if (method === "tools/list") { - const disabled = this.disabledToolsByThread.get(threadId) ?? new Set(); + // A provider-session connection is shared by concurrent sessions, so + // discovery cannot safely apply one thread's filter. OpenCode applies + // per-session deny rules; tools/call below still enforces the resolved + // thread's policy authoritatively. + const disabled = + auth.mode === "thread" + ? (this.disabledToolsByThread.get(auth.threadId) ?? new Set()) + : new Set(); return { jsonrpc: "2.0", id, @@ -288,7 +344,18 @@ export class CrossagentMcpIngress { if (method === "tools/call") { const p = (params ?? {}) as { name?: string; arguments?: Record }; const name = String(p.name ?? ""); - const args = (p.arguments ?? {}) as Record; + const rawArgs = p.arguments; + const args = + rawArgs && typeof rawArgs === "object" && !Array.isArray(rawArgs) ? { ...rawArgs } : {}; + const threadId = + auth.mode === "thread" ? auth.threadId : this.resolveProviderSessionThreadId(args); + if (!threadId) { + return { + jsonrpc: "2.0", + id, + result: errorResult("Unable to route Crossagents call to a live parent thread."), + }; + } if (this.disabledToolsByThread.get(threadId)?.has(name)) { return { jsonrpc: "2.0", id, result: errorResult(`Tool disabled by Poracode: ${name}`) }; } diff --git a/src/supervisor/crossagentMcp/SubagentRunManager.test.ts b/src/supervisor/crossagentMcp/SubagentRunManager.test.ts index e5df39c7..51cf8528 100644 --- a/src/supervisor/crossagentMcp/SubagentRunManager.test.ts +++ b/src/supervisor/crossagentMcp/SubagentRunManager.test.ts @@ -63,6 +63,9 @@ class FakeHandle implements StructuredSessionHandle { completeTurn(state: "completed" | "failed" | "interrupted" | "cancelled"): void { this.emit({ type: "turn.completed", threadId: "child", turnId: "turn-1", state }); } + update(status: "idle" | "working"): void { + this.listener?.onUpdate({ status, attention: "none" }); + } } interface Harness { @@ -70,6 +73,7 @@ interface Harness { handles: FakeHandle[]; inputs: CreateStructuredSessionInput[]; appended: Array<{ threadId: string; event: RuntimeEvent }>; + mcpTargets: string[]; } function makeHarness(options?: { @@ -79,6 +83,7 @@ function makeHarness(options?: { const handles: FakeHandle[] = []; const inputs: CreateStructuredSessionInput[] = []; const appended: Array<{ threadId: string; event: RuntimeEvent }> = []; + const mcpTargets: string[] = []; const adapter = { kind: "codex", @@ -123,18 +128,21 @@ function makeHarness(options?: { }, } : undefined, - resolveParentMcpAccess: async () => ({ - mcpServers: ["browser", "computer_use", "chrome"].map((name) => ({ - id: name, - name, - timeoutMs: 30_000, - transport: { - type: "http" as const, - url: `http://${name}/mcp`, - headers: { Authorization: `Bearer ${name}-token` }, - }, - })), - }), + resolveParentMcpAccess: async (_threadId, _identity, targetAgentKind) => { + mcpTargets.push(targetAgentKind); + return { + mcpServers: ["browser", "computer_use", "chrome"].map((name) => ({ + id: name, + name, + timeoutMs: 30_000, + transport: { + type: "http" as const, + url: `http://${name}/mcp`, + headers: { Authorization: `Bearer ${name}-token` }, + }, + })), + }; + }, appendRuntimeEvent: (threadId, event) => appended.push({ threadId, event }), }; @@ -144,7 +152,7 @@ function makeHarness(options?: { ...(statusCapabilities ? { getStatusCapabilities: () => statusCapabilities } : {}), host, }); - return { manager, handles, inputs, appended }; + return { manager, handles, inputs, appended, mcpTargets }; } describe("SubagentRunManager", () => { @@ -338,6 +346,19 @@ describe("SubagentRunManager", () => { }); }); + it("settles on an idle update after the child turn starts", async () => { + const h = makeHarness(); + const { runId } = h.manager.spawn(PARENT, { agent: "codex", prompt: "go" }); + await flush(); + + h.handles[0]!.update("idle"); + + await expect(h.manager.waitFor(runId, 1000)).resolves.toEqual({ + status: "completed", + output: "", + }); + }); + it("does NOT forward child turn.completed onto the parent stream", async () => { const h = makeHarness(); h.manager.spawn(PARENT, { agent: "codex", prompt: "go" }); @@ -408,6 +429,7 @@ describe("SubagentRunManager", () => { expect(childInput.config.approvalPolicy).toBe("never"); expect(childInput.config.sandboxMode).toBe("danger-full-access"); expect(childInput.presentationMode).toBe("gui"); + expect(h.mcpTargets).toEqual(["codex"]); expect(childInput).not.toHaveProperty("crossagentMcp"); expect(childInput.mcpServers).toEqual( expect.arrayContaining([ diff --git a/src/supervisor/crossagentMcp/SubagentRunManager.ts b/src/supervisor/crossagentMcp/SubagentRunManager.ts index 89d58a9b..5ce260cf 100644 --- a/src/supervisor/crossagentMcp/SubagentRunManager.ts +++ b/src/supervisor/crossagentMcp/SubagentRunManager.ts @@ -68,6 +68,7 @@ interface RunRecord { */ pendingRequestIds: Set; cancelRequested: boolean; + turnStarted: boolean; settled: boolean; settledPromise: Promise; resolveSettled: () => void; @@ -212,6 +213,7 @@ export class SubagentRunManager { oneShot: undefined, pendingRequestIds: new Set(), cancelRequested: false, + turnStarted: false, settled: false, settledPromise, resolveSettled, @@ -327,10 +329,14 @@ export class SubagentRunManager { return; } try { - const mcpAccess = await this.deps.host.resolveParentMcpAccess?.(record.parentThreadId, { - threadId: record.childThreadId, - title: record.label, - }); + const mcpAccess = await this.deps.host.resolveParentMcpAccess?.( + record.parentThreadId, + { + threadId: record.childThreadId, + title: record.label, + }, + adapter.kind, + ); const handle = await adapter.createStructuredSession?.({ threadId: record.childThreadId, projectLocation, @@ -350,7 +356,11 @@ export class SubagentRunManager { handle.setListener({ onClose: () => this.settle(record, "completed"), onError: (message) => this.settle(record, "failed", message), - onUpdate: () => {}, + onUpdate: (update) => { + if (record.turnStarted && update.status === "idle") { + this.settle(record, "completed"); + } + }, onRuntimeEvent: (event) => this.onChildEvent(record, event), }); @@ -358,7 +368,10 @@ export class SubagentRunManager { if (record.cancelRequested) return void this.settle(record, "cancelled"); if (handle.openThread) await handle.openThread(childConfig); if (record.cancelRequested) return void this.settle(record, "cancelled"); - if (handle.startTurn) await handle.startTurn(prompt, childConfig); + if (handle.startTurn) { + record.turnStarted = true; + await handle.startTurn(prompt, childConfig); + } } catch (error) { this.settle( record, diff --git a/src/supervisor/crossagentMcp/types.ts b/src/supervisor/crossagentMcp/types.ts index 415407eb..24883554 100644 --- a/src/supervisor/crossagentMcp/types.ts +++ b/src/supervisor/crossagentMcp/types.ts @@ -1,4 +1,5 @@ import type { + AgentKind, AgentCapability, ProjectLocation, RuntimeEvent, @@ -137,6 +138,7 @@ export interface SubagentRunHost { resolveParentMcpAccess?( threadId: string, identity: McpThreadIdentity, + targetAgentKind: AgentKind, ): Promise<{ mcpServers?: ResolvedMcpServer[] }>; /** Append a (re-tagged) runtime event into the parent thread's event stream. */ appendRuntimeEvent(parentThreadId: string, event: RuntimeEvent): void; diff --git a/src/supervisor/ipcHandlers.ts b/src/supervisor/ipcHandlers.ts index 2647db5a..c95f1eaa 100644 --- a/src/supervisor/ipcHandlers.ts +++ b/src/supervisor/ipcHandlers.ts @@ -53,6 +53,7 @@ export function createSupervisorIpcHandlers(runtime: SupervisorRuntime): Supervi stageThreadInput: (payload) => threads.stageThreadInput(payload), resizeTerminal: (payload) => threads.resizeTerminal(payload), resolveThreadServerRequest: (payload) => threads.resolveThreadServerRequest(payload), + reloadAgentMcpServers: (payload) => threads.reloadAgentMcpServers(payload), closeThread: (payload) => threads.closeThread(payload), startShell: (payload) => threads.startShell(payload), extractContext: (payload) => generation.extractContext(payload), diff --git a/src/supervisor/runtime/threadSession/invalidSessionRecovery.ts b/src/supervisor/runtime/threadSession/invalidSessionRecovery.ts index 6eefbab6..5f5439ec 100644 --- a/src/supervisor/runtime/threadSession/invalidSessionRecovery.ts +++ b/src/supervisor/runtime/threadSession/invalidSessionRecovery.ts @@ -83,6 +83,7 @@ export class InvalidSessionRecoveryCoordinator { mcpLaunchSnapshot, identity: { threadId: session.threadId }, crossagentThreadId: session.threadId, + adapter: session.adapter, }); const cliHookExtras = await context.cliHookPlugin.resolveCliHookPluginExtras( session.threadId, diff --git a/src/supervisor/runtime/threadSession/managerOptions.ts b/src/supervisor/runtime/threadSession/managerOptions.ts index 9ee103ef..eb57cb90 100644 --- a/src/supervisor/runtime/threadSession/managerOptions.ts +++ b/src/supervisor/runtime/threadSession/managerOptions.ts @@ -48,6 +48,10 @@ export interface ThreadSessionManagerOptions { threadId: string, disabledTools?: readonly string[], ): CrossagentMcpHttpConfig | undefined; + registerProviderSession( + threadId: string, + disabledTools?: readonly string[], + ): CrossagentMcpHttpConfig | undefined; unregister(threadId: string): void; cancelAll(threadId: string): void; /** diff --git a/src/supervisor/runtime/threadSession/spawnPipeline.test.ts b/src/supervisor/runtime/threadSession/spawnPipeline.test.ts index 0ba202c5..f03d9eae 100644 --- a/src/supervisor/runtime/threadSession/spawnPipeline.test.ts +++ b/src/supervisor/runtime/threadSession/spawnPipeline.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { ThreadConfig } from "@/shared/contracts"; -import { composeResolvedMcpServers, effectiveLaunchConfig } from "./spawnPipeline"; +import { + applyAgentSettingsMcpFlags, + composeResolvedMcpServers, + effectiveLaunchConfig, +} from "./spawnPipeline"; const baseConfig: ThreadConfig = { model: "test-model", @@ -47,6 +51,40 @@ describe("effectiveLaunchConfig — single gate for built-in MCP disables", () = }); }); +describe("applyAgentSettingsMcpFlags", () => { + it("maps agentSettings booleans and keeps Crossagents off without provider-session routing", () => { + const result = applyAgentSettingsMcpFlags(baseConfig, { browserMcp: true }, [], false); + expect(result).toEqual({ + ...baseConfig, + browserMcp: true, + chromeMcp: false, + computerUse: false, + crossagentMcp: false, + }); + }); + + it("enables provider-level Crossagents when trusted provider-session routing is available", () => { + const result = applyAgentSettingsMcpFlags(baseConfig, { crossagentMcp: true }, [], true); + expect(result.crossagentMcp).toBe(true); + }); + + it("keeps globally disabled servers off when provider settings enable them", () => { + const result = applyAgentSettingsMcpFlags( + baseConfig, + { browserMcp: true, crossagentMcp: true, chromeMcp: true, computerUse: true }, + ["browser", "crossagents", "chrome", "computer-use"], + true, + ); + expect(result).toEqual({ + ...baseConfig, + browserMcp: false, + chromeMcp: false, + computerUse: false, + crossagentMcp: false, + }); + }); +}); + describe("composeResolvedMcpServers", () => { it("combines custom and built-in servers before the provider boundary", () => { const servers = composeResolvedMcpServers( diff --git a/src/supervisor/runtime/threadSession/spawnPipeline.ts b/src/supervisor/runtime/threadSession/spawnPipeline.ts index 8d2f843e..dddeee9e 100644 --- a/src/supervisor/runtime/threadSession/spawnPipeline.ts +++ b/src/supervisor/runtime/threadSession/spawnPipeline.ts @@ -119,6 +119,32 @@ export function effectiveLaunchConfig( return next; } +/** + * Launch config for providers that declare `mcpConfigSource: "agentSettings"`: + * the built-in MCP flags come from the provider's saved settings + * (`sharedSettings.agentSettings[kind]`) instead of the per-thread composer + * flags. Crossagents remains off unless the provider explicitly supports + * trusted provider-session routing, which lets a pooled runtime share one MCP + * credential without losing the calling parent thread. + */ +export function applyAgentSettingsMcpFlags( + config: ThreadConfig, + agentSettings: Record, + disabledBuiltInMcpServerIds: readonly BuiltInMcpServerId[], + providerSessionCrossagents: boolean, +): ThreadConfig { + return effectiveLaunchConfig( + { + ...config, + browserMcp: agentSettings.browserMcp === true, + chromeMcp: agentSettings.chromeMcp === true, + computerUse: agentSettings.computerUse === true, + crossagentMcp: providerSessionCrossagents && agentSettings.crossagentMcp === true, + }, + disabledBuiltInMcpServerIds, + ); +} + export function composeResolvedMcpServers( snapshot: McpLaunchSnapshot, browserMcp: BrowserMcpHttpConfig | undefined, @@ -129,7 +155,13 @@ export function composeResolvedMcpServers( ): ResolvedMcpServer[] { const http = ( id: BuiltInMcpServerId, - config: { url: string; headers: Record } | undefined, + config: + | { + url: string; + headers: Record; + disabledTools?: readonly string[]; + } + | undefined, timeoutMs = DEFAULT_MCP_SERVER_TIMEOUT_MS, approvalMode?: "approve", ): ResolvedMcpServer | undefined => @@ -139,6 +171,9 @@ export function composeResolvedMcpServers( name: BUILT_IN_MCP_SERVER_NAMES[id], timeoutMs, transport: { type: "http", url: config.url, headers: config.headers }, + ...(config.disabledTools && config.disabledTools.length > 0 + ? { disabledTools: [...config.disabledTools] } + : {}), ...(approvalMode ? { approvalMode } : {}), } : undefined; @@ -297,6 +332,7 @@ export class SpawnPipeline { mcpLaunchSnapshot, identity: mcpIdentity, crossagentThreadId: payload.threadId, + adapter, }); const structuredSession = await this.createStructuredSession( adapter, @@ -591,6 +627,7 @@ export class SpawnPipeline { mcpLaunchSnapshot, identity: mcpIdentity, crossagentThreadId: session.threadId, + adapter: session.adapter, }); const structuredSession = await this.createStructuredSession( session.adapter, @@ -872,13 +909,32 @@ export class SpawnPipeline { mcpLaunchSnapshot, identity, crossagentThreadId, + adapter, }: { location: ProjectLocation; config: ThreadConfig; mcpLaunchSnapshot: McpLaunchSnapshot; identity?: McpThreadIdentity; crossagentThreadId?: string; + adapter?: AgentAdapter; }): Promise { + const providerSessionCrossagents = + adapter?.capabilities.crossagentMcpRouting === "provider-session" && + crossagentThreadId !== undefined; + if (adapter?.capabilities.mcpConfigSource === "agentSettings") { + // Provider-level MCP: flags come from the provider's settings page. Drop + // the thread identity so a shared provider runtime never retains + // per-thread bearer credentials; project-specific config is routed by + // the provider's directory-scoped client. + config = applyAgentSettingsMcpFlags( + config, + this.ctx.resolveAgentSettings(adapter), + mcpLaunchSnapshot.disabledBuiltInMcpServerIds, + providerSessionCrossagents, + ); + identity = undefined; + if (!providerSessionCrossagents) crossagentThreadId = undefined; + } const browserMcp = await this.resolveBrowserMcpForLaunch( location, config, @@ -891,6 +947,7 @@ export class SpawnPipeline { location, config, mcpLaunchSnapshot, + providerSessionCrossagents, ) : undefined; const computerUseMcp = this.resolveComputerUseMcpForLaunch( @@ -1004,12 +1061,16 @@ export class SpawnPipeline { location: ProjectLocation, config: ThreadConfig, mcpLaunchSnapshot: McpLaunchSnapshot, + providerSessionRouting = false, ): Promise { - if (config.crossagentMcp !== true) return undefined; - const native = this.ctx.options.crossagentMcp?.register( - threadId, - mcpLaunchSnapshot.disabledBuiltInMcpTools?.crossagents ?? [], - ); + if (config.crossagentMcp !== true) { + this.ctx.options.crossagentMcp?.unregister(threadId); + return undefined; + } + const disabledTools = mcpLaunchSnapshot.disabledBuiltInMcpTools?.crossagents ?? []; + const native = providerSessionRouting + ? this.ctx.options.crossagentMcp?.registerProviderSession(threadId, disabledTools) + : this.ctx.options.crossagentMcp?.register(threadId, disabledTools); return resolveCrossagentMcpHttpConfigForLaunch( native, location, @@ -1070,7 +1131,9 @@ export class SpawnPipeline { config, agentSettings: this.ctx.resolveAgentSettings(adapter), ...(mcpIdentity ? { mcpIdentity } : {}), - ...(mcpServers.length > 0 ? { mcpServers } : {}), + ...(mcpServers.length > 0 || adapter.capabilities.mcpConfigSource === "agentSettings" + ? { mcpServers } + : {}), ...(sessionRef ? { sessionRef } : {}), ...(presentationMode ? { presentationMode } : {}), }); diff --git a/src/supervisor/runtime/threadSessionManager.startClose.test.ts b/src/supervisor/runtime/threadSessionManager.startClose.test.ts index f1289384..ce8c56ed 100644 --- a/src/supervisor/runtime/threadSessionManager.startClose.test.ts +++ b/src/supervisor/runtime/threadSessionManager.startClose.test.ts @@ -171,7 +171,49 @@ afterEach(async () => { } }); +describe("ThreadSessionManager provider-session routing", () => { + it("resolves both root and provider-owned child sessions to the live thread", () => { + const structuredSession = createStructuredSession(Promise.resolve()); + structuredSession.ownsProviderSession = (sessionId) => sessionId === "ses_child"; + const adapter = createAdapter("opencode", structuredSession); + adapter.capabilities.crossagentMcpRouting = "provider-session"; + const manager = createManager("opencode", adapter); + const runtime = createInactiveRuntime("opencode", adapter, structuredSession); + manager.sessions.set(runtime.threadId, runtime); + manager.sessionsBySessionId.set("ses_existing", runtime); + + expect(manager.getThreadIdByProviderSessionId("ses_existing")).toBe(runtime.threadId); + expect(manager.getThreadIdByProviderSessionId("ses_child")).toBe(runtime.threadId); + expect(manager.getThreadIdByProviderSessionId("ses_unknown")).toBeUndefined(); + + delete adapter.capabilities.crossagentMcpRouting; + expect(manager.getThreadIdByProviderSessionId("ses_existing")).toBeUndefined(); + }); +}); + describe("ThreadSessionManager start guards", () => { + it("passes an empty MCP set to provider-owned structured sessions", async () => { + const structuredSession = createStructuredSession(Promise.resolve()); + const adapter = createAdapter("opencode", structuredSession); + adapter.capabilities.mcpConfigSource = "agentSettings"; + const manager = createManager("opencode", adapter); + + await manager.startThread({ + threadId: "thread-opencode-empty-mcp", + projectLocation: { kind: "windows", path: "C:\\repo" }, + agentKind: "opencode", + config: { model: "opencode/model" }, + prompt: "", + initialSize: { cols: 80, rows: 24 }, + presentationMode: "gui", + disabledBuiltInMcpServerIds: ["app-controls"], + }); + + expect(adapter.createStructuredSession).toHaveBeenCalledWith( + expect.objectContaining({ mcpServers: [] }), + ); + }); + it.each(guardedStructuredProviders)( "disposes a %s structured GUI session that is closed before activation completes", async (agentKind) => { diff --git a/src/supervisor/runtime/threadSessionManager.ts b/src/supervisor/runtime/threadSessionManager.ts index 650dc083..3f5a9a50 100644 --- a/src/supervisor/runtime/threadSessionManager.ts +++ b/src/supervisor/runtime/threadSessionManager.ts @@ -8,10 +8,12 @@ import type { McpThreadIdentity } from "@/shared/browserMcpThread"; import { type ClearPendingSteerPayload, type AgentEventEnvelope, + type AgentKind, type CloseThreadPayload, type PromptSegment, type ProjectLocation, type ResizeTerminalPayload, + type ReloadAgentMcpServersPayload, type ResolveThreadServerRequestPayload, type RollbackThreadConversationPayload, type SendThreadInputPayload, @@ -181,6 +183,30 @@ export class ThreadSessionManager { }); } + /** + * Resolve a provider-native root or child session to its live Poracode + * thread. Root ids use the reverse index; provider-owned child sessions can + * opt into the fallback through `ownsProviderSession`. + */ + getThreadIdByProviderSessionId(providerSessionId: string): string | undefined { + const indexed = this.sessionsBySessionId.get(providerSessionId); + if (indexed?.adapter.capabilities.crossagentMcpRouting === "provider-session") { + return indexed.threadId; + } + + for (const session of this.sessions.values()) { + if (session.adapter.capabilities.crossagentMcpRouting !== "provider-session") continue; + if (session.sessionRef?.providerSessionId === providerSessionId) { + this.sessionsBySessionId.set(providerSessionId, session); + return session.threadId; + } + if (session.structuredSession?.ownsProviderSession?.(providerSessionId)) { + return session.threadId; + } + } + return undefined; + } + getThreadSnapshots(): ThreadRuntimeSnapshot[] { return [...this.sessions.values()].map((session) => ({ threadId: session.threadId, @@ -247,9 +273,12 @@ export class ThreadSessionManager { async resolveSubagentParentMcpAccess( threadId: string, identity: McpThreadIdentity, + targetAgentKind: AgentKind, ): Promise<{ mcpServers?: ResolvedMcpServer[] }> { const session = this.sessions.get(threadId); if (!session) return {}; + const targetAdapter = this.options.adapters.get(targetAgentKind); + if (!targetAdapter) return {}; const mcpLaunchSnapshot = session.mcpLaunchSnapshot; const launchConfig = effectiveLaunchConfig( session.config, @@ -260,8 +289,55 @@ export class ThreadSessionManager { config: launchConfig, mcpLaunchSnapshot, identity, + adapter: targetAdapter, }); - return mcpServers.length > 0 ? { mcpServers } : {}; + return mcpServers.length > 0 || targetAdapter.capabilities.mcpConfigSource === "agentSettings" + ? { mcpServers } + : {}; + } + + /** + * Apply a provider-level MCP settings change (the provider settings page's + * Save) to the provider's live sessions. Only meaningful for adapters that + * declare `mcpConfigSource: "agentSettings"` — their MCP set is re-resolved + * from the freshly saved settings and each structured session that exposes + * `updateMcpServers` applies the new set to its directory instance. Terminal + * threads and sessions without the hook pick the change up on next launch. + * Per-session failures are logged, never fatal: one broken directory update + * must not strand the remaining sessions on the old set. + */ + async reloadAgentMcpServers(payload: ReloadAgentMcpServersPayload): Promise { + const reloads: Promise[] = []; + for (const session of this.sessions.values()) { + if (session.agentKind !== payload.agentKind) continue; + if (session.adapter.capabilities.mcpConfigSource !== "agentSettings") continue; + const update = session.structuredSession?.updateMcpServers?.bind(session.structuredSession); + if (!update) continue; + reloads.push( + (async () => { + try { + const launchConfig = effectiveLaunchConfig( + session.config, + session.mcpLaunchSnapshot.disabledBuiltInMcpServerIds, + ); + const mcpServers = await this.spawnPipeline.resolveMcpServersForLaunch({ + location: session.projectLocation, + config: launchConfig, + mcpLaunchSnapshot: session.mcpLaunchSnapshot, + crossagentThreadId: session.threadId, + adapter: session.adapter, + }); + await update(mcpServers); + } catch (error) { + console.warn( + `[supervisor] failed to reload MCP servers for thread ${session.threadId}:`, + error, + ); + } + })(), + ); + } + await Promise.all(reloads); } /** diff --git a/src/supervisor/supervisorRuntime.ts b/src/supervisor/supervisorRuntime.ts index b9b2dc9d..805c1bf3 100644 --- a/src/supervisor/supervisorRuntime.ts +++ b/src/supervisor/supervisorRuntime.ts @@ -301,8 +301,12 @@ export class SupervisorRuntime { host: { getParentContext: (threadId) => this.threadSessionManager.getSubagentParentContext(threadId), - resolveParentMcpAccess: (threadId, identity) => - this.threadSessionManager.resolveSubagentParentMcpAccess(threadId, identity), + resolveParentMcpAccess: (threadId, identity, targetAgentKind) => + this.threadSessionManager.resolveSubagentParentMcpAccess( + threadId, + identity, + targetAgentKind, + ), appendRuntimeEvent: (parentThreadId, event) => this.threadSessionManager.appendSubagentRuntimeEvent(parentThreadId, event), }, @@ -370,6 +374,8 @@ export class SupervisorRuntime { const { windows } = await this.agentStatusService.getAgentStatuses({ wslDistros: [] }); return buildSpawnableAgents(this.adapters, windows); }, + resolveProviderSessionThreadId: (sessionId) => + this.threadSessionManager.getThreadIdByProviderSessionId(sessionId), // User-configured routing guidance, read live from shared settings (the // cache invalidates on file change) so edits take effect on the next turn // without a supervisor restart. Empty/whitespace-only = no guidance. @@ -401,6 +407,8 @@ export class SupervisorRuntime { crossagentMcp: { register: (threadId, disabledTools) => this.crossagentMcpIngress.registerThread(threadId, disabledTools), + registerProviderSession: (threadId, disabledTools) => + this.crossagentMcpIngress.registerProviderSessionThread(threadId, disabledTools), unregister: (threadId) => this.crossagentMcpIngress.unregisterThread(threadId), cancelAll: (threadId) => this.subagentRunManager.cancelAllForThread(threadId), resolveChildRequest: (requestId, response) => diff --git a/tests/integration/crossagent-mcp.integration.test.ts b/tests/integration/crossagent-mcp.integration.test.ts index 4b3bd3aa..21c5ade5 100644 --- a/tests/integration/crossagent-mcp.integration.test.ts +++ b/tests/integration/crossagent-mcp.integration.test.ts @@ -27,6 +27,7 @@ describe("Crossagents MCP (live)", () => { let runManager: SubagentRunManager; let mcp: CrossagentMcpHttpConfig; let claude: AgentAdapter | undefined; + let opencode: AgentAdapter | undefined; const parentEvents: RuntimeEvent[] = []; const projectLocation = (): ProjectLocation => @@ -43,6 +44,7 @@ describe("Crossagents MCP (live)", () => { createAgentRegistry().map((a) => [a.kind, a]), ); claude = adapters.get("claude" as AgentKind); + opencode = adapters.get("opencode" as AgentKind); runManager = new SubagentRunManager({ adapters, @@ -57,25 +59,25 @@ describe("Crossagents MCP (live)", () => { }, }); - const spawnable: SpawnableAgent[] = claude - ? [ - { - provider: { value: claude.kind, label: claude.label }, - models: claude.capabilities.models.map((m) => ({ - value: m.id, - label: m.label, - reasoning: { values: [] }, - })), - reasoningOptions: [], - defaultModel: claude.capabilities.models[0]?.id ?? "haiku", - permissions: { - options: [{ value: "full-access", label: "Full access" }], - default: "full-access", - }, - execution: "structured", - }, - ] - : []; + const spawnable: SpawnableAgent[] = []; + for (const adapter of [claude, opencode]) { + if (!adapter) continue; + spawnable.push({ + provider: { value: adapter.kind, label: adapter.label }, + models: adapter.capabilities.models.map((m) => ({ + value: m.id, + label: m.label, + reasoning: { values: [] }, + })), + reasoningOptions: [], + defaultModel: adapter.capabilities.models[0]?.id ?? "haiku", + permissions: { + options: [{ value: "full-access", label: "Full access" }], + default: "full-access", + }, + execution: "structured", + }); + } ingress = new CrossagentMcpIngress({ runManager, @@ -194,7 +196,7 @@ describe("Crossagents MCP (live)", () => { (e) => e.type === "item.started" && e.itemId === `sub:${parsed.run_id}`, ); expect(started).toBeDefined(); - expect((started as { payload?: { isSubAgent?: boolean } }).payload?.isSubAgent).toBe(true); + expect((started as { payload?: { isCrossagent?: boolean } }).payload?.isCrossagent).toBe(true); const completed = parentEvents.find( (e) => e.type === "item.completed" && e.itemId === `sub:${parsed.run_id}`, ); @@ -216,6 +218,39 @@ describe("Crossagents MCP (live)", () => { } }, 240_000); + it("run_agent completes a real OpenCode child turn", async () => { + if (!opencode) return; + const status = await opencode.detectInstall().catch(() => undefined); + if (!status?.installed || status.authState !== "authenticated") { + console.log( + `[subagent-int] SKIPPED OpenCode run: installed=${status?.installed} auth=${status?.authState}`, + ); + return; + } + + parentEvents.length = 0; + const result = await callTool("run_agent", { + provider: "opencode", + model: "opencode/big-pickle", + name: "opencode-dogfood", + prompt: + "Reply with exactly the single word OPENCODE_CROSSAGENT_OK and nothing else. Do not use any tools.", + }); + expect(result.isError).toBeFalsy(); + const parsed = JSON.parse(result.content[0]!.text) as { + run_id: string; + status: string; + output: string; + }; + expect(parsed.status).toBe("completed"); + expect(parsed.output).toContain("OPENCODE_CROSSAGENT_OK"); + expect( + parentEvents.some( + (event) => event.type === "item.completed" && event.itemId === `sub:${parsed.run_id}`, + ), + ).toBe(true); + }, 240_000); + it("wait_for_agent on an unknown run id fails cleanly", async () => { const result = await callTool("wait_for_agent", { run_id: "nope" }); const parsed = JSON.parse(result.content[0]!.text) as { status: string }; diff --git a/tests/integration/opencode-crossagent-mcp.integration.test.ts b/tests/integration/opencode-crossagent-mcp.integration.test.ts index d4b0ab3a..7b932a7f 100644 --- a/tests/integration/opencode-crossagent-mcp.integration.test.ts +++ b/tests/integration/opencode-crossagent-mcp.integration.test.ts @@ -2,85 +2,53 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import type { AgentKind, ProjectLocation, RuntimeEvent } from "@/shared/contracts"; +import type { AgentKind, ProjectLocation } from "@/shared/contracts"; import type { AgentAdapter } from "@/supervisor/agents/base"; +import type { CrossagentMcpHttpConfig } from "@/supervisor/agents/crossagentMcp"; import { createAgentRegistry } from "@/supervisor/agents/registry"; import { acquireOpenCodeServer, resolveOpenCodeSessionDirectory, shutdownSpawnedOpenCodeServers, } from "@/supervisor/agents/opencode/sdkClient"; +import { + CROSSAGENT_PROVIDER_SESSION_ID_ARG, + CrossagentMcpIngress, +} from "@/supervisor/crossagentMcp/CrossagentMcpIngress"; import { OrchestratorThreadManager } from "@/supervisor/crossagentMcp/OrchestratorThreadManager"; -import { CrossagentMcpIngress } from "@/supervisor/crossagentMcp/CrossagentMcpIngress"; import { SubagentRunManager } from "@/supervisor/crossagentMcp/SubagentRunManager"; -import type { SpawnableAgent } from "@/supervisor/crossagentMcp/types"; -import type { CrossagentMcpHttpConfig } from "@/supervisor/agents/crossagentMcp"; - -// Live integration for OpenCode HOSTING the Crossagents MCP. -// Stands up the real ingress + run manager, then acquires a DEDICATED per-thread -// `opencode serve` with the thread's Crossagents config and asserts, via the -// OpenCode client's `mcp.status`, that the `crossagents` server was registered -// (mcp.add) and successfully connected (mcp.connect handshook against the real -// ingress with the per-thread bearer token — a 401 would surface as "failed"). -// Skips when OpenCode is not installed on the host. const PARENT_THREAD_ID = "oc-int-parent-thread"; -describe("opencode hosts Crossagents MCP (live)", () => { - let projectDir: string; +describe("opencode hosts Crossagents MCP on its shared server (live)", () => { + let firstProjectDir: string; + let secondProjectDir: string; let ingress: CrossagentMcpIngress; let runManager: SubagentRunManager; let mcp: CrossagentMcpHttpConfig; let opencode: AgentAdapter | undefined; + const providerSessions = new Map(); - const projectLocation = (): ProjectLocation => - process.platform === "win32" - ? { kind: "windows", path: projectDir } - : { kind: "posix", path: projectDir }; + const projectLocation = (path: string): ProjectLocation => + process.platform === "win32" ? { kind: "windows", path } : { kind: "posix", path }; beforeAll(async () => { - projectDir = mkdtempSync(join(tmpdir(), "poracode-oc-subagent-int-")); - writeFileSync(join(projectDir, "README.md"), "# opencode subagent host fixture\n"); + firstProjectDir = mkdtempSync(join(tmpdir(), "poracode-oc-shared-a-")); + secondProjectDir = mkdtempSync(join(tmpdir(), "poracode-oc-shared-b-")); + writeFileSync(join(firstProjectDir, "README.md"), "# first shared server fixture\n"); + writeFileSync(join(secondProjectDir, "README.md"), "# second shared server fixture\n"); const adapters = new Map( - createAgentRegistry().map((a) => [a.kind, a]), + createAgentRegistry().map((adapter) => [adapter.kind, adapter]), ); opencode = adapters.get("opencode" as AgentKind); - - const parentEvents: RuntimeEvent[] = []; runManager = new SubagentRunManager({ adapters, host: { - getParentContext: (threadId) => - threadId === PARENT_THREAD_ID - ? { projectLocation: projectLocation(), config: { model: "opencode/big-pickle" } } - : undefined, - appendRuntimeEvent: (_parentThreadId, event) => { - parentEvents.push(event); - }, + getParentContext: () => undefined, + appendRuntimeEvent: () => {}, }, }); - - const spawnable: SpawnableAgent[] = opencode - ? [ - { - provider: { value: opencode.kind, label: opencode.label }, - models: opencode.capabilities.models.map((m) => ({ - value: m.id, - label: m.label, - reasoning: { values: [] }, - })), - reasoningOptions: [], - defaultModel: opencode.capabilities.models[0]?.id ?? "opencode/big-pickle", - permissions: { - options: [{ value: "full-access", label: "Full access" }], - default: "full-access", - }, - execution: "structured", - }, - ] - : []; - ingress = new CrossagentMcpIngress({ runManager, orchestrator: new OrchestratorThreadManager({ @@ -97,12 +65,12 @@ describe("opencode hosts Crossagents MCP (live)", () => { createWorktree: async () => ({ path: "/unused" }), removeWorktree: async () => {}, }), - getSpawnableAgents: async () => spawnable, - getRoutingGuide: () => "Prefer opencode/big-pickle for everything in this test.", + getSpawnableAgents: async () => [], + resolveProviderSessionThreadId: (sessionId) => providerSessions.get(sessionId), }); await ingress.start(); - const registered = ingress.registerThread(PARENT_THREAD_ID); - if (!registered) throw new Error("ingress did not mint a thread config"); + const registered = ingress.registerProviderSessionThread(PARENT_THREAD_ID); + if (!registered) throw new Error("ingress did not create a provider-session config"); mcp = registered; }); @@ -110,46 +78,79 @@ describe("opencode hosts Crossagents MCP (live)", () => { shutdownSpawnedOpenCodeServers(); runManager.cancelAllForThread(PARENT_THREAD_ID); ingress.dispose(); - rmSync(projectDir, { recursive: true, force: true }); + rmSync(firstProjectDir, { recursive: true, force: true }); + rmSync(secondProjectDir, { recursive: true, force: true }); }); - it("registers + connects the Crossagents MCP on a dedicated per-thread server", async () => { + it("reuses one server and routes the shared credential by session id", async () => { if (!opencode) { - console.log("[oc-subagent-int] SKIPPED: opencode adapter not registered"); + console.log("[oc-crossagents-int] SKIPPED: opencode adapter not registered"); return; } const status = await opencode.detectInstall().catch(() => undefined); if (!status?.installed) { - console.log(`[oc-subagent-int] SKIPPED: opencode installed=${status?.installed}`); + console.log(`[oc-crossagents-int] SKIPPED: opencode installed=${status?.installed}`); return; } - const location = projectLocation(); - const acquired = await acquireOpenCodeServer({ - projectLocation: location, - mcpServers: [ - { - id: "crossagents", - name: "crossagents", - timeoutMs: 300_000, - transport: { type: "http", url: mcp.url, headers: mcp.headers }, - }, - ], - dedicatedKey: PARENT_THREAD_ID, + const serverConfig = { + id: "crossagents", + name: "crossagents", + timeoutMs: 300_000, + transport: { type: "http" as const, url: mcp.url, headers: mcp.headers }, + }; + const firstLocation = projectLocation(firstProjectDir); + const secondLocation = projectLocation(secondProjectDir); + const first = await acquireOpenCodeServer({ + projectLocation: firstLocation, + mcpServers: [serverConfig], + }); + const second = await acquireOpenCodeServer({ + projectLocation: secondLocation, + mcpServers: [serverConfig], }); try { - const directory = resolveOpenCodeSessionDirectory(location); - const result = await acquired.client.mcp.status({ directory }); - const servers = (result.data ?? {}) as Record; - const crossagents = servers.crossagents; - expect(crossagents).toBeDefined(); - // Connected proves the full path: dedicated server spawned, mcp.add - // registered the entry, mcp.connect completed the MCP initialize - // handshake against the live ingress with the per-thread bearer token. - expect(crossagents?.status).toBe("connected"); + expect(second.handle).toBe(first.handle); + expect(second.baseUrl).toBe(first.baseUrl); + + for (const [acquired, location] of [ + [first, firstLocation], + [second, secondLocation], + ] as const) { + const directory = resolveOpenCodeSessionDirectory(location); + const result = await acquired.client.mcp.status({ directory }); + const servers = (result.data ?? {}) as Record; + expect(servers.crossagents?.status).toBe("connected"); + } + + const directory = resolveOpenCodeSessionDirectory(firstLocation); + const created = await first.client.session.create({ + directory, + title: "poracode/crossagents-shared-test", + }); + const providerSessionId = created.data?.id; + if (!providerSessionId) throw new Error("opencode session.create returned no id"); + providerSessions.set(providerSessionId, PARENT_THREAD_ID); + + const response = await fetch(mcp.url, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${mcp.token}` }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "list_agents", + arguments: { [CROSSAGENT_PROVIDER_SESSION_ID_ARG]: providerSessionId }, + }, + }), + }); + expect(response.status).toBe(200); + expect((await response.json()).result.isError).not.toBe(true); } finally { - await acquired.dispose(); + await second.dispose(); + await first.dispose(); } }, 120_000); }); diff --git a/website/public/changelog.json b/website/public/changelog.json index 41d9b859..76af945d 100644 --- a/website/public/changelog.json +++ b/website/public/changelog.json @@ -1,5 +1,85 @@ { "releases": [ + { + "version": "1.5.0", + "date": "2026-07-17", + "title": "Poracode arrives with mobile access & cross-provider agents", + "summary": "A big feature release, and the first under the Poracode name: Lightcode graduates into Poracode with a brand-new identity, your threads follow you to any phone or browser through the Poracode PWA, and your agents can now hand work to one another across providers. New agents, a full MCP toolkit, multi-agent worktree experiments, and a chat rebuilt for long, tool-heavy sessions round it out.", + "changes": [ + { + "kind": "added", + "text": "Lightcode becomes Poracode — a new name and a fresh visual identity with the indigo Pora-dot wordmark, Geist type, and refreshed dark and moonlight themes, all with a new home at poracode.com; your projects, settings, and automatic updates carry over." + }, + { + "kind": "added", + "text": "Take your work anywhere with the Poracode PWA at poracode.com/pwa — pair it with your desktop over your local network or an SSH connection to follow live threads, read terminal output, and send messages from a phone or any browser, with dynamic port forwarding and native iOS and Android betas on the way." + }, + { + "kind": "added", + "text": "Your agents can now delegate to one another: a thread can spawn subagents on any agent you have installed — Claude, Codex, Grok, Kimi Code, Cursor, OpenCode, and more — through a built-in orchestration layer, and you can watch every subagent and workflow chat unfold live from the parent thread, approving child actions when they need it." + }, + { + "kind": "added", + "text": "Run Kimi Code as a new built-in agent with sign-in and usage tracking, add Factory Droid through a new ACP integration, drive Antigravity in plan or accept-edits mode, and pick Codex's Fast mode straight from the model menu." + }, + { + "kind": "added", + "text": "A full MCP toolkit lands: manage MCP servers globally and toggle them per thread, give agents computer-use control of your desktop, connect an external Chrome browser, and let scheduled tasks run agents automatically or drive Poracode itself through app-control tools." + }, + { + "kind": "added", + "text": "Create a skill once and inject it into any provider, then see the skills you attach rendered as badges on your message." + }, + { + "kind": "added", + "text": "Race the same task across competing agents in separate Git worktrees, compare each candidate's diff and stats, have Claude judge the results, and merge the winner with safeguards." + }, + { + "kind": "added", + "text": "Review pull requests from every project in one global view, complete with check statuses and how long each run took, and start merges from a prefilled commit message." + }, + { + "kind": "added", + "text": "Summon a quick composer from anywhere with a global shortcut, and reopen recent threads straight from the system tray." + }, + { + "kind": "added", + "text": "Preview PDFs inline, drop files into the composer to attach them, render local images directly in Markdown, and include media attachments in Grok sessions." + }, + { + "kind": "added", + "text": "Open an existing WSL folder as a project straight from the project picker, and spot threads that hold unsent drafts right in the sidebar." + }, + { + "kind": "improved", + "text": "Long conversations stay fast: transcripts are virtualized with paged loading, streamed Markdown renders smoothly, background events are batched, and animations ease off while the window sits in the background." + }, + { + "kind": "improved", + "text": "Reasoning now groups with its tool calls into collapsible runs with live previews, repeated edits to the same file merge into a single diff, Claude goals show their iterations and completion reasons, and every message gains copy controls." + }, + { + "kind": "improved", + "text": "Switching threads keeps your pane, composer text, and scroll position intact." + }, + { + "kind": "fixed", + "text": "Claude now applies a model change before a steered turn and reports its usage limits correctly again." + }, + { + "kind": "fixed", + "text": "Codex identifies rate-limit windows by their real duration, keeps reasoning-summary boundaries intact, and holds goal commands active through model turns." + }, + { + "kind": "fixed", + "text": "Cursor usage dollars stay aligned with your plan limit and percentage, unnamed ACP tool calls keep their labels, and Grok reconnects with its current session and model ID formats." + }, + { + "kind": "fixed", + "text": "The Git panel preserves staging state and decodes quoted paths, pulls follow an explicit strategy, and agent updates keep npm shims and version-prefixed releases intact across every profile that shares a binary." + } + ] + }, { "version": "1.4.3", "date": "2026-07-02",