claude-sessions: folder browser, unique topic, and location column in menu
Claude-Session: https://claude.ai/code/session_01JXneLTuvb5Gxim3DTN2tQa
This commit is contained in:
@@ -4,6 +4,7 @@ Current work focus and active tasks. Used by `context-load` to orient agents at
|
|||||||
|
|
||||||
## Recent
|
## 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.
|
- `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.
|
- 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).
|
- Untracked, left alone: `insights-claude-md-profile-PLAN.md`, `scripts/__pycache__/` (consider gitignoring).
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
## Scripts
|
## 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
|
- [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
|
- [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
|
- [install-hooks](memory/script-install-hooks.md) — Symlinks hooks into ~/.claude/hooks/ and prints settings.json config
|
||||||
|
|||||||
22
memory/script-claude-sessions.md
Normal file
22
memory/script-claude-sessions.md
Normal file
@@ -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.
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
claude-sessions — Session lifecycle manager for Claude Code tmux sessions.
|
claude-sessions — Session lifecycle manager for Claude Code tmux sessions.
|
||||||
|
|
||||||
Commands:
|
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
|
list Show live Claude tmux sessions and resumable registry entries
|
||||||
recover [--days N] Detect sessions active before a restart
|
recover [--days N] Detect sessions active before a restart
|
||||||
spawn <project> Spawn a new session (wraps claude-tmux)
|
spawn <project> 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())
|
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 ─────────────────────────────────────────────────────
|
# ── Session file parsing ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
def read_jsonl_header(path: Path, max_lines: int = 30) -> dict:
|
def read_jsonl_header(path: Path, max_lines: int = 30) -> dict:
|
||||||
@@ -589,24 +773,38 @@ def cmd_menu(_args=None) -> None:
|
|||||||
if not options:
|
if not options:
|
||||||
print(f"\n {DIM}No sessions.{NC}")
|
print(f"\n {DIM}No sessions.{NC}")
|
||||||
else:
|
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:
|
if live:
|
||||||
print(f"\n {BOLD}Live:{NC}")
|
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":
|
if kind != "live":
|
||||||
continue
|
continue
|
||||||
idx = options.index(("live", data)) + 1
|
n = data["tmux_name"]
|
||||||
print(f" {CYAN}{idx:>2}){NC} {GREEN}●{NC} {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:
|
if resumable:
|
||||||
print(f"\n {BOLD}Resumable:{NC}")
|
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":
|
if kind != "resumable":
|
||||||
continue
|
continue
|
||||||
proj = Path(data.get("project_dir", "")).name or data.get("project", "?")
|
proj = resumable_names[id(data)]
|
||||||
sid = (data.get("session_id") or "")[:8]
|
sid = (data.get("session_id") or "")[:8]
|
||||||
ts = (data.get("last_activity") or data.get("spawned_at") or "?")[:16]
|
ts = (data.get("last_activity") or data.get("spawned_at") or "?")[:16]
|
||||||
profile = data.get("profile", "?")
|
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"\n {CYAN} r){NC} Recover sessions from restart (--days 7)")
|
||||||
print(f" {CYAN} n){NC} New session")
|
print(f" {CYAN} n){NC} New session")
|
||||||
@@ -633,20 +831,33 @@ def cmd_menu(_args=None) -> None:
|
|||||||
try:
|
try:
|
||||||
if default_dir:
|
if default_dir:
|
||||||
print(f" Found CLAUDE.md in {BOLD}{default_dir}{NC}")
|
print(f" Found CLAUDE.md in {BOLD}{default_dir}{NC}")
|
||||||
project = input(
|
ans = input(
|
||||||
f" Project [{BOLD}{default_dir.name}{NC}] (Enter = use this, or type another): "
|
f" Folder [{BOLD}{short_path(str(default_dir))}{NC}] "
|
||||||
).strip()
|
f"(Enter = use this, b = browse, or type a project/path): "
|
||||||
if not project:
|
).strip() or str(default_dir)
|
||||||
project = str(default_dir)
|
|
||||||
else:
|
else:
|
||||||
project = input(" Project name: ").strip()
|
ans = input(" Folder (b = browse, or type a project/path): ").strip()
|
||||||
except (KeyboardInterrupt, EOFError):
|
except (KeyboardInterrupt, EOFError):
|
||||||
print()
|
print()
|
||||||
continue
|
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
|
continue
|
||||||
import types
|
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()
|
print()
|
||||||
cmd_spawn(a) # exec's claude-tmux --attach; replaces this process
|
cmd_spawn(a) # exec's claude-tmux --attach; replaces this process
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user