Files
Genarrative/plugins/agc-cocos-editor/src/entry.mjs
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

301 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* AGC Cocos Creator 插件运行时入口。
*
* 宿主以插件目录为 cwd 用系统 `node` 启动本文件,stdio 上使用一行一个 JSON-RPC
* 2.0 消息(`agc.plugin.v1`)。入口只做注册与转发:命令、能力、面板通过通用宿主
* 注册,编辑器操作经 `host.rpc` 路由到插件包自带 native 适配器。
*
* 说明:通用 SDK `@genarrative/agc-plugin-sdk` 目前以 TypeScript 源码分发,而运行
* 入口必须能被系统 node 直接执行,因此这里内联同一份 stdio 协议实现;package.json
* 仍声明 SDK 版本依赖,测试会校验协议常量、方法名与 native 适配器一致。
*/
import { createInterface } from 'node:readline';
import { pathToFileURL } from 'node:url';
import {
buildEditorRpcRequest,
COCOS_EDITOR_OPERATIONS,
validateExecuteCode,
} from './cocos-editor-adapter.mjs';
import {
buildCocosOperationCode,
COCOS_EDITOR_OPERATIONS as COCOS_OPERATION_NAMES,
} from './cocos-editor-operations.mjs';
export const COCOS_PLUGIN_PROTOCOL_VERSION = 'agc.plugin.v1';
export const COCOS_EXECUTE_COMMAND_ID = 'cocos.editor.execute';
export const COCOS_OPERATION_COMMAND_ID = 'cocos.editor.operation';
export const COCOS_CONNECTION_CAPABILITY_ID = 'cocos.editor.connection';
export const COCOS_EDITOR_PANEL = Object.freeze({
id: 'cocos-editor',
title: 'Cocos Creator',
entry: './panels/cocos-editor.html',
placement: 'sidebar',
});
export const PROJECT_CHANGED_EVENT = 'project.changed';
// 默认执行可先安装桥接;覆盖 15 秒引导、最长 60 秒命令和发现开销,早于宿主 90 秒截止。
const RPC_TIMEOUT_MS = 85_000;
export function createCocosEditorPlugin({
send,
log = () => undefined,
timeoutMs = RPC_TIMEOUT_MS,
}) {
let nextId = 1;
let activeProjectPath = null;
let disposed = false;
let executionUncertain = false;
let executionPending = false;
const pending = new Map();
const handlers = new Map([
[COCOS_EXECUTE_COMMAND_ID, handleExecute],
[COCOS_OPERATION_COMMAND_ID, handleOperation],
[COCOS_CONNECTION_CAPABILITY_ID, handleConnection],
]);
function request(method, params) {
const id = nextId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error(`插件宿主 RPC 超时:${method}`));
}, timeoutMs);
pending.set(id, { resolve, reject, timer });
send({ jsonrpc: '2.0', id, method, params });
});
}
function respond(id, result, error) {
if (error) {
send({
jsonrpc: '2.0',
id,
error: { code: -32603, message: error },
});
return;
}
send({ jsonrpc: '2.0', id, result: result ?? null });
}
function resolveProjectPath(params) {
const explicit = params?.projectPath;
if (typeof explicit === 'string' && explicit) return explicit;
if (activeProjectPath) return activeProjectPath;
throw new Error('当前没有受控项目路径,宿主需先设置项目上下文');
}
async function callEditor(operation, params) {
const { method, params: editorParams } = buildEditorRpcRequest(
operation,
params,
);
return request('host.rpc', { method, params: editorParams });
}
async function handleExecute(params) {
const code = params?.code;
validateExecuteCode(code);
const projectPath = resolveProjectPath(params);
const reconcile = (response) => ({
status: 'needs-reconciliation',
retryAllowed: false,
response,
});
if (executionUncertain)
return reconcile({ ok: false, error: '先前执行结果待核对' });
// 不积压稍后执行的 mutation,避免调用方超时后请求仍从队列发出。
if (executionPending)
return {
status: 'failed',
retryAllowed: false,
response: {
ok: false,
error: '已有 Cocos execute 正在执行,请等待回执',
},
};
executionPending = true;
try {
const response = await callEditor('execute', {
projectPath,
code,
timeoutMs: params?.timeoutMs,
});
if (response?.status === 'needs-reconciliation') {
executionUncertain = true;
return reconcile(response);
}
if (typeof response?.ok !== 'boolean')
throw new Error('宿主缺少可信执行回执');
return { status: response.ok ? 'completed' : 'failed', response };
} catch (error) {
// 已交给宿主的 execute 超时/断线不能推断为未执行。
executionUncertain = true;
return reconcile({ ok: false, error: error.message });
} finally {
executionPending = false;
}
}
async function handleConnection(params) {
const operation = params?.operation ?? 'detect';
if (!COCOS_EDITOR_OPERATIONS.includes(operation)) {
throw new Error(`不支持的能力操作:${operation}`);
}
if (operation === 'execute') return handleExecute(params);
if (operation === 'disconnect') {
return callEditor('disconnect', {});
}
return callEditor(operation, {
projectPath: resolveProjectPath(params),
processId: params?.processId,
timeoutMs: params?.timeoutMs,
payloadPath: params?.payloadPath,
});
}
async function handleOperation(params) {
const operation = params?.operation;
if (!COCOS_OPERATION_NAMES.includes(operation)) {
throw new Error(`不支持的 Cocos 操作:${operation}`);
}
const projectPath = resolveProjectPath(params);
const result = await handleExecute({
projectPath,
code: buildCocosOperationCode(operation, params?.args ?? {}),
timeoutMs: 60_000,
});
const status = result?.response?.result?.status;
if (status === 'failed' || status === 'needs-reconciliation') {
result.status = status;
if (status === 'needs-reconciliation') {
executionUncertain = true;
result.retryAllowed = false;
}
}
return result;
}
async function handleMessage(message) {
if (disposed) return;
const envelope =
typeof message === 'string' ? JSON.parse(message) : message;
if (!envelope || envelope.jsonrpc !== '2.0') return;
if (envelope.method === 'host.event') {
const event = envelope.params ?? {};
if (event.type === PROJECT_CHANGED_EVENT) {
const projectPath = event.payload?.projectPath;
activeProjectPath =
typeof projectPath === 'string' && projectPath ? projectPath : null;
}
return;
}
if (envelope.method !== undefined) {
if (envelope.id === undefined) return;
const handler = handlers.get(envelope.method);
if (!handler) {
respond(envelope.id, undefined, `插件未注册方法:${envelope.method}`);
return;
}
try {
respond(envelope.id, await handler(envelope.params ?? {}));
} catch (error) {
log(`cocos plugin 处理 ${envelope.method} 失败:${error.message}`);
respond(envelope.id, undefined, error.message);
}
return;
}
const request = pending.get(envelope.id);
if (!request) return;
pending.delete(envelope.id);
clearTimeout(request.timer);
if (envelope.error) {
request.reject(new Error(envelope.error.message ?? '插件宿主 RPC 失败'));
return;
}
request.resolve(envelope.result);
}
async function start() {
const command = await request('host.registerCommand', {
id: COCOS_EXECUTE_COMMAND_ID,
title: '在 Cocos Creator 中执行代码',
description: '在已打开的 Cocos Creator 项目中执行受控 JavaScript 函数体',
});
const capability = await request('host.registerCapability', {
id: COCOS_CONNECTION_CAPABILITY_ID,
description: 'Cocos Creator 连接探测、目标预检、ping、status 与注入',
});
const operations = await request('host.registerCommand', {
id: COCOS_OPERATION_COMMAND_ID,
title: '调用 Cocos Creator 编辑器能力',
description: `调用内置 Cocos 编辑器操作(${COCOS_OPERATION_NAMES.join(', ')}`,
});
const panel = await request('host.registerPanel', {
...COCOS_EDITOR_PANEL,
});
const subscription = await request('host.events.subscribe', {
type: PROJECT_CHANGED_EVENT,
});
activeProjectPath = subscription?.projectPath ?? null;
return {
command,
capability,
operations,
panel,
subscriptionId: subscription?.subscriptionId ?? null,
};
}
function dispose() {
disposed = true;
for (const request of pending.values()) {
clearTimeout(request.timer);
request.reject(new Error('插件已停止'));
}
pending.clear();
}
return {
start,
handleMessage,
dispose,
get activeProjectPath() {
return activeProjectPath;
},
};
}
export function startCocosEditorStdioPlugin({
stdin = process.stdin,
stdout = process.stdout,
log = (message) => process.stderr.write(`${message}\n`),
} = {}) {
const plugin = createCocosEditorPlugin({
send: (message) => stdout.write(`${JSON.stringify(message)}\n`),
log,
});
const lines = createInterface({ input: stdin, crlfDelay: Infinity });
lines.on('line', (line) => {
const trimmed = line.trim();
if (!trimmed) return;
void plugin.handleMessage(trimmed).catch((error) => {
log(`cocos plugin 消息处理失败:${error.message}`);
});
});
void plugin.start().catch((error) => {
log(`cocos plugin 注册失败:${error.message}`);
});
return plugin;
}
const entryPoint = process.argv[1];
if (entryPoint && import.meta.url === pathToFileURL(entryPoint).href) {
startCocosEditorStdioPlugin();
}