feat(@scripts): ✨ add claude triage helper cli tool
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
a28b2209af
commit
31659cf20b
2 changed files with 414 additions and 168 deletions
287
bin/_claude-triage
Executable file
287
bin/_claude-triage
Executable 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()
|
||||
295
bin/rclaude
295
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 <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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue