新增 AGC 内置插件模式与插件工作区

- 新增 plugins/ 插件工作区及 agc-cocos-editor 插件包,Cocos 直连模块从宿主源码树迁入插件包
- 新增 server-rs/crates/editor-adapter-api 通用编辑器适配器契约,宿主只保留适配器注册与 RPC 路由
- 新增内置插件登记表与可用开关,状态持久化在 AppData extensions/builtin-plugins.json,状态文件损坏时失败关闭
- 禁用内置插件时先停止进程并拒绝启动,同时从 Runtime 工具目录、工具策略快照、原生函数目录与 DirectProject 工具面移除对应工具
- 插件宿主新增 plugins/ 工作区扫描,内置插件优先于同名 AppData 导入插件,前端只提供可用开关而不提供重命名和卸载入口
- 编辑器操作统一经 host.rpc 路由到插件包自带的 native 适配器,并删除 Cocos 专属 Tauri 命令
- 修正 Windows 打包与开发态 payload 路径,随包映射 plugins 工作区资源
- 同步插件、Cocos 桥接、实施计划文档与 decision log
This commit is contained in:
2026-09-10 20:00:02 +08:00
parent 9d2ad69a75
commit dd36de3aa6
47 changed files with 2674 additions and 409 deletions
+2
View File
@@ -40,6 +40,8 @@ temp*build*/
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-path/
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-resources/
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-package.json
/apps/ai-game-creator-shell/src-tauri/resources/plugins/
/plugins/agc-cocos-editor/native/payload/
/apps/ai-game-creator-shell/logs/
/apps/ai-game-creator-shell/.llm-drafts/
/apps/ai-game-creator-shell/game-creator.config.local.json
@@ -126,11 +126,7 @@ const allowedUncalledTauriCommands = [
'call_agc_plugin',
'read_agc_plugin_panel',
'set_agc_plugin_project_path',
'prepare_cocos_editor_injection',
'ping_cocos_editor',
'status_cocos_editor',
'execute_cocos_editor_code',
'inject_cocos_editor',
'set_agc_plugin_enabled',
];
const sourceExtensions = new Set([
'.json',
@@ -1313,7 +1309,7 @@ const expectedBundledWindowsResources = {
'codex/win-x64/codex-package.json',
'resources/codex/win-x64/NOTICE.md': 'codex/win-x64/NOTICE.md',
'resources/codex/win-x64/manifest.json': 'codex/win-x64/manifest.json',
'resources/cocos-editor-bridge': 'cocos-editor-bridge',
'resources/plugins': 'plugins',
};
if (tauriConfig.bundle?.resources !== undefined) {
throw new Error(
+10
View File
@@ -738,6 +738,7 @@ name = "cocos-editor-bridge"
version = "0.1.0"
dependencies = [
"cc",
"editor-adapter-api",
"serde",
"serde_json",
"sha2",
@@ -1216,6 +1217,14 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "editor-adapter-api"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "either"
version = "1.16.0"
@@ -1721,6 +1730,7 @@ dependencies = [
"base64 0.22.1",
"chromiumoxide",
"cocos-editor-bridge",
"editor-adapter-api",
"futures",
"getrandom 0.3.4",
"http",
@@ -22,7 +22,8 @@ ts-rs = "12.0.1"
typed_floats = { version = "1.0.7", features = ["serde"] }
nalgebra = { version = "0.35.0", features = ["serde-serialize"] }
agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" }
cocos-editor-bridge = { path = "../../../server-rs/crates/cocos-editor-bridge", default-features = false }
cocos-editor-bridge = { path = "../../../plugins/agc-cocos-editor/native/cocos-editor-bridge", default-features = false }
editor-adapter-api = { path = "../../../server-rs/crates/editor-adapter-api" }
base64 = "0.22"
axum = "0.8"
chromiumoxide = "0.9.1"
+106 -5
View File
@@ -182,6 +182,7 @@ fn main() {
);
let manifest_path = manifest_dir.join("prompts/runtime/manifest.json");
stage_bundled_codex_cli(&manifest_dir);
stage_plugin_workspace(&manifest_dir);
stage_cocos_editor_payload(&manifest_dir);
let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path)
.unwrap_or_else(|error| panic!("Prompt Bundle 编译失败:{error}"));
@@ -223,13 +224,113 @@ fn stage_cocos_editor_payload(manifest_dir: &std::path::Path) {
let source = candidates
.iter()
.find(|path| path.is_file())
.unwrap_or_else(|| panic!("Cocos bridge native payload 未构建:{}", candidates.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join("")));
let destination = manifest_dir.join("resources/cocos-editor-bridge/cocos-editor-bridge.dll");
std::fs::create_dir_all(destination.parent().expect("payload resource parent"))
.expect("创建 Cocos bridge 资源目录失败");
std::fs::copy(source, &destination).expect("复制 Cocos bridge native payload 失败");
.unwrap_or_else(|| {
panic!(
"Cocos bridge native payload 未构建:{}",
candidates
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join("")
)
});
for destination in [
// 插件工作区里的 payload 是开发态与打包态的唯一真源。
manifest_dir
.join("../../../plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"),
// 随包资源目录与 tauri.windows.conf.json 的 `resources/plugins` 映射保持一致。
manifest_dir
.join("resources/plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"),
] {
std::fs::create_dir_all(destination.parent().expect("payload resource parent"))
.expect("创建 Cocos bridge payload 目录失败");
std::fs::copy(source, &destination).expect("复制 Cocos bridge native payload 失败");
}
println!("cargo:rerun-if-changed={}", source.display());
}
#[cfg(not(windows))]
fn stage_cocos_editor_payload(_manifest_dir: &std::path::Path) {}
/// 把 `plugins/` 工作区里的插件包随包映射到应用资源目录。
///
/// 只复制插件运行需要的清单、入口、面板和 native payload,不复制 native 源码、
/// Cargo target 目录或 node_modules。
#[cfg(windows)]
fn stage_plugin_workspace(manifest_dir: &std::path::Path) {
let repo_root = manifest_dir
.parent()
.and_then(|app_root| app_root.parent())
.and_then(|apps_dir| apps_dir.parent())
.expect("AGC 应用必须位于仓库 apps 目录下")
.to_path_buf();
let workspace = repo_root.join("plugins");
let destination_root = manifest_dir.join("resources/plugins");
std::fs::create_dir_all(&destination_root).expect("创建插件资源目录失败");
let entries = match std::fs::read_dir(&workspace) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let plugin_root = entry.path();
if !plugin_root.is_dir() || !plugin_root.join("plugin.json").is_file() {
continue;
}
let name = entry.file_name();
let destination = destination_root.join(&name);
copy_plugin_file(
&plugin_root.join("plugin.json"),
&destination.join("plugin.json"),
);
for relative in [
std::path::PathBuf::from("src"),
std::path::PathBuf::from("panels"),
std::path::PathBuf::from("native/payload"),
] {
copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative));
}
println!("cargo:rerun-if-changed={}", plugin_root.display());
}
}
#[cfg(windows)]
fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) {
let entries = match std::fs::read_dir(source) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let target = destination.join(entry.file_name());
let path = entry.path();
if path.is_dir() {
let name = entry.file_name();
let name = name.to_string_lossy();
if matches!(name.as_ref(), "target" | "node_modules" | ".git") {
continue;
}
std::fs::create_dir_all(&target).expect("创建插件资源目录失败");
copy_plugin_tree(&path, &target);
} else {
// 测试文件不随包分发。
let name = entry.file_name();
let name = name.to_string_lossy();
if name.contains(".test.") {
continue;
}
copy_plugin_file(&path, &target);
}
}
}
#[cfg(windows)]
fn copy_plugin_file(source: &std::path::Path, destination: &std::path::Path) {
if !source.is_file() {
return;
}
std::fs::create_dir_all(destination.parent().expect("插件资源父目录"))
.expect("创建插件资源目录失败");
std::fs::copy(source, destination).expect("复制插件资源失败");
}
#[cfg(not(windows))]
fn stage_plugin_workspace(_manifest_dir: &std::path::Path) {}
@@ -2301,35 +2301,56 @@ async fn bridge_web_search_at(root: &Path, arguments: &Value, search_url: &str)
#[cfg(all(windows, feature = "cocos-editor-execute"))]
async fn bridge_cocos_execute(state: &DirectToolBridgeState, arguments: &Value) -> Value {
if !crate::builtin_plugins::cocos_editor_agent_tool_available() {
return bridge_tool_result(
"Cocos Creator 插件已禁用,agc_cocos_execute 不可用".to_string(),
Vec::new(),
true,
);
}
let prepared = (|| {
bridge_reject_unknown_fields(arguments, &["code"])?;
enforce_project_permission_policy(&state.root, "cocos.editor.execute")?;
let code = arguments.get("code").and_then(Value::as_str)
let code = arguments
.get("code")
.and_then(Value::as_str)
.ok_or_else(|| "code 必须是 JavaScript 函数体".to_string())?;
cocos_editor_bridge::validate_execute_code(code).map_err(|error| error.to_string())?;
Ok::<_, String>(code.to_string())
})();
let code = match prepared {
Ok(code) => code,
Err(error) => return bridge_tool_result(
redact_agent_runtime_error(&state.root, &error, 480), Vec::new(), true,
),
Err(error) => {
return bridge_tool_result(
redact_agent_runtime_error(&state.root, &error, 480),
Vec::new(),
true,
)
}
};
let mut uncertain = state.cocos_execute_uncertain.lock().await;
if *uncertain {
return bridge_tool_result(json!({
"status": "needs-reconciliation", "retryAllowed": false,
"message": "先前 Cocos execute 结果待核对,当前 bridge 不再发送执行命令"
}).to_string(), Vec::new(), true);
return bridge_tool_result(
json!({
"status": "needs-reconciliation", "retryAllowed": false,
"message": "先前 Cocos execute 结果待核对,当前 bridge 不再发送执行命令"
})
.to_string(),
Vec::new(),
true,
);
}
let root = state.root.clone();
let result = tokio::task::spawn_blocking(move || {
let _lock = acquire_project_write_lock(&root, "direct-cocos.execute")
.map_err(cocos_editor_bridge::BridgeError::InvalidInput)?;
cocos_editor_bridge::execute_cocos_editor_code_for_project(
root.to_string_lossy().as_ref(), &code, cocos_editor_bridge::DEFAULT_COMMAND_TIMEOUT_MS,
root.to_string_lossy().as_ref(),
&code,
cocos_editor_bridge::DEFAULT_COMMAND_TIMEOUT_MS,
)
}).await;
})
.await;
match result {
Ok(Ok(response)) => {
let is_error = !response.ok;
@@ -2338,24 +2359,40 @@ async fn bridge_cocos_execute(state: &DirectToolBridgeState, arguments: &Value)
"requestId": response.request_id,
"result": response.result,
"error": response.error,
}).to_string();
bridge_tool_result(redact_agent_runtime_project_paths(&state.root, &text, 32_000), Vec::new(), is_error)
})
.to_string();
bridge_tool_result(
redact_agent_runtime_project_paths(&state.root, &text, 32_000),
Vec::new(),
is_error,
)
}
failed => {
let (is_uncertain, error) = match failed {
Ok(Err(error)) => (
matches!(&error, cocos_editor_bridge::BridgeError::ExecutionUncertain(_)),
matches!(
&error,
cocos_editor_bridge::BridgeError::ExecutionUncertain(_)
),
error.to_string(),
),
Err(_) => (true, "Cocos execute worker 退出,执行结果需要核对".to_string()),
Err(_) => (
true,
"Cocos execute worker 退出,执行结果需要核对".to_string(),
),
Ok(Ok(_)) => unreachable!(),
};
*uncertain = is_uncertain;
bridge_tool_result(json!({
"status": if is_uncertain { "needs-reconciliation" } else { "failed" },
"retryAllowed": !is_uncertain,
"message": redact_agent_runtime_error(&state.root, &error, 480),
}).to_string(), Vec::new(), true)
bridge_tool_result(
json!({
"status": if is_uncertain { "needs-reconciliation" } else { "failed" },
"retryAllowed": !is_uncertain,
"message": redact_agent_runtime_error(&state.root, &error, 480),
})
.to_string(),
Vec::new(),
true,
)
}
}
}
@@ -432,16 +432,18 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value {
];
let mut tools = tools;
#[cfg(all(windows, feature = "cocos-editor-execute"))]
tools.push(json!({
"name": "agc_cocos_execute",
"description": "在当前项目已连接的 Cocos Creator 主进程执行 JavaScript 函数体,支持 await 和 return。宿主绑定项目和目标进程,只提交 code。结果待核对或超时后禁止自动重发;使用 Editor.Message 调用 Creator API。",
"inputSchema": {
"type": "object",
"properties": { "code": { "type": "string", "minLength": 1, "maxLength": cocos_editor_bridge::MAX_EXECUTE_CODE_BYTES } },
"required": ["code"],
"additionalProperties": false
}
}));
if crate::builtin_plugins::cocos_editor_agent_tool_available() {
tools.push(json!({
"name": "agc_cocos_execute",
"description": "在当前项目已连接的 Cocos Creator 主进程执行 JavaScript 函数体,支持 await 和 return。宿主绑定项目和目标进程,只提交 code。结果待核对或超时后禁止自动重发;使用 Editor.Message 调用 Creator API。",
"inputSchema": {
"type": "object",
"properties": { "code": { "type": "string", "minLength": 1, "maxLength": cocos_editor_bridge::MAX_EXECUTE_CODE_BYTES } },
"required": ["code"],
"additionalProperties": false
}
}));
}
if controlled_web_search {
tools.push(json!({
"name": "agc_web_search",
@@ -517,7 +519,9 @@ fn validate_write_file_arguments(arguments: &Value) -> Result<(), String> {
#[cfg(all(windows, feature = "cocos-editor-execute"))]
async fn call_agc_cocos_execute(arguments: &Value) -> Value {
let validated = validate_tool_object_fields(arguments, &["code"]).and_then(|()| {
let code = arguments.get("code").and_then(Value::as_str)
let code = arguments
.get("code")
.and_then(Value::as_str)
.ok_or_else(|| "code 必须是 JavaScript 函数体".to_string())?;
cocos_editor_bridge::validate_execute_code(code).map_err(|error| error.to_string())
});
@@ -1896,7 +1900,9 @@ mod tests {
"agc_browser_playtest",
]
.into_iter()
.chain(cfg!(all(windows, feature = "cocos-editor-execute")).then_some("agc_cocos_execute"))
.chain(
cfg!(all(windows, feature = "cocos-editor-execute")).then_some("agc_cocos_execute")
)
.collect::<Vec<_>>()
);
let serialized = specs.to_string();
@@ -17,7 +17,7 @@ mod canvas_asset_kind_contract_tests {
}
pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> {
let tools = vec![
let mut tools = vec![
GAME_CREATOR_USER_INPUT_REQUEST_TOOL,
"memory.read",
"memory.write",
@@ -64,10 +64,12 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> {
"agent.action_history",
"agent.run_status",
];
// 内置插件被用户禁用后,对应 Runtime 工具不再进入工具目录、Agent 上下文
// 和工具策略快照。
if crate::builtin_plugins::cocos_editor_agent_tool_available() {
tools.push(crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME);
}
tools
.into_iter()
.chain(cfg!(feature = "cocos-editor-execute").then_some("cocos.editor.execute"))
.collect()
}
pub(crate) fn agent_runtime_native_executable_tools() -> Vec<&'static str> {
@@ -1,8 +1,8 @@
use super::*;
mod action_history;
mod command_ops;
mod cocos_editor;
mod command_ops;
mod context;
mod delegation;
mod delivery;
@@ -21,8 +21,8 @@ mod task_ops;
mod ui_workflow;
pub(in crate::agent) use action_history::*;
pub(in crate::agent) use command_ops::*;
pub(in crate::agent) use cocos_editor::*;
pub(in crate::agent) use command_ops::*;
pub(in crate::agent) use context::*;
pub(in crate::agent) use delegation::*;
pub(in crate::agent) use delivery::*;
@@ -251,8 +251,17 @@ fn build_agent_runtime_native_capability_registry() -> Result<CapabilityRegistry
fn agent_runtime_native_capability_registry() -> Result<&'static CapabilityRegistry<String>, String>
{
static REGISTRY: OnceLock<Result<CapabilityRegistry<String>, String>> = OnceLock::new();
REGISTRY
// 内置插件开关会改变工具目录,因此按“可用 / 不可用”各缓存一份:切换后立即
// 生效,又不需要每次调用都重建 registry。
static ENABLED_REGISTRY: OnceLock<Result<CapabilityRegistry<String>, String>> = OnceLock::new();
static DISABLED_REGISTRY: OnceLock<Result<CapabilityRegistry<String>, String>> =
OnceLock::new();
let cache = if crate::builtin_plugins::cocos_editor_agent_tool_available() {
&ENABLED_REGISTRY
} else {
&DISABLED_REGISTRY
};
cache
.get_or_init(build_agent_runtime_native_capability_registry)
.as_ref()
.map_err(Clone::clone)
@@ -0,0 +1,321 @@
//! 内置插件登记表与可用开关。
//!
//! 内置插件随 `plugins/` 工作区随包分发,用户不能卸载,只能控制是否可用。
//! 开关状态持久化在 AppData `extensions/builtin-plugins.json`,同时被两处消费:
//! 插件宿主(禁用后不能启动,状态显示为 disabled)和 Agent 工具目录(禁用后
//! 不出现在工具列表与 Agent 上下文里)。
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use serde::{Deserialize, Serialize};
/// Cocos Creator 编辑器插件的插件 id,与 `plugins/agc-cocos-editor/plugin.json` 一致。
pub(crate) const AGC_COCOS_EDITOR_PLUGIN_ID: &str = "agc-cocos-editor";
/// 该插件在 Agent 侧对应的 Runtime 工具名。
pub(crate) const AGC_COCOS_EDITOR_TOOL_NAME: &str = "cocos.editor.execute";
const STATE_FILE_NAME: &str = "builtin-plugins.json";
const STATE_SCHEMA_VERSION: &str = "agc.builtin-plugins.v1";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum BuiltinPlugin {
CocosEditor,
}
impl BuiltinPlugin {
pub(crate) fn id(self) -> &'static str {
match self {
Self::CocosEditor => AGC_COCOS_EDITOR_PLUGIN_ID,
}
}
/// 未持久化任何开关时的默认状态。
fn default_enabled(self) -> bool {
match self {
Self::CocosEditor => true,
}
}
/// 该插件是否向 Agent 暴露 Runtime 工具。
fn exposes_agent_tools(self) -> bool {
match self {
Self::CocosEditor => true,
}
}
}
pub(crate) const BUILTIN_PLUGINS: &[BuiltinPlugin] = &[BuiltinPlugin::CocosEditor];
pub(crate) fn builtin_plugin(id: &str) -> Option<BuiltinPlugin> {
BUILTIN_PLUGINS
.iter()
.copied()
.find(|plugin| plugin.id() == id.trim())
}
pub(crate) fn is_builtin(id: &str) -> bool {
builtin_plugin(id).is_some()
}
#[derive(Debug, Default)]
struct BuiltinPluginState {
path: Option<PathBuf>,
enabled: BTreeMap<String, bool>,
/// 开关文件不可读或格式不受支持时,内置插件全部按不可用处理。
fail_closed: bool,
}
static STATE: OnceLock<Mutex<BuiltinPluginState>> = OnceLock::new();
fn state() -> &'static Mutex<BuiltinPluginState> {
STATE.get_or_init(|| Mutex::new(BuiltinPluginState::default()))
}
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct BuiltinPluginStateFile {
#[serde(default)]
schema_version: Option<String>,
#[serde(default)]
enabled: BTreeMap<String, bool>,
}
/// 读取 AppData 里的开关状态;文件缺失按默认状态处理,坏文件失败关闭。
pub(crate) fn initialize(config_dir: &Path) -> Result<(), String> {
let root = config_dir.join("extensions");
let path = root.join(STATE_FILE_NAME);
if let Err(error) = fs::create_dir_all(&root) {
mark_fail_closed(Some(path));
return Err(format!("准备内置插件目录失败:{error}"));
}
let loaded = match fs::read(&path) {
Ok(bytes) => match serde_json::from_slice::<BuiltinPluginStateFile>(&bytes) {
Ok(file) if file.schema_version.as_deref() == Some(STATE_SCHEMA_VERSION) => file,
Ok(_) => {
mark_fail_closed(Some(path));
return Err(format!(
"内置插件开关文件版本不受支持,需要 {STATE_SCHEMA_VERSION}"
));
}
Err(error) => {
mark_fail_closed(Some(path));
return Err(format!("内置插件开关文件无效:{error}"));
}
},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
BuiltinPluginStateFile::default()
}
Err(error) => {
mark_fail_closed(Some(path));
return Err(format!("读取内置插件开关失败:{error}"));
}
};
let mut guard = state()
.lock()
.map_err(|_| "内置插件状态锁已损坏".to_string())?;
guard.path = Some(path);
guard.fail_closed = false;
// 只接受登记表里的 id,避免坏文件把未知对象带进运行时。
guard.enabled = loaded
.enabled
.into_iter()
.filter(|(id, _)| is_builtin(id))
.collect();
Ok(())
}
fn mark_fail_closed(path: Option<PathBuf>) {
if let Ok(mut guard) = state().lock() {
guard.path = path;
guard.fail_closed = true;
guard.enabled = BUILTIN_PLUGINS
.iter()
.map(|plugin| (plugin.id().to_string(), false))
.collect();
}
}
pub(crate) fn is_enabled(id: &str) -> bool {
let Some(plugin) = builtin_plugin(id) else {
return false;
};
state()
.lock()
.map(|guard| {
if guard.fail_closed {
return false;
}
guard
.enabled
.get(plugin.id())
.copied()
.unwrap_or_else(|| plugin.default_enabled())
})
// 状态锁损坏时同样 fail-closed,避免异常状态重新放开内置能力。
.unwrap_or(false)
}
/// 内置插件的用户开关;`None` 表示该 id 不是内置插件,由来源自己决定启用状态。
pub(crate) fn toggle_state(id: &str) -> Option<bool> {
builtin_plugin(id).map(|_| is_enabled(id))
}
pub(crate) fn set_enabled(id: &str, enabled: bool) -> Result<bool, String> {
let Some(plugin) = builtin_plugin(id) else {
return Err(format!("{id} 不是内置插件,不能使用内置插件开关"));
};
let mut guard = state()
.lock()
.map_err(|_| "内置插件状态锁已损坏".to_string())?;
let previous = guard.enabled.get(plugin.id()).copied();
guard.enabled.insert(plugin.id().to_string(), enabled);
if let Err(error) = persist(&guard) {
match previous {
Some(value) => {
guard.enabled.insert(plugin.id().to_string(), value);
}
None => {
guard.enabled.remove(plugin.id());
}
}
return Err(error);
}
Ok(enabled)
}
fn persist(guard: &BuiltinPluginState) -> Result<(), String> {
let path = guard
.path
.clone()
.ok_or_else(|| "内置插件开关尚未初始化".to_string())?;
let file = BuiltinPluginStateFile {
schema_version: Some(STATE_SCHEMA_VERSION.to_string()),
enabled: guard.enabled.clone(),
};
let bytes = serde_json::to_vec_pretty(&file)
.map_err(|error| format!("序列化内置插件开关失败:{error}"))?;
let temporary = path.with_extension("json.tmp");
fs::write(&temporary, bytes).map_err(|error| format!("写入内置插件开关失败:{error}"))?;
fs::rename(&temporary, &path).map_err(|error| format!("提交内置插件开关失败:{error}"))?;
Ok(())
}
/// Agent 工具面是否可用:编译期 feature 打开且用户没有禁用该内置插件。
pub(crate) fn agent_tool_available(plugin: BuiltinPlugin) -> bool {
plugin.exposes_agent_tools()
&& cfg!(feature = "cocos-editor-execute")
&& is_enabled(plugin.id())
}
pub(crate) fn cocos_editor_agent_tool_available() -> bool {
agent_tool_available(BuiltinPlugin::CocosEditor)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn test_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|error| error.into_inner())
}
#[test]
fn builtin_plugins_default_to_enabled_and_reject_unknown_ids() {
let _guard = test_lock();
let directory = tempdir().expect("temp config");
initialize(directory.path()).expect("initialize");
assert!(is_builtin(AGC_COCOS_EDITOR_PLUGIN_ID));
assert!(is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
assert_eq!(toggle_state(AGC_COCOS_EDITOR_PLUGIN_ID), Some(true));
assert_eq!(toggle_state("imported-plugin"), None);
assert!(!is_enabled("imported-plugin"));
assert!(set_enabled("imported-plugin", false).is_err());
}
#[test]
fn toggle_state_round_trips_through_appdata_file() {
let _guard = test_lock();
let directory = tempdir().expect("temp config");
initialize(directory.path()).expect("initialize");
assert_eq!(
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, false).expect("disable"),
false
);
let path = directory.path().join("extensions").join(STATE_FILE_NAME);
let written = fs::read_to_string(&path).expect("state file");
assert!(written.contains(STATE_SCHEMA_VERSION));
assert!(written.contains(AGC_COCOS_EDITOR_PLUGIN_ID));
// 重新初始化模拟下次启动读取持久化结果。
initialize(directory.path()).expect("re-initialize");
assert!(!is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).expect("enable");
assert!(is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
}
#[test]
fn corrupt_state_file_fails_closed() {
let _guard = test_lock();
let directory = tempdir().expect("temp config");
let root = directory.path().join("extensions");
fs::create_dir_all(&root).expect("extensions dir");
fs::write(root.join(STATE_FILE_NAME), "{ not json").expect("write corrupt state");
assert!(initialize(directory.path()).is_err());
assert!(!is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
assert_eq!(toggle_state(AGC_COCOS_EDITOR_PLUGIN_ID), Some(false));
}
#[test]
fn unsupported_schema_fails_closed() {
let _guard = test_lock();
let directory = tempdir().expect("temp config");
let root = directory.path().join("extensions");
fs::create_dir_all(&root).expect("extensions dir");
fs::write(
root.join(STATE_FILE_NAME),
serde_json::json!({"schemaVersion": "agc.builtin-plugins.v0", "enabled": {}})
.to_string(),
)
.expect("write unsupported state");
assert!(initialize(directory.path()).is_err());
assert!(!is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
}
#[test]
fn agent_tool_visibility_follows_the_toggle() {
let _guard = test_lock();
let directory = tempdir().expect("temp config");
initialize(directory.path()).expect("initialize");
let tool_visible_when_enabled = cfg!(feature = "cocos-editor-execute");
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).expect("enable");
assert_eq!(
cocos_editor_agent_tool_available(),
tool_visible_when_enabled
);
assert_eq!(
crate::agent::agent_runtime_executable_tools().contains(&AGC_COCOS_EDITOR_TOOL_NAME),
tool_visible_when_enabled
);
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, false).expect("disable");
assert!(!cocos_editor_agent_tool_available());
assert!(
!crate::agent::agent_runtime_executable_tools().contains(&AGC_COCOS_EDITOR_TOOL_NAME)
);
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).expect("re-enable");
assert_eq!(
crate::agent::agent_runtime_executable_tools().contains(&AGC_COCOS_EDITOR_TOOL_NAME),
tool_visible_when_enabled
);
}
}
@@ -1,172 +0,0 @@
#[cfg(feature = "cocos-editor-injection")]
use cocos_editor_bridge::CocosEditorInjectionRequest;
use cocos_editor_bridge::{
CocosEditorCommandResponse, CocosEditorInjectionResult, CocosEditorProcess,
};
use serde::Deserialize;
#[cfg(feature = "cocos-editor-injection")]
use tauri::Manager;
#[cfg(feature = "cocos-editor-injection")]
const BUNDLED_COCOS_BRIDGE_PAYLOAD: &str = "cocos-editor-bridge/cocos-editor-bridge.dll";
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct CocosEditorTargetRequest {
process_id: u32,
project_path: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct CocosEditorCommandInput {
process_id: u32,
project_path: String,
#[serde(default = "default_command_timeout_ms")]
timeout_ms: u32,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct CocosEditorInjectionInput {
process_id: u32,
project_path: String,
#[serde(default = "default_injection_timeout_ms")]
timeout_ms: u32,
}
fn default_command_timeout_ms() -> u32 {
cocos_editor_bridge::DEFAULT_COMMAND_TIMEOUT_MS
}
fn default_injection_timeout_ms() -> u32 {
cocos_editor_bridge::DEFAULT_INJECTION_TIMEOUT_MS
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct CocosEditorExecuteInput {
process_id: u32,
project_path: String,
code: String,
#[serde(default = "default_command_timeout_ms")]
timeout_ms: u32,
}
#[tauri::command]
pub(crate) fn prepare_cocos_editor_injection(
request: CocosEditorTargetRequest,
) -> Result<CocosEditorProcess, String> {
#[cfg(feature = "cocos-editor")]
{
return cocos_editor_bridge::validate_injection_target(
request.process_id,
&request.project_path,
)
.map_err(|error| error.to_string());
}
#[cfg(not(feature = "cocos-editor"))]
{
let _ = (request.process_id, request.project_path);
Err("Cocos Editor bridge feature 未启用".to_string())
}
}
#[tauri::command]
pub(crate) fn ping_cocos_editor(
request: CocosEditorCommandInput,
) -> Result<CocosEditorCommandResponse, String> {
#[cfg(feature = "cocos-editor-execute")]
{
return cocos_editor_bridge::ping_cocos_editor(
request.process_id,
&request.project_path,
request.timeout_ms,
)
.map_err(|error| error.to_string());
}
#[cfg(not(feature = "cocos-editor-execute"))]
{
let _ = (request.process_id, request.project_path, request.timeout_ms);
Err("Cocos Editor execute feature 未启用".to_string())
}
}
#[tauri::command]
pub(crate) fn status_cocos_editor(
request: CocosEditorCommandInput,
) -> Result<CocosEditorCommandResponse, String> {
#[cfg(feature = "cocos-editor-execute")]
{
return cocos_editor_bridge::status_cocos_editor(
request.process_id,
&request.project_path,
request.timeout_ms,
)
.map_err(|error| error.to_string());
}
#[cfg(not(feature = "cocos-editor-execute"))]
{
let _ = (request.process_id, request.project_path, request.timeout_ms);
Err("Cocos Editor execute feature 未启用".to_string())
}
}
#[tauri::command]
pub(crate) fn execute_cocos_editor_code(
request: CocosEditorExecuteInput,
) -> Result<CocosEditorCommandResponse, String> {
#[cfg(feature = "cocos-editor-execute")]
{
return cocos_editor_bridge::execute_cocos_editor_code(
request.process_id,
&request.project_path,
&request.code,
request.timeout_ms,
)
.map_err(|error| error.to_string());
}
#[cfg(not(feature = "cocos-editor-execute"))]
{
let _ = (
request.process_id,
request.project_path,
request.code,
request.timeout_ms,
);
Err("Cocos Editor execute feature 未启用".to_string())
}
}
#[tauri::command]
pub(crate) fn inject_cocos_editor(
app: tauri::AppHandle,
input: CocosEditorInjectionInput,
) -> Result<CocosEditorInjectionResult, String> {
#[cfg(feature = "cocos-editor-injection")]
{
let payload = app
.path()
.resource_dir()
.map_err(|error| format!("解析 AGC 资源目录失败:{error}"))?
.join(BUNDLED_COCOS_BRIDGE_PAYLOAD);
if !payload.is_file() {
return Err(format!(
"AGC 未随包提供 Cocos bridge payload{}",
BUNDLED_COCOS_BRIDGE_PAYLOAD
));
}
let request = CocosEditorInjectionRequest {
process_id: input.process_id,
project_path: input.project_path,
bridge_dll_path: payload.to_string_lossy().into_owned(),
timeout_ms: input.timeout_ms,
};
return cocos_editor_bridge::inject_bridge_dll(&request).map_err(|error| error.to_string());
}
#[cfg(not(feature = "cocos-editor-injection"))]
{
let _ = app;
let _ = (input.process_id, input.project_path, input.timeout_ms);
Err("Cocos Editor injection feature 未启用;当前构建只支持目标预检".to_string())
}
}
@@ -1,36 +1,8 @@
//! Editor adapters used by the generic AGC plugin host.
//!
//! The host owns plugin lifecycle, RPC, permissions and auditing. Adapters
//! only know how to find and talk to a particular editor.
//! only know how to find and talk to a particular editor, and ship inside the
//! plugin package they belong to under the `plugins/` workspace. This module
//! only re-exports the shared contract so the host stays editor-agnostic.
use std::path::Path;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct EditorConnectionInfo {
pub(crate) adapter: String,
pub(crate) connected: bool,
pub(crate) pid: Option<u32>,
pub(crate) project_path: Option<String>,
pub(crate) version: Option<String>,
}
/// Narrow seam between the generic plugin runtime and a target editor.
pub(crate) trait EditorAdapter: Send + Sync {
fn id(&self) -> &'static str;
fn detect(&self, project_path: &Path) -> Result<EditorConnectionInfo, String>;
fn connect(
&mut self,
pid: u32,
project_path: &Path,
version: &str,
) -> Result<EditorConnectionInfo, String>;
fn disconnect(&mut self);
fn translate_rpc(&self, method: &str, params: Value) -> Result<Value, String>;
fn rpc(&self, _method: &str, _params: Value) -> Result<Value, String> {
Err("编辑器原生连接尚未建立".to_string())
}
}
pub(crate) use editor_adapter_api::{EditorAdapter, EditorConnectionInfo};
@@ -0,0 +1,54 @@
//! 已链接的编辑器适配器目录。
//!
//! 编辑器专属实现随 `plugins/` 工作区里的插件包分发;其中 native 适配器模块
//! 目前由宿主在编译期链接(Cargo path 依赖),再按插件 manifest 的 `adapter`
//! 字段注册到通用插件宿主。宿主只认适配器 id,不包含目标编辑器知识。
#[cfg(feature = "cocos-editor")]
use std::path::PathBuf;
#[cfg(feature = "cocos-editor")]
use tauri::Manager;
use crate::plugin_host::PluginHost;
/// 随包 payload 相对资源根目录的位置,与 `tauri.windows.conf.json` 的资源映射保持一致。
#[allow(dead_code)]
pub(crate) const COCOS_BRIDGE_PAYLOAD_RELATIVE: &str =
"plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll";
pub(crate) fn register_linked_editor_adapters(
app: &tauri::AppHandle,
host: &PluginHost,
) -> Result<(), String> {
#[cfg(feature = "cocos-editor")]
{
let adapter =
cocos_editor_bridge::CocosEditorAdapter::new(cocos_bridge_payload_candidates(app));
host.register_editor_adapter(Box::new(adapter))?;
}
#[cfg(not(feature = "cocos-editor"))]
{
let _ = (app, host);
}
Ok(())
}
#[cfg(feature = "cocos-editor")]
fn cocos_bridge_payload_candidates(app: &tauri::AppHandle) -> Vec<PathBuf> {
let mut candidates = Vec::new();
if let Ok(resource_dir) = app.path().resource_dir() {
candidates.push(resource_dir.join(COCOS_BRIDGE_PAYLOAD_RELATIVE));
}
// 开发构建还要能直接从 plugins/ 工作区读取尚未打包的 payload。
#[cfg(debug_assertions)]
{
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
candidates.push(
manifest_dir
.join("../../../plugins/agc-cocos-editor/native/payload")
.join("cocos-editor-bridge.dll"),
);
}
candidates
}
@@ -244,6 +244,7 @@ mod agent;
mod agent_native_tools;
mod assets;
mod browser;
mod builtin_plugins;
mod cli;
mod client_extensions;
mod collaboration;
@@ -251,7 +252,6 @@ mod command_exec;
mod command_output;
mod command_sandbox;
mod command_sandbox_trampoline;
mod cocos_editor;
mod commands;
mod config;
mod context_compaction;
@@ -260,6 +260,7 @@ mod context_menu;
mod debug;
mod delegation;
mod editor_adapter;
mod editor_adapters;
pub mod error_report;
mod git_inspect;
mod goal;
@@ -294,7 +295,6 @@ use collaboration::*;
use command_exec::*;
use command_output::*;
use command_sandbox::*;
use cocos_editor::*;
use commands::*;
use config::*;
use context_compaction::*;
@@ -308,8 +308,8 @@ use patchset::*;
use platform_session::*;
use plugin_host::{
call_agc_plugin, list_agc_extensions, list_agc_plugins, read_agc_plugin_panel,
refresh_agc_plugins, reload_agc_plugin, set_agc_plugin_project_path, start_agc_plugin,
stop_agc_plugin, PluginHost,
refresh_agc_plugins, reload_agc_plugin, set_agc_plugin_enabled, set_agc_plugin_project_path,
start_agc_plugin, stop_agc_plugin, PluginHost,
};
use preview::*;
use process_session::*;
@@ -2504,9 +2504,23 @@ fn main() {
setup_log.fail("startup.appdata.resolve.failed details=config-dir-uninitialized");
error
})?;
if let Err(error) = builtin_plugins::initialize(&config_dir) {
app_log!("startup.builtin-plugins.initialize.failed: {error}");
}
if let Err(error) = app.state::<PluginHost>().initialize(&config_dir) {
app_log!("startup.plugin-host.initialize.failed: {error}");
}
if let Some(workspace) = plugin_host::resolve_plugin_workspace(app.handle()) {
if let Err(error) = app.state::<PluginHost>().set_plugin_workspace(workspace) {
app_log!("startup.plugin-host.workspace.failed: {error}");
}
}
if let Err(error) = editor_adapters::register_linked_editor_adapters(
app.handle(),
app.state::<PluginHost>().inner(),
) {
app_log!("startup.plugin-host.adapter.failed: {error}");
}
load_platform_session_fixture_from_env(&config_dir).map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
@@ -2589,6 +2603,7 @@ fn main() {
start_agc_plugin,
stop_agc_plugin,
reload_agc_plugin,
set_agc_plugin_enabled,
call_agc_plugin,
read_agc_plugin_panel,
set_agc_plugin_project_path,
@@ -2731,11 +2746,6 @@ fn main() {
report_client_error,
get_pending_error_reports,
ack_error_reports,
prepare_cocos_editor_injection,
ping_cocos_editor,
status_cocos_editor,
execute_cocos_editor_code,
inject_cocos_editor,
])
.build(tauri_context);
let app = match app {
@@ -121,6 +121,8 @@ pub(crate) struct PluginSummary {
pub(crate) version: String,
pub(crate) api_version: String,
pub(crate) enabled: bool,
/// 内置插件随包分发,不能卸载,只能通过可用开关启停。
pub(crate) builtin: bool,
pub(crate) has_runtime: bool,
pub(crate) status: String,
pub(crate) adapter: Option<String>,
@@ -140,6 +142,7 @@ pub(crate) struct AgcExtensionSummary {
pub(crate) id: String,
pub(crate) name: String,
pub(crate) enabled: bool,
pub(crate) builtin: bool,
pub(crate) status: String,
pub(crate) plugin: Option<PluginSummary>,
pub(crate) client_extension: Option<crate::client_extensions::ClientExtensionItem>,
@@ -206,6 +209,7 @@ struct PluginRecord {
#[derive(Default)]
struct PluginHostState {
root: Option<PathBuf>,
workspace: Option<PathBuf>,
plugins: BTreeMap<String, PluginRecord>,
active_project: ProjectContext,
editors: EditorRegistry,
@@ -461,6 +465,84 @@ fn plugin_root(config_dir: &Path) -> Result<PathBuf, String> {
Ok(root)
}
/// 解析插件工作区目录:环境变量优先,其次随包资源目录 `plugins/`
/// 开发构建再回退仓库里的 `plugins/` 工作区。
pub(crate) fn resolve_plugin_workspace(app: &tauri::AppHandle) -> Option<PathBuf> {
if let Some(workspace) = std::env::var_os("AGC_PLUGIN_WORKSPACE") {
let workspace = PathBuf::from(workspace);
if workspace.is_dir() {
return Some(workspace);
}
}
if let Ok(resource_dir) = app.path().resource_dir() {
let bundled = resource_dir.join("plugins");
if bundled.is_dir() {
return Some(bundled);
}
}
#[cfg(debug_assertions)]
{
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..");
let workspace = repo_root.join("plugins");
if workspace.is_dir() {
return Some(workspace);
}
}
None
}
/// 统一后的插件来源,屏蔽“AppData 导入”和“plugins/ 工作区”的差别。
#[derive(Clone, Debug)]
struct ScannedPluginSource {
id: String,
name: String,
original_name: String,
/// `Some` 表示启用状态由来源索引决定;`None` 表示沿用插件 manifest 声明。
enabled: Option<bool>,
root: PathBuf,
}
impl ScannedPluginSource {
fn from_imported(source: crate::client_extensions::ClientPluginSource) -> Self {
Self {
id: source.item.id,
name: source.item.name,
original_name: source.item.original_name,
enabled: Some(source.item.enabled),
root: source.root,
}
}
}
/// 扫描 `plugins/` 工作区:每个含根目录 `plugin.json` 的子目录是一个插件包。
///
/// 工作区插件随包分发:内置插件按用户可用开关决定启用状态,其它工作区插件
/// 沿用 manifest 声明。
fn workspace_plugin_sources(root: &Path) -> Result<Vec<ScannedPluginSource>, String> {
let entries = match fs::read_dir(root) {
Ok(entries) => entries,
Err(error) => return Err(format!("读取插件工作区失败:{error}")),
};
let mut sources = Vec::new();
for entry in entries.flatten() {
let plugin_root = entry.path();
if !plugin_root.is_dir() || !plugin_root.join(PLUGIN_MANIFEST_FILE_NAME).is_file() {
continue;
}
let id = entry.file_name().to_string_lossy().into_owned();
let enabled = crate::builtin_plugins::toggle_state(&id);
sources.push(ScannedPluginSource {
id: id.clone(),
name: id.clone(),
original_name: id,
enabled,
root: plugin_root,
});
}
sources.sort_by(|left, right| left.id.cmp(&right.id));
Ok(sources)
}
fn audit_path(root: &Path) -> PathBuf {
root.join(AUDIT_FILE_NAME)
}
@@ -691,19 +773,68 @@ impl PluginHost {
.lock()
.map_err(|_| "插件宿主锁已损坏".to_string())?;
state.root = Some(root.clone());
if state.workspace.is_none() {
if let Some(workspace) = std::env::var_os("AGC_PLUGIN_WORKSPACE") {
let workspace = PathBuf::from(workspace);
if workspace.is_dir() {
state.workspace = Some(workspace);
}
}
}
self.scan_locked(&mut state, &root)
}
/// 注册 `plugins/` 工作区目录,让随包插件无需 AppData 导入即可被发现。
pub(crate) fn set_plugin_workspace(&self, workspace: PathBuf) -> Result<(), String> {
if !workspace.is_dir() {
return Err("插件工作区必须是目录".to_string());
}
let mut state = self
.state
.lock()
.map_err(|_| "插件宿主锁已损坏".to_string())?;
state.workspace = Some(workspace);
let root = state
.root
.clone()
.ok_or_else(|| "插件宿主尚未初始化".to_string())?;
self.scan_locked(&mut state, &root)
}
fn scan_locked(&self, state: &mut PluginHostState, root: &Path) -> Result<(), String> {
let sources = crate::client_extensions::client_plugin_sources_at(root)?;
let workspace_sources = match state.workspace.clone() {
Some(workspace) => workspace_plugin_sources(&workspace)?,
None => Vec::new(),
};
// 内置插件优先:随包插件不能被 AppData 同名导入覆盖,也不能被卸载。
let mut sources = workspace_sources
.iter()
.filter(|source| crate::builtin_plugins::is_builtin(&source.id))
.cloned()
.collect::<Vec<_>>();
for source in crate::client_extensions::client_plugin_sources_at(root)?
.into_iter()
.map(ScannedPluginSource::from_imported)
{
if !sources.iter().any(|existing| existing.id == source.id) {
sources.push(source);
}
}
for source in workspace_sources {
if !sources.iter().any(|existing| existing.id == source.id) {
sources.push(source);
}
}
let mut discovered = BTreeMap::new();
for source in sources {
let id = source.item.id;
let id = source.id.clone();
match read_plugin_manifest(&source.root) {
Ok(mut manifest) => {
manifest.enabled = source.item.enabled;
if source.item.name != source.item.original_name {
manifest.name = source.item.name;
if let Some(enabled) = source.enabled {
manifest.enabled = enabled;
}
if source.name != source.original_name {
manifest.name = source.name.clone();
}
let existing = state
.plugins
@@ -758,7 +889,7 @@ impl PluginHost {
id: id.clone(),
manifest: PluginManifest {
id,
name: source.item.name,
name: source.name,
version: "0".to_string(),
api_version: PLUGIN_API_VERSION.to_string(),
entry: None,
@@ -824,6 +955,7 @@ impl PluginHost {
id: plugin.id.clone(),
name: plugin.name.clone(),
enabled: plugin.enabled,
builtin: plugin.builtin,
status: plugin.status.clone(),
plugin: Some(plugin),
client_extension: None,
@@ -848,6 +980,7 @@ impl PluginHost {
id: extension.id.clone(),
name: extension.name.clone(),
enabled: extension.enabled,
builtin: false,
status: extension.status.clone(),
plugin: None,
client_extension: Some(extension),
@@ -933,6 +1066,25 @@ impl PluginHost {
self.start(id)
}
/// 内置插件的可用开关:禁用时先停进程,再持久化状态并重新扫描。
///
/// 该状态同时被 Agent 工具目录消费,禁用后插件不能启动,对应 Runtime 工具
/// 也不再出现在工具列表与 Agent 上下文里。
pub(crate) fn set_enabled(
&self,
id: &str,
enabled: bool,
) -> Result<Vec<PluginSummary>, String> {
if !crate::builtin_plugins::is_builtin(id) {
return Err("只有内置插件可以使用可用开关;导入扩展请使用扩展启用状态".to_string());
}
if !enabled {
let _ = self.stop(id);
}
crate::builtin_plugins::set_enabled(id, enabled)?;
self.refresh()
}
pub(crate) fn read_panel(
&self,
id: &str,
@@ -1273,7 +1425,12 @@ impl PluginHost {
id.clone(),
event_type.to_string(),
)?;
Ok(json!({"subscriptionId": id}))
let project_path = active_project
.lock()
.map_err(|_| "项目上下文锁已损坏".to_string())?
.clone()
.map(|path| path.to_string_lossy().into_owned());
Ok(json!({"subscriptionId": id, "projectPath": project_path}))
}
"host.events.unsubscribe" => {
let id = params
@@ -1353,6 +1510,7 @@ impl PluginHost {
version: record.manifest.version.clone(),
api_version: record.manifest.api_version.clone(),
enabled: record.manifest.enabled,
builtin: crate::builtin_plugins::is_builtin(&record.id),
has_runtime: record.manifest.entry.is_some(),
status: record.status.clone(),
adapter: record.manifest.adapter.clone(),
@@ -1395,7 +1553,19 @@ impl PluginHost {
if subscribed {
let _ = write_rpc_shared(
&running.stdin,
&json!({"jsonrpc":"2.0", "method":"host.event", "params":{"type":"project.changed"}}),
&json!({
"jsonrpc":"2.0",
"method":"host.event",
"params":{
"type":"project.changed",
"payload":{"projectPath": state
.active_project
.lock()
.map_err(|_| "项目上下文锁已损坏".to_string())?
.as_ref()
.map(|path| path.to_string_lossy().into_owned())},
},
}),
);
}
}
@@ -1542,6 +1712,15 @@ pub(crate) fn reload_agc_plugin(
host.reload(id.trim())
}
#[tauri::command]
pub(crate) fn set_agc_plugin_enabled(
id: String,
enabled: bool,
host: State<'_, PluginHost>,
) -> Result<Vec<PluginSummary>, String> {
host.set_enabled(id.trim(), enabled)
}
#[tauri::command]
pub(crate) async fn call_agc_plugin(
id: String,
@@ -1651,6 +1830,57 @@ mod tests {
assert_eq!(list[0].id, id);
}
fn write_workspace_plugin(workspace: &Path, name: &str, enabled: bool) {
let plugin = workspace.join(name);
fs::create_dir_all(&plugin).expect("plugin directory");
fs::write(
plugin.join("plugin.json"),
json!({
"$schema": AGENT_PLUGINS_SCHEMA,
"name": name,
"version": "1.0.0",
"extensions": {
"world.genarrative.agc": {
"entry": "index.js",
"permissions": ["ui.register"],
"enabled": enabled
}
}
})
.to_string(),
)
.expect("manifest");
fs::write(plugin.join("index.js"), "process.stdin.resume();").expect("entry");
}
#[test]
fn scans_plugins_workspace_and_honors_manifest_enabled_flag() {
let directory = tempdir().expect("temp config");
let workspace = directory.path().join("workspace");
write_workspace_plugin(&workspace, "sample-plugin", true);
write_workspace_plugin(&workspace, "disabled-plugin", false);
let host = PluginHost::default();
host.initialize(directory.path()).expect("initialize");
assert!(host.list().expect("list before workspace").is_empty());
host.set_plugin_workspace(workspace.clone())
.expect("set workspace");
let list = host.list().expect("list after workspace");
assert_eq!(list.len(), 2);
let enabled = list
.iter()
.find(|plugin| plugin.id == "sample-plugin")
.expect("enabled plugin");
assert!(enabled.enabled);
assert_eq!(enabled.status, "stopped");
assert_eq!(enabled.adapter, None);
let disabled = list
.iter()
.find(|plugin| plugin.id == "disabled-plugin")
.expect("disabled plugin");
assert!(!disabled.enabled);
assert_eq!(disabled.status, "disabled");
}
#[test]
fn denies_ungranted_host_registration() {
let directory = tempdir().expect("temp config");
@@ -1742,4 +1972,126 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p
let mut reader = std::io::Cursor::new(vec![b'a'; MAX_RPC_BYTES + 1]);
assert!(read_bounded_rpc_line(&mut reader).is_err());
}
struct StubCocosAdapter;
impl EditorAdapter for StubCocosAdapter {
fn id(&self) -> &'static str {
"cocos-editor"
}
fn detect(&self, _project_path: &Path) -> Result<EditorConnectionInfo, String> {
Err("stub adapter 不探测进程".to_string())
}
fn connect(
&mut self,
_pid: u32,
_project_path: &Path,
_version: &str,
) -> Result<EditorConnectionInfo, String> {
Err("stub adapter 不建立连接".to_string())
}
fn disconnect(&mut self) {}
fn translate_rpc(&self, _method: &str, params: Value) -> Result<Value, String> {
Ok(params)
}
fn rpc(&self, method: &str, params: Value) -> Result<Value, String> {
// 与 native 适配器的 CocosEditorCommandResponse 同形,供插件入口判断 status。
Ok(json!({"ok": true, "method": method, "params": params}))
}
}
#[test]
fn builtin_plugin_toggle_controls_availability() {
let directory = tempdir().expect("temp config");
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins");
let host = PluginHost::default();
crate::builtin_plugins::initialize(directory.path()).expect("builtin plugin state");
host.initialize(directory.path()).expect("initialize");
host.set_plugin_workspace(workspace)
.expect("set plugins workspace");
let summary = |list: Vec<PluginSummary>| {
list.into_iter()
.find(|plugin| plugin.id == "agc-cocos-editor")
.expect("built-in plugin")
};
let enabled = summary(host.list().expect("list"));
assert!(enabled.builtin);
assert!(enabled.enabled);
let disabled = summary(
host.set_enabled("agc-cocos-editor", false)
.expect("disable built-in plugin"),
);
assert!(!disabled.enabled);
assert_eq!(disabled.status, "disabled");
assert!(host.start("agc-cocos-editor").is_err());
assert!(host
.set_enabled("imported-extension", false)
.expect_err("imported extensions use the extension index")
.contains("只有内置插件"));
let re_enabled = summary(
host.set_enabled("agc-cocos-editor", true)
.expect("enable built-in plugin"),
);
assert!(re_enabled.enabled);
assert_eq!(re_enabled.status, "stopped");
}
#[test]
fn workspace_cocos_plugin_round_trips_editor_rpc() {
let directory = tempdir().expect("temp config");
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins");
let host = PluginHost::default();
host.initialize(directory.path()).expect("initialize");
host.set_plugin_workspace(workspace)
.expect("set plugins workspace");
host.register_editor_adapter(Box::new(StubCocosAdapter))
.expect("register adapter");
let project = fs::canonicalize(directory.path())
.expect("canonical project")
.to_string_lossy()
.into_owned();
host.set_active_project(Some(project.clone()))
.expect("set active project");
host.start("agc-cocos-editor").expect("start plugin");
let deadline = std::time::Instant::now() + Duration::from_secs(15);
loop {
let registered = host.list().expect("list").into_iter().any(|plugin| {
plugin.id == "agc-cocos-editor"
&& plugin.commands.len() == 1
&& plugin.capabilities.len() == 1
&& plugin.panels.len() == 1
});
if registered {
break;
}
assert!(
std::time::Instant::now() < deadline,
"Cocos 插件未在期限内完成注册"
);
thread::sleep(Duration::from_millis(25));
}
let response = host
.call(
"agc-cocos-editor",
"cocos.editor.execute".to_string(),
json!({"code": "return 1 + 1;"}),
)
.expect("cocos execute rpc");
assert_eq!(response["status"], "completed");
assert_eq!(response["response"]["method"], "editor.execute");
assert_eq!(response["response"]["params"]["projectPath"], project);
assert_eq!(response["response"]["params"]["code"], "return 1 + 1;");
assert_eq!(
host.stop("agc-cocos-editor").expect("stop plugin").status,
"stopped"
);
}
}
@@ -12,7 +12,7 @@
"resources/codex/win-x64/codex-package.json": "codex/win-x64/codex-package.json",
"resources/codex/win-x64/NOTICE.md": "codex/win-x64/NOTICE.md",
"resources/codex/win-x64/manifest.json": "codex/win-x64/manifest.json",
"resources/cocos-editor-bridge": "cocos-editor-bridge"
"resources/plugins": "plugins"
}
}
}
@@ -105,6 +105,8 @@ export type AgcPluginSummary = {
version: string;
apiVersion: string;
enabled: boolean;
/** 内置插件随包分发、不能卸载,只能通过可用开关控制。 */
builtin: boolean;
hasRuntime: boolean;
status: AgcPluginStatus;
adapter: string | null;
@@ -120,6 +122,7 @@ export type AgcExtensionSummary = {
id: string;
name: string;
enabled: boolean;
builtin: boolean;
status: string;
plugin: AgcPluginSummary | null;
clientExtension: ClientExtensionItem | null;
@@ -37,6 +37,7 @@ import { checkForAppUpdate } from '../../services/appUpdate';
import {
listAgcExtensions,
reloadAgcPlugin,
setAgcPluginEnabled,
setAgcPluginProjectPath,
startAgcPlugin,
stopAgcPlugin,
@@ -346,6 +347,27 @@ export function RuntimeConfigDialog({
}
}
/** 内置插件没有卸载入口,只用可用开关控制是否对客户端和 Agent 生效。 */
async function setBuiltinPluginEnabled(
plugin: AgcPluginSummary,
enabled: boolean,
) {
if (agcPluginsBusy) return;
setAgcPluginsBusy(true);
try {
const plugins = await setAgcPluginEnabled(plugin.id, enabled);
setAgcPlugins(plugins);
await readClientExtensions();
setAgcPluginsStatus(enabled ? '内置插件已启用' : '内置插件已禁用');
} catch (error) {
setAgcPluginsStatus(
error instanceof Error ? error.message : String(error),
);
} finally {
setAgcPluginsBusy(false);
}
}
async function readClientExtensions() {
const invoke = resolveTauriInvoke();
if (!invoke) {
@@ -940,93 +962,40 @@ export function RuntimeConfigDialog({
{clientExtensionsStatus}
</p>
) : null}
{clientExtensionsLoadState === 'loading' ? (
<div className="runtime-settings-empty-state" role="status">
<strong></strong>
<span> Skill MCP</span>
</div>
) : clientExtensionsLoadState === 'error' ? (
<div className="runtime-settings-empty-state" role="alert">
<strong></strong>
<span>
{clientExtensionsStatus || '暂时无法读取扩展列表。'}
</span>
</div>
) : clientExtensions.length > 0 ? (
{agcPlugins.some((plugin) => plugin.builtin) ? (
<div className="runtime-settings-extension-list">
{clientExtensions.map((item) => {
const editing = editingExtensionId === item.id;
const plugin = agcPlugins.find(
(plugin) => plugin.id === item.id,
);
const typeLabel =
item.extensionType === 'plugin'
? 'Plugin'
: item.extensionType === 'skill'
? 'Skill'
: item.extensionType === 'mcp'
? 'MCP'
: '未识别';
const statusLabel =
item.status === 'enabled'
? '已启用'
: item.status === 'disabled'
? '已禁用'
: item.status === 'startup-failed'
? '启动失败'
: '当前不可用';
return (
{agcPlugins
.filter((plugin) => plugin.builtin)
.map((plugin) => (
<article
className="runtime-settings-extension-item"
key={item.id}
key={plugin.id}
>
<div className="runtime-settings-extension-main">
{editing ? (
<input
aria-label={`${item.name} 新名称`}
autoFocus
value={editingExtensionName}
onChange={(event) =>
setEditingExtensionName(
event.currentTarget.value,
)
}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
void saveClientExtensionName(item);
} else if (event.key === 'Escape') {
event.stopPropagation();
cancelRenameClientExtension();
}
}}
/>
) : (
<strong title={item.name}>{item.name}</strong>
)}
<span>
{typeLabel} · {item.sourceName}
</span>
{item.lastError ? (
<small title={item.lastError}>
{item.lastError}
<strong title={plugin.name}>{plugin.name}</strong>
<span> Plugin · </span>
{plugin.lastError ? (
<small title={plugin.lastError}>
{plugin.lastError}
</small>
) : null}
</div>
<div className="runtime-settings-extension-actions">
<span>
{plugin?.status === 'running'
? '运行中'
: statusLabel}
{!plugin.enabled
? '已禁用'
: plugin.status === 'running'
? '运行中'
: plugin.status === 'invalid'
? '当前不可用'
: '已启用'}
</span>
{plugin?.enabled && plugin.hasRuntime ? (
{plugin.enabled && plugin.hasRuntime ? (
<>
<button
type="button"
disabled={
agcPluginsBusy ||
clientExtensionsBusy ||
plugin.status === 'invalid'
}
onClick={() => void toggleAgcPlugin(plugin)}
@@ -1047,7 +1016,7 @@ export function RuntimeConfigDialog({
</button>
</>
) : null}
{plugin?.status === 'running'
{plugin.status === 'running'
? plugin.panels.map((panel) => (
<button
type="button"
@@ -1063,66 +1032,226 @@ export function RuntimeConfigDialog({
</button>
))
: null}
{editing ? (
<>
<button
type="button"
disabled={clientExtensionsBusy}
onClick={() =>
void saveClientExtensionName(item)
}
>
</button>
<button
type="button"
disabled={clientExtensionsBusy}
onClick={cancelRenameClientExtension}
>
</button>
</>
) : (
<button
type="button"
aria-label={`重命名 ${item.name}`}
disabled={clientExtensionsBusy}
onClick={() =>
beginRenameClientExtension(item)
}
>
<Pencil size={14} aria-hidden="true" />
</button>
)}
{item.extensionType === 'unknown' ? null : (
<button
type="button"
role="switch"
aria-checked={item.enabled}
aria-label={`${item.name} ${item.enabled ? '禁用' : '启用'}`}
disabled={clientExtensionsBusy}
onClick={() =>
void setClientExtensionEnabled(
item,
!item.enabled,
)
}
>
{item.enabled ? '禁用' : '启用'}
</button>
)}
<button
type="button"
aria-label={`删除 ${item.name}`}
disabled={clientExtensionsBusy}
onClick={() => void removeClientExtension(item)}
role="switch"
aria-checked={plugin.enabled}
aria-label={`${plugin.name} ${
plugin.enabled ? '禁用' : '启用'
}`}
disabled={agcPluginsBusy}
onClick={() =>
void setBuiltinPluginEnabled(
plugin,
!plugin.enabled,
)
}
>
<Trash2 size={14} aria-hidden="true" />
{plugin.enabled ? '禁用' : '启用'}
</button>
</div>
</article>
);
})}
))}
</div>
) : null}
{clientExtensionsLoadState === 'loading' ? (
<div className="runtime-settings-empty-state" role="status">
<strong></strong>
<span> Skill MCP</span>
</div>
) : clientExtensionsLoadState === 'error' ? (
<div className="runtime-settings-empty-state" role="alert">
<strong></strong>
<span>
{clientExtensionsStatus || '暂时无法读取扩展列表。'}
</span>
</div>
) : clientExtensions.length > 0 ? (
<div className="runtime-settings-extension-list">
{clientExtensions
.filter(
(item) =>
!(
item.extensionType === 'plugin' &&
agcPlugins.some(
(plugin) =>
plugin.builtin && plugin.id === item.id,
)
),
)
.map((item) => {
const editing = editingExtensionId === item.id;
const plugin = agcPlugins.find(
(plugin) => plugin.id === item.id,
);
const typeLabel =
item.extensionType === 'plugin'
? 'Plugin'
: item.extensionType === 'skill'
? 'Skill'
: item.extensionType === 'mcp'
? 'MCP'
: '未识别';
const statusLabel =
item.status === 'enabled'
? '已启用'
: item.status === 'disabled'
? '已禁用'
: item.status === 'startup-failed'
? '启动失败'
: '当前不可用';
return (
<article
className="runtime-settings-extension-item"
key={item.id}
>
<div className="runtime-settings-extension-main">
{editing ? (
<input
aria-label={`${item.name} 新名称`}
autoFocus
value={editingExtensionName}
onChange={(event) =>
setEditingExtensionName(
event.currentTarget.value,
)
}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
void saveClientExtensionName(item);
} else if (event.key === 'Escape') {
event.stopPropagation();
cancelRenameClientExtension();
}
}}
/>
) : (
<strong title={item.name}>{item.name}</strong>
)}
<span>
{typeLabel} · {item.sourceName}
</span>
{item.lastError ? (
<small title={item.lastError}>
{item.lastError}
</small>
) : null}
</div>
<div className="runtime-settings-extension-actions">
<span>
{plugin?.status === 'running'
? '运行中'
: statusLabel}
</span>
{plugin?.enabled && plugin.hasRuntime ? (
<>
<button
type="button"
disabled={
agcPluginsBusy ||
clientExtensionsBusy ||
plugin.status === 'invalid'
}
onClick={() =>
void toggleAgcPlugin(plugin)
}
>
{plugin.status === 'running'
? '停止'
: '启动'}
</button>
<button
type="button"
disabled={
agcPluginsBusy ||
plugin.status !== 'running'
}
onClick={() => void reloadPlugin(plugin)}
>
</button>
</>
) : null}
{plugin?.status === 'running'
? plugin.panels.map((panel) => (
<button
type="button"
key={panel.id}
onClick={() =>
setMountedPluginPanel({
pluginId: plugin.id,
panel,
})
}
>
{panel.title}
</button>
))
: null}
{editing ? (
<>
<button
type="button"
disabled={clientExtensionsBusy}
onClick={() =>
void saveClientExtensionName(item)
}
>
</button>
<button
type="button"
disabled={clientExtensionsBusy}
onClick={cancelRenameClientExtension}
>
</button>
</>
) : (
<button
type="button"
aria-label={`重命名 ${item.name}`}
disabled={clientExtensionsBusy}
onClick={() =>
beginRenameClientExtension(item)
}
>
<Pencil size={14} aria-hidden="true" />
</button>
)}
{item.extensionType === 'unknown' ? null : (
<button
type="button"
role="switch"
aria-checked={item.enabled}
aria-label={`${item.name} ${item.enabled ? '禁用' : '启用'}`}
disabled={clientExtensionsBusy}
onClick={() =>
void setClientExtensionEnabled(
item,
!item.enabled,
)
}
>
{item.enabled ? '禁用' : '启用'}
</button>
)}
<button
type="button"
aria-label={`删除 ${item.name}`}
disabled={clientExtensionsBusy}
onClick={() =>
void removeClientExtension(item)
}
>
<Trash2 size={14} aria-hidden="true" />
</button>
</div>
</article>
);
})}
</div>
) : (
<div className="runtime-settings-empty-state">
@@ -45,6 +45,14 @@ export async function reloadAgcPlugin(id: string) {
}) as Promise<AgcPluginSummary>;
}
/** 内置插件的可用开关;禁用后不能启动,对应 Agent 工具也不再出现。 */
export async function setAgcPluginEnabled(id: string, enabled: boolean) {
return invokeOrThrow()('set_agc_plugin_enabled', {
id,
enabled,
}) as Promise<AgcPluginSummary[]>;
}
export async function callAgcPlugin<T = unknown>(
id: string,
method: string,
@@ -8211,3 +8211,19 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 注入仅加载随 AGC 资源目录提供的 DLL,结果先标记 `injected-unverified`,必须由 payload 完成握手后才可开放有限 Cocos 操作;Runtime 的 execute 代码有界并受确认策略保护,不开放未受限 eval 或项目扩展自动写入。
- Runtime 第一阶段只广告 `cocos.editor.execute`,代码长度有界、默认走确认策略,项目根和目标 PID 不交给模型;`ping/status` 先作为宿主命令保留,不扩大全局 Agent 工具面。
- DirectProject 的 `agc_tools` 对应入口是 `agc_cocos_execute`,同样只接收 code,并沿用当前项目权限。2026-09-10 已通过临时真实 Creator 3.8.8 验证 Node 的 Windows 调试 handler 激活 Inspector、注入 bootstrap、pipe execute 及关闭 Inspector 后继续执行;此路线尚未替换当前 native DLL 源码。现有 `RequestInterrupt` 回调不能调用 JavaScript,不能把 DLL 加载和窗口线程钩子当作可用握手。执行发送后的未知结果禁止自动重放,Direct bridge 会阻断后续 execute。详细步骤、版本/fuse 和端口边界见 Cocos bridge 技术方案。
## 2026-09-10 Cocos 直连模块改为插件包与独立插件工作区
- 决策:Cocos 直连模块从 AGC 源码树移入插件包 `plugins/agc-cocos-editor``plugin.json` 使用 Agent Plugins 清单加 `extensions.world.genarrative.agc``src/entry.mjs` 作为 Runtime 入口注册 `cocos.editor.execute` 命令、`cocos.editor.connection` 能力和 `cocos-editor` 面板,native 模块 `native/cocos-editor-bridge` 实现通用 `EditorAdapter`
- 决策:新增 `plugins/` 工作区(npm workspace 成员)作为插件唯一存放位置;宿主解析顺序为 `AGC_PLUGIN_WORKSPACE`、随包 `<resource_dir>/plugins`、开发构建的仓库 `plugins/`。工作区插件按自身 manifest 声明启用状态,AppData 同名导入插件优先。
- 决策:通用适配器契约抽到 `server-rs/crates/editor-adapter-api`,宿主 `editor_adapter` 只做 re-export`editor_adapters.rs` 负责编译期链接注册;AGC 删除 Cocos 专属 Tauri 命令和 `src/cocos_editor.rs`,编辑器操作统一走 `host.rpc``EditorAdapter::rpc`
- 边界:native 适配器当前仍由宿主编译期链接(Cargo path 依赖),动态加载插件 native 模块不在本次范围;Runtime 的 `cocos.editor.execute` 工具与 DirectProject 的 `agc_cocos_execute` 继续使用同一 native 实现,共享项目锁与“结果不确定禁止重放”语义。
- 验证:插件包 `node --test` 与 native crate 单元测试、宿主工作区扫描与 manifest 启用状态测试、AGC typecheck、`check:npm-workspaces`、编码检查分别执行;真实 Creator 注入验收仍按 Cocos 桥接方案单独执行。
## 2026-09-10 内置插件不可卸载与可用开关
- 决策:`plugins/` 工作区里的插件按内置插件处理,随客户端分发、不能卸载或删除;同名 AppData 导入插件不覆盖内置定义。内置插件在 `PluginSummary` / `AgcExtensionSummary` 里带 `builtin`,前端只显示可用开关。
- 决策:唯一开关入口为 `set_agc_plugin_enabled`,只接受登记过的内置插件 id,状态持久化到 AppData `extensions/builtin-plugins.json``schemaVersion = agc.builtin-plugins.v1`);文件缺失按 manifest `enabled` 处理,坏文件失败关闭。
- 决策:禁用时先停止运行中的插件进程并让 `start_agc_plugin` 失败;同时把对应 Runtime 工具从 `agent_runtime_executable_tools()` 移除,使其不再进入工具策略快照、原生函数目录和系统提示词工具目录,DirectProject 的 `agc_tools` 规格与 bridge 执行入口同步拒绝。启用后立即恢复,不需要重启客户端。
- 边界:导入扩展的启用状态仍走既有 `set_client_extension_enabled` 和扩展索引,不并入内置插件开关文件;内置插件开关不改变 manifest、权限或审计协议。
- 验证:`builtin_plugins` 单测覆盖默认值、持久化往返、坏文件失败关闭和“禁用后工具目录不再出现该工具”;`plugin_host` 单测覆盖禁用后不能启动、导入 id 被拒绝、启用后回到 stopped。
@@ -2,7 +2,7 @@
## 目标
目标是在用户已打开 Cocos Creator 项目时,由 AGC 识别正确的 Creator 主进程,并在进程内注入随包 JavaScript bootstrap;用户不需要在 Cocos 项目中手动安装扩展。桥接核心独立于 Tauri,位于 `server-rs/crates/cocos-editor-bridge`。2026-09-10 已验证通过 Node 自带的运行中 Inspector 激活入口完成引导,具体见本文“Inspector 注入调研”;这条路径尚未替换当前 crate 的 DLL 实现。
目标是在用户已打开 Cocos Creator 项目时,由 AGC 识别正确的 Creator 主进程,并在进程内注入随包 JavaScript bootstrap;用户不需要在 Cocos 项目中手动安装扩展。桥接核心独立于 Tauri,随 Cocos 插件包分发,位于 `plugins/agc-cocos-editor/native/cocos-editor-bridge`。2026-09-10 已验证通过 Node 自带的运行中 Inspector 激活入口完成引导,具体见本文“Inspector 注入调研”;这条路径尚未替换当前 crate 的 DLL 实现。
## 边界
@@ -20,7 +20,7 @@ Cocos Creator 3.x 是 Electron/Node 编辑器,不能复用 Unity Mono 的 Core
crate 默认不启用任何宿主集成:
```toml
cocos-editor-bridge = { path = ".../server-rs/crates/cocos-editor-bridge", default-features = false, features = ["process-discovery"] }
cocos-editor-bridge = { path = ".../plugins/agc-cocos-editor/native/cocos-editor-bridge", default-features = false, features = ["process-discovery"] }
```
- `process-discovery`:启用 Windows Creator 主进程发现;不加载 Windows 注入 API。
@@ -29,11 +29,28 @@ cocos-editor-bridge = { path = ".../server-rs/crates/cocos-editor-bridge", defau
AGC 或其它桌面宿主应将 `windows-injection` 作为单独的发行构建开关,服务端和非桌面构建保持 `default-features = false`
当前 AGC Tauri adapter 只暴露 `prepare_cocos_editor_injection`(确认前预检)、`inject_cocos_editor`(确认后注入)、`ping_cocos_editor``status_cocos_editor` `execute_cocos_editor_code`。Runtime 只广告一个 `cocos.editor.execute` 工具,代码输入使用当前项目根,目标 PID 由 crate 内部唯一匹配;默认命令权限为 confirm,具体运行档沿用已有 Runtime 策略。进程发现留在 crate 内部作为目标校验步骤,不建立客户端扫描服务或独立发现入口。默认 AGC 构建不启用 Cocos 集成;桌面构建需显式传 `--features cocos-editor`,命令执行需传 `--features cocos-editor-execute`,注入构建再传 `--features cocos-editor-injection`。注入命令不接收 DLL 路径,只加载资源目录中的 `cocos-editor-bridge/cocos-editor-bridge.dll`,避免把 Tauri command 变成任意 DLL 注入器
AGC 不再内置 Cocos 专属 Tauri 命令。适配器 `cocos-editor` 由插件包 `plugins/agc-cocos-editor` 提供,实现通用 `EditorAdapter``prepare` / `inject` / `ping` / `status` / `execute` / `detect` / `connect` / `disconnect`),由宿主按 manifest 的 `adapter` 字段注册;插件入口通过 `host.rpc` 触发这些操作,宿主校验 `editor.rpc` 权限后路由到 native 模块
Runtime 只广告一个 `cocos.editor.execute` 工具,代码输入使用当前项目根,目标 PID 由 crate 内部唯一匹配;默认命令权限为 confirm,具体运行档沿用已有 Runtime 策略。进程发现留在 crate 内部作为目标校验步骤,不建立客户端扫描服务或独立发现入口。默认 AGC 构建不启用 Cocos 集成;桌面构建需显式传 `--features cocos-editor`,命令执行需传 `--features cocos-editor-execute`,注入构建再传 `--features cocos-editor-injection`。注入只加载插件包 payload 目录里的 `cocos-editor-bridge.dll`(打包后位于 `<resource_dir>/plugins/agc-cocos-editor/native/payload`),适配器拒绝任何其它路径,避免变成任意 DLL 注入器。
## 插件包形态
```text
plugins/agc-cocos-editor/
├─ plugin.json Agent Plugins 清单 + AGC Runtime 扩展(adapter=cocos-editor
├─ src/entry.mjs 运行时入口:注册命令 / 能力 / 面板,转发 host.rpc
├─ src/cocos-editor-adapter.mjs 通用请求 → 编辑器请求的翻译与入参校验
├─ panels/cocos-editor.html 自包含面板
└─ native/cocos-editor-bridge/ native 模块:进程发现、pipe 协议、注入、EditorAdapter 实现
```
宿主按 `AGC_PLUGIN_WORKSPACE`、随包 `<resource_dir>/plugins`、开发构建仓库 `plugins/` 的顺序解析工作区;插件包内的 `native/payload` 由构建脚本随包映射,生成的 DLL 不入库。插件协议、权限和面板挂载全部复用通用宿主,Cocos 专属逻辑只存在于本插件包:进程名与 `--project` 解析、Creator 版本校验、named pipe 协议和 Windows 注入。
该插件是**内置插件**:随客户端分发、不能卸载,只能通过 `set_agc_plugin_enabled` 控制是否可用。禁用后插件进程停止且不能启动,Runtime 工具 `cocos.editor.execute` 与 DirectProject 的 `agc_cocos_execute` 同时从工具目录、工具策略快照和 Agent 上下文里消失;重新启用后立即恢复。开关状态保存在 AppData `extensions/builtin-plugins.json`
## 第一阶段命令协议
DirectProject 的现役 `agc_tools` 目录通过 Windows `cocos-editor-execute` feature 注册 `agc_cocos_execute`,参数只有 `code`。客户端在 blocking worker 内调用 crate,保留项目锁和现有项目权限;当前 bridge 出现执行结果不确定后拒绝后续 execute。旧 Runtime 的对应工具名为 `cocos.editor.execute`,继续使用它已有的 pending action、权限和恢复语义。
DirectProject 的现役 `agc_tools` 目录通过 Windows `cocos-editor-execute` feature 注册 `agc_cocos_execute`,参数只有 `code`。客户端在 blocking worker 内调用插件 native 模块,保留项目锁和现有项目权限;当前 bridge 出现执行结果不确定后拒绝后续 execute。旧 Runtime 的对应工具名为 `cocos.editor.execute`,继续使用它已有的 pending action、权限和恢复语义;插件入口注册的同名命令走宿主 `host.rpc``EditorAdapter` 路径,两条路径共享同一 native 实现和不确定结果阻断语义
注入 payload 在目标 Creator 主进程内监听 `\\.\pipe\genarrative-cocos-editor-{pid}`,使用换行分隔的 JSON。crate 只生成三种操作:
@@ -11,8 +11,10 @@ AGC 插件系统由一个通用宿主和一个通用 SDK 组成。宿主统一
```text
apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs
apps/ai-game-creator-shell/src-tauri/src/editor_adapter/mod.rs
apps/ai-game-creator-shell/src-tauri/src/editor_adapter/mod.rs
apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs
packages/agc-plugin-sdk/src/index.ts
server-rs/crates/editor-adapter-api/src/lib.rs
plugins/agc-cocos-editor/ (第一个编辑器插件包)
```
现有 DirectProject 的 Skill/MCP 导入仍保留。它们是 Codex 扩展注入链路,不等同于本宿主管理的可运行 AGC Plugin。
@@ -59,6 +61,21 @@ OpenAI 的标准模型是“Plugin 作为可安装包,组合 Skills、可选 M
当前允许的权限为 `events.subscribe``project.read``editor.rpc``ui.register``capability.register`。未知权限、重复面板 id、非法入口和不支持的 API/适配器会使插件进入 `invalid` 状态,不启动进程。
### plugins/ 工作区
除 AppData 导入外,宿主还扫描 `plugins/` 工作区:每个含根目录 `plugin.json` 的一级子目录是一个插件包。解析顺序为环境变量 `AGC_PLUGIN_WORKSPACE`、随包资源目录 `<resource_dir>/plugins`、开发构建的仓库 `plugins/`。仓库工作区约定见 [`plugins/README.md`](../../../plugins/README.md)。
### 内置插件与可用开关
`plugins/` 工作区里的插件是**内置插件**:随客户端分发,用户不能卸载或删除,只能通过可用开关控制是否生效。开关状态持久化在 AppData `extensions/builtin-plugins.json``schemaVersion = agc.builtin-plugins.v1``enabled` 是 id 到布尔的映射);文件缺失按插件 manifest 的 `enabled` 处理,坏文件失败关闭。内置插件优先级高于同名导入插件,AppData 里的同名 Plugin 不会覆盖或间接卸载它。
开关同时驱动两处行为:
1. 插件宿主:禁用时先停止运行中的插件进程,状态变为 `disabled``start_agc_plugin` 返回“插件已禁用”。内置插件在 `PluginSummary` / `AgcExtensionSummary` 里带 `builtin: true`,前端只显示可用开关,不显示重命名和卸载入口。
2. Agent 工具面:禁用后对应 Runtime 工具从 `agent_runtime_executable_tools()` 里移除,因此不再进入工具策略快照(`autoTools` / `confirmTools` / `allowedTools`)、原生函数目录和系统提示词中的工具目录;DirectProject 的 `agc_tools` 规格同步移除,bridge 执行入口也会拒绝。启用后立即恢复,不需要重启客户端。
唯一的开关入口是 Tauri 命令 `set_agc_plugin_enabled`,它只接受登记过的内置插件 id;导入扩展继续使用既有 `set_client_extension_enabled`
## 运行和 RPC
宿主以已安装插件目录为 cwd 启动入口;JavaScript 入口使用系统 `node` 执行,其它入口直接执行。环境先清空,再保留 PATH、Windows 系统目录和临时目录等必要变量,并注入插件身份和协议版本;不继承客户端凭据。Windows 复用进程模块的 Job Object,Unix 使用独立进程组,停止/卸载时回收自有进程。
@@ -82,14 +99,18 @@ host.rpc(method, params)
## 编辑器适配器扩展点
`EditorAdapter` 只定义 `detect``connect``disconnect``translate_rpc` 和原生 `rpc`。宿主只保存适配器 registry,并把插件声明的适配器名称路由到对应实现;具体编辑器如何查找进程、校验 PID/项目/版本、建立连接和翻译编辑器消息,由后续适配器包独立实现。
`EditorAdapter` 契约位于通用 crate `server-rs/crates/editor-adapter-api`只定义 `detect``connect``disconnect``translate_rpc` 和原生 `rpc`。宿主只保存适配器 registry,并把插件声明的适配器名称路由到对应实现;具体编辑器如何查找进程、校验 PID/项目/版本、建立连接和翻译编辑器消息,由插件包自带模块实现。
本次不内置任何目标编辑器适配器,也不包含编辑器专属进程名、注入逻辑或 Tauri 命令。新增适配器不会改变 Plugin 生命周期、SDK 或权限协议。
宿主源码不包含编辑器专属进程名、注入逻辑或 Tauri 命令。第一个适配器 `cocos-editor``plugins/agc-cocos-editor` 提供:native 模块实现 `EditorAdapter`,由 `editor_adapters.rs` 在启动时按编译期链接注册。新增适配器不会改变 Plugin 生命周期、SDK 或权限协议。
当前 native 适配器仍由宿主在编译期链接(Cargo path 依赖);动态加载插件 native 模块不在本次范围,插件包格式与宿主协议不受此限制。
## Tauri 命令
`list_agc_extensions` 返回统一的 Plugin/Skill/MCP catalog`list_agc_plugins``refresh_agc_plugins``start_agc_plugin``stop_agc_plugin``reload_agc_plugin``call_agc_plugin``read_agc_plugin_panel` 提供 Runtime Plugin 管理入口;`set_agc_plugin_project_path` 设置当前项目的受控上下文。编辑器适配器通过宿主 registry 和 Plugin RPC 使用,不增加编辑器专属 Tauri 命令。
编辑器操作统一走 `host.rpc`:插件用 `extensions.world.genarrative.agc.adapter` 或显式 `adapter` 参数选择适配器,宿主校验 `editor.rpc` 权限后调用 `EditorAdapter::rpc`。项目上下文通过 `host.events.subscribe` 的响应和 `project.changed` 事件 payload 下发,插件不需要自己扫描目录。
每次启停、RPC 成功/失败和权限拒绝都追加到 AppData `extensions/audit.jsonl`,日志只写插件 id、动作、结果和固定错误摘要,不写 API Key、Cookie、Token 或宿主绝对路径。
## 验收门禁
@@ -101,6 +122,8 @@ OpenAI 官方 Plugins 文档将 Skills、MCP Server 和可选 UI 定义为同一
- Rustmanifest 路径/权限校验、目录扫描、权限拒绝和通用适配器 registry 边界单测;`cargo check --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`
- 前端:`agc-plugin-sdk` TypeScript 编译、宿主服务类型检查,以及 `PluginPanelHost` 的挂载/卸载测试。
- 内置插件开关:`builtin_plugins` 单测覆盖默认值、持久化往返、坏文件失败关闭,以及“禁用后工具目录里不再出现该工具”;`plugin_host` 单测覆盖禁用后不能启动、启用后回到 stopped。
- 插件工作区:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` 覆盖工作区扫描与 manifest 启用状态;`cargo test --manifest-path plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml` 覆盖 Cocos 适配器;`node --test plugins/agc-cocos-editor/src/entry.test.mjs` 覆盖插件入口协议与 manifest 一致性。
- 通用仓库门禁:`npm run check:encoding``git diff --check`;发布前仍需单独执行 AGC package smoke 和安装包 smoke。
当前版本完成统一扩展 catalog、通用宿主、SDK、面板宿主通用 EditorAdapter registry;目标编辑器适配器属于后续独立实现,不用未验证的连接状态替代真实编辑器验收
当前版本完成统一扩展 catalog、通用宿主、SDK、面板宿主通用 EditorAdapter registry`plugins/` 工作区;`agc-cocos-editor` 是第一个插件包。真实编辑器验收仍按 Cocos 方案文档单独执行,不用未验证的连接状态替代。
@@ -183,7 +183,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创
## 目标
在 Genarrative 内建设独立桌面 App:普通用户通过项目开发工作台中的陶泥儿对话、资源画布、运行状态和确认操作,让平台生成保存在本地的可运行 Web 游戏原型,并通过本地 HTTP server 预览;主窗口提供运行时配置入口,用于保存发布版 AppData / Tauri 配置目录里的 LLM 配置及受控开发者 External Editor 配置,设置弹窗同时提供独立“关于”页并显示从客户端构建版本注入的版本号。普通客户素材画布使用平台登录态调用内部编辑器 API,不展示或要求填写画板 Base URL / API Key。任务明细、原始文件、命令日志和专业 Agent 调试控制只通过显式开发调试入口查看,不随普通客户端启动额外打开窗口。v1 的生成闭环仍以 Web 小游戏为主,同时允许用户打开已有 Godot 项目:用户选择的目录始终作为工作区根,`.agent/`、Session、Runtime、文件工具和外围资料都留在该根;客户端检查根目录及一层直接子目录中的普通文件 `project.godot`,将唯一命中的实际目录以工作区相对 `godotProjectRoot` 记录到 manifest。Agent 使用标准运行档继续修改,不创建 `game/``assets/``memory/``exports/` 平行目录;本期不扩展 Unity、Godot 内嵌预览、云同步或插件市场。新增的 Cocos Creator bridge 核心独立为 `server-rs/crates/cocos-editor-bridge`AGC 仅通过 feature 转发桌面进程发现、受控 execute 和 Windows 注入能力;它不改变服务端路线,也不把原始 pipe、句柄或未绑定项目身份的代码执行面暴露给 Agent。
在 Genarrative 内建设独立桌面 App:普通用户通过项目开发工作台中的陶泥儿对话、资源画布、运行状态和确认操作,让平台生成保存在本地的可运行 Web 游戏原型,并通过本地 HTTP server 预览;主窗口提供运行时配置入口,用于保存发布版 AppData / Tauri 配置目录里的 LLM 配置及受控开发者 External Editor 配置,设置弹窗同时提供独立“关于”页并显示从客户端构建版本注入的版本号。普通客户素材画布使用平台登录态调用内部编辑器 API,不展示或要求填写画板 Base URL / API Key。任务明细、原始文件、命令日志和专业 Agent 调试控制只通过显式开发调试入口查看,不随普通客户端启动额外打开窗口。v1 的生成闭环仍以 Web 小游戏为主,同时允许用户打开已有 Godot 项目:用户选择的目录始终作为工作区根,`.agent/`、Session、Runtime、文件工具和外围资料都留在该根;客户端检查根目录及一层直接子目录中的普通文件 `project.godot`,将唯一命中的实际目录以工作区相对 `godotProjectRoot` 记录到 manifest。Agent 使用标准运行档继续修改,不创建 `game/``assets/``memory/``exports/` 平行目录;本期不扩展 Unity、Godot 内嵌预览、云同步或插件市场。新增的 Cocos Creator bridge 核心独立为插件 `plugins/agc-cocos-editor`native 模块位于其 `native/cocos-editor-bridge`AGC 仅通过通用插件宿主和 feature 转发桌面进程发现、受控 execute 和 Windows 注入能力;它不改变服务端路线,也不把原始 pipe、句柄或未绑定项目身份的代码执行面暴露给 Agent。
## 技术选择
+18
View File
@@ -17,6 +17,7 @@
"packages/image-canvas-react",
"packages/agc-plugin-sdk",
"packages/shared",
"plugins/agc-cocos-editor",
"tools/spine-json-export-validator"
],
"dependencies": {
@@ -5141,6 +5142,10 @@
"resolved": "apps/admin-web",
"link": true
},
"node_modules/@genarrative/agc-plugin-cocos-editor": {
"resolved": "plugins/agc-cocos-editor",
"link": true
},
"node_modules/@genarrative/agc-plugin-sdk": {
"resolved": "packages/agc-plugin-sdk",
"link": true
@@ -22927,6 +22932,13 @@
"react-dom": "^19.0.0"
}
},
"plugins/agc-cocos-editor": {
"name": "@genarrative/agc-plugin-cocos-editor",
"version": "0.1.0",
"dependencies": {
"@genarrative/agc-plugin-sdk": "0.1.0"
}
},
"tools/spine-json-export-validator": {
"name": "@genarrative/spine-json-export-validator",
"version": "0.1.0",
@@ -26302,6 +26314,12 @@
"vitest": "^0.34.6"
}
},
"@genarrative/agc-plugin-cocos-editor": {
"version": "file:plugins/agc-cocos-editor",
"requires": {
"@genarrative/agc-plugin-sdk": "0.1.0"
}
},
"@genarrative/agc-plugin-sdk": {
"version": "file:packages/agc-plugin-sdk"
},

Some files were not shown because too many files have changed in this diff Show More