diff --git a/RELEASE_NOTES.en.md b/RELEASE_NOTES.en.md
index 9c39d9919..0cd59bb28 100644
--- a/RELEASE_NOTES.en.md
+++ b/RELEASE_NOTES.en.md
@@ -1,23 +1,37 @@
-## Lime v1.120.1
+## Lime v1.121.0
Simplified Chinese release notes are the primary version.
### New Features
+- Introduced the Plugin v2 current path. App Center can discover Codex-compatible plugins from bundled, repository, personal, and local-directory catalogs, then review, install, enable, disable, and uninstall them.
+- Enabled installed plugins in the current Agent runtime through immutable activation snapshots, including structured `plugin://` mentions in Claw, Skill/MCP injection, MCP App Right Surface rendering, and history restoration.
+- Added a controlled Electron WebContentsView HTML host and bundled Browser Plugin assets, and included plugin assets in the Forge packaging path.
+
### Fixes
-- Prevented internal provenance metadata from ordinary runtime Thread Items crossing the v2 projection boundary, so public items such as `contextCompaction` no longer expose private fields such as `sourceEventType`; imported history keeps its controlled typed metadata.
+- Fixed Plugin MCP provider namespaces being treated as canonical server identities, so resource reads, tool traces, Right Surface rendering, and reload recovery use one stable identity.
+- Fixed Skills watcher changes being dropped inside the throttle window by notifying on the leading edge and coalescing a trailing update.
+- Fixed stale session-switch, initial-navigation, and Plugin Right Surface state, and made uninstalled plugins retain history without restarting runtimes or replaying tools.
+- Fixed canonical history persistence merging already-materialized snapshots a second time, which duplicated reasoning deltas, dropped repeated fragments, and regressed large Codex history imports; added bounded waits for transient SQLite lock contention.
### Improvements and Refactoring
+- Consolidated Plugin catalog, installed/enabled state, package validation, and installation transactions in the App Server current owner; Renderer access now goes through the typed JSON-RPC gateway.
+- Improved MCP resource provenance, canonical Thread/Turn/Item projection, and Agent Workspace Right Surface hosting while removing production dependence on the renderer legacy registry and Plugin worker.
+
### Testing and Quality
-- Added projection library coverage and a fork-compaction JSON-RPC integration regression for the metadata boundary and replacement/tail recovery after restart.
+- Added Rust, Vitest, contract, and real Electron Gate B coverage for Plugin v2 App Center, typed gateway, mention/activation, MCP resources, Right Surface, history restoration, and uninstall semantics.
+- Added regression guards for Electron packaged assets, embedded Browser HTML, GUI smoke, Skills watcher behavior, and the tool lifecycle.
+- Added canonical reasoning linear-persistence and 1,200-command history-import performance regressions covering repeated deltas, final snapshot replacement, and background progress completion.
### Documentation
+- Added Plugin v2 product contracts, installation model, command boundaries, App Center/Claw surfaces, migration cleanup, verification plans, and the corresponding current architecture diagrams.
+
### Other
-- Bumped the root app, CLI npm package, Rust workspace, and lockfile versions to `1.120.1`.
+- Bumped the root app, CLI npm package, Rust workspace, and lockfile versions to `1.121.0`.
-**Full changes**: `v1.120.0` -> `v1.120.1`
+**Full changes**: `v1.120.1` -> `v1.121.0`
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 2e4cd57ee..cbbd1a16a 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,21 +1,35 @@
-## Lime v1.120.1
+## Lime v1.121.0
### 新功能
+- 推出 Plugin v2 current 主链:App Center 可从 bundled、仓库、个人与本地目录 catalog 发现 Codex-compatible Plugin,完成详情查看、安装确认、启停与卸载。
+- 已安装 Plugin 通过不可变 activation snapshot 进入当前 Agent runtime,支持 Claw 结构化 `plugin://` mention、Skill/MCP 注入、MCP App Right Surface 与历史恢复。
+- 新增受控的 Electron WebContentsView HTML 宿主与 bundled Browser Plugin 资产,并将 Plugin 资产纳入 Forge 打包链路。
+
### 修复
-- 修复普通运行时 Thread Item 的内部 provenance metadata 穿过 v2 投影边界,避免 `contextCompaction` 等公开 item 泄漏 `sourceEventType` 等私有字段;历史导入项仍保留受控的 typed metadata。
+- 修复 Plugin MCP provider namespace 被误当作 canonical server identity 的问题,使资源读取、tool trace、Right Surface 与 reload 恢复使用同一稳定 identity。
+- 修复 Skills watcher 在 throttle 窗口内丢失变更,改为首次立即通知并合并尾沿更新。
+- 修复会话切换、初始导航与 Plugin Right Surface 的过期状态,并确保 Plugin 卸载后只保留历史证据,不重启 runtime 或重放 tool。
+- 修复 canonical history 持久化对已物化快照二次 merge 导致的 reasoning delta 重复、重复片段丢失与 Codex 大历史导入退化,并为 SQLite 短暂锁竞争增加有界等待。
### 优化与重构
+- 将 Plugin catalog、installed/enabled state、包校验与安装事务收口到 App Server current owner,Renderer 统一通过 typed JSON-RPC gateway 访问。
+- 完善 MCP resource provenance、canonical Thread/Turn/Item 投影与 Agent Workspace 右侧承载,移除对 renderer 旧 registry 和 Plugin worker 的生产依赖。
+
### 测试与质量
-- 补充 projection library 单测与 fork-compaction JSON-RPC 集成回归,确认 metadata 边界和重启后的替换/尾部恢复行为。
+- 新增 Plugin v2 App Center、typed gateway、mention/activation、MCP resource、Right Surface、历史恢复与卸载语义的 Rust、Vitest、contract 和真实 Electron Gate B 覆盖。
+- 补充 Electron 打包资产、内嵌 Browser HTML、GUI smoke、Skills watcher 和 tool lifecycle 的回归守卫。
+- 新增 canonical reasoning 线性持久化与 1,200 条命令历史导入性能回归,覆盖重复 delta、final 快照替换和后台进度完成。
### 文档
+- 新增 Plugin v2 产品合同、安装模型、命令边界、App Center/Claw Surface、迁移清理与验收计划,并同步 current 架构图。
+
### 其他
-- 将根应用、CLI npm 包、Rust workspace 与锁文件版本统一提升到 `1.120.1`。
+- 将根应用、CLI npm 包、Rust workspace 与锁文件版本统一提升到 `1.121.0`。
-**完整变更**: `v1.120.0` -> `v1.120.1`
+**完整变更**: `v1.120.1` -> `v1.121.0`
diff --git a/assets/plugins/openai-bundled/.agents/plugins/marketplace.json b/assets/plugins/openai-bundled/.agents/plugins/marketplace.json
new file mode 100644
index 000000000..48a0c32da
--- /dev/null
+++ b/assets/plugins/openai-bundled/.agents/plugins/marketplace.json
@@ -0,0 +1,20 @@
+{
+ "name": "openai-bundled",
+ "interface": {
+ "displayName": "Lime Bundled"
+ },
+ "plugins": [
+ {
+ "name": "browser",
+ "source": {
+ "source": "local",
+ "path": "./plugins/browser"
+ },
+ "policy": {
+ "installation": "AVAILABLE",
+ "authentication": "ON_USE"
+ },
+ "category": "Productivity"
+ }
+ ]
+}
diff --git a/assets/plugins/openai-bundled/plugins/browser/skills/control-browser/SKILL.md b/assets/plugins/openai-bundled/plugins/browser/skills/control-browser/SKILL.md
new file mode 100644
index 000000000..e4015f5ba
--- /dev/null
+++ b/assets/plugins/openai-bundled/plugins/browser/skills/control-browser/SKILL.md
@@ -0,0 +1,13 @@
+---
+name: control-browser
+description: Research and inspect pages through Lime's managed browser session.
+---
+
+# Managed Browser
+
+Use the existing Lime browser session for page inspection and navigation tasks.
+
+- Prefer the current Lime browser session and its Right Surface projection.
+- Keep browser intent and the resulting tool item associated with the current thread.
+- Ask for confirmation before external writes, account changes, or irreversible actions.
+- Do not claim that a page was opened or changed unless the browser tool returns a terminal result.
diff --git a/electron/appServerHost.ts b/electron/appServerHost.ts
index 4b4c1026b..ff119e9f5 100644
--- a/electron/appServerHost.ts
+++ b/electron/appServerHost.ts
@@ -708,6 +708,10 @@ async function resolveAppServerSidecarEnv(
// AppDataRoot 由 Host 显式解析后下发;App Server 的叶子 writer 不再自行猜平台根。
APP_SERVER_APP_DATA_DIR: resolveAppServerAppDataRoot(),
};
+ const bundledMarketplacePath = resolveBundledPluginMarketplacePath();
+ if (bundledMarketplacePath) {
+ env.LIME_BUNDLED_PLUGIN_MARKETPLACE = bundledMarketplacePath;
+ }
const currentNoProxy = APP_SERVER_NO_PROXY_ENV_KEYS.map(
(key) => process.env[key],
).find((value) => Boolean(value?.trim()));
@@ -731,6 +735,31 @@ async function resolveAppServerSidecarEnv(
return Object.keys(env).length > 0 ? env : undefined;
}
+function resolveBundledPluginMarketplacePath(): string | undefined {
+ const resourceRoots = [
+ process.resourcesPath,
+ path.resolve(app.getAppPath(), "dist-electron"),
+ ];
+ for (const resourceRoot of resourceRoots) {
+ const marketplacePath = path.join(
+ resourceRoot,
+ "plugins",
+ "openai-bundled",
+ ".agents",
+ "plugins",
+ "marketplace.json",
+ );
+ try {
+ if (readFileSync(marketplacePath, "utf8")) {
+ return marketplacePath;
+ }
+ } catch {
+ // 开发态或旧包可能没有 bundled marketplace,App Server 继续发现其他来源。
+ }
+ }
+ return undefined;
+}
+
function resolveAppServerRuntimeLibraryEnv(
binaryPath: string,
): NodeJS.ProcessEnv {
diff --git a/electron/embeddedBrowserHost.test.ts b/electron/embeddedBrowserHost.test.ts
index 9c7e19e2c..4c36d8c54 100644
--- a/electron/embeddedBrowserHost.test.ts
+++ b/electron/embeddedBrowserHost.test.ts
@@ -166,6 +166,10 @@ vi.mock("./electronRuntime", () => ({
function createWindow() {
let destroyed = false;
+ const windowEventHandlers = new Map<
+ string,
+ Array<(...args: unknown[]) => void>
+ >();
const windowWebContentsEventHandlers = new Map<
string,
Array<(...args: unknown[]) => void>
@@ -184,9 +188,24 @@ function createWindow() {
handler(...args);
}
},
+ emitWindowEventForTest: (eventName: string, ...args: unknown[]) => {
+ for (const handler of windowEventHandlers.get(eventName) || []) {
+ handler(...args);
+ }
+ },
isDestroyed: () => destroyed,
- off: vi.fn(),
- on: vi.fn(),
+ off: vi.fn((eventName: string, handler: (...args: unknown[]) => void) => {
+ const handlers = windowEventHandlers.get(eventName) || [];
+ windowEventHandlers.set(
+ eventName,
+ handlers.filter((item) => item !== handler),
+ );
+ }),
+ on: vi.fn((eventName: string, handler: (...args: unknown[]) => void) => {
+ const handlers = windowEventHandlers.get(eventName) || [];
+ handlers.push(handler);
+ windowEventHandlers.set(eventName, handlers);
+ }),
webContents: {
off: vi.fn((eventName: string, handler: (...args: unknown[]) => void) => {
const handlers = windowWebContentsEventHandlers.get(eventName) || [];
@@ -329,7 +348,88 @@ describe("ElectronEmbeddedBrowserHost", () => {
).toBe(true);
});
- it("窗口关闭后销毁内嵌浏览器不访问已销毁 BrowserWindow", async () => {
+ it("通过受控命令加载 MCP App HTML,并保持 ui source URI", async () => {
+ resetMocks();
+ const host = new ElectronEmbeddedBrowserHost();
+ const window = createWindow();
+
+ await host.invoke(window as never, "embedded_browser_view_mount", {
+ viewId: "mcp-app-1",
+ bounds: { x: 0, y: 0, width: 480, height: 640 },
+ });
+ await expect(
+ host.invoke(window as never, "embedded_browser_view_load_html", {
+ viewId: "mcp-app-1",
+ html: "
Plugin report",
+ source: "mcpApp",
+ sourceUri: "ui://plugin/report.html",
+ }),
+ ).resolves.toMatchObject({
+ viewId: "mcp-app-1",
+ url: "ui://plugin/report.html",
+ });
+
+ expect(loadUrlMock).toHaveBeenLastCalledWith(
+ expect.stringMatching(/^data:text\/html;charset=utf-8;base64,/),
+ );
+ });
+
+ it("受控 HTML 命令拒绝普通网页来源,普通导航仍拒绝 ui/data", async () => {
+ resetMocks();
+ const host = new ElectronEmbeddedBrowserHost();
+ const window = createWindow();
+
+ await expect(
+ host.invoke(window as never, "embedded_browser_view_load_html", {
+ viewId: "mcp-app-1",
+ html: "bad
",
+ source: "browser",
+ sourceUri: "ui://plugin/report.html",
+ }),
+ ).rejects.toThrow("source 必须是 mcpApp");
+ await expect(
+ host.invoke(window as never, "embedded_browser_view_navigate", {
+ viewId: "mcp-app-1",
+ url: "ui://plugin/report.html",
+ }),
+ ).rejects.toThrow("只支持 http/https");
+ await expect(
+ host.invoke(window as never, "embedded_browser_view_navigate", {
+ viewId: "mcp-app-1",
+ url: "data:text/html,unsafe",
+ }),
+ ).rejects.toThrow("只支持 http/https");
+ });
+
+ it("MCP App 加载后拒绝页面内 http 导航和新窗口", async () => {
+ resetMocks();
+ const host = new ElectronEmbeddedBrowserHost();
+ const window = createWindow();
+ await host.invoke(window as never, "embedded_browser_view_mount", {
+ viewId: "mcp-app-1",
+ });
+ await host.invoke(window as never, "embedded_browser_view_load_html", {
+ viewId: "mcp-app-1",
+ html: 'blocked',
+ source: "mcpApp",
+ sourceUri: "ui://plugin/report.html",
+ });
+
+ const preventDefault = vi.fn();
+ emitWebContentsEvent(
+ "will-navigate",
+ { preventDefault },
+ "https://example.com/",
+ );
+ expect(preventDefault).toHaveBeenCalledOnce();
+ const openHandler = setWindowOpenHandlerMock.mock.calls.at(-1)?.[0];
+ expect(openHandler?.({ url: "https://example.com/" })).toEqual({
+ action: "deny",
+ });
+ expect(loadUrlMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("host dispose 不访问已销毁 BrowserWindow", async () => {
resetMocks();
const emitted: Array<{ event: string; payload?: unknown }> = [];
const host = new ElectronEmbeddedBrowserHost((event, payload) => {
@@ -350,11 +450,80 @@ describe("ElectronEmbeddedBrowserHost", () => {
expect(closeMock).toHaveBeenCalled();
expect(emitted).toContainEqual({
event: "embedded-browser-view-destroyed",
- payload: { viewId: "browser-1" },
+ payload: {
+ viewId: "browser-1",
+ reason: "host-dispose",
+ requestedLeaseId: null,
+ remainingLeaseCount: 0,
+ },
});
});
- it("主窗口 renderer 刷新时销毁内嵌浏览器原生视图", async () => {
+ it("窗口 closed 事件应记录 window-closed 销毁原因", async () => {
+ resetMocks();
+ const emitted: Array<{ event: string; payload?: unknown }> = [];
+ const host = new ElectronEmbeddedBrowserHost((event, payload) => {
+ emitted.push({ event, payload });
+ });
+ const window = createWindow();
+
+ await host.invoke(window as never, "embedded_browser_view_mount", {
+ viewId: "browser-1",
+ leaseId: "lease-1",
+ });
+ window.destroyForTest();
+ window.emitWindowEventForTest("closed");
+
+ expect(closeMock).toHaveBeenCalledOnce();
+ expect(emitted).toContainEqual({
+ event: "embedded-browser-view-destroyed",
+ payload: {
+ viewId: "browser-1",
+ reason: "window-closed",
+ requestedLeaseId: null,
+ remainingLeaseCount: 1,
+ },
+ });
+ expect(() => host.dispose()).not.toThrow();
+ expect(closeMock).toHaveBeenCalledOnce();
+ });
+
+ it("陈旧租约释放不得销毁同 viewId 的当前原生视图", async () => {
+ resetMocks();
+ const host = new ElectronEmbeddedBrowserHost();
+ const window = createWindow();
+
+ await host.invoke(window as never, "embedded_browser_view_mount", {
+ viewId: "plugin-surface-1",
+ leaseId: "lease-old",
+ bounds: { x: 10, y: 20, width: 300, height: 200 },
+ });
+ await host.invoke(window as never, "embedded_browser_view_mount", {
+ viewId: "plugin-surface-1",
+ leaseId: "lease-current",
+ bounds: { x: 10, y: 20, width: 300, height: 200 },
+ });
+
+ removeChildViewMock.mockClear();
+ closeMock.mockClear();
+ await host.invoke(window as never, "embedded_browser_view_destroy", {
+ viewId: "plugin-surface-1",
+ leaseId: "lease-old",
+ });
+
+ expect(removeChildViewMock).not.toHaveBeenCalled();
+ expect(closeMock).not.toHaveBeenCalled();
+
+ await host.invoke(window as never, "embedded_browser_view_destroy", {
+ viewId: "plugin-surface-1",
+ leaseId: "lease-current",
+ });
+
+ expect(removeChildViewMock).toHaveBeenCalledTimes(1);
+ expect(closeMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("只在主窗口开始完整导航时销毁内嵌浏览器原生视图", async () => {
resetMocks();
const emitted: Array<{ event: string; payload?: unknown }> = [];
const host = new ElectronEmbeddedBrowserHost((event, payload) => {
@@ -371,12 +540,42 @@ describe("ElectronEmbeddedBrowserHost", () => {
removeChildViewMock.mockClear();
closeMock.mockClear();
window.emitWebContentsEventForTest("did-start-loading");
+ window.emitWebContentsEventForTest(
+ "did-start-navigation",
+ {},
+ "http://127.0.0.1:1420/#settings",
+ true,
+ true,
+ );
+ window.emitWebContentsEventForTest(
+ "did-start-navigation",
+ {},
+ "https://example.com/frame",
+ false,
+ false,
+ );
+
+ expect(removeChildViewMock).not.toHaveBeenCalled();
+ expect(closeMock).not.toHaveBeenCalled();
+
+ window.emitWebContentsEventForTest(
+ "did-start-navigation",
+ {},
+ "http://127.0.0.1:1420/",
+ false,
+ true,
+ );
expect(removeChildViewMock).toHaveBeenCalled();
expect(closeMock).toHaveBeenCalled();
expect(emitted).toContainEqual({
event: "embedded-browser-view-destroyed",
- payload: { viewId: "browser-1" },
+ payload: {
+ viewId: "browser-1",
+ reason: "renderer-navigation",
+ requestedLeaseId: null,
+ remainingLeaseCount: 0,
+ },
});
});
diff --git a/electron/embeddedBrowserHost.ts b/electron/embeddedBrowserHost.ts
index c856db214..6520a808e 100644
--- a/electron/embeddedBrowserHost.ts
+++ b/electron/embeddedBrowserHost.ts
@@ -7,6 +7,10 @@ import {
import { installEmbeddedBrowserContextMenu } from "./embeddedBrowserContextMenu";
import { installEmbeddedBrowserDownloadHandling } from "./embeddedBrowserDownloads";
import { installEmbeddedBrowserPermissionHandling } from "./embeddedBrowserPermissions";
+import {
+ buildEmbeddedBrowserHtmlDataUrl,
+ readEmbeddedBrowserHtmlPayload,
+} from "./embeddedBrowserHtml";
import type {
Session as ElectronSession,
WebContents as ElectronWebContents,
@@ -14,11 +18,17 @@ import type {
type HostArgs = Record | null | undefined;
type HostEventEmitter = (event: string, payload?: unknown) => void;
+type EmbeddedBrowserDestroyReason =
+ | "renderer-cleanup"
+ | "renderer-navigation"
+ | "window-closed"
+ | "host-dispose";
export const EMBEDDED_BROWSER_COMMANDS = [
"embedded_browser_view_mount",
"embedded_browser_view_set_bounds",
"embedded_browser_view_navigate",
+ "embedded_browser_view_load_html",
"embedded_browser_view_reload",
"embedded_browser_view_stop",
"embedded_browser_view_find_in_page",
@@ -63,8 +73,15 @@ interface EmbeddedBrowserEntry {
view: WebContentsView;
window: BrowserWindow;
closeListener: () => void;
- rendererLoadListener: () => void;
+ rendererNavigationListener: (
+ event: unknown,
+ url: string,
+ isInPlace: boolean,
+ isMainFrame: boolean,
+ ) => void;
+ leaseIds: Set;
pendingUrl?: string;
+ sourceUri?: string;
navigationToken: number;
faviconUrl: string | null;
loadProgress: number;
@@ -96,7 +113,7 @@ export class ElectronEmbeddedBrowserHost {
args?: HostArgs,
): Promise {
if (command === "embedded_browser_view_destroy") {
- return this.#destroy(args);
+ return this.#destroy(args, "renderer-cleanup");
}
if (!window || window.isDestroyed()) {
@@ -110,6 +127,8 @@ export class ElectronEmbeddedBrowserHost {
return this.#setBounds(window, args);
case "embedded_browser_view_navigate":
return await this.#navigate(window, args);
+ case "embedded_browser_view_load_html":
+ return await this.#loadHtml(window, args);
case "embedded_browser_view_reload":
return this.#reload(window, args);
case "embedded_browser_view_stop":
@@ -131,7 +150,7 @@ export class ElectronEmbeddedBrowserHost {
dispose(): void {
for (const viewId of [...this.#entries.keys()]) {
- this.#destroy({ viewId });
+ this.#destroy({ viewId }, "host-dispose");
}
}
@@ -144,6 +163,10 @@ export class ElectronEmbeddedBrowserHost {
const bounds = readOptionalBounds(args);
const visible = readOptionalBoolean(args, "visible") ?? true;
const entry = this.#ensureEntry(window, viewId);
+ const leaseId = readString(args, "leaseId");
+ if (leaseId) {
+ entry.leaseIds.add(leaseId);
+ }
if (bounds) {
applyBounds(entry.view, bounds, visible);
@@ -175,6 +198,21 @@ export class ElectronEmbeddedBrowserHost {
return this.#emitState(entry);
}
+ async #loadHtml(
+ window: BrowserWindow,
+ args?: HostArgs,
+ ): Promise {
+ const entry = this.#ensureEntry(window, readViewId(args));
+ const payload = readEmbeddedBrowserHtmlPayload(args);
+ entry.sourceUri = payload.sourceUri;
+ this.#startNavigation(
+ entry,
+ payload.sourceUri,
+ buildEmbeddedBrowserHtmlDataUrl(payload),
+ );
+ return this.#emitState(entry);
+ }
+
#reload(window: BrowserWindow, args?: HostArgs): EmbeddedBrowserViewState {
const entry = this.#ensureEntry(window, readViewId(args));
entry.loadProgress = 0.1;
@@ -249,17 +287,31 @@ export class ElectronEmbeddedBrowserHost {
return this.#emitState(entry);
}
- #destroy(args?: HostArgs): Record {
+ #destroy(
+ args: HostArgs,
+ reason: EmbeddedBrowserDestroyReason,
+ ): Record {
const viewId = readViewId(args);
const entry = this.#entries.get(viewId);
if (!entry) {
return {};
}
+ const leaseId = readString(args, "leaseId");
+ if (leaseId) {
+ if (!entry.leaseIds.delete(leaseId) || entry.leaseIds.size > 0) {
+ return {};
+ }
+ }
this.#entries.delete(viewId);
detachEntryFromWindow(entry);
closeEntryView(entry);
- this.#emit("embedded-browser-view-destroyed", { viewId });
+ this.#emit("embedded-browser-view-destroyed", {
+ viewId,
+ reason,
+ requestedLeaseId: leaseId,
+ remainingLeaseCount: entry.leaseIds.size,
+ });
return {};
}
@@ -269,8 +321,10 @@ export class ElectronEmbeddedBrowserHost {
if (existing.window !== window) {
detachEntryFromWindow(existing);
existing.window = window;
- existing.closeListener = () => this.#destroy({ viewId });
- existing.rendererLoadListener = () => this.#destroy({ viewId });
+ existing.closeListener = () =>
+ this.#destroy({ viewId }, "window-closed");
+ existing.rendererNavigationListener =
+ this.#createRendererNavigationListener(viewId);
attachEntryToWindow(existing, window);
return existing;
}
@@ -297,8 +351,10 @@ export class ElectronEmbeddedBrowserHost {
viewId,
view,
window,
- closeListener: () => this.#destroy({ viewId }),
- rendererLoadListener: () => this.#destroy({ viewId }),
+ closeListener: () => this.#destroy({ viewId }, "window-closed"),
+ rendererNavigationListener:
+ this.#createRendererNavigationListener(viewId),
+ leaseIds: new Set(),
navigationToken: 0,
faviconUrl: null,
loadProgress: 1,
@@ -306,6 +362,9 @@ export class ElectronEmbeddedBrowserHost {
findRequestId: null,
};
view.webContents.setWindowOpenHandler(({ url }) => {
+ if (entry.sourceUri) {
+ return { action: "deny" };
+ }
const normalizedUrl = normalizeHttpUrl(url);
if (normalizedUrl) {
this.#startNavigation(entry, normalizedUrl);
@@ -313,6 +372,11 @@ export class ElectronEmbeddedBrowserHost {
}
return { action: "deny" };
});
+ view.webContents.on("will-navigate", (event, url) => {
+ if (entry.sourceUri && normalizeHttpUrl(url)) {
+ event.preventDefault();
+ }
+ });
installEmbeddedBrowserContextMenu({
view,
window,
@@ -383,6 +447,16 @@ export class ElectronEmbeddedBrowserHost {
return entry;
}
+ #createRendererNavigationListener(
+ viewId: string,
+ ): EmbeddedBrowserEntry["rendererNavigationListener"] {
+ return (_event, _url, isInPlace, isMainFrame) => {
+ if (isMainFrame && !isInPlace) {
+ this.#destroy({ viewId }, "renderer-navigation");
+ }
+ };
+ }
+
#installSessionHandlers(embeddedSession: ElectronSession): void {
if (this.#sessionHandlersInstalled) {
return;
@@ -416,7 +490,14 @@ export class ElectronEmbeddedBrowserHost {
return null;
}
- #startNavigation(entry: EmbeddedBrowserEntry, url: string): void {
+ #startNavigation(
+ entry: EmbeddedBrowserEntry,
+ url: string,
+ loadUrl = url,
+ ): void {
+ if (loadUrl === url) {
+ entry.sourceUri = undefined;
+ }
entry.pendingUrl = url;
entry.loadProgress = 0.1;
const navigationToken = entry.navigationToken + 1;
@@ -424,7 +505,7 @@ export class ElectronEmbeddedBrowserHost {
let loadPromise: Promise;
try {
- loadPromise = entry.view.webContents.loadURL(url);
+ loadPromise = entry.view.webContents.loadURL(loadUrl);
} catch (error) {
this.#clearPendingNavigation(entry);
this.#emitLoadFailed(
@@ -535,7 +616,7 @@ function readState(entry: EmbeddedBrowserEntry): EmbeddedBrowserViewState {
const webContents = entry.view.webContents;
return {
viewId: entry.viewId,
- url: entry.pendingUrl || webContents.getURL(),
+ url: entry.pendingUrl || entry.sourceUri || webContents.getURL(),
title: webContents.getTitle(),
faviconUrl: entry.faviconUrl,
canGoBack: webContents.navigationHistory.canGoBack(),
@@ -658,7 +739,10 @@ function attachEntryToWindow(
window: BrowserWindow,
): void {
window.on("closed", entry.closeListener);
- window.webContents.on("did-start-loading", entry.rendererLoadListener);
+ window.webContents.on(
+ "did-start-navigation",
+ entry.rendererNavigationListener,
+ );
window.contentView.addChildView(entry.view);
}
@@ -667,8 +751,8 @@ function detachEntryFromWindow(entry: EmbeddedBrowserEntry): void {
if (!entry.window.isDestroyed()) {
entry.window.off("closed", entry.closeListener);
entry.window.webContents.off(
- "did-start-loading",
- entry.rendererLoadListener,
+ "did-start-navigation",
+ entry.rendererNavigationListener,
);
entry.window.contentView.removeChildView(entry.view);
}
diff --git a/electron/embeddedBrowserHtml.test.ts b/electron/embeddedBrowserHtml.test.ts
new file mode 100644
index 000000000..0bc262a53
--- /dev/null
+++ b/electron/embeddedBrowserHtml.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildEmbeddedBrowserHtmlDataUrl,
+ EMBEDDED_BROWSER_HTML_MAX_BYTES,
+ readEmbeddedBrowserHtmlPayload,
+} from "./embeddedBrowserHtml";
+
+describe("embeddedBrowserHtml", () => {
+ it("只接受受控 MCP App HTML 载荷", () => {
+ expect(
+ readEmbeddedBrowserHtmlPayload({
+ html: "Plugin",
+ source: "mcpApp",
+ sourceUri: "ui://plugin/report.html",
+ }),
+ ).toEqual({
+ csp: {
+ baseUriDomains: [],
+ connectDomains: [],
+ frameDomains: [],
+ resourceDomains: [],
+ },
+ html: "Plugin",
+ source: "mcpApp",
+ sourceUri: "ui://plugin/report.html",
+ });
+ });
+
+ it("拒绝非 MCP App 来源、非 ui URI 与超限 HTML", () => {
+ expect(() =>
+ readEmbeddedBrowserHtmlPayload({
+ html: "bad source
",
+ source: "browser",
+ sourceUri: "ui://plugin/report.html",
+ }),
+ ).toThrow("source 必须是 mcpApp");
+ expect(() =>
+ readEmbeddedBrowserHtmlPayload({
+ html: "bad uri
",
+ source: "mcpApp",
+ sourceUri: "https://example.com/report.html",
+ }),
+ ).toThrow("sourceUri 必须使用 ui:// 协议");
+ expect(() =>
+ readEmbeddedBrowserHtmlPayload({
+ html: "a".repeat(EMBEDDED_BROWSER_HTML_MAX_BYTES + 1),
+ source: "mcpApp",
+ sourceUri: "ui://plugin/report.html",
+ }),
+ ).toThrow("HTML 超过");
+ });
+
+ it("将 HTML 与受控 CSP 编码为不暴露原文的数据 URL", () => {
+ const dataUrl = buildEmbeddedBrowserHtmlDataUrl({
+ csp: {
+ baseUriDomains: [],
+ connectDomains: ["https://api.example.com"],
+ frameDomains: [],
+ resourceDomains: ["https://cdn.example.com"],
+ },
+ html: "报告
",
+ });
+ expect(dataUrl).toMatch(/^data:text\/html;charset=utf-8;base64,/);
+ expect(dataUrl).not.toContain("报告");
+ const encoded = dataUrl.slice(dataUrl.indexOf(",") + 1);
+ const decoded = Buffer.from(encoded, "base64").toString("utf8");
+ expect(decoded).toContain('http-equiv="Content-Security-Policy"');
+ expect(decoded).toContain("connect-src https://api.example.com");
+ expect(decoded).toContain("script-src 'unsafe-inline' https://cdn.example.com");
+ expect(decoded).toContain("frame-src 'none'");
+ });
+
+ it("拒绝 CSP 指令注入、路径与不受支持协议", () => {
+ for (const connectDomain of [
+ "https://api.example.com; script-src *",
+ "https://api.example.com/path",
+ "file:///tmp/app",
+ ]) {
+ expect(() =>
+ readEmbeddedBrowserHtmlPayload({
+ csp: { connectDomains: [connectDomain] },
+ html: "app",
+ source: "mcpApp",
+ sourceUri: "ui://plugin/report.html",
+ }),
+ ).toThrow("包含无效 origin");
+ }
+ });
+});
diff --git a/electron/embeddedBrowserHtml.ts b/electron/embeddedBrowserHtml.ts
new file mode 100644
index 000000000..5c2dba3e7
--- /dev/null
+++ b/electron/embeddedBrowserHtml.ts
@@ -0,0 +1,197 @@
+type HostArgs = Record | null | undefined;
+
+export const EMBEDDED_BROWSER_HTML_MAX_BYTES = 1024 * 1024;
+
+export interface EmbeddedBrowserHtmlPayload {
+ csp: EmbeddedBrowserMcpAppCsp;
+ html: string;
+ source: "mcpApp";
+ sourceUri: string;
+}
+
+export interface EmbeddedBrowserMcpAppCsp {
+ baseUriDomains: string[];
+ connectDomains: string[];
+ frameDomains: string[];
+ resourceDomains: string[];
+}
+
+export function readEmbeddedBrowserHtmlPayload(
+ args: HostArgs,
+): EmbeddedBrowserHtmlPayload {
+ const record = readRecord(args);
+ if (record?.source !== "mcpApp") {
+ throw new Error("embedded browser HTML source 必须是 mcpApp。");
+ }
+
+ const sourceUri = readRequiredString(record, "sourceUri");
+ if (!isMcpAppResourceUri(sourceUri)) {
+ throw new Error("MCP App sourceUri 必须使用 ui:// 协议。");
+ }
+
+ const html = readRequiredString(record, "html", false);
+ const byteLength = Buffer.byteLength(html, "utf8");
+ if (byteLength > EMBEDDED_BROWSER_HTML_MAX_BYTES) {
+ throw new Error(
+ `MCP App HTML 超过 ${EMBEDDED_BROWSER_HTML_MAX_BYTES} 字节上限。`,
+ );
+ }
+
+ return {
+ csp: readMcpAppCsp(record?.csp),
+ html,
+ source: "mcpApp",
+ sourceUri,
+ };
+}
+
+export function buildEmbeddedBrowserHtmlDataUrl(
+ payload: Pick,
+): string {
+ const html = injectMcpAppContentSecurityPolicy(payload.html, payload.csp);
+ return `data:text/html;charset=utf-8;base64,${Buffer.from(html, "utf8").toString("base64")}`;
+}
+
+function readMcpAppCsp(value: unknown): EmbeddedBrowserMcpAppCsp {
+ const record = readRecord(value);
+ return {
+ baseUriDomains: readDomainList(record?.baseUriDomains, "baseUriDomains", [
+ "http:",
+ "https:",
+ ]),
+ connectDomains: readDomainList(record?.connectDomains, "connectDomains", [
+ "http:",
+ "https:",
+ "ws:",
+ "wss:",
+ ]),
+ frameDomains: readDomainList(record?.frameDomains, "frameDomains", [
+ "http:",
+ "https:",
+ ]),
+ resourceDomains: readDomainList(
+ record?.resourceDomains,
+ "resourceDomains",
+ ["http:", "https:"],
+ ),
+ };
+}
+
+function readDomainList(
+ value: unknown,
+ field: string,
+ protocols: readonly string[],
+): string[] {
+ if (value === undefined) {
+ return [];
+ }
+ if (!Array.isArray(value) || value.length > 32) {
+ throw new Error(`MCP App CSP ${field} 必须是最多 32 项的数组。`);
+ }
+ return Array.from(
+ new Set(
+ value.map((entry) => normalizeCspOrigin(entry, field, protocols)),
+ ),
+ );
+}
+
+function normalizeCspOrigin(
+ value: unknown,
+ field: string,
+ protocols: readonly string[],
+): string {
+ if (typeof value !== "string" || value.length > 2048) {
+ throw new Error(`MCP App CSP ${field} 包含无效 origin。`);
+ }
+ const trimmed = value.trim();
+ const wildcard = /^(https?):\/\/\*\.(.+)$/i.exec(trimmed);
+ const parseTarget = wildcard ? `${wildcard[1]}://${wildcard[2]}` : trimmed;
+ try {
+ const url = new URL(parseTarget);
+ if (
+ !protocols.includes(url.protocol) ||
+ url.username ||
+ url.password ||
+ url.pathname !== "/" ||
+ url.search ||
+ url.hash ||
+ !url.hostname
+ ) {
+ throw new Error("invalid origin");
+ }
+ return wildcard
+ ? `${url.protocol}//* .${url.host}`.replace("* .", "*.")
+ : url.origin;
+ } catch {
+ throw new Error(`MCP App CSP ${field} 包含无效 origin。`);
+ }
+}
+
+function injectMcpAppContentSecurityPolicy(
+ html: string,
+ csp: EmbeddedBrowserMcpAppCsp,
+): string {
+ const resourceSources = csp.resourceDomains.join(" ");
+ const policy = [
+ "default-src 'none'",
+ `script-src 'unsafe-inline'${appendSources(resourceSources)}`,
+ `style-src 'unsafe-inline'${appendSources(resourceSources)}`,
+ `img-src data: blob:${appendSources(resourceSources)}`,
+ `font-src data:${appendSources(resourceSources)}`,
+ `media-src data: blob:${appendSources(resourceSources)}`,
+ `connect-src ${sourcesOrNone(csp.connectDomains)}`,
+ `frame-src ${sourcesOrNone(csp.frameDomains)}`,
+ `base-uri ${sourcesOrNone(csp.baseUriDomains)}`,
+ "object-src 'none'",
+ "form-action 'none'",
+ ].join("; ");
+ const tag = ``;
+ const headMatch = /]*)?>/i.exec(html);
+ if (headMatch?.index !== undefined) {
+ const offset = headMatch.index + headMatch[0].length;
+ return `${html.slice(0, offset)}${tag}${html.slice(offset)}`;
+ }
+ return `${tag}${html}`;
+}
+
+function appendSources(value: string): string {
+ return value ? ` ${value}` : "";
+}
+
+function sourcesOrNone(values: readonly string[]): string {
+ return values.length > 0 ? values.join(" ") : "'none'";
+}
+
+function escapeHtmlAttribute(value: string): string {
+ return value.replaceAll("&", "&").replaceAll('"', """);
+}
+
+function isMcpAppResourceUri(value: string): boolean {
+ try {
+ return new URL(value).protocol === "ui:";
+ } catch {
+ return false;
+ }
+}
+
+function readRequiredString(
+ record: Record | null,
+ key: string,
+ trim = true,
+): string {
+ const value = record?.[key];
+ if (typeof value !== "string") {
+ throw new Error(`embedded browser ${key} 不能为空。`);
+ }
+ const normalized = trim ? value.trim() : value;
+ if (!normalized.trim()) {
+ throw new Error(`embedded browser ${key} 不能为空。`);
+ }
+ return normalized;
+}
+
+function readRecord(value: unknown): Record | null {
+ return value && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : null;
+}
diff --git a/electron/ipcChannels.test.ts b/electron/ipcChannels.test.ts
index 17924ef6c..95e84d23e 100644
--- a/electron/ipcChannels.test.ts
+++ b/electron/ipcChannels.test.ts
@@ -135,6 +135,7 @@ describe("electron/ipcChannels", () => {
true,
);
expect(isElectronHostCommand("embedded_browser_view_navigate")).toBe(true);
+ expect(isElectronHostCommand("embedded_browser_view_load_html")).toBe(true);
expect(isElectronHostCommand("embedded_browser_view_reload")).toBe(true);
expect(isElectronHostCommand("embedded_browser_view_stop")).toBe(true);
expect(isElectronHostCommand("embedded_browser_view_find_in_page")).toBe(
diff --git a/electron/ipcChannels.ts b/electron/ipcChannels.ts
index a32fe3fea..0dd44561a 100644
--- a/electron/ipcChannels.ts
+++ b/electron/ipcChannels.ts
@@ -18,6 +18,7 @@ export const ELECTRON_HOST_COMMANDS = [
"embedded_browser_view_mount",
"embedded_browser_view_set_bounds",
"embedded_browser_view_navigate",
+ "embedded_browser_view_load_html",
"embedded_browser_view_reload",
"embedded_browser_view_stop",
"embedded_browser_view_find_in_page",
diff --git a/forge.config.mjs b/forge.config.mjs
index eba36121f..85feabfa4 100644
--- a/forge.config.mjs
+++ b/forge.config.mjs
@@ -240,6 +240,7 @@ export default {
: "lime-rs/icons/icon.icns",
extraResource: [
"dist-electron/desktop-assets",
+ "dist-electron/plugins",
"dist-electron/app-server.release.json",
"dist-electron/app-server",
],
diff --git a/internal/aiprompts/architecture.md b/internal/aiprompts/architecture.md
index 29935fc46..0b1af16d5 100644
--- a/internal/aiprompts/architecture.md
+++ b/internal/aiprompts/architecture.md
@@ -891,6 +891,47 @@ Architecture impact: major; this changes the Renderer live/read/resume Item stat
Plugin UI/worker 只通过 typed client 和 App Server method 进入 runtime;不得复制 turn start、cancel、tool dispatch 或 evidence 链。Skill 是产品与领域工作流单元,MCP 是标准化 tool/resource/prompt 接入;二者均由 App Server 和各自 runtime owner 注入当前 turn,而不是由 Renderer 直接执行。
+Plugin v2 的 catalog、installed state、enabled state、package identity 和安装事务由 App Server current domain 唯一持有。Renderer App Center 只能通过 `src/lib/api/pluginCatalog.ts` 调用 typed `plugin/list`、`plugin/read`、`plugin/install`、`plugin/uninstall`、`plugin/installed` 与 `plugin/enabled/set`;Electron 只转发 `app_server_handle_json_lines` 和提供原生目录选择,不扫描 `.codex-plugin/plugin.json`、不合并 marketplace,也不保存第二份 installed state。
+
+```text
+PluginCatalogPage
+ -> Renderer pluginCatalog gateway
+ -> typed AppServerClient
+ -> Electron preload / app_server_handle_json_lines
+ -> App Server Plugin processor
+ -> RuntimeCore PluginDataSource
+ -> plugin_catalog
+ catalog discovery
+ package validation + sha256 identity
+ staging + atomic installed record
+ -> /plugins/v2/{packages,installed,staging,marketplaces}
+```
+
+包合同只接受 Codex-compatible `.codex-plugin/plugin.json`;旧 `schemaVersion`、`contributions.runtime/workbench`、绝对或父级资源路径、symlink、超预算 package 必须 fail closed。安装 identity 至少包含 `pluginId + marketplaceId + version + contentDigest`。同 identity 同 digest 的重复安装幂等;同 identity 不同 digest 拒绝;更新必须先提交新版本和 installed record,失败时保留上一可用版本。`sourceUri` 只用于受控本地 catalog/install 响应,不进入 Thread 历史、日志或跨设备 metadata。
+
+installed+enabled Plugin 由同一 App Server store 生成 `plugin-activation/v2` 与 `plugin-runtime-capabilities/v2`。activation snapshot 冻结 `pluginId + version + contentDigest + marketplaceId + packageSourceUri`,其 Skills 进入现有 skill snapshot;Renderer 不得构造或注入 activation。Claw 的已安装候选只从 typed `plugin/installed` 投影,选择后把显示文本 `@DisplayName` 与结构化 `{ type: "mention", name, path: "plugin://" }` 同时送入 current turn contract。App Server 使用同一回合 activation snapshot 校验 mention:无显式 mention 时保留全部 enabled Plugin 供描述发现;有 `plugin://` 时只把解析成功的 Plugin activation 装配到该回合,未知、禁用或 identity 漂移必须 fail closed,不能查询旧 renderer registry 或旧 installed state 兜底。
+
+```text
+App Server plugin v2 installed store
+ -> enabled activation snapshot
+ -> RuntimeCore session config / Skill snapshot
+
+Claw @ picker
+ -> Renderer pluginCatalog gateway -> plugin/installed
+ -> stable plugin:// UserInput::Mention
+ -> Electron IPC / app_server_handle_json_lines
+ -> App Server mention selection
+ -> selected activation -> Turn metadata / runtime context
+```
+
+Plugin MCP 声明由唯一 package manifest `.codex-plugin/plugin.json` 选择:可以使用 manifest 内联 object、package-relative 配置路径,或包根默认 `.mcp.json`;`.mcp.json` 只是 MCP server 配置,不是第二份 Plugin manifest。installed+enabled Plugin 生成带稳定 `plugin____` runtime identity 的 server spec,并进入既有 `McpThreadRuntime -> McpClientManager -> tool-runtime` lifecycle;disabled Plugin 不装配,非法 sibling 与越界 `cwd` fail closed。MCP App UI 从 canonical tool item 的 `resourceUri` 经 `mcpResource/read` 进入现有 Right Surface/WebContentsView,并以 canonical identity 在 Renderer reload 后恢复,不启动 Plugin worker 或私有 UI runtime。
+
+当前仍未完成独立 Plugin Apps(非 MCP App)与 Hooks activation、Plugin identity 到完整 Item/tool trace 的全量历史投影、跨进程 cold restore,以及 Browser/file/structured-result 的 Plugin 专项 Gate B;这些缺口不得通过旧 renderer registry、plugin worker 或独立 UI runtime fallback 填补。
+
+2026-08-05 的 macOS controlled fixture 已证明 `plugin/install -> enabled activation -> runtime MCP tool -> elicitation -> provider final -> canonical item -> MCP App Right Surface -> Renderer reload restore`,且 preload/IPC 与 `app_server_handle_json_lines` 可见、legacy MCP command / production mock fallback / Plugin worker hit 均为 0。证据位于 `.lime/qc/gui-evidence/plugin-v2-current-electron-fixture/`;该证据不扩张为 App Center 安装点击、Claw `@` picker 点击、卸载历史或 Windows release gate 已通过。
+
+Architecture impact: major; this adds the Plugin v2 App Server domain, moves the production App Center and Claw mention source to typed JSON-RPC catalog/activation state, and preserves Electron as transport host. Architecture diagram updated: this section. Responsible developer confirmation: pending.
+
MCP server 的执行环境身份只来自 `McpServerConfig.environment_id`,由 `lime-mcp::McpEnvironmentRegistry`
在 transport 启动前解析。当前 registry 只注册 `local`;未知显式身份必须 fail closed,禁止把
`remote` 或其它配置值降级成本机 stdio/HTTP 执行,也禁止从 `cwd` 猜测环境。远程 executor/backend
diff --git a/internal/exec-plans/README.md b/internal/exec-plans/README.md
index 8cdf67ef7..919388954 100644
--- a/internal/exec-plans/README.md
+++ b/internal/exec-plans/README.md
@@ -36,6 +36,7 @@
- Lime v1.114.0 发布执行计划:`internal/exec-plans/release-v1.114.0-plan.md`
- Lime v1.115.0 发布执行计划:`internal/exec-plans/release-v1.115.0-plan.md`
- Lime v1.116.0 发布执行计划:`internal/exec-plans/release-v1.116.0-plan.md`
+- Lime v1.121.0 发布执行计划:`internal/exec-plans/release-v1.121.0-plan.md`
- Codex App GUI 对齐执行计划:`internal/exec-plans/codex-app-gui-alignment-plan.md`
- Codex 对话兼容重构:`internal/exec-plans/codex-conversation-compat-refactor-plan.md`
- Codex 对齐 v1 并行协调:`internal/exec-plans/codex-alignment-v1-coordination-plan.md`
@@ -64,6 +65,7 @@
- `@` 命令本地执行纠偏计划:`internal/exec-plans/at-command-local-execution-alignment-plan.md`
- Agent QC 运营级测试体系执行计划:`internal/exec-plans/agent-qc-ops-testing-plan.md`
- Agent App v2 独立安装与 Runtime 底座拆分执行计划:`internal/exec-plans/agentapp-v2-standalone-runtime.md`
+- Plugin v2 current 实施与 Gate B 计划:`internal/exec-plans/plugin-v2-current-plan.md`
- AI 图层化设计实现计划:`internal/exec-plans/ai-layered-design-implementation-plan.md`
- 图片能力 feature-flag / extension-tool 执行计划:`internal/exec-plans/image-capability-feature-flag-extension-tool-plan.md`
- 图片能力 feature-flag / extension-tool 进度:`internal/exec-plans/image-capability-feature-flag-extension-tool-progress.md`
diff --git a/internal/exec-plans/refactor-v2-test-phase-2-plan.md b/internal/exec-plans/refactor-v2-test-phase-2-plan.md
index 70d6b1299..8caff9b47 100644
--- a/internal/exec-plans/refactor-v2-test-phase-2-plan.md
+++ b/internal/exec-plans/refactor-v2-test-phase-2-plan.md
@@ -447,3 +447,23 @@ Electron Desktop Host
- current adapter 改用仓库已锁定的 Node `smol-toml` 结构化解析器,移除 Python subprocess 和版本假设;未引入 ad-hoc 行解析,也未改变 DeepSWE task schema、workspace 隔离、live authorization 或 verifier ownership。
- 修复后验证:`npx vitest run scripts/harness/deepswe-adapter.test.mjs scripts/harness/deepswe-coding-slice.test.mjs` 为 `26/26`;`npm run harness:deepswe:preflight` 为 Release 20 `61/61`;adapter ESLint、`npm run governance:scripts`、`npm run test:contracts` 全部通过。
- 分类:Node TOML metadata parser 和 adapter fail-closed/verifier evidence 为 `current / closed`;Python `tomllib` 运行时假设为 `dead / deleted / forbidden-to-restore`。完整 DeepSWE 仍是 `diagnostic_true_runs_blocked`:Agnes 选题固定预算内无 non-empty candidate,Pier editable package 已失效且本机无 Docker/Podman/nerdctl/Colima,不能生成 `reward.json`、`ctrf.json` 或 score;第二期整体完成度保持 `80%`。
+
+## 41. 2026-08-04 Benchmark 新一轮 Gate B 验收
+
+- 目标与写集:复跑 `internal/roadmap/benchmark` 的 current fixture、恢复、cancel/continue、approval/resume、unknown Item、AgentControl cold-restart、`home-hotpath` 和 `SOAK-01`,只新增 `.gstack/benchmark-reports/2026-08-04-benchmark.{md,json}` 与本轮失败证据;未修改业务源码。
+- 真实 Electron 性能观测:通过 `http://127.0.0.1:9223` 确认 Lime/Electron 42.3.3、`window.__LIME_ELECTRON__=true` 和 `electronAPI.invoke`;reload 的 TTFB/FCP/DOM Interactive/Full Load 为 `49/412/316/1424ms`,250 个请求,transfer `505873` bytes,reload console/page error 均为 0。
+- Gate B 通过:history/cache `31/31`、streaming completion `32/32`、fixture guard `99/99`;session `thread/start/read/list/resume`、cancel/continue、approval/resume、unknown Item 均为 current Electron/App Server/read model/GUI 闭环;AgentControl cold-restart 39 条 assertion 全绿,证据分别见 `.lime/qc/gui-evidence/benchmark-20260804/` 和对应 current fixture 目录。
+- `home-hotpath` 保持失败:250ms budget 下 `homeInputToSendDispatchMs` 两次为 `287ms`、`274ms`,7 月成功基线为 `100ms`、`114ms`;provider wait `90ms`、renderer 首次 delta apply `2ms`,turn/read model/GUI/Electron IPC 成功,invoke/page/console error 为 0。归因仍是 Renderer 首发 dispatch 性能回归,不是 provider、IPC、Runtime 或 terminal failure。
+- `SOAK-01` 未通过:10 轮 / 2 次 cold restart 在首轮失败,唯一失败断言为 `waitAgentReturnedTerminalResult`;其他工具、turn terminal 和 `thread/read` 完成。单轮 AgentControl cold-restart 后续通过,不能替代完整 soak receipt。
+- 最后一次 cold-restart recheck 在 `launch-electron` 失败:`canvasImageInsertHistory-BQ3-T3zp.js` 不提供导出 `c`,bridge 未就绪;该证据归类为构建产物/启动环境不一致,需清理并一致构建后复测,当前不直接归因 runtime owner。
+- Gate B 结论:`partial evidence / failed acceptance`。核心链路已证明,但 `home-hotpath`、`SOAK-01` 和 cold-restart recheck 仍是 release evidence blocker;下一刀为 dispatch 性能定位、构建产物一致性复测和 SOAK-01 10x2 完整重跑。
+
+## 42. 2026-08-04 Benchmark Gate B 修复后复验
+
+- 目标与写集:在第 41 节失败现场上继续复验 `home-hotpath`、AgentControl cold-restart 和 `SOAK-01`;写集为 SOAK 观察器、观察器单测、已有 Renderer dispatch/构建一致性修复及 `.gstack/benchmark-reports/2026-08-04-benchmark.{md,json}`,未修改 Rust RuntimeCore 生产逻辑,未放宽投影 repair 的 fail-closed 断言。
+- SOAK 观察器根因:v2 `thread/list` 的 `Thread` 主键字段是 `id`,不是 `threadId`;旧观察器因此把真实 canonical thread 误判为未列出。请求同时从旧 `includeArchived` 对齐到 v2 `archived: false`,断言现在要求 `id + sessionId` 精确匹配,并由 `tool-execution-soak-evidence.test.mjs` 的 10 条单测覆盖。
+- `home-hotpath` 修复后真实 Electron Gate B 通过:`homeInputToSendDispatchMs=37ms`(预算 250ms),pending preview paint `63ms`,first text paint `543ms`,provider wait `90ms`,renderer 首次 delta apply `1ms`,first delta 到 paint `23ms`;Electron/preload/IPC/App Server/read model/GUI 闭环成功,console/page error 为 0。证据:`.lime/qc/gui-evidence/benchmark-20260804/benchmark-gateb-home-hotpath-after-observer-fix-20260804-summary.json`。
+- 一致 renderer 重建后 AgentControl cold-restart 独立复验通过,旧 `canvasImageInsertHistory` export mismatch 未复现;证据:`.lime/qc/gui-evidence/benchmark-20260804/benchmark-gateb-agent-control-cold-restart-after-renderer-rebuild-20260804.json`。
+- `SOAK-01` 10 轮 / 2 次 cold restart 通过:`allRoundsPassed`、`roundSessionsIsolated`、`readModelsStableAcrossColdRestarts`、`everyPreviousProcessTreeExited`、`finalProcessTreeExited` 全为 `true`;总 RSS `561,312 -> 442,768 KiB`(delta `-118,544 KiB`),App Server RSS delta `+8,976 KiB`,10 轮 duration `2,820-5,371ms`。证据:`.lime/qc/soak-01/agent-control-soak-final.json`。
+- 验证:SOAK evidence 单测 `10/10`、单轮 cold-restart、10x2 SOAK、home-hotpath 及一致构建后的 cold-restart 全部通过;未执行 commit/push/reset/分支操作。
+- 分类与结论:v2 `thread/list` 字段归一化和 SOAK 观察器为 `current / closed`;旧 `threadId` 观察字段与 `includeArchived` 请求为 `dead / deleted / forbidden-to-restore`;历史失败 JSON/截图保留为诊断 evidence。当前本轮 Gate B 为 `passed`,但本地 controlled fixture 不能替代 live provider、Windows RC 或正式签名/notarization 证据。
diff --git a/internal/exec-plans/release-v1.121.0-plan.md b/internal/exec-plans/release-v1.121.0-plan.md
new file mode 100644
index 000000000..c91a1e1e6
--- /dev/null
+++ b/internal/exec-plans/release-v1.121.0-plan.md
@@ -0,0 +1,69 @@
+# Lime v1.121.0 发布执行计划
+
+状态:completed
+日期:2026-08-05
+目标版本:`1.121.0`
+目标 tag:`v1.121.0`
+
+## 主目标
+
+以 `v1.120.1` 为基线,将当前工作树中的 Plugin v2 catalog/安装/启停、Agent runtime activation、Claw mention、MCP App Right Surface、受控 Browser HTML 宿主、Skills watcher 修复及其协议、GUI、测试和文档作为同一 release candidate,完成版本事实源、双语 release notes、发布门禁、release commit、tag、`main`/tag 推送与远端复核。
+
+## 当前阶段与下一刀
+
+- 当前阶段:版本事实源、双语 release notes、发布门禁、架构图确认及 Git 发布收口均已完成。
+- 下一刀:无;后续由 `v1.121.0` 发布后的线上观察与下一版本执行计划承接。
+
+## Release Candidate
+
+- 基线:`v1.120.1`,本地 `main` 与 `origin/main` 均为 `bba332c65`。
+- 初始盘点:145 个 tracked diff 文件、48 个未跟踪文件;tracked diff 为 7,838 行新增、537 行删除。
+- 门禁期间并行候选补入 canonical store 快照持久化、SQLite busy timeout 与回归测试;发版文件及闭环修复纳入后为 154 个 tracked diff 文件、49 个未跟踪文件,其中 `.gitignore` 仍排除。
+- `release metadata`:`package.json`、`packages/lime-cli-npm/package.json`、`lime-rs/Cargo.toml`、`lime-rs/Cargo.lock`、`RELEASE_NOTES.md`、`RELEASE_NOTES.en.md`、本计划及执行计划索引。
+- `candidate changes`:除 `.gitignore` 外的当前工作树产品、协议、schema、生成客户端、Rust runtime、Electron host/fixture、GUI、五语资源、测试、bundled Plugin 资产与文档改动。
+- `excluded changes`:`.gitignore` 中本地 `.gstack/` 忽略项;该改动已在 `v1.120.1` 发布计划中明确排除,本次继续保留在未提交工作树。
+- 并行避让:除 release metadata 与本计划外,其余候选文件只读验证;门禁暴露 blocker 时只做最小可验证修复并回写本计划。
+
+## 写集与退出条件
+
+- 版本事实源与双语 release notes 统一为 `1.121.0`,notes 只保留当前版本单页。
+- 必跑 `npm run verify:app-version`、`npm run typecheck`、`npm run test:contracts`、受影响 Rust 验证、`npm run smoke:plugin-v2-current-electron-fixture`、`npm run verify:gui-smoke` 与 `git diff --check`。
+- 候选已修改 `internal/aiprompts/architecture.md` 并补充 Plugin v2 current 图。直接发布 `main` 无 PR 描述,责任开发者必须在 Git 高风险操作确认中同时确认架构图,未确认不得进入 release evidence。
+- Git 写操作(stage、commit、tag、push)须在门禁完成后按危险操作格式取得一次明确确认。
+- 完成 release commit、`v1.121.0` tag、`main` 与 tag 推送后,复核本地与远端引用;`.gitignore` 应作为唯一未提交排除项保留。
+
+## 已知非本版阻断项
+
+- Windows 安装到卸载证据、360px 级五语最长文案截图矩阵、repo/personal marketplace 的完整用户路径、更新/授权/管理员策略与安装事务崩溃恢复未验收。
+- 通用 Plugin Apps/Hooks、Browser intent、文件/结构化结果与完整 Plugin identity 追踪仍由 `plugin-v2-current-plan.md` 后续阶段承接。
+- 本版只完成上述已实现的 macOS current 候选发布,不将 Plugin v2 整体路线图标记为完成。
+
+## 验证记录
+
+- `npm run verify:app-version`:通过,根应用、CLI npm 包、Rust workspace/lock 与 Electron/App Server 版本统一为 `1.121.0`。
+- `npm run typecheck`:通过,Renderer 与 Node TypeScript 发布硬门禁无错误。
+- `npm run test:contracts`:通过;862 个协议类型生成失败 0/漂移 0,App Server client 292 checks,命令、Harness、多模态、脚本、Forge 发布流程与文档边界通过。
+- `npm run test:rust:changed -- --changed=origin/main`:版本/lock 触发后自动扩大为 `cargo test --lib --workspace`;首轮暴露 reasoning 指数重复和两个 Codex 导入性能失败,修复持久化二次 merge 及重复 delta 语义后重跑全 workspace 通过。
+- Rust 定向回归:`thread-store` 重复 reasoning delta/final 快照与空 completion 保留快照 1/1;App Server canonical reasoning 线性持久化、空 completion 保留物化内容及持久化恢复 3/3;Codex 1,200 命令同步导入与 40 turn 后台导入 2/2,分别约 4.0s 与 9.6s。
+- `npm run smoke:plugin-v2-current-electron-fixture`:通过,Gate B/runtime 覆盖 App Center 安装/启停、Claw mention、MCP tool/resource、Right Surface、reload、cold restart、卸载及历史恢复;summary=`.lime/qc/gui-evidence/plugin-v2-current-electron-fixture/plugin-v2-current-electron-fixture-summary.json`。
+- `npm run verify:gui-smoke`:通过,真实 Electron/preload/IPC/`app_server_handle_json_lines`/App Server current 链路、版本 `1.121.0`、reload、Workbench 与 Settings 正常;summary=`.lime/qc/project-gates/standalone-shell-01-20260805091101-56033/shell-01-electron-smoke/summary.json`。
+- `npm run i18n:check:json`:通过,5 个 locale、13 个 namespace、10,063 个源键,缺失/多余均为 0。
+- `cargo fmt --manifest-path "lime-rs/Cargo.toml" --all -- --check`:通过。
+- `git diff --check`:通过。
+- GUI 构建只输出已知非阻断告警:`oem-runtime-config.js` 非 module script、Browserslist 数据过期与 Electron `console-message` API 废弃提示;本次未更新核心依赖。
+
+## 架构确认
+
+- `internal/aiprompts/architecture.md` 已记录 Plugin v2 catalog、activation snapshot、MCP runtime/Right Surface 和历史恢复主链图。
+- 责任开发者确认:confirmed;Plugin v2 current 图与本次发布候选主链一致。
+
+## 分类
+
+- `current`:Plugin v2 App Server catalog/install/enabled owner、typed JSON-RPC gateway、activation snapshot、MCP runtime/resource 与 Right Surface current 投影。
+- `compat`:无新增;保留的旧 surface 只能委托 current owner。
+- `deprecated`:Plugin v1 manifest/parser/registry/worker/UI runtime 继续按 `plugin-v2-current-plan.md` 迁出,本次不恢复调用。
+- `dead / deleted / forbidden-to-restore`:无本轮新增删除;生产 mock fallback、Plugin worker 第二 runtime 与 renderer 旧 registry 不得回流 current 主链。
+
+## 完成度
+
+- 当前发版完成度:100%。候选范围、发版文件、标准门禁、架构图确认、release commit、tag、`main`/tag 推送与远端复核均已完成。
diff --git a/internal/roadmap/browser/README.md b/internal/roadmap/browser/README.md
index 831bbafaf..cc29aa625 100644
--- a/internal/roadmap/browser/README.md
+++ b/internal/roadmap/browser/README.md
@@ -8,10 +8,10 @@
- `internal/roadmap/rightsurface/README.md`
- `internal/roadmap/plugin/README.md`
-- `internal/roadmap/plugin/prd.md`
-- `internal/roadmap/plugin/architecture.md`
-- `internal/roadmap/plugin/interface-contracts.md`
-- `internal/roadmap/plugin/technical-baseline.md`
+- `internal/roadmap/plugin/v2/01-product-contract.md`
+- `internal/roadmap/plugin/v2/03-architecture-and-command-contracts.md`
+- `internal/roadmap/plugin/v2/04-app-center-and-claw-surfaces.md`
+- `internal/roadmap/plugin/v2/07-verification-contract.md`
- `internal/roadmap/agentworkbench/README.md`
- `internal/aiprompts/commands.md`
- `internal/aiprompts/quality-workflow.md`
diff --git a/internal/roadmap/plugin/README.md b/internal/roadmap/plugin/README.md
index 4ce93a9b9..ddccc5fcd 100644
--- a/internal/roadmap/plugin/README.md
+++ b/internal/roadmap/plugin/README.md
@@ -1,91 +1,58 @@
-# Lime 插件路线图
+# Lime Plugin 路线图
-更新时间:2026-06-27
-状态:Skeleton Implemented / Productization In Progress
+更新时间:2026-08-04
-## 1. 定位
+状态:`Plugin v2 proposed / implementation-ready`
-Lime 的一级产品概念统一为 **插件**。插件负责安装、授权、发布、启用与分发;`插件工作区能力` 只作为插件内部的一种能力形态,表示“带独立 UI 的工作台能力”。旧 `Plugin / 工作台应用` 命名只允许作为迁移输入或历史证据,不再作为插件中心的产品根对象。
+## 当前事实源
+
+Plugin 当前路线图统一进入 [v2/README.md](./v2/README.md)。v2 直接采用 Codex-compatible `.codex-plugin/plugin.json`、marketplace、Skills、MCP servers/apps 与 Hooks 模型,并固定以下生产主链:
```text
-插件 = 分发与授权根对象
-插件工作区能力 = 插件内部 UI 子能力
-Right Surface = Host 管理的产物渲染工作区
+Electron Desktop Host
+ -> App Server JSON-RPC
+ -> RuntimeCore / MCP / Skills / Hooks
+ -> Thread / Turn / Item projection
+ -> App Center / Claw / Right Surface
```
-这条路线参考上游插件与市场模型:插件使用 `@` 稳定 ID,marketplace 下发 manifest 摘要、安装策略和认证策略,用户通过显式选择或 `@` 调用,而不是靠语义猜测。
+App Center 参考 Codex 的信息架构和状态语义,但继续使用 Lime 当前主题。Claw 通过 `@plugin` 调用能力,并在唯一 Right Surface 中承载 MCP/App UI、Browser、结构化结果和文件预览。
-服务端 marketplace 事实源在 LimeCore:
+## 阅读顺序
-```text
-LimeCore 外仓 plugin roadmap / control-plane 文档
-GET /api/v1/public/tenants/{tenantId}/client/plugins/marketplace
-```
+1. [研究结论](./v2/00-research-findings.md)
+2. [产品合同](./v2/01-product-contract.md)
+3. [包、市场与安装](./v2/02-package-marketplace-installation.md)
+4. [架构与命令合同](./v2/03-architecture-and-command-contracts.md)
+5. [App Center 与 Claw Surface](./v2/04-app-center-and-claw-surfaces.md)
+6. [迁移与清理账本](./v2/05-migration-and-cleanup.md)
+7. [实施计划](./v2/06-implementation-plan.md)
+8. [Gate B 验收合同](./v2/07-verification-contract.md)
-Lime Desktop 只负责本地安装态、显式激活、Right Surface 渲染和 App Server prompt context;不在 Lime App Server 内新增 marketplace 服务端。
+## Legacy 声明
-## 2. 文档索引
+本目录根部旧 PRD、架构、接口、原型、证据和实施跟踪文件保留为 `historical reference`,用于回看产品洞察、交互方案、历史决策和既有证据。它们不再是 current 设计、实现或验收事实源,也不得独立继续演进。
-| 文档 | 用途 |
-| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
-| [`prd.md`](./prd.md) | 插件产品定义、背景、用户路径、需求、里程碑和验收。 |
-| [`architecture.md`](./architecture.md) | 插件/工作区能力/Renderer/Claw/Right Surface 的分层架构。 |
-| [`technical-baseline.md`](./technical-baseline.md) | 插件运行的宿主基线、承载方式、current / deprecated / dead 分类。 |
-| [`interface-contracts.md`](./interface-contracts.md) | 插件 manifest、激活上下文、renderer contract、surface action contract。 |
-| [`implementation-plan.md`](./implementation-plan.md) | 插件中心、composer 激活、renderer host、迁移收口的实施顺序。 |
-| [`../../tech/plugin/README.md`](../../tech/plugin/README.md) | Lime Plugin Package v1 技术标准:`plugin.json`、runtime/workbench、skills、worker 和验证口径。 |
-| [`history-product-workspace.md`](./history-product-workspace.md) | 历史对话、插件上下文和产物 tab 的恢复规则。 |
-| [`user-operations-guide.md`](./user-operations-guide.md) | 插件中心消费、授权、本地安装态、显式激活、Right Surface 和运营排查指南。 |
-| [`e2e-evidence.md`](./e2e-evidence.md) | 第二轮跨仓端到端证据包,串联 LimeCore 发布 / 审计与 Lime Desktop GUI fixture。 |
-| [`evidence/plugin-productization-e2e-summary.json`](./evidence/plugin-productization-e2e-summary.json) | 本轮桌面 GUI fixture 的版本化精简 summary。 |
-| [`prototype.html`](./prototype.html) | 可直接打开的静态 HTML 原型。 |
-| [`prototype.md`](./prototype.md) | 插件中心、插件详情、激活 strip、右侧 dock 的低保真原型。 |
+旧 `lime.plugin.package.v1`、根 `plugin.json`、`contributions.runtime/workbench`、插件专用 worker 和 renderer registry 仍处于 `deprecated`,不得新增调用或能力;只有 current 主链迁移和 Gate B 验收完成后才能删除。文档保留与旧实现退役是两个独立治理维度,详见 [迁移与清理账本](./v2/05-migration-and-cleanup.md)。
-## 2.1 图表索引
+## 历史参考导航
-| 图表 | 位置 | 用途 |
-| ---------------------- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
-| 跨仓职责图 | [`architecture.md`](./architecture.md#31-跨仓职责图) | 固定 LimeCore marketplace、Lime Desktop、本地 registry、App Server prompt context 与 Right Surface 的职责边界。 |
-| 分层架构图 | [`architecture.md`](./architecture.md#4-分层架构) | 固定 Plugin Contract、Runtime、Session Plugin Workspace、Right Surface 的分层。 |
-| 显式激活时序图 | [`architecture.md`](./architecture.md#显式激活时序图) | 描述 `@plugin` 从 composer 到 App Server prompt context 的时序。 |
-| Marketplace 消费流程图 | [`architecture.md`](./architecture.md#marketplace-消费流程图) | 描述客户端拉取 LimeCore marketplace、合并本地安装态和构造 activation metadata。 |
-| 历史恢复拓扑 | [`architecture.md`](./architecture.md#8-历史恢复拓扑) | 描述历史会话、plugin workspace 和 Right Surface 恢复关系。 |
-| Surface Action 回流图 | [`architecture.md`](./architecture.md#9-surface-action-回流) | 固定右侧 action 必须回流 runtime,不直连 provider 或文件系统。 |
-| 开发切片总览 | [`implementation-plan.md`](./implementation-plan.md#2-开发切片总览) | 跟踪从文档 contract 到内容工厂 dogfood 的实施顺序。 |
-| 客户端消费流程图 | [`user-operations-guide.md`](./user-operations-guide.md#3-端到端消费流程) | 描述插件中心拉取、合并本地 registry、安装、上报和显式激活。 |
-| 显式激活时序图 | [`user-operations-guide.md`](./user-operations-guide.md#4-用户路径) | 描述 `@插件` 从 UI 到 App Server current 运行链的时序。 |
-| 安装态与审计流程图 | [`user-operations-guide.md`](./user-operations-guide.md#7-安装态与审计) | 描述本地 installed registry 与 LimeCore 审计之间的边界。 |
-| 跨仓证据流程图 | [`e2e-evidence.md`](./e2e-evidence.md#2-跨仓证据流程图) | 描述平台发布、客户端消费、安装态报告、显式激活、Right Surface 和审计证据。 |
+### 产品与设计
-## 3. 当前决策
+- [旧版 PRD](./prd.md)
+- [旧版架构](./architecture.md)
+- [旧版接口合同](./interface-contracts.md)
+- [旧版技术基线](./technical-baseline.md)
+- [旧版交互原型说明](./prototype.md)与[静态原型](./prototype.html)
+- [历史工作区与恢复设计](./history-product-workspace.md)
+- [旧版用户操作指南](./user-operations-guide.md)
-| 决策 | 口径 |
-| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| 根对象 | 插件是用户侧可安装、可授权、可发布的根对象。 |
-| Marketplace 服务端 | `current` 在 LimeCore control-plane,不在 Lime App Server。 |
-| 插件工作区能力 | 作为插件内独立 UI 能力;旧 Plugin / 工作台应用只允许作为迁移输入,不再作为插件 marketplace 的设计模板。 |
-| 右侧渲染 | Right Surface 继续作为 Host 的唯一物理右栏,插件只提供数据模型、视图与 action。 |
-| 激活方式 | 不再在每次发送消息时全量读取插件列表做语义猜测;激活必须显式。 |
-| Renderer 输出合同 | manifest / marketplace summary 可以声明 `outputArtifactKind`、`paneKind` 和 action,但 runtime 当前只接管内容工厂 workspace patch,不开放任意插件执行。 |
-| 内容工厂 | 内容工厂应作为插件重建,而不是复用旧 `旧内容工作台` 代码。 |
-| 现有路线上下文 | 现有 `rightsurface` 负责统一右侧 dock;plugin 路线只定义该 dock 如何承载插件产物。 |
-| 旧插件中心命令 | 历史插件中心命令族继续按 `dead` 处理,不恢复为生产入口。 |
-| 旧 Plugin 目录 | `client/plugins` 和本地 Plugin manifest 投影只允许作为 compat / migration 输入;插件中心 current 数据源是 `client/plugins/marketplace` 与本地插件安装态。 |
-| `@` 命令边界 | 平台 `@` 原子命令仍以 `SkillCatalog.entries.kind=command` 为事实源;插件只可贡献显式 activation command entry,并通过 `agentSession/turn/start` metadata 进入 current 主链。 |
+### 历史跟踪与证据
-## 4. 与现有路线图关系
+- [旧版实施计划](./implementation-plan.md)
+- [旧版 E2E 证据](./e2e-evidence.md)
+- [历史 E2E 摘要](./evidence/plugin-productization-e2e-summary.json)
+- [旧发布中心 PRD](./deverlop/plugin-publish-center-prd.md)
+- [旧发布服务端计划](./deverlop/plugin-publish-limecore-server-plan.md)
-| 路线图 | 关系 |
-| ---------------------------------------------------------------------- | ------------------------------------------------------------------- |
-| [`internal/roadmap/rightsurface/README.md`](../rightsurface/README.md) | 定义唯一右侧 dock、多 tab 和 pane 状态机;plugin 路线复用这层宿主。 |
-| [`internal/roadmap/workbench/v4/README.md`](../workbench/v4/README.md) | 定义插件工作区能力在插件体系里的位置,以及内容工厂的工作台形态。 |
-| [`internal/roadmap/workbench/v3/README.md`](../workbench/v3/README.md) | 作为历史参考,帮助理解从 Workbench Profile 过渡到插件分层的原因。 |
-
-## 5. 开发者阅读顺序
-
-1. 先读 [`prd.md`](./prd.md)。
-2. 再读 [`architecture.md`](./architecture.md)。
-3. 然后读 [`technical-baseline.md`](./technical-baseline.md) 了解宿主边界。
-4. 对照 [`interface-contracts.md`](./interface-contracts.md) 固定 contract。
-5. 落包结构、skills、worker 或 workbench 时,回到 [`../../tech/plugin/README.md`](../../tech/plugin/README.md) 作为技术事实源。
-6. 按 [`implementation-plan.md`](./implementation-plan.md) 分阶段落地。
+从历史文档复用的有效决策必须先写入 v2 对应合同并通过 current owner 审核,不能让实现直接同时依赖 v1 与 v2。
diff --git a/internal/roadmap/plugin/v2/00-research-findings.md b/internal/roadmap/plugin/v2/00-research-findings.md
new file mode 100644
index 000000000..bdc2b44d4
--- /dev/null
+++ b/internal/roadmap/plugin/v2/00-research-findings.md
@@ -0,0 +1,238 @@
+# Plugin v2 研究结论
+
+状态:`research-complete`
+
+取证日期:2026-08-04
+
+## 研究问题
+
+本次研究回答四个问题:
+
+1. Codex 现在如何定义、发现、安装和运行插件?
+2. ChatGPT/Codex Desktop 的真实安装包如何组织 bundled plugins?
+3. Lime 当前实现与 Codex 的关键差异是什么?
+4. 哪些差异属于 Lime 产品特性,哪些只是应删除的历史自定义协议?
+
+## 官方文档结论
+
+本次使用当前 Codex Manual 并检索以下官方页面:
+
+- [Build plugins](https://learn.chatgpt.com/docs/build-plugins)
+- [Package your plugin](https://developers.openai.com/plugins/build/plugins)
+- [Plugin architecture](https://developers.openai.com/plugins/concepts/plugins)
+- [Build an MCP server](https://developers.openai.com/plugins/build/mcp-server)
+- [Connect and test your plugin](https://developers.openai.com/plugins/deploy/connect-chatgpt)
+- [Plugins](https://learn.chatgpt.com/docs/plugins)
+- [Skills and plugins](https://learn.chatgpt.com/docs/skills-and-plugins)
+- [Plugin controls](https://learn.chatgpt.com/docs/enterprise/apps-and-connectors)
+
+文档给出的稳定结论:
+
+- Plugin 是可安装的能力包,可以包含 Skills、MCP server/apps、Hooks、assets 与展示元数据。
+- Skill 适合可复用指令与资源;MCP 适合实时数据、鉴权、受控动作或远端执行;UI 只在结构化交互确有价值时加入 MCP server。
+- ChatGPT 与 Codex 使用同一个公共插件目录,但不同产品 surface 的可用性和安装控制仍分开。
+- 插件安装不等于 connector 已授权,也不等于 runtime 获得文件、网络或命令权限。
+- 安装后应在新 conversation/thread 中验证,以确保 Skills 和工具清单按新状态加载。
+
+## 本机 Desktop 安装包证据
+
+### 应用身份
+
+```text
+Application: /Applications/ChatGPT.app
+Bundle identifier: com.openai.codex
+Version: 26.727.51351
+```
+
+虽然应用目录名是 `ChatGPT.app`,bundle identifier 与资源都表明它同时承载 Codex Desktop surface。不能依据 Finder 展示名推断产品边界。
+
+### 关键资源目录
+
+```text
+/Applications/ChatGPT.app/Contents/Resources/app.asar
+/Applications/ChatGPT.app/Contents/Resources/codex
+/Applications/ChatGPT.app/Contents/Resources/codex-code-mode-host
+/Applications/ChatGPT.app/Contents/Resources/plugins/openai-bundled
+/Applications/ChatGPT.app/Contents/Resources/skills
+/Applications/ChatGPT.app/Contents/Resources/native
+```
+
+取证时体积约为:
+
+```text
+app.asar: 208 MiB
+plugins/openai-bundled: 69 MiB
+skills: 372 KiB
+```
+
+这些目录表明 bundled plugin 不是写死在前端卡片中的 metadata,而是随应用发布的真实包、脚本、Skills、assets 与本地运行资源。
+
+### bundled marketplace
+
+入口文件:
+
+```text
+Resources/plugins/openai-bundled/.agents/plugins/marketplace.json
+```
+
+取证时包含以下插件:
+
+- `sites`
+- `browser`
+- `chrome`
+- `computer-use`
+- `messages`
+- `reminders-macos`
+- `record-and-replay`
+- `latex`
+- `deep-research`
+- `visualize`
+
+marketplace 只描述来源、安装策略、鉴权时机与类别;插件自己的能力和展示信息位于各包的 `.codex-plugin/plugin.json`。
+
+### 真实插件包样例
+
+Browser 插件使用以下结构:
+
+```text
+plugins/browser/
+├── .codex-plugin/plugin.json
+├── assets/
+├── docs/
+├── scripts/
+└── skills/
+ └── control-in-app-browser/SKILL.md
+```
+
+manifest 包含稳定 identity、版本、描述、作者、Skills 路径与 `interface` 元数据;能力脚本和大体积依赖留在插件包内,不进入 renderer bundle。
+
+### 用户态目录
+
+本机可见的相关位置包括:
+
+```text
+~/Library/Application Support/Codex
+~/Library/Application Support/OpenAI/Codex/NativeMessagingHosts
+```
+
+前者主要是 Chromium/Electron profile、缓存和 browser partition;后者是 Codex 原生消息桥接位置。它们不是 plugin manifest 事实源。
+
+官方文档给出的本地作者目录约定为:
+
+```text
+Repo marketplace: $REPO_ROOT/.agents/plugins/marketplace.json
+Personal marketplace: ~/.agents/plugins/marketplace.json
+Common plugin source: $REPO_ROOT/plugins/ 或 ~/.codex/plugins/
+```
+
+`marketplace.json` 的 `source.path` 才是解析依据,示例目录不是必须硬编码的唯一位置。
+
+## Codex 源码证据
+
+### 领域 owner
+
+| 路径 | 责任 |
+| ------------------------------------------------------- | ------------------------------------------------------- |
+| `codex-rs/plugin` | manifest、plugin ID、source authority、resolved plugin |
+| `codex-rs/core/plugins` | marketplace discovery、安装、卸载、remote catalog、配置 |
+| `codex-rs/utils/plugins` | mention syntax、namespace、MCP connector helper |
+| `codex-rs/tools/request_plugin_install.rs` | agent 发起的插件安装请求 |
+| `codex-rs/app-server/src/request_processors/plugins.rs` | 对 GUI/CLI 暴露 current plugin API |
+| `codex-rs/tui/src/chatwidget/plugins.rs` | 列表、搜索、详情、安装、启停、marketplace UI |
+
+Codex 的 resolver 先生成 inert descriptor,再由上层决定装配哪些能力。路径资源保留 source authority,避免把来自其他 environment 的资源误当成本机路径。
+
+### App Server 方法
+
+Codex current 方法包括:
+
+```text
+marketplace/add
+marketplace/remove
+marketplace/upgrade
+plugin/list
+plugin/installed
+plugin/read
+plugin/install
+plugin/uninstall
+```
+
+`plugin/list` 支持 local、vertical、workspace-directory、shared-with-me、created-by-me-remote 等 marketplace kind;`plugin/read` 返回 Skills、Hooks、Apps、MCP servers 与完整展示信息。
+
+### 产品状态不是单一布尔值
+
+Codex protocol 分开表达:
+
+- `installed`
+- `enabled`
+- `installPolicy`: `AVAILABLE | INSTALLED_BY_DEFAULT | NOT_AVAILABLE`
+- `authPolicy`: `ON_INSTALL | ON_USE`
+- `availability`: `AVAILABLE | DISABLED_BY_ADMIN`
+- `disabledReason`
+- `version` 与 `localVersion`
+
+Lime v2 必须保留这些正交维度,不能再用 `installed/disabled/readiness` 的前端拼装近似替代。
+
+### UI 交互证据
+
+Codex TUI snapshot 和 Desktop `app.asar` 中的真实页面资源共同证明以下 surface 已存在:
+
+- Plugins 目录页与插件详情页
+- All / Installed / source marketplace 分区
+- 搜索
+- 安装数量摘要
+- 安装、卸载、启用、禁用
+- 管理员分配与管理员禁用
+- Skills、Hooks、Apps、MCP servers 披露
+- 来源、鉴权时机、隐私和条款披露
+- `@plugin` mention 与安装建议
+
+Desktop bundle 中对应资源包括 `plugins-page`、`plugin-detail-page`、`plugin-picker-menu-content`、`plugins-settings` 与 `skills-page`。
+
+## Lime 当前差异
+
+### 包标准分叉
+
+Lime 当前 App Server 要求:
+
+```text
+plugin.json
+schemaVersion: lime.plugin.package.v1
+contributions.runtime
+contributions.workbench
+```
+
+随后由 `plugin_packages/plugin_manifest.rs` 投影成前端使用的另一份 manifest。这个流程与 Codex `.codex-plugin/plugin.json` 不是同一标准,且该文件已超过 1000 行,混合了解析、projection、兼容和 UI/runtime 推断。
+
+### 前端承担过多事实合并
+
+`src/features/plugin/marketplace/marketplaceRegistryLoader.ts` 同时读取 marketplace、installed state、manifest、setup state、runtime profile 和 readiness,再组合页面 registry。它实际成为第二个插件事实源。
+
+### 自定义 worker 与 UI runtime
+
+现有 `electron/pluginRuntimeTaskHost.ts`、`pluginTaskWorker.ts`、`src/features/plugin/runtime/**` 和 App Server plugin worker runtime 形成了插件专用执行/投影体系。它与 RuntimeCore、MCP、Skills 和 Right Surface 的 current owner 重叠。
+
+### 产品范围膨胀
+
+旧路线图把插件消费、独立应用壳、发布中心、云端上传、历史工作区和内容生产 runtime 同时纳入一个计划,导致 App Center 既像 marketplace,又像应用启动器和发布后台。
+
+## 对 Plugin v2 的直接结论
+
+1. 直接采用 `.codex-plugin/plugin.json`,不设计旧 manifest compat 层。
+2. App Server 返回完整 PluginSummary/PluginDetail,Renderer 不再合并 registry。
+3. 插件能力装配复用 Skills、MCP、Hooks 与 RuntimeCore owner。
+4. `@plugin` mention 是 Claw 的首要调用入口;目录页不是唯一入口。
+5. Right Surface 承载 MCP/App UI 或结果投影,不启动第二套插件业务后端。
+6. 发布平台与消费者 runtime 分离;v2 先完成本地、repo、bundled 与 remote catalog 消费闭环。
+7. 旧文档和 implementation tracker 冻结为历史参考;有效洞察提炼进入 v2,但旧文档不再作为 current 实现依据或独立继续演进。
+
+## 研究限制
+
+当前会话没有暴露 `computer-use` skill 要求的 Node REPL 工具,因此没有通过自动化直接点击本机 ChatGPT/Codex Desktop 的 Plugins 页面。视觉与交互结论来自:
+
+- 官方文档
+- 本机真实应用包和 bundle 资源
+- Codex TUI snapshot
+- Codex App Server protocol 与实现
+
+这足以确定产品结构和协议,但正式 UI 实现前仍需补一轮真实 Desktop 交互对照截图,作为设计输入而不是运行时事实源。
diff --git a/internal/roadmap/plugin/v2/01-product-contract.md b/internal/roadmap/plugin/v2/01-product-contract.md
new file mode 100644
index 000000000..4e718b731
--- /dev/null
+++ b/internal/roadmap/plugin/v2/01-product-contract.md
@@ -0,0 +1,227 @@
+# Plugin v2 产品合同
+
+状态:`proposed`
+
+## 产品定义
+
+Plugin 是一个可安装、可治理、可在对话中调用的能力包。它把相关 Skills、MCP servers/apps、Hooks、assets 和展示元数据组织为一个稳定 identity。
+
+Plugin 不是:
+
+- 单独一张应用中心卡片
+- 任意网页 URL
+- 另一套 Agent runtime
+- 只有 UI、没有可调用能力的 iframe
+- 把 Skill、MCP 和 Hook 复制进自己的私有协议
+
+## 当前主对象
+
+App Center 的主对象是“插件包”;Claw 的主对象仍是“当前 thread/turn”。插件只向 thread 提供可发现能力,不能把用户从对话主线带到第二套任务系统。
+
+## 核心用户目标
+
+1. 找到可信的插件并理解它能做什么。
+2. 看清来源、权限、鉴权时机和外部影响后安装。
+3. 在 Claw 中通过自然语言或 `@plugin` 明确调用。
+4. 在右侧查看插件返回的结构化 UI、浏览器或结果详情。
+5. 随时启停、更新或卸载,并知道残留数据和授权如何处理。
+
+## 来源模型
+
+| 来源 | 说明 | 默认信任 |
+| ---------- | ---------------------------------------------------- | ------------------------ |
+| bundled | 随 Lime 安装包发布并由 release 签名保护 | 高,但仍受运行时权限约束 |
+| repo | 当前 workspace 的 `.agents/plugins/marketplace.json` | 仅当前 workspace |
+| personal | 用户级 marketplace 和本地插件源 | 当前用户 |
+| configured | 用户显式添加的 Git/local/npm marketplace | 按来源和校验结果 |
+| workspace | 工作区管理员或远端目录提供 | 按 workspace policy |
+| shared | 其他成员分享或链接安装 | 需要展示发布者和分享边界 |
+
+来源只决定发现和安装路径,不自动授予 connector、文件、网络、命令或 external action 权限。
+
+## 生命周期状态
+
+### 目录可见性
+
+- `available`:可安装。
+- `installedByDefault`:由 bundled/workspace policy 默认安装。
+- `notAvailable`:目录中可解释,但当前不能安装。
+- `disabledByAdmin`:管理员禁止使用。
+
+### 本地状态
+
+- `notInstalled`
+- `installing`
+- `installedDisabled`
+- `installedEnabled`
+- `updateAvailable`
+- `uninstalling`
+- `failed`
+
+### 鉴权状态
+
+- `notRequired`
+- `requiredOnInstall`
+- `requiredOnUse`
+- `authorizing`
+- `authorized`
+- `authorizationFailed`
+
+这些状态必须保持正交。一个插件可以“已安装但未授权”,也可以“管理员默认安装但用户禁用”。
+
+## 关键流程
+
+### 目录安装
+
+```text
+打开 App Center
+ -> 选择来源或搜索
+ -> 打开详情
+ -> 查看能力、权限、来源与条款
+ -> 安装
+ -> 必要时授权 connector
+ -> 新建或刷新 thread
+ -> 在 Claw 中调用
+```
+
+### Claw 中安装建议
+
+```text
+用户输入 @plugin 或提出明确目标
+ -> installed projection 未找到能力
+ -> 目录 projection 存在可安装候选
+ -> 展示安装建议
+ -> 用户确认安装
+ -> 必要时授权
+ -> 新 thread/turn 生效
+```
+
+Agent 可以请求安装,但不能绕过安装确认、管理员策略或 connector 授权。
+
+### 右侧工作区
+
+```text
+插件工具返回 structured content / UI resource
+ -> RuntimeCore 记录 tool item
+ -> GUI projection 解析 surface descriptor
+ -> Claw Right Surface 打开对应面板
+ -> 用户在同一 thread 继续确认或操作
+```
+
+右侧关闭后,对话与 tool item 仍可恢复;右侧不能成为独立于 thread 的隐藏状态源。
+
+## App Center 信息架构
+
+一级结构:
+
+- 全部
+- 已安装
+- 官方/bundled
+- Workspace
+- Repo/Local
+- 用户添加的 marketplace
+
+主要动作:
+
+- 未安装:`安装`
+- 已安装且启用:`在 Claw 中使用`
+- 已安装且禁用:`启用`
+- 有更新:`更新`
+- 详情更多操作:`卸载`、`查看来源`、`管理授权`
+
+不在卡片上同时放置发布、审核、导出、删除数据等低频管理动作。
+
+## 详情披露合同
+
+详情必须展示:
+
+- 名称、图标、开发者、版本
+- 一句话能力说明与完整说明
+- 来源 marketplace 和安装策略
+- Skills
+- MCP servers/apps
+- Hooks 及触发事件
+- 需要的 connector 与鉴权时机
+- Read/Write/Interactive 等能力标签
+- 网站、隐私政策和服务条款
+- 安装/启停/更新/卸载状态
+
+技术细节可以折叠,但外部写入、敏感数据访问和不可逆动作不能隐藏。
+
+## Claw 兼容合同
+
+### Composer
+
+- `@` picker 同时显示已安装插件和明确标注的可安装建议。
+- 已安装插件可插入 `plugin://` 结构化 mention,显示文本使用 `@DisplayName`。
+- 未安装插件不能伪装成已可调用;选中后进入安装确认。
+- mention identity 必须贯穿 turn request、tool selection、trace 和历史恢复。
+
+### Runtime
+
+- Plugin mention 只增加插件选择上下文,不绕过 Skill/MCP/Tool 选择与权限检查。
+- 未显式 mention 时仍可按 Skill 描述和工具 metadata 自动发现。
+- 同一插件的 Skills、MCP 与 Hooks 使用同一 plugin identity 和 source authority。
+
+### Right Surface
+
+- 支持 MCP/App UI resource。
+- 支持 browser intent 与现有 Browser Right Surface。
+- 支持结构化结果、文件预览和审查视图。
+- 不支持插件自行启动未受管 iframe、任意本地 server 或 renderer-to-worker 私有协议。
+
+## 启用与安装的语义
+
+- 安装:把受校验的包和来源写入 installed store。
+- 启用:允许其能力进入新 thread 的 discovery/activation projection。
+- 授权:允许 connector 代表当前身份访问外部系统。
+- 运行权限:决定当前 turn 是否可执行文件、网络、命令或外部写操作。
+
+这四件事不得合并为一个“可用”按钮。
+
+## 错误与恢复
+
+每个失败必须给出可执行恢复动作:
+
+| 失败 | 恢复动作 |
+| -------------------- | ------------------------------------ |
+| marketplace 无法读取 | 重试、刷新来源、查看具体来源错误 |
+| manifest 无效 | 显示字段/路径错误,不允许安装 |
+| 版本不兼容 | 显示所需 host/runtime 版本 |
+| connector 未授权 | 进入授权,不重新安装插件 |
+| 管理员禁用 | 说明策略来源,不提供无效启用按钮 |
+| MCP 启动失败 | 查看连接诊断、重试或禁用该 connector |
+| 安装中断 | 回滚 staging,不留下半安装状态 |
+| 卸载有残留 | 显示残留位置和重试入口 |
+
+## 首版范围
+
+P0/P1 必须支持:
+
+- bundled/repo/personal/configured local marketplace
+- `.codex-plugin/plugin.json`
+- skills-only plugin
+- MCP plugin,包括 auth-on-install 与 auth-on-use
+- Hooks 声明和信任披露
+- App Center 目录、详情、安装、启停、卸载
+- `@plugin` mention
+- Claw Right Surface 的 MCP/App UI
+
+后续再支持:
+
+- public universal directory 发布
+- workspace sharing 与 groups
+- remote billing/commerce
+- scheduled tasks
+- 插件作者 analytics
+
+## 产品验收问题
+
+发布前必须能明确回答:
+
+1. 用户正在处理哪个插件、来自哪里?
+2. 它为什么可用、不可用或被禁用?
+3. 安装后还需要什么授权?
+4. 在 Claw 中如何调用?
+5. 右侧内容属于哪个 thread/turn/tool item?
+6. 卸载后包、配置、授权和数据各自如何处理?
diff --git a/internal/roadmap/plugin/v2/02-package-marketplace-installation.md b/internal/roadmap/plugin/v2/02-package-marketplace-installation.md
new file mode 100644
index 000000000..d980b488b
--- /dev/null
+++ b/internal/roadmap/plugin/v2/02-package-marketplace-installation.md
@@ -0,0 +1,293 @@
+# Plugin v2 包、Marketplace 与安装合同
+
+状态:`proposed`
+
+## 唯一包结构
+
+Plugin v2 只接受 Codex-compatible package:
+
+```text
+my-plugin/
+├── .codex-plugin/
+│ └── plugin.json
+├── skills/
+│ └── my-workflow/
+│ ├── SKILL.md
+│ ├── references/
+│ ├── scripts/
+│ └── assets/
+├── .mcp.json # 可选
+├── .app.json # 可选,兼容 MCP/App connection 映射
+├── hooks.json # 可选
+└── assets/ # 可选,logo/icon/screenshots
+```
+
+旧根目录 `plugin.json`、`schemaVersion: lime.plugin.package.v1`、`contributions.runtime`、`contributions.workbench` 不进入 v2 parser。
+
+## Manifest 合同
+
+最小 skills-only 插件:
+
+```json
+{
+ "name": "meeting-follow-up",
+ "version": "1.0.0",
+ "description": "Turn meeting notes into decisions and next steps",
+ "skills": "./skills/"
+}
+```
+
+完整示例:
+
+```json
+{
+ "name": "acme-projects",
+ "version": "1.2.0",
+ "description": "Review and update Acme projects",
+ "author": {
+ "name": "Acme"
+ },
+ "homepage": "https://example.com/docs",
+ "repository": "https://example.com/repo",
+ "license": "MIT",
+ "keywords": ["projects", "planning"],
+ "skills": "./skills/",
+ "mcpServers": "./.mcp.json",
+ "apps": "./.app.json",
+ "hooks": "./hooks.json",
+ "interface": {
+ "displayName": "Acme Projects",
+ "shortDescription": "Review projects and blockers",
+ "longDescription": "Inspect project status, review blockers, and update approved fields.",
+ "developerName": "Acme",
+ "category": "Productivity",
+ "capabilities": ["Read", "Write", "Interactive"],
+ "websiteURL": "https://example.com",
+ "privacyPolicyURL": "https://example.com/privacy",
+ "termsOfServiceURL": "https://example.com/terms",
+ "defaultPrompt": ["Show blockers in my active projects"],
+ "brandColor": "#0F766E",
+ "composerIcon": "./assets/composer.png",
+ "logo": "./assets/logo.png",
+ "logoDark": "./assets/logo-dark.png",
+ "screenshots": ["./assets/overview.png"]
+ }
+}
+```
+
+## 字段规则
+
+### Identity
+
+- `name`:必填,kebab-case,作为 package identity 与 namespace 基础。
+- `version`:发布包必填,严格 semver;本地开发包也建议必填。
+- 同一 marketplace 内 `name` 唯一。
+- installed identity 不能只用 `name`;至少组合 source identity 与 plugin name,远端包还保留 remote plugin ID。
+
+### Components
+
+- `skills`:包内相对路径,默认发现 `skills/`。
+- `mcpServers`:包内 `.mcp.json` 路径或内联 server object。
+- `apps`:包内 `.app.json`,指向已注册或可解析的 app/connector declaration。
+- `hooks`:包内 hook config;启用前必须做信任和事件披露。
+- 所有路径都必须解析到 package root 内,禁止绝对路径、`..` 越界、符号链接逃逸与大小写/Unicode normalization 冲突。
+
+### Interface
+
+- `displayName` 是用户可见名称;缺失时回退 `name`。
+- `shortDescription` 用于列表;`longDescription` 用于详情。
+- `defaultPrompt` 最多展示 3 条,每条最多 128 字符。
+- logo、icon 和 screenshots 必须存在于包内。
+- `brandColor` 只用于局部品牌提示,不覆盖 Lime 全局主题。
+- 外部写入或交互能力必须来自实际组件声明,不允许只靠 marketing metadata 宣称。
+
+## Marketplace 合同
+
+Repo marketplace:
+
+```text
+$REPO_ROOT/.agents/plugins/marketplace.json
+```
+
+Personal marketplace:
+
+```text
+~/.agents/plugins/marketplace.json
+```
+
+示例:
+
+```json
+{
+ "name": "local-team",
+ "interface": {
+ "displayName": "Team Plugins"
+ },
+ "plugins": [
+ {
+ "name": "acme-projects",
+ "source": {
+ "source": "local",
+ "path": "./plugins/acme-projects"
+ },
+ "policy": {
+ "installation": "AVAILABLE",
+ "authentication": "ON_INSTALL"
+ },
+ "category": "Productivity"
+ }
+ ]
+}
+```
+
+### 支持来源
+
+- `local`:marketplace root 内的相对目录。
+- `url`:插件位于 Git repository 根目录。
+- `git-subdir`:插件位于 Git repository 子目录,可带 `ref` 或 `sha`。
+- `npm`:包名、版本范围和 HTTPS registry;下载时禁止执行 lifecycle scripts。
+- `remote`:workspace/public catalog 返回的不可伪造 remote identity。
+
+### Policy
+
+- `installation`: `AVAILABLE | INSTALLED_BY_DEFAULT | NOT_AVAILABLE`
+- `authentication`: `ON_INSTALL | ON_USE`
+- `products`:仅作为显式 surface override;默认省略。
+- `category`:目录分组,不参与权限判断。
+
+## Discovery 顺序
+
+App Server 根据当前 workspace roots 计算 discovery:
+
+1. release-bundled marketplace
+2. 当前 repo marketplace
+3. personal marketplace
+4. 用户显式配置的 marketplace snapshots
+5. workspace/remote catalog
+
+顺序只影响展示与冲突诊断,不允许用“后读覆盖前读”静默替换同 identity 包。冲突必须返回 source-aware diagnostics。
+
+Renderer 不读取任何 marketplace 文件,也不自行扫描 `plugins/`。
+
+## Installed Store
+
+installed store 至少保存:
+
+```text
+pluginId
+pluginName
+sourceKind
+sourceIdentity
+marketplaceIdentity
+remotePluginId?
+installedVersion
+contentDigest
+installedAt
+enabled
+installPolicy
+authPolicy
+packageRootLocator
+componentSummary
+```
+
+不要把 connector access token、OAuth refresh token 或用户数据写进 installed record。凭证进入统一 secret/credential owner。
+
+## 安装事务
+
+```text
+resolve source
+ -> fetch/copy into isolated staging
+ -> validate marketplace policy
+ -> validate manifest and all paths
+ -> compute package digest
+ -> inspect Skills/MCP/Apps/Hooks
+ -> produce install review
+ -> user confirmation
+ -> atomic materialization
+ -> write installed store
+ -> start required auth flow
+ -> emit pluginsChanged
+```
+
+### Install review
+
+确认界面必须披露:
+
+- 插件名称、版本、开发者和来源
+- 安装策略与 auth policy
+- Skills 数量与名称
+- MCP servers/apps
+- Hook 事件与信任状态
+- Read/Write/Interactive 能力
+- 需要的外部授权
+- 包 digest 或签名状态
+
+### 原子性
+
+- staging 与 final package root 必须在同一可原子替换的存储边界。
+- 任一步失败都清理 staging,installed store 保持原值。
+- 更新使用 side-by-side staging,验证通过后替换,不原地覆盖运行中的包。
+- Runtime 使用 version/digest pin;正在运行的 turn 不切换到半更新组件。
+
+## 启用与刷新
+
+- 安装后默认是否启用由 marketplace policy 和用户确认共同决定。
+- enabled state 只影响新 thread/turn 的能力 snapshot。
+- 改变启用状态后发出 `pluginsChanged`,Claw 提示新 conversation/thread 生效。
+- 不在运行中的 turn 动态增删工具定义,避免 model context 与 tool registry 不一致。
+
+## 更新
+
+更新检查比较 source version、installed version 与 local materialized version:
+
+- 同 digest 不重复安装。
+- semver 降级需要显式确认。
+- source identity 改变视为新安装,不静默更新。
+- Git branch/range 可以刷新,但 installed record 必须 pin 到解析后的 commit/digest。
+- npm 安装不运行 `preinstall/postinstall` 等生命周期脚本。
+
+## 卸载
+
+卸载分四类资源:
+
+1. package materialization
+2. installed/enabled configuration
+3. connector authorization
+4. plugin-owned user data
+
+默认卸载只删除 1 和 2;3 和 4 必须在 UI 中单独说明和选择。管理员默认安装的插件不可由用户卸载,只能按 policy 允许时禁用。
+
+卸载必须先停止该插件的 MCP process/hooks,等待 terminal 或明确中止,再删除 package。正在运行的 turn 保留历史 item,不因卸载被改写。
+
+## 平台路径
+
+生产代码不得直接拼接 `$HOME`、`~/Library` 或 Windows profile 路径。所有位置通过统一 app paths/platform API 解析:
+
+- bundled source:应用 Resources 内只读目录
+- marketplace snapshots:用户 app data/config owner
+- installed packages:用户 app data 下的 plugin store
+- staging/cache:统一 cache/temp owner
+- secrets:系统 keychain/credential owner
+
+macOS 和 Windows 使用相同逻辑 identity,不要求物理路径一致。
+
+## 安全边界
+
+- manifest 与 marketplace 都是不可信输入。
+- 路径必须 canonicalize 后验证仍在 authority root 内。
+- remote download 必须有大小、超时、content-type、digest/signature 与重定向限制。
+- archive 解包防 zip-slip、symlink/hardlink escape、大小炸弹和文件数量炸弹。
+- MCP/Hook/Script 的存在不意味着可执行;执行受 runtime policy 和 approval 控制。
+- UI resource 使用受控 origin/CSP,不接受任意 `file://` 或 unrestricted localhost。
+- 日志只保存 identity、digest、阶段和错误码,不保存 token、完整用户数据或 tool result。
+
+## 明确删除的旧合同
+
+Plugin v2 完成迁移后,下列合同为 `dead / forbidden-to-restore`:
+
+- `lime.plugin.package.v1`
+- 根 `plugin.json` 的 `contributions.runtime/workbench`
+- `app.runtime.yaml` 作为插件 runtime 主入口
+- Renderer 自行 projection manifest
+- 以本地完整路径作为跨层 plugin identity
+- 安装记录中混合凭证、运行状态和 UI readiness
diff --git a/internal/roadmap/plugin/v2/03-architecture-and-command-contracts.md b/internal/roadmap/plugin/v2/03-architecture-and-command-contracts.md
new file mode 100644
index 000000000..f2b2fa314
--- /dev/null
+++ b/internal/roadmap/plugin/v2/03-architecture-and-command-contracts.md
@@ -0,0 +1,285 @@
+# Plugin v2 架构与命令合同
+
+状态:`implementation in progress`
+
+## 唯一产品链
+
+```text
+Electron Desktop Host
+ -> app_server_handle_json_lines
+ -> App Server plugin JSON-RPC
+ -> Plugin catalog/install owner
+ -> Skills / MCP / Hooks activation
+ -> RuntimeCore Thread/Turn/Item
+ -> Renderer projection
+ -> App Center / Claw / Right Surface
+```
+
+Electron 只负责 desktop host、文件选择、窗口/webContents 和受控原生能力;它不解析 marketplace、不决定 plugin readiness,也不运行第二套 plugin task runtime。
+
+## Owner 分工
+
+| Owner | 责任 | 禁止承担 |
+| ------------------------ | ------------------------------------------------------- | ----------------------------- |
+| `app-server-protocol` | plugin/marketplace JSON-RPC schema、notification | 文件扫描、安装实现 |
+| App Server plugin domain | discovery、read、install、update、uninstall、projection | provider request、UI 状态拼装 |
+| app paths/repository | 跨平台目录、installed store、原子写入 | 运行时工具执行 |
+| Skills owner | Skill discovery、namespace、selection、prompt fragment | marketplace 安装 |
+| MCP owner | server lifecycle、OAuth、tools/resources/apps | plugin catalog UI |
+| Hooks owner | hook config、trust、lifecycle enforcement | 任意插件私有 worker |
+| RuntimeCore | thread/turn snapshot、工具与审批、item projection | 安装包文件操作 |
+| Renderer gateway | typed JSON-RPC client、notification subscription | manifest 解析、registry 合并 |
+| App Center | 目录、详情和生命周期操作 | catalog 事实源 |
+| Claw Right Surface | UI/result projection | 独立业务后端 |
+
+## 协议方法
+
+Plugin v2 采用下列 current 方法:
+
+```text
+marketplace/add
+marketplace/remove
+marketplace/upgrade
+plugin/list
+plugin/installed
+plugin/read
+plugin/install
+plugin/uninstall
+plugin/enabled/set
+```
+
+`plugin/enabled/set` 是 Lime 需要的明确扩展,用于表达本地启停;其语义不得混入 install/uninstall。
+
+后续按真实需求再加入:
+
+```text
+plugin/update
+plugin/share/save
+plugin/skill/read
+```
+
+不要把旧 `pluginInstalledSave`、`pluginLocalPackageInspect`、`pluginUiRuntimeStart` 等方法机械改名后继续保留同一实现。
+
+## 核心 DTO
+
+### PluginSummary
+
+```text
+id
+remotePluginId?
+name
+version?
+localVersion?
+source
+installed
+installedAt?
+enabled
+installPolicy
+installPolicySource?
+authPolicy
+availability
+disabledReason?
+interface?
+keywords[]
+```
+
+### PluginDetail
+
+```text
+marketplaceName
+marketplacePath?
+summary
+description?
+skills[]
+hooks[]
+apps[]
+mcpServers[]
+screenshots[]
+sourceDiagnostics[]
+```
+
+### Source authority
+
+资源 locator 必须携带 owner:
+
+```text
+Bundled { releaseId, relativePath }
+Workspace { workspaceId, relativePath }
+Local { marketplaceId, relativePath }
+Installed { pluginId, digest, relativePath }
+Remote { remotePluginId, releaseId, resourceId }
+```
+
+跨 App Server 边界不暴露可被 Renderer 任意读取的绝对路径。需要展示来源时返回 sanitized label 和可审计 source descriptor。
+
+## Discovery 与缓存
+
+App Server 每次 list 以 workspace roots、configured marketplaces、release bundle 和 remote policy 为输入,生成 source-aware projection。
+
+缓存必须以以下维度键控:
+
+```text
+workspace roots
+marketplace snapshot versions
+installed store revision
+workspace/admin policy revision
+remote catalog revision
+```
+
+不得只按 plugin name 缓存,也不得由 Renderer localStorage 充当 installed fact。
+
+## 安装时序
+
+```text
+Renderer plugin/install
+ -> App Server resolve marketplace entry
+ -> installer staging + validation
+ -> optional confirmation/authorization request
+ -> atomic store update
+ -> activate component descriptors
+ -> pluginsChanged notification
+ -> Renderer refresh list/detail
+```
+
+安装请求必须包含 marketplace identity 与 plugin name,不能只传 URL 或任意文件路径。导入本地目录时先建立显式 local marketplace/source authority,再走同一安装流程。
+
+## Runtime 装配
+
+### Thread snapshot
+
+启动或恢复 thread 时,RuntimeCore 获取当前 enabled plugins snapshot:
+
+- plugin identity/version/digest
+- enabled Skills roots
+- MCP server/app declarations
+- trusted Hooks
+- policy and auth readiness
+
+snapshot 写入 thread/runtime metadata,只保存 marker 和 identity,不保存完整 Skill body、secret 或 MCP token。
+
+### Turn
+
+- 显式 `@plugin` mention 进入 turn input 的结构化 selection。
+- Skills selector 可从 enabled plugin skill roots 发现候选。
+- MCP tool registry 只暴露已启动、已授权且 policy 允许的 server tools。
+- Hooks 在 tool/runtime owner 的统一 lifecycle 上执行。
+- 安装/启停变化不修改正在运行的 turn tool schema。
+
+### MCP current 装配
+
+Plugin v2 的 MCP 声明已进入统一 runtime owner:
+
+```text
+LocalAppDataSource
+ -> list_mcp_runtime_server_specs
+ -> RuntimeBackend::ensure_thread_mcp_runtime_if_available
+ -> AgentRuntimeState::ensure_mcp_runtime
+ -> McpThreadRuntime
+ -> McpClientManager
+```
+
+当前合同如下:
+
+- manifest `mcpServers` 可以指向包内 JSON 文件,也可以直接声明 inline object;manifest 未声明时发现 package root 的 `.mcp.json`。
+- `.mcp.json` 同时接受 `{ "mcpServers": {...} }` 和直接 server map,并复用 `lime_mcp::McpServerConfig` 解析。
+- stdio server 默认 `cwd` 是 installed package root;显式相对或绝对 `cwd` canonicalize 后必须仍位于该 root 内。
+- disabled Plugin 不生成 runtime spec;单个无效 server fail closed,但不丢弃有效 sibling。
+- runtime server name 固定为 `plugin____`,tool name 继续由统一 MCP owner 投影为 `mcp____`。
+- 用户 MCP 与 Plugin MCP 重名时保留用户配置并跳过冲突的 Plugin server。
+- activation descriptor 记录可审计的 `runtimeCapabilities.mcpServers`,但 `mcpBindings` 不伪造 wildcard tool binding;具体工具只能在 server initialize/list tools 后进入 snapshot。
+
+该实现不新增 Plugin worker、第二套 MCP manager 或私有 IPC。Apps 与 Hooks 的 current lifecycle 装配仍待完成。
+
+### Item projection
+
+tool item 必须保留:
+
+```text
+pluginId?
+skillName?
+mcpServerName?
+toolName
+callId
+approvalId?
+surfaceDescriptor?
+status
+```
+
+这样 Claw timeline、Right Surface 和历史恢复使用同一 identity。
+
+## Right Surface 合同
+
+插件可以通过标准 tool result 声明 surface:
+
+```text
+McpAppResource { server, resourceUri, csp, initialState }
+BrowserTarget { url, browserFamily?, ownership }
+StructuredResult { rendererKind, dataRef }
+FilePreview { pathRef, mimeType }
+```
+
+Renderer 只根据受支持 descriptor 打开已有 Right Surface owner。插件不能注册任意 React component import、任意 iframe src 或 Electron IPC channel。
+
+UI 内的动作回到 MCP tool call/elicitation/approval;不建立 `pluginUiRuntimeStart -> plugin worker -> custom event` 私有闭环。
+
+## Auth 与权限
+
+```text
+Plugin installed
+ != Connector authorized
+ != MCP ready
+ != Tool allowed
+ != External write approved
+```
+
+App Server projection 分别返回这些状态。授权由 MCP/OAuth owner 执行,secret 由统一凭证 owner 保存;approval 由 tool-runtime/agent-runtime 执行。
+
+## Notifications
+
+至少需要:
+
+```text
+plugin/changed
+marketplace/changed
+mcpServer/statusUpdated
+mcpServer/oauthLoginCompleted
+skills/changed
+```
+
+通知只表示 revision 改变,Renderer 收到后重新读取 projection;不要把整份 catalog 通过事件重复广播。
+
+## 错误模型
+
+错误按 owner 分类:
+
+- `marketplace_*`:来源不可达、格式错误、冲突
+- `package_*`:manifest、路径、digest、archive、版本
+- `policy_*`:管理员禁用、计划不满足、产品不支持
+- `auth_*`:未授权、scope 不足、登录失败
+- `runtime_*`:MCP/Hook/Skill activation 失败
+- `surface_*`:UI resource/CSP/renderer 不支持
+
+错误必须包含稳定 code、sanitized message、retryability 与 recovery action;不向 Renderer 泄露 secret 或未清理的本地绝对路径。
+
+## 架构删除线
+
+以下方向禁止进入 Plugin v2:
+
+- Renderer -> filesystem 直接扫描 plugin package
+- Renderer -> Electron plugin worker 作为业务后端
+- App Center registry 与 RuntimeCore 各维护一份 enabled plugins
+- 插件包注册任意 IPC/capability 名称
+- 生产路径回退 mock capability host
+- 为旧 `lime.plugin.package.v1` 新增 compat wrapper
+- 用 `pluginId@tenant` 字符串拼装代替结构化 source identity
+
+## 架构确认项
+
+实现阶段的责任开发者需要在执行计划与 PR 描述确认:
+
+- [ ] App Server 是 catalog/installed 唯一事实源
+- [ ] RuntimeCore 使用同一 plugin identity snapshot
+- [ ] Right Surface 没有第二套业务后端
+- [ ] 旧 manifest 和 worker 入口已迁出或删除
+- [ ] macOS/Windows 路径走统一平台 owner
+- [ ] Gate B trace 能证明 current JSON-RPC 与用户可见状态
diff --git a/internal/roadmap/plugin/v2/04-app-center-and-claw-surfaces.md b/internal/roadmap/plugin/v2/04-app-center-and-claw-surfaces.md
new file mode 100644
index 000000000..4ef802574
--- /dev/null
+++ b/internal/roadmap/plugin/v2/04-app-center-and-claw-surfaces.md
@@ -0,0 +1,242 @@
+# Plugin v2 App Center 与 Claw Surface
+
+状态:`proposed`
+
+## 设计原则
+
+复刻 Codex 的信息架构和状态语义,不复刻其视觉主题。Lime 继续使用当前浅色、安静、桌面应用感的设计语言,不引入 Codex/ChatGPT 的深色背景、品牌渐变或超大圆角。
+
+## 页面类型
+
+App Center 是目录型列表工作台,不是营销 landing page,也不是发布后台。
+
+主对象:插件包。
+
+当前阶段:发现、检查、安装、启停或更新。
+
+主操作:根据状态显示一个明确动作;低频动作进入更多菜单或详情页。
+
+## 页面结构
+
+```text
+┌────────────────────────────────────────────────────────────┐
+│ Plugins [搜索] [来源] [刷新] │
+├──────────────┬─────────────────────────────────────────────┤
+│ 全部 │ 已安装 3 / 可用 18 │
+│ 已安装 │ │
+│ 官方 │ 插件列表 │
+│ Workspace │ icon 名称 / 描述 / 来源 / 状态 [主动作] │
+│ Repo / Local │ icon 名称 / 描述 / 来源 / 状态 [主动作] │
+│ 自定义来源 │ │
+│ + 添加来源 │ │
+└──────────────┴─────────────────────────────────────────────┘
+```
+
+宽窗口使用左侧来源 rail + 列表;窄窗口把来源折叠为下拉菜单。不要把每个来源做成等权大卡片。
+
+## 顶部工具区
+
+- 标题与安装摘要保持紧凑。
+- 搜索框支持名称、描述、keyword、Skill、MCP server。
+- 来源筛选优先使用下拉或左侧 rail。
+- 刷新使用图标按钮。
+- 添加 marketplace 是次级动作,不与安装主动作竞争。
+- 不显示大段“插件是什么”说明;帮助信息进入 tooltip/help panel。
+
+## 列表项
+
+每项固定展示:
+
+- 40px 左右 logo;无 logo 时使用稳定生成的 fallback,不复用同一默认图标。
+- display name。
+- 一行 short description。
+- source label。
+- installed/enabled/admin/auth/update 中最关键的一个状态。
+- 单一主动作。
+- 更多菜单。
+
+状态与动作映射:
+
+| 状态 | 主动作 | 次级动作 |
+| ----------------- | -------------- | ---------------------- |
+| available | 安装 | 查看详情 |
+| installing | 安装中 | 取消,仅在可安全取消时 |
+| installedEnabled | 在 Claw 中使用 | 禁用、卸载、来源 |
+| installedDisabled | 启用 | 卸载、来源 |
+| updateAvailable | 更新 | 在 Claw 中使用、卸载 |
+| authRequired | 连接 | 禁用、卸载 |
+| disabledByAdmin | 无 | 查看策略说明 |
+| failed | 重试 | 查看错误、卸载 |
+
+卡片/行内不同时出现“打开、安装、更新、发布、审核、删除”五六个按钮。
+
+## 来源导航
+
+与 Codex 对齐的来源分区:
+
+- 全部插件
+- 已安装
+- Lime Bundled/Official
+- Workspace
+- Shared with me
+- Repo marketplace
+- Personal/Local
+- 用户配置 marketplace
+- 添加 marketplace
+
+空来源不必强制显示;Workspace 可在远端加载中显示稳定 skeleton,失败时只影响该来源,不清空其他 marketplace。
+
+## 搜索
+
+- 输入即时过滤当前 projection,不重新触发安装状态读取。
+- 搜索为空时展示目录;无结果时显示当前 query 和清空入口。
+- source tab 与 query 同时生效。
+- 搜索结果仍展示 source 和 policy,不能因过滤丢失安全语境。
+
+## 详情页/抽屉
+
+详情不是卡片里的嵌套卡片。使用独立详情视图或右侧详情 pane:
+
+```text
+Header: logo / name / developer / version / primary action
+Summary: long description / source / policy / auth
+Capabilities: Skills / MCP Apps / Hooks
+Examples: default prompts
+Trust: website / privacy / terms / digest or signature
+Diagnostics: only when blocked or failed
+```
+
+详情中的能力列表可以折叠,但外部写入、Hook 事件和授权要求默认可见。
+
+## 安装确认
+
+确认弹窗只在真正安装前出现,内容聚焦影响:
+
+- 来源与开发者
+- Read/Write/Interactive 能力
+- Skills、MCP/apps、Hooks 数量
+- auth-on-install/on-use
+- 隐私和条款链接
+
+按钮:`取消`、`安装`。不要把“安装并运行、安装并打开、安装并授权”混成多个竞争按钮;安装后按 auth policy 进入下一状态。
+
+## Claw Composer
+
+### `@` Picker
+
+Picker 分组:
+
+- 已安装插件
+- 可安装建议
+- 其他现有 mention 类型
+
+每个插件项展示 logo、名称、简述和状态。已安装插件选中后插入结构化 mention;可安装项选中后打开安装确认,不直接插入可执行 mention。
+
+### mention 显示
+
+显示:`@Acme Projects`
+
+结构化值:
+
+```text
+kind: plugin
+pluginId
+sourceIdentity
+displayName
+version?
+```
+
+发送时不得退化成只靠文本正则重新识别。
+
+### 新 thread 提示
+
+安装、启停或更新后,如果当前 thread 的 runtime snapshot 已建立,显示简短提示:“插件将在新对话中生效”。提供“新建对话”动作,不静默修改当前 turn 的 tool schema。
+
+## Claw Right Surface
+
+### 支持 surface
+
+- MCP/App UI:结构化查看、比较、编辑、确认。
+- Browser:复用 Browser Right Surface。
+- Structured result:表格、列表、详情或 diff。
+- File/media preview:复用现有 workspace preview。
+
+### 布局
+
+- Right Surface 与 timeline 保持同一 thread/turn context。
+- 顶栏显示插件名、当前工具/资源名、刷新/关闭等明确命令。
+- 主内容全高、可滚动,不放在装饰性外层卡片中。
+- 窄窗口改为可切换主视图,不压缩到不可读的双栏。
+- UI resource 自身不覆盖 Lime 全局标题栏和导航。
+
+### 生命周期
+
+- 打开 surface 不等于重新调用 tool。
+- 刷新需要新的 tool/resource read 时,走标准 MCP 调用并记录 item。
+- 用户提交写操作时走 approval/elicitation。
+- thread 恢复后从 item/read model 恢复 surface descriptor。
+- 插件卸载后历史 item 保留只读结果;不能再发起新动作。
+
+## 视觉规则
+
+- 主题继续使用 Lime 当前 color tokens。
+- 卡片圆角不超过现有应用中心规范;列表优先,避免大块营销卡。
+- 一个插件只使用自己的 logo/brand color 做局部识别,不染色整页。
+- 状态色:成功 emerald、信息 sky/slate、提醒 amber、错误 rose/red。
+- 主动作使用稳定按钮宽度,异步状态不引发布局跳动。
+- 不使用大面积渐变、半透明主表面、嵌套卡片或说明型 hero。
+
+## 响应式约束
+
+至少验证:
+
+- 1024x768:来源可折叠,列表名称与主动作不重叠。
+- 1280x800:标准 rail + 列表 + 可选详情 pane。
+- 1440x900:提高信息密度,不放大字体。
+- 390x844:来源变下拉,详情占主视图,按钮文案可换行但不溢出。
+
+稳定尺寸:
+
+- logo/icon 固定方形。
+- 工具按钮固定 `h/w`。
+- 状态列与主动作有 min/max width。
+- 长名称和长 source label 使用 truncate + title/tooltip。
+
+## 可访问性与国际化
+
+- 所有 icon button 有 `aria-label` 与 `title`。
+- tab/segmented control 使用正确 selected state。
+- 安装、启停使用 `aria-busy`/disabled 状态并保留可读原因。
+- 键盘可完成搜索、切换来源、打开详情、安装和返回。
+- 用户文案覆盖 `zh-CN`、`zh-TW`、`en-US`、`ja-JP`、`ko-KR`。
+- plugin manifest 内容按原文展示;系统状态和动作本地化。
+
+## 稳定 DOM 合同
+
+建议 test id:
+
+```text
+plugin-directory
+plugin-source-selector
+plugin-search-input
+plugin-list
+plugin-list-item
+plugin-primary-action
+plugin-detail
+plugin-install-dialog
+plugin-mention-option
+plugin-right-surface
+```
+
+测试使用 identity 属性,例如 `data-plugin-id`、`data-source-kind`、`data-installed`,不依赖样式 class 或中文文本定位关键对象。
+
+## UI 退出条件
+
+- [ ] 用户 5 秒内能看出当前来源、安装状态和下一步动作
+- [ ] installed/auth/admin/update 状态不互相覆盖
+- [ ] App Center 与 Claw 显示同一 plugin identity 和版本
+- [ ] `@plugin` 可安装建议不会伪装为已可用能力
+- [ ] Right Surface 从 Thread/Turn/Item 恢复
+- [ ] 1024 与移动宽度无文字/按钮重叠
+- [ ] 五语言和键盘操作有稳定回归
+- [ ] 真实 Electron Gate B 覆盖安装、mention、tool call 与 surface
diff --git a/internal/roadmap/plugin/v2/05-migration-and-cleanup.md b/internal/roadmap/plugin/v2/05-migration-and-cleanup.md
new file mode 100644
index 000000000..9941c4a0d
--- /dev/null
+++ b/internal/roadmap/plugin/v2/05-migration-and-cleanup.md
@@ -0,0 +1,170 @@
+# Plugin v2 迁移与清理账本
+
+状态:`historical-docs-frozen / implementation-migration-pending`
+
+更新时间:2026-08-04
+
+## 目标
+
+本文件定义 Plugin v2 从旧包标准、旧前端 registry 和插件专用 worker 迁入 current 主链的唯一清理顺序。它是删除账本,不是兼容层设计。
+
+迁移完成后的唯一产品链为:
+
+```text
+.codex-plugin/plugin.json / marketplace.json
+ -> App Server plugin domain
+ -> Skills / MCP / Hooks
+ -> RuntimeCore / Tool Runtime
+ -> Thread / Turn / Item projection
+ -> App Center / Claw / Right Surface
+```
+
+## 清理原则
+
+1. 先建立 current owner 和回归,再迁移调用,最后删除旧 owner。
+2. 不增加 `v1 | v2` 双读、manifest 自动转换或 renderer fallback。
+3. 旧代码只有在调用者清零、fixture 已迁移、回流守卫生效后才能删除。
+4. 发布、审核和商业化平台不是 Plugin consumer runtime 的组成部分;需要时进入独立路线图。
+5. 删除动作必须保留可审计证据,并遵循仓库危险操作确认要求。
+6. 重大架构落地时,同一变更集必须更新 `internal/aiprompts/architecture.md` 和对应执行计划。
+
+## 分类定义
+
+| 分类 | 允许行为 | 禁止行为 |
+| ---------------------- | ------------------------------ | --------------------------------------------- |
+| `current` | 扩展、修复、补测试 | 绕过 owner 建第二套实现 |
+| `deprecated` | 迁出调用、补观测、为删除做准备 | 新增能力、新增调用者、作为 fallback |
+| `dead` | 删除、加禁止回流守卫 | 恢复、包装、继续维护 |
+| `historical reference` | 查阅、引用、向 v2 提炼有效决策 | 直接作为 current 实现或验收依据、独立继续演进 |
+| `historical evidence` | 查阅和复核历史结果 | 冒充当前 Gate B 证据、覆盖 current 结果 |
+
+## Current:保留并对齐
+
+| 能力 | Current owner | v2 动作 |
+| ---------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------- |
+| Plugin protocol | `lime-rs/crates/app-server-protocol`、App Server request processor | 增加 Codex-compatible marketplace/plugin 方法与 projection |
+| Plugin domain | `lime-rs/crates/app-server` 内聚后的 plugin owner | 拆分 discovery、manifest、install store、activation,避免继续堆入单文件 |
+| Skills | 现有 Skills discovery/loader owner | 按 plugin identity 和 source authority 装配 |
+| MCP servers/apps | 现有 MCP owner | 复用 server lifecycle、auth、tool/resource 和 App UI 协议 |
+| Hooks | RuntimeCore / tool runtime 对应 lifecycle owner | 只接受 manifest 声明并进入统一权限与事件链 |
+| Agent execution | `agent-runtime`、RuntimeCore、`tool-runtime` | Plugin 仅贡献能力,不拥有 agent loop |
+| Read model | App Server、`thread-store` 与 projection package | 记录 plugin identity、tool item、surface descriptor 和恢复状态 |
+| Desktop bridge | Electron preload/IPC 到 `app_server_handle_json_lines` | 只转发 current JSON-RPC,不承接 catalog 和业务状态 |
+| App Center | `src/features/plugin/ui/**` 的 current 页面 owner | 改为消费 App Server projection,不再合并 registry |
+| Claw | composer、streaming、thread/read model owner | 增加结构化 `@plugin` mention 与调用状态 |
+| Right Surface | `RightSurfaceRegistry` 及 surface owner | 承载 MCP/App UI、Browser、结构化结果和文件预览 |
+
+## Deprecated:只迁出
+
+以下区域在 v2 迁移期间可以被读取和测试,但不得新增产品能力:
+
+| 旧事实或实现 | 当前问题 | 迁出目标 |
+| -------------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------- |
+| 根目录 `plugin.json` 与 `lime.plugin.package.v1` | 与 Codex 包标准分叉 | `.codex-plugin/plugin.json` |
+| `contributions.runtime` / `contributions.workbench` | 把 agent runtime 和 UI host 私有化 | Skills、MCP servers/apps、Hooks、Right Surface descriptor |
+| `app.runtime.yaml` 主入口 | 引入插件专用 worker 生命周期 | RuntimeCore/MCP/Skill current activation |
+| `plugin_packages/plugin_manifest.rs` 旧 projection | 解析、兼容、UI/runtime 推断混在一起且超过文件治理阈值 | 小职责 manifest parser 与 protocol projection |
+| `marketplaceRegistryLoader.ts` | Renderer 合并 marketplace、installed、manifest、readiness | App Server `plugin/list` 与 `plugin/installed` |
+| `src/features/plugin/manifest/**` 旧解析链 | Renderer 直接理解安装包标准 | Renderer 只使用 protocol-generated types |
+| `src/features/plugin/install/**` 中本地事实拼装 | installed/setup/cache 多源并存 | App Server installed store 与原子安装事务 |
+| `src/features/plugin/runtime/**` 私有 runtime/projection | 与 RuntimeCore、MCP、read model 重叠 | current runtime 与统一 item projection |
+| `pluginUiRuntimeStart/Status/Stop` 命令族 | 第二套 UI runtime 生命周期 | MCP/App UI resource 与 Right Surface lifecycle |
+| `src/features/plugin/publish/**` | 发布后台混入插件消费主线 | 独立发布平台路线图或删除 |
+| `src/features/plugin/packaging/**` | 自定义桌面应用打包与 Plugin 包混为一体 | 只保留 Codex-compatible 包校验所需能力 |
+
+`deprecated` 不等于立即整目录删除。每个子域必须先完成调用图、测试与数据所有权核对,再按最小写集迁出。
+
+## Dead:迁移后删除
+
+满足对应退出条件后,下列路径或语义应删除:
+
+| Dead surface | 候选路径 | 删除前置条件 |
+| --------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
+| Electron plugin worker | `electron/pluginRuntimeTaskHost.ts`、`electron/pluginTaskWorker.ts` | 所有执行已进入 App Server/RuntimeCore;IPC 调用清零 |
+| App Server plugin worker | `lime-rs/crates/app-server/src/runtime/plugin_worker_*`、`plugin_task_runtime.rs` | runtime tests 与 read model fixture 已迁移到 current tool/MCP item |
+| 独立 Plugin UI host | `src/features/plugin/runtime/uiExtensionHost.ts` 及私有 bridge | MCP/App UI 可在 Right Surface 完成同等用户流程 |
+| Renderer capability mock fallback | `src/features/plugin/sdk/MockCapabilityHost.ts` 等生产可达 mock | mock 仅存在于测试夹具且构建守卫证明生产不可达 |
+| 旧 package fixtures | `src/features/plugin/testing/fixtures/**/plugin.json`、`app.runtime.yaml` | fixture 已换成 `.codex-plugin/plugin.json` 与 MCP/Skill/Hook 样例 |
+| 旧技术标准 | `internal/tech/plugin/lime-plugin-package-v1.md` | v2 contract 已成为唯一文档入口,入站引用清零 |
+| 旧 smoke scripts | `smoke:plugin-ui-runtime-*`、`smoke:plugin-runtime-*` 对应脚本 | Gate B current plugin fixture 覆盖安装到 Right Surface 闭环 |
+
+不要用空壳 facade 保留旧命令名。调用者迁完后直接删除命令、类型、fixture 和脚本入口。
+
+## 冻结保留的历史文档
+
+以下文档保留在仓库中,但冻结为 `historical reference` 或 `historical evidence`:
+
+| 分类 | 文件 | 保留价值 |
+| ------------ | -------------------------------------------------------------------------------------------- | ---------------------------------- |
+| 历史设计参考 | `prd.md`、`architecture.md`、`interface-contracts.md`、`technical-baseline.md` | 产品需求、分层、接口和旧实现约束 |
+| 历史体验参考 | `prototype.md`、`prototype.html`、`user-operations-guide.md`、`history-product-workspace.md` | 页面结构、用户流程、恢复和运维体验 |
+| 历史跟踪 | `implementation-plan.md` | 已实施切片、问题背景和验证记录 |
+| 历史证据 | `e2e-evidence.md`、`evidence/plugin-productization-e2e-summary.json` | 旧链路的 E2E 结果与局限 |
+| 独立范围输入 | `deverlop/plugin-publish-center-prd.md`、`deverlop/plugin-publish-limecore-server-plan.md` | 发布平台需求,供未来独立路线图提炼 |
+
+只有 `internal/roadmap/plugin/README.md` 与 `internal/roadmap/plugin/v2/**` 可以继续演进 Plugin current 路线。历史文档若发现仍有效的决策,应在 v2 对应文档中重述并记录当前依据,而不是直接恢复其事实源地位。
+
+## 入站引用迁移
+
+已知需要同步的 current 入站引用:
+
+| 来源 | 动作 |
+| ------------------------------------------- | --------------------------------------------------------------------------------- |
+| `internal/roadmap/browser/README.md` | 将旧 PRD、architecture、contracts、baseline 链接改到 v2 产品、架构和 surface 合同 |
+| `internal/roadmap/agentworkbench/README.md` | 保持指向根 `plugin/README.md`,由根索引导航 v2 |
+| `internal/roadmap/zuanjia/README.md` | 保持指向根 `plugin/README.md`,不复制 Plugin 规则 |
+| `internal/tech/plugin/README.md` | v2 实施时改为 deprecated 声明,最终删除旧标准入口 |
+
+历史执行日志中出现旧路径不需要机械重写;如果文档仍被 `docs:boundary` 当作 current 输入,则应由边界规则显式排除历史记录,而不是伪造过去的证据。
+
+## 数据与状态迁移
+
+本仓库没有外部用户和兼容负担,因此不做长期格式迁移器。开发态本地数据按以下策略处理:
+
+1. v2 installed store 使用新的 schema/version 和独立目录。
+2. 首次启动发现旧 installed/setup/cache 时,只显示“一次性清理旧插件数据”诊断,不自动转换为已安装 v2 插件。
+3. bundled 插件由当前 marketplace 重新解析与安装,不继承旧 readiness。
+4. connector 凭证由既有 auth owner 管理;删除插件只解除引用,不擅自删除共享凭证。
+5. 删除旧缓存前列出 namespace、预计体积和恢复边界,并走用户确认。
+6. thread 历史保留旧 tool/item 文本投影,但不恢复已删除 worker;恢复时明确显示历史能力不可重跑。
+
+## 回流守卫
+
+V2-0 和 V2-6 至少建立以下静态守卫:
+
+```text
+forbidden manifest: schemaVersion = lime.plugin.package.v1
+forbidden fields: contributions.runtime, contributions.workbench
+forbidden entry: app.runtime.yaml
+forbidden production imports: MockCapabilityHost, mockCapabilityProfile
+forbidden commands: pluginUiRuntimeStart, pluginUiRuntimeStatus, pluginUiRuntimeStop
+forbidden renderer behavior: fs/path scan of plugin package or marketplace
+```
+
+守卫应扫描生产代码、package scripts、协议 catalog 和 current 文档;测试 fixture 例外必须精确到文件,不能按目录放行。
+
+## 单切替换顺序
+
+```text
+冻结旧标准并加“禁止新增”守卫
+ -> 建立 v2 parser/protocol/store
+ -> 迁移 Skills/MCP/Hooks activation
+ -> 迁移 App Center 与 @plugin
+ -> 迁移 Right Surface 与历史投影
+ -> 迁移 Gate B fixture
+ -> 清零旧调用和旧命令
+ -> 删除 worker、旧 manifest、旧脚本并冻结历史文档
+ -> 加“禁止恢复”守卫
+```
+
+任一步出现主链缺口都应修复 current owner,不允许临时回落到旧 worker 或 renderer registry。
+
+## 删除完成条件
+
+- `rg` 无生产代码引用旧 manifest、旧 worker 命令和旧 registry loader。
+- package scripts 不再暴露旧 runtime smoke。
+- protocol/client/catalog/fixture 中不存在旧命令。
+- App Center 与 Claw 只消费 App Server v2 projection。
+- Gate B current fixture 覆盖安装、调用、Right Surface、恢复、卸载。
+- `governance:legacy-report` 将旧能力标记为 `dead/deleted/forbidden-to-restore`。
+- 根 Plugin README 以 v2 为 current 导航,并将旧文档明确分区为历史参考。
diff --git a/internal/roadmap/plugin/v2/06-implementation-plan.md b/internal/roadmap/plugin/v2/06-implementation-plan.md
new file mode 100644
index 000000000..5b9a58cea
--- /dev/null
+++ b/internal/roadmap/plugin/v2/06-implementation-plan.md
@@ -0,0 +1,245 @@
+# Plugin v2 实施计划
+
+状态:`active / P1-P5 core implemented`
+
+更新时间:2026-08-05
+
+## 主目标
+
+以最短可验证路径建立 Codex-compatible Plugin consumer runtime,并在同一主链内完成 App Center、Claw `@plugin` 和 Right Surface。实施期间不维持 v1/v2 产品双轨。
+
+## 写集纪律
+
+每个阶段开始前,在 `internal/exec-plans/` 建立或更新执行计划,声明:
+
+- 本阶段目标和退出条件
+- 精确写集与脏热区避让
+- current/deprecated/dead 变化
+- 架构图确认
+- 定向测试与 Gate B 证据路径
+- 阻塞和未验证原因
+
+并行开发时,protocol、App Server plugin domain、RuntimeCore、App Center、Claw/Right Surface 五个写集不得由不同变更同时重写同一事实。
+
+## 阶段总览
+
+| 阶段 | 主结果 | 前置 | 完成信号 |
+| ---- | ----------------------------- | ----------- | ----------------------------------------------------- |
+| V2-0 | 冻结标准与回流守卫 | v2 文档确认 | 旧标准禁止新增,执行计划落盘 |
+| V2-1 | Manifest/marketplace/protocol | V2-0 | App Server 可列出和读取 bundled/repo/personal 插件 |
+| V2-2 | 原子安装与 installed store | V2-1 | install/update/uninstall 可恢复且无半安装态 |
+| V2-3 | RuntimeCore activation | V2-2 | Skills/MCP/Hooks 在新 thread 可追踪装配 |
+| V2-4 | App Center | V2-1、V2-2 | 页面只消费 App Server projection |
+| V2-5 | Claw 与 Right Surface | V2-3 | `@plugin` 到 tool item/UI surface 闭环 |
+| V2-6 | 旧实现删除与文档冻结 | V2-4、V2-5 | 旧 worker、manifest、registry、命令清零,历史文档冻结 |
+| V2-7 | 跨平台与 release gate | V2-6 | macOS/Windows 与 Gate B 全通过 |
+
+## 当前实施快照
+
+| 阶段 | 状态 | 2026-08-05 事实 |
+| ---- | ------- | ------------------------------------------------------------------------------------------------- |
+| V2-0 | DONE | v2 文档、唯一 manifest 与 current owner 已冻结 |
+| V2-1 | DONE | typed catalog/read/install/installed/enabled protocol 已接入 |
+| V2-2 | PARTIAL | staging、digest、幂等与原子替换已接入;崩溃恢复/残留清理仍待补 |
+| V2-3 | PARTIAL | Plugin Skills 与 MCP 已装配;独立 Apps/Hooks 仍待补 |
+| V2-4 | PARTIAL | App Center 已只读 typed catalog;真实安装点击与窄窗证据仍待补 |
+| V2-5 | PARTIAL | `plugin://`、MCP App Right Surface 与 reload 恢复已接入;picker 点击、Browser/file surface 仍待补 |
+| V2-6 | PENDING | 未物理删除旧实现;删除前必须再次取得用户确认 |
+| V2-7 | PARTIAL | macOS core Gate B 已通过;Windows 与完整 P0/release matrix 未通过 |
+
+当前完成度为 `75%`。当前主目标已从“建立 parser/protocol”转为“补齐真实用户点击、卸载历史、cold restore 与跨平台证据”。
+
+## V2-0:冻结旧标准
+
+### 写集
+
+- `internal/roadmap/plugin/v2/**`
+- `internal/roadmap/plugin/README.md`
+- `internal/exec-plans/.md`
+- 既有 governance 检查的最小扩展点
+
+### 工作项
+
+1. 确认 `.codex-plugin/plugin.json` 是唯一 manifest。
+2. 确认 App Server 是 catalog、installed、enabled、policy、auth readiness 唯一事实源。
+3. 把旧 `lime.plugin.package.v1` 和 worker 命令列为 deprecated。
+4. 增加“禁止新增旧字段/新调用者”的窄范围守卫。
+5. 记录架构图确认,标出 current owner 与待删除 owner。
+
+### 退出条件
+
+- v2 系列文档通过格式和链接检查。
+- 旧文档不再被根 README 作为 current 导航。
+- 新代码无法继续引入旧 manifest 字段或生产 mock。
+
+## V2-1:Manifest、Marketplace 与 Protocol
+
+### 建议 owner
+
+```text
+lime-rs/crates/app-server/src/plugins/
+ manifest.rs
+ marketplace.rs
+ resolver.rs
+ projection.rs
+
+lime-rs/crates/app-server-protocol/
+ plugin v2 request/response types
+```
+
+最终目录以现有 crate 边界为准;不要新建与既有 owner 重叠的 crate。
+
+### 工作项
+
+1. 解析 `.codex-plugin/plugin.json`,校验 identity、version、relative paths 和 capability declarations。
+2. 解析 repo/personal marketplace,并支持 bundled/configured/remote source descriptor。
+3. 保留 source authority,禁止把远端路径当本地路径。
+4. 实现 `marketplace/add|remove|upgrade` 与 `plugin/list|installed|read` current protocol。
+5. 返回完整 `PluginSummary` / `PluginDetail`,包含 installed、enabled、policy、auth、availability 和版本字段。
+6. 生成或共享 protocol types,Renderer 不手写镜像类型。
+
+### 退出条件
+
+- bundled、repo、personal 三类来源可被同一 list API 表达。
+- 非法 manifest、路径越界、重复 ID、冲突版本返回稳定错误码。
+- Renderer 没有参与目录扫描或 manifest 解析。
+- `npm run test:contracts` 通过。
+
+## V2-2:安装 Store 与事务
+
+### 工作项
+
+1. 定义 installed record、source lock、content digest、enabled state 和 schema version。
+2. 下载或复制到 staging,完成大小、路径、digest、manifest 和 policy 校验后原子切换。
+3. 实现 `plugin/install`、`plugin/uninstall` 和 update flow。
+4. 安装与 connector auth 分离;`ON_INSTALL` 只触发授权流程,不把授权写成安装成功的必要伪状态。
+5. 卸载前关闭能力装配,清理包数据,保留共享凭证和 thread 历史。
+6. 崩溃恢复时回滚 staging 或完成已提交事务,不留悬空状态。
+
+### 退出条件
+
+- 安装中断、磁盘不足、digest 不一致和重复请求均有确定结果。
+- update 失败保留上一可用版本。
+- uninstall 后 package、index、activation projection 无残留。
+- macOS 与 Windows 路径测试通过。
+
+## V2-3:Skills、MCP 与 Hooks 激活
+
+### 工作项
+
+1. 将 installed+enabled 插件解析为 inert capability descriptors。
+2. 新 thread 创建或明确 reload 时生成 activation snapshot。
+3. Skills 进入现有 skill discovery;MCP servers/apps 进入现有 MCP lifecycle;Hooks 进入统一事件与权限 owner。
+4. 为每个 capability 携带 plugin ID、marketplace、version、source authority 和 digest。
+5. tool call、hook invocation、auth challenge 和失败事件进入 Thread/Turn/Item read model。
+6. enable/disable 不篡改运行中的 turn;新状态从下一个规定边界生效。
+
+### 退出条件
+
+- 不显式 mention 时,Skill/MCP 仍可按描述被正常发现。
+- 显式 mention 时只收窄插件上下文,不绕过权限。
+- 插件 MCP 启停与普通 MCP 共用 lifecycle 和诊断。
+- 运行路径不进入 plugin worker。
+
+## V2-4:App Center 重做
+
+### 写集
+
+- `src/features/plugin/ui/**`
+- App Server plugin gateway/client
+- 五语言资源与定向测试
+
+### 工作项
+
+1. 页面改为 `All / Installed / source marketplace` 信息架构。
+2. 增加搜索、来源筛选、详情、安装、启停、更新、卸载和 auth 入口。
+3. 详情披露 Skills、Hooks、Apps、MCP servers、source、auth、privacy、terms。
+4. 保持 Lime 当前主题、密度、颜色、圆角和 icon 语言,只复刻 Codex 的产品结构与状态语义。
+5. 通过按钮或下拉收纳低频动作;不恢复发布后台和独立 runtime page。
+6. 删除 renderer registry 合并、manifest parsing 和 mock fallback。
+
+### 退出条件
+
+- App Center 只调用 current plugin gateway。
+- 加载、空、错误、安装中、管理员禁用、待授权、可更新状态完整。
+- `zh-CN`、`zh-TW`、`en-US`、`ja-JP`、`ko-KR` 文案齐全。
+- 桌面与窄窗口无溢出、重叠或布局变形。
+
+## V2-5:Claw Mention 与 Right Surface
+
+### 工作项
+
+1. `@` picker 接入 installed projection,并把可安装建议与可调用插件明确区分。
+2. composer 写入结构化 `plugin://` mention,不依赖显示名反解析。
+3. turn request、RuntimeCore trace、tool item 和历史恢复贯穿同一 plugin identity。
+4. MCP/App UI resource 注册到 Right Surface;Browser intent 复用 browser tab;文件和结构化结果复用既有 surface。
+5. 后台结果进入 pending badge,不抢用户当前右侧 tab。
+6. surface action 回流 App Server current action/turn contract,不直连 provider、文件系统或插件 worker。
+
+### 退出条件
+
+- `@plugin` 可从 composer 触发真实 MCP/Skill 调用。
+- Right Surface 显示与当前 turn/tool item 对应的真实结果。
+- 关闭、重开和恢复 thread 后 identity 与 surface state 一致。
+- 插件 UI 不启动任意 iframe、本地 server 或私有 worker bridge。
+
+## V2-6:旧路删除
+
+### 工作项
+
+1. 生成旧 manifest、命令、import、script 和 fixture 调用图。
+2. 迁移最后调用者和测试。
+3. 删除 Electron/App Server plugin worker、plugin UI runtime、renderer registry 和旧包 parser。
+4. 删除旧 smoke scripts、package scripts 和 fixture。
+5. 将 v1 技术标准与旧路线图冻结为历史参考,current 导航只进入 v2。
+6. 将“禁止新增”守卫升级为“禁止恢复”守卫。
+
+### 退出条件
+
+- [05-migration-and-cleanup.md](./05-migration-and-cleanup.md) 的 dead 清单全部满足前置条件。
+- `rg`、protocol catalog、package scripts 和 build graph 均无旧入口。
+- `governance:legacy-report` 无 current/compat 误判。
+- current Gate B fixture 在删除后仍通过。
+
+删除属于高风险操作,执行前必须单独列出精确文件并取得明确确认。
+
+## V2-7:跨平台与 Release Gate
+
+### 工作项
+
+1. 在 macOS 验证 bundled、repo、本地安装、auth、调用、UI、更新和卸载。
+2. 在 Windows 验证路径、权限、进程、归档和原子替换语义。
+3. 验证应用升级后的 bundled marketplace upgrade 与 user-installed 保留策略。
+4. 完成恶意包、损坏包、管理员禁用和网络中断测试。
+5. 运行完整 Gate B 并记录证据摘要。
+
+### 退出条件
+
+- [07-verification-contract.md](./07-verification-contract.md) 的 P0 场景全部通过。
+- `npm run verify:local` 通过。
+- manifest/workspace/Electron 版本变化时 `npm run verify:app-version` 通过。
+- release evidence 可追踪 plugin/thread/turn/item/surface identity。
+
+## 测试扩展策略
+
+| 风险 | 最小验证 | 扩展条件 |
+| ------------------ | -------------------------------------- | --------------------------------------------- |
+| 文档/守卫 | 格式、链接、`git diff --check` | 影响 docs boundary 时跑 contracts |
+| Parser/protocol | Rust 单元、schema fixture、contracts | 影响多 crate 时跑 related tests |
+| Install store | 单元、崩溃恢复、路径安全 | 涉及 OS 行为时双平台 |
+| Runtime activation | current fixture、MCP/Skill integration | 影响 Thread/Turn/Item 时扩大 read model tests |
+| GUI | component tests、GUI smoke | 影响 bridge/Right Surface 时必须 Gate B |
+| 清理 | 旧引用扫描、contracts、current fixture | 删除共享 owner 时跑 verify:local |
+
+## 不允许的实施捷径
+
+- 先在前端写一份 v2 marketplace 假数据等待后端补齐。
+- 用 feature flag 长期保留 v1/v2 两套 production path。
+- 把 Codex manifest 转换成 `lime.plugin.package.v1` 后继续走旧 worker。
+- 以“兼容 Desktop”为由在 Electron main process 重建业务状态。
+- 以 UI demo 代替真实 App Server/RuntimeCore 调用证据。
+- 将发布中心、审核后台或商业化能力塞回 P0 consumer runtime。
+
+## 完成度口径
+
+阶段完成度只按退出条件计算,不按代码量或页面可见程度计算。任何阶段存在 production mock、legacy command hit、身份断链或未记录的跨 owner fallback,完成度不得超过 `90%`,也不得进入 release evidence。
diff --git a/internal/roadmap/plugin/v2/07-verification-contract.md b/internal/roadmap/plugin/v2/07-verification-contract.md
new file mode 100644
index 000000000..fc1216ed0
--- /dev/null
+++ b/internal/roadmap/plugin/v2/07-verification-contract.md
@@ -0,0 +1,316 @@
+# Plugin v2 验收合同
+
+状态:`active / macOS P0 core Gate B passed / release pending`
+
+更新时间:2026-08-05
+
+## 验收目标
+
+证明 Plugin v2 不是静态目录页或 renderer fixture,而是从真实安装来源经过 Electron/App Server/RuntimeCore 到 Claw 用户可见状态的生产主链。
+
+Gate A 用于快速验证浏览器投影;Gate B 才是功能验收。任何 P0 场景只有 Gate A、组件测试或 mock 证据时,一律记为未通过。
+
+## 质量层级
+
+| 层级 | 证明内容 | 能否替代 Gate B |
+| ----------- | ------------------------------------------------------------------------------ | --------------- |
+| Unit | parser、state reducer、transaction、projection 的局部行为 | 否 |
+| Contract | protocol、Electron IPC、preload、gateway、catalog 一致 | 否 |
+| Integration | App Server 与 filesystem/MCP/RuntimeCore 协作 | 否 |
+| Gate A | 浏览器或静态 fixture 的页面信息架构与布局 | 否 |
+| Gate B | 真实 Electron、真实 bridge、真实 App Server、真实 runtime/read model 和可见 UI | 是 |
+| Release | Gate B 加跨平台、升级、恶意输入和残留检查 | 是,最终门禁 |
+
+## Gate B 必须穿过的链路
+
+```text
+Electron Desktop Host
+ -> preload / IPC
+ -> app_server_handle_json_lines
+ -> App Server JSON-RPC
+ -> plugin domain / installed store
+ -> RuntimeCore / MCP / Skills / Hooks
+ -> Thread / Turn / Item read model
+ -> renderer gateway
+ -> App Center / Claw / Right Surface
+```
+
+证据必须能关联:
+
+```text
+pluginId + marketplaceId + version/digest
+threadId + turnId + itemId/toolCallId
+surfaceId/resourceUri
+```
+
+若任一层使用 renderer mock、测试专用 bridge、旧 plugin worker 或手工注入 read model,则该次运行不能标记 Gate B。
+
+## 当前 Gate B 证据
+
+2026-08-05 已 fresh 通过单一 Plugin v2 Electron fixture:
+
+```bash
+npm run smoke:plugin-v2-current-electron-fixture
+```
+
+该 fixture 强制从源码重建 App Server sidecar,并从真实 GUI 实际经过:
+
+```text
+App Center sidebar
+ -> plugin/list / bundled Browser identity
+ -> window.electronAPI.dialog.open
+ -> app:dialog:open / Electron dialog.showOpenDialog
+ -> install review / confirm
+ -> plugin/install
+ -> plugin/read
+ -> plugin/installed
+ -> App Center menu disable / enable
+ -> plugin/enabled/set(false / true)
+ -> Claw model selector
+ -> Plus / Plugins / installed Plugin option
+ -> plugin://mcp-elicitation-plugin structured mention
+ -> thread/start
+ -> GUI send / turn/start
+ -> McpThreadRuntime / McpClientManager
+ -> mcp__plugin__mcp-elicitation-plugin__demo__release_check
+ -> mcpServer/elicitation/request
+ -> Renderer 表单提交
+ -> provider final
+ -> thread/read
+ -> mcpResource/read
+ -> MCP App Right Surface / WebContentsView
+ -> Renderer reload restore
+ -> App Center uninstall confirm
+ -> plugin/uninstall / plugin/installed cleared
+ -> reopen original Thread
+ -> historical mention / tool item / Right Surface unavailable state
+```
+
+自动断言结果:
+
+```text
+proofLevel=Gate B
+backendMode=runtime
+electronPreloadBridge=true
+appServerHandleJsonLinesSeen=true
+appCenterBundledListVisible=true
+appCenterBundledIdentityStable=true
+nativeDirectoryDialogObserved=true
+appCenterInstallReviewVisible=true
+appCenterInstallCompleted=true
+appCenterEnableToggleCompleted=true
+clawFixtureModelSelected=true
+clawPluginPickerVisible=true
+clawPluginBadgeVisible=true
+clawStructuredMentionObserved=true
+clawThreadTurnItemIdentityStable=true
+rendererFormVisible=true
+rendererConfirmedSubmitted=true
+dynamicToolCanonicalCompleted=true
+runtimeInitializeProtocolVersion=2025-06-18
+missingRequiredMethods=[]
+legacyMcpCommandsSeen=[]
+pluginWorkerHitCount=0
+productionMockFallbackHitCount=0
+providerFinalTextObserved=true
+mcpLedgerAccepted=true
+mcpAppRightSurfaceVisible=true
+mcpAppResourceReadCount=4
+mcpAppHtmlLoadCount=4
+mcpAppToolCallCount=1
+mcpAppRestoredAfterReload=true
+mcpAppHistoryUnavailableAfterUninstall=true
+mcpAppCanonicalIdentityStable=true
+uninstallViaAppCenterCompleted=true
+installedProjectionClearedAfterUninstall=true
+historyReadableAfterUninstall=true
+historyMcpRuntimeNotRestartedAfterUninstall=true
+historyProviderNotReexecutedAfterUninstall=true
+historyToolNotReexecutedAfterUninstall=true
+consoleErrors=[]
+```
+
+证据位于 `.lime/qc/gui-evidence/plugin-v2-current-electron-fixture/`,包含 summary、脱敏 raw evidence 与 7 张当前 Electron 截图。人工复核确认 App Center、安装 review、Claw picker/mention、Right Surface、cold restore、卸载确认和卸载后历史均非空,无重叠、裁切、变形或主题偏离。
+
+summary 记录应用版本、平台/架构、Electron 版本、repository commit、起止时间,以及 `plugin/source/version/digest + session/thread/turn/user item/tool item/tool call/surface/resource` 身份。MCP App resource/HTML 累计计数按“首次打开与显式恢复 2 次、Renderer reload 3 次、跨进程 cold restore 4 次、卸载后保持 4 次”精确断言;MCP tool call 始终仅 1 次。卸载后只恢复 canonical 历史 item 与 surface identity,GUI 明确显示“仅保留历史”,不挂载 WebContentsView,不读取 MCP resource,也不重启 MCP runtime、provider 或 tool。
+
+当前 claim boundary 覆盖 bundled 列表、真实 Electron 目录选择与 App Center 安装/启停/卸载、Claw picker 与结构化 mention、manifest 选择的默认 `.mcp.json` 自动启动、真实 MCP tool、elicitation、provider final、canonical tool terminal、`thread/read`、MCP App Right Surface、Renderer reload、跨进程 cold restore 和卸载后历史不可重跑状态。`.mcp.json` 是 MCP 配置文件,唯一 Plugin manifest 仍是 `.codex-plugin/plugin.json`。
+
+该证据不证明 repo/personal marketplace、授权、更新、管理员策略、Windows release gate 或五语言窄窗矩阵已通过。
+
+## P0 场景矩阵
+
+| ID | 场景 | 当前 Gate B | 操作 | 必须观察到的结果 |
+| ----- | ------------------------- | ----------- | ------------------------------------------------------------ | ------------------------------------------------------------------------- |
+| GB-01 | bundled 列表 | PASS | 启动 Desktop,打开 App Center | `plugin/list` 返回 bundled source;卡片 identity/version 与 manifest 一致 |
+| GB-02 | repo/personal marketplace | PENDING | 打开含 repo marketplace 的 workspace,再添加 personal source | 来源分区正确;同 ID 冲突按 authority/policy 给出确定结果 |
+| GB-03 | 本地安装 | PASS | 从详情安装未安装插件 | staging 校验、原子提交、installed projection 和按钮状态一致 |
+| GB-04 | 安装时授权 | PENDING | 安装 `ON_INSTALL` connector 插件 | 安装与授权状态分开;取消授权不伪装成未安装 |
+| GB-05 | 使用时授权 | PENDING | 调用 `ON_USE` 插件 | 首次真实调用时出现授权;授权后原 turn 按合同继续或明确重试 |
+| GB-06 | 启用/禁用 | PASS | 禁用已安装插件,创建新 thread,再启用 | 新 thread 不发现禁用能力;启用后的新边界恢复发现 |
+| GB-07 | `@plugin` mention | PASS | 在 Claw picker 选择已安装插件并发送 | composer、turn、trace、tool item 使用同一 plugin ID |
+| GB-08 | MCP tool call | PASS | 调用插件 MCP 工具 | 真实 MCP lifecycle、权限、流式 tool item 和结果完成 |
+| GB-09 | MCP/App UI | PASS | 工具返回 UI resource | Right Surface 打开对应 App UI,surface identity 与 tool item 一致 |
+| GB-10 | Browser surface | PENDING | 插件发出 browser intent | 复用 Right Surface browser tab,不弹出第二右栏或系统浏览器 |
+| GB-11 | 历史恢复 | PASS | 关闭并恢复 thread | plugin/tool/surface 投影可恢复;不偷偷重跑旧动作 |
+| GB-12 | 更新 | PENDING | 安装有新版本的插件 | 新版本校验后原子替换;失败保留旧版本;新 thread 使用新 digest |
+| GB-13 | 卸载 | PASS | 卸载已使用插件 | catalog/installed/activation 清理;历史仍可读;共享 auth 不误删 |
+| GB-14 | 管理员禁用 | PENDING | 加载 disabled-by-admin 插件 | 可解释但不能启用/调用;UI 不展示无效动作 |
+
+`PARTIAL` 只表示底层 current contract 或受控 fixture 已覆盖部分步骤,不能替代该行要求的真实用户动作。当前 release gate 仍为未通过。
+
+GB-01、03、06、07、08、09、11、13 已通过 macOS 真实 Electron current 链路。其余 P0 场景、Windows 和五语言窄窗矩阵仍阻塞 release。
+
+## 安全与鲁棒性场景
+
+| ID | 输入/故障 | 预期 |
+| ------ | -------------------------------------- | ----------------------------------------------------- |
+| SEC-01 | 缺失或非法 `.codex-plugin/plugin.json` | 拒绝解析,错误定位到字段,不写 installed store |
+| SEC-02 | `../`、绝对路径、symlink 逃逸 | 拒绝越过 package/source authority |
+| SEC-03 | archive bomb、超大文件、文件数爆炸 | 在解包前/中按预算停止并清理 staging |
+| SEC-04 | digest/signature 不一致 | 拒绝安装或更新,保留上一可用版本 |
+| SEC-05 | 重复 ID、source spoofing | 按 marketplace authority 处理,不静默覆盖 |
+| SEC-06 | 安装中断或进程崩溃 | 重启后无半安装态,可重试 |
+| SEC-07 | 卸载时文件占用 | 明确残留和重试动作,不谎报成功 |
+| SEC-08 | MCP server 启动失败 | installed 保持,readiness 失败可诊断,不回落旧 worker |
+| SEC-09 | connector token 失效 | 进入重新授权,不重装插件,不泄漏 token |
+| SEC-10 | UI resource 非受信 origin | Host policy 拒绝或隔离,不允许任意本地 bridge |
+| SEC-11 | Hook 请求高风险动作 | 进入统一权限/确认,不因插件来源绕过 |
+| SEC-12 | 管理员策略运行中变化 | 新请求停止,用户看到策略原因,历史状态保持可读 |
+
+## UI 验收
+
+### App Center
+
+- All、Installed 和 marketplace 分区可切换,状态不互相污染。
+- 搜索覆盖名称、描述、开发者和能力关键词。
+- 卡片只保留主动作;启停、更新、卸载、授权和来源等低频动作使用详情或菜单。
+- 详情展示 Skills、Hooks、Apps、MCP Servers、source、auth、privacy 和 terms。
+- 使用 Lime 当前主题,不复制 Codex 配色;按钮、图标、间距和状态反馈遵循 Lime design language。
+- 360px 级窄窗口、常规桌面和宽屏下无文字溢出、卡片变形、重叠或横向滚动。
+- 加载、空、错误、离线、安装中、待授权、管理员禁用和更新失败状态均可恢复。
+
+### Claw 与 Right Surface
+
+- `@` picker 中“已安装可调用”和“可安装建议”视觉与行为不同。
+- mention 显示名变化不影响稳定 plugin identity。
+- 后台插件结果只增加 pending badge,不抢当前 tab。
+- MCP/App UI、Browser、文件和结构化结果复用现有 Right Surface registry。
+- 关闭右侧不丢 thread/tool item;恢复时不会自动重复外部动作。
+- surface action 回流 current action/turn contract,网络面板中不存在私有 plugin worker 请求。
+
+### 多语言
+
+以下 locale 必须同时验收:
+
+```text
+zh-CN
+zh-TW
+en-US
+ja-JP
+ko-KR
+```
+
+至少对最长文案 locale 做窄宽度截图,不能只验证 key 存在。
+
+## 协议与身份断言
+
+Gate B fixture 应自动断言:
+
+1. `plugin/list`、`plugin/install`、`plugin/read`、`plugin/installed`、`plugin/uninstall` 请求经过 App Server JSON-RPC。
+2. Desktop 调用经过 preload/IPC 和 `app_server_handle_json_lines`。
+3. Renderer 未直接读取 filesystem、marketplace JSON 或 plugin manifest。
+4. runtime item 包含 plugin identity、capability identity 和 source authority。
+5. UI surface 的 `resourceUri/surfaceId` 可以反查同一 tool item。
+6. thread restore 后 identity 不按 display name 重建。
+7. legacy command hit 为 `0`。
+8. production mock fallback hit 为 `0`。
+9. plugin worker process/IPC hit 为 `0`。
+
+## 建议证据包
+
+```text
+.lime/qc/gui-evidence/plugin-v2-gate-b//
+ summary.json
+ bridge-events.jsonl
+ app-server-requests.jsonl
+ runtime-items.jsonl
+ plugin-state-before.json
+ plugin-state-after.json
+ screenshots/
+ app-center-list.png
+ plugin-detail.png
+ claw-mention.png
+ tool-running.png
+ right-surface.png
+ restored-thread.png
+```
+
+`summary.json` 至少记录:应用版本、平台、commit、scenario、plugin/source/version/digest、thread/turn/item/surface IDs、legacy/mock hit count、开始结束时间和最终 verdict。
+
+证据中不得包含 token、cookie、用户输入的敏感字段、绝对凭证路径或完整环境变量。
+
+## 最低验证命令
+
+按改动范围执行,最终 release gate 至少包括:
+
+```bash
+npm run test:contracts
+npm run test:rust:related --
+npm run smoke:agent-runtime-current-fixture
+npm run verify:gui-smoke
+npm run governance:legacy-report
+npm run verify:local
+npm run verify:app-version
+```
+
+Plugin v2 的单一 current Gate B 命令为:
+
+```bash
+npm run smoke:plugin-v2-current-electron-fixture
+```
+
+该命令稳定前不得删除旧 fixture 的最后覆盖;稳定后应删除旧 plugin runtime/UI worker smoke,而不是永久并存。
+
+## 失败分类
+
+| 分类 | 判定 | 处理 |
+| --------------------------- | ------------------------------------- | ------------------------------------------ |
+| Product failure | 用户流程、状态、文案或恢复不符合合同 | 修复后重跑对应场景和相邻场景 |
+| Bridge failure | preload/IPC/App Server 不通或走旁路 | Gate B 失败,禁止用 Gate A 替代 |
+| Runtime failure | MCP/Skill/Hook 未进入 current item 链 | 修复 owner,不回落 plugin worker |
+| Projection failure | runtime 成功但 GUI 状态错误 | 修复 read model/gateway/projection |
+| Test infrastructure failure | fixture、端口、构建或环境故障 | 单独标记,不能计为产品通过 |
+| External auth failure | 第三方服务不可用或账号缺失 | 保留本地链路证据并标记阻塞,不伪造授权成功 |
+
+## 验收报告模板
+
+```text
+主目标:
+当前阶段:
+平台与应用版本:
+场景:GB-xx
+结果:PASS / FAIL / BLOCKED
+真实链路:Electron -> preload/IPC -> App Server -> RuntimeCore -> read model -> GUI
+身份:plugin / thread / turn / item / surface
+legacy command hits:0
+mock fallback hits:0
+证据路径:
+失败原因与修复:
+未验证项:
+current / deprecated / dead 变化:
+完成度:xx%
+```
+
+## Release 阻塞条件
+
+出现任一项即阻塞发布:
+
+- P0 Gate B 场景失败或仅有 mock/Gate A 证据。
+- Plugin identity 在 composer、runtime、read model 或 surface 之间断链。
+- 任意 production path 命中旧 plugin worker、旧 manifest 或 renderer registry fallback。
+- 安装/update 可能留下半安装态或覆盖上一可用版本。
+- 卸载会误删共享凭证、用户文件或不可解释残留。
+- macOS/Windows 任一支持平台没有安装到卸载的真实证据。
+- 五语言缺失或窄窗口存在遮挡、变形和不可操作控件。
+- 架构图确认、执行计划或 evidence summary 未落在仓库内。
diff --git a/internal/roadmap/plugin/v2/08-legacy-synthesis.md b/internal/roadmap/plugin/v2/08-legacy-synthesis.md
new file mode 100644
index 000000000..fcf9a38e5
--- /dev/null
+++ b/internal/roadmap/plugin/v2/08-legacy-synthesis.md
@@ -0,0 +1,188 @@
+# 旧 Plugin 文档提炼矩阵
+
+状态:`historical-audit-complete`
+
+更新时间:2026-08-04
+
+## 目的
+
+旧 Plugin 文档包含已经验证过的产品直觉、交互方案和问题记录,也包含与 Codex/current 主链冲突的历史实现假设。本文件把两者拆开,避免“保留文档”被误解为“保留旧事实源”。
+
+原则只有一条:历史文档可以被阅读和提炼,v2 合同才可以驱动实现。
+
+## 结论总览
+
+| 旧内容 | 处理 | v2 落点 |
+| -------------------------------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Plugin 是分发、授权和能力组织的根对象 | 吸收 | [01-product-contract.md](./01-product-contract.md) |
+| Claw 是对话、运行、审批和事实链主工作区 | 吸收 | [01-product-contract.md](./01-product-contract.md)、[03-architecture-and-command-contracts.md](./03-architecture-and-command-contracts.md) |
+| Right Surface 是唯一物理右栏 | 吸收并收紧 | [04-app-center-and-claw-surfaces.md](./04-app-center-and-claw-surfaces.md) |
+| 显式 `@plugin` 激活 | 吸收并改用稳定 plugin identity | [01-product-contract.md](./01-product-contract.md)、[04-app-center-and-claw-surfaces.md](./04-app-center-and-claw-surfaces.md) |
+| 历史恢复 selected object、tabs、产物和插件上下文 | 吸收并改为 thread/read model projection | [03-architecture-and-command-contracts.md](./03-architecture-and-command-contracts.md)、[07-verification-contract.md](./07-verification-contract.md) |
+| 安装、启用、授权、运行权限分离 | 吸收 | [01-product-contract.md](./01-product-contract.md)、[02-package-marketplace-installation.md](./02-package-marketplace-installation.md) |
+| WebContentsView、Host policy、surface action 回流 | 吸收 | [03-architecture-and-command-contracts.md](./03-architecture-and-command-contracts.md)、Browser 路线图 |
+| 插件自带 runtime/workbench、任意 renderer contract | 淘汰 | 不进入 v2;统一到 Skills/MCP/Hooks/Right Surface |
+| LimeCore marketplace 作为 Lime consumer runtime 的强依赖 | 拆分 | consumer 先支持 bundled/repo/personal/configured/remote;发布平台另行治理 |
+| 内容工厂、独立应用壳、发布后台同属 Plugin 根主线 | 拆分 | 内容工厂是插件样例,发布平台是独立范围,不阻塞 P0 consumer |
+| 旧 `plugin.json`、`lime.plugin.package.v1` | 淘汰 | `.codex-plugin/plugin.json` |
+| 旧 worker 和 renderer mock | 淘汰 | RuntimeCore、MCP、Skills 与 current read model |
+
+## 按文档提炼
+
+### `prd.md`:保留产品问题和用户路径
+
+保留内容:
+
+- 安装、授权、启用、显式激活、产物查看、历史恢复和卸载的完整用户路径。
+- 用户需要知道来源、状态、权限、失败原因和恢复动作。
+- 插件中心不能只是卡片列表,Claw 才是调用和继续工作的主场。
+- 复杂结果应进入 Right Surface,不能把用户带到第二个任务系统。
+
+修正内容:
+
+- 旧 PRD 把发布、注册、租户 enablement 和消费 runtime 交织在一起;v2 把它们分成 consumer protocol、local install store 和独立 publishing scope。
+- 旧 PRD 允许 workspace app/runtime 作为插件贡献;v2 只允许 Skills、MCP servers/apps、Hooks、assets 和受管 UI resource。
+
+### `architecture.md`:保留边界图,替换事实源
+
+保留内容:
+
+- Host、App Server、Claw、Runtime、Right Surface、artifact/evidence 的分层思路。
+- Surface 不直接调用 provider、filesystem、secret 或旧 desktop facade。
+- action 必须通过统一 intent 回流 runtime,并产生可追踪 evidence。
+- 历史恢复优先恢复对象和 surface 状态,不重跑危险 action。
+
+替换内容:
+
+- 旧图将 LimeCore marketplace 和本地 registry 合并视为主要事实源;v2 改为 App Server plugin domain 统一 projection。
+- 旧图允许插件 workbench shell 成为能力边界;v2 规定 Right Surface 是宿主边界,插件提供 resource/descriptor,不拥有独立业务后端。
+
+### `interface-contracts.md`:保留行为合同,废弃类型合同
+
+保留内容:
+
+- activation intent 的来源区分:user、route、restore、runtime。
+- surface action 的确认、权限、错误和 evidence 回流要求。
+- 历史恢复中的 primary/selected object 优先级。
+- 稳定错误必须面向用户可恢复,而不是暴露 raw JSON。
+
+废弃内容:
+
+- 旧 `PluginManifest`、`activeWorkspaceAppId`、`historyRestore` 和 renderer/workbench contribution 类型不能直接变成 v2 schema。
+- v2 只从 Codex manifest、App Server protocol 和现有 Right Surface contract 重新生成类型。
+
+### `technical-baseline.md`:保留 Host 安全底线
+
+可直接吸收的底线:
+
+- Electron Host 管理 view、bounds、权限、错误边界和生命周期。
+- 插件不能自行携带第二套 tab/history/permission/runtime 逻辑。
+- ``、BrowserView、raw Tauri command、生产 mock 不作为新主路径。
+- App Center 的安装/升级/卸载动作必须经过 current readiness 和 policy。
+
+需要改写的地方:
+
+- 旧 baseline 把插件独立 UI runtime 当成可选承载;v2 仅保留 MCP/App UI resource 和 Right Surface host。
+- `articleDraft`、`imageGenerationSet` 等业务 artifact 不属于 Plugin 通用 manifest;它们应由对应领域 projection 定义。
+
+### `prototype.md` / `prototype.html`:保留信息架构,不复制视觉实现
+
+保留内容:
+
+- App Center 首页、详情、能力列表、安装状态、composer 激活 strip、Right Surface 和历史恢复的布局关系。
+- 详情页需要把 Overview、Skills、能力/权限、来源和状态分层展示。
+- 用户需要在当前 Claw 上下文看到插件已激活,而不是被跳转到独立应用页。
+
+不直接复用:
+
+- 原型中的 `Renderer` tab、独立 workbench shell 和旧命令名称。
+- 旧视觉尺寸、文案、品牌图标;实现必须遵守 Lime 当前主题和五语言合同。
+
+### `history-product-workspace.md`:提炼恢复规则
+
+v2 采用以下有效规则:
+
+1. 先恢复 thread/read model,再恢复 plugin identity 和能力快照。
+2. 再恢复 primary/selected object、surface tab 和布局状态。
+3. 恢复失败显示可理解原因,不暴露内部 JSON 或绝对路径。
+4. 不恢复未完成危险 action、不复活过期权限、不自动重跑外部副作用。
+5. 历史恢复不得抢占用户当前正在使用的 thread 或 Right Surface tab。
+
+旧的“工作台应用”命名和业务产物类型不作为 v2 通用协议字段。
+
+### `user-operations-guide.md`:提炼运营诊断
+
+保留内容:
+
+- 安装、启用、禁用、卸载、授权和 blocked 状态要分别排查。
+- 诊断需要关联 source、version/digest、policy、auth、runtime readiness 和 evidence。
+- 卸载不应误删共享 connector 凭证;历史记录应保持可读。
+- GUI 验收要覆盖安装态刷新、Right Surface 产物和历史入口。
+
+替换内容:
+
+- “客户端上报到 LimeCore 是唯一安装态事实”的旧说法改为 App Server installed projection;外部审计只消费受控摘要。
+- 旧远端运行和发布授权不进入 v2 P0。
+
+### `implementation-plan.md`:保留顺序,冻结进度记录
+
+可复用顺序:
+
+```text
+contract
+ -> marketplace/manifest
+ -> explicit activation
+ -> Right Surface
+ -> history restore
+ -> evidence
+```
+
+需要舍弃的路线:
+
+- 先做大量 renderer registry 和 plugin worker,再补 current runtime。
+- 将内容工厂 dogfood、LimeCore 发布、租户灰度和客户端消费混成一个完成度。
+- 用“骨架完成”替代真实 Electron/App Server/RuntimeCore 证据。
+
+旧进度日志保留为历史审计,不再更新;新的进度只写入 `internal/exec-plans/` 和 v2 验收证据。
+
+### `e2e-evidence.md` 与 JSON:保留证据结构,不能当 Gate B
+
+可复用内容:
+
+- 证据摘要、版本、scenario、安装态、激活态、Right Surface 和失败项应有固定字段。
+- 证据需要同时记录客户端状态和服务端审计,避免只截 UI。
+- 未覆盖项必须明确列出,不把 partial evidence 写成通过。
+
+不能直接复用的原因:
+
+- 旧证据围绕发布中心、LimeCore catalog 和 content-factory fixture,不能证明 v2 的真实 Electron/preload/App Server JSON-RPC/RuntimeCore 链。
+- v2 Gate B 以 [07-verification-contract.md](./07-verification-contract.md) 为唯一验收合同,旧证据只能作为历史背景。
+
+### `deverlop/*`:独立范围输入
+
+发布中心文档仍有价值,尤其是签名、target 预检、审计、灰度、回滚和证据要求。但它们不应继续放大 Plugin consumer runtime 的范围。
+
+处理方式:
+
+- 当前保留原文作为历史范围输入。
+- v2 只引用其安全和发布边界,不依赖其 API、数据库或 LimeCore endpoint。
+- 未来如重新建设发布平台,应创建独立 roadmap/change,并重新确认 owner、协议和权限。
+
+## 采纳后的不变量
+
+以下不变量同时来自旧文档的有效经验和 v2 的 current 纠偏:
+
+1. App Center 是发现、详情和生命周期入口,Claw 是调用和继续工作入口。
+2. App Server 是 Plugin state/readiness 的事实源;Renderer 不扫描目录、不合并 registry。
+3. Plugin 不拥有 agent loop、thread store、独立 worker 或第二右栏。
+4. 所有高风险 action 经过统一权限、确认、事件和 evidence。
+5. 历史恢复恢复状态和引用,不重放旧副作用。
+6. 历史文档可以提供证据和上下文,但不覆盖 v2 protocol、current owner 和 Gate B 结果。
+
+## 审计完成条件
+
+- 根 README 能导航到历史参考和 v2 current。
+- v2 文档能明确指出旧文档的吸收、替换和暂缓内容。
+- 历史文档没有被标记为 current 实现输入。
+- 新实现计划只引用 v2 文档、current owner 和新的 Gate B evidence。
+- 旧文档保持可读,除非发现敏感数据、断链或明确错误需要单独修订。
diff --git a/internal/roadmap/plugin/v2/README.md b/internal/roadmap/plugin/v2/README.md
new file mode 100644
index 000000000..d9f6e4b81
--- /dev/null
+++ b/internal/roadmap/plugin/v2/README.md
@@ -0,0 +1,82 @@
+# Plugin v2 路线图
+
+状态:`active / macOS core Gate B passed`
+
+更新时间:2026-08-05
+
+## 主目标
+
+Plugin v2 把 Lime 的插件能力收敛为一条与 Codex 对齐、由 App Server 持有事实、同时适配 Lime App Center 与 Claw 右侧工作区的产品链:
+
+```text
+Marketplace / local package
+ -> App Server plugin domain
+ -> installed plugin projection
+ -> Skills / MCP / Hooks activation
+ -> RuntimeCore turn context
+ -> App Center / Claw composer / Right Surface
+```
+
+本轮不是给旧应用中心换皮,也不是继续扩展 `lime.plugin.package.v1`。目标是直接替换旧包标准、旧前端 registry 合并和旧插件 worker UI 协议,建立唯一继续演进的 Plugin owner。
+
+## 事实源声明
+
+1. 包结构、marketplace、插件 ID、Skills/MCP/Hooks 组成与安装语义对齐 Codex。
+2. Lime 的生产命令链固定为 `Electron Desktop Host -> App Server JSON-RPC -> RuntimeCore -> GUI`。
+3. App Server 是插件目录、安装状态、启用状态、授权状态和运行时装配的唯一事实源。
+4. Renderer 只消费 projection;不得扫描插件目录、解析 manifest、合并多份 registry 或启动第二套业务后端。
+5. Claw 右侧只承载 MCP/App UI、浏览器或声明式结果投影;插件执行仍归 RuntimeCore、MCP 与工具运行时。
+
+## 文档导航
+
+| 文档 | 职责 |
+| -------------------------------------------------------------------------------------- | ------------------------------------------------------ |
+| [00-research-findings.md](./00-research-findings.md) | 官方文档、Codex 源码和本机 Desktop 安装包研究结论 |
+| [01-product-contract.md](./01-product-contract.md) | 产品对象、用户流程、状态模型与范围边界 |
+| [02-package-marketplace-installation.md](./02-package-marketplace-installation.md) | 包结构、manifest、marketplace、安装与存储合同 |
+| [03-architecture-and-command-contracts.md](./03-architecture-and-command-contracts.md) | owner、JSON-RPC、运行时装配与事件投影 |
+| [04-app-center-and-claw-surfaces.md](./04-app-center-and-claw-surfaces.md) | App Center、详情、composer mention 与 Claw 右侧交互 |
+| [05-migration-and-cleanup.md](./05-migration-and-cleanup.md) | current/deprecated/dead 分类、旧实现退役与历史文档治理 |
+| [06-implementation-plan.md](./06-implementation-plan.md) | 分阶段写集、退出条件和主链顺序 |
+| [07-verification-contract.md](./07-verification-contract.md) | 单元、协议、集成、Gate A/B 与跨平台验收 |
+| [08-legacy-synthesis.md](./08-legacy-synthesis.md) | 旧文档价值提炼、冲突决策与历史参考边界 |
+
+## 非目标
+
+- 不复刻 Codex/ChatGPT 的配色、圆角、字体或图标主题。
+- 不把 Plugin、Skill、MCP、Agent App 继续做成相互独立的四套安装系统。
+- 不在 Plugin v2 首阶段建设公共商业化发布平台、结算或审核后台。
+- 不保留旧 manifest 双读双写、旧 worker fallback 或 renderer mock 生产降级。
+- 不让 Electron main process 成为插件业务后端。
+
+## 当前阶段与下一刀
+
+当前阶段是 `V2-5 Claw 与 Right Surface / V2-7 release gate`。
+
+已完成的 current 主链包括:唯一 `.codex-plugin/plugin.json` manifest、App Server typed catalog/install/enable store、Plugin Skill/MCP activation、结构化 `plugin://` mention、MCP tool/elicitation、MCP App Right Surface 与 Renderer reload 恢复。2026-08-05 fresh Gate B 证据位于 `.lime/qc/gui-evidence/plugin-v2-current-electron-fixture/`。
+
+下一刀是把 Gate B 从受控 runtime 安装动作推进到真实 App Center 安装点击、Claw `@` picker 点击和卸载后历史可读,补齐 GB-01/07/13;随后补跨进程 cold restore、Windows 安装到卸载证据和剩余 Apps/Hooks/Browser/file surface。旧实现的物理删除仍须先列出精确目标并取得用户确认,旧版高价值文档继续保留为历史资料。
+
+当前路线图完成度:`75%`。核心 macOS runtime/Right Surface 闭环已通过,但尚未达到跨平台 release gate。
+
+## 完成定义
+
+Plugin v2 只有同时满足以下条件才可标记完成:
+
+- `.codex-plugin/plugin.json` 成为唯一插件 manifest。
+- bundled、repo、personal、configured、workspace/remote marketplace 可由同一 list projection 表达。
+- 安装、启用、授权、可用性和管理员策略是不同字段,不再压成一个布尔值。
+- 插件 Skills、MCP servers/apps 与 Hooks 在新 thread/turn 中可追踪地装配。
+- App Center 和 Claw 使用同一 plugin identity 与 installed projection。
+- MCP/App UI 能进入 Claw Right Surface,且不存在第二套插件业务后端。
+- 旧包标准、旧前端 registry 与旧 plugin worker UI 路径已删除并有回流守卫;旧路线图冻结为历史参考,不再形成并行事实源。
+- macOS 与 Windows 至少各有一条安装、激活、运行和卸载证据。
+- Gate B 证明真实 Electron、preload/IPC、App Server JSON-RPC、runtime/read model 与用户可见状态闭环。
+
+## 参考优先级
+
+1. `/Users/coso/Documents/dev/rust/codex`
+2. OpenAI 官方 Plugins / Skills / MCP 文档
+3. 本机 `/Applications/ChatGPT.app` 的真实安装包结构
+4. Lime current App Server、MCP、Skills、Right Surface owner
+5. `internal/roadmap/plugin/` v1 文件仅作为冻结的历史参考,不再是实现依据
diff --git a/lime-rs/Cargo.lock b/lime-rs/Cargo.lock
index 281d45e2f..4c49212ca 100644
--- a/lime-rs/Cargo.lock
+++ b/lime-rs/Cargo.lock
@@ -21,7 +21,7 @@ dependencies = [
[[package]]
name = "agent-protocol"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"schemars",
"serde",
@@ -30,7 +30,7 @@ dependencies = [
[[package]]
name = "agent-runtime"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"agent-protocol",
"anyhow",
@@ -181,7 +181,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "app-server"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"agent-protocol",
"agent-runtime",
@@ -221,6 +221,7 @@ dependencies = [
"runtime-core",
"rusqlite",
"scopeguard",
+ "semver",
"serde",
"serde_json",
"serde_yaml",
@@ -242,7 +243,7 @@ dependencies = [
[[package]]
name = "app-server-client"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"app-server-protocol",
"app-server-transport",
@@ -253,7 +254,7 @@ dependencies = [
[[package]]
name = "app-server-daemon"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"app-server-protocol",
"app-server-transport",
@@ -265,7 +266,7 @@ dependencies = [
[[package]]
name = "app-server-protocol"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"agent-protocol",
"schemars",
@@ -275,7 +276,7 @@ dependencies = [
[[package]]
name = "app-server-test-client"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"anyhow",
"app-server-client",
@@ -286,7 +287,7 @@ dependencies = [
[[package]]
name = "app-server-transport"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"app-server-protocol",
"axum",
@@ -1356,7 +1357,7 @@ dependencies = [
[[package]]
name = "document-preview"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"flate2",
"tempfile",
@@ -2619,7 +2620,7 @@ dependencies = [
[[package]]
name = "lime-agent"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"agent-protocol",
"agent-runtime",
@@ -2652,7 +2653,7 @@ dependencies = [
[[package]]
name = "lime-browser-runtime"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"chrono",
"futures",
@@ -2669,7 +2670,7 @@ dependencies = [
[[package]]
name = "lime-cli"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"clap",
"lime-core",
@@ -2681,7 +2682,7 @@ dependencies = [
[[package]]
name = "lime-config"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"async-trait",
"lime-core",
@@ -2697,7 +2698,7 @@ dependencies = [
[[package]]
name = "lime-core"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"agent-protocol",
"async-trait",
@@ -2753,7 +2754,7 @@ dependencies = [
[[package]]
name = "lime-gateway"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"aes",
"async-trait",
@@ -2781,7 +2782,7 @@ dependencies = [
[[package]]
name = "lime-infra"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"chrono",
"dashmap",
@@ -2803,7 +2804,7 @@ dependencies = [
[[package]]
name = "lime-knowledge"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"chrono",
"hex",
@@ -2816,7 +2817,7 @@ dependencies = [
[[package]]
name = "lime-mcp"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"async-trait",
"axum",
@@ -2842,7 +2843,7 @@ dependencies = [
[[package]]
name = "lime-media-runtime"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"axum",
"base64 0.22.1",
@@ -2861,7 +2862,7 @@ dependencies = [
[[package]]
name = "lime-processor"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"async-trait",
"lime-core",
@@ -2901,7 +2902,7 @@ dependencies = [
[[package]]
name = "lime-server"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"agent-protocol",
"async-stream",
@@ -2944,7 +2945,7 @@ dependencies = [
[[package]]
name = "lime-server-utils"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"axum",
"futures",
@@ -2959,7 +2960,7 @@ dependencies = [
[[package]]
name = "lime-services"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"anyhow",
"async-trait",
@@ -3002,7 +3003,7 @@ dependencies = [
[[package]]
name = "lime-skills"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -3023,7 +3024,7 @@ dependencies = [
[[package]]
name = "lime-websocket"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"axum",
"chrono",
@@ -3232,7 +3233,7 @@ dependencies = [
[[package]]
name = "model-provider"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"agent-protocol",
"anyhow",
@@ -3902,7 +3903,7 @@ checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec"
[[package]]
name = "patch-apply"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"tempfile",
"thiserror 1.0.69",
@@ -4661,7 +4662,7 @@ dependencies = [
[[package]]
name = "runtime-core"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"agent-protocol",
"app-server-protocol",
@@ -5409,7 +5410,7 @@ dependencies = [
[[package]]
name = "thread-store"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"agent-protocol",
"chrono",
@@ -5687,7 +5688,7 @@ dependencies = [
[[package]]
name = "tool-runtime"
-version = "1.120.1"
+version = "1.121.0"
dependencies = [
"agent-protocol",
"app-server-protocol",
diff --git a/lime-rs/Cargo.toml b/lime-rs/Cargo.toml
index ba0ddbd1d..6760f20b8 100644
--- a/lime-rs/Cargo.toml
+++ b/lime-rs/Cargo.toml
@@ -6,7 +6,7 @@ exclude = [
resolver = "2"
[workspace.package]
-version = "1.120.1"
+version = "1.121.0"
edition = "2021"
authors = ["coso"]
repository = "https://github.com/aiclientproxy/lime"
diff --git a/lime-rs/crates/agent-protocol/src/thread.rs b/lime-rs/crates/agent-protocol/src/thread.rs
index 6f828458a..47887d869 100644
--- a/lime-rs/crates/agent-protocol/src/thread.rs
+++ b/lime-rs/crates/agent-protocol/src/thread.rs
@@ -359,6 +359,10 @@ pub enum ThreadItemPayload {
call_id: String,
server_name: String,
tool_name: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ mcp_app_resource_uri: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ plugin_id: Option,
#[serde(default)]
arguments: Vec,
#[serde(default, skip_serializing_if = "Option::is_none")]
diff --git a/lime-rs/crates/agent/src/current_provider_turn/tool_executor.rs b/lime-rs/crates/agent/src/current_provider_turn/tool_executor.rs
index 73125ed1c..34cea7544 100644
--- a/lime-rs/crates/agent/src/current_provider_turn/tool_executor.rs
+++ b/lime-rs/crates/agent/src/current_provider_turn/tool_executor.rs
@@ -1019,6 +1019,8 @@ mod tests {
server_name: "docs".to_string(),
tool_name: "search".to_string(),
runtime_tool_name: "mcp__docs__search".to_string(),
+ mcp_app_resource_uri: None,
+ plugin_id: None,
};
let result = await_mcp_call(&event_sender, "mcp-call-1", &route, call)
diff --git a/lime-rs/crates/agent/src/current_provider_turn/tool_lifecycle_emitter.rs b/lime-rs/crates/agent/src/current_provider_turn/tool_lifecycle_emitter.rs
index 8beae51dc..66e787416 100644
--- a/lime-rs/crates/agent/src/current_provider_turn/tool_lifecycle_emitter.rs
+++ b/lime-rs/crates/agent/src/current_provider_turn/tool_lifecycle_emitter.rs
@@ -241,6 +241,8 @@ fn project_mcp_tool_item(
call_id,
server_name: route.server_name.clone(),
tool_name: route.tool_name.clone(),
+ mcp_app_resource_uri: route.mcp_app_resource_uri.clone(),
+ plugin_id: route.plugin_id.clone(),
arguments,
output,
},
@@ -277,6 +279,8 @@ mod tests {
server_name: "docs".to_string(),
tool_name: "search".to_string(),
runtime_tool_name: "docs__search".to_string(),
+ mcp_app_resource_uri: Some("ui://plugin/docs.html".to_string()),
+ plugin_id: Some("docs-plugin".to_string()),
}]);
let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
let emitter = CurrentTurnToolLifecycleEmitter::with_tool_routes(
@@ -309,8 +313,13 @@ mod tests {
ThreadItemPayload::McpToolCall {
ref server_name,
ref tool_name,
+ ref mcp_app_resource_uri,
+ ref plugin_id,
..
- } if server_name == "docs" && tool_name == "search"
+ } if server_name == "docs"
+ && tool_name == "search"
+ && mcp_app_resource_uri.as_deref() == Some("ui://plugin/docs.html")
+ && plugin_id.as_deref() == Some("docs-plugin")
));
}
diff --git a/lime-rs/crates/agent/src/mcp_bridge.rs b/lime-rs/crates/agent/src/mcp_bridge.rs
index e4be649e6..69f4cd9ba 100644
--- a/lime-rs/crates/agent/src/mcp_bridge.rs
+++ b/lime-rs/crates/agent/src/mcp_bridge.rs
@@ -82,7 +82,9 @@ impl McpBridgeRuntimeRegistry {
McpConnectionProvenance::new(
snapshot.environment_id.clone(),
snapshot.auth_scopes.clone(),
- ),
+ )
+ .with_server_name(Some(snapshot.server_name.clone()))
+ .with_plugin_id(snapshot.plugin_id.clone()),
snapshot.supports_parallel_tool_calls,
client,
)
diff --git a/lime-rs/crates/agent/src/runtime_state.rs b/lime-rs/crates/agent/src/runtime_state.rs
index e1639b0d5..ef280b403 100644
--- a/lime-rs/crates/agent/src/runtime_state.rs
+++ b/lime-rs/crates/agent/src/runtime_state.rs
@@ -504,6 +504,19 @@ impl AgentRuntimeState {
runtime.shutdown().await;
}
+ pub async fn read_mcp_resource(
+ &self,
+ session_id: &str,
+ thread_id: &str,
+ server_name: &str,
+ uri: &str,
+ ) -> Result {
+ self.mcp_runtime(session_id, thread_id)
+ .await?
+ .read_resource(server_name, uri)
+ .await
+ }
+
#[cfg(test)]
pub(crate) async fn mcp_runtime_count(&self) -> usize {
self.mcp_runtimes.read().await.len()
diff --git a/lime-rs/crates/agent/src/runtime_state/mcp_runtime.rs b/lime-rs/crates/agent/src/runtime_state/mcp_runtime.rs
index b70dec9b1..9ee352bce 100644
--- a/lime-rs/crates/agent/src/runtime_state/mcp_runtime.rs
+++ b/lime-rs/crates/agent/src/runtime_state/mcp_runtime.rs
@@ -59,6 +59,17 @@ impl McpThreadRuntime {
self.server_specs.clone()
}
+ pub(crate) async fn read_resource(
+ &self,
+ server_name: &str,
+ uri: &str,
+ ) -> Result {
+ self.manager
+ .read_resource(server_name, uri)
+ .await
+ .map_err(|error| error.to_string())
+ }
+
pub(crate) async fn start(&self) -> Result<(), String> {
let mut unavailable_servers = Vec::new();
let mut required_failures = Vec::new();
@@ -95,11 +106,18 @@ impl McpThreadRuntime {
required_failures.join("; ")
));
}
- let snapshots = self
+ let mut snapshots = self
.manager
.bridge_snapshots()
.await
.map_err(|error| error.to_string())?;
+ for snapshot in &mut snapshots {
+ snapshot.plugin_id = self
+ .server_specs
+ .iter()
+ .find(|spec| spec.name == snapshot.server_name)
+ .and_then(|spec| spec.plugin_id.clone());
+ }
let bridge_count = self
.bridge_registry
.sync(&self.connections, snapshots)
diff --git a/lime-rs/crates/agent/src/runtime_state/mcp_runtime_tests.rs b/lime-rs/crates/agent/src/runtime_state/mcp_runtime_tests.rs
index 71f0cad41..2a31a3604 100644
--- a/lime-rs/crates/agent/src/runtime_state/mcp_runtime_tests.rs
+++ b/lime-rs/crates/agent/src/runtime_state/mcp_runtime_tests.rs
@@ -17,6 +17,7 @@ fn stdio_server_spec(
) -> McpRuntimeServerSpec {
McpRuntimeServerSpec {
name: name.to_string(),
+ plugin_id: None,
config: McpServerConfig {
transport: McpServerTransport::Stdio {
command,
@@ -232,6 +233,111 @@ lines.on("line", (line) => {
state.clear_mcp_runtimes().await;
}
+#[tokio::test]
+async fn session_owned_mcp_runtime_reads_app_resource_with_meta() {
+ let Some(node) = node_binary() else {
+ return;
+ };
+ let temp_dir = tempfile::tempdir().expect("create runtime MCP app fixture directory");
+ let server_path = temp_dir.path().join("mcp-app-resource-server.mjs");
+ std::fs::write(
+ &server_path,
+ r#"
+import readline from "node:readline";
+
+const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
+const send = (message) => process.stdout.write(JSON.stringify(message) + "\n");
+
+lines.on("line", (line) => {
+ if (!line.trim()) return;
+ const message = JSON.parse(line);
+ if (message.method === "initialize") {
+ send({
+ jsonrpc: "2.0",
+ id: message.id,
+ result: {
+ protocolVersion: "2025-03-26",
+ capabilities: { resources: {}, tools: {} },
+ serverInfo: { name: "mcp-app-resource-fixture", version: "1.0.0" },
+ },
+ });
+ return;
+ }
+ if (message.method === "notifications/initialized") return;
+ if (message.method === "tools/list") {
+ send({ jsonrpc: "2.0", id: message.id, result: { tools: [] } });
+ return;
+ }
+ if (message.method === "resources/read") {
+ send({
+ jsonrpc: "2.0",
+ id: message.id,
+ result: {
+ contents: [{
+ uri: message.params?.uri,
+ mimeType: "text/html;profile=mcp-app",
+ text: "MCP App",
+ _meta: {
+ ui: { csp: { connectDomains: ["https://api.example.com"] } },
+ },
+ }],
+ },
+ });
+ }
+});
+"#,
+ )
+ .expect("write runtime MCP app fixture");
+
+ let state = AgentRuntimeState::new();
+ state
+ .ensure_mcp_runtime_generation(
+ "session-app".to_string(),
+ "thread-app".to_string(),
+ ElicitationRequestRouter::default(),
+ vec![stdio_server_spec(
+ "plugin__demo__server",
+ node,
+ vec![server_path.to_string_lossy().into_owned()],
+ true,
+ )],
+ )
+ .await
+ .expect("publish MCP App runtime generation");
+
+ let content = state
+ .read_mcp_resource(
+ "session-app",
+ "thread-app",
+ "plugin__demo__server",
+ "ui://demo/report.html",
+ )
+ .await
+ .expect("read MCP App resource from session runtime");
+ assert_eq!(content.uri, "ui://demo/report.html");
+ assert_eq!(
+ content.mime_type.as_deref(),
+ Some("text/html;profile=mcp-app")
+ );
+ assert_eq!(
+ content
+ .meta
+ .as_ref()
+ .and_then(|meta| meta.pointer("/ui/csp/connectDomains/0")),
+ Some(&serde_json::json!("https://api.example.com"))
+ );
+ assert!(state
+ .read_mcp_resource(
+ "session-app",
+ "thread-other",
+ "plugin__demo__server",
+ "ui://demo/report.html",
+ )
+ .await
+ .is_err());
+ state.clear_mcp_runtimes().await;
+}
+
#[tokio::test]
async fn required_runtime_server_failure_keeps_the_previous_generation_published() {
let state = AgentRuntimeState::new();
diff --git a/lime-rs/crates/app-server-client/src/lib.rs b/lime-rs/crates/app-server-client/src/lib.rs
index 6a52bc0cb..a5f42f2a2 100644
--- a/lime-rs/crates/app-server-client/src/lib.rs
+++ b/lime-rs/crates/app-server-client/src/lib.rs
@@ -3128,6 +3128,8 @@ mod tests {
.read_mcp_resource(McpResourceReadParams {
server: "filesystem".to_string(),
uri: "file:///workspace/README.md".to_string(),
+ session_id: None,
+ thread_id: None,
})
.expect("resource");
let resource_subscription = client
@@ -3256,6 +3258,8 @@ mod tests {
let read = client.read_mcp_resource(McpResourceReadParams {
server: " ".to_string(),
uri: "docs://readme".to_string(),
+ session_id: None,
+ thread_id: None,
});
let subscribe = client.subscribe_mcp_resource(McpResourceSubscribeParams {
server: "docs".to_string(),
diff --git a/lime-rs/crates/app-server-protocol/schema/json/app_server_protocol.schemas.json b/lime-rs/crates/app-server-protocol/schema/json/app_server_protocol.schemas.json
index 54e4e43c5..1a6e39b2a 100644
--- a/lime-rs/crates/app-server-protocol/schema/json/app_server_protocol.schemas.json
+++ b/lime-rs/crates/app-server-protocol/schema/json/app_server_protocol.schemas.json
@@ -11544,6 +11544,99 @@
}
]
},
+ "PluginCatalogEnabledSetParams": {
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "pluginId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "enabled",
+ "pluginId"
+ ],
+ "type": "object"
+ },
+ "PluginCatalogInstallParams": {
+ "properties": {
+ "expectedDigest": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "marketplaceId": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "source": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "sourcePath": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "sourcePath"
+ ],
+ "type": "object"
+ },
+ "PluginCatalogInstalledParams": {
+ "type": "object"
+ },
+ "PluginCatalogListParams": {
+ "properties": {
+ "marketplacePaths": {
+ "default": [],
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "query": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "source": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "type": "object"
+ },
+ "PluginCatalogReadParams": {
+ "properties": {
+ "pluginId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "pluginId"
+ ],
+ "type": "object"
+ },
+ "PluginCatalogUninstallParams": {
+ "properties": {
+ "pluginId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "pluginId"
+ ],
+ "type": "object"
+ },
"RequestId": {
"anyOf": [
{
@@ -13712,6 +13805,126 @@
],
"type": "object"
},
+ {
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/RequestId"
+ },
+ "method": {
+ "const": "plugin/list",
+ "type": "string"
+ },
+ "params": {
+ "$ref": "#/$defs/PluginCatalogListParams"
+ }
+ },
+ "required": [
+ "id",
+ "method",
+ "params"
+ ],
+ "type": "object"
+ },
+ {
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/RequestId"
+ },
+ "method": {
+ "const": "plugin/read",
+ "type": "string"
+ },
+ "params": {
+ "$ref": "#/$defs/PluginCatalogReadParams"
+ }
+ },
+ "required": [
+ "id",
+ "method",
+ "params"
+ ],
+ "type": "object"
+ },
+ {
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/RequestId"
+ },
+ "method": {
+ "const": "plugin/install",
+ "type": "string"
+ },
+ "params": {
+ "$ref": "#/$defs/PluginCatalogInstallParams"
+ }
+ },
+ "required": [
+ "id",
+ "method",
+ "params"
+ ],
+ "type": "object"
+ },
+ {
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/RequestId"
+ },
+ "method": {
+ "const": "plugin/uninstall",
+ "type": "string"
+ },
+ "params": {
+ "$ref": "#/$defs/PluginCatalogUninstallParams"
+ }
+ },
+ "required": [
+ "id",
+ "method",
+ "params"
+ ],
+ "type": "object"
+ },
+ {
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/RequestId"
+ },
+ "method": {
+ "const": "plugin/installed",
+ "type": "string"
+ },
+ "params": {
+ "$ref": "#/$defs/PluginCatalogInstalledParams"
+ }
+ },
+ "required": [
+ "id",
+ "method",
+ "params"
+ ],
+ "type": "object"
+ },
+ {
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/RequestId"
+ },
+ "method": {
+ "const": "plugin/enabled/set",
+ "type": "string"
+ },
+ "params": {
+ "$ref": "#/$defs/PluginCatalogEnabledSetParams"
+ }
+ },
+ "required": [
+ "id",
+ "method",
+ "params"
+ ],
+ "type": "object"
+ },
{
"properties": {
"id": {
@@ -29308,6 +29521,20 @@
"minLength": 1,
"type": "string"
},
+ "sessionId": {
+ "minLength": 1,
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "threadId": {
+ "minLength": 1,
+ "type": [
+ "null",
+ "string"
+ ]
+ },
"uri": {
"minLength": 1,
"type": "string"
@@ -29329,6 +29556,7 @@
"string"
]
},
+ "meta": true,
"mime_type": {
"type": [
"null",
@@ -32658,6 +32886,12 @@
"artifact/write",
"media/read",
"model/list",
+ "plugin/enabled/set",
+ "plugin/install",
+ "plugin/installed",
+ "plugin/list",
+ "plugin/read",
+ "plugin/uninstall",
"thread/approveGuardianDeniedAction",
"thread/archive",
"thread/backgroundTerminals/clean",
@@ -38139,6 +38373,1040 @@
"title": "PluginArticleWorkspaceObject",
"type": "object"
},
+ "PluginCatalogCapability": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "description": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "requiresAuth": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "description",
+ "id",
+ "name",
+ "requiresAuth"
+ ],
+ "title": "PluginCatalogCapability",
+ "type": "object"
+ },
+ "PluginCatalogDetail": {
+ "$defs": {
+ "PluginCatalogCapability": {
+ "properties": {
+ "description": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "requiresAuth": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "description",
+ "id",
+ "name",
+ "requiresAuth"
+ ],
+ "type": "object"
+ },
+ "PluginCatalogHook": {
+ "properties": {
+ "event": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "event",
+ "id"
+ ],
+ "type": "object"
+ },
+ "PluginCatalogSummary": {
+ "properties": {
+ "appsCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "authPolicy": {
+ "type": "string"
+ },
+ "availability": {
+ "type": "string"
+ },
+ "contentDigest": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "disabledReason": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "hooksCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "id": {
+ "type": "string"
+ },
+ "installPolicy": {
+ "type": "string"
+ },
+ "installed": {
+ "type": "boolean"
+ },
+ "localVersion": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "marketplaceId": {
+ "type": "string"
+ },
+ "mcpServersCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "skillsCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "source": {
+ "type": "string"
+ },
+ "sourceUri": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "appsCount",
+ "authPolicy",
+ "availability",
+ "contentDigest",
+ "description",
+ "enabled",
+ "hooksCount",
+ "id",
+ "installPolicy",
+ "installed",
+ "marketplaceId",
+ "mcpServersCount",
+ "name",
+ "skillsCount",
+ "source",
+ "sourceUri",
+ "version"
+ ],
+ "type": "object"
+ },
+ "PluginCatalogUiResource": {
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "type": "string"
+ },
+ "resourceUri": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "kind",
+ "resourceUri"
+ ],
+ "type": "object"
+ }
+ },
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "apps": {
+ "items": {
+ "$ref": "#/$defs/PluginCatalogCapability"
+ },
+ "type": "array"
+ },
+ "hooks": {
+ "items": {
+ "$ref": "#/$defs/PluginCatalogHook"
+ },
+ "type": "array"
+ },
+ "mcpServers": {
+ "items": {
+ "$ref": "#/$defs/PluginCatalogCapability"
+ },
+ "type": "array"
+ },
+ "skills": {
+ "items": {
+ "$ref": "#/$defs/PluginCatalogCapability"
+ },
+ "type": "array"
+ },
+ "summary": {
+ "$ref": "#/$defs/PluginCatalogSummary"
+ },
+ "uiResources": {
+ "items": {
+ "$ref": "#/$defs/PluginCatalogUiResource"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "apps",
+ "hooks",
+ "mcpServers",
+ "skills",
+ "summary",
+ "uiResources"
+ ],
+ "title": "PluginCatalogDetail",
+ "type": "object"
+ },
+ "PluginCatalogEnabledSetParams": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "pluginId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "enabled",
+ "pluginId"
+ ],
+ "title": "PluginCatalogEnabledSetParams",
+ "type": "object"
+ },
+ "PluginCatalogEnabledSetResponse": {
+ "$defs": {
+ "PluginCatalogSummary": {
+ "properties": {
+ "appsCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "authPolicy": {
+ "type": "string"
+ },
+ "availability": {
+ "type": "string"
+ },
+ "contentDigest": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "disabledReason": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "hooksCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "id": {
+ "type": "string"
+ },
+ "installPolicy": {
+ "type": "string"
+ },
+ "installed": {
+ "type": "boolean"
+ },
+ "localVersion": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "marketplaceId": {
+ "type": "string"
+ },
+ "mcpServersCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "skillsCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "source": {
+ "type": "string"
+ },
+ "sourceUri": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "appsCount",
+ "authPolicy",
+ "availability",
+ "contentDigest",
+ "description",
+ "enabled",
+ "hooksCount",
+ "id",
+ "installPolicy",
+ "installed",
+ "marketplaceId",
+ "mcpServersCount",
+ "name",
+ "skillsCount",
+ "source",
+ "sourceUri",
+ "version"
+ ],
+ "type": "object"
+ }
+ },
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "plugin": {
+ "$ref": "#/$defs/PluginCatalogSummary"
+ }
+ },
+ "required": [
+ "plugin"
+ ],
+ "title": "PluginCatalogEnabledSetResponse",
+ "type": "object"
+ },
+ "PluginCatalogHook": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "event": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "event",
+ "id"
+ ],
+ "title": "PluginCatalogHook",
+ "type": "object"
+ },
+ "PluginCatalogInstallParams": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "expectedDigest": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "marketplaceId": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "source": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "sourcePath": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "sourcePath"
+ ],
+ "title": "PluginCatalogInstallParams",
+ "type": "object"
+ },
+ "PluginCatalogInstallResponse": {
+ "$defs": {
+ "PluginCatalogSummary": {
+ "properties": {
+ "appsCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "authPolicy": {
+ "type": "string"
+ },
+ "availability": {
+ "type": "string"
+ },
+ "contentDigest": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "disabledReason": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "hooksCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "id": {
+ "type": "string"
+ },
+ "installPolicy": {
+ "type": "string"
+ },
+ "installed": {
+ "type": "boolean"
+ },
+ "localVersion": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "marketplaceId": {
+ "type": "string"
+ },
+ "mcpServersCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "skillsCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "source": {
+ "type": "string"
+ },
+ "sourceUri": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "appsCount",
+ "authPolicy",
+ "availability",
+ "contentDigest",
+ "description",
+ "enabled",
+ "hooksCount",
+ "id",
+ "installPolicy",
+ "installed",
+ "marketplaceId",
+ "mcpServersCount",
+ "name",
+ "skillsCount",
+ "source",
+ "sourceUri",
+ "version"
+ ],
+ "type": "object"
+ }
+ },
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "plugin": {
+ "$ref": "#/$defs/PluginCatalogSummary"
+ }
+ },
+ "required": [
+ "plugin"
+ ],
+ "title": "PluginCatalogInstallResponse",
+ "type": "object"
+ },
+ "PluginCatalogInstalledParams": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "PluginCatalogInstalledParams",
+ "type": "object"
+ },
+ "PluginCatalogListParams": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "marketplacePaths": {
+ "default": [],
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "query": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "source": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "title": "PluginCatalogListParams",
+ "type": "object"
+ },
+ "PluginCatalogListResponse": {
+ "$defs": {
+ "PluginCatalogSummary": {
+ "properties": {
+ "appsCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "authPolicy": {
+ "type": "string"
+ },
+ "availability": {
+ "type": "string"
+ },
+ "contentDigest": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "disabledReason": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "hooksCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "id": {
+ "type": "string"
+ },
+ "installPolicy": {
+ "type": "string"
+ },
+ "installed": {
+ "type": "boolean"
+ },
+ "localVersion": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "marketplaceId": {
+ "type": "string"
+ },
+ "mcpServersCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "skillsCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "source": {
+ "type": "string"
+ },
+ "sourceUri": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "appsCount",
+ "authPolicy",
+ "availability",
+ "contentDigest",
+ "description",
+ "enabled",
+ "hooksCount",
+ "id",
+ "installPolicy",
+ "installed",
+ "marketplaceId",
+ "mcpServersCount",
+ "name",
+ "skillsCount",
+ "source",
+ "sourceUri",
+ "version"
+ ],
+ "type": "object"
+ }
+ },
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "generatedAt": {
+ "type": "string"
+ },
+ "plugins": {
+ "items": {
+ "$ref": "#/$defs/PluginCatalogSummary"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "generatedAt",
+ "plugins"
+ ],
+ "title": "PluginCatalogListResponse",
+ "type": "object"
+ },
+ "PluginCatalogReadParams": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "pluginId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "pluginId"
+ ],
+ "title": "PluginCatalogReadParams",
+ "type": "object"
+ },
+ "PluginCatalogReadResponse": {
+ "$defs": {
+ "PluginCatalogCapability": {
+ "properties": {
+ "description": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "requiresAuth": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "description",
+ "id",
+ "name",
+ "requiresAuth"
+ ],
+ "type": "object"
+ },
+ "PluginCatalogDetail": {
+ "properties": {
+ "apps": {
+ "items": {
+ "$ref": "#/$defs/PluginCatalogCapability"
+ },
+ "type": "array"
+ },
+ "hooks": {
+ "items": {
+ "$ref": "#/$defs/PluginCatalogHook"
+ },
+ "type": "array"
+ },
+ "mcpServers": {
+ "items": {
+ "$ref": "#/$defs/PluginCatalogCapability"
+ },
+ "type": "array"
+ },
+ "skills": {
+ "items": {
+ "$ref": "#/$defs/PluginCatalogCapability"
+ },
+ "type": "array"
+ },
+ "summary": {
+ "$ref": "#/$defs/PluginCatalogSummary"
+ },
+ "uiResources": {
+ "items": {
+ "$ref": "#/$defs/PluginCatalogUiResource"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "apps",
+ "hooks",
+ "mcpServers",
+ "skills",
+ "summary",
+ "uiResources"
+ ],
+ "type": "object"
+ },
+ "PluginCatalogHook": {
+ "properties": {
+ "event": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "event",
+ "id"
+ ],
+ "type": "object"
+ },
+ "PluginCatalogSummary": {
+ "properties": {
+ "appsCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "authPolicy": {
+ "type": "string"
+ },
+ "availability": {
+ "type": "string"
+ },
+ "contentDigest": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "disabledReason": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "hooksCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "id": {
+ "type": "string"
+ },
+ "installPolicy": {
+ "type": "string"
+ },
+ "installed": {
+ "type": "boolean"
+ },
+ "localVersion": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "marketplaceId": {
+ "type": "string"
+ },
+ "mcpServersCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "skillsCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "source": {
+ "type": "string"
+ },
+ "sourceUri": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "appsCount",
+ "authPolicy",
+ "availability",
+ "contentDigest",
+ "description",
+ "enabled",
+ "hooksCount",
+ "id",
+ "installPolicy",
+ "installed",
+ "marketplaceId",
+ "mcpServersCount",
+ "name",
+ "skillsCount",
+ "source",
+ "sourceUri",
+ "version"
+ ],
+ "type": "object"
+ },
+ "PluginCatalogUiResource": {
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "type": "string"
+ },
+ "resourceUri": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "kind",
+ "resourceUri"
+ ],
+ "type": "object"
+ }
+ },
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "plugin": {
+ "$ref": "#/$defs/PluginCatalogDetail"
+ }
+ },
+ "required": [
+ "plugin"
+ ],
+ "title": "PluginCatalogReadResponse",
+ "type": "object"
+ },
+ "PluginCatalogSummary": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "appsCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "authPolicy": {
+ "type": "string"
+ },
+ "availability": {
+ "type": "string"
+ },
+ "contentDigest": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "disabledReason": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "hooksCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "id": {
+ "type": "string"
+ },
+ "installPolicy": {
+ "type": "string"
+ },
+ "installed": {
+ "type": "boolean"
+ },
+ "localVersion": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "marketplaceId": {
+ "type": "string"
+ },
+ "mcpServersCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "skillsCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "source": {
+ "type": "string"
+ },
+ "sourceUri": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "appsCount",
+ "authPolicy",
+ "availability",
+ "contentDigest",
+ "description",
+ "enabled",
+ "hooksCount",
+ "id",
+ "installPolicy",
+ "installed",
+ "marketplaceId",
+ "mcpServersCount",
+ "name",
+ "skillsCount",
+ "source",
+ "sourceUri",
+ "version"
+ ],
+ "title": "PluginCatalogSummary",
+ "type": "object"
+ },
+ "PluginCatalogUiResource": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "type": "string"
+ },
+ "resourceUri": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "kind",
+ "resourceUri"
+ ],
+ "title": "PluginCatalogUiResource",
+ "type": "object"
+ },
+ "PluginCatalogUninstallParams": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "pluginId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "pluginId"
+ ],
+ "title": "PluginCatalogUninstallParams",
+ "type": "object"
+ },
+ "PluginCatalogUninstallResponse": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "pluginId": {
+ "type": "string"
+ },
+ "uninstalled": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "pluginId",
+ "uninstalled"
+ ],
+ "title": "PluginCatalogUninstallResponse",
+ "type": "object"
+ },
"PluginCloudReleaseDescriptor": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
@@ -100464,6 +101732,12 @@
"modelProviderUiState/read",
"modelProviderUiState/write",
"modelSyncState/read",
+ "plugin/enabled/set",
+ "plugin/install",
+ "plugin/installed",
+ "plugin/list",
+ "plugin/read",
+ "plugin/uninstall",
"pluginHostLifecycle/list",
"pluginInstalled/disabled/set",
"pluginInstalled/list",
diff --git a/lime-rs/crates/app-server-protocol/schema/json/manifest.json b/lime-rs/crates/app-server-protocol/schema/json/manifest.json
index 800e796cf..0ced86eb6 100644
--- a/lime-rs/crates/app-server-protocol/schema/json/manifest.json
+++ b/lime-rs/crates/app-server-protocol/schema/json/manifest.json
@@ -750,6 +750,30 @@
"kind": "request",
"method": "modelSyncState/read"
},
+ {
+ "kind": "request",
+ "method": "plugin/enabled/set"
+ },
+ {
+ "kind": "request",
+ "method": "plugin/install"
+ },
+ {
+ "kind": "request",
+ "method": "plugin/installed"
+ },
+ {
+ "kind": "request",
+ "method": "plugin/list"
+ },
+ {
+ "kind": "request",
+ "method": "plugin/read"
+ },
+ {
+ "kind": "request",
+ "method": "plugin/uninstall"
+ },
{
"kind": "request",
"method": "pluginHostLifecycle/list"
@@ -2285,6 +2309,22 @@
"PermissionsRequestApprovalParams",
"PermissionsRequestApprovalResponse",
"PlanDeltaNotification",
+ "PluginCatalogCapability",
+ "PluginCatalogDetail",
+ "PluginCatalogEnabledSetParams",
+ "PluginCatalogEnabledSetResponse",
+ "PluginCatalogHook",
+ "PluginCatalogInstallParams",
+ "PluginCatalogInstallResponse",
+ "PluginCatalogInstalledParams",
+ "PluginCatalogListParams",
+ "PluginCatalogListResponse",
+ "PluginCatalogReadParams",
+ "PluginCatalogReadResponse",
+ "PluginCatalogSummary",
+ "PluginCatalogUiResource",
+ "PluginCatalogUninstallParams",
+ "PluginCatalogUninstallResponse",
"ReasoningEffortOption",
"ReasoningSummaryPartAddedNotification",
"ReasoningSummaryTextDeltaNotification",
diff --git a/lime-rs/crates/app-server-protocol/schema/json/v0/McpResourceReadParams.json b/lime-rs/crates/app-server-protocol/schema/json/v0/McpResourceReadParams.json
index 6bcb90b59..d100efa1c 100644
--- a/lime-rs/crates/app-server-protocol/schema/json/v0/McpResourceReadParams.json
+++ b/lime-rs/crates/app-server-protocol/schema/json/v0/McpResourceReadParams.json
@@ -5,6 +5,20 @@
"minLength": 1,
"type": "string"
},
+ "sessionId": {
+ "minLength": 1,
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "threadId": {
+ "minLength": 1,
+ "type": [
+ "null",
+ "string"
+ ]
+ },
"uri": {
"minLength": 1,
"type": "string"
diff --git a/lime-rs/crates/app-server-protocol/schema/json/v0/McpResourceReadResponse.json b/lime-rs/crates/app-server-protocol/schema/json/v0/McpResourceReadResponse.json
index 78e43a5e1..1b30a0a44 100644
--- a/lime-rs/crates/app-server-protocol/schema/json/v0/McpResourceReadResponse.json
+++ b/lime-rs/crates/app-server-protocol/schema/json/v0/McpResourceReadResponse.json
@@ -7,6 +7,7 @@
"string"
]
},
+ "meta": true,
"mime_type": {
"type": [
"null",
diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/ClientRequest.json b/lime-rs/crates/app-server-protocol/schema/json/v2/ClientRequest.json
index 7cddb6e5f..6409f7574 100644
--- a/lime-rs/crates/app-server-protocol/schema/json/v2/ClientRequest.json
+++ b/lime-rs/crates/app-server-protocol/schema/json/v2/ClientRequest.json
@@ -351,6 +351,99 @@
}
]
},
+ "PluginCatalogEnabledSetParams": {
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "pluginId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "enabled",
+ "pluginId"
+ ],
+ "type": "object"
+ },
+ "PluginCatalogInstallParams": {
+ "properties": {
+ "expectedDigest": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "marketplaceId": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "source": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "sourcePath": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "sourcePath"
+ ],
+ "type": "object"
+ },
+ "PluginCatalogInstalledParams": {
+ "type": "object"
+ },
+ "PluginCatalogListParams": {
+ "properties": {
+ "marketplacePaths": {
+ "default": [],
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "query": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "source": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "type": "object"
+ },
+ "PluginCatalogReadParams": {
+ "properties": {
+ "pluginId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "pluginId"
+ ],
+ "type": "object"
+ },
+ "PluginCatalogUninstallParams": {
+ "properties": {
+ "pluginId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "pluginId"
+ ],
+ "type": "object"
+ },
"RequestId": {
"anyOf": [
{
@@ -2519,6 +2612,126 @@
],
"type": "object"
},
+ {
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/RequestId"
+ },
+ "method": {
+ "const": "plugin/list",
+ "type": "string"
+ },
+ "params": {
+ "$ref": "#/$defs/PluginCatalogListParams"
+ }
+ },
+ "required": [
+ "id",
+ "method",
+ "params"
+ ],
+ "type": "object"
+ },
+ {
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/RequestId"
+ },
+ "method": {
+ "const": "plugin/read",
+ "type": "string"
+ },
+ "params": {
+ "$ref": "#/$defs/PluginCatalogReadParams"
+ }
+ },
+ "required": [
+ "id",
+ "method",
+ "params"
+ ],
+ "type": "object"
+ },
+ {
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/RequestId"
+ },
+ "method": {
+ "const": "plugin/install",
+ "type": "string"
+ },
+ "params": {
+ "$ref": "#/$defs/PluginCatalogInstallParams"
+ }
+ },
+ "required": [
+ "id",
+ "method",
+ "params"
+ ],
+ "type": "object"
+ },
+ {
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/RequestId"
+ },
+ "method": {
+ "const": "plugin/uninstall",
+ "type": "string"
+ },
+ "params": {
+ "$ref": "#/$defs/PluginCatalogUninstallParams"
+ }
+ },
+ "required": [
+ "id",
+ "method",
+ "params"
+ ],
+ "type": "object"
+ },
+ {
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/RequestId"
+ },
+ "method": {
+ "const": "plugin/installed",
+ "type": "string"
+ },
+ "params": {
+ "$ref": "#/$defs/PluginCatalogInstalledParams"
+ }
+ },
+ "required": [
+ "id",
+ "method",
+ "params"
+ ],
+ "type": "object"
+ },
+ {
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/RequestId"
+ },
+ "method": {
+ "const": "plugin/enabled/set",
+ "type": "string"
+ },
+ "params": {
+ "$ref": "#/$defs/PluginCatalogEnabledSetParams"
+ }
+ },
+ "required": [
+ "id",
+ "method",
+ "params"
+ ],
+ "type": "object"
+ },
{
"properties": {
"id": {
diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/Method.json b/lime-rs/crates/app-server-protocol/schema/json/v2/Method.json
index 03bd74d49..1c0f6c99d 100644
--- a/lime-rs/crates/app-server-protocol/schema/json/v2/Method.json
+++ b/lime-rs/crates/app-server-protocol/schema/json/v2/Method.json
@@ -4,6 +4,12 @@
"artifact/write",
"media/read",
"model/list",
+ "plugin/enabled/set",
+ "plugin/install",
+ "plugin/installed",
+ "plugin/list",
+ "plugin/read",
+ "plugin/uninstall",
"thread/approveGuardianDeniedAction",
"thread/archive",
"thread/backgroundTerminals/clean",
diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogCapability.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogCapability.json
new file mode 100644
index 000000000..0f7d328d3
--- /dev/null
+++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogCapability.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "description": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "requiresAuth": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "description",
+ "id",
+ "name",
+ "requiresAuth"
+ ],
+ "title": "PluginCatalogCapability",
+ "type": "object"
+}
diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogDetail.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogDetail.json
new file mode 100644
index 000000000..e93747712
--- /dev/null
+++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogDetail.json
@@ -0,0 +1,202 @@
+{
+ "$defs": {
+ "PluginCatalogCapability": {
+ "properties": {
+ "description": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "requiresAuth": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "description",
+ "id",
+ "name",
+ "requiresAuth"
+ ],
+ "type": "object"
+ },
+ "PluginCatalogHook": {
+ "properties": {
+ "event": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "event",
+ "id"
+ ],
+ "type": "object"
+ },
+ "PluginCatalogSummary": {
+ "properties": {
+ "appsCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "authPolicy": {
+ "type": "string"
+ },
+ "availability": {
+ "type": "string"
+ },
+ "contentDigest": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "disabledReason": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "hooksCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "id": {
+ "type": "string"
+ },
+ "installPolicy": {
+ "type": "string"
+ },
+ "installed": {
+ "type": "boolean"
+ },
+ "localVersion": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "marketplaceId": {
+ "type": "string"
+ },
+ "mcpServersCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "skillsCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "source": {
+ "type": "string"
+ },
+ "sourceUri": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "appsCount",
+ "authPolicy",
+ "availability",
+ "contentDigest",
+ "description",
+ "enabled",
+ "hooksCount",
+ "id",
+ "installPolicy",
+ "installed",
+ "marketplaceId",
+ "mcpServersCount",
+ "name",
+ "skillsCount",
+ "source",
+ "sourceUri",
+ "version"
+ ],
+ "type": "object"
+ },
+ "PluginCatalogUiResource": {
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "type": "string"
+ },
+ "resourceUri": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "kind",
+ "resourceUri"
+ ],
+ "type": "object"
+ }
+ },
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "apps": {
+ "items": {
+ "$ref": "#/$defs/PluginCatalogCapability"
+ },
+ "type": "array"
+ },
+ "hooks": {
+ "items": {
+ "$ref": "#/$defs/PluginCatalogHook"
+ },
+ "type": "array"
+ },
+ "mcpServers": {
+ "items": {
+ "$ref": "#/$defs/PluginCatalogCapability"
+ },
+ "type": "array"
+ },
+ "skills": {
+ "items": {
+ "$ref": "#/$defs/PluginCatalogCapability"
+ },
+ "type": "array"
+ },
+ "summary": {
+ "$ref": "#/$defs/PluginCatalogSummary"
+ },
+ "uiResources": {
+ "items": {
+ "$ref": "#/$defs/PluginCatalogUiResource"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "apps",
+ "hooks",
+ "mcpServers",
+ "skills",
+ "summary",
+ "uiResources"
+ ],
+ "title": "PluginCatalogDetail",
+ "type": "object"
+}
diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogEnabledSetParams.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogEnabledSetParams.json
new file mode 100644
index 000000000..029543b07
--- /dev/null
+++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogEnabledSetParams.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "pluginId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "enabled",
+ "pluginId"
+ ],
+ "title": "PluginCatalogEnabledSetParams",
+ "type": "object"
+}
diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogEnabledSetResponse.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogEnabledSetResponse.json
new file mode 100644
index 000000000..04a14a880
--- /dev/null
+++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogEnabledSetResponse.json
@@ -0,0 +1,110 @@
+{
+ "$defs": {
+ "PluginCatalogSummary": {
+ "properties": {
+ "appsCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "authPolicy": {
+ "type": "string"
+ },
+ "availability": {
+ "type": "string"
+ },
+ "contentDigest": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "disabledReason": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "hooksCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "id": {
+ "type": "string"
+ },
+ "installPolicy": {
+ "type": "string"
+ },
+ "installed": {
+ "type": "boolean"
+ },
+ "localVersion": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "marketplaceId": {
+ "type": "string"
+ },
+ "mcpServersCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "skillsCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "source": {
+ "type": "string"
+ },
+ "sourceUri": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "appsCount",
+ "authPolicy",
+ "availability",
+ "contentDigest",
+ "description",
+ "enabled",
+ "hooksCount",
+ "id",
+ "installPolicy",
+ "installed",
+ "marketplaceId",
+ "mcpServersCount",
+ "name",
+ "skillsCount",
+ "source",
+ "sourceUri",
+ "version"
+ ],
+ "type": "object"
+ }
+ },
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "plugin": {
+ "$ref": "#/$defs/PluginCatalogSummary"
+ }
+ },
+ "required": [
+ "plugin"
+ ],
+ "title": "PluginCatalogEnabledSetResponse",
+ "type": "object"
+}
diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogHook.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogHook.json
new file mode 100644
index 000000000..91498623f
--- /dev/null
+++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogHook.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "event": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "event",
+ "id"
+ ],
+ "title": "PluginCatalogHook",
+ "type": "object"
+}
diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogInstallParams.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogInstallParams.json
new file mode 100644
index 000000000..1c66bf84a
--- /dev/null
+++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogInstallParams.json
@@ -0,0 +1,31 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "expectedDigest": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "marketplaceId": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "source": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "sourcePath": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "sourcePath"
+ ],
+ "title": "PluginCatalogInstallParams",
+ "type": "object"
+}
diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogInstallResponse.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogInstallResponse.json
new file mode 100644
index 000000000..8fadc688c
--- /dev/null
+++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogInstallResponse.json
@@ -0,0 +1,110 @@
+{
+ "$defs": {
+ "PluginCatalogSummary": {
+ "properties": {
+ "appsCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "authPolicy": {
+ "type": "string"
+ },
+ "availability": {
+ "type": "string"
+ },
+ "contentDigest": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "disabledReason": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "hooksCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "id": {
+ "type": "string"
+ },
+ "installPolicy": {
+ "type": "string"
+ },
+ "installed": {
+ "type": "boolean"
+ },
+ "localVersion": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "marketplaceId": {
+ "type": "string"
+ },
+ "mcpServersCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "skillsCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "source": {
+ "type": "string"
+ },
+ "sourceUri": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "appsCount",
+ "authPolicy",
+ "availability",
+ "contentDigest",
+ "description",
+ "enabled",
+ "hooksCount",
+ "id",
+ "installPolicy",
+ "installed",
+ "marketplaceId",
+ "mcpServersCount",
+ "name",
+ "skillsCount",
+ "source",
+ "sourceUri",
+ "version"
+ ],
+ "type": "object"
+ }
+ },
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "plugin": {
+ "$ref": "#/$defs/PluginCatalogSummary"
+ }
+ },
+ "required": [
+ "plugin"
+ ],
+ "title": "PluginCatalogInstallResponse",
+ "type": "object"
+}
diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogInstalledParams.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogInstalledParams.json
new file mode 100644
index 000000000..cb4bbc97e
--- /dev/null
+++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogInstalledParams.json
@@ -0,0 +1,5 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "PluginCatalogInstalledParams",
+ "type": "object"
+}
diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogListParams.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogListParams.json
new file mode 100644
index 000000000..a0a4aba23
--- /dev/null
+++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogListParams.json
@@ -0,0 +1,26 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "marketplacePaths": {
+ "default": [],
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "query": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "source": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "title": "PluginCatalogListParams",
+ "type": "object"
+}
diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogListResponse.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogListResponse.json
new file mode 100644
index 000000000..f5f051f88
--- /dev/null
+++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogListResponse.json
@@ -0,0 +1,117 @@
+{
+ "$defs": {
+ "PluginCatalogSummary": {
+ "properties": {
+ "appsCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "authPolicy": {
+ "type": "string"
+ },
+ "availability": {
+ "type": "string"
+ },
+ "contentDigest": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "disabledReason": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "hooksCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "id": {
+ "type": "string"
+ },
+ "installPolicy": {
+ "type": "string"
+ },
+ "installed": {
+ "type": "boolean"
+ },
+ "localVersion": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "marketplaceId": {
+ "type": "string"
+ },
+ "mcpServersCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "skillsCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "source": {
+ "type": "string"
+ },
+ "sourceUri": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "appsCount",
+ "authPolicy",
+ "availability",
+ "contentDigest",
+ "description",
+ "enabled",
+ "hooksCount",
+ "id",
+ "installPolicy",
+ "installed",
+ "marketplaceId",
+ "mcpServersCount",
+ "name",
+ "skillsCount",
+ "source",
+ "sourceUri",
+ "version"
+ ],
+ "type": "object"
+ }
+ },
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "generatedAt": {
+ "type": "string"
+ },
+ "plugins": {
+ "items": {
+ "$ref": "#/$defs/PluginCatalogSummary"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "generatedAt",
+ "plugins"
+ ],
+ "title": "PluginCatalogListResponse",
+ "type": "object"
+}
diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogReadParams.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogReadParams.json
new file mode 100644
index 000000000..c2b1628f7
--- /dev/null
+++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogReadParams.json
@@ -0,0 +1,13 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "pluginId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "pluginId"
+ ],
+ "title": "PluginCatalogReadParams",
+ "type": "object"
+}
diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogReadResponse.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogReadResponse.json
new file mode 100644
index 000000000..f93f7e647
--- /dev/null
+++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogReadResponse.json
@@ -0,0 +1,213 @@
+{
+ "$defs": {
+ "PluginCatalogCapability": {
+ "properties": {
+ "description": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "requiresAuth": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "description",
+ "id",
+ "name",
+ "requiresAuth"
+ ],
+ "type": "object"
+ },
+ "PluginCatalogDetail": {
+ "properties": {
+ "apps": {
+ "items": {
+ "$ref": "#/$defs/PluginCatalogCapability"
+ },
+ "type": "array"
+ },
+ "hooks": {
+ "items": {
+ "$ref": "#/$defs/PluginCatalogHook"
+ },
+ "type": "array"
+ },
+ "mcpServers": {
+ "items": {
+ "$ref": "#/$defs/PluginCatalogCapability"
+ },
+ "type": "array"
+ },
+ "skills": {
+ "items": {
+ "$ref": "#/$defs/PluginCatalogCapability"
+ },
+ "type": "array"
+ },
+ "summary": {
+ "$ref": "#/$defs/PluginCatalogSummary"
+ },
+ "uiResources": {
+ "items": {
+ "$ref": "#/$defs/PluginCatalogUiResource"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "apps",
+ "hooks",
+ "mcpServers",
+ "skills",
+ "summary",
+ "uiResources"
+ ],
+ "type": "object"
+ },
+ "PluginCatalogHook": {
+ "properties": {
+ "event": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "event",
+ "id"
+ ],
+ "type": "object"
+ },
+ "PluginCatalogSummary": {
+ "properties": {
+ "appsCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "authPolicy": {
+ "type": "string"
+ },
+ "availability": {
+ "type": "string"
+ },
+ "contentDigest": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "disabledReason": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "hooksCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "id": {
+ "type": "string"
+ },
+ "installPolicy": {
+ "type": "string"
+ },
+ "installed": {
+ "type": "boolean"
+ },
+ "localVersion": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "marketplaceId": {
+ "type": "string"
+ },
+ "mcpServersCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "skillsCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "source": {
+ "type": "string"
+ },
+ "sourceUri": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "appsCount",
+ "authPolicy",
+ "availability",
+ "contentDigest",
+ "description",
+ "enabled",
+ "hooksCount",
+ "id",
+ "installPolicy",
+ "installed",
+ "marketplaceId",
+ "mcpServersCount",
+ "name",
+ "skillsCount",
+ "source",
+ "sourceUri",
+ "version"
+ ],
+ "type": "object"
+ },
+ "PluginCatalogUiResource": {
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "type": "string"
+ },
+ "resourceUri": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "kind",
+ "resourceUri"
+ ],
+ "type": "object"
+ }
+ },
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "plugin": {
+ "$ref": "#/$defs/PluginCatalogDetail"
+ }
+ },
+ "required": [
+ "plugin"
+ ],
+ "title": "PluginCatalogReadResponse",
+ "type": "object"
+}
diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogSummary.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogSummary.json
new file mode 100644
index 000000000..abcbabc90
--- /dev/null
+++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogSummary.json
@@ -0,0 +1,97 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "appsCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "authPolicy": {
+ "type": "string"
+ },
+ "availability": {
+ "type": "string"
+ },
+ "contentDigest": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "disabledReason": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "hooksCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "id": {
+ "type": "string"
+ },
+ "installPolicy": {
+ "type": "string"
+ },
+ "installed": {
+ "type": "boolean"
+ },
+ "localVersion": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "marketplaceId": {
+ "type": "string"
+ },
+ "mcpServersCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "skillsCount": {
+ "format": "uint32",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "source": {
+ "type": "string"
+ },
+ "sourceUri": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "appsCount",
+ "authPolicy",
+ "availability",
+ "contentDigest",
+ "description",
+ "enabled",
+ "hooksCount",
+ "id",
+ "installPolicy",
+ "installed",
+ "marketplaceId",
+ "mcpServersCount",
+ "name",
+ "skillsCount",
+ "source",
+ "sourceUri",
+ "version"
+ ],
+ "title": "PluginCatalogSummary",
+ "type": "object"
+}
diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogUiResource.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogUiResource.json
new file mode 100644
index 000000000..6c35c14a7
--- /dev/null
+++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogUiResource.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "type": "string"
+ },
+ "resourceUri": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "kind",
+ "resourceUri"
+ ],
+ "title": "PluginCatalogUiResource",
+ "type": "object"
+}
diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogUninstallParams.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogUninstallParams.json
new file mode 100644
index 000000000..56233f133
--- /dev/null
+++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogUninstallParams.json
@@ -0,0 +1,13 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "pluginId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "pluginId"
+ ],
+ "title": "PluginCatalogUninstallParams",
+ "type": "object"
+}
diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogUninstallResponse.json b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogUninstallResponse.json
new file mode 100644
index 000000000..986062136
--- /dev/null
+++ b/lime-rs/crates/app-server-protocol/schema/json/v2/PluginCatalogUninstallResponse.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "pluginId": {
+ "type": "string"
+ },
+ "uninstalled": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "pluginId",
+ "uninstalled"
+ ],
+ "title": "PluginCatalogUninstallResponse",
+ "type": "object"
+}
diff --git a/lime-rs/crates/app-server-protocol/src/protocol/v0/mcp.rs b/lime-rs/crates/app-server-protocol/src/protocol/v0/mcp.rs
index 5a2879d25..50f4bbb4f 100644
--- a/lime-rs/crates/app-server-protocol/src/protocol/v0/mcp.rs
+++ b/lime-rs/crates/app-server-protocol/src/protocol/v0/mcp.rs
@@ -190,6 +190,12 @@ pub struct McpResourceReadParams {
pub server: String,
#[schemars(length(min = 1))]
pub uri: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ #[schemars(length(min = 1))]
+ pub session_id: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ #[schemars(length(min = 1))]
+ pub thread_id: Option,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
@@ -223,6 +229,8 @@ pub struct McpResourceReadResponse {
pub text: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blob: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub meta: Option,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
diff --git a/lime-rs/crates/app-server-protocol/src/protocol/v2/envelopes.rs b/lime-rs/crates/app-server-protocol/src/protocol/v2/envelopes.rs
index afd90b277..7412ec849 100644
--- a/lime-rs/crates/app-server-protocol/src/protocol/v2/envelopes.rs
+++ b/lime-rs/crates/app-server-protocol/src/protocol/v2/envelopes.rs
@@ -9,7 +9,9 @@ use super::{
McpToolCallProgressNotification, MediaReadParams, MediaReadResponse, Method, ModelListParams,
ModelListUpdatedNotification, ModelReroutedNotification,
ModelSafetyBufferingUpdatedNotification, ModelVerificationNotification,
- PermissionsRequestApprovalParams, PlanDeltaNotification, ReasoningSummaryPartAddedNotification,
+ PermissionsRequestApprovalParams, PlanDeltaNotification, PluginCatalogEnabledSetParams,
+ PluginCatalogInstallParams, PluginCatalogInstalledParams, PluginCatalogListParams,
+ PluginCatalogReadParams, PluginCatalogUninstallParams, ReasoningSummaryPartAddedNotification,
ReasoningSummaryTextDeltaNotification, ReasoningTextDeltaNotification,
ServerRequestResolvedNotification, SkillsChangedNotification,
ThreadApproveGuardianDeniedActionParams, ThreadApproveGuardianDeniedActionResponse,
@@ -236,6 +238,36 @@ pub enum ClientRequest {
id: RequestId,
params: ModelListParams,
},
+ #[serde(rename = "plugin/list")]
+ PluginList {
+ id: RequestId,
+ params: PluginCatalogListParams,
+ },
+ #[serde(rename = "plugin/read")]
+ PluginRead {
+ id: RequestId,
+ params: PluginCatalogReadParams,
+ },
+ #[serde(rename = "plugin/install")]
+ PluginInstall {
+ id: RequestId,
+ params: PluginCatalogInstallParams,
+ },
+ #[serde(rename = "plugin/uninstall")]
+ PluginUninstall {
+ id: RequestId,
+ params: PluginCatalogUninstallParams,
+ },
+ #[serde(rename = "plugin/installed")]
+ PluginInstalled {
+ id: RequestId,
+ params: PluginCatalogInstalledParams,
+ },
+ #[serde(rename = "plugin/enabled/set")]
+ PluginEnabledSet {
+ id: RequestId,
+ params: PluginCatalogEnabledSetParams,
+ },
#[serde(rename = "turn/start")]
TurnStart {
id: RequestId,
@@ -289,6 +321,12 @@ impl ClientRequest {
| Self::ArtifactWrite { id, .. }
| Self::MediaRead { id, .. }
| Self::ModelList { id, .. }
+ | Self::PluginList { id, .. }
+ | Self::PluginRead { id, .. }
+ | Self::PluginInstall { id, .. }
+ | Self::PluginUninstall { id, .. }
+ | Self::PluginInstalled { id, .. }
+ | Self::PluginEnabledSet { id, .. }
| Self::TurnStart { id, .. }
| Self::TurnSteer { id, .. }
| Self::TurnInterrupt { id, .. } => id,
@@ -334,6 +372,12 @@ impl ClientRequest {
Self::ArtifactWrite { .. } => Method::ArtifactWrite,
Self::MediaRead { .. } => Method::MediaRead,
Self::ModelList { .. } => Method::ModelList,
+ Self::PluginList { .. } => Method::PluginList,
+ Self::PluginRead { .. } => Method::PluginRead,
+ Self::PluginInstall { .. } => Method::PluginInstall,
+ Self::PluginUninstall { .. } => Method::PluginUninstall,
+ Self::PluginInstalled { .. } => Method::PluginInstalled,
+ Self::PluginEnabledSet { .. } => Method::PluginEnabledSet,
Self::TurnStart { .. } => Method::TurnStart,
Self::TurnSteer { .. } => Method::TurnSteer,
Self::TurnInterrupt { .. } => Method::TurnInterrupt,
diff --git a/lime-rs/crates/app-server-protocol/src/protocol/v2/methods.rs b/lime-rs/crates/app-server-protocol/src/protocol/v2/methods.rs
index 1734c273d..13ca05fa8 100644
--- a/lime-rs/crates/app-server-protocol/src/protocol/v2/methods.rs
+++ b/lime-rs/crates/app-server-protocol/src/protocol/v2/methods.rs
@@ -35,6 +35,12 @@ pub const METHOD_THREAD_GOAL_CLEAR: &str = "thread/goal/clear";
pub const METHOD_ARTIFACT_WRITE: &str = "artifact/write";
pub const METHOD_MEDIA_READ: &str = "media/read";
pub const METHOD_MODEL_LIST: &str = "model/list";
+pub const METHOD_PLUGIN_LIST: &str = "plugin/list";
+pub const METHOD_PLUGIN_READ: &str = "plugin/read";
+pub const METHOD_PLUGIN_INSTALL: &str = "plugin/install";
+pub const METHOD_PLUGIN_UNINSTALL: &str = "plugin/uninstall";
+pub const METHOD_PLUGIN_INSTALLED: &str = "plugin/installed";
+pub const METHOD_PLUGIN_ENABLED_SET: &str = "plugin/enabled/set";
pub const METHOD_MODEL_LIST_UPDATED: &str = "model/list/updated";
pub const METHOD_TURN_START: &str = "turn/start";
pub const METHOD_TURN_STEER: &str = "turn/steer";
@@ -153,6 +159,18 @@ pub enum Method {
MediaRead,
#[serde(rename = "model/list")]
ModelList,
+ #[serde(rename = "plugin/list")]
+ PluginList,
+ #[serde(rename = "plugin/read")]
+ PluginRead,
+ #[serde(rename = "plugin/install")]
+ PluginInstall,
+ #[serde(rename = "plugin/uninstall")]
+ PluginUninstall,
+ #[serde(rename = "plugin/installed")]
+ PluginInstalled,
+ #[serde(rename = "plugin/enabled/set")]
+ PluginEnabledSet,
#[serde(rename = "turn/start")]
TurnStart,
#[serde(rename = "turn/steer")]
@@ -199,6 +217,12 @@ impl Method {
Self::ArtifactWrite => METHOD_ARTIFACT_WRITE,
Self::MediaRead => METHOD_MEDIA_READ,
Self::ModelList => METHOD_MODEL_LIST,
+ Self::PluginList => METHOD_PLUGIN_LIST,
+ Self::PluginRead => METHOD_PLUGIN_READ,
+ Self::PluginInstall => METHOD_PLUGIN_INSTALL,
+ Self::PluginUninstall => METHOD_PLUGIN_UNINSTALL,
+ Self::PluginInstalled => METHOD_PLUGIN_INSTALLED,
+ Self::PluginEnabledSet => METHOD_PLUGIN_ENABLED_SET,
Self::TurnStart => METHOD_TURN_START,
Self::TurnSteer => METHOD_TURN_STEER,
Self::TurnInterrupt => METHOD_TURN_INTERRUPT,
@@ -244,6 +268,12 @@ impl Method {
METHOD_ARTIFACT_WRITE => Some(Self::ArtifactWrite),
METHOD_MEDIA_READ => Some(Self::MediaRead),
METHOD_MODEL_LIST => Some(Self::ModelList),
+ METHOD_PLUGIN_LIST => Some(Self::PluginList),
+ METHOD_PLUGIN_READ => Some(Self::PluginRead),
+ METHOD_PLUGIN_INSTALL => Some(Self::PluginInstall),
+ METHOD_PLUGIN_UNINSTALL => Some(Self::PluginUninstall),
+ METHOD_PLUGIN_INSTALLED => Some(Self::PluginInstalled),
+ METHOD_PLUGIN_ENABLED_SET => Some(Self::PluginEnabledSet),
METHOD_TURN_START => Some(Self::TurnStart),
METHOD_TURN_STEER => Some(Self::TurnSteer),
METHOD_TURN_INTERRUPT => Some(Self::TurnInterrupt),
@@ -286,6 +316,12 @@ pub const METHODS: &[&str] = &[
METHOD_ARTIFACT_WRITE,
METHOD_MEDIA_READ,
METHOD_MODEL_LIST,
+ METHOD_PLUGIN_LIST,
+ METHOD_PLUGIN_READ,
+ METHOD_PLUGIN_INSTALL,
+ METHOD_PLUGIN_UNINSTALL,
+ METHOD_PLUGIN_INSTALLED,
+ METHOD_PLUGIN_ENABLED_SET,
METHOD_TURN_START,
METHOD_TURN_STEER,
METHOD_TURN_INTERRUPT,
diff --git a/lime-rs/crates/app-server-protocol/src/protocol/v2/mod.rs b/lime-rs/crates/app-server-protocol/src/protocol/v2/mod.rs
index 873a2d878..fb2c180ba 100644
--- a/lime-rs/crates/app-server-protocol/src/protocol/v2/mod.rs
+++ b/lime-rs/crates/app-server-protocol/src/protocol/v2/mod.rs
@@ -11,6 +11,7 @@ mod media;
mod methods;
mod model;
mod notification;
+mod plugin;
mod request_permissions;
mod schema_types;
mod thread;
@@ -31,6 +32,7 @@ pub use media::*;
pub use methods::*;
pub use model::*;
pub use notification::*;
+pub use plugin::*;
pub use request_permissions::*;
pub use schema_types::*;
pub use thread::*;
diff --git a/lime-rs/crates/app-server-protocol/src/protocol/v2/plugin.rs b/lime-rs/crates/app-server-protocol/src/protocol/v2/plugin.rs
new file mode 100644
index 000000000..541a3ad21
--- /dev/null
+++ b/lime-rs/crates/app-server-protocol/src/protocol/v2/plugin.rs
@@ -0,0 +1,141 @@
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct PluginCatalogListParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub query: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub source: Option,
+ #[serde(default)]
+ pub marketplace_paths: Vec,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct PluginCatalogListResponse {
+ pub plugins: Vec,
+ pub generated_at: String,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct PluginCatalogInstalledParams {}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct PluginCatalogEnabledSetParams {
+ pub plugin_id: String,
+ pub enabled: bool,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct PluginCatalogEnabledSetResponse {
+ pub plugin: PluginCatalogSummary,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct PluginCatalogSummary {
+ pub id: String,
+ pub name: String,
+ pub version: String,
+ pub marketplace_id: String,
+ pub content_digest: String,
+ pub description: String,
+ pub source: String,
+ pub source_uri: String,
+ pub installed: bool,
+ pub enabled: bool,
+ pub install_policy: String,
+ pub auth_policy: String,
+ pub availability: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub disabled_reason: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub local_version: Option,
+ pub skills_count: u32,
+ pub mcp_servers_count: u32,
+ pub apps_count: u32,
+ pub hooks_count: u32,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct PluginCatalogReadParams {
+ pub plugin_id: String,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct PluginCatalogReadResponse {
+ pub plugin: PluginCatalogDetail,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct PluginCatalogDetail {
+ pub summary: PluginCatalogSummary,
+ pub skills: Vec,
+ pub mcp_servers: Vec,
+ pub apps: Vec,
+ pub hooks: Vec,
+ pub ui_resources: Vec,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct PluginCatalogCapability {
+ pub id: String,
+ pub name: String,
+ pub description: String,
+ pub requires_auth: bool,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct PluginCatalogHook {
+ pub id: String,
+ pub event: String,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct PluginCatalogUiResource {
+ pub id: String,
+ pub resource_uri: String,
+ pub kind: String,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct PluginCatalogInstallParams {
+ pub source_path: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub marketplace_id: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub source: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub expected_digest: Option,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct PluginCatalogInstallResponse {
+ pub plugin: PluginCatalogSummary,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct PluginCatalogUninstallParams {
+ pub plugin_id: String,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct PluginCatalogUninstallResponse {
+ pub plugin_id: String,
+ pub uninstalled: bool,
+}
diff --git a/lime-rs/crates/app-server-protocol/src/protocol/v2/schema_types.rs b/lime-rs/crates/app-server-protocol/src/protocol/v2/schema_types.rs
index 71859fcac..09972f4ab 100644
--- a/lime-rs/crates/app-server-protocol/src/protocol/v2/schema_types.rs
+++ b/lime-rs/crates/app-server-protocol/src/protocol/v2/schema_types.rs
@@ -70,6 +70,22 @@ pub const V2_SCHEMA_TYPE_NAMES: &[&str] = &[
"Model",
"ModelListResponse",
"ModelListUpdatedNotification",
+ "PluginCatalogListParams",
+ "PluginCatalogInstalledParams",
+ "PluginCatalogEnabledSetParams",
+ "PluginCatalogEnabledSetResponse",
+ "PluginCatalogListResponse",
+ "PluginCatalogSummary",
+ "PluginCatalogReadParams",
+ "PluginCatalogReadResponse",
+ "PluginCatalogDetail",
+ "PluginCatalogCapability",
+ "PluginCatalogHook",
+ "PluginCatalogUiResource",
+ "PluginCatalogInstallParams",
+ "PluginCatalogInstallResponse",
+ "PluginCatalogUninstallParams",
+ "PluginCatalogUninstallResponse",
"ModelRerouteReason",
"ModelReroutedNotification",
"ModelVerification",
diff --git a/lime-rs/crates/app-server-protocol/src/schema_export/registry.rs b/lime-rs/crates/app-server-protocol/src/schema_export/registry.rs
index cfec70f64..a24df8be5 100644
--- a/lime-rs/crates/app-server-protocol/src/schema_export/registry.rs
+++ b/lime-rs/crates/app-server-protocol/src/schema_export/registry.rs
@@ -823,6 +823,22 @@ pub(super) fn v2_schemas() -> Vec {
typed_schema::("Model"),
typed_schema::("ModelListResponse"),
typed_schema::("ModelListUpdatedNotification"),
+ typed_schema::("PluginCatalogListParams"),
+ typed_schema::("PluginCatalogInstalledParams"),
+ typed_schema::("PluginCatalogEnabledSetParams"),
+ typed_schema::("PluginCatalogEnabledSetResponse"),
+ typed_schema::("PluginCatalogListResponse"),
+ typed_schema::("PluginCatalogSummary"),
+ typed_schema::("PluginCatalogReadParams"),
+ typed_schema::("PluginCatalogReadResponse"),
+ typed_schema::("PluginCatalogDetail"),
+ typed_schema::("PluginCatalogCapability"),
+ typed_schema::("PluginCatalogHook"),
+ typed_schema::("PluginCatalogUiResource"),
+ typed_schema::("PluginCatalogInstallParams"),
+ typed_schema::("PluginCatalogInstallResponse"),
+ typed_schema::("PluginCatalogUninstallParams"),
+ typed_schema::("PluginCatalogUninstallResponse"),
typed_schema::("ModelRerouteReason"),
typed_schema::("ModelReroutedNotification"),
typed_schema::("ModelVerification"),
diff --git a/lime-rs/crates/app-server/Cargo.toml b/lime-rs/crates/app-server/Cargo.toml
index 14b56c680..28676cb26 100644
--- a/lime-rs/crates/app-server/Cargo.toml
+++ b/lime-rs/crates/app-server/Cargo.toml
@@ -54,6 +54,7 @@ scopeguard.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
+semver.workspace = true
sha2.workspace = true
portable-pty.workspace = true
patch-apply.workspace = true
diff --git a/lime-rs/crates/app-server/src/local_data_source.rs b/lime-rs/crates/app-server/src/local_data_source.rs
index c60e63aff..620cc1a63 100644
--- a/lime-rs/crates/app-server/src/local_data_source.rs
+++ b/lime-rs/crates/app-server/src/local_data_source.rs
@@ -33,6 +33,7 @@ mod mcp;
mod media_tasks;
mod model_projection;
mod model_providers;
+pub(super) mod plugin_catalog;
mod plugins;
mod project_materials;
mod right_surface;
diff --git a/lime-rs/crates/app-server/src/local_data_source/impls/mcp.rs b/lime-rs/crates/app-server/src/local_data_source/impls/mcp.rs
index 5e212a7db..a14c7a22b 100644
--- a/lime-rs/crates/app-server/src/local_data_source/impls/mcp.rs
+++ b/lime-rs/crates/app-server/src/local_data_source/impls/mcp.rs
@@ -87,7 +87,7 @@ impl McpAppDataSource for LocalAppDataSource {
async fn list_mcp_runtime_server_specs(
&self,
) -> Result, RuntimeCoreError> {
- mcp::list_mcp_runtime_server_specs(&self.db)
+ mcp::list_mcp_runtime_server_specs(&self.db, &self.plugin_data_root)
}
async fn list_mcp_tools_for_context(
diff --git a/lime-rs/crates/app-server/src/local_data_source/impls/plugins.rs b/lime-rs/crates/app-server/src/local_data_source/impls/plugins.rs
index 0b0be596b..592126659 100644
--- a/lime-rs/crates/app-server/src/local_data_source/impls/plugins.rs
+++ b/lime-rs/crates/app-server/src/local_data_source/impls/plugins.rs
@@ -1,4 +1,10 @@
use super::super::*;
+use app_server_protocol::protocol::v2::{
+ PluginCatalogEnabledSetParams, PluginCatalogEnabledSetResponse, PluginCatalogInstallParams,
+ PluginCatalogInstallResponse, PluginCatalogInstalledParams, PluginCatalogListParams,
+ PluginCatalogListResponse, PluginCatalogReadParams, PluginCatalogReadResponse,
+ PluginCatalogUninstallParams, PluginCatalogUninstallResponse,
+};
use async_trait::async_trait;
#[async_trait]
@@ -7,6 +13,61 @@ impl PluginDataSource for LocalAppDataSource {
Ok(self.plugin_data_root.clone())
}
+ async fn list_plugin_catalog(
+ &self,
+ params: PluginCatalogListParams,
+ ) -> Result {
+ crate::local_data_source::plugin_catalog::list(&self.plugin_data_root, params)
+ .map_err(data_error)
+ }
+
+ async fn read_plugin_catalog(
+ &self,
+ params: PluginCatalogReadParams,
+ ) -> Result {
+ crate::local_data_source::plugin_catalog::read(&self.plugin_data_root, params)
+ .map_err(data_error)
+ }
+
+ async fn install_plugin_catalog(
+ &self,
+ params: PluginCatalogInstallParams,
+ ) -> Result {
+ crate::local_data_source::plugin_catalog::install(&self.plugin_data_root, params)
+ .map_err(data_error)
+ }
+
+ async fn uninstall_plugin_catalog(
+ &self,
+ params: PluginCatalogUninstallParams,
+ ) -> Result {
+ crate::local_data_source::plugin_catalog::uninstall(&self.plugin_data_root, params)
+ .map_err(data_error)
+ }
+
+ async fn list_plugin_catalog_installed(
+ &self,
+ params: PluginCatalogInstalledParams,
+ ) -> Result {
+ crate::local_data_source::plugin_catalog::installed(&self.plugin_data_root, params)
+ .map_err(data_error)
+ }
+
+ async fn list_plugin_catalog_activations(&self) -> Result, RuntimeCoreError> {
+ crate::local_data_source::plugin_catalog::enabled_activation_descriptors(
+ &self.plugin_data_root,
+ )
+ .map_err(data_error)
+ }
+
+ async fn set_plugin_catalog_enabled(
+ &self,
+ params: PluginCatalogEnabledSetParams,
+ ) -> Result {
+ crate::local_data_source::plugin_catalog::set_enabled(&self.plugin_data_root, params)
+ .map_err(data_error)
+ }
+
async fn list_plugin_installed(&self) -> Result {
plugins::list_plugin_installed_state(&self.plugin_data_root).map_err(data_error)
}
diff --git a/lime-rs/crates/app-server/src/local_data_source/mcp.rs b/lime-rs/crates/app-server/src/local_data_source/mcp.rs
index aac0ef5b7..3e8d2082c 100644
--- a/lime-rs/crates/app-server/src/local_data_source/mcp.rs
+++ b/lime-rs/crates/app-server/src/local_data_source/mcp.rs
@@ -38,6 +38,8 @@ use lime_mcp::McpServerConfig;
use lime_services::mcp_service::McpService;
use serde_json::json;
use serde_json::Value;
+use std::collections::HashSet;
+use std::path::Path;
pub(crate) fn list_mcp_servers(
db: &DbConnection,
@@ -196,8 +198,9 @@ pub(crate) async fn list_mcp_tools(
pub(crate) fn list_mcp_runtime_server_specs(
db: &DbConnection,
+ plugin_data_root: &Path,
) -> Result, RuntimeCoreError> {
- Ok(McpService::get_all(db)
+ let mut specs = McpService::get_all(db)
.map_err(data_error)?
.into_iter()
.filter(|server| server.enabled_lime)
@@ -205,10 +208,34 @@ pub(crate) fn list_mcp_runtime_server_specs(
let config = parse_mcp_server_config(&server.server_config);
config.enabled.then(|| lime_mcp::McpRuntimeServerSpec {
name: server.name,
+ plugin_id: None,
config,
})
})
- .collect::>())
+ .collect::>();
+ let mut names = specs
+ .iter()
+ .map(|spec| spec.name.clone())
+ .collect::>();
+ match super::plugin_catalog::list_plugin_mcp_runtime_server_specs(plugin_data_root) {
+ Ok(plugin_specs) => {
+ for spec in plugin_specs {
+ if !names.insert(spec.name.clone()) {
+ tracing::warn!(
+ server_name = %spec.name,
+ "跳过与现有 MCP server 同名的 Plugin MCP server"
+ );
+ continue;
+ }
+ specs.push(spec);
+ }
+ }
+ Err(error) => {
+ tracing::warn!(%error, "Plugin MCP runtime 配置读取失败,跳过 Plugin MCP servers")
+ }
+ }
+ specs.sort_by(|left, right| left.name.cmp(&right.name));
+ Ok(specs)
}
pub(crate) async fn list_mcp_tools_for_context(
@@ -318,6 +345,7 @@ pub(crate) async fn read_mcp_resource(
mime_type: result.mime_type,
text: result.text,
blob: result.blob,
+ meta: result.meta,
})
}
diff --git a/lime-rs/crates/app-server/src/local_data_source/plugin_catalog.rs b/lime-rs/crates/app-server/src/local_data_source/plugin_catalog.rs
new file mode 100644
index 000000000..16b9e379e
--- /dev/null
+++ b/lime-rs/crates/app-server/src/local_data_source/plugin_catalog.rs
@@ -0,0 +1,1218 @@
+use app_server_protocol::protocol::v2::{
+ PluginCatalogCapability, PluginCatalogDetail, PluginCatalogHook, PluginCatalogInstallParams,
+ PluginCatalogInstallResponse, PluginCatalogInstalledParams, PluginCatalogListParams,
+ PluginCatalogListResponse, PluginCatalogReadParams, PluginCatalogReadResponse,
+ PluginCatalogSummary, PluginCatalogUiResource, PluginCatalogUninstallParams,
+ PluginCatalogUninstallResponse,
+};
+use chrono::Utc;
+use lime_mcp::{McpRuntimeServerSpec, McpServerConfig, McpServerTransport};
+use semver::Version;
+use serde_json::Value;
+use sha2::{Digest, Sha256};
+use std::fs;
+use std::io::Read;
+use std::path::{Component, Path, PathBuf};
+
+const V2_STORE_DIR: &str = "v2";
+const PACKAGES_DIR: &str = "packages";
+const INSTALLED_DIR: &str = "installed";
+const STAGING_DIR: &str = "staging";
+const DEFAULT_MCP_CONFIG_PATH: &str = ".mcp.json";
+const INSTALLED_SCHEMA_VERSION: u64 = 1;
+const MAX_MANIFEST_BYTES: u64 = 2 * 1024 * 1024;
+const MAX_PACKAGE_FILES: usize = 10_000;
+const MAX_PACKAGE_BYTES: u64 = 256 * 1024 * 1024;
+
+pub(crate) fn list(
+ plugin_data_root: &Path,
+ params: PluginCatalogListParams,
+) -> Result {
+ let installed = read_installed_records(plugin_data_root)?;
+ let mut summaries = Vec::new();
+ let mut source_paths = params
+ .marketplace_paths
+ .into_iter()
+ .map(PathBuf::from)
+ .collect::>();
+ let explicit_source_paths = !source_paths.is_empty();
+ if source_paths.is_empty() {
+ source_paths = implicit_marketplace_paths(plugin_data_root);
+ }
+
+ for source_path in &source_paths {
+ for package_root in discover_package_roots(source_path)? {
+ let source_uri = package_root.to_string_lossy().into_owned();
+ let installed_record = read_manifest(&package_root)
+ .ok()
+ .and_then(|manifest| manifest_name(&manifest).ok())
+ .and_then(|plugin_id| installed.get(&plugin_id).cloned());
+ let summary = build_summary(
+ &package_root,
+ source_kind_for_marketplace_path(source_path),
+ &source_uri,
+ installed_record.as_ref(),
+ )?;
+ let mut summary = summary;
+ if installed_record.is_none() {
+ summary.marketplace_id = marketplace_id_for_path(source_path)
+ .unwrap_or_else(|| source_kind_for_marketplace_path(source_path).to_string());
+ }
+ if matches_query(&summary, params.query.as_deref())
+ && matches_source(&summary, params.source.as_deref())
+ {
+ summaries.push(summary);
+ }
+ }
+ }
+
+ if !explicit_source_paths {
+ for record in installed.values() {
+ let package_root = installed_package_root(plugin_data_root, record)?;
+ let already_visible = summaries.iter().any(|summary: &PluginCatalogSummary| {
+ summary.id == record_string(record, "pluginId").unwrap_or_default()
+ && summary.version
+ == record_string(record, "installedVersion").unwrap_or_default()
+ });
+ if already_visible {
+ continue;
+ }
+ let summary = build_summary(
+ &package_root,
+ record_string(record, "sourceKind").unwrap_or_else(|| "local".to_string()),
+ record_string(record, "sourceUri").unwrap_or_default(),
+ Some(record),
+ )?;
+ if matches_query(&summary, params.query.as_deref())
+ && matches_source(&summary, params.source.as_deref())
+ {
+ summaries.push(summary);
+ }
+ }
+ }
+
+ summaries.sort_by(|left, right| left.id.cmp(&right.id));
+ summaries.dedup_by(|left, right| left.id == right.id && left.version == right.version);
+ Ok(PluginCatalogListResponse {
+ plugins: summaries,
+ generated_at: now_iso(),
+ })
+}
+
+pub(crate) fn installed(
+ plugin_data_root: &Path,
+ _params: PluginCatalogInstalledParams,
+) -> Result {
+ let mut summaries = Vec::new();
+ for record in read_installed_records(plugin_data_root)?.values() {
+ let package_root = installed_package_root(plugin_data_root, record)?;
+ summaries.push(build_summary(
+ &package_root,
+ record_string(record, "sourceKind").unwrap_or_else(|| "local".to_string()),
+ record_string(record, "sourceUri").unwrap_or_default(),
+ Some(record),
+ )?);
+ }
+ summaries.sort_by(|left, right| left.id.cmp(&right.id));
+ Ok(PluginCatalogListResponse {
+ plugins: summaries,
+ generated_at: now_iso(),
+ })
+}
+
+pub(crate) fn enabled_activation_descriptors(
+ plugin_data_root: &Path,
+) -> Result, String> {
+ let mut activations = Vec::new();
+ for record in read_installed_records(plugin_data_root)?.values() {
+ if !record
+ .get("enabled")
+ .and_then(Value::as_bool)
+ .unwrap_or(false)
+ {
+ continue;
+ }
+ let package_root = installed_package_root(plugin_data_root, record)?;
+ let manifest = read_manifest(&package_root)?;
+ let plugin_id = manifest_name(&manifest)?;
+ let version = manifest_version(&manifest)?;
+ let runtime_capabilities = serde_json::json!({
+ "schemaVersion": "plugin-runtime-capabilities/v2",
+ "pluginId": plugin_id.clone(),
+ "version": version.clone(),
+ "packageSourceUri": package_root.to_string_lossy(),
+ "skills": runtime_skill_capabilities(&package_root, &manifest)?,
+ "mcpServers": plugin_mcp_server_descriptors(&package_root, &manifest, &plugin_id)?
+ .into_iter()
+ .map(|server| serde_json::json!({
+ "id": server.id,
+ "runtimeName": server.runtime_name,
+ "source": "plugin-v2-installed"
+ }))
+ .collect::>(),
+ "mcpBindings": [],
+ });
+ activations.push(serde_json::json!({
+ "schemaVersion": "plugin-activation/v2",
+ "pluginId": plugin_id,
+ "version": version,
+ "contentDigest": record_string(record, "contentDigest"),
+ "marketplaceId": record_string(record, "marketplaceId"),
+ "packageSourceUri": package_root.to_string_lossy(),
+ "sourceUri": record_string(record, "sourceUri"),
+ "runtimeCapabilities": runtime_capabilities,
+ }));
+ }
+ Ok(activations)
+}
+
+pub(crate) fn list_plugin_mcp_runtime_server_specs(
+ plugin_data_root: &Path,
+) -> Result, String> {
+ let mut specs = Vec::new();
+ for record in read_installed_records(plugin_data_root)?.values() {
+ if !record
+ .get("enabled")
+ .and_then(Value::as_bool)
+ .unwrap_or(false)
+ {
+ continue;
+ }
+
+ let package_root = installed_package_root(plugin_data_root, record)?;
+ let manifest = read_manifest(&package_root)?;
+ let plugin_id = manifest_name(&manifest)?;
+ for server in plugin_mcp_server_descriptors(&package_root, &manifest, &plugin_id)? {
+ specs.push(McpRuntimeServerSpec {
+ name: server.runtime_name,
+ plugin_id: Some(plugin_id.clone()),
+ config: server.config,
+ });
+ }
+ }
+ specs.sort_by(|left, right| left.name.cmp(&right.name));
+ Ok(specs)
+}
+
+struct PluginMcpServerDescriptor {
+ id: String,
+ runtime_name: String,
+ config: McpServerConfig,
+}
+
+fn plugin_mcp_server_descriptors(
+ package_root: &Path,
+ manifest: &Value,
+ plugin_id: &str,
+) -> Result, String> {
+ let package_root = canonical_package_root(package_root)?;
+ let source = match manifest.get("mcpServers") {
+ Some(Value::String(path)) => resource_path(&package_root, path).and_then(read_json_file),
+ Some(Value::Object(object)) => Ok(Value::Object(object.clone())),
+ Some(_) => Err("manifest.mcpServers 必须是包内路径或 object。".to_string()),
+ None => {
+ let default_path = package_root.join(DEFAULT_MCP_CONFIG_PATH);
+ if default_path.is_file() {
+ read_json_file(default_path)
+ } else {
+ return Ok(Vec::new());
+ }
+ }
+ }?;
+ let server_values = source
+ .get("mcpServers")
+ .and_then(Value::as_object)
+ .or_else(|| source.as_object())
+ .ok_or_else(|| "Plugin MCP 配置必须是 server object。".to_string())?;
+
+ let mut descriptors = Vec::new();
+ for (id, value) in server_values {
+ let runtime_name = plugin_mcp_runtime_name(plugin_id, id)?;
+ let mut config = match McpServerConfig::from_value(value.clone()) {
+ Ok(config) => config,
+ Err(error) => {
+ tracing::warn!(
+ plugin_id,
+ server_id = id,
+ %error,
+ "跳过非法 Plugin MCP server 配置"
+ );
+ continue;
+ }
+ };
+ if !config.enabled {
+ continue;
+ }
+ if let Err(error) = resolve_plugin_mcp_cwd(&package_root, &mut config) {
+ tracing::warn!(
+ plugin_id,
+ server_id = id,
+ %error,
+ "跳过 cwd 越界的 Plugin MCP server 配置"
+ );
+ continue;
+ }
+ descriptors.push(PluginMcpServerDescriptor {
+ id: id.clone(),
+ runtime_name,
+ config,
+ });
+ }
+ descriptors.sort_by(|left, right| left.id.cmp(&right.id));
+ Ok(descriptors)
+}
+
+fn plugin_mcp_runtime_name(plugin_id: &str, server_id: &str) -> Result {
+ let server_id = server_id.trim();
+ if server_id.is_empty() || server_id.len() > 96 || server_id.contains('\0') {
+ return Err(format!("Plugin MCP server id 非法: {server_id}"));
+ }
+ Ok(format!("plugin__{plugin_id}__{server_id}"))
+}
+
+fn resolve_plugin_mcp_cwd(package_root: &Path, config: &mut McpServerConfig) -> Result<(), String> {
+ let McpServerTransport::Stdio { cwd, .. } = &mut config.transport else {
+ return Ok(());
+ };
+ let configured_cwd = cwd.clone();
+ let candidate = configured_cwd
+ .as_deref()
+ .map(|value| {
+ let value = value.split('\0').next().unwrap_or_default().trim();
+ if value.is_empty() {
+ package_root.to_path_buf()
+ } else if Path::new(value).is_absolute() {
+ PathBuf::from(value)
+ } else {
+ package_root.join(value)
+ }
+ })
+ .unwrap_or_else(|| package_root.to_path_buf());
+ let resolved = fs::canonicalize(&candidate).map_err(io_error("解析 Plugin MCP cwd 失败"))?;
+ if !resolved.starts_with(package_root) || !resolved.is_dir() {
+ return Err(format!(
+ "Plugin MCP cwd 必须位于 package root 内且为目录: {}",
+ candidate.display()
+ ));
+ }
+ *cwd = Some(resolved.to_string_lossy().into_owned());
+ Ok(())
+}
+
+fn read_json_file(path: PathBuf) -> Result {
+ let metadata = fs::metadata(&path).map_err(io_error("读取 Plugin MCP 配置元数据失败"))?;
+ if metadata.len() > MAX_MANIFEST_BYTES {
+ return Err("Plugin MCP 配置超过大小限制。".to_string());
+ }
+ let content = fs::read_to_string(path).map_err(io_error("读取 Plugin MCP 配置失败"))?;
+ serde_json::from_str(&content).map_err(|error| format!("解析 Plugin MCP 配置失败: {error}"))
+}
+
+pub(crate) fn set_enabled(
+ plugin_data_root: &Path,
+ params: app_server_protocol::protocol::v2::PluginCatalogEnabledSetParams,
+) -> Result {
+ validate_plugin_id(¶ms.plugin_id)?;
+ let records = read_installed_records(plugin_data_root)?;
+ let mut record = records
+ .get(¶ms.plugin_id)
+ .cloned()
+ .ok_or_else(|| format!("Plugin 未安装: {}", params.plugin_id))?;
+ record
+ .as_object_mut()
+ .ok_or_else(|| "Plugin installed record 必须是对象。".to_string())?
+ .insert("enabled".to_string(), Value::Bool(params.enabled));
+ write_installed_record(plugin_data_root, &record)?;
+ let package_root = installed_package_root(plugin_data_root, &record)?;
+ let summary = build_summary(
+ &package_root,
+ record_string(&record, "sourceKind").unwrap_or_else(|| "local".to_string()),
+ record_string(&record, "sourceUri").unwrap_or_default(),
+ Some(&record),
+ )?;
+ Ok(app_server_protocol::protocol::v2::PluginCatalogEnabledSetResponse { plugin: summary })
+}
+
+pub(crate) fn read(
+ plugin_data_root: &Path,
+ params: PluginCatalogReadParams,
+) -> Result {
+ validate_plugin_id(¶ms.plugin_id)?;
+ let records = read_installed_records(plugin_data_root)?;
+ let record = records
+ .get(¶ms.plugin_id)
+ .ok_or_else(|| format!("Plugin 未安装: {}", params.plugin_id))?;
+ let package_root = installed_package_root(plugin_data_root, record)?;
+ let summary = build_summary(
+ &package_root,
+ record_string(record, "sourceKind").unwrap_or_else(|| "local".to_string()),
+ record_string(record, "sourceUri").unwrap_or_default(),
+ Some(record),
+ )?;
+ Ok(PluginCatalogReadResponse {
+ plugin: build_detail(&package_root, summary)?,
+ })
+}
+
+pub(crate) fn install(
+ plugin_data_root: &Path,
+ params: PluginCatalogInstallParams,
+) -> Result {
+ let source_root = canonical_package_root(Path::new(¶ms.source_path))?;
+ let store_root = plugin_data_root.join(V2_STORE_DIR);
+ let canonical_store =
+ fs::canonicalize(plugin_data_root).unwrap_or_else(|_| plugin_data_root.to_path_buf());
+ if source_root.starts_with(&canonical_store) {
+ return Err("Plugin source 不能位于 v2 installed store 内。".to_string());
+ }
+ let manifest = read_manifest(&source_root)?;
+ let plugin_id = manifest_name(&manifest)?;
+ let version = manifest_version(&manifest)?;
+ let content_digest = package_digest(&source_root)?;
+ if params
+ .expected_digest
+ .as_deref()
+ .is_some_and(|expected| expected != content_digest)
+ {
+ return Err(format!(
+ "Plugin package digest 不一致: expected {}, actual {content_digest}",
+ params.expected_digest.as_deref().unwrap_or_default()
+ ));
+ }
+ let installed_records = read_installed_records(plugin_data_root)?;
+ let previous_record = installed_records.get(&plugin_id).cloned();
+ if let Some(record) = previous_record.as_ref() {
+ let installed_version = record_string(record, "installedVersion").unwrap_or_default();
+ let installed_digest = record_string(record, "contentDigest")
+ .or_else(|| {
+ installed_package_root(plugin_data_root, record)
+ .ok()
+ .and_then(|root| package_digest(&root).ok())
+ })
+ .unwrap_or_default();
+ if installed_version == version {
+ if installed_digest == content_digest {
+ let package_root = installed_package_root(plugin_data_root, record)?;
+ return Ok(PluginCatalogInstallResponse {
+ plugin: build_summary(
+ &package_root,
+ record_string(record, "sourceKind").unwrap_or_else(|| "local".to_string()),
+ record_string(record, "sourceUri").unwrap_or_default(),
+ Some(record),
+ )?,
+ });
+ }
+ return Err(format!(
+ "Plugin package identity 冲突: {plugin_id}@{version} 的 content digest 不同"
+ ));
+ }
+ }
+ let final_root = store_root
+ .join(PACKAGES_DIR)
+ .join(&plugin_id)
+ .join(&version);
+ if final_root.exists() {
+ let existing_digest = package_digest(&final_root)?;
+ if existing_digest != content_digest {
+ return Err(format!(
+ "Plugin package 已存在且 digest 冲突: {plugin_id}@{version}"
+ ));
+ }
+ }
+
+ let staging_root = store_root.join(STAGING_DIR).join(format!(
+ "{plugin_id}-{version}-{}",
+ Utc::now().timestamp_nanos_opt().unwrap_or_default()
+ ));
+ fs::create_dir_all(&staging_root).map_err(io_error("创建 Plugin staging 目录失败"))?;
+ if let Err(error) = copy_package_tree(&source_root, &staging_root) {
+ let _ = fs::remove_dir_all(&staging_root);
+ return Err(error);
+ }
+ let _staging_manifest = match read_manifest(&staging_root) {
+ Ok(manifest) => manifest,
+ Err(error) => {
+ let _ = fs::remove_dir_all(&staging_root);
+ return Err(format!("校验 Plugin staging manifest 失败: {error}"));
+ }
+ };
+ if let Err(error) = build_summary(&staging_root, "local", ¶ms.source_path, None) {
+ let _ = fs::remove_dir_all(&staging_root);
+ return Err(format!("校验 Plugin staging capability 失败: {error}"));
+ }
+ fs::create_dir_all(final_root.parent().expect("version has parent"))
+ .map_err(io_error("创建 Plugin package 目录失败"))?;
+ if package_digest(&staging_root)? != content_digest {
+ let _ = fs::remove_dir_all(&staging_root);
+ return Err("Plugin staging digest 与 source 不一致。".to_string());
+ }
+ if !final_root.exists() {
+ fs::rename(&staging_root, &final_root).map_err(io_error("提交 Plugin package 失败"))?;
+ } else {
+ fs::remove_dir_all(&staging_root).map_err(io_error("清理 Plugin staging 目录失败"))?;
+ }
+
+ let package_root_locator = format!("{PACKAGES_DIR}/{plugin_id}/{version}");
+ let source_kind = params.source.as_deref().unwrap_or("local");
+ let marketplace_id = params.marketplace_id.as_deref().unwrap_or(source_kind);
+ let record = serde_json::json!({
+ "schemaVersion": INSTALLED_SCHEMA_VERSION,
+ "pluginId": plugin_id.clone(),
+ "installedVersion": version.clone(),
+ "marketplaceId": marketplace_id,
+ "contentDigest": content_digest,
+ "sourceKind": source_kind,
+ "sourceUri": params.source_path,
+ "enabled": true,
+ "installedAt": now_iso(),
+ "packageRoot": package_root_locator,
+ });
+ if let Err(error) = write_installed_record(plugin_data_root, &record) {
+ if previous_record.is_none() {
+ let _ = fs::remove_dir_all(&final_root);
+ }
+ return Err(error);
+ }
+ if let Some(previous_record) = previous_record.as_ref() {
+ if let Ok(previous_root) = installed_package_root(plugin_data_root, previous_record) {
+ if previous_root != final_root && previous_root.exists() {
+ fs::remove_dir_all(previous_root)
+ .map_err(io_error("清理 Plugin 旧版本 package 失败"))?;
+ }
+ }
+ }
+ let summary = build_summary(
+ &final_root,
+ "local",
+ record_string(&record, "sourceUri")
+ .as_deref()
+ .unwrap_or_default(),
+ Some(&record),
+ )?;
+ Ok(PluginCatalogInstallResponse { plugin: summary })
+}
+
+pub(crate) fn uninstall(
+ plugin_data_root: &Path,
+ params: PluginCatalogUninstallParams,
+) -> Result {
+ validate_plugin_id(¶ms.plugin_id)?;
+ let records = read_installed_records(plugin_data_root)?;
+ let Some(record) = records.get(¶ms.plugin_id) else {
+ return Ok(PluginCatalogUninstallResponse {
+ plugin_id: params.plugin_id,
+ uninstalled: false,
+ });
+ };
+ let package_root = installed_package_root(plugin_data_root, record)?;
+ if package_root.exists() {
+ fs::remove_dir_all(&package_root).map_err(io_error("删除 Plugin package 失败"))?;
+ }
+ let record_path = installed_record_path(plugin_data_root, ¶ms.plugin_id)?;
+ if record_path.exists() {
+ fs::remove_file(record_path).map_err(io_error("删除 Plugin installed record 失败"))?;
+ }
+ Ok(PluginCatalogUninstallResponse {
+ plugin_id: params.plugin_id,
+ uninstalled: true,
+ })
+}
+
+fn build_summary, U: AsRef>(
+ package_root: &Path,
+ source: S,
+ source_uri: U,
+ installed_record: Option<&Value>,
+) -> Result {
+ let manifest = read_manifest(package_root)?;
+ let plugin_id = manifest_name(&manifest)?;
+ let version = manifest_version(&manifest)?;
+ let detail = build_capability_detail(package_root, &manifest)?;
+ let installed = installed_record.is_some();
+ let enabled = installed_record
+ .and_then(|record| record.get("enabled"))
+ .and_then(Value::as_bool)
+ .unwrap_or(false);
+ Ok(PluginCatalogSummary {
+ name: interface_string(&manifest, "displayName").unwrap_or_else(|| plugin_id.clone()),
+ id: plugin_id,
+ version,
+ marketplace_id: installed_record
+ .and_then(|record| record_string(record, "marketplaceId"))
+ .unwrap_or_else(|| source.as_ref().to_string()),
+ content_digest: match installed_record
+ .and_then(|record| record_string(record, "contentDigest"))
+ {
+ Some(content_digest) => content_digest,
+ None => package_digest(package_root)?,
+ },
+ description: interface_string(&manifest, "shortDescription")
+ .or_else(|| manifest_string(&manifest, "description"))
+ .unwrap_or_default(),
+ source: source.as_ref().to_string(),
+ source_uri: source_uri.as_ref().to_string(),
+ installed,
+ enabled,
+ install_policy: "AVAILABLE".to_string(),
+ auth_policy: "ON_USE".to_string(),
+ availability: if installed { "installed" } else { "available" }.to_string(),
+ disabled_reason: if installed && !enabled {
+ Some("Plugin 当前未启用。".to_string())
+ } else {
+ None
+ },
+ local_version: installed_record
+ .and_then(|record| record_string(record, "installedVersion")),
+ skills_count: detail.skills.len() as u32,
+ mcp_servers_count: detail.mcp_servers.len() as u32,
+ apps_count: detail.apps.len() as u32,
+ hooks_count: detail.hooks.len() as u32,
+ })
+}
+
+fn build_detail(
+ package_root: &Path,
+ summary: PluginCatalogSummary,
+) -> Result {
+ let manifest = read_manifest(package_root)?;
+ let detail = build_capability_detail(package_root, &manifest)?;
+ Ok(PluginCatalogDetail {
+ summary,
+ skills: detail.skills,
+ mcp_servers: detail.mcp_servers,
+ apps: detail.apps,
+ hooks: detail.hooks,
+ ui_resources: detail.ui_resources,
+ })
+}
+
+struct CapabilityDetail {
+ skills: Vec,
+ mcp_servers: Vec,
+ apps: Vec,
+ hooks: Vec,
+ ui_resources: Vec,
+}
+
+fn build_capability_detail(
+ package_root: &Path,
+ manifest: &Value,
+) -> Result {
+ let skills = skill_capabilities(package_root, manifest)?;
+ let mcp_servers = object_capabilities(package_root, manifest.get("mcpServers"), "MCP")?;
+ let apps = object_capabilities(package_root, manifest.get("apps"), "App")?;
+ let hooks = hook_capabilities(package_root, manifest.get("hooks"))?;
+ let ui_resources = interface_resources(package_root, manifest)?;
+ Ok(CapabilityDetail {
+ skills,
+ mcp_servers,
+ apps,
+ hooks,
+ ui_resources,
+ })
+}
+
+fn skill_capabilities(
+ package_root: &Path,
+ manifest: &Value,
+) -> Result, String> {
+ let value = manifest.get("skills");
+ let Some(value) = value else {
+ return Ok(Vec::new());
+ };
+ let path = value
+ .as_str()
+ .ok_or_else(|| "manifest.skills 必须是包内相对路径。".to_string())?;
+ let root = resource_path(package_root, path)?;
+ let mut capabilities = Vec::new();
+ if root.is_dir() {
+ for entry in fs::read_dir(&root).map_err(io_error("读取 Plugin skills 目录失败"))? {
+ let entry = entry.map_err(io_error("读取 Plugin skill 条目失败"))?;
+ let path = entry.path();
+ if path.is_dir() && path.join("SKILL.md").is_file() {
+ let id = entry.file_name().to_string_lossy().into_owned();
+ capabilities.push(PluginCatalogCapability {
+ id: id.clone(),
+ name: id,
+ description: String::new(),
+ requires_auth: false,
+ });
+ }
+ }
+ }
+ capabilities.sort_by(|left, right| left.id.cmp(&right.id));
+ Ok(capabilities)
+}
+
+fn runtime_skill_capabilities(package_root: &Path, manifest: &Value) -> Result, String> {
+ let Some(value) = manifest.get("skills") else {
+ return Ok(Vec::new());
+ };
+ let path = value
+ .as_str()
+ .ok_or_else(|| "manifest.skills 必须是包内相对路径。".to_string())?;
+ let root = resource_path(package_root, path)?;
+ if !root.is_dir() {
+ return Ok(Vec::new());
+ }
+ let mut capabilities = Vec::new();
+ for entry in fs::read_dir(&root).map_err(io_error("读取 Plugin skills 目录失败"))? {
+ let entry = entry.map_err(io_error("读取 Plugin skill 条目失败"))?;
+ let skill_root = entry.path();
+ if !skill_root.is_dir() || !skill_root.join("SKILL.md").is_file() {
+ continue;
+ }
+ let id = entry.file_name().to_string_lossy().into_owned();
+ capabilities.push(serde_json::json!({
+ "id": id,
+ "title": id,
+ "path": path,
+ "activation": "available",
+ "required": false,
+ "promptInjectionPolicy": {
+ "mode": "available",
+ "source": "plugin-v2-installed"
+ }
+ }));
+ }
+ capabilities.sort_by(|left, right| {
+ left.get("id")
+ .and_then(Value::as_str)
+ .cmp(&right.get("id").and_then(Value::as_str))
+ });
+ Ok(capabilities)
+}
+
+fn object_capabilities(
+ package_root: &Path,
+ value: Option<&Value>,
+ kind: &str,
+) -> Result, String> {
+ let Some(value) = value else {
+ return Ok(Vec::new());
+ };
+ let object = if let Some(path) = value.as_str() {
+ let path = resource_path(package_root, path)?;
+ let content =
+ fs::read_to_string(path).map_err(io_error("读取 Plugin capability 文件失败"))?;
+ serde_json::from_str::(&content)
+ .map_err(|error| format!("解析 Plugin {kind} 配置失败: {error}"))?
+ } else {
+ value.clone()
+ };
+ let object = object.get("mcpServers").unwrap_or(&object);
+ let Some(object) = object.as_object() else {
+ return Ok(Vec::new());
+ };
+ Ok(object
+ .keys()
+ .map(|id| PluginCatalogCapability {
+ id: id.clone(),
+ name: id.clone(),
+ description: String::new(),
+ requires_auth: false,
+ })
+ .collect())
+}
+
+fn hook_capabilities(
+ package_root: &Path,
+ value: Option<&Value>,
+) -> Result, String> {
+ let Some(value) = value else {
+ return Ok(Vec::new());
+ };
+ let value = if let Some(path) = value.as_str() {
+ let path = resource_path(package_root, path)?;
+ let content = fs::read_to_string(path).map_err(io_error("读取 Plugin hooks 配置失败"))?;
+ serde_json::from_str::(&content)
+ .map_err(|error| format!("解析 Plugin hooks 配置失败: {error}"))?
+ } else {
+ value.clone()
+ };
+ let Some(object) = value.as_object() else {
+ return Ok(Vec::new());
+ };
+ Ok(object
+ .keys()
+ .map(|event| PluginCatalogHook {
+ id: event.clone(),
+ event: event.clone(),
+ })
+ .collect())
+}
+
+fn interface_resources(
+ package_root: &Path,
+ manifest: &Value,
+) -> Result, String> {
+ let Some(interface) = manifest.get("interface").and_then(Value::as_object) else {
+ return Ok(Vec::new());
+ };
+ let mut resources = Vec::new();
+ for (key, kind) in [
+ ("composerIcon", "composerIcon"),
+ ("logo", "logo"),
+ ("logoDark", "logoDark"),
+ ] {
+ if let Some(path) = interface.get(key).and_then(Value::as_str) {
+ let resolved = resource_path(package_root, path)?;
+ resources.push(PluginCatalogUiResource {
+ id: key.to_string(),
+ resource_uri: resolved
+ .strip_prefix(package_root)
+ .unwrap_or(&resolved)
+ .to_string_lossy()
+ .into_owned(),
+ kind: kind.to_string(),
+ });
+ }
+ }
+ if let Some(screenshots) = interface.get("screenshots").and_then(Value::as_array) {
+ for (index, path) in screenshots.iter().filter_map(Value::as_str).enumerate() {
+ let resolved = resource_path(package_root, path)?;
+ resources.push(PluginCatalogUiResource {
+ id: format!("screenshot-{index}"),
+ resource_uri: resolved
+ .strip_prefix(package_root)
+ .unwrap_or(&resolved)
+ .to_string_lossy()
+ .into_owned(),
+ kind: "screenshot".to_string(),
+ });
+ }
+ }
+ Ok(resources)
+}
+
+fn discover_package_roots(path: &Path) -> Result, String> {
+ let path = fs::canonicalize(path).map_err(io_error("解析 Plugin catalog source 失败"))?;
+ if path.is_file() {
+ let content = fs::read_to_string(&path).map_err(io_error("读取 marketplace 文件失败"))?;
+ let value: Value = serde_json::from_str(&content)
+ .map_err(|error| format!("解析 marketplace 文件失败: {error}"))?;
+ let source_root = marketplace_source_root(&path)?;
+ let canonical_source_root =
+ fs::canonicalize(&source_root).map_err(io_error("解析 marketplace root 失败"))?;
+ let mut roots = Vec::new();
+ for entry in value
+ .get("plugins")
+ .and_then(Value::as_array)
+ .into_iter()
+ .flatten()
+ {
+ let Some(relative) = entry
+ .get("source")
+ .and_then(Value::as_object)
+ .and_then(|source| source.get("path"))
+ .and_then(Value::as_str)
+ else {
+ continue;
+ };
+ let package_root = canonical_package_root(&source_root.join(relative))?;
+ if !package_root.starts_with(&canonical_source_root) {
+ return Err(format!("Plugin marketplace local source 越界: {relative}"));
+ }
+ roots.push(package_root);
+ }
+ return Ok(roots);
+ }
+ if path.join(".codex-plugin/plugin.json").is_file() {
+ return Ok(vec![path]);
+ }
+ let mut roots = Vec::new();
+ for entry in fs::read_dir(&path).map_err(io_error("读取 Plugin catalog 目录失败"))? {
+ let entry = entry.map_err(io_error("读取 Plugin catalog 条目失败"))?;
+ let child = entry.path();
+ if child.is_dir() && child.join(".codex-plugin/plugin.json").is_file() {
+ roots.push(child);
+ }
+ }
+ Ok(roots)
+}
+
+fn marketplace_source_root(path: &Path) -> Result {
+ for relative_path in [
+ ".agents/plugins/marketplace.json",
+ ".agents/plugins/api_marketplace.json",
+ ".claude-plugin/marketplace.json",
+ ".cursor-plugin/marketplace.json",
+ ] {
+ let relative_path = Path::new(relative_path);
+ if !path.ends_with(relative_path) {
+ continue;
+ }
+ let mut root = path.to_path_buf();
+ for _ in relative_path.components() {
+ root.pop();
+ }
+ return Ok(root);
+ }
+ path.parent()
+ .map(Path::to_path_buf)
+ .ok_or_else(|| "Plugin marketplace 文件缺少父目录。".to_string())
+}
+
+fn implicit_marketplace_paths(plugin_data_root: &Path) -> Vec {
+ let mut paths = Vec::new();
+ if let Some(path) = bundled_marketplace_path_from_env() {
+ paths.push(path);
+ }
+ if let Some(home) = dirs::home_dir() {
+ paths.push(home.join(".agents/plugins/marketplace.json"));
+ }
+ if let Ok(current_dir) = std::env::current_dir() {
+ paths.push(current_dir.join(".agents/plugins/marketplace.json"));
+ }
+ let configured_root = plugin_data_root.join(V2_STORE_DIR).join("marketplaces");
+ if let Ok(entries) = fs::read_dir(configured_root) {
+ paths.extend(entries.flatten().map(|entry| entry.path()).filter(|path| {
+ path.extension().and_then(|extension| extension.to_str()) == Some("json")
+ }));
+ }
+ paths.retain(|path| path.is_file());
+ paths.sort();
+ paths.dedup();
+ paths
+}
+
+fn bundled_marketplace_path_from_env() -> Option {
+ std::env::var_os("LIME_BUNDLED_PLUGIN_MARKETPLACE")
+ .map(PathBuf::from)
+ .filter(|path| path.is_file())
+}
+
+fn source_kind_for_marketplace_path(path: &Path) -> &'static str {
+ if bundled_marketplace_path_from_env().is_some_and(|bundled_path| path == bundled_path) {
+ return "bundled";
+ }
+ if dirs::home_dir()
+ .map(|home| path == home.join(".agents/plugins/marketplace.json"))
+ .unwrap_or(false)
+ {
+ return "personal";
+ }
+ if std::env::current_dir()
+ .map(|current_dir| path == current_dir.join(".agents/plugins/marketplace.json"))
+ .unwrap_or(false)
+ {
+ return "repo";
+ }
+ "local"
+}
+
+fn marketplace_id_for_path(path: &Path) -> Option {
+ let content = fs::read_to_string(path).ok()?;
+ let value: Value = serde_json::from_str(&content).ok()?;
+ value
+ .get("name")
+ .and_then(Value::as_str)
+ .map(str::trim)
+ .filter(|name| !name.is_empty())
+ .map(ToString::to_string)
+}
+
+fn read_manifest(package_root: &Path) -> Result {
+ let root = canonical_package_root(package_root)?;
+ let path = resource_path(&root, ".codex-plugin/plugin.json")?;
+ let metadata = fs::metadata(&path).map_err(io_error("读取 Plugin manifest 元数据失败"))?;
+ if metadata.len() > MAX_MANIFEST_BYTES {
+ return Err("Plugin manifest 超过大小限制。".to_string());
+ }
+ let content = fs::read_to_string(path).map_err(io_error("读取 Plugin manifest 失败"))?;
+ let value: Value = serde_json::from_str(&content)
+ .map_err(|error| format!("解析 Codex Plugin manifest 失败: {error}"))?;
+ if value.get("schemaVersion").is_some()
+ || value
+ .get("contributions")
+ .and_then(Value::as_object)
+ .is_some_and(|contributions| {
+ contributions.contains_key("runtime") || contributions.contains_key("workbench")
+ })
+ {
+ return Err(
+ "拒绝旧 Lime Plugin manifest;v2 只接受 .codex-plugin/plugin.json。".to_string(),
+ );
+ }
+ manifest_name(&value)?;
+ manifest_version(&value)?;
+ Ok(value)
+}
+
+fn manifest_name(manifest: &Value) -> Result {
+ let name = manifest_string(manifest, "name")
+ .ok_or_else(|| "Plugin manifest 缺少 name。".to_string())?;
+ validate_plugin_id(&name)?;
+ Ok(name)
+}
+
+fn manifest_version(manifest: &Value) -> Result {
+ let version = manifest_string(manifest, "version")
+ .ok_or_else(|| "Plugin manifest 缺少 version。".to_string())?;
+ Version::parse(&version).map_err(|error| format!("Plugin version 不是有效 semver: {error}"))?;
+ Ok(version)
+}
+
+fn package_digest(package_root: &Path) -> Result {
+ let package_root = canonical_package_root(package_root)?;
+ let mut files = Vec::new();
+ collect_package_files(&package_root, &package_root, &mut files)?;
+ files.sort_by(|left, right| left.0.cmp(&right.0));
+ let mut hasher = Sha256::new();
+ for (relative, path) in files {
+ hasher.update((relative.len() as u64).to_be_bytes());
+ hasher.update(relative.as_bytes());
+ let mut file = fs::File::open(path).map_err(io_error("读取 Plugin digest 文件失败"))?;
+ let mut buffer = [0u8; 64 * 1024];
+ loop {
+ let read = file
+ .read(&mut buffer)
+ .map_err(io_error("计算 Plugin package digest 失败"))?;
+ if read == 0 {
+ break;
+ }
+ hasher.update(&buffer[..read]);
+ }
+ }
+ Ok(format!("sha256:{}", hex::encode(hasher.finalize())))
+}
+
+fn collect_package_files(
+ package_root: &Path,
+ current: &Path,
+ files: &mut Vec<(String, PathBuf)>,
+) -> Result<(), String> {
+ for entry in fs::read_dir(current).map_err(io_error("读取 Plugin package 目录失败"))? {
+ let entry = entry.map_err(io_error("读取 Plugin package 条目失败"))?;
+ let path = entry.path();
+ let metadata =
+ fs::symlink_metadata(&path).map_err(io_error("读取 Plugin package 元数据失败"))?;
+ if metadata.file_type().is_symlink() {
+ return Err(format!(
+ "Plugin package 禁止包含符号链接: {}",
+ path.display()
+ ));
+ }
+ if metadata.is_dir() {
+ collect_package_files(package_root, &path, files)?;
+ } else if metadata.is_file() {
+ let relative = path
+ .strip_prefix(package_root)
+ .map_err(|error| format!("计算 Plugin package 相对路径失败: {error}"))?
+ .components()
+ .map(|component| component.as_os_str().to_string_lossy())
+ .collect::>()
+ .join("/");
+ files.push((relative, path));
+ }
+ }
+ Ok(())
+}
+
+fn manifest_string(value: &Value, key: &str) -> Option {
+ value
+ .get(key)
+ .and_then(Value::as_str)
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(ToString::to_string)
+}
+
+fn interface_string(manifest: &Value, key: &str) -> Option {
+ manifest
+ .get("interface")
+ .and_then(Value::as_object)
+ .and_then(|interface| interface.get(key))
+ .and_then(Value::as_str)
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(ToString::to_string)
+}
+
+fn validate_plugin_id(id: &str) -> Result<(), String> {
+ if id.is_empty()
+ || id.len() > 96
+ || !id
+ .chars()
+ .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
+ || id.starts_with('-')
+ || id.ends_with('-')
+ {
+ return Err(format!("Plugin name 不是合法 kebab-case identity: {id}"));
+ }
+ Ok(())
+}
+
+fn canonical_package_root(path: &Path) -> Result {
+ let root = fs::canonicalize(path).map_err(io_error("解析 Plugin package root 失败"))?;
+ if !root.is_dir() {
+ return Err(format!("Plugin package root 不是目录: {}", root.display()));
+ }
+ Ok(root)
+}
+
+fn resource_path(package_root: &Path, relative: &str) -> Result {
+ let relative_path = Path::new(relative);
+ if relative_path.is_absolute()
+ || relative_path.components().any(|component| {
+ matches!(
+ component,
+ Component::ParentDir | Component::RootDir | Component::Prefix(_)
+ )
+ })
+ {
+ return Err(format!("Plugin resource path 越界: {relative}"));
+ }
+ let package_root =
+ fs::canonicalize(package_root).map_err(io_error("解析 Plugin package root 失败"))?;
+ let resolved = fs::canonicalize(package_root.join(relative_path))
+ .map_err(io_error("解析 Plugin resource 失败"))?;
+ if !resolved.starts_with(&package_root) {
+ return Err(format!("Plugin resource path 越界: {relative}"));
+ }
+ Ok(resolved)
+}
+
+fn copy_package_tree(source_root: &Path, destination_root: &Path) -> Result<(), String> {
+ let mut files = 0usize;
+ let mut bytes = 0u64;
+ copy_package_entry(source_root, destination_root, &mut files, &mut bytes)
+}
+
+fn copy_package_entry(
+ source: &Path,
+ destination: &Path,
+ files: &mut usize,
+ bytes: &mut u64,
+) -> Result<(), String> {
+ let metadata =
+ fs::symlink_metadata(source).map_err(io_error("读取 Plugin package 条目失败"))?;
+ if metadata.file_type().is_symlink() {
+ return Err(format!(
+ "Plugin package 禁止包含符号链接: {}",
+ source.display()
+ ));
+ }
+ if metadata.is_dir() {
+ fs::create_dir_all(destination).map_err(io_error("创建 Plugin package 条目失败"))?;
+ for entry in fs::read_dir(source).map_err(io_error("读取 Plugin package 目录失败"))? {
+ let entry = entry.map_err(io_error("读取 Plugin package 条目失败"))?;
+ let entry_path = entry.path();
+ let destination_path = destination.join(entry.file_name());
+ copy_package_entry(&entry_path, &destination_path, files, bytes)?;
+ }
+ } else if metadata.is_file() {
+ *files += 1;
+ *bytes = bytes.saturating_add(metadata.len());
+ if *files > MAX_PACKAGE_FILES || *bytes > MAX_PACKAGE_BYTES {
+ return Err("Plugin package 超过文件数量或总大小限制。".to_string());
+ }
+ if let Some(parent) = destination.parent() {
+ fs::create_dir_all(parent).map_err(io_error("创建 Plugin package 文件目录失败"))?;
+ }
+ fs::copy(source, destination).map_err(io_error("复制 Plugin package 文件失败"))?;
+ }
+ Ok(())
+}
+
+fn read_installed_records(
+ plugin_data_root: &Path,
+) -> Result, String> {
+ let directory = plugin_data_root.join(V2_STORE_DIR).join(INSTALLED_DIR);
+ fs::create_dir_all(&directory).map_err(io_error("创建 Plugin v2 installed 目录失败"))?;
+ let mut records = std::collections::BTreeMap::new();
+ for entry in fs::read_dir(directory).map_err(io_error("读取 Plugin v2 installed 目录失败"))?
+ {
+ let entry = entry.map_err(io_error("读取 Plugin installed record 失败"))?;
+ if entry.path().extension().and_then(|value| value.to_str()) != Some("json") {
+ continue;
+ }
+ let content = fs::read_to_string(entry.path())
+ .map_err(io_error("读取 Plugin installed record 失败"))?;
+ let record: Value = serde_json::from_str(&content)
+ .map_err(|error| format!("解析 Plugin installed record 失败: {error}"))?;
+ if record.get("schemaVersion").and_then(Value::as_u64) != Some(INSTALLED_SCHEMA_VERSION) {
+ return Err(format!(
+ "不支持的 Plugin v2 installed schema: {}",
+ entry.path().display()
+ ));
+ }
+ let id = record_string(&record, "pluginId")
+ .ok_or_else(|| "Plugin installed record 缺少 pluginId。".to_string())?;
+ validate_plugin_id(&id)?;
+ records.insert(id, record);
+ }
+ Ok(records)
+}
+
+fn installed_record_path(plugin_data_root: &Path, plugin_id: &str) -> Result {
+ validate_plugin_id(plugin_id)?;
+ Ok(plugin_data_root
+ .join(V2_STORE_DIR)
+ .join(INSTALLED_DIR)
+ .join(format!("{plugin_id}.json")))
+}
+
+fn installed_package_root(plugin_data_root: &Path, record: &Value) -> Result {
+ let relative = record_string(record, "packageRoot")
+ .ok_or_else(|| "Plugin installed record 缺少 packageRoot。".to_string())?;
+ let path = resource_path(&plugin_data_root.join(V2_STORE_DIR), &relative)?;
+ let packages_root = fs::canonicalize(plugin_data_root.join(V2_STORE_DIR).join(PACKAGES_DIR))
+ .map_err(io_error("解析 Plugin package store 失败"))?;
+ if !path.starts_with(&packages_root) {
+ return Err("Plugin installed record 的 packageRoot 越界。".to_string());
+ }
+ Ok(path)
+}
+
+fn write_installed_record(plugin_data_root: &Path, record: &Value) -> Result<(), String> {
+ let id = record_string(record, "pluginId")
+ .ok_or_else(|| "Plugin installed record 缺少 pluginId。".to_string())?;
+ let path = installed_record_path(plugin_data_root, &id)?;
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent).map_err(io_error("创建 Plugin v2 installed 目录失败"))?;
+ }
+ let temporary = path.with_extension("json.tmp");
+ fs::write(
+ &temporary,
+ serde_json::to_vec_pretty(record)
+ .map_err(|error| format!("序列化 Plugin installed record 失败: {error}"))?,
+ )
+ .map_err(io_error("写入 Plugin installed record 失败"))?;
+ fs::rename(temporary, path).map_err(io_error("提交 Plugin installed record 失败"))
+}
+
+fn record_string(record: &Value, key: &str) -> Option {
+ record
+ .get(key)
+ .and_then(Value::as_str)
+ .map(ToString::to_string)
+}
+
+fn matches_query(summary: &PluginCatalogSummary, query: Option<&str>) -> bool {
+ let Some(query) = query.map(str::trim).filter(|query| !query.is_empty()) else {
+ return true;
+ };
+ let query = query.to_ascii_lowercase();
+ summary.id.to_ascii_lowercase().contains(&query)
+ || summary.name.to_ascii_lowercase().contains(&query)
+ || summary.description.to_ascii_lowercase().contains(&query)
+}
+
+fn matches_source(summary: &PluginCatalogSummary, source: Option<&str>) -> bool {
+ source
+ .map(str::trim)
+ .filter(|source| !source.is_empty())
+ .is_none_or(|source| summary.source == source)
+}
+
+fn io_error(prefix: &'static str) -> impl FnOnce(std::io::Error) -> String {
+ move |error| format!("{prefix}: {error}")
+}
+
+fn now_iso() -> String {
+ Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/lime-rs/crates/app-server/src/local_data_source/plugin_catalog/tests.rs b/lime-rs/crates/app-server/src/local_data_source/plugin_catalog/tests.rs
new file mode 100644
index 000000000..7e2ae53d0
--- /dev/null
+++ b/lime-rs/crates/app-server/src/local_data_source/plugin_catalog/tests.rs
@@ -0,0 +1,311 @@
+use super::*;
+use tempfile::TempDir;
+
+fn fixture(root: &Path) {
+ fs::create_dir_all(root.join(".codex-plugin")).unwrap();
+ fs::create_dir_all(root.join("skills/example")).unwrap();
+ fs::write(
+ root.join("skills/example/SKILL.md"),
+ "---\nname: example\n---\n",
+ )
+ .unwrap();
+ fs::write(
+ root.join(".codex-plugin/plugin.json"),
+ r#"{
+ "name": "example-plugin",
+ "version": "1.2.3",
+ "description": "Example",
+ "skills": "./skills/",
+ "interface": {"displayName": "Example Plugin"}
+ }"#,
+ )
+ .unwrap();
+}
+
+fn install_params(source: &Path) -> PluginCatalogInstallParams {
+ PluginCatalogInstallParams {
+ source_path: source.to_string_lossy().into_owned(),
+ marketplace_id: Some("test-marketplace".to_string()),
+ source: Some("repo".to_string()),
+ expected_digest: None,
+ }
+}
+
+#[test]
+fn installs_codex_manifest_into_v2_store() {
+ let temp = TempDir::new().unwrap();
+ let source = temp.path().join("source");
+ fixture(&source);
+ let data_root = temp.path().join("data");
+ let response = install(&data_root, install_params(&source)).unwrap();
+ assert_eq!(response.plugin.id, "example-plugin");
+ assert_eq!(response.plugin.marketplace_id, "test-marketplace");
+ assert!(response.plugin.content_digest.starts_with("sha256:"));
+ let detail = read(
+ &data_root,
+ PluginCatalogReadParams {
+ plugin_id: "example-plugin".to_string(),
+ },
+ )
+ .unwrap();
+ assert_eq!(detail.plugin.skills.len(), 1);
+ let activations = enabled_activation_descriptors(&data_root).unwrap();
+ assert_eq!(activations.len(), 1);
+ assert_eq!(activations[0]["pluginId"], "example-plugin");
+ assert_eq!(
+ activations[0]["runtimeCapabilities"]["skills"][0]["id"],
+ "example"
+ );
+ assert_eq!(
+ installed(&data_root, PluginCatalogInstalledParams {})
+ .unwrap()
+ .plugins
+ .len(),
+ 1
+ );
+ let disabled = set_enabled(
+ &data_root,
+ app_server_protocol::protocol::v2::PluginCatalogEnabledSetParams {
+ plugin_id: "example-plugin".to_string(),
+ enabled: false,
+ },
+ )
+ .unwrap();
+ assert!(!disabled.plugin.enabled);
+ assert!(enabled_activation_descriptors(&data_root)
+ .unwrap()
+ .is_empty());
+ assert!(
+ uninstall(
+ &data_root,
+ PluginCatalogUninstallParams {
+ plugin_id: "example-plugin".to_string()
+ }
+ )
+ .unwrap()
+ .uninstalled
+ );
+}
+
+#[test]
+fn repeated_install_is_idempotent_but_same_version_digest_conflict_fails() {
+ let temp = TempDir::new().unwrap();
+ let source = temp.path().join("source");
+ fixture(&source);
+ let data_root = temp.path().join("data");
+ let first = install(&data_root, install_params(&source)).unwrap();
+ let second = install(&data_root, install_params(&source)).unwrap();
+ assert_eq!(first.plugin.content_digest, second.plugin.content_digest);
+
+ fs::write(source.join("skills/example/SKILL.md"), "changed").unwrap();
+ let error = install(&data_root, install_params(&source)).unwrap_err();
+ assert!(error.contains("content digest 不同"));
+}
+
+#[test]
+fn expected_digest_and_semver_fail_closed() {
+ let temp = TempDir::new().unwrap();
+ let source = temp.path().join("source");
+ fixture(&source);
+ let data_root = temp.path().join("data");
+ let mut params = install_params(&source);
+ params.expected_digest = Some("sha256:wrong".to_string());
+ assert!(install(&data_root, params)
+ .unwrap_err()
+ .contains("digest 不一致"));
+
+ fs::write(
+ source.join(".codex-plugin/plugin.json"),
+ r#"{"name":"example-plugin","version":"1.2"}"#,
+ )
+ .unwrap();
+ assert!(read_manifest(&source).unwrap_err().contains("semver"));
+}
+
+#[test]
+fn rejects_legacy_manifest_and_parent_paths() {
+ let temp = TempDir::new().unwrap();
+ let root = temp.path().join("source");
+ fs::create_dir_all(root.join(".codex-plugin")).unwrap();
+ fs::write(
+ root.join(".codex-plugin/plugin.json"),
+ r#"{"name":"example-plugin","version":"1.0.0","schemaVersion":"lime.plugin.package.v1"}"#,
+ )
+ .unwrap();
+ assert!(read_manifest(&root).is_err());
+ assert!(resource_path(&root, "../outside").is_err());
+}
+
+#[test]
+fn resolves_codex_marketplace_sources_from_marketplace_root() {
+ let temp = TempDir::new().unwrap();
+ let marketplace_root = temp.path().join("openai-bundled");
+ let package_root = marketplace_root.join("plugins/browser");
+ fixture(&package_root);
+ let marketplace_path = marketplace_root.join(".agents/plugins/marketplace.json");
+ fs::create_dir_all(marketplace_path.parent().unwrap()).unwrap();
+ fs::write(
+ &marketplace_path,
+ r#"{
+ "name": "openai-bundled",
+ "plugins": [{
+ "name": "example-plugin",
+ "source": {"source": "local", "path": "./plugins/browser"}
+ }]
+ }"#,
+ )
+ .unwrap();
+
+ let response = list(
+ &temp.path().join("data"),
+ PluginCatalogListParams {
+ marketplace_paths: vec![marketplace_path.to_string_lossy().into_owned()],
+ ..Default::default()
+ },
+ )
+ .unwrap();
+
+ assert_eq!(response.plugins.len(), 1);
+ assert_eq!(response.plugins[0].id, "example-plugin");
+ assert_eq!(response.plugins[0].marketplace_id, "openai-bundled");
+}
+
+#[test]
+fn rejects_marketplace_local_source_outside_marketplace_root() {
+ let temp = TempDir::new().unwrap();
+ let marketplace_root = temp.path().join("marketplace");
+ let outside = temp.path().join("outside");
+ fixture(&outside);
+ let marketplace_path = marketplace_root.join(".agents/plugins/marketplace.json");
+ fs::create_dir_all(marketplace_path.parent().unwrap()).unwrap();
+ fs::write(
+ &marketplace_path,
+ r#"{
+ "name": "invalid-local-source",
+ "plugins": [{
+ "name": "example-plugin",
+ "source": {"source": "local", "path": "../outside"}
+ }]
+ }"#,
+ )
+ .unwrap();
+
+ let error = discover_package_roots(&marketplace_path).unwrap_err();
+ assert!(error.contains("local source 越界"), "{error}");
+}
+
+#[test]
+fn loads_default_mcp_file_and_resolves_cwd_to_installed_package_root() {
+ let temp = TempDir::new().unwrap();
+ let source = temp.path().join("source");
+ fixture(&source);
+ fs::create_dir_all(source.join("scripts")).unwrap();
+ fs::write(
+ source.join(".mcp.json"),
+ r#"{
+ "mcpServers": {
+ "demo": {"command": "demo-mcp", "cwd": "scripts"}
+ }
+ }"#,
+ )
+ .unwrap();
+ let data_root = temp.path().join("data");
+ install(&data_root, install_params(&source)).unwrap();
+
+ let specs = list_plugin_mcp_runtime_server_specs(&data_root).unwrap();
+ assert_eq!(specs.len(), 1);
+ assert_eq!(specs[0].name, "plugin__example-plugin__demo");
+ assert_eq!(specs[0].plugin_id.as_deref(), Some("example-plugin"));
+ assert_eq!(
+ specs[0].config.sanitized_cwd(),
+ Some(fs::canonicalize(data_root.join("v2/packages/example-plugin/1.2.3/scripts")).unwrap())
+ );
+ let activation = enabled_activation_descriptors(&data_root).unwrap();
+ assert_eq!(
+ activation[0]["runtimeCapabilities"]["mcpServers"][0]["runtimeName"],
+ "plugin__example-plugin__demo"
+ );
+}
+
+#[test]
+fn loads_inline_and_manifest_path_mcp_declarations() {
+ let temp = TempDir::new().unwrap();
+ let inline_source = temp.path().join("inline");
+ fixture(&inline_source);
+ fs::write(
+ inline_source.join(".codex-plugin/plugin.json"),
+ r#"{
+ "name": "inline-plugin",
+ "version": "1.0.0",
+ "mcpServers": {"inline": {"command": "inline-mcp"}}
+ }"#,
+ )
+ .unwrap();
+
+ let file_source = temp.path().join("file");
+ fixture(&file_source);
+ fs::write(
+ file_source.join(".codex-plugin/plugin.json"),
+ r#"{
+ "name": "file-plugin",
+ "version": "1.0.0",
+ "mcpServers": "./config/mcp.json"
+ }"#,
+ )
+ .unwrap();
+ fs::create_dir_all(file_source.join("config")).unwrap();
+ fs::write(
+ file_source.join("config/mcp.json"),
+ r#"{"mcpServers":{"file":{"command":"file-mcp"}}}"#,
+ )
+ .unwrap();
+
+ let data_root = temp.path().join("data");
+ install(&data_root, install_params(&inline_source)).unwrap();
+ install(&data_root, install_params(&file_source)).unwrap();
+ let specs = list_plugin_mcp_runtime_server_specs(&data_root).unwrap();
+ assert_eq!(
+ specs
+ .iter()
+ .map(|spec| spec.name.as_str())
+ .collect::>(),
+ vec!["plugin__file-plugin__file", "plugin__inline-plugin__inline"]
+ );
+}
+
+#[test]
+fn disabled_plugin_and_invalid_mcp_siblings_are_fail_closed() {
+ let temp = TempDir::new().unwrap();
+ let source = temp.path().join("source");
+ fixture(&source);
+ fs::create_dir_all(temp.path().join("outside")).unwrap();
+ fs::write(
+ source.join(".mcp.json"),
+ r#"{
+ "mcpServers": {
+ "valid": {"command": "valid-mcp"},
+ "invalid": {"url": "ftp://not-supported"},
+ "escape": {"command": "escape-mcp", "cwd": "../outside"}
+ }
+ }"#,
+ )
+ .unwrap();
+ let data_root = temp.path().join("data");
+ install(&data_root, install_params(&source)).unwrap();
+
+ let specs = list_plugin_mcp_runtime_server_specs(&data_root).unwrap();
+ assert_eq!(specs.len(), 1);
+ assert_eq!(specs[0].name, "plugin__example-plugin__valid");
+
+ set_enabled(
+ &data_root,
+ app_server_protocol::protocol::v2::PluginCatalogEnabledSetParams {
+ plugin_id: "example-plugin".to_string(),
+ enabled: false,
+ },
+ )
+ .unwrap();
+ assert!(list_plugin_mcp_runtime_server_specs(&data_root)
+ .unwrap()
+ .is_empty());
+}
diff --git a/lime-rs/crates/app-server/src/processor/dispatch.rs b/lime-rs/crates/app-server/src/processor/dispatch.rs
index 2fed9e0c0..5a54ff787 100644
--- a/lime-rs/crates/app-server/src/processor/dispatch.rs
+++ b/lime-rs/crates/app-server/src/processor/dispatch.rs
@@ -502,6 +502,24 @@ impl RequestProcessor {
METHOD_BROWSER_SESSION_ACTION_EXECUTE => self
.handle_browser_session_action_execute_impl(params)
.boxed(),
+ app_server_protocol::protocol::v2::METHOD_PLUGIN_LIST => {
+ self.handle_plugin_catalog_list_v2_impl(params).boxed()
+ }
+ app_server_protocol::protocol::v2::METHOD_PLUGIN_READ => {
+ self.handle_plugin_catalog_read_v2_impl(params).boxed()
+ }
+ app_server_protocol::protocol::v2::METHOD_PLUGIN_INSTALL => {
+ self.handle_plugin_catalog_install_v2_impl(params).boxed()
+ }
+ app_server_protocol::protocol::v2::METHOD_PLUGIN_UNINSTALL => {
+ self.handle_plugin_catalog_uninstall_v2_impl(params).boxed()
+ }
+ app_server_protocol::protocol::v2::METHOD_PLUGIN_INSTALLED => {
+ self.handle_plugin_catalog_installed_v2_impl(params).boxed()
+ }
+ app_server_protocol::protocol::v2::METHOD_PLUGIN_ENABLED_SET => self
+ .handle_plugin_catalog_enabled_set_v2_impl(params)
+ .boxed(),
METHOD_PLUGIN_LOCAL_PACKAGE_INSPECT => self
.handle_plugin_local_package_inspect_impl(params)
.boxed(),
diff --git a/lime-rs/crates/app-server/src/processor/dispatch/v2_ingress.rs b/lime-rs/crates/app-server/src/processor/dispatch/v2_ingress.rs
index 0eb6ae0a0..8a06800e7 100644
--- a/lime-rs/crates/app-server/src/processor/dispatch/v2_ingress.rs
+++ b/lime-rs/crates/app-server/src/processor/dispatch/v2_ingress.rs
@@ -78,6 +78,14 @@ pub(super) fn into_parts(
ClientRequest::ArtifactWrite { id, params } => parts(id, Method::ArtifactWrite, params),
ClientRequest::MediaRead { id, params } => parts(id, Method::MediaRead, params),
ClientRequest::ModelList { id, params } => parts(id, Method::ModelList, params),
+ ClientRequest::PluginList { id, params } => parts(id, Method::PluginList, params),
+ ClientRequest::PluginRead { id, params } => parts(id, Method::PluginRead, params),
+ ClientRequest::PluginInstall { id, params } => parts(id, Method::PluginInstall, params),
+ ClientRequest::PluginUninstall { id, params } => parts(id, Method::PluginUninstall, params),
+ ClientRequest::PluginInstalled { id, params } => parts(id, Method::PluginInstalled, params),
+ ClientRequest::PluginEnabledSet { id, params } => {
+ parts(id, Method::PluginEnabledSet, params)
+ }
ClientRequest::ThreadSettingsUpdate { id, params } => {
parts(id, Method::ThreadSettingsUpdate, params)
}
@@ -123,7 +131,9 @@ fn parts(
mod tests {
use super::*;
use app_server_protocol::protocol::v2::{
- METHOD_MEDIA_READ, METHOD_THREAD_READ, METHOD_THREAD_RESUME, METHOD_TURN_INTERRUPT,
+ METHOD_MEDIA_READ, METHOD_PLUGIN_ENABLED_SET, METHOD_PLUGIN_INSTALL,
+ METHOD_PLUGIN_INSTALLED, METHOD_PLUGIN_LIST, METHOD_PLUGIN_READ, METHOD_PLUGIN_UNINSTALL,
+ METHOD_THREAD_READ, METHOD_THREAD_RESUME, METHOD_TURN_INTERRUPT,
};
use app_server_protocol::RequestId;
use serde_json::json;
@@ -212,4 +222,28 @@ mod tests {
let (_, method, _) = into_parts(request).expect("lower request");
assert_eq!(method, METHOD_TURN_INTERRUPT);
}
+
+ #[test]
+ fn plugin_v2_methods_lower_through_typed_ingress() {
+ for (method, params) in [
+ (METHOD_PLUGIN_LIST, json!({})),
+ (METHOD_PLUGIN_READ, json!({ "pluginId": "demo" })),
+ (
+ METHOD_PLUGIN_INSTALL,
+ json!({ "sourcePath": "/tmp/demo-plugin" }),
+ ),
+ (METHOD_PLUGIN_UNINSTALL, json!({ "pluginId": "demo" })),
+ (METHOD_PLUGIN_INSTALLED, json!({})),
+ (
+ METHOD_PLUGIN_ENABLED_SET,
+ json!({ "pluginId": "demo", "enabled": true }),
+ ),
+ ] {
+ let request = decode(&request(method, params))
+ .expect("decode plugin request")
+ .expect("plugin method is v2");
+ let (_, lowered_method, _) = into_parts(request).expect("lower plugin request");
+ assert_eq!(lowered_method, method);
+ }
+ }
}
diff --git a/lime-rs/crates/app-server/src/processor/plugin.rs b/lime-rs/crates/app-server/src/processor/plugin.rs
index 6ad1b06c8..0c8ba7e8d 100644
--- a/lime-rs/crates/app-server/src/processor/plugin.rs
+++ b/lime-rs/crates/app-server/src/processor/plugin.rs
@@ -1,6 +1,10 @@
//! plugin domain handlers for the App Server processor.
use super::{dispatch_result, parse_params, to_jsonrpc_error, RequestProcessor, RpcDispatch};
+use app_server_protocol::protocol::v2::{
+ PluginCatalogEnabledSetParams, PluginCatalogInstallParams, PluginCatalogInstalledParams,
+ PluginCatalogListParams, PluginCatalogReadParams, PluginCatalogUninstallParams,
+};
use app_server_protocol::{
JsonRpcError, PluginFetchCloudPackageParams, PluginInstalledDisabledSetParams,
PluginInstalledSaveParams, PluginLocalPackageExportParams, PluginLocalPackageInspectParams,
@@ -9,6 +13,90 @@ use app_server_protocol::{
};
impl RequestProcessor {
+ pub(super) async fn handle_plugin_catalog_list_v2_impl(
+ &self,
+ params: Option,
+ ) -> Result {
+ self.ensure_initialized()?;
+ let params: PluginCatalogListParams = parse_params(params)?;
+ let response = self
+ .runtime
+ .list_plugin_catalog(params)
+ .await
+ .map_err(to_jsonrpc_error)?;
+ dispatch_result(response)
+ }
+
+ pub(super) async fn handle_plugin_catalog_read_v2_impl(
+ &self,
+ params: Option,
+ ) -> Result {
+ self.ensure_initialized()?;
+ let params: PluginCatalogReadParams = parse_params(params)?;
+ let response = self
+ .runtime
+ .read_plugin_catalog(params)
+ .await
+ .map_err(to_jsonrpc_error)?;
+ dispatch_result(response)
+ }
+
+ pub(super) async fn handle_plugin_catalog_install_v2_impl(
+ &self,
+ params: Option,
+ ) -> Result {
+ self.ensure_initialized()?;
+ let params: PluginCatalogInstallParams = parse_params(params)?;
+ let response = self
+ .runtime
+ .install_plugin_catalog(params)
+ .await
+ .map_err(to_jsonrpc_error)?;
+ dispatch_result(response)
+ }
+
+ pub(super) async fn handle_plugin_catalog_uninstall_v2_impl(
+ &self,
+ params: Option,
+ ) -> Result {
+ self.ensure_initialized()?;
+ let params: PluginCatalogUninstallParams = parse_params(params)?;
+ let response = self
+ .runtime
+ .uninstall_plugin_catalog(params)
+ .await
+ .map_err(to_jsonrpc_error)?;
+ dispatch_result(response)
+ }
+
+ pub(super) async fn handle_plugin_catalog_installed_v2_impl(
+ &self,
+ params: Option,
+ ) -> Result {
+ self.ensure_initialized()?;
+ let params: PluginCatalogInstalledParams = parse_params(params)?;
+ let response = self
+ .runtime
+ .list_plugin_catalog_installed(params)
+ .await
+ .map_err(to_jsonrpc_error)?;
+ dispatch_result(response)
+ }
+
+ pub(super) async fn handle_plugin_catalog_enabled_set_v2_impl(
+ &self,
+ params: Option,
+ ) -> Result {
+ self.ensure_initialized()?;
+ let params: PluginCatalogEnabledSetParams = parse_params(params)?;
+ let response = self
+ .runtime
+ .set_plugin_catalog_enabled(params)
+ .await
+ .map_err(to_jsonrpc_error)?;
+ dispatch_result(response)
+ }
+
pub(super) async fn handle_plugin_installed_list_impl(
&self,
) -> Result {
diff --git a/lime-rs/crates/app-server/src/processor/thread/projection.rs b/lime-rs/crates/app-server/src/processor/thread/projection.rs
index 0ee919d3e..3e2442328 100644
--- a/lime-rs/crates/app-server/src/processor/thread/projection.rs
+++ b/lime-rs/crates/app-server/src/processor/thread/projection.rs
@@ -408,6 +408,8 @@ fn project_item(item: canonical::ThreadItem) -> Result Result Result {
+ Err(RuntimeCoreError::Backend(
+ "runtime backend does not expose MCP resources".to_string(),
+ ))
+ }
+
async fn prepare_runtime_worker_artifact_events(
&self,
_request: &ExecutionRequest,
diff --git a/lime-rs/crates/app-server/src/runtime/app_data/plugins.rs b/lime-rs/crates/app-server/src/runtime/app_data/plugins.rs
index 88389e6b3..882e5baf6 100644
--- a/lime-rs/crates/app-server/src/runtime/app_data/plugins.rs
+++ b/lime-rs/crates/app-server/src/runtime/app_data/plugins.rs
@@ -1,8 +1,15 @@
use super::unavailable;
use super::NoopAppDataSource;
use super::RuntimeCoreError;
+use app_server_protocol::protocol::v2::{
+ PluginCatalogEnabledSetParams, PluginCatalogEnabledSetResponse, PluginCatalogInstallParams,
+ PluginCatalogInstallResponse, PluginCatalogInstalledParams, PluginCatalogListParams,
+ PluginCatalogListResponse, PluginCatalogReadParams, PluginCatalogReadResponse,
+ PluginCatalogUninstallParams, PluginCatalogUninstallResponse,
+};
use app_server_protocol::*;
use async_trait::async_trait;
+use serde_json::Value;
use std::path::PathBuf;
#[async_trait]
@@ -11,6 +18,52 @@ pub trait PluginDataSource: Send + Sync {
Err(unavailable("pluginData/root"))
}
+ async fn list_plugin_catalog(
+ &self,
+ _params: PluginCatalogListParams,
+ ) -> Result {
+ Err(unavailable("plugin/list"))
+ }
+
+ async fn read_plugin_catalog(
+ &self,
+ _params: PluginCatalogReadParams,
+ ) -> Result {
+ Err(unavailable("plugin/read"))
+ }
+
+ async fn install_plugin_catalog(
+ &self,
+ _params: PluginCatalogInstallParams,
+ ) -> Result {
+ Err(unavailable("plugin/install"))
+ }
+
+ async fn uninstall_plugin_catalog(
+ &self,
+ _params: PluginCatalogUninstallParams,
+ ) -> Result {
+ Err(unavailable("plugin/uninstall"))
+ }
+
+ async fn list_plugin_catalog_installed(
+ &self,
+ _params: PluginCatalogInstalledParams,
+ ) -> Result {
+ Err(unavailable("plugin/installed"))
+ }
+
+ async fn list_plugin_catalog_activations(&self) -> Result, RuntimeCoreError> {
+ Ok(Vec::new())
+ }
+
+ async fn set_plugin_catalog_enabled(
+ &self,
+ _params: PluginCatalogEnabledSetParams,
+ ) -> Result {
+ Err(unavailable("plugin/enabled/set"))
+ }
+
async fn list_plugin_installed(&self) -> Result {
Ok(PluginInstalledListResponse::default())
}
diff --git a/lime-rs/crates/app-server/src/runtime/canonical_thread_store.rs b/lime-rs/crates/app-server/src/runtime/canonical_thread_store.rs
index 6b0c7c665..5406a92e0 100644
--- a/lime-rs/crates/app-server/src/runtime/canonical_thread_store.rs
+++ b/lime-rs/crates/app-server/src/runtime/canonical_thread_store.rs
@@ -1,5 +1,6 @@
use std::any::Any;
use std::collections::{HashMap, HashSet};
+use std::time::Duration;
use agent_protocol::{
CollabAgentState, CollabAgentStatus, SortDirection, Thread, ThreadActiveFlag,
@@ -48,6 +49,7 @@ use queries::{
};
const MAX_PAGE_SIZE: u32 = 500;
+const THREAD_STORE_BUSY_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -374,6 +376,8 @@ impl ProjectionStore {
pub(super) fn open_thread_store(&self) -> ThreadStoreResult {
let conn = Connection::open(self.state_path()).map_err(store_error)?;
+ conn.busy_timeout(THREAD_STORE_BUSY_TIMEOUT)
+ .map_err(store_error)?;
conn.execute_batch("PRAGMA foreign_keys = ON;")
.map_err(store_error)?;
if self.state_path() != self.thread_history_path() {
diff --git a/lime-rs/crates/app-server/src/runtime/canonical_thread_store/persistence.rs b/lime-rs/crates/app-server/src/runtime/canonical_thread_store/persistence.rs
index 9e3c77f51..2536c1d9b 100644
--- a/lime-rs/crates/app-server/src/runtime/canonical_thread_store/persistence.rs
+++ b/lime-rs/crates/app-server/src/runtime/canonical_thread_store/persistence.rs
@@ -173,7 +173,7 @@ pub(super) fn apply_change_set(
upsert_turn(tx, turn, params.changes.sequence)?;
}
for item in ¶ms.changes.changed_items {
- upsert_item(tx, item)?;
+ upsert_item_snapshot(tx, item)?;
}
Ok(())
}
@@ -221,7 +221,7 @@ fn upsert_turn(tx: &Transaction<'_>, turn: &Turn, sequence: u64) -> ThreadStoreR
Ok(())
}
-fn upsert_item(tx: &Transaction<'_>, item: &ThreadItem) -> ThreadStoreResult<()> {
+fn upsert_item_snapshot(tx: &Transaction<'_>, item: &ThreadItem) -> ThreadStoreResult<()> {
if item.kind != item.payload_kind() {
return Err(error(format!(
"item {} kind does not match payload",
@@ -230,24 +230,20 @@ fn upsert_item(tx: &Transaction<'_>, item: &ThreadItem) -> ThreadStoreResult<()>
}
let existing = tx
.query_row(
- "SELECT turn_id, item_json FROM canonical_items WHERE thread_id = ?1 AND item_id = ?2",
+ "SELECT turn_id FROM canonical_items WHERE thread_id = ?1 AND item_id = ?2",
params![item.thread_id.as_str(), item.item_id.as_str()],
- |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
+ |row| row.get::<_, String>(0),
)
.optional()
.map_err(store_error)?;
- let snapshot = if let Some((existing_turn_id, json)) = existing {
+ if let Some(existing_turn_id) = existing {
if existing_turn_id != item.turn_id.as_str() {
return Err(error(format!(
"item {} changed turn identity",
item.item_id
)));
}
- let previous = decode_json(&json)?;
- super::super::thread_item_projection::merge_item_snapshot(previous, item.clone())
- } else {
- item.clone()
- };
+ }
tx.execute(
"INSERT INTO canonical_items (
thread_id, turn_id, item_id, ordinal, sequence, item_json
@@ -257,12 +253,12 @@ fn upsert_item(tx: &Transaction<'_>, item: &ThreadItem) -> ThreadStoreResult<()>
sequence = excluded.sequence,
item_json = excluded.item_json",
params![
- snapshot.thread_id.as_str(),
- snapshot.turn_id.as_str(),
- snapshot.item_id.as_str(),
- to_i64(snapshot.ordinal, "item ordinal")?,
- to_i64(snapshot.sequence, "item sequence")?,
- encode_json(&snapshot)?,
+ item.thread_id.as_str(),
+ item.turn_id.as_str(),
+ item.item_id.as_str(),
+ to_i64(item.ordinal, "item ordinal")?,
+ to_i64(item.sequence, "item sequence")?,
+ encode_json(item)?,
],
)
.map_err(|source| error(format!("cannot persist canonical item: {source}")))?;
diff --git a/lime-rs/crates/app-server/src/runtime/canonical_thread_store_tests.rs b/lime-rs/crates/app-server/src/runtime/canonical_thread_store_tests.rs
index 0906b8991..a3a735753 100644
--- a/lime-rs/crates/app-server/src/runtime/canonical_thread_store_tests.rs
+++ b/lime-rs/crates/app-server/src/runtime/canonical_thread_store_tests.rs
@@ -101,6 +101,17 @@ fn production_storage_paths_keep_physical_table_owners_separate() {
);
}
+#[test]
+fn canonical_store_connections_wait_for_transient_sqlite_locks() {
+ let (_temp, store) = store();
+ let conn = store.open_thread_store().expect("open canonical store");
+ let busy_timeout_ms = conn
+ .query_row("PRAGMA busy_timeout", [], |row| row.get::<_, u64>(0))
+ .expect("read SQLite busy timeout");
+
+ assert_eq!(busy_timeout_ms, 5_000);
+}
+
#[test]
fn queued_recovery_requires_a_current_canonical_thread_owner() {
let temp = tempfile::tempdir().expect("tempdir");
@@ -1835,6 +1846,190 @@ fn production_event_batches_create_and_incrementally_update_canonical_history()
));
}
+#[test]
+fn reasoning_deltas_persist_linearly_in_canonical_history_and_rollout() {
+ let (_temp, agent_root, store) = store_with_rollout();
+ let stored = StoredSession {
+ session: AgentSession {
+ session_id: "session-reasoning-linear".to_string(),
+ thread_id: "thread-reasoning-linear".to_string(),
+ app_id: "agent-chat".to_string(),
+ workspace_id: None,
+ business_object_ref: None,
+ status: AgentSessionStatus::Running,
+ created_at: "2026-08-05T00:00:00Z".to_string(),
+ updated_at: "2026-08-05T00:00:01Z".to_string(),
+ },
+ turns: Vec::new(),
+ turn_inputs: HashMap::new(),
+ turn_runtime_options: HashMap::new(),
+ events: Vec::new(),
+ output_blobs: HashMap::new(),
+ };
+ let fragments = [
+ "The", " user", " said", " hello", ".", " Respond", " briefly", " now",
+ ];
+
+ for (index, fragment) in fragments.iter().enumerate() {
+ let sequence = (index + 1) as u64;
+ store
+ .apply_canonical_events(
+ &stored,
+ &[AgentEvent {
+ event_id: format!("reasoning-event-{sequence}"),
+ sequence,
+ session_id: stored.session.session_id.clone(),
+ thread_id: Some(stored.session.thread_id.clone()),
+ turn_id: Some("turn-reasoning-linear".to_string()),
+ event_type: "reasoning.delta".to_string(),
+ timestamp: format!("2026-08-05T00:00:{sequence:02}Z"),
+ payload: json!({
+ "reasoningId": "reasoning-linear",
+ "delta": fragment,
+ }),
+ }],
+ )
+ .expect("persist reasoning delta");
+ }
+
+ let thread = block_on(store.read_thread(ReadThreadParams {
+ thread_id: ThreadId::new(&stored.session.thread_id),
+ include_archived: false,
+ turns_view: ThreadTurnsView::Full,
+ }))
+ .expect("read canonical reasoning thread")
+ .expect("canonical reasoning thread");
+ let reasoning_content = thread.turns[0]
+ .items
+ .iter()
+ .find_map(|item| match &item.payload {
+ ThreadItemPayload::Reasoning { content, .. } => Some(content.clone()),
+ _ => None,
+ })
+ .expect("canonical reasoning item");
+ assert_eq!(reasoning_content, fragments.map(str::to_string).to_vec());
+
+ let path = rollout_path(
+ &store,
+ &agent_root,
+ &ThreadId::new(&stored.session.thread_id),
+ );
+ let lines = rollout_lines(&path);
+ assert_eq!(lines.len(), fragments.len() + 1);
+ for (index, line) in lines.iter().skip(1).enumerate() {
+ let changes: ThreadHistoryChangeSet = serde_json::from_value(line["changes"].clone())
+ .expect("decode rollout history changes");
+ let content = changes
+ .changed_items
+ .iter()
+ .find_map(|item| match &item.payload {
+ ThreadItemPayload::Reasoning { content, .. } => Some(content),
+ _ => None,
+ })
+ .expect("rollout reasoning item");
+ assert_eq!(content.len(), index + 1);
+ assert_eq!(content.last().map(String::as_str), Some(fragments[index]));
+ }
+}
+
+#[test]
+fn empty_reasoning_completion_preserves_canonical_content_across_persistence() {
+ let (_temp, store) = store();
+ let stored = StoredSession {
+ session: AgentSession {
+ session_id: "session-reasoning-empty-completion".to_string(),
+ thread_id: "thread-reasoning-empty-completion".to_string(),
+ app_id: "agent-chat".to_string(),
+ workspace_id: None,
+ business_object_ref: None,
+ status: AgentSessionStatus::Running,
+ created_at: "2026-08-05T00:00:00Z".to_string(),
+ updated_at: "2026-08-05T00:00:01Z".to_string(),
+ },
+ turns: Vec::new(),
+ turn_inputs: HashMap::new(),
+ turn_runtime_options: HashMap::new(),
+ events: Vec::new(),
+ output_blobs: HashMap::new(),
+ };
+ let reasoning_item_id = "item_reasoning-turn-reasoning-empty-completion";
+ let event = |sequence, event_type: &str, payload| AgentEvent {
+ event_id: format!("reasoning-empty-event-{sequence}"),
+ sequence,
+ session_id: stored.session.session_id.clone(),
+ thread_id: Some(stored.session.thread_id.clone()),
+ turn_id: Some("turn-reasoning-empty-completion".to_string()),
+ event_type: event_type.to_string(),
+ timestamp: format!("2026-08-05T00:00:{sequence:02}Z"),
+ payload,
+ };
+
+ store
+ .apply_canonical_events(
+ &stored,
+ &[event(
+ 1,
+ "reasoning.delta",
+ json!({
+ "reasoningId": "reasoning-empty-completion",
+ "text": "inspect inputs"
+ }),
+ )],
+ )
+ .expect("persist reasoning content");
+ store
+ .apply_canonical_events(
+ &stored,
+ &[event(
+ 2,
+ "item.completed",
+ json!({
+ "item": {
+ "sessionId": stored.session.session_id.clone(),
+ "threadId": stored.session.thread_id.clone(),
+ "turnId": "turn-reasoning-empty-completion",
+ "itemId": reasoning_item_id,
+ "sequence": 2,
+ "ordinal": 2,
+ "createdAtMs": 2,
+ "updatedAtMs": 2,
+ "completedAtMs": 2,
+ "kind": "reasoning",
+ "status": "completed",
+ "payload": {
+ "type": "reasoning",
+ "summary": [],
+ "content": []
+ },
+ "metadata": {}
+ }
+ }),
+ )],
+ )
+ .expect("persist empty reasoning completion");
+
+ let thread = block_on(store.read_thread(ReadThreadParams {
+ thread_id: ThreadId::new(&stored.session.thread_id),
+ include_archived: false,
+ turns_view: ThreadTurnsView::Full,
+ }))
+ .expect("read persisted reasoning thread")
+ .expect("persisted reasoning thread");
+ let reasoning = thread.turns[0]
+ .items
+ .iter()
+ .find(|item| matches!(item.payload, ThreadItemPayload::Reasoning { .. }))
+ .expect("persisted reasoning item");
+ assert_eq!(reasoning.status, ItemStatus::Completed);
+ assert_eq!(
+ reasoning.payload,
+ ThreadItemPayload::Reasoning {
+ summary: Vec::new(),
+ content: vec!["inspect inputs".to_string()],
+ }
+ );
+}
+
#[test]
fn canonical_live_cold_and_replay_projections_are_equivalent() {
let stored = StoredSession {
diff --git a/lime-rs/crates/app-server/src/runtime/conversation_import/codex/canonical_items.rs b/lime-rs/crates/app-server/src/runtime/conversation_import/codex/canonical_items.rs
index d21350ba4..971d9c2af 100644
--- a/lime-rs/crates/app-server/src/runtime/conversation_import/codex/canonical_items.rs
+++ b/lime-rs/crates/app-server/src/runtime/conversation_import/codex/canonical_items.rs
@@ -458,6 +458,8 @@ fn canonical_tool_payload(
call_id: call_id.to_string(),
server_name,
tool_name: mcp_tool_name,
+ mcp_app_resource_uri: None,
+ plugin_id: None,
arguments,
output,
},
diff --git a/lime-rs/crates/app-server/src/runtime/evidence_provider/canonical_tool.rs b/lime-rs/crates/app-server/src/runtime/evidence_provider/canonical_tool.rs
index 75a4332c7..4010fdb63 100644
--- a/lime-rs/crates/app-server/src/runtime/evidence_provider/canonical_tool.rs
+++ b/lime-rs/crates/app-server/src/runtime/evidence_provider/canonical_tool.rs
@@ -97,6 +97,7 @@ pub(super) fn canonical_tool(event: &AgentEvent) -> Option {
tool_name,
arguments,
output,
+ ..
} => (
call_id,
tool_name,
diff --git a/lime-rs/crates/app-server/src/runtime/mcp.rs b/lime-rs/crates/app-server/src/runtime/mcp.rs
index 4ba0dfeb4..6853c8aff 100644
--- a/lime-rs/crates/app-server/src/runtime/mcp.rs
+++ b/lime-rs/crates/app-server/src/runtime/mcp.rs
@@ -129,7 +129,17 @@ impl RuntimeCore {
&self,
params: McpResourceReadParams,
) -> Result {
- self.app_data_source.read_mcp_resource(params).await
+ match (¶ms.session_id, ¶ms.thread_id) {
+ (None, None) => self.app_data_source.read_mcp_resource(params).await,
+ (Some(session_id), Some(thread_id))
+ if !session_id.trim().is_empty() && !thread_id.trim().is_empty() =>
+ {
+ self.backend.read_mcp_runtime_resource(params).await
+ }
+ _ => Err(RuntimeCoreError::InvalidRequest(
+ "mcpResource/read sessionId and threadId must be provided together".to_string(),
+ )),
+ }
}
pub async fn subscribe_mcp_resource(
diff --git a/lime-rs/crates/app-server/src/runtime/output_refs/tests.rs b/lime-rs/crates/app-server/src/runtime/output_refs/tests.rs
index 7e9cac238..f824607ec 100644
--- a/lime-rs/crates/app-server/src/runtime/output_refs/tests.rs
+++ b/lime-rs/crates/app-server/src/runtime/output_refs/tests.rs
@@ -38,6 +38,8 @@ fn canonical_tool_payload(
call_id: "call-mcp-large".to_string(),
server_name: "files".to_string(),
tool_name: "read_resource".to_string(),
+ mcp_app_resource_uri: None,
+ plugin_id: None,
arguments: Vec::new(),
output,
},
diff --git a/lime-rs/crates/app-server/src/runtime/plugins.rs b/lime-rs/crates/app-server/src/runtime/plugins.rs
index edf3701f4..ccd9e286f 100644
--- a/lime-rs/crates/app-server/src/runtime/plugins.rs
+++ b/lime-rs/crates/app-server/src/runtime/plugins.rs
@@ -6,6 +6,17 @@ use super::plugin_task_runtime::{
use super::timestamp;
use super::RuntimeCore;
use super::RuntimeCoreError;
+use app_server_protocol::protocol::v2::PluginCatalogEnabledSetParams;
+use app_server_protocol::protocol::v2::PluginCatalogEnabledSetResponse;
+use app_server_protocol::protocol::v2::PluginCatalogInstallParams;
+use app_server_protocol::protocol::v2::PluginCatalogInstallResponse;
+use app_server_protocol::protocol::v2::PluginCatalogInstalledParams;
+use app_server_protocol::protocol::v2::PluginCatalogListParams;
+use app_server_protocol::protocol::v2::PluginCatalogListResponse;
+use app_server_protocol::protocol::v2::PluginCatalogReadParams;
+use app_server_protocol::protocol::v2::PluginCatalogReadResponse;
+use app_server_protocol::protocol::v2::PluginCatalogUninstallParams;
+use app_server_protocol::protocol::v2::PluginCatalogUninstallResponse;
use app_server_protocol::PluginFetchCloudPackageParams;
use app_server_protocol::PluginInstalledDisabledSetParams;
use app_server_protocol::PluginInstalledListResponse;
@@ -70,6 +81,52 @@ struct PluginShellDescriptorFields {
}
impl RuntimeCore {
+ pub async fn list_plugin_catalog(
+ &self,
+ params: PluginCatalogListParams,
+ ) -> Result {
+ self.app_data_source.list_plugin_catalog(params).await
+ }
+
+ pub async fn read_plugin_catalog(
+ &self,
+ params: PluginCatalogReadParams,
+ ) -> Result {
+ self.app_data_source.read_plugin_catalog(params).await
+ }
+
+ pub async fn install_plugin_catalog(
+ &self,
+ params: PluginCatalogInstallParams,
+ ) -> Result {
+ self.app_data_source.install_plugin_catalog(params).await
+ }
+
+ pub async fn uninstall_plugin_catalog(
+ &self,
+ params: PluginCatalogUninstallParams,
+ ) -> Result {
+ self.app_data_source.uninstall_plugin_catalog(params).await
+ }
+
+ pub async fn list_plugin_catalog_installed(
+ &self,
+ params: PluginCatalogInstalledParams,
+ ) -> Result {
+ self.app_data_source
+ .list_plugin_catalog_installed(params)
+ .await
+ }
+
+ pub async fn set_plugin_catalog_enabled(
+ &self,
+ params: PluginCatalogEnabledSetParams,
+ ) -> Result {
+ self.app_data_source
+ .set_plugin_catalog_enabled(params)
+ .await
+ }
+
pub(super) fn installed_plugin_runtime_dir(
&self,
state: &serde_json::Value,
diff --git a/lime-rs/crates/app-server/src/runtime/provider_history/canonical.rs b/lime-rs/crates/app-server/src/runtime/provider_history/canonical.rs
index c67a4cfb6..92267f446 100644
--- a/lime-rs/crates/app-server/src/runtime/provider_history/canonical.rs
+++ b/lime-rs/crates/app-server/src/runtime/provider_history/canonical.rs
@@ -342,6 +342,8 @@ mod tests {
call_id: "mcp-call".to_string(),
server_name: "docs".to_string(),
tool_name: tool_name.to_string(),
+ mcp_app_resource_uri: None,
+ plugin_id: None,
arguments: vec![ToolArgument {
name: "query".to_string(),
value: "\"fork\"".to_string(),
diff --git a/lime-rs/crates/app-server/src/runtime/read_model/canonical_items.rs b/lime-rs/crates/app-server/src/runtime/read_model/canonical_items.rs
index 05b143f4e..e572c5863 100644
--- a/lime-rs/crates/app-server/src/runtime/read_model/canonical_items.rs
+++ b/lime-rs/crates/app-server/src/runtime/read_model/canonical_items.rs
@@ -252,6 +252,8 @@ fn canonical_payload_to_agent_detail(
call_id,
server_name,
tool_name,
+ mcp_app_resource_uri,
+ plugin_id,
arguments,
output,
} => {
@@ -259,6 +261,12 @@ fn canonical_payload_to_agent_detail(
detail.insert("mcp_server".to_string(), json!(server_name));
detail.insert("tool_name".to_string(), json!(tool_name));
detail.insert("arguments".to_string(), json!(arguments));
+ if let Some(resource_uri) = mcp_app_resource_uri {
+ detail.insert("mcp_app_resource_uri".to_string(), json!(resource_uri));
+ }
+ if let Some(plugin_id) = plugin_id {
+ detail.insert("plugin_id".to_string(), json!(plugin_id));
+ }
if let Some(output) = output {
insert_tool_output(&mut detail, output);
}
diff --git a/lime-rs/crates/app-server/src/runtime/tests/agent_control/fork.rs b/lime-rs/crates/app-server/src/runtime/tests/agent_control/fork.rs
index 9603aeba7..976748652 100644
--- a/lime-rs/crates/app-server/src/runtime/tests/agent_control/fork.rs
+++ b/lime-rs/crates/app-server/src/runtime/tests/agent_control/fork.rs
@@ -173,6 +173,8 @@ async fn fork_uses_codex_sanitize_profile_for_internal_items() {
call_id: "mcp-call".to_string(),
server_name: "docs".to_string(),
tool_name: "search".to_string(),
+ mcp_app_resource_uri: None,
+ plugin_id: None,
arguments: Vec::new(),
output: Some(ToolOutput {
text: Some("mcp output".to_string()),
diff --git a/lime-rs/crates/app-server/src/runtime/thread_item_projection.rs b/lime-rs/crates/app-server/src/runtime/thread_item_projection.rs
index 56a86b399..25cee7e6e 100644
--- a/lime-rs/crates/app-server/src/runtime/thread_item_projection.rs
+++ b/lime-rs/crates/app-server/src/runtime/thread_item_projection.rs
@@ -7,7 +7,7 @@ pub(super) mod materializer;
pub(in crate::runtime) mod media_result;
mod plan;
-pub(in crate::runtime) use change_set::{merge_item_snapshot, merge_turn_snapshot};
+pub(in crate::runtime) use change_set::merge_turn_snapshot;
pub(in crate::runtime) use materializer::{materialize_events, IncrementalMaterializer};
#[cfg(test)]
diff --git a/lime-rs/crates/app-server/src/runtime/thread_item_projection/change_set.rs b/lime-rs/crates/app-server/src/runtime/thread_item_projection/change_set.rs
index 5a56f6783..1d10fda14 100644
--- a/lime-rs/crates/app-server/src/runtime/thread_item_projection/change_set.rs
+++ b/lime-rs/crates/app-server/src/runtime/thread_item_projection/change_set.rs
@@ -323,8 +323,16 @@ fn merge_payload(
) => match source_event_type {
Some("reasoning.final" | "item.completed" | "item.updated") => {
ThreadItemPayload::Reasoning {
- summary: next_summary,
- content: next_content,
+ summary: if next_summary.is_empty() {
+ summary
+ } else {
+ next_summary
+ },
+ content: if next_content.is_empty() {
+ content
+ } else {
+ next_content
+ },
}
}
_ => {
@@ -365,6 +373,8 @@ fn merge_payload(
call_id: previous_call_id,
server_name: previous_server_name,
tool_name: previous_tool_name,
+ mcp_app_resource_uri: previous_mcp_app_resource_uri,
+ plugin_id: previous_plugin_id,
arguments: previous_arguments,
output: previous_output,
},
@@ -372,6 +382,8 @@ fn merge_payload(
call_id,
server_name,
tool_name,
+ mcp_app_resource_uri,
+ plugin_id,
arguments,
output,
},
@@ -379,6 +391,8 @@ fn merge_payload(
call_id: prefer_string(previous_call_id, call_id, ""),
server_name: prefer_string(previous_server_name, server_name, "unknown"),
tool_name: prefer_string(previous_tool_name, tool_name, "tool"),
+ mcp_app_resource_uri: mcp_app_resource_uri.or(previous_mcp_app_resource_uri),
+ plugin_id: plugin_id.or(previous_plugin_id),
arguments: if arguments.is_empty() {
previous_arguments
} else {
diff --git a/lime-rs/crates/app-server/src/runtime/thread_item_projection/materializer/lowering.rs b/lime-rs/crates/app-server/src/runtime/thread_item_projection/materializer/lowering.rs
index 34d428934..a88cafdf6 100644
--- a/lime-rs/crates/app-server/src/runtime/thread_item_projection/materializer/lowering.rs
+++ b/lime-rs/crates/app-server/src/runtime/thread_item_projection/materializer/lowering.rs
@@ -244,6 +244,11 @@ pub(super) fn typed_payload(
.unwrap_or_else(|| "unknown".to_string()),
tool_name: map_string(payload, &["toolName", "tool_name", "name"])
.unwrap_or_else(|| "tool".to_string()),
+ mcp_app_resource_uri: map_string(
+ payload,
+ &["mcpAppResourceUri", "mcp_app_resource_uri"],
+ ),
+ plugin_id: map_string(payload, &["pluginId", "plugin_id"]),
arguments: tool_arguments(payload),
output: tool_output(payload),
},
diff --git a/lime-rs/crates/app-server/src/runtime/thread_item_projection/typed_tests/incremental.rs b/lime-rs/crates/app-server/src/runtime/thread_item_projection/typed_tests/incremental.rs
index 99babc170..988361504 100644
--- a/lime-rs/crates/app-server/src/runtime/thread_item_projection/typed_tests/incremental.rs
+++ b/lime-rs/crates/app-server/src/runtime/thread_item_projection/typed_tests/incremental.rs
@@ -106,6 +106,97 @@ fn reasoning_deltas_preserve_repeated_fragments_and_final_snapshot() {
);
}
+#[test]
+fn empty_reasoning_completion_preserves_materialized_content() {
+ let delta = materialize_events(
+ &[
+ event(
+ "reasoning-started",
+ 1,
+ "reasoning.started",
+ "turn-1",
+ json!({"reasoningId": "reasoning-1"}),
+ ),
+ event(
+ "reasoning-delta",
+ 2,
+ "reasoning.delta",
+ "turn-1",
+ json!({"reasoningId": "reasoning-1", "text": "inspect inputs"}),
+ ),
+ ],
+ "session-1",
+ "thread-1",
+ )
+ .expect("materialize reasoning delta");
+ let mut completed_item = delta
+ .changed_items
+ .into_iter()
+ .find(|item| {
+ matches!(
+ item.payload,
+ agent_protocol::ThreadItemPayload::Reasoning { .. }
+ )
+ })
+ .expect("reasoning delta item");
+ completed_item.status = agent_protocol::ItemStatus::Completed;
+ completed_item.completed_at_ms = Some(3);
+ completed_item.sequence = 3;
+ completed_item.updated_at_ms = 3;
+ completed_item.metadata = json!({"source_event_type": "item.completed"});
+ completed_item.payload = agent_protocol::ThreadItemPayload::Reasoning {
+ summary: Vec::new(),
+ content: Vec::new(),
+ };
+
+ let changes = materialize_events(
+ &[
+ event(
+ "reasoning-started",
+ 1,
+ "reasoning.started",
+ "turn-1",
+ json!({"reasoningId": "reasoning-1"}),
+ ),
+ event(
+ "reasoning-delta",
+ 2,
+ "reasoning.delta",
+ "turn-1",
+ json!({"reasoningId": "reasoning-1", "text": "inspect inputs"}),
+ ),
+ event(
+ "reasoning-completed",
+ 3,
+ "item.completed",
+ "turn-1",
+ json!({"item": serde_json::to_value(completed_item).expect("serialize item")}),
+ ),
+ ],
+ "session-1",
+ "thread-1",
+ )
+ .expect("materialize empty reasoning completion");
+ let reasoning = changes
+ .changed_items
+ .iter()
+ .find(|item| {
+ matches!(
+ item.payload,
+ agent_protocol::ThreadItemPayload::Reasoning { .. }
+ )
+ })
+ .expect("completed reasoning item");
+ assert_eq!(reasoning.status, agent_protocol::ItemStatus::Completed);
+ assert_eq!(
+ reasoning.payload,
+ agent_protocol::ThreadItemPayload::Reasoning {
+ summary: Vec::new(),
+ content: vec!["inspect inputs".to_string()],
+ }
+ );
+}
+
#[test]
fn incremental_materializer_does_not_revive_removed_item_identity() {
let started = event(
diff --git a/lime-rs/crates/app-server/src/runtime_backend.rs b/lime-rs/crates/app-server/src/runtime_backend.rs
index 2db22b8c2..53c1212a9 100644
--- a/lime-rs/crates/app-server/src/runtime_backend.rs
+++ b/lime-rs/crates/app-server/src/runtime_backend.rs
@@ -65,11 +65,14 @@ mod request_context;
pub(crate) use model_routing::configured_provider_readiness;
pub(crate) use provider_config::current_agent_runtime_config_metadata;
use provider_config::{initialize_runtime_database, model_effective_event_from_runtime};
+#[cfg(test)]
+use request_context::session_config_from_request;
use request_context::{
apply_app_server_turn_policy, direct_provider_config_from_request,
request_tool_policy_from_request, resolve_runtime_model_selection,
- runtime_request_from_request, service_tier_from_request, session_config_from_request,
- session_scope_from_request, should_use_compact_tool_surface,
+ runtime_request_from_request, service_tier_from_request,
+ session_config_from_request_with_plugin_activations, session_scope_from_request,
+ should_use_compact_tool_surface,
};
use route_support::{
agent_control_route_snapshot_for_resolved_route, durable_credential_ref_for_generation,
@@ -418,8 +421,15 @@ impl RuntimeBackend {
let soul_style = tool_process_metadata::SoulStyleMetadata::from_config_metadata(
config_metadata.as_ref(),
);
- let mention_selection =
- mention_selection::resolve_mentions(&request, self.current_app_data_source()?).await;
+ let plugin_activations = self.current_plugin_activations().await?;
+ let mention_selection = mention_selection::resolve_mentions(
+ &request,
+ self.current_app_data_source()?,
+ &plugin_activations,
+ )
+ .await;
+ let turn_plugin_activations =
+ mention_selection.plugin_activations_for_turn(&plugin_activations);
let mut emit_error = None;
let mut coding_event_mirror = coding_events::CodingEventMirror::default();
let mut proposed_plan_parser = proposed_plan_parser::ProposedPlanParser::default();
@@ -493,13 +503,14 @@ impl RuntimeBackend {
runtime_initialized = true;
}
- let mut session_config = session_config_from_request(
+ let mut session_config = session_config_from_request_with_plugin_activations(
&request,
host_request.as_ref(),
&session_scope,
&selection,
&request_tool_policy,
config_metadata.clone(),
+ &turn_plugin_activations,
);
mention_selection.apply_to_session_config(&mut session_config);
let model_context_window = lime_agent::model_request_policy_from_turn_context(
@@ -710,6 +721,13 @@ impl RuntimeBackend {
})
.map(|guard| guard.clone())
}
+
+ async fn current_plugin_activations(&self) -> Result, RuntimeCoreError> {
+ let Some(app_data_source) = self.current_app_data_source()? else {
+ return Ok(Vec::new());
+ };
+ app_data_source.list_plugin_catalog_activations().await
+ }
}
fn backend_error(error: impl std::fmt::Display) -> RuntimeCoreError {
diff --git a/lime-rs/crates/app-server/src/runtime_backend/execution_backend.rs b/lime-rs/crates/app-server/src/runtime_backend/execution_backend.rs
index a0a137d9a..da006a0b5 100644
--- a/lime-rs/crates/app-server/src/runtime_backend/execution_backend.rs
+++ b/lime-rs/crates/app-server/src/runtime_backend/execution_backend.rs
@@ -1,7 +1,7 @@
use super::{
action_response, current_agent_runtime_config_metadata, initialize_runtime_database,
- plugin_worker_generation, request_context::effective_runtime_options_for_turn, tool_inventory,
- workspace_patch_host_execution, RuntimeBackend,
+ mcp_bridges, plugin_worker_generation, request_context::effective_runtime_options_for_turn,
+ tool_inventory, workspace_patch_host_execution, RuntimeBackend,
};
use crate::runtime::ToolInventoryReadRequest;
use crate::{
@@ -346,6 +346,43 @@ impl ExecutionBackend for RuntimeBackend {
.await
}
+ async fn read_mcp_runtime_resource(
+ &self,
+ params: app_server_protocol::McpResourceReadParams,
+ ) -> Result {
+ let session_id = params.session_id.as_deref().ok_or_else(|| {
+ RuntimeCoreError::InvalidRequest(
+ "mcpResource/read runtime owner requires sessionId".to_string(),
+ )
+ })?;
+ let thread_id = params.thread_id.as_deref().ok_or_else(|| {
+ RuntimeCoreError::InvalidRequest(
+ "mcpResource/read runtime owner requires threadId".to_string(),
+ )
+ })?;
+ let db = initialize_runtime_database(self.db.as_ref())?;
+ self.ensure_agent_initialized(&db).await?;
+ mcp_bridges::ensure_thread_mcp_runtime_if_available(
+ &self.agent_state,
+ &self.app_data_source,
+ session_id,
+ thread_id,
+ )
+ .await?;
+ let content = self
+ .agent_state
+ .read_mcp_resource(session_id, thread_id, ¶ms.server, ¶ms.uri)
+ .await
+ .map_err(RuntimeCoreError::Backend)?;
+ Ok(app_server_protocol::McpResourceReadResponse {
+ uri: content.uri,
+ mime_type: content.mime_type,
+ text: content.text,
+ blob: content.blob,
+ meta: content.meta,
+ })
+ }
+
async fn prepare_runtime_worker_artifact_events(
&self,
request: &ExecutionRequest,
diff --git a/lime-rs/crates/app-server/src/runtime_backend/initialization_tests.rs b/lime-rs/crates/app-server/src/runtime_backend/initialization_tests.rs
index e32a3ea57..01a645659 100644
--- a/lime-rs/crates/app-server/src/runtime_backend/initialization_tests.rs
+++ b/lime-rs/crates/app-server/src/runtime_backend/initialization_tests.rs
@@ -170,6 +170,30 @@ async fn main_turn_initializes_agent_before_live_execution_hook() {
assert!(backend.agent_state.is_initialized().await);
}
+#[tokio::test]
+async fn mcp_resource_read_rehydrates_session_runtime_without_starting_turn() {
+ let backend = RuntimeBackend::with_db(test_db());
+ ExecutionBackend::set_app_data_source(&backend, Arc::new(crate::NoopAppDataSource))
+ .expect("set app data source");
+
+ assert!(!backend.agent_state.is_initialized().await);
+
+ let error = ExecutionBackend::read_mcp_runtime_resource(
+ &backend,
+ app_server_protocol::McpResourceReadParams {
+ server: "missing-server".to_string(),
+ uri: "ui://missing-server/app.html".to_string(),
+ session_id: Some("session-history".to_string()),
+ thread_id: Some("thread-history".to_string()),
+ },
+ )
+ .await
+ .expect_err("empty fixture runtime has no MCP server");
+
+ assert!(backend.agent_state.is_initialized().await);
+ assert!(!error.to_string().contains("MCP runtime is not initialized"));
+}
+
#[tokio::test]
async fn unexecutable_route_returns_typed_rejection_before_provider_call() {
let backend = RuntimeBackend::with_db(test_db());
diff --git a/lime-rs/crates/app-server/src/runtime_backend/mention_selection.rs b/lime-rs/crates/app-server/src/runtime_backend/mention_selection.rs
index 6bdf2af75..135e53871 100644
--- a/lime-rs/crates/app-server/src/runtime_backend/mention_selection.rs
+++ b/lime-rs/crates/app-server/src/runtime_backend/mention_selection.rs
@@ -7,13 +7,16 @@ use std::sync::Arc;
const APP_MENTION_PREFIX: &str = "app://";
const PLUGIN_MENTION_PREFIX: &str = "plugin://";
-const RUNTIME_CAPABILITY_SCHEMA_VERSION: &str = "plugin-runtime-capabilities/v0.1";
+const PLUGIN_ACTIVATION_SCHEMA_VERSION: &str = "plugin-activation/v2";
+const PLUGIN_RUNTIME_CAPABILITY_SCHEMA_VERSION: &str = "plugin-runtime-capabilities/v2";
+const LEGACY_APP_RUNTIME_CAPABILITY_SCHEMA_VERSION: &str = "plugin-runtime-capabilities/v0.1";
const MENTION_SELECTION_TURN_METADATA_KEY: &str = "mention_selection";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(super) struct MentionSelection {
apps: Vec,
plugins: Vec,
+ plugin_filter_requested: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -29,11 +32,17 @@ struct ResolvedPluginMention {
}
#[derive(Debug, Clone, PartialEq, Eq)]
-struct InstalledPluginRecord {
+struct PluginActivationRecord {
id: String,
runtime_capabilities: Value,
}
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct InstalledAppRecord {
+ plugin_id: String,
+ runtime_capabilities: Value,
+}
+
#[derive(Debug, Default, PartialEq, Eq)]
struct MentionRequests {
app_ids: Vec,
@@ -43,35 +52,64 @@ struct MentionRequests {
pub(super) async fn resolve_mentions(
request: &ExecutionRequest,
app_data_source: Option>,
+ plugin_activations: &[Value],
) -> MentionSelection {
let requests = mention_requests(request);
if requests.app_ids.is_empty() && requests.plugin_ids.is_empty() {
return MentionSelection::default();
}
- let Some(app_data_source) = app_data_source else {
+ let installed_app_states = if requests.app_ids.is_empty() {
+ Vec::new()
+ } else if let Some(app_data_source) = app_data_source {
+ match app_data_source.list_plugin_installed().await {
+ Ok(installed) => installed.states,
+ Err(error) => {
+ tracing::warn!(
+ error = %error,
+ "App installed registry 不可用,app:// Mention 按 fail-closed 处理"
+ );
+ Vec::new()
+ }
+ }
+ } else {
tracing::warn!(
app_count = requests.app_ids.len(),
- plugin_count = requests.plugin_ids.len(),
- "忽略无法由 installed registry 校验的结构化 Mention"
+ "忽略无法由 app installed registry 校验的 app:// Mention"
);
- return MentionSelection::default();
- };
- let installed = match app_data_source.list_plugin_installed().await {
- Ok(installed) => installed,
- Err(error) => {
- tracing::warn!(
- error = %error,
- "Plugin installed registry 不可用,结构化 Mention 按 fail-closed 处理"
- );
- return MentionSelection::default();
- }
+ Vec::new()
};
- resolve_from_installed_states(requests, &installed.states)
+ if !requests.plugin_ids.is_empty() && plugin_activations.is_empty() {
+ tracing::warn!(
+ plugin_count = requests.plugin_ids.len(),
+ "Plugin v2 activation snapshot 为空,plugin:// Mention 按 fail-closed 处理"
+ );
+ }
+
+ resolve_from_sources(requests, plugin_activations, &installed_app_states)
}
impl MentionSelection {
+ pub(super) fn plugin_activations_for_turn(&self, activations: &[Value]) -> Vec {
+ if !self.plugin_filter_requested {
+ return activations.to_vec();
+ }
+ let selected_ids = self
+ .plugins
+ .iter()
+ .map(|plugin| plugin.id.as_str())
+ .collect::>();
+ activations
+ .iter()
+ .filter(|activation| {
+ string_at(activation, "/pluginId")
+ .is_some_and(|id| selected_ids.contains(id.as_str()))
+ })
+ .cloned()
+ .collect()
+ }
+
pub(super) fn apply_to_session_config(&self, session_config: &mut AgentSessionConfig) {
if self.apps.is_empty() && self.plugins.is_empty() {
return;
@@ -94,22 +132,6 @@ impl MentionSelection {
})).collect::>(),
}),
);
-
- let plugin_metadata = self
- .plugins
- .iter()
- .map(|plugin| {
- json!({
- "plugin_runtime_capabilities": plugin.runtime_capabilities,
- })
- })
- .collect::>();
- let plugin_metadata = plugin_metadata.iter().collect::>();
- session_config.system_prompt =
- super::plugin_runtime_context::append_plugin_runtime_context_to_system_prompt(
- session_config.system_prompt.take(),
- &plugin_metadata,
- );
}
}
@@ -139,20 +161,28 @@ fn mention_id<'a>(path: &'a str, prefix: &str) -> Option<&'a str> {
path.strip_prefix(prefix).filter(|id| !id.is_empty())
}
-fn resolve_from_installed_states(requests: MentionRequests, states: &[Value]) -> MentionSelection {
- let records = states
+fn resolve_from_sources(
+ requests: MentionRequests,
+ plugin_activations: &[Value],
+ installed_app_states: &[Value],
+) -> MentionSelection {
+ let plugin_records = plugin_activations
.iter()
- .filter_map(installed_plugin_record)
+ .filter_map(plugin_activation_record)
+ .collect::>();
+ let app_records = installed_app_states
+ .iter()
+ .filter_map(installed_app_record)
.collect::>();
let plugins = requests
.plugin_ids
.iter()
- .filter_map(|id| resolve_plugin_mention(id, &records))
+ .filter_map(|id| resolve_plugin_mention(id, &plugin_records))
.collect::>();
let apps = requests
.app_ids
.iter()
- .filter_map(|id| resolve_app_mention(id, &records))
+ .filter_map(|id| resolve_app_mention(id, &app_records))
.collect::>();
let unresolved_plugin_count = requests.plugin_ids.len().saturating_sub(plugins.len());
@@ -165,18 +195,41 @@ fn resolve_from_installed_states(requests: MentionRequests, states: &[Value]) ->
);
}
- MentionSelection { apps, plugins }
+ MentionSelection {
+ apps,
+ plugins,
+ plugin_filter_requested: !requests.plugin_ids.is_empty(),
+ }
}
-fn installed_plugin_record(state: &Value) -> Option {
+fn plugin_activation_record(activation: &Value) -> Option {
+ if string_at(activation, "/schemaVersion").as_deref() != Some(PLUGIN_ACTIVATION_SCHEMA_VERSION)
+ {
+ return None;
+ }
+ let id = string_at(activation, "/pluginId")?;
+ let runtime_capabilities = activation.get("runtimeCapabilities")?.clone();
+ if string_at(&runtime_capabilities, "/schemaVersion").as_deref()
+ != Some(PLUGIN_RUNTIME_CAPABILITY_SCHEMA_VERSION)
+ || string_at(&runtime_capabilities, "/pluginId").as_deref() != Some(id.as_str())
+ {
+ return None;
+ }
+ Some(PluginActivationRecord {
+ id,
+ runtime_capabilities,
+ })
+}
+
+fn installed_app_record(state: &Value) -> Option {
if state.get("disabled").and_then(Value::as_bool) != Some(false) {
return None;
}
- let id = string_at(state, "/appId")?;
+ let plugin_id = string_at(state, "/appId")?;
let mut runtime_capabilities = state.pointer("/manifest/runtimeCapabilities")?.clone();
if string_at(&runtime_capabilities, "/schemaVersion").as_deref()
- != Some(RUNTIME_CAPABILITY_SCHEMA_VERSION)
- || string_at(&runtime_capabilities, "/pluginId").as_deref() != Some(id.as_str())
+ != Some(LEGACY_APP_RUNTIME_CAPABILITY_SCHEMA_VERSION)
+ || string_at(&runtime_capabilities, "/pluginId").as_deref() != Some(plugin_id.as_str())
{
return None;
}
@@ -186,15 +239,15 @@ fn installed_plugin_record(state: &Value) -> Option {
.entry("packageSourceUri".to_string())
.or_insert(Value::String(package_source_uri));
}
- Some(InstalledPluginRecord {
- id,
+ Some(InstalledAppRecord {
+ plugin_id,
runtime_capabilities,
})
}
fn resolve_plugin_mention(
id: &str,
- records: &[InstalledPluginRecord],
+ records: &[PluginActivationRecord],
) -> Option {
let mut matches = records.iter().filter(|record| record.id == id);
let record = matches.next()?;
@@ -207,7 +260,7 @@ fn resolve_plugin_mention(
})
}
-fn resolve_app_mention(id: &str, records: &[InstalledPluginRecord]) -> Option {
+fn resolve_app_mention(id: &str, records: &[InstalledAppRecord]) -> Option {
let mut matches = records.iter().filter(|record| {
record
.runtime_capabilities
@@ -227,7 +280,7 @@ fn resolve_app_mention(id: &str, records: &[InstalledPluginRecord]) -> Option").count(), 1);
- assert!(prompt.contains(""));
- assert!(prompt.contains("plugin_id: creator"));
+ assert_eq!(prompt, "base");
assert!(!prompt.contains("plugin://creator"));
assert!(!prompt.contains("app://docs"));
}
@@ -370,7 +453,7 @@ mod tests {
},
"manifest": {
"runtimeCapabilities": {
- "schemaVersion": RUNTIME_CAPABILITY_SCHEMA_VERSION,
+ "schemaVersion": LEGACY_APP_RUNTIME_CAPABILITY_SCHEMA_VERSION,
"pluginId": capability_plugin_id,
"skills": [],
"tools": connectors.iter().map(|id| json!({
@@ -383,4 +466,23 @@ mod tests {
}
})
}
+
+ fn plugin_activation(plugin_id: &str, capability_plugin_id: &str) -> Value {
+ json!({
+ "schemaVersion": PLUGIN_ACTIVATION_SCHEMA_VERSION,
+ "pluginId": plugin_id,
+ "version": "1.0.0",
+ "contentDigest": format!("sha256:{plugin_id}"),
+ "marketplaceId": "test-marketplace",
+ "packageSourceUri": format!("/plugins/{plugin_id}"),
+ "runtimeCapabilities": {
+ "schemaVersion": PLUGIN_RUNTIME_CAPABILITY_SCHEMA_VERSION,
+ "pluginId": capability_plugin_id,
+ "version": "1.0.0",
+ "packageSourceUri": format!("/plugins/{plugin_id}"),
+ "skills": [],
+ "mcpBindings": [],
+ }
+ })
+ }
}
diff --git a/lime-rs/crates/app-server/src/runtime_backend/request_context.rs b/lime-rs/crates/app-server/src/runtime_backend/request_context.rs
index 9e41af668..6b128f699 100644
--- a/lime-rs/crates/app-server/src/runtime_backend/request_context.rs
+++ b/lime-rs/crates/app-server/src/runtime_backend/request_context.rs
@@ -28,7 +28,9 @@ mod session_config;
mod turn_context;
mod workspace_scope;
+#[cfg(test)]
pub(super) use session_config::session_config_from_request;
+pub(super) use session_config::session_config_from_request_with_plugin_activations;
pub(super) use turn_context::turn_context_from_request;
pub(super) use workspace_scope::request_workspace_scope;
diff --git a/lime-rs/crates/app-server/src/runtime_backend/request_context/session_config.rs b/lime-rs/crates/app-server/src/runtime_backend/request_context/session_config.rs
index 15f3ee19e..38cc0b046 100644
--- a/lime-rs/crates/app-server/src/runtime_backend/request_context/session_config.rs
+++ b/lime-rs/crates/app-server/src/runtime_backend/request_context/session_config.rs
@@ -19,6 +19,7 @@ const DEFAULT_SESSION_SYSTEM_PROMPT: &str = "你是 Lime 桌面端中的 AI 助
const HARNESS_MAX_PROVIDER_STEPS_POINTER: &str = "/harness/provider_budget/max_provider_steps";
const HARNESS_PROVIDER_TOKEN_BUDGET_POINTER: &str = "/harness/provider_budget/token_budget";
+#[cfg(test)]
pub(in crate::runtime_backend) fn session_config_from_request(
request: &ExecutionRequest,
host_request: Option<&RuntimeRequest>,
@@ -26,9 +27,34 @@ pub(in crate::runtime_backend) fn session_config_from_request(
selection: &RuntimeModelSelection,
request_tool_policy: &RequestToolPolicy,
config_metadata: Option,
+) -> AgentSessionConfig {
+ session_config_from_request_with_plugin_activations(
+ request,
+ host_request,
+ scope,
+ selection,
+ request_tool_policy,
+ config_metadata,
+ &[],
+ )
+}
+
+pub(in crate::runtime_backend) fn session_config_from_request_with_plugin_activations(
+ request: &ExecutionRequest,
+ host_request: Option<&RuntimeRequest>,
+ scope: &RuntimeSessionScope,
+ selection: &RuntimeModelSelection,
+ request_tool_policy: &RequestToolPolicy,
+ config_metadata: Option,
+ plugin_activations: &[Value],
) -> AgentSessionConfig {
let workspace_scope = request_workspace_scope(request, host_request);
- let metadata_values = super::super::skill_runtime_enable::request_metadata_values(request);
+ let mut metadata_values = super::super::skill_runtime_enable::request_metadata_values(request);
+ let plugin_activation_metadata = plugin_activations
+ .iter()
+ .map(|activation| serde_json::json!({ "plugin_activation": activation }))
+ .collect::>();
+ metadata_values.extend(plugin_activation_metadata.iter());
let turn_tool_surface = super::turn_tool_surface_for_request(request);
let runtime_metadata = request.runtime_metadata();
let (system_prompt, skill_snapshot) = if turn_tool_surface.uses_light_session_prompt() {
@@ -93,6 +119,19 @@ pub(in crate::runtime_backend) fn session_config_from_request(
serde_json::to_value(skill_snapshot).expect("skill snapshot must serialize"),
);
}
+ if !plugin_activations.is_empty() {
+ let turn_context = turn_context.get_or_insert_with(Default::default);
+ turn_context.metadata.insert(
+ "plugin_activations".to_string(),
+ Value::Array(plugin_activations.to_vec()),
+ );
+ if plugin_activations.len() == 1 {
+ turn_context.metadata.insert(
+ "plugin_activation".to_string(),
+ plugin_activations[0].clone(),
+ );
+ }
+ }
if turn_tool_surface.is_harness_direct_answer() {
let turn_context = turn_context.get_or_insert_with(Default::default);
let runtime = turn_context
diff --git a/lime-rs/crates/app-server/src/runtime_backend/tests/session_skill_context.rs b/lime-rs/crates/app-server/src/runtime_backend/tests/session_skill_context.rs
index 7ccb1881f..5eac50963 100644
--- a/lime-rs/crates/app-server/src/runtime_backend/tests/session_skill_context.rs
+++ b/lime-rs/crates/app-server/src/runtime_backend/tests/session_skill_context.rs
@@ -62,6 +62,95 @@ Use concise language.
assert!(snapshot.skills.iter().any(|skill| skill.name == "writer"));
}
+#[test]
+fn session_config_loads_enabled_plugin_v2_skills_and_freezes_identity() {
+ let workspace = TempDir::new().expect("workspace");
+ let plugin_root = workspace.path().join("plugins/browser");
+ let skill_dir = plugin_root.join("skills/control-browser");
+ std::fs::create_dir_all(&skill_dir).expect("plugin skill dir");
+ std::fs::write(
+ skill_dir.join("SKILL.md"),
+ r#"---
+name: Control Browser
+description: Inspect pages through the managed browser.
+---
+
+# Control Browser
+
+Use the managed browser session.
+"#,
+ )
+ .expect("plugin skill file");
+ let mut request = request_for_test("检查当前页面", None, None);
+ let options = request.runtime_options.as_mut().expect("runtime options");
+ options.runtime_request_mut().provider_preference = Some("openai".to_string());
+ options.runtime_request_mut().model_preference = Some("gpt-4.1".to_string());
+ let host_request = runtime_request_from_request(&request);
+ let scope = session_scope_from_request(&request).expect("session scope");
+ let selection = selection_from_explicit_preferences(&request).expect("selection");
+ let policy = request_tool_policy_from_request(host_request.as_ref());
+ let activations = vec![json!({
+ "schemaVersion": "plugin-activation/v2",
+ "pluginId": "browser",
+ "version": "1.0.0",
+ "contentDigest": "sha256:fixture",
+ "marketplaceId": "openai-bundled",
+ "packageSourceUri": plugin_root.to_string_lossy(),
+ "runtimeCapabilities": {
+ "schemaVersion": "plugin-runtime-capabilities/v2",
+ "pluginId": "browser",
+ "version": "1.0.0",
+ "packageSourceUri": plugin_root.to_string_lossy(),
+ "skills": [{
+ "id": "control-browser",
+ "title": "Control Browser",
+ "path": "./skills/",
+ "activation": "available",
+ "required": false,
+ "promptInjectionPolicy": {
+ "mode": "available",
+ "source": "plugin-v2-installed"
+ }
+ }],
+ "mcpBindings": []
+ }
+ })];
+
+ let config = session_config_from_request_with_plugin_activations(
+ &request,
+ host_request.as_ref(),
+ &scope,
+ &selection,
+ &policy,
+ None,
+ &activations,
+ );
+
+ let prompt = config.system_prompt.expect("system prompt");
+ assert!(prompt.contains(""));
+ assert!(prompt.contains("plugin_id: browser"));
+ let turn_context = config.turn_context.expect("turn context");
+ assert_eq!(
+ turn_context.metadata["plugin_activation"]["pluginId"],
+ "browser"
+ );
+ assert_eq!(
+ turn_context.metadata["plugin_activations"],
+ Value::Array(activations)
+ );
+ let snapshot = turn_context
+ .metadata
+ .get(lime_skills::SKILL_SNAPSHOT_TURN_METADATA_KEY)
+ .and_then(|value| {
+ serde_json::from_value::(value.clone()).ok()
+ })
+ .expect("skill snapshot");
+ assert!(snapshot
+ .skills
+ .iter()
+ .any(|skill| skill.name.eq_ignore_ascii_case("control-browser")));
+}
+
#[test]
fn session_config_appends_plugin_activation_metadata_to_system_prompt() {
let mut request = request_for_test(
diff --git a/lime-rs/crates/app-server/src/skills_watcher.rs b/lime-rs/crates/app-server/src/skills_watcher.rs
index 9f9d9794b..e6cd31bb9 100644
--- a/lime-rs/crates/app-server/src/skills_watcher.rs
+++ b/lime-rs/crates/app-server/src/skills_watcher.rs
@@ -5,18 +5,110 @@ use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
+use tokio::task::AbortHandle;
const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_secs(10);
pub(crate) struct SkillsWatcher {
_watcher: Arc>,
_watched_roots: Arc>>,
+ notifier: CatalogChangeNotifier,
reconcile_task: tokio::task::JoinHandle<()>,
}
impl Drop for SkillsWatcher {
fn drop(&mut self) {
self.reconcile_task.abort();
+ self.notifier.cancel_pending();
+ }
+}
+
+#[derive(Default)]
+struct NotificationState {
+ last_notification: Option,
+ trailing_task: Option,
+}
+
+#[derive(Clone)]
+struct CatalogChangeNotifier {
+ bridge: AppServerEventBridge,
+ handle: tokio::runtime::Handle,
+ throttle_interval: Duration,
+ state: Arc>,
+}
+
+impl CatalogChangeNotifier {
+ fn new(
+ bridge: AppServerEventBridge,
+ handle: tokio::runtime::Handle,
+ throttle_interval: Duration,
+ ) -> Self {
+ Self {
+ bridge,
+ handle,
+ throttle_interval,
+ state: Arc::new(Mutex::new(NotificationState::default())),
+ }
+ }
+
+ fn notify(&self) {
+ let now = Instant::now();
+ let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
+
+ if state.trailing_task.is_some() {
+ return;
+ }
+
+ let Some(last_notification) = state.last_notification else {
+ state.last_notification = Some(now);
+ drop(state);
+ self.broadcast();
+ return;
+ };
+ let elapsed = now.duration_since(last_notification);
+ if elapsed >= self.throttle_interval {
+ state.last_notification = Some(now);
+ drop(state);
+ self.broadcast();
+ return;
+ }
+
+ let delay = self.throttle_interval - elapsed;
+ let notifier = self.clone();
+ let task = self.handle.spawn(async move {
+ tokio::time::sleep(delay).await;
+ notifier.flush_trailing();
+ });
+ state.trailing_task = Some(task.abort_handle());
+ }
+
+ fn flush_trailing(&self) {
+ let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
+ if state.trailing_task.take().is_none() {
+ return;
+ }
+ state.last_notification = Some(Instant::now());
+ drop(state);
+ self.broadcast();
+ }
+
+ fn cancel_pending(&self) {
+ let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
+ if let Some(task) = state.trailing_task.take() {
+ task.abort();
+ }
+ }
+
+ fn broadcast(&self) {
+ lime_skills::invalidate_agent_skill_snapshot_cache();
+ let bridge = self.bridge.clone();
+ self.handle.spawn(async move {
+ bridge
+ .broadcast_message(JsonRpcMessage::Notification(
+ ServerNotification::SkillsChanged(SkillsChangedNotification {}).into(),
+ ))
+ .await;
+ });
}
}
@@ -48,10 +140,8 @@ impl SkillsWatcher {
tracing::warn!("skills watcher has no default Skill roots");
return None;
}
- let last_notification = Arc::new(Mutex::new(None::));
- let callback_last_notification = last_notification.clone();
- let callback_bridge = bridge.clone();
- let callback_handle = handle.clone();
+ let notifier = CatalogChangeNotifier::new(bridge, handle, throttle_interval);
+ let callback_notifier = notifier.clone();
let watcher = match notify::recommended_watcher(move |result: notify::Result| {
let event = match result {
Ok(event) if is_catalog_change(&event.kind) => event,
@@ -64,12 +154,7 @@ impl SkillsWatcher {
if event.paths.is_empty() {
return;
}
- notify_catalog_changed(
- &callback_bridge,
- &callback_handle,
- &callback_last_notification,
- throttle_interval,
- );
+ callback_notifier.notify();
}) {
Ok(watcher) => watcher,
Err(error) => {
@@ -83,21 +168,15 @@ impl SkillsWatcher {
let reconcile_watcher = watcher.clone();
let reconcile_watched_roots = watched_roots.clone();
- let reconcile_last_notification = last_notification.clone();
- let reconcile_handle = handle.clone();
- let reconcile_task = handle.spawn(async move {
+ let reconcile_notifier = notifier.clone();
+ let reconcile_task = notifier.handle.spawn(async move {
let mut interval = tokio::time::interval(throttle_interval);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
interval.tick().await;
loop {
interval.tick().await;
if reconcile_watches(&reconcile_watcher, &reconcile_watched_roots, &roots) {
- notify_catalog_changed(
- &bridge,
- &reconcile_handle,
- &reconcile_last_notification,
- throttle_interval,
- );
+ reconcile_notifier.notify();
}
}
});
@@ -105,6 +184,7 @@ impl SkillsWatcher {
Some(Arc::new(Self {
_watcher: watcher,
_watched_roots: watched_roots,
+ notifier,
reconcile_task,
}))
}
@@ -155,33 +235,6 @@ fn reconcile_watches(
changed
}
-fn notify_catalog_changed(
- bridge: &AppServerEventBridge,
- handle: &tokio::runtime::Handle,
- last_notification: &Mutex