新增 AGC 通用插件宿主与 SDK

统一 Agent Plugin、Skill、MCP 的来源索引和启停状态
新增通用进程宿主、JSON-RPC、能力注册、权限审计与面板挂载
新增 AGC Plugin SDK 与标准 plugin.json 解析支持
保留 EditorAdapter 扩展点,不内置具体编辑器适配器
补充运行时设置界面、项目上下文同步和定向测试
同步更新插件方案、DirectProject、workspace 与项目记忆文档
This commit is contained in:
2026-09-10 13:37:27 +08:00
parent 04128eb661
commit 3c1711c7df
29 changed files with 3216 additions and 59 deletions
@@ -117,6 +117,15 @@ const allowedUncalledTauriCommands = [
'stop_local_game_preview_if_matches',
'start_game_creator_external_mcp',
'stop_game_creator_external_mcp',
'list_agc_plugins',
'list_agc_extensions',
'refresh_agc_plugins',
'start_agc_plugin',
'stop_agc_plugin',
'reload_agc_plugin',
'call_agc_plugin',
'read_agc_plugin_panel',
'set_agc_plugin_project_path',
];
const sourceExtensions = new Set([
'.json',
@@ -23,6 +23,11 @@ pub(crate) struct ClientMcpRuntimeServer {
pub(crate) config: BTreeMap<String, toml::Value>,
}
pub(crate) struct ClientPluginSource {
pub(crate) item: ClientExtensionItem,
pub(crate) root: PathBuf,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ClientExtensionItem {
@@ -169,7 +174,7 @@ fn claim_client_mcp_connection_at(
let enabled_ids = index
.items
.iter()
.filter(|item| item.extension_type == "mcp" && item.enabled)
.filter(|item| item.extension_type == "mcp" && extension_effectively_enabled(&index, item))
.map(|item| item.id.as_str())
.collect::<BTreeSet<_>>();
let mut owners = client_mcp_connection_owners()
@@ -294,6 +299,13 @@ fn sorted_directory_files(root: &Path) -> Result<Vec<(String, PathBuf)>, String>
if metadata.file_type().is_symlink() {
return Err(format!("扩展目录不能包含符号链接:{}", path.display()));
}
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
if metadata.file_attributes() & 0x400 != 0 {
return Err("扩展目录不能包含重解析点".to_string());
}
}
if metadata.is_dir() {
visit(root, &path, entries)?;
} else if metadata.is_file() {
@@ -487,13 +499,34 @@ fn parse_mcp_config_file(path: &Path) -> Result<Vec<(String, serde_json::Value)>
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default();
if file_name.eq_ignore_ascii_case(".mcp.json") {
if file_name.eq_ignore_ascii_case(".mcp.json") || file_name.eq_ignore_ascii_case("mcp.json") {
let Ok(content) = fs::read_to_string(path) else {
return Ok(Vec::new());
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) else {
return Ok(Vec::new());
};
if file_name.eq_ignore_ascii_case("mcp.json") {
if value.get("$schema").and_then(serde_json::Value::as_str)
!= Some("https://agent-plugins.org/schemas/1.0.0/mcp.schema.json")
{
return Err("不支持的 Agent Plugins MCP schema".to_string());
}
if let Some(servers) = value
.get("mcpServers")
.and_then(serde_json::Value::as_object)
{
for config in servers.values() {
match config.get("type").and_then(serde_json::Value::as_str) {
Some("stdio")
if config.get("command").is_some() && config.get("url").is_none() => {}
Some("streamable-http")
if config.get("url").is_some() && config.get("command").is_none() => {}
_ => return Err("Agent Plugins MCP transport 配置无效".to_string()),
}
}
}
}
let Some(servers) = value
.get("mcpServers")
.and_then(serde_json::Value::as_object)
@@ -530,7 +563,57 @@ fn parse_mcp_config_file(path: &Path) -> Result<Vec<(String, serde_json::Value)>
fn discover_candidates(payload: &Path) -> Result<Vec<ImportedCandidate>, String> {
let files = sorted_directory_files(payload)?;
let mut candidates = Vec::new();
let has_manifest = |path: &Path| {
path.join("plugin.json").is_file() || path.join(".codex-plugin/plugin.json").is_file()
};
let plugin_root = if has_manifest(payload) {
Some(payload.to_path_buf())
} else {
let roots = fs::read_dir(payload)
.map_err(|_| "扩展来源不可读".to_string())?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_dir() && has_manifest(path))
.collect::<Vec<_>>();
if roots.len() > 1 {
return Err("请一次导入一个 Plugin 目录".to_string());
}
roots.into_iter().next()
};
let portable = plugin_root
.as_ref()
.is_some_and(|path| path.join("plugin.json").is_file());
let mut prefix = String::new();
if let Some(plugin_root) = plugin_root.as_ref() {
let relative_root = plugin_root
.strip_prefix(payload)
.map_err(|_| "Plugin 来源越界".to_string())?;
if !relative_root.as_os_str().is_empty() {
prefix = format!("{}/", normalize_relative_path(relative_root)?);
}
let manifest = crate::plugin_host::read_plugin_manifest(plugin_root)?;
candidates.push(ImportedCandidate {
extension_type: "plugin".to_string(),
original_name: manifest.id,
source_relative_path: format!(
"{prefix}{}",
if portable {
"plugin.json"
} else {
".codex-plugin/plugin.json"
}
),
fingerprint: fingerprint_directory(plugin_root)?,
mcp_config: None,
});
}
for (relative, path) in files {
if portable
&& !(relative.starts_with(&format!("{prefix}skills/")) && is_skill_file(&path)
|| relative == format!("{prefix}mcp.json"))
{
continue;
}
if is_skill_file(&path) {
candidates.push(ImportedCandidate {
extension_type: "skill".to_string(),
@@ -607,7 +690,7 @@ fn stored_item_view(
"unknown"
} else if item.last_error.is_some() {
"startup-failed"
} else if item.enabled {
} else if extension_effectively_enabled(index, item) {
"enabled"
} else {
"disabled"
@@ -629,7 +712,7 @@ fn client_skill_set_fingerprint(index: &ClientExtensionIndex) -> String {
let mut entries = index
.items
.iter()
.filter(|item| item.extension_type == "skill" && item.enabled)
.filter(|item| item.extension_type == "skill" && extension_effectively_enabled(index, item))
.map(|item| {
(
item.name.clone(),
@@ -655,7 +738,7 @@ fn client_mcp_set_fingerprint(index: &ClientExtensionIndex) -> String {
let mut entries = index
.items
.iter()
.filter(|item| item.extension_type == "mcp" && item.enabled)
.filter(|item| item.extension_type == "mcp" && extension_effectively_enabled(index, item))
.map(|item| {
(
item.id.clone(),
@@ -1040,7 +1123,7 @@ fn prepare_client_skill_runtime_root(
for item in index
.items
.iter()
.filter(|item| item.extension_type == "skill" && item.enabled)
.filter(|item| item.extension_type == "skill" && extension_effectively_enabled(index, item))
{
let Some(source) = index
.sources
@@ -1087,7 +1170,9 @@ pub(crate) fn prepare_enabled_client_mcp_servers() -> Result<Vec<ClientMcpRuntim
let mut index_changed = false;
for item_index in 0..index.items.len() {
if index.items[item_index].extension_type != "mcp" || !index.items[item_index].enabled {
if index.items[item_index].extension_type != "mcp"
|| !extension_effectively_enabled(index, &index.items[item_index])
{
continue;
}
let source = index
@@ -1140,11 +1225,18 @@ fn apply_client_mcp_startup_status(
extension_id: &str,
status: &str,
) -> Result<bool, String> {
let Some(item) = index
let parent_disabled = index
.items
.iter_mut()
.find(|item| item.extension_type == "mcp" && item.enabled && item.id == extension_id)
else {
.iter()
.filter(|item| item.extension_type == "plugin" && !item.enabled)
.map(|item| item.source_id.clone())
.collect::<BTreeSet<_>>();
let Some(item) = index.items.iter_mut().find(|item| {
item.extension_type == "mcp"
&& item.enabled
&& !parent_disabled.contains(&item.source_id)
&& item.id == extension_id
}) else {
return Ok(false);
};
let last_error = match status {
@@ -1265,7 +1357,7 @@ pub(crate) fn list_client_extensions() -> Result<Vec<ClientExtensionItem>, Strin
list_client_extensions_at(&root)
}
fn list_client_extensions_at(root: &Path) -> Result<Vec<ClientExtensionItem>, String> {
pub(crate) fn list_client_extensions_at(root: &Path) -> Result<Vec<ClientExtensionItem>, String> {
read_client_extension_index_locked(&root, |_, index| {
Ok(index
.items
@@ -1275,9 +1367,69 @@ fn list_client_extensions_at(root: &Path) -> Result<Vec<ClientExtensionItem>, St
})
}
pub(crate) fn client_plugin_sources_at(root: &Path) -> Result<Vec<ClientPluginSource>, String> {
read_client_extension_index_locked(root, |_, index| {
index
.items
.iter()
.filter(|item| item.extension_type == "plugin")
.map(|item| {
let source = index
.sources
.iter()
.find(|source| source.id == item.source_id)
.ok_or_else(|| "插件来源记录不存在".to_string())?;
normalize_relative_path(Path::new(&source.storage_path))?;
normalize_relative_path(Path::new(&item.source_relative_path))?;
let manifest_path = root
.join(&source.storage_path)
.join(&item.source_relative_path);
let manifest_parent = manifest_path
.parent()
.ok_or_else(|| "插件 manifest 路径无效".to_string())?;
let source_root = if item
.source_relative_path
.ends_with(".codex-plugin/plugin.json")
{
manifest_parent
.parent()
.ok_or_else(|| "插件根路径无效".to_string())?
.to_path_buf()
} else {
manifest_parent.to_path_buf()
};
if !source_root
.canonicalize()
.map_err(|_| "插件来源目录不可读".to_string())?
.starts_with(
root.canonicalize()
.map_err(|_| "扩展目录不可读".to_string())?,
)
{
return Err("插件来源目录越界".to_string());
}
Ok(ClientPluginSource {
item: stored_item_view(index, item),
root: source_root,
})
})
.collect()
})
}
fn extension_effectively_enabled(index: &ClientExtensionIndex, item: &StoredExtensionItem) -> bool {
item.enabled
&& !index.items.iter().any(|parent| {
parent.extension_type == "plugin"
&& parent.source_id == item.source_id
&& !parent.enabled
})
}
#[tauri::command]
pub(crate) fn import_client_extension(
source_path: String,
host: tauri::State<'_, crate::plugin_host::PluginHost>,
) -> Result<ClientExtensionImportResult, String> {
let source = PathBuf::from(source_path.trim());
if source.as_os_str().is_empty() {
@@ -1298,14 +1450,18 @@ pub(crate) fn import_client_extension(
)?;
let root = extensions_root()?;
import_client_extension_at(&root, &source, &metadata)
let result = import_client_extension_at(&root, &source, &metadata)?;
host.refresh()?;
Ok(result)
}
fn import_client_extension_at(
pub(crate) fn import_client_extension_at(
root: &Path,
source: &Path,
metadata: &fs::Metadata,
) -> Result<ClientExtensionImportResult, String> {
fs::create_dir_all(root.join(CLIENT_EXTENSIONS_SOURCES_DIR_NAME))
.map_err(|error| format!("准备扩展来源目录失败:{error}"))?;
let source_id = new_id("source");
let source_display_name = source_name(&source);
let source_storage_relative = format!("{}/{}", CLIENT_EXTENSIONS_SOURCES_DIR_NAME, source_id);
@@ -1424,9 +1580,12 @@ fn import_client_extension_at(
pub(crate) fn set_client_extension_enabled(
id: String,
enabled: bool,
host: tauri::State<'_, crate::plugin_host::PluginHost>,
) -> Result<ClientExtensionItem, String> {
let root = extensions_root()?;
set_client_extension_enabled_at(&root, &id, enabled)
let item = set_client_extension_enabled_at(&root, &id, enabled)?;
host.refresh()?;
Ok(item)
}
fn set_client_extension_enabled_at(
@@ -1447,6 +1606,18 @@ fn set_client_extension_enabled_at(
item.enabled = enabled;
item.last_error = None;
let view_item = item.clone();
if view_item.extension_type == "plugin" {
let child_ids = index
.items
.iter()
.filter(|item| item.source_id == view_item.source_id)
.map(|item| item.id.as_str())
.collect::<BTreeSet<_>>();
client_mcp_connection_owners()
.lock()
.map_err(|_| "客户端 MCP 连接锁已损坏".to_string())?
.retain(|id, _| !child_ids.contains(id.as_str()));
}
Ok((stored_item_view(index, &view_item), true))
})
}
@@ -1455,6 +1626,7 @@ fn set_client_extension_enabled_at(
pub(crate) fn rename_client_extension(
id: String,
name: String,
host: tauri::State<'_, crate::plugin_host::PluginHost>,
) -> Result<ClientExtensionItem, String> {
let requested = name.trim();
if requested.is_empty() {
@@ -1462,7 +1634,9 @@ pub(crate) fn rename_client_extension(
}
let normalized = native_name(requested);
let root = extensions_root()?;
rename_client_extension_at(&root, &id, &normalized)
let item = rename_client_extension_at(&root, &id, &normalized)?;
host.refresh()?;
Ok(item)
}
fn rename_client_extension_at(
@@ -1513,9 +1687,14 @@ fn rename_client_extension_at(
}
#[tauri::command]
pub(crate) fn remove_client_extension(id: String) -> Result<(), String> {
pub(crate) fn remove_client_extension(
id: String,
host: tauri::State<'_, crate::plugin_host::PluginHost>,
) -> Result<(), String> {
let root = extensions_root()?;
remove_client_extension_at(&root, &id)
remove_client_extension_at(&root, &id)?;
host.refresh()?;
Ok(())
}
fn remove_client_extension_at(root: &Path, id: &str) -> Result<(), String> {
@@ -1526,7 +1705,22 @@ fn remove_client_extension_at(root: &Path, id: &str) -> Result<(), String> {
.iter()
.position(|item| item.id == id.trim())
.ok_or_else(|| "未找到客户端扩展".to_string())?;
index.items.remove(item_index);
let removed = index.items.remove(item_index);
if removed.extension_type == "plugin" {
let child_ids = index
.items
.iter()
.filter(|item| item.source_id == removed.source_id)
.map(|item| item.id.clone())
.collect::<BTreeSet<_>>();
client_mcp_connection_owners()
.lock()
.map_err(|_| "客户端 MCP 连接锁已损坏".to_string())?
.retain(|id, _| !child_ids.contains(id));
index
.items
.retain(|item| item.source_id != removed.source_id);
}
Ok(((), true))
})
}
@@ -2149,7 +2343,7 @@ mod tests {
}
#[test]
fn plugin_source_discovers_skill_and_mcp_as_independent_items() {
fn plugin_source_discovers_package_and_skill_and_mcp_items() {
let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
@@ -2165,11 +2359,82 @@ mod tests {
)
})
.collect::<BTreeSet<_>>();
assert_eq!(candidates.len(), 2);
assert_eq!(candidates.len(), 3);
assert!(names.contains(&("plugin", "stage-0-fixture-plugin")));
assert!(names.contains(&("skill", "plugin-skill")));
assert!(names.contains(&("mcp", "plugin-search")));
}
#[test]
fn portable_plugin_parent_controls_component_injection_and_removal() {
let source = tempfile::tempdir().expect("plugin source");
fs::create_dir_all(source.path().join("skills/help")).expect("skills");
fs::write(
source.path().join("plugin.json"),
serde_json::json!({
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "portable-tools"
})
.to_string(),
)
.expect("manifest");
fs::write(
source.path().join("skills/help/SKILL.md"),
"---\nname: help\ndescription: Help with the project.\n---\nRead the project.\n",
)
.expect("skill");
fs::write(source.path().join("mcp.json"), serde_json::json!({
"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers": {"docs": {"type":"streamable-http", "url":"http://127.0.0.1:12345/mcp"}}
}).to_string()).expect("MCP config");
let (_directory, root) = test_extension_root();
let imported = import_client_extension_at(
&root,
source.path(),
&fs::metadata(source.path()).expect("metadata"),
)
.expect("import");
assert_eq!(imported.imported.len(), 3);
let parent = imported
.imported
.iter()
.find(|item| item.extension_type == "plugin")
.expect("parent");
let index = read_index(&root).expect("index");
let skill_fingerprint = client_skill_set_fingerprint(&index);
let mcp_fingerprint = client_mcp_set_fingerprint(&index);
set_client_extension_enabled_at(&root, &parent.id, false).expect("disable package");
let disabled = read_index(&root).expect("disabled index");
assert_ne!(client_skill_set_fingerprint(&disabled), skill_fingerprint);
assert_ne!(client_mcp_set_fingerprint(&disabled), mcp_fingerprint);
assert!(disabled
.items
.iter()
.filter(|item| item.extension_type != "plugin")
.all(|item| !extension_effectively_enabled(&disabled, item)));
set_client_extension_enabled_at(&root, &parent.id, true).expect("enable package");
let enabled = read_index(&root).expect("enabled index");
assert_eq!(client_skill_set_fingerprint(&enabled), skill_fingerprint);
remove_client_extension_at(&root, &parent.id).expect("remove package");
assert!(read_index(&root).expect("remaining index").items.is_empty());
}
#[test]
fn portable_mcp_transport_must_match_connection_fields() {
let directory = tempfile::tempdir().expect("config directory");
let path = directory.path().join("mcp.json");
fs::write(
&path,
serde_json::json!({
"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers": {"docs": {"type":"stdio", "url":"https://example.invalid/mcp"}}
})
.to_string(),
)
.expect("MCP config");
assert!(parse_mcp_config_file(&path).is_err());
}
#[test]
fn fixture_directory_splits_independent_skill_and_mcp_items() {
let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
@@ -0,0 +1,36 @@
//! 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.
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())
}
}
@@ -258,6 +258,7 @@ mod context_menu;
#[cfg(all(debug_assertions, not(test)))]
mod debug;
mod delegation;
mod editor_adapter;
pub mod error_report;
mod git_inspect;
mod goal;
@@ -266,6 +267,7 @@ mod image_inspect;
mod isolated_agent;
mod patchset;
mod platform_session;
mod plugin_host;
mod preview;
mod process_session;
mod process_session_bridge;
@@ -302,6 +304,11 @@ use image_inspect::*;
use isolated_agent::*;
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,
};
use preview::*;
use process_session::*;
use project::*;
@@ -2336,6 +2343,7 @@ fn main() {
.plugin(context_menu::init())
.manage(game_creator_preview_registry())
.manage(ProjectResourcePreviewReadManager::default())
.manage(PluginHost::default())
.setup(move |app| {
error_report::initialize_notifications(app.handle());
if let Some(path) = setup_log.as_deref() {
@@ -2371,6 +2379,9 @@ fn main() {
}
error
})?;
if let Err(error) = app.state::<PluginHost>().initialize(&config_dir) {
app_log!("startup.plugin-host.initialize.failed: {error}");
}
load_platform_session_fixture_from_env(&config_dir).map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
@@ -2472,6 +2483,15 @@ fn main() {
set_client_extension_enabled,
rename_client_extension,
remove_client_extension,
list_agc_plugins,
list_agc_extensions,
refresh_agc_plugins,
start_agc_plugin,
stop_agc_plugin,
reload_agc_plugin,
call_agc_plugin,
read_agc_plugin_panel,
set_agc_plugin_project_path,
open_local_project_directory,
open_local_project_plan_gdd_markdown,
control_agent_run,
File diff suppressed because it is too large Load Diff
@@ -204,7 +204,7 @@ pub(super) struct LiveProcessSession {
}
#[cfg(windows)]
pub(super) struct WindowsProcessJob(windows_sys::Win32::Foundation::HANDLE);
pub(crate) struct WindowsProcessJob(windows_sys::Win32::Foundation::HANDLE);
#[cfg(windows)]
unsafe impl Send for WindowsProcessJob {}
@@ -215,6 +215,21 @@ unsafe impl Sync for WindowsProcessJob {}
#[cfg(windows)]
impl WindowsProcessJob {
pub(super) fn assign(child: &dyn Child) -> Result<Self, String> {
let process = child
.as_raw_handle()
.ok_or_else(|| "command.start Windows child 缺少 process handle".to_string())?
as windows_sys::Win32::Foundation::HANDLE;
Self::assign_handle(process)
}
pub(crate) fn assign_std(child: &std::process::Child) -> Result<Self, String> {
use std::os::windows::io::AsRawHandle;
Self::assign_handle(
AsRawHandle::as_raw_handle(child) as windows_sys::Win32::Foundation::HANDLE
)
}
fn assign_handle(process: windows_sys::Win32::Foundation::HANDLE) -> Result<Self, String> {
use std::mem::size_of;
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::JobObjects::{
@@ -223,10 +238,6 @@ impl WindowsProcessJob {
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
};
let process = child
.as_raw_handle()
.ok_or_else(|| "command.start Windows child 缺少 process handle".to_string())?
as windows_sys::Win32::Foundation::HANDLE;
let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
if handle.is_null() || handle == INVALID_HANDLE_VALUE {
return Err(format!(
@@ -10,7 +10,7 @@
| `multi-skill/` | 2 个 Skill`art-skill``code-skill` | 一个 Skill root,分别导入 |
| `mcp-config/` | 2 个 MCP`search``filesystem` | 原生 `config.toml` |
| `mcp-json/` | 2 个 MCP`search``filesystem` | 兼容 `.mcp.json` |
| `plugin/` | 1 个 Skill`plugin-skill`、1 个 MCP`plugin-search` | Plugin 只作为导入来源 |
| `plugin/` | 1 个 Plugin`stage-0-fixture-plugin`、1 个 Skill`plugin-skill`、1 个 MCP`plugin-search` | Plugin 父项与组件共用来源 |
| `mixed-source/` | 1 个 Skill`mixed-skill`、2 个 MCP`search``filesystem` | 一个来源拆成多个独立项 |
| `unknown/` | 1 个未知项:`unknown.bin` | 不执行、不作为 MCP 入口 |
@@ -23,4 +23,4 @@
- 同名或重复内容再次导入时保留新项,名称使用原生标识追加 `-2``-3`
- 前端列表名称和 Codex 运行时名称相同,不维护两套名称。
- 单个可执行文件或脚本不提供手动指定为 MCP 入口的功能。
- Plugin 只提取支持的 Skill/MCP,不开启 hooksapps 或完整 Plugin Runtime
- Plugin 父项控制其 Skill/MCP 子项的有效启用;Codex hooks/apps 不注入,AGC Runtime 入口由通用 Plugin Host 管理
@@ -36,6 +36,7 @@
"id": "plugin",
"source": "plugin",
"items": [
{ "type": "plugin", "name": "stage-0-fixture-plugin" },
{ "type": "skill", "name": "plugin-skill" },
{ "type": "mcp", "name": "plugin-search" }
],
+11
View File
@@ -251,6 +251,7 @@ import { ProjectWorkspaceChatPane } from './features/project-workspace/ProjectWo
import { SupervisorChatOnlyView } from './features/project-workspace/SupervisorChatOnlyView';
import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog';
import { captureAgentRuntimeError } from './services/errorReporting';
import { setAgcPluginProjectPath } from './services/pluginHost';
import type { HomeCreationType } from './view/home';
import {
type ProjectAgentResultSummary,
@@ -554,6 +555,16 @@ export function App({
const localProjectPathRef = useRef<string | null>(null);
localProjectPathRef.current = localProject?.projectPath ?? null;
useEffect(() => {
if (supervisorChatOnly) return;
void setAgcPluginProjectPath(localProject?.projectPath ?? null).catch(
() => undefined,
);
return () => {
void setAgcPluginProjectPath(null).catch(() => undefined);
};
}, [localProject?.projectPath, supervisorChatOnly]);
const manifestRefreshMountedRef = useRef(true);
const manifestRefreshStatesRef = useRef(
new Map<
+54 -1
View File
@@ -51,7 +51,7 @@ export type LauncherImportedAttachment = {
size?: number;
};
export type ClientExtensionType = 'skill' | 'mcp' | 'unknown';
export type ClientExtensionType = 'plugin' | 'skill' | 'mcp' | 'unknown';
export type ClientExtensionItem = {
id: string;
@@ -72,6 +72,59 @@ export type ClientExtensionImportResult = {
duplicate: boolean;
};
export type AgcPluginStatus =
| 'package'
| 'discovered'
| 'stopped'
| 'running'
| 'failed'
| 'disabled'
| 'invalid';
export type AgcPluginCommand = {
id: string;
title: string;
description: string | null;
};
export type AgcPluginPanel = {
id: string;
title: string;
entry: string;
placement: string | null;
};
export type AgcPluginCapability = {
id: string;
description: string | null;
};
export type AgcPluginSummary = {
id: string;
name: string;
version: string;
apiVersion: string;
enabled: boolean;
hasRuntime: boolean;
status: AgcPluginStatus;
adapter: string | null;
permissions: string[];
commands: AgcPluginCommand[];
panels: AgcPluginPanel[];
capabilities: AgcPluginCapability[];
lastError: string | null;
};
export type AgcExtensionSummary = {
kind: 'plugin' | 'skill' | 'mcp' | 'unknown';
id: string;
name: string;
enabled: boolean;
status: string;
plugin: AgcPluginSummary | null;
clientExtension: ClientExtensionItem | null;
};
export type LocalProjectKind = 'web' | 'godot';
export type ProjectStartMode = 'planning' | 'direct-build';
@@ -0,0 +1,73 @@
import { useEffect, useState } from 'react';
import type { AgcPluginPanel } from '../../app/types';
import { ThemedModal } from '../../components/modal/ThemedModal';
import { readAgcPluginPanel } from '../../services/pluginHost';
export type PluginPanelHostProps = {
pluginId: string;
panel: AgcPluginPanel;
onClose: () => void;
};
const PANEL_CSP =
"<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:;\">";
/** Renders registered plugin panels and removes their iframe on unmount. */
export function PluginPanelHost({
pluginId,
panel,
onClose,
}: PluginPanelHostProps) {
const [html, setHtml] = useState<string | null>(null);
const [error, setError] = useState('');
useEffect(() => {
let active = true;
setHtml(null);
setError('');
void readAgcPluginPanel(pluginId, panel.id).then(
(result) => {
if (active) setHtml(result.html);
},
() => {
if (active) setError('插件面板加载失败');
},
);
return () => {
active = false;
};
}, [panel.id, pluginId]);
return (
<ThemedModal
open
ariaLabel={panel.title}
onClose={onClose}
panelClassName="agc-plugin-panel"
overlayClassName="agc-plugin-panel-overlay"
>
<header>
<strong>{panel.title}</strong>
<button
type="button"
aria-label={`关闭 ${panel.title}`}
onClick={onClose}
>
</button>
</header>
{error ? (
<p role="alert">{error}</p>
) : html === null ? (
<p role="status"></p>
) : (
<iframe
title={panel.title}
srcDoc={PANEL_CSP + html}
sandbox="allow-scripts"
/>
)}
</ThemedModal>
);
}
@@ -24,6 +24,8 @@ import {
} from '../../app/dialogs';
import { resolveTauriInvoke } from '../../app/tauri';
import {
type AgcPluginPanel,
type AgcPluginSummary,
type ClientExtensionImportResult,
type ClientExtensionItem,
type GameCreatorAppConfig,
@@ -32,6 +34,14 @@ import {
gameCreatorLlmReasoningEfforts,
} from '../../app/types';
import { checkForAppUpdate } from '../../services/appUpdate';
import {
listAgcExtensions,
reloadAgcPlugin,
setAgcPluginProjectPath,
startAgcPlugin,
stopAgcPlugin,
} from '../../services/pluginHost';
import { PluginPanelHost } from '../plugins/PluginPanelHost';
const defaultRuntimeConfigDraft: GameCreatorAppConfig = {
schemaVersion: 'game-creator-config.v2',
@@ -182,6 +192,7 @@ function normalizeRuntimeConfigDraft(
}
export function RuntimeConfigDialog({
projectPath,
allowAdvancedExternalEditorConfig = false,
onClose,
onLog,
@@ -215,6 +226,13 @@ export function RuntimeConfigDialog({
const [clientExtensionsLoadState, setClientExtensionsLoadState] =
useState<ClientExtensionsLoadState>('loading');
const [clientExtensionsStatus, setClientExtensionsStatus] = useState('');
const [agcPlugins, setAgcPlugins] = useState<AgcPluginSummary[]>([]);
const [agcPluginsBusy, setAgcPluginsBusy] = useState(false);
const [agcPluginsStatus, setAgcPluginsStatus] = useState('');
const [mountedPluginPanel, setMountedPluginPanel] = useState<{
pluginId: string;
panel: AgcPluginPanel;
} | null>(null);
const [editingExtensionId, setEditingExtensionId] = useState<string | null>(
null,
);
@@ -223,7 +241,33 @@ export function RuntimeConfigDialog({
const [appUpdateChecking, setAppUpdateChecking] = useState(false);
const runtimeConfigBusyRef = useRef(false);
useEscapeToClose(onClose);
useEscapeToClose(
mountedPluginPanel ? () => setMountedPluginPanel(null) : onClose,
);
useEffect(() => {
void setAgcPluginProjectPath(projectPath ?? null).catch(() => undefined);
}, [projectPath]);
useEffect(() => {
if (activeSection !== 'extensions') return;
const timer = window.setInterval(() => void readAgcPlugins(), 1000);
return () => window.clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeSection]);
useEffect(() => {
if (
mountedPluginPanel &&
!agcPlugins.some(
(plugin) =>
plugin.id === mountedPluginPanel.pluginId &&
plugin.status === 'running',
)
) {
setMountedPluginPanel(null);
}
}, [agcPlugins, mountedPluginPanel]);
useEffect(() => {
const htmlOverflow = document.documentElement.style.overflow;
@@ -240,10 +284,68 @@ export function RuntimeConfigDialog({
useEffect(() => {
void readRuntimeConfig();
void readClientExtensions();
void readAgcPlugins();
// The dialog reads once on mount; subsequent reads are explicit user actions.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function readAgcPlugins() {
try {
const extensions = await listAgcExtensions();
setAgcPlugins(
extensions.flatMap((extension) =>
extension.kind === 'plugin' && extension.plugin
? [extension.plugin]
: [],
),
);
setAgcPluginsStatus('');
} catch (error) {
setAgcPluginsStatus(
error instanceof Error ? error.message : String(error),
);
}
}
async function toggleAgcPlugin(plugin: AgcPluginSummary) {
if (agcPluginsBusy) return;
setAgcPluginsBusy(true);
try {
const updated =
plugin.status === 'running'
? await stopAgcPlugin(plugin.id)
: await startAgcPlugin(plugin.id);
setAgcPlugins((current) =>
current.map((item) => (item.id === updated.id ? updated : item)),
);
setAgcPluginsStatus('');
} catch (error) {
setAgcPluginsStatus(
error instanceof Error ? error.message : String(error),
);
} finally {
setAgcPluginsBusy(false);
}
}
async function reloadPlugin(plugin: AgcPluginSummary) {
if (agcPluginsBusy) return;
setAgcPluginsBusy(true);
try {
const updated = await reloadAgcPlugin(plugin.id);
setAgcPlugins((current) =>
current.map((item) => (item.id === updated.id ? updated : item)),
);
setAgcPluginsStatus('插件已重新加载');
} catch (error) {
setAgcPluginsStatus(
error instanceof Error ? error.message : String(error),
);
} finally {
setAgcPluginsBusy(false);
}
}
async function readClientExtensions() {
const invoke = resolveTauriInvoke();
if (!invoke) {
@@ -298,6 +400,7 @@ export function RuntimeConfigDialog({
'list_client_extensions',
);
setClientExtensions(refreshed);
await readAgcPlugins();
setClientExtensionsLoadState('ready');
const importedCount = result.imported.length;
setClientExtensionsStatus(
@@ -341,6 +444,7 @@ export function RuntimeConfigDialog({
candidate.id === updated.id ? updated : candidate,
),
);
await readAgcPlugins();
const requestedName = editingExtensionName.trim();
setClientExtensionsStatus(
updated.name === requestedName
@@ -376,6 +480,8 @@ export function RuntimeConfigDialog({
candidate.id === updated.id ? updated : candidate,
),
);
await readClientExtensions();
await readAgcPlugins();
} catch (error) {
setClientExtensionsStatus(
error instanceof Error ? error.message : String(error),
@@ -393,9 +499,8 @@ export function RuntimeConfigDialog({
setClientExtensionsBusy(true);
try {
await invoke('remove_client_extension', { id: item.id });
setClientExtensions((current) =>
current.filter((candidate) => candidate.id !== item.id),
);
await readClientExtensions();
await readAgcPlugins();
if (editingExtensionId === item.id) {
cancelRenameClientExtension();
}
@@ -851,12 +956,17 @@ export function RuntimeConfigDialog({
<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 === 'skill'
? 'Skill'
: item.extensionType === 'mcp'
? 'MCP'
: '未识别';
item.extensionType === 'plugin'
? 'Plugin'
: item.extensionType === 'skill'
? 'Skill'
: item.extensionType === 'mcp'
? 'MCP'
: '未识别';
const statusLabel =
item.status === 'enabled'
? '已启用'
@@ -905,7 +1015,54 @@ export function RuntimeConfigDialog({
) : null}
</div>
<div className="runtime-settings-extension-actions">
<span>{statusLabel}</span>
<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
@@ -975,6 +1132,11 @@ export function RuntimeConfigDialog({
</span>
</div>
)}
{agcPluginsStatus ? (
<p className="runtime-settings-inline-status" role="status">
{agcPluginsStatus}
</p>
) : null}
</section>
) : null}
{activeSection === 'about' ? (
@@ -1083,6 +1245,12 @@ export function RuntimeConfigDialog({
</div>
</footer>
</form>
{mountedPluginPanel ? (
<PluginPanelHost
{...mountedPluginPanel}
onClose={() => setMountedPluginPanel(null)}
/>
) : null}
</div>
);
}
@@ -0,0 +1,71 @@
import { resolveTauriInvoke } from '../app/tauri';
import type {
AgcExtensionSummary,
AgcPluginPanel,
AgcPluginSummary,
} from '../app/types';
export type PluginRpcParams = Record<string, unknown> | unknown[] | null;
function invokeOrThrow() {
const invoke = resolveTauriInvoke();
if (!invoke) throw new Error('需要在 Tauri App 内运行');
return invoke;
}
export async function listAgcPlugins() {
return invokeOrThrow()('list_agc_plugins') as Promise<AgcPluginSummary[]>;
}
export async function listAgcExtensions() {
return invokeOrThrow()('list_agc_extensions') as Promise<
AgcExtensionSummary[]
>;
}
export async function refreshAgcPlugins() {
return invokeOrThrow()('refresh_agc_plugins') as Promise<AgcPluginSummary[]>;
}
export async function startAgcPlugin(id: string) {
return invokeOrThrow()('start_agc_plugin', {
id,
}) as Promise<AgcPluginSummary>;
}
export async function stopAgcPlugin(id: string) {
return invokeOrThrow()('stop_agc_plugin', {
id,
}) as Promise<AgcPluginSummary>;
}
export async function reloadAgcPlugin(id: string) {
return invokeOrThrow()('reload_agc_plugin', {
id,
}) as Promise<AgcPluginSummary>;
}
export async function callAgcPlugin<T = unknown>(
id: string,
method: string,
params: PluginRpcParams = null,
) {
return invokeOrThrow()('call_agc_plugin', {
id,
method,
params,
}) as Promise<T>;
}
export async function readAgcPluginPanel(id: string, panelId: string) {
return invokeOrThrow()('read_agc_plugin_panel', { id, panelId }) as Promise<{
panel: AgcPluginPanel;
html: string;
}>;
}
export async function setAgcPluginProjectPath(projectPath: string | null) {
return invokeOrThrow()('set_agc_plugin_project_path', {
projectPath,
}) as Promise<void>;
}
+30
View File
@@ -3996,6 +3996,36 @@ h2 {
font-size: 13px;
}
.agc-plugin-panel-overlay {
z-index: 230;
}
.agc-plugin-panel {
width: min(900px, 94vw);
overflow: hidden;
border: 1px solid var(--platform-subpanel-border);
border-radius: 12px;
background: var(--platform-subpanel-fill);
}
.agc-plugin-panel > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
color: var(--platform-text-strong);
font-size: 12px;
}
.agc-plugin-panel iframe {
display: block;
width: 100%;
height: min(640px, 75vh);
border: 0;
background: var(--platform-input-fill);
}
.runtime-settings-empty-state span {
font-size: 11px;
}
@@ -0,0 +1,63 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { useState } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PluginPanelHost } from '../src/features/plugins/PluginPanelHost';
import { readAgcPluginPanel } from '../src/services/pluginHost';
vi.mock('../src/services/pluginHost', () => ({ readAgcPluginPanel: vi.fn() }));
const panel = {
id: 'summary',
title: '项目摘要',
entry: './panel.html',
placement: null,
};
function Harness() {
const [open, setOpen] = useState(true);
return open ? (
<PluginPanelHost
pluginId="installed-plugin"
panel={panel}
onClose={() => setOpen(false)}
/>
) : null;
}
describe('plugin panel mounting', () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it('loads registered HTML into an isolated frame and removes it on close', async () => {
vi.mocked(readAgcPluginPanel).mockResolvedValue({
panel,
html: '<h1>项目摘要</h1>',
});
render(<Harness />);
const frame = await screen.findByTitle('项目摘要');
expect(readAgcPluginPanel).toHaveBeenCalledWith(
'installed-plugin',
'summary',
);
expect(frame.getAttribute('sandbox')).toBe('allow-scripts');
expect(frame.getAttribute('srcdoc')).toContain("default-src 'none'");
expect(frame.getAttribute('srcdoc')).toContain('<h1>项目摘要</h1>');
fireEvent.click(screen.getByRole('button', { name: '关闭 项目摘要' }));
expect(screen.queryByTitle('项目摘要')).toBeNull();
});
it('shows a safe error when the host rejects panel access', async () => {
vi.mocked(readAgcPluginPanel).mockRejectedValue(new Error('private path'));
render(<Harness />);
expect((await screen.findByRole('alert')).textContent).toBe(
'插件面板加载失败',
);
expect(document.body.textContent).not.toContain('private path');
expect(screen.queryByTitle('项目摘要')).toBeNull();
});
});
+1
View File
@@ -25,6 +25,7 @@
- [策划会话 Runtime V2 接入与旧链路退役方案](./technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md):新单 Agent 策划会话、GDD 策略、未来 MCP/Skill 兼容插槽、阶段任务与退役验收合同。
- [DirectProject Codex 原始历史与异常恢复](./technical/【技术方案】DirectProject%20Codex原始历史与异常恢复-2026-09-04.md):原始 Responses item 持久化、线程注入与异常回合收尾。
- [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。
- [AGC 通用插件宿主与编辑器适配](./technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md):通用插件宿主、SDK、权限审计、UI 挂载和 Cocos 编辑器适配边界。
- [AGC 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、OSS 清单格式和下载约定。
- [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。
- [Direct 回合行为审计账本](./technical/【技术方案】Direct回合行为审计账本-2026-08-31.md)Direct GUI 回合把 native 读 / MCP / 写文件落成项目内有界时间线,用于判断有没有打开本轮附件。
@@ -164,11 +164,11 @@
## 2026-08-31 DirectProject 客户端扩展按独立 Skill/MCP 导入
- 背景:DirectProject 需要使用用户在 AGC 客户端导入的市面原生 Skill、MCP 和 Plugin 内容,但第三方内容不应直接安装到运行时 Codex,也不应要求用户转换为 AGC 自定义格式。
- 决策:客户端提供一个全局“扩展”入口,统一接受文件、目录、zip 和标准 Plugin目录、zip、Plugin 只是导入来源,发现出的每个 Skill 和每个 MCP Server 分别成为独立扩展项,分别列表、重命名、启用、禁用和删除。已识别项导入后默认启用,下次 DirectProject Codex 启动时按原生 Skill root 和 MCP 配置注入。
- 决策:客户端提供一个全局“扩展”入口,统一接受文件、目录、zip 和 Agent PluginPlugin 父项与其中的 Skill/MCP 子项共用来源和索引。Skill/MCP 保留独立开关,父项禁用会阻断子项注入,父项移除会移除整个包的登记。有效启用的组件在下次 DirectProject Codex 启动时按原生 Skill root 和 MCP 配置注入。
- 命名:客户端列表名称与 Codex 运行时名称使用同一个原生标识,不维护 display/runtime 两套名称;重复或同名项保留为新的独立项并自动追加 `-2``-3`。Skill 重命名只修改客户端运行时副本中的有效名称,原始导入内容不修改。
- Plugin 边界:Plugin 只作为导入容器提取 Skill/MCP;当前 DirectProject 关闭的 hooks、apps、remote plugin 和完整 Plugin Runtime 不接入。单个可执行文件或脚本不提供手动指定为 MCP 入口的功能。
- Plugin 边界:Agent Plugins 核心包格式由客户端解析;Codex 原生 hooks、apps、remote plugin 不注入。AGC Runtime Plugin 由通用 Plugin Host 管理,单个可执行文件或脚本不提供手动指定为 MCP 入口的功能。
- 信任边界:不审核第三方 Skill 文案、脚本、二进制、MCP tool 或网络行为;导入阶段不执行内容。客户端只做标准结构识别、必要配置解析和 zip staging 路径边界处理,且不向第三方扩展注入 AGC 凭据或内部路径。
- 影响范围:AGC 客户端扩展设置 UI、客户端本地扩展存储、DirectProject Codex app-server 启动准备pool fingerprint;不新增 HTTP 服务、SpacetimeDB schema公开 API 或独立 Plugin Runtime
- 影响范围:AGC 客户端扩展设置 UI、本地扩展存储、DirectProject 启动准备/pool fingerprint 和通用 Plugin Host;不新增 HTTP 服务、SpacetimeDB schema公开 API。
- 当前实现:客户端导入/list、Skill 临时 root 和 MCP 隔离配置注入均已落地。第三方 MCP 只从客户端已启用独立项生成本次隔离 `CODEX_HOME/config.toml`,每项固定非 required;配置错误或 app-server 启动状态失败只更新对应 `last_error`,内置 `agc_tools` 继续由客户端单独注入。客户端已启用 Skill/MCP 的名称、来源路径和内容指纹共同参与 DirectProject app-server pool identity。
- 验证方式:分三阶段验收:先验证导入拆分和完整列表,再验证 Skill 运行时发现和重命名,最后验证 MCP 配置合并、Plugin 提取和失败隔离;只增加对应的定向测试、`npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md``apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx``apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs`
@@ -8188,3 +8188,9 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 问题回答携带被回答卡片的 questionId,在已有回合锁内核对 Session 和当前问题;自由文本回答同样绑定问题,已完成回合保留幂等重放。此身份匹配服务于用户提交,不增加恢复门禁或模型输出要求。
- hydrate 结果(包括空结果)写入前端状态前同时核对请求序列和当前项目路径;过期结果直接丢弃,不重试、不阻塞正常 run。
## 2026-09-09 AGC 通用插件宿主与编辑器适配
- 用户侧 AGC Plugin 按 OpenAI Agent Plugins 组合模型吸收现有 Skill/MCP:统一 catalog、来源和审计,但 Skill 仍由 Codex 原生读取、MCP 仍由 MCP transport 启动。新增通用 `plugin_host``@genarrative/agc-plugin-sdk`;扫描、manifest 校验、Runtime Plugin 子进程启停/热重载、行分隔 JSON-RPC、UI/Capability 注册、权限和审计统一由宿主负责。
- 插件 manifest 使用 Agent Plugins 根目录 `plugin.json` 和标准 schema,兼容 `.codex-plugin/plugin.json`AGC Runtime 字段放在 `extensions.world.genarrative.agc`,来源和父/子项统一保存到既有 `extensions` 索引。入口和面板资源只能是插件目录内普通文件;权限采用白名单,进程继承最小系统环境,不接收客户端凭据。
- 目标编辑器只实现 `EditorAdapter` 的查找、PID/项目/版本校验、连接和请求转换;本次仅保留通用 registry 和 trait,不随标准 Plugin 核心内置具体编辑器适配器。
@@ -55,6 +55,7 @@ SpacetimeDB crate、SDK、CLI / standalone 与生成 bindings 按 `2.8.3` 对齐
- 通用 Agent Rust 分层为 `agent-runtime-core`catalog、执行生命周期、ToolHost/spawn/all-join/Provider 契约)、`agent-runtime-orchestration`(动态无环任务图、ready、依赖波次、返工下游闭包和受限自主扩图提案)与 `platform-agent` 游戏适配器;循环返工通过新 pass / epoch 表达,不在单张依赖图中建立回边。LLM 可经宿主结构化 function call 提出新增节点/边,编排层只生成经校验的新候选图,epoch 与持久化仍由宿主掌控。
- DirectProject 始终连接客户端内置的 `agc_tools` STDIO MCP,并在启动时额外读取客户端扩展仓库中已启用的第三方 MCP 独立项。第三方 STDIO/HTTP 配置只写入本次隔离 `CODEX_HOME`,单项非 required,启停、重命名和内容指纹进入 app-server pool identity;完整 Plugin Runtime、hooks/apps 和单文件脚本手动指定入口仍关闭。Skill 正文与 references 由 Codex 原生按需读取;`agc_tools` 负责标准美术准备、已登记资源有界查询、视频 / 角色动画 / 音效 / BGM 的 create-or-derive、已登记图片去背景、desktop/mobile 浏览器试玩和受控 `agc_web_search`;付费资源调用仍由客户端绑定回合、幂等账本、请求上限和投影权威。
- 2026-09-09 起,AGC 已新增遵循 OpenAI Agent Plugins 组合模型的通用 Plugin Host/SDKPlugin、Skill 和 MCP 进入统一扩展 catalog;插件生命周期、行分隔 JSON-RPC、UI 面板、Capability Registry、权限和审计由 `plugin_host` 统一承接,Skill/MCP 仍分别交给各自现有 loader/transport;目标编辑器只通过通用 `EditorAdapter` 扩展点接入。详见 `docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md`
- DirectProject 的 Codex 原生文件、搜索、命令、图片查看和 Skill 仅在用户项目 cwd 与 `workspaceWrite(writableRoots=[project])` 内可用;原生命令允许联网以支持 npm 安装,npm 缓存位于项目内 `.npm-cache/`。多 Agent、Apps、插件、hooks、图片生成、Goals、Workspace Dependencies、Tool Suggestion 和原生浏览器/电脑控制保持关闭。app-server 使用隔离 `CODEX_HOME`provider 凭据只由 AGC 客户端代理持有,不能进入模型上下文或 shell 环境。
- `ui-prototype`(设计图片)与 UI 编辑器 `UI` JSON 是不同资源。白名单 `ui.workflow.run` 按页面执行 `prepare → recognize → status → finalize`,由 provider-backed 识别、合并和组件绑定持久化 State/revision,并把 `reference-ready → structure-ready → merge-ready → binding-ready → application-ready → completed` 投影到 manifest。Provider 缺失、请求失败、工具缺失、结果不匹配或仍有待审节点时保留真实阶段并返回 blocker,不得用 deterministic seed 伪造完成。
- UI workflow 的资源桥接与 Runtime 边界以 `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md` 和 AGC 实施计划的 2026-08-24 覆盖段为准;只生成图片、登记空 JSON 或进入普通图片画布都不构成 workflow 完成。
@@ -0,0 +1,106 @@
# AGC 通用插件宿主与编辑器适配
更新时间:`2026-09-09`
## 目标与边界
AGC 插件系统由一个通用宿主和一个通用 SDK 组成。宿主统一负责插件扫描、manifest 校验、进程启停与热重载、行分隔 JSON-RPC、UI 面板挂载/卸载、Capability Registry、权限检查和审计;编辑器差异只进入 `editor_adapter`
现役代码位置:
```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
packages/agc-plugin-sdk/src/index.ts
```
现有 DirectProject 的 Skill/MCP 导入仍保留。它们是 Codex 扩展注入链路,不等同于本宿主管理的可运行 AGC Plugin。
## 插件目录和 manifest
插件来源统一存放在客户端 AppData 的 `extensions/sources/` 下,由既有扩展索引登记;每个来源目录最多包含一个 Plugin。宿主优先接受 OpenAI Agent Plugins 标准的根目录 `plugin.json`,同时兼容 `.codex-plugin/plugin.json`。标准 Plugin 可以只包含 `skills/``mcp.json`,不要求本地可执行入口;带 `extensions.world.genarrative.agc.entry` 的 AGC Runtime Plugin 才由通用宿主启停。入口必须是插件目录内的普通文件,不能是符号链接、绝对路径或带 `..` 的路径。
OpenAI 的标准模型是“Plugin 作为可安装包,组合 Skills、可选 MCP Server 和可选 UI”。因此 AGC 的统一扩展目录会把一个 Plugin 及其 `skills/``mcp.json` 子项放进同一 catalogSkill 继续交给 Codex 原生 Skill loaderMCP 继续交给现有 MCP transportRuntime Plugin 才使用本页的子进程 JSON-RPC。这样三种能力共享来源、启停状态和审计,不要求它们共享错误的执行方式。
带 AGC Runtime 入口的 manifest
```json
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "editor-tools",
"version": "1.0.0",
"extensions": {
"com.openai": { "interface": { "displayName": "编辑器工具" } },
"world.genarrative.agc": {
"apiVersion": "v1",
"entry": "./index.js",
"adapter": "target-editor",
"permissions": ["ui.register", "capability.register", "editor.rpc"],
"panels": [{ "id": "scene", "title": "场景工具", "entry": "./panel.html", "placement": "sidebar" }]
}
}
}
```
只打包 Skill/MCP 的标准 Plugin 可以使用 OpenAI 的 portable 形态:
```json
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "editor-workflows",
"version": "1.0.0",
"description": "编辑器工作流",
"extensions": { "com.openai": { "interface": { "displayName": "编辑器工作流" } } }
}
```
其中 `skills/<skill-name>/SKILL.md` 和根目录 `mcp.json` 由统一 catalog 发现;`.codex-plugin/plugin.json` 仅作为兼容回退。
当前允许的权限为 `events.subscribe``project.read``editor.rpc``ui.register``capability.register`。未知权限、重复面板 id、非法入口和不支持的 API/适配器会使插件进入 `invalid` 状态,不启动进程。
## 运行和 RPC
宿主以已安装插件目录为 cwd 启动入口;JavaScript 入口使用系统 `node` 执行,其它入口直接执行。环境先清空,再保留 PATH、Windows 系统目录和临时目录等必要变量,并注入插件身份和协议版本;不继承客户端凭据。Windows 复用进程模块的 Job Object,Unix 使用独立进程组,停止/卸载时回收自有进程。
stdin/stdout 使用一行一个 JSON-RPC 2.0 消息,单条消息限制 2 MiB,队列和并发请求有上限;独立消息循环持续处理注册请求、事件和响应。写入与响应共享 10 秒期限,写入阻塞只终止对应运行实例。宿主 API 权限用于约束 `host.*` 调用;Runtime Plugin 是用户主动启动的本地程序,这不是 OS 沙箱。
SDK 对插件暴露稳定的通用 API:
```ts
host.registerCommand(command)
host.registerPanel(panel)
host.registerCapability(capability)
host.events.subscribe(type, listener)
host.project.read(path)
host.rpc(method, params)
```
注册函数返回卸载函数;命令与能力可附带 RPC handler`createJsonRpcStdioTransport` 提供双向 stdio 传输。`PluginPanelRegistry` 提供订阅、列表和卸载,客户端通过受控读取命令加载自包含 HTML,并在独立弹窗的隔离 iframe 中展示。关闭面板或停止插件会卸载 iframe;面板不获得 Tauri API、同源存储或任意网络权限。
当前本地面板是 AGC 扩展能力,尚未实现 MCP Apps UI 消息桥。Agent Plugins 核心包格式、Skill 和 MCP transport 是当前兼容范围;OpenAI 注册应用映射、hooks、公开市场发布和完整 MCP Apps UI 不在本次宿主实现中。
## 编辑器适配器扩展点
`EditorAdapter` 只定义 `detect``connect``disconnect``translate_rpc` 和原生 `rpc`。宿主只保存适配器 registry,并把插件声明的适配器名称路由到对应实现;具体编辑器如何查找进程、校验 PID/项目/版本、建立连接和翻译编辑器消息,由后续适配器包独立实现。
本次不内置任何目标编辑器适配器,也不包含编辑器专属进程名、注入逻辑或 Tauri 命令。新增适配器不会改变 Plugin 生命周期、SDK 或权限协议。
## 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 命令。
每次启停、RPC 成功/失败和权限拒绝都追加到 AppData `extensions/audit.jsonl`,日志只写插件 id、动作、结果和固定错误摘要,不写 API Key、Cookie、Token 或宿主绝对路径。
## 验收门禁
OpenAI 官方 Plugins 文档将 Skills、MCP Server 和可选 UI 定义为同一 Plugin 包的组成部分;AGC 以该组合模型为兼容目标:
- [Plugin architecture](https://developers.openai.com/plugins/concepts/plugins)
- [Package your plugin](https://developers.openai.com/plugins/build/plugins)
- Rustmanifest 路径/权限校验、目录扫描、权限拒绝和通用适配器 registry 边界单测;`cargo check --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`
- 前端:`agc-plugin-sdk` TypeScript 编译、宿主服务类型检查,以及 `PluginPanelHost` 的挂载/卸载测试。
- 通用仓库门禁:`npm run check:encoding``git diff --check`;发布前仍需单独执行 AGC package smoke 和安装包 smoke。
当前版本完成统一扩展 catalog、通用宿主、SDK、面板宿主和通用 EditorAdapter registry;目标编辑器适配器属于后续独立实现,不用未验证的连接状态替代真实编辑器验收。
@@ -1,16 +1,16 @@
# DirectProject 客户端 Skill 与 MCP 扩展导入方案
更新时间:`2026-08-31`
更新时间:`2026-09-10`
## 1. 文档定位
本方案是 DirectProject 客户端第三方 Skill、MCP 和 Plugin 导入能力的当前实现依据。它描述客户端侧的导入、拆分、命名、启用和 DirectProject 启动注入边界,不改变 AGC 内置 Skill Pack、`agc_tools` MCP、项目文件工具和现有 Runtime 权威
本方案是 DirectProject 客户端第三方 Skill、MCP 和 OpenAI Agent Plugin 导入能力的当前实现依据。它描述统一扩展目录中的来源登记、拆分、命名、启用和 DirectProject 启动注入边界;可执行 AGC Runtime Plugin 的生命周期、JSON-RPC、UI 和 Capability 由通用 Plugin Host 承接,见 `AGC通用插件宿主与编辑器适配-2026-09-09.md`
本方案只覆盖客户端安装和下一次 Codex 启动时接入。第三方扩展不是安装到全局运行时 Codex,也不要求用户把市面上的扩展重新打包为 AGC 自定义格式。
## 2. 一句话交付结果
客户端提供一个全局“扩展”入口,直接接受文件、目录、zip 和标准 Plugin;导入后把其中发现的每个 Skill 和每个 MCP Server 拆成独立扩展项,用户可以分别重命名、启用、禁用和删除DirectProject 启动 Codex 时只注入启用的独立项
客户端提供一个全局“扩展”入口,直接接受文件、目录、zip 和标准 Plugin;Plugin 自身登记为一个父级扩展项,内部发现的 Skill/MCP 同时登记为同一来源的子项。用户可管理 Plugin 开关和 Runtime 生命周期,也可分别管理 Skill/MCPDirectProject 启动 Codex 时只注入有效启用的 Skill/MCP
## 3. 已确定的产品边界
@@ -23,7 +23,7 @@
- 扩展内容保存在 AGC 客户端的扩展仓库。
- 不把扩展永久安装到用户的全局 `CODEX_HOME`
- DirectProject 创建或复用 app-server 时,读取客户端当前已启用的扩展并生成本次运行的临时 Skill root 与 MCP 配置。
- 导入、启用、禁用和重命名不热更新正在运行的 Codex;变更从下一次 DirectProject 启动生效。
- Skill/MCP 的导入、启用、禁用和重命名不热更新正在运行的 Codex;变更从下一次 DirectProject 启动生效。Runtime Plugin 的启停/重载由通用宿主立即执行。
- 扩展默认对客户端内所有 DirectProject 生效,不做项目级启用映射。
### 3.2 用户信任和最小处理
@@ -45,16 +45,15 @@
- 单个 `.exe``.py``.js` 或其它可执行文件/脚本由用户手动指定为 MCP 入口;
- 根据 README 或文件后缀猜测如何启动普通程序;
- 完整 Codex Plugin Runtime
- Plugin hooks、apps、remote plugin 和依赖这些能力的运行时行为;
- Codex 原生 hooks、apps、remote plugin 和其它不属于 AGC Host 的运行时行为
- 自定义扩展 manifest、审核清单或自定义安装包格式;
- Skill/MCP 行为安全扫描、脚本沙箱和网络白名单;
- 项目级扩展配置;
- 热更新、后台扩展服务、在线市场、版本历史、回滚和自动升级。
- Codex Skill/MCP 热更新、扩展自动后台启动、在线市场、版本历史、回滚和自动升级。
## 4. 导入与拆分模型
目录zip Plugin 是“导入来源”,不是管理对象。一个来源中识别出的每个 Skill 和每个 MCP Server 都独立成为客户端扩展项
目录zip 是导入来源;Plugin 是可管理对象。Plugin、Skill 和 MCP 子项共享 source_id,并进入统一扩展 catalog
```text
导入来源
@@ -121,9 +120,9 @@ filesystem
### 4.3 Plugin 处理
存在 `.codex-plugin/plugin.json` 时,将目录或 zip 识别为标准 Plugin 导入来源
存在带 Agent Plugins schema 的根目录 `plugin.json` 时,将目录或 zip 识别为标准 Plugin 包;`.codex-plugin/plugin.json` 作为兼容输入。zip 可包含一层包目录
本期只提取其中可识别的 Skill 和 MCP,并分别创建独立扩展项
包自身及其中可识别的 Skill/MCP 分别登记到同一扩展索引
```text
Plugin
@@ -132,7 +131,7 @@ Plugin
└── MCP C → 独立 MCP 项
```
Plugin 自身生成父级列表项,也不提供 Plugin 级开关。hooks、apps、remote plugin 以及依赖完整 Plugin Runtime 的内容忽略;如果没有任何可支持的 Skill/MCP,则来源保留为未知内容。
Plugin 自身生成一个父级列表项并提供统一启用开关。hooks、apps、remote plugin 仍不由 DirectProject 注入;AGC Runtime Plugin 仅由通用 Plugin Host 管理。没有可支持组件且没有 AGC Runtime 入口时,来源保留为未知内容。
### 4.4 未知输入
@@ -223,7 +222,7 @@ ImportedSource
ExtensionItem
├── id
├── source_id
├── type: skill | mcp | unknown
├── type: plugin | skill | mcp | unknown
├── name
├── original_name
├── source_relative_path
@@ -232,7 +231,7 @@ ExtensionItem
└── last_error
```
`source_id` 用于来源溯源和清理,不形成可操作的父级扩展项,也不产生父子级联启用状态
`source_id` 用于来源溯源和清理Plugin 父项与同来源 Skill/MCP 子项形成级联启用状态,父项禁用时子项不会注入 DirectProject
对于目录或 zip
@@ -263,9 +262,12 @@ ExtensionItem
- 删除操作;
- 必要时的一行启动错误。
一个目录、zip 或 Plugin 中的多个内容直接平铺显示,不显示父级包和父级开关
Plugin 父项和子项在同一列表显示,父项开关控制其运行组件
```text
my-plugin
Plugin · 来自 my-plugin.zip 已启用
art-skill
Skill · 来自 my-plugin.zip 已启用
@@ -421,7 +423,7 @@ remove_client_extension(id)
- 名称统一使用原生标识;
- 重复导入自动追加后缀;
- 已识别内容默认启用;
- Plugin 只作为导入容器
- Plugin 登记为父级扩展项,Skill/MCP 登记为子项
- 单个可执行文件/脚本不作为 MCP 入口;
- 客户端全局生效。
@@ -476,7 +478,7 @@ remove_client_extension(id)
### 阶段 3MCP 和 Plugin 部分闭环
当前实施状态:已完成。客户端会在 DirectProject 启动前读取所有已启用 MCP 独立项,把可转换的 STDIO/HTTP 原生字段合并进本次隔离 `CODEX_HOME/config.toml`,再由现有启动参数单独注入内置 `agc_tools`。第三方项固定为非 required,结构错误或启动失败只更新对应扩展项的 `last_error`Codex app-server 的 `mcpServer/startupStatus/updated` 通知用于清除或记录单项启动状态。已启用 MCP 的名称、来源相对路径和内容指纹已纳入 app-server pool key。Plugin 样例已覆盖同一来源中的 Skill/MCP 独立提取,不开启 Plugin Runtime。
当前实施状态:已完成。客户端会在 DirectProject 启动前读取统一 catalog 中有效启用 MCP 项,把可转换的 STDIO/HTTP 原生字段合并进本次隔离 `CODEX_HOME/config.toml`,再由现有启动参数单独注入内置 `agc_tools`。第三方项固定为非 required,结构错误或启动失败只更新对应扩展项的 `last_error`Codex app-server 的 `mcpServer/startupStatus/updated` 通知用于清除或记录单项启动状态。已启用 MCP 的名称、来源相对路径和内容指纹已纳入 app-server pool key。Plugin Host 同时管理带 AGC Runtime 入口的父项
完成:
@@ -492,7 +494,7 @@ remove_client_extension(id)
- 一个 MCP 配置包含多个 Server 时,列表出现多个独立项;
- 每个 MCP 可以单独启用、禁用和重命名;
- 重名 Server 能生成稳定的唯一 key
- Plugin 不会开启 hooksapps 或完整 Plugin Runtime
- Plugin 不会向 DirectProject 开启 Codex hooks/apps;带 AGC Runtime 入口的 Plugin 由独立通用 Host 启停
- 单个可执行文件/脚本仍不会自动作为 MCP 入口。
## 11. 最小验证范围
@@ -23,6 +23,7 @@ Genarrative 的 JavaScript 工程统一使用 npm workspaces。仓库只提交
"apps/preview-deployer-web",
"packages/image-canvas-core",
"packages/image-canvas-react",
"packages/agc-plugin-sdk",
"packages/shared",
"tools/spine-json-export-validator"
]
@@ -38,6 +39,7 @@ Genarrative 的 JavaScript 工程统一使用 npm workspaces。仓库只提交
- `apps/preview-deployer-web`
- `packages/image-canvas-core`
- `packages/image-canvas-react`
- `packages/agc-plugin-sdk`
- `packages/shared`
- `tools/spine-json-export-validator`
+12
View File
@@ -15,6 +15,7 @@
"apps/preview-deployer-web",
"packages/image-canvas-core",
"packages/image-canvas-react",
"packages/agc-plugin-sdk",
"packages/shared",
"tools/spine-json-export-validator"
],
@@ -5140,6 +5141,10 @@
"resolved": "apps/admin-web",
"link": true
},
"node_modules/@genarrative/agc-plugin-sdk": {
"resolved": "packages/agc-plugin-sdk",
"link": true
},
"node_modules/@genarrative/ai-game-creator-shell": {
"resolved": "apps/ai-game-creator-shell",
"link": true
@@ -22870,6 +22875,10 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/agc-plugin-sdk": {
"name": "@genarrative/agc-plugin-sdk",
"version": "0.1.0"
},
"packages/image-canvas-core": {
"name": "@genarrative/image-canvas-core",
"version": "0.1.0",
@@ -26293,6 +26302,9 @@
"vitest": "^0.34.6"
}
},
"@genarrative/agc-plugin-sdk": {
"version": "file:packages/agc-plugin-sdk"
},
"@genarrative/ai-game-creator-shell": {
"version": "file:apps/ai-game-creator-shell",
"requires": {
+1
View File
@@ -12,6 +12,7 @@
"apps/preview-deployer-web",
"packages/image-canvas-core",
"packages/image-canvas-react",
"packages/agc-plugin-sdk",
"packages/shared",
"tools/spine-json-export-validator"
],
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@genarrative/agc-plugin-sdk",
"private": true,
"version": "0.1.0",
"type": "module",
"exports": {
".": "./src/index.ts"
}
}
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it, vi } from 'vitest';
import {
createJsonRpcStdioTransport,
createPluginHost,
PluginPanelRegistry,
} from './index';
describe('AGC plugin SDK', () => {
it('routes registrations and project/editor calls through the transport', async () => {
const request = vi.fn(async <T>(method: string, params?: unknown) => {
if (method === 'host.project.read')
return { path: params, content: 'ok' } as T;
return { registered: true } as T;
});
const host = createPluginHost({ request });
const disposePanel = await host.registerPanel({
id: 'panel',
title: 'Panel',
entry: 'panel.html',
});
expect(host.panels.list()).toHaveLength(1);
await host.project.read('game/index.html');
await host.rpc('scene.open', { path: 'Main.scene' });
disposePanel();
expect(host.panels.list()).toHaveLength(0);
expect(request).toHaveBeenCalledWith('host.registerPanel', {
id: 'panel',
title: 'Panel',
entry: 'panel.html',
});
expect(request).toHaveBeenCalledWith('host.project.read', {
path: 'game/index.html',
});
expect(request).toHaveBeenCalledWith('host.rpc', {
method: 'scene.open',
params: { path: 'Main.scene' },
});
expect(request).toHaveBeenCalledWith('host.unregisterPanel', {
id: 'panel',
});
});
it('notifies UI subscribers when panels are mounted or removed', () => {
const registry = new PluginPanelRegistry();
const changed = vi.fn();
registry.subscribe(changed);
const dispose = registry.register({
id: 'panel',
title: 'Panel',
entry: 'panel.html',
});
expect(registry.list()).toHaveLength(1);
dispose();
expect(registry.list()).toHaveLength(0);
expect(changed).toHaveBeenCalledTimes(2);
});
it('keeps bidirectional request ids separate and dispatches handlers', async () => {
let input: (chunk: string) => void = () => undefined;
const output: string[] = [];
const transport = createJsonRpcStdioTransport({
stdin: {
on: (_event, listener) => {
input = listener;
},
},
stdout: {
write: (chunk) => {
output.push(chunk);
},
},
});
transport.registerHandler?.('echo', (params) => params);
const request = transport.request('host.project.read', { path: 'game.js' });
input(
JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'echo',
params: { echo: true },
}) + '\n',
);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(JSON.parse(output[1] ?? '{}')).toEqual({
jsonrpc: '2.0',
id: 1,
result: { echo: true },
});
input(
JSON.stringify({ jsonrpc: '2.0', id: 1, result: { content: 'ok' } }) +
'\n',
);
expect(await request).toEqual({ content: 'ok' });
});
});

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