feat(@scripts): add claude triage helper cli tool

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-17 04:47:15 -07:00
parent a28b2209af
commit 31659cf20b
2 changed files with 414 additions and 168 deletions

287
bin/_claude-triage Executable file
View file

@ -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):
<mtime>\\t<uuid>\\t<cwd>\\t<priority>\\t<status>\\t<summary>\\t<next_action>
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 = (
"<command-name>", "<command-message>", "<system-reminder>", "<local-command-",
"Caveat:", "<bash-input>", "<bash-stdout>", "[task-persistence]", "[tts-state]",
"This session is being continued", "Please continue", "<task-notification>",
"[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()

View file

@ -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 <host> <dir> # remote (or local) at <dir>
# 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 <pattern> # 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:
# <host>\tsession\t<uuid>\t<snippet>\t<cwd> · <relative-time>\t<mtime_epoch>
# (col 6 is hidden — used for cross-host dedup/sort, stripped before display)
# <host>\tsession\t<uuid>\t<snippet>\t<cwd> · <relative-time>
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 <host> (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 <host> 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 <src_host>'s ~/.claude/projects/<src_slug>/<uuid>.jsonl
# to <dst_host>'s ~/.claude/projects/<dst_slug>/<uuid>.jsonl, rewriting every
# `"cwd":"<src_cwd...>"` occurrence to point at <dst_cwd...>. 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" <<EOF
$_rewritten
EOF
else
ssh -o BatchMode=yes -o ConnectTimeout=5 "$_dst" "$_mkdir && $_write" <<EOF
$_rewritten
EOF
fi || { echo "rclaude: failed to write to $_dst" >&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 <host> 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 "<cwd> · <rel-time>");
# 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 "<cwd> · <rel-time>"
# 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 <uuid> 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
# ---------------------------------------------------------------------------