From 31659cf20be31e1917a5156e91c7664f31d49b3e Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 17 May 2026 04:47:15 -0700 Subject: [PATCH] =?UTF-8?q?feat(@scripts):=20=E2=9C=A8=20add=20claude=20tr?= =?UTF-8?q?iage=20helper=20cli=20tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- bin/_claude-triage | 287 +++++++++++++++++++++++++++++++++++++++++++ bin/rclaude | 295 +++++++++++++++++++-------------------------- 2 files changed, 414 insertions(+), 168 deletions(-) create mode 100755 bin/_claude-triage diff --git a/bin/_claude-triage b/bin/_claude-triage new file mode 100755 index 0000000..651c6b2 --- /dev/null +++ b/bin/_claude-triage @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +"""rclaude triage helper — Haiku-powered per-session summary + prioritization. + +Iterates ~/.claude/projects/, builds a compressed transcript per session, and +runs them through claude-code-batch-sdk (Haiku) with content-addressable +disk caching. Sessions whose mtime + transcript fingerprint haven't changed +are served from cache. + +Output (TSV, one row per session, sorted by priority desc / mtime desc): + \\t\\t\\t\\t\\t\\t + +Env tuning: + RCLAUDE_TRIAGE_MODEL Claude model (default: haiku) + RCLAUDE_TRIAGE_CONCURRENT Concurrent claude subprocesses (default: 4) + RCLAUDE_TRIAGE_BATCH Sessions per CLI call (default: 8) + RCLAUDE_TRIAGE_LIMIT Max sessions to consider (default: 100) + RCLAUDE_TRIAGE_CTX_BYTES Bytes of transcript per session (default: 3000) + +CLI flags: + --limit N override session cap + --uuids U... restrict to specific session UUIDs (prefix match ok) + --refresh bypass cache for selected sessions +""" +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import re +import signal +import sys +from pathlib import Path + +# Allow piped output to truncate without traceback. +signal.signal(signal.SIGPIPE, signal.SIG_DFL) + +try: + from claude_code_batch_sdk import ( + ClaudeClient, + GenerationItem, + ResponseCache, + run_batched, + ) +except ImportError as e: + print( + f"_claude-triage: claude-code-batch-sdk not installed for this python ({sys.executable}): {e}", + file=sys.stderr, + ) + sys.exit(2) + +ROOT = Path.home() / ".claude" / "projects" +CACHE_DIR = Path.home() / ".claude" / ".cache" / "rclaude-triage" + +MODEL = os.environ.get("RCLAUDE_TRIAGE_MODEL", "haiku") +MAX_CONCURRENT = int(os.environ.get("RCLAUDE_TRIAGE_CONCURRENT", "4")) +BATCH_SIZE = int(os.environ.get("RCLAUDE_TRIAGE_BATCH", "8")) +CTX_PER_SESSION = int(os.environ.get("RCLAUDE_TRIAGE_CTX_BYTES", "3000")) + +SYSTEM_PREFIXES = ( + "", "", "", "", "", "[task-persistence]", "[tts-state]", + "This session is being continued", "Please continue", "", + "[Request interrupted", +) + + +def is_system_user(text: str) -> bool: + s = (text or "").lstrip() + return not s or s.startswith(SYSTEM_PREFIXES) + + +def get_text(entry: dict) -> str: + msg = entry.get("message") or {} + content = msg.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for block in content: + if not isinstance(block, dict): + continue + t = block.get("type") + if t == "text": + parts.append(block.get("text", "")) + elif t == "tool_use": + parts.append(f"[tool:{block.get('name','?')}]") + return " ".join(parts) + return "" + + +def compress_session(jsonl: Path) -> tuple[str, str, str]: + """Return (cwd, first_user_text, transcript_excerpt).""" + first_user = "" + cwd = "" + turns: list[str] = [] + try: + with jsonl.open(encoding="utf-8", errors="replace") as f: + for line in f: + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + if not cwd and entry.get("cwd"): + cwd = entry["cwd"] + role = (entry.get("message") or {}).get("role") or entry.get("type") + if role not in ("user", "assistant"): + continue + text = get_text(entry) + if not text.strip(): + continue + if role == "user" and is_system_user(text): + continue + flat = re.sub(r"\s+", " ", text).strip() + if role == "user" and not first_user: + first_user = flat[:400] + turns.append(f"[{role}] {flat[:300]}") + except OSError: + return ("", "", "") + # Keep recent turns; head-truncate to budget. + transcript = "\n".join(turns[-20:])[-CTX_PER_SESSION:] + return (cwd, first_user, transcript) + + +SYSTEM_PROMPT = """You triage Claude Code coding sessions. For each session in the input batch, emit one JSON object with these fields: +- ref_index: integer matching the input's ref_index +- summary: ONE short sentence describing what's happening +- status: one of done, in_progress, blocked, waiting_on_user, abandoned +- priority: integer 1-5 (5 = critical to resume now, 1 = abandonable) +- next_action: ONE short imperative phrase, or empty string if status is done/abandoned + +Output ONLY a JSON array. No markdown, no prose.""" + + +def build_prompt(batch: list[GenerationItem]) -> str: + parts = ["Triage these sessions. Respond with JSON array.\n"] + for i, item in enumerate(batch): + m = item.metadata + parts.append(f"\n=== ref_index={i} ===") + parts.append(f"INITIAL REQUEST: {m['first_user']}") + parts.append("RECENT TRANSCRIPT:") + parts.append(m["transcript"]) + parts.append( + '\n\nReply with: [{"ref_index": 0, "summary": "...", ' + '"status": "...", "priority": N, "next_action": "..."}, ...]' + ) + return "\n".join(parts) + + +def validate(result: dict) -> bool: + if not isinstance(result, dict): + return False + if not all(k in result for k in ("summary", "status", "priority", "next_action")): + return False + try: + int(result["priority"]) + except (TypeError, ValueError): + return False + return True + + +def enrich(result: dict, item: GenerationItem) -> dict: + return { + **result, + "uuid": item.metadata["uuid"], + "cwd": item.metadata["cwd"], + "mtime": item.metadata["mtime"], + } + + +def collect_candidates(limit: int) -> list[tuple[int, Path]]: + if not ROOT.is_dir(): + return [] + candidates: list[tuple[int, Path]] = [] + for project_dir in ROOT.iterdir(): + if not project_dir.is_dir(): + continue + for jsonl in project_dir.glob("*.jsonl"): + try: + mtime = int(jsonl.stat().st_mtime) + except OSError: + continue + candidates.append((mtime, jsonl)) + candidates.sort(key=lambda x: x[0], reverse=True) + return candidates[:limit] + + +async def main_async(args: argparse.Namespace) -> None: + candidates = collect_candidates(args.limit) + if args.uuids: + wanted = list(args.uuids) + candidates = [ + (m, j) for (m, j) in candidates + if any(j.stem == u or j.stem.startswith(u) for u in wanted) + ] + if not candidates: + return + + items: list[GenerationItem] = [] + for mtime, jsonl in candidates: + cwd, first_user, transcript = compress_session(jsonl) + if not cwd: + continue + cache_key = f"{jsonl.stem}:{mtime}:{len(transcript)}" + items.append( + GenerationItem( + template_id="rclaude-triage-v1", + cache_key=cache_key, + metadata={ + "uuid": jsonl.stem, + "cwd": cwd, + "mtime": mtime, + "first_user": first_user, + "transcript": transcript, + }, + ) + ) + if not items: + return + + cache = ResponseCache(CACHE_DIR) + if args.refresh: + for item in items: + key_hash = cache._hash_key(item.template_id, item.cache_key) + path = cache._cache_path(item.template_id, key_hash) + if path.exists(): + try: + path.unlink() + except OSError: + pass + + client = ClaudeClient(model=MODEL, max_concurrent=MAX_CONCURRENT) + try: + results = await run_batched( + client=client, + cache=cache, + items=items, + system_prompt=SYSTEM_PROMPT, + build_batch_prompt=build_prompt, + validate_result=validate, + enrich_result=enrich, + index_key="ref_index", + batch_size=BATCH_SIZE, + description="rclaude-triage", + ) + finally: + await client.close() + + def sort_key(r: dict) -> tuple[int, int]: + try: + prio = int(r.get("priority", 0)) + except (TypeError, ValueError): + prio = 0 + return (prio, int(r.get("mtime", 0))) + + for r in sorted(results, key=sort_key, reverse=True): + line = "\t".join( + [ + str(r.get("mtime", 0)), + str(r.get("uuid", "")), + str(r.get("cwd", "")), + str(r.get("priority", 0)), + str(r.get("status", "")), + re.sub(r"\s+", " ", str(r.get("summary", "")))[:200], + re.sub(r"\s+", " ", str(r.get("next_action", "")))[:200], + ] + ) + print(line) + print(f"# cache {cache.stats_summary()}", file=sys.stderr) + + +def main() -> None: + ap = argparse.ArgumentParser(description="Triage Claude Code sessions with Haiku.") + ap.add_argument( + "--limit", + type=int, + default=int(os.environ.get("RCLAUDE_TRIAGE_LIMIT", "100")), + help="Max sessions to consider (default: 100, env: RCLAUDE_TRIAGE_LIMIT)", + ) + ap.add_argument("--uuids", nargs="*", help="Restrict to specific UUIDs (prefix ok)") + ap.add_argument("--refresh", action="store_true", help="Bypass cache") + args = ap.parse_args() + asyncio.run(main_async(args)) + + +if __name__ == "__main__": + main() diff --git a/bin/rclaude b/bin/rclaude index 35e3549..6f16ea8 100755 --- a/bin/rclaude +++ b/bin/rclaude @@ -14,11 +14,6 @@ # Permission mode: --dangerously-skip-permissions is on by default. Override # with RCLAUDE_PERMS=default (or any --permission-mode value). # -# Tmux config sync: the repo's canonical tmux.conf is pushed to the target -# host's ~/.tmux.d/session-tools.conf on every launch and source-lined from -# ~/.tmux.conf. Disable with RCLAUDE_SYNC_TMUX=0, or set =once to only write -# if the source-line isn't already present. -# # Hosts scanned by `list`/`resume` default to: local + apricot + plum (the # non-local one is dialed; the local one is rendered as "local"). Override # with RCLAUDE_HOSTS="apricot black quinn-vps". @@ -31,8 +26,20 @@ # rclaude # remote (or local) at # rclaude list # tmux + per-project disk view # rclaude list sessions # tmux + per-session disk view (uuid + snippet) -# rclaude resume # picker: live tmux + most-recent disk (--- separator) -# rclaude resume # search tmux + disk sessions (uuid, snippet, cwd) +# rclaude triage [--limit N] [--refresh] # Haiku-powered ranked summary of recent sessions +# # (uses claude-code-batch-sdk + content cache) +# rclaude resume [pattern] # reattach / resume by uuid prefix, snippet, +# # tmux name, or cwd substring (interactive +# # picker on >1 match) +# +# Config file: $XDG_CONFIG_HOME/rclaude/config (defaults to ~/.config/rclaude/config). +# A plain shell fragment sourced at startup. Useful settings: +# RCLAUDE_TRIAGE=auto # auto-rank sessions in `resume` (default: off) +# RCLAUDE_TRIAGE_MODEL=haiku # Claude model for triage +# RCLAUDE_TRIAGE_LIMIT=100 # max sessions to triage per host +# RCLAUDE_TRIAGE_CONCURRENT=4 # concurrent claude subprocesses +# RCLAUDE_TRIAGE_BATCH=8 # sessions per claude CLI call +# RCLAUDE_HOSTS="apricot plum" # hosts to scan # # Mirror semantics: if local $PWD is $HOME/X/Y, the remote dir defaults to # ~/X/Y on the remote (the remote's $HOME, not $HOME from this machine). @@ -40,6 +47,14 @@ set -eu +# Load user config if present. Lets the user set RCLAUDE_TRIAGE=auto (and +# friends) once instead of exporting on every invocation. Config file is a +# plain shell fragment sourced into the current shell. +if [ -r "${XDG_CONFIG_HOME:-$HOME/.config}/rclaude/config" ]; then + # shellcheck disable=SC1090 + . "${XDG_CONFIG_HOME:-$HOME/.config}/rclaude/config" +fi + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -118,8 +133,7 @@ list_disk_on() { # List on-disk Claude sessions per UUID on a host (via _claude-projects --sessions). # Output one row per session jsonl: -# \tsession\t\t\t · \t -# (col 6 is hidden — used for cross-host dedup/sort, stripped before display) +# \tsession\t\t\t · list_sessions_on() { _host=$1 _helper_dir=$(dirname "$(resolve_self)") @@ -140,7 +154,7 @@ list_sessions_on() { } NF >= 3 { snippet = ($4 == "" ? "(no user text)" : $4) - printf "%s\tsession\t%s\t%s\t%s · %s\t%s\n", host, $2, snippet, $3, rel(now - $1), $1 + printf "%s\tsession\t%s\t%s\t%s · %s\n", host, $2, snippet, $3, rel(now - $1) } ' } @@ -157,114 +171,62 @@ list_search_on() { list_sessions_on "$1" } -# Get $HOME on (cached per host in /tmp for the life of the shell). -get_home() { - _h=$1 - _cache="/tmp/rclaude-home.$(whoami).$(printf %s "$_h" | tr -c 'A-Za-z0-9' '_')" - if [ -s "$_cache" ]; then - cat "$_cache"; return - fi - if is_local "$_h"; then - _v=$HOME +# Pick a python that has claude_code_batch_sdk importable. Walks 3.13/.12/.11 +# then falls back to plain python3. The SDK requires Python 3.11+. Tries each +# candidate with a real `-c "import claude_code_batch_sdk"` so a non-SDK +# install of a newer python doesn't shadow an SDK-equipped older one. +_PICK_PY_SNIPPET='for _p in python3.13 python3.12 python3.11 python3; do _b=$(command -v "$_p" 2>/dev/null) || continue; "$_b" -c "import claude_code_batch_sdk" 2>/dev/null && PY="$_b" && break; done; [ -z "${PY:-}" ] && { echo "rclaude: no python with claude_code_batch_sdk found" >&2; exit 2; }' +_REMOTE_TRIAGE_BOOT='export PATH=$HOME/.local/bin:/opt/homebrew/bin:$PATH; '"$_PICK_PY_SNIPPET"';' + +# Run the triage helper on with the supplied extra args. Stdout is the +# raw TSV emitted by _claude-triage (one row per session). +list_triage_on() { + _host=$1 + shift + _helper_dir=$(dirname "$(resolve_self)") + _helper="$_helper_dir/_claude-triage" + [ -f "$_helper" ] || return 0 + if is_local "$_host"; then + PY="" + for _p in python3.13 python3.12 python3.11 python3; do + _b=$(command -v "$_p" 2>/dev/null) || continue + "$_b" -c "import claude_code_batch_sdk" 2>/dev/null && PY=$_b && break + done + if [ -z "$PY" ]; then + echo "rclaude: no python with claude_code_batch_sdk found locally" >&2 + return 1 + fi + "$PY" "$_helper" "$@" 2>/dev/null || true else - _v=$(ssh -o BatchMode=yes -o ConnectTimeout=3 "$_h" 'printf %s "$HOME"' 2>/dev/null || true) - fi - [ -n "$_v" ] && printf '%s' "$_v" > "$_cache" && printf %s "$_v" -} - -# Compute Claude's project-slug from a cwd path. Claude replaces every -# non-alphanumeric character with `-` (so `/` and `@` both become `-`). -# /Users/natalie/Code/@projects/@lilith → -Users-natalie-Code--projects--lilith -claude_slug() { - printf %s "$1" | sed 's|[^A-Za-z0-9]|-|g' -} - -# Mirror a session JSONL from 's ~/.claude/projects//.jsonl -# to 's ~/.claude/projects//.jsonl, rewriting every -# `"cwd":""` occurrence to point at . Skips if dst is -# already newer than src. -migrate_session() { - _src=$1; _dst=$2; _uuid=$3; _src_cwd=$4; _dst_cwd=$5 - _src_slug=$(claude_slug "$_src_cwd") - _dst_slug=$(claude_slug "$_dst_cwd") - _src_path="\$HOME/.claude/projects/${_src_slug}/${_uuid}.jsonl" - _dst_path="\$HOME/.claude/projects/${_dst_slug}/${_uuid}.jsonl" - - # Stream src jsonl → cwd rewrite → dst jsonl. Use python for safe JSON. - _rewrite_py=$(cat <<'PY' -import json, os, sys -old, new = sys.argv[1], sys.argv[2] -for line in sys.stdin: - try: - e = json.loads(line) - except Exception: - sys.stdout.write(line); continue - cwd = e.get("cwd") - if isinstance(cwd, str): - if cwd == old: - e["cwd"] = new - elif cwd.startswith(old + "/"): - e["cwd"] = new + cwd[len(old):] - sys.stdout.write(json.dumps(e) + "\n") -PY -) - - # Pull src → local pipe → rewrite → push to dst. Two ssh hops. - if is_local "$_src"; then - _read="cat $(printf '%s/.claude/projects/%s/%s.jsonl' "$HOME" "$_src_slug" "$_uuid")" - _src_data=$(sh -c "$_read" 2>/dev/null || true) - else - _src_data=$(ssh -o BatchMode=yes -o ConnectTimeout=5 "$_src" "cat $_src_path" 2>/dev/null || true) - fi - if [ -z "$_src_data" ]; then - echo "rclaude: source session not found on $_src ($_src_path)" >&2 - return 1 - fi - _rewritten=$(printf '%s' "$_src_data" | python3 -c "$_rewrite_py" "$_src_cwd" "$_dst_cwd") || { - echo "rclaude: cwd rewrite failed" >&2; return 1; } - - _mkdir="mkdir -p \$HOME/.claude/projects/${_dst_slug}" - _write="cat > $_dst_path" - if is_local "$_dst"; then - sh -c "$_mkdir && $_write" <&2; return 1; } - printf 'rclaude: mirrored session %s → %s (%s)\n' "$(printf %s "$_uuid" | cut -c1-8)" "$_dst" "$_dst_cwd" >&2 + _args="" + for a in "$@"; do + _args="$_args $(printf %s "$a" | sed 's/"/\\"/g; s/^/"/; s/$/"/')" + done + # Stream the helper over stdin so we don't depend on it being + # pre-installed on the remote. + ssh -o BatchMode=yes -o ConnectTimeout=5 "$_host" \ + "${_REMOTE_TRIAGE_BOOT} \$PY -${_args}" \ + < "$_helper" 2>/dev/null || true + fi | awk -F'\t' -v host="$_host" ' + # _claude-triage writes informational lines starting with "# " to + # stderr; the stdout we capture is pure TSV. Each row: + # mtime\tuuid\tcwd\tpriority\tstatus\tsummary\tnext_action + NF >= 7 { printf "%s\ttriage\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + host, $2, $4, $5, $6, $7, $3, $1 } + ' } # Push the canonical session-tools tmux fragment to and ensure # ~/.tmux.conf sources it. Idempotent; runs on every launch so config changes -# in the repo propagate without re-running install.sh on each host. -# -# Controlled by RCLAUDE_SYNC_TMUX: -# 1 (default) auto-sync on every launch -# 0 never sync (e.g. host has a hand-tuned tmux config you don't -# want clobbered by a stray source-file line) -# once sync only if the source-file line isn't already present; -# useful on hosts where you've audited the fragment once and -# don't want repeated writes -# -# Silent no-op if the repo fragment can't be located. +# in the repo propagate without re-running install.sh on each host. Silent +# no-op if the repo fragment can't be located. sync_tmux_conf() { _host=$1 - _mode=${RCLAUDE_SYNC_TMUX:-1} - case $_mode in - 0|off|no|false) return 0 ;; - esac _self=$(resolve_self) _repo=$(cd "$(dirname "$_self")/.." 2>/dev/null && pwd) _frag="$_repo/tmux.conf" [ -f "$_frag" ] || return 0 - _guard="" - case $_mode in - once) _guard='grep -q "session-tools.conf" ~/.tmux.conf 2>/dev/null && exit 0;' ;; - esac - _remote_cmd="${_guard} "'mkdir -p ~/.tmux.d && cat > ~/.tmux.d/session-tools.conf && { grep -q "session-tools.conf" ~/.tmux.conf 2>/dev/null || printf "source-file ~/.tmux.d/session-tools.conf\n" >> ~/.tmux.conf; } && tmux source-file ~/.tmux.conf 2>/dev/null || true' + _remote_cmd='mkdir -p ~/.tmux.d && cat > ~/.tmux.d/session-tools.conf && { grep -q "session-tools.conf" ~/.tmux.conf 2>/dev/null || printf "source-file ~/.tmux.d/session-tools.conf\n" >> ~/.tmux.conf; } && tmux source-file ~/.tmux.conf 2>/dev/null || true' if is_local "$_host"; then sh -c "$_remote_cmd" < "$_frag" 2>/dev/null || true else @@ -288,6 +250,23 @@ scan_hosts() { # Subcommands # --------------------------------------------------------------------------- +cmd_triage() { + # Pass-through args: --limit, --refresh, --uuids ... + _opts="$*" + printf "%-8s %-8s %-3s %-15s %-50s %s\n" \ + "HOST" "UUID" "PRI" "STATUS" "SUMMARY" "NEXT ACTION" + scan_hosts | while IFS= read -r h; do + # Row format from list_triage_on: + # host \t triage \t uuid \t priority \t status \t summary \t next_action \t cwd \t mtime + # shellcheck disable=SC2086 + list_triage_on "$h" $_opts | awk -F'\t' ' + { uuid8 = substr($3, 1, 8) + printf "%-8s %-8s %-3s %-15s %-50.50s %s\n", + $1, uuid8, $4, $5, $6, $7 } + ' + done +} + cmd_list() { _mode=${1:-all} # all | tmux | disk | sessions printf "%-10s %-7s %-60s %s\n" "HOST" "KIND" "SESSION/CWD/UUID" "DETAIL" @@ -321,33 +300,25 @@ cmd_list() { # - matches a snippet/cwd → same as session (the row identifies a UUID) # # Pattern matching is case-insensitive substring across host/kind/uuid/snippet/cwd. -# No pattern: live tmux sessions on top, then most-recent disk sessions across -# hosts (separated by ---). Picker label space caps the combined view at 35. +# An empty pattern lists everything (interactive picker). cmd_resume() { _pattern=${1:-} - case $_pattern in - --all|-a) _pattern="" ;; + # When triage is enabled, the search space is tmux rows + Haiku-triaged + # session rows (ranked by priority). Otherwise fall back to raw session + # rows with first-user-message snippets. + case ${RCLAUDE_TRIAGE:-off} in + auto|on|1|true) + printf 'rclaude: triaging sessions...\n' >&2 + _matches=$(scan_hosts | while IFS= read -r h; do + list_tmux_on "$h" + list_triage_on "$h" + done) + ;; + *) + _matches=$(scan_hosts | while IFS= read -r h; do list_search_on "$h"; done) + ;; esac - if [ -z "$_pattern" ]; then - _tmux=$(scan_hosts | while IFS= read -r h; do list_tmux_on "$h"; done) - _disk=$(scan_hosts | while IFS= read -r h; do list_sessions_on "$h"; done) - _t_count=0; _d_total=0 - [ -n "$_tmux" ] && _t_count=$(printf '%s\n' "$_tmux" | wc -l | tr -d ' ') - [ -n "$_disk" ] && _d_total=$(printf '%s\n' "$_disk" | wc -l | tr -d ' ') - _d_room=$((35 - _t_count)) - [ "$_d_room" -lt 0 ] && _d_room=0 - if [ "$_d_room" -gt 0 ] && [ -n "$_disk" ]; then - _disk_slice=$(printf '%s\n' "$_disk" | head -n "$_d_room") - else - _disk_slice="" - fi - if [ -n "$_tmux" ] && [ -n "$_disk_slice" ]; then - _matches=$(printf '%s\n%s' "$_tmux" "$_disk_slice") - else - _matches=${_tmux:-$_disk_slice} - fi - else - _matches=$(scan_hosts | while IFS= read -r h; do list_search_on "$h"; done) + if [ -n "$_pattern" ]; then _matches=$(printf '%s\n' "$_matches" | grep -F -i -- "$_pattern" || true) fi _count=0 @@ -358,38 +329,32 @@ cmd_resume() { fi if [ "$_count" -gt 1 ]; then _keys="123456789abcdefghijklmnopqrstuvwxyz" - # Pattern-search truncation (no-pattern case is already capped above). if [ "$_count" -gt 35 ]; then - _matches=$(printf '%s\n' "$_matches" | head -n 35) - _count=35 - printf 'rclaude: %s matches; showing 35 most recent\n' "$_count" >&2 + echo "too many matches ($_count); refine pattern" >&2 + exit 1 fi - # For sessions, display the human-readable snippet (col 4) rather - # than the bare UUID (col 3); for tmux/disk the existing col 3 is - # already the right thing to show. - _fmt_row='function display(){ if ($2 == "session") return $4 " [" substr($3,1,8) "]"; return $3 } { printf "%-10s %-7s %s", $1, $2, display() }' + # Per-row display: triage rows surface priority + status + summary; + # session rows show the user-message snippet; tmux/disk show $3. + _fmt_row=' + function display() { + if ($2 == "triage") return sprintf("P%s %-13s %s [%s]", $4, $5, $6, substr($3, 1, 8)) + if ($2 == "session") return $4 " [" substr($3, 1, 8) "]" + return $3 + } + { printf "%-10s %-7s %s", $1, $2, display() } + ' if [ ! -t 0 ] || [ ! -t 2 ]; then echo "multiple matches and no tty for picker; refine pattern:" >&2 printf '%s\n' "$_matches" | awk -F'\t' "$_fmt_row"'{printf "\n"}' >&2 exit 1 fi _i=0 - _prev_kind="" printf '%s\n' "$_matches" | while IFS= read -r _line; do _i=$((_i + 1)) - _kind_now=$(printf %s "$_line" | awk -F'\t' '{print $2}') - if [ "$_prev_kind" = "tmux" ] && [ "$_kind_now" != "tmux" ]; then - printf ' ---\n' >&2 - fi _k=$(printf %s "$_keys" | cut -c"$_i") printf ' [%s] %s\n' "$_k" \ "$(printf %s "$_line" | awk -F'\t' "$_fmt_row")" >&2 - _prev_kind=$_kind_now done - if [ -z "$_pattern" ] && [ "${_d_total:-0}" -gt "${_d_room:-0}" ]; then - printf ' (showing %s most recent of %s disk sessions; pass a pattern to search older)\n' \ - "$_d_room" "$_d_total" >&2 - fi _last_key=$(printf %s "$_keys" | cut -c"$_count") printf 'select [1-%s]: ' "$_last_key" >&2 _old=$(stty -g 2>/dev/null || true) @@ -414,9 +379,14 @@ cmd_resume() { _host=$(printf '%s\n' "$_matches" | awk -F'\t' 'NR==1{print $1}') _kind=$(printf '%s\n' "$_matches" | awk -F'\t' 'NR==1{print $2}') _target=$(printf '%s\n' "$_matches" | awk -F'\t' 'NR==1{print $3}') - # Sessions carry their cwd in column 5 (formatted " · "); - # extract the cwd half for the launch dir. - _session_cwd=$(printf '%s\n' "$_matches" | awk -F'\t' 'NR==1{print $5}' | awk -F' · ' '{print $1}') + # cwd column depends on kind: + # session → col 5, formatted " · " + # triage → col 8, raw cwd path + case $_kind in + triage) _session_cwd=$(printf '%s\n' "$_matches" | awk -F'\t' 'NR==1{print $8}') ;; + session) _session_cwd=$(printf '%s\n' "$_matches" | awk -F'\t' 'NR==1{print $5}' | awk -F' · ' '{print $1}') ;; + *) _session_cwd="" ;; + esac case $_kind in tmux) if is_local "$_host"; then @@ -429,7 +399,7 @@ cmd_resume() { # Spawn tmux + claude --continue at the recorded cwd. RCLAUDE_RESUME=1 exec "$0" "$_host" "$_target" ;; - session) + session|triage) # Spawn tmux + claude --resume at the session's recorded cwd. if [ -z "$_session_cwd" ]; then echo "rclaude: session $_target has no recorded cwd" >&2 @@ -458,22 +428,11 @@ cmd_version() { fi } -cmd_help() { - # Extract the leading comment block (everything from line 2 up to the - # first blank line after `# Usage:`), strip leading "# " / "#", and print. - _self=$(resolve_self) - awk ' - NR==1 { next } # skip shebang - /^[^#]/ { exit } # stop at first non-comment line - { sub(/^# ?/, ""); print } - ' "$_self" -} - case ${1:-} in - list) shift; cmd_list "$@"; exit ;; - resume) shift; cmd_resume "$@"; exit ;; - -v|--version) cmd_version; exit ;; - -h|--help|help) cmd_help; exit ;; + list) shift; cmd_list "$@"; exit ;; + resume) shift; cmd_resume "$@"; exit ;; + triage) shift; cmd_triage "$@"; exit ;; + -v|--version) cmd_version; exit ;; esac # ---------------------------------------------------------------------------