diff --git a/apps/app/package.json b/apps/app/package.json index 1e200387f..29b2c3df1 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -22,7 +22,6 @@ "test:voice-cdp": "node scripts/voice-cdp.mjs", "test:remote-diagnostics": "bun test scripts/remote-workspace-diagnostics.test.ts", "test:open-target": "bun test scripts/open-target.test.ts", - "test:desktop-cloud-sync": "bun test scripts/desktop-cloud-sync.test.ts", "test:artifact-spreadsheet": "bun test scripts/artifact-spreadsheet.test.ts", "test:dev-log": "bun scripts/dev-log.ts", "test:session-error-recovery": "bun scripts/session-error-recovery.ts", diff --git a/apps/app/scripts/desktop-cloud-sync.test.ts b/apps/app/scripts/desktop-cloud-sync.test.ts deleted file mode 100644 index e38858db7..000000000 --- a/apps/app/scripts/desktop-cloud-sync.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - derivePendingCloudPluginChanges, - readPendingCloudSyncChanges, -} from "../src/app/cloud/desktop-cloud-sync"; -import type { iPolloWorkDesktopCloudSyncChange } from "../src/app/lib/ipollowork-server"; - -function change(input: Partial & Pick): iPolloWorkDesktopCloudSyncChange { - return { - marketplaceId: undefined, - pluginId: undefined, - previousLastUpdatedAt: null, - nextLastUpdatedAt: null, - queuedAt: 0, - ...input, - }; -} - -const installedPlugins = { - "plugin-1": { - updatedAt: "2026-01-01", - files: [{ configObjectId: "obj-1", updatedAt: "2026-01-01" }], - }, - "plugin-2": { - updatedAt: "2026-01-01", - files: [], - }, -}; - -describe("derivePendingCloudPluginChanges", () => { - test("maps modified plugin changes to update available", () => { - const pending = derivePendingCloudPluginChanges({ - changes: [ - change({ id: "plugin-1", kind: "modified", resourceKind: "plugin", nextLastUpdatedAt: "2026-02-01" }), - ], - installedPlugins, - }); - expect(pending).toEqual({ "plugin-1": "modified" }); - }); - - test("maps removed plugin changes to removed", () => { - const pending = derivePendingCloudPluginChanges({ - changes: [ - change({ id: "plugin-2", kind: "removed", resourceKind: "plugin" }), - ], - installedPlugins, - }); - expect(pending).toEqual({ "plugin-2": "removed" }); - }); - - test("removed wins over config item modifications regardless of order", () => { - const configItemChange = change({ - id: "obj-1", - kind: "modified", - resourceKind: "configItem", - pluginId: "plugin-1", - nextLastUpdatedAt: "2026-03-01", - }); - const removedChange = change({ id: "plugin-1", kind: "removed", resourceKind: "plugin" }); - expect(derivePendingCloudPluginChanges({ - changes: [configItemChange, removedChange], - installedPlugins, - })).toEqual({ "plugin-1": "removed" }); - expect(derivePendingCloudPluginChanges({ - changes: [removedChange, configItemChange], - installedPlugins, - })).toEqual({ "plugin-1": "removed" }); - }); - - test("config item changes mark the parent plugin as modified", () => { - const pending = derivePendingCloudPluginChanges({ - changes: [ - change({ - id: "obj-1", - kind: "modified", - resourceKind: "configItem", - pluginId: "plugin-1", - nextLastUpdatedAt: "2026-04-01", - }), - ], - installedPlugins, - }); - expect(pending).toEqual({ "plugin-1": "modified" }); - }); - - test("ignores changes for plugins that are not installed", () => { - const pending = derivePendingCloudPluginChanges({ - changes: [ - change({ id: "plugin-gone", kind: "modified", resourceKind: "plugin", nextLastUpdatedAt: "2026-02-01" }), - change({ id: "plugin-gone", kind: "removed", resourceKind: "plugin" }), - ], - installedPlugins, - }); - expect(pending).toEqual({}); - }); - - test("ignores stale changes already applied locally", () => { - const pending = derivePendingCloudPluginChanges({ - changes: [ - change({ id: "plugin-1", kind: "modified", resourceKind: "plugin", nextLastUpdatedAt: "2026-01-01" }), - change({ - id: "obj-1", - kind: "modified", - resourceKind: "configItem", - pluginId: "plugin-1", - nextLastUpdatedAt: "2026-01-01", - }), - ], - installedPlugins, - }); - expect(pending).toEqual({}); - }); - - test("ignores other resource kinds", () => { - const pending = derivePendingCloudPluginChanges({ - changes: [ - change({ id: "provider-1", kind: "modified", resourceKind: "llmProvider", nextLastUpdatedAt: "2026-02-01" }), - change({ id: "marketplace-1", kind: "modified", resourceKind: "marketplace", nextLastUpdatedAt: "2026-02-01" }), - ], - installedPlugins, - }); - expect(pending).toEqual({}); - }); -}); - -describe("readPendingCloudSyncChanges", () => { - test("reads pending changes from persisted sync state entries", () => { - const changes = readPendingCloudSyncChanges({ - entries: { - "org::member": { - pendingChanges: [ - { - id: "plugin-1", - kind: "modified", - resourceKind: "plugin", - previousLastUpdatedAt: "2026-01-01", - nextLastUpdatedAt: "2026-02-01", - queuedAt: 1, - }, - { id: "", kind: "modified", resourceKind: "plugin" }, - { id: "x", kind: "bogus", resourceKind: "plugin" }, - "not-a-change", - ], - }, - broken: null, - }, - updatedAt: 1, - version: 1, - }); - expect(changes).toEqual([ - { - id: "plugin-1", - kind: "modified", - resourceKind: "plugin", - marketplaceId: undefined, - pluginId: undefined, - previousLastUpdatedAt: "2026-01-01", - nextLastUpdatedAt: "2026-02-01", - queuedAt: 1, - }, - ]); - }); -}); diff --git a/apps/app/scripts/extension-items.test.ts b/apps/app/scripts/extension-items.test.ts deleted file mode 100644 index 5c04059e3..000000000 --- a/apps/app/scripts/extension-items.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { buildExtensionItems } from "../src/react-app/domains/settings/extension-items"; - -describe("extension item grouping", () => { - test("groups imported cloud plugin resources and suppresses child skill rows", () => { - const result = buildExtensionItems({ - quickConnect: [], - mcpServers: [], - installedSkills: [ - { - name: "brief-builder", - description: "Use for creative briefs", - path: "/workspace/project/.opencode/skills/creative-brief-plugin/brief-builder/SKILL.md", - }, - ], - importedCloudPlugins: { - plugin_1: { - pluginId: "plugin_1", - marketplaceId: "marketplace_1", - name: "Creative Brief Plugin", - description: "Brief writing workflow", - updatedAt: "2026-06-02T00:00:00.000Z", - importedAt: 1, - files: [ - { - configObjectId: "config_skill_1", - versionId: "version_skill_1", - objectType: "skill", - title: "Brief Builder", - path: ".opencode/skills/creative-brief-plugin/brief-builder/SKILL.md", - updatedAt: "2026-06-02T00:00:00.000Z", - }, - ], - }, - }, - cloudMarketplaces: [], - enablementContext: {}, - isBuiltInConnected: () => false, - }); - - expect(result.items.map((item) => item.id)).toEqual(["marketplace:installed:plugin_1"]); - expect(result.items[0]?.resources).toEqual([ - { - id: "config_skill_1", - type: "skill", - title: "Brief Builder", - path: ".opencode/skills/creative-brief-plugin/brief-builder/SKILL.md", - }, - ]); - }); -}); diff --git a/apps/app/scripts/remote-workspace-diagnostics.test.ts b/apps/app/scripts/remote-workspace-diagnostics.test.ts index e16063131..fbd51a585 100644 --- a/apps/app/scripts/remote-workspace-diagnostics.test.ts +++ b/apps/app/scripts/remote-workspace-diagnostics.test.ts @@ -53,7 +53,6 @@ function client(overrides: Partial = {}): iPolloWorkServ }), capabilities: async () => ({ skills: { read: true, write: true, source: "ipollowork" }, - plugins: { read: true, write: true }, mcp: { read: true, write: true }, commands: { read: true, write: true }, config: { read: true, write: true }, diff --git a/apps/app/src/app/cloud/desktop-cloud-sync.ts b/apps/app/src/app/cloud/desktop-cloud-sync.ts index e0d9d19dc..f47476a57 100644 --- a/apps/app/src/app/cloud/desktop-cloud-sync.ts +++ b/apps/app/src/app/cloud/desktop-cloud-sync.ts @@ -2,101 +2,7 @@ import { createDenClient, readDenSettings, } from "../lib/den"; -import type { - iPolloWorkDesktopCloudSyncChange, - iPolloWorkDesktopCloudSyncResult, - iPolloWorkDesktopCloudSyncState, - iPolloWorkServerClient, -} from "../lib/ipollowork-server"; - -export type PendingCloudPluginChange = "modified" | "removed"; - -type InstalledCloudPluginLike = { - updatedAt: string | null; - files: Array<{ configObjectId: string; updatedAt: string | null }>; -}; - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function readSyncChange(value: unknown): iPolloWorkDesktopCloudSyncChange | null { - if (!isRecord(value)) return null; - const id = typeof value.id === "string" ? value.id.trim() : ""; - const kind = value.kind === "new" || value.kind === "modified" || value.kind === "removed" ? value.kind : null; - const resourceKind = value.resourceKind === "llmProvider" || - value.resourceKind === "marketplace" || - value.resourceKind === "plugin" || - value.resourceKind === "configItem" - ? value.resourceKind - : null; - if (!id || !kind || !resourceKind) return null; - return { - id, - kind, - resourceKind, - marketplaceId: typeof value.marketplaceId === "string" ? value.marketplaceId : undefined, - pluginId: typeof value.pluginId === "string" ? value.pluginId : undefined, - previousLastUpdatedAt: typeof value.previousLastUpdatedAt === "string" ? value.previousLastUpdatedAt : null, - nextLastUpdatedAt: typeof value.nextLastUpdatedAt === "string" ? value.nextLastUpdatedAt : null, - queuedAt: typeof value.queuedAt === "number" && Number.isFinite(value.queuedAt) ? value.queuedAt : 0, - }; -} - -/** Read all pending changes from a persisted desktop-cloud-sync state (GET response). */ -export function readPendingCloudSyncChanges(state: iPolloWorkDesktopCloudSyncState): iPolloWorkDesktopCloudSyncChange[] { - return Object.values(state.entries).flatMap((entry) => { - if (!isRecord(entry) || !Array.isArray(entry.pendingChanges)) return []; - return entry.pendingChanges.flatMap((change) => { - const parsed = readSyncChange(change); - return parsed ? [parsed] : []; - }); - }); -} - -/** - * Derive a map of installed cloud plugin id -> pending change. - * "modified" means an update is available; "removed" means the plugin was - * removed upstream. Stale changes (already applied locally or for plugins - * no longer installed) are filtered out. - */ -export function derivePendingCloudPluginChanges(input: { - changes: iPolloWorkDesktopCloudSyncChange[]; - installedPlugins: Record; -}): Record { - const pending: Record = {}; - const markModified = (pluginId: string) => { - if (pending[pluginId] !== "removed") pending[pluginId] = "modified"; - }; - - for (const change of input.changes) { - if (change.resourceKind === "plugin") { - const installed = input.installedPlugins[change.id]; - if (!installed) continue; - if (change.kind === "removed") { - pending[change.id] = "removed"; - } else if (installed.updatedAt !== change.nextLastUpdatedAt) { - markModified(change.id); - } - continue; - } - - if (change.resourceKind !== "configItem") continue; - const pluginId = change.pluginId?.trim() ?? ""; - const installed = pluginId ? input.installedPlugins[pluginId] : undefined; - if (!installed) continue; - const file = installed.files.find((entry) => entry.configObjectId === change.id) ?? null; - if (change.kind === "removed") { - if (file) markModified(pluginId); - } else if (change.kind === "new") { - if (!file) markModified(pluginId); - } else if (file && file.updatedAt !== change.nextLastUpdatedAt) { - markModified(pluginId); - } - } - - return pending; -} +import type { iPolloWorkDesktopCloudSyncResult, iPolloWorkServerClient } from "../lib/ipollowork-server"; let desktopCloudSyncQueue: Promise = Promise.resolve(); diff --git a/apps/app/src/app/cloud/import-state.ts b/apps/app/src/app/cloud/import-state.ts index 36bfa4c92..6c6a36e70 100644 --- a/apps/app/src/app/cloud/import-state.ts +++ b/apps/app/src/app/cloud/import-state.ts @@ -19,40 +19,9 @@ export type CloudImportedProvider = { importedAt: number | null; }; -export type CloudImportedMarketplace = { - marketplaceId: string; - name: string; - updatedAt: string | null; - pluginIds: string[]; - importedAt: number | null; -}; - -export type CloudImportedPluginFile = { - configObjectId: string; - denSkillId?: string | null; - externalMcpConnectionId?: string | null; - versionId: string | null; - objectType: string; - title: string; - path: string; - updatedAt: string | null; -}; - -export type CloudImportedPlugin = { - pluginId: string; - marketplaceId: string | null; - name: string; - description: string | null; - updatedAt: string | null; - files: CloudImportedPluginFile[]; - importedAt: number | null; -}; - export type WorkspaceCloudImports = { skills: Record; providers: Record; - marketplaces: Record; - plugins: Record; }; const isRecord = (value: unknown): value is Record => @@ -68,8 +37,6 @@ export function readWorkspaceCloudImports(value: unknown): WorkspaceCloudImports const cloudImports = isRecord(root.cloudImports) ? root.cloudImports : {}; const rawSkills = isRecord(cloudImports.skills) ? cloudImports.skills : {}; const rawProviders = isRecord(cloudImports.providers) ? cloudImports.providers : {}; - const rawMarketplaces = isRecord(cloudImports.marketplaces) ? cloudImports.marketplaces : {}; - const rawPlugins = isRecord(cloudImports.plugins) ? cloudImports.plugins : {}; const providers = Object.fromEntries( Object.entries(rawProviders).flatMap(([key, entry]) => { @@ -123,77 +90,7 @@ export function readWorkspaceCloudImports(value: unknown): WorkspaceCloudImports }), ); - const marketplaces = Object.fromEntries( - Object.entries(rawMarketplaces).flatMap(([key, entry]) => { - if (!isRecord(entry)) return []; - const marketplaceId = typeof entry.marketplaceId === "string" - ? entry.marketplaceId.trim() - : key.trim(); - const name = typeof entry.name === "string" ? entry.name.trim() : marketplaceId; - if (!marketplaceId || !name) return []; - const imported = { - marketplaceId, - name, - updatedAt: typeof entry.updatedAt === "string" ? entry.updatedAt.trim() || null : null, - pluginIds: readStringArray(entry.pluginIds), - importedAt: typeof entry.importedAt === "number" && Number.isFinite(entry.importedAt) - ? entry.importedAt - : null, - } satisfies CloudImportedMarketplace; - return [[marketplaceId, imported] as const]; - }), - ); - - const plugins = Object.fromEntries( - Object.entries(rawPlugins).flatMap(([key, entry]) => { - if (!isRecord(entry)) return []; - const pluginId = typeof entry.pluginId === "string" ? entry.pluginId.trim() : key.trim(); - const name = typeof entry.name === "string" ? entry.name.trim() : pluginId; - if (!pluginId || !name) return []; - const files = Array.isArray(entry.files) - ? entry.files.flatMap((file) => { - if (!isRecord(file)) return []; - const configObjectId = typeof file.configObjectId === "string" ? file.configObjectId.trim() : ""; - const objectType = typeof file.objectType === "string" ? file.objectType.trim() : ""; - const title = typeof file.title === "string" ? file.title.trim() : configObjectId; - const path = typeof file.path === "string" ? file.path.trim() : ""; - const externalMcpConnectionId = typeof file.externalMcpConnectionId === "string" - ? file.externalMcpConnectionId.trim() || null - : null; - const denSkillId = typeof file.denSkillId === "string" - ? file.denSkillId.trim() || null - : null; - if (!configObjectId || !objectType || !title || !path) return []; - return [ - { - configObjectId, - denSkillId, - externalMcpConnectionId, - versionId: typeof file.versionId === "string" ? file.versionId.trim() || null : null, - objectType, - title, - path, - updatedAt: typeof file.updatedAt === "string" ? file.updatedAt.trim() || null : null, - } satisfies CloudImportedPluginFile, - ]; - }) - : []; - const imported = { - pluginId, - marketplaceId: typeof entry.marketplaceId === "string" ? entry.marketplaceId.trim() || null : null, - name, - description: typeof entry.description === "string" ? entry.description.trim() || null : null, - updatedAt: typeof entry.updatedAt === "string" ? entry.updatedAt.trim() || null : null, - files, - importedAt: typeof entry.importedAt === "number" && Number.isFinite(entry.importedAt) - ? entry.importedAt - : null, - } satisfies CloudImportedPlugin; - return [[pluginId, imported] as const]; - }), - ); - - return { skills, providers, marketplaces, plugins }; + return { skills, providers }; } export function withWorkspaceCloudImports( @@ -205,8 +102,6 @@ export function withWorkspaceCloudImports( cloudImports: { skills: cloudImports.skills, providers: cloudImports.providers, - marketplaces: cloudImports.marketplaces, - plugins: cloudImports.plugins, }, }; } diff --git a/apps/app/src/app/constants.ts b/apps/app/src/app/constants.ts index 6e48b972a..c6c0e9a96 100644 --- a/apps/app/src/app/constants.ts +++ b/apps/app/src/app/constants.ts @@ -1,4 +1,4 @@ -import type { ModelRef, SuggestedPlugin } from "./types"; +import type { ModelRef } from "./types"; import { t } from "../i18n"; import { getDenMcpUrl } from "./lib/den"; import { publicAssetUrl } from "./lib/public-asset"; @@ -10,10 +10,6 @@ import { type iPolloWorkExtensionManifest, } from "./extensions"; -export const MODEL_PREF_KEY = "ipollowork.defaultModel"; -export const SESSION_MODEL_PREF_KEY = "ipollowork.sessionModels"; -export const THINKING_PREF_KEY = "ipollowork.showThinking"; -export const VARIANT_PREF_KEY = "ipollowork.modelVariant"; export { LANGUAGE_PREF_KEY } from "../i18n"; export const HIDE_TITLEBAR_PREF_KEY = "ipollowork.hideTitlebar"; @@ -22,8 +18,6 @@ export const DEFAULT_MODEL: ModelRef = { modelID: "big-pickle", }; -export const SUGGESTED_PLUGINS: SuggestedPlugin[] = []; - export type ExtensionKind = "mcp" | "plugin" | "skill" | "ui-control" | "extension"; export type McpDirectoryInfo = { diff --git a/apps/app/src/app/lib/deepseek-harness-client.ts b/apps/app/src/app/lib/deepseek-harness-client.ts index 0e66ebe31..b494fbed9 100644 --- a/apps/app/src/app/lib/deepseek-harness-client.ts +++ b/apps/app/src/app/lib/deepseek-harness-client.ts @@ -1,3 +1,8 @@ +import type { + EnginePluginPromptSelection, + PluginPromptCapabilitySummary, +} from "@ipollowork/types/plugins"; + type RpcValue = { value: T }; export type DeepSeekHarnessServerRequest = { @@ -43,6 +48,24 @@ export class DeepSeekHarnessClient implements DeepSeekHarnessRpcClient { if (!response.ok) throw await responseError(response); } + async pluginCapabilities(): Promise { + const response = await fetch(`${this.#baseUrl}/plugin-capabilities`, { + headers: this.#headers, + }); + if (!response.ok) throw await responseError(response); + const payload = await response.json() as { items?: PluginPromptCapabilitySummary[] }; + return Array.isArray(payload.items) ? payload.items : []; + } + + async prompt(payload: Record, plugins?: EnginePluginPromptSelection): Promise { + const response = await fetch(`${this.#baseUrl}/prompt`, { + method: "POST", + headers: this.#headers, + body: JSON.stringify({ payload, ...(plugins ? { plugins } : {}) }), + }); + if (!response.ok) throw await responseError(response); + } + async *events(stream: "mux" | "host", signal: AbortSignal): AsyncGenerator { const response = await fetch(`${this.#baseUrl}/events/${stream}`, { headers: this.#headers, diff --git a/apps/app/src/app/lib/den-types.ts b/apps/app/src/app/lib/den-types.ts index a84314222..0729e0bd1 100644 --- a/apps/app/src/app/lib/den-types.ts +++ b/apps/app/src/app/lib/den-types.ts @@ -138,7 +138,7 @@ export type DenMarketplacePluginDownload = { export type DenOrgPluginResolved = { plugin: DenOrgPlugin; memberships: DenPluginMembership[]; - /** Future Den extension manifest; absent while Claude plugin imports are resource-only. */ + /** Canonical package projection when the organization release exposes a version-2 manifest. */ extension?: DenOrgExtensionProjection | null; }; diff --git a/apps/app/src/app/lib/den.ts b/apps/app/src/app/lib/den.ts index addf97fac..8a350faf9 100644 --- a/apps/app/src/app/lib/den.ts +++ b/apps/app/src/app/lib/den.ts @@ -1268,10 +1268,7 @@ function parseExtensionSourceFormat(value: unknown): iPolloWorkExtensionSourceFo switch (value) { case "ipollowork-builtin": case "ipollowork-extension-manifest": - case "claude-plugin": - case "opencode-plugin": - case "mcp-directory": - case "manual": + case "github-compatible": return value; default: return null; diff --git a/apps/app/src/app/lib/ipollowork-server.ts b/apps/app/src/app/lib/ipollowork-server.ts index 42245c45e..6a8d02227 100644 --- a/apps/app/src/app/lib/ipollowork-server.ts +++ b/apps/app/src/app/lib/ipollowork-server.ts @@ -2,8 +2,7 @@ import type { Message, Part, Session, Todo } from "@opencode-ai/sdk/v2/client"; import { desktopFetch } from "./desktop"; import { isDesktopRuntime } from "./runtime-env"; import type { ExecResult, OpencodeConfigFile, WorkspaceInfo, WorkspaceList } from "./desktop"; -import type { DenOrgMarketplace, DenOrgPluginResolved, DenResourceSnapshot } from "./den-types"; -import type { CloudImportedMarketplace, CloudImportedPlugin } from "../cloud/import-state"; +import type { DenResourceSnapshot } from "./den-types"; import type { HyperframesCatalogItem } from "@ipollowork/types/hyperframes"; import { templatePackageMediaTypeForFilename, @@ -26,7 +25,6 @@ export type iPolloWorkServerCapabilities = { repo?: { owner: string; name: string; ref: string }; }; }; - plugins: { read: boolean; write: boolean }; mcp: { read: boolean; write: boolean }; commands: { read: boolean; write: boolean }; config: { read: boolean; write: boolean }; @@ -152,13 +150,6 @@ export type iPolloWorkSessionSnapshot = { export type iPolloWorkResourceScope = "personal" | `enterprise:${string}`; -export type iPolloWorkPluginItem = { - spec: string; - source: "config" | "dir.project" | "dir.global"; - scope: "project" | "global"; - path?: string; -}; - export type iPolloWorkSkillItem = { name: string; path: string; @@ -231,15 +222,6 @@ export type iPolloWorkAuthorizedFoldersUpdateResponse = { updatedAt: number; }; -export type iPolloWorkRuntimeConfigMigrationResult = { - migrated: boolean; - keys: string[]; - legacyKeys: string[]; - userOpencodeKeys: string[]; - updatedAt: number | null; - legacyError?: string | null; -}; - export type iPolloWorkRuntimeConfigStatus = { runtime: Record; runtimeKeys: string[]; @@ -250,25 +232,12 @@ export type iPolloWorkRuntimeConfigStatus = { runtimeDatabase: { keys: string[]; config: Record }; injected: { keys: string[]; config: Record }; }; - legacyiPolloWork: { - path: string; - keys: string[]; - error: string | null; - }; - userOpencode: { - path: string; - exists: boolean; - keys: string[]; - migratableKeys: string[]; - }; }; export type iPolloWorkDesktopCloudSyncChange = { id: string; kind: "new" | "modified" | "removed"; - resourceKind: "llmProvider" | "marketplace" | "plugin" | "configItem"; - marketplaceId?: string; - pluginId?: string; + resourceKind: "llmProvider"; previousLastUpdatedAt: string | null; nextLastUpdatedAt: string | null; queuedAt: number; @@ -285,16 +254,6 @@ export type iPolloWorkDesktopCloudSyncResult = { state: iPolloWorkDesktopCloudSyncState; }; -export type iPolloWorkCloudPluginInstallResult = { - item: CloudImportedPlugin; - warnings: string[]; -}; - -export type iPolloWorkCloudPluginsResult = { - marketplaces: Record; - plugins: Record; -}; - export type iPolloWorkPluginPackageItem = { pluginId: string; name: string; @@ -390,19 +349,19 @@ export type iPolloWorkPluginAuthorizationFlow = { expiresAt: number; }; -export type iPolloWorkClaudePluginComponent = { +export type iPolloWorkGitHubPluginComponent = { type: "mcp" | "skill" | "command" | "agent"; name: string; description: string | null; }; -export type iPolloWorkClaudePluginPreview = { +export type iPolloWorkGitHubPluginPreview = { pluginId: string; name: string; description: string | null; version: string | null; source: { owner: string; repo: string; ref: string; dir: string | null }; - components: iPolloWorkClaudePluginComponent[]; + components: iPolloWorkGitHubPluginComponent[]; warnings: string[]; }; @@ -1503,17 +1462,6 @@ export function createiPolloWorkServerClient(options: { baseUrl: string; token?: timeoutMs: timeouts.config, }, ), - migrateRuntimeConfig: (workspaceId: string) => - requestJson( - baseUrl, - `/workspace/${encodeURIComponent(workspaceId)}/runtime-config/migrate`, - { - token, - hostToken, - method: "POST", - timeoutMs: timeouts.config, - }, - ), getRuntimeConfigStatus: (workspaceId: string) => requestJson( baseUrl, @@ -1541,27 +1489,6 @@ export function createiPolloWorkServerClient(options: { baseUrl: string; token?: body: { snapshot }, timeoutMs: timeouts.config, }), - listCloudPlugins: (workspaceId: string) => - requestJson(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/cloud-plugins`, { - token, - hostToken, - timeoutMs: timeouts.config, - }), - installCloudPlugin: (workspaceId: string, payload: { marketplaceId: string | null; marketplace?: DenOrgMarketplace | null; resolved: DenOrgPluginResolved }) => - requestJson(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/cloud-plugins`, { - token, - hostToken, - method: "POST", - body: payload, - timeoutMs: timeouts.config, - }), - removeCloudPlugin: (workspaceId: string, pluginId: string) => - requestJson(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/cloud-plugins/${encodeURIComponent(pluginId)}`, { - token, - hostToken, - method: "DELETE", - timeoutMs: timeouts.config, - }), listPluginPackages: (workspaceId: string) => requestJson<{ items: iPolloWorkPluginPackageItem[] }>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/plugin-packages`, { token, @@ -1704,16 +1631,21 @@ export function createiPolloWorkServerClient(options: { baseUrl: string; token?: method: "DELETE", timeoutMs: timeouts.config, }), - previewClaudePlugin: (workspaceId: string, payload: { url: string; ref?: string }) => - requestJson<{ preview: iPolloWorkClaudePluginPreview }>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/claude-plugins`, { + previewGithubPluginPackage: (workspaceId: string, payload: { url: string; ref?: string }) => + requestJson<{ preview: iPolloWorkPluginPackagePreview; source: iPolloWorkGitHubPluginPreview }>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/plugin-packages/import/github`, { token, hostToken, method: "POST", body: { ...payload, dryRun: true }, timeoutMs: timeouts.config, }), - installClaudePlugin: (workspaceId: string, payload: { url: string; ref?: string }) => - requestJson(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/claude-plugins`, { + importGithubPluginPackage: (workspaceId: string, payload: { url: string; ref?: string }) => + requestJson<{ + result: { status: "installed" | "updated" | "unchanged"; pluginId: string; version: string; previousVersion?: string }; + item?: iPolloWorkPluginPackageItem; + safety: iPolloWorkPluginPackageImportSafety; + source: iPolloWorkGitHubPluginPreview; + }>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/plugin-packages/import/github`, { token, hostToken, method: "POST", @@ -1749,26 +1681,6 @@ export function createiPolloWorkServerClient(options: { baseUrl: string; token?: method: "POST", timeoutMs: ENGINE_RELOAD_TIMEOUT_MS, }), - listPlugins: (workspaceId: string, options?: { includeGlobal?: boolean }) => { - const query = options?.includeGlobal ? "?includeGlobal=true" : ""; - return requestJson<{ items: iPolloWorkPluginItem[]; loadOrder: string[] }>( - baseUrl, - `/workspace/${workspaceId}/plugins${query}`, - { token, hostToken }, - ); - }, - addPlugin: (workspaceId: string, spec: string) => - requestJson<{ items: iPolloWorkPluginItem[]; loadOrder: string[] }>( - baseUrl, - `/workspace/${workspaceId}/plugins`, - { token, hostToken, method: "POST", body: { spec } }, - ), - removePlugin: (workspaceId: string, name: string) => - requestJson<{ items: iPolloWorkPluginItem[]; loadOrder: string[] }>( - baseUrl, - `/workspace/${workspaceId}/plugins/${encodeURIComponent(name)}`, - { token, hostToken, method: "DELETE" }, - ), listSkills: (workspaceId: string, options?: { includeGlobal?: boolean }) => { const query = options?.includeGlobal ? "?includeGlobal=true" : ""; return requestJson<{ items: iPolloWorkSkillItem[] }>( diff --git a/apps/app/src/app/types.ts b/apps/app/src/app/types.ts index 4657cc706..6bae04814 100644 --- a/apps/app/src/app/types.ts +++ b/apps/app/src/app/types.ts @@ -327,27 +327,6 @@ export type DenOrgSkillCard = { updatedAt: string | null; }; -export type PluginInstallStep = { - title: string; - description: string; - command?: string; - url?: string; - path?: string; - note?: string; -}; - -export type SuggestedPlugin = { - name: string; - packageName: string; - description: string; - tags: string[]; - aliases?: string[]; - installMode?: "simple" | "guided"; - steps?: PluginInstallStep[]; -}; - -export type PluginScope = "project" | "global"; - export type McpServerSource = "config.project" | "config.global" | "config.remote"; export type McpServerConfig = { @@ -451,12 +430,6 @@ export type WorkspaceState = { root: string; }; -export type PluginState = { - scope: PluginScope; - config: OpencodeConfigFile | null; - list: string[]; -}; - export type WorkspaceDisplay = WorkspaceInfo & { name: string; }; diff --git a/apps/app/src/app/utils/plugins.ts b/apps/app/src/app/utils/plugins.ts deleted file mode 100644 index 5669efa55..000000000 --- a/apps/app/src/app/utils/plugins.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { parse } from "jsonc-parser"; - -import type { OpencodeConfigFile } from "../lib/desktop"; - -type PluginListValue = string | string[] | null | undefined; - -type PluginConfig = { - content: string | null; -} | null; - -export function normalizePluginList(value: PluginListValue) { - if (!value) return [] as string[]; - if (Array.isArray(value)) { - return value.flatMap((entry) => { - const trimmed = typeof entry === "string" ? entry.trim() : ""; - return trimmed ? [trimmed] : []; - }); - } - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed ? [trimmed] : []; - } - return [] as string[]; -} - -export function stripPluginVersion(spec: string) { - const trimmed = spec.trim(); - if (!trimmed) return ""; - - const looksLikeVersion = (suffix: string) => - /^(latest|next|beta|alpha|canary|rc|stable|\d)/i.test(suffix); - - if (trimmed.startsWith("@")) { - const slashIndex = trimmed.indexOf("/"); - if (slashIndex === -1) return trimmed; - - const atIndex = trimmed.indexOf("@", slashIndex + 1); - if (atIndex === -1) return trimmed; - - const suffix = trimmed.slice(atIndex + 1); - return looksLikeVersion(suffix) ? trimmed.slice(0, atIndex) : trimmed; - } - - const atIndex = trimmed.indexOf("@"); - if (atIndex === -1) return trimmed; - - const suffix = trimmed.slice(atIndex + 1); - return looksLikeVersion(suffix) ? trimmed.slice(0, atIndex) : trimmed; -} - -export function isPluginInstalled(pluginList: string[], pluginName: string, aliases: string[] = []) { - const normalized = pluginList.flatMap((entry) => { - const raw = entry.toLowerCase(); - const stripped = stripPluginVersion(entry).toLowerCase(); - return stripped && stripped !== raw ? [raw, stripped] : [raw]; - }); - - const list = new Set(normalized); - return [pluginName, ...aliases].some((entry) => list.has(entry.toLowerCase())); -} - -export function loadPluginsFromConfig( - config: PluginConfig, - onList: (next: string[]) => void, - onError: (message: string) => void, -) { - if (!config?.content) { - onList([]); - return; - } - - try { - const parsed = parse(config.content) as Record | undefined; - const next = normalizePluginList(parsed?.plugin as PluginListValue); - onList(next); - } catch (e) { - onList([]); - onError(e instanceof Error ? e.message : "Failed to parse opencode.json"); - } -} - -export function parsePluginListFromContent(content: string) { - try { - const parsed = parse(content) as Record | undefined; - return normalizePluginList(parsed?.plugin as PluginListValue); - } catch { - return [] as string[]; - } -} diff --git a/apps/app/src/i18n/locales/ca.ts b/apps/app/src/i18n/locales/ca.ts index 54960e564..2ff84688f 100644 --- a/apps/app/src/i18n/locales/ca.ts +++ b/apps/app/src/i18n/locales/ca.ts @@ -362,7 +362,7 @@ export default { "extensions.plugin_count_one": "{count} plugin", "extensions.plugin_count_other": "{count} plugins", "extensions.plugins_opencode_header": "Plugins (OpenCode)", - "extensions.subtitle": "Les apps (MCP) i els Plugins d'OpenCode són al mateix lloc.", + "extensions.subtitle": "Les aplicacions i els paquets de connectors es gestionen en un sol lloc.", "extensions.title": "Extensions", "mcp.add_modal_subtitle": "Connecta un servidor MCP personalitzat mitjançant una URL o una comanda local.", "mcp.add_modal_title": "Afegeix una aplicació personalitzada", @@ -511,25 +511,6 @@ export default { "model_picker.other_connected_models": "Altres models connectats", "model_picker.recommended": "Recomanat", "onboarding.authorize_folder": "Carpeta d'autorització", - "plugins.add": "Afegeix", - "plugins.add_hint": "Afegeix noms de paquets npm, p. ex. opencode-wakatime", - "plugins.add_label": "Afegeix un Plugin", - "plugins.added": "Afegit", - "plugins.config": "Config", - "plugins.config_label": "Config", - "plugins.desc": "Gestiona `opencode.json` per al vostre projecte o OpenCode plugins global.", - "plugins.empty": "Encara no s'ha configurat cap Plugin.", - "plugins.enabled": "Habilitat", - "plugins.hide_setup": "Amaga la configuració", - "plugins.not_loaded": "Encara no s'ha carregat", - "plugins.not_loaded_yet": "Encara no s'ha carregat", - "plugins.remove": "Eliminar", - "plugins.scope_global": "Global", - "plugins.scope_project": "Projecte", - "plugins.setup": "Configuració", - "plugins.suggested": "Connectors suggerits", - "plugins.suggested_heading": "Connectors suggerits", - "plugins.title": "OpenCode plugins", "providers.api_key_label": "Tecla API", "providers.api_key_required": "La clau API és necessària", "providers.auth_failed": "L'autenticació ha fallat", @@ -690,7 +671,6 @@ export default { "settings.cap_mcp": "MCP: {value}", "settings.cap_outbox_off": "bústia de sortida desactivada", "settings.cap_outbox_on": "bústia de sortida activada", - "settings.cap_plugins": "Plugins: {value}", "settings.cap_read": "llegir", "settings.cap_sandbox": "Sandbox: {value}", "settings.cap_skills": "Skills: {value}", @@ -956,7 +936,7 @@ export default { "settings.tab_description_cloud_marketplaces": "Browse and import plugins from your organization's marketplaces.", "settings.tab_description_cloud_providers": "Import and manage LLM provider keys from your organization.", "settings.tab_description_environment": "Save API keys and tokens for local agents, skills, and MCP servers. Secrets stay on this device.", - "settings.tab_description_extensions": "Gestiona apps MCP i Plugins d'OpenCode per a aquest workspace.", + "settings.tab_description_extensions": "Gestiona paquets globals de connectors i habilitats; instal·la'ls una vegada i fes-los servir en tots els projectes.", "settings.tab_description_general": "Connecta proveïdors, tria el model predeterminat, autoritza carpetes i controla el workspace iPolloWork seleccionat juntament amb la seva connexió de runtime.", "settings.tab_description_recovery": "Repara l'estat de migració, restableix els valors predeterminats del workspace i recupera la configuració local.", "settings.tab_description_skills": "Explora, edita i instal·la Skills sense sortir de la configuració.", diff --git a/apps/app/src/i18n/locales/en.ts b/apps/app/src/i18n/locales/en.ts index e4e834098..d54951c6f 100644 --- a/apps/app/src/i18n/locales/en.ts +++ b/apps/app/src/i18n/locales/en.ts @@ -29,7 +29,7 @@ export default { "plugin_platform.enable": "Enable", "plugin_platform.rollback": "Roll back", "plugin_platform.uninstall": "Uninstall plugin", - "plugin_platform.uninstall_description": "Remove this plugin, its installed resources, connection, and saved authorization from this workspace.", + "plugin_platform.uninstall_description": "Remove this plugin globally from iPolloWork, including all installed resources, connections, and saved authorization.", "plugin_platform.revoke": "Revoke", "plugin_platform.configure": "Configure authorization", "plugin_platform.connect": "Save and connect", @@ -54,6 +54,10 @@ export default { "plugin_platform.import_button": "Import plugin", "plugin_platform.import_title": "Import a complete plugin package", "plugin_platform.import_description": "Choose an iPolloWork plugin archive from a developer. Its structure, capabilities, and safety boundary are checked before installation.", + "plugin_platform.import_file": "File", + "plugin_platform.import_github_title": "Import from GitHub", + "plugin_platform.import_github_description": "Paste a GitHub repository containing a compatible plugin. It will be converted to the standard iPolloWork plugin package format before installation.", + "plugin_platform.import_preview": "Preview", "plugin_platform.import_choose_title": "Choose a plugin archive", "plugin_platform.import_choose_description": "Supports .zip or .ipollowork-plugin with ipollowork.plugin.json inside.", "plugin_platform.import_choose": "Choose file", @@ -103,10 +107,10 @@ export default { "plugin_library.open_plugin": "Open {name}", "plugin_library.source_label": "Plugin source", "plugin_library.marketplace": "Public", - "plugin_library.marketplace_description": "Discover reviewed capability packages and install them in this workspace.", + "plugin_library.marketplace_description": "Discover reviewed capability packages. Install once and use them in every project.", "plugin_library.personal": "Personal", "plugin_library.personal_title": "Personal plugins", - "plugin_library.personal_description": "Manage complete plugin packages installed or imported into this workspace.", + "plugin_library.personal_description": "Manage globally installed or imported plugins. Every project shares them, and the current engine loads compatible capabilities automatically.", "plugin_library.personal_empty": "No personal plugins yet.", "plugin_library.featured": "Featured", "plugin_library.all_categories": "All categories", @@ -966,24 +970,6 @@ export default { "den.remote_worker_help_feedback_prefix": "Use the in-app", "den.remote_worker_help_feedback_suffix": "button to send us a note, and we'll pick it up from there.", "den.remote_worker_help_email_support": "Email support", - "claude_plugin.component_mcp_one": "{count} MCP server", - "claude_plugin.component_mcp_other": "{count} MCP servers", - "claude_plugin.component_skill_one": "{count} Skill", - "claude_plugin.component_skill_other": "{count} Skills", - "claude_plugin.component_command_one": "{count} Command", - "claude_plugin.component_command_other": "{count} Commands", - "claude_plugin.component_agent_one": "{count} Agent", - "claude_plugin.component_agent_other": "{count} Agents", - "claude_plugin.invalid_url": "Enter a GitHub repository URL.", - "claude_plugin.load_preview_failed": "Failed to load plugin preview", - "claude_plugin.install_failed": "Failed to install plugin", - "claude_plugin.title": "Install a plugin from GitHub", - "claude_plugin.description": "Works with Claude Code plugins: a repo with .claude-plugin/plugin.json bundling an MCP server, skills, and commands.", - "claude_plugin.repository": "GitHub repository", - "claude_plugin.preview": "Preview", - "claude_plugin.will_install": "Will install", - "claude_plugin.cancel": "Cancel", - "claude_plugin.install": "Install", "composer.agent_label": "Agent", "composer.agent_selected": "Agent set to {agent}.", "composer.agent_selected_default": "Agent reset to default.", @@ -1369,7 +1355,7 @@ export default { "extensions.plugins_opencode_header": "Plugins (OpenCode)", "extensions.remove_from_workspace_button": "Remove from workspace", "extensions.removed_upstream_notice": "{name} is no longer available in your organization's marketplace.", - "extensions.subtitle": "Apps (MCP) and OpenCode plugins live in one place.", + "extensions.subtitle": "Apps and plugin packages live in one place.", "extensions.title": "Extensions", "extensions.update_all_button": "Update all", "extensions.update_all_progress": "Updating {current} of {total}...", @@ -1655,25 +1641,6 @@ export default { "welcome.folder_write": "Create and edit files for you", "welcome.folder_anything": "Work with spreadsheets, docs, images \u2014 anything in the folder", "welcome.folder_drop_hint": "Drop files in anytime and iPolloWork can pick them up.", - "plugins.add": "Add", - "plugins.add_hint": "Add npm package names, e.g. opencode-wakatime", - "plugins.add_label": "Add plugin", - "plugins.added": "Added", - "plugins.config": "Config", - "plugins.config_label": "Config", - "plugins.desc": "Manage `opencode.json` for your project or global OpenCode plugins.", - "plugins.empty": "No plugins configured yet.", - "plugins.enabled": "Enabled", - "plugins.hide_setup": "Hide setup", - "plugins.not_loaded": "Not loaded yet", - "plugins.not_loaded_yet": "Not loaded yet", - "plugins.remove": "Remove", - "plugins.scope_global": "Global", - "plugins.scope_project": "Project", - "plugins.setup": "Setup", - "plugins.suggested": "Suggested plugins", - "plugins.suggested_heading": "Suggested plugins", - "plugins.title": "OpenCode plugins", "providers.api_key_label": "API key", "providers.api_key_required": "API key is required", "providers.auth_failed": "Authentication failed", @@ -1919,7 +1886,6 @@ export default { "settings.cap_mcp": "MCP: {value}", "settings.cap_outbox_off": "outbox off", "settings.cap_outbox_on": "outbox on", - "settings.cap_plugins": "Plugins: {value}", "settings.cap_read": "read", "settings.cap_read_only": "read only", "settings.cap_read_write": "read/write", @@ -2212,7 +2178,7 @@ export default { "settings.tab_description_cloud_marketplaces": "Browse complete plugin capability packages from iPolloWork Cloud.", "settings.tab_description_cloud_providers": "Import and manage LLM provider keys from your organization.", "settings.tab_description_debug": "Review runtime diagnostics, logs, and low-level debugging utilities.", - "settings.tab_description_extensions": "Manage complete plugin capability packages and skills for this workspace.", + "settings.tab_description_extensions": "Manage global plugin capability packages and skills. Install once and use them in every project.", "settings.tab_description_general": "Connect providers, choose the default model, authorize folders, and control the selected iPolloWork workspace plus its runtime connection.", "settings.environment.add_button": "Add variable", "settings.environment.add_title": "Add environment variable", @@ -2801,7 +2767,7 @@ export default { "settings.cloud.search_skill": "Search for a skill.", "settings.cloud.no_skills_match": "No skills match your search.", "settings.cloud.marketplaces_title": "Marketplaces & Plugins", - "settings.cloud.marketplaces_description": "Browse organization marketplaces and import plugin files into this workspace.", + "settings.cloud.marketplaces_description": "Browse organization marketplaces and import plugin files into the global plugin list.", "settings.cloud.no_marketplaces": "No marketplaces are available yet.", "settings.cloud.choose_org_for_marketplaces": "Choose an organization to view marketplaces.", "settings.cloud.search_plugin": "Search for a plugin.", @@ -2889,7 +2855,6 @@ export default { "settings.ai.subscribe": "Subscribe", "settings.ai.not_connected": "Not connected", "settings.ai.models_connect_description": "Hosted frontier models without managing API keys.", - "settings.extensions.opencode_plugins": "OpenCode Plugins", "settings.marketplace.signin_title": "Sign in to browse the Plugin Marketplace", "settings.marketplace.signin_hint": "Marketplace content comes from your iPolloWork Cloud account. Sign in to browse, purchase, and install complete plugin capability packages.", "settings.marketplace.loading": "Loading the Plugin Marketplace…", @@ -2948,34 +2913,22 @@ export default { "settings.runtime_config.not_found": "Not found", "settings.runtime_config.keys": "Keys: {keys}", "settings.runtime_config.show_raw_json": "Show raw JSON", - "settings.runtime_migration.title": "OpenCode config sources", - "settings.runtime_migration.description": "Inspect what iPolloWork controls at runtime versus what belongs to your workspace config. This works through the iPolloWork server and does not require the OpenCode engine to be healthy.", - "settings.runtime_migration.move_title": "Move iPolloWork-managed config", - "settings.runtime_migration.move_description": "Moves older iPolloWork-owned runtime keys from .opencode/ipollowork.json and safe iPolloWork-managed keys from opencode.jsonc into the runtime database.", - "settings.runtime_migration.refresh": "Refresh", - "settings.runtime_migration.migrating": "Migrating…", - "settings.runtime_migration.migrate": "Migrate", - "settings.runtime_migration.effective_title": "Effective injected OpenCode config", - "settings.runtime_migration.effective_description": "This is the iPolloWork-built config object injected through the server-managed OPENCODE_CONFIG file. It includes iPolloWork defaults plus runtime DB values and is rewritten on every runtime config change.", - "settings.runtime_migration.show_injected_json": "Show raw injected JSON", - "settings.runtime_migration.sources_title": "OpenCode source breakdown", - "settings.runtime_migration.sources_description": "OpenCode also reads its own project and global config files. iPolloWork injects the runtime config separately; for iPolloWork-managed keys, the injected config is the source to inspect.", - "settings.runtime_migration.project_config_title": "Project OpenCode config", - "settings.runtime_migration.project_config_description": "Workspace-level OpenCode config owned by the user or project.", - "settings.runtime_migration.global_config_title": "Global OpenCode config", - "settings.runtime_migration.global_config_description": "User-level OpenCode config under ~/.config/opencode.", - "settings.runtime_migration.runtime_db_title": "iPolloWork runtime database", - "settings.runtime_migration.runtime_db_description": "iPolloWork-managed runtime values stored outside workspace files.", - "settings.runtime_migration.injected_config_title": "iPolloWork injected config", - "settings.runtime_migration.injected_config_description": "The object iPolloWork injects into OpenCode at runtime.", - "settings.runtime_migration.runtime_database": "Runtime database", - "settings.runtime_migration.stored_keys": "Stored keys: {keys}", - "settings.runtime_migration.legacy_metadata": "Legacy iPolloWork metadata", - "settings.runtime_migration.legacy_error": "{error}; fix this file before moving legacy config.", - "settings.runtime_migration.migratable_keys": "Migratable keys: {keys}", - "settings.runtime_migration.user_opencode_config": "User opencode.jsonc", - "settings.runtime_migration.user_owned_keys": "User-owned keys: {keys}", - "settings.runtime_migration.runtime_db_json": "Runtime DB JSON", + "settings.runtime_config_sources.title": "OpenCode config sources", + "settings.runtime_config_sources.description": "Inspect what iPolloWork controls at runtime versus what belongs to your workspace config. This works through the iPolloWork server and does not require the OpenCode engine to be healthy.", + "settings.runtime_config_sources.refresh": "Refresh", + "settings.runtime_config_sources.effective_title": "Effective injected OpenCode config", + "settings.runtime_config_sources.effective_description": "This is the iPolloWork-built config object injected through the server-managed OPENCODE_CONFIG file. It includes iPolloWork defaults plus runtime DB values and is rewritten on every runtime config change.", + "settings.runtime_config_sources.show_injected_json": "Show raw injected JSON", + "settings.runtime_config_sources.sources_title": "OpenCode source breakdown", + "settings.runtime_config_sources.sources_description": "OpenCode also reads its own project and global config files. iPolloWork injects the runtime config separately; for iPolloWork-managed keys, the injected config is the source to inspect.", + "settings.runtime_config_sources.project_config_title": "Project OpenCode config", + "settings.runtime_config_sources.project_config_description": "Workspace-level OpenCode config owned by the user or project.", + "settings.runtime_config_sources.global_config_title": "Global OpenCode config", + "settings.runtime_config_sources.global_config_description": "User-level OpenCode config under ~/.config/opencode.", + "settings.runtime_config_sources.runtime_db_title": "iPolloWork runtime database", + "settings.runtime_config_sources.runtime_db_description": "iPolloWork-managed runtime values stored outside workspace files.", + "settings.runtime_config_sources.injected_config_title": "iPolloWork injected config", + "settings.runtime_config_sources.injected_config_description": "The object iPolloWork injects into OpenCode at runtime.", "settings.feature_flags.title": "Feature flags", "settings.feature_flags.description": "Experimental controls for sandbox and workspace behaviors.", "settings.feature_flags.microsandbox_title": "Create Sandbox uses microsandbox image", diff --git a/apps/app/src/i18n/locales/es.ts b/apps/app/src/i18n/locales/es.ts index b084ea3fe..291e0fb32 100644 --- a/apps/app/src/i18n/locales/es.ts +++ b/apps/app/src/i18n/locales/es.ts @@ -362,7 +362,7 @@ export default { "extensions.plugin_count_one": "Plugin {count}", "extensions.plugin_count_other": "Plugins {count}", "extensions.plugins_opencode_header": "Plugins (OpenCode)", - "extensions.subtitle": "Las aplicaciones (MCP) y los Plugins OpenCode se encuentran en un solo lugar.", + "extensions.subtitle": "Las aplicaciones y los paquetes de plugins se gestionan en un solo lugar.", "extensions.title": "Extensiones", "mcp.add_modal_subtitle": "Conecta un servidor MCP personalizado mediante URL o un Command local.", "mcp.add_modal_title": "Añadir aplicación personalizada", @@ -511,25 +511,6 @@ export default { "model_picker.other_connected_models": "Otros modelos conectados", "model_picker.recommended": "Recomendado", "onboarding.authorize_folder": "Autorizar carpeta", - "plugins.add": "Añadir", - "plugins.add_hint": "Añade nombres de paquetes npm, por ejemplo, opencode-wakatime", - "plugins.add_label": "Añadir Plugin", - "plugins.added": "Añadido", - "plugins.config": "Configuración", - "plugins.config_label": "Configuración", - "plugins.desc": "Gestiona `opencode.json` para tu proyecto o los Plugins globales de OpenCode.", - "plugins.empty": "Aún no hay Plugins configurados.", - "plugins.enabled": "Activado", - "plugins.hide_setup": "Ocultar configuración", - "plugins.not_loaded": "Aún no cargado", - "plugins.not_loaded_yet": "Aún no cargado", - "plugins.remove": "Eliminar", - "plugins.scope_global": "Global", - "plugins.scope_project": "Proyecto", - "plugins.setup": "Configuración", - "plugins.suggested": "Plugins sugeridos", - "plugins.suggested_heading": "Plugins sugeridos", - "plugins.title": "Plugins OpenCode", "providers.api_key_label": "Clave API", "providers.api_key_required": "Hace falta una clave API", "providers.auth_failed": "Error de autenticación", @@ -690,7 +671,6 @@ export default { "settings.cap_mcp": "MCP: {value}", "settings.cap_outbox_off": "bandeja de salida desactivada", "settings.cap_outbox_on": "bandeja de salida activada", - "settings.cap_plugins": "Plugins: {value}", "settings.cap_read": "leer", "settings.cap_sandbox": "Sandbox: {value}", "settings.cap_skills": "Skills: {value}", @@ -956,7 +936,7 @@ export default { "settings.tab_description_cloud_marketplaces": "Browse and import plugins from your organization's marketplaces.", "settings.tab_description_cloud_providers": "Import and manage LLM provider keys from your organization.", "settings.tab_description_environment": "Save API keys and tokens for local agents, skills, and MCP servers. Secrets stay on this device.", - "settings.tab_description_extensions": "Gestiona aplicaciones MCP y Plugins de OpenCode para este espacio de trabajo.", + "settings.tab_description_extensions": "Gestiona paquetes globales de plugins y habilidades; instálalos una vez y úsalos en todos los proyectos.", "settings.tab_description_general": "Conecta proveedores, elige el modelo predeterminado, autoriza carpetas y controla el espacio de trabajo seleccionado junto con su conexión de runtime.", "settings.tab_description_recovery": "Repara el estado de migración, restablece los valores predeterminados del espacio de trabajo y recupera la configuración local.", "settings.tab_description_skills": "Explora, edita e instala Skills sin salir de la configuración.", diff --git a/apps/app/src/i18n/locales/fr.ts b/apps/app/src/i18n/locales/fr.ts index 73c29c72f..706c4209f 100644 --- a/apps/app/src/i18n/locales/fr.ts +++ b/apps/app/src/i18n/locales/fr.ts @@ -362,7 +362,7 @@ export default { "extensions.plugin_count_one": "{count} plugin", "extensions.plugin_count_other": "{count} plugins", "extensions.plugins_opencode_header": "Plugins (OpenCode)", - "extensions.subtitle": "Les applications (MCP) et les Plugins OpenCode se trouvent au même endroit.", + "extensions.subtitle": "Les applications et les paquets de plugins sont gérés au même endroit.", "extensions.title": "Extensions", "mcp.add_modal_subtitle": "Connectez un serveur MCP personnalisé par URL ou commande locale.", "mcp.add_modal_title": "Ajouter une application personnalisée", @@ -511,25 +511,6 @@ export default { "model_picker.other_connected_models": "Autres modèles connectés", "model_picker.recommended": "Recommandé", "onboarding.authorize_folder": "Autoriser le dossier", - "plugins.add": "Ajouter", - "plugins.add_hint": "Ajoutez des noms de packages npm, par ex. opencode-wakatime", - "plugins.add_label": "Ajouter un plugin", - "plugins.added": "Ajouté", - "plugins.config": "Config", - "plugins.config_label": "Config", - "plugins.desc": "Gérez `opencode.json` pour votre projet ou les Plugins OpenCode globaux.", - "plugins.empty": "Aucun Plugin configuré pour le moment.", - "plugins.enabled": "Activé", - "plugins.hide_setup": "Masquer la configuration", - "plugins.not_loaded": "Pas encore chargé", - "plugins.not_loaded_yet": "Pas encore chargé", - "plugins.remove": "Supprimer", - "plugins.scope_global": "Global", - "plugins.scope_project": "Projet", - "plugins.setup": "Configuration", - "plugins.suggested": "Plugins suggérés", - "plugins.suggested_heading": "Plugins suggérés", - "plugins.title": "Plugins OpenCode", "providers.api_key_label": "Clé API", "providers.api_key_required": "La clé API est requise", "providers.auth_failed": "Échec de l'authentification", @@ -690,7 +671,6 @@ export default { "settings.cap_mcp": "MCP : {value}", "settings.cap_outbox_off": "boîte d'envoi désactivée", "settings.cap_outbox_on": "boîte d'envoi activée", - "settings.cap_plugins": "Plugins : {value}", "settings.cap_read": "lecture", "settings.cap_sandbox": "Sandbox : {value}", "settings.cap_skills": "Skills : {value}", @@ -956,7 +936,7 @@ export default { "settings.tab_description_cloud_marketplaces": "Browse and import plugins from your organization's marketplaces.", "settings.tab_description_cloud_providers": "Import and manage LLM provider keys from your organization.", "settings.tab_description_environment": "Save API keys and tokens for local agents, skills, and MCP servers. Secrets stay on this device.", - "settings.tab_description_extensions": "Gérez les applications MCP et les Plugins OpenCode pour cet espace de travail.", + "settings.tab_description_extensions": "Gérez les paquets de plugins et les compétences au niveau global ; installez-les une fois pour tous les projets.", "settings.tab_description_general": "Connectez des fournisseurs, choisissez le modèle par défaut, autorisez des dossiers et contrôlez l'espace de travail iPolloWork sélectionné ainsi que sa connexion runtime.", "settings.tab_description_recovery": "Réparez l'état des migrations, réinitialisez les valeurs par défaut de l'espace de travail et récupérez les paramètres locaux.", "settings.tab_description_skills": "Parcourez, modifiez et installez des Skills sans quitter les paramètres.", diff --git a/apps/app/src/i18n/locales/ja.ts b/apps/app/src/i18n/locales/ja.ts index cc9423047..f150fa71d 100644 --- a/apps/app/src/i18n/locales/ja.ts +++ b/apps/app/src/i18n/locales/ja.ts @@ -338,7 +338,7 @@ export default { "extensions.filter_plugins": "プラグイン", "extensions.plugin_count": "{count}件のプラグイン", "extensions.plugins_opencode_header": "プラグイン(OpenCode)", - "extensions.subtitle": "アプリ(MCP)とOpenCodeプラグインをまとめて管理できます。", + "extensions.subtitle": "アプリとプラグインパッケージを一か所で管理できます。", "extensions.title": "拡張機能", "mcp.add_modal_subtitle": "URLまたはローカルコマンドでカスタムMCPサーバーを接続します。", "mcp.add_modal_title": "カスタムアプリを追加", @@ -486,25 +486,6 @@ export default { "model_picker.other_connected_models": "その他の接続済みモデル", "model_picker.recommended": "おすすめ", "onboarding.authorize_folder": "フォルダを承認", - "plugins.add": "追加", - "plugins.add_hint": "npmパッケージ名を追加してください(例: opencode-wakatime)", - "plugins.add_label": "プラグインを追加", - "plugins.added": "追加済み", - "plugins.config": "設定", - "plugins.config_label": "設定", - "plugins.desc": "プロジェクトまたはグローバルのOpenCodeプラグインの`opencode.json`を管理します。", - "plugins.empty": "まだプラグインが設定されていません。", - "plugins.enabled": "有効", - "plugins.hide_setup": "セットアップを隠す", - "plugins.not_loaded": "まだ読み込まれていません", - "plugins.not_loaded_yet": "まだ読み込まれていません", - "plugins.remove": "削除", - "plugins.scope_global": "グローバル", - "plugins.scope_project": "プロジェクト", - "plugins.setup": "セットアップ", - "plugins.suggested": "おすすめのプラグイン", - "plugins.suggested_heading": "おすすめプラグイン", - "plugins.title": "OpenCodeプラグイン", "providers.api_key_label": "APIキー", "providers.api_key_required": "APIキーが必要です", "providers.auth_failed": "認証に失敗しました", @@ -665,7 +646,6 @@ export default { "settings.cap_mcp": "MCP: {value}", "settings.cap_outbox_off": "アウトボックスオフ", "settings.cap_outbox_on": "アウトボックスオン", - "settings.cap_plugins": "プラグイン: {value}", "settings.cap_read": "読み取り", "settings.cap_sandbox": "サンドボックス: {value}", "settings.cap_skills": "スキル: {value}", @@ -930,7 +910,7 @@ export default { "settings.tab_description_cloud_account": "Sign in, manage your organization, and configure your cloud connection.", "settings.tab_description_cloud_marketplaces": "Browse and import plugins from your organization's marketplaces.", "settings.tab_description_cloud_providers": "Import and manage LLM provider keys from your organization.", - "settings.tab_description_extensions": "このワークスペースのMCPアプリとOpenCodeプラグインを管理します。", + "settings.tab_description_extensions": "グローバルなプラグインパッケージとスキルを管理します。一度インストールすれば、すべてのプロジェクトで使えます。", "settings.tab_description_general": "プロバイダーを接続し、デフォルトモデルを選択し、フォルダへのアクセスを許可し、iPolloWorkワークスペースとそのランタイム接続を管理します。", "settings.tab_description_environment": "ローカルのエージェント、skills、MCP servers が使う API キーやトークンを保存します。シークレットはこのデバイスに保持されます。", "settings.tab_description_recovery": "マイグレーション状態を修復し、ワークスペースのデフォルトをリセットし、ローカル設定を復元します。", diff --git a/apps/app/src/i18n/locales/pt-BR.ts b/apps/app/src/i18n/locales/pt-BR.ts index e0d9833fd..d7b84398f 100644 --- a/apps/app/src/i18n/locales/pt-BR.ts +++ b/apps/app/src/i18n/locales/pt-BR.ts @@ -344,7 +344,7 @@ export default { "extensions.plugin_count_one": "{count} plugin", "extensions.plugin_count_other": "{count} plugins", "extensions.plugins_opencode_header": "Plugins (OpenCode)", - "extensions.subtitle": "Apps (MCP) e plugins OpenCode ficam em um só lugar.", + "extensions.subtitle": "Apps e pacotes de plugins ficam em um só lugar.", "extensions.title": "Extensões", "mcp.add_modal_subtitle": "Conecte um servidor MCP personalizado por URL ou comando local.", "mcp.add_modal_title": "Adicionar App Personalizado", @@ -493,25 +493,6 @@ export default { "model_picker.other_connected_models": "Outros modelos conectados", "model_picker.recommended": "Recomendado", "onboarding.authorize_folder": "Autorizar pasta", - "plugins.add": "Adicionar", - "plugins.add_hint": "Adicione nomes de pacotes npm, ex: opencode-wakatime", - "plugins.add_label": "Adicionar plugin", - "plugins.added": "Adicionado", - "plugins.config": "Config", - "plugins.config_label": "Config", - "plugins.desc": "Gerenciar `opencode.json` para plugins do projeto ou globais do OpenCode.", - "plugins.empty": "Nenhum plugin configurado ainda.", - "plugins.enabled": "Ativado", - "plugins.hide_setup": "Ocultar configuração", - "plugins.not_loaded": "Ainda não carregado", - "plugins.not_loaded_yet": "Ainda não carregado", - "plugins.remove": "Remover", - "plugins.scope_global": "Global", - "plugins.scope_project": "Projeto", - "plugins.setup": "Configurar", - "plugins.suggested": "Plugins sugeridos", - "plugins.suggested_heading": "Plugins sugeridos", - "plugins.title": "Plugins OpenCode", "providers.api_key_label": "Chave de API", "providers.api_key_required": "A chave de API é obrigatória", "providers.auth_failed": "Falha na autenticação", @@ -672,7 +653,6 @@ export default { "settings.cap_mcp": "MCP: {value}", "settings.cap_outbox_off": "outbox desativado", "settings.cap_outbox_on": "outbox ativado", - "settings.cap_plugins": "Plugins: {value}", "settings.cap_read": "leitura", "settings.cap_sandbox": "Sandbox: {value}", "settings.cap_skills": "Skills: {value}", @@ -938,7 +918,7 @@ export default { "settings.tab_description_cloud_marketplaces": "Browse and import plugins from your organization's marketplaces.", "settings.tab_description_cloud_providers": "Import and manage LLM provider keys from your organization.", "settings.tab_description_environment": "Save API keys and tokens for local agents, skills, and MCP servers. Secrets stay on this device.", - "settings.tab_description_extensions": "Gerencie apps MCP e plugins OpenCode para este workspace.", + "settings.tab_description_extensions": "Gerencie pacotes globais de plugins e habilidades; instale uma vez e use em todos os projetos.", "settings.tab_description_general": "Conecte provedores, escolha o modelo padrão, autorize pastas e controle o workspace iPolloWork selecionado e sua conexão de runtime.", "settings.tab_description_recovery": "Repare estado de migração, redefina padrões do workspace e recupere configurações locais.", "settings.tab_description_skills": "Navegue, edite e instale skills sem sair das configurações.", diff --git a/apps/app/src/i18n/locales/ru.ts b/apps/app/src/i18n/locales/ru.ts index cc6dc9cda..f53da40ee 100644 --- a/apps/app/src/i18n/locales/ru.ts +++ b/apps/app/src/i18n/locales/ru.ts @@ -412,25 +412,6 @@ export default { "model_picker.other_connected_models": "Другие подключенные модели", "model_picker.recommended": "Рекомендуется", "onboarding.authorize_folder": "Авторизовать папку", - "plugins.add": "Добавить", - "plugins.add_hint": "Введите названия npm-пакетов, например opencode-wakatime", - "plugins.add_label": "Добавить plugin", - "plugins.added": "Добавлено", - "plugins.config": "Конфиг", - "plugins.config_label": "Конфиг", - "plugins.desc": "Управляйте `opencode.json` для плагинов OpenCode вашего проекта или глобальных плагинов OpenCode.", - "plugins.empty": "Пока не настроено ни одного плагина.", - "plugins.enabled": "Включено", - "plugins.hide_setup": "Скрыть настройку", - "plugins.not_loaded": "Еще не загружено", - "plugins.not_loaded_yet": "Еще не загружено", - "plugins.remove": "Удалить", - "plugins.scope_global": "Глобально", - "plugins.scope_project": "Проект", - "plugins.setup": "Настройка", - "plugins.suggested": "Предложенные Plugins", - "plugins.suggested_heading": "Предложенные Plugins", - "plugins.title": "Плагины OpenCode", "providers.api_key_label": "API key", "providers.api_key_required": "Требуется API key", "providers.auth_failed": "Ошибка аутентификации", @@ -843,7 +824,6 @@ export default { "settings.cap_mcp": "MCP: {value}", "settings.cap_outbox_off": "исходящие выкл.", "settings.cap_outbox_on": "исходящие вкл.", - "settings.cap_plugins": "Плагины: {value}", "settings.cap_read": "чтение", "settings.cap_read_only": "только чтение", "settings.cap_read_write": "чтение/запись", @@ -1106,7 +1086,7 @@ export default { "settings.tab_description_cloud_account": "Sign in, manage your organization, and configure your cloud connection.", "settings.tab_description_cloud_marketplaces": "Browse and import plugins from your organization's marketplaces.", "settings.tab_description_cloud_providers": "Import and manage LLM provider keys from your organization.", - "settings.tab_description_extensions": "Управляйте MCP apps и OpenCode plugins для этого workspace.", + "settings.tab_description_extensions": "Управляйте глобальными пакетами плагинов и навыками: установите один раз и используйте во всех проектах.", "settings.tab_description_general": "Подключайте провайдеров, выбирайте модель по умолчанию, авторизуйте папки и управляйте выбранным workspace iPolloWork вместе с его подключением к среде выполнения.", "settings.environment.add_button": "Добавить переменную", "settings.environment.add_title": "Добавить переменную окружения", @@ -1309,6 +1289,6 @@ export default { "extensions.plugin_count_many": "{count} плагинов", "extensions.plugin_count_other": "{count} плагинов", "extensions.plugins_opencode_header": "Плагины (OpenCode)", - "extensions.subtitle": "Приложения (MCP) и плагины OpenCode собраны в одном месте.", + "extensions.subtitle": "Приложения и пакеты плагинов собраны в одном месте.", "extensions.title": "Расширения", } as const; diff --git a/apps/app/src/i18n/locales/th.ts b/apps/app/src/i18n/locales/th.ts index 93f737d92..018e3e75f 100644 --- a/apps/app/src/i18n/locales/th.ts +++ b/apps/app/src/i18n/locales/th.ts @@ -339,7 +339,7 @@ export default { "extensions.filter_plugins": "Plugins", "extensions.plugin_count": "{count} ปลั๊กอิน", "extensions.plugins_opencode_header": "Plugins (OpenCode)", - "extensions.subtitle": "แอป (MCP) และ OpenCode plugins อยู่ในที่เดียว", + "extensions.subtitle": "จัดการแอปและแพ็กเกจปลั๊กอินไว้ในที่เดียว", "extensions.title": "ส่วนขยาย", "mcp.add_modal_subtitle": "เชื่อมต่อ MCP server กำหนดเองด้วย URL หรือคำสั่งภายในเครื่อง", "mcp.add_modal_title": "เพิ่มแอปกำหนดเอง", @@ -487,25 +487,6 @@ export default { "model_picker.other_connected_models": "โมเดลที่เชื่อมต่ออื่นๆ", "model_picker.recommended": "แนะนำ", "onboarding.authorize_folder": "อนุญาตโฟลเดอร์", - "plugins.add": "เพิ่ม", - "plugins.add_hint": "เพิ่มชื่อแพ็กเกจ npm เช่น opencode-wakatime", - "plugins.add_label": "เพิ่ม plugin", - "plugins.added": "เพิ่มแล้ว", - "plugins.config": "การตั้งค่า", - "plugins.config_label": "การตั้งค่า", - "plugins.desc": "จัดการ `opencode.json` สำหรับโปรเจกต์หรือ OpenCode plugins ทั่วไป", - "plugins.empty": "ยังไม่มี plugins ที่ตั้งค่า", - "plugins.enabled": "เปิดใช้งาน", - "plugins.hide_setup": "ซ่อนการตั้งค่า", - "plugins.not_loaded": "ยังไม่ได้โหลด", - "plugins.not_loaded_yet": "ยังไม่ได้โหลด", - "plugins.remove": "ลบ", - "plugins.scope_global": "ทั่วไป", - "plugins.scope_project": "โปรเจกต์", - "plugins.setup": "ตั้งค่า", - "plugins.suggested": "Plugins ที่แนะนำ", - "plugins.suggested_heading": "Plugins ที่แนะนำ", - "plugins.title": "OpenCode plugins", "providers.api_key_label": "API key", "providers.api_key_required": "ต้องใส่ API key", "providers.auth_failed": "การยืนยันตัวตนล้มเหลว", @@ -666,7 +647,6 @@ export default { "settings.cap_mcp": "MCP: {value}", "settings.cap_outbox_off": "outbox ปิด", "settings.cap_outbox_on": "outbox เปิด", - "settings.cap_plugins": "Plugins: {value}", "settings.cap_read": "อ่าน", "settings.cap_sandbox": "Sandbox: {value}", "settings.cap_skills": "Skills: {value}", @@ -932,7 +912,7 @@ export default { "settings.tab_description_cloud_marketplaces": "Browse and import plugins from your organization's marketplaces.", "settings.tab_description_cloud_providers": "Import and manage LLM provider keys from your organization.", "settings.tab_description_environment": "Save API keys and tokens for local agents, skills, and MCP servers. Secrets stay on this device.", - "settings.tab_description_extensions": "จัดการแอป MCP และ OpenCode plugins สำหรับพื้นที่ทำงานนี้", + "settings.tab_description_extensions": "จัดการแพ็กเกจปลั๊กอินและสกิลส่วนกลาง ติดตั้งครั้งเดียวและใช้ได้กับทุกโปรเจกต์", "settings.tab_description_general": "เชื่อมต่อผู้ให้บริการ เลือกโมเดลเริ่มต้น อนุญาตโฟลเดอร์ และควบคุมพื้นที่ทำงาน iPolloWork ที่เลือกพร้อมการเชื่อมต่อรันไทม์", "settings.tab_description_recovery": "ซ่อมแซมสถานะ migration รีเซ็ตค่าเริ่มต้นพื้นที่ทำงาน และกู้คืนการตั้งค่าภายในเครื่อง", "settings.tab_description_skills": "เรียกดู แก้ไข และติดตั้ง skills โดยไม่ต้องออกจากการตั้งค่า", diff --git a/apps/app/src/i18n/locales/vi.ts b/apps/app/src/i18n/locales/vi.ts index 09cff8f0d..aa6267368 100644 --- a/apps/app/src/i18n/locales/vi.ts +++ b/apps/app/src/i18n/locales/vi.ts @@ -339,7 +339,7 @@ export default { "extensions.filter_plugins": "Plugins", "extensions.plugin_count": "{count} plugins", "extensions.plugins_opencode_header": "Plugins (OpenCode)", - "extensions.subtitle": "Ứng dụng (MCP) và OpenCode plugins gộp chung một nơi.", + "extensions.subtitle": "Quản lý ứng dụng và gói plugin tại một nơi.", "extensions.title": "Tiện ích mở rộng", "mcp.add_modal_subtitle": "Kết nối MCP server tùy chỉnh bằng URL hoặc lệnh nội bộ.", "mcp.add_modal_title": "Thêm ứng dụng tùy chỉnh", @@ -487,25 +487,6 @@ export default { "model_picker.other_connected_models": "Model đã kết nối khác", "model_picker.recommended": "Đề xuất", "onboarding.authorize_folder": "Cấp quyền thư mục", - "plugins.add": "Thêm", - "plugins.add_hint": "Thêm tên gói npm, ví dụ: opencode-wakatime", - "plugins.add_label": "Thêm plugin", - "plugins.added": "Đã thêm", - "plugins.config": "Cấu hình", - "plugins.config_label": "Cấu hình", - "plugins.desc": "Quản lý `opencode.json` cho dự án hoặc plugins OpenCode toàn cục.", - "plugins.empty": "Chưa cấu hình plugin nào.", - "plugins.enabled": "Đã bật", - "plugins.hide_setup": "Ẩn thiết lập", - "plugins.not_loaded": "Chưa tải", - "plugins.not_loaded_yet": "Chưa tải", - "plugins.remove": "Xóa", - "plugins.scope_global": "Toàn cục", - "plugins.scope_project": "Dự án", - "plugins.setup": "Thiết lập", - "plugins.suggested": "Plugins gợi ý", - "plugins.suggested_heading": "Plugins gợi ý", - "plugins.title": "OpenCode Plugins", "providers.api_key_label": "API key", "providers.api_key_required": "API key là bắt buộc", "providers.auth_failed": "Xác thực thất bại", @@ -666,7 +647,6 @@ export default { "settings.cap_mcp": "MCP: {value}", "settings.cap_outbox_off": "hộp đi tắt", "settings.cap_outbox_on": "hộp đi bật", - "settings.cap_plugins": "Plugins: {value}", "settings.cap_read": "đọc", "settings.cap_sandbox": "Sandbox: {value}", "settings.cap_skills": "Skills: {value}", @@ -932,7 +912,7 @@ export default { "settings.tab_description_cloud_marketplaces": "Browse and import plugins from your organization's marketplaces.", "settings.tab_description_cloud_providers": "Import and manage LLM provider keys from your organization.", "settings.tab_description_environment": "Save API keys and tokens for local agents, skills, and MCP servers. Secrets stay on this device.", - "settings.tab_description_extensions": "Quản lý ứng dụng MCP và plugins OpenCode cho workspace này.", + "settings.tab_description_extensions": "Quản lý gói plugin và kỹ năng dùng chung; cài một lần và dùng trong mọi dự án.", "settings.tab_description_general": "Kết nối provider, chọn model mặc định, cấp quyền thư mục và kiểm soát workspace iPolloWork cùng kết nối runtime.", "settings.tab_description_recovery": "Sửa trạng thái di chuyển, đặt lại mặc định workspace và khôi phục cài đặt cục bộ.", "settings.tab_description_skills": "Duyệt, chỉnh sửa và cài đặt skills mà không cần rời cài đặt.", diff --git a/apps/app/src/i18n/locales/zh.ts b/apps/app/src/i18n/locales/zh.ts index 45cce34b5..625ab2bc3 100644 --- a/apps/app/src/i18n/locales/zh.ts +++ b/apps/app/src/i18n/locales/zh.ts @@ -32,7 +32,7 @@ export default { "plugin_platform.enable": "启用", "plugin_platform.rollback": "回退版本", "plugin_platform.uninstall": "卸载插件", - "plugin_platform.uninstall_description": "从当前工作区移除此插件、已安装资源、连接和保存的授权。", + "plugin_platform.uninstall_description": "从 iPolloWork 全局移除此插件、全部已安装资源、连接和保存的授权。", "plugin_platform.revoke": "撤销授权", "plugin_platform.configure": "配置授权", "plugin_platform.connect": "保存并连接", @@ -57,6 +57,10 @@ export default { "plugin_platform.import_button": "导入插件", "plugin_platform.import_title": "导入完整插件包", "plugin_platform.import_description": "选择开发者提供的 iPolloWork 插件压缩包。安装前会检查结构、能力和安全边界。", + "plugin_platform.import_file": "文件", + "plugin_platform.import_github_title": "从 GitHub 导入", + "plugin_platform.import_github_description": "粘贴包含兼容插件的 GitHub 仓库地址。安装前会转换为标准 iPolloWork 插件包。", + "plugin_platform.import_preview": "预览", "plugin_platform.import_choose_title": "选择插件压缩包", "plugin_platform.import_choose_description": "支持 .zip 或 .ipollowork-plugin,包内需要包含 ipollowork.plugin.json。", "plugin_platform.import_choose": "选择文件", @@ -74,7 +78,7 @@ export default { "plugin_platform.error.load": "无法加载插件包。", "plugin_platform.error.operation": "插件操作失败。", "plugin_platform.error.conflict": "以下文件已存在且内容不同。为避免覆盖你的文件,插件没有安装:", - "plugin_platform.error.not_found": "这个插件已经不在当前工作区中。", + "plugin_platform.error.not_found": "这个插件已经不在全局插件列表中。", "plugin_platform.back_to_plugins": "返回插件列表", "plugin_platform.breadcrumb_plugins": "插件", "plugin_platform.apps": "应用", @@ -106,10 +110,10 @@ export default { "plugin_library.open_plugin": "打开{name}", "plugin_library.source_label": "插件来源", "plugin_library.marketplace": "公开", - "plugin_library.marketplace_description": "发现经过审核的完整能力包,按需安装到当前工作区。", + "plugin_library.marketplace_description": "发现经过审核的完整能力包;安装一次,即可在所有项目中使用。", "plugin_library.personal": "个人", "plugin_library.personal_title": "个人插件", - "plugin_library.personal_description": "管理当前工作区安装或导入的完整插件能力包。", + "plugin_library.personal_description": "管理全局安装或导入的插件;所有项目共享,当前引擎自动加载兼容能力。", "plugin_library.personal_empty": "还没有个人插件。", "plugin_library.featured": "精选", "plugin_library.all_categories": "全部分类", @@ -969,24 +973,6 @@ export default { "den.remote_worker_help_feedback_prefix": "使用应用内的", "den.remote_worker_help_feedback_suffix": "按钮给我们留言,我们会继续处理。", "den.remote_worker_help_email_support": "联系支持团队", - "claude_plugin.component_mcp_one": "{count} 个 MCP 服务器", - "claude_plugin.component_mcp_other": "{count} 个 MCP 服务器", - "claude_plugin.component_skill_one": "{count} 个技能", - "claude_plugin.component_skill_other": "{count} 个技能", - "claude_plugin.component_command_one": "{count} 个命令", - "claude_plugin.component_command_other": "{count} 个命令", - "claude_plugin.component_agent_one": "{count} 个智能体", - "claude_plugin.component_agent_other": "{count} 个智能体", - "claude_plugin.invalid_url": "请输入 GitHub 仓库网址。", - "claude_plugin.load_preview_failed": "加载插件预览失败", - "claude_plugin.install_failed": "安装插件失败", - "claude_plugin.title": "从 GitHub 安装插件", - "claude_plugin.description": "兼容 Claude Code 插件:使用 .claude-plugin/plugin.json 打包 MCP 服务器、技能和命令的仓库。", - "claude_plugin.repository": "GitHub 仓库", - "claude_plugin.preview": "预览", - "claude_plugin.will_install": "将安装", - "claude_plugin.cancel": "取消", - "claude_plugin.install": "安装", "composer.agent_label": "智能体", "composer.any_file_type_supported": "支持任何文件类型。", "composer.attach_files": "附加文件", @@ -1238,7 +1224,7 @@ export default { "extensions.filter_plugins": "插件", "extensions.plugin_count": "{count}个插件", "extensions.plugins_opencode_header": "插件(OpenCode)", - "extensions.subtitle": "应用(MCP)和OpenCode插件集中管理。", + "extensions.subtitle": "应用与插件能力包集中管理。", "extensions.title": "扩展", "mcp.add_modal_subtitle": "通过URL或本地命令连接自定义MCP服务器。", "mcp.add_modal_title": "添加自定义应用", @@ -1406,25 +1392,6 @@ export default { "model_picker.select_model": "选择模型", "model_picker.title": "模型", "onboarding.authorize_folder": "授权文件夹", - "plugins.add": "添加", - "plugins.add_hint": "添加npm包名称,例如opencode-wakatime", - "plugins.add_label": "添加插件", - "plugins.added": "已添加", - "plugins.config": "配置", - "plugins.config_label": "配置", - "plugins.desc": "管理项目或全局OpenCode插件的`opencode.json`。", - "plugins.empty": "尚未配置插件。", - "plugins.enabled": "已启用", - "plugins.hide_setup": "隐藏设置", - "plugins.not_loaded": "尚未加载", - "plugins.not_loaded_yet": "尚未加载", - "plugins.remove": "移除", - "plugins.scope_global": "全局", - "plugins.scope_project": "项目", - "plugins.setup": "设置", - "plugins.suggested": "建议的插件", - "plugins.suggested_heading": "建议的插件", - "plugins.title": "OpenCode插件", "providers.api_key_label": "API密钥", "providers.api_key_required": "API密钥为必填项", "providers.auth_failed": "认证失败", @@ -1609,7 +1576,6 @@ export default { "settings.cap_mcp": "MCP:{value}", "settings.cap_outbox_off": "发件箱关闭", "settings.cap_outbox_on": "发件箱开启", - "settings.cap_plugins": "插件:{value}", "settings.cap_read": "读取", "settings.cap_sandbox": "沙箱:{value}", "settings.cap_skills": "技能:{value}", @@ -1878,7 +1844,7 @@ export default { "settings.tab_description_cloud_account": "登录并配置你的个人云端空间。", "settings.tab_description_cloud_marketplaces": "浏览来自 iPolloWork Cloud 的完整插件能力包。", "settings.tab_description_cloud_providers": "导入并管理组织提供的 LLM 提供商密钥。", - "settings.tab_description_extensions": "管理此工作区的完整插件能力包与技能。", + "settings.tab_description_extensions": "管理全局插件能力包与技能;安装一次,所有项目共享。", "settings.tab_description_general": "连接提供商、选择默认模型、授权文件夹,以及控制所选iPolloWork工作区和运行时连接。", "settings.tab_description_environment": "保存本机 agents、skills 和 MCP servers 使用的 API keys 与 tokens。Secret 只保留在这台设备上。", "settings.authorization.title": "授权中心", @@ -2820,7 +2786,7 @@ export default { "settings.cloud.search_skill": "搜索 skill。", "settings.cloud.no_skills_match": "没有匹配搜索条件的 skills。", "settings.cloud.marketplaces_title": "市场与插件", - "settings.cloud.marketplaces_description": "浏览组织市场,并将插件文件导入当前工作区。", + "settings.cloud.marketplaces_description": "浏览组织市场,并将插件文件导入全局插件列表。", "settings.cloud.no_marketplaces": "暂时没有可用的市场。", "settings.cloud.choose_org_for_marketplaces": "选择一个组织以查看市场。", "settings.cloud.search_plugin": "搜索插件。", @@ -2908,7 +2874,6 @@ export default { "settings.ai.subscribe": "订阅", "settings.ai.not_connected": "未连接", "settings.ai.models_connect_description": "无需管理 API 密钥的托管前沿模型。", - "settings.extensions.opencode_plugins": "OpenCode 插件", "settings.marketplace.signin_title": "登录后浏览插件市场", "settings.marketplace.signin_hint": "市场内容来自你的 iPolloWork Cloud 账号。登录后可查看、购买并安装完整插件能力包。", "settings.marketplace.loading": "正在加载插件市场…", @@ -2967,34 +2932,22 @@ export default { "settings.runtime_config.not_found": "未找到", "settings.runtime_config.keys": "键:{keys}", "settings.runtime_config.show_raw_json": "显示原始 JSON", - "settings.runtime_migration.title": "OpenCode 配置来源", - "settings.runtime_migration.description": "查看 iPolloWork 在运行时控制的内容与工作区配置中的内容。此功能通过 iPolloWork 服务器运行,无需 OpenCode 引擎处于健康状态。", - "settings.runtime_migration.move_title": "迁移由 iPolloWork 管理的配置", - "settings.runtime_migration.move_description": "将 .opencode/ipollowork.json 中较旧的 iPolloWork 运行时键,以及 opencode.jsonc 中可安全迁移的 iPolloWork 管理键,迁移至运行时数据库。", - "settings.runtime_migration.refresh": "刷新", - "settings.runtime_migration.migrating": "正在迁移…", - "settings.runtime_migration.migrate": "迁移", - "settings.runtime_migration.effective_title": "当前注入的 OpenCode 配置", - "settings.runtime_migration.effective_description": "这是 iPolloWork 构建并通过服务器管理的 OPENCODE_CONFIG 文件注入的配置对象。它包含 iPolloWork 默认值和运行时数据库值,并会在每次运行时配置变更后重写。", - "settings.runtime_migration.show_injected_json": "显示注入的原始 JSON", - "settings.runtime_migration.sources_title": "OpenCode 配置来源明细", - "settings.runtime_migration.sources_description": "OpenCode 还会读取自己的项目和全局配置文件。iPolloWork 会单独注入运行时配置;对于由 iPolloWork 管理的键,应查看注入的配置。", - "settings.runtime_migration.project_config_title": "项目 OpenCode 配置", - "settings.runtime_migration.project_config_description": "由用户或项目拥有的工作区级 OpenCode 配置。", - "settings.runtime_migration.global_config_title": "全局 OpenCode 配置", - "settings.runtime_migration.global_config_description": "位于 ~/.config/opencode 下的用户级 OpenCode 配置。", - "settings.runtime_migration.runtime_db_title": "iPolloWork 运行时数据库", - "settings.runtime_migration.runtime_db_description": "存储在工作区文件之外、由 iPolloWork 管理的运行时值。", - "settings.runtime_migration.injected_config_title": "iPolloWork 注入的配置", - "settings.runtime_migration.injected_config_description": "iPolloWork 在运行时注入 OpenCode 的配置对象。", - "settings.runtime_migration.runtime_database": "运行时数据库", - "settings.runtime_migration.stored_keys": "已存储的键:{keys}", - "settings.runtime_migration.legacy_metadata": "旧版 iPolloWork 元数据", - "settings.runtime_migration.legacy_error": "{error};请先修复此文件,再迁移旧配置。", - "settings.runtime_migration.migratable_keys": "可迁移的键:{keys}", - "settings.runtime_migration.user_opencode_config": "用户 opencode.jsonc", - "settings.runtime_migration.user_owned_keys": "用户拥有的键:{keys}", - "settings.runtime_migration.runtime_db_json": "运行时数据库 JSON", + "settings.runtime_config_sources.title": "OpenCode 配置来源", + "settings.runtime_config_sources.description": "查看 iPolloWork 在运行时控制的内容与工作区配置中的内容。此功能通过 iPolloWork 服务器运行,无需 OpenCode 引擎处于健康状态。", + "settings.runtime_config_sources.refresh": "刷新", + "settings.runtime_config_sources.effective_title": "当前注入的 OpenCode 配置", + "settings.runtime_config_sources.effective_description": "这是 iPolloWork 构建并通过服务器管理的 OPENCODE_CONFIG 文件注入的配置对象。它包含 iPolloWork 默认值和运行时数据库值,并会在每次运行时配置变更后重写。", + "settings.runtime_config_sources.show_injected_json": "显示注入的原始 JSON", + "settings.runtime_config_sources.sources_title": "OpenCode 配置来源明细", + "settings.runtime_config_sources.sources_description": "OpenCode 还会读取自己的项目和全局配置文件。iPolloWork 会单独注入运行时配置;对于由 iPolloWork 管理的键,应查看注入的配置。", + "settings.runtime_config_sources.project_config_title": "项目 OpenCode 配置", + "settings.runtime_config_sources.project_config_description": "由用户或项目拥有的工作区级 OpenCode 配置。", + "settings.runtime_config_sources.global_config_title": "全局 OpenCode 配置", + "settings.runtime_config_sources.global_config_description": "位于 ~/.config/opencode 下的用户级 OpenCode 配置。", + "settings.runtime_config_sources.runtime_db_title": "iPolloWork 运行时数据库", + "settings.runtime_config_sources.runtime_db_description": "存储在工作区文件之外、由 iPolloWork 管理的运行时值。", + "settings.runtime_config_sources.injected_config_title": "iPolloWork 注入的配置", + "settings.runtime_config_sources.injected_config_description": "iPolloWork 在运行时注入 OpenCode 的配置对象。", "settings.feature_flags.title": "功能开关", "settings.feature_flags.description": "用于沙箱和工作区行为的实验性控制项。", "settings.feature_flags.microsandbox_title": "创建沙箱时使用 microsandbox 镜像", diff --git a/apps/app/src/react-app/domains/connections/ipollowork-server-store.ts b/apps/app/src/react-app/domains/connections/ipollowork-server-store.ts index 05ba01c9f..bcf8a72a6 100644 --- a/apps/app/src/react-app/domains/connections/ipollowork-server-store.ts +++ b/apps/app/src/react-app/domains/connections/ipollowork-server-store.ts @@ -47,7 +47,6 @@ export type iPolloWorkServerStoreSnapshot = { ipolloworkServerWorkspaceReady: boolean; resolvediPolloWorkCapabilities: iPolloWorkServerCapabilities | null; ipolloworkServerCanWriteSkills: boolean; - ipolloworkServerCanWritePlugins: boolean; ipolloworkServerHostInfo: iPolloWorkServerInfo | null; ipolloworkServerDiagnostics: iPolloWorkServerDiagnostics | null; ipolloworkReconnectBusy: boolean; @@ -230,9 +229,6 @@ export function createiPolloWorkServerStore(options: CreateiPolloWorkServerStore ipolloworkServerCanWriteSkills: ipolloworkServerReady && (resolvediPolloWorkCapabilities?.skills?.write ?? false), - ipolloworkServerCanWritePlugins: - ipolloworkServerReady && - (resolvediPolloWorkCapabilities?.plugins?.write ?? false), ipolloworkServerHostInfo: state.ipolloworkServerHostInfo, ipolloworkServerDiagnostics: state.ipolloworkServerDiagnostics, ipolloworkReconnectBusy: state.ipolloworkReconnectBusy, diff --git a/apps/app/src/react-app/domains/connections/modals/claude-plugin-import-modal.tsx b/apps/app/src/react-app/domains/connections/modals/claude-plugin-import-modal.tsx deleted file mode 100644 index 1a94bfd0f..000000000 --- a/apps/app/src/react-app/domains/connections/modals/claude-plugin-import-modal.tsx +++ /dev/null @@ -1,268 +0,0 @@ -/** @jsxImportSource react */ -import { useReducer, useRef } from "react"; -import { Download, Loader2, Search } from "lucide-react"; - -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { toast } from "@/components/ui/sonner"; -import { t } from "@/i18n"; -import { TextInput } from "../../../design-system/text-input"; -import type { iPolloWorkClaudePluginPreview } from "../../../../app/lib/ipollowork-server"; - -export type ClaudePluginImportModalProps = { - open: boolean; - onClose: () => void; - onPreview: (url: string) => Promise; - onInstall: (url: string) => Promise<{ ok: boolean; message: string }>; - /** Called after a successful install so the host view can refresh. */ - onInstalled?: () => void; -}; - -type ModalState = { - url: string; - preview: iPolloWorkClaudePluginPreview | null; - /** URL the current preview was generated from; install always targets this. */ - previewedUrl: string | null; - previewing: boolean; - installing: boolean; - error: string | null; -}; - -const initialState: ModalState = { - url: "", - preview: null, - previewedUrl: null, - previewing: false, - installing: false, - error: null, -}; - -type ModalAction = - | Partial - | "reset" - | { kind: "preview-success"; url: string; preview: iPolloWorkClaudePluginPreview }; - -function reducer(state: ModalState, action: ModalAction): ModalState { - if (action === "reset") return initialState; - if ("kind" in action) { - // Ignore preview responses for a URL the user has since edited away from. - if (state.url.trim() !== action.url) return { ...state, previewing: false }; - return { ...state, previewing: false, preview: action.preview, previewedUrl: action.url }; - } - return { ...state, ...action }; -} - -const COMPONENT_LABEL_KEYS: Record = { - mcp: "claude_plugin.component_mcp", - skill: "claude_plugin.component_skill", - command: "claude_plugin.component_command", - agent: "claude_plugin.component_agent", -}; - -export function ClaudePluginImportModal(props: ClaudePluginImportModalProps) { - const [state, dispatch] = useReducer(reducer, initialState); - const operationRef = useRef(0); - - const handleClose = () => { - operationRef.current += 1; - dispatch("reset"); - props.onClose(); - }; - - const handlePreview = async () => { - const url = state.url.trim(); - if (!url) { - dispatch({ error: t("claude_plugin.invalid_url") }); - return; - } - const operation = ++operationRef.current; - dispatch({ previewing: true, error: null, preview: null, previewedUrl: null }); - try { - const preview = await props.onPreview(url); - if (operationRef.current !== operation) return; - dispatch({ kind: "preview-success", url, preview }); - } catch (error) { - if (operationRef.current !== operation) return; - dispatch({ - previewing: false, - error: error instanceof Error ? error.message : t("claude_plugin.load_preview_failed"), - }); - } - }; - - const handleInstall = async () => { - // Install exactly what was previewed — never a URL edited after preview. - const url = state.previewedUrl; - if (!url || state.installing) return; - const operation = ++operationRef.current; - dispatch({ installing: true, error: null }); - try { - const result = await props.onInstall(url); - if (!result.ok) { - if (operationRef.current !== operation) { - toast.error(result.message); - return; - } - dispatch({ installing: false, error: result.message }); - return; - } - toast.success(result.message); - } catch (error) { - const message = error instanceof Error ? error.message : t("claude_plugin.install_failed"); - toast.error(message); - if (operationRef.current !== operation) return; - dispatch({ - installing: false, - error: message, - }); - return; - } - if (operationRef.current !== operation) { - props.onInstalled?.(); - return; - } - dispatch("reset"); - props.onInstalled?.(); - props.onClose(); - }; - - const preview = state.preview; - const groups = preview - ? (["mcp", "skill", "command", "agent"] as const) - .map((type) => ({ - type, - items: preview.components.filter((component) => component.type === type), - })) - .filter((group) => group.items.length > 0) - : []; - - return ( - { - if (!open) handleClose(); - }} - > - - - {t("claude_plugin.title")} - - {t("claude_plugin.description")} - - - -
-
-
- - dispatch({ url: event.currentTarget.value, preview: null, previewedUrl: null }) - } - /> -
- -
- - {state.preview ? ( -
-
-
- {state.preview.name} - {state.preview.version ? ( - v{state.preview.version} - ) : null} -
- {state.preview.description ? ( -
{state.preview.description}
- ) : null} -
- {state.preview.source.owner}/{state.preview.source.repo} @ {state.preview.source.ref} - {state.preview.source.dir ? ` · ${state.preview.source.dir}` : ""} -
-
- -
-
{t("claude_plugin.will_install")}
-
- {groups.map((group) => ( -
-
- {t(COMPONENT_LABEL_KEYS[group.type] ?? "claude_plugin.component_skill", { count: group.items.length })} -
-
    - {group.items.map((item) => ( -
  • - {item.name} - {item.description ? ( - — {item.description} - ) : null} -
  • - ))} -
-
- ))} -
-
- - {state.preview.warnings.length > 0 ? ( -
- {state.preview.warnings.map((warning) => ( -
{warning}
- ))} -
- ) : null} -
- ) : null} - - {state.error ? ( -
- {state.error} -
- ) : null} -
- - - } - disabled={state.previewing || state.installing} - > - {t("claude_plugin.cancel")} - - - -
-
- ); -} diff --git a/apps/app/src/react-app/domains/connections/provider-auth/deepseek-harness-provider-engine-adapter.ts b/apps/app/src/react-app/domains/connections/provider-auth/deepseek-harness-provider-engine-adapter.ts index b5ef7ddd3..4c4775ef0 100644 --- a/apps/app/src/react-app/domains/connections/provider-auth/deepseek-harness-provider-engine-adapter.ts +++ b/apps/app/src/react-app/domains/connections/provider-auth/deepseek-harness-provider-engine-adapter.ts @@ -2,6 +2,7 @@ import { DEEPSEEK_HARNESS_ENGINE_ID } from "@ipollowork/types/workspace"; import { providerApiKeyCredentialRef } from "@ipollowork/types/provider-credentials"; import { + DeepSeekHarnessClient, isDeepSeekHarnessRpcClient, } from "@/app/lib/deepseek-harness-client"; import type { ProviderListItem } from "@/app/types"; @@ -184,6 +185,13 @@ export const deepSeekHarnessProviderEngineAdapter: ProviderEngineAdapter = { disabledProviders: false, authChangesRequireReload: false, }, + createClient({ endpoint }) { + return new DeepSeekHarnessClient({ + serverBaseUrl: endpoint.baseUrl, + workspaceId: endpoint.workspaceId, + token: endpoint.token, + }); + }, connect: connection, emptyProjectConfig: () => "{}\n", async readProjectConfig() { @@ -200,7 +208,6 @@ export const deepSeekHarnessProviderEngineAdapter: ProviderEngineAdapter = { }, projectProviderIds: () => [], formatProjectProviderDisabledState: (raw) => raw, - formatProjectWithoutProvider: (raw) => raw, buildCloudProviderPatch: unsupportedProviderConfiguration, buildCompatibleProviderPatch: unsupportedProviderConfiguration, }; diff --git a/apps/app/src/react-app/domains/connections/provider-auth/opencode-provider-engine-adapter.ts b/apps/app/src/react-app/domains/connections/provider-auth/opencode-provider-engine-adapter.ts index 6f32f76c4..c8d0d9430 100644 --- a/apps/app/src/react-app/domains/connections/provider-auth/opencode-provider-engine-adapter.ts +++ b/apps/app/src/react-app/domains/connections/provider-auth/opencode-provider-engine-adapter.ts @@ -6,7 +6,7 @@ import { writeOpencodeConfig, } from "../../../../app/lib/desktop"; import type { DenOrgLlmProviderConnection } from "../../../../app/lib/den"; -import { unwrap, waitForHealthy } from "../../../../app/lib/opencode"; +import { createClient, unwrap, waitForHealthy } from "../../../../app/lib/opencode"; import type { Client } from "../../../../app/types"; import { isDesktopRuntime } from "../../../../app/utils"; import { getCloudProviderEnv } from "./cloud-provider-config"; @@ -128,38 +128,6 @@ function projectProviderIds(raw: string) { return Object.keys(config.provider); } -function escapeRegExp(value: string) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function formatOpenCodeConfigWithoutProvider( - raw: string, - providerId: string, - disabledProviders: string[], -) { - const commentPattern = new RegExp( - `(^[ \\t]*)// iPolloWork Cloud import:.*\\n\\1(?="${escapeRegExp(providerId)}":)`, - "m", - ); - let updated = raw.replace(commentPattern, "$1"); - updated = applyEdits( - updated, - modify(updated, ["provider", providerId], undefined, { - formattingOptions: { insertSpaces: true, tabSize: 2 }, - }), - ); - updated = applyEdits( - updated, - modify( - updated, - ["disabled_providers"], - disabledProviders.filter((id) => id !== providerId), - { formattingOptions: { insertSpaces: true, tabSize: 2 } }, - ), - ); - return updated.endsWith("\n") ? updated : `${updated}\n`; -} - function buildOpenCodeCloudProviderPatch( provider: DenOrgLlmProviderConnection, localProviderId: string, @@ -234,6 +202,13 @@ export const openCodeProviderEngineAdapter: ProviderEngineAdapter = { disabledProviders: true, authChangesRequireReload: true, }, + createClient({ endpoint, directory }) { + return createClient( + endpoint.opencodeBaseUrl, + directory?.trim() || undefined, + { token: endpoint.token, mode: "ipollowork" }, + ); + }, connect: openCodeConnection, emptyProjectConfig: () => '{\n "$schema": "https://opencode.ai/config.json"\n}\n', async readProjectConfig(target) { @@ -303,7 +278,6 @@ export const openCodeProviderEngineAdapter: ProviderEngineAdapter = { const updated = applyEdits(raw, edits); return updated.endsWith("\n") ? updated : `${updated}\n`; }, - formatProjectWithoutProvider: formatOpenCodeConfigWithoutProvider, buildCloudProviderPatch: buildOpenCodeCloudProviderPatch, buildCompatibleProviderPatch(profile) { const api = profile.api?.trim(); diff --git a/apps/app/src/react-app/domains/connections/provider-auth/provider-engine-adapter.ts b/apps/app/src/react-app/domains/connections/provider-auth/provider-engine-adapter.ts index f6f07490f..dcd198fe1 100644 --- a/apps/app/src/react-app/domains/connections/provider-auth/provider-engine-adapter.ts +++ b/apps/app/src/react-app/domains/connections/provider-auth/provider-engine-adapter.ts @@ -1,6 +1,7 @@ import { DEFAULT_ENGINE_ID } from "@ipollowork/types/workspace"; import type { iPolloWorkServerClient } from "../../../../app/lib/ipollowork-server"; +import type { ResolvedWorkspaceEndpoint } from "../../../../app/lib/workspace-endpoint"; import type { DenOrgLlmProviderConnection } from "../../../../app/lib/den"; import type { ProviderListResponse } from "../../../../app/types"; import { deepSeekHarnessProviderEngineAdapter } from "./deepseek-harness-provider-engine-adapter"; @@ -38,6 +39,11 @@ export type ProviderEngineConfigTarget = { root: string; }; +export type ProviderEngineClientTarget = { + endpoint: ResolvedWorkspaceEndpoint; + directory?: string | null; +}; + export type ProviderEngineConnection = { listProviders(directory?: string): Promise; listAuthMethods(): Promise>; @@ -60,6 +66,7 @@ export interface ProviderEngineAdapter { disabledProviders: boolean; authChangesRequireReload: boolean; }; + createClient(target: ProviderEngineClientTarget): unknown; connect(client: unknown): ProviderEngineConnection; emptyProjectConfig(): string; readProjectConfig(target: ProviderEngineConfigTarget): Promise<{ content?: string | null } | null>; @@ -68,7 +75,6 @@ export interface ProviderEngineAdapter { runtimeProviderIds(target: ProviderEngineConfigTarget): Promise; projectProviderIds(raw: string): string[]; formatProjectProviderDisabledState(raw: string, providerId: string, disabled: boolean): string; - formatProjectWithoutProvider(raw: string, providerId: string, disabledProviders: string[]): string; buildCloudProviderPatch( provider: DenOrgLlmProviderConnection, localProviderId: string, @@ -103,6 +109,14 @@ export class ProviderEngineAdapterRegistry { ids(): string[] { return [...this.#adapters.keys()]; } + + createClient( + id: string | null | undefined, + target: ProviderEngineClientTarget, + ): unknown | null { + const resolved = id?.trim() || DEFAULT_ENGINE_ID; + return this.#adapters.get(resolved)?.createClient(target) ?? null; + } } export const providerEngineAdapters = new ProviderEngineAdapterRegistry([ diff --git a/apps/app/src/react-app/domains/connections/provider-auth/shared-provider-workspace.ts b/apps/app/src/react-app/domains/connections/provider-auth/shared-provider-workspace.ts index 1587f5ab9..3fab0a876 100644 --- a/apps/app/src/react-app/domains/connections/provider-auth/shared-provider-workspace.ts +++ b/apps/app/src/react-app/domains/connections/provider-auth/shared-provider-workspace.ts @@ -3,6 +3,7 @@ import { DEFAULT_ENGINE_ID } from "@ipollowork/types/workspace"; type ProviderWorkspace = { id: string; engineId?: string | null; + workspaceType?: string | null; }; /** @@ -15,10 +16,11 @@ export function selectSharedProviderWorkspace( workspaces: readonly T[], selectedWorkspace: T | null | undefined, ): T | null { - if ((selectedWorkspace?.engineId?.trim() || DEFAULT_ENGINE_ID) === DEFAULT_ENGINE_ID) { - return selectedWorkspace ?? null; - } - return workspaces.find( + const openCodeWorkspaces = workspaces.filter( (workspace) => (workspace.engineId?.trim() || DEFAULT_ENGINE_ID) === DEFAULT_ENGINE_ID, - ) ?? selectedWorkspace ?? null; + ); + return openCodeWorkspaces.find((workspace) => workspace.workspaceType !== "remote") + ?? openCodeWorkspaces[0] + ?? selectedWorkspace + ?? null; } diff --git a/apps/app/src/react-app/domains/connections/provider-auth/store.ts b/apps/app/src/react-app/domains/connections/provider-auth/store.ts index cf738b697..c8dccaed9 100644 --- a/apps/app/src/react-app/domains/connections/provider-auth/store.ts +++ b/apps/app/src/react-app/domains/connections/provider-auth/store.ts @@ -69,6 +69,7 @@ import { import { TOKENSTAR_PROVIDER, tokenStarRuntimeModels } from "./tokenstar-provider"; import { providerEngineAdapters, + type CompatibleProviderProfile, type ProviderEngineAuthAuthorization, type ProviderEngineAuthMethod, type ProviderEngineConfigTarget, @@ -193,6 +194,55 @@ const DEEPSEEK_OFFICIAL_PROVIDER = { }, } as const; +type CompatibleProviderPreset = { + providerId: string; + name: string; + baseURL: string; + models(modelIds?: string[]): CompatibleProviderProfile["models"]; +}; + +const COMPATIBLE_PROVIDER_PRESETS: CompatibleProviderPreset[] = [ + { + ...DEEPSEEK_OFFICIAL_PROVIDER, + models: () => DEEPSEEK_OFFICIAL_PROVIDER.models, + }, + { + providerId: QWEN3_CODER_PROVIDER.providerId, + name: QWEN3_CODER_PROVIDER.name, + baseURL: QWEN3_CODER_PROVIDER.baseURL, + models: () => ({ + [QWEN3_CODER_PROVIDER.modelId]: { name: QWEN3_CODER_PROVIDER.modelName }, + }), + }, + { + providerId: TOKENSTAR_PROVIDER.providerId, + name: TOKENSTAR_PROVIDER.name, + baseURL: TOKENSTAR_PROVIDER.baseURL, + models: (modelIds) => tokenStarRuntimeModels([ + ...new Set( + (modelIds?.length ? modelIds : TOKENSTAR_PROVIDER.fallbackModels.map((model) => model.id)) + .map((modelId) => modelId.trim()) + .filter(Boolean), + ), + ]), + }, +]; + +function compatibleProviderProfile( + providerId: string, + modelIds?: string[], +): CompatibleProviderProfile | null { + const preset = COMPATIBLE_PROVIDER_PRESETS.find((entry) => entry.providerId === providerId); + return preset + ? { + id: preset.providerId, + name: preset.name, + baseURL: preset.baseURL, + models: preset.models(modelIds), + } + : null; +} + export type ProviderAuthStore = ReturnType; export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) { @@ -273,49 +323,21 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) } } - if ( - getProviderEngineAdapter().capabilities.customProviders && - !merged.has(DEEPSEEK_OFFICIAL_PROVIDER.providerId) && - !isDesktopProviderBlocked({ - providerId: DEEPSEEK_OFFICIAL_PROVIDER.providerId, - checkRestriction: options.checkDesktopAppRestriction, - }) - ) { - merged.set(DEEPSEEK_OFFICIAL_PROVIDER.providerId, { - id: DEEPSEEK_OFFICIAL_PROVIDER.providerId, - name: DEEPSEEK_OFFICIAL_PROVIDER.name, - env: [], - }); - } - - if ( - getProviderEngineAdapter().capabilities.customProviders && - !merged.has(QWEN3_CODER_PROVIDER.providerId) && - !isDesktopProviderBlocked({ - providerId: QWEN3_CODER_PROVIDER.providerId, - checkRestriction: options.checkDesktopAppRestriction, - }) - ) { - merged.set(QWEN3_CODER_PROVIDER.providerId, { - id: QWEN3_CODER_PROVIDER.providerId, - name: QWEN3_CODER_PROVIDER.name, - env: [], - }); - } - - if ( - getProviderEngineAdapter().capabilities.customProviders && - !merged.has(TOKENSTAR_PROVIDER.providerId) && - !isDesktopProviderBlocked({ - providerId: TOKENSTAR_PROVIDER.providerId, - checkRestriction: options.checkDesktopAppRestriction, - }) - ) { - merged.set(TOKENSTAR_PROVIDER.providerId, { - id: TOKENSTAR_PROVIDER.providerId, - name: TOKENSTAR_PROVIDER.name, - env: [], - }); + if (getProviderEngineAdapter().capabilities.customProviders) { + for (const provider of COMPATIBLE_PROVIDER_PRESETS) { + if ( + merged.has(provider.providerId) + || isDesktopProviderBlocked({ + providerId: provider.providerId, + checkRestriction: options.checkDesktopAppRestriction, + }) + ) continue; + merged.set(provider.providerId, { + id: provider.providerId, + name: provider.name, + env: [], + }); + } } return Array.from(merged.values()).toSorted(compareProviders); @@ -554,30 +576,6 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) await getProviderEngineAdapter().patchRuntimeProviders(target, update); }; - /** - * Best-effort migration: pre-runtime builds wrote cloud provider blocks into - * project config. Strip them so the runtime entry remains the single owner. - */ - const stripLegacyCloudProviderBlocks = async (providerIds: Array) => { - const ids = [...new Set(providerIds.flatMap((id) => (id?.trim() ? [id.trim()] : [])))]; - if (ids.length === 0) return; - try { - await updateProjectConfigFile((raw) => { - let next = raw; - for (const id of ids) { - next = getProviderEngineAdapter().formatProjectWithoutProvider( - next, - id, - options.disabledProviders(), - ); - } - return next; - }); - } catch { - // Legacy cleanup only — the runtime entry already owns the provider. - } - }; - const updateProjectConfigFile = async ( updater: (raw: string) => string, ) => { @@ -649,14 +647,11 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) } }; - // Sweep all cloud-managed provider entries (keys matching /^lpr_/) from - // both runtime and project engine config, regardless of - // importedCloudProviders state. Returns the list of provider IDs that were - // removed so callers can also clear their auth credentials. - const sweepOrphanCloudProvidersFromConfig = async (): Promise => { + // Sweep all cloud-managed provider entries (keys matching /^lpr_/) from the + // single runtime-owned provider config, regardless of imported state. + const sweepOrphanCloudProvidersFromRuntime = async (): Promise => { const orphanIds = new Set(); - // Runtime-managed orphans (`lpr_*` keys in the workspace runtime config). try { const target = await resolveProviderEngineConfigTarget("write"); const runtimeOrphans = (await getProviderEngineAdapter().runtimeProviderIds(target)) @@ -668,29 +663,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) for (const id of runtimeOrphans) orphanIds.add(id); } } catch { - // Best-effort; the legacy file sweep below still runs. - } - - // Legacy project-config blocks written by pre-runtime builds. - const configFile = await readProjectConfigFile().catch(() => null) as { content?: string } | null; - if (configFile?.content?.trim()) { - const fileOrphans = getProviderEngineAdapter() - .projectProviderIds(configFile.content) - .filter((key) => /^lpr_/i.test(key)); - if (fileOrphans.length > 0) { - await updateProjectConfigFile((raw) => { - let next = raw; - for (const id of fileOrphans) { - next = getProviderEngineAdapter().formatProjectWithoutProvider( - next, - id, - options.disabledProviders(), - ); - } - return next; - }); - for (const id of fileOrphans) orphanIds.add(id); - } + // Best-effort cleanup; tracked entries are still removed individually. } return [...orphanIds]; @@ -1308,85 +1281,21 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) } assertProviderAllowedByDesktopPolicy(providerId); const resolvedProviderId = providerId.trim().toLowerCase(); - const isDeepSeekOfficial = resolvedProviderId === DEEPSEEK_OFFICIAL_PROVIDER.providerId; - const materializesDeepSeekProvider = - isDeepSeekOfficial && getProviderEngineAdapter().capabilities.customProviders; - const isQwen3Coder = resolvedProviderId === QWEN3_CODER_PROVIDER.providerId; - const isTokenStar = resolvedProviderId === TOKENSTAR_PROVIDER.providerId; + const compatibleProfile = getProviderEngineAdapter().capabilities.customProviders + ? compatibleProviderProfile(resolvedProviderId, modelIds) + : null; try { const connection = getProviderEngineConnection(); - if (materializesDeepSeekProvider) { + if (compatibleProfile) { await patchRuntimeProviders( - getProviderEngineAdapter().buildCompatibleProviderPatch({ - id: DEEPSEEK_OFFICIAL_PROVIDER.providerId, - name: DEEPSEEK_OFFICIAL_PROVIDER.name, - baseURL: DEEPSEEK_OFFICIAL_PROVIDER.baseURL, - models: DEEPSEEK_OFFICIAL_PROVIDER.models, - }), + getProviderEngineAdapter().buildCompatibleProviderPatch(compatibleProfile), ); } - if (isQwen3Coder) { - await patchRuntimeProviders( - getProviderEngineAdapter().buildCompatibleProviderPatch({ - id: QWEN3_CODER_PROVIDER.providerId, - name: QWEN3_CODER_PROVIDER.name, - baseURL: QWEN3_CODER_PROVIDER.baseURL, - models: { - [QWEN3_CODER_PROVIDER.modelId]: { - name: QWEN3_CODER_PROVIDER.modelName, - }, - }, - }), - ); - try { - await updateProjectConfigFile((raw) => - getProviderEngineAdapter().formatProjectWithoutProvider( - raw, - QWEN3_CODER_PROVIDER.providerId, - options.disabledProviders(), - ), - ); - } catch { - // Runtime config owns this provider; legacy file cleanup is best-effort. - } - } - if (isTokenStar) { - const selectedModelIds = [ - ...new Set( - (modelIds?.length ? modelIds : TOKENSTAR_PROVIDER.fallbackModels.map((model) => model.id)) - .map((modelId) => modelId.trim()) - .filter(Boolean), - ), - ]; - await patchRuntimeProviders( - getProviderEngineAdapter().buildCompatibleProviderPatch({ - id: TOKENSTAR_PROVIDER.providerId, - name: TOKENSTAR_PROVIDER.name, - baseURL: TOKENSTAR_PROVIDER.baseURL, - models: tokenStarRuntimeModels(selectedModelIds), - }), - ); - try { - await updateProjectConfigFile((raw) => - getProviderEngineAdapter().formatProjectWithoutProvider( - raw, - TOKENSTAR_PROVIDER.providerId, - options.disabledProviders(), - ), - ); - } catch { - // Runtime config owns this provider; legacy file cleanup is best-effort. - } - } await connection.setApiKey(providerId, trimmed); await mirrorSharedProviderApiKey(providerId, trimmed); - if (materializesDeepSeekProvider || isQwen3Coder || isTokenStar) { - const syntheticProviderId = isTokenStar - ? TOKENSTAR_PROVIDER.providerId - : isQwen3Coder - ? QWEN3_CODER_PROVIDER.providerId - : DEEPSEEK_OFFICIAL_PROVIDER.providerId; + if (compatibleProfile) { + const syntheticProviderId = compatibleProfile.id; const nextConnected = [ ...options.providerConnectedIds().filter((id) => id !== syntheticProviderId), syntheticProviderId, @@ -1477,8 +1386,6 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) existingImported?.providerId ?? null, ), ); - await stripLegacyCloudProviderBlocks([localProviderId, existingImported?.providerId]); - const nextImportedProviders = { ...state.importedCloudProviders, [provider.id]: { @@ -1540,11 +1447,8 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) throw error; } } - // Runtime-managed: delete the provider entry via the server's per-key - // merge (`null` deletes), then strip any legacy project-config block - // left by pre-runtime builds. Both are idempotent. + // Runtime-managed providers have one owner; `null` deletes that entry. await patchRuntimeProviders({ [imported.providerId]: null }); - await stripLegacyCloudProviderBlocks([imported.providerId]); const nextImportedProviders = { ...state.importedCloudProviders }; delete nextImportedProviders[cloudProviderId]; @@ -1750,23 +1654,16 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) } try { - const removableRuntimeProviderId = [ - TOKENSTAR_PROVIDER.providerId, - DEEPSEEK_OFFICIAL_PROVIDER.providerId, - ].find((providerId) => resolved.toLowerCase() === providerId); - if (removableRuntimeProviderId) { - await patchRuntimeProviders({ [removableRuntimeProviderId]: null }); - try { - await updateProjectConfigFile((raw) => - getProviderEngineAdapter().formatProjectWithoutProvider( - raw, - removableRuntimeProviderId, - options.disabledProviders(), - ), - ); - } catch { - // Runtime config owns this provider; legacy file cleanup is best-effort. - } + let runtimeManagedProviderId: string | null = null; + try { + const target = await resolveProviderEngineConfigTarget("write"); + runtimeManagedProviderId = (await getProviderEngineAdapter().runtimeProviderIds(target)) + .find((providerId) => providerId === resolved) ?? null; + } catch { + // Credential removal still works when runtime config inspection is unavailable. + } + if (runtimeManagedProviderId) { + await patchRuntimeProviders({ [runtimeManagedProviderId]: null }); } await removeProviderAuthCredentials(resolved); const updated = await refreshProviders({ dispose: true }); @@ -1901,11 +1798,9 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) // Ignore individual removal failures during sign-out cleanup } } - // Final sweep: remove any orphan `lpr_*` provider keys that remain - // in project config but weren't tracked in importedCloudProviders - // (e.g. from a previous failed cleanup or external edit). + // Final sweep removes runtime entries left by an interrupted cleanup. try { - const orphans = await sweepOrphanCloudProvidersFromConfig(); + const orphans = await sweepOrphanCloudProvidersFromRuntime(); for (const providerId of orphans) { try { await removeProviderAuthCredentials(providerId); @@ -1956,9 +1851,9 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) } catch {} } } - // Then: sweep any `lpr_*` keys that remain in project config + // Then sweep any untracked runtime-owned `lpr_*` keys. try { - const orphans = await sweepOrphanCloudProvidersFromConfig(); + const orphans = await sweepOrphanCloudProvidersFromRuntime(); for (const providerId of orphans) { try { await removeProviderAuthCredentials(providerId); diff --git a/apps/app/src/react-app/domains/session/engine/deepseek-harness-conversation-engine.ts b/apps/app/src/react-app/domains/session/engine/deepseek-harness-conversation-engine.ts index a60f2033c..b1f2a6368 100644 --- a/apps/app/src/react-app/domains/session/engine/deepseek-harness-conversation-engine.ts +++ b/apps/app/src/react-app/domains/session/engine/deepseek-harness-conversation-engine.ts @@ -1,4 +1,7 @@ -import { DEEPSEEK_HARNESS_ENGINE_ID } from "@ipollowork/types/workspace"; +import { + DEEPSEEK_HARNESS_ENGINE_ID, + DEEPSEEK_HARNESS_INTERNAL_SYSTEM_PREFIX, +} from "@ipollowork/types/workspace"; import { DeepSeekHarnessClient } from "@/app/lib/deepseek-harness-client"; import { t } from "@/i18n"; @@ -13,7 +16,6 @@ import { import { deepSeekHarnessForkSeq, deepSeekHarnessNativeRpcId, - DEEPSEEK_HARNESS_INTERNAL_SYSTEM_PREFIX, mapDeepSeekHarnessEnvelope, mapDeepSeekHarnessSession, mapDeepSeekHarnessSnapshot, @@ -83,7 +85,10 @@ function internalPromptText(text: string): string { return `${DEEPSEEK_HARNESS_INTERNAL_SYSTEM_PREFIX}${text}\n`; } -function promptContent(parts: ConversationPromptPart[], system?: string) { +function promptContent( + parts: ConversationPromptPart[], + system?: string, +) { const content: Array< { type: "text"; text: string } | { type: "image"; mediaType: "image/png" | "image/jpeg" | "image/webp" | "image/gif"; data: string; name?: string } @@ -94,7 +99,6 @@ function promptContent(parts: ConversationPromptPart[], system?: string) { continue; } if (part.type === "agent") { - content.push({ type: "text", text: `@${part.name}` }); continue; } const match = /^data:(image\/(?:png|jpeg|webp|gif));base64,(.+)$/u.exec(part.url); @@ -147,6 +151,7 @@ function deepSeekHarnessConnection(input: { const questions = new Map(); const liveState: DeepSeekHarnessLiveState = { parts: new Set(), tools: new Map() }; let agentPresets: AgentPresetList | null = null; + let pluginCapabilitiesCache: { at: number; items: Awaited> } | null = null; const selectedModels = new Map(); const selectedModes = new Map(); const mutableModes = new Map(); @@ -156,6 +161,15 @@ function deepSeekHarnessConnection(input: { return agentPresets; }; + const listPluginCapabilities = async () => { + if (pluginCapabilitiesCache && Date.now() - pluginCapabilitiesCache.at < 2_000) { + return pluginCapabilitiesCache.items; + } + const items = await client.pluginCapabilities(); + pluginCapabilitiesCache = { at: Date.now(), items }; + return items; + }; + const selectModel = async (request: { sessionId: string; model?: { providerID: string; modelID: string }; @@ -318,11 +332,16 @@ function deepSeekHarnessConnection(input: { async runCommand(request) { try { await selectModel(request); - await client.call("session.prompt", { + await client.prompt({ sessionId: request.sessionId, mode: "queue", - content: [{ type: "text", text: `/${request.command}${request.arguments ? ` ${request.arguments}` : ""}` }], + content: [], clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }, { + command: { + name: request.command, + ...(request.arguments ? { arguments: request.arguments } : {}), + }, }); mutableModes.set(request.sessionId, false); } catch (error) { @@ -333,19 +352,29 @@ function deepSeekHarnessConnection(input: { try { await selectMode(request.sessionId, request.mode); await selectModel(request); - await client.call("session.prompt", { + const selectedAgents = [...new Set( + request.parts.flatMap((part) => part.type === "agent" ? [part.name] : []), + )]; + await client.prompt({ sessionId: request.sessionId, mode: "queue", content: promptContent(request.parts, request.system), clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, - }); + }, selectedAgents.length > 0 ? { agents: selectedAgents } : undefined); mutableModes.set(request.sessionId, false); } catch (error) { throw conversationError(error); } }, async listCommands() { - return []; + return (await listPluginCapabilities()) + .filter((item) => item.type === "command") + .map((item) => ({ + id: `${item.pluginId}:${item.resourceId}`, + name: item.name, + description: item.description, + source: "command" as const, + })); }, async listModes() { return (await listAgentPresets()).presets @@ -361,7 +390,13 @@ function deepSeekHarnessConnection(input: { })); }, async listAgents() { - return []; + return (await listPluginCapabilities()) + .filter((item) => item.type === "agent") + .map((item) => ({ + name: item.name, + description: item.description, + mode: "all", + })); }, async searchFiles() { return []; diff --git a/apps/app/src/react-app/domains/session/engine/deepseek-harness-conversation-mapper.ts b/apps/app/src/react-app/domains/session/engine/deepseek-harness-conversation-mapper.ts index bb3d72ba5..0f46a649a 100644 --- a/apps/app/src/react-app/domains/session/engine/deepseek-harness-conversation-mapper.ts +++ b/apps/app/src/react-app/domains/session/engine/deepseek-harness-conversation-mapper.ts @@ -1,4 +1,5 @@ import type { DynamicToolUIPart, UIMessage } from "ai"; +import { DEEPSEEK_HARNESS_INTERNAL_SYSTEM_PREFIX } from "@ipollowork/types/workspace"; import type { TodoItem } from "@/app/types"; import { t } from "@/i18n"; @@ -40,8 +41,6 @@ type ToolState = { input: unknown; }; -export const DEEPSEEK_HARNESS_INTERNAL_SYSTEM_PREFIX = "\n\n"; - const LEGACY_SYSTEM_BLOCK = /^\n[\s\S]*\n<\/system>$/u; const INTERNAL_SESSION_TITLE = /^|\s)/iu; diff --git a/apps/app/src/react-app/domains/session/surface/composer/composer.tsx b/apps/app/src/react-app/domains/session/surface/composer/composer.tsx index d82f131d6..ac454f4a4 100644 --- a/apps/app/src/react-app/domains/session/surface/composer/composer.tsx +++ b/apps/app/src/react-app/domains/session/surface/composer/composer.tsx @@ -6,7 +6,6 @@ import { toast } from "@/components/ui/sonner"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { IPOLLOWORK_EXTENSION_CATALOG, type McpDirectoryInfo } from "@/app/constants"; -import type { CloudImportedPlugin, CloudImportedPluginFile } from "@/app/cloud/import-state"; import type { iPolloWorkPluginPackageItem } from "@/app/lib/ipollowork-server"; import type { ComposerAttachment, McpServerEntry, McpStatusMap, ModelRef, SkillCard, SlashCommandOption } from "@/app/types"; import type { ConversationAgent, ConversationMode, ConversationModeIcon } from "../../engine/conversation-engine"; @@ -36,7 +35,7 @@ type PastedTextChip = { }; type ToolMenuSettingsSection = "commands" | "skills" | "mcps" | "plugins"; -type ToolMenuSection = "commands" | "skills" | "mcps" | "extensions" | `plugin:${string}`; +type ToolMenuSection = "commands" | "skills" | "mcps" | "extensions"; type PlusMenuSection = "tools" | "delegation"; function isComposerExtensionAvailable(entry: McpDirectoryInfo) { @@ -86,8 +85,6 @@ type ComposerProps = { mcpServers?: McpServerEntry[]; mcpStatus?: string | null; mcpStatuses?: McpStatusMap; - listImportedPlugins?: () => Promise; - importedPlugins?: CloudImportedPlugin[]; listExternalAgents: () => Promise; onOpenSettingsSection?: (section: ToolMenuSettingsSection) => void; recentFiles: string[]; @@ -262,26 +259,6 @@ function extensionIcon(entry: McpDirectoryInfo, size = 16) { return ; } -function formatPluginObjectType(type: string) { - const normalized = type.trim().toLowerCase(); - if (!normalized) return "File"; - if (normalized === "mcp") return "MCP"; - return `${normalized.charAt(0).toUpperCase()}${normalized.slice(1)}`; -} - -function pluginSlashCommandName(file: CloudImportedPluginFile) { - const path = file.path.trim(); - if (file.objectType === "command") { - const command = path.match(/^\.opencode\/(?:command|commands)\/(.+)\.md$/i)?.[1]; - return command?.trim() || null; - } - if (file.objectType === "skill") { - const skill = path.match(/^\.opencode\/(?:skill|skills)\/(?:[^/]+\/)?([^/]+)\/SKILL\.md$/i)?.[1]; - return skill?.trim() || null; - } - return null; -} - export function ReactSessionComposer(props: ComposerProps) { const builtInExtensionsDisabled = useDesktopRestriction("allowBuiltInExtensions"); let fileInput: HTMLInputElement | undefined; @@ -296,8 +273,6 @@ export function ReactSessionComposer(props: ComposerProps) { const [mcpServers, setMcpServers] = useState(props.mcpServers ?? []); const [mcpStatus, setMcpStatus] = useState(props.mcpStatus ?? null); const [mcpStatuses, setMcpStatuses] = useState(props.mcpStatuses ?? {}); - const [importedPlugins, setImportedPlugins] = useState(props.importedPlugins ?? []); - const [pluginsLoading, setPluginsLoading] = useState(false); const [slashOpen, setSlashOpen] = useState(false); const [plusMenuOpen, setPlusMenuOpen] = useState(false); const [plusMenuSection, setPlusMenuSection] = useState(null); @@ -317,19 +292,16 @@ export function ReactSessionComposer(props: ComposerProps) { const listCommandsRef = useRef(props.listCommands); const listSkillsRef = useRef(props.listSkills); const listMcpRef = useRef(props.listMcp); - const listImportedPluginsRef = useRef(props.listImportedPlugins); const listExternalAgentsRef = useRef(props.listExternalAgents); const toolMenuLoadRef = useRef({ openId: 0, commands: false, skills: false, mcps: false, - plugins: false, }); const [commandsLoaded, setCommandsLoaded] = useState(false); const [skillsLoaded, setSkillsLoaded] = useState(Boolean(props.skills)); const [mcpLoaded, setMcpLoaded] = useState(Boolean(props.mcpServers)); - const [pluginsLoaded, setPluginsLoaded] = useState(Boolean(props.importedPlugins)); const [, setExtensionStateVersion] = useState(0); const [delegationMenuIndex, setDelegationMenuIndex] = useState(0); const delegationItemRefs = useRef>([]); @@ -447,10 +419,6 @@ export function ReactSessionComposer(props: ComposerProps) { setMcpStatuses(props.mcpStatuses ?? {}); }, [props.mcpServers, props.mcpStatus, props.mcpStatuses]); - useEffect(() => { - setImportedPlugins(props.importedPlugins ?? []); - }, [props.importedPlugins]); - useEffect(() => { listCommandsRef.current = props.listCommands; }, [props.listCommands]); @@ -481,10 +449,6 @@ export function ReactSessionComposer(props: ComposerProps) { if (props.busy || props.modeSelectionDisabled) setWorkModeOpen(false); }, [props.busy, props.modeSelectionDisabled]); - useEffect(() => { - listImportedPluginsRef.current = props.listImportedPlugins; - }, [props.listImportedPlugins]); - useEffect(() => { listExternalAgentsRef.current = props.listExternalAgents; }, [props.listExternalAgents]); @@ -587,12 +551,10 @@ export function ReactSessionComposer(props: ComposerProps) { commands: false, skills: false, mcps: false, - plugins: false, }; setCommandsLoaded(false); setSkillsLoaded(Boolean(props.skills)); setMcpLoaded(Boolean(props.mcpServers)); - setPluginsLoaded(Boolean(props.importedPlugins)); }, [toolMenuOpen]); useEffect(() => { @@ -684,37 +646,6 @@ export function ReactSessionComposer(props: ComposerProps) { }; }, [delegationMenuOpen]); - useEffect(() => { - if (!toolMenuOpen) return; - const openId = toolMenuLoadRef.current.openId; - const listImportedPlugins = listImportedPluginsRef.current; - if (listImportedPlugins && !toolMenuLoadRef.current.plugins) { - let cancelled = false; - toolMenuLoadRef.current.plugins = true; - setPluginsLoading(true); - void listImportedPlugins() - .then((next) => { - if (!cancelled && toolMenuLoadRef.current.openId === openId) { - setImportedPlugins(next); - setPluginsLoaded(true); - } - }) - .catch(() => { - if (!cancelled && toolMenuLoadRef.current.openId === openId) { - setImportedPlugins([]); - setPluginsLoaded(true); - } - }) - .finally(() => { - if (!cancelled && toolMenuLoadRef.current.openId === openId) setPluginsLoading(false); - }); - return () => { - cancelled = true; - }; - } - return undefined; - }, [toolMenuOpen]); - useEffect(() => { if (!toolMenuOpen) return; const openId = toolMenuLoadRef.current.openId; @@ -799,22 +730,10 @@ export function ReactSessionComposer(props: ComposerProps) { const toolSkillItems = commands.filter((command) => command.source === "skill"); const toolMcpItems = commands.filter((command) => command.source === "mcp"); void toolMcpItems; - const pluginSections = importedPlugins - .filter((plugin) => plugin.files.length > 0) - .map((plugin) => ({ section: `plugin:${plugin.pluginId}` as const, plugin })); - const activePlugin = toolMenuSection.startsWith("plugin:") - ? pluginSections.find((entry) => entry.section === toolMenuSection)?.plugin ?? null - : null; const composerExtensions = IPOLLOWORK_EXTENSION_CATALOG.filter((entry) => !builtInExtensionsDisabled && !isiPolloWorkExtensionHidden(entry) && isComposerExtensionAvailable(entry) ); - useEffect(() => { - if (!toolMenuSection.startsWith("plugin:")) return; - if (activePlugin) return; - setToolMenuSection("commands"); - }, [activePlugin, toolMenuSection]); - useEffect(() => { if (!activeItems.length) { setMenuIndex(0); @@ -855,21 +774,6 @@ export function ReactSessionComposer(props: ComposerProps) { setToolMenuOpen(false); }; - const applyPluginFileSelection = (file: CloudImportedPluginFile) => { - const commandName = pluginSlashCommandName(file); - if (commandName) { - if (file.objectType === "skill") applySkillSelection(commandName); - else applyCommandSelection({ - id: `plugin:${file.configObjectId}`, - name: commandName, - source: "command", - }); - return; - } - props.onInsertMention("file", file.path); - setToolMenuOpen(false); - }; - const applyExternalAgentSelection = (item: iPolloWorkPluginPackageItem) => { const prompt = item.manifest.composer?.prompt; if (!prompt) return; @@ -1502,18 +1406,6 @@ export function ReactSessionComposer(props: ComposerProps) { ))} - {pluginSections.length > 0 ?
: null} - {pluginSections.map(({ section, plugin }) => ( - - ))}
@@ -1630,36 +1522,6 @@ export function ReactSessionComposer(props: ComposerProps) {
{t("composer.no_extensions_enabled")}
) ) : null} - {activePlugin ? ( - activePlugin.files.length > 0 ? ( -
- {activePlugin.files.map((file) => ( - - ))} -
- ) : ( -
{t("composer.no_plugin_files")}
- ) - ) : toolMenuSection.startsWith("plugin:") ? ( -
- {!pluginsLoaded && pluginsLoading ? t("composer.loading_commands") : t("composer.plugin_files_unavailable")} -
- ) : null}
diff --git a/apps/app/src/react-app/domains/session/surface/session-surface.tsx b/apps/app/src/react-app/domains/session/surface/session-surface.tsx index 8435b8a4c..9abf1be1e 100644 --- a/apps/app/src/react-app/domains/session/surface/session-surface.tsx +++ b/apps/app/src/react-app/domains/session/surface/session-surface.tsx @@ -9,7 +9,6 @@ import { toast } from "@/components/ui/sonner"; import { captureAnalyticsEvent } from "@/app/lib/analytics"; import { createClient, unwrap } from "@/app/lib/opencode"; import { t } from "@/i18n"; -import { readWorkspaceCloudImports, type CloudImportedPlugin } from "@/app/cloud/import-state"; import type { HyperframesAnimationSelection, HyperframesCatalogItem, @@ -641,7 +640,6 @@ export function SessionSurface(props: SessionSurfaceProps) { const [toolMcpServers, setToolMcpServers] = useState([]); const [toolMcpStatus, setToolMcpStatus] = useState(null); const [toolMcpStatuses, setToolMcpStatuses] = useState({}); - const [toolImportedPlugins, setToolImportedPlugins] = useState([]); const [verifiedOpenTargets, setVerifiedOpenTargets] = useState([]); const [newConversationMode, setNewConversationMode] = useState("work"); const [starterCapability, setStarterCapability] = useState(null); @@ -1621,14 +1619,6 @@ export function SessionSurface(props: SessionSurfaceProps) { return { servers, statuses, status }; }; - const listImportedPlugins = async (): Promise => { - const response = await props.client.getConfig(props.workspaceId); - const plugins = Object.values(readWorkspaceCloudImports(response.ipollowork).plugins) - .sort((left, right) => left.name.localeCompare(right.name)); - setToolImportedPlugins(plugins); - return plugins; - }; - const listExternalAgents = async (): Promise => { const response = await props.client.listPluginPackages(props.workspaceId); return response.items @@ -1865,8 +1855,6 @@ export function SessionSurface(props: SessionSurfaceProps) { mcpServers={toolMcpServers} mcpStatus={toolMcpStatus} mcpStatuses={toolMcpStatuses} - listImportedPlugins={listImportedPlugins} - importedPlugins={toolImportedPlugins} listExternalAgents={listExternalAgents} onOpenSettingsSection={props.onOpenSettingsSection} recentFiles={props.recentFiles} diff --git a/apps/app/src/react-app/domains/session/sync/session-sync.ts b/apps/app/src/react-app/domains/session/sync/session-sync.ts index 7ae9e14b2..cd5b774c6 100644 --- a/apps/app/src/react-app/domains/session/sync/session-sync.ts +++ b/apps/app/src/react-app/domains/session/sync/session-sync.ts @@ -579,6 +579,10 @@ function applyEvent(entry: SyncEntry, workspaceId: string, event: ConversationEv if (event.type === "message.upsert") { useSessionActivityStore.getState().markMessageRole(workspaceId, event.sessionId, event.message.id, event.message.role); if (!isTrackedSession(entry, event.sessionId)) return; + // Some engines publish their authoritative assistant message immediately + // after the last streamed chunks. Flush those queued deltas first so the + // final message replaces the stream instead of receiving it a second time. + if (entry.deltaFlushBuffer.length > 0) flushDeltas(entry, workspaceId); queryClient.setQueryData(transcriptKey(workspaceId, event.sessionId), (current = []) => upsertMessage(current, event.message), ); diff --git a/apps/app/src/react-app/domains/settings/cloud/sections.tsx b/apps/app/src/react-app/domains/settings/cloud/sections.tsx index 55c2c8afc..9a100aa1f 100644 --- a/apps/app/src/react-app/domains/settings/cloud/sections.tsx +++ b/apps/app/src/react-app/domains/settings/cloud/sections.tsx @@ -1,10 +1,6 @@ /** @jsxImportSource react */ -import type { CloudImportedPlugin, CloudImportedProvider, CloudImportedSkill } from "../../../../app/cloud/import-state"; -import type { - DenOrgMarketplaceResolved, - DenOrgLlmProvider, - DenOrgPlugin, -} from "../../../../app/lib/den"; +import type { CloudImportedProvider, CloudImportedSkill } from "../../../../app/cloud/import-state"; +import type { DenOrgLlmProvider } from "../../../../app/lib/den"; import type { DenOrgSkillCard } from "../../../../app/types"; import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"; import { cva } from "class-variance-authority"; @@ -12,7 +8,6 @@ import fuzzysort from "fuzzysort"; import * as React from "react"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { Button } from "@/components/ui/button"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { RefreshButton, SettingsSection, @@ -59,13 +54,6 @@ export type CloudSkillRow = { installedName: string | null; }; -export type CloudPluginRow = { - marketplaceId: string; - plugin: DenOrgPlugin; - imported: CloudImportedPlugin | null; - status: "available" | "imported" | "out_of_sync"; -}; - const statusBadgeVariants = cva("", { variants: { tone: { @@ -78,7 +66,6 @@ const statusBadgeVariants = cva("", { }); const skillSearchKeys = ["title"]; -const pluginSearchKeys = ["plugin.name"]; const nameSearchKeys = ["name"]; function resourceStatusTone(status: string) { @@ -201,57 +188,6 @@ function CloudSkillListItem({ ); } -interface MarketplacePluginListItemProps { - actionId: string | null; - row: CloudPluginRow; - onImportPlugin: (marketplaceId: string | null, plugin: DenOrgPlugin) => void | Promise; -} - -function MarketplacePluginListItem({ - actionId, - row, - onImportPlugin, -}: MarketplacePluginListItemProps) { - const actionBusy = actionId === row.plugin.id; - const counts = Object.entries(row.plugin.componentCounts).flatMap(([type, count]) => - count > 0 ? [`${count} ${type}${count === 1 ? "" : "s"}`] : [], - ); - - return ( - - - - {row.plugin.name} - {row.status !== "available" ? ( - - {row.status === "imported" ? t("den.imported_badge") : t("den.out_of_sync_badge")} - - ) : null} - {counts.map((label) => ( - {label} - ))} - - - {row.plugin.description || "No description provided."} - - {row.imported?.files.length ? ( -
- Installed files: {row.imported.files.map((file) => file.path).join(", ")} -
- ) : null} -
- -
- ); -} - interface CloudProviderListItemProps { actionId: string | null; actionKind: ResourceActionKind | null; @@ -458,145 +394,6 @@ export function CloudSkillsSection({ ); } -export interface MarketplacePluginsSectionProps { - actionError: string | null; - actionId: string | null; - activeMarketplaceId: string | null; - busy: boolean; - marketplaces: DenOrgMarketplaceResolved[]; - rowsByMarketplace: Record; - statusError: string | null; - onImportPlugin: (marketplaceId: string | null, plugin: DenOrgPlugin) => void | Promise; - onRefresh: () => void | Promise; - onSelectMarketplace: (marketplaceId: string) => void; -} - -export function MarketplacePluginsSection({ - actionError, - actionId, - activeMarketplaceId, - busy, - marketplaces, - rowsByMarketplace, - statusError, - onImportPlugin, - onRefresh, - onSelectMarketplace, -}: MarketplacePluginsSectionProps) { - const { hasActiveOrg } = useCloudSession(); - const [searchQuery, setSearchQuery] = React.useState(""); - const selectedMarketplace = - marketplaces.find((entry) => entry.marketplace.id === activeMarketplaceId) ?? marketplaces[0] ?? null; - const selectedRows = selectedMarketplace ? rowsByMarketplace[selectedMarketplace.marketplace.id] ?? [] : []; - const visibleRows = useSearch({ items: selectedRows, keys: pluginSearchKeys, query: searchQuery }); - const pluginGroups = [ - { value: "available", label: t("settings.cloud.available"), rows: visibleRows.filter((row) => row.status === "available") }, - { value: "out_of_sync", label: t("den.out_of_sync_badge"), rows: visibleRows.filter((row) => row.status === "out_of_sync") }, - { value: "imported", label: t("den.imported_badge"), rows: visibleRows.filter((row) => row.status === "imported") }, - ].filter((group) => group.rows.length > 0); - - return ( - - - - - {t("settings.cloud.marketplaces_title")} - - - {t("settings.cloud.marketplaces_description")} - - - - - {t("den.refresh")} - - - - - {actionError ?? statusError ? ( - {actionError ?? statusError} - ) : null} - - {!busy && marketplaces.length === 0 ? ( - - {hasActiveOrg ? t("settings.cloud.no_marketplaces") : t("settings.cloud.choose_org_for_marketplaces")} - - ) : null} - - {marketplaces.length > 0 ? ( - - - {marketplaces.map((entry) => ( - - {entry.marketplace.name} - - ))} - - - - - {t("settings.cloud.search")} - - setSearchQuery(event.currentTarget.value)} - /> - {t("settings.cloud.search_plugin")} - - - - {visibleRows.length > 0 ? ( - - {pluginGroups.map((group) => ( - - - {group.label} - {group.rows.length} - - - - {group.rows.map((row) => ( - - ))} - - - - ))} - - ) : null} - - {selectedRows.length > 0 && visibleRows.length === 0 ? ( - {t("settings.cloud.no_plugins_match")} - ) : null} - - {selectedMarketplace && selectedRows.length === 0 ? ( - {t("settings.cloud.marketplace_empty")} - ) : null} - - - ) : null} - - ); -} - export interface CloudProvidersSectionProps { actionError: string | null; actionId: string | null; diff --git a/apps/app/src/react-app/domains/settings/extension-items.ts b/apps/app/src/react-app/domains/settings/extension-items.ts index bb269381a..eb165cc46 100644 --- a/apps/app/src/react-app/domains/settings/extension-items.ts +++ b/apps/app/src/react-app/domains/settings/extension-items.ts @@ -1,13 +1,11 @@ import { getMcpServerName, isBuiltIniPolloWorkExtension, type McpDirectoryInfo } from "../../../app/constants"; -import type { CloudImportedPlugin, CloudImportedPluginFile } from "../../../app/cloud/import-state"; -import type { PendingCloudPluginChange } from "../../../app/cloud/desktop-cloud-sync"; import { evaluateEnablement, type EnablementContext } from "../../../app/enablement"; import type { EnablementResult } from "../../../app/extensions"; -import type { DenExternalMcpConnection, DenOrgMarketplaceResolved, DenOrgPlugin } from "../../../app/lib/den"; +import type { DenExternalMcpConnection } from "../../../app/lib/den"; import type { McpServerEntry } from "../../../app/types"; import { t, type Language } from "../../../i18n"; -export type ExtensionItemSource = "builtin" | "marketplace" | "org-connection" | "mcp-directory" | "skill"; +export type ExtensionItemSource = "builtin" | "org-connection" | "mcp-connection" | "skill"; export type ExtensionInstallState = "available" | "installed" | "update_available"; export type ExtensionSetupState = "ready" | "needs_setup" | "partial"; @@ -29,12 +27,6 @@ export type ExtensionItem = { enablement: { active: boolean; results: EnablementResult[] } | null; resources: ExtensionResourceItem[]; builtInEntry?: McpDirectoryInfo; - marketplaceId?: string | null; - marketplaceName?: string; - plugin?: DenOrgPlugin; - importedPlugin?: CloudImportedPlugin; - /** Installed cloud plugin that was removed from the organization marketplace. */ - removedUpstream?: boolean; orgMcpConnection?: DenExternalMcpConnection; mcpEntry?: McpDirectoryInfo; skill?: { name: string; description?: string; path: string }; @@ -46,15 +38,11 @@ export type ExtensionItemBuildInput = { installedSkills: Array<{ name: string; description?: string; path: string }>; pluginPackageSkillNames?: string[]; pluginPackageMcpServerNames?: string[]; - importedCloudPlugins: Record; - pendingCloudPluginChanges?: Record; - cloudMarketplaces: DenOrgMarketplaceResolved[]; orgMcpConnections?: DenExternalMcpConnection[]; enablementContext: EnablementContext; isBuiltInConnected: (entry: McpDirectoryInfo) => boolean; }; -const MCP_IMPORT_PATH_PREFIX = "opencode.jsonc#mcp."; const HAN_TEXT_RE = /\p{Script=Han}/u; export function skillDescriptionForLocale(description: string | undefined, locale: Language): string { @@ -76,13 +64,6 @@ function setupStateFromEnablement(enablement: { active: boolean; results: Enable return enablement.results.some((result) => result.met) ? "partial" : "needs_setup"; } -function cloudPluginStatus(imported: CloudImportedPlugin | null, plugin: DenOrgPlugin): ExtensionInstallState { - if (!imported) return "available"; - const importedObjectCount = new Set(imported.files.map((file) => file.configObjectId)).size; - if (imported.updatedAt !== plugin.updatedAt || importedObjectCount !== plugin.memberCount) return "update_available"; - return "installed"; -} - export function isOrgMcpConnectionReady(connection: Pick) { return connection.credentialMode === "shared" ? connection.connected : connection.connectedForMe && connection.needsReconnect !== true; } @@ -133,37 +114,6 @@ function orgConnectionCanRender(connection: DenExternalMcpConnection) { return connection.credentialMode === "per_member" || connection.connected; } -function resourceFromImportedFile(file: CloudImportedPluginFile): ExtensionResourceItem { - return { - id: file.configObjectId, - type: file.objectType, - title: file.title, - path: file.path, - }; -} - -function childKeysForPlugin(plugin: CloudImportedPlugin) { - const mcpServerNames = new Set(); - const externalMcpConnectionIds = new Set(); - const skillPaths = new Set(); - const skillNames = new Set(); - for (const file of plugin.files) { - if (file.path.startsWith(MCP_IMPORT_PATH_PREFIX)) { - mcpServerNames.add(file.path.slice(MCP_IMPORT_PATH_PREFIX.length)); - } - if (file.externalMcpConnectionId) { - externalMcpConnectionIds.add(file.externalMcpConnectionId); - } - if (file.objectType === "skill") { - skillPaths.add(file.path); - const name = file.path.match(/^\.opencode\/skills\/(?:[^/]+\/)?([^/]+)\/SKILL\.md$/)?.[1]; - if (name) skillNames.add(name); - skillNames.add(file.title); - } - } - return { externalMcpConnectionIds, mcpServerNames, skillPaths, skillNames }; -} - export function buildExtensionItems(input: ExtensionItemBuildInput) { const builtInItems = input.quickConnect.filter(isBuiltIniPolloWorkExtension).map((entry): ExtensionItem => { const enablement = entry.extensionManifest?.enablement @@ -189,61 +139,6 @@ export function buildExtensionItems(input: ExtensionItemBuildInput) { }; }); - const cloudPluginItems = input.cloudMarketplaces.flatMap((marketplace) => marketplace.plugins.map((plugin): ExtensionItem => { - const imported = input.importedCloudPlugins[plugin.id] ?? null; - const manifest = plugin.extension?.manifest ?? undefined; - const enablement = manifest?.enablement ? evaluateEnablement(manifest.enablement, input.enablementContext) : null; - const pendingChange = input.pendingCloudPluginChanges?.[plugin.id]; - const installState = imported && pendingChange === "modified" ? "update_available" : cloudPluginStatus(imported, plugin); - const externalConnectionIds = new Set(imported?.files.flatMap((file) => file.externalMcpConnectionId ? [file.externalMcpConnectionId] : []) ?? []); - const connectionStates = [...externalConnectionIds].flatMap((id) => { - const connection = input.orgMcpConnections?.find((entry) => entry.id === id); - return connection ? [isOrgMcpConnectionReady(connection)] : [false]; - }); - const connectionSetupState = connectionStates.length === 0 - ? null - : connectionStates.every(Boolean) - ? "ready" - : "needs_setup"; - return { - id: `marketplace:${marketplace.marketplace.id}:${plugin.id}`, - source: "marketplace", - name: plugin.extension?.name ?? plugin.name, - description: plugin.extension?.description ?? plugin.description, - installState, - setupState: enablement ? setupStateFromEnablement(enablement) : installState === "available" ? "needs_setup" : connectionSetupState ?? "ready", - active: enablement?.active ?? (installState !== "available" && connectionSetupState !== "needs_setup"), - enablement, - resources: imported?.files.map(resourceFromImportedFile) ?? Object.entries(plugin.componentCounts).flatMap(([type, count]) => count > 0 ? [{ - id: `${plugin.id}:${type}`, - type, - title: `${count} ${type}${count === 1 ? "" : "s"}`, - }] : []), - marketplaceId: marketplace.marketplace.id, - marketplaceName: marketplace.marketplace.name, - plugin, - importedPlugin: imported, - }; - })); - - const importedPluginItems = Object.values(input.importedCloudPlugins).flatMap((plugin): ExtensionItem[] => { - if (cloudPluginItems.some((item) => item.importedPlugin?.pluginId === plugin.pluginId)) return []; - return [{ - id: `marketplace:installed:${plugin.pluginId}`, - source: "marketplace", - name: plugin.name, - description: plugin.description, - installState: "installed", - setupState: "ready", - active: true, - enablement: null, - resources: plugin.files.map(resourceFromImportedFile), - marketplaceId: plugin.marketplaceId, - importedPlugin: plugin, - removedUpstream: input.pendingCloudPluginChanges?.[plugin.pluginId] === "removed", - }]; - }); - const orgMcpConnectionItems = (input.orgMcpConnections ?? []).flatMap((connection): ExtensionItem[] => { if (!orgConnectionCanRender(connection)) return []; const ready = isOrgMcpConnectionReady(connection); @@ -268,30 +163,18 @@ export function buildExtensionItems(input: ExtensionItemBuildInput) { return renderableOrgConnections.some((connection) => orgConnectionMatchesQuickEntry(connection, entry)); }; - const groupedMcpServerNames = new Set(); - const groupedExternalMcpConnectionIds = new Set(); - const groupedSkillPaths = new Set(); const groupedSkillNames = new Set(); input.pluginPackageSkillNames?.forEach((value) => groupedSkillNames.add(value)); const pluginPackageMcpServerNames = new Set(input.pluginPackageMcpServerNames ?? []); - for (const plugin of Object.values(input.importedCloudPlugins)) { - const keys = childKeysForPlugin(plugin); - keys.externalMcpConnectionIds.forEach((value) => groupedExternalMcpConnectionIds.add(value)); - keys.mcpServerNames.forEach((value) => groupedMcpServerNames.add(value)); - keys.skillPaths.forEach((value) => groupedSkillPaths.add(value)); - keys.skillNames.forEach((value) => groupedSkillNames.add(value)); - } const standaloneMcpEntries = input.quickConnect.filter((entry) => { if (isBuiltIniPolloWorkExtension(entry)) return false; const serverName = getMcpServerName(entry); if (pluginPackageMcpServerNames.has(serverName)) return false; - if (groupedMcpServerNames.has(serverName)) return false; return input.mcpServers.some((server) => server.name === serverName); }); const standaloneSkillItems = input.installedSkills.filter((skill) => { - if ([...groupedSkillPaths].some((path) => skill.path.endsWith(path))) return false; if (groupedSkillNames.has(skill.name)) return false; return true; }).map((skill): ExtensionItem => ({ @@ -307,14 +190,11 @@ export function buildExtensionItems(input: ExtensionItemBuildInput) { skill, })); - const visibleOrgMcpConnectionItems = orgMcpConnectionItems.filter((item) => - !item.orgMcpConnection || !groupedExternalMcpConnectionIds.has(item.orgMcpConnection.id)); - return { // Org-managed MCP connections are beta, so keep them last in unified lists. - items: [...builtInItems, ...cloudPluginItems, ...importedPluginItems, ...standaloneMcpEntries.map((entry): ExtensionItem => ({ + items: [...builtInItems, ...standaloneMcpEntries.map((entry): ExtensionItem => ({ id: `mcp:${getMcpServerName(entry)}`, - source: "mcp-directory", + source: "mcp-connection", name: entry.name, description: entry.description, installState: "installed", @@ -323,10 +203,9 @@ export function buildExtensionItems(input: ExtensionItemBuildInput) { enablement: null, resources: [{ id: getMcpServerName(entry), type: "mcp", title: entry.name }], mcpEntry: entry, - })), ...standaloneSkillItems, ...visibleOrgMcpConnectionItems], + })), ...standaloneSkillItems, ...orgMcpConnectionItems], builtInItems, - cloudPluginItems: [...cloudPluginItems, ...importedPluginItems], - orgMcpConnectionItems: visibleOrgMcpConnectionItems, + orgMcpConnectionItems, installedMcpEntries: [ ...builtInItems.flatMap((item) => item.active && item.builtInEntry ? [item.builtInEntry] : []), ...standaloneMcpEntries, @@ -343,7 +222,6 @@ export function buildExtensionItems(input: ExtensionItemBuildInput) { if (isBuiltIniPolloWorkExtension(entry)) return false; if (entry.pluginPackageId) return false; const serverName = getMcpServerName(entry); - if (groupedMcpServerNames.has(serverName)) return false; if (hasRenderableOrgEquivalent(entry)) return false; return !input.mcpServers.some((server) => server.name === serverName); }), diff --git a/apps/app/src/react-app/domains/settings/pages/advanced-view-sections.tsx b/apps/app/src/react-app/domains/settings/pages/advanced-view-sections.tsx index 5a09cb1f5..da5caabf0 100644 --- a/apps/app/src/react-app/domains/settings/pages/advanced-view-sections.tsx +++ b/apps/app/src/react-app/domains/settings/pages/advanced-view-sections.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource react */ import { useState, type ComponentProps, type ReactNode } from "react"; -import { CircleAlert, Cpu, Database, Info, RefreshCcw, Server } from "lucide-react"; +import { CircleAlert, Cpu, Info, RefreshCcw, Server } from "lucide-react"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; @@ -320,16 +320,12 @@ export function AdvancedRuntimeSection(props: AdvancedRuntimeSectionProps) { ); } -interface AdvancedRuntimeMigrationSectionProps { - busy: boolean; - canMigrate: boolean; - migrationBusy: boolean; - migrationStatus: string | null; +interface AdvancedRuntimeConfigSectionProps { + canRead: boolean; configStatus: iPolloWorkRuntimeConfigStatus | null; configStatusBusy: boolean; configStatusError: string | null; onRefresh: () => Promise; - onMigrate: () => Promise; } function formatKeys(keys: string[]) { @@ -423,57 +419,42 @@ function RuntimeConfigSourceBlock(props: { ); } -export function AdvancedRuntimeMigrationSection(props: AdvancedRuntimeMigrationSectionProps) { +export function AdvancedRuntimeConfigSection(props: AdvancedRuntimeConfigSectionProps) { return ( - {t("settings.runtime_migration.title")} + {t("settings.runtime_config_sources.title")} - {t("settings.runtime_migration.description")} + {t("settings.runtime_config_sources.description")} - {t("settings.runtime_migration.move_title")} - - {t("settings.runtime_migration.move_description")} - - - {props.migrationStatus ? {props.migrationStatus} : null} {props.configStatusError ? {props.configStatusError} : null} {props.configStatus ? (
-
{t("settings.runtime_migration.effective_title")}
+
{t("settings.runtime_config_sources.effective_title")}
- {t("settings.runtime_migration.effective_description")} + {t("settings.runtime_config_sources.effective_description")}
- {t("settings.runtime_migration.show_injected_json")} + {t("settings.runtime_config_sources.show_injected_json")}
                   {JSON.stringify(props.configStatus.effectiveRuntime ?? props.configStatus.runtime, null, 2)}
                 
@@ -482,66 +463,41 @@ export function AdvancedRuntimeMigrationSection(props: AdvancedRuntimeMigrationS {props.configStatus.sources ? (
-
{t("settings.runtime_migration.sources_title")}
+
{t("settings.runtime_config_sources.sources_title")}
- {t("settings.runtime_migration.sources_description")} + {t("settings.runtime_config_sources.sources_description")}
) : null} -
-
{t("settings.runtime_migration.runtime_database")}
-
{t("settings.runtime_migration.stored_keys", { keys: formatKeys(props.configStatus.runtimeKeys) })}
-
-
-
{t("settings.runtime_migration.legacy_metadata")}
-
{props.configStatus.legacyiPolloWork.path}
- {props.configStatus.legacyiPolloWork.error ? ( -
{t("settings.runtime_migration.legacy_error", { error: props.configStatus.legacyiPolloWork.error })}
- ) : null} -
{t("settings.runtime_migration.migratable_keys", { keys: formatKeys(props.configStatus.legacyiPolloWork.keys) })}
-
-
-
{t("settings.runtime_migration.user_opencode_config")}
-
{props.configStatus.userOpencode.path}
-
{props.configStatus.userOpencode.exists ? t("settings.runtime_config.found") : t("settings.runtime_config.not_found")}
-
{t("settings.runtime_migration.user_owned_keys", { keys: formatKeys(props.configStatus.userOpencode.keys) })}
-
{t("settings.runtime_migration.migratable_keys", { keys: formatKeys(props.configStatus.userOpencode.migratableKeys) })}
-
-
-
{t("settings.runtime_migration.runtime_db_json")}
-
-                {JSON.stringify(props.configStatus.runtime, null, 2)}
-              
-
) : null} diff --git a/apps/app/src/react-app/domains/settings/pages/advanced-view-state.ts b/apps/app/src/react-app/domains/settings/pages/advanced-view-state.ts index ce386c94d..937a5f03f 100644 --- a/apps/app/src/react-app/domains/settings/pages/advanced-view-state.ts +++ b/apps/app/src/react-app/domains/settings/pages/advanced-view-state.ts @@ -8,8 +8,6 @@ type AdvancedLocalState = { deepLinkInput: string; deepLinkBusy: boolean; deepLinkStatus: string | null; - migrationBusy: boolean; - migrationStatus: string | null; }; type AdvancedLocalAction = @@ -25,10 +23,7 @@ type AdvancedLocalAction = | { type: "deepLinkStart" } | { type: "deepLinkStatus"; status: string | null } | { type: "deepLinkDone" } - | { type: "deepLinkSuccess"; status: string | null } - | { type: "migrationStart" } - | { type: "migrationStatus"; status: string | null } - | { type: "migrationDone" }; + | { type: "deepLinkSuccess"; status: string | null }; export const initialAdvancedLocalState: AdvancedLocalState = { reconnectStatus: null, @@ -40,8 +35,6 @@ export const initialAdvancedLocalState: AdvancedLocalState = { deepLinkInput: "", deepLinkBusy: false, deepLinkStatus: null, - migrationBusy: false, - migrationStatus: null, }; export function advancedLocalReducer( @@ -75,11 +68,5 @@ export function advancedLocalReducer( return { ...state, deepLinkBusy: false }; case "deepLinkSuccess": return { ...state, deepLinkInput: "", deepLinkStatus: action.status }; - case "migrationStart": - return { ...state, migrationBusy: true, migrationStatus: null }; - case "migrationStatus": - return { ...state, migrationStatus: action.status }; - case "migrationDone": - return { ...state, migrationBusy: false }; } } diff --git a/apps/app/src/react-app/domains/settings/pages/advanced-view.tsx b/apps/app/src/react-app/domains/settings/pages/advanced-view.tsx index 6c65da0e5..90de65f4d 100644 --- a/apps/app/src/react-app/domains/settings/pages/advanced-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/advanced-view.tsx @@ -13,7 +13,7 @@ import { advancedLocalReducer, initialAdvancedLocalState } from "./advanced-view import { AdvancedDeveloperSection, AdvancedOrganizationServerSection, - AdvancedRuntimeMigrationSection, + AdvancedRuntimeConfigSection, AdvancedRuntimeSection, } from "./advanced-view-sections"; @@ -40,8 +40,7 @@ export type AdvancedViewProps = { toggleDeveloperMode: () => void; opencodeDevModeEnabled: boolean; openDebugDeepLink: (rawUrl: string) => Promise<{ ok: boolean; message: string }>; - canMigrateRuntimeConfig: boolean; - migrateRuntimeConfig: () => Promise<{ migrated: boolean; keys: string[] }>; + canReadRuntimeConfig: boolean; getRuntimeConfigStatus: () => Promise; organizationServer: AdvancedOrganizationServerSession; cloudMcpUrl: string | null; @@ -62,8 +61,6 @@ export function AdvancedView(props: AdvancedViewProps) { deepLinkInput: debugDeepLinkInput, deepLinkBusy: debugDeepLinkBusy, deepLinkStatus: debugDeepLinkStatus, - migrationBusy, - migrationStatus, } = localState; const clientStatusLabel = (() => { @@ -110,7 +107,7 @@ export function AdvancedView(props: AdvancedViewProps) { ]; const ipolloworkDetailLines = props.ipolloworkServerStatus === "connected" - ? ["Runtime DB, workspace config, and migration diagnostics are available."] + ? ["Runtime DB and workspace config diagnostics are available."] : ["Runtime config diagnostics need the iPolloWork server connection."]; const submitDebugDeepLink = async () => { @@ -135,7 +132,7 @@ export function AdvancedView(props: AdvancedViewProps) { }; const refreshRuntimeConfigStatus = async () => { - if (!props.canMigrateRuntimeConfig) { + if (!props.canReadRuntimeConfig) { setConfigStatus(null); return; } @@ -152,29 +149,7 @@ export function AdvancedView(props: AdvancedViewProps) { useEffect(() => { void refreshRuntimeConfigStatus(); - }, [props.canMigrateRuntimeConfig]); - - const migrateRuntimeConfig = async () => { - if (props.busy || migrationBusy || !props.canMigrateRuntimeConfig) return; - dispatchLocal({ type: "migrationStart" }); - try { - const result = await props.migrateRuntimeConfig(); - await refreshRuntimeConfigStatus(); - dispatchLocal({ - type: "migrationStatus", - status: result.migrated - ? `Migrated legacy runtime config: ${result.keys.join(", ")}.` - : "No legacy runtime config found for this workspace.", - }); - } catch (error) { - dispatchLocal({ - type: "migrationStatus", - status: error instanceof Error ? error.message : "Failed to migrate legacy runtime config.", - }); - } finally { - dispatchLocal({ type: "migrationDone" }); - } - }; + }, [props.canReadRuntimeConfig]); return ( @@ -201,16 +176,12 @@ export function AdvancedView(props: AdvancedViewProps) { ipolloworkDetailLines={ipolloworkDetailLines} /> - Promise | void; }; -type CloudMarketplaceItem = ExtensionItem & { plugin: DenOrgPlugin }; - function denManageConnectionsUrl() { return new URL("/dashboard/mcp-connections", readDenSettings().baseUrl).toString(); } @@ -168,10 +165,6 @@ function ConnectSignInPanel(props: ConnectViewProps) { ); } -function isCloudMarketplaceItem(item: ExtensionItem): item is CloudMarketplaceItem { - return Boolean(item.plugin); -} - type ConnectOrganizationRow = | { kind: "connection"; @@ -229,7 +222,7 @@ function rowSearchText(row: ConnectOrganizationRow) { function buildConnectRows(input: { connections: DenExternalMcpConnection[]; - items: ExtensionItem[]; + plugins: DenOrgPlugin[]; role: "owner" | "admin" | "member" | null | undefined; }) { const connectionRows: ConnectOrganizationRow[] = input.connections.map((connection) => ({ @@ -242,17 +235,17 @@ function buildConnectRows(input: { connection, })); - const pluginRows: ConnectOrganizationRow[] = input.items.filter(isCloudMarketplaceItem).flatMap((item) => { - const group = resolveConnectRowGroup(item.plugin.cloudReadiness, input.role, item.plugin.componentCounts); + const pluginRows: ConnectOrganizationRow[] = input.plugins.flatMap((plugin) => { + const group = resolveConnectRowGroup(plugin.cloudReadiness, input.role, plugin.componentCounts); if (group === "excluded") return []; return [{ kind: "plugin", - id: item.plugin.id, + id: plugin.id, group, - name: item.plugin.name, - description: item.plugin.description ?? "", - meta: formatPluginConnectRowMeta(item.plugin), - plugin: item.plugin, + name: plugin.name, + description: plugin.description ?? "", + meta: formatPluginConnectRowMeta(plugin), + plugin, }]; }); @@ -337,13 +330,13 @@ function ConnectOrganizationList(props: { connectingId: string | null; disconnectingId: string | null; connections: DenExternalMcpConnection[]; - items: ExtensionItem[]; + plugins: DenOrgPlugin[]; onConnect: (connectionId: string) => void; onDisconnect: (connectionId: string) => void; role: "owner" | "admin" | "member" | null | undefined; }) { const [search, setSearch] = useState(""); - const rows = useMemo(() => buildConnectRows({ connections: props.connections, items: props.items, role: props.role }), [props.connections, props.items, props.role]); + const rows = useMemo(() => buildConnectRows({ connections: props.connections, plugins: props.plugins, role: props.role }), [props.connections, props.plugins, props.role]); const query = search.trim().toLowerCase(); const filteredRows = query ? rows.filter((row) => rowSearchText(row).includes(query)) : rows; const rowsByGroup = new Map(); @@ -407,7 +400,7 @@ function ConnectOrganizationList(props: { function ConnectActivePanel(props: { connections: DenExternalMcpConnection[]; - marketplaceItems: ExtensionItem[]; + organizationPlugins: DenOrgPlugin[]; loading: boolean; error: string | null; connectingId: string | null; @@ -435,7 +428,7 @@ function ConnectActivePanel(props: {
{t("settings.cap_skills", { value: formatCapability(props.ipolloworkServerCapabilities.skills) })}
-
{t("settings.cap_plugins", { value: formatCapability(props.ipolloworkServerCapabilities.plugins) })}
{t("settings.cap_mcp", { value: formatCapability(props.ipolloworkServerCapabilities.mcp) })}
{t("settings.cap_commands", { value: formatCapability(props.ipolloworkServerCapabilities.commands) })}
{t("settings.cap_config", { value: formatCapability(props.ipolloworkServerCapabilities.config) })}
diff --git a/apps/app/src/react-app/domains/settings/pages/extensions-view.tsx b/apps/app/src/react-app/domains/settings/pages/extensions-view.tsx index 9f6ff13ce..6f554aa0a 100644 --- a/apps/app/src/react-app/domains/settings/pages/extensions-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/extensions-view.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource react */ import { useEffect, useMemo, useState, type ReactNode } from "react"; -import { Building2, Cpu, Loader2, Package, RefreshCw } from "lucide-react"; +import { Building2, Loader2, Package, RefreshCw } from "lucide-react"; import { t } from "../../../../i18n"; import { Button } from "@/components/ui/button"; @@ -16,35 +16,9 @@ import { useActiveEnterpriseConnection } from "@/react-app/domains/enterprise/us import { WorkResourceScopeSwitch } from "@/react-app/domains/enterprise/work-resource-scope-switch"; import { readPluginPackageArchive } from "@/react-app/domains/settings/plugin-package-archive"; -import { PluginsView, type PluginsExtensionsStore } from "./plugins-view"; - export type ExtensionsSection = "all" | "mcp" | "skills" | "plugins"; -type SuggestedPlugin = { - name: string; - packageName: string; - description: string; - tags: string[]; - aliases?: string[]; - installMode?: "simple" | "guided"; - steps?: Array<{ - title: string; - description: string; - command?: string; - url?: string; - path?: string; - note?: string; - }>; -}; - export type ExtensionsViewProps = { - busy: boolean; - selectedWorkspaceRoot: string; - canEditPlugins: boolean; - canUseGlobalScope: boolean; - accessHint?: string | null; - suggestedPlugins: SuggestedPlugin[]; - extensions: PluginsExtensionsStore; client: iPolloWorkServerClient | null; workspaceId: string | null; pluginPackagesView: ReactNode; @@ -61,7 +35,6 @@ export function ExtensionsView(props: ExtensionsViewProps) { const [enterpriseError, setEnterpriseError] = useState(null); const [enterpriseBusyId, setEnterpriseBusyId] = useState(null); const [enterpriseRefreshRevision, setEnterpriseRefreshRevision] = useState(0); - const pluginCount = useMemo(() => props.extensions.pluginList().length, [props.extensions]); useEffect(() => { setResourceScope(readActiveWorkContextId()); @@ -107,7 +80,6 @@ export function ExtensionsView(props: ExtensionsViewProps) { const result = await props.client.importPluginPackage(props.workspaceId, upload); setInstalledEnterpriseExtensionVersions((versions) => new Map(versions).set(result.result.pluginId, result.result.version)); toast.success(`${resource.name} v${result.result.version}`); - await props.extensions.refreshPlugins(); } catch (error) { const message = error instanceof Error ? error.message : ""; toast.error(message === "desktop_binary_fetch_requires_restart" @@ -178,31 +150,7 @@ export function ExtensionsView(props: ExtensionsViewProps) { {t("enterprise_connection.enterprise_extensions_empty")}
) - ) : props.activeTab === "skills" ? props.skillsView : ( -
- {props.pluginPackagesView} - {pluginCount > 0 ? ( -
- - - {t("settings.extensions.opencode_plugins")} - ({pluginCount}) - -
- -
-
- ) : null} -
- )} + ) : props.activeTab === "skills" ? props.skillsView : props.pluginPackagesView} ); } diff --git a/apps/app/src/react-app/domains/settings/pages/mcp-view.tsx b/apps/app/src/react-app/domains/settings/pages/mcp-view.tsx index c375392af..57b6f32c1 100644 --- a/apps/app/src/react-app/domains/settings/pages/mcp-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/mcp-view.tsx @@ -8,7 +8,6 @@ import { Cloud, Code2, CreditCard, - Download, ExternalLink, FolderOpen, Globe, @@ -51,9 +50,7 @@ import { Button } from "@/components/ui/button"; import { settingsStandardContentClass } from "@/react-app/domains/settings/shell/panel"; import { ConfirmModal } from "../../../design-system/modals/confirm-modal"; import { AddMcpModal } from "../../connections/modals/add-mcp-modal"; -import { ClaudePluginImportModal } from "../../connections/modals/claude-plugin-import-modal"; import { canDisconnectNativeProviderAccount } from "../../connections/native-provider-connections"; -import type { iPolloWorkClaudePluginPreview } from "../../../../app/lib/ipollowork-server"; import { isiPolloWorkExtensionEnabled, isiPolloWorkExtensionHidden, @@ -118,10 +115,6 @@ export type McpViewProps = { enablementContext?: import("../../../../app/enablement").EnablementContext; /** Organization policy restriction for iPolloWork-provided built-in extensions. */ builtInExtensionsDisabled?: boolean; - /** Preview a Claude Code plugin bundle from a GitHub URL ("Will install" disclosure). */ - previewClaudePlugin?: (url: string) => Promise; - /** Install a Claude Code plugin bundle from a GitHub URL. */ - installClaudePlugin?: (url: string) => Promise<{ ok: boolean; message: string }>; /** Connected org-level External MCP Connections rendered in My Extensions. */ installedOrgMcpItems?: ExtensionItem[]; orgMcpDisconnectingId?: string | null; @@ -248,7 +241,6 @@ export function McpView(props: McpViewProps) { const [search, setSearch] = useState(""); const [filter, setFilter] = useState("all"); const [showHidden, setShowHidden] = useState(false); - const [claudeImportOpen, setClaudeImportOpen] = useState(false); const [, setExtensionStateVersion] = useState(0); const [localState, dispatchLocal] = useReducer( @@ -520,14 +512,7 @@ export function McpView(props: McpViewProps) { ) : null} - setAddMcpModalOpen(true)} - onOpenGithubImport={ - props.previewClaudePlugin && props.installClaudePlugin - ? () => setClaudeImportOpen(true) - : undefined - } - /> + setAddMcpModalOpen(true)} /> {/* Search + filter */}
@@ -712,15 +697,6 @@ export function McpView(props: McpViewProps) { isRemoteWorkspace={props.isRemoteWorkspace} /> - {props.previewClaudePlugin && props.installClaudePlugin ? ( - setClaudeImportOpen(false)} - onPreview={props.previewClaudePlugin} - onInstall={props.installClaudePlugin} - /> - ) : null} - {detailEntry ? (() => { const extensionConfigSlot = props.configSlotForEntry?.(detailEntry) ?? null; const hasConfigSlot = extensionConfigSlot !== null; @@ -854,7 +830,7 @@ function McpViewHeader(props: { connectedCount: number }) { ); } -function McpCustomAppCard(props: { onOpen: () => void; onOpenGithubImport?: () => void }) { +function McpCustomAppCard(props: { onOpen: () => void }) { return (
@@ -863,12 +839,6 @@ function McpCustomAppCard(props: { onOpen: () => void; onOpenGithubImport?: () =
{t("mcp.custom_app_cta_hint")}
- {props.onOpenGithubImport ? ( - - ) : null} - - -
-
- -
-
{t("plugins.config_label")}
-
- {extensions.pluginConfigPath() ?? - extensions.pluginConfig()?.path ?? - t("plugins.not_loaded_yet")} -
- {props.accessHint ? ( -
{props.accessHint}
- ) : null} -
- -
-
- {t("plugins.suggested_heading")} -
-
- {props.suggestedPlugins.map((plugin) => { - const isGuided = plugin.installMode === "guided"; - const isInstalled = extensions.isPluginInstalledByName( - plugin.packageName, - plugin.aliases ?? [], - ); - const isGuideOpen = - extensions.activePluginGuide() === plugin.packageName; - - return ( -
-
-
-
- {plugin.name} -
-
- {plugin.description} -
- {plugin.packageName !== plugin.name ? ( -
- {plugin.packageName} -
- ) : null} -
-
- {isGuided ? ( - - ) : null} - -
-
-
- {plugin.tags.map((tag) => ( - - {tag} - - ))} -
- {isGuided && isGuideOpen ? ( -
- {(plugin.steps ?? []).map((step, idx) => ( -
-
- {idx + 1}. {step.title} -
-
- {step.description} -
- {step.command ? ( -
- {step.command} -
- ) : null} - {step.note ? ( -
- {step.note} -
- ) : null} - {step.url ? ( -
- Open:{" "} - - {step.url} - -
- ) : null} - {step.path ? ( -
- Path:{" "} - - {step.path} - -
- ) : null} -
- ))} -
- ) : null} -
- ); - })} -
-
- - {extensions.pluginList().length === 0 ? ( -
- {t("plugins.empty")} -
- ) : ( -
- {extensions.pluginList().map((plugin) => ( -
-
-
- - {plugin.name} -
- {!plugin.removable ? ( -
- {plugin.source === "dir.global" - ? "Discovered from a global plugin folder." - : "Discovered from the workspace plugin folder."} -
- ) : null} -
-
-
- {plugin.removable ? t("plugins.enabled") : t("settings.cap_read_only")} -
- {plugin.removable ? ( - - ) : null} -
-
- ))} -
- )} - -
-
-
- - extensions.setPluginInput(event.currentTarget.value) - } - hint={t("plugins.add_hint")} - /> -
- -
- {extensions.pluginStatus() ? ( -
- {extensions.pluginStatus()} -
- ) : null} -
-
- - ); -} diff --git a/apps/app/src/react-app/domains/settings/plugin-package-import-modal.tsx b/apps/app/src/react-app/domains/settings/plugin-package-import-modal.tsx index 59d2a8894..f03d1b5d8 100644 --- a/apps/app/src/react-app/domains/settings/plugin-package-import-modal.tsx +++ b/apps/app/src/react-app/domains/settings/plugin-package-import-modal.tsx @@ -1,8 +1,9 @@ /** @jsxImportSource react */ import { useMemo, useRef, useState } from "react"; -import { Archive, Bot, FileText, Loader2, Package, ShieldCheck, Upload } from "lucide-react"; +import { Archive, Bot, FileText, Github, Loader2, Package, Search, ShieldCheck, Upload } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { toast } from "@/components/ui/sonner"; import { Dialog, @@ -36,7 +37,11 @@ export function PluginPackageImportModal(props: PluginPackageImportModalProps) { const locale = currentLocale(); const inputRef = useRef(null); const operationRef = useRef(0); + const [source, setSource] = useState<"file" | "github">("file"); const [upload, setUpload] = useState(null); + const [githubUrl, setGithubUrl] = useState(""); + const [previewedGithubUrl, setPreviewedGithubUrl] = useState(null); + const [sourceWarnings, setSourceWarnings] = useState([]); const [preview, setPreview] = useState(null); const [busy, setBusy] = useState<"preview" | "install" | null>(null); const [error, setError] = useState(null); @@ -50,6 +55,9 @@ export function PluginPackageImportModal(props: PluginPackageImportModalProps) { const reset = () => { setUpload(null); + setGithubUrl(""); + setPreviewedGithubUrl(null); + setSourceWarnings([]); setPreview(null); setBusy(null); setError(null); @@ -72,6 +80,8 @@ export function PluginPackageImportModal(props: PluginPackageImportModalProps) { const response = await props.client.validatePluginPackageUpload(props.workspaceId, nextUpload); if (operationRef.current !== operation) return; setUpload(nextUpload); + setPreviewedGithubUrl(null); + setSourceWarnings([]); setPreview(response.preview); } catch (cause) { if (operationRef.current !== operation) return; @@ -82,13 +92,41 @@ export function PluginPackageImportModal(props: PluginPackageImportModalProps) { } }; + const previewGithub = async () => { + const url = githubUrl.trim(); + if (!url) return; + const operation = ++operationRef.current; + setBusy("preview"); + setError(null); + setPreview(null); + setUpload(null); + setPreviewedGithubUrl(null); + try { + const response = await props.client.previewGithubPluginPackage(props.workspaceId, { url }); + if (operationRef.current !== operation) return; + setPreview(response.preview); + setPreviewedGithubUrl(url); + setSourceWarnings(response.source.warnings); + } catch (cause) { + if (operationRef.current !== operation) return; + setError(formatPluginPlatformError(cause, t("plugin_platform.import_error"))); + } finally { + if (operationRef.current === operation) setBusy(null); + } + }; + const install = async () => { - if (!upload || !preview) return; + if (!preview || (source === "file" ? !upload : !previewedGithubUrl)) return; const operation = ++operationRef.current; setBusy("install"); setError(null); try { - const response = await props.client.importPluginPackage(props.workspaceId, upload); + const response = source === "file" && upload + ? await props.client.importPluginPackage(props.workspaceId, upload) + : previewedGithubUrl + ? await props.client.importGithubPluginPackage(props.workspaceId, { url: previewedGithubUrl }) + : null; + if (!response) return; await props.onInstalled(response.result.pluginId); toast.success(t("plugin_platform.status.installed")); if (operationRef.current !== operation) return; @@ -127,25 +165,43 @@ export function PluginPackageImportModal(props: PluginPackageImportModalProps) { }} /> -
- -

- {upload?.archiveName ?? t("plugin_platform.import_choose_title")} -

-

{t("plugin_platform.import_choose_description")}

- +
+ {source === "file" ? ( +
+ +

+ {upload?.archiveName ?? t("plugin_platform.import_choose_title")} +

+

{t("plugin_platform.import_choose_description")}

+ +
+ ) : ( +
+
+

{t("plugin_platform.import_github_title")}

+

{t("plugin_platform.import_github_description")}

+
+
+ { setGithubUrl(event.currentTarget.value); setPreview(null); setPreviewedGithubUrl(null); }} placeholder="https://github.com/owner/repository" disabled={busy !== null} /> + +
+
+ )} + {preview && previewManifest ? (
@@ -194,6 +250,11 @@ export function PluginPackageImportModal(props: PluginPackageImportModalProps) { ) : null} {error ?
{error}
: null} + {sourceWarnings.length > 0 ? ( +
+ {sourceWarnings.map((warning) =>
{warning}
)} +
+ ) : null}
diff --git a/apps/app/src/react-app/domains/settings/state/extensions-store.ts b/apps/app/src/react-app/domains/settings/state/extensions-store.ts index f1ad193c6..9d5613031 100644 --- a/apps/app/src/react-app/domains/settings/state/extensions-store.ts +++ b/apps/app/src/react-app/domains/settings/state/extensions-store.ts @@ -1,26 +1,17 @@ import * as React from "react"; -import { applyEdits, modify } from "jsonc-parser"; - import { t } from "../../../../i18n"; import type { Client, DenOrgSkillCard, HubSkillCard, HubSkillRepo, - PluginScope, ReloadReason, ReloadTrigger, SkillCard, } from "../../../../app/types"; import { addOpencodeCacheHint, isDesktopRuntime, normalizeDirectoryPath } from "../../../../app/utils"; import skillCreatorTemplate from "../../../../app/data/skill-creator.md?raw"; -import { - isPluginInstalled, - loadPluginsFromConfig as loadPluginsFromConfigHelpers, - parsePluginListFromContent, - stripPluginVersion, -} from "../../../../app/utils/plugins"; import { importSkill, installSkillTemplate, @@ -29,17 +20,13 @@ import { openDesktopPath, pickDirectory, readLocalSkill, - readOpencodeConfig, revealDesktopItemInDir, uninstallSkill as uninstallSkillCommand, workspaceiPolloWorkRead, workspaceiPolloWorkWrite, writeLocalSkill, - writeOpencodeConfig, - type OpencodeConfigFile, } from "../../../../app/lib/desktop"; import type { - iPolloWorkClaudePluginPreview, iPolloWorkHubRepo, iPolloWorkServerCapabilities, iPolloWorkServerClient, @@ -50,29 +37,15 @@ import { fetchDenOrgSkillsCatalog, readDenSettings, type DenOrgMarketplaceResolved, - type DenOrgPlugin, - type DenOrgPluginResolved, } from "../../../../app/lib/den"; import { readWorkspaceCloudImports, withWorkspaceCloudImports, - type CloudImportedMarketplace, - type CloudImportedPlugin, - type CloudImportedPluginFile, type CloudImportedSkill, } from "../../../../app/cloud/import-state"; -import { - derivePendingCloudPluginChanges, - readPendingCloudSyncChanges, - refreshDesktopCloudSync, - type PendingCloudPluginChange, -} from "../../../../app/cloud/desktop-cloud-sync"; -import { notifyEvent } from "../../../shell/notifications"; import type { iPolloWorkServerStore } from "../../connections/ipollowork-server-store"; const OPENCODE_SKILL_NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/; -const OPENCODE_MCP_NAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_-]*$/; -const OPENCODE_MCP_IMPORT_PATH_PREFIX = "opencode.jsonc#mcp."; const DEFAULT_HUB_REPO: HubSkillRepo = { owner: "different-ai", repo: "ipollowork-hub", @@ -82,12 +55,6 @@ const HUB_REPOS_STORAGE_KEY = "ipollowork.skills.hubRepos.v1"; type SetStateAction = T | ((current: T) => T); -type PluginListEntry = { - name: string; - source: "config" | "dir.project" | "dir.global"; - removable: boolean; -}; - export type ExtensionsStoreSnapshot = { workspaceContextKey: string; skills: SkillCard[]; @@ -99,29 +66,15 @@ export type ExtensionsStoreSnapshot = { importedCloudSkills: Record; cloudOrgMarketplaces: DenOrgMarketplaceResolved[]; cloudOrgMarketplacesStatus: string | null; - importedCloudMarketplaces: Record; - importedCloudPlugins: Record; - pendingCloudPluginChanges: Record; hubRepo: HubSkillRepo | null; hubRepos: HubSkillRepo[]; - pluginScope: PluginScope; - pluginConfig: OpencodeConfigFile | null; - pluginConfigPath: string | null; - pluginList: PluginListEntry[]; - pluginInput: string; - pluginStatus: string | null; - activePluginGuide: string | null; - sidebarPluginList: string[]; - sidebarPluginStatus: string | null; skillsStale: boolean; - pluginsStale: boolean; hubSkillsStale: boolean; cloudOrgSkillsStale: boolean; }; type MutableState = { skillsContextKey: string; - pluginsContextKey: string; hubSkillsContextKey: string; cloudOrgSkillsContextKey: string; skills: SkillCard[]; @@ -133,20 +86,8 @@ type MutableState = { importedCloudSkills: Record; cloudOrgMarketplaces: DenOrgMarketplaceResolved[]; cloudOrgMarketplacesStatus: string | null; - importedCloudMarketplaces: Record; - importedCloudPlugins: Record; - pendingCloudPluginChanges: Record; hubRepo: HubSkillRepo | null; hubRepos: HubSkillRepo[]; - pluginScope: PluginScope; - pluginConfig: OpencodeConfigFile | null; - pluginConfigPath: string | null; - pluginList: PluginListEntry[]; - pluginInput: string; - pluginStatus: string | null; - activePluginGuide: string | null; - sidebarPluginList: string[]; - sidebarPluginStatus: string | null; }; export type ExtensionsStore = ReturnType; @@ -160,125 +101,6 @@ function extractSkillBodyMarkdown(skillText: string): string { return rest.slice(end + 4).replace(/^\s*\n?/, ""); } -function stripYamlScalarQuotes(value: string): string { - const trimmed = value.trim(); - if ( - trimmed.length >= 2 && - ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) - ) { - return trimmed.slice(1, -1); - } - return trimmed; -} - -function parseClaudeFrontmatter(text: string): { data: Record; body: string } { - const trimmed = text.trim(); - const match = trimmed.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); - if (!match) return { data: {}, body: trimmed }; - const data: Record = {}; - let listKey: string | null = null; - for (const line of (match[1] ?? "").split(/\r?\n/)) { - if (listKey) { - const listItem = line.match(/^\s+-\s*(.*)$/); - if (listItem) { - const entry = stripYamlScalarQuotes(listItem[1] ?? ""); - const current = data[listKey]; - if (entry && Array.isArray(current)) current.push(entry); - continue; - } - } - const keyMatch = line.match(/^([A-Za-z0-9_-]+):(.*)$/); - if (!keyMatch) continue; - const key = keyMatch[1] ?? ""; - const value = (keyMatch[2] ?? "").trim(); - if (!value) { - data[key] = []; - listKey = key; - continue; - } - listKey = null; - data[key] = value === "true" ? true : value === "false" ? false : stripYamlScalarQuotes(value); - } - return { data, body: trimmed.slice(match[0].length) }; -} - -const OPENCODE_MODEL_ID_RE = /^[^\s/]+\/[^\s]+$/; - -function translateClaudeTools(value: unknown): Record | null { - const names = typeof value === "string" - ? value.split(",") - : Array.isArray(value) - ? value.flatMap((entry) => (typeof entry === "string" ? [entry] : [])) - : null; - if (names) { - const tools: Record = {}; - for (const raw of names) { - const name = raw.trim().toLowerCase(); - if (name) tools[name] = true; - } - return Object.keys(tools).length ? tools : null; - } - if (isRecord(value)) { - const tools: Record = {}; - for (const [key, entry] of Object.entries(value)) { - const name = key.trim().toLowerCase(); - if (name && typeof entry === "boolean") tools[name] = entry; - } - return Object.keys(tools).length ? tools : null; - } - return null; -} - -function translateClaudeModel(value: unknown): string | null { - const model = readNonEmptyString(value); - return model && OPENCODE_MODEL_ID_RE.test(model) ? model : null; -} - -function buildCloudPluginFrontmatter(data: Record>): string { - const lines: string[] = ["---"]; - for (const [key, value] of Object.entries(data)) { - if (typeof value === "string") { - lines.push(`${key}: ${JSON.stringify(value)}`); - } else if (typeof value === "boolean") { - lines.push(`${key}: ${value}`); - } else { - lines.push(`${key}:`); - for (const [name, enabled] of Object.entries(value)) { - lines.push(` ${JSON.stringify(name)}: ${enabled}`); - } - } - } - lines.push("---"); - return lines.join("\n") + "\n"; -} - -function buildCloudAgentContent(description: string, rawSourceText: string): string { - const { data, body } = parseClaudeFrontmatter(rawSourceText); - const safeDescription = (readNonEmptyString(data.description) ?? description).replace(/\s+/g, " ").trim(); - const model = translateClaudeModel(data.model); - const tools = translateClaudeTools(data.tools); - const frontmatter = buildCloudPluginFrontmatter({ - description: safeDescription, - ...(model ? { model } : {}), - ...(tools ? { tools } : {}), - }); - return frontmatter + "\n" + body.replace(/^\s*\n?/, ""); -} - -function buildCloudCommandContent(name: string, description: string, rawSourceText: string): string { - const { data, body } = parseClaudeFrontmatter(rawSourceText); - const safeDescription = (readNonEmptyString(data.description) ?? description).replace(/\s+/g, " ").trim(); - const model = translateClaudeModel(data.model); - const agent = readNonEmptyString(data.agent); - const frontmatter = buildCloudPluginFrontmatter({ - name, - description: safeDescription, - ...(agent ? { agent } : {}), - ...(model ? { model } : {}), - ...(typeof data.subtask === "boolean" ? { subtask: data.subtask } : {}), - }); - return frontmatter + "\n" + body.replace(/^\s*\n?/, ""); -} function slugifyOpencodeSkillName(title: string): string { let base = title @@ -305,92 +127,12 @@ function uniqueSkillInstallName(base: string, taken: Set, stableSuffix: return `skill-${suffixSource}`.slice(0, 64); } -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function parseJsonRecord(value: string | null): Record | null { - if (!value?.trim()) return null; - try { - const parsed: unknown = JSON.parse(value); - return isRecord(parsed) ? parsed : null; - } catch { - return null; - } -} - -function readNonEmptyString(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - -function readStringArray(value: unknown): string[] { - return Array.isArray(value) - ? value.flatMap((entry) => { - const text = readNonEmptyString(entry); - return text ? [text] : []; - }) - : []; -} - -function readStringRecord(value: unknown): Record | null { - if (!isRecord(value)) return null; - const output: Record = {}; - for (const [key, entry] of Object.entries(value)) { - const text = readNonEmptyString(entry); - if (text) output[key] = text; - } - return Object.keys(output).length ? output : null; -} - -function cloudPluginMcpNameFromPath(path: string): string | null { - if (!path.startsWith(OPENCODE_MCP_IMPORT_PATH_PREFIX)) return null; - const name = path.slice(OPENCODE_MCP_IMPORT_PATH_PREFIX.length).trim(); - return OPENCODE_MCP_NAME_RE.test(name) ? name : null; -} - -function toConfigPluginListEntries(names: string[]): PluginListEntry[] { - const next: PluginListEntry[] = []; - const seen = new Set(); - for (const rawName of names) { - const name = rawName.trim(); - if (!name || seen.has(name)) continue; - seen.add(name); - next.push({ name, source: "config", removable: true }); - } - return next; -} - -function toProjectPluginListEntries( - items: Array<{ spec: string; source: string }>, -): PluginListEntry[] { - const byName = new Map(); - for (const item of items) { - const name = item.spec.trim(); - if (!name) continue; - const source: PluginListEntry["source"] = - item.source === "dir.project" || item.source === "dir.global" - ? item.source - : "config"; - const entry: PluginListEntry = { - name, - source, - removable: source === "config", - }; - const existing = byName.get(name); - if (!existing || (entry.removable && !existing.removable)) { - byName.set(name, entry); - } - } - return [...byName.values()]; -} - export function createExtensionsStore(options: { client: () => Client | null; projectDir: () => string; selectedWorkspaceId: () => string; selectedWorkspaceRoot: () => string; workspaceType: () => "local" | "remote"; - allowGlobalExtensions?: () => boolean; ipolloworkServer: iPolloWorkServerStore; ipolloworkServerConnection?: () => { ipolloworkServerClient: iPolloWorkServerClient | null; @@ -415,14 +157,12 @@ export function createExtensionsStore(options: { let snapshot: ExtensionsStoreSnapshot; let refreshSkillsInFlight = false; - let refreshPluginsInFlight = false; let refreshHubSkillsInFlight = false; let refreshCloudOrgSkillsInFlight = false; let refreshCloudOrgMarketplacesInFlight = false; let refreshCloudOrgSkillsInFlightKey = ""; let refreshCloudOrgMarketplacesInFlightKey = ""; let refreshSkillsAborted = false; - let refreshPluginsAborted = false; let refreshHubSkillsAborted = false; let refreshCloudOrgSkillsAborted = false; let refreshCloudOrgMarketplacesAborted = false; @@ -434,13 +174,9 @@ export function createExtensionsStore(options: { let hubSkillsLoadKey = ""; let cloudOrgSkillsLoadKey = ""; let cloudOrgMarketplacesLoadKey = ""; - /** Plugin IDs the user has already been notified about. Prevents repeated - * "new extension available" notifications across sync cycles. */ - const seenMarketplacePluginIds = new Set(); let state: MutableState = { skillsContextKey: "", - pluginsContextKey: "", hubSkillsContextKey: "", cloudOrgSkillsContextKey: "", skills: [], @@ -452,20 +188,8 @@ export function createExtensionsStore(options: { importedCloudSkills: {}, cloudOrgMarketplaces: [], cloudOrgMarketplacesStatus: null, - importedCloudMarketplaces: {}, - importedCloudPlugins: {}, - pendingCloudPluginChanges: {}, hubRepo: DEFAULT_HUB_REPO, hubRepos: [DEFAULT_HUB_REPO], - pluginScope: "project", - pluginConfig: null, - pluginConfigPath: null, - pluginList: [], - pluginInput: "", - pluginStatus: null, - activePluginGuide: null, - sidebarPluginList: [], - sidebarPluginStatus: null, }; const emitChange = () => { @@ -524,22 +248,9 @@ export function createExtensionsStore(options: { importedCloudSkills: state.importedCloudSkills, cloudOrgMarketplaces: state.cloudOrgMarketplaces, cloudOrgMarketplacesStatus: state.cloudOrgMarketplacesStatus, - importedCloudMarketplaces: state.importedCloudMarketplaces, - importedCloudPlugins: state.importedCloudPlugins, - pendingCloudPluginChanges: state.pendingCloudPluginChanges, hubRepo: state.hubRepo, hubRepos: state.hubRepos, - pluginScope: state.pluginScope, - pluginConfig: state.pluginConfig, - pluginConfigPath: state.pluginConfigPath, - pluginList: state.pluginList, - pluginInput: state.pluginInput, - pluginStatus: state.pluginStatus, - activePluginGuide: state.activePluginGuide, - sidebarPluginList: state.sidebarPluginList, - sidebarPluginStatus: state.sidebarPluginStatus, skillsStale: state.skillsContextKey !== workspaceContextKey, - pluginsStale: state.pluginsContextKey !== workspaceContextKey, hubSkillsStale: state.hubSkillsContextKey !== workspaceContextKey, cloudOrgSkillsStale: state.cloudOrgSkillsContextKey !== `${workspaceContextKey}::${orgId}`, }; @@ -660,97 +371,6 @@ export function createExtensionsStore(options: { } }; - const refreshPendingCloudPluginChanges = async (installedPlugins?: Record) => { - try { - const target = await resolveWorkspaceServerTarget(); - if (!target.ipolloworkClient || !target.ipolloworkWorkspaceId) { - setStateField("pendingCloudPluginChanges", {}); - return; - } - const syncResult = await refreshDesktopCloudSync({ - ipolloworkClient: target.ipolloworkClient, - workspaceId: target.ipolloworkWorkspaceId, - }).catch(() => null); - const changes = syncResult - ? syncResult.changes - : readPendingCloudSyncChanges(await target.ipolloworkClient.getDesktopCloudSync(target.ipolloworkWorkspaceId)); - const pending = derivePendingCloudPluginChanges({ - changes, - installedPlugins: installedPlugins ?? snapshot.importedCloudPlugins, - }); - const previousPending = snapshot.pendingCloudPluginChanges; - setStateField("pendingCloudPluginChanges", pending); - - // Notify about newly detected plugin updates or removals. - for (const [pluginId, change] of Object.entries(pending)) { - if (previousPending[pluginId] === change) continue; - const installed = (installedPlugins ?? snapshot.importedCloudPlugins)[pluginId]; - const pluginLabel = installed?.name ?? pluginId; - if (change === "modified") { - notifyEvent({ - kind: "cloud", - severity: "info", - title: "Extension update available", - body: `${pluginLabel} has been updated`, - dedupeKey: `plugin-update:${pluginId}`, - action: { type: "open-extensions-marketplace" }, - actionLabel: "View updates", - }); - } else if (change === "removed") { - notifyEvent({ - kind: "cloud", - severity: "warning", - title: "Extension removed by admin", - body: `${pluginLabel} is no longer available`, - dedupeKey: `plugin-removed:${pluginId}`, - action: { type: "open-extensions-marketplace" }, - }); - } - } - } catch { - // keep previous pending state on failure - } - }; - - const refreshImportedCloudPlugins = async () => { - try { - const target = await resolveWorkspaceServerTarget(); - if (target.ipolloworkClient && target.ipolloworkWorkspaceId) { - const result = await target.ipolloworkClient.listCloudPlugins(target.ipolloworkWorkspaceId); - setStateField("importedCloudMarketplaces", result.marketplaces); - setStateField("importedCloudPlugins", result.plugins); - void refreshPendingCloudPluginChanges(result.plugins); - return result.plugins; - } - const config = await readWorkspaceiPolloWorkConfigRecord(); - const cloudImports = readWorkspaceCloudImports(config); - setStateField("importedCloudMarketplaces", cloudImports.marketplaces); - setStateField("importedCloudPlugins", cloudImports.plugins); - return cloudImports.plugins; - } catch { - setStateField("importedCloudMarketplaces", {}); - setStateField("importedCloudPlugins", {}); - setStateField("pendingCloudPluginChanges", {}); - return {}; - } - }; - - const persistImportedCloudMarketplaces = async (nextMarketplaces: Record) => { - const config = await readWorkspaceiPolloWorkConfigRecord(); - const cloudImports = readWorkspaceCloudImports(config); - const nextCloudImports = { - ...cloudImports, - marketplaces: nextMarketplaces, - }; - const nextConfig = withWorkspaceCloudImports(config, nextCloudImports); - const persisted = await writeWorkspaceiPolloWorkConfigRecord(nextConfig); - if (!persisted) { - throw new Error("iPolloWork server unavailable. Connect to manage imported cloud marketplaces."); - } - setStateField("importedCloudMarketplaces", nextMarketplaces); - void refreshPendingCloudPluginChanges(); - }; - const persistImportedCloudSkills = async (nextSkills: Record) => { const config = await readWorkspaceiPolloWorkConfigRecord(); const cloudImports = readWorkspaceCloudImports(config); @@ -765,25 +385,6 @@ export function createExtensionsStore(options: { setStateField("importedCloudSkills", nextSkills); }; - const persistImportedCloudPlugins = async (nextPlugins: Record) => { - const config = await readWorkspaceiPolloWorkConfigRecord(); - const cloudImports = readWorkspaceCloudImports(config); - const nextCloudImports = { - ...cloudImports, - plugins: nextPlugins, - }; - const nextConfig = withWorkspaceCloudImports(config, nextCloudImports); - const persisted = await writeWorkspaceiPolloWorkConfigRecord(nextConfig); - if (!persisted) { - throw new Error("iPolloWork server unavailable. Connect to manage imported cloud plugins."); - } - setStateField("importedCloudPlugins", nextPlugins); - void refreshPendingCloudPluginChanges(nextPlugins); - }; - - const findCloudMarketplace = (marketplaceId: string) => - snapshot.cloudOrgMarketplaces.find((entry) => entry.marketplace.id === marketplaceId)?.marketplace ?? null; - const buildCloudSkillContent = (name: string, description: string, body: string) => { const safeDescription = description.replace(/\s+/g, " ").trim(); const normalizedBody = body.replace(/^\s*\n?/, ""); @@ -902,432 +503,6 @@ export function createExtensionsStore(options: { } }; - const slugifyConfigObjectName = (title: string, fallback: string) => { - const slug = slugifyOpencodeSkillName(title || fallback); - return slug === "skill" && fallback ? slugifyOpencodeSkillName(fallback) : slug; - }; - - const pluginNamespace = (pluginName: string, pluginId: string) => { - const base = slugifyConfigObjectName(pluginName, pluginId); - return `${base.replace(/-plugin$/, "")}-plugin`; - }; - - const normalizePluginSourcePath = (path: string, objectType: string, namespace: string) => { - const parts = path.trim().replace(/^\/+/, "").split("/").filter(Boolean); - if (parts.length === 0 || parts.some((part) => part === ".." || part === ".")) return ""; - - const folderByType: Record = { - agent: "agents", - command: "commands", - context: "context", - hook: "hooks", - mcp: "mcps", - skill: "skills", - tool: "tools", - }; - const folder = folderByType[objectType]; - if (!folder) return ""; - const opencodeIndex = parts.findIndex((part) => part === ".opencode"); - const searchParts = opencodeIndex >= 0 ? parts.slice(opencodeIndex + 1) : parts; - const folderIndex = searchParts.findIndex((part) => part === folder); - if (folderIndex < 0 || folderIndex === searchParts.length - 1) return ""; - const rest = searchParts.slice(folderIndex + 1); - if (rest[0] === namespace) return [".opencode", folder, ...rest].join("/"); - return [".opencode", folder, namespace, ...rest].join("/"); - }; - - const getPluginObjectInstallPath = ( - object: NonNullable, - namespace: string, - ) => { - const existing = normalizePluginSourcePath(object.currentRelativePath ?? "", object.objectType, namespace); - if (existing) { - if (object.objectType === "skill") { - const parts = existing.split("/").filter(Boolean); - const lastPart = parts.at(-1) ?? ""; - const skillName = /^SKILL\.md$/i.test(lastPart) - ? parts.at(-2) ?? slugifyConfigObjectName(object.title, object.id) - : lastPart || slugifyConfigObjectName(object.title, object.id); - return `.opencode/skills/${namespace}/${skillName}/SKILL.md`; - } - return existing; - } - const name = slugifyConfigObjectName(object.title, object.id); - switch (object.objectType) { - case "skill": - return `.opencode/skills/${namespace}/${name}/SKILL.md`; - case "agent": - return `.opencode/agents/${namespace}/${name}.md`; - case "command": - return `.opencode/commands/${namespace}/${name}.md`; - case "mcp": - return `.opencode/mcps/${namespace}/${name}.json`; - case "hook": - return `.opencode/hooks/${namespace}/${name}.json`; - case "tool": - return `.opencode/tools/${namespace}/${name}.ts`; - case "context": - return `.opencode/context/${namespace}/${name}.md`; - default: - return `.opencode/plugins/${namespace}/${name}.txt`; - } - }; - - const pluginMcpName = (rawName: string, namespace: string, fallback: string, namespaceName: boolean) => { - const trimmed = rawName.trim(); - const base = OPENCODE_MCP_NAME_RE.test(trimmed) - ? trimmed - : slugifyConfigObjectName(trimmed || fallback, fallback); - if (!namespaceName) return base; - const namespaced = base.startsWith(`${namespace}-`) ? base : `${namespace}-${base}`; - return OPENCODE_MCP_NAME_RE.test(namespaced) - ? namespaced - : slugifyConfigObjectName(namespaced, fallback); - }; - - const mcpCommandFromConfig = (config: Record) => { - if (Array.isArray(config.command)) return readStringArray(config.command); - const command = readNonEmptyString(config.command); - if (!command) return []; - return [command, ...readStringArray(config.args)]; - }; - - const normalizePluginMcpConfig = (input: unknown): Record | null => { - if (!isRecord(input)) return null; - const enabled = typeof input.enabled === "boolean" - ? input.enabled - : typeof input.disabled === "boolean" - ? !input.disabled - : true; - const url = readNonEmptyString(input.url); - if (url) { - const config: Record = { type: "remote", url, enabled }; - const headers = readStringRecord(input.headers); - if (headers) config.headers = headers; - if (isRecord(input.oauth)) config.oauth = input.oauth; - if (input.oauth === true) config.oauth = {}; - return config; - } - - const command = mcpCommandFromConfig(input); - if (command.length > 0) { - const config: Record = { type: "local", command, enabled }; - const environment = readStringRecord(input.environment) ?? readStringRecord(input.env); - if (environment) config.environment = environment; - return config; - } - - return null; - }; - - const pluginMcpConfigsFromPayload = ( - object: NonNullable, - namespace: string, - ) => { - const version = object.latestVersion; - const payload = version?.normalizedPayloadJson ?? parseJsonRecord(version?.rawSourceText ?? null); - if (!payload) return []; - - const configs = new Map; path: string }>(); - const addConfig = (rawName: string, rawConfig: unknown, namespaceName: boolean) => { - const config = normalizePluginMcpConfig(rawConfig); - if (!config) return; - const name = pluginMcpName(rawName, namespace, object.id, namespaceName); - configs.set(name, { - name, - config, - path: `${OPENCODE_MCP_IMPORT_PATH_PREFIX}${name}`, - }); - }; - - if (isRecord(payload.mcp)) { - for (const [name, config] of Object.entries(payload.mcp)) addConfig(name, config, false); - } - if (isRecord(payload.mcpServers)) { - for (const [name, config] of Object.entries(payload.mcpServers)) addConfig(name, config, false); - } - if (configs.size === 0) addConfig(object.title, payload, true); - - return [...configs.values()]; - }; - - const mcpNoConfigWarning = (title: string) => - `MCP component "${title}" could not be installed: no server config with a "url" or "command" was found.`; - - const mcpInactiveWarning = (title: string) => - `MCP component "${title}" could not be installed because it is not active.`; - - const cloudPluginImportMessage = (pluginName: string, fileCount: number, warnings: string[]) => { - const message = `Imported ${pluginName} with ${fileCount} file${fileCount === 1 ? "" : "s"}.`; - return warnings.length > 0 ? `${message} ${warnings.join(" ")}` : message; - }; - - const externalMcpConnectionIdFromPayload = ( - object: NonNullable, - ): string | null => { - const version = object.latestVersion; - const payload = version?.normalizedPayloadJson ?? parseJsonRecord(version?.rawSourceText ?? null); - if (!payload) return null; - if (payload.ipolloworkManaged === "den_external_mcp") { - const id = readNonEmptyString(payload.externalMcpConnectionId); - if (id) return id; - } - const containers = [ - isRecord(payload.mcpServers) ? payload.mcpServers : null, - isRecord(payload.mcp) ? payload.mcp : null, - ].filter((entry): entry is Record => Boolean(entry)); - for (const container of containers) { - for (const config of Object.values(container)) { - if (!isRecord(config) || config.ipolloworkManaged !== "den_external_mcp") continue; - const id = readNonEmptyString(config.externalMcpConnectionId); - if (id) return id; - } - } - return null; - }; - - const denSkillIdFromPayload = ( - object: NonNullable, - ): string | null => { - const version = object.latestVersion; - const payload = version?.normalizedPayloadJson ?? parseJsonRecord(version?.rawSourceText ?? null); - if (!payload || payload.ipolloworkManaged !== "den_skill") return null; - return readNonEmptyString(payload.denSkillId); - }; - - const upsertPluginMcpConfig = async (name: string, config: Record) => { - const ipolloworkSnapshot = getiPolloWorkServerSnapshot(); - const ipolloworkClient = ipolloworkSnapshot.ipolloworkServerClient; - const ipolloworkWorkspaceId = options.runtimeWorkspaceId(); - if ( - ipolloworkSnapshot.ipolloworkServerStatus === "connected" && - ipolloworkClient && - ipolloworkWorkspaceId && - ipolloworkSnapshot.ipolloworkServerCapabilities?.mcp?.write - ) { - await ipolloworkClient.addMcp(ipolloworkWorkspaceId, { name, config }); - return; - } - throw new Error("iPolloWork server unavailable. Connect to import MCP servers into this workspace."); - }; - - const deletePluginMcpConfig = async (name: string) => { - const ipolloworkSnapshot = getiPolloWorkServerSnapshot(); - const ipolloworkClient = ipolloworkSnapshot.ipolloworkServerClient; - const ipolloworkWorkspaceId = options.runtimeWorkspaceId(); - if ( - ipolloworkSnapshot.ipolloworkServerStatus === "connected" && - ipolloworkClient && - ipolloworkWorkspaceId && - ipolloworkSnapshot.ipolloworkServerCapabilities?.mcp?.write - ) { - await ipolloworkClient.removeMcp(ipolloworkWorkspaceId, name); - return; - } - throw new Error("iPolloWork server unavailable. Connect to remove imported MCP servers from this workspace."); - }; - - const pluginReloadReason = (objectType: string): ReloadReason => { - switch (objectType) { - case "skill": - return "skills"; - case "agent": - return "agents"; - case "command": - return "commands"; - case "mcp": - return "mcp"; - default: - return "config"; - } - }; - - const writePluginWorkspaceFile = async (path: string, content: string) => { - const { ipolloworkSnapshot, ipolloworkClient, ipolloworkWorkspaceId, hasiPolloWorkTarget } = - await resolveWorkspaceServerTarget(); - if ( - hasiPolloWorkTarget && - ipolloworkClient && - ipolloworkWorkspaceId && - ipolloworkSnapshot.ipolloworkServerCapabilities?.config?.write !== false && - typeof ipolloworkClient.writeWorkspaceFile === "function" - ) { - await ipolloworkClient.writeWorkspaceFile(ipolloworkWorkspaceId, { path, content, force: true }); - return; - } - throw new Error("iPolloWork server unavailable. Connect to import plugin files into this workspace."); - }; - - const deletePluginWorkspaceFiles = async (files: Array<{ path: string; recursive?: boolean }>) => { - if (files.length === 0) return; - const { ipolloworkSnapshot, ipolloworkClient, ipolloworkWorkspaceId, hasiPolloWorkTarget } = - await resolveWorkspaceServerTarget(); - if ( - hasiPolloWorkTarget && - ipolloworkClient && - ipolloworkWorkspaceId && - ipolloworkSnapshot.ipolloworkServerCapabilities?.config?.write !== false && - typeof ipolloworkClient.deleteWorkspaceFiles === "function" - ) { - const results = await ipolloworkClient.deleteWorkspaceFiles(ipolloworkWorkspaceId, files); - const failed = results.filter((result) => !result.ok && result.code !== "file_not_found"); - if (failed.length > 0) { - throw new Error( - `Failed to remove ${failed.length} imported plugin file${failed.length === 1 ? "" : "s"} from the workspace.`, - ); - } - return; - } - throw new Error("iPolloWork server unavailable. Connect to remove imported plugin files from this workspace."); - }; - - const applyCloudOrgPluginImport = async ( - marketplaceId: string | null, - resolved: DenOrgPluginResolved, - ): Promise<{ files: CloudImportedPluginFile[]; warnings: string[] }> => { - const files: CloudImportedPluginFile[] = []; - const warnings: string[] = []; - const existing = snapshot.importedCloudPlugins[resolved.plugin.id]; - const namespace = pluginNamespace(resolved.plugin.name, resolved.plugin.id); - - for (const membership of resolved.memberships) { - const object = membership.configObject; - const version = object?.latestVersion ?? null; - if (!object) continue; - if (object.status !== "active") { - if (object.objectType === "mcp") warnings.push(mcpInactiveWarning(object.title)); - continue; - } - - if (object.objectType === "mcp") { - const externalMcpConnectionId = externalMcpConnectionIdFromPayload(object); - if (externalMcpConnectionId) { - files.push({ - configObjectId: object.id, - externalMcpConnectionId, - versionId: version?.id ?? null, - objectType: object.objectType, - title: object.title, - path: `den#mcp-connection.${externalMcpConnectionId}`, - updatedAt: object.updatedAt, - }); - continue; - } - const configs = pluginMcpConfigsFromPayload(object, namespace); - if (configs.length === 0) warnings.push(mcpNoConfigWarning(object.title)); - for (const config of configs) { - await upsertPluginMcpConfig(config.name, config.config); - files.push({ - configObjectId: object.id, - versionId: version?.id ?? null, - objectType: object.objectType, - title: object.title, - path: config.path, - updatedAt: object.updatedAt, - }); - options.markReloadRequired?.("mcp", { - type: "mcp", - name: config.name, - action: existing ? "updated" : "added", - }); - } - continue; - } - - if (object.objectType === "skill") { - const denSkillId = denSkillIdFromPayload(object); - if (denSkillId) { - files.push({ - configObjectId: object.id, - denSkillId, - versionId: version?.id ?? null, - objectType: object.objectType, - title: object.title, - path: `den#skill.${denSkillId}`, - updatedAt: object.updatedAt, - }); - continue; - } - } - - if (version?.rawSourceText == null) continue; - - const path = getPluginObjectInstallPath(object, namespace); - let content = version.rawSourceText; - const rawDesc = (object.description?.trim() || object.title).trim(); - const description = rawDesc.slice(0, 1024) || object.title.slice(0, 1024); - if (object.objectType === "skill") { - const installName = path.match(/^\.opencode\/skills\/[^/]+\/([^/]+)\/SKILL\.md$/)?.[1] ?? slugifyConfigObjectName(object.title, object.id); - content = buildCloudSkillContent(installName, description || "Skill", extractSkillBodyMarkdown(content)); - } else if (object.objectType === "agent") { - content = buildCloudAgentContent(description, content); - } else if (object.objectType === "command") { - const fileName = path.match(/\/([^/]+)\.md$/)?.[1] ?? object.title; - content = buildCloudCommandContent(slugifyConfigObjectName(fileName, object.id), description, content); - } - await writePluginWorkspaceFile(path, content); - - files.push({ - configObjectId: object.id, - versionId: version.id, - objectType: object.objectType, - title: object.title, - path, - updatedAt: object.updatedAt, - }); - options.markReloadRequired?.(pluginReloadReason(object.objectType), { - type: - object.objectType === "skill" || object.objectType === "agent" || object.objectType === "command" - ? object.objectType - : "config", - name: object.title, - action: existing ? "updated" : "added", - }); - } - - const nextPaths = new Set(files.map((file) => file.path)); - const removedMcpNames = (existing?.files ?? []).flatMap((file) => { - const name = file.objectType === "mcp" && !nextPaths.has(file.path) - ? cloudPluginMcpNameFromPath(file.path) - : null; - return name ? [name] : []; - }); - await Promise.all(removedMcpNames.map((name) => deletePluginMcpConfig(name))); - - const nextPlugins = { - ...snapshot.importedCloudPlugins, - [resolved.plugin.id]: { - pluginId: resolved.plugin.id, - marketplaceId, - name: resolved.plugin.name, - description: resolved.plugin.description, - updatedAt: resolved.plugin.updatedAt, - files, - importedAt: existing?.importedAt ?? Date.now(), - }, - } satisfies Record; - await persistImportedCloudPlugins(nextPlugins); - - if (marketplaceId) { - const marketplace = findCloudMarketplace(marketplaceId); - const existingMarketplace = snapshot.importedCloudMarketplaces[marketplaceId] ?? null; - const pluginIds = new Set(existingMarketplace?.pluginIds ?? []); - pluginIds.add(resolved.plugin.id); - await persistImportedCloudMarketplaces({ - ...snapshot.importedCloudMarketplaces, - [marketplaceId]: { - marketplaceId, - name: marketplace?.name ?? existingMarketplace?.name ?? marketplaceId, - updatedAt: marketplace?.updatedAt ?? existingMarketplace?.updatedAt ?? null, - pluginIds: [...pluginIds].toSorted(), - importedAt: existingMarketplace?.importedAt ?? Date.now(), - }, - }); - } - - return { files, warnings }; - }; - const persistHubRepos = () => { if (typeof window === "undefined") return; try { @@ -1555,10 +730,7 @@ export function createExtensionsStore(options: { cloudOrgMarketplacesLoaded = false; } - if (!optionsOverride?.force && cloudOrgMarketplacesLoaded) { - await refreshImportedCloudPlugins(); - return; - } + if (!optionsOverride?.force && cloudOrgMarketplacesLoaded) return; if (refreshCloudOrgMarketplacesInFlight && refreshCloudOrgMarketplacesInFlightKey === loadKey) return; refreshCloudOrgMarketplacesInFlight = true; @@ -1576,7 +748,6 @@ export function createExtensionsStore(options: { })); cloudOrgMarketplacesLoaded = true; cloudOrgMarketplacesLoadKey = loadKey; - await refreshImportedCloudPlugins(); return; } @@ -1592,41 +763,8 @@ export function createExtensionsStore(options: { cloudOrgMarketplacesStatus: null, })); - // Notify the user about newly available marketplace plugins. On the - // first load we seed the seen set silently so only subsequent publishes - // trigger a notification. - const allPluginIds = new Set(); - for (const marketplace of resolved) { - for (const plugin of marketplace.plugins ?? []) { - if (plugin.id) allPluginIds.add(plugin.id); - } - } - if (seenMarketplacePluginIds.size === 0) { - // First load: seed without notifying. - for (const id of allPluginIds) seenMarketplacePluginIds.add(id); - } else { - for (const marketplace of resolved) { - const marketplaceName = marketplace.marketplace?.name ?? "your marketplace"; - for (const plugin of marketplace.plugins ?? []) { - if (plugin.id && !seenMarketplacePluginIds.has(plugin.id)) { - seenMarketplacePluginIds.add(plugin.id); - notifyEvent({ - kind: "cloud", - severity: "info", - title: "New extension available", - body: `${plugin.name ?? plugin.id} was added to ${marketplaceName}`, - dedupeKey: `new-marketplace-plugin:${plugin.id}`, - action: { type: "install-marketplace-plugin", pluginName: plugin.name ?? plugin.id }, - actionLabel: "View and install", - }); - } - } - } - } - cloudOrgMarketplacesLoaded = true; cloudOrgMarketplacesLoadKey = loadKey; - await refreshImportedCloudPlugins(); } catch (error) { if (refreshCloudOrgMarketplacesAborted || getCurrentCloudOrgLoadKey() !== loadKey) return; mutateState((current) => ({ @@ -1643,151 +781,6 @@ export function createExtensionsStore(options: { } } - async function importCloudOrgPlugin( - marketplaceId: string | null, - plugin: DenOrgPlugin, - ): Promise<{ ok: boolean; message: string; warnings: string[]; files: CloudImportedPluginFile[] }> { - options.setBusy(true); - options.setError(null); - setStateField("cloudOrgMarketplacesStatus", null); - - try { - const settings = readDenSettings(); - const token = settings.authToken?.trim() ?? ""; - const orgId = settings.activeOrgId?.trim() ?? ""; - if (!token || !orgId) throw new Error("Sign in to iPolloWork Cloud and choose an organization first."); - const client = createDenClient({ baseUrl: settings.baseUrl, token }); - const resolved = await client.getOrgPluginResolved(orgId, plugin); - const target = await resolveWorkspaceServerTarget(); - if (target.ipolloworkClient && target.ipolloworkWorkspaceId) { - const marketplace = marketplaceId ? findCloudMarketplace(marketplaceId) : null; - const result = await target.ipolloworkClient.installCloudPlugin(target.ipolloworkWorkspaceId, { - marketplaceId, - marketplace, - resolved, - }); - await refreshSkills({ force: true }); - await refreshCloudOrgMarketplaces({ force: true }); - void refreshPendingCloudPluginChanges(); - return { - ok: true, - message: cloudPluginImportMessage(plugin.name, result.item.files.length, result.warnings), - warnings: result.warnings, - files: result.item.files, - }; - } - const result = await applyCloudOrgPluginImport(marketplaceId, resolved); - await refreshSkills({ force: true }); - await refreshCloudOrgMarketplaces({ force: true }); - return { - ok: true, - message: cloudPluginImportMessage(plugin.name, result.files.length, result.warnings), - warnings: result.warnings, - files: result.files, - }; - } catch (error) { - const message = error instanceof Error ? error.message : t("skills.unknown_error"); - options.setError(addOpencodeCacheHint(message)); - return { ok: false, message, warnings: [], files: [] }; - } finally { - options.setBusy(false); - } - } - - async function previewClaudePlugin(url: string): Promise { - const target = await resolveWorkspaceServerTarget(); - if (!target.ipolloworkClient || !target.ipolloworkWorkspaceId) { - throw new Error("iPolloWork server unavailable. Connect to install plugins from GitHub."); - } - const result = await target.ipolloworkClient.previewClaudePlugin(target.ipolloworkWorkspaceId, { url }); - return result.preview; - } - - async function installClaudePlugin(url: string): Promise<{ ok: boolean; message: string }> { - options.setBusy(true); - options.setError(null); - try { - const target = await resolveWorkspaceServerTarget(); - if (!target.ipolloworkClient || !target.ipolloworkWorkspaceId) { - throw new Error("iPolloWork server unavailable. Connect to install plugins from GitHub."); - } - const result = await target.ipolloworkClient.installClaudePlugin(target.ipolloworkWorkspaceId, { url }); - await refreshSkills({ force: true }); - await refreshImportedCloudPlugins(); - return { - ok: true, - message: `Installed ${result.item.name} with ${result.item.files.length} component${result.item.files.length === 1 ? "" : "s"}.`, - }; - } catch (error) { - const message = error instanceof Error ? error.message : t("skills.unknown_error"); - options.setError(addOpencodeCacheHint(message)); - return { ok: false, message }; - } finally { - options.setBusy(false); - } - } - - async function removeCloudOrgPlugin(pluginId: string): Promise<{ ok: boolean; message: string }> { - options.setBusy(true); - options.setError(null); - setStateField("cloudOrgMarketplacesStatus", null); - - try { - const target = await resolveWorkspaceServerTarget(); - if (target.ipolloworkClient && target.ipolloworkWorkspaceId) { - const result = await target.ipolloworkClient.removeCloudPlugin(target.ipolloworkWorkspaceId, pluginId); - await refreshSkills({ force: true }); - await refreshCloudOrgMarketplaces({ force: true }); - void refreshPendingCloudPluginChanges(); - return { - ok: true, - message: `Removed ${result.item.name}.`, - }; - } - - const imported = snapshot.importedCloudPlugins[pluginId]; - if (!imported) throw new Error("Marketplace package is not installed in this workspace."); - - const removedMcpNames: string[] = []; - const fileDeletes: Array<{ path: string; recursive?: boolean }> = []; - for (const file of imported.files) { - const mcpName = file.objectType === "mcp" ? cloudPluginMcpNameFromPath(file.path) : null; - if (mcpName) { - removedMcpNames.push(mcpName); - continue; - } - if (!file.path.startsWith(".opencode/")) continue; - const skillDir = file.path.match(/^(\.opencode\/skills\/[^/]+\/[^/]+)\/SKILL\.md$/)?.[1]; - fileDeletes.push(skillDir ? { path: skillDir, recursive: true } : { path: file.path }); - } - await Promise.all(removedMcpNames.map((name) => deletePluginMcpConfig(name))); - await deletePluginWorkspaceFiles(fileDeletes); - - const nextPlugins = { ...snapshot.importedCloudPlugins }; - delete nextPlugins[pluginId]; - await persistImportedCloudPlugins(nextPlugins); - - if (removedMcpNames.length > 0) { - options.markReloadRequired?.("mcp", { type: "mcp", name: imported.name, action: "removed" }); - } - if (fileDeletes.length > 0) { - options.markReloadRequired?.("config", { type: "config", name: imported.name, action: "removed" }); - } - await Promise.all([ - refreshSkills({ force: true }), - refreshCloudOrgMarketplaces({ force: true }), - ]); - - return { ok: true, message: `Removed ${imported.name}.` }; - } catch (error) { - const message = error instanceof Error ? error.message : t("skills.unknown_error"); - options.setError(addOpencodeCacheHint(message)); - return { ok: false, message }; - } finally { - options.setBusy(false); - } - } - async function installHubSkill(name: string): Promise<{ ok: boolean; message: string }> { const trimmed = name.trim(); if (!trimmed) return { ok: false, message: "Skill name is required." }; @@ -1897,28 +890,6 @@ export function createExtensionsStore(options: { } } - const isPluginInstalledByName = (pluginName: string, aliases: string[] = []) => - isPluginInstalled(snapshot.pluginList.map((entry) => entry.name), pluginName, aliases); - - const loadPluginsFromConfig = (config: OpencodeConfigFile | null) => { - const nextPluginNames: string[] = []; - let nextPluginStatus: string | null = null; - loadPluginsFromConfigHelpers( - config, - (value) => { - nextPluginNames.splice(0, nextPluginNames.length, ...applyStateAction(nextPluginNames, value)); - }, - (message) => { - nextPluginStatus = message; - }, - ); - mutateState((current) => ({ - ...current, - pluginList: toConfigPluginListEntries(nextPluginNames), - pluginStatus: nextPluginStatus, - })); - }; - async function refreshSkills(optionsOverride?: { force?: boolean }) { const root = options.selectedWorkspaceRoot().trim(); const isLocalWorkspace = options.workspaceType() === "local"; @@ -1948,7 +919,7 @@ export function createExtensionsStore(options: { try { setStateField("skillsStatus", null); const response = await ipolloworkClient.listSkills(ipolloworkWorkspaceId, { - includeGlobal: isLocalWorkspace && (options.allowGlobalExtensions?.() ?? true), + includeGlobal: isLocalWorkspace, }); if (refreshSkillsAborted) return; const next: SkillCard[] = Array.isArray(response.items) @@ -2086,356 +1057,6 @@ export function createExtensionsStore(options: { } } - async function refreshPlugins(scopeOverride?: PluginScope) { - const isRemoteWorkspace = options.workspaceType() === "remote"; - const isLocalWorkspace = options.workspaceType() === "local"; - const { ipolloworkSnapshot, ipolloworkClient, ipolloworkWorkspaceId, hasiPolloWorkTarget } = - await resolveWorkspaceServerTarget(); - const canUseiPolloWorkServer = - hasiPolloWorkTarget && - ipolloworkSnapshot.ipolloworkServerCapabilities?.plugins?.read !== false; - - if (refreshPluginsInFlight) return; - refreshPluginsInFlight = true; - refreshPluginsAborted = false; - - const scope = scopeOverride ?? snapshot.pluginScope; - const targetDir = options.projectDir().trim(); - - if (scope !== "project" && !isLocalWorkspace) { - mutateState((current) => ({ - ...current, - pluginStatus: "Global plugins are only available for local workers.", - pluginList: [], - sidebarPluginStatus: "Global plugins require a local worker.", - sidebarPluginList: [], - })); - refreshPluginsInFlight = false; - return; - } - - if (scope === "project" && canUseiPolloWorkServer && ipolloworkClient && ipolloworkWorkspaceId) { - mutateState((current) => ({ - ...current, - pluginConfig: null, - pluginConfigPath: `opencode.json (${isRemoteWorkspace ? "remote" : "ipollowork"} server)`, - })); - - try { - mutateState((current) => ({ ...current, pluginStatus: null, sidebarPluginStatus: null })); - if (refreshPluginsAborted) return; - const result = await ipolloworkClient.listPlugins(ipolloworkWorkspaceId, { includeGlobal: false }); - if (refreshPluginsAborted) return; - const projectItems = result.items.filter((item) => item.scope === "project"); - const list = toProjectPluginListEntries(projectItems); - mutateState((current) => ({ - ...current, - pluginList: list, - sidebarPluginList: list.map((entry) => entry.name), - pluginStatus: list.length ? null : "No plugins configured yet.", - sidebarPluginStatus: null, - pluginsContextKey: getWorkspaceContextKey(), - })); - } catch (error) { - if (refreshPluginsAborted) return; - mutateState((current) => ({ - ...current, - pluginList: [], - sidebarPluginList: [], - sidebarPluginStatus: "Failed to load plugins.", - pluginStatus: error instanceof Error ? error.message : "Failed to load plugins.", - })); - } finally { - refreshPluginsInFlight = false; - } - return; - } - - if (scope === "project" && hasiPolloWorkTarget) { - mutateState((current) => ({ - ...current, - pluginStatus: "iPolloWork server cannot read plugins for this workspace.", - pluginList: [], - sidebarPluginStatus: "iPolloWork server cannot read plugins for this workspace.", - sidebarPluginList: [], - })); - refreshPluginsInFlight = false; - return; - } - - if (!isDesktopRuntime()) { - mutateState((current) => ({ - ...current, - pluginStatus: t("skills.plugin_management_host_only"), - pluginList: [], - sidebarPluginStatus: t("skills.plugins_host_only"), - sidebarPluginList: [], - })); - refreshPluginsInFlight = false; - return; - } - - if (!isLocalWorkspace && !canUseiPolloWorkServer) { - mutateState((current) => ({ - ...current, - pluginStatus: "iPolloWork server unavailable. Connect to manage plugins.", - pluginList: [], - sidebarPluginStatus: "Connect an iPolloWork server to load plugins.", - sidebarPluginList: [], - })); - refreshPluginsInFlight = false; - return; - } - - if (scope === "project" && !targetDir) { - mutateState((current) => ({ - ...current, - pluginStatus: t("skills.pick_project_for_plugins"), - pluginList: [], - sidebarPluginStatus: t("skills.pick_project_for_active"), - sidebarPluginList: [], - })); - refreshPluginsInFlight = false; - return; - } - - try { - mutateState((current) => ({ ...current, pluginStatus: null, sidebarPluginStatus: null })); - if (refreshPluginsAborted) return; - const config = (await readOpencodeConfig(scope, targetDir)) as OpencodeConfigFile; - if (refreshPluginsAborted) return; - mutateState((current) => ({ ...current, pluginConfig: (config as OpencodeConfigFile | null), pluginConfigPath: config.path ?? null })); - - if (!config.exists) { - mutateState((current) => ({ - ...current, - pluginList: [], - pluginStatus: t("skills.no_opencode_found"), - sidebarPluginList: [], - sidebarPluginStatus: t("skills.no_opencode_workspace"), - })); - return; - } - - let nextSidebarPluginList: string[] = []; - let nextSidebarPluginStatus: string | null = null; - try { - nextSidebarPluginList = parsePluginListFromContent(config.content ?? ""); - } catch { - nextSidebarPluginList = []; - nextSidebarPluginStatus = t("skills.failed_parse_opencode"); - } - - const nextPluginNames: string[] = []; - let nextPluginStatus: string | null = null; - loadPluginsFromConfigHelpers( - config as never, - (value) => { - nextPluginNames.splice(0, nextPluginNames.length, ...applyStateAction(nextPluginNames, value)); - }, - (message) => { - nextPluginStatus = message; - }, - ); - - mutateState((current) => ({ - ...current, - pluginList: toConfigPluginListEntries(nextPluginNames), - pluginStatus: nextPluginStatus, - sidebarPluginList: nextSidebarPluginList, - sidebarPluginStatus: nextSidebarPluginStatus, - pluginsContextKey: getWorkspaceContextKey(), - })); - } catch (error) { - if (refreshPluginsAborted) return; - mutateState((current) => ({ - ...current, - pluginConfig: null, - pluginConfigPath: null, - pluginList: [], - pluginStatus: error instanceof Error ? error.message : t("skills.failed_load_opencode"), - sidebarPluginStatus: t("skills.failed_load_active"), - sidebarPluginList: [], - })); - } finally { - refreshPluginsInFlight = false; - } - } - - async function addPlugin(pluginNameOverride?: string) { - const pluginName = (pluginNameOverride ?? snapshot.pluginInput).trim(); - const isManualInput = pluginNameOverride == null; - const triggerName = stripPluginVersion(pluginName); - - const isLocalWorkspace = options.workspaceType() === "local"; - const { ipolloworkSnapshot, ipolloworkClient, ipolloworkWorkspaceId, hasiPolloWorkTarget } = - await resolveWorkspaceServerTarget(); - const canUseiPolloWorkServer = - hasiPolloWorkTarget && - ipolloworkSnapshot.ipolloworkServerCapabilities?.plugins?.write !== false; - - if (!pluginName) { - if (isManualInput) setStateField("pluginStatus", t("skills.enter_plugin_name")); - return; - } - - if (snapshot.pluginScope !== "project" && !isLocalWorkspace) { - setStateField("pluginStatus", "Global plugins are only available for local workers."); - return; - } - - if (snapshot.pluginScope === "project" && canUseiPolloWorkServer && ipolloworkClient && ipolloworkWorkspaceId) { - try { - setStateField("pluginStatus", null); - await ipolloworkClient.addPlugin(ipolloworkWorkspaceId, pluginName); - options.markReloadRequired?.("plugins", { type: "plugin", name: triggerName, action: "added" }); - if (isManualInput) setStateField("pluginInput", ""); - await refreshPlugins("project"); - } catch (error) { - setStateField("pluginStatus", error instanceof Error ? error.message : "Failed to add plugin."); - } - return; - } - - if (snapshot.pluginScope === "project" && hasiPolloWorkTarget) { - setStateField("pluginStatus", "iPolloWork server cannot write plugins for this workspace."); - return; - } - - if (!isDesktopRuntime()) { - setStateField("pluginStatus", t("skills.plugin_management_host_only")); - return; - } - - if (!isLocalWorkspace) { - setStateField("pluginStatus", "iPolloWork server unavailable. Connect to manage plugins."); - return; - } - - const scope = snapshot.pluginScope; - const targetDir = options.projectDir().trim(); - - if (scope === "project" && !targetDir) { - setStateField("pluginStatus", t("skills.pick_project_for_plugins")); - return; - } - - try { - setStateField("pluginStatus", null); - const config = (await readOpencodeConfig(scope, targetDir)) as OpencodeConfigFile; - const raw = config.content ?? ""; - - if (!raw.trim()) { - const payload = { $schema: "https://opencode.ai/config.json", plugin: [pluginName] }; - await writeOpencodeConfig(scope, targetDir, `${JSON.stringify(payload, null, 2)}\n`); - options.markReloadRequired?.("plugins", { type: "plugin", name: triggerName, action: "added" }); - if (isManualInput) setStateField("pluginInput", ""); - await refreshPlugins(scope); - return; - } - - const plugins = parsePluginListFromContent(raw); - const desired = stripPluginVersion(pluginName).toLowerCase(); - if (plugins.some((entry) => stripPluginVersion(entry).toLowerCase() === desired)) { - setStateField("pluginStatus", t("skills.plugin_already_listed")); - return; - } - - const next = [...plugins, pluginName]; - const edits = modify(raw, ["plugin"], next, { formattingOptions: { insertSpaces: true, tabSize: 2 } }); - const updated = applyEdits(raw, edits); - await writeOpencodeConfig(scope, targetDir, updated); - options.markReloadRequired?.("plugins", { type: "plugin", name: triggerName, action: "added" }); - if (isManualInput) setStateField("pluginInput", ""); - await refreshPlugins(scope); - } catch (error) { - setStateField("pluginStatus", error instanceof Error ? error.message : t("skills.failed_update_opencode")); - } - } - - async function removePlugin(pluginName: string) { - const name = pluginName.trim(); - if (!name) return; - const triggerName = stripPluginVersion(name); - const existingPlugin = snapshot.pluginList.find((entry) => entry.name === name); - if (existingPlugin && !existingPlugin.removable) { - setStateField("pluginStatus", "Directory-discovered plugins are read-only."); - return; - } - - const isLocalWorkspace = options.workspaceType() === "local"; - const { ipolloworkSnapshot, ipolloworkClient, ipolloworkWorkspaceId, hasiPolloWorkTarget } = - await resolveWorkspaceServerTarget(); - const canUseiPolloWorkServer = - hasiPolloWorkTarget && - ipolloworkSnapshot.ipolloworkServerCapabilities?.plugins?.write !== false; - - if (snapshot.pluginScope !== "project" && !isLocalWorkspace) { - setStateField("pluginStatus", "Global plugins are only available for local workers."); - return; - } - - if (snapshot.pluginScope === "project" && canUseiPolloWorkServer && ipolloworkClient && ipolloworkWorkspaceId) { - try { - setStateField("pluginStatus", null); - await ipolloworkClient.removePlugin(ipolloworkWorkspaceId, name); - options.markReloadRequired?.("plugins", { type: "plugin", name: triggerName, action: "removed" }); - await refreshPlugins("project"); - } catch (error) { - setStateField("pluginStatus", error instanceof Error ? error.message : "Failed to remove plugin."); - } - return; - } - - if (snapshot.pluginScope === "project" && hasiPolloWorkTarget) { - setStateField("pluginStatus", "iPolloWork server cannot write plugins for this workspace."); - return; - } - - if (!isDesktopRuntime()) { - setStateField("pluginStatus", t("skills.plugin_management_host_only")); - return; - } - - if (!isLocalWorkspace) { - setStateField("pluginStatus", "iPolloWork server unavailable. Connect to manage plugins."); - return; - } - - const scope = snapshot.pluginScope; - const targetDir = options.projectDir().trim(); - if (scope === "project" && !targetDir) { - setStateField("pluginStatus", t("skills.pick_project_for_plugins")); - return; - } - - try { - setStateField("pluginStatus", null); - const config = (await readOpencodeConfig(scope, targetDir)) as OpencodeConfigFile; - const raw = config.content ?? ""; - if (!raw.trim()) { - setStateField("pluginStatus", "No plugins configured yet."); - return; - } - - const plugins = parsePluginListFromContent(raw); - const desired = stripPluginVersion(name).toLowerCase(); - const next = plugins.filter((entry) => stripPluginVersion(entry).toLowerCase() !== desired); - if (next.length === plugins.length) { - setStateField("pluginStatus", "Plugin not found."); - return; - } - - const edits = modify(raw, ["plugin"], next, { formattingOptions: { insertSpaces: true, tabSize: 2 } }); - const updated = applyEdits(raw, edits); - await writeOpencodeConfig(scope, targetDir, updated); - options.markReloadRequired?.("plugins", { type: "plugin", name: triggerName, action: "removed" }); - await refreshPlugins(scope); - } catch (error) { - setStateField("pluginStatus", error instanceof Error ? error.message : t("skills.failed_update_opencode")); - } - } - async function importLocalSkill() { const isLocalWorkspace = options.workspaceType() === "local"; if (!isDesktopRuntime()) { @@ -2582,10 +1203,9 @@ export function createExtensionsStore(options: { } try { - const [opencodeSkills, claudeSkills, legacySkills] = await Promise.all([ + const [opencodeSkills, claudeSkills] = await Promise.all([ joinDesktopPath(root, ".opencode", "skills"), joinDesktopPath(root, ".claude", "skills"), - joinDesktopPath(root, ".opencode", "skill"), ]); const tryOpen = async (target: string) => { try { @@ -2597,7 +1217,6 @@ export function createExtensionsStore(options: { }; if (await tryOpen(opencodeSkills)) return; if (await tryOpen(claudeSkills)) return; - if (await tryOpen(legacySkills)) return; await revealDesktopItemInDir(opencodeSkills); } catch (error) { setStateField("skillsStatus", error instanceof Error ? error.message : t("skills.reveal_failed")); @@ -2641,7 +1260,7 @@ export function createExtensionsStore(options: { try { setStateField("skillsStatus", null); const result = await ipolloworkClient.getSkill(ipolloworkWorkspaceId, trimmed, { - includeGlobal: isLocalWorkspace && (options.allowGlobalExtensions?.() ?? true), + includeGlobal: isLocalWorkspace, }); return { name: result.item.name, path: result.item.path, content: result.content }; } catch (error) { @@ -2762,7 +1381,6 @@ export function createExtensionsStore(options: { function abortRefreshes() { refreshSkillsAborted = true; - refreshPluginsAborted = true; refreshHubSkillsAborted = true; refreshCloudOrgSkillsAborted = true; refreshCloudOrgMarketplacesAborted = true; @@ -2773,11 +1391,6 @@ export function createExtensionsStore(options: { void refreshSkills({ force: true }); } - function ensurePluginsFresh(scopeOverride?: PluginScope) { - if (!snapshot.pluginsStale) return; - void refreshPlugins(scopeOverride); - } - function ensureHubSkillsFresh() { if (!snapshot.hubSkillsStale) return; void refreshHubSkills({ force: true }); @@ -2908,9 +1521,7 @@ export function createExtensionsStore(options: { touch(); if (!key || key === "::::") return; void refreshSkills({ force: true }); - void refreshPlugins(); void refreshImportedCloudSkills(); - void refreshImportedCloudPlugins(); }; refreshSnapshot(); @@ -2939,40 +1550,12 @@ export function createExtensionsStore(options: { importedCloudSkills: () => snapshot.importedCloudSkills, cloudOrgMarketplaces: () => snapshot.cloudOrgMarketplaces, cloudOrgMarketplacesStatus: () => snapshot.cloudOrgMarketplacesStatus, - importedCloudMarketplaces: () => snapshot.importedCloudMarketplaces, - importedCloudPlugins: () => snapshot.importedCloudPlugins, - pendingCloudPluginChanges: () => snapshot.pendingCloudPluginChanges, hubRepo: () => snapshot.hubRepo, hubRepos: () => snapshot.hubRepos, - get pluginScope() { - return snapshot.pluginScope; - }, - setPluginScope(value: SetStateAction) { - const resolved = applyStateAction(state.pluginScope, value); - setStateField("pluginScope", resolved); - }, - pluginConfig: () => snapshot.pluginConfig, - pluginConfigPath: () => snapshot.pluginConfigPath, - pluginList: () => snapshot.pluginList, - pluginInput: () => snapshot.pluginInput, - setPluginInput(value: SetStateAction) { - const resolved = applyStateAction(state.pluginInput, value); - setStateField("pluginInput", resolved); - }, - pluginStatus: () => snapshot.pluginStatus, - activePluginGuide: () => snapshot.activePluginGuide, - setActivePluginGuide(value: SetStateAction) { - const resolved = applyStateAction(state.activePluginGuide, value); - setStateField("activePluginGuide", resolved); - }, - sidebarPluginList: () => snapshot.sidebarPluginList, - sidebarPluginStatus: () => snapshot.sidebarPluginStatus, workspaceContextKey: () => snapshot.workspaceContextKey, skillsStale: () => snapshot.skillsStale, - pluginsStale: () => snapshot.pluginsStale, hubSkillsStale: () => snapshot.hubSkillsStale, cloudOrgSkillsStale: () => snapshot.cloudOrgSkillsStale, - isPluginInstalledByName, refreshSkills, refreshHubSkills, refreshCloudOrgSkills, @@ -2980,26 +1563,18 @@ export function createExtensionsStore(options: { setHubRepo, addHubRepo, removeHubRepo, - refreshPlugins, - addPlugin, - removePlugin, importLocalSkill, installSkillCreator, installHubSkill, installCloudOrgSkill, syncCloudOrgSkill, removeCloudOrgSkill, - importCloudOrgPlugin, - removeCloudOrgPlugin, - previewClaudePlugin, - installClaudePlugin, revealSkillsFolder, uninstallSkill, readSkill, saveSkill, abortRefreshes, ensureSkillsFresh, - ensurePluginsFresh, ensureHubSkillsFresh, ensureCloudOrgSkillsFresh, }; diff --git a/apps/app/src/react-app/infra/preferred-chat-model.ts b/apps/app/src/react-app/infra/preferred-chat-model.ts index a6925e2c1..845269bd5 100644 --- a/apps/app/src/react-app/infra/preferred-chat-model.ts +++ b/apps/app/src/react-app/infra/preferred-chat-model.ts @@ -37,3 +37,32 @@ export function resolvePreferredSelectableChatModel(input: { return currentAvailable ? (input.current ?? null) : null; } + +/** + * Resolve the model an engine can execute without changing the app-wide + * preference. Switching engines must not replace the user's shared model just + * because one runtime lacks that route; the preferred model becomes active + * again as soon as the user returns to an engine that supports it. + */ +export function resolveEngineSelectableChatModel(input: { + providers: SelectableChatModelSnapshot; + defaults?: Record; + preferred: ModelRef | null | undefined; +}): ModelRef | null { + const preferred = resolvePreferredSelectableChatModel({ + providers: input.providers, + defaults: input.defaults, + current: input.preferred, + }); + if (preferred) return preferred; + + for (const provider of input.providers) { + const defaultModel = input.defaults?.[provider.providerID]; + const modelID = defaultModel && provider.modelIDs.includes(defaultModel) + ? defaultModel + : provider.modelIDs[0]; + if (modelID) return { providerID: provider.providerID, modelID }; + } + + return null; +} diff --git a/apps/app/src/react-app/kernel/local-provider.tsx b/apps/app/src/react-app/kernel/local-provider.tsx index 47659d9e3..9ff65b507 100644 --- a/apps/app/src/react-app/kernel/local-provider.tsx +++ b/apps/app/src/react-app/kernel/local-provider.tsx @@ -5,13 +5,12 @@ import { use, useEffect, useMemo, - useRef, useState, type ReactNode, } from "react"; import { DEFAULT_ENGINE_ID } from "@ipollowork/types/workspace"; -import { THINKING_PREF_KEY } from "../../app/constants"; +import { DEFAULT_MODEL } from "../../app/constants"; import type { ModelRef, SettingsTab, View } from "../../app/types"; import { DEFAULT_DESKTOP_NOTIFICATION_PREFERENCE, @@ -19,7 +18,6 @@ import { type DesktopNotificationPreference, } from "./desktop-notification-preferences"; import { LOCAL_PREFERENCES_KEY } from "./local-preferences-storage"; -import { readStoredDefaultModel } from "./model-config"; export type LocalUIState = { view: View; @@ -28,6 +26,8 @@ export type LocalUIState = { export type LocalPreferences = { showThinking: boolean; + model: ModelRef; + modelVariant: string | null; enginePreferences: Record; featureFlags: { microsandboxCreateSandbox: boolean; @@ -57,8 +57,6 @@ export type LocalPreferences = { }; export type EnginePreferences = { - model: ModelRef | null; - modelVariant: string | null; mode: string | null; }; @@ -78,6 +76,8 @@ export const DEFAULT_SHOW_THINKING = true; const INITIAL_UI: LocalUIState = { view: "settings", tab: "preferences" }; const INITIAL_PREFS: LocalPreferences = { showThinking: DEFAULT_SHOW_THINKING, + model: DEFAULT_MODEL, + modelVariant: null, enginePreferences: {}, featureFlags: { microsandboxCreateSandbox: true, memory: false }, hasCompletedOnboarding: false, @@ -86,8 +86,6 @@ const INITIAL_PREFS: LocalPreferences = { }; const EMPTY_ENGINE_PREFERENCES: EnginePreferences = { - model: null, - modelVariant: null, mode: null, }; @@ -96,15 +94,7 @@ export function getEnginePreferences( engineId?: string | null, ): EnginePreferences { const resolvedEngineId = engineId?.trim() || DEFAULT_ENGINE_ID; - const enginePreferences = preferences.enginePreferences[resolvedEngineId] - ?? EMPTY_ENGINE_PREFERENCES; - const sharedModelPreferences = preferences.enginePreferences[DEFAULT_ENGINE_ID] - ?? EMPTY_ENGINE_PREFERENCES; - return { - ...enginePreferences, - model: sharedModelPreferences.model, - modelVariant: sharedModelPreferences.modelVariant, - }; + return preferences.enginePreferences[resolvedEngineId] ?? EMPTY_ENGINE_PREFERENCES; } export function updateEnginePreferences( @@ -115,28 +105,27 @@ export function updateEnginePreferences( const resolvedEngineId = engineId?.trim() || DEFAULT_ENGINE_ID; const previousEngine = getEnginePreferences(preferences, resolvedEngineId); const next = updater(previousEngine); - const previousShared = preferences.enginePreferences[DEFAULT_ENGINE_ID] - ?? EMPTY_ENGINE_PREFERENCES; return { ...preferences, enginePreferences: { ...preferences.enginePreferences, - [DEFAULT_ENGINE_ID]: { - ...previousShared, - model: next.model, - modelVariant: next.modelVariant, - }, [resolvedEngineId]: { - ...(preferences.enginePreferences[resolvedEngineId] ?? EMPTY_ENGINE_PREFERENCES), - ...(resolvedEngineId === DEFAULT_ENGINE_ID - ? { model: next.model, modelVariant: next.modelVariant } - : {}), mode: next.mode, }, }, }; } +export function updateModelPreferences( + preferences: LocalPreferences, + updater: (previous: Pick) => Pick, +): LocalPreferences { + return { + ...preferences, + ...updater({ model: preferences.model, modelVariant: preferences.modelVariant }), + }; +} + function normalizeModelRef(value: unknown): ModelRef | null { if (!value || typeof value !== "object") return null; const model = value as Partial; @@ -152,8 +141,6 @@ function normalizeEnginePreferences(value: unknown): Record; return [[engineId, { - model: normalizeModelRef(record.model), - modelVariant: typeof record.modelVariant === "string" ? record.modelVariant : null, mode: typeof record.mode === "string" ? record.mode : null, }]]; }), @@ -193,41 +180,20 @@ export function LocalProvider({ children }: LocalProviderProps) { readPersisted(UI_STORAGE_KEY, INITIAL_UI), ); const [prefs, setPrefsRaw] = useState(() => { - const persisted = readPersisted(LOCAL_PREFERENCES_KEY, INITIAL_PREFS) as LocalPreferences & { - defaultModel?: unknown; - modelVariant?: unknown; - selectedAgent?: unknown; - }; + const persisted = readPersisted(LOCAL_PREFERENCES_KEY, INITIAL_PREFS); delete (persisted as { releaseChannel?: unknown }).releaseChannel; delete (persisted as { providerStepCompleted?: unknown }).providerStepCompleted; persisted.desktopNotifications = isDesktopNotificationPreference(persisted.desktopNotifications) ? persisted.desktopNotifications : DEFAULT_DESKTOP_NOTIFICATION_PREFERENCE; - const enginePreferences = normalizeEnginePreferences(persisted.enginePreferences); - const openCodePreferences = enginePreferences[DEFAULT_ENGINE_ID] ?? EMPTY_ENGINE_PREFERENCES; - const firstConfiguredModel = Object.values(enginePreferences).find((selection) => selection.model)?.model - ?? null; - const firstConfiguredVariant = Object.values(enginePreferences).find( - (selection) => selection.modelVariant, - )?.modelVariant ?? null; - enginePreferences[DEFAULT_ENGINE_ID] = { - model: openCodePreferences.model - ?? firstConfiguredModel - ?? normalizeModelRef(persisted.defaultModel) - ?? readStoredDefaultModel(), - modelVariant: openCodePreferences.modelVariant - ?? firstConfiguredVariant - ?? (typeof persisted.modelVariant === "string" ? persisted.modelVariant : null), - mode: openCodePreferences.mode - ?? (typeof persisted.selectedAgent === "string" ? persisted.selectedAgent : null), + return { + ...persisted, + model: normalizeModelRef(persisted.model) ?? DEFAULT_MODEL, + modelVariant: typeof persisted.modelVariant === "string" ? persisted.modelVariant : null, + enginePreferences: normalizeEnginePreferences(persisted.enginePreferences), }; - delete persisted.defaultModel; - delete persisted.modelVariant; - delete persisted.selectedAgent; - return { ...persisted, enginePreferences }; }); const ready = true; - const migratedThinkingRef = useRef(false); useEffect(() => { writePersisted(UI_STORAGE_KEY, ui); @@ -237,30 +203,6 @@ export function LocalProvider({ children }: LocalProviderProps) { writePersisted(LOCAL_PREFERENCES_KEY, prefs); }, [prefs]); - useEffect(() => { - if (typeof window === "undefined") return; - if (migratedThinkingRef.current) return; - migratedThinkingRef.current = true; - - const raw = window.localStorage.getItem(THINKING_PREF_KEY); - if (raw == null) return; - - try { - const parsed = JSON.parse(raw); - if (typeof parsed === "boolean") { - setPrefsRaw((previous) => ({ ...previous, showThinking: parsed })); - } - } catch { - // ignore invalid legacy values - } - - try { - window.localStorage.removeItem(THINKING_PREF_KEY); - } catch { - // ignore - } - }, []); - const setUi = useCallback( (updater: (previous: LocalUIState) => LocalUIState) => { setUiRaw(updater); diff --git a/apps/app/src/react-app/kernel/model-config.ts b/apps/app/src/react-app/kernel/model-config.ts deleted file mode 100644 index 711019920..000000000 --- a/apps/app/src/react-app/kernel/model-config.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; - -import { - DEFAULT_MODEL, - MODEL_PREF_KEY, - SESSION_MODEL_PREF_KEY, - VARIANT_PREF_KEY, -} from "../../app/constants"; -import type { ModelRef } from "../../app/types"; -import { - formatModelRef, - parseModelRef, -} from "../../app/utils"; -import { normalizeModelBehaviorValue } from "../../app/lib/model-behavior"; - -export type SessionChoiceOverride = { - model?: ModelRef | null; - variant?: string | null; -}; - -export type ModelPickerTarget = "default" | "session"; - -const hasOwn = ( - value: object, - key: K, -): value is Record => - Object.prototype.hasOwnProperty.call(value, key); - -export function sessionModelOverridesKey(workspaceId: string): string { - return `${SESSION_MODEL_PREF_KEY}.${workspaceId}`; -} - -export function workspaceModelVariantsKey(workspaceId: string): string { - return `${VARIANT_PREF_KEY}.${workspaceId}`; -} - -const normalizeVariantOverride = (value: unknown) => { - if (typeof value === "string") return normalizeModelBehaviorValue(value); - if (value == null) return null; - return null; -}; - -const parseStoredModel = (value: unknown) => { - if (typeof value === "string") return parseModelRef(value); - if (!value || typeof value !== "object") return null; - const record = value as Record; - if ( - typeof record.providerID === "string" && - typeof record.modelID === "string" - ) { - return { providerID: record.providerID, modelID: record.modelID }; - } - return null; -}; - -const normalizeSessionChoice = ( - value: SessionChoiceOverride | null | undefined, -): SessionChoiceOverride | null => { - if (!value || typeof value !== "object") return null; - const next: SessionChoiceOverride = {}; - if (value.model) next.model = value.model; - if (hasOwn(value, "variant")) { - next.variant = normalizeModelBehaviorValue(value.variant ?? null); - } - return hasOwn(next, "variant") || next.model ? next : null; -}; - -export function parseSessionChoiceOverrides( - raw: string | null, -): Record { - if (!raw) return {}; - try { - const parsed = JSON.parse(raw) as Record; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return {}; - } - - const next: Record = {}; - for (const [sessionId, value] of Object.entries(parsed)) { - if (typeof value === "string") { - const model = parseModelRef(value); - if (model) next[sessionId] = { model }; - continue; - } - if (!value || typeof value !== "object" || Array.isArray(value)) continue; - const record = value as Record; - const model = parseStoredModel(record.model ?? record); - const choice = normalizeSessionChoice({ - ...(model ? { model } : {}), - ...(hasOwn(record, "variant") - ? { variant: normalizeVariantOverride(record.variant) } - : {}), - }); - if (choice) next[sessionId] = choice; - } - return next; - } catch { - return {}; - } -} - -export function serializeSessionChoiceOverrides( - overrides: Record, -): string | null { - const entries = Object.entries(overrides).flatMap(([sessionId, choice]) => { - const normalized = normalizeSessionChoice(choice); - return normalized ? [[sessionId, normalized] as const] : []; - }); - - if (!entries.length) return null; - - const payload: Record = - {}; - for (const [sessionId, choice] of entries) { - const next: { model?: string; variant?: string | null } = {}; - if (choice.model) next.model = formatModelRef(choice.model); - if (hasOwn(choice, "variant")) next.variant = choice.variant ?? null; - payload[sessionId] = next; - } - return JSON.stringify(payload); -} - -export function parseWorkspaceModelVariants( - raw: string | null, - fallbackModel: ModelRef = DEFAULT_MODEL, -): Record { - if (!raw || !raw.trim()) return {}; - try { - const parsed = JSON.parse(raw) as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - const normalized = normalizeModelBehaviorValue(raw); - return normalized ? { [formatModelRef(fallbackModel)]: normalized } : {}; - } - const next: Record = {}; - for (const [key, value] of Object.entries( - parsed as Record, - )) { - const normalized = normalizeVariantOverride(value); - if (normalized) next[key] = normalized; - } - return next; - } catch { - const normalized = normalizeModelBehaviorValue(raw); - return normalized ? { [formatModelRef(fallbackModel)]: normalized } : {}; - } -} - -export function readStoredDefaultModel(): ModelRef { - if (typeof window === "undefined") return DEFAULT_MODEL; - try { - const stored = window.localStorage.getItem(MODEL_PREF_KEY); - return parseModelRef(stored) ?? DEFAULT_MODEL; - } catch { - return DEFAULT_MODEL; - } -} - -export function writeStoredDefaultModel(model: ModelRef): void { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem(MODEL_PREF_KEY, formatModelRef(model)); - } catch { - // ignore quota errors - } -} - -/** - * Minimal React hook covering the default model picker state. The richer - * session/workspace model overrides from context/model-config.ts will be - * ported incrementally as the session and settings surfaces migrate. - */ -export function useDefaultModel(): [ModelRef, (next: ModelRef) => void] { - const [model, setModel] = useState(() => readStoredDefaultModel()); - - useEffect(() => { - writeStoredDefaultModel(model); - }, [model]); - - const update = useCallback((next: ModelRef) => { - setModel(next); - }, []); - - return [model, update]; -} diff --git a/apps/app/src/react-app/shell/session-route.tsx b/apps/app/src/react-app/shell/session-route.tsx index 7971e08f4..080b41af0 100644 --- a/apps/app/src/react-app/shell/session-route.tsx +++ b/apps/app/src/react-app/shell/session-route.tsx @@ -17,7 +17,6 @@ import { } from "@ipollowork/types/workspace"; import { captureAnalyticsEvent, markTaskRunStart } from "@/app/lib/analytics"; -import { DeepSeekHarnessClient } from "@/app/lib/deepseek-harness-client"; import { createClient } from "@/app/lib/opencode"; import { PERSONAL_WORK_CONTEXT_ID, @@ -71,6 +70,7 @@ import { } from "@/react-app/shell/route-workspaces"; import { getEnginePreferences, + updateModelPreferences, updateEnginePreferences, useLocal, } from "@/react-app/kernel/local-provider"; @@ -155,11 +155,10 @@ import { SettingsSurface } from "./settings-route"; import { ensureProviderListQuery, getSelectableChatModelSnapshot, - isModelAvailableInSelectableChatProviders, type ProviderListQueryInput, useProviderListQuery, } from "@/react-app/infra/provider-list-query"; -import { resolvePreferredSelectableChatModel } from "@/react-app/infra/preferred-chat-model"; +import { resolveEngineSelectableChatModel } from "@/react-app/infra/preferred-chat-model"; import { designSelectionContextsForDraft, draftToParts, @@ -284,7 +283,7 @@ export function SessionRoute() { const conversationConnectionKey = `${selectedWorkspace?.engineId?.trim() || DEFAULT_ENGINE_ID}:${opencodeBaseUrl}:${selectedWorkspaceServerToken}`; const activeEngineId = selectedWorkspace?.engineId?.trim() || DEFAULT_ENGINE_ID; const activeEnginePreferences = getEnginePreferences(local.prefs, activeEngineId); - const selectedModel = activeEnginePreferences.model; + const selectedModel = local.prefs.model; const selectedMode = activeEnginePreferences.mode; const [modeSelectionLocked, setModeSelectionLocked] = useState(false); useEffect(() => { @@ -292,19 +291,12 @@ export function SessionRoute() { }, [activeEngineId, selectedSessionId]); const engineProviderClient = useMemo(() => { if (activeEngineId === DEFAULT_ENGINE_ID) return opencodeClient; - if ( - activeEngineId !== DEEPSEEK_HARNESS_ENGINE_ID - || !selectedWorkspaceEndpoint - || !selectedWorkspaceServerToken - ) { - return null; - } - return new DeepSeekHarnessClient({ - serverBaseUrl: selectedWorkspaceEndpoint.baseUrl, - workspaceId: selectedWorkspaceEndpoint.workspaceId, - token: selectedWorkspaceServerToken, + if (!selectedWorkspaceEndpoint || !selectedWorkspaceServerToken) return null; + return providerEngineAdapters.createClient(activeEngineId, { + endpoint: selectedWorkspaceEndpoint, + directory: selectedWorkspace?.path, }); - }, [activeEngineId, opencodeClient, selectedWorkspaceEndpoint, selectedWorkspaceServerToken]); + }, [activeEngineId, opencodeClient, selectedWorkspace?.path, selectedWorkspaceEndpoint, selectedWorkspaceServerToken]); const sharedProviderWorkspace = useMemo( () => selectSharedProviderWorkspace(workspaces, selectedWorkspace), [selectedWorkspace, workspaces], @@ -327,28 +319,18 @@ export function SessionRoute() { const sharedProviderRoot = sharedProviderWorkspace?.path?.trim() || ""; const sharedProviderClient = useMemo(() => { if (!sharedProviderEndpoint?.token) return null; - if (sharedProviderEngineId === DEFAULT_ENGINE_ID) { - return createClient( - sharedProviderEndpoint.opencodeBaseUrl, - sharedProviderRoot || undefined, - { token: sharedProviderEndpoint.token, mode: "ipollowork" }, - ); - } - if (sharedProviderEngineId !== DEEPSEEK_HARNESS_ENGINE_ID) return null; - return new DeepSeekHarnessClient({ - serverBaseUrl: sharedProviderEndpoint.baseUrl, - workspaceId: sharedProviderEndpoint.workspaceId, - token: sharedProviderEndpoint.token, + return providerEngineAdapters.createClient(sharedProviderEngineId, { + endpoint: sharedProviderEndpoint, + directory: sharedProviderRoot, }); }, [sharedProviderEndpoint, sharedProviderEngineId, sharedProviderRoot]); const deepSeekHarnessProviderClient = useMemo(() => { if (!deepSeekHarnessEndpoint?.token) return null; - return new DeepSeekHarnessClient({ - serverBaseUrl: deepSeekHarnessEndpoint.baseUrl, - workspaceId: deepSeekHarnessEndpoint.workspaceId, - token: deepSeekHarnessEndpoint.token, + return providerEngineAdapters.createClient(DEEPSEEK_HARNESS_ENGINE_ID, { + endpoint: deepSeekHarnessEndpoint, + directory: deepSeekHarnessWorkspace?.path, }); - }, [deepSeekHarnessEndpoint]); + }, [deepSeekHarnessEndpoint, deepSeekHarnessWorkspace?.path]); const modelCatalogSources = useMemo(() => { const sources: ProviderListQueryInput[] = []; if (sharedProviderClient) { @@ -501,17 +483,14 @@ export function SessionRoute() { void loadWorkspaceSessionsInBackground([project]); } - if (isDesktopRuntime()) { - void workspaceSetSelected(workspaceId).catch(() => undefined); - void workspaceSetRuntimeActive(workspaceId).catch(() => undefined); - } - - const endpoint = endpointForWorkspace(project); - if (endpoint) { - void endpoint.client.activateWorkspace(endpoint.workspaceId, { persist: true }).catch(() => undefined); - } - - navigateToWorkspaceSession(workspaceId); + const knownSessions = sessionsByWorkspaceId[workspaceId] ?? []; + const rememberedSessionId = readLastSessionFor(workspaceId); + const targetSessionId = knownSessions.find((session) => session.id === rememberedSessionId)?.id + ?? knownSessions[0]?.id + ?? rememberedSessionId + ?? null; + if (targetSessionId) writeLastSessionFor(workspaceId, targetSessionId); + navigateToWorkspaceSession(workspaceId, targetSessionId); return true; }, [ activeWorkContextId, @@ -680,13 +659,6 @@ export function SessionRoute() { baseUrl: selectedWorkspaceEndpoint?.opencodeBaseUrl, directory: selectedWorkspaceRoot || undefined, }); - const { providerCatalog, modelVariantLabel, modelBehaviorOptions, modelVariantValue } = - useModelBehavior({ - providerList: providerListQuery.data, - defaultModel: selectedModel, - modelVariant: activeEnginePreferences.modelVariant, - }); - const selectedModelSupportsAttachments = modelSupportsAttachments(providerCatalog, selectedModel); const modelPicker = useModelPicker({ client: engineProviderClient, engineId: activeEngineId, @@ -695,11 +667,9 @@ export function SessionRoute() { catalogSources: modelCatalogSources, }); const setSelectedModel = useCallback((model: ModelRef) => { - local.setPrefs((previous) => updateEnginePreferences( + local.setPrefs((previous) => updateModelPreferences( previous, - activeEngineId, (selection) => ({ - ...selection, model, modelVariant: selection.model?.providerID === model.providerID && selection.model.modelID === model.modelID @@ -707,54 +677,49 @@ export function SessionRoute() { : null, }), )); - }, [activeEngineId, local.setPrefs]); + }, [local.setPrefs]); const setModelVariant = useCallback((modelVariant: string | null) => { - local.setPrefs((previous) => updateEnginePreferences( + local.setPrefs((previous) => updateModelPreferences( previous, - activeEngineId, (selection) => ({ ...selection, modelVariant }), )); - }, [activeEngineId, local.setPrefs]); - useEffect(() => { - if (!providerListQuery.data) return; - const preferredSelectableModel = resolvePreferredSelectableChatModel({ - providers: getSelectableChatModelSnapshot(providerListQuery.data), - defaults: providerListQuery.data.default, - current: selectedModel, - }); - const preferredModel = preferredSelectableModel; - if ( - !preferredModel || - ( - preferredModel.providerID === selectedModel?.providerID && - preferredModel.modelID === selectedModel.modelID - ) - ) { - return; - } - setSelectedModel(preferredModel); - }, [providerListQuery.data, selectedModel, setSelectedModel]); + }, [local.setPrefs]); const selectableModels = getSelectableChatModelSnapshot(providerListQuery.data); - const selectedModelUnavailable = Boolean( - providerListQuery.data && ( - !selectedModel || - ( - isDesktopProviderBlocked({ - providerId: selectedModel.providerID, - checkRestriction: checkDesktopRestriction, - }) || - ( - checkDesktopRestriction({ restriction: "allowCustomProviders" }) && - !providerConnectedIds.some( - (providerId) => providerId.trim() === selectedModel.providerID.trim(), - ) - ) || - !isModelAvailableInSelectableChatProviders(providerListQuery.data, selectedModel) - ) || - selectableModels.every((provider) => provider.modelIDs.length === 0) + const customProvidersRestricted = checkDesktopRestriction({ restriction: "allowCustomProviders" }); + const permittedSelectableModels = selectableModels.filter((provider) => ( + !isDesktopProviderBlocked({ + providerId: provider.providerID, + checkRestriction: checkDesktopRestriction, + }) && ( + !customProvidersRestricted || + providerConnectedIds.some((providerId) => providerId.trim() === provider.providerID.trim()) ) + )); + const activeSelectedModel = providerListQuery.data + ? resolveEngineSelectableChatModel({ + providers: permittedSelectableModels, + defaults: providerListQuery.data.default, + preferred: selectedModel, + }) + : selectedModel; + const usesSharedModelPreference = Boolean( + selectedModel && + activeSelectedModel && + selectedModel.providerID === activeSelectedModel.providerID && + selectedModel.modelID === activeSelectedModel.modelID, ); - const hasUsableModel = Boolean(selectedModel && !selectedModelUnavailable); + const activeModelVariant = usesSharedModelPreference + ? local.prefs.modelVariant + : null; + const { providerCatalog, modelVariantLabel, modelBehaviorOptions, modelVariantValue } = + useModelBehavior({ + providerList: providerListQuery.data, + defaultModel: activeSelectedModel, + modelVariant: activeModelVariant, + }); + const selectedModelSupportsAttachments = modelSupportsAttachments(providerCatalog, activeSelectedModel); + const selectedModelUnavailable = Boolean(providerListQuery.data && !activeSelectedModel); + const hasUsableModel = Boolean(activeSelectedModel && !selectedModelUnavailable); // Creating and opening a conversation does not require a usable model. // Keeping this separate from `canCreateTask` prevents a first-run workspace // from landing on an empty pane when its model setup is still incomplete or @@ -877,8 +842,8 @@ export function SessionRoute() { }; }, [denSessionVersion, sharedProviderClient, sharedProviderEndpoint?.opencodeBaseUrl, sharedProviderEngineId, sharedProviderRoot]); - const modelLabel = selectedModel - ? resolveModelDisplayName(selectedModel.modelID) + const modelLabel = activeSelectedModel + ? resolveModelDisplayName(activeSelectedModel.modelID) : t("session.default_model"); const listSlashCommands = useCallback(async (): Promise => { @@ -996,7 +961,7 @@ export function SessionRoute() { }, modelPickerOpen: modelPicker.compactOpen, modelUnavailable: selectedModelUnavailable, - selectedModel: selectedModel ?? { providerID: "", modelID: "" }, + selectedModel: activeSelectedModel ?? { providerID: "", modelID: "" }, onModelPickerOpenChange: (open: boolean) => { if (open && !hasUsableModel) { void sessionProviderAuthStore.openProviderAuthModal({ returnFocusTarget: "composer" }); @@ -1059,8 +1024,8 @@ export function SessionRoute() { attachment_count: draft.attachments.length, text_length: text.length, workspace_type: selectedWorkspace?.workspaceType ?? "unknown", - provider_id: selectedModel?.providerID ?? null, - model_id: selectedModel?.modelID ?? null, + provider_id: activeSelectedModel?.providerID ?? null, + model_id: activeSelectedModel?.modelID ?? null, }); markTaskRunStart(targetSessionId); // Den org adoption signals (auth-gated inside; no-op when signed out). @@ -1237,12 +1202,12 @@ export function SessionRoute() { prompt: () => conversation.sendPrompt({ sessionId: targetSessionId, parts: promptParts, - model: selectedModel ?? undefined, + model: activeSelectedModel ?? undefined, mode: selectedMode ?? undefined, - reasoningEffort: selectedModel?.providerID === "tokenstar" && modelVariantValue && tokenStarModelSupportsEffort(selectedModel.modelID) + reasoningEffort: activeSelectedModel?.providerID === "tokenstar" && modelVariantValue && tokenStarModelSupportsEffort(activeSelectedModel.modelID) ? modelVariantValue : undefined, - variant: selectedModel?.providerID === "tokenstar" && tokenStarModelSupportsEffort(selectedModel.modelID) + variant: activeSelectedModel?.providerID === "tokenstar" && tokenStarModelSupportsEffort(activeSelectedModel.modelID) ? undefined : modelVariantValue ?? undefined, system: systemContext || undefined, @@ -1338,7 +1303,7 @@ export function SessionRoute() { opencodeBaseUrl, providerConnectedIds, selectedMode, - selectedModel, + activeSelectedModel, selectedSessionId, selectedModelSupportsAttachments, selectedModelUnavailable, @@ -2231,7 +2196,7 @@ export function SessionRoute() { query={modelPicker.query} setQuery={modelPicker.setQuery} target="default" - current={selectedModel ?? ({ providerID: "", modelID: "" } satisfies ModelRef)} + current={activeSelectedModel ?? ({ providerID: "", modelID: "" } satisfies ModelRef)} onSelect={(next: ModelRef) => { setSelectedModel(next); modelPicker.setOpen(false); diff --git a/apps/app/src/react-app/shell/settings-route.tsx b/apps/app/src/react-app/shell/settings-route.tsx index 49721dccf..b3087151a 100644 --- a/apps/app/src/react-app/shell/settings-route.tsx +++ b/apps/app/src/react-app/shell/settings-route.tsx @@ -8,7 +8,6 @@ import { Button } from "@/components/ui/button"; import { getMcpServerName, MCP_QUICK_CONNECT, - SUGGESTED_PLUGINS, } from "@/app/constants"; import { filterWorkspacesForWorkContext, @@ -26,7 +25,6 @@ import { type iPolloWorkServerCapabilities, type iPolloWorkServerClient, } from "@/app/lib/ipollowork-server"; -import { DeepSeekHarnessClient } from "@/app/lib/deepseek-harness-client"; import { resolveWorkspaceEndpoint } from "@/app/lib/workspace-endpoint"; import { buildiPolloWorkEnvRuntimeKey } from "@/app/lib/ipollowork-env-runtime"; import { @@ -104,11 +102,7 @@ import { useBootState } from "./boot-state"; import { SettingsShell } from "@/react-app/domains/settings/shell/settings-shell"; import { createExtensionsStore, useExtensionsStoreSnapshot } from "@/react-app/domains/settings/state/extensions-store"; import { usePlatform } from "@/react-app/kernel/platform"; -import { - getEnginePreferences, - updateEnginePreferences, - useLocal, -} from "@/react-app/kernel/local-provider"; +import { updateModelPreferences, useLocal } from "@/react-app/kernel/local-provider"; import { ipolloworkServerRestart, engineStart, @@ -164,7 +158,6 @@ import { WorkspaceAppFrame } from "@/react-app/plugin-ui/workspace-app-frame"; const ROUTE_IPOLLOWORK_CAPABILITIES: iPolloWorkServerCapabilities = { skills: { read: true, write: true, source: "ipollowork" }, - plugins: { read: true, write: true }, mcp: { read: true, write: true }, commands: { read: true, write: true }, config: { read: true, write: true }, @@ -696,7 +689,6 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { selectedWorkspaceId: () => routeStateRef.current.selectedWorkspaceId, selectedWorkspaceRoot: () => routeStateRef.current.selectedWorkspaceRoot, workspaceType: () => routeStateRef.current.selectedWorkspaceType, - allowGlobalExtensions: () => readActiveWorkContextId() === PERSONAL_WORK_CONTEXT_ID, ipolloworkServer: ipolloworkServerStore, ipolloworkServerConnection: () => ({ ipolloworkServerClient: routeStateRef.current.ipolloworkServerClient, @@ -869,7 +861,6 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { description: page.description, iconSrc: page.iconSrc, })), [settingsPages]); - const activeEnginePreferences = getEnginePreferences(local.prefs, activeEngineId); routeStateRef.current.runtimeWorkspaceId = runtimeWorkspaceId; const opencodeClient = useMemo(() => { @@ -898,28 +889,18 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { ); const sharedProviderClient = useMemo(() => { if (!sharedProviderEndpoint?.token) return null; - if (sharedProviderEngineId === DEFAULT_ENGINE_ID) { - return createClient( - sharedProviderEndpoint.opencodeBaseUrl, - sharedProviderRoot || undefined, - { token: sharedProviderEndpoint.token, mode: "ipollowork" }, - ); - } - if (sharedProviderEngineId !== DEEPSEEK_HARNESS_ENGINE_ID) return null; - return new DeepSeekHarnessClient({ - serverBaseUrl: sharedProviderEndpoint.baseUrl, - workspaceId: sharedProviderEndpoint.workspaceId, - token: sharedProviderEndpoint.token, + return providerEngineAdapters.createClient(sharedProviderEngineId, { + endpoint: sharedProviderEndpoint, + directory: sharedProviderRoot, }); }, [sharedProviderEndpoint, sharedProviderEngineId, sharedProviderRoot]); const deepSeekHarnessProviderClient = useMemo(() => { if (!deepSeekHarnessEndpoint?.token) return null; - return new DeepSeekHarnessClient({ - serverBaseUrl: deepSeekHarnessEndpoint.baseUrl, - workspaceId: deepSeekHarnessEndpoint.workspaceId, - token: deepSeekHarnessEndpoint.token, + return providerEngineAdapters.createClient(DEEPSEEK_HARNESS_ENGINE_ID, { + endpoint: deepSeekHarnessEndpoint, + directory: deepSeekHarnessWorkspace?.path, }); - }, [deepSeekHarnessEndpoint]); + }, [deepSeekHarnessEndpoint, deepSeekHarnessWorkspace?.path]); const deepSeekHarnessProviderQuery = useProviderListQuery({ client: deepSeekHarnessProviderClient, engineId: DEEPSEEK_HARNESS_ENGINE_ID, @@ -961,11 +942,9 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { ? deepSeekHarnessWorkspace?.path?.trim() || "" : sharedProviderRoot; const setActiveModel = useCallback((model: ModelRef) => { - local.setPrefs((previous) => updateEnginePreferences( + local.setPrefs((previous) => updateModelPreferences( previous, - activeEngineId, (selection) => ({ - ...selection, model, modelVariant: selection.model?.providerID === model.providerID && selection.model.modelID === model.modelID @@ -973,7 +952,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { : null, }), )); - }, [activeEngineId, local.setPrefs]); + }, [local.setPrefs]); useEffect(() => { setActiveClient(opencodeClient); @@ -1617,12 +1596,8 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { const isRemoteWorkspace = workspaceType === "remote"; const canWriteWorkspaceSkills = !isRemoteWorkspace || ipolloworkServerSnapshot.ipolloworkServerCanWriteSkills; - const canWriteWorkspacePlugins = - !isRemoteWorkspace || ipolloworkServerSnapshot.ipolloworkServerCanWritePlugins; const skillsAccessHint = isRemoteWorkspace && !canWriteWorkspaceSkills ? t("app.skills_hint_readonly") : null; - const pluginsAccessHint = - isRemoteWorkspace && !canWriteWorkspacePlugins ? t("app.plugins_hint_readonly") : null; const providerConnectedIdSet = new Set(providerConnectedIds); const sharedConnectedProviders = providers.flatMap((provider) => providerConnectedIdSet.has(provider.id) @@ -1750,9 +1725,6 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { installedSkills: extensionsStore.skills(), pluginPackageSkillNames: pluginPackageRelationships.skillNames, pluginPackageMcpServerNames: pluginPackageRelationships.mcpServerNames, - importedCloudPlugins: extensionsStore.importedCloudPlugins(), - pendingCloudPluginChanges: extensionsStore.pendingCloudPluginChanges(), - cloudMarketplaces: extensionsStore.cloudOrgMarketplaces(), orgMcpConnections: orgMcpConnections.connections, enablementContext, isBuiltInConnected: extensionController.isConnected, @@ -2012,13 +1984,6 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { if (route.pluginPackageId) return pluginPackagesView; return ( marketplace.plugins)} refreshMarketplaceItems={refreshConnectMarketplaceItems} /> ); @@ -2084,18 +2049,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { opencodeDevModeEnabled={false} openDebugDeepLink={async () => ({ ok: false, message: "Debug deep links are not wired into the React settings route yet." })} cloudMcpUrl={ipolloworkCloudMcpUrl} - canMigrateRuntimeConfig={Boolean(ipolloworkClient && selectedWorkspaceId)} - migrateRuntimeConfig={async () => { - if (!ipolloworkClient || !selectedWorkspaceId) { - throw new Error("Select a workspace before migrating legacy runtime config."); - } - const result = await ipolloworkClient.migrateRuntimeConfig(selectedWorkspaceId); - if (result.migrated) { - void connectionsStore.refreshMcpServers(); - void extensionsStore.refreshPlugins(); - } - return { migrated: result.migrated, keys: result.keys }; - }} + canReadRuntimeConfig={Boolean(ipolloworkClient && selectedWorkspaceId)} getRuntimeConfigStatus={async () => { if (!ipolloworkClient || !selectedWorkspaceId) { throw new Error("Select a workspace to inspect runtime config."); @@ -2312,7 +2266,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { setQuery={modelPicker.setQuery} target="default" current={ - activeEnginePreferences.model ?? { providerID: "", modelID: "" } + local.prefs.model ?? { providerID: "", modelID: "" } } onSelect={(next: ModelRef) => { setActiveModel(next); diff --git a/apps/app/src/react-app/shell/use-workspace-route-state.ts b/apps/app/src/react-app/shell/use-workspace-route-state.ts index 56c4f3820..e53aefd15 100644 --- a/apps/app/src/react-app/shell/use-workspace-route-state.ts +++ b/apps/app/src/react-app/shell/use-workspace-route-state.ts @@ -14,6 +14,8 @@ import { desktopResumeEvent, resolveWorkspaceListSelectedId, workspaceBootstrap, + workspaceSetRuntimeActive, + workspaceSetSelected, type iPolloWorkServerInfo, type WorkspaceList, } from "@/app/lib/desktop"; @@ -132,6 +134,28 @@ export function useWorkspaceRouteState(input: UseWorkspaceRouteStateInput) { resolveWorkspaceEndpoint(workspace, localServerRef.current), [], ); + + // The canonical route owns workspace selection. Session rows, search, + // external controls, and task creation can all navigate directly without + // passing through the project-row handler, so synchronizing only inside + // individual click handlers leaves Electron and the active engine pointed + // at a different workspace than the visible conversation. + useEffect(() => { + if (loading || !selectedWorkspace) return; + const workspaceId = selectedWorkspace.id.trim(); + if (!workspaceId) return; + + writeActiveWorkspaceId(workspaceId); + const endpoint = endpointForWorkspace(selectedWorkspace); + if (endpoint) { + void endpoint.client.activateWorkspace(endpoint.workspaceId, { persist: true }).catch(() => undefined); + } + if (!isDesktopRuntime()) return; + void workspaceSetSelected(workspaceId) + .then(() => workspaceSetRuntimeActive(workspaceId)) + .catch(() => undefined); + }, [endpointForWorkspace, loading, selectedWorkspace]); + const refreshInFlightRef = useRef(false); const refreshEpochRef = useRef(0); const workContextRef = useRef(workContextId); diff --git a/apps/app/tests/cloud-marketplaces-view.test.ts b/apps/app/tests/cloud-marketplaces-view.test.ts index 1a9114a0b..3b5822361 100644 --- a/apps/app/tests/cloud-marketplaces-view.test.ts +++ b/apps/app/tests/cloud-marketplaces-view.test.ts @@ -140,6 +140,7 @@ describe("Cloud marketplace row visibility", () => { expect(extensionsSource).toContain("pluginPackagesView"); expect(extensionsSource).toContain("skillsView"); expect(extensionsSource).not.toContain("mcpView"); + expect(extensionsSource).not.toContain("PluginsView"); expect(routeSource).not.toContain(" { test("exposes native DeepSeek Harness modes and applies model selection before prompting", async () => { const originalFetch = globalThis.fetch; const requests: Array<{ method: string; payload: Record }> = []; - globalThis.fetch = (async (_input, init) => { - const body = JSON.parse(String(init?.body)) as { method: string; payload: Record }; - requests.push(body); + globalThis.fetch = (async (input, init) => { + const body = JSON.parse(String(init?.body)) as { + method?: string; + payload: Record; + }; + const request = String(input).endsWith("/prompt") + ? { method: "session.prompt", payload: body.payload } + : { method: body.method ?? "", payload: body.payload }; + requests.push(request); if (body.method === "agentPreset.list") { return Response.json({ value: { presets: [ @@ -309,7 +318,7 @@ describe("conversation engine adapters", () => { ], } }); } - return Response.json({ value: {} }); + return Response.json(String(input).endsWith("/prompt") ? { ok: true } : { value: {} }); }) as typeof fetch; try { @@ -379,6 +388,89 @@ describe("conversation engine adapters", () => { ]); }); + test("maps installed plugin commands and agents into DeepSeek Harness prompts", async () => { + const originalFetch = globalThis.fetch; + const requests: Array> = []; + globalThis.fetch = (async (input, init) => { + if (String(input).endsWith("/plugin-capabilities")) { + return Response.json({ + items: [ + { + pluginId: "school-tools", + resourceId: "review-command", + type: "command", + name: "review-labels", + description: "Review annotations", + }, + { + pluginId: "school-tools", + resourceId: "review-agent", + type: "agent", + name: "annotation-reviewer", + description: "Annotation reviewer", + }, + ], + }); + } + const body = JSON.parse(String(init?.body)) as Record; + requests.push(body); + return Response.json({ ok: true }); + }) as typeof fetch; + + try { + const connection = conversationEngineAdapters.get(DEEPSEEK_HARNESS_ENGINE_ID).connect({ + baseUrl: "http://unused.test", + serverBaseUrl: "http://ipollowork.test", + workspaceId: "ws_dsh", + token: "token", + }); + expect(await connection.listCommands()).toEqual([ + expect.objectContaining({ name: "review-labels", description: "Review annotations" }), + ]); + expect(await connection.listAgents()).toEqual([ + expect.objectContaining({ name: "annotation-reviewer", description: "Annotation reviewer" }), + ]); + + await connection.runCommand({ + sessionId: "session-1", + command: "review-labels", + arguments: "project 12", + }); + await connection.sendPrompt({ + sessionId: "session-1", + parts: [ + { type: "agent", name: "annotation-reviewer" }, + { type: "text", text: "Review the current batch" }, + ], + }); + } finally { + globalThis.fetch = originalFetch; + } + + expect(requests).toEqual([ + { + payload: { + sessionId: "session-1", + mode: "queue", + content: [], + clientTimeZone: expect.any(String), + }, + plugins: { command: { name: "review-labels", arguments: "project 12" } }, + }, + { + payload: { + sessionId: "session-1", + mode: "queue", + content: [{ type: "text", text: "Review the current batch" }], + clientTimeZone: expect.any(String), + }, + plugins: { agents: ["annotation-reviewer"] }, + }, + ]); + expect(JSON.stringify(requests)).not.toContain("Check every annotation"); + expect(JSON.stringify(requests)).not.toContain("Act as a careful annotation quality reviewer"); + }); + test("hides DeepSeek Harness synthetic user events from the live conversation", () => { const synthetic = mapDeepSeekHarnessEnvelope({ type: "server-request", @@ -672,9 +764,9 @@ describe("conversation engine adapters", () => { } expect(requestBody).toMatchObject({ - method: "session.prompt", payload: { sessionId: "session-1", + mode: "queue", content: [{ type: "text", text: "[Attached file: notes.txt]\nHello DSH" }], }, }); diff --git a/apps/app/tests/den-marketplace.test.ts b/apps/app/tests/den-marketplace.test.ts index a74100a6d..9d63be2b2 100644 --- a/apps/app/tests/den-marketplace.test.ts +++ b/apps/app/tests/den-marketplace.test.ts @@ -13,7 +13,7 @@ const manifest = { package: { version: "1.0.0", updateId: "plugin_test", publisher: { id: "ipollowork", name: "iPolloWork" } }, }; -describe("iPolloWork Cloud plugin marketplace", () => { +describe("iPolloWork Cloud plugin-package source", () => { afterEach(() => { Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch }); }); diff --git a/apps/app/tests/design-html-runtime.test.ts b/apps/app/tests/design-html-runtime.test.ts index 6baadc5d4..311d3d197 100644 --- a/apps/app/tests/design-html-runtime.test.ts +++ b/apps/app/tests/design-html-runtime.test.ts @@ -246,9 +246,9 @@ describe("Design HTML runtime", () => { const runtimeSource = await Bun.file(runtimePath).text(); expect(preview).toContain('type: "deselected"'); - expect(preview).toContain("clearSelection(!0)"); expect(preview).toContain("ipollowork-design-deck-navigated"); expect(runtimeSource).toContain("if (isDeckControl) notifyNavigation();"); + expect(runtimeSource).toContain('document.addEventListener("ipollowork-design-deck-navigated", () => clearSelection(true));'); }); test("intercepts Ctrl or Meta wheel zoom only for presentation canvases", () => { diff --git a/apps/app/tests/dialog-close-regressions.test.ts b/apps/app/tests/dialog-close-regressions.test.ts index 68b514334..048ab7b47 100644 --- a/apps/app/tests/dialog-close-regressions.test.ts +++ b/apps/app/tests/dialog-close-regressions.test.ts @@ -11,13 +11,11 @@ function source(path: string) { describe("ordinary dialog close behavior", () => { test("keeps close controls available while background work continues", () => { const addMcp = source("domains/connections/modals/add-mcp-modal.tsx"); - const claudeImport = source("domains/connections/modals/claude-plugin-import-modal.tsx"); const pluginImport = source("domains/settings/plugin-package-import-modal.tsx"); const environment = source("domains/settings/pages/environment-view.tsx"); const authorization = source("domains/settings/authorization-form-dialog.tsx"); expect(addMcp).not.toContain("if (state.submitting) return;\n reset();"); - expect(claudeImport).not.toContain("if (state.previewing || state.installing) return;"); expect(pluginImport).not.toContain("if (busy) return;\n reset();"); expect(pluginImport).toContain('DialogClose render={ -
@@ -438,15 +437,6 @@ export const TOY_UI_HTML = `
- -