Files
Genarrative/packages/agc-plugin-sdk/src/index.ts
T
kdletters 4a46f89c9b
Project CI / Repository checks (push) Successful in 2m45s
Project CI / Frontend tests (push) Successful in 3m27s
Project CI / Backend tests (push) Successful in 6m18s
Project CI / Native shell tests (push) Failing after 13m52s
接入 AGC 内置插件宿主并补齐 Cocos 编辑器能力 (#338)
客户端新增随包提供的插件宿主和 Cocos Creator 集成:识别并导入 Cocos 项目,通过内置桥接操作已打开的编辑器,无需安装项目 MCP 扩展。DirectProject 现在公开 36 个独立 cocos_* 工具,保留通用 JavaScript 执行入口。

- 通用插件 SDK、命令/能力/面板注册、编辑器适配器和跨进程内置插件开关。
- Cocos 场景、节点、组件、Prefab、UI、Layout/Widget、资源、保存、撤销、日志与预览调试;目录和实现由 JS/native 共用。
- 编辑事务回读、失败回滚、后续手动修改保护及不确定结果禁止重放;预览截图通过 MCP image 返回。
- DirectProject 跳过无关专业 Agent 历史,将项目打开和历史读取中的同步 I/O 移出窗口线程,消除 Cocos 执行与项目文件锁的错误耦合。

验证:
- 合并 master 后:类型/配置检查、编码检查、Rust 格式检查和提交钩子通过。
- 合并 master 后:Cocos 项目打开、插件面板和开发启动定向测试 10 通过、2 跳过;DirectProject MCP 测试 17 通过、1 项真实 Creator opt-in 忽略;插件宿主测试 9/9。
- 插件行为测试 17/17;native 测试 20/20,4 项 opt-in 测试默认忽略。
- 真实 Creator 3.8.8 的 36/36 操作 smoke,以及客户端 MCP tools/list、tools/call、UI/撤销和预览截图,在功能实现阶段已验证通过;本次 master 合并后未重复真实 GUI smoke。

验证边界:发行安装包和远端 CI 尚未验收。

Reviewed-on: #338
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
2026-09-13 14:48:55 +08:00

360 lines
11 KiB
TypeScript

/**
* Public SDK for AGC plugins.
*
* The SDK is transport agnostic. A plugin can run inside the AGC process or
* as a child process speaking line-delimited JSON-RPC; only the transport
* implementation changes. Lifecycle, UI and capability registration remain
* the same for Cocos, Unity and Godot adapters.
*/
export const AGC_PLUGIN_PROTOCOL_VERSION = 'agc.plugin.v1' as const;
export const AGC_PLUGIN_API_VERSION = 'v1' as const;
export const AGENT_PLUGINS_SCHEMA =
'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json' as const;
export type PluginRpcHandler = (params: unknown) => unknown | Promise<unknown>;
export type AgcPluginPermission =
| 'events.subscribe'
| 'project.read'
| 'editor.rpc'
| 'ui.register'
| 'capability.register';
export type PluginCommand = {
id: string;
title: string;
description?: string;
};
export type PluginPanel = {
id: string;
title: string;
entry: string;
placement?: string;
};
export type PluginCapability = {
id: string;
description?: string;
};
export type AgentPluginManifest = {
$schema: typeof AGENT_PLUGINS_SCHEMA;
name: string;
version?: string;
description?: string;
extensions?: {
'world.genarrative.agc'?: {
apiVersion?: typeof AGC_PLUGIN_API_VERSION;
entry?: string;
adapter?: string;
permissions?: AgcPluginPermission[];
panels?: PluginPanel[];
};
[namespace: string]: unknown;
};
};
export type PluginEvent = {
type: string;
payload?: unknown;
};
export type PluginHostTransport = {
request<T = unknown>(method: string, params?: unknown): Promise<T>;
subscribe?(listener: (event: PluginEvent) => void): () => void;
registerHandler?(method: string, handler: PluginRpcHandler): Unsubscribe;
};
type StdioProcess = {
stdin: {
setEncoding?(encoding: string): void;
on(event: string, listener: (chunk: string) => void): void;
};
stdout: { write(chunk: string): void };
};
export type Unsubscribe = () => void;
export type PluginHostApi = {
registerCommand(
command: PluginCommand,
handler?: PluginRpcHandler,
): Promise<Unsubscribe>;
registerPanel(panel: PluginPanel): Promise<Unsubscribe>;
registerCapability(
capability: PluginCapability,
handler?: PluginRpcHandler,
): Promise<Unsubscribe>;
events: {
subscribe(
type: string,
listener: (event: PluginEvent) => void,
): Promise<Unsubscribe>;
};
project: {
read<T = unknown>(path: string): Promise<T>;
};
rpc<T = unknown>(method: string, params?: unknown): Promise<T>;
};
type Listener = (event: PluginEvent) => void;
/** In-memory UI registry used by the AGC shell to mount and unmount panels. */
export class PluginPanelRegistry {
private readonly panels = new Map<string, PluginPanel>();
private readonly listeners = new Set<() => void>();
register(panel: PluginPanel): Unsubscribe {
if (this.panels.has(panel.id)) throw new Error('插件面板已注册');
this.panels.set(panel.id, panel);
this.notify();
return () => this.unregister(panel.id);
}
unregister(id: string): void {
if (this.panels.delete(id)) this.notify();
}
list(): PluginPanel[] {
return [...this.panels.values()];
}
subscribe(listener: () => void): Unsubscribe {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
for (const listener of this.listeners) listener();
}
}
/** JSON-RPC transport for the default AGC child-process protocol. */
export function createJsonRpcStdioTransport(
processLike = (globalThis as typeof globalThis & { process?: StdioProcess })
.process,
): PluginHostTransport {
if (!processLike) throw new Error('当前运行环境没有 stdio 进程');
let nextId = 1;
let buffer = '';
const pending = new Map<
number,
{
resolve(value: unknown): void;
reject(error: Error): void;
timer: ReturnType<typeof setTimeout>;
}
>();
const handlers = new Map<string, PluginRpcHandler>();
const listeners = new Set<(event: PluginEvent) => void>();
processLike.stdin.setEncoding?.('utf8');
processLike.stdin.on('data', (chunk) => {
buffer += chunk;
if (buffer.length > 2 * 1024 * 1024) {
buffer = '';
for (const request of pending.values()) {
clearTimeout(request.timer);
request.reject(new Error('插件 RPC 消息过大'));
}
pending.clear();
return;
}
let newline = buffer.indexOf('\n');
while (newline >= 0) {
const line = buffer.slice(0, newline).trim();
buffer = buffer.slice(newline + 1);
newline = buffer.indexOf('\n');
if (!line) continue;
let message: {
jsonrpc?: string;
id?: number;
result?: unknown;
error?: { message?: string };
method?: string;
params?: unknown;
};
try {
message = JSON.parse(line) as typeof message;
} catch {
continue;
}
if (message.jsonrpc !== '2.0') continue;
if (
!message.method &&
typeof message.id === 'number' &&
pending.has(message.id)
) {
const request = pending.get(message.id);
pending.delete(message.id);
if (request) clearTimeout(request.timer);
if (message.error)
request?.reject(
new Error(message.error.message ?? '插件宿主 RPC 失败'),
);
else request?.resolve(message.result);
} else if (message.method === 'host.event') {
const params = (message.params ?? {}) as {
type?: unknown;
payload?: unknown;
};
if (typeof params.type === 'string') {
const event = { type: params.type, payload: params.payload };
for (const listener of listeners) listener(event);
}
} else if (message.method && message.id !== undefined) {
const handler = handlers.get(message.method);
const id = message.id;
void Promise.resolve()
.then(() => {
if (!handler) throw new Error('插件未注册该方法');
return handler(message.params);
})
.then(
(result) =>
processLike.stdout.write(
`${JSON.stringify({ jsonrpc: '2.0', id, result: result ?? null })}\n`,
),
() =>
processLike.stdout.write(
`${JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32603, message: '插件方法执行失败' } })}\n`,
),
);
}
}
});
return {
request: <T = unknown>(method: string, params?: unknown) => {
const id = nextId++;
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error('插件宿主 RPC 超时'));
}, 10000);
pending.set(id, { resolve, reject, timer });
processLike.stdout.write(
`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`,
);
});
},
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
registerHandler: (method, handler) => {
if (handlers.has(method)) throw new Error('插件方法已注册');
handlers.set(method, handler);
return () => {
if (handlers.get(method) === handler) handlers.delete(method);
};
},
};
}
/**
* Creates the single host API exposed to plugin code. Every mutating call is
* sent through the host transport so Rust can enforce manifest permissions and
* record the outcome in the audit log.
*/
export function createPluginHost(
transport: PluginHostTransport,
panelRegistry = new PluginPanelRegistry(),
): PluginHostApi & { panels: PluginPanelRegistry; dispose(): void } {
const eventListeners = new Map<string, Set<Listener>>();
const disposers = new Set<Unsubscribe>();
const removeTransportSubscription = transport.subscribe?.((event) => {
for (const listener of eventListeners.get(event.type) ?? [])
listener(event);
});
const register = async <T extends { id: string }>(
method: string,
value: T,
onDispose: () => void,
): Promise<Unsubscribe> => {
await transport.request(method, value);
let disposed = false;
const dispose = () => {
if (disposed) return;
disposed = true;
onDispose();
disposers.delete(dispose);
void transport
.request(method.replace('register', 'unregister'), { id: value.id })
.catch(() => undefined);
};
disposers.add(dispose);
return dispose;
};
return {
panels: panelRegistry,
registerCommand: async (command, handler) => {
if (handler && !transport.registerHandler)
throw new Error('传输不支持插件方法处理器');
const disposeHandler = handler
? transport.registerHandler?.(command.id, handler)
: undefined;
try {
return await register('host.registerCommand', command, () =>
disposeHandler?.(),
);
} catch (error) {
disposeHandler?.();
throw error;
}
},
registerPanel: async (panel) => {
let disposeLocal: Unsubscribe = () => undefined;
const dispose = await register('host.registerPanel', panel, () =>
disposeLocal(),
);
disposeLocal = panelRegistry.register(panel);
return dispose;
},
registerCapability: async (capability, handler) => {
if (handler && !transport.registerHandler)
throw new Error('传输不支持插件方法处理器');
const disposeHandler = handler
? transport.registerHandler?.(capability.id, handler)
: undefined;
try {
return await register('host.registerCapability', capability, () =>
disposeHandler?.(),
);
} catch (error) {
disposeHandler?.();
throw error;
}
},
events: {
subscribe: async (type, listener) => {
const { subscriptionId } = await transport.request<{
subscriptionId: string;
}>('host.events.subscribe', { type });
const listeners = eventListeners.get(type) ?? new Set<Listener>();
listeners.add(listener);
eventListeners.set(type, listeners);
const dispose = () => {
listeners.delete(listener);
if (listeners.size === 0) eventListeners.delete(type);
disposers.delete(dispose);
void transport
.request('host.events.unsubscribe', { subscriptionId })
.catch(() => undefined);
};
disposers.add(dispose);
return dispose;
},
},
project: {
read: (path) => transport.request('host.project.read', { path }),
},
rpc: (method, params) => transport.request('host.rpc', { method, params }),
dispose: () => {
for (const dispose of [...disposers]) dispose();
removeTransportSubscription?.();
},
};
}