Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
560 changes: 536 additions & 24 deletions apps/codex-plus-launcher/src/main.rs

Large diffs are not rendered by default.

1,536 changes: 1,536 additions & 0 deletions apps/codex-plus-launcher/src/user_script_reload.rs

Large diffs are not rendered by default.

58 changes: 46 additions & 12 deletions apps/codex-plus-manager/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1432,6 +1432,17 @@ fn launch_codex_multi_instance_blocking(request: CodexMultiLaunchRequest) -> Com
worker_settings
.codex_extra_args
.push(format!("--user-data-dir={}", user_data_dir.display()));
worker_settings.relay_context_config_contents = match std::env::current_exe()
.map_err(anyhow::Error::from)
.and_then(|executable| crate::extension_creator_commands::rebind_multi_instance_creator(
&codex_home, &worker_state_dir, &executable, &worker_settings.relay_context_config_contents,
))
{
Ok(context) => context,
Err(error) => return failed(
&format!("绑定多开拓展重载会话失败:{error}"), json!({ "instanceId": instance_id }),
),
};
if let Err(error) = worker_store.save(&worker_settings) {
return failed(
&format!("保存多开 launcher 设置失败:{error}"),
Expand Down Expand Up @@ -6925,8 +6936,17 @@ pub async fn refresh_user_script_inventory() -> CommandResult<SettingsPayload> {
)
}

fn user_script_reload_success_message(mode: &str) -> &'static str {
match mode {
"scripts" => "用户脚本已热重载。",
"page" => "已刷新 Codex 页面并确认拓展加载。",
"navigation" => "Codex 页面已切换,并确认拓展加载。",
_ => "已确认当前 Codex 页面的拓展加载。",
}
}

#[tauri::command]
pub async fn reload_user_scripts() -> CommandResult<SettingsPayload> {
pub async fn reload_user_scripts(operation: Option<Value>) -> CommandResult<SettingsPayload> {
let debug_port = StatusStore::default()
.load_latest()
.ok()
Expand All @@ -6936,9 +6956,15 @@ pub async fn reload_user_scripts() -> CommandResult<SettingsPayload> {
let manager = default_user_script_manager();
// 手动改过拓展文件后热重载:顺带让 Codex 钩子条目与拓展声明一致。
codex_plus_core::extension_hooks::sync_default(&manager);
match codex_plus_core::user_scripts::reload_live_scripts(debug_port, &manager).await {
match codex_plus_core::user_scripts::reload_native_scripts(
&codex_plus_core::paths::default_app_state_dir(), debug_port, &manager, true, operation.as_ref(),
).await {
Ok(user_scripts) => {
let page_reload = user_scripts["reload_mode"] == "page";
let reload_mode = user_scripts["reload_mode"].as_str().unwrap_or_default().to_owned();
let phase = user_scripts["reloadOperation"]["phase"].as_str().unwrap_or("unknown").to_owned();
let operation_id = user_scripts["reloadOperation"]["operationId"].as_str().unwrap_or_default().to_string();
let reason = user_scripts["reloadOperation"]["reason"].as_str().unwrap_or_default().to_string();
let applied = phase == "applied";
let script_failed = user_scripts["scripts"]
.as_array()
.is_some_and(|scripts| scripts.iter().any(|script| script["status"] == "failed"));
Expand All @@ -6950,17 +6976,17 @@ pub async fn reload_user_scripts() -> CommandResult<SettingsPayload> {
.to_string(),
user_scripts,
};
if script_failed {
failed("部分脚本执行失败,请查看本地脚本状态。", payload)
} else {
ok(
if page_reload {
"已请求刷新 Codex 页面以安全重载旧脚本。"
} else {
"用户脚本已热重载。"
},
if !applied {
failed(
&format!("用户脚本重载{}(操作 {operation_id}){}",
if phase == "unknown" { "结果待确认" } else { "未完成" },
if reason.is_empty() { String::new() } else { format!(":{reason}") }),
payload,
)
} else if script_failed {
failed("部分脚本执行失败,请查看本地脚本状态。", payload)
} else {
ok(user_script_reload_success_message(&reload_mode), payload)
}
}
Err(error) => failed(
Expand Down Expand Up @@ -11068,6 +11094,14 @@ fn default_log_lines() -> usize {

#[cfg(test)]
mod tests {
#[test]
fn user_script_reload_message_distinguishes_document_changes_from_script_reload() {
assert_eq!(super::user_script_reload_success_message("scripts"), "用户脚本已热重载。");
assert_eq!(super::user_script_reload_success_message("page"), "已刷新 Codex 页面并确认拓展加载。");
assert_eq!(super::user_script_reload_success_message("navigation"), "Codex 页面已切换,并确认拓展加载。");
assert_eq!(super::user_script_reload_success_message(""), "已确认当前 Codex 页面的拓展加载。");
}

#[test]
fn multi_instance_user_data_respects_explicit_electron_root() {
let temp = tempfile::tempdir().unwrap();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,44 @@ pub fn run_mcp(args: &[OsString]) -> anyhow::Result<()> {
// 拓展工具调用后台服务时,经这些端口上的 Codex 页面桥接转给托管后台的进程。
codex_plus_core::extension_backend::set_remote_bridge_ports(ports.clone());
let creator = ExtensionCreator {
manager: crate::commands::default_user_script_manager(),
manager: codex_plus_core::user_scripts::default_user_script_manager_for_settings(state_dir.join("settings.json")),
debug_ports: ports,
state_dir,
};
tauri::async_runtime::block_on(extension_creator::serve(creator))
}

/// 多开只重绑定确属本工具的条目;未接入、停用和其他程序的同名条目都保留原意。
pub(crate) fn rebind_multi_instance_creator(
home: &Path,
state_dir: &Path,
executable: &Path,
context: &str,
) -> anyhow::Result<String> {
fn rebind(contents: &str, executable: &Path, state_dir: &Path) -> anyhow::Result<String> {
let document = contents.parse::<toml_edit::DocumentMut>()
.map_err(|_| anyhow::anyhow!("多开拓展创作配置不是有效 TOML"))?;
let entries = std::iter::once((extension_creator::SERVER_ID, extension_creator::MCP_FLAG))
.chain(std::iter::once((extension_creator::LEGACY_SERVER_ID, extension_creator::LEGACY_MCP_FLAG)));
let owned = entries.filter_map(|(id, flag)| {
let entry = document.get("mcp_servers")?.get(id)?;
(entry.get("args")?.as_array()?.get(0)?.as_str()? == flag)
.then(|| entry.get("enabled").and_then(toml_edit::Item::as_bool).unwrap_or(true))
}).next();
let Some(enabled) = owned else { return Ok(contents.to_owned()) };
let updated = managed_mcp::upsert_managed_context(&extension_creator::SPEC, contents, executable, state_dir)?;
if enabled { Ok(updated) } else { managed_mcp::disable_managed_context(&extension_creator::SPEC, &updated) }
}
let context = rebind(context, executable, state_dir)?;
let config_path = home.join("config.toml");
let contents = std::fs::read_to_string(&config_path).context("读取多开拓展创作配置失败")?;
let updated = rebind(&contents, executable, state_dir)?;
if updated != contents {
codex_plus_core::settings::atomic_write(&config_path, updated.as_bytes())?;
}
Ok(context)
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -139,6 +171,27 @@ mod tests {
assert_eq!(state_dir_from_args(&args).unwrap(), dir.path());
}

#[test]
fn multi_creator_rebinds_only_owned_entries_and_preserves_disabled() {
let dir = tempfile::tempdir().unwrap();
let executable = std::env::current_exe().unwrap();
let worker_state = dir.path().join("worker-state");
let main_state = dir.path().join("main-state");
let original = managed_mcp::upsert_managed_context(
&extension_creator::SPEC, "", &executable, &main_state,
).unwrap();
let disabled = managed_mcp::disable_managed_context(&extension_creator::SPEC, &original).unwrap();
std::fs::write(dir.path().join("config.toml"), &disabled).unwrap();
let rebound = rebind_multi_instance_creator(dir.path(), &worker_state, &executable, &disabled).unwrap();
let document = rebound.parse::<toml_edit::DocumentMut>().unwrap();
assert_eq!(document["mcp_servers"][extension_creator::SERVER_ID]["args"][2].as_str(), worker_state.to_str());
assert_eq!(document["mcp_servers"][extension_creator::SERVER_ID]["enabled"].as_bool(), Some(false));
let foreign = "[mcp_servers.codex_plus_extensions]\ncommand='other'\nargs=['--other']\n";
std::fs::write(dir.path().join("config.toml"), foreign).unwrap();
assert_eq!(rebind_multi_instance_creator(dir.path(), &worker_state, &executable, foreign).unwrap(), foreign);
assert_eq!(std::fs::read_to_string(dir.path().join("config.toml")).unwrap(), foreign);
}

#[test]
fn extension_creator_coexists_with_collaboration_in_managed_context() {
let dir = tempfile::tempdir().unwrap();
Expand Down
57 changes: 46 additions & 11 deletions apps/codex-plus-manager/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,14 @@ type UserScriptInventory = {
enabled?: boolean;
claude_enabled?: boolean;
skip_next_start?: boolean;
reloadOperation?: {
operationId: string;
ownerEpoch: string;
allowPage: boolean;
phase: string;
active?: boolean;
reason?: string;
};
builtins?: BuiltinExtension[];
scripts?: Array<{
key: string;
Expand All @@ -788,6 +796,10 @@ type UserScriptInventory = {
settingsPage?: boolean;
} | null;
enabled: boolean;
configuredEnabled?: boolean;
runtimeStatus?: string;
applyState?: string;
missing?: boolean;
claude_enabled?: boolean | null;
status: string;
error: string;
Expand Down Expand Up @@ -2145,13 +2157,30 @@ export function App() {
return result;
};

const reloadUserScripts = async (silent = false) => {
const result = await run(() => call<SettingsResult>("reload_user_scripts"));
if (result) {
setSettings(result);
setScriptMarket((current) => syncMarketInstalledState(current, result.user_scripts));
showResultNotice(t("本地拓展"), result, { silentSuccess: silent });
}
const pendingUserScriptReload = useRef<UserScriptInventory["reloadOperation"] | null>(null);
const [pendingReload, setPendingReload] = useState<UserScriptInventory["reloadOperation"] | null>(null);
const userScriptReloadQueue = useRef<Promise<void>>(Promise.resolve());
const reloadUserScripts = (silent = false) => {
const request = userScriptReloadQueue.current.then(async () => {
const result = await run(() => call<SettingsResult>("reload_user_scripts", {
operation: pendingUserScriptReload.current,
}));
if (result) {
const operation = result.user_scripts?.reloadOperation;
if (operation?.phase === "unknown") {
pendingUserScriptReload.current = operation;
setPendingReload(operation);
} else if (operation && ["applied", "failed", "blocked", "degraded", "rejected"].includes(operation.phase)) {
pendingUserScriptReload.current = null;
setPendingReload(null);
}
setSettings(result);
setScriptMarket((current) => syncMarketInstalledState(current, result.user_scripts));
showResultNotice(t("本地拓展"), result, { silentSuccess: silent });
}
});
userScriptReloadQueue.current = request.catch(() => {});
return request;
};

const installMarketScript = async (id: string) => {
Expand Down Expand Up @@ -2232,6 +2261,7 @@ export function App() {
setSettings(result);
setScriptMarket((current) => syncMarketInstalledState(current, result.user_scripts));
showResultNotice(t("本地拓展"), result);
if (isSuccessStatus(result.status)) await reloadUserScripts();
await refreshUserScriptInventory();
}
};
Expand Down Expand Up @@ -5456,7 +5486,7 @@ export function App() {
actions={actions}
/>
) : null}
{route === "userScripts" ? <UserScriptsScreen host={extensionHost} focus={agentFocus} onHostChange={setExtensionHost} settings={settings} market={scriptMarket} onOpenBuiltins={() => void navigate("enhance")} onOpenClaude={() => void switchTool("claude", "claude")} actions={actions} /> : null}
{route === "userScripts" ? <UserScriptsScreen host={extensionHost} focus={agentFocus} onHostChange={setExtensionHost} settings={settings} market={scriptMarket} reloadPending={pendingReload !== null} onOpenBuiltins={() => void navigate("enhance")} onOpenClaude={() => void switchTool("claude", "claude")} actions={actions} /> : null}
{route === "pluginMarket" ? <PluginMarketScreen /> : null}
{route === "home" ? (
<HomeScreen
Expand Down Expand Up @@ -10594,7 +10624,7 @@ function dreamSkinStateLabel(state: "pass" | "warning" | "fail" | "not_running")
return t("Codex 未运行或不可连接");
}

function UserScriptsScreen({ host, focus = null, onHostChange, settings, market, onOpenBuiltins, onOpenClaude, actions }: { host: ExtensionHost; focus?: AgentFocus; onHostChange: (host: ExtensionHost) => void; settings: SettingsResult | null; market: ScriptMarketResult | null; onOpenBuiltins: () => void; onOpenClaude: () => void; actions: Actions }) {
function UserScriptsScreen({ host, focus = null, onHostChange, settings, market, reloadPending, onOpenBuiltins, onOpenClaude, actions }: { host: ExtensionHost; focus?: AgentFocus; onHostChange: (host: ExtensionHost) => void; settings: SettingsResult | null; market: ScriptMarketResult | null; reloadPending: boolean; onOpenBuiltins: () => void; onOpenClaude: () => void; actions: Actions }) {
const codexHost = host === "codex";
const [reloading, setReloading] = useState(false);
const reload = async () => {
Expand Down Expand Up @@ -10713,7 +10743,7 @@ function UserScriptsScreen({ host, focus = null, onHostChange, settings, market,
<TopbarActions>
{codexHost ? <button className="cx-secondary" disabled={reloading} onClick={() => void reload()} title={t("应用本地拓展及开关;旧版脚本可能需要刷新 Codex 页面")} type="button">
<RefreshCw className={reloading ? "animate-spin" : ""} />
{t("热重载拓展")}
{t(reloadPending ? "查询重载状态" : "热重载拓展")}
</button> : <button className="cx-secondary" onClick={onOpenClaude} type="button">
<AppWindow />
{t("Claude 补丁版")}
Expand Down Expand Up @@ -17846,6 +17876,10 @@ function localScriptStateLabel(state: LocalScriptState, host: ExtensionHost = "c
return { label: t("重启后应用"), hint: t("重启 Claude 补丁版后应用") };
}
switch (state) {
case "unknownOff":
return { label: t("状态未确认"), hint: t("已设置关闭,但无法确认 Codex 页面中的运行状态") };
case "pendingStop":
return { label: t("等待停用"), hint: t("已设置关闭,但 Codex 页面中仍有该拓展的运行记录") };
case "off":
return { label: t("已关闭"), hint: t("该拓展已关闭,不会注入 Codex") };
case "globalOff":
Expand Down Expand Up @@ -17993,7 +18027,7 @@ function ScriptRow({ host, script, globalEnabled, actions }: { host: ExtensionHo
? tf("拓展包 · {0}", [script.package?.version || t("未知版本")])
: script.source === "builtin" ? t("内置") : t("用户");
// 拓展包是文件夹:后端整个移进废纸篓(可找回,#2919),单文件脚本直接删除。
const canDelete = codexHost && script.source === "user";
const canDelete = codexHost && script.source === "user" && !script.missing;
const state = extensionStateForHost(script, host, globalEnabled);
const { label, hint } = localScriptStateLabel(state, host);
const stateHint = codexHost && state === "failed" && script.error ? `${hint}\n${script.error}` : hint;
Expand Down Expand Up @@ -18086,6 +18120,7 @@ function ScriptRow({ host, script, globalEnabled, actions }: { host: ExtensionHo
<input
aria-label={tf("启用 {0}", [script.name])}
checked={enabled}
disabled={script.missing}
onChange={() => void actions.setUserScriptEnabled(script.key, !enabled, host)}
type="checkbox"
/>
Expand Down
Loading
Loading