Skip to content
Open
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
22 changes: 21 additions & 1 deletion app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <team> <role>
// asks the already-running instance to open a pane for <role>
// in <team> — 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<String> payload → the frontend reads [team, role].
let _ = app.emit("external-spawn", vec![team, role]);
}
}
}))
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
Expand Down
26 changes: 26 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <team> <role>` 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<string[]>("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(
Expand Down