Files
1jehuang--jcode/crates/jcode-app-core/src/session_launch.rs
T
wehub-resource-sync a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

642 lines
21 KiB
Rust

//! Launching jcode sessions in new terminal windows.
//!
//! These helpers spawn a fresh `jcode` process (resume or self-dev) inside a
//! new terminal window. They are pure process/terminal orchestration built on
//! the low-level `terminal_launch` facade and depend only on core modules
//! (`id`, `process_title`, `platform`), so
//! they live in the core layer rather than the CLI command layer. This lets
//! lower layers like `server`, `restart_snapshot`, and `tool` relaunch
//! sessions without depending on `cli`.
use anyhow::Result;
use crate::{id, server};
/// Map a persisted session/runtime provider key (e.g. `anthropic-api-key`,
/// `claude-oauth`) to the value the resumed process accepts for `--provider`
/// (the CLI `ProviderChoice` vocabulary, e.g. `anthropic-api`, `claude`).
///
/// The two vocabularies are not identical, so passing the raw runtime key
/// straight through makes clap reject it (`invalid value 'anthropic-api-key'`)
/// and the freshly spawned window exits immediately before the TUI starts.
/// Returns `None` when the key has no clean standalone CLI provider value; the
/// flag is then omitted and the persisted session reconstructs the route on
/// resume.
fn resume_provider_arg(provider_key: Option<&str>) -> Option<&'static str> {
provider_key
.map(str::trim)
.filter(|value| !value.is_empty())
.and_then(crate::provider::cli_provider_arg_for_session_key)
}
/// Metadata describing why a session window is being spawned, exported to
/// spawn hooks and spawned terminals as `JCODE_SPAWN_*` env vars so external
/// programs (tmux, kitty remote, herd, window managers) can reroute or place
/// the window. See `[terminal] spawn_hook` in config.
#[derive(Debug, Clone, Default)]
pub struct SessionSpawnContext {
/// Spawn kind override (e.g. "swarm-agent", "restart"). Defaults to
/// "resume" or "selfdev" based on the launch helper used.
pub kind: Option<String>,
/// Extra `JCODE_SPAWN_*` env entries (e.g. swarm/coordinator ids).
pub extra_env: Vec<(String, String)>,
/// Terminal-identifying env vars captured from the client that requested
/// the spawn (tmux/zellij/kitty/DISPLAY/...). Re-exported to spawn/focus
/// hooks so the new window lands in the client's terminal instead of the
/// server's stale startup env (#405).
pub client_terminal_env: Vec<(String, String)>,
}
impl SessionSpawnContext {
pub fn kind(kind: impl Into<String>) -> Self {
Self {
kind: Some(kind.into()),
extra_env: Vec::new(),
client_terminal_env: Vec::new(),
}
}
pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.extra_env.push((key.into(), value.into()));
self
}
/// Attach the requesting client's terminal env snapshot (#405).
pub fn with_client_terminal_env(mut self, env: Vec<(String, String)>) -> Self {
self.client_terminal_env = env;
self
}
fn apply(
&self,
mut command: crate::terminal_launch::TerminalCommand,
default_kind: &str,
session_id: &str,
) -> crate::terminal_launch::TerminalCommand {
command = command
.kind(self.kind.as_deref().unwrap_or(default_kind))
.session_id(session_id);
for (key, value) in &self.extra_env {
command = command.spawn_env(key.clone(), value.clone());
}
if !self.client_terminal_env.is_empty() {
command = command.client_terminal_env(self.client_terminal_env.clone());
}
command
}
}
/// Compute the window/terminal title used when (re)launching a session.
pub fn resumed_window_title(session_id: &str) -> String {
let session_name = crate::process_title::session_name(session_id);
let icon = id::session_icon(&session_name);
let display_title = crate::process_title::terminal_display_title_for_id(session_id);
let session_label = crate::process_title::terminal_session_label(&session_name, None);
let fallback_label = if let Some(server_info) =
crate::registry::find_server_by_socket_sync(&server::socket_path())
{
format!("jcode/{} {}", server_info.name, session_label)
} else {
format!("jcode {}", session_label)
};
crate::process_title::terminal_window_title(
&icon,
display_title.as_deref(),
Some(&fallback_label),
false,
)
}
/// Focus/raise the window for `session_id` via the configured focus hook.
///
/// Returns `true` when a hook was configured and its process started (the
/// built-in wmctrl/xdotool fallback should then be skipped). The hook receives
/// `JCODE_FOCUS_SESSION_ID` and `JCODE_FOCUS_TITLE` env vars.
pub fn focus_session_via_hook(session_id: &str, title: &str) -> bool {
focus_session_via_hook_with_env(session_id, title, &[])
}
/// Like [`focus_session_via_hook`] but also re-exports the requesting client's
/// terminal env (#405) so focus hooks (e.g. `zellij action go-to-tab-name`)
/// target the client's terminal session instead of the server's stale env. Each
/// var is exported natively and under a `JCODE_CLIENT_<NAME>` alias.
pub fn focus_session_via_hook_with_env(
session_id: &str,
title: &str,
client_terminal_env: &[(String, String)],
) -> bool {
let hook = {
let config = &crate::config::config().terminal;
config
.focus_hook
.as_deref()
.map(str::trim)
.filter(|hook| !hook.is_empty())
.map(str::to_string)
};
let Some(hook) = hook else {
return false;
};
let parts = match crate::terminal_launch::parse_hook_command(&hook) {
Ok(parts) => parts,
Err(error) => {
crate::logging::warn(&format!("Focus hook '{hook}' failed to parse: {error}"));
return false;
}
};
let (program, args) = parts
.split_first()
.expect("parse_hook_command guarantees at least one part");
let mut cmd = std::process::Command::new(crate::terminal_launch::expand_home(program));
cmd.args(args)
.env("JCODE_FOCUS_SESSION_ID", session_id)
.env("JCODE_FOCUS_TITLE", title)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
for (key, value) in client_terminal_env {
cmd.env(key, value);
cmd.env(format!("JCODE_CLIENT_{key}"), value);
}
match crate::platform::spawn_detached(&mut cmd) {
Ok(_) => true,
Err(error) => {
crate::logging::warn(&format!(
"Focus hook '{hook}' failed to start ({error}); falling back to built-in focus"
));
false
}
}
}
/// Focus a session window: configured focus hook first, then the built-in
/// wmctrl/xdotool title search (Linux only) as a best-effort fallback.
pub fn focus_session_window_best_effort(session_id: &str, title: &str) {
focus_session_window_best_effort_with_env(session_id, title, &[]);
}
/// Like [`focus_session_window_best_effort`] but forwards the requesting
/// client's terminal env to the focus hook (#405).
pub fn focus_session_window_best_effort_with_env(
session_id: &str,
title: &str,
client_terminal_env: &[(String, String)],
) {
if focus_session_via_hook_with_env(session_id, title, client_terminal_env) {
return;
}
focus_title_best_effort(title);
}
#[cfg(all(unix, not(target_os = "macos")))]
fn focus_title_best_effort(title: &str) {
use std::process::{Command, Stdio};
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg(
"sleep 0.4; \
if command -v wmctrl >/dev/null 2>&1; then wmctrl -a \"$JCODE_WINDOW_TITLE\" >/dev/null 2>&1 && exit 0; fi; \
if command -v xdotool >/dev/null 2>&1; then xdotool search --name \"$JCODE_WINDOW_TITLE\" windowactivate >/dev/null 2>&1 && exit 0; fi; \
exit 0",
)
.env("JCODE_WINDOW_TITLE", title)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let _ = crate::platform::spawn_detached(&mut cmd);
}
#[cfg(any(not(unix), target_os = "macos"))]
fn focus_title_best_effort(_title: &str) {}
#[cfg(unix)]
pub fn spawn_resume_in_new_terminal(
exe: &std::path::Path,
session_id: &str,
cwd: &std::path::Path,
) -> Result<bool> {
spawn_resume_in_new_terminal_with_provider(exe, session_id, cwd, None)
}
#[cfg(unix)]
pub fn spawn_resume_in_new_terminal_with_provider(
exe: &std::path::Path,
session_id: &str,
cwd: &std::path::Path,
provider_key: Option<&str>,
) -> Result<bool> {
spawn_resume_in_new_terminal_with_context(
exe,
session_id,
cwd,
provider_key,
&SessionSpawnContext::default(),
)
}
#[cfg(unix)]
pub fn spawn_resume_in_new_terminal_with_context(
exe: &std::path::Path,
session_id: &str,
cwd: &std::path::Path,
provider_key: Option<&str>,
context: &SessionSpawnContext,
) -> Result<bool> {
let title = resumed_window_title(session_id);
let mut args = vec!["--fresh-spawn".to_string()];
if let Some(provider_arg) = resume_provider_arg(provider_key) {
args.push("--provider".to_string());
args.push(provider_arg.to_string());
}
args.extend(["--resume".to_string(), session_id.to_string()]);
let command = crate::terminal_launch::TerminalCommand::new(exe, args)
.title(title)
.fresh_spawn();
let command = context.apply(command, "resume", session_id);
crate::terminal_launch::spawn_command_in_new_terminal(&command, cwd)
}
#[cfg(unix)]
pub fn spawn_selfdev_in_new_terminal(
exe: &std::path::Path,
session_id: &str,
cwd: &std::path::Path,
) -> Result<bool> {
spawn_selfdev_in_new_terminal_with_provider(exe, session_id, cwd, None)
}
#[cfg(unix)]
pub fn spawn_selfdev_in_new_terminal_with_provider(
exe: &std::path::Path,
session_id: &str,
cwd: &std::path::Path,
provider_key: Option<&str>,
) -> Result<bool> {
spawn_selfdev_in_new_terminal_with_context(
exe,
session_id,
cwd,
provider_key,
&SessionSpawnContext::default(),
)
}
#[cfg(unix)]
pub fn spawn_selfdev_in_new_terminal_with_context(
exe: &std::path::Path,
session_id: &str,
cwd: &std::path::Path,
provider_key: Option<&str>,
context: &SessionSpawnContext,
) -> Result<bool> {
let selfdev_title = format!("{} [self-dev]", resumed_window_title(session_id));
let mut args = vec!["--fresh-spawn".to_string()];
if let Some(provider_arg) = resume_provider_arg(provider_key) {
args.push("--provider".to_string());
args.push(provider_arg.to_string());
}
args.extend([
"--resume".to_string(),
session_id.to_string(),
"self-dev".to_string(),
]);
let command = crate::terminal_launch::TerminalCommand::new(exe, args)
.title(selfdev_title.clone())
.fresh_spawn();
let command = context.apply(command, "selfdev", session_id);
let spawned = crate::terminal_launch::spawn_command_in_new_terminal(&command, cwd)?;
if spawned {
focus_session_window_best_effort_with_env(
session_id,
&selfdev_title,
&context.client_terminal_env,
);
}
Ok(spawned)
}
#[cfg(not(unix))]
fn find_wezterm_gui_binary() -> Option<String> {
use std::process::{Command, Stdio};
if let Ok(exe) = std::env::var("WEZTERM_EXECUTABLE") {
let p = std::path::Path::new(&exe);
let gui = p.with_file_name("wezterm-gui.exe");
if gui.exists() {
return Some(gui.to_string_lossy().into_owned());
}
return Some(exe);
}
let candidates = [
r"C:\Program Files\WezTerm\wezterm-gui.exe",
r"C:\Program Files (x86)\WezTerm\wezterm-gui.exe",
];
for c in &candidates {
if std::path::Path::new(c).exists() {
return Some(c.to_string());
}
}
for bin in &["wezterm-gui", "wezterm"] {
if let Ok(output) = Command::new("where")
.arg(bin)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
{
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if let Some(line) = stdout.lines().next() {
let trimmed = line.trim();
if !trimmed.is_empty() {
if *bin == "wezterm" {
let p = std::path::Path::new(trimmed);
let gui = p.with_file_name("wezterm-gui.exe");
if gui.exists() {
return Some(gui.to_string_lossy().into_owned());
}
}
return Some(trimmed.to_string());
}
}
}
}
}
None
}
#[cfg(not(unix))]
fn resume_terminal_candidates_windows() -> Vec<String> {
std::env::var("JCODE_RESUME_TERMINAL")
.ok()
.map(|value| {
value
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.filter(|candidates| !candidates.is_empty())
.unwrap_or_else(|| {
vec![
"wezterm".to_string(),
"wt".to_string(),
"alacritty".to_string(),
]
})
}
#[cfg(not(unix))]
pub fn spawn_resume_in_new_terminal(
exe: &std::path::Path,
session_id: &str,
cwd: &std::path::Path,
) -> Result<bool> {
spawn_resume_in_new_terminal_with_provider(exe, session_id, cwd, None)
}
#[cfg(not(unix))]
pub fn spawn_resume_in_new_terminal_with_provider(
exe: &std::path::Path,
session_id: &str,
cwd: &std::path::Path,
provider_key: Option<&str>,
) -> Result<bool> {
spawn_resume_in_new_terminal_with_context(
exe,
session_id,
cwd,
provider_key,
&SessionSpawnContext::default(),
)
}
#[cfg(not(unix))]
pub fn spawn_resume_in_new_terminal_with_context(
exe: &std::path::Path,
session_id: &str,
cwd: &std::path::Path,
provider_key: Option<&str>,
context: &SessionSpawnContext,
) -> Result<bool> {
use std::process::{Command, Stdio};
let mut jcode_args: Vec<String> = Vec::new();
if let Some(provider_arg) = resume_provider_arg(provider_key) {
jcode_args.push("--provider".to_string());
jcode_args.push(provider_arg.to_string());
}
jcode_args.push("--resume".to_string());
jcode_args.push(session_id.to_string());
let hook_command = crate::terminal_launch::TerminalCommand::new(exe, jcode_args.clone())
.title(resumed_window_title(session_id));
let hook_command = context.apply(hook_command, "resume", session_id);
if crate::terminal_launch::try_spawn_via_configured_hook(&hook_command, cwd) {
return Ok(true);
}
let wezterm_gui = find_wezterm_gui_binary();
let alacritty_available = Command::new("where")
.arg("alacritty")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
let wt_available = std::env::var("WT_SESSION").is_ok()
|| Command::new("where")
.arg("wt")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
for term in resume_terminal_candidates_windows() {
let status = match term.as_str() {
"wezterm" => {
let Some(ref wezterm_bin) = wezterm_gui else {
continue;
};
let mut cmd = Command::new(wezterm_bin);
cmd.args(["start", "--always-new-process", "--"])
.arg(exe)
.args(&jcode_args)
.current_dir(cwd)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
crate::platform::spawn_detached(&mut cmd)
}
"wt" | "windows-terminal" => {
if !wt_available {
continue;
}
let mut cmd = Command::new("wt.exe");
cmd.args(["-p", "Command Prompt"])
.arg(exe)
.args(&jcode_args)
.current_dir(cwd)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
crate::platform::spawn_detached(&mut cmd)
}
"alacritty" => {
if !alacritty_available {
continue;
}
let mut cmd = Command::new("alacritty");
cmd.args(["-e"])
.arg(exe)
.args(&jcode_args)
.current_dir(cwd)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
crate::platform::spawn_detached(&mut cmd)
}
_ => continue,
};
if status.is_ok() {
return Ok(true);
}
}
Ok(false)
}
#[cfg(not(unix))]
pub fn spawn_selfdev_in_new_terminal(
exe: &std::path::Path,
session_id: &str,
cwd: &std::path::Path,
) -> Result<bool> {
spawn_selfdev_in_new_terminal_with_provider(exe, session_id, cwd, None)
}
#[cfg(not(unix))]
pub fn spawn_selfdev_in_new_terminal_with_provider(
exe: &std::path::Path,
session_id: &str,
cwd: &std::path::Path,
provider_key: Option<&str>,
) -> Result<bool> {
spawn_selfdev_in_new_terminal_with_context(
exe,
session_id,
cwd,
provider_key,
&SessionSpawnContext::default(),
)
}
#[cfg(not(unix))]
pub fn spawn_selfdev_in_new_terminal_with_context(
exe: &std::path::Path,
session_id: &str,
cwd: &std::path::Path,
provider_key: Option<&str>,
context: &SessionSpawnContext,
) -> Result<bool> {
use std::process::{Command, Stdio};
let mut jcode_args: Vec<String> = Vec::new();
if let Some(provider_arg) = resume_provider_arg(provider_key) {
jcode_args.push("--provider".to_string());
jcode_args.push(provider_arg.to_string());
}
jcode_args.extend([
"--resume".to_string(),
session_id.to_string(),
"self-dev".to_string(),
]);
let hook_command = crate::terminal_launch::TerminalCommand::new(exe, jcode_args.clone())
.title(format!("{} [self-dev]", resumed_window_title(session_id)));
let hook_command = context.apply(hook_command, "selfdev", session_id);
if crate::terminal_launch::try_spawn_via_configured_hook(&hook_command, cwd) {
return Ok(true);
}
let wezterm_gui = find_wezterm_gui_binary();
let alacritty_available = Command::new("where")
.arg("alacritty")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
let wt_available = std::env::var("WT_SESSION").is_ok()
|| Command::new("where")
.arg("wt")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
for term in resume_terminal_candidates_windows() {
let status = match term.as_str() {
"wezterm" => {
let Some(ref wezterm_bin) = wezterm_gui else {
continue;
};
let mut cmd = Command::new(wezterm_bin);
cmd.args(["start", "--always-new-process", "--"])
.arg(exe)
.args(&jcode_args)
.current_dir(cwd)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
crate::platform::spawn_detached(&mut cmd)
}
"wt" | "windows-terminal" => {
if !wt_available {
continue;
}
let mut cmd = Command::new("wt.exe");
cmd.args(["-p", "Command Prompt"])
.arg(exe)
.args(&jcode_args)
.current_dir(cwd)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
crate::platform::spawn_detached(&mut cmd)
}
"alacritty" => {
if !alacritty_available {
continue;
}
let mut cmd = Command::new("alacritty");
cmd.args(["-e"])
.arg(exe)
.args(&jcode_args)
.current_dir(cwd)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
crate::platform::spawn_detached(&mut cmd)
}
_ => continue,
};
if status.is_ok() {
return Ok(true);
}
}
Ok(false)
}