diff --git a/dot_local/bin/executable_omp-session b/dot_local/bin/executable_omp-session new file mode 100755 index 0000000..93058d4 --- /dev/null +++ b/dot_local/bin/executable_omp-session @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +"""omp-session — start or resume omp for the current git repo (ctrl-a in fish +and zsh), and the backend of the television `omp-sessions` cable +(~/.config/television/cable/omp-sessions.toml). + +Launch rules, inside a git repo: + default branch resume the newest session on it if it was written less than + 3 hours ago; otherwise pick one with `tv omp-sessions` + (all sessions of the repo). No sessions at all → new session. + other branch resume the newest session on it; none → new session. + detached HEAD pick with `tv omp-sessions` (no sessions → new session). +Outside a git repo: plain `omp`. + +A session belongs to the repo when its cwd is the worktree root or below it, +excluding worktrees nested inside it. omp does not record the git branch, so +a session's branch is the branch HEAD was on (per `git reflog`) when the +session file was last written. + +Cable entry points: + omp-session --list one "displaysession file" record per session + omp-session --preview FILE title, cwd, and the conversation's message text + omp-session --resume FILE the cable's enter action: `omp --resume FILE` + +Env: + OMP_SESSION_STATE=FILE --resume writes `ran` to it, so the launcher can tell + a resumed session from a cancelled picker and only + move the cursor back up after a cancel (tv --inline). + PI_CODING_AGENT_DIR omp's agent dir (default ~/.omp/agent). +""" + +import json +import os +import re +import signal +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +RECENT = 3 * 3600 +SESSIONS = Path(os.environ.get("PI_CODING_AGENT_DIR") or Path.home() / ".omp" / "agent") / "sessions" + +# tv closes the preview pipe early on long sessions; exit quietly. +signal.signal(signal.SIGPIPE, signal.SIG_DFL) + +MOVES = ( + # (pattern, group of the branch HEAD moved to, group of the branch it left) + (re.compile(r"checkout: moving from (.+) to (.+)$"), 2, 1), + (re.compile(r"Branch: renamed refs/heads/(.+) to refs/heads/(.+)$"), 2, 1), + # rebase (start) detaches HEAD; the finish returns to the rebased branch. + (re.compile(r"rebase.*\(finish\): returning to refs/heads/(.+)$"), 1, 1), +) + + +def git(*args): + r = subprocess.run(["git", *args], capture_output=True, text=True) + return r.stdout.strip() if r.returncode == 0 else "" + + +def inside(path, root): + return path == root or path.startswith(root.rstrip("/") + "/") + + +class Session: + __slots__ = ("path", "mtime", "cwd", "title") + + def __init__(self, path, mtime, cwd, title): + self.path, self.mtime, self.cwd, self.title = path, mtime, cwd, title + + +def header(path): + """(cwd, title) from the head of a session file: an optional title record, + then the session record.""" + title = None + try: + with open(path, encoding="utf-8", errors="replace") as fh: + for _, line in zip(range(4), fh): + rec = json.loads(line) + if rec.get("type") == "title": + title = rec.get("title") + elif rec.get("type") == "session": + return rec.get("cwd"), title + except (OSError, ValueError): + pass + return None + + +def repo_sessions(top): + """Sessions whose cwd is inside the worktree `top`, newest first.""" + nested = [ + line[len("worktree "):] + for line in git("worktree", "list", "--porcelain").splitlines() + if line.startswith("worktree ") + ] + nested = [w for w in nested if w != top and inside(w, top)] + out = [] + for path in SESSIONS.glob("*/*.jsonl"): + h = header(path) + if not h or not h[0]: + continue + cwd, title = h + if not inside(cwd, top) or any(inside(cwd, w) for w in nested): + continue + try: + mtime = path.stat().st_mtime + except OSError: + continue + out.append(Session(path, mtime, cwd, title)) + out.sort(key=lambda s: s.mtime, reverse=True) + return out + + +class Timeline: + """Which branch HEAD was on at a given time, from this worktree's reflog.""" + + def __init__(self, current): + self.current = current + self.moves = [] # (unix time, to, from), newest first + for line in git("reflog", "--date=unix", "--format=%gd%x09%gs").splitlines(): + ref, _, subject = line.partition("\t") + m = re.search(r"@\{(\d+)\}", ref) + if not m: + continue + for pat, to, frm in MOVES: + hit = pat.search(subject) + if hit: + self.moves.append((int(m.group(1)), hit.group(to), hit.group(frm))) + break + + def branch_at(self, t): + for ts, to, _ in self.moves: + if ts <= t: + return to + # Older than every recorded move: the branch the oldest move left. + return self.moves[-1][2] if self.moves else self.current + + +def default_branch(): + ref = git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD") + if ref: + return ref.split("/", 1)[1] + for b in (git("config", "init.defaultBranch"), "main", "master"): + if b and git("rev-parse", "--verify", "--quiet", f"refs/heads/{b}"): + return b + return "" + + +def first_user_text(path): + try: + with open(path, encoding="utf-8", errors="replace") as fh: + for line in fh: + if '"role":"user"' not in line: + continue + text = message_text(json.loads(line).get("message") or {}) + if text.strip(): + return text.strip().splitlines()[0] + except (OSError, ValueError): + pass + return "" + + +def message_text(msg): + content = msg.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + return "\n".join(p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text") + return "" + + +def age(seconds): + for unit, size in (("w", 604800), ("d", 86400), ("h", 3600), ("m", 60)): + if seconds >= size: + return f"{int(seconds // size)}{unit}" + return "now" + + +# ── Cable entry points ───────────────────────────────────────────── + + +def cmd_list(): + top = git("rev-parse", "--show-toplevel") + if not top: + return + sessions = repo_sessions(top) + timeline = Timeline(git("branch", "--show-current")) + now = time.time() + rows = [] + for s in sessions: + branch = timeline.branch_at(s.mtime) + title = s.title or first_user_text(s.path) or s.path.stem + sub = os.path.relpath(s.cwd, top) + rows.append((age(now - s.mtime), branch, title + ("" if sub == "." else f" · {sub}"), s.path)) + width = min(max((len(r[1]) for r in rows), default=0), 24) + for when, branch, title, path in rows: + print(f"{when:>4} {branch[:width]:<{width}} {title}\t{path}") + + +def cmd_preview(path): + path = Path(path) + h = header(path) + cwd, title = h if h else ("", None) + updated = time.strftime("%Y-%m-%d %H:%M", time.localtime(path.stat().st_mtime)) + print(f"\033[1m{title or path.stem}\033[0m") + print(f"\033[2m{updated} · {cwd}\033[0m") + colors = {"user": "\033[1;36m", "assistant": "\033[1;35m"} + with open(path, encoding="utf-8", errors="replace") as fh: + for line in fh: + if '"type":"message"' not in line: + continue + try: + msg = json.loads(line).get("message") or {} + except ValueError: + continue + role = msg.get("role") + text = message_text(msg).strip() if role in colors else "" + if text: + print(f"\n{colors[role]}▌ {role}\033[0m\n{text}") + + +def cmd_resume(path): + state = os.environ.get("OMP_SESSION_STATE") + if state: + Path(state).write_text("ran\n") + os.execvp("omp", ["omp", "--resume", str(path)]) + + +# ── Launcher ─────────────────────────────────────────────────────── + + +def new_session(): + os.execvp("omp", ["omp"]) + + +def pick(sessions): + if not sessions: + new_session() + fd, state = tempfile.mkstemp(prefix="omp-session.") + os.close(fd) + try: + subprocess.run(["tv", "omp-sessions", "--inline"], env={**os.environ, "OMP_SESSION_STATE": state}) + ran = Path(state).read_text().strip() == "ran" + finally: + os.unlink(state) + if not ran: + # Cancelled: tv left the cursor on the line below the prompt. + with open("/dev/tty", "w") as tty: + tty.write("\033[A") + + +def launch(): + top = git("rev-parse", "--show-toplevel") + if not top: + new_session() + sessions = repo_sessions(top) + branch = git("branch", "--show-current") + if not branch: + pick(sessions) + return + timeline = Timeline(branch) + mine = next((s for s in sessions if timeline.branch_at(s.mtime) == branch), None) + if branch == default_branch(): + if mine and time.time() - mine.mtime < RECENT: + cmd_resume(mine.path) + pick(sessions) + elif mine: + cmd_resume(mine.path) + else: + new_session() + + +def main(argv): + if not argv: + launch() + elif argv[0] == "--list": + cmd_list() + elif argv[0] == "--preview" and len(argv) == 2: + cmd_preview(argv[1]) + elif argv[0] == "--resume" and len(argv) == 2: + cmd_resume(argv[1]) + else: + sys.exit("usage: omp-session [--list | --preview FILE | --resume FILE]") + + +if __name__ == "__main__": + main(sys.argv[1:])