diff --git a/README.md b/README.md
index db7ed52..0487b5e 100644
--- a/README.md
+++ b/README.md
@@ -74,7 +74,7 @@ Or Git Bash: `./launch_lucy.sh`
## Using the Lucy launcher
-After **`Launch`**, enable **Core + Control Panel** in the launcher. Once it is running, the **Lucy Control Panel is accessible in your browser at [http://localhost:4004](http://localhost:4004)** (or the next free port if 4004 is already taken). The launcher also shows the exact URL next to the Control Panel entry once it's up.
+**Launch** starts a **tmux** session (Linux/macOS) and the **Lucy Control Center** TUI (`python -m launcher`, or `python launcher.py` / `./launch_lucy.sh`):
| Key | Action |
|-----|--------|
diff --git a/config/repos.json b/config/repos.json
index c378042..2fdf79f 100644
--- a/config/repos.json
+++ b/config/repos.json
@@ -24,13 +24,6 @@
"optional": true,
"url_https": "https://github.com/micro-ROS/micro-ROS-Agent.git",
"url_ssh": "git@github.com:micro-ROS/micro-ROS-Agent.git"
- },
- {
- "name": "audio_common",
- "branch": "ros2",
- "optional": true,
- "url_https": "https://github.com/ros-drivers/audio_common.git",
- "url_ssh": "git@github.com:ros-drivers/audio_common.git"
}
]
}
diff --git a/docs/developer_lucy_packages.md b/docs/developer_lucy_packages.md
index 4ac92d7..18a5b41 100644
--- a/docs/developer_lucy_packages.md
+++ b/docs/developer_lucy_packages.md
@@ -70,7 +70,7 @@ Developer CLI equivalents:
| `Lucy.exe` | `./launch_lucy.sh` |
| `Lucy.exe --cli build-only` | `./install.sh --build-only` |
-Launch runs via Git Bash (`bash launch_lucy.sh`). Without tmux, the Control Center runs directly (`pixi run -- python launcher.py`).
+Launch runs via Git Bash (`bash launch_lucy.sh`). Without tmux, the Control Center runs directly (`pixi run -- python -m launcher`).
### Workspace install (`install.sh`)
diff --git a/docs/launcher_packages.md b/docs/launcher_packages.md
index 937982d..6e96ea1 100644
--- a/docs/launcher_packages.md
+++ b/docs/launcher_packages.md
@@ -4,7 +4,7 @@ The launcher's configuration is entirely driven by the `launcher_config.json` fi
### Local launcher overrides (`config/launcher_config.json.local`)
-To customize the launcher package list (add experimental tools, tweak commands, or hide entries) without editing the tracked `config/launcher_config.json`, create **`config/launcher_config.json.local`**. When present it is used instead of `launcher_config.json` by `launcher.py`, and it is gitignored so overrides are never committed.
+To customize the launcher package list (add experimental tools, tweak commands, or hide entries) without editing the tracked `config/launcher_config.json`, create **`config/launcher_config.json.local`**. When present it is used instead of `launcher_config.json` by the launcher (`python -m launcher` or `python launcher.py`), and it is gitignored so overrides are never committed.
Use the same structure as `launcher_config.json` — copy the file and edit as needed, or include only the `packages` entries you want to change if you prefer a full replacement (the local file replaces the tracked file entirely, it is not merged).
@@ -40,16 +40,27 @@ Every package entry in `config/launcher_config.json` uses the following fields t
| `command` | `string` or `object` | The shell command to execute.
- **For `"core"`**: The base command (e.g. `ros2 launch ...`).
- **For `"modifier"`**: The argument string appended to the core command.
- **For `"interface"` / `"tool"`**: A simple string executed in a new tmux window, or a complex object containing `"start"`, `"stop"`, and `"is_running"` shell commands for custom background handling (like the web control panel). |
| `default_on` | `boolean` | If set to `true`, the package will be selected by default when the launcher boots up (currently unused as the launcher loads an empty initial state, but available for future functionality). |
-### Under the Hood (`launcher.py` and `launch_lucy.sh`)
+### Under the Hood (`launcher` and `launch_lucy.sh`)
When you run `./launch_lucy.sh`:
1. It ensures the workspace is built (`install/setup.bash` exists).
2. On Linux/macOS with **host tmux**, it starts or attaches to a **tmux** session named `lucy_ws`.
-3. The main window runs `pixi run -- python launcher.py` (Control Center TUI).
+3. The main window runs `pixi run -- python -m launcher` (Control Center TUI).
Package commands in other tmux windows are wrapped in `pixi run` so each pane gets RoboStack and the colcon overlay. Display and OpenGL-related variables from your shell and `.env` are forwarded into those panes.
-When you apply changes in `launcher.py`:
+When you apply changes in the launcher:
- **Core + Modifiers:** The script takes the core command, appends all active modifier commands, and spins up a dedicated `core` tmux window.
- **Interfaces / Tools:** The script spins up a new tmux window named after the package's `id` and executes its command via Pixi. Legacy complex `{start, stop, is_running}` objects are still supported for local overrides.
+
+### Stopping packages and exiting
+
+On **Linux and macOS**, services run in **tmux** windows. On **Windows** (Git Bash, no tmux), the same launcher TUI runs but process detection uses cross-platform logic; orphan cleanup is scoped to this workspace path so other projects are not affected.
+
+- **Q**, **X**, or **Esc** — prompts *Stop all processes and exit?*; on **y**, every running package is torn down, then workspace-scoped orphans are killed in a **single cleanup pass** (including **Gazebo `gz sim`**, which often appears as **ruby** in `top` because Pixi wraps it), then the tmux session ends.
+- Short-lived **`gz sim server`** / **`gz sim gui`** processes often have no workspace path in their command line; cleanup also checks **cwd**, **Pixi/Conda env**, and **`GZ_SIM_*`** variables so they are still reaped safely.
+- **Enter (Apply)** with boxes unticked — stops those packages asynchronously; orphan cleanup runs after each stop completes.
+- **Ctrl+C** outside the TUI — also triggers a full stop on exit.
+
+Unticking a single package (e.g. Control Panel) sends **Ctrl+C** to its tmux window, waits briefly, removes the window, then reaps any workspace-scoped stragglers (`vite`, `gz sim`, `rosbridge`, etc.).
diff --git a/launch_lucy.sh b/launch_lucy.sh
index 812b9c8..51e6194 100755
--- a/launch_lucy.sh
+++ b/launch_lucy.sh
@@ -137,7 +137,7 @@ TMUX_SESSION="${LUCY_TMUX_SESSION:-lucy_ws}"
case "$(uname -s)" in
Linux|Darwin)
if command -v tmux >/dev/null 2>&1; then
- LAUNCH_CMD="cd \"${SCRIPT_DIR}\" && pixi run -- python launcher.py"
+ LAUNCH_CMD="cd \"${SCRIPT_DIR}\" && pixi run -- python -m launcher"
export LUCY_TMUX_SESSION="$TMUX_SESSION"
exec bash -c "
set -e
@@ -157,4 +157,4 @@ case "$(uname -s)" in
esac
# Git Bash / MSYS on Windows: no tmux — run the Control Center launcher directly.
-exec pixi run -- python launcher.py
+exec pixi run -- python -m launcher
diff --git a/launcher.py b/launcher.py
index 682c91d..fe93343 100644
--- a/launcher.py
+++ b/launcher.py
@@ -1,844 +1,7 @@
#!/usr/bin/env python3
+"""Compatibility entry point — prefer `python -m launcher`."""
-try:
- import curses
-except ImportError:
- curses = None # Windows: no _curses; TUI helpers import-only on other platforms
-import os
-import sys
-import subprocess
-import threading
-import time
-import json
-import shlex
-from pathlib import Path
-
-WORKSPACE_ROOT = Path(__file__).resolve().parent
-CONFIG_DIR = WORKSPACE_ROOT / "config"
-DEFAULT_CONFIG_FILE = CONFIG_DIR / "launcher_config.json"
-LOCAL_CONFIG_FILE = CONFIG_DIR / "launcher_config.json.local"
-STATE_FILE = WORKSPACE_ROOT / ".lucy_launcher_modifiers.json"
-SELECTION_FILE = WORKSPACE_ROOT / ".lucy_launcher_state.json"
-TMUX_SESSION = os.environ.get("LUCY_TMUX_SESSION", "lucy_ws")
-MIN_TERM_HEIGHT = 22
-MIN_TERM_WIDTH = 65
-
-LOADING_TIMEOUT = 30 # seconds before LOADING transitions to CRASHED
-STOPPING_TIMEOUT = 30 # seconds to show STOPPING before giving up
-
-_pkg_start_times = {} # pkg_id -> float, timestamp when start was issued
-_intended_running = set() # pkg_ids that should be running (for crash detection)
-_pkg_stop_times = {} # pkg_id -> float, timestamp when an async stop was issued
-
-# Tearing down the core window with `tmux kill-window` alone orphans the GUI
-# processes ros2 launch spawned (notably `gz sim`), so they keep showing on the
-# Native GUI. Send SIGINT first for a clean ros2 launch shutdown, wait for the
-# sim/RViz to exit, force-kill any stragglers, then remove the window.
-CORE_TEARDOWN = (
- f"tmux send-keys -t {TMUX_SESSION}:core C-c 2>/dev/null; "
- "for _ in $(seq 1 12); do "
- "pgrep -f '[g]z sim' >/dev/null 2>&1 || pgrep -x rviz2 >/dev/null 2>&1 || break; sleep 0.25; "
- "done; "
- "pkill -f '[g]z sim' 2>/dev/null; pkill -x rviz2 2>/dev/null; "
- f"tmux kill-window -t {TMUX_SESSION}:core 2>/dev/null"
-)
-
-def get_dev_mode():
- env_path = WORKSPACE_ROOT / ".env"
- if not os.path.exists(env_path):
- return False
- with open(env_path, "r") as f:
- for line in f:
- if line.strip().startswith("DEV="):
- return line.strip().split("=")[1].lower() == "true"
- return False
-
-def load_workspace_env():
- """Load optional .env into os.environ (ports, GUI overrides, DEV=)."""
- env_path = WORKSPACE_ROOT / ".env"
- if not env_path.exists():
- return
- with open(env_path, "r") as f:
- for line in f:
- line = line.strip()
- if not line or line.startswith("#") or "=" not in line:
- continue
- key, _, val = line.partition("=")
- key = key.strip()
- if not key:
- continue
- val = val.strip().strip('"').strip("'")
- os.environ[key] = val
-
-# Forward into tmux panes — GUI processes do not inherit the launcher session env.
-_GUI_ENV_KEYS = (
- "DISPLAY",
- "WAYLAND_DISPLAY",
- "XAUTHORITY",
- "XDG_RUNTIME_DIR",
- "QT_QPA_PLATFORM",
- "QT_XCB_GL_INTEGRATION",
- "LIBGL_ALWAYS_SOFTWARE",
- "MESA_LOADER_DRIVER_OVERRIDE",
- "LIBGL_DRIVERS_PATH",
- "LD_LIBRARY_PATH",
- "LD_PRELOAD",
- "__EGL_VENDOR_LIBRARY_FILENAMES",
- "GZ_IP",
-)
-
-def _gui_env_exports() -> str:
- parts = []
- for key in _GUI_ENV_KEYS:
- val = os.environ.get(key)
- if val:
- parts.append(f"export {key}={shlex.quote(val)}")
- return "; ".join(parts)
-
-def is_in_tmux():
- return 'TMUX' in os.environ
-
-
-def needs_tmux_session():
- """tmux launcher is used on Linux/macOS; Windows runs launcher.py directly."""
- return sys.platform not in ('win32', 'cygwin', 'msys') and os.name != 'nt'
-
-def _launcher_config_path():
- """config/launcher_config.json.local (gitignored) overrides the tracked file."""
- return str(LOCAL_CONFIG_FILE if LOCAL_CONFIG_FILE.exists() else DEFAULT_CONFIG_FILE)
-
-def load_config():
- config_path = _launcher_config_path()
- if not os.path.exists(config_path):
- raise FileNotFoundError(f"Configuration file not found at {config_path}")
- with open(config_path, 'r') as f:
- return json.load(f)
-
-def load_state():
- if not STATE_FILE.is_file():
- return {"modifiers": []}
- with open(STATE_FILE, 'r') as f:
- try:
- return json.load(f)
- except json.JSONDecodeError:
- return {"modifiers": []}
-
-def save_state(state_data):
- with open(STATE_FILE, 'w') as f:
- json.dump(state_data, f)
-
-def load_selection():
- """Set of package ids the user last applied, or None if never saved."""
- if not os.path.exists(SELECTION_FILE):
- return None
- try:
- with open(SELECTION_FILE) as f:
- return set(json.load(f).get("selected", []))
- except (json.JSONDecodeError, OSError):
- return None
-
-def save_selection(selected_ids):
- """Persist the applied tick selection so it is restored on the next launch."""
- try:
- with open(SELECTION_FILE, 'w') as f:
- json.dump({"selected": sorted(selected_ids)}, f)
- except OSError:
- pass
-
-NIX_GL_ENV_SCRIPT = WORKSPACE_ROOT / "scripts" / "nix_gl_env.sh"
-
-def _nix_gl_source() -> str:
- """Source hook for NixOS: prepend host Mesa before Pixi conda GL (no-op elsewhere)."""
- if os.environ.get("LUCY_NIX_GL", "auto").lower() in ("0", "false", "no", "off"):
- return ""
- if not NIX_GL_ENV_SCRIPT.is_file():
- return ""
- return f"source {shlex.quote(str(NIX_GL_ENV_SCRIPT))}; "
-
-def _pixi_workspace_script(user_cmd: str) -> str:
- """Shell script body: workspace root + Pixi env (RoboStack + colcon overlay)."""
- user_cmd = user_cmd.strip()
- nix_gl = _nix_gl_source()
- if user_cmd.startswith("pixi "):
- pixi_part = user_cmd
- elif nix_gl or any(op in user_cmd for op in (";", "&&", "||", "|", "&")):
- pixi_part = f"pixi run -- bash -lc {shlex.quote(nix_gl + user_cmd)}"
- elif user_cmd.startswith("ros2 "):
- pixi_part = f"pixi run -- bash -lc {shlex.quote(nix_gl + user_cmd)}"
- else:
- pixi_part = f"pixi run -- {user_cmd}"
- body = f"cd {WORKSPACE_ROOT} && {pixi_part}"
- exports = _gui_env_exports()
- if exports:
- body = f"{exports}; {body}"
- return body
-
-def _tmux_new_pixi_window(window: str, user_cmd: str, remain_on_exit: bool = False) -> str:
- """Open a tmux window that runs user_cmd inside pixi run (tmux panes don't inherit pixi)."""
- inner = f"bash -lc {shlex.quote(_pixi_workspace_script(user_cmd))}"
- cmd = f"tmux new-window -d -t {TMUX_SESSION} -n {window} {inner}"
- if remain_on_exit:
- cmd += f"; tmux set-window-option -t {TMUX_SESSION}:{window} remain-on-exit on"
- return cmd
-
-def _complex_package_start(pkg) -> str:
- """Legacy complex {start,stop,is_running} entries — route through Pixi when possible."""
- if pkg.id == "control_panel":
- return _tmux_new_pixi_window("control_panel", "pixi run panel-dev", remain_on_exit=True)
- return pkg.command["start"]
-
-def run_shell_command(cmd, capture_output=False):
- if capture_output:
- return subprocess.run(cmd, shell=True, capture_output=True, text=True).returncode == 0
- else:
- subprocess.run(cmd, shell=True)
-
-def run_shell_command_async(cmd):
- """Fire a shell command without blocking the UI (daemon thread reaps the child).
-
- Used for stops so the TUI can show STOPPING while a slow shutdown runs."""
- def _target():
- try:
- subprocess.run(cmd, shell=True)
- except Exception:
- pass
- threading.Thread(target=_target, daemon=True).start()
-
-def _pane_exit_status(pkg_id):
- """Exit code of the package's dead tmux pane, or None if it isn't dead.
- remain-on-exit keeps the dead pane (and its output) so we can read the code:
- 0 is a clean exit (STOPPED), anything else (incl. signal death) a crash (CRASHED)."""
- out = subprocess.run(
- f"tmux list-panes -t {TMUX_SESSION}:{pkg_id} -F '#{{pane_dead}}:#{{pane_dead_status}}' 2>/dev/null",
- shell=True, capture_output=True, text=True,
- ).stdout
- for line in out.splitlines():
- dead, _, status = line.strip().partition(":")
- if dead == "1":
- try:
- return int(status)
- except ValueError:
- return -1 # signal death reports no status; treat as a crash
- return None
-
-class Package:
- def __init__(self, data, running_modifiers):
- self.id = data['id']
- self.name = data['name']
- self.description = data.get('description', '')
- self.type = data['type']
- self.dependencies = data.get('dependencies', [])
- self.conflicts = data.get('conflicts', [])
- self.command = data.get('command', '')
- self.lifecycle_hooks = data.get('lifecycle_hooks', {})
- self.selected = data.get('default_on', False)
- # Robot-description package this entry selects (e.g. inmoov_urdf). When set,
- # the entry is hidden unless that package is built, so only installed robots
- # appear in the mutually-exclusive selector.
- self.requires_pkg = data.get('requires_pkg')
- # Render with a deeper indent so it reads as a sub-option of its dependency
- # (e.g. headless under "... with Simulator").
- self.subitem = data.get('subitem', False)
- # Optional shell probe that exits 0 only once the package is truly up.
- # Without it, a package is considered "ready" the instant its window exists.
- self.readiness_check = data.get('readiness_check')
- self.readiness_timeout = data.get('readiness_timeout', LOADING_TIMEOUT)
- # Legacy config fields (VNC removed); kept for JSON compat, unused.
- self.runs_on_vnc = data.get('runs_on_vnc', False)
- self.display_switch = data.get('display_switch', False)
- # Access URL shown after [RUNNING] (e.g. control panel). May reference env
- # vars as ${VAR} — expanded at render time.
- self.url = data.get('url')
- # Navigation hint for non-web packages (e.g. "Ctrl-B W" for tmux windows).
- self.nav_hint = data.get('nav_hint', '')
-
- # is_running = window/process exists; ready = readiness probe passed;
- # pane_dead = window kept open (remain-on-exit) after its process exited.
- self.is_running = False
- self.ready = False
- self.pane_dead = False
- self.pane_exit_status = None
- self.update_running_status(running_modifiers)
-
- # Robot-package radios are mutually exclusive
- if self.type == 'modifier' and self.requires_pkg:
- self.selected = self.is_running
- # Reflect running state as ticked — but not while it is being stopped, so an
- # in-progress shutdown doesn't re-check the box the user just unticked.
- elif self.is_running and self.id not in _pkg_stop_times:
- self.selected = True
-
- def update_running_status(self, running_modifiers):
- if self.is_complex_command():
- self.is_running = run_shell_command(self.command['is_running'], capture_output=True)
- elif self.type == 'modifier':
- self.is_running = self.id in running_modifiers
- elif self.type == 'core':
- self.is_running = run_shell_command(f"tmux list-windows -F '#{{window_name}}' | grep -q '^{self.id}$'", capture_output=True)
- if not self.is_running:
- save_state({"modifiers": []})
- elif self.type in ['tool', 'interface']:
- self.is_running = run_shell_command(f"tmux list-windows -F '#{{window_name}}' | grep -q '^{self.id}$'", capture_output=True)
-
- # Derive readiness: only meaningful while the window/process exists.
- if not self.is_running:
- self.ready = False
- elif self.readiness_check:
- self.ready = run_shell_command(self.readiness_check, capture_output=True)
- else:
- self.ready = True
-
- # Window still up (remain-on-exit) but the process has exited.
- # The exit code distinguishes a clean stop from a crash (see get_pkg_status).
- self.pane_exit_status = _pane_exit_status(self.id) if self.is_running else None
- self.pane_dead = self.pane_exit_status is not None
-
- def is_complex_command(self):
- return isinstance(self.command, dict)
-
-def _env_enabled(var_name):
- """True when a package has no env gate, or its `requires_env` var is truthy.
-
- Optional packages can gate on an env var via `requires_env` in launcher_config."""
- if not var_name:
- return True
- return os.environ.get(var_name, "").strip().lower() in ("1", "true", "yes")
-
-
-def _ros_pkg_installed(pkg_name):
- """True when a ROS package is built in the workspace overlay (install/).
-
- Used to gate the robot-package selector entries so only robots that are
- actually built show up — mirrors lucy.launch.py's runtime discovery."""
- if not pkg_name:
- return True
- return (WORKSPACE_ROOT / "install" / pkg_name).is_dir()
-
-def _pkg_visible(pkg_config, dev_mode):
- """Whether a package appears in the launcher: hidden when it is `dev_only` and
- Developer Mode is off, when its `requires_env` gate isn't satisfied, or when a
- `requires_pkg` robot package isn't built."""
- if pkg_config.get('dev_only') and not dev_mode:
- return False
- if not _ros_pkg_installed(pkg_config.get('requires_pkg')):
- return False
- return _env_enabled(pkg_config.get('requires_env'))
-
-class LauncherState:
- def __init__(self, config_data):
- running_state = load_state()
- # Hide gated packages before building them, so their readiness probes don't run and they don't render.
- dev_mode = get_dev_mode()
- package_configs = [
- p for p in config_data['packages'] if _pkg_visible(p, dev_mode)
- ]
- self.packages = [Package(p, running_state['modifiers']) for p in package_configs]
- self.package_map = {p.id: p for p in self.packages}
-
- def get_by_id(self, pkg_id):
- return self.package_map.get(pkg_id)
-
- def refresh_status(self):
- """Re-probe running/ready state for all packages without touching selected.
-
- Used by the poll timer so in-flight user tick changes aren't wiped out
- between keypresses (LauncherState.__init__ resets selected to default_on)."""
- running_state = load_state()
- for pkg in self.packages:
- pkg.update_running_status(running_state['modifiers'])
-
- def _enable(self, pkg):
- """Tick a package, clearing anything it conflicts with first."""
- for conflict_id in pkg.conflicts:
- conflict_pkg = self.get_by_id(conflict_id)
- if conflict_pkg and conflict_pkg.selected:
- conflict_pkg.selected = False
- pkg.selected = True
-
- def _enable_with_deps(self, pkg):
- """Tick a package and any of its (transitive) dependencies that are off, so a
- sub-option pulls in its parent (e.g. headless ticks the simulator)."""
- for dep_id in pkg.dependencies:
- dep = self.get_by_id(dep_id)
- if dep and not dep.selected:
- self._enable_with_deps(dep)
- self._enable(pkg)
-
- def _disable_with_dependents(self, pkg):
- """Untick a package and any (transitive) dependents, so turning off a parent
- also turns off its sub-options (e.g. unticking core drops the simulator and
- headless together rather than leaving them orphaned)."""
- for other_pkg in self.packages:
- if pkg.id in other_pkg.dependencies and other_pkg.selected:
- self._disable_with_dependents(other_pkg)
- pkg.selected = False
-
- def toggle(self, pkg_id):
- pkg = self.get_by_id(pkg_id)
- if not pkg:
- return None
- if pkg_id in _pkg_stop_times and not pkg.selected:
- return "Still stopping…"
- if not pkg.selected:
- # Ticking any option auto-enables its (transitive) dependencies instead
- # of being blocked — e.g. the simulator/RViz/a robot pulls in core, and
- # headless pulls in the simulator.
- self._enable_with_deps(pkg)
- else:
- self._disable_with_dependents(pkg)
- return None
-
-def get_pkg_status(pkg):
- """Return one of: running, loading, crashed, stopped.
-
- ``pkg.is_running`` means the tmux window / process merely exists; ``pkg.ready``
- means its readiness probe passed (the stack is actually up). A package we
- started (in ``_intended_running``) that isn't ready yet shows LOADING until its
- timeout elapses, after which it is reported CRASHED. A package being shut down
- (in ``_pkg_stop_times``) shows STOPPING until its process is gone.
- """
- if pkg.id in _pkg_stop_times:
- if not pkg.is_running:
- _pkg_stop_times.pop(pkg.id, None)
- return "stopped"
- if time.time() - _pkg_stop_times[pkg.id] < STOPPING_TIMEOUT:
- return "stopping"
- _pkg_stop_times.pop(pkg.id, None) # gave up; fall through to real state
- # Window left open by an exited process (remain-on-exit).
- # Reported right away so the output can be read in tmux: exit 0 is a clean stop, else a crash.
- if pkg.pane_dead:
- _pkg_start_times.pop(pkg.id, None)
- if pkg.pane_exit_status == 0:
- _intended_running.discard(pkg.id)
- return "stopped"
- return "crashed"
- if pkg.ready:
- _pkg_start_times.pop(pkg.id, None)
- return "running"
- if pkg.id in _intended_running:
- timeout = getattr(pkg, "readiness_timeout", LOADING_TIMEOUT)
- started = _pkg_start_times.get(pkg.id)
- if started is None:
- if pkg.is_running:
- _pkg_start_times[pkg.id] = time.time()
- return "loading"
- return "crashed"
- if time.time() - started < timeout:
- return "loading"
- _pkg_start_times.pop(pkg.id, None)
- return "crashed"
- return "stopped"
-
-def _has_unapplied_changes(state):
- for pkg in state.packages:
- if pkg.id in _pkg_start_times or pkg.id in _pkg_stop_times:
- continue
- if pkg.selected != pkg.is_running:
- return True
- return False
-
-def _nav_hint(pkg):
- """Navigation hint for packages without a web URL (e.g. tmux terminal windows)."""
- if not pkg.nav_hint or not pkg.is_running:
- return ""
- return f"({pkg.nav_hint})"
-
-def _status_url(pkg):
- """Expanded access URL for a package, or '' if it has none / an env var in it
- is unset (so we never show a half-resolved 'localhost:${...}')."""
- if not pkg.url:
- return ""
- expanded = os.path.expandvars(pkg.url)
- if "${" in expanded or expanded.endswith(":"):
- return ""
- return expanded
-
-def _draw_pkg_row(stdscr, y, x, prefix, indent, checkbox, name, attr, status, hint="", url=""):
- base = f"{prefix}{indent}{checkbox} {name}"
- stdscr.addstr(y, x, base, attr)
- col = x + len(base)
- labels = {
- "running": (" [RUNNING]", curses.color_pair(4)),
- "loading": (" [LOADING]", curses.color_pair(1)),
- "stopping": (" [STOPPING]", curses.color_pair(1)),
- "crashed": (" [CRASHED]", curses.color_pair(2) | curses.A_BOLD),
- "stopped": (" [STOPPED]", curses.A_DIM),
- }
- status_str, status_attr = labels.get(status, (" [STOPPED]", curses.A_DIM))
- try:
- stdscr.addstr(y, col, status_str, status_attr)
- col += len(status_str)
- except curses.error:
- pass
- # Access URL after the status, shown only while actually running.
- if url and status == "running":
- text = f" ({url})"
- try:
- stdscr.addstr(y, col, text, curses.color_pair(3)) # cyan
- col += len(text)
- except curses.error:
- pass
- # Navigation hint after the status label (e.g. Ctrl-B W).
- if hint:
- text = f" {hint}"
- try:
- stdscr.addstr(y, col, text, curses.color_pair(3)) # cyan
- col += len(text)
- except curses.error:
- pass
-
-def draw_too_small_message(stdscr):
- h, w = stdscr.getmaxyx()
- stdscr.clear()
- message = "Please increase terminal size"
- message2 = f"({MIN_TERM_WIDTH}x{MIN_TERM_HEIGHT} required)"
- stdscr.addstr(h // 2 - 1, max(0, (w - len(message)) // 2), message, curses.A_BOLD)
- stdscr.addstr(h // 2, max(0, (w - len(message2)) // 2), message2, curses.A_DIM)
- stdscr.refresh()
-
-def draw_tui(stdscr, state, current_idx, error_msg, status_msg, unapplied=False):
- h, w = stdscr.getmaxyx()
- if h < MIN_TERM_HEIGHT or w < MIN_TERM_WIDTH:
- draw_too_small_message(stdscr)
- return None
-
- stdscr.clear()
- title = "Lucy Control Center"
- stdscr.addstr(0, max(0, (w - len(title)) // 2), title, curses.A_BOLD)
- stdscr.addstr(h - 1, 2, "Enter: Apply | Space: Toggle | X: Stop All & Exit", curses.A_BOLD)
-
- if status_msg:
- stdscr.addstr(h - 2, 2, status_msg, curses.A_BOLD)
- elif error_msg:
- stdscr.addstr(h - 2, 2, f"Warning: {error_msg}", curses.color_pair(2))
- elif unapplied:
- stdscr.addstr(h - 2, 2, "Unapplied changes — press Enter to apply", curses.color_pair(1))
-
- # Robot-package selectors are modifiers (their command is appended to the core
- # launch), but get their own section so the robot choice reads as a distinct
- # group rather than another core toggle.
- robots = [p for p in state.packages if p.type == 'modifier' and p.requires_pkg]
- cores_and_mods = [p for p in state.packages if p.type in ['core', 'modifier'] and not p.requires_pkg]
- interfaces = [p for p in state.packages if p.type == 'interface']
- tools = [p for p in state.packages if p.type == 'tool']
- display_list = cores_and_mods + robots + interfaces + tools
-
- def draw_section(title, color, items, offset, gap=1, indent_all=False):
- nonlocal row
- stdscr.addstr(row, 2, title, curses.A_BOLD | color)
- row += gap
- for i, p in enumerate(items):
- list_idx = offset + i
- prefix = "> " if current_idx == list_idx else " "
- checkbox = "[x]" if p.selected else "[ ]"
- can_enable = all(state.get_by_id(dep).selected for dep in p.dependencies)
- attr = curses.A_NORMAL if can_enable else curses.A_DIM
- if p.type == 'core':
- attr |= curses.A_BOLD
- if p.subitem:
- indent = " "
- elif indent_all or p.type == 'modifier':
- indent = " "
- else:
- indent = ""
- status = get_pkg_status(p)
- hint = _nav_hint(p)
- _draw_pkg_row(stdscr, row + i, 4, prefix, indent, checkbox, p.name, attr,
- status, hint, _status_url(p))
- row += len(items) + 1
-
- row = 2
- draw_section("Primary Launch Targets", curses.color_pair(1), cores_and_mods, 0, gap=2)
- offset = len(cores_and_mods)
- if robots:
- draw_section("Robot", curses.color_pair(1), robots, offset, gap=1, indent_all=True)
- offset += len(robots)
- draw_section("Interfaces", curses.color_pair(3), interfaces, offset, gap=1)
- offset += len(interfaces)
- draw_section("Tools", curses.color_pair(3), tools, offset, gap=1)
-
- stdscr.refresh()
- return display_list
-
-def apply_changes(state):
- last_launched_window = None
- core_pkg = state.get_by_id('core')
-
- # Check if core modifiers have changed
- modifiers_changed = False
- if core_pkg and core_pkg.selected:
- selected_modifier_ids = set(p.id for p in state.packages if p.type == 'modifier' and p.selected)
- running_modifier_ids = set(p.id for p in state.packages if p.type == 'modifier' and p.is_running)
- if selected_modifier_ids != running_modifier_ids:
- modifiers_changed = True
-
- # Force core restart if it's selected but modifiers changed. Tear down
- # synchronously (kills gz sim / RViz, not just the window) so the old sim is
- # gone before the new core launches.
- if modifiers_changed and core_pkg and core_pkg.selected:
- run_shell_command(CORE_TEARDOWN)
- save_state({"modifiers": []})
- core_pkg.is_running = False
- _pkg_start_times.pop('core', None)
- _intended_running.discard('core')
- for mod in state.packages:
- if mod.type == 'modifier':
- if mod.is_running and 'stop' in mod.lifecycle_hooks:
- run_shell_command(mod.lifecycle_hooks['stop'])
- mod.is_running = False
- _pkg_start_times.pop(mod.id, None)
- _intended_running.discard(mod.id)
-
- # First Pass: Stop processes that should be turned off (or were forced off).
- # Stops run asynchronously so the TUI stays responsive and can show STOPPING
- # while a slow shutdown runs; the package leaves STOPPING once its probe reports
- # it gone. A modifier with no stop action is just marked stopped (its teardown
- # happens via the core restart above).
- for pkg in state.packages:
- if not pkg.selected and pkg.is_running:
- stopping = True
- if pkg.is_complex_command():
- run_shell_command_async(pkg.command['stop'])
- elif pkg.type == 'core':
- run_shell_command_async(CORE_TEARDOWN)
- save_state({"modifiers": []})
- for mod in state.packages:
- if mod.type == 'modifier':
- _pkg_start_times.pop(mod.id, None)
- _intended_running.discard(mod.id)
- elif pkg.type in ['tool', 'interface']:
- run_shell_command_async(f"tmux kill-window -t {TMUX_SESSION}:{pkg.id} 2>/dev/null")
- elif pkg.type == 'modifier' and 'stop' in pkg.lifecycle_hooks:
- run_shell_command_async(pkg.lifecycle_hooks['stop'])
- else:
- stopping = False
- _pkg_start_times.pop(pkg.id, None)
- _intended_running.discard(pkg.id)
- if stopping:
- _pkg_stop_times[pkg.id] = time.time()
- else:
- pkg.is_running = False
-
- # Second Pass: Start processes that should be turned on (never re-launch one that is still shutting down).
- # A crashed service (non-zero exit) is relaunched too, after reaping the dead window; a clean exit (STOPPED) is left alone.
- for pkg in state.packages:
- crashed = pkg.pane_dead and pkg.pane_exit_status != 0
- if pkg.selected and pkg.id not in _pkg_stop_times and (not pkg.is_running or crashed):
- if pkg.pane_dead:
- run_shell_command(f"tmux kill-window -t {TMUX_SESSION}:{pkg.id} 2>/dev/null")
- pkg.pane_dead = False
- pkg.is_running = False
- if pkg.is_complex_command():
- run_shell_command(_complex_package_start(pkg))
- # Keep a crashed service's window open with its error (see core).
- if pkg.readiness_check:
- run_shell_command(f"tmux set-window-option -t {TMUX_SESSION}:{pkg.id} remain-on-exit on 2>/dev/null")
- _pkg_start_times[pkg.id] = time.time()
- _intended_running.add(pkg.id)
- elif pkg.type == 'core':
- base_cmd = pkg.command
- selected_modifiers = [p for p in state.packages if p.type == 'modifier' and p.selected]
- modifier_args = [p.command for p in selected_modifiers]
- modifier_ids = [p.id for p in selected_modifiers]
- full_cmd = f"{base_cmd} {' '.join(modifier_args)}"
- run_shell_command(_tmux_new_pixi_window("core", full_cmd, remain_on_exit=True))
- save_state({"modifiers": modifier_ids})
- _pkg_start_times[pkg.id] = time.time()
- _intended_running.add(pkg.id)
- for mod in selected_modifiers:
- _pkg_start_times[mod.id] = time.time()
- _intended_running.add(mod.id)
- elif pkg.type in ['tool', 'interface']:
- if pkg.type == 'interface':
- run_shell_command(_tmux_new_pixi_window(pkg.id, pkg.command, remain_on_exit=True))
- else:
- run_shell_command(
- _tmux_new_pixi_window(
- pkg.id,
- f'{pkg.command}; echo "--- Process finished, press any key to close ---"; read',
- )
- )
- # Only auto-switch to tool windows (e.g. console), not interfaces which manage their own terminal visibility (lucy_cli, control_panel).
- if pkg.type == 'tool':
- last_launched_window = pkg.id
- _pkg_start_times[pkg.id] = time.time()
- _intended_running.add(pkg.id)
- pkg.is_running = True
-
- if last_launched_window:
- run_shell_command(f"tmux select-window -t {TMUX_SESSION}:{last_launched_window}")
-
-def restore_selection(state):
- """Pre-tick packages from the last applied selection (.lucy_launcher_state.json)."""
- saved = load_selection()
- if saved is None:
- return
- robots = [p for p in state.packages if p.requires_pkg]
- for pkg in state.packages:
- if pkg.requires_pkg:
- continue
- pkg.selected = pkg.id in saved
- # Robot radios: exactly one ticked when saved names a robot.
- chosen = next((p for p in robots if p.id in saved), None)
- if chosen is not None:
- for pkg in robots:
- pkg.selected = pkg is chosen
-
-def default_robot_selection(state):
- """Auto-tick a robot-package modifier when none is selected yet (mirrors
- lucy.launch.py: sole installed robot, or inmoov_urdf when several are built).
- Gated on core being selected so it can be applied alongside core."""
- core = state.get_by_id('core')
- robots = [p for p in state.packages if p.requires_pkg]
- if not robots or not (core and core.selected):
- return
- if any(p.selected for p in robots):
- return
- if len(robots) == 1:
- robots[0].selected = True
- return
- inmoov = state.get_by_id('robot_inmoov')
- if inmoov:
- inmoov.selected = True
-
-def main(stdscr):
- curses.curs_set(0)
- stdscr.nodelay(0)
- stdscr.timeout(-1)
- curses.start_color()
- curses.use_default_colors()
-
- if curses.has_colors():
- curses.init_pair(1, curses.COLOR_YELLOW, -1)
- curses.init_pair(2, curses.COLOR_RED, -1)
- curses.init_pair(3, curses.COLOR_CYAN, -1)
- curses.init_pair(4, curses.COLOR_GREEN, -1)
-
- state = LauncherState(load_config())
- restore_selection(state)
- default_robot_selection(state)
- current_idx = 0
- error_msg = None
- status_msg = None
- status_msg_until = 0.0
-
- if not get_dev_mode():
- # Production: always ensure core + control panel, then start everything
- # selected (including any restored selection).
- core_pkg = state.get_by_id('core')
- lcp_pkg = state.get_by_id('control_panel')
- if core_pkg:
- core_pkg.selected = True
- if lcp_pkg:
- lcp_pkg.selected = True
- default_robot_selection(state)
- apply_changes(state)
- save_selection({p.id for p in state.packages if p.selected})
- status_msg = "Starting default services for production mode..."
- status_msg_until = time.time() + 3.0
- state.refresh_status()
-
- while True:
- try:
- if status_msg and time.time() >= status_msg_until:
- status_msg = None
- display_list = draw_tui(stdscr, state, current_idx, error_msg, status_msg, _has_unapplied_changes(state))
- error_msg = None
-
- if display_list is None:
- # If display_list is None, it means the screen is too small.
- # We switch to non-blocking getch to poll for resize events
- stdscr.nodelay(1)
- stdscr.timeout(100)
- key = stdscr.getch()
- if key != curses.KEY_RESIZE:
- time.sleep(0.1)
- continue
- else:
- # Poll fast while something is still coming up, slow once everything
- # we launched is up (so a later crash still surfaces), and block
- # entirely when nothing is running. Re-read state on each tick.
- if _pkg_start_times or _pkg_stop_times:
- poll_ms = 1000
- elif _intended_running:
- poll_ms = 5000
- else:
- poll_ms = None
- if poll_ms is None:
- stdscr.nodelay(0)
- stdscr.timeout(-1)
- else:
- stdscr.nodelay(1)
- stdscr.timeout(poll_ms)
- key = stdscr.getch()
- if key == -1:
- state.refresh_status()
- continue
-
- if key == curses.KEY_RESIZE:
- continue
-
- if key == curses.KEY_UP:
- current_idx = (current_idx - 1) % len(display_list)
- elif key == curses.KEY_DOWN:
- current_idx = (current_idx + 1) % len(display_list)
- elif key == ord(' '):
- pkg_to_toggle = display_list[current_idx]
- error_msg = state.toggle(pkg_to_toggle.id)
- elif key == ord('\n'):
- apply_changes(state)
- save_selection({p.id for p in state.packages if p.selected})
- status_msg = "Configuration Applied!"
- status_msg_until = time.time() + 2.0
- state.refresh_status()
- elif key in [ord('x'), ord('X')]:
- h, w = stdscr.getmaxyx()
- stdscr.addstr(h - 2, 2, "Stop all processes and exit? (y/n)", curses.A_BOLD | curses.color_pair(2))
- stdscr.refresh()
- confirm_key = stdscr.getch()
- if confirm_key in [ord('y'), ord('Y')]:
- return "ExitWorkspace", state
- elif key in [ord('q'), ord('Q'), 27]:
- return "Quit", None
-
- except curses.error:
- # This will catch errors from addstr if the window is resized
- # between the size check and the drawing.
- time.sleep(0.1)
- continue
+from launcher.__main__ import run
if __name__ == "__main__":
- if curses is None:
- print("Error: launcher TUI requires curses (not available on this platform).", file=sys.stderr)
- sys.exit(1)
- load_workspace_env()
- if needs_tmux_session() and not is_in_tmux():
- print(f"Error: launcher.py must run inside the {TMUX_SESSION} tmux session (./launch_lucy.sh).", file=sys.stderr)
- sys.exit(1)
- os.chdir(WORKSPACE_ROOT)
-
- status, state = None, None
- try:
- status, state = curses.wrapper(main)
- except Exception as e:
- # Clean up curses on any exception
- curses.endwin()
- print(f"An unexpected error occurred: {e}", file=sys.stderr)
- sys.exit(1)
-
- if status == "ExitWorkspace":
- print("\nStopping all processes and exiting workspace...")
- if state:
- for pkg in state.packages:
- if pkg.is_complex_command():
- run_shell_command(pkg.command['stop'])
- elif 'stop' in pkg.lifecycle_hooks:
- run_shell_command(pkg.lifecycle_hooks['stop'])
- if STATE_FILE.is_file():
- STATE_FILE.unlink()
- if needs_tmux_session():
- print("Terminating tmux session...")
- time.sleep(0.5)
- run_shell_command(f"tmux kill-session -t {TMUX_SESSION} 2>/dev/null")
- else:
- pass
+ run()
diff --git a/launcher/__init__.py b/launcher/__init__.py
new file mode 100644
index 0000000..8681656
--- /dev/null
+++ b/launcher/__init__.py
@@ -0,0 +1,185 @@
+"""Lucy workspace launcher — modular package with backward-compatible exports."""
+
+from .apply import apply_changes, default_robot_selection, restore_selection, stop_all_packages
+from .config import (
+ get_dev_mode,
+ load_config,
+ load_selection,
+ load_state,
+ load_workspace_env,
+ save_selection,
+ save_state,
+)
+from .constants import (
+ CONFIG_DIR,
+ DEFAULT_CONFIG_FILE,
+ LOADING_TIMEOUT,
+ LOCAL_CONFIG_FILE,
+ LUCY_WS_MARKER,
+ MIN_TERM_HEIGHT,
+ MIN_TERM_WIDTH,
+ NIX_GL_ENV_SCRIPT,
+ SELECTION_FILE,
+ STATE_FILE,
+ STOPPING_TIMEOUT,
+ TMUX_SESSION,
+ WORKSPACE_ROOT,
+ _CONTROL_PANEL_DIR,
+ _GUI_ENV_KEYS,
+ _ORPHAN_CLEANUP_DEBOUNCE,
+ _PIXI_ENV_MARKER,
+ _norm_path,
+)
+from .package import Package, _env_enabled, _pkg_visible, _ros_pkg_installed
+from .platform import (
+ _path_in_text,
+ _process_workspace_markers,
+ _read_proc_cwd,
+ _read_proc_environ,
+ _read_proc_environ_darwin,
+ _read_proc_exe,
+ path_in_text,
+ process_workspace_markers,
+ read_proc_cwd,
+ read_proc_environ,
+ read_proc_exe,
+)
+from .process import (
+ _child_pids,
+ _finish_teardown,
+ _in_lucy_workspace,
+ _is_gz_sim_cmdline,
+ _is_vite_orphan,
+ _iter_processes,
+ _kill_pid,
+ _kill_process_tree,
+ _matches_orphan_signature,
+ _schedule_orphan_cleanup,
+ cleanup_lucy_orphan_processes,
+ find_lucy_orphan_pids,
+ is_lucy_orphan,
+ is_lucy_orphan_cmdline,
+ set_orphan_preserve_windows,
+ wait_for_orphans_gone,
+)
+from .shell import (
+ _complex_package_start,
+ _gui_env_exports,
+ _nix_gl_source,
+ _pane_exit_status,
+ _pixi_workspace_script,
+ _tmux_new_pixi_window,
+ run_shell_command,
+ run_shell_command_async,
+ run_teardown_async,
+)
+from .state import (
+ LauncherState,
+ _has_unapplied_changes,
+ _intended_running,
+ _nav_hint,
+ _pkg_start_times,
+ _pkg_stop_times,
+ _status_url,
+ get_pkg_status,
+)
+from .tmux import (
+ CORE_TEARDOWN,
+ _core_teardown_shell,
+ _stop_core_tmux,
+ _stop_tmux_window,
+ _window_teardown_shell,
+ is_in_tmux,
+ needs_tmux_session,
+)
+from .tui import draw_too_small_message, draw_tui, main
+
+__all__ = [
+ "apply_changes",
+ "cleanup_lucy_orphan_processes",
+ "CORE_TEARDOWN",
+ "CONFIG_DIR",
+ "DEFAULT_CONFIG_FILE",
+ "draw_too_small_message",
+ "draw_tui",
+ "find_lucy_orphan_pids",
+ "get_dev_mode",
+ "get_pkg_status",
+ "is_in_tmux",
+ "is_lucy_orphan",
+ "is_lucy_orphan_cmdline",
+ "LauncherState",
+ "LOADING_TIMEOUT",
+ "LOCAL_CONFIG_FILE",
+ "LUCY_WS_MARKER",
+ "load_config",
+ "load_selection",
+ "load_state",
+ "load_workspace_env",
+ "main",
+ "MIN_TERM_HEIGHT",
+ "MIN_TERM_WIDTH",
+ "needs_tmux_session",
+ "NIX_GL_ENV_SCRIPT",
+ "Package",
+ "path_in_text",
+ "process_workspace_markers",
+ "read_proc_cwd",
+ "read_proc_environ",
+ "read_proc_exe",
+ "restore_selection",
+ "default_robot_selection",
+ "run_shell_command",
+ "run_shell_command_async",
+ "run_teardown_async",
+ "save_selection",
+ "save_state",
+ "SELECTION_FILE",
+ "set_orphan_preserve_windows",
+ "STATE_FILE",
+ "STOPPING_TIMEOUT",
+ "stop_all_packages",
+ "TMUX_SESSION",
+ "wait_for_orphans_gone",
+ "WORKSPACE_ROOT",
+ "_CONTROL_PANEL_DIR",
+ "_GUI_ENV_KEYS",
+ "_ORPHAN_CLEANUP_DEBOUNCE",
+ "_PIXI_ENV_MARKER",
+ "_child_pids",
+ "_complex_package_start",
+ "_core_teardown_shell",
+ "_env_enabled",
+ "_finish_teardown",
+ "_gui_env_exports",
+ "_has_unapplied_changes",
+ "_in_lucy_workspace",
+ "_intended_running",
+ "_is_gz_sim_cmdline",
+ "_is_vite_orphan",
+ "_iter_processes",
+ "_kill_pid",
+ "_kill_process_tree",
+ "_matches_orphan_signature",
+ "_nav_hint",
+ "_nix_gl_source",
+ "_norm_path",
+ "_pane_exit_status",
+ "_path_in_text",
+ "_pixi_workspace_script",
+ "_pkg_start_times",
+ "_pkg_stop_times",
+ "_pkg_visible",
+ "_process_workspace_markers",
+ "_read_proc_cwd",
+ "_read_proc_environ",
+ "_read_proc_environ_darwin",
+ "_read_proc_exe",
+ "_ros_pkg_installed",
+ "_schedule_orphan_cleanup",
+ "_status_url",
+ "_stop_core_tmux",
+ "_stop_tmux_window",
+ "_tmux_new_pixi_window",
+ "_window_teardown_shell",
+]
diff --git a/launcher/__main__.py b/launcher/__main__.py
new file mode 100644
index 0000000..6699f7f
--- /dev/null
+++ b/launcher/__main__.py
@@ -0,0 +1,63 @@
+"""Entry point for `python -m launcher`."""
+
+import os
+import sys
+
+try:
+ import curses
+except ImportError:
+ curses = None
+
+from .config import load_config, load_workspace_env
+from .constants import STATE_FILE, TMUX_SESSION, WORKSPACE_ROOT
+from .apply import stop_all_packages
+from .shell import run_shell_command
+from .state import LauncherState
+from .tmux import is_in_tmux, needs_tmux_session
+from .tui import main
+
+
+def run():
+ if curses is None:
+ print(
+ "Error: launcher TUI requires curses (not available on this platform).",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+ load_workspace_env()
+ if needs_tmux_session() and not is_in_tmux():
+ print(
+ f"Error: launcher must run inside the {TMUX_SESSION} tmux session (./launch_lucy.sh).",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+ os.chdir(WORKSPACE_ROOT)
+
+ status, state = None, None
+ try:
+ status, state = curses.wrapper(main)
+ except KeyboardInterrupt:
+ curses.endwin()
+ print("\nStopping all processes and exiting workspace...")
+ status = "ExitWorkspace"
+ try:
+ state = LauncherState(load_config())
+ except Exception:
+ state = None
+ except Exception as e:
+ curses.endwin()
+ print(f"An unexpected error occurred: {e}", file=sys.stderr)
+ sys.exit(1)
+
+ if status == "ExitWorkspace":
+ print("\nStopping all processes and exiting workspace...")
+ stop_all_packages(state)
+ if STATE_FILE.is_file():
+ STATE_FILE.unlink()
+ if needs_tmux_session():
+ print("Terminating tmux session...")
+ run_shell_command(f"tmux kill-session -t {TMUX_SESSION} 2>/dev/null")
+
+
+if __name__ == "__main__":
+ run()
diff --git a/launcher/apply.py b/launcher/apply.py
new file mode 100644
index 0000000..b0abb11
--- /dev/null
+++ b/launcher/apply.py
@@ -0,0 +1,226 @@
+"""Apply package selection changes and full teardown."""
+
+import time
+
+from .config import load_selection, save_selection
+from .constants import TMUX_SESSION
+from .shell import (
+ run_shell_command_async,
+ run_teardown_async,
+)
+from .state import (
+ _intended_running,
+ _pkg_start_times,
+ _pkg_stop_times,
+)
+
+
+def _package_needs_vite_preserve(pkg) -> bool:
+ """True when this package's readiness probe or command targets a Vite dev server."""
+ if pkg.readiness_check and "vite" in pkg.readiness_check.lower():
+ return True
+ if isinstance(pkg.command, str):
+ cmd = pkg.command
+ elif isinstance(pkg.command, dict):
+ cmd = str(pkg.command.get("start", ""))
+ else:
+ cmd = ""
+ cl = cmd.lower()
+ return "vite" in cl or "panel-dev" in cl
+
+
+def _orphan_preserve_from_state(state):
+ """Tmux windows and Vite protection for services that should survive a core restart."""
+ windows = set()
+ protect_vite = False
+ for pkg in state.packages:
+ if not pkg.selected or pkg.id == "core":
+ continue
+ if pkg.id in _pkg_stop_times:
+ continue
+ if not pkg.is_running:
+ continue
+ if pkg.type not in ("interface", "tool") and not pkg.is_complex_command():
+ continue
+ windows.add(pkg.id)
+ if _package_needs_vite_preserve(pkg):
+ protect_vite = True
+ return windows, protect_vite
+
+
+def stop_all_packages(state):
+ """Synchronously tear down every running package and orphan-prone processes."""
+ import launcher
+
+ launcher.set_orphan_preserve_windows([], protect_vite=False)
+ if state:
+ state.refresh_status()
+ for pkg in state.packages:
+ if pkg.id == "core" or not pkg.is_running:
+ continue
+ if pkg.is_complex_command():
+ launcher.run_shell_command(pkg.command["stop"])
+ elif pkg.type in ("tool", "interface"):
+ launcher._stop_tmux_window(pkg.id)
+ elif pkg.type == "modifier" and "stop" in pkg.lifecycle_hooks:
+ launcher.run_shell_command(pkg.lifecycle_hooks["stop"])
+ core = state.get_by_id("core")
+ if core and core.is_running:
+ launcher._stop_core_tmux()
+ launcher.save_state({"modifiers": []})
+ launcher._finish_teardown()
+
+
+def apply_changes(state):
+ import launcher
+
+ preserve_windows, protect_vite = _orphan_preserve_from_state(state)
+ launcher.set_orphan_preserve_windows(preserve_windows, protect_vite=protect_vite)
+
+ last_launched_window = None
+ core_pkg = state.get_by_id("core")
+
+ modifiers_changed = False
+ if core_pkg and core_pkg.selected:
+ selected_modifier_ids = set(
+ p.id for p in state.packages if p.type == "modifier" and p.selected
+ )
+ running_modifier_ids = set(
+ p.id for p in state.packages if p.type == "modifier" and p.is_running
+ )
+ if selected_modifier_ids != running_modifier_ids:
+ modifiers_changed = True
+
+ if modifiers_changed and core_pkg and core_pkg.selected:
+ launcher._stop_core_tmux()
+ launcher._finish_teardown(
+ preserve_package_windows=frozenset(preserve_windows),
+ protect_vite=protect_vite,
+ )
+ launcher.save_state({"modifiers": []})
+ core_pkg.is_running = False
+ _pkg_start_times.pop("core", None)
+ _intended_running.discard("core")
+ for mod in state.packages:
+ if mod.type == "modifier":
+ if mod.is_running and "stop" in mod.lifecycle_hooks:
+ launcher.run_shell_command(mod.lifecycle_hooks["stop"])
+ mod.is_running = False
+ _pkg_start_times.pop(mod.id, None)
+ _intended_running.discard(mod.id)
+
+ for pkg in state.packages:
+ if not pkg.selected and pkg.is_running:
+ stopping = True
+ if pkg.is_complex_command():
+ launcher.run_shell_command_async(pkg.command["stop"], schedule_cleanup=True)
+ elif pkg.type == "core":
+ run_teardown_async(launcher._stop_core_tmux)
+ launcher.save_state({"modifiers": []})
+ for mod in state.packages:
+ if mod.type == "modifier":
+ _pkg_start_times.pop(mod.id, None)
+ _intended_running.discard(mod.id)
+ elif pkg.type in ("tool", "interface"):
+ run_teardown_async(lambda pid=pkg.id: launcher._stop_tmux_window(pid))
+ elif pkg.type == "modifier" and "stop" in pkg.lifecycle_hooks:
+ launcher.run_shell_command_async(pkg.lifecycle_hooks["stop"], schedule_cleanup=True)
+ else:
+ stopping = False
+ _pkg_start_times.pop(pkg.id, None)
+ _intended_running.discard(pkg.id)
+ if stopping:
+ _pkg_stop_times[pkg.id] = time.time()
+ else:
+ pkg.is_running = False
+
+ for pkg in state.packages:
+ crashed = pkg.pane_dead and pkg.pane_exit_status != 0
+ if pkg.selected and pkg.id not in _pkg_stop_times and (not pkg.is_running or crashed):
+ if pkg.pane_dead:
+ launcher.run_shell_command(
+ f"tmux kill-window -t {TMUX_SESSION}:{pkg.id} 2>/dev/null"
+ )
+ pkg.pane_dead = False
+ pkg.is_running = False
+ if pkg.is_complex_command():
+ launcher.run_shell_command(launcher._complex_package_start(pkg))
+ if pkg.readiness_check:
+ launcher.run_shell_command(
+ f"tmux set-window-option -t {TMUX_SESSION}:{pkg.id} remain-on-exit on 2>/dev/null"
+ )
+ _pkg_start_times[pkg.id] = time.time()
+ _intended_running.add(pkg.id)
+ elif pkg.type == "core":
+ base_cmd = pkg.command
+ selected_modifiers = [
+ p for p in state.packages if p.type == "modifier" and p.selected
+ ]
+ modifier_args = [p.command for p in selected_modifiers]
+ modifier_ids = [p.id for p in selected_modifiers]
+ full_cmd = f"{base_cmd} {' '.join(modifier_args)}"
+ launcher.run_shell_command(
+ launcher._tmux_new_pixi_window("core", full_cmd, remain_on_exit=True)
+ )
+ launcher.save_state({"modifiers": modifier_ids})
+ _pkg_start_times[pkg.id] = time.time()
+ _intended_running.add(pkg.id)
+ for mod in selected_modifiers:
+ _pkg_start_times[mod.id] = time.time()
+ _intended_running.add(mod.id)
+ elif pkg.type in ("tool", "interface"):
+ if pkg.type == "interface":
+ launcher.run_shell_command(
+ launcher._tmux_new_pixi_window(
+ pkg.id, pkg.command, remain_on_exit=True
+ )
+ )
+ else:
+ launcher.run_shell_command(
+ launcher._tmux_new_pixi_window(
+ pkg.id,
+ f'{pkg.command}; echo "--- Process finished, press any key to close ---"; read',
+ )
+ )
+ if pkg.type == "tool":
+ last_launched_window = pkg.id
+ _pkg_start_times[pkg.id] = time.time()
+ _intended_running.add(pkg.id)
+ pkg.is_running = True
+
+ if last_launched_window:
+ launcher.run_shell_command(
+ f"tmux select-window -t {TMUX_SESSION}:{last_launched_window}"
+ )
+
+
+def restore_selection(state):
+ """Pre-tick packages from the last applied selection (.lucy_launcher_state.json)."""
+ saved = load_selection()
+ if saved is None:
+ return
+ robots = [p for p in state.packages if p.requires_pkg]
+ for pkg in state.packages:
+ if pkg.requires_pkg:
+ continue
+ pkg.selected = pkg.id in saved
+ chosen = next((p for p in robots if p.id in saved), None)
+ if chosen is not None:
+ for pkg in robots:
+ pkg.selected = pkg is chosen
+
+
+def default_robot_selection(state):
+ """Auto-tick a robot-package modifier when none is selected yet."""
+ core = state.get_by_id("core")
+ robots = [p for p in state.packages if p.requires_pkg]
+ if not robots or not (core and core.selected):
+ return
+ if any(p.selected for p in robots):
+ return
+ if len(robots) == 1:
+ robots[0].selected = True
+ return
+ inmoov = state.get_by_id("robot_inmoov")
+ if inmoov:
+ inmoov.selected = True
diff --git a/launcher/config.py b/launcher/config.py
new file mode 100644
index 0000000..2bd5227
--- /dev/null
+++ b/launcher/config.py
@@ -0,0 +1,91 @@
+"""Configuration, state persistence, and workspace environment loading."""
+
+import json
+import os
+
+from .constants import (
+ DEFAULT_CONFIG_FILE,
+ LOCAL_CONFIG_FILE,
+ SELECTION_FILE,
+ STATE_FILE,
+ WORKSPACE_ROOT,
+)
+
+
+def get_dev_mode():
+ env_path = WORKSPACE_ROOT / ".env"
+ if not os.path.exists(env_path):
+ return False
+ with open(env_path, "r") as f:
+ for line in f:
+ if line.strip().startswith("DEV="):
+ return line.strip().split("=")[1].lower() == "true"
+ return False
+
+
+def load_workspace_env():
+ """Load optional .env into os.environ (ports, GUI overrides, DEV=)."""
+ import launcher
+
+ env_path = launcher.WORKSPACE_ROOT / ".env"
+ if not env_path.exists():
+ return
+ with open(env_path, "r") as f:
+ for line in f:
+ line = line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, _, val = line.partition("=")
+ key = key.strip()
+ if not key:
+ continue
+ val = val.strip().strip('"').strip("'")
+ os.environ[key] = val
+
+
+def _launcher_config_path():
+ """config/launcher_config.json.local (gitignored) overrides the tracked file."""
+ return str(LOCAL_CONFIG_FILE if LOCAL_CONFIG_FILE.exists() else DEFAULT_CONFIG_FILE)
+
+
+def load_config():
+ config_path = _launcher_config_path()
+ if not os.path.exists(config_path):
+ raise FileNotFoundError(f"Configuration file not found at {config_path}")
+ with open(config_path, "r") as f:
+ return json.load(f)
+
+
+def load_state():
+ if not STATE_FILE.is_file():
+ return {"modifiers": []}
+ with open(STATE_FILE, "r") as f:
+ try:
+ return json.load(f)
+ except json.JSONDecodeError:
+ return {"modifiers": []}
+
+
+def save_state(state_data):
+ with open(STATE_FILE, "w") as f:
+ json.dump(state_data, f)
+
+
+def load_selection():
+ """Set of package ids the user last applied, or None if never saved."""
+ if not os.path.exists(SELECTION_FILE):
+ return None
+ try:
+ with open(SELECTION_FILE) as f:
+ return set(json.load(f).get("selected", []))
+ except (json.JSONDecodeError, OSError):
+ return None
+
+
+def save_selection(selected_ids):
+ """Persist the applied tick selection so it is restored on the next launch."""
+ try:
+ with open(SELECTION_FILE, "w") as f:
+ json.dump({"selected": sorted(selected_ids)}, f)
+ except OSError:
+ pass
diff --git a/launcher/constants.py b/launcher/constants.py
new file mode 100644
index 0000000..47d2027
--- /dev/null
+++ b/launcher/constants.py
@@ -0,0 +1,52 @@
+"""Workspace paths, timeouts, and markers for the Lucy launcher."""
+
+import os
+from pathlib import Path
+
+WORKSPACE_ROOT = Path(__file__).resolve().parent.parent
+CONFIG_DIR = WORKSPACE_ROOT / "config"
+DEFAULT_CONFIG_FILE = CONFIG_DIR / "launcher_config.json"
+LOCAL_CONFIG_FILE = CONFIG_DIR / "launcher_config.json.local"
+STATE_FILE = WORKSPACE_ROOT / ".lucy_launcher_modifiers.json"
+SELECTION_FILE = WORKSPACE_ROOT / ".lucy_launcher_state.json"
+TMUX_SESSION = os.environ.get("LUCY_TMUX_SESSION", "lucy_ws")
+MIN_TERM_HEIGHT = 22
+MIN_TERM_WIDTH = 65
+
+LOADING_TIMEOUT = 30 # seconds before LOADING transitions to CRASHED
+STOPPING_TIMEOUT = 30 # seconds to show STOPPING before giving up
+
+LUCY_WS_MARKER = str(WORKSPACE_ROOT)
+
+def _norm_path(s: str) -> str:
+ return s.replace("\\", "/")
+
+
+PIXI_ENV_MARKER = f"{_norm_path(LUCY_WS_MARKER)}/.pixi/"
+CONTROL_PANEL_DIR = f"{_norm_path(LUCY_WS_MARKER)}/src/lucy_control_panel"
+ORPHAN_CLEANUP_DEBOUNCE = 1.5
+
+NIX_GL_ENV_SCRIPT = WORKSPACE_ROOT / "scripts" / "nix_gl_env.sh"
+
+# Forward into tmux panes — GUI processes do not inherit the launcher session env.
+GUI_ENV_KEYS = (
+ "DISPLAY",
+ "WAYLAND_DISPLAY",
+ "XAUTHORITY",
+ "XDG_RUNTIME_DIR",
+ "QT_QPA_PLATFORM",
+ "QT_XCB_GL_INTEGRATION",
+ "LIBGL_ALWAYS_SOFTWARE",
+ "MESA_LOADER_DRIVER_OVERRIDE",
+ "LIBGL_DRIVERS_PATH",
+ "LD_LIBRARY_PATH",
+ "LD_PRELOAD",
+ "__EGL_VENDOR_LIBRARY_FILENAMES",
+ "GZ_IP",
+)
+
+# Back-compat aliases used by tests and internal modules.
+_PIXI_ENV_MARKER = PIXI_ENV_MARKER
+_CONTROL_PANEL_DIR = CONTROL_PANEL_DIR
+_ORPHAN_CLEANUP_DEBOUNCE = ORPHAN_CLEANUP_DEBOUNCE
+_GUI_ENV_KEYS = GUI_ENV_KEYS
diff --git a/launcher/package.py b/launcher/package.py
new file mode 100644
index 0000000..5be4489
--- /dev/null
+++ b/launcher/package.py
@@ -0,0 +1,96 @@
+"""Package model and visibility helpers."""
+
+import os
+
+from .constants import LOADING_TIMEOUT, WORKSPACE_ROOT
+from .config import save_state
+from .shell import run_shell_command, _pane_exit_status
+
+
+def _env_enabled(var_name):
+ """True when a package has no env gate, or its `requires_env` var is truthy."""
+ if not var_name:
+ return True
+ return os.environ.get(var_name, "").strip().lower() in ("1", "true", "yes")
+
+
+def _ros_pkg_installed(pkg_name):
+ """True when a ROS package is built in the workspace overlay (install/)."""
+ if not pkg_name:
+ return True
+ return (WORKSPACE_ROOT / "install" / pkg_name).is_dir()
+
+
+def _pkg_visible(pkg_config, dev_mode):
+ """Whether a package appears in the launcher."""
+ if pkg_config.get("dev_only") and not dev_mode:
+ return False
+ if not _ros_pkg_installed(pkg_config.get("requires_pkg")):
+ return False
+ return _env_enabled(pkg_config.get("requires_env"))
+
+
+class Package:
+ def __init__(self, data, running_modifiers):
+ self.id = data["id"]
+ self.name = data["name"]
+ self.description = data.get("description", "")
+ self.type = data["type"]
+ self.dependencies = data.get("dependencies", [])
+ self.conflicts = data.get("conflicts", [])
+ self.command = data.get("command", "")
+ self.lifecycle_hooks = data.get("lifecycle_hooks", {})
+ self.selected = data.get("default_on", False)
+ self.requires_pkg = data.get("requires_pkg")
+ self.subitem = data.get("subitem", False)
+ self.readiness_check = data.get("readiness_check")
+ self.readiness_timeout = data.get("readiness_timeout", LOADING_TIMEOUT)
+ self.runs_on_vnc = data.get("runs_on_vnc", False)
+ self.display_switch = data.get("display_switch", False)
+ self.url = data.get("url")
+ self.nav_hint = data.get("nav_hint", "")
+
+ self.is_running = False
+ self.ready = False
+ self.pane_dead = False
+ self.pane_exit_status = None
+ self.update_running_status(running_modifiers)
+
+ if self.type == "modifier" and self.requires_pkg:
+ self.selected = self.is_running
+ else:
+ from .state import _pkg_stop_times
+
+ if self.is_running and self.id not in _pkg_stop_times:
+ self.selected = True
+
+ def update_running_status(self, running_modifiers):
+ if self.is_complex_command():
+ self.is_running = run_shell_command(self.command["is_running"], capture_output=True)
+ elif self.type == "modifier":
+ self.is_running = self.id in running_modifiers
+ elif self.type == "core":
+ self.is_running = run_shell_command(
+ f"tmux list-windows -F '#{{window_name}}' | grep -q '^{self.id}$'",
+ capture_output=True,
+ )
+ if not self.is_running:
+ save_state({"modifiers": []})
+ elif self.type in ("tool", "interface"):
+ self.is_running = run_shell_command(
+ f"tmux list-windows -F '#{{window_name}}' | grep -q '^{self.id}$'",
+ capture_output=True,
+ )
+
+ if not self.is_running:
+ self.ready = False
+ elif self.readiness_check:
+ self.ready = run_shell_command(self.readiness_check, capture_output=True)
+ else:
+ self.ready = True
+
+ self.pane_exit_status = _pane_exit_status(self.id) if self.is_running else None
+ self.pane_dead = self.pane_exit_status is not None
+
+ def is_complex_command(self):
+ return isinstance(self.command, dict)
diff --git a/launcher/platform.py b/launcher/platform.py
new file mode 100644
index 0000000..78b2c26
--- /dev/null
+++ b/launcher/platform.py
@@ -0,0 +1,181 @@
+"""Platform-specific process introspection (Linux, macOS, Windows)."""
+
+import os
+import subprocess
+import sys
+
+from .constants import (
+ LUCY_WS_MARKER,
+ PIXI_ENV_MARKER,
+ WORKSPACE_ROOT,
+ _norm_path,
+)
+
+
+def path_in_text(text: str) -> bool:
+ if not text:
+ return False
+ return _norm_path(LUCY_WS_MARKER) in _norm_path(text)
+
+
+def _path_in_text(text: str) -> bool:
+ return path_in_text(text)
+
+
+def read_proc_cwd(pid: int) -> str:
+ if sys.platform == "win32":
+ return ""
+ if sys.platform == "darwin":
+ result = subprocess.run(
+ ["lsof", "-a", "-p", str(pid), "-d", "cwd"],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ for line in result.stdout.splitlines():
+ if " cwd " in line:
+ parts = line.split()
+ if parts:
+ return parts[-1]
+ return ""
+ try:
+ return os.readlink(f"/proc/{pid}/cwd")
+ except OSError:
+ return ""
+
+
+def _read_proc_cwd(pid: int) -> str:
+ return read_proc_cwd(pid)
+
+
+def read_proc_environ_darwin(pid: int) -> dict[str, str]:
+ """Parse env vars from ps eww by searching for KEY=… substrings (handles spaces in values)."""
+ result = subprocess.run(
+ ["ps", "eww", "-p", str(pid)],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ blob = result.stdout
+ if not blob.strip():
+ return {}
+ keys = (
+ "PIXI_PROJECT_MANIFEST",
+ "CONDA_PREFIX",
+ "GZ_SIM_RESOURCE_PATH",
+ "GZ_SIM_SYSTEM_PLUGIN_PATH",
+ "PWD",
+ "PIXI_PROJECT_NAME",
+ )
+ env = {}
+ for key in keys:
+ marker = f"{key}="
+ start = blob.find(marker)
+ if start == -1:
+ continue
+ val_start = start + len(marker)
+ end = len(blob)
+ for other in keys:
+ if other == key:
+ continue
+ pos = blob.find(f" {other}=", val_start)
+ if pos != -1:
+ end = min(end, pos)
+ env[key] = blob[val_start:end].strip()
+ return env
+
+
+def _read_proc_environ_darwin(pid: int) -> dict[str, str]:
+ return read_proc_environ_darwin(pid)
+
+
+def read_proc_environ(pid: int) -> dict[str, str]:
+ if sys.platform == "win32":
+ return {}
+ if sys.platform == "darwin":
+ return read_proc_environ_darwin(pid)
+ try:
+ with open(f"/proc/{pid}/environ", "rb") as f:
+ raw = f.read()
+ except OSError:
+ return {}
+ env = {}
+ for entry in raw.split(b"\0"):
+ if b"=" not in entry:
+ continue
+ key, _, val = entry.partition(b"=")
+ try:
+ env[key.decode()] = val.decode(errors="replace")
+ except UnicodeDecodeError:
+ continue
+ return env
+
+
+def _read_proc_environ(pid: int) -> dict[str, str]:
+ return read_proc_environ(pid)
+
+
+def read_proc_exe(pid: int) -> str:
+ if sys.platform == "win32":
+ return ""
+ if sys.platform == "darwin":
+ result = subprocess.run(
+ ["ps", "-p", str(pid), "-o", "comm="],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ return result.stdout.strip()
+ try:
+ return os.readlink(f"/proc/{pid}/exe")
+ except OSError:
+ return ""
+
+
+def _read_proc_exe(pid: int) -> str:
+ return read_proc_exe(pid)
+
+
+def process_workspace_markers(pid: int) -> bool:
+ """True when cwd, env, or binary ties a short-cmdline process to this workspace."""
+ import launcher
+
+ if pid <= 0:
+ return False
+ if sys.platform == "win32":
+ ws = LUCY_WS_MARKER.replace("'", "''")
+ script = (
+ f"$p = Get-CimInstance Win32_Process -Filter \"ProcessId={pid}\"; "
+ "if (-not $p) { exit 1 }; "
+ f"if ($p.ExecutablePath -like '*{ws}*') {{ exit 0 }}; "
+ f"if ($p.CommandLine -like '*{ws}*') {{ exit 0 }}; "
+ "exit 1"
+ )
+ result = subprocess.run(
+ ["powershell", "-NoProfile", "-Command", script],
+ capture_output=True,
+ check=False,
+ )
+ return result.returncode == 0
+ if path_in_text(launcher._read_proc_cwd(pid)):
+ return True
+ exe = launcher._read_proc_exe(pid)
+ if path_in_text(exe) or PIXI_ENV_MARKER in _norm_path(exe):
+ return True
+ env = launcher._read_proc_environ(pid)
+ for key in (
+ "PIXI_PROJECT_MANIFEST",
+ "CONDA_PREFIX",
+ "GZ_SIM_RESOURCE_PATH",
+ "GZ_SIM_SYSTEM_PLUGIN_PATH",
+ "PWD",
+ ):
+ if path_in_text(env.get(key, "")):
+ return True
+ if env.get("PIXI_PROJECT_NAME") == WORKSPACE_ROOT.name:
+ return True
+ return False
+
+
+def _process_workspace_markers(pid: int) -> bool:
+ return process_workspace_markers(pid)
diff --git a/launcher/process.py b/launcher/process.py
new file mode 100644
index 0000000..b3ce781
--- /dev/null
+++ b/launcher/process.py
@@ -0,0 +1,314 @@
+"""Orphan process detection, iteration, and cleanup."""
+
+import os
+import signal
+import subprocess
+import sys
+import threading
+import time
+
+from .constants import (
+ CONTROL_PANEL_DIR,
+ ORPHAN_CLEANUP_DEBOUNCE,
+ _norm_path,
+)
+from .platform import (
+ path_in_text,
+ process_workspace_markers,
+ read_proc_cwd,
+)
+
+_orphan_cleanup_timer = None
+_orphan_cleanup_lock = threading.Lock()
+_pending_preserve_windows: frozenset[str] = frozenset()
+_pending_protect_vite: bool = False
+
+
+def set_orphan_preserve_windows(package_window_ids, protect_vite=False):
+ """Package tmux window ids to keep alive during the next orphan cleanup pass."""
+ global _pending_preserve_windows, _pending_protect_vite
+ if package_window_ids:
+ _pending_preserve_windows = frozenset(package_window_ids)
+ else:
+ _pending_preserve_windows = frozenset()
+ _pending_protect_vite = protect_vite
+
+
+def _clear_orphan_preserve():
+ global _pending_preserve_windows, _pending_protect_vite
+ _pending_preserve_windows = frozenset()
+ _pending_protect_vite = False
+
+
+def _orphan_protected_by_preserve(
+ cmdline: str,
+ pid: int,
+ preserve_package_windows: frozenset[str],
+ protect_vite: bool,
+) -> bool:
+ """True when an orphan signature matches a package we are intentionally keeping."""
+ if not preserve_package_windows and not protect_vite:
+ return False
+ if protect_vite and _is_vite_orphan(cmdline, pid):
+ return True
+ return False
+
+
+def _is_gz_sim_cmdline(cmdline: str) -> bool:
+ return "gz sim" in cmdline.lower()
+
+
+def _matches_orphan_signature(cmdline: str) -> bool:
+ """Cheap cmdline pre-filter — avoids /proc reads for unrelated processes."""
+ if not cmdline:
+ return False
+ if (
+ "launcher.py" in cmdline
+ or "Lucy.py" in cmdline
+ or "-m launcher" in cmdline
+ or "launcher/__main__.py" in cmdline
+ or "launcher/__main__" in cmdline
+ ):
+ return False
+ cl = cmdline.lower()
+ if _is_gz_sim_cmdline(cmdline):
+ return True
+ if "rosbridge_websocket" in cmdline:
+ return True
+ if "lucy.launch.py" in cmdline:
+ return True
+ if "rviz2" in cmdline:
+ return True
+ if "vite" in cl:
+ return True
+ return False
+
+
+def _is_vite_orphan(cmdline: str, pid: int) -> bool:
+ import launcher
+
+ if "vite" not in cmdline.lower():
+ return False
+ if CONTROL_PANEL_DIR in _norm_path(cmdline):
+ return True
+ if pid > 0:
+ cwd = launcher._read_proc_cwd(pid)
+ if CONTROL_PANEL_DIR in _norm_path(cwd):
+ return True
+ return False
+
+
+def _in_lucy_workspace(cmdline: str, pid: int) -> bool:
+ import launcher
+
+ if path_in_text(cmdline):
+ return True
+ if pid <= 0:
+ return False
+ return launcher._process_workspace_markers(pid)
+
+
+def is_lucy_orphan(pid: int, cmdline: str) -> bool:
+ """True when this process should be reaped during Lucy shutdown."""
+ if not _matches_orphan_signature(cmdline):
+ return False
+ if not _in_lucy_workspace(cmdline, pid):
+ return False
+ if "vite" in cmdline.lower():
+ return _is_vite_orphan(cmdline, pid)
+ return True
+
+
+def is_lucy_orphan_cmdline(cmdline: str) -> bool:
+ """Cmdline-only check (when pid metadata is unavailable)."""
+ return is_lucy_orphan(0, cmdline) and path_in_text(cmdline)
+
+
+def _iter_processes():
+ """Yield (pid, command_line) for all processes (Linux, macOS, Windows)."""
+ if sys.platform == "win32":
+ script = (
+ "Get-CimInstance Win32_Process | "
+ "ForEach-Object { \"$($_.ProcessId)`t$($_.CommandLine)\" }"
+ )
+ result = subprocess.run(
+ ["powershell", "-NoProfile", "-Command", script],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ for line in result.stdout.splitlines():
+ if not line.strip():
+ continue
+ pid_str, _, cmdline = line.partition("\t")
+ try:
+ yield int(pid_str.strip()), cmdline.strip()
+ except ValueError:
+ continue
+ return
+ result = subprocess.run(
+ ["ps", "ax", "-o", "pid=,command="],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ for line in result.stdout.splitlines():
+ line = line.strip()
+ if not line:
+ continue
+ pid_str, _, cmdline = line.partition(" ")
+ try:
+ yield int(pid_str), cmdline.strip()
+ except ValueError:
+ continue
+
+
+def _child_pids(pid: int) -> list[int]:
+ if sys.platform == "win32":
+ return []
+ children = []
+ if sys.platform == "darwin":
+ result = subprocess.run(
+ ["ps", "-ax", "-o", "pid=,ppid="],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ for line in result.stdout.splitlines():
+ parts = line.split()
+ if len(parts) >= 2:
+ try:
+ cpid, ppid = int(parts[0]), int(parts[1])
+ if ppid == pid:
+ children.append(cpid)
+ except ValueError:
+ continue
+ return children
+ for entry in os.listdir("/proc"):
+ if not entry.isdigit():
+ continue
+ cpid = int(entry)
+ try:
+ with open(f"/proc/{cpid}/status") as f:
+ for line in f:
+ if line.startswith("PPid:"):
+ ppid = int(line.split()[1])
+ if ppid == pid:
+ children.append(cpid)
+ break
+ except OSError:
+ continue
+ return children
+
+
+def _kill_pid(pid: int):
+ if sys.platform == "win32":
+ subprocess.run(
+ ["taskkill", "/F", "/PID", str(pid), "/T"],
+ capture_output=True,
+ check=False,
+ )
+ return
+ try:
+ os.kill(pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ except PermissionError:
+ pass
+
+
+def _kill_process_tree(pid: int):
+ for child in _child_pids(pid):
+ _kill_process_tree(child)
+ _kill_pid(pid)
+
+
+def find_lucy_orphan_pids(
+ *,
+ exclude_pids=None,
+ preserve_package_windows=None,
+ protect_vite=False,
+):
+ """PIDs of workspace-scoped Lucy child processes (never the launcher itself)."""
+ exclude = {os.getpid(), os.getppid()}
+ if exclude_pids:
+ exclude.update(exclude_pids)
+ preserve = preserve_package_windows or frozenset()
+ return [
+ pid
+ for pid, cmdline in _iter_processes()
+ if pid not in exclude
+ and not _orphan_protected_by_preserve(cmdline, pid, preserve, protect_vite)
+ and is_lucy_orphan(pid, cmdline)
+ ]
+
+
+def cleanup_lucy_orphan_processes(
+ preserve_package_windows=None,
+ protect_vite=False,
+):
+ """Force-stop workspace-scoped orphan processes (cross-platform, scoped)."""
+ preserve = preserve_package_windows or frozenset()
+ for pid in find_lucy_orphan_pids(
+ preserve_package_windows=preserve,
+ protect_vite=protect_vite,
+ ):
+ _kill_process_tree(pid)
+
+
+def wait_for_orphans_gone(
+ timeout: float = 5.0,
+ poll: float = 0.25,
+ preserve_package_windows=None,
+ protect_vite=False,
+):
+ """Block until find_lucy_orphan_pids() is empty or timeout elapses."""
+ preserve = preserve_package_windows or frozenset()
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ if not find_lucy_orphan_pids(
+ preserve_package_windows=preserve,
+ protect_vite=protect_vite,
+ ):
+ return
+ time.sleep(poll)
+
+
+def _finish_teardown(preserve_package_windows=None, protect_vite=None):
+ explicit = preserve_package_windows is not None or protect_vite is not None
+ if explicit:
+ preserve = preserve_package_windows or frozenset()
+ vite_protect = protect_vite if protect_vite is not None else False
+ else:
+ preserve = _pending_preserve_windows
+ vite_protect = _pending_protect_vite
+ cleanup_lucy_orphan_processes(
+ preserve_package_windows=preserve,
+ protect_vite=vite_protect,
+ )
+ wait_for_orphans_gone(
+ timeout=10.0,
+ preserve_package_windows=preserve,
+ protect_vite=vite_protect,
+ )
+ cleanup_lucy_orphan_processes(
+ preserve_package_windows=preserve,
+ protect_vite=vite_protect,
+ )
+ if not explicit:
+ _clear_orphan_preserve()
+
+
+def _schedule_orphan_cleanup():
+ """Debounced orphan cleanup — coalesces parallel async stops into one pass."""
+ global _orphan_cleanup_timer
+
+ def _fire():
+ _finish_teardown()
+
+ with _orphan_cleanup_lock:
+ if _orphan_cleanup_timer is not None:
+ _orphan_cleanup_timer.cancel()
+ _orphan_cleanup_timer = threading.Timer(ORPHAN_CLEANUP_DEBOUNCE, _fire)
+ _orphan_cleanup_timer.daemon = True
+ _orphan_cleanup_timer.start()
diff --git a/launcher/shell.py b/launcher/shell.py
new file mode 100644
index 0000000..eba4e59
--- /dev/null
+++ b/launcher/shell.py
@@ -0,0 +1,123 @@
+"""Shell command execution, Pixi wrapping, and async runners."""
+
+import os
+import shlex
+import subprocess
+import threading
+
+from .constants import (
+ GUI_ENV_KEYS,
+ NIX_GL_ENV_SCRIPT,
+ TMUX_SESSION,
+ WORKSPACE_ROOT,
+)
+from .process import _schedule_orphan_cleanup
+
+
+def _gui_env_exports() -> str:
+ parts = []
+ for key in GUI_ENV_KEYS:
+ val = os.environ.get(key)
+ if val:
+ parts.append(f"export {key}={shlex.quote(val)}")
+ return "; ".join(parts)
+
+
+def _nix_gl_source() -> str:
+ """Source hook for NixOS: prepend host Mesa before Pixi conda GL (no-op elsewhere)."""
+ if os.environ.get("LUCY_NIX_GL", "auto").lower() in ("0", "false", "no", "off"):
+ return ""
+ if not NIX_GL_ENV_SCRIPT.is_file():
+ return ""
+ return f"source {shlex.quote(str(NIX_GL_ENV_SCRIPT))}; "
+
+
+def _pixi_workspace_script(user_cmd: str) -> str:
+ """Shell script body: workspace root + Pixi env (RoboStack + colcon overlay)."""
+ user_cmd = user_cmd.strip()
+ nix_gl = _nix_gl_source()
+ if user_cmd.startswith("pixi "):
+ pixi_part = user_cmd
+ elif nix_gl or any(op in user_cmd for op in (";", "&&", "||", "|", "&")):
+ pixi_part = f"pixi run -- bash -lc {shlex.quote(nix_gl + user_cmd)}"
+ elif user_cmd.startswith("ros2 "):
+ pixi_part = f"pixi run -- bash -lc {shlex.quote(nix_gl + user_cmd)}"
+ else:
+ pixi_part = f"pixi run -- {user_cmd}"
+ body = f"cd {WORKSPACE_ROOT} && {pixi_part}"
+ exports = _gui_env_exports()
+ if exports:
+ body = f"{exports}; {body}"
+ return body
+
+
+def _tmux_new_pixi_window(window: str, user_cmd: str, remain_on_exit: bool = False) -> str:
+ """Open a tmux window that runs user_cmd inside pixi run (tmux panes don't inherit pixi)."""
+ inner = f"bash -lc {shlex.quote(_pixi_workspace_script(user_cmd))}"
+ cmd = f"tmux new-window -d -t {TMUX_SESSION} -n {window} {inner}"
+ if remain_on_exit:
+ cmd += f"; tmux set-window-option -t {TMUX_SESSION}:{window} remain-on-exit on"
+ return cmd
+
+
+def _complex_package_start(pkg) -> str:
+ """Legacy complex {start,stop,is_running} entries — route through Pixi when possible."""
+ if pkg.id == "control_panel":
+ return _tmux_new_pixi_window("control_panel", "pixi run panel-dev", remain_on_exit=True)
+ return pkg.command["start"]
+
+
+def run_shell_command(cmd, capture_output=False):
+ if capture_output:
+ return subprocess.run(cmd, shell=True, capture_output=True, text=True).returncode == 0
+ subprocess.run(cmd, shell=True)
+
+
+def run_shell_command_async(cmd, *, schedule_cleanup=False):
+ """Fire a shell command without blocking the UI (daemon thread reaps the child).
+
+ Used for stops so the TUI can show STOPPING while a slow shutdown runs.
+ When schedule_cleanup is True, a debounced orphan cleanup runs afterward."""
+
+ def _target():
+ try:
+ if cmd:
+ subprocess.run(cmd, shell=True, check=False)
+ except Exception:
+ pass
+ if schedule_cleanup:
+ _schedule_orphan_cleanup()
+
+ threading.Thread(target=_target, daemon=True).start()
+
+
+def run_teardown_async(teardown_fn):
+ """Run a tmux stop callable without blocking the UI; debounced orphan cleanup after."""
+
+ def _target():
+ try:
+ teardown_fn()
+ finally:
+ _schedule_orphan_cleanup()
+
+ threading.Thread(target=_target, daemon=True).start()
+
+
+def _pane_exit_status(pkg_id):
+ """Exit code of the package's dead tmux pane, or None if it isn't dead.
+ remain-on-exit keeps the dead pane (and its output) so we can read the code:
+ 0 is a clean exit (STOPPED), anything else (incl. signal death) a crash (CRASHED)."""
+ out = subprocess.run(
+ f"tmux list-panes -t {TMUX_SESSION}:{pkg_id} -F '#{{pane_dead}}:#{{pane_dead_status}}' 2>/dev/null",
+ shell=True,
+ capture_output=True,
+ text=True,
+ ).stdout
+ for line in out.splitlines():
+ dead, _, status = line.strip().partition(":")
+ if dead == "1":
+ try:
+ return int(status)
+ except ValueError:
+ return -1 # signal death reports no status; treat as a crash
+ return None
diff --git a/launcher/state.py b/launcher/state.py
new file mode 100644
index 0000000..8fdc80b
--- /dev/null
+++ b/launcher/state.py
@@ -0,0 +1,127 @@
+"""Launcher state, package status tracking, and UI helpers."""
+
+import os
+import time
+
+from .constants import LOADING_TIMEOUT, STOPPING_TIMEOUT
+from .config import get_dev_mode, load_state
+from .package import Package, _pkg_visible
+
+
+_pkg_start_times = {} # pkg_id -> float, timestamp when start was issued
+_intended_running = set() # pkg_ids that should be running (for crash detection)
+_pkg_stop_times = {} # pkg_id -> float, timestamp when an async stop was issued
+
+
+class LauncherState:
+ def __init__(self, config_data):
+ running_state = load_state()
+ dev_mode = get_dev_mode()
+ package_configs = [
+ p for p in config_data["packages"] if _pkg_visible(p, dev_mode)
+ ]
+ self.packages = [Package(p, running_state["modifiers"]) for p in package_configs]
+ self.package_map = {p.id: p for p in self.packages}
+
+ def get_by_id(self, pkg_id):
+ return self.package_map.get(pkg_id)
+
+ def refresh_status(self):
+ """Re-probe running/ready state for all packages without touching selected."""
+ running_state = load_state()
+ for pkg in self.packages:
+ pkg.update_running_status(running_state["modifiers"])
+
+ def _enable(self, pkg):
+ """Tick a package, clearing anything it conflicts with first."""
+ for conflict_id in pkg.conflicts:
+ conflict_pkg = self.get_by_id(conflict_id)
+ if conflict_pkg and conflict_pkg.selected:
+ conflict_pkg.selected = False
+ pkg.selected = True
+
+ def _enable_with_deps(self, pkg):
+ """Tick a package and any of its (transitive) dependencies that are off."""
+ for dep_id in pkg.dependencies:
+ dep = self.get_by_id(dep_id)
+ if dep and not dep.selected:
+ self._enable_with_deps(dep)
+ self._enable(pkg)
+
+ def _disable_with_dependents(self, pkg):
+ """Untick a package and any (transitive) dependents."""
+ for other_pkg in self.packages:
+ if pkg.id in other_pkg.dependencies and other_pkg.selected:
+ self._disable_with_dependents(other_pkg)
+ pkg.selected = False
+
+ def toggle(self, pkg_id):
+ pkg = self.get_by_id(pkg_id)
+ if not pkg:
+ return None
+ if pkg_id in _pkg_stop_times and not pkg.selected:
+ return "Still stopping…"
+ if not pkg.selected:
+ self._enable_with_deps(pkg)
+ else:
+ self._disable_with_dependents(pkg)
+ return None
+
+
+def get_pkg_status(pkg):
+ """Return one of: running, loading, crashed, stopped, stopping."""
+ if pkg.id in _pkg_stop_times:
+ if not pkg.is_running:
+ _pkg_stop_times.pop(pkg.id, None)
+ return "stopped"
+ if time.time() - _pkg_stop_times[pkg.id] < STOPPING_TIMEOUT:
+ return "stopping"
+ _pkg_stop_times.pop(pkg.id, None)
+ if pkg.pane_dead:
+ _pkg_start_times.pop(pkg.id, None)
+ if pkg.pane_exit_status == 0:
+ _intended_running.discard(pkg.id)
+ return "stopped"
+ return "crashed"
+ if pkg.ready:
+ _pkg_start_times.pop(pkg.id, None)
+ return "running"
+ if pkg.id in _intended_running:
+ timeout = getattr(pkg, "readiness_timeout", LOADING_TIMEOUT)
+ started = _pkg_start_times.get(pkg.id)
+ if started is None:
+ if pkg.is_running:
+ _pkg_start_times[pkg.id] = time.time()
+ return "loading"
+ return "crashed"
+ if time.time() - started < timeout:
+ return "loading"
+ _pkg_start_times.pop(pkg.id, None)
+ return "crashed"
+ return "stopped"
+
+
+def _has_unapplied_changes(state):
+ for pkg in state.packages:
+ if pkg.id in _pkg_start_times or pkg.id in _pkg_stop_times:
+ continue
+ if pkg.selected != pkg.is_running:
+ return True
+ return False
+
+
+def _nav_hint(pkg):
+ """Navigation hint for packages without a web URL (e.g. tmux terminal windows)."""
+ if not pkg.nav_hint or not pkg.is_running:
+ return ""
+ return f"({pkg.nav_hint})"
+
+
+def _status_url(pkg):
+ """Expanded access URL for a package, or '' if unset env vars leave it unresolved."""
+ if not pkg.url:
+ return ""
+ expanded = os.path.expandvars(pkg.url)
+ if "${" in expanded or expanded.endswith(":"):
+ return ""
+ return expanded
diff --git a/launcher/tmux.py b/launcher/tmux.py
new file mode 100644
index 0000000..517c67c
--- /dev/null
+++ b/launcher/tmux.py
@@ -0,0 +1,52 @@
+"""Tmux session and window management."""
+
+import os
+import sys
+
+from .constants import TMUX_SESSION
+
+
+def is_in_tmux():
+ return "TMUX" in os.environ
+
+
+def needs_tmux_session():
+ """tmux launcher is used on Linux/macOS; Windows runs launcher directly."""
+ return sys.platform not in ("win32", "cygwin", "msys") and os.name != "nt"
+
+
+def _window_teardown_shell(window: str) -> str:
+ """Gracefully stop a tmux window: SIGINT, brief poll wait, kill-window."""
+ return (
+ f"tmux send-keys -t {TMUX_SESSION}:{window} C-c 2>/dev/null; "
+ "for _ in $(seq 1 8); do sleep 0.25; done; "
+ f"tmux kill-window -t {TMUX_SESSION}:{window} 2>/dev/null"
+ )
+
+
+def _core_teardown_shell() -> str:
+ return (
+ f"tmux send-keys -t {TMUX_SESSION}:core C-c 2>/dev/null; "
+ "for _ in $(seq 1 20); do "
+ "pgrep -f '[g]z sim' >/dev/null 2>&1 || pgrep -x rviz2 >/dev/null 2>&1 || break; "
+ "sleep 0.25; done; "
+ f"tmux kill-window -t {TMUX_SESSION}:core 2>/dev/null"
+ )
+
+
+# Back-compat alias for tests / docs that referenced the shell snippet.
+CORE_TEARDOWN = _core_teardown_shell()
+
+
+def _stop_tmux_window(window: str):
+ import launcher
+
+ if launcher.needs_tmux_session():
+ launcher.run_shell_command(launcher._window_teardown_shell(window))
+
+
+def _stop_core_tmux():
+ import launcher
+
+ if launcher.needs_tmux_session():
+ launcher.run_shell_command(launcher._core_teardown_shell())
diff --git a/launcher/tui.py b/launcher/tui.py
new file mode 100644
index 0000000..abd0c83
--- /dev/null
+++ b/launcher/tui.py
@@ -0,0 +1,258 @@
+"""Curses TUI rendering and main event loop."""
+
+import time
+
+try:
+ import curses
+except ImportError:
+ curses = None
+
+from .apply import apply_changes, default_robot_selection, restore_selection
+from .config import get_dev_mode, load_config, save_selection
+from .constants import MIN_TERM_HEIGHT, MIN_TERM_WIDTH
+from .state import (
+ LauncherState,
+ _has_unapplied_changes,
+ _intended_running,
+ _nav_hint,
+ _pkg_start_times,
+ _pkg_stop_times,
+ _status_url,
+ get_pkg_status,
+)
+
+
+def _draw_pkg_row(stdscr, y, x, prefix, indent, checkbox, name, attr, status, hint="", url=""):
+ base = f"{prefix}{indent}{checkbox} {name}"
+ stdscr.addstr(y, x, base, attr)
+ col = x + len(base)
+ labels = {
+ "running": (" [RUNNING]", curses.color_pair(4)),
+ "loading": (" [LOADING]", curses.color_pair(1)),
+ "stopping": (" [STOPPING]", curses.color_pair(1)),
+ "crashed": (" [CRASHED]", curses.color_pair(2) | curses.A_BOLD),
+ "stopped": (" [STOPPED]", curses.A_DIM),
+ }
+ status_str, status_attr = labels.get(status, (" [STOPPED]", curses.A_DIM))
+ try:
+ stdscr.addstr(y, col, status_str, status_attr)
+ col += len(status_str)
+ except curses.error:
+ pass
+ if url and status == "running":
+ text = f" ({url})"
+ try:
+ stdscr.addstr(y, col, text, curses.color_pair(3))
+ col += len(text)
+ except curses.error:
+ pass
+ if hint:
+ text = f" {hint}"
+ try:
+ stdscr.addstr(y, col, text, curses.color_pair(3))
+ col += len(text)
+ except curses.error:
+ pass
+
+
+def draw_too_small_message(stdscr):
+ h, w = stdscr.getmaxyx()
+ stdscr.clear()
+ message = "Please increase terminal size"
+ message2 = f"({MIN_TERM_WIDTH}x{MIN_TERM_HEIGHT} required)"
+ stdscr.addstr(h // 2 - 1, max(0, (w - len(message)) // 2), message, curses.A_BOLD)
+ stdscr.addstr(h // 2, max(0, (w - len(message2)) // 2), message2, curses.A_DIM)
+ stdscr.refresh()
+
+
+def draw_tui(stdscr, state, current_idx, error_msg, status_msg, unapplied=False):
+ h, w = stdscr.getmaxyx()
+ if h < MIN_TERM_HEIGHT or w < MIN_TERM_WIDTH:
+ draw_too_small_message(stdscr)
+ return None
+
+ stdscr.clear()
+ title = "Lucy Control Center"
+ stdscr.addstr(0, max(0, (w - len(title)) // 2), title, curses.A_BOLD)
+ stdscr.addstr(
+ h - 1,
+ 2,
+ "Enter: Apply | Space: Toggle | Q/X/Esc: Stop All & Exit",
+ curses.A_BOLD,
+ )
+
+ if status_msg:
+ stdscr.addstr(h - 2, 2, status_msg, curses.A_BOLD)
+ elif error_msg:
+ stdscr.addstr(h - 2, 2, f"Warning: {error_msg}", curses.color_pair(2))
+ elif unapplied:
+ stdscr.addstr(
+ h - 2,
+ 2,
+ "Unapplied changes — press Enter to apply",
+ curses.color_pair(1),
+ )
+
+ robots = [p for p in state.packages if p.type == "modifier" and p.requires_pkg]
+ cores_and_mods = [
+ p for p in state.packages if p.type in ("core", "modifier") and not p.requires_pkg
+ ]
+ interfaces = [p for p in state.packages if p.type == "interface"]
+ tools = [p for p in state.packages if p.type == "tool"]
+ display_list = cores_and_mods + robots + interfaces + tools
+
+ def draw_section(title, color, items, offset, gap=1, indent_all=False):
+ nonlocal row
+ stdscr.addstr(row, 2, title, curses.A_BOLD | color)
+ row += gap
+ for i, p in enumerate(items):
+ list_idx = offset + i
+ prefix = "> " if current_idx == list_idx else " "
+ checkbox = "[x]" if p.selected else "[ ]"
+ can_enable = all(state.get_by_id(dep).selected for dep in p.dependencies)
+ attr = curses.A_NORMAL if can_enable else curses.A_DIM
+ if p.type == "core":
+ attr |= curses.A_BOLD
+ if p.subitem:
+ indent = " "
+ elif indent_all or p.type == "modifier":
+ indent = " "
+ else:
+ indent = ""
+ status = get_pkg_status(p)
+ hint = _nav_hint(p)
+ _draw_pkg_row(
+ stdscr,
+ row + i,
+ 4,
+ prefix,
+ indent,
+ checkbox,
+ p.name,
+ attr,
+ status,
+ hint,
+ _status_url(p),
+ )
+ row += len(items) + 1
+
+ row = 2
+ draw_section("Primary Launch Targets", curses.color_pair(1), cores_and_mods, 0, gap=2)
+ offset = len(cores_and_mods)
+ if robots:
+ draw_section("Robot", curses.color_pair(1), robots, offset, gap=1, indent_all=True)
+ offset += len(robots)
+ draw_section("Interfaces", curses.color_pair(3), interfaces, offset, gap=1)
+ offset += len(interfaces)
+ draw_section("Tools", curses.color_pair(3), tools, offset, gap=1)
+
+ stdscr.refresh()
+ return display_list
+
+
+def main(stdscr):
+ curses.curs_set(0)
+ stdscr.nodelay(0)
+ stdscr.timeout(-1)
+ curses.start_color()
+ curses.use_default_colors()
+
+ if curses.has_colors():
+ curses.init_pair(1, curses.COLOR_YELLOW, -1)
+ curses.init_pair(2, curses.COLOR_RED, -1)
+ curses.init_pair(3, curses.COLOR_CYAN, -1)
+ curses.init_pair(4, curses.COLOR_GREEN, -1)
+
+ state = LauncherState(load_config())
+ restore_selection(state)
+ default_robot_selection(state)
+ current_idx = 0
+ error_msg = None
+ status_msg = None
+ status_msg_until = 0.0
+
+ if not get_dev_mode():
+ core_pkg = state.get_by_id("core")
+ lcp_pkg = state.get_by_id("control_panel")
+ if core_pkg:
+ core_pkg.selected = True
+ if lcp_pkg:
+ lcp_pkg.selected = True
+ default_robot_selection(state)
+ apply_changes(state)
+ save_selection({p.id for p in state.packages if p.selected})
+ status_msg = "Starting default services for production mode..."
+ status_msg_until = time.time() + 3.0
+ state.refresh_status()
+
+ while True:
+ try:
+ if status_msg and time.time() >= status_msg_until:
+ status_msg = None
+ display_list = draw_tui(
+ stdscr,
+ state,
+ current_idx,
+ error_msg,
+ status_msg,
+ _has_unapplied_changes(state),
+ )
+ error_msg = None
+
+ if display_list is None:
+ stdscr.nodelay(1)
+ stdscr.timeout(100)
+ key = stdscr.getch()
+ if key != curses.KEY_RESIZE:
+ time.sleep(0.1)
+ continue
+
+ if _pkg_start_times or _pkg_stop_times:
+ poll_ms = 1000
+ elif _intended_running:
+ poll_ms = 5000
+ else:
+ poll_ms = None
+ if poll_ms is None:
+ stdscr.nodelay(0)
+ stdscr.timeout(-1)
+ else:
+ stdscr.nodelay(1)
+ stdscr.timeout(poll_ms)
+ key = stdscr.getch()
+ if key == -1:
+ state.refresh_status()
+ continue
+
+ if key == curses.KEY_RESIZE:
+ continue
+
+ if key == curses.KEY_UP:
+ current_idx = (current_idx - 1) % len(display_list)
+ elif key == curses.KEY_DOWN:
+ current_idx = (current_idx + 1) % len(display_list)
+ elif key == ord(" "):
+ pkg_to_toggle = display_list[current_idx]
+ error_msg = state.toggle(pkg_to_toggle.id)
+ elif key == ord("\n"):
+ apply_changes(state)
+ save_selection({p.id for p in state.packages if p.selected})
+ status_msg = "Configuration Applied!"
+ status_msg_until = time.time() + 2.0
+ state.refresh_status()
+ elif key in [ord("x"), ord("X"), ord("q"), ord("Q"), 27]:
+ h, w = stdscr.getmaxyx()
+ stdscr.addstr(
+ h - 2,
+ 2,
+ "Stop all processes and exit? (y/n)",
+ curses.A_BOLD | curses.color_pair(2),
+ )
+ stdscr.refresh()
+ confirm_key = stdscr.getch()
+ if confirm_key in [ord("y"), ord("Y")]:
+ return "ExitWorkspace", state
+
+ except curses.error:
+ time.sleep(0.1)
+ continue
diff --git a/scripts/ci_tmux_launcher_smoke.sh b/scripts/ci_tmux_launcher_smoke.sh
index ae86b3c..66f3d59 100755
--- a/scripts/ci_tmux_launcher_smoke.sh
+++ b/scripts/ci_tmux_launcher_smoke.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# CI smoke: tmux + pixi-wrapped core (headless sim) and control panel.
-# Exercises the same tmux/pixi paths as launcher.py apply_changes without the TUI.
+# Exercises the same tmux/pixi paths as launcher apply_changes without the TUI.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
@@ -68,5 +68,15 @@ wait_for() {
wait_for '[r]osbridge_websocket' 'rosbridge' 180
wait_for '[v]ite' 'control panel (vite)' 180
+pixi run -- python3 <<'PY'
+import os
+
+os.chdir(os.environ["LUCY_WS_ROOT"])
+from launcher import load_workspace_env, stop_all_packages, LauncherState, load_config
+
+load_workspace_env()
+stop_all_packages(LauncherState(load_config()))
+PY
+
tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true
echo "ci_tmux_launcher_smoke: OK"
diff --git a/scripts/nix_gl_env.sh b/scripts/nix_gl_env.sh
index 29fa65b..d3b2d75 100644
--- a/scripts/nix_gl_env.sh
+++ b/scripts/nix_gl_env.sh
@@ -7,7 +7,7 @@
# 1. Prepends host GL libs (nixGL wrapper, or /run/opengl-driver/lib)
# 2. Sets Mesa EGL vendor + GZ_IP for OGRE / Gazebo transport
#
-# Usage (via scripts/pixi_lucy_launch.sh or launcher.py):
+# Usage (via scripts/pixi_lucy_launch.sh or python -m launcher):
# pixi run sim
#
# Do not set LUCY_NIX_GL=0 on NixOS — EGL vars alone are not enough.
diff --git a/tests/test_launcher_pixi.py b/tests/test_launcher_pixi.py
index 4c0e501..cf65c2b 100644
--- a/tests/test_launcher_pixi.py
+++ b/tests/test_launcher_pixi.py
@@ -6,11 +6,18 @@
from launcher import (
STATE_FILE,
WORKSPACE_ROOT,
+ CORE_TEARDOWN,
+ _core_teardown_shell,
_gui_env_exports,
_pixi_workspace_script,
_tmux_new_pixi_window,
+ _window_teardown_shell,
+ apply_changes,
+ is_lucy_orphan,
+ is_lucy_orphan_cmdline,
load_workspace_env,
needs_tmux_session,
+ stop_all_packages,
)
@@ -64,3 +71,412 @@ def test_load_workspace_env_reads_dotenv(tmp_path, monkeypatch):
def test_needs_tmux_session_false_on_windows():
if os.name == "nt":
assert needs_tmux_session() is False
+
+
+def test_window_teardown_sends_sigint_before_kill():
+ cmd = _window_teardown_shell("control_panel")
+ assert "send-keys" in cmd
+ assert "C-c" in cmd
+ assert "kill-window" in cmd
+ assert "control_panel" in cmd
+ assert "sleep 0.25" in cmd
+
+
+def test_core_teardown_waits_before_kill_window():
+ cmd = _core_teardown_shell()
+ assert "C-c" in cmd
+ assert "kill-window" in cmd
+ assert "pgrep" in cmd
+ assert cmd == CORE_TEARDOWN
+
+
+def test_is_lucy_orphan_cmdline_requires_workspace():
+ ws = str(WORKSPACE_ROOT)
+ assert is_lucy_orphan_cmdline(f"gz sim server {ws}/install/world.sdf")
+ assert not is_lucy_orphan_cmdline("gz sim server /other/project/world.sdf")
+
+
+def test_is_lucy_orphan_gz_sim_server_via_workspace_markers(monkeypatch):
+ monkeypatch.setattr(launcher, "_process_workspace_markers", lambda pid: pid == 1120406)
+ assert is_lucy_orphan(1120406, "gz sim server")
+ assert not is_lucy_orphan(999, "gz sim server")
+
+
+def test_is_lucy_orphan_gz_sim_gui_via_workspace_markers(monkeypatch):
+ monkeypatch.setattr(launcher, "_process_workspace_markers", lambda _pid: True)
+ assert is_lucy_orphan(1, "gz sim gui")
+
+
+def test_is_lucy_orphan_cmdline_excludes_launcher():
+ ws = str(WORKSPACE_ROOT)
+ assert not is_lucy_orphan_cmdline(f"python {ws}/launcher.py")
+ assert not is_lucy_orphan_cmdline(f"python {ws}/Lucy.py")
+
+
+def test_is_lucy_orphan_skips_unrelated_processes(monkeypatch):
+ marker_calls = []
+ monkeypatch.setattr(
+ launcher,
+ "_process_workspace_markers",
+ lambda pid: marker_calls.append(pid) or False,
+ )
+ assert not is_lucy_orphan(42, "/usr/lib/systemd/systemd --user")
+ assert not is_lucy_orphan(42, "firefox")
+ assert marker_calls == []
+
+
+def test_is_lucy_orphan_vite_short_cmdline_via_cwd(monkeypatch):
+ cp = f"{WORKSPACE_ROOT}/src/lucy_control_panel"
+ monkeypatch.setattr(launcher, "_read_proc_cwd", lambda _pid: cp)
+ assert is_lucy_orphan(123, "node node_modules/vite/bin/vite.js")
+
+
+def test_is_lucy_orphan_vite_rejects_other_app_in_workspace():
+ ws = str(WORKSPACE_ROOT)
+ assert not is_lucy_orphan(0, f"node {ws}/other-app/vite.js")
+
+
+def test_is_lucy_orphan_cmdline_vite_scoped_to_control_panel():
+ ws = str(WORKSPACE_ROOT)
+ assert is_lucy_orphan_cmdline(
+ f"node {ws}/src/lucy_control_panel/node_modules/vite/bin/vite.js"
+ )
+ assert not is_lucy_orphan_cmdline(f"node {ws}/other-app/vite.js")
+
+
+def test_find_lucy_orphan_pids_preserves_control_panel_vite(monkeypatch):
+ cp = f"{WORKSPACE_ROOT}/src/lucy_control_panel"
+ monkeypatch.setattr(launcher, "_read_proc_cwd", lambda _pid: cp)
+ vite_pid = 999999
+ vite_cmd = "node node_modules/vite/bin/vite.js"
+
+ def fake_iter():
+ yield vite_pid, vite_cmd
+
+ monkeypatch.setattr(launcher.process, "_iter_processes", fake_iter)
+ assert launcher.is_lucy_orphan(vite_pid, vite_cmd)
+ assert vite_pid not in launcher.find_lucy_orphan_pids(
+ preserve_package_windows=frozenset(["control_panel"]),
+ protect_vite=True,
+ )
+ assert vite_pid in launcher.find_lucy_orphan_pids(
+ preserve_package_windows=frozenset(["control_panel"]),
+ protect_vite=False,
+ )
+ assert vite_pid in launcher.find_lucy_orphan_pids(protect_vite=False)
+
+
+def test_stop_all_packages_single_finish_teardown(monkeypatch):
+ finish_count = []
+
+ class FakePkg:
+ def __init__(self, pid, ptype, running):
+ self.id = pid
+ self.type = ptype
+ self.is_running = running
+ self.lifecycle_hooks = {}
+
+ def is_complex_command(self):
+ return False
+
+ class FakeState:
+ def refresh_status(self):
+ pass
+
+ def get_by_id(self, pid):
+ if pid == "core":
+ return FakePkg("core", "core", True)
+ return None
+
+ packages = [
+ FakePkg("control_panel", "interface", True),
+ FakePkg("core", "core", True),
+ ]
+
+ monkeypatch.setattr(launcher, "_stop_tmux_window", lambda _w: None)
+ monkeypatch.setattr(launcher, "_stop_core_tmux", lambda: None)
+ monkeypatch.setattr(
+ launcher, "_finish_teardown", lambda: finish_count.append(1)
+ )
+ monkeypatch.setattr(launcher, "save_state", lambda _data: None)
+
+ stop_all_packages(FakeState())
+ assert finish_count == [1]
+
+
+def test_stop_all_packages_always_finishes_teardown(monkeypatch):
+ calls = []
+ monkeypatch.setattr(launcher, "_finish_teardown", lambda: calls.append("finish"))
+ stop_all_packages(None)
+ assert calls == ["finish"]
+
+
+def test_stop_all_packages_stops_running_interfaces(monkeypatch):
+ calls = []
+
+ class FakePkg:
+ def __init__(self, pid, ptype, running):
+ self.id = pid
+ self.type = ptype
+ self.is_running = running
+ self.lifecycle_hooks = {}
+
+ def is_complex_command(self):
+ return False
+
+ class FakeState:
+ def refresh_status(self):
+ pass
+
+ def get_by_id(self, _):
+ return None
+
+ packages = [
+ FakePkg("control_panel", "interface", True),
+ FakePkg("core", "core", False),
+ ]
+
+ monkeypatch.setattr(
+ launcher, "_stop_tmux_window", lambda w: calls.append(f"window:{w}")
+ )
+ monkeypatch.setattr(launcher, "_finish_teardown", lambda: calls.append("finish"))
+ monkeypatch.setattr(launcher, "save_state", lambda _data: calls.append("save"))
+
+ stop_all_packages(FakeState())
+ assert calls == ["window:control_panel", "save", "finish"]
+
+
+def test_stop_all_packages_runs_core_teardown_when_running(monkeypatch):
+ calls = []
+
+ class FakePkg:
+ def __init__(self, pid, ptype, running):
+ self.id = pid
+ self.type = ptype
+ self.is_running = running
+ self.lifecycle_hooks = {}
+
+ def is_complex_command(self):
+ return False
+
+ class FakeCore(FakePkg):
+ pass
+
+ core = FakeCore("core", "core", True)
+
+ class FakeState:
+ def refresh_status(self):
+ pass
+
+ def get_by_id(self, pid):
+ return core if pid == "core" else None
+
+ packages = [core]
+
+ monkeypatch.setattr(launcher, "_stop_core_tmux", lambda: calls.append("core"))
+ monkeypatch.setattr(launcher, "_finish_teardown", lambda: calls.append("finish"))
+ monkeypatch.setattr(launcher, "save_state", lambda _data: None)
+
+ stop_all_packages(FakeState())
+ assert calls == ["core", "finish"]
+
+
+def test_stop_all_packages_skips_tmux_on_windows(monkeypatch):
+ monkeypatch.setattr(launcher, "needs_tmux_session", lambda: False)
+ shell_calls = []
+ finish_calls = []
+
+ monkeypatch.setattr(
+ launcher, "run_shell_command", lambda cmd: shell_calls.append(cmd)
+ )
+ monkeypatch.setattr(
+ launcher, "_finish_teardown", lambda: finish_calls.append(True)
+ )
+
+ class FakeCore:
+ id = "core"
+ type = "core"
+ is_running = True
+ lifecycle_hooks = {}
+
+ def is_complex_command(self):
+ return False
+
+ class FakeState:
+ packages = [FakeCore()]
+
+ def refresh_status(self):
+ pass
+
+ def get_by_id(self, _):
+ return FakeCore()
+
+ stop_all_packages(FakeState())
+ assert shell_calls == []
+ assert len(finish_calls) >= 1
+
+
+def test_apply_changes_modifier_restart_calls_finish_teardown(monkeypatch):
+ """Changing modifiers while core is selected must tear down before relaunch."""
+ calls = []
+
+ class FakePkg:
+ def __init__(self, pid, ptype, selected, running, command=""):
+ self.id = pid
+ self.type = ptype
+ self.selected = selected
+ self.is_running = running
+ self.command = command
+ self.lifecycle_hooks = {}
+ self.pane_dead = False
+ self.pane_exit_status = None
+ self.readiness_check = None
+
+ def is_complex_command(self):
+ return False
+
+ core = FakePkg(
+ "core",
+ "core",
+ True,
+ True,
+ "ros2 launch lucy_bringup lucy.launch.py",
+ )
+ gazebo = FakePkg("gazebo", "modifier", True, False, "gazebo:=true")
+
+ class FakeState:
+ packages = [core, gazebo]
+
+ def get_by_id(self, pid):
+ return {"core": core, "gazebo": gazebo}.get(pid)
+
+ monkeypatch.setattr(launcher, "_stop_core_tmux", lambda: calls.append("core_stop"))
+ finish_args = []
+ monkeypatch.setattr(
+ launcher,
+ "_finish_teardown",
+ lambda preserve_package_windows=None, protect_vite=None: finish_args.append(
+ (preserve_package_windows, protect_vite)
+ ),
+ )
+ monkeypatch.setattr(launcher, "save_state", lambda _data: calls.append("save"))
+
+ apply_changes(FakeState())
+ assert calls[0] == "core_stop"
+ assert "save" in calls
+ assert finish_args == [(frozenset(), False)]
+
+
+def test_apply_changes_modifier_restart_preserves_control_panel(monkeypatch):
+ finish_args = []
+
+ class FakePkg:
+ def __init__(self, pid, ptype, selected, running, command="", readiness_check=None):
+ self.id = pid
+ self.type = ptype
+ self.selected = selected
+ self.is_running = running
+ self.command = command
+ self.lifecycle_hooks = {}
+ self.pane_dead = False
+ self.pane_exit_status = None
+ self.readiness_check = readiness_check
+
+ def is_complex_command(self):
+ return False
+
+ core = FakePkg(
+ "core",
+ "core",
+ True,
+ True,
+ "ros2 launch lucy_bringup lucy.launch.py",
+ )
+ gazebo = FakePkg("gazebo", "modifier", True, False, "gazebo:=true")
+ control_panel = FakePkg(
+ "control_panel",
+ "interface",
+ True,
+ True,
+ "pixi run panel-dev",
+ readiness_check="pgrep -f '[v]ite' >/dev/null 2>&1",
+ )
+
+ class FakeState:
+ packages = [core, gazebo, control_panel]
+
+ def get_by_id(self, pid):
+ return {"core": core, "gazebo": gazebo, "control_panel": control_panel}.get(pid)
+
+ monkeypatch.setattr(launcher, "_stop_core_tmux", lambda: None)
+ monkeypatch.setattr(
+ launcher,
+ "_finish_teardown",
+ lambda preserve_package_windows=None, protect_vite=None: finish_args.append(
+ (preserve_package_windows, protect_vite)
+ ),
+ )
+ monkeypatch.setattr(launcher, "save_state", lambda _data: None)
+ monkeypatch.setattr(launcher, "run_shell_command", lambda _cmd: None)
+
+ apply_changes(FakeState())
+ assert finish_args == [(frozenset({"control_panel"}), True)]
+
+
+def test_apply_changes_stopping_core_preserves_running_control_panel(monkeypatch):
+ preserve_calls = []
+
+ class FakePkg:
+ def __init__(self, pid, ptype, selected, running, command="", readiness_check=None):
+ self.id = pid
+ self.type = ptype
+ self.selected = selected
+ self.is_running = running
+ self.command = command
+ self.lifecycle_hooks = {}
+ self.pane_dead = False
+ self.pane_exit_status = None
+ self.readiness_check = readiness_check
+
+ def is_complex_command(self):
+ return False
+
+ core = FakePkg("core", "core", False, True, "ros2 launch lucy_bringup lucy.launch.py")
+ control_panel = FakePkg(
+ "control_panel",
+ "interface",
+ True,
+ True,
+ "pixi run panel-dev",
+ readiness_check="pgrep -f '[v]ite' >/dev/null 2>&1",
+ )
+
+ class FakeState:
+ packages = [core, control_panel]
+
+ def get_by_id(self, pid):
+ return {"core": core, "control_panel": control_panel}.get(pid)
+
+ monkeypatch.setattr(
+ launcher,
+ "set_orphan_preserve_windows",
+ lambda windows, protect_vite=False: preserve_calls.append(
+ (frozenset(windows), protect_vite)
+ ),
+ )
+ monkeypatch.setattr(launcher, "run_teardown_async", lambda fn: fn())
+ monkeypatch.setattr(launcher, "save_state", lambda _data: None)
+ monkeypatch.setattr(launcher, "run_shell_command", lambda _cmd: None)
+
+ apply_changes(FakeState())
+ assert preserve_calls[0] == (frozenset({"control_panel"}), True)
+
+
+def test_finish_teardown_clears_pending_preserve(monkeypatch):
+ launcher.set_orphan_preserve_windows(["control_panel"], protect_vite=True)
+ monkeypatch.setattr(launcher, "cleanup_lucy_orphan_processes", lambda **kwargs: None)
+ monkeypatch.setattr(launcher, "wait_for_orphans_gone", lambda **kwargs: None)
+
+ launcher._finish_teardown()
+ assert launcher.process._pending_preserve_windows == frozenset()
+ assert launcher.process._pending_protect_vite is False
diff --git a/windows/install_ops.py b/windows/install_ops.py
index ec5996d..98b7add 100644
--- a/windows/install_ops.py
+++ b/windows/install_ops.py
@@ -246,7 +246,7 @@ def parse_repos(project_root: str, developer: bool, repos_branch: Optional[str]
name = repo.get("name", "").strip().strip("\r\n")
if not name:
continue
- branch = (repos_branch or repo.get("branch", DEFAULT_REPOS_BRANCH)).strip().strip("\r\n")
+ branch = (repo.get("branch") or repos_branch or DEFAULT_REPOS_BRANCH).strip().strip("\r\n")
url_https = (repo.get("url_https") or repo.get("url") or "").strip().strip("\r\n")
url_ssh = (repo.get("url_ssh") or "").strip().strip("\r\n")
url = (url_ssh or url_https) if developer else (url_https or url_ssh)
diff --git a/windows/install_runner.py b/windows/install_runner.py
index 3c01fc7..36833af 100644
--- a/windows/install_runner.py
+++ b/windows/install_runner.py
@@ -53,7 +53,7 @@ def main(argv: list[str] | None = None) -> int:
help="Install operation to run",
)
parser.add_argument("--developer", action="store_true", help="Developer install (requires git, SSH clones)")
- parser.add_argument("--repos-branch", default="master", help="Branch for sub-repositories")
+ parser.add_argument("--repos-branch", default=None, help="Fallback branch for repos without one set")
parser.add_argument("--lucy-ws-ref", default="master", help="lucy_ws git ref (branch or tag)")
parser.add_argument(
"--lucy-ws-ref-type",