新增 AGC 通用插件宿主与 SDK
统一 Agent Plugin、Skill、MCP 的来源索引和启停状态 新增通用进程宿主、JSON-RPC、能力注册、权限审计与面板挂载 新增 AGC Plugin SDK 与标准 plugin.json 解析支持 保留 EditorAdapter 扩展点,不内置具体编辑器适配器 补充运行时设置界面、项目上下文同步和定向测试 同步更新插件方案、DirectProject、workspace 与项目记忆文档
This commit is contained in:
@@ -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!(
|
||||
|
||||
+2
-2
@@ -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,不开启 hooks、apps 或完整 Plugin Runtime。
|
||||
- Plugin 父项控制其 Skill/MCP 子项的有效启用;Codex hooks/apps 不注入,AGC Runtime 入口由通用 Plugin Host 管理。
|
||||
|
||||
Vendored
+1
@@ -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" }
|
||||
],
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 Plugin;Plugin 父项与其中的 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/SDK:Plugin、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` 子项放进同一 catalog;Skill 继续交给 Codex 原生 Skill loader,MCP 继续交给现有 MCP transport,Runtime 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)
|
||||
|
||||
- Rust:manifest 路径/权限校验、目录扫描、权限拒绝和通用适配器 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/MCP;DirectProject 启动 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)
|
||||
|
||||
### 阶段 3:MCP 和 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 不会开启 hooks、apps 或完整 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`
|
||||
|
||||
|
||||
Generated
+12
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@genarrative/agc-plugin-sdk",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user