From 04fd48c4f91576ecace1b19a08fd4f7b24ef1896 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?parta=E5=8F=B8=E4=BB=A4=E5=A1=94?= Date: Mon, 6 Jul 2026 17:13:45 +0900 Subject: [PATCH] feat(app): external-spawn via single-instance args Launch a member pane from outside the GUI: a second launch of the shape agmsg-app spawn is forwarded by the single-instance handler to the running instance, which emits an external-spawn event. The frontend validates / against the live member registry before calling the existing spawnMember (idempotent: an already-running member just gets focused). The Rust side never execs the args itself, keeping the trust boundary at the same place a GUI click is. Enables scripted / orchestrator-driven launch (the missing counterpart to the app's stdin-inject delivery), needed for headless team automation on Windows where there is no LaunchAgent-style external launcher. --- app/src-tauri/src/lib.rs | 22 +++++++++++++++++++++- app/src/App.tsx | 26 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index b9bb2082..ca4d5fff 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -378,11 +378,31 @@ pub fn run() { // new one, so quitting/relaunching from the Dock or a second // double-click doesn't spawn duplicate instances each with their // own PTYs and agmsg DB watcher. - .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { if let Some(window) = app.get_webview_window("main") { let _ = window.set_focus(); let _ = window.unminimize(); } + // External-spawn entry point. A second launch of the shape + // agmsg-app[.exe] spawn + // asks the already-running instance to open a pane for + // in — the missing "launch a member from outside the GUI" + // capability (scripts, an orchestrator agent, a shell alias). + // args[0] is the executable path; the verb + operands follow. + // + // The Rust side only recognizes the single "spawn" verb and does + // NOT exec anything from these args itself: it forwards the raw + // (team, role) strings to the frontend, which validates them + // against the live member registry before calling spawnMember. + // That keeps the trust boundary at the same place a GUI click is. + if args.len() >= 4 && args[1] == "spawn" { + let team = args[2].clone(); + let role = args[3].clone(); + if !team.is_empty() && !role.is_empty() { + // Vec payload → the frontend reads [team, role]. + let _ = app.emit("external-spawn", vec![team, role]); + } + } })) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) diff --git a/app/src/App.tsx b/app/src/App.tsx index a83d9c7a..9a181807 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -547,6 +547,32 @@ export default function App() { [cmdName, spawnTypes, team], ); + // External-spawn entry point, paired with the single-instance handler in + // lib.rs: `agmsg-app spawn ` run from outside the GUI (a + // script, an orchestrator agent, a shell alias) emits an "external-spawn" + // event carrying [team, role]. We validate it against the live member + // registry here — the Rust side forwards the raw strings without acting on + // them — so the trust boundary matches a manual member click. An unknown + // team/role is logged rather than silently dropped. + useEffect(() => { + const p = listen("external-spawn", (e) => { + const [evTeam, role] = e.payload; + if (evTeam !== team) { + console.warn(`[external-spawn] ignored: team "${evTeam}" is not the active team "${team}"`); + return; + } + const member = members.find((m) => m.name === role); + if (!member) { + console.warn(`[external-spawn] ignored: no member "${role}" in team "${team}"`); + return; + } + // spawnMember is idempotent — an already-running member just gets its + // window focused instead of opening a duplicate pane. + void spawnMember(member); + }); + return () => void p.then((u) => u()); + }, [team, members, spawnMember]); + // Close one pane (from its header's × or the context menu). If it was the // last pane in its window, the window/tab disappears too. const closeWindowPane = useCallback(