diff --git a/CONTEXT.md b/CONTEXT.md index a56f391..5e701b7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -4,6 +4,7 @@ Current work focus and active tasks. Used by `context-load` to orient agents at ## Recent +- `claude-sessions` (2026-10-26): menu "new" now offers a folder browser (zenity or terminal, with new-folder), a topic prompt (defaults to folder name, must be unique among live sessions), and a Location column in the list. Uncommitted; not yet tried live from the menu. - `claude-sessions` (2026-10-13): new session defaults to cwd's CLAUDE.md project; new and resumed sessions attach (recover attaches only when one session is selected). Pushed (11ee50e). Not yet tried live end-to-end from the menu. - Sonnet driver in `small-scripts/scripts/claude-profile` now `claude-sonnet-5-5`; other drivers (haiku, opus, fable) still on older IDs. - Untracked, left alone: `insights-claude-md-profile-PLAN.md`, `scripts/__pycache__/` (consider gitignoring). diff --git a/MEMORY.md b/MEMORY.md index 1217917..81622d7 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -6,6 +6,7 @@ ## Scripts +- [claude-sessions](memory/script-claude-sessions.md) — tmux session manager: menu with folder browser, unique topic, location column - [context-load](memory/script-context-load.md) — Walks cwd upward collecting CLAUDE.md, CONTEXT.md, MEMORY.md, BESTPRACTICES.md, trees, and git status for system prompt injection - [start-claude](memory/script-start-claude.md) — Wrapper that runs context-load and launches claude with --append-system-prompt - [install-hooks](memory/script-install-hooks.md) — Symlinks hooks into ~/.claude/hooks/ and prints settings.json config diff --git a/memory/script-claude-sessions.md b/memory/script-claude-sessions.md new file mode 100644 index 0000000..a248efb --- /dev/null +++ b/memory/script-claude-sessions.md @@ -0,0 +1,22 @@ +--- +name: script-claude-sessions +description: claude-sessions — tmux session manager; interactive menu with folder picker, unique topic, and location column +metadata: + type: reference +--- + +# claude-sessions + +Python script (`scripts/claude-sessions`, symlinked to `~/sbin/`). Manages Claude Code tmux sessions: list, recover, resume, spawn, gc. Run with no args for the interactive menu. + +## Menu behaviour (2026-10-26) + +- **Location column.** Live and resumable rows show the session folder (`~` for `$HOME`). Live rows take it from the registry (`project_dir`), falling back to tmux `#{session_path}`. +- **`n` New session.** Asks for a folder: Enter accepts the nearest `CLAUDE.md` folder, `b` opens the folder browser, or type a project name/path (same lookup as `claude-tmux`). +- **Folder browser.** `zenity --file-selection --directory` when `DISPLAY`/`WAYLAND_DISPLAY` is set and zenity exists (has Create Folder). Otherwise a terminal browser: number = open, `u` up, `n` new folder, `/text` filter, `>` `<` page, `h` hidden, `~`/`/path` jump, Enter/`s` select, `q` cancel. +- **Topic.** Becomes the tmux session name and the Remote Control name (passed as `--name` to `claude-tmux`). Defaults to the Title-Cased folder name. If that name is already a live tmux session, no default is offered and a unique topic is required. Topics containing `.` or `:` are rejected because tmux rewrites them to `_`. + +## Gotchas + +- "In use" means a live tmux session. Resumable registry entries do not block a topic. +- `claude-tmux` still auto-appends `#N` on a collision, but the menu rejects collisions first. diff --git a/scripts/claude-sessions b/scripts/claude-sessions index 581ce7a..6ad7f76 100755 --- a/scripts/claude-sessions +++ b/scripts/claude-sessions @@ -3,7 +3,7 @@ claude-sessions — Session lifecycle manager for Claude Code tmux sessions. Commands: - (no args) Interactive menu + (no args) Interactive menu (new session: folder browser + unique topic) list Show live Claude tmux sessions and resumable registry entries recover [--days N] Detect sessions active before a restart spawn Spawn a new session (wraps claude-tmux) @@ -162,6 +162,190 @@ def title_case(s: str) -> str: return " ".join(w.capitalize() for w in s.replace("-", " ").replace("_", " ").split()) +def live_session_paths() -> dict[str, str]: + """Map live tmux session name -> directory the session was created in.""" + try: + r = subprocess.run(["tmux", "ls", "-F", "#{session_name}\t#{session_path}"], + capture_output=True, text=True) + except Exception: + return {} + if r.returncode != 0: + return {} + result = {} + for line in r.stdout.splitlines(): + name, _, path = line.partition("\t") + result[name] = path + return result + + +def short_path(path: str) -> str: + """Abbreviate $HOME to ~ for display.""" + if not path: + return "?" + home = str(HOME) + if path == home: + return "~" + if path.startswith(home + "/"): + return "~" + path[len(home):] + return path + + +def resolve_project_dir(p: str) -> Optional[Path]: + """Same lookup order as claude-tmux: literal path, then under CLAUDE_ROOT, then CLAUDE_ROOT/projects.""" + for cand in (Path(p).expanduser(), CLAUDE_ROOT / p, CLAUDE_ROOT / "projects" / p): + if cand.is_dir(): + return cand.resolve() + return None + + +def topic_in_use(topic: str, live: set[str]) -> bool: + # tmux rewrites '.' and ':' in session names to '_', so compare the stored form. + return topic in live or topic.replace(".", "_").replace(":", "_") in live + + +# ── Folder picker ──────────────────────────────────────────────────────────── + +def pick_folder_gui(start: Path) -> Optional[Path]: + """GTK folder chooser via zenity (has a Create Folder button). None if cancelled or unavailable.""" + if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")): + return None + if subprocess.run(["which", "zenity"], capture_output=True).returncode != 0: + return None + r = subprocess.run( + ["zenity", "--file-selection", "--directory", + "--title=Select a folder for the new Claude session", + f"--filename={start}/"], + capture_output=True, text=True, + ) + if r.returncode == 0 and r.stdout.strip(): + return Path(r.stdout.strip()) + return None + + +def pick_folder_tui(start: Path, page_size: int = 15) -> Optional[Path]: + """Terminal folder browser with new-folder support. Returns None if cancelled.""" + cur = start.resolve() if start.is_dir() else HOME + flt = "" + page = 0 + show_hidden = False + while True: + try: + subdirs = sorted( + (d for d in cur.iterdir() + if d.is_dir() and (show_hidden or not d.name.startswith("."))), + key=lambda d: d.name.lower(), + ) + except PermissionError: + subdirs = [] + warn(f"Cannot read {cur}") + if flt: + subdirs = [d for d in subdirs if flt.lower() in d.name.lower()] + pages = max(1, -(-len(subdirs) // page_size)) + page = min(page, pages - 1) + shown = subdirs[page * page_size:(page + 1) * page_size] + + os.system("clear 2>/dev/null || true") + print(f"\n{BOLD}Select folder{NC} {CYAN}{short_path(str(cur))}{NC}") + print("─" * 50) + if flt: + print(f" {DIM}filter: {flt!r}{NC}") + if not shown: + print(f" {DIM}(no subfolders){NC}") + for i, d in enumerate(shown, 1): + print(f" {CYAN}{i:>2}){NC} {d.name}/") + if pages > 1: + print(f" {DIM}page {page + 1}/{pages}{NC}") + print(f"\n {DIM}number = open · Enter or s = select this folder · u = up · n = new folder{NC}") + print(f" {DIM}/text = filter · > < = page · h = hidden ({'on' if show_hidden else 'off'}) · ~ or /path = jump · q = cancel{NC}") + + try: + raw = input(" > ").strip() + except (KeyboardInterrupt, EOFError): + print() + return None + + if raw in ("", "s"): + return cur + elif raw == "q": + return None + elif raw == "u": + cur, flt, page = cur.parent, "", 0 + elif raw == ">": + page = min(page + 1, pages - 1) + elif raw == "<": + page = max(page - 1, 0) + elif raw == "h": + show_hidden = not show_hidden + page = 0 + elif raw == "n": + try: + name = input(" New folder name: ").strip() + except (KeyboardInterrupt, EOFError): + print() + continue + if not name: + continue + if "/" in name or name in (".", ".."): + print(f" {YELLOW}Name must not contain '/' or be '.'/'..'{NC}") + time.sleep(1.2) + continue + target = cur / name + if target.exists(): + print(f" {YELLOW}{name} already exists{NC}") + time.sleep(1.2) + continue + try: + target.mkdir() + except OSError as e: + print(f" {YELLOW}Could not create folder: {e}{NC}") + time.sleep(1.5) + continue + cur, flt, page = target, "", 0 + elif raw.startswith("/") and len(raw) > 1 and not Path(raw).is_dir(): + flt, page = raw[1:], 0 + elif raw.startswith(("/", "~")): + dest = Path(raw).expanduser() + if dest.is_dir(): + cur, flt, page = dest.resolve(), "", 0 + else: + print(f" {YELLOW}Not a folder: {raw}{NC}") + time.sleep(1.2) + elif raw.isdigit() and 1 <= int(raw) <= len(shown): + cur, flt, page = shown[int(raw) - 1], "", 0 + else: + print(f" {YELLOW}Unknown choice: {raw!r}{NC}") + time.sleep(1) + + +def pick_folder(start: Path) -> Optional[Path]: + return pick_folder_gui(start) or pick_folder_tui(start) + + +def prompt_topic(folder: Path, live: set[str]) -> Optional[str]: + """Topic defaults to the folder name; if that is taken, the user must supply a unique one.""" + default = title_case(folder.name) + taken = topic_in_use(default, live) + if taken: + print(f" {YELLOW}Topic '{default}' is already in use — enter a unique topic.{NC}") + while True: + try: + if taken: + topic = input(" Topic: ").strip() + else: + topic = input(f" Topic [{BOLD}{default}{NC}]: ").strip() or default + except (KeyboardInterrupt, EOFError): + print() + return None + if not topic: + print(f" {YELLOW}A topic is required.{NC}") + elif topic_in_use(topic, live): + print(f" {YELLOW}Topic '{topic}' is already in use — enter a different one.{NC}") + elif any(c in topic for c in ".:"): + print(f" {YELLOW}Topic must not contain '.' or ':' (tmux rewrites them).{NC}") + else: + return topic + + # ── Session file parsing ───────────────────────────────────────────────────── def read_jsonl_header(path: Path, max_lines: int = 30) -> dict: @@ -589,24 +773,38 @@ def cmd_menu(_args=None) -> None: if not options: print(f"\n {DIM}No sessions.{NC}") else: + reg_dirs = {e.get("tmux_name"): e.get("project_dir", "") for e in registry.get("sessions", [])} + tmux_dirs = live_session_paths() + resumable_names = { + id(d): Path(d.get("project_dir", "")).name or d.get("project", "?") + for k, d in options if k == "resumable" + } + name_w = min(36, max([len(d["tmux_name"]) for k, d in options if k == "live"] + + [len(n) for n in resumable_names.values()] + [7])) + if live: print(f"\n {BOLD}Live:{NC}") - for i, (kind, data) in enumerate(options, 1): + print(f" {DIM}{'Session':<{name_w}} Location{NC}") + for idx, (kind, data) in enumerate(options, 1): if kind != "live": continue - idx = options.index(("live", data)) + 1 - print(f" {CYAN}{idx:>2}){NC} {GREEN}●{NC} {data['tmux_name']}") + n = data["tmux_name"] + loc = short_path(reg_dirs.get(n) or tmux_dirs.get(n, "")) + print(f" {CYAN}{idx:>2}){NC} {GREEN}●{NC} {n:<{name_w}} {DIM}{loc}{NC}") if resumable: print(f"\n {BOLD}Resumable:{NC}") - for i, (kind, data) in enumerate(options, 1): + print(f" {DIM}{'Session':<{name_w}} {'Location':<40} Details{NC}") + for idx, (kind, data) in enumerate(options, 1): if kind != "resumable": continue - proj = Path(data.get("project_dir", "")).name or data.get("project", "?") + proj = resumable_names[id(data)] sid = (data.get("session_id") or "")[:8] ts = (data.get("last_activity") or data.get("spawned_at") or "?")[:16] profile = data.get("profile", "?") - print(f" {CYAN}{i:>2}){NC} {DIM}○{NC} {proj:<28} {DIM}{profile} · {sid} · {ts}{NC}") + loc = short_path(data.get("project_dir", "")) + print(f" {CYAN}{idx:>2}){NC} {DIM}○{NC} {proj:<{name_w}} " + f"{loc:<40} {DIM}{profile} · {sid} · {ts}{NC}") print(f"\n {CYAN} r){NC} Recover sessions from restart (--days 7)") print(f" {CYAN} n){NC} New session") @@ -633,20 +831,33 @@ def cmd_menu(_args=None) -> None: try: if default_dir: print(f" Found CLAUDE.md in {BOLD}{default_dir}{NC}") - project = input( - f" Project [{BOLD}{default_dir.name}{NC}] (Enter = use this, or type another): " - ).strip() - if not project: - project = str(default_dir) + ans = input( + f" Folder [{BOLD}{short_path(str(default_dir))}{NC}] " + f"(Enter = use this, b = browse, or type a project/path): " + ).strip() or str(default_dir) else: - project = input(" Project name: ").strip() + ans = input(" Folder (b = browse, or type a project/path): ").strip() except (KeyboardInterrupt, EOFError): print() continue - if not project: + if not ans: + continue + if ans.lower() == "b": + folder = pick_folder(default_dir or Path.cwd()) + if folder is None: + continue + print(f" Folder: {BOLD}{short_path(str(folder))}{NC}") + else: + folder = resolve_project_dir(ans) + if folder is None: + print(f" {YELLOW}Folder not found: {ans}{NC}") + time.sleep(1.5) + continue + topic = prompt_topic(folder, list_tmux_sessions()) + if topic is None: continue import types - a = types.SimpleNamespace(project=project, mode=None, profile=None, name=None, attach=True) + a = types.SimpleNamespace(project=str(folder), mode=None, profile=None, name=topic, attach=True) print() cmd_spawn(a) # exec's claude-tmux --attach; replaces this process