From 364e894b8982894296bd72a4ec1507615f73b71d Mon Sep 17 00:00:00 2001 From: Aura - jc <67582323+Catafal@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:31:23 +0200 Subject: [PATCH 1/2] feat: close sessions from Bach + Started column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `bach sessions close ` / REPL `/sessions close `: SIGTERM the process behind a running/idle session. Attribution is heuristic (runtime binary + lsof cwd match), so ambiguity lists candidate PIDs and kills nothing — rerun with --pid to pick. Always confirms; ended sessions refused. - Started column (transcript birth time, st_birthtime with mtime fallback) in CLI table, REPL table, and --json output. Co-Authored-By: Claude Fable 5 --- src/bach/cli/sessions.py | 99 +++++++++++++++++++++++- src/bach/domain/models.py | 3 + src/bach/repl/help_content.py | 4 +- src/bach/repl/sessions_view.py | 100 +++++++++++++++++++++++++ src/bach/runtimes/session_discovery.py | 77 +++++++++++++++++++ src/bach/services/sessions_service.py | 68 +++++++++++++++++ tests/unit/test_session_discovery.py | 67 +++++++++++++++++ tests/unit/test_sessions_service.py | 77 +++++++++++++++++++ 8 files changed, 492 insertions(+), 3 deletions(-) diff --git a/src/bach/cli/sessions.py b/src/bach/cli/sessions.py index 9f8af7b..1ac7216 100644 --- a/src/bach/cli/sessions.py +++ b/src/bach/cli/sessions.py @@ -16,6 +16,9 @@ import json import logging +import os +import signal +from datetime import datetime from pathlib import Path from typing import Annotated @@ -27,13 +30,19 @@ from bach.config.settings import load_config from bach.config.themes import THEMES, ThemePalette, get_theme from bach.domain.models import DiscoveredSession -from bach.runtimes.session_discovery import default_process_probe, discover_sessions +from bach.runtimes.session_discovery import ( + default_cwd_probe, + default_process_probe, + discover_sessions, + find_session_pids, +) from bach.runtimes.transcript import read_tail_events from bach.services.session_tracker import SESSIONS_DIRNAME from bach.services.sessions_service import ( DEFAULT_ENDED_WINDOW_S, SessionRow, adopt_session, + close_session, find_artifact_for_ref, list_sessions, resume_session, @@ -139,6 +148,9 @@ def _output_sessions_json(rows: list[SessionRow]) -> None: "runtime": row.session.runtime.value, "liveness": row.session.liveness.value, "project_dir": str(row.session.project_dir) if row.session.project_dir else None, + "started_at": ( + row.session.started_at.isoformat() if row.session.started_at else None + ), "last_activity": ( row.session.last_activity.isoformat() if row.session.last_activity else None ), @@ -150,6 +162,16 @@ def _output_sessions_json(rows: list[SessionRow]) -> None: _console.print(json.dumps(output, indent=2)) +def _format_started(started_at: datetime | None) -> str: + """Compact local start time: today → HH:MM, else MM-DD HH:MM.""" + if started_at is None: + return "—" + local = started_at.astimezone() + if local.date() == datetime.now().astimezone().date(): + return local.strftime("%H:%M") + return local.strftime("%m-%d %H:%M") + + def _render_sessions_table(rows: list[SessionRow], t: ThemePalette) -> None: """Render discovered sessions as a Rich table.""" if not rows: @@ -162,6 +184,7 @@ def _render_sessions_table(rows: list[SessionRow], t: ThemePalette) -> None: table.add_column("State") table.add_column("Task") table.add_column("Project") + table.add_column("Started", style="dim") table.add_column("Preview", max_width=40) # Color mapping for liveness states — makes at-a-glance scanning fast. @@ -179,6 +202,7 @@ def _render_sessions_table(rows: list[SessionRow], t: ThemePalette) -> None: project_name = s.project_dir.name if s.project_dir else "—" task_display = row.task_id or "—" preview_display = (row.preview or "")[:40] if row.preview else "—" + started_display = _format_started(s.started_at) table.add_row( sid_short, @@ -186,6 +210,7 @@ def _render_sessions_table(rows: list[SessionRow], t: ThemePalette) -> None: f"[{color}]{liveness_val}[/{color}]", task_display, project_name, + started_display, preview_display, ) @@ -338,3 +363,75 @@ def sessions_watch( judge_fn=judge_session, ) raise typer.Exit(code=exit_code) + + +# --------------------------------------------------------------------------- +# `bach sessions close` +# --------------------------------------------------------------------------- + + +@sessions_app.command("close") +def sessions_close( + session_ref: Annotated[str, typer.Argument(help="Session ID or unique prefix.")], + pid: Annotated[ + int | None, + typer.Option("--pid", help="Disambiguate: kill exactly this candidate PID."), + ] = None, + yes: Annotated[ + bool, + typer.Option("--yes", "-y", help="Skip the confirmation prompt."), + ] = False, +) -> None: + """Terminate the process behind a running or idle session (SIGTERM). + + Process attribution is heuristic (runtime binary + working directory), + so when several sessions share a project the command lists candidate + PIDs instead of guessing — rerun with --pid to pick one. + """ + t = _theme() + + if not yes and not typer.confirm(f"Send SIGTERM to the process behind {session_ref}?"): + raise typer.Exit(code=0) + + def _find_pids(session: DiscoveredSession) -> list[int]: + return find_session_pids( + session, + process_probe=default_process_probe, + cwd_probe=default_cwd_probe, + ) + + try: + result = close_session( + session_ref, + discover_fn=_default_discover_fn, + find_pids_fn=_find_pids, + kill_fn=lambda p: os.kill(p, signal.SIGTERM), + pid_override=pid, + ) + except ValueError as exc: + _console.print(f"[{t.error}]error[/{t.error}] {exc}") + raise typer.Exit(code=1) from exc + + if result.reason == "terminated": + _console.print( + f"[{t.success}]closed[/{t.success}] session {result.session_id[:12]} " + f"(pid {result.killed_pid})" + ) + elif result.reason == "not_running": + _console.print(f"[{t.muted}]session {result.session_id[:12]} already ended[/{t.muted}]") + elif result.reason == "no_process_found": + _console.print( + f"[{t.warning}]no live process attributable to " + f"{result.session_id[:12]}[/{t.warning}] — it may have exited already, " + "or its project has no unambiguous working directory" + ) + raise typer.Exit(code=1) + else: # ambiguous + _console.print( + f"[{t.warning}]multiple candidate processes[/{t.warning}] for " + f"{result.session_id[:12]} (same runtime + project):" + ) + for cand in result.candidates: + _console.print(f" pid {cand}") + _console.print(f"Pick one: bach sessions close {session_ref} --pid ") + raise typer.Exit(code=1) diff --git a/src/bach/domain/models.py b/src/bach/domain/models.py index ef11e10..eeaad96 100644 --- a/src/bach/domain/models.py +++ b/src/bach/domain/models.py @@ -309,6 +309,9 @@ class DiscoveredSession: project_dir: Path | None # decoded working dir if derivable, else None liveness: SessionLiveness last_activity: datetime | None # transcript mtime; None on stat failure + # Transcript birth time (macOS st_birthtime, mtime fallback) — when the + # session started. Default None keeps older constructor sites valid. + started_at: datetime | None = None @dataclass(frozen=True) diff --git a/src/bach/repl/help_content.py b/src/bach/repl/help_content.py index 6b51bac..ae33054 100644 --- a/src/bach/repl/help_content.py +++ b/src/bach/repl/help_content.py @@ -982,8 +982,8 @@ class CommandHelp: # ===================================================================== "sessions": CommandHelp( synopsis="Show live and recent agent sessions (runtime-plane radar).", - syntax="/sessions [project] [running|idle|ended] [claude|codex] [all]", - args="free-form tokens: project name, state, runtime, `all` for full history", + syntax="/sessions [project] [running|idle|ended] [claude|codex] [all] | /sessions close ", + args="filter tokens in any order; `close ` terminates a session (confirms first)", what=( "Scans ~/.claude/projects/ and ~/.codex/sessions/ to find all " "active and recently-ended Claude Code / Codex sessions. Shows " diff --git a/src/bach/repl/sessions_view.py b/src/bach/repl/sessions_view.py index c8902a9..e223ae1 100644 --- a/src/bach/repl/sessions_view.py +++ b/src/bach/repl/sessions_view.py @@ -17,6 +17,10 @@ from __future__ import annotations import logging +import os +import signal +from collections.abc import Callable +from datetime import datetime from pathlib import Path from typing import Any @@ -59,6 +63,7 @@ def render_sessions_table( table.add_column("State", max_width=10) table.add_column("Task", max_width=8) table.add_column("Project", max_width=20) + table.add_column("Started", style="dim", max_width=12) table.add_column("Preview", max_width=44) for row in rows: @@ -72,6 +77,7 @@ def render_sessions_table( project_name = s.project_dir.name if s.project_dir else "—" task_display = row.task_id or "—" preview_text = row.preview[:44] if row.preview else "—" + started_text = _format_started(s.started_at) table.add_row( sid_short, @@ -79,6 +85,7 @@ def render_sessions_table( f"[{color}]{liveness_val}[/{color}]", task_display, project_name, + started_text, preview_text, ) @@ -86,6 +93,16 @@ def render_sessions_table( logger.debug("event=sessions_table_rendered rows=%d", len(rows)) +def _format_started(started_at: datetime | None) -> str: + """Compact local start time: today → HH:MM, else MM-DD HH:MM.""" + if started_at is None: + return "—" + local = started_at.astimezone() + if local.date() == datetime.now().astimezone().date(): + return local.strftime("%H:%M") + return local.strftime("%m-%d %H:%M") + + def _parse_sessions_args(args: str) -> tuple[str | None, str | None, str | None, bool]: """Parse free-form /sessions tokens into (project, runtime, state, show_all). @@ -150,6 +167,17 @@ def _discover() -> list[DiscoveredSession]: idle_threshold_s=cfg.liveness_idle_minutes * 60, ) + tokens = args.split() + if tokens and tokens[0].lower() == "close": + ref = tokens[1] if len(tokens) > 1 else "" + if not ref: + console.print( + f"[{theme.error}]usage:[/{theme.error}] /sessions close " + ) + return + _close_from_repl(ref, console=console, theme=theme, discover_fn=_discover) + return + project, runtime, state, show_all = _parse_sessions_args(args) try: @@ -169,3 +197,75 @@ def _discover() -> list[DiscoveredSession]: return render_sessions_table(rows, console=console, theme=theme) + + +def _close_from_repl( + ref: str, + *, + console: Console, + theme: ThemePalette, + discover_fn: Callable[[], list[DiscoveredSession]], + confirm_fn: Callable[[str], bool] | None = None, + kill_fn: Callable[[int], None] | None = None, +) -> None: + """Terminate a session's process from inside the REPL. + + Killing a process is the one destructive action in the sessions view, + so it always confirms first (plain input() — safe between prompt_toolkit + prompts). Ambiguity is never resolved by guessing: candidates are listed + and the user reruns with an exact PID via the CLI. + """ + from bach.runtimes.session_discovery import ( + default_cwd_probe, + default_process_probe, + find_session_pids, + ) + from bach.services.sessions_service import close_session + + def _default_confirm(prompt: str) -> bool: + try: + return input(prompt).strip().lower() in ("y", "yes") + except (EOFError, KeyboardInterrupt): + return False + + confirm = confirm_fn or _default_confirm + if not confirm(f"Send SIGTERM to the process behind {ref}? [y/N] "): + console.print(f"[{theme.muted}]aborted[/{theme.muted}]") + return + + def _find_pids(session: DiscoveredSession) -> list[int]: + return find_session_pids( + session, + process_probe=default_process_probe, + cwd_probe=default_cwd_probe, + ) + + try: + result = close_session( + ref, + discover_fn=discover_fn, + find_pids_fn=_find_pids, + kill_fn=kill_fn or (lambda p: os.kill(p, signal.SIGTERM)), + ) + except ValueError as exc: + console.print(f"[{theme.error}]close error:[/{theme.error}] {exc}") + return + + if result.reason == "terminated": + console.print( + f"[{theme.success}]closed[/{theme.success}] {result.session_id[:12]} " + f"(pid {result.killed_pid})" + ) + elif result.reason == "not_running": + console.print(f"[{theme.muted}]{result.session_id[:12]} already ended[/{theme.muted}]") + elif result.reason == "no_process_found": + console.print( + f"[{theme.warning}]no live process attributable to " + f"{result.session_id[:12]}[/{theme.warning}]" + ) + else: # ambiguous + console.print( + f"[{theme.warning}]multiple candidates[/{theme.warning}] for {result.session_id[:12]}: " + f"{', '.join(str(c) for c in result.candidates)} — " + f"pick one with: bach sessions close {ref} --pid " + ) diff --git a/src/bach/runtimes/session_discovery.py b/src/bach/runtimes/session_discovery.py index 289bc04..a79eae7 100644 --- a/src/bach/runtimes/session_discovery.py +++ b/src/bach/runtimes/session_discovery.py @@ -182,12 +182,27 @@ def _scan_claude( project_dir=project_dir, liveness=liveness, last_activity=last_activity, + started_at=_birth_time(transcript), ) ) return sessions +def _birth_time(path: Path) -> datetime | None: + """Transcript creation time = session start time. + + macOS exposes st_birthtime; on filesystems without it fall back to + mtime (wrong for long sessions but never crashes the radar). + """ + try: + st = path.stat() + except OSError: + return None + ts = getattr(st, "st_birthtime", None) or st.st_mtime + return datetime.fromtimestamp(ts, tz=UTC) + + def _decode_claude_slug(slug: str) -> Path | None: """Decode a Claude project slug back to an absolute path. @@ -270,6 +285,7 @@ def _scan_codex( project_dir=project_dir, liveness=liveness, last_activity=last_activity, + started_at=_birth_time(transcript), ) ) @@ -435,3 +451,64 @@ def _has_runtime_process(processes: list[tuple[int, str]], binary_marker: str) - """ marker_lower = binary_marker.lower() return any(marker_lower in cmd.lower() for _, cmd in processes) + + +# --------------------------------------------------------------------------- +# Per-session process attribution (close/kill support) +# --------------------------------------------------------------------------- + +# Maps runtime → the substring that identifies its CLI in a `ps` command line. +_BINARY_MARKERS = { + AgentRuntime.claude_code: _CLAUDE_BINARY_MARKER, + AgentRuntime.codex: _CODEX_BINARY_MARKER, +} + +CwdProbe = Callable[[int], Path | None] + + +def default_cwd_probe(pid: int) -> Path | None: + """Working directory of a live process via `lsof -a -p -d cwd -Fn`. + + Returns None when the process is gone or lsof is unavailable — the + caller treats that as "cannot attribute", never as an error. + """ + try: + result = subprocess.run( + ["lsof", "-a", "-p", str(pid), "-d", "cwd", "-Fn"], + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired): + return None + for line in result.stdout.splitlines(): + if line.startswith("n"): + return Path(line[1:]) + return None + + +def find_session_pids( + session: DiscoveredSession, + *, + process_probe: ProcessProbe, + cwd_probe: CwdProbe, +) -> list[int]: + """PIDs of live runtime processes plausibly owning this session. + + There is no OS-level session→PID mapping, so attribution is heuristic: + a process counts when its command line matches the session's runtime + binary AND its working directory equals the session's project_dir. + Multiple sessions in the same project are therefore indistinguishable — + callers MUST treat len > 1 as ambiguous and never kill blindly. + """ + if session.project_dir is None: + return [] + marker = _BINARY_MARKERS[session.runtime] + pids: list[int] = [] + for pid, command in process_probe(): + if marker not in command: + continue + cwd = cwd_probe(pid) + if cwd is not None and cwd == session.project_dir: + pids.append(pid) + return pids diff --git a/src/bach/services/sessions_service.py b/src/bach/services/sessions_service.py index 05d4a36..a5c7005 100644 --- a/src/bach/services/sessions_service.py +++ b/src/bach/services/sessions_service.py @@ -555,3 +555,71 @@ def _build_orphan_resume_command(session: DiscoveredSession) -> str: if proj: return f"codex --cd {shlex.quote(str(proj))} --skip-git-repo-check resume {sid}" return f"codex resume {sid}" + + +# --------------------------------------------------------------------------- +# Close (terminate) a running session +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class CloseResult: + """Outcome of close_session — the CLI renders this, the service decides.""" + + session_id: str + killed_pid: int | None + # PIDs that matched the session's runtime + project when attribution was + # ambiguous (>1 candidate). Empty on success or no-match. + candidates: list[int] + reason: str # "terminated" | "not_running" | "no_process_found" | "ambiguous" + + +def close_session( + session_ref: str, + *, + discover_fn: Callable[[], list[DiscoveredSession]], + find_pids_fn: Callable[[DiscoveredSession], list[int]], + kill_fn: Callable[[int], None], + pid_override: int | None = None, +) -> CloseResult: + """Terminate the process behind a running/idle session. + + Attribution is heuristic (runtime binary + cwd match — see + find_session_pids), so the rules are deliberately conservative: + - ended sessions are refused (nothing to kill); + - exactly one candidate PID → SIGTERM it; + - multiple candidates → return them and do NOTHING; the caller + re-invokes with pid_override after the human picks one. + SIGTERM (not SIGKILL) so the runtime can flush its transcript. + """ + session = _resolve_session_ref(session_ref, discover_fn()) + + if session.liveness is SessionLiveness.ended: + return CloseResult(session.session_id, None, [], "not_running") + + pids = find_pids_fn(session) + if pid_override is not None: + if pid_override not in pids: + raise ValueError( + f"pid {pid_override} is not a candidate for session " + f"{session.session_id} (candidates: {pids or 'none'})" + ) + pids = [pid_override] + + if not pids: + return CloseResult(session.session_id, None, [], "no_process_found") + if len(pids) > 1: + logger.info( + "event=close_session_ambiguous session_id=%s candidates=%s", + session.session_id, + pids, + ) + return CloseResult(session.session_id, None, pids, "ambiguous") + + kill_fn(pids[0]) + logger.info( + "event=close_session_terminated session_id=%s pid=%d", + session.session_id, + pids[0], + ) + return CloseResult(session.session_id, pids[0], [], "terminated") diff --git a/tests/unit/test_session_discovery.py b/tests/unit/test_session_discovery.py index 9cf7d33..88c70c0 100644 --- a/tests/unit/test_session_discovery.py +++ b/tests/unit/test_session_discovery.py @@ -985,3 +985,70 @@ def test_codex_project_dir_falls_back_to_session_meta_cwd(tmp_path: Path) -> Non assert len(sessions) == 1 assert sessions[0].project_dir == Path("/home/user/proj") + + +# --------------------------------------------------------------------------- +# started_at + per-session PID attribution +# --------------------------------------------------------------------------- + + +def test_claude_session_has_started_at(tmp_path: Path) -> None: + """started_at is populated from the transcript's creation time.""" + projects_dir = tmp_path / "projects" + _make_claude_session(projects_dir, "-home-user-proj", "aaaa-bbbb-cccc") + + sessions = discover_sessions( + claude_projects_dir=projects_dir, + codex_home=tmp_path / "codex", + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert sessions[0].started_at is not None + + +def test_find_session_pids_matches_binary_and_cwd(tmp_path: Path) -> None: + """Only processes with the runtime marker AND the session's cwd match.""" + from bach.domain.models import DiscoveredSession + from bach.runtimes.session_discovery import find_session_pids + + session = DiscoveredSession( + runtime=AgentRuntime.claude_code, + session_id="s1", + transcript_path=tmp_path / "s1.jsonl", + project_dir=Path("/home/user/proj"), + liveness=SessionLiveness.running, + last_activity=NOW, + ) + procs = [(100, "claude --session-id s1"), (200, "claude"), (300, "vim")] + cwds = {100: Path("/home/user/proj"), 200: Path("/elsewhere"), 300: Path("/home/user/proj")} + + pids = find_session_pids( + session, + process_probe=lambda: procs, + cwd_probe=lambda pid: cwds.get(pid), + ) + assert pids == [100] + + +def test_find_session_pids_no_project_dir_returns_empty(tmp_path: Path) -> None: + """Without a project_dir there is nothing safe to match against.""" + from bach.domain.models import DiscoveredSession + from bach.runtimes.session_discovery import find_session_pids + + session = DiscoveredSession( + runtime=AgentRuntime.codex, + session_id="s2", + transcript_path=tmp_path / "s2.jsonl", + project_dir=None, + liveness=SessionLiveness.running, + last_activity=NOW, + ) + pids = find_session_pids( + session, + process_probe=lambda: [(1, "codex exec")], + cwd_probe=lambda _pid: Path("/anything"), + ) + assert pids == [] diff --git a/tests/unit/test_sessions_service.py b/tests/unit/test_sessions_service.py index c407eb5..b5ed6f3 100644 --- a/tests/unit/test_sessions_service.py +++ b/tests/unit/test_sessions_service.py @@ -33,6 +33,7 @@ from __future__ import annotations import uuid +from collections.abc import Callable from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -838,3 +839,79 @@ def test_repl_sessions_arg_parsing() -> None: assert _parse_sessions_args("codex all") == (None, "codex", None, True) assert _parse_sessions_args("claude ended") == (None, "claude-code", "ended", False) assert _parse_sessions_args("list --all") == (None, None, None, True) + + +# --------------------------------------------------------------------------- +# close_session — terminate the process behind a session +# --------------------------------------------------------------------------- + + +def _close( + session: DiscoveredSession, + find_pids: Callable[[DiscoveredSession], list[int]], + killed: list[int], + pid_override: int | None = None, +) -> Any: + from bach.services.sessions_service import close_session + + return close_session( + session.session_id, + discover_fn=lambda: [session], + find_pids_fn=find_pids, + kill_fn=killed.append, + pid_override=pid_override, + ) + + +def test_close_session_single_candidate_is_terminated() -> None: + session = _make_session(liveness=SessionLiveness.running) + killed: list[int] = [] + result = _close(session, lambda _s: [4242], killed) + assert result.reason == "terminated" + assert result.killed_pid == 4242 + assert killed == [4242] + + +def test_close_session_refuses_ended_session() -> None: + session = _make_session(liveness=SessionLiveness.ended) + killed: list[int] = [] + result = _close(session, lambda _s: [4242], killed) + assert result.reason == "not_running" + assert killed == [] + + +def test_close_session_ambiguous_kills_nothing() -> None: + """Two claude processes in the same project — never guess which to kill.""" + session = _make_session(liveness=SessionLiveness.running) + killed: list[int] = [] + result = _close(session, lambda _s: [111, 222], killed) + assert result.reason == "ambiguous" + assert result.candidates == [111, 222] + assert killed == [] + + +def test_close_session_pid_override_resolves_ambiguity() -> None: + session = _make_session(liveness=SessionLiveness.idle) + killed: list[int] = [] + result = _close(session, lambda _s: [111, 222], killed, pid_override=222) + assert result.reason == "terminated" + assert killed == [222] + + +def test_close_session_pid_override_must_be_a_candidate() -> None: + """--pid pointing at a non-candidate process is an error, not a kill.""" + import pytest + + session = _make_session(liveness=SessionLiveness.running) + killed: list[int] = [] + with pytest.raises(ValueError, match="not a candidate"): + _close(session, lambda _s: [111], killed, pid_override=999) + assert killed == [] + + +def test_close_session_no_process_found() -> None: + session = _make_session(liveness=SessionLiveness.idle) + killed: list[int] = [] + result = _close(session, lambda _s: [], killed) + assert result.reason == "no_process_found" + assert killed == [] From cb5c54f10fff94b60b985feec22545425f04c377 Mon Sep 17 00:00:00 2001 From: Aura - jc <67582323+Catafal@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:32:12 +0200 Subject: [PATCH 2/2] fix: shorten /sessions help lines to lint budget Co-Authored-By: Claude Fable 5 --- src/bach/repl/help_content.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bach/repl/help_content.py b/src/bach/repl/help_content.py index ae33054..f552e90 100644 --- a/src/bach/repl/help_content.py +++ b/src/bach/repl/help_content.py @@ -982,8 +982,8 @@ class CommandHelp: # ===================================================================== "sessions": CommandHelp( synopsis="Show live and recent agent sessions (runtime-plane radar).", - syntax="/sessions [project] [running|idle|ended] [claude|codex] [all] | /sessions close ", - args="filter tokens in any order; `close ` terminates a session (confirms first)", + syntax="/sessions [project] [state] [claude|codex] [all] | /sessions close ", + args="filter tokens in any order; `close ` ends a session (confirms first)", what=( "Scans ~/.claude/projects/ and ~/.codex/sessions/ to find all " "active and recently-ended Claude Code / Codex sessions. Shows "