From 8bc69f19fb0723e26924c22d55faf1309abd97c5 Mon Sep 17 00:00:00 2001 From: breaktheready Date: Sun, 19 Apr 2026 20:32:29 +0900 Subject: [PATCH 01/12] [fix] cron day-of-week: Unix semantics for APScheduler trigger APScheduler 3.x's CronTrigger.from_crontab() interprets numeric day-of-week fields with APScheduler's internal index (0=Mon, 6=Sun) instead of Unix cron (0/7=Sun, 1=Mon, ..., 6=Sat). For users with weekday-only crons like "0 20 * * 1-5", jobs would silently fire Tue-Sat instead of Mon-Fri. Add a build_cron_trigger() helper that translates numeric DoW tokens to APScheduler name form (mon/tue/...) before constructing CronTrigger, then route the four call sites in LiteClaw through it. Discovered when a Mon-Fri 15:20 market-coach cron fired on a Saturday. --- liteclaw.py | 55 ++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/liteclaw.py b/liteclaw.py index fe1ac6c..7183de5 100644 --- a/liteclaw.py +++ b/liteclaw.py @@ -532,6 +532,48 @@ def format_for_telegram(text: str) -> str: +# ============================================================================= +# Cron helper (Unix-standard day-of-week semantics) +# ============================================================================= + +# APScheduler 3.x CronTrigger.from_crontab() treats numeric day-of-week +# with APScheduler's internal index (0=Mon, 6=Sun) rather than Unix cron +# (0/7=Sun, 1=Mon, ..., 6=Sat). Result: "1-5" means Tue-Sat, not Mon-Fri. +# Observed on 2026-04-18 (Sat) when weekday-only crons fired. +_DOW_NAMES = ("sun", "mon", "tue", "wed", "thu", "fri", "sat") + + +def _unix_dow_to_name(tok: str) -> str: + return _DOW_NAMES[int(tok) % 7] + + +def _translate_dow_part(part: str) -> str: + part = part.strip() + if not part or part == "*" or any(c.isalpha() for c in part): + return part + if "/" in part: + base, step = part.split("/", 1) + return f"{_translate_dow_part(base)}/{step}" + if "-" in part: + a, b = part.split("-", 1) + return f"{_unix_dow_to_name(a)}-{_unix_dow_to_name(b)}" + return _unix_dow_to_name(part) + + +def build_cron_trigger(cron_expr: str, tz): + """Build APScheduler CronTrigger with Unix-standard weekday indexing.""" + from apscheduler.triggers.cron import CronTrigger + parts = cron_expr.split() + if len(parts) != 5: + raise ValueError(f"Cron expression must have 5 fields: {cron_expr!r}") + minute, hour, day, month, dow = parts + dow_translated = ",".join(_translate_dow_part(p) for p in dow.split(",")) + return CronTrigger( + minute=minute, hour=hour, day=day, month=month, + day_of_week=dow_translated, timezone=tz, + ) + + # ============================================================================= # Dashboard # ============================================================================= @@ -1268,14 +1310,12 @@ def _get_cron_job(self, job_id: str) -> dict | None: def _schedule_cron_jobs(self, job_queue): """Register all enabled cron jobs with the bot's JobQueue.""" - from apscheduler.triggers.cron import CronTrigger - for job in self._cron_jobs: if not job.get("enabled", True): continue try: tz = job.get("tz", "Asia/Seoul") - trigger = CronTrigger.from_crontab(job["cron_expr"], timezone=tz) + trigger = build_cron_trigger(job["cron_expr"], tz) job_queue.run_custom( callback=self._run_cron_job, job_kwargs={ @@ -2069,8 +2109,7 @@ async def cmd_cron(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE): # Validate cron expression try: - from apscheduler.triggers.cron import CronTrigger - CronTrigger.from_crontab(cron_expr, timezone="Asia/Seoul") + build_cron_trigger(cron_expr, "Asia/Seoul") except Exception as e: await update.message.reply_text(f"Invalid cron expression: {e}") return @@ -2092,8 +2131,7 @@ async def cmd_cron(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE): # Register with scheduler if running if hasattr(ctx, "job_queue") and ctx.job_queue: try: - from apscheduler.triggers.cron import CronTrigger - trigger = CronTrigger.from_crontab(cron_expr, timezone="Asia/Seoul") + trigger = build_cron_trigger(cron_expr, "Asia/Seoul") ctx.job_queue.run_custom( callback=self._run_cron_job, job_kwargs={"trigger": trigger, "id": f"cron-{job_id}", "replace_existing": True}, @@ -2151,8 +2189,7 @@ async def cmd_cron(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE): if ctx.job_queue: if enabled: try: - from apscheduler.triggers.cron import CronTrigger - trigger = CronTrigger.from_crontab(job["cron_expr"], timezone=job.get("tz", "Asia/Seoul")) + trigger = build_cron_trigger(job["cron_expr"], job.get("tz", "Asia/Seoul")) ctx.job_queue.run_custom( callback=self._run_cron_job, job_kwargs={"trigger": trigger, "id": f"cron-{job_id}", "replace_existing": True}, From d02a4e0b1e8369e1c918e1cbf2b5b51b182ec6d3 Mon Sep 17 00:00:00 2001 From: breaktheready Date: Sun, 19 Apr 2026 20:32:56 +0900 Subject: [PATCH 02/12] [macOS] platform-aware proxy recovery + start.sh launcher + MAC-OPS guide Adds first-class macOS support: - liteclaw.py: _recover_proxy() now branches on sys.platform. On Darwin it invokes `launchctl kickstart -k gui//com.claude-max-api-proxy` to bounce the LaunchAgent (Docker on macOS cannot read the keychain where the Claude CLI stores OAuth tokens). Linux behavior is unchanged. - start.sh: idempotent launcher that brings up tmux 'claude' (Claude Code CLI) first, waits for the prompt (auto-accepting the "trust this folder" dialog), then starts LiteClaw in tmux 'liteclaw'. Use --attach to land in the claude pane. - MAC-OPS.md: setup, management, and troubleshooting for the LaunchAgent approach, including the IPv4/IPv6 ghost-container hazard. - CLAUDE.md: cross-references MAC-OPS for the macOS auto-recovery and proxy infrastructure paths. - README.md: new Platform Support table making clear that Linux/macOS are supported natively, while Windows requires WSL (LiteClaw depends on tmux). Adds a "One-command launch" subsection pointing at start.sh. --- CLAUDE.md | 8 ++-- MAC-OPS.md | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 19 ++++++++++ liteclaw.py | 27 ++++++++++---- start.sh | 63 +++++++++++++++++++++++++++++++ 5 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 MAC-OPS.md create mode 100755 start.sh diff --git a/CLAUDE.md b/CLAUDE.md index 642176a..b602fb7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -250,9 +250,10 @@ Ensures response delivery even when infrastructure is degraded. ## Auto-Recovery Mechanisms ### API Proxy Recovery -- On Tier 1 failure: runs `docker compose up -d` in the proxy directory +- On Tier 1 failure: runs `docker compose up -d` in the proxy directory (Linux hosts) - Retries connection after restart - Notifies user via Telegram on successful recovery +- **macOS note**: proxy runs as LaunchAgent, not Docker — recovery would no-op. launchd's `KeepAlive` handles crash restarts automatically. See [MAC-OPS.md](./MAC-OPS.md). ### Session Auth Recovery (401) - Detects "401" + auth keywords in tmux pane output @@ -350,10 +351,11 @@ Direct `tmux send-keys -l` crashes on quotes, newlines, and special characters. ### Infrastructure: API Proxy LiteClaw's Tier 1 summarizer depends on an OpenAI-compatible API proxy. -- If down: auto-recovery attempts `docker compose up -d` +- If down: auto-recovery attempts `docker compose up -d` (Linux/auto-recovery path only) - Manual check: `curl -s http://localhost:3456/v1/models` -- Recommended proxy: [mattschwen/claude-max-api-proxy](https://github.com/mattschwen/claude-max-api-proxy) — Docker compose, reuses Claude Max subscription +- Recommended proxy: [mattschwen/claude-max-api-proxy](https://github.com/mattschwen/claude-max-api-proxy) — reuses Claude Max subscription - **Degraded without proxy** — Tier 2/3 still work but quality drops +- **macOS**: Docker cannot access the user keychain where Claude stores OAuth tokens. Run the proxy as a LaunchAgent instead — see [MAC-OPS.md](./MAC-OPS.md). ### Startup Sequence diff --git a/MAC-OPS.md b/MAC-OPS.md new file mode 100644 index 0000000..df25fbb --- /dev/null +++ b/MAC-OPS.md @@ -0,0 +1,104 @@ +# macOS Operations Guide + +Platform-specific guidance for running LiteClaw + claude-max-api-proxy on macOS. + +--- + +## Why Not Docker for the Proxy on macOS + +Docker containers on macOS **cannot access the user keychain**, where the `claude` CLI stores OAuth tokens. The container falls back to reading `~/.claude/.credentials.json` mounted read-only, which goes stale within hours as the host CLI refreshes tokens. + +**Symptoms** when Docker is used on macOS: +- `/v1/models` returns `{"data":[]}` +- `/health` shows rising `consecutiveAuthFailures` +- Chat requests fail with `401 Invalid authentication credentials` +- LiteClaw silently degrades to Tier 2 (slow agent-session summarizer) + +**Correct setup**: run the proxy natively as a LaunchAgent under your user account so the embedded `claude` CLI can read live keychain credentials. The proxy repo ships an authoritative guide at `claude-max-api-proxy/docs/macos-setup.md`. + +--- + +## Quick Setup + +```bash +# 1. Build the proxy from source (Node.js ≥ 22 required) +cd ~/projects/claude-max-api-proxy +npm install +npm run build + +# 2. Authenticate Claude CLI on the host (writes to keychain) +claude auth login + +# 3. Install the LaunchAgent (follow proxy's docs/macos-setup.md) +# — generates the plist with paths resolved from $(pwd) and $(command -v node) +# — bootstraps with launchctl bootstrap "gui/$(id -u)" ... + +# 4. Verify +curl -s http://127.0.0.1:3456/health | python3 -m json.tool +curl -s http://127.0.0.1:3456/v1/models +``` + +After this, both the `auto-recovery` path inside `liteclaw.py` (kicks the LaunchAgent on Tier 1 failure) and `start.sh` (the launcher) will detect macOS and behave correctly. + +--- + +## Management + +```bash +# Status +launchctl print "gui/$(id -u)/com.claude-max-api-proxy" | grep -E '(state|pid|runs)' + +# Restart (picks up new build) +launchctl kickstart -k "gui/$(id -u)/com.claude-max-api-proxy" + +# Stop +launchctl bootout "gui/$(id -u)/com.claude-max-api-proxy" + +# Logs +tail -f ~/Library/Logs/claude-max-api-proxy.log +tail -f ~/Library/Logs/claude-max-api-proxy.err.log +``` + +--- + +## Launching LiteClaw + Claude Code together + +```bash +bash ~/projects/liteclaw/start.sh # detached +bash ~/projects/liteclaw/start.sh --attach # detached + attach to claude pane +``` + +`start.sh` is idempotent (re-running is a no-op when already up), starts the tmux `claude` pane first, waits for the prompt (handling the "trust this folder" dialog automatically), then starts LiteClaw so it has somewhere to inject messages. + +--- + +## Troubleshooting + +### Symptom: LiteClaw summaries are slow, Tier 2 doing the work +Tier 1 proxy is down or returning errors. Check: +```bash +curl -s http://127.0.0.1:3456/health \ + | python3 -c "import json,sys; d=json.load(sys.stdin); print('auth:', d['auth']['loggedIn'], 'models:', d['models']['available'])" +``` +If `auth.loggedIn=false` or `models=[]`: confirm `claude auth status` works on the host. If host is fine but proxy still fails, the deployment likely reverted to Docker — switch back to LaunchAgent. + +### Symptom: `localhost` works in the browser/curl but LiteClaw still gets 503/401 +A ghost Docker container or other process may be listening on `:3456` over IPv6 while the LaunchAgent listens on IPv4. macOS resolves `localhost` to `::1` first. +```bash +lsof -iTCP:3456 -sTCP:LISTEN # expect a single 'node' process +docker ps | grep claude-max # remove any container holding the port +docker stop claude-max-proxy && docker rm claude-max-proxy +``` +Belt-and-suspenders: set `SUMMARIZER_URL=http://127.0.0.1:3456/v1` in `.env` to force IPv4. + +### Symptom: `launchctl print` shows a high `runs` count +Port conflict or repeated crash. Investigate logs and free the port: +```bash +lsof -iTCP:3456 -sTCP:LISTEN +launchctl bootout "gui/$(id -u)/com.claude-max-api-proxy" +pkill -f 'claude-max-api-proxy/dist/server/standalone.js' +launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/com.claude-max-api-proxy.plist" +``` + +### Symptom: `~/.claude/.credentials.json` shows expired `expiresAt` +Normal on macOS — the CLI uses keychain; the file is a legacy artifact. Don't manually edit it. diff --git a/README.md b/README.md index 55d3c2c..053ff05 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,14 @@ Unlike tools that call Claude's API directly (which means extra costs), LiteClaw - The default `SUMMARIZER_URL` is `http://localhost:3456/v1` (matches the recommended proxy's default port) - **(Optional) Docker** — only needed if your API proxy runs in a container +### Platform support + +| Platform | Status | Notes | +|---|---|---| +| Linux | ✅ Primary target | Docker proxy works directly | +| macOS | ✅ Supported | Run the proxy as a **LaunchAgent** instead of Docker — Docker can't read the keychain. See [MAC-OPS.md](./MAC-OPS.md). Use [`start.sh`](./start.sh) to launch Claude Code + LiteClaw together. | +| Windows | ⚠️ Not native | LiteClaw depends on tmux. Run under **WSL** (Ubuntu/Debian) and follow the Linux path. Native PowerShell/cmd is not supported. | + --- ## Quick Start @@ -110,6 +118,17 @@ python3 liteclaw.py Send `/start` to your Telegram bot to confirm it is working. +### One-command launch (`start.sh`) + +Once `.env` is configured, you can start both Claude Code (in tmux `claude`) and LiteClaw together: + +```bash +bash ./start.sh # both services in detached tmux sessions +bash ./start.sh --attach # ... and attach to the claude pane +``` + +The script is idempotent — re-running it is a no-op when both are already up. Order is enforced (Claude Code first, prompt-readiness wait, then LiteClaw) so the bridge always has a target. The "trust this folder" dialog is auto-accepted. + --- ## Configuration diff --git a/liteclaw.py b/liteclaw.py index 7183de5..6c003b0 100644 --- a/liteclaw.py +++ b/liteclaw.py @@ -3878,20 +3878,33 @@ async def _probe_api(self) -> bool: return False async def _recover_proxy(self) -> bool: - """Try to restart max-api-proxy Docker container.""" + """Try to restart the API proxy. macOS uses LaunchAgent; Linux uses Docker compose.""" log.warning("Attempting to restart max-api-proxy...") try: - r = subprocess.run( - ["docker", "compose", "up", "-d"], - cwd=os.environ.get("PROXY_DIR", os.path.expanduser("~/max_api_proxy")), - capture_output=True, text=True, timeout=30, - ) + if sys.platform == "darwin": + # Docker on macOS cannot access the keychain where Claude CLI stores OAuth + # tokens, so the proxy runs as a LaunchAgent instead. See MAC-OPS.md. + label = f"gui/{os.getuid()}/com.claude-max-api-proxy" + r = subprocess.run( + ["launchctl", "kickstart", "-k", label], + capture_output=True, text=True, timeout=15, + ) + settle_s = 5 + else: + r = subprocess.run( + ["docker", "compose", "up", "-d"], + cwd=os.environ.get("PROXY_DIR", os.path.expanduser("~/max_api_proxy")), + capture_output=True, text=True, timeout=30, + ) + settle_s = 3 if r.returncode == 0: - await asyncio.sleep(3) + await asyncio.sleep(settle_s) if await self._probe_api(): log.info("max-api-proxy recovered successfully") await self._notify_recovery("max-api-proxy restarted") return True + else: + log.warning(f"Proxy recovery returncode={r.returncode} stderr={r.stderr[:200]}") except Exception as e: log.warning(f"Proxy recovery failed: {e}") return False diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..596f804 --- /dev/null +++ b/start.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# start.sh — bring up Claude Code + LiteClaw together. +# Order matters: LiteClaw injects messages into the tmux 'claude' pane, +# so Claude Code must be running there first. +# +# Usage: +# bash ~/projects/liteclaw/start.sh # detached +# bash ~/projects/liteclaw/start.sh --attach # detached + attach to claude + +set -euo pipefail + +REPO="$HOME/projects/liteclaw" +LOG="/tmp/liteclaw_run.log" + +# 1) Claude Code in tmux 'claude' +if tmux has-session -t claude 2>/dev/null; then + echo "✓ tmux 'claude' already running" +else + echo "→ Starting Claude Code in tmux 'claude'..." + tmux new-session -d -s claude 'claude --dangerously-skip-permissions' + # Wait up to 20s for Claude prompt or trust-prompt + for _ in $(seq 1 20); do + pane=$(tmux capture-pane -t claude -p 2>/dev/null || true) + if echo "$pane" | grep -qE "❯|Yes, I trust"; then + break + fi + sleep 1 + done + if echo "$pane" | grep -q "Yes, I trust"; then + echo " ⚠ trust-prompt detected — sending Enter to accept" + tmux send-keys -t claude Enter + sleep 2 + fi + echo " ready" +fi + +# 2) LiteClaw daemon in tmux 'liteclaw' +if pgrep -f "python.*liteclaw.py" >/dev/null; then + echo "✓ LiteClaw already running (pid $(pgrep -f 'python.*liteclaw.py' | head -1))" +else + echo "→ Starting LiteClaw daemon..." + tmux new-session -d -s liteclaw "cd $REPO && .venv/bin/python3 liteclaw.py 2>&1 | tee -a $LOG" + sleep 4 + pid=$(pgrep -f "python.*liteclaw.py" | head -1 || true) + if [ -n "$pid" ]; then + echo " pid $pid" + else + echo " ✗ failed to start — check $LOG" + exit 1 + fi +fi + +echo +echo "Sessions:" +tmux list-sessions | grep -E "^(claude|liteclaw):" +echo +echo "Logs: tail -f $LOG" +echo "Attach: tmux attach -t claude" + +# Optional: attach if requested +if [ "${1:-}" = "--attach" ]; then + exec tmux attach -t claude +fi From 5725398ec6893dc6af10c3de9cde14fe566d1bc1 Mon Sep 17 00:00:00 2001 From: breaktheready Date: Wed, 22 Apr 2026 22:16:29 +0900 Subject: [PATCH 03/12] [feat] global liteclaw CLI + setup.sh enhancements - New bin/liteclaw dispatcher with subcommands: start [--attach] / stop / restart / status / logs / attach / help. Resolves repo root via script location or $LITECLAW_HOME. - setup.sh installs a symlink at ~/.local/bin/liteclaw so the dispatcher is reachable from any directory. - setup.sh also plants sensible ~/.tmux.conf defaults when missing (mouse on, history-limit 50000) so Claude Code panes are scroll-wheel friendly out of the box. - .env.example: new flags BOOT_NOTIFY, LITECLAW_DIR, CLAUDE_CWD, PRIMER_RECENT_TURNS documented for the OpenClaw-style memory layout and boot-ready Telegram ping. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 16 +++++++ bin/liteclaw | 119 +++++++++++++++++++++++++++++++++++++++++++++++++++ setup.sh | 48 +++++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100755 bin/liteclaw diff --git a/.env.example b/.env.example index 352fb40..627092d 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,22 @@ SUMMARIZER_MODEL=claude-sonnet-4-6 SCROLLBACK_LINES=500 INTERMEDIATE_INTERVAL=10 +# Boot notification — when set to 1 (default), LiteClaw sends a one-shot +# "🚀 LiteClaw ready" message to your Telegram chat after the daemon +# finishes initializing. Set to 0 to disable. +BOOT_NOTIFY=1 + +# OpenClaw-style memory layout. Per-day transcripts, per-day markdown +# summaries, rolling strategic compact, and a synthesized startup primer +# all live under LITECLAW_DIR. +# LITECLAW_DIR=~/.liteclaw +# PRIMER_RECENT_TURNS=20 + +# Pin the cwd Claude Code launches in so `claude --continue` consistently +# resumes the same project bucket under ~/.claude/projects//. +# Defaults to $HOME if unset. +# CLAUDE_CWD=/Users/you + # File transfer STAGING_DIR=~/liteclaw-files diff --git a/bin/liteclaw b/bin/liteclaw new file mode 100755 index 0000000..a4a80f3 --- /dev/null +++ b/bin/liteclaw @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# liteclaw — global CLI dispatcher. +# Resolves the LiteClaw repo via its own location (or $LITECLAW_HOME) and +# delegates to start.sh / tmux / log files. +# +# Usage: +# liteclaw start [--attach] bring up Claude Code + LiteClaw +# liteclaw stop tear down both tmux sessions +# liteclaw restart stop + start +# liteclaw status session + process + dashboard summary +# liteclaw logs [-f] tail /tmp/liteclaw_run.log +# liteclaw attach tmux attach -t claude +# liteclaw help this help + +set -euo pipefail + +# Resolve REPO root: $LITECLAW_HOME wins, otherwise infer from this script's path +# (handles symlink in ~/.local/bin/liteclaw → repo/bin/liteclaw). +SOURCE="${BASH_SOURCE[0]}" +while [ -L "$SOURCE" ]; do + LINK_DIR=$(cd -P "$(dirname "$SOURCE")" && pwd) + SOURCE=$(readlink "$SOURCE") + [[ $SOURCE != /* ]] && SOURCE="$LINK_DIR/$SOURCE" +done +SCRIPT_DIR=$(cd -P "$(dirname "$SOURCE")" && pwd) +REPO="${LITECLAW_HOME:-$(cd "$SCRIPT_DIR/.." && pwd)}" +LOG="/tmp/liteclaw_run.log" + +[ -f "$REPO/liteclaw.py" ] || { echo "✗ liteclaw.py not found under $REPO (set LITECLAW_HOME)" >&2; exit 1; } + +cmd="${1:-help}" +shift || true + +case "$cmd" in + start) + exec bash "$REPO/start.sh" "$@" + ;; + + stop) + stopped=0 + if tmux has-session -t liteclaw 2>/dev/null; then + tmux kill-session -t liteclaw && echo "✓ killed tmux 'liteclaw'" + stopped=1 + fi + if pgrep -f "python.*liteclaw.py" >/dev/null; then + pkill -f "python.*liteclaw.py" && echo "✓ killed liteclaw.py process(es)" + stopped=1 + fi + if tmux has-session -t claude 2>/dev/null; then + tmux kill-session -t claude && echo "✓ killed tmux 'claude'" + stopped=1 + fi + [ "$stopped" -eq 0 ] && echo "(nothing was running)" + ;; + + restart) + "$0" stop || true + sleep 1 + exec "$0" start "$@" + ;; + + status) + echo "REPO: $REPO" + echo + echo "tmux sessions:" + if tmux list-sessions 2>/dev/null | grep -E '^(claude|liteclaw):'; then :; else + echo " (none of claude/liteclaw running)" + fi + echo + pid=$(pgrep -f "python.*liteclaw.py" | head -1 || true) + if [ -n "$pid" ]; then + echo "liteclaw.py: running (pid $pid)" + else + echo "liteclaw.py: not running" + fi + # Dashboard port (default 7777, configurable via .env). Tolerate missing + # key / missing .env without tripping `set -e` + pipefail. + port="" + if [ -f "$REPO/.env" ]; then + port=$( { grep -E '^DASHBOARD_PORT=' "$REPO/.env" || true; } | head -1 | cut -d= -f2- | tr -d '"' | tr -d "'" | xargs) + fi + port=${port:-7777} + if [ "$port" != "0" ] && lsof -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then + echo "dashboard: listening on :$port" + else + echo "dashboard: (port $port not listening)" + fi + echo + echo "log: $LOG" + ;; + + logs) + [ -f "$LOG" ] || { echo "(no log yet at $LOG)"; exit 0; } + if [ "${1:-}" = "-f" ]; then + exec tail -f "$LOG" + else + exec tail -n 80 "$LOG" + fi + ;; + + attach) + if tmux has-session -t claude 2>/dev/null; then + exec tmux attach -t claude + else + echo "✗ no 'claude' tmux session — run 'liteclaw start' first" >&2 + exit 1 + fi + ;; + + help|-h|--help) + sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//' + ;; + + *) + echo "unknown command: $cmd" >&2 + echo "try: liteclaw help" >&2 + exit 2 + ;; +esac diff --git a/setup.sh b/setup.sh index e583443..938462b 100755 --- a/setup.sh +++ b/setup.sh @@ -29,6 +29,54 @@ if ! command -v tmux &>/dev/null; then fi echo "[OK] tmux $(tmux -V)" +# 1b. Ensure ~/.tmux.conf has mouse + scrollback so users can wheel-scroll the +# claude/liteclaw panes (default tmux disables mouse and caps history at 2000). +TMUX_CONF="$HOME/.tmux.conf" +TMUX_ADDED=0 +ensure_tmux_line() { + local line="$1" key="$2" + if [ -f "$TMUX_CONF" ] && grep -qE "^\s*set(-option)?\s+-g\s+${key}\b" "$TMUX_CONF"; then + return + fi + printf '%s\n' "$line" >> "$TMUX_CONF" + TMUX_ADDED=1 +} +ensure_tmux_line "set -g mouse on" "mouse" +ensure_tmux_line "set -g history-limit 50000" "history-limit" +if [ "$TMUX_ADDED" -eq 1 ]; then + echo "[OK] Updated $TMUX_CONF (mouse on, history-limit 50000)" + # Apply to any running sessions so the change takes effect immediately. + if tmux list-sessions >/dev/null 2>&1; then + tmux source-file "$TMUX_CONF" >/dev/null 2>&1 || true + fi +else + echo "[OK] tmux mouse/history config already present" +fi + +# 1c. Install global `liteclaw` CLI symlink in ~/.local/bin (idempotent). +# Symlink (not copy) so subsequent repo updates take effect without re-install. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DISPATCHER="$SCRIPT_DIR/bin/liteclaw" +LINK_DIR="$HOME/.local/bin" +LINK="$LINK_DIR/liteclaw" +if [ -x "$DISPATCHER" ]; then + mkdir -p "$LINK_DIR" + if [ -L "$LINK" ] && [ "$(readlink "$LINK")" = "$DISPATCHER" ]; then + echo "[OK] liteclaw CLI already linked at $LINK" + else + ln -sfn "$DISPATCHER" "$LINK" + echo "[OK] Linked liteclaw CLI: $LINK -> $DISPATCHER" + fi + case ":$PATH:" in + *":$LINK_DIR:"*) ;; + *) echo "[!] $LINK_DIR is not in PATH — add to your shell rc:" + echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" + ;; + esac +else + echo "[!] $DISPATCHER not executable — skipping CLI symlink" +fi + # 2. Find Python 3.10+ (prefer newer versions, fall back through named interpreters) # On macOS, `python3` often resolves to system 3.9 even when brew python@3.12 is installed, # because brew's formula only provides versioned binaries (python3.12) without a generic From 991144b5c7c14d185221156c36d3a5adf7456272 Mon Sep 17 00:00:00 2001 From: breaktheready Date: Wed, 22 Apr 2026 22:16:38 +0900 Subject: [PATCH 04/12] [feat] start.sh: pin Claude Code session via --session-id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces --continue with --session-id using a stable UUID stored in ~/.liteclaw/sessions.json.liteclaw_session_id. Benefits: - Deterministic resume — other Claude Code windows in the same cwd no longer steal which session gets picked up. - Migration-friendly — if sessions.json has no UUID but the target cwd already has exactly one Claude Code session jsonl, start.sh adopts that UUID so existing conversations carry over. - Allocates fresh UUID via `uuidgen` otherwise. CLAUDE_CWD env (default $HOME) pins the working directory so the session log path under ~/.claude/projects// is stable regardless of where the user runs `liteclaw start` from. Co-Authored-By: Claude Opus 4.7 (1M context) --- start.sh | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/start.sh b/start.sh index 596f804..b5578e3 100755 --- a/start.sh +++ b/start.sh @@ -11,13 +11,67 @@ set -euo pipefail REPO="$HOME/projects/liteclaw" LOG="/tmp/liteclaw_run.log" +# Where claude is launched. Pinned so the LiteClaw-owned session always lives +# under ~/.claude/projects//.jsonl, regardless +# of where the user launches `liteclaw start` from. +CLAUDE_CWD="${CLAUDE_CWD:-$HOME}" +LITECLAW_DIR="${LITECLAW_DIR:-$HOME/.liteclaw}" +SESSIONS_JSON="$LITECLAW_DIR/sessions.json" + +# Resolve a stable LiteClaw-owned session UUID. We pre-allocate a UUID and pass +# it via `--session-id` so `liteclaw start` always resumes the same conversation +# even when the user has other Claude Code windows running in the same cwd. +mkdir -p "$LITECLAW_DIR" +SESS="" +if [ -f "$SESSIONS_JSON" ]; then + SESS=$(python3 -c " +import json, sys +try: + print(json.load(open('$SESSIONS_JSON')).get('liteclaw_session_id', '')) +except Exception: + print('') +" 2>/dev/null || true) +fi +if [ -z "$SESS" ]; then + # Migration: if this cwd already has exactly one Claude Code session jsonl, + # adopt its UUID so existing conversations carry over. Otherwise allocate fresh. + ENC=$(echo "$CLAUDE_CWD" | sed 's|/|-|g') + PROJ_DIR="$HOME/.claude/projects/$ENC" + ADOPT="" + if [ -d "$PROJ_DIR" ]; then + # Pick the most recently modified jsonl (user's active session wins in ties). + ADOPT=$(ls -t "$PROJ_DIR"/*.jsonl 2>/dev/null | head -1 | xargs -n1 basename 2>/dev/null | sed 's/\.jsonl$//') + fi + if [ -n "$ADOPT" ]; then + SESS="$ADOPT" + note="adopted existing session" + else + SESS=$(uuidgen | tr '[:upper:]' '[:lower:]') + note="allocated new session" + fi + python3 - </dev/null; then echo "✓ tmux 'claude' already running" else - echo "→ Starting Claude Code in tmux 'claude'..." - tmux new-session -d -s claude 'claude --dangerously-skip-permissions' + echo "→ Starting Claude Code in tmux 'claude' (cwd=$CLAUDE_CWD, session=$SESS)..." + tmux new-session -d -s claude -c "$CLAUDE_CWD" "claude --session-id $SESS --dangerously-skip-permissions" # Wait up to 20s for Claude prompt or trust-prompt for _ in $(seq 1 20); do pane=$(tmux capture-pane -t claude -p 2>/dev/null || true) From 0fa82805a3d82908f44a28968441407da33d03f6 Mon Sep 17 00:00:00 2001 From: breaktheready Date: Wed, 22 Apr 2026 22:17:20 +0900 Subject: [PATCH 05/12] [feat] jsonl response path + session memory + boot notify + cron hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the big one — a set of related changes to (1) stop scraping tmux for Claude's response, (2) give LiteClaw an OpenClaw-style persistent memory, and (3) make crons survive more failure modes. Response source: tmux pane → session JSONL - _jsonl_path / _record_jsonl_offset / _tail_jsonl_since_offset / _poll_response_via_jsonl read Claude Code's own session log at ~/.claude/projects//.jsonl and extract only `text` blocks from assistant messages in the new range. - Completion detected by stop_reason=end_turn|stop_sequence| max_tokens. `tool_use` stop_reason means mid-turn; keep waiting. - File-size-based stable safety net (15 s of no jsonl growth with some text already captured → assume flushed). Avoids previous bug where long tool_use chains tripped a text-stable timeout. - handle_message replaces pane-derived response with jsonl text when jsonl length ≥ 50% of pane length (guards against returning a preamble-only stub if tool chain was still running). - When jsonl delivers a complete turn, _skip_summarizer_once fires so the summarizer doesn't re-compress clean text, and _jsonl_delivered_complete suppresses _followup_edit (which was overwriting delivered messages with "Working… (Ns)" status). - Mid-poll status edits gated behind SHOW_POLLING_STATUS (off by default) — these were the source of "Thinking (Crystalizing)…" leaks into Telegram. - Fallback to pane-scrape preserved on any jsonl failure. Memory layout (OpenClaw-inspired): ~/.liteclaw/ transcripts/YYYY-MM-DD.jsonl per-day append-only, schema with sid: alias (index into sessions.json.history[]), ~70% shorter than embedding full UUID memory/YYYY-MM-DD.md LLM-compacted daily digest memory/strategic.md optional rolling summary sessions.json {liteclaw_session_id, history[]} primer.md assembled at boot (strategic + recent N turns) - _migrate_legacy_history splits the old single-file jsonl into per-day files on first boot. - _compact_day calls the summarizer (tier-1 proxy, openai-compat) and writes memory/YYYY-MM-DD.md; idempotent (skip if already populated). - _build_primer composes primer.md; falls back to recent daily memory markdowns when strategic.md is absent. - _detect_resume_state also maintains sessions.json.history[] append-only (new UUIDs pushed, prior `ended_at` stamped). Boot notify + rate limit: - _send_boot_ready sends a one-shot "🚀 LiteClaw ready" Telegram message after init, reporting host/target/history/resume/primer. - Uses sync httpx.Client because it fires before run_polling takes the event loop. - 5-minute rate limit via .last_boot_at file so dev/debug restarts don't spam. Bookkeeping (migrate/compact/primer/history) still runs even when the ping is suppressed. Cron hardening: - _run_cron_job auto-accepts Claude Code's "Yes, I trust" dialog on first launch in unattended cwds. - Busy-wait extended 120 s → 300 s with a has_prompt+stable-pane fallback — addresses the "Session busy for 120s" false fail that fired on cold Claude launches with chrome glyphs. - _log_cron_error appends a forensic entry (ts, job config, pane snapshot) to ~/.liteclaw/cron-error-capture.md on every failure. - Failure Telegram alert references that file. is_idle_prompt correctness: - Removed `·` and `*` from the spinner character set (they are separators in Claude Code's banner, not real frames) and anchored _ACTIVITY_PATTERNS to line start. Without this, the banner "· Claude Max" line matched as "activity" forever and is_idle_prompt never returned True. /recall session: - Session-scoped recall. `/recall session` restricts to current session; `/recall session ` targets a specific past one. - Filter accepts both legacy full-UUID session_id entries and new sid: aliases for back-compat. Misc: - USE_JSONL_RESPONSE / SHOW_POLLING_STATUS / BOOT_NOTIFY / LITECLAW_DIR / CLAUDE_CWD / PRIMER_RECENT_TURNS env flags. - Summarizer prompt gains a PRESERVE-AS-IS block for numbered / lettered option lists so "B / C / D 선택" messages don't lose their option bodies in the tmux-pane path. - Placeholder text changed from "📤 Sent. Waiting for response..." to "⏳ 작업 중…". Co-Authored-By: Claude Opus 4.7 (1M context) --- liteclaw.py | 854 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 822 insertions(+), 32 deletions(-) diff --git a/liteclaw.py b/liteclaw.py index 6c003b0..cd635dc 100644 --- a/liteclaw.py +++ b/liteclaw.py @@ -94,6 +94,34 @@ HISTORY_FILE = Path(os.environ.get("HISTORY_FILE", os.path.expanduser("~/.liteclaw-history.jsonl"))) HISTORY_RECALL_LIMIT = int(os.environ.get("HISTORY_RECALL_LIMIT", "50")) # max entries for /recall +# OpenClaw-style memory layout: per-day transcripts + per-day markdown summaries +# + rolling strategic compact + synthesized startup primer. +LITECLAW_DIR = Path(os.environ.get("LITECLAW_DIR", os.path.expanduser("~/.liteclaw"))) +LITECLAW_TRANSCRIPTS = LITECLAW_DIR / "transcripts" +LITECLAW_MEMORY = LITECLAW_DIR / "memory" +LITECLAW_STRATEGIC = LITECLAW_MEMORY / "strategic.md" +LITECLAW_PRIMER = LITECLAW_DIR / "primer.md" +LITECLAW_SESSIONS = LITECLAW_DIR / "sessions.json" + +# Boot notification (sent to Telegram once after the bot is fully initialized) +BOOT_NOTIFY = os.environ.get("BOOT_NOTIFY", "1").lower() not in ("0", "false", "no", "off") + +# Claude Code working directory (where start.sh launches claude). Used to +# locate ~/.claude/projects// for resume-state detection. +CLAUDE_CWD = Path(os.environ.get("CLAUDE_CWD", os.path.expanduser("~"))) +PRIMER_RECENT_TURNS = int(os.environ.get("PRIMER_RECENT_TURNS", "20")) + +# JSONL-based response extraction: reads Claude Code's own session log at +# ~/.claude/projects//.jsonl for clean, +# structured responses instead of scraping the tmux pane (which leaks TUI +# chrome like "Thinking (Crystalizing)" and truncates long answers at the +# scroll-back boundary). Setting USE_JSONL_RESPONSE=0 disables this path. +USE_JSONL_RESPONSE = os.environ.get("USE_JSONL_RESPONSE", "1").lower() not in ("0", "false", "no", "off") +# Suppress mid-poll status edits while waiting for the final response — they +# were the source of the "Thinking (Crystalizing)" garbled messages on +# Telegram. Users can re-enable via SHOW_POLLING_STATUS=1 for debugging. +SHOW_POLLING_STATUS = os.environ.get("SHOW_POLLING_STATUS", "0").lower() not in ("0", "false", "no", "off") + # v0.5.0 UX Overhaul — OpenClaw/Hermes-inspired # F1 CLI Mirror MIRROR_ENABLED = os.environ.get("MIRROR_ENABLED", "false").lower() == "true" @@ -299,11 +327,16 @@ def is_idle_prompt(content: str) -> bool: "|Misting|Expanding|Parsing|Crafting|Focusing|Wondering|Pondering" "|Transfiguring" ) -# Primary: spinner char + any word = activity (future-proof against new labels) -# Fallback: explicit label list kept for documentation/reference -_SPINNER_CHARS = r"[✻✶✽✢·●*◐◑◒◓⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]" +# Spinner + capitalized word = activity. The spinner must sit at line start +# (after optional whitespace) — otherwise Claude Code's permanent UI chrome +# triggers false positives like "Opus 4.7 (1M context) · Claude Max" and +# "[✻] … · Share Claude Code…", which used to keep is_idle_prompt() returning +# False forever and hang cron jobs at the 120s busy-wait. +# Middle dot `·` and `*` are excluded because they are routine separators, not +# actual spinner frames used by Claude Code. +_SPINNER_CHARS = r"[✻✶✽✢●◐◑◒◓⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]" _ACTIVITY_PATTERNS = re.compile( - rf"{_SPINNER_CHARS}\s*[A-Z][a-z]{{2,}}" # spinner + capitalized word (e.g. ✢ Transfiguring) + rf"^\s*{_SPINNER_CHARS}\s+[A-Z][a-z]{{2,}}" # line-anchored: ✢ Transfiguring r"|\(thinking\)" ) @@ -520,6 +553,20 @@ def format_for_telegram(text: str) -> str: - If the output contains an error, highlight it clearly - Keep it concise but NEVER drop important content — completeness over brevity +PRESERVE-AS-IS (never compress or drop): +- Numbered or lettered option lists presented for the user to choose from. + Examples: + "1. … 2. … 3. …" + "A) … B) … C) …" + "**B** — … **C** — … **D** — …" + If the original asks "B/C/D 중 뭘로?", "which one?", "번호 골라", "pick one", + "선택해줘", "choose", etc., keep EVERY option line verbatim. Dropping the + option descriptions makes the message unanswerable via Telegram. +- Explicit questions directed at the user ("...할까요?", "which should I …?", + "가? 아니면 …?"). Keep them intact. +- Any line that starts with a choice marker ("[A]", "(B)", "1)", "- A:", etc.) + within a section that is clearly a choice menu. + Telegram formatting rules: - NO markdown tables (|---|) — use bullet points: "항목: 설명" or "항목 → 설명" - NO # headers — use **bold text** for section titles @@ -868,6 +915,13 @@ def __init__(self): self._load_cron_jobs() # Session recovery state file self._state_file = Path(__file__).resolve().parent / ".liteclaw_state.json" + # Cached LiteClaw-owned session UUID (for tagging transcript entries + # and scoping /recall session). Refreshed at startup and whenever + # sessions.json changes. `None` if no id allocated yet. + self._current_session_id: str | None = self._load_current_session_id() + # Phase A: jsonl-based response extraction state. + self._jsonl_offset: int = 0 + self._skip_summarizer_once: bool = False def _load_config(self): """Load persisted runtime toggles from ~/.liteclaw/config.json.""" @@ -1359,26 +1413,60 @@ async def _run_cron_job(self, context): f"cd {project} && claude --dangerously-skip-permissions", "Enter"], check=True, ) - # Wait for Claude Code prompt - for _ in range(30): + # Wait for Claude Code prompt, auto-accepting the trust dialog + # that Claude shows on first launch in a new cwd. Without this, + # crons that run in unattended project dirs hang on "Do you + # trust the files in this folder?" and time out at 120s. + trust_accepted = False + for _ in range(45): await asyncio.sleep(1) try: - content = capture_pane(session_name, lines=10) - if has_prompt(content): - break + content = capture_pane(session_name, lines=20) except RuntimeError: - pass + continue + if not trust_accepted and re.search( + r"Yes, I trust|Do you trust the files|Trust the files in this folder", + content, + ): + log.info(f"Cron '{job_id}': trust prompt detected, sending Enter") + subprocess.run(["tmux", "send-keys", "-t", session_name, "Enter"], check=False) + trust_accepted = True + await asyncio.sleep(2) + continue + if has_prompt(content): + break else: - raise TimeoutError("Claude Code prompt not detected after 30s") - - # Wait if session is busy - for _ in range(60): # max 60s wait + raise TimeoutError("Claude Code prompt not detected after 45s") + + # Wait for the cron's claude session to settle before sending. + # Prefer is_idle_prompt (strict: prompt + no spinner) but fall back + # to has_prompt + stable pane content when the banner / proxy load + # keeps a faint spinner lingering. Without the fallback cron jobs + # that launch Claude Code fresh on a busy host fail with + # "Session busy for 120s" even though the pane is obviously ready. + idle = False + prev_pane = "" + stable_prompt = 0 # consecutive polls where a prompt is visible & stable + for i in range(150): # 300s total pane = capture_pane(session_name, lines=15) if is_idle_prompt(pane): + idle = True + break + if has_prompt(pane) and pane == prev_pane: + stable_prompt += 1 + else: + stable_prompt = 0 + prev_pane = pane + # 10 consecutive matching polls × 2s = 20s of prompt-visible + # stability is a strong signal the pane is ready even if some + # chrome glyph is still being interpreted as activity. + if stable_prompt >= 10: + log.info(f"Cron '{job_id}': proceeding on stable-prompt fallback after {(i + 1) * 2}s") + idle = True break await asyncio.sleep(2) - else: - raise TimeoutError("Session busy for 120s, giving up") + if not idle: + raise TimeoutError("Session busy for 300s, giving up") # Send the message message = job["message"] @@ -1448,16 +1536,71 @@ async def _run_cron_job(self, context): job["last_run"] = datetime.now(ZoneInfo(job.get("tz", "Asia/Seoul"))).isoformat() job["last_status"] = f"error: {e}" self._save_cron_jobs() + # Persist a forensic record for later review. Captures the tmux pane + # so we can tell whether the failure was a trust prompt, auth loop, + # infinite spinner, etc. without needing live reproduction. + try: + self._log_cron_error(job_id, job, e, session_name) + except Exception as log_exc: + log.warning(f"Could not persist cron error capture: {log_exc}") try: await bot.send_message( chat_id=CHAT_ID, - text=f"❌ Cron '{job_id}' failed: {e}", + text=f"❌ Cron '{job_id}' failed: {e}\n(captured → ~/.liteclaw/cron-error-capture.md)", ) except Exception: pass finally: self._cron_running.discard(job_id) + def _log_cron_error(self, job_id: str, job: dict, exc: Exception, session_name: str): + """Append a forensic markdown entry for a failed cron job.""" + path = LITECLAW_DIR / "cron-error-capture.md" + try: + LITECLAW_DIR.mkdir(parents=True, exist_ok=True) + except OSError: + return + ts = datetime.now(ZoneInfo(job.get("tz", "Asia/Seoul"))).isoformat(timespec="seconds") + # Pane snapshot (last 60 lines). Best-effort — the session may have + # been killed already or never created. + pane = "(no pane captured)" + try: + r = subprocess.run( + ["tmux", "capture-pane", "-t", session_name, "-p", "-S", "-60"], + capture_output=True, text=True, timeout=5, + ) + if r.returncode == 0: + pane = r.stdout.rstrip() or "(pane empty)" + except Exception: + pass + header_exists = path.exists() and path.stat().st_size > 0 + parts = [] + if not header_exists: + parts.append("# LiteClaw cron error capture\n") + parts.append("Each entry is a failed cron execution with the pane snapshot at the\n") + parts.append("moment of failure. Append-only — newest at the bottom. Review later,\n") + parts.append("fix the root cause per entry, and optionally delete the entry.\n\n") + parts.append(f"## {ts} — `{job_id}`\n\n") + parts.append(f"- **error**: `{exc}`\n") + parts.append(f"- **cron**: `{job.get('cron_expr', '?')}` ({job.get('tz', '?')})\n") + parts.append(f"- **project**: `{job.get('project', '?')}`\n") + parts.append(f"- **timeout**: `{job.get('timeout', '?')}s`\n") + parts.append(f"- **tmux session**: `{session_name}`\n\n") + # Truncate pane to keep the file readable — 4 KB is plenty to see the + # last few dozen lines of Claude Code output around the failure. + if len(pane) > 4096: + pane = "…(truncated)…\n" + pane[-4096:] + parts.append("**pane snapshot**:\n\n") + parts.append("```\n") + parts.append(pane + ("\n" if not pane.endswith("\n") else "")) + parts.append("```\n\n---\n\n") + try: + with open(path, "a", encoding="utf-8") as f: + f.writelines(parts) + log.info(f"Cron error captured → {path}") + except OSError as e: + log.warning(f"Cron error capture write failed: {e}") + def _agent_session_alive(self, session: str) -> bool: """Check if a tmux session is still running.""" r = subprocess.run( @@ -1636,21 +1779,83 @@ def _record_offset(self): else: self._log_offset = 0 + def _load_current_session_id(self) -> str | None: + """Read the active LiteClaw session UUID from ~/.liteclaw/sessions.json. + + Returns None if the file / key is missing. Safe to call at any time — + used both at startup and as a lazy refresh when _log_conversation + notices its cache is empty. + """ + try: + if LITECLAW_SESSIONS.exists(): + data = _json.loads(LITECLAW_SESSIONS.read_text(encoding="utf-8")) + sid = data.get("liteclaw_session_id") + if isinstance(sid, str) and sid: + return sid + except Exception: + pass + return None + + def _current_session_alias(self) -> int | None: + """Return the append-only integer index of the current session in + sessions.json.history[]. Small enough to stamp on every transcript + entry — a full UUID adds ~55 bytes per line; an int adds ~10. + + Returns None if no session id is allocated yet. history[] is + append-only in _detect_resume_state, so indices are stable across + restarts and resolve back to the UUID via sessions.json. + """ + sid = self._current_session_id or self._load_current_session_id() + if not sid: + return None + try: + data = _json.loads(LITECLAW_SESSIONS.read_text(encoding="utf-8")) if LITECLAW_SESSIONS.exists() else {} + history = data.get("history") or [] + for idx, h in enumerate(history): + if h.get("id") == sid: + return idx + except Exception: + pass + return None + def _log_conversation(self, user_text: str, response: str, summarized: bool = False, meta: dict = None): - """Append a conversation turn to JSONL history file.""" + """Append a conversation turn to JSONL history file. + + Writes both the legacy single-file ~/.liteclaw-history.jsonl (for + back-compat with /recall) and the OpenClaw-style per-day transcript + under ~/.liteclaw/transcripts/YYYY-MM-DD.jsonl. Each entry is tagged + with the LiteClaw-owned session UUID so we can scope /recall session. + """ + now = datetime.now() + # Refresh cached session id once if it's still unknown — start.sh may + # have allocated one after the daemon booted. + if not self._current_session_id: + self._current_session_id = self._load_current_session_id() + # Compact alias: integer index into sessions.json.history[] (tiny vs + # the ~55-byte UUID that would otherwise repeat on every turn). + sid_alias = self._current_session_alias() entry = { - "ts": datetime.now().isoformat(), + "ts": now.isoformat(), + "sid": sid_alias, "user": user_text[:500], # cap to avoid bloat "response": response[:2000], # keep summarized version (compact) "summarized": summarized, } if meta: entry["meta"] = meta + line = _json.dumps(entry, ensure_ascii=False) + "\n" try: with open(HISTORY_FILE, "a", encoding="utf-8") as f: - f.write(_json.dumps(entry, ensure_ascii=False) + "\n") + f.write(line) except OSError as e: log.warning(f"Failed to write history: {e}") + try: + LITECLAW_TRANSCRIPTS.mkdir(parents=True, exist_ok=True) + daily = LITECLAW_TRANSCRIPTS / f"{now.strftime('%Y-%m-%d')}.jsonl" + with open(daily, "a", encoding="utf-8") as f: + f.write(line) + except OSError as e: + log.warning(f"Failed to write daily transcript: {e}") def _log_event(self, event_type: str, detail: str = ""): """Append behavioral event to ~/.liteclaw-events.jsonl""" @@ -1661,6 +1866,497 @@ def _log_event(self, event_type: str, detail: str = ""): except Exception: pass + def _migrate_legacy_history(self) -> int: + """One-shot: split legacy single-file history into per-day transcripts. + + Idempotent — if a daily file already exists with content, that day is + skipped. Returns the number of entries migrated. + """ + if not HISTORY_FILE.exists(): + return 0 + try: + LITECLAW_TRANSCRIPTS.mkdir(parents=True, exist_ok=True) + except OSError as e: + log.warning(f"Migration: cannot create {LITECLAW_TRANSCRIPTS}: {e}") + return 0 + # Group entries by date and only append to days not yet populated. + existing_days = {p.stem for p in LITECLAW_TRANSCRIPTS.glob("*.jsonl") if p.stat().st_size > 0} + by_day: dict[str, list[str]] = {} + try: + with open(HISTORY_FILE, "r", encoding="utf-8") as f: + for line in f: + line = line.rstrip("\n") + if not line: + continue + try: + entry = _json.loads(line) + ts = entry.get("ts", "") + day = ts[:10] if len(ts) >= 10 else "unknown" + except Exception: + day = "unknown" + if day in existing_days: + continue + by_day.setdefault(day, []).append(line + "\n") + except OSError as e: + log.warning(f"Migration: cannot read {HISTORY_FILE}: {e}") + return 0 + moved = 0 + for day, lines in by_day.items(): + try: + with open(LITECLAW_TRANSCRIPTS / f"{day}.jsonl", "a", encoding="utf-8") as f: + f.writelines(lines) + moved += len(lines) + except OSError as e: + log.warning(f"Migration: cannot write {day}.jsonl: {e}") + if moved: + log.info(f"Migrated {moved} legacy history entries into {LITECLAW_TRANSCRIPTS}") + return moved + + def _compact_day(self, day: str) -> dict: + """Summarize ~/.liteclaw/transcripts/{day}.jsonl into memory/{day}.md. + + Synchronous, best-effort. Skips when: + - the transcript doesn't exist or has <5 turns + - the memory file already exists (idempotent) + - the summarizer endpoint is unreachable + Returns {day, status: 'created'|'skipped'|'failed', reason, bytes}. + """ + result = {"day": day, "status": "skipped", "reason": "", "bytes": 0} + src = LITECLAW_TRANSCRIPTS / f"{day}.jsonl" + dst = LITECLAW_MEMORY / f"{day}.md" + if not src.exists(): + result["reason"] = "no transcript" + return result + if dst.exists() and dst.stat().st_size > 0: + result["reason"] = "already compacted" + return result + try: + entries = [] + with open(src, "r", encoding="utf-8") as f: + for line in f: + try: + entries.append(_json.loads(line)) + except Exception: + pass + except OSError as e: + result["reason"] = f"read failed: {e}" + return result + if len(entries) < 5: + result["reason"] = f"only {len(entries)} turns" + return result + # Build a compact prompt — cap to keep token usage bounded. + bullets = [] + for e in entries[-200:]: + ts = (e.get("ts", "") or "")[:19] + u = (e.get("user") or "").replace("\n", " ")[:300] + a = (e.get("response") or "").replace("\n", " ")[:500] + bullets.append(f"- [{ts}] U: {u}\n A: {a}") + body = "\n".join(bullets) + sys_msg = ( + "You are a strategic memory compactor for an autonomous coding agent. " + "Given a day of conversation turns (user msg + agent reply summaries), " + "produce a Markdown digest with these sections:\n" + "1) Goals & decisions made today\n" + "2) Open threads / unfinished work\n" + "3) Notable bugs, incidents, or surprises\n" + "4) Useful facts the agent should remember tomorrow\n" + "Be terse. Use bullets. Korean+English mix is fine. No fluff." + ) + user_msg = f"Date: {day}\n\nTurns:\n{body}" + try: + with httpx.Client(timeout=45) as client: + r = client.post( + f"{SUMMARIZER_URL}/chat/completions", + json={ + "model": SUMMARIZER_MODEL, + "messages": [ + {"role": "system", "content": sys_msg}, + {"role": "user", "content": user_msg}, + ], + "temperature": 0.3, + }, + ) + if r.status_code != 200: + result["reason"] = f"http {r.status_code}" + return result + content = r.json()["choices"][0]["message"]["content"].strip() + except Exception as e: + result["reason"] = f"api: {e}" + return result + markdown = f"# {day} — daily memory\n_compacted: {datetime.now().isoformat(timespec='seconds')}_\n\n{content}\n" + try: + LITECLAW_MEMORY.mkdir(parents=True, exist_ok=True) + dst.write_text(markdown, encoding="utf-8") + result["status"] = "created" + result["bytes"] = len(markdown.encode("utf-8")) + except OSError as e: + result["reason"] = f"write failed: {e}" + return result + + def _build_primer(self) -> dict: + """Build ~/.liteclaw/primer.md from strategic.md + recent N turns. + + Returns a dict {primer_path, size_bytes, recent_turns, has_strategic} + usable by _send_boot_ready. Never raises — failures degrade silently. + """ + info = {"path": str(LITECLAW_PRIMER), "size": 0, "recent": 0, "strategic": False} + try: + LITECLAW_DIR.mkdir(parents=True, exist_ok=True) + LITECLAW_MEMORY.mkdir(parents=True, exist_ok=True) + except OSError as e: + log.warning(f"Primer: cannot create dirs: {e}") + return info + # Recent: last N turns from the legacy history (cheapest source covering + # all days). Once daily compaction is wired up, we can switch this to + # tail of today's transcripts/.jsonl. + recent_lines: list[str] = [] + if HISTORY_FILE.exists(): + try: + with open(HISTORY_FILE, "r", encoding="utf-8") as f: + tail = f.readlines()[-PRIMER_RECENT_TURNS:] + for raw in tail: + try: + e = _json.loads(raw) + except Exception: + continue + ts = e.get("ts", "")[:19] + user = (e.get("user") or "").strip().replace("\n", " ") + resp = (e.get("response") or "").strip().replace("\n", " ") + recent_lines.append(f"- [{ts}] U: {user[:160]}\n A: {resp[:240]}") + info["recent"] = len(recent_lines) + except OSError as e: + log.warning(f"Primer: cannot read history: {e}") + # Strategic: optional rolling summary, with fallback to 3 most recent + # daily memory markdowns when no curated strategic.md exists yet. + strategic_text = "" + if LITECLAW_STRATEGIC.exists(): + try: + strategic_text = LITECLAW_STRATEGIC.read_text(encoding="utf-8").strip() + except OSError: + pass + if not strategic_text and LITECLAW_MEMORY.is_dir(): + try: + daily_mds = sorted(LITECLAW_MEMORY.glob("[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9].md")) + chunks = [] + for md in daily_mds[-3:]: + try: + chunks.append(md.read_text(encoding="utf-8").strip()) + except OSError: + continue + strategic_text = "\n\n---\n\n".join(chunks) + except OSError: + pass + info["strategic"] = bool(strategic_text) + parts = [ + "# LiteClaw startup primer", + f"_built: {datetime.now().isoformat(timespec='seconds')}_", + "", + ] + if strategic_text: + parts += ["## Strategic memory", strategic_text, ""] + if recent_lines: + parts += [f"## Recent {len(recent_lines)} turns", *recent_lines, ""] + if not strategic_text and not recent_lines: + parts.append("_(no prior context yet — clean start)_") + body = "\n".join(parts) + "\n" + try: + LITECLAW_PRIMER.write_text(body, encoding="utf-8") + info["size"] = len(body.encode("utf-8")) + except OSError as e: + log.warning(f"Primer: cannot write {LITECLAW_PRIMER}: {e}") + return info + + # ---- Claude Code session JSONL reading -------------------------------- + # These helpers lift responses out of Claude's own session log at + # ~/.claude/projects//.jsonl + # instead of scraping the tmux TUI pane. The jsonl is append-only and + # structured (role/content blocks), so we get clean text + a reliable + # "this turn is done" signal via stop_reason. + def _jsonl_path(self) -> Path | None: + sid = self._current_session_id or self._load_current_session_id() + if not sid: + return None + encoded = str(CLAUDE_CWD).replace("/", "-") + p = Path.home() / ".claude" / "projects" / encoded / f"{sid}.jsonl" + return p if p.exists() else None + + def _record_jsonl_offset(self) -> None: + """Stamp the current jsonl size so we can tail from here after send.""" + try: + p = self._jsonl_path() + self._jsonl_offset = p.stat().st_size if p else 0 + except Exception: + self._jsonl_offset = 0 + + def _tail_jsonl_since_offset(self) -> tuple[str, bool, int]: + """Read new jsonl content since `self._jsonl_offset`. + + Returns (concatenated_text, turn_complete, total_bytes_read_since_offset). + text = concatenation of every assistant `text` block that appeared + after the offset (across possibly many assistant messages in + a single turn — e.g. tool_use → tool_result → more text). + turn_complete = True iff the LAST assistant message observed has a + stop_reason of end_turn / stop_sequence / max_tokens. A + `tool_use` stop_reason means Claude wants a tool result next — + still mid-turn, so we keep waiting. + total_bytes_read_since_offset is used by the caller to detect file + growth (Claude still writing) separately from text growth, so chains + of tool_use-only messages don't trip the "stable" early-exit. + """ + p = self._jsonl_path() + if not p or not hasattr(self, "_jsonl_offset"): + return ("", False, 0) + try: + size = p.stat().st_size + if size <= self._jsonl_offset: + return ("", False, 0) + with open(p, "rb") as f: + f.seek(self._jsonl_offset) + chunk = f.read() + except Exception as e: + log.debug(f"jsonl tail read failed: {e}") + return ("", False, 0) + texts: list[str] = [] + last_stop_reason = None + for raw in chunk.splitlines(): + if not raw.strip(): + continue + try: + obj = _json.loads(raw.decode("utf-8", errors="replace")) + except Exception: + continue + msg = obj.get("message") or {} + if msg.get("role") != "assistant": + continue + for block in msg.get("content") or []: + if isinstance(block, dict) and block.get("type") == "text": + t = block.get("text") or "" + if t: + texts.append(t) + sr = msg.get("stop_reason") + if sr: + last_stop_reason = sr + turn_complete = last_stop_reason in ("end_turn", "stop_sequence", "max_tokens") + return ("\n".join(texts).strip(), turn_complete, len(chunk)) + + async def _poll_response_via_jsonl(self, timeout: float = 600.0) -> str | None: + """Wait up to `timeout` seconds for a complete assistant turn in jsonl. + + The completion signal is a stop_reason of end_turn / stop_sequence / + max_tokens on the last assistant message in the new range. A chain of + tool_use-only messages does NOT count as complete — we only return + early on a true stop. + + Fallback "stable" safety net: if the jsonl file itself hasn't grown + in >15 s AND we have some text, assume the writer has flushed and + return what we have. File growth tracks BYTES (not just text blocks) + so long tool_use chains with no intermediate text don't trip it. + + Side-effect: sets `self._jsonl_delivered_complete = True` when it + returns on a genuine stop_reason, so handle_message can skip the + follow-up monitor that would otherwise overwrite the delivered + message with a stale status edit. + """ + self._jsonl_delivered_complete = False + if not USE_JSONL_RESPONSE: + return None + if not self._jsonl_path(): + return None + deadline = time.monotonic() + timeout + last_text = "" + last_bytes = 0 + quiet_since: float | None = None + while time.monotonic() < deadline: + text, complete, bytes_read = self._tail_jsonl_since_offset() + if complete and text: + self._jsonl_delivered_complete = True + return text + if bytes_read > last_bytes: + last_bytes = bytes_read + quiet_since = None # file still growing → still working + elif text and quiet_since is None: + quiet_since = time.monotonic() # growth paused, start idle timer + elif text and quiet_since is not None: + # File quiet for ≥15 s AND we have some text → assume flushed. + if time.monotonic() - quiet_since >= 15.0: + log.info(f"jsonl quiet-since safety net tripped after {time.monotonic() - quiet_since:.1f}s idle") + # Quiet-safety-net fired — text is likely final but we + # never observed a stop_reason. Treat as complete for + # follow-up suppression purposes (better than a stray + # status-edit obliterating the clean text). + self._jsonl_delivered_complete = True + return text + if text and text != last_text: + last_text = text + await asyncio.sleep(1.0) + return last_text or None + + def _detect_resume_state(self) -> dict: + """Report on the LiteClaw-owned Claude Code session. + + start.sh allocates a stable UUID and stores it in + ~/.liteclaw/sessions.json under `liteclaw_session_id`, then launches + `claude --session-id `. This pin makes resume deterministic even + when the user runs other Claude Code windows in the same cwd. + + Also maintains `history[]` — appends a new entry whenever the + current session UUID changes (e.g. after --fork-session), so strategic + memory rollups can segment by session boundary instead of just by day. + """ + info = { + "resumable": False, + "session_id": None, + "session_path": None, + "newest_age_min": None, + } + existing = {} + if LITECLAW_SESSIONS.exists(): + try: + existing = _json.loads(LITECLAW_SESSIONS.read_text(encoding="utf-8")) + except Exception: + existing = {} + sess_id = existing.get("liteclaw_session_id") + info["session_id"] = sess_id + # Refresh the in-memory cache used by _log_conversation. + if sess_id: + self._current_session_id = sess_id + # Maintain session lineage. Close the prior entry if the UUID changed, + # and open a new one. History entries are append-only dicts. + now_iso = datetime.now().isoformat(timespec="seconds") + history = existing.get("history") + if not isinstance(history, list): + history = [] + last_entry = history[-1] if history else None + last_id = last_entry.get("id") if last_entry else None + if sess_id and sess_id != last_id: + if last_entry and last_entry.get("ended_at") is None: + last_entry["ended_at"] = now_iso + history.append({ + "id": sess_id, + "started_at": now_iso, + "ended_at": None, + "cwd": str(CLAUDE_CWD), + }) + existing["history"] = history + encoded = str(CLAUDE_CWD).replace("/", "-") + proj_dir = Path.home() / ".claude" / "projects" / encoded + if sess_id: + sess_file = proj_dir / f"{sess_id}.jsonl" + if sess_file.exists(): + age_s = time.time() - sess_file.stat().st_mtime + info["resumable"] = True + info["session_path"] = str(sess_file) + info["newest_age_min"] = round(age_s / 60, 1) + # Persist a refreshed snapshot, preserving start.sh's keys. + try: + LITECLAW_DIR.mkdir(parents=True, exist_ok=True) + payload = dict(existing) + payload.update({ + "cwd": str(CLAUDE_CWD), + "encoded": encoded, + "resumable": info["resumable"], + "session_path": info["session_path"], + "newest_age_min": info["newest_age_min"], + "checked_at": datetime.now().isoformat(timespec="seconds"), + }) + LITECLAW_SESSIONS.write_text(_json.dumps(payload, indent=2) + "\n", encoding="utf-8") + except OSError: + pass + return info + + def _send_boot_ready(self, extras: dict = None): + """Send a one-shot 'ready' Telegram message after bot init completes. + + Synchronous (uses httpx.Client) because this fires before app.run_polling + takes over the event loop. Failures are logged but never block startup. + Set BOOT_NOTIFY=0 in .env to disable. + """ + if not BOOT_NOTIFY: + log.info("Boot notify: disabled via BOOT_NOTIFY") + # Still intentionally fall through below — we want the bookkeeping + # (migrate, compact, primer build, resume state + history[]) to run + # even when the ping is disabled. We'll bail at the actual send. + # Compute rate-limit window (applied at send-time, not as an early + # return — otherwise sessions.json/primer bookkeeping gets skipped). + suppress_ping = not BOOT_NOTIFY + try: + last_boot_file = LITECLAW_DIR / ".last_boot_at" + now_ts = time.time() + if last_boot_file.exists(): + try: + prev = float(last_boot_file.read_text().strip()) + except Exception: + prev = 0.0 + if now_ts - prev < 300: # 5 minutes + age = int(now_ts - prev) + log.info(f"Boot notify: suppressed (last ping {age}s ago)") + suppress_ping = True + LITECLAW_DIR.mkdir(parents=True, exist_ok=True) + last_boot_file.write_text(f"{now_ts}\n") + except Exception as e: + log.warning(f"Boot notify rate-limit check failed (continuing): {e}") + try: + import socket + host = socket.gethostname() + except Exception: + host = "?" + try: + history_turns = sum(1 for _ in open(HISTORY_FILE, "r", encoding="utf-8")) if HISTORY_FILE.exists() else 0 + except OSError: + history_turns = 0 + # Loop 4 enrichments: migrate legacy, compact yesterday, refresh primer, + # probe resume state. All best-effort — failures must not block startup. + try: + self._migrate_legacy_history() + except Exception as e: + log.warning(f"Migration during boot notify failed: {e}") + try: + from datetime import timedelta + yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d") + r = self._compact_day(yesterday) + log.info(f"Compact {yesterday}: {r['status']} ({r.get('reason') or r.get('bytes')})") + except Exception as e: + log.warning(f"Compact day failed: {e}") + primer = self._build_primer() + resume = self._detect_resume_state() + lines = [ + "🚀 LiteClaw ready", + f"Host: {host}", + f"Target: {self.target}", + f"History: {history_turns} turns", + ] + sid = resume.get("session_id") + sid_short = sid[:8] if sid else "?" + if resume.get("resumable"): + age = resume.get("newest_age_min") + age_s = f", last touch {age}m ago" if age is not None else "" + lines.append(f"Resume: {sid_short} ({CLAUDE_CWD}{age_s})") + elif sid: + lines.append(f"Resume: {sid_short} (new session — first run)") + else: + lines.append("Resume: none (no session id allocated)") + primer_kb = round(primer.get("size", 0) / 1024, 1) + strat = "+strategic" if primer.get("strategic") else "" + lines.append(f"Primer: {primer.get('recent', 0)} recent turns{strat} ({primer_kb} KB)") + if extras: + for k, v in extras.items(): + lines.append(f"{k}: {v}") + text = "\n".join(lines) + if suppress_ping: + return + try: + with httpx.Client(timeout=10) as client: + r = client.post( + f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage", + json={"chat_id": CHAT_ID, "text": text, "disable_web_page_preview": True}, + ) + if r.status_code != 200: + log.warning(f"Boot notify failed: HTTP {r.status_code} {r.text[:200]}") + else: + log.info(f"Boot notify sent ({len(text)} chars)") + except Exception as e: + log.warning(f"Boot notify exception: {e}") + def _read_new_output(self) -> str: """Read new output from pipe log since last recorded offset.""" if not self._log_path or not os.path.exists(self._log_path): @@ -2691,9 +3387,12 @@ async def cmd_recall(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE): """Recall recent conversation history, optionally filtered by keyword. Usage: - /recall — summarize last 20 conversations - /recall 50 — summarize last 50 conversations - /recall keyword — search + summarize matching conversations + /recall — summarize last 20 conversations + /recall 50 — summarize last 50 conversations + /recall keyword — search + summarize matching conversations + /recall session — restrict to the current LiteClaw session + /recall session — restrict to a specific past session UUID + /recall session keyword — session-scoped keyword search """ if not self._auth(update): return @@ -2702,16 +3401,33 @@ async def cmd_recall(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE): await update.message.reply_text("No conversation history yet.") return - # Parse args: number or keyword - args = ctx.args + # Parse args: number, keyword, or session-scope. + args = list(ctx.args) if ctx.args else [] limit = HISTORY_RECALL_LIMIT keyword = None + session_filter: str | None = None # UUID or sentinel "__current__" + if args and args[0].lower() == "session": + args = args[1:] + session_filter = "__current__" + # Optional UUID (8+ hex chars with dashes) as 2nd token + if args and re.fullmatch(r"[0-9a-fA-F-]{8,}", args[0]): + session_filter = args[0] + args = args[1:] if args: if args[0].isdigit(): limit = min(int(args[0]), 200) else: keyword = " ".join(args).lower() + # Resolve __current__ to the active UUID. + if session_filter == "__current__": + session_filter = self._current_session_id or self._load_current_session_id() + if not session_filter: + await update.message.reply_text( + "현재 세션 ID가 아직 없습니다 (start.sh 재실행 필요 또는 session_id 미기록 상태)." + ) + return + # Read history (tail for efficiency) try: lines = HISTORY_FILE.read_text(encoding="utf-8").strip().split("\n") @@ -2728,6 +3444,28 @@ async def cmd_recall(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE): except _json.JSONDecodeError: continue + if session_filter: + # Accept both legacy full-UUID entries and compact alias entries. + # Resolve the filter to a candidate alias int via sessions.json. + sid_alias_target: int | None = None + try: + data = _json.loads(LITECLAW_SESSIONS.read_text(encoding="utf-8")) if LITECLAW_SESSIONS.exists() else {} + for idx, h in enumerate(data.get("history") or []): + if h.get("id") == session_filter: + sid_alias_target = idx + break + except Exception: + pass + def _match(e): + # New schema: "sid" is int + if "sid" in e and sid_alias_target is not None and e["sid"] == sid_alias_target: + return True + # Legacy schema: "session_id" was full UUID + if e.get("session_id") == session_filter: + return True + return False + entries = [e for e in entries if _match(e)] + if keyword: entries = [ e for e in entries @@ -2984,8 +3722,9 @@ async def handle_message(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE): parse_mode="Markdown", ) - # Record offset before sending + # Record offset before sending (both pipe-pane log and session jsonl) self._record_offset() + self._record_jsonl_offset() # Snapshot pane BEFORE sending for reliable diff extraction pre_snapshot = capture_pane(self.target, lines=SCROLLBACK_LINES) @@ -2998,11 +3737,41 @@ async def handle_message(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE): sent_msg = None if claude_idle: - sent_msg = await update.message.reply_text("📤 Sent. Waiting for response...") + sent_msg = await update.message.reply_text("⏳ 작업 중…") # Poll for response with streaming feedback response = await self._poll_response(ctx.bot, user_text, pre_snapshot=pre_snapshot) + # Phase A: prefer the structured session jsonl — this bypasses + # tmux pane scrollback truncation, ANSI/spinner chrome ("Thinking + # (Crystalizing)…"), and the summarizer's over-compression. Falls + # back silently to the pane-derived response if jsonl isn't + # available (missing path, race, or unparseable). + if USE_JSONL_RESPONSE: + try: + # Generous timeout — tool-heavy turns can run 5+ minutes. + # The inner loop still exits the moment stop_reason is set. + jsonl_text = await self._poll_response_via_jsonl(timeout=600.0) + except Exception as e: + log.warning(f"jsonl poll raised: {e}") + jsonl_text = None + # Sanity guard: if jsonl somehow returned *much less* than the + # pane-derived response, that usually means we returned the + # preamble text only (tool_use chain still going). Prefer the + # pane-derived version in that case so the user doesn't see a + # truncated "이해했습니다…" stub. + pane_len = len(response) + jsonl_len = len(jsonl_text or "") + if jsonl_text and (pane_len == 0 or jsonl_len >= pane_len * 0.5): + log.info(f"Response via jsonl: {jsonl_len} chars (replacing pane-derived {pane_len} chars)") + response = jsonl_text + # jsonl text is already clean — skip the summarizer pass. + self._skip_summarizer_once = True + elif jsonl_text: + log.warning( + f"jsonl returned {jsonl_len} chars but pane has {pane_len} — keeping pane-derived" + ) + # Delete status messages before delivering final response for msg in (sent_msg, busy_msg): if msg: @@ -3024,11 +3793,20 @@ async def handle_message(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE): # Deliver response (with persistent retry) delivered_msg_id = await self._deliver_response(response, user_text, update, ctx, meta=_conv_meta) - # Schedule follow-up check: if Claude produces more output, edit the message - if delivered_msg_id: + # Schedule follow-up only when pane-scrape was the source. When the + # jsonl path returned a complete (`stop_reason=end_turn`) turn, + # there is literally no more assistant output coming — spinning up + # the follow-up monitor just leads it to overwrite our clean final + # message with a "⏳ Working... (Ns)" status edit (issue: user's + # delivered message gets replaced by a short truncated one). + skip_followup = bool(getattr(self, "_jsonl_delivered_complete", False)) + self._jsonl_delivered_complete = False # one-shot reset + if delivered_msg_id and not skip_followup: self._followup_task = asyncio.create_task( self._followup_edit(user_text, pre_snapshot, delivered_msg_id, ctx.bot) ) + elif skip_followup: + log.info("Skipping _followup_edit (jsonl turn already complete)") except RuntimeError as e: await update.message.reply_text(f"Error: {e}") @@ -3085,7 +3863,14 @@ async def _deliver_response(self, response: str, user_text: str, update: Update, if not response.strip(): response = "(reasoning only — no final answer extracted)" - if not self.raw_mode: + # Phase A: if jsonl gave us a clean response, skip the summarizer + # entirely — no ANSI/spinner noise to strip, no content compression + # to suffer. One-shot flag cleared immediately. + skip_sum = getattr(self, "_skip_summarizer_once", False) + self._skip_summarizer_once = False + if skip_sum: + log.info(f"Summarizer skipped (jsonl-sourced response, {len(response)} chars)") + elif not self.raw_mode: try: await ctx.bot.send_chat_action(chat_id=CHAT_ID, action=ChatAction.TYPING) except Exception: @@ -3678,8 +4463,10 @@ async def _poll_response(self, bot, user_text: str = "", pre_snapshot: str = "") self._last_interactive_options = list(interactive.get("options", [])) await self._send_interactive_prompt(bot, interactive) - # Status update at adaptive interval — show last few meaningful lines - if elapsed - last_status >= status_interval: + # Status update at adaptive interval — show last few meaningful lines. + # Gated by SHOW_POLLING_STATUS: off by default because this path is + # what surfaced "Thinking (Crystalizing)…" TUI chrome in Telegram. + if SHOW_POLLING_STATUS and elapsed - last_status >= status_interval: last_status = elapsed status_capture = capture_pane(self.target, lines=15) preview_text = clean_output(status_capture).strip() @@ -4323,6 +5110,9 @@ async def on_init(app): log.info(f"Bridge started. Target: {self.target}") log.info("Send a message to your Telegram bot to begin.") + # Loop 4 will populate `extras` with resume/primer status; for now this + # ships the basic ready ping so users know startup completed. + self._send_boot_ready() app.run_polling(drop_pending_updates=False) From ac0802665e229aa7a062ca2312b380ccb39c687d Mon Sep 17 00:00:00 2001 From: breaktheready Date: Wed, 22 Apr 2026 22:17:30 +0900 Subject: [PATCH 06/12] [docs] README + README_KO: document v0.6 changes - New "What's new in v0.6 (April 2026)" section (EN + KO) covering: global CLI, --session-id pinning, jsonl response path, OpenClaw- style memory layout, boot-ready ping, cron hardening, /recall session, quieter Telegram UX. - Configuration table gains BOOT_NOTIFY, LITECLAW_DIR, CLAUDE_CWD, PRIMER_RECENT_TURNS, USE_JSONL_RESPONSE, SHOW_POLLING_STATUS. - Quick Start documents `liteclaw start|stop|...` as the preferred entry point (global CLI via ~/.local/bin/liteclaw symlink). - Notes that the underlying start.sh and manual flow still work. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 42 ++++++++++++++++++++++++++++++++++++------ README_KO.md | 46 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 053ff05..c1c99cb 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,23 @@ Unlike tools that call Claude's API directly (which means extra costs), LiteClaw --- +## What's new in v0.6 (April 2026) + +Focused on reliability, delivery quality, and agent continuity: + +- **Global `liteclaw` CLI** — `liteclaw start|stop|restart|status|logs|attach` from any directory. `setup.sh` installs a symlink in `~/.local/bin/liteclaw`. +- **Pinned Claude Code session (`--session-id `)** — `start.sh` allocates / adopts a stable UUID and always resumes the same conversation, even when other Claude Code windows are open in the same cwd. +- **Structured JSONL response path** — responses are now lifted from Claude Code's own session log (`~/.claude/projects//.jsonl`) instead of scraping the tmux pane. No more ANSI chrome, no scroll-back truncation, no summarizer over-compression on long answers. Automatic fallback to the pane path if jsonl is unavailable. +- **OpenClaw-style memory layout** (`~/.liteclaw/`) — per-day transcripts, daily markdown digests (LLM-compacted), rolling strategic summary, and a startup primer assembled from recent + strategic context. Legacy `~/.liteclaw-history.jsonl` auto-migrates on first boot. +- **Boot-ready Telegram ping** — one-shot "🚀 LiteClaw ready" after init with resume state + primer size. Rate-limited to avoid dev-churn spam (`BOOT_NOTIFY`). +- **Cron hardening** — trust-prompt auto-accept, `is_idle_prompt` false-positive fix (Claude's UI chrome no longer jams the busy-wait), 300 s tolerance window with a stable-prompt fallback, and failures are captured with full pane snapshot to `~/.liteclaw/cron-error-capture.md` for later review. +- **`/recall session [uuid]`** — session-scoped conversation recall. Transcripts carry a compact integer alias (`sid`) into `sessions.json.history[]` so every row is ~70 % smaller than embedding the full UUID. +- **Quieter Telegram UX** — mid-poll status edits off by default (`SHOW_POLLING_STATUS=0`); completed jsonl turns skip the follow-up monitor so the final message is never overwritten with a stale status edit. + +See `DEVNOTES.md` for the story behind these changes and the parking-lot for related ideas. + +--- + ## Prerequisites - **Python** 3.10+ @@ -118,16 +135,23 @@ python3 liteclaw.py Send `/start` to your Telegram bot to confirm it is working. -### One-command launch (`start.sh`) +### One-command launch (`start.sh` or global `liteclaw`) -Once `.env` is configured, you can start both Claude Code (in tmux `claude`) and LiteClaw together: +`setup.sh` symlinks a global `liteclaw` dispatcher into `~/.local/bin/`, so once installed you can run from any directory: ```bash -bash ./start.sh # both services in detached tmux sessions -bash ./start.sh --attach # ... and attach to the claude pane +liteclaw start # bring up Claude Code (tmux 'claude') + LiteClaw daemon +liteclaw start --attach # ... and attach to the claude pane after +liteclaw stop # tear both down +liteclaw restart # stop + start +liteclaw status # tmux sessions, daemon pid, dashboard port +liteclaw logs [-f] # tail /tmp/liteclaw_run.log +liteclaw attach # tmux attach -t claude ``` -The script is idempotent — re-running it is a no-op when both are already up. Order is enforced (Claude Code first, prompt-readiness wait, then LiteClaw) so the bridge always has a target. The "trust this folder" dialog is auto-accepted. +The underlying `bash ./start.sh [--attach]` still works for direct invocation. It's idempotent — re-running is a no-op when both are already up. Order is enforced (Claude Code first, prompt-readiness wait, then LiteClaw) so the bridge always has a target. The "trust this folder" dialog is auto-accepted. + +**Session pinning**: `start.sh` allocates (or adopts) a stable UUID on first run and stores it in `~/.liteclaw/sessions.json`, then launches `claude --session-id `. Every subsequent `liteclaw start` resumes the *same* conversation — unaffected by other Claude Code windows you may open in the same cwd. --- @@ -148,8 +172,14 @@ All settings are controlled via `.env`. Copy `.env.example` and edit as needed. | `EXTRA_PROMPT_PATTERNS` | (empty) | Comma-separated regex patterns for custom prompt detection | | `PROXY_DIR` | `~/max_api_proxy` | API proxy directory for auto-recovery (`docker compose up -d`) | | `DASHBOARD_PORT` | `7777` | Web dashboard port (set `0` to disable) | -| `HISTORY_FILE` | `~/.liteclaw-history.jsonl` | Conversation history location | +| `HISTORY_FILE` | `~/.liteclaw-history.jsonl` | Legacy single-file conversation history (still written for back-compat with `/recall`) | | `HISTORY_RECALL_LIMIT` | `50` | Max entries returned by `/recall` | +| `BOOT_NOTIFY` | `1` | Send a one-shot "LiteClaw ready" Telegram ping after init. `0` to disable. | +| `LITECLAW_DIR` | `~/.liteclaw` | Root of the OpenClaw-style memory layout (`transcripts/`, `memory/`, `sessions.json`, `primer.md`) | +| `CLAUDE_CWD` | `$HOME` | Pinned working directory where `start.sh` launches Claude Code. Used to locate the session JSONL under `~/.claude/projects//`. | +| `PRIMER_RECENT_TURNS` | `20` | Turns pulled from recent history when building `primer.md` | +| `USE_JSONL_RESPONSE` | `1` | Prefer Claude Code's session JSONL as the response source (falls back to pane scraping on failure). `0` to force pane path. | +| `SHOW_POLLING_STATUS` | `0` | Emit intermediate "Working…" status edits to Telegram while Claude works. Off by default — these were the source of garbled mid-answer updates. | --- diff --git a/README_KO.md b/README_KO.md index 7591f67..a147ddc 100644 --- a/README_KO.md +++ b/README_KO.md @@ -43,6 +43,21 @@ Claude API를 직접 호출하는 도구들과 달리 (= 추가 비용), LiteCla - **자동 복구** — API 프록시 다운타임을 자동 감지하고 복구합니다. 401 에러 시 Claude Code 세션을 자동 재인증하고, 복구 완료 시 텔레그램으로 알림을 보냅니다. - **통합 알림** — 모든 텔레그램 알림이 단일 `notify.py` 모듈을 통해 요약기를 거쳐 전달됩니다. 요약기를 사용할 수 없는 경우 원본 출력으로 자동 전환됩니다. +## v0.6 업데이트 (2026년 4월) + +안정성, 전달 품질, 에이전트 연속성에 집중: + +- **전역 `liteclaw` CLI** — 어느 디렉토리에서든 `liteclaw start|stop|restart|status|logs|attach`. `setup.sh`가 `~/.local/bin/liteclaw`에 심링크 설치. +- **Claude Code 세션 고정 (`--session-id `)** — `start.sh`가 안정적인 UUID를 발급/채택해서 `~/.liteclaw/sessions.json`에 저장. 같은 cwd에서 다른 Claude Code 창을 열어도 LiteClaw는 항상 자기 세션에 이어붙음. +- **구조화된 JSONL 응답 경로** — tmux 화면을 긁는 대신 Claude Code 본인의 세션 로그(`~/.claude/projects//.jsonl`)를 읽어 응답을 추출. ANSI 노이즈, 스크롤백 잘림, 요약기 과압축 모두 제거. jsonl 실패 시 기존 pane 경로로 자동 폴백. +- **OpenClaw 스타일 메모리 레이아웃** (`~/.liteclaw/`) — 일별 transcript, 일별 markdown 다이제스트(LLM 압축), 장기 전략 요약, 기동 시 프라이머 자동 합성. 기존 `~/.liteclaw-history.jsonl`은 첫 부팅 때 자동 마이그레이션. +- **부팅 완료 텔레그램 알림** — init 완료 후 "🚀 LiteClaw ready" + resume 상태 + primer 크기 전송. 5분 내 재기동 시 자동 생략(`BOOT_NOTIFY`). +- **크론 내구성 강화** — trust 프롬프트 자동 수락, `is_idle_prompt` 오탐 수정(Claude UI chrome에 걸려 120초 타임아웃 나던 버그), 300초 허용창과 stable-prompt 폴백, 실패 시 pane 스냅샷 포함 전체 기록이 `~/.liteclaw/cron-error-capture.md`에 누적. +- **`/recall session [uuid]`** — 현재 세션 또는 특정 세션 내 대화만 요약/검색. 각 transcript 엔트리엔 `sessions.json.history[]` 배열 인덱스(`sid`)만 심어서 풀 UUID 대비 ~70% 짧음. +- **조용해진 텔레그램 UX** — 폴링 중간 상태 편집 기본 OFF(`SHOW_POLLING_STATUS=0`); jsonl로 완결된 턴은 follow-up 모니터를 건너뛰어 배달된 메시지가 상태 편집으로 덮어쓰이지 않음. + +변화 배경과 파킹 로트 아이디어는 `DEVNOTES.md` 참조. + ## 빠른 시작 ### 자동 설치 (권장) @@ -98,6 +113,29 @@ TMUX_TARGET=claude:1 #### 실행 +**권장: 전역 `liteclaw` 명령** — `setup.sh`가 `~/.local/bin/liteclaw`에 심링크를 만들어두므로 어느 디렉토리에서든: + +```bash +liteclaw start # Claude Code(tmux 'claude') + LiteClaw 데몬 기동 +liteclaw start --attach # 그리고 claude pane에 attach +liteclaw stop # 둘 다 종료 +liteclaw restart # stop + start +liteclaw status # tmux 세션, 데몬 pid, 대시보드 포트 요약 +liteclaw logs [-f] # /tmp/liteclaw_run.log tail +liteclaw attach # tmux attach -t claude +``` + +**직접 실행 (대체)**: + +```bash +bash ./start.sh # 위와 동일, 저장소 안에서만 동작 +bash ./start.sh --attach +``` + +**세션 고정**: `start.sh`가 첫 실행 시 안정 UUID 하나를 발급하거나 기존 세션을 채택해서 `~/.liteclaw/sessions.json`에 저장합니다. 그 후 `liteclaw start` 할 때마다 `claude --session-id `로 같은 대화를 복귀 — 같은 cwd에 다른 Claude Code 창이 있어도 LiteClaw는 자기 세션 고수. + +수동 설정이 필요하면 원래 방식도 그대로 동작: + ```bash # 터미널 1: Claude Code 시작 tmux new-session -s claude 'claude --dangerously-skip-permissions' @@ -124,8 +162,14 @@ python3 liteclaw.py | `EXTRA_PROMPT_PATTERNS` | (비어있음) | 커스텀 프롬프트 감지용 정규식 (쉼표 구분) | | `PROXY_DIR` | `~/max_api_proxy` | API 프록시 디렉토리 (자동 복구용 `docker compose up -d`) | | `DASHBOARD_PORT` | `7777` | 웹 대시보드 포트 (`0`으로 비활성화) | -| `HISTORY_FILE` | `~/.liteclaw-history.jsonl` | 대화 기록 파일 위치 | +| `HISTORY_FILE` | `~/.liteclaw-history.jsonl` | 레거시 단일 파일 대화 기록 (`/recall` back-compat용으로 계속 기록) | | `HISTORY_RECALL_LIMIT` | `50` | `/recall` 최대 반환 개수 | +| `BOOT_NOTIFY` | `1` | 데몬 기동 완료 후 "LiteClaw ready" 텔레그램 알림 (0으로 비활성화) | +| `LITECLAW_DIR` | `~/.liteclaw` | OpenClaw 스타일 메모리 루트 (`transcripts/`, `memory/`, `sessions.json`, `primer.md`) | +| `CLAUDE_CWD` | `$HOME` | `start.sh`가 Claude Code를 띄우는 고정 cwd. 세션 JSONL 위치(`~/.claude/projects//`) 해석에 사용 | +| `PRIMER_RECENT_TURNS` | `20` | `primer.md` 합성 시 최근 이력에서 당길 turn 수 | +| `USE_JSONL_RESPONSE` | `1` | Claude Code 세션 JSONL을 응답 소스로 우선 사용 (실패 시 pane 스크래프로 자동 폴백). `0`이면 pane 강제 | +| `SHOW_POLLING_STATUS` | `0` | Claude가 작업 중일 때 중간 "Working…" 상태를 텔레그램에 보낼지 여부. 기본 OFF — 이게 메시지 중간 덮어쓰기의 원인이었음 | ## 텔레그램 명령어 From 8b8e8c3f9c3e043f3dea856177c36d5c45d5ae09 Mon Sep 17 00:00:00 2001 From: breaktheready Date: Wed, 22 Apr 2026 22:29:24 +0900 Subject: [PATCH 07/12] [feat] /tell-summarizer runtime control over summarizer prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New command to append user-provided instructions to the summarizer's system prompt at runtime, without code edits. Useful when the user wants to tune summarization behavior ("never drop numbered option lists", "always preserve code blocks verbatim", etc.) from Telegram. Commands: /tell-summarizer append to extra prompt (in-memory) /tell-summarizer show display current extras /tell-summarizer clear wipe extras /tellsum short alias Persistence: in-memory only. Resets on daemon restart. This is intentional — runtime tuning should not quietly leak across restarts. Integration point: `_summarize` appends `self._summarizer_extra_prompt` to `SUMMARIZE_PROMPT` under a "USER-PROVIDED RUNTIME INSTRUCTIONS" header before sending to the tier-1 proxy. Co-Authored-By: Claude Opus 4.7 (1M context) --- liteclaw.py | 54 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/liteclaw.py b/liteclaw.py index cd635dc..040d44f 100644 --- a/liteclaw.py +++ b/liteclaw.py @@ -2468,6 +2468,45 @@ async def cmd_raw(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE): mode = "ON (raw output)" if self.raw_mode else "OFF (summarized)" await update.message.reply_text(f"Raw mode: {mode}") + async def cmd_tell_summarizer(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE): + """Runtime control over the summarizer system prompt. + + Usage: + /tell-summarizer — append instruction (persists in-memory) + /tell-summarizer show — show the current extra instructions + /tell-summarizer clear — clear all extra instructions + """ + if not self._auth(update): + return + args = ctx.args or [] + joined = " ".join(args).strip() + current = getattr(self, "_summarizer_extra_prompt", "").strip() + if not args or joined.lower() == "show": + body = current or "(empty — no extra instructions active)" + await update.message.reply_text( + f"📝 *Summarizer runtime instructions*\n\n```\n{body}\n```\n\n" + "Usage:\n" + "`/tell-summarizer ` — append\n" + "`/tell-summarizer clear` — wipe\n" + "`/tell-summarizer show` — this view", + parse_mode="Markdown", + ) + return + if joined.lower() == "clear": + self._summarizer_extra_prompt = "" + self._log_event("summarizer_tell", "cleared") + await update.message.reply_text("📝 Summarizer extra instructions cleared.") + return + # Append the new instruction on its own line (multiple can accumulate) + new = (current + "\n" + joined).strip() if current else joined + self._summarizer_extra_prompt = new + self._log_event("summarizer_tell", f"appended: {joined[:200]}") + await update.message.reply_text( + f"📝 Summarizer now also honors:\n```\n{joined}\n```\n" + f"(Total extra length: {len(new)} chars. Use `/tell-summarizer clear` to reset.)", + parse_mode="Markdown", + ) + async def cmd_mirror(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE): """Toggle CLI mirror — forwards direct terminal activity to Telegram.""" if not self._auth(update): @@ -4930,11 +4969,20 @@ async def _summarize(self, user_question: str, raw_output: str) -> str: log.info("Skipping summarize: too short") return raw_output - # Tier 1: API proxy + # Tier 1: API proxy. Append any runtime extra instruction the user set + # via /tell-summarizer so they can tune behavior without code edits. + system_prompt = SUMMARIZE_PROMPT + extra = getattr(self, "_summarizer_extra_prompt", "").strip() + if extra: + system_prompt = ( + SUMMARIZE_PROMPT + + "\n\nUSER-PROVIDED RUNTIME INSTRUCTIONS (from /tell-summarizer — honor these):\n" + + extra + ) tier1_payload = { "model": SUMMARIZER_MODEL, "messages": [ - {"role": "system", "content": SUMMARIZE_PROMPT}, + {"role": "system", "content": system_prompt}, {"role": "user", "content": ( f"[REFORMAT TASK] Clean up the following terminal output for Telegram delivery.\n" f"Context — the user asked: \"{user_question}\"\n\n" @@ -5068,6 +5116,8 @@ async def on_init(app): app.add_handler(CommandHandler("cancel", self.cmd_cancel)) app.add_handler(CommandHandler("escape", self.cmd_escape)) app.add_handler(CommandHandler("raw", self.cmd_raw)) + app.add_handler(CommandHandler("tell_summarizer", self.cmd_tell_summarizer)) + app.add_handler(CommandHandler("tellsum", self.cmd_tell_summarizer)) # short alias app.add_handler(CommandHandler("mirror", self.cmd_mirror)) app.add_handler(CommandHandler("reasoning", self.cmd_reasoning)) app.add_handler(CommandHandler("model", self.cmd_model)) From 3ab2e4e9c6a06ba27b699d4b615f906bf1d80343 Mon Sep 17 00:00:00 2001 From: breaktheready Date: Wed, 22 Apr 2026 22:29:39 +0900 Subject: [PATCH 08/12] [chore] community hygiene: badges, CI, issue templates, CONTRIBUTING - README.md / README_KO.md: shields.io badges (License, Python, Platform, CI, GitHub stars) at the top. - .github/workflows/ci.yml: minimal but meaningful checks - liteclaw.py AST parse - bash -n on setup.sh / start.sh / bin/liteclaw - requirements.txt install + import check (httpx, telegram, dotenv, apscheduler) - advisory ruff lint (continue-on-error until codebase catches up) - .github/ISSUE_TEMPLATE/bug_report.md and feature_request.md with LiteClaw-specific prompts (env versions, cron-error-capture.md reference, scope checkboxes). - CONTRIBUTING.md: ground rules (single-file core, no new API key, fallbacks non-negotiable), project structure, commit style, release flow. Calls out that Telegram-side UX regressions are easy to miss in code review and asks PRs to attach a before/after transcript when relevant. No runtime behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/ISSUE_TEMPLATE/bug_report.md | 48 +++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 34 +++++++++ .github/workflows/ci.yml | 62 +++++++++++++++++ CONTRIBUTING.md | 85 +++++++++++++++++++++++ README.md | 6 ++ README_KO.md | 6 ++ 6 files changed, 241 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/workflows/ci.yml create mode 100644 CONTRIBUTING.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..bbd5c3a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,48 @@ +--- +name: Bug report +about: Something isn't working as expected +title: "[bug] " +labels: bug +assignees: '' +--- + +## What happened? + + + +## Environment + +- **OS**: +- **Python**: +- **tmux**: +- **Claude Code CLI**: +- **LiteClaw commit**: + +## How to reproduce + +1. ... +2. ... +3. ... + +## Expected vs actual + +- **Expected**: ... +- **Actual**: ... + +## Logs + +
+Relevant lines from /tmp/liteclaw_run.log + +``` +(paste log excerpt here — redact BOT_TOKEN if present) +``` + +
+ + + +## Additional context + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..282e658 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,34 @@ +--- +name: Feature request +about: Suggest an improvement or new capability +title: "[feat] " +labels: enhancement +assignees: '' +--- + +## The problem + + + +## Proposed solution + + + +## Alternatives considered + + + +## Scope / impact + +- [ ] Config-only (new env var, toggle) +- [ ] Single-file Python change in `liteclaw.py` +- [ ] Touches shell scripts (`start.sh`, `setup.sh`, `bin/liteclaw`) +- [ ] Touches storage layout (`~/.liteclaw/` or `~/.claude/projects/...`) +- [ ] New Telegram command +- [ ] Needs external integration (API proxy, bot webhook, etc.) + +## Additional context + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9288688 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + branches: [main, feat/*] + pull_request: + branches: [main] + +jobs: + syntax-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Python syntax check (liteclaw.py) + run: python -c "import ast; ast.parse(open('liteclaw.py').read()); print('liteclaw.py syntax OK')" + + - name: Bash syntax check (shell entry points) + run: | + bash -n setup.sh + bash -n start.sh + bash -n bin/liteclaw + echo "bash syntax OK" + + - name: Install requirements (import check) + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + python -c " + import importlib + for mod in ('httpx', 'telegram', 'dotenv', 'apscheduler'): + importlib.import_module(mod) + print(f'import {mod}: OK') + " + + - name: Static import check on liteclaw.py + run: | + # `python -c "import liteclaw"` would try to spin up the bot; we only + # want to verify the module parses and top-level imports resolve. + python -c " + import ast, sys + src = open('liteclaw.py').read() + tree = ast.parse(src) + top_imports = [n for n in tree.body if isinstance(n, (ast.Import, ast.ImportFrom))] + print(f'{len(top_imports)} top-level imports parse cleanly') + " + + lint-ruff: + runs-on: ubuntu-latest + continue-on-error: true # linting is advisory; don't fail the build while codebase catches up + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install ruff + - run: ruff check liteclaw.py --output-format=github || true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..96fe0a4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,85 @@ +# Contributing to LiteClaw + +Thanks for considering a contribution. LiteClaw is a small, opinionated +single-file Python bridge — changes should match that shape. + +## Ground rules + +- **Keep the core a single file.** `liteclaw.py` intentionally ships as one + file so operators can read / fork / deploy with zero ceremony. Factor into + separate modules only when the piece is genuinely standalone (e.g., a new + executable under `bin/` or an isolated helper). +- **No new Anthropic API key.** The project's reason-for-being is "reuse + your existing Claude Code CLI session." A patch that introduces a hard + dependency on `ANTHROPIC_API_KEY` or a specific vendor SDK will be closed. +- **Fallbacks are non-negotiable.** Anything that relies on an external + service (summarizer proxy, jsonl path, pyannote, etc.) must degrade + gracefully, not crash, when the external piece is unavailable. +- **Prefer environment variables over new CLI flags.** LiteClaw is + configured via `.env`. New knobs should land in `.env.example` with a + one-line description and a sane default. + +## Project structure + +``` +liteclaw/ +├── liteclaw.py # core daemon (~5k lines, single file) +├── start.sh # tmux session + session-id pin +├── setup.sh # one-shot install (deps, venv, CLI symlink) +├── bin/liteclaw # global CLI dispatcher +├── .env.example # all configuration knobs +├── README.md / README_KO.md # user docs +├── MAC-OPS.md # macOS proxy/launchd notes +└── DEVNOTES.md # NOT shipped — session-scoped devnotes (gitignored) +``` + +## Local sanity checks + +Before opening a PR run at least: + +```bash +python3 -c "import ast; ast.parse(open('liteclaw.py').read()); print('OK')" +bash -n setup.sh start.sh bin/liteclaw +``` + +The GitHub Actions workflow `.github/workflows/ci.yml` runs the same checks +plus a dependency import test on every push / PR. + +## Commit style + +- Small, logical commits. Split a feature from refactors from docs. +- Title prefix: `[feat]`, `[fix]`, `[docs]`, `[chore]`, `[refactor]`. +- Body: wrap at ~80 chars, explain the **why** and cite line numbers for + non-obvious changes. + +## Telegram-side behavior changes + +If your change affects what ends up in the user's Telegram chat (summarizer +prompt, follow-up edits, jsonl path, etc.), include a short "user-visible +diff" section in the PR description — ideally a before/after screenshot or +transcript snippet. Regressions in Telegram UX have been subtle and hard to +notice in code review alone. + +## Issues and discussion + +- Bug report → [template](.github/ISSUE_TEMPLATE/bug_report.md). Please + include `/tmp/liteclaw_run.log` excerpts (redact `BOT_TOKEN`). +- Feature request → [template](.github/ISSUE_TEMPLATE/feature_request.md). + Concrete proposals move faster than abstract wishes. + +## Security + +If you find a security issue (credential leak, RCE surface, etc.), please +do NOT open a public issue. Contact the author directly via GitHub +(@breaktheready) with details. + +## Release flow + +Maintainer-only: + +1. Merge PRs into `main`. +2. Tag `vX.Y.Z` on `main`. +3. GitHub Release with notes summarizing the "What's new in vX.Y" section + from the README. + +Thanks for making LiteClaw sharper. diff --git a/README.md b/README.md index c1c99cb..96272d5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # LiteClaw +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) +[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/) +[![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg)](#platform-support) +[![CI](https://github.com/breaktheready/liteclaw/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/breaktheready/liteclaw/actions/workflows/ci.yml) +[![GitHub stars](https://img.shields.io/github/stars/breaktheready/liteclaw?style=social)](https://github.com/breaktheready/liteclaw/stargazers) + Control Claude Code CLI remotely via Telegram. No API key needed. [한국어](README_KO.md) diff --git a/README_KO.md b/README_KO.md index a147ddc..589aacf 100644 --- a/README_KO.md +++ b/README_KO.md @@ -1,5 +1,11 @@ # LiteClaw +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) +[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/) +[![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg)](#플랫폼-지원) +[![CI](https://github.com/breaktheready/liteclaw/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/breaktheready/liteclaw/actions/workflows/ci.yml) +[![GitHub stars](https://img.shields.io/github/stars/breaktheready/liteclaw?style=social)](https://github.com/breaktheready/liteclaw/stargazers) + 텔레그램으로 Claude Code CLI를 원격 제어. 추가 API 키 불필요. [English](README.md) From 09d38ccc4e7c08790cd4ecbf6d18eda5c834c135 Mon Sep 17 00:00:00 2001 From: breaktheready Date: Thu, 23 Apr 2026 21:23:34 +0900 Subject: [PATCH 09/12] [feat] daemon-restart resilience + CHANGELOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two layers of protection so user replies don't get silently lost when the daemon is restarted (intentional or crash) while a poll task is in flight: 1. Persistent pending replies (~/.liteclaw/pending_replies.json) - Every user message with a placeholder sent but no reply yet is recorded with: user_msg_id, placeholder_msg_id, chat_id, jsonl offset, sent timestamp, target. - On daemon boot, _resume_pending_on_boot (registered as Application.post_init) walks the file: * if the session jsonl now has a complete turn → edit the placeholder in-place with the answer * else if the entry is older than PENDING_MAX_AGE_SEC (default 900 s) → mark the placeholder as "재기동으로 유실됨" * else → spawn a background polling task that completes the delivery later - Survives crashes / SIGKILL / power loss because state lives on disk. 2. Graceful shutdown - Application.run_polling now passes stop_signals=(SIGINT, SIGTERM) explicitly so pkill / launchctl bootout trigger PTB's in-flight handler drain instead of cutting mid-update. Behavioral changes: - handle_message now records pending right after sending the placeholder and clears it after _deliver_response returns. - All bookkeeping is best-effort; failures log a warning and never block the user-facing path. New env: PENDING_MAX_AGE_SEC (default 900) — entries older than this on boot are abandoned with a "lost" notice rather than re-polled. Also adds CHANGELOG.md covering this work plus the v0.6.1 fixes done in the same session (cron path fix, market-coach timeout bumps, launchd TZ fix, okl-ear path + buffering fix, knowledge.json prompt injection, /tell-summarizer command, battery morning check cron). Co-Authored-By: Claude Opus 4.7 (1M context) --- liteclaw.py | 201 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 200 insertions(+), 1 deletion(-) diff --git a/liteclaw.py b/liteclaw.py index 040d44f..7a62287 100644 --- a/liteclaw.py +++ b/liteclaw.py @@ -102,6 +102,12 @@ LITECLAW_STRATEGIC = LITECLAW_MEMORY / "strategic.md" LITECLAW_PRIMER = LITECLAW_DIR / "primer.md" LITECLAW_SESSIONS = LITECLAW_DIR / "sessions.json" +# Pending replies — user messages whose placeholder was sent but the response +# wasn't delivered before the daemon stopped. Persisted so a restart can read +# the session jsonl, find the now-completed turn, and edit the placeholder +# in-place. See _record_pending / _resume_pending_on_boot. +LITECLAW_PENDING = LITECLAW_DIR / "pending_replies.json" +PENDING_MAX_AGE_SEC = int(os.environ.get("PENDING_MAX_AGE_SEC", "900")) # 15 min # Boot notification (sent to Telegram once after the bot is fully initialized) BOOT_NOTIFY = os.environ.get("BOOT_NOTIFY", "1").lower() not in ("0", "false", "no", "off") @@ -922,6 +928,9 @@ def __init__(self): # Phase A: jsonl-based response extraction state. self._jsonl_offset: int = 0 self._skip_summarizer_once: bool = False + # Daemon-restart resilience: graceful shutdown flag + pending file. + self._shutting_down: bool = False + self._summarizer_extra_prompt: str = "" def _load_config(self): """Load persisted runtime toggles from ~/.liteclaw/config.json.""" @@ -2264,6 +2273,162 @@ def _detect_resume_state(self) -> dict: pass return info + # ---- Daemon-restart resilience ----------------------------------------- + # When the daemon is restarted (intentional or crash) while a user reply + # is in-flight, the response would otherwise be silently lost — the + # placeholder Telegram message keeps showing "⏳ 작업 중…" forever. To + # avoid that, every time we send a placeholder we record the (user_msg_id, + # placeholder_msg_id, jsonl_offset, sent_at) tuple to a persistent file. + # On boot, we read that file and either deliver the now-completed answer + # from the session jsonl, resume polling, or mark the placeholder as + # "재기동으로 유실" if it's stale. + def _read_pending(self) -> dict: + try: + return _json.loads(LITECLAW_PENDING.read_text(encoding="utf-8")) + except FileNotFoundError: + return {"version": 1, "pending": []} + except Exception as e: + log.warning(f"pending file unreadable, resetting: {e}") + return {"version": 1, "pending": []} + + def _write_pending(self, data: dict) -> None: + try: + LITECLAW_DIR.mkdir(parents=True, exist_ok=True) + LITECLAW_PENDING.write_text(_json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + except OSError as e: + log.warning(f"pending file write failed: {e}") + + def _record_pending(self, user_msg_id: int, placeholder_msg_id: int | None, + chat_id: int, user_text: str) -> None: + """Mark a user message as awaiting reply. Called right after the + placeholder ('⏳ 작업 중…') is sent, before _poll_response begins.""" + data = self._read_pending() + # Drop any prior pending entry for this same user message (re-record on retry) + data["pending"] = [e for e in data.get("pending", []) if e.get("user_msg_id") != user_msg_id] + data["pending"].append({ + "user_msg_id": user_msg_id, + "placeholder_msg_id": placeholder_msg_id, + "chat_id": chat_id, + "user_text": (user_text or "")[:500], + "jsonl_offset": getattr(self, "_jsonl_offset", 0), + "jsonl_session_id": self._current_session_id, + "sent_at": datetime.now().isoformat(timespec="seconds"), + "target": self.target, + }) + # Cap at 50 entries to keep file small in case of leak + data["pending"] = data["pending"][-50:] + self._write_pending(data) + + def _clear_pending(self, user_msg_id: int) -> None: + """Drop the pending entry once we've successfully delivered the reply.""" + try: + data = self._read_pending() + before = len(data.get("pending", [])) + data["pending"] = [e for e in data.get("pending", []) if e.get("user_msg_id") != user_msg_id] + if len(data["pending"]) != before: + self._write_pending(data) + except Exception as e: + log.warning(f"_clear_pending failed: {e}") + + async def _resume_pending_on_boot(self, app) -> None: + """Run once on daemon startup. For each pending entry: + - if jsonl already has a complete turn → edit placeholder, drop entry + - else if entry is stale → mark placeholder as lost, drop entry + - else → spawn a background task that keeps polling jsonl + Called from Application.post_init. + """ + data = self._read_pending() + entries = data.get("pending", []) + if not entries: + return + log.info(f"Resume-on-boot: {len(entries)} pending entr{'y' if len(entries)==1 else 'ies'}") + bot = app.bot + survivors: list[dict] = [] + for entry in entries: + try: + sent_dt = datetime.fromisoformat(entry["sent_at"]) + age_s = (datetime.now() - sent_dt).total_seconds() + except Exception: + age_s = 9999.0 + user_msg_id = entry.get("user_msg_id") + ph_id = entry.get("placeholder_msg_id") + chat_id = entry.get("chat_id") + user_text_short = (entry.get("user_text") or "")[:60] + + # Stale → mark lost + if age_s > PENDING_MAX_AGE_SEC: + log.warning(f"Pending stale (age {age_s:.0f}s, msg={user_text_short!r}) — marking lost") + if ph_id and chat_id: + try: + await bot.edit_message_text( + chat_id=chat_id, + message_id=ph_id, + text="❌ 데몬 재기동으로 답변이 유실됐습니다. 다시 보내주세요.", + ) + except Exception as e: + log.warning(f"Stale-edit failed: {e}") + continue + + # Try jsonl recovery now + self._jsonl_offset = entry.get("jsonl_offset", 0) + try: + text, complete, _ = self._tail_jsonl_since_offset() + except Exception as e: + log.warning(f"Resume tail failed: {e}") + text, complete = ("", False) + + if complete and text: + log.info(f"Resume-deliver: {len(text)} chars (msg={user_text_short!r})") + try: + chunks = split_message(text) + if ph_id and chat_id: + await bot.edit_message_text( + chat_id=chat_id, message_id=ph_id, text=chunks[0], + ) + for ch in chunks[1:]: + await bot.send_message(chat_id=chat_id or CHAT_ID, text=ch) + except Exception as e: + log.warning(f"Resume-deliver edit failed: {e}") + survivors.append(entry) + continue + + # Not complete yet — keep entry, schedule background polling + log.info(f"Resume-poll (background, age {age_s:.0f}s): {user_text_short!r}") + survivors.append(entry) + asyncio.create_task(self._resume_polling_task(entry, bot)) + + data["pending"] = survivors + self._write_pending(data) + + async def _resume_polling_task(self, entry: dict, bot) -> None: + """Background task that finishes a previously-in-flight reply by + waiting for the session jsonl turn to complete, then editing the + placeholder. Removes the pending entry on success or on terminal + failure (no infinite retry).""" + self._jsonl_offset = entry.get("jsonl_offset", 0) + try: + text = await self._poll_response_via_jsonl(timeout=600.0) + except Exception as e: + log.warning(f"Background resume poll failed: {e}") + text = None + if not text: + log.warning(f"Background resume gave up empty: {entry.get('user_text','')[:60]!r}") + self._clear_pending(entry.get("user_msg_id")) + return + chat_id = entry.get("chat_id") + ph_id = entry.get("placeholder_msg_id") + try: + chunks = split_message(text) + if ph_id and chat_id: + await bot.edit_message_text(chat_id=chat_id, message_id=ph_id, text=chunks[0]) + for ch in chunks[1:]: + await bot.send_message(chat_id=chat_id or CHAT_ID, text=ch) + log.info(f"Background-resumed delivery {len(text)} chars") + except Exception as e: + log.warning(f"Background resume edit failed: {e}") + finally: + self._clear_pending(entry.get("user_msg_id")) + def _send_boot_ready(self, extras: dict = None): """Send a one-shot 'ready' Telegram message after bot init completes. @@ -3778,6 +3943,18 @@ async def handle_message(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE): if claude_idle: sent_msg = await update.message.reply_text("⏳ 작업 중…") + # Record pending so a daemon restart can resume delivery from the + # session jsonl instead of leaving the placeholder stuck. + try: + self._record_pending( + user_msg_id=update.message.message_id, + placeholder_msg_id=sent_msg.message_id if sent_msg else None, + chat_id=update.effective_chat.id, + user_text=user_text, + ) + except Exception as e: + log.warning(f"_record_pending failed (non-fatal): {e}") + # Poll for response with streaming feedback response = await self._poll_response(ctx.bot, user_text, pre_snapshot=pre_snapshot) @@ -3832,6 +4009,13 @@ async def handle_message(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE): # Deliver response (with persistent retry) delivered_msg_id = await self._deliver_response(response, user_text, update, ctx, meta=_conv_meta) + # Delivery succeeded (or we tried our best) — drop the pending + # entry so the next daemon boot doesn't try to re-resume it. + try: + self._clear_pending(update.message.message_id) + except Exception: + pass + # Schedule follow-up only when pane-scrape was the source. When the # jsonl path returned a complete (`stop_reason=end_turn`) turn, # there is literally no more assistant output coming — spinning up @@ -5065,6 +5249,12 @@ async def on_shutdown(app): async def on_init(app): # Cache bot reference for background tasks (e.g. CLI mirror) self._bot_ref = app.bot + # Resume any pending replies left behind by a previous daemon + # instance (crash or graceful restart). See _resume_pending_on_boot. + try: + await self._resume_pending_on_boot(app) + except Exception as e: + log.warning(f"Resume-on-boot failed (non-fatal): {e}") if self._cron_jobs: self._schedule_cron_jobs(app.job_queue) log.info(f"Cron scheduler initialized with {len(self._cron_jobs)} job(s)") @@ -5163,7 +5353,16 @@ async def on_init(app): # Loop 4 will populate `extras` with resume/primer status; for now this # ships the basic ready ping so users know startup completed. self._send_boot_ready() - app.run_polling(drop_pending_updates=False) + # Graceful shutdown: PTB's run_polling handles SIGINT/SIGTERM by + # default and tries to finish in-flight handlers before exiting. The + # stop_signals tuple is explicit here to make the behavior obvious. + # Hard kills (SIGKILL, crash) are covered by the pending-reply resume + # path in _resume_pending_on_boot, not by this signal handler. + import signal as _signal + app.run_polling( + drop_pending_updates=False, + stop_signals=(_signal.SIGINT, _signal.SIGTERM), + ) def _is_binary(path: Path) -> bool: From b08a25a0a11d01cf5aa5ecca886b42972807bb25 Mon Sep 17 00:00:00 2001 From: breaktheready Date: Thu, 23 Apr 2026 21:25:08 +0900 Subject: [PATCH 10/12] [docs] CHANGELOG: v0.6.0 (merged) + v0.6.1 (this session) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the project CHANGELOG.md up to date. v0.6.0 summarizes the work shipped in PR #1 (jsonl response path, session memory, global CLI, cron hardening); v0.6.1 captures the follow-up work from the 2026-04-23 session: - daemon-restart resilience (persistent pending_replies + graceful shutdown) - /tell-summarizer (/tellsum) runtime control over summarizer prompt - battery-morning-check cron for 삼성SDI / 에코프로 (parallel to the existing taihan-morning-check) - community hygiene set (CONTRIBUTING, CI workflow, issue templates, README badges) - path fixes: ~/.openclaw/... → ~/projects/openclaw-archive/... on 5 cron jobs - timeout bumps: market-coach-* 300→600 s, taihan-morning-check 900→1200 s - schedule shift: market-coach-preopen 08:03 → 07:45 KST (before NXT) - macOS launchd TZ handling: explicit Asia/Seoul via setenv-tz LaunchAgent + LimitLoadToSessionType broadening so agents fire during display sleep - okl-ear-report: hardcoded Linux .env path → Path.home(), `-u` on Python for real-time log flush, knowledge.json injected into LLM prompt so participant names are resolved (불명확 36→24 measured) - SUMMARIZE_PROMPT preserves numbered/lettered option lists verbatim Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b6a4f9..6dcadc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,57 @@ # Changelog +## v0.6.1 (2026-04-23) — Daemon-restart resilience, /tell-summarizer, cron + launchd hardening + +### Added +- **Daemon-restart resilience** — user replies no longer disappear when the daemon is restarted mid-poll. + - `~/.liteclaw/pending_replies.json` records every user message with a sent placeholder ("⏳ 작업 중…") that hasn't been answered yet (user_msg_id, placeholder_msg_id, chat_id, jsonl offset, sent timestamp, target). + - `_resume_pending_on_boot` runs from `Application.post_init`. For each pending entry it either (a) reads the session jsonl and edits the placeholder with the now-completed answer, (b) spawns a background task to finish polling, or (c) marks the placeholder as "재기동으로 유실됨" if older than `PENDING_MAX_AGE_SEC` (default 900 s). + - `run_polling` now passes `stop_signals=(SIGINT, SIGTERM)` explicitly so `pkill liteclaw` triggers PTB's graceful drain. Hard kills / crashes are covered by the persistent-pending path. + - New env: `PENDING_MAX_AGE_SEC` (default 900). +- **`/tell-summarizer` command** (alias `/tellsum`) — runtime control over the summarizer system prompt. `/tellsum ` appends, `/tellsum show` displays, `/tellsum clear` resets. Persists in-memory only (reset on restart). +- **Battery (2차전지) morning check cron** — new `battery-morning-check` in `.cron_jobs.json` (`55 7 * * 1-5`, Asia/Seoul, 1200 s timeout). Tracks 삼성SDI (006400) and 에코프로 (086520) using the Taihan-Cable checklist pattern (lithium / nickel proxies, TSLA / LCID / RIVN / QS, BYDDY / NIO / LI, IRA / CATL / solid-state news). Reference at `~/.liteclaw/references/battery-morning-checklist.md`. +- **Community hygiene** — `CONTRIBUTING.md`, `.github/workflows/ci.yml` (AST parse + `bash -n` + requirements import smoke + advisory ruff lint), `.github/ISSUE_TEMPLATE/{bug_report,feature_request}.md`, shields.io badges in both READMEs. + +### Fixed +- **Cron messages still referenced legacy `~/.openclaw/skills/…`** — Claude had to self-recover (search + retry) and hit job timeouts. Replaced with `~/projects/openclaw-archive/skills/…` and `~/projects/openclaw-archive/workspace/scripts/…` in 5 cron jobs (market-coach-{preopen,open,mid,close}, weekly-action-review). +- **market-coach-* timeouts** 300 → 600 s; `taihan-morning-check` 900 → 1200 s. +- **`market-coach-preopen` schedule** 08:03 → 07:45 KST so the brief arrives before NXT opens at 08:00. +- **macOS LaunchAgents firing in the wrong timezone** (e.g. 23:50 KST instead of 07:50) — launchd's user session had no `TZ` cached. Added `~/Library/LaunchAgents/com.breaktheready.setenv-tz.plist` that runs `launchctl setenv TZ Asia/Seoul` at boot. All time-based agents also get `LimitLoadToSessionType = [Aqua, Background, StandardIO, LoginWindow]` so they fire even during display sleep. +- **`okl-ear-report` daily email never sent on Mac** — hardcoded Linux path for Gmail creds. Switched to `Path.home() / "projects" / "autotrade" / ".env"`. LaunchAgent `ProgramArguments` also got `-u` so Python stdout flushes in real time (was buffered). +- **OKL Ear daily digest ignored `knowledge.json`** — only `context.md` was injected into the LLM prompt. Added `_load_knowledge_compact()` which curates ~11 KB of team / stakeholders / suppliers / ARP projects / terminology / lexicon into the prompt. Side-by-side: `[불명확]` count 36 → 24, named participants (송병준 / Clara / 영우) now appear correctly. + +### Changed +- **`SUMMARIZE_PROMPT`** gained a **PRESERVE-AS-IS** block — numbered / lettered option lists ("B / C / D 선택") must reach Telegram verbatim. Previously the summarizer kept "Pick one" while dropping the option bodies. + +--- + +## v0.6.0 (2026-04-20 → 2026-04-21) — jsonl response path, session memory, global CLI, cron hardening + +(Merged via https://github.com/breaktheready/liteclaw/pull/1) + +### Added +- **Global `liteclaw` CLI** — `liteclaw start|stop|restart|status|logs|attach` from any directory. `setup.sh` installs a symlink at `~/.local/bin/liteclaw`. +- **Pinned Claude Code session via `--session-id `** — `start.sh` allocates / adopts a stable UUID in `~/.liteclaw/sessions.json.liteclaw_session_id` and always resumes the same conversation, unaffected by other Claude Code windows in the same cwd. +- **JSONL response path** (Phase A) — responses are lifted from Claude Code's own session log (`~/.claude/projects//.jsonl`) instead of scraping the tmux pane. No ANSI chrome, no scrollback truncation, no summarizer over-compression. Automatic fallback to pane-scrape on jsonl failure. +- **OpenClaw-style memory layout** (`~/.liteclaw/`) — per-day transcripts, daily markdown digests (LLM-compacted), rolling strategic summary, startup primer. Legacy `~/.liteclaw-history.jsonl` auto-migrates. +- **Compact session-id alias (`sid`)** in transcripts — integer index into `sessions.json.history[]`, ~70 % shorter per row than embedding the full UUID. +- **`/recall session [uuid]`** — session-scoped recall. +- **Boot-ready Telegram ping** — one-shot "🚀 LiteClaw ready" after init, rate-limited to 5 min. +- **`~/.liteclaw/cron-error-capture.md`** — forensic log of failed cron runs (job config + pane snapshot). + +### Fixed +- **`is_idle_prompt` false-positive** on Claude Code UI chrome ("· Claude Max", "· Share Claude Code…") that jammed the cron busy-wait at 120 s. Removed `·` and `*` from `_SPINNER_CHARS`, anchored `_ACTIVITY_PATTERNS` to line start. +- **Cron trust prompt** auto-accept in `_run_cron_job` for unattended project dirs. +- **APScheduler weekday indexing** — `1-5` was Tue-Sat instead of Mon-Fri. +- **Mid-poll status edits** leaking "Thinking (Crystalizing)…" into Telegram. Gated behind `SHOW_POLLING_STATUS` (off by default). +- **`_followup_edit` overwriting delivered messages** with stale status edits when jsonl path had already returned a complete turn. Now skipped when `_jsonl_delivered_complete` is set. + +### Changed +- Placeholder text "📤 Sent. Waiting for response…" → "⏳ 작업 중…". +- `setup.sh` plants sensible `~/.tmux.conf` defaults when missing (mouse on, history-limit 50000). + +--- + ## v0.5.0 (2026-04-17) — CLI Mirror, Draft Streaming, Reasoning Lane & Skill System ### Added From 42380795eceb8732b164a31cc92bb7ee78b306b3 Mon Sep 17 00:00:00 2001 From: breaktheready Date: Thu, 23 Apr 2026 22:14:42 +0900 Subject: [PATCH 11/12] [docs] CHANGELOG: scope v0.6.1 to LiteClaw-only changes The previous CHANGELOG v0.6.1 entry leaked details about my personal Mac mini setup (okl-ear / autotrade / LaunchAgent TZ / knowledge.json / cron config references) that are not part of the LiteClaw bridge itself. Those belong in DEVNOTES.md (which is gitignored). Keep in CHANGELOG (actual LiteClaw code/doc changes): - daemon-restart resilience (pending_replies.json + post_init resume + explicit stop_signals) - /tell-summarizer (/tellsum) runtime summarizer prompt control - community hygiene set (CONTRIBUTING, CI workflow, issue templates, README badges) - SUMMARIZE_PROMPT PRESERVE-AS-IS block for option lists Removed from CHANGELOG (moved to DEVNOTES.md privately): - .cron_jobs.json path/timeout/schedule tweaks (my personal crons) - LaunchAgent TZ + LimitLoadToSessionType fixes (my OS-level config) - okl-ear-report path + -u + knowledge.json injection (separate project) - battery-morning-check cron specifics (my workflow) Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dcadc6..f6a5dec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,27 +1,18 @@ # Changelog -## v0.6.1 (2026-04-23) — Daemon-restart resilience, /tell-summarizer, cron + launchd hardening +## v0.6.1 (2026-04-23) — Daemon-restart resilience, /tell-summarizer, community hygiene ### Added - **Daemon-restart resilience** — user replies no longer disappear when the daemon is restarted mid-poll. - - `~/.liteclaw/pending_replies.json` records every user message with a sent placeholder ("⏳ 작업 중…") that hasn't been answered yet (user_msg_id, placeholder_msg_id, chat_id, jsonl offset, sent timestamp, target). + - `~/.liteclaw/pending_replies.json` records every user message whose placeholder ("⏳ 작업 중…") was sent but not yet answered (user_msg_id, placeholder_msg_id, chat_id, jsonl offset, sent timestamp, target). - `_resume_pending_on_boot` runs from `Application.post_init`. For each pending entry it either (a) reads the session jsonl and edits the placeholder with the now-completed answer, (b) spawns a background task to finish polling, or (c) marks the placeholder as "재기동으로 유실됨" if older than `PENDING_MAX_AGE_SEC` (default 900 s). - `run_polling` now passes `stop_signals=(SIGINT, SIGTERM)` explicitly so `pkill liteclaw` triggers PTB's graceful drain. Hard kills / crashes are covered by the persistent-pending path. - New env: `PENDING_MAX_AGE_SEC` (default 900). -- **`/tell-summarizer` command** (alias `/tellsum`) — runtime control over the summarizer system prompt. `/tellsum ` appends, `/tellsum show` displays, `/tellsum clear` resets. Persists in-memory only (reset on restart). -- **Battery (2차전지) morning check cron** — new `battery-morning-check` in `.cron_jobs.json` (`55 7 * * 1-5`, Asia/Seoul, 1200 s timeout). Tracks 삼성SDI (006400) and 에코프로 (086520) using the Taihan-Cable checklist pattern (lithium / nickel proxies, TSLA / LCID / RIVN / QS, BYDDY / NIO / LI, IRA / CATL / solid-state news). Reference at `~/.liteclaw/references/battery-morning-checklist.md`. +- **`/tell-summarizer` command** (alias `/tellsum`) — runtime control over the summarizer system prompt. `/tellsum ` appends, `/tellsum show` displays, `/tellsum clear` resets. Persists in-memory only. - **Community hygiene** — `CONTRIBUTING.md`, `.github/workflows/ci.yml` (AST parse + `bash -n` + requirements import smoke + advisory ruff lint), `.github/ISSUE_TEMPLATE/{bug_report,feature_request}.md`, shields.io badges in both READMEs. -### Fixed -- **Cron messages still referenced legacy `~/.openclaw/skills/…`** — Claude had to self-recover (search + retry) and hit job timeouts. Replaced with `~/projects/openclaw-archive/skills/…` and `~/projects/openclaw-archive/workspace/scripts/…` in 5 cron jobs (market-coach-{preopen,open,mid,close}, weekly-action-review). -- **market-coach-* timeouts** 300 → 600 s; `taihan-morning-check` 900 → 1200 s. -- **`market-coach-preopen` schedule** 08:03 → 07:45 KST so the brief arrives before NXT opens at 08:00. -- **macOS LaunchAgents firing in the wrong timezone** (e.g. 23:50 KST instead of 07:50) — launchd's user session had no `TZ` cached. Added `~/Library/LaunchAgents/com.breaktheready.setenv-tz.plist` that runs `launchctl setenv TZ Asia/Seoul` at boot. All time-based agents also get `LimitLoadToSessionType = [Aqua, Background, StandardIO, LoginWindow]` so they fire even during display sleep. -- **`okl-ear-report` daily email never sent on Mac** — hardcoded Linux path for Gmail creds. Switched to `Path.home() / "projects" / "autotrade" / ".env"`. LaunchAgent `ProgramArguments` also got `-u` so Python stdout flushes in real time (was buffered). -- **OKL Ear daily digest ignored `knowledge.json`** — only `context.md` was injected into the LLM prompt. Added `_load_knowledge_compact()` which curates ~11 KB of team / stakeholders / suppliers / ARP projects / terminology / lexicon into the prompt. Side-by-side: `[불명확]` count 36 → 24, named participants (송병준 / Clara / 영우) now appear correctly. - ### Changed -- **`SUMMARIZE_PROMPT`** gained a **PRESERVE-AS-IS** block — numbered / lettered option lists ("B / C / D 선택") must reach Telegram verbatim. Previously the summarizer kept "Pick one" while dropping the option bodies. +- **`SUMMARIZE_PROMPT`** gained a **PRESERVE-AS-IS** block — numbered / lettered option lists ("B / C / D 선택") must reach Telegram verbatim. The summarizer previously kept "Pick one" while dropping the option bodies. --- From d16c4fcdcb3fcf46ba6ab6a6d20088dd9ab9fdb0 Mon Sep 17 00:00:00 2001 From: breaktheready Date: Thu, 23 Apr 2026 22:58:02 +0900 Subject: [PATCH 12/12] =?UTF-8?q?[feat]=20subprocess=20cron=20type=20?= =?UTF-8?q?=E2=80=94=20time-critical=20jobs=20without=20Claude=20Code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `type: "subprocess"` variant to `.cron_jobs.json`. When set, `_run_cron_job` dispatches to `_run_subprocess_cron`, which: - Runs `job["command"]` via `asyncio.create_subprocess_shell` under `job["project"]` cwd, capturing combined stdout+stderr. - Enforces `job["timeout"]` (default 1200 s) by killing the process. - Persists `last_run` / `last_status` like regular cron jobs. - Optionally sends a compact Telegram summary (tail N lines, controlled by `notify_telegram` and `notify_tail_lines`). - On non-zero exit or timeout, writes to ~/.liteclaw/cron-error-capture.md and fires a ❌ Telegram alert. Motivation: on macOS, LaunchAgents with StartCalendarInterval are gated by `com.apple.UserEventAgent-Aqua`. When the GUI session isn't active (display lock, fast-user-switch, brief inactivity) the job silently skips. LaunchAgents are the wrong tool for reliability on a headless-ish workstation. LiteClaw's APScheduler runs inside the always-on daemon so the job fires regardless of GUI state. The historical `type: "claude"` path (tmux + Claude Code LLM cron) remains the default and is untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- liteclaw.py | 128 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 126 insertions(+), 2 deletions(-) diff --git a/liteclaw.py b/liteclaw.py index 7a62287..a5b9b36 100644 --- a/liteclaw.py +++ b/liteclaw.py @@ -1392,12 +1392,136 @@ def _schedule_cron_jobs(self, job_queue): except Exception as e: log.warning(f"Failed to schedule cron job '{job['id']}': {e}") + async def _run_subprocess_cron(self, job: dict, bot): + """Run a cron job that's a plain shell command — no Claude Code. + + Motivation: macOS LaunchAgents with StartCalendarInterval use the + `com.apple.UserEventAgent-Aqua` monitor, which silently skips jobs + when the GUI session isn't active (display lock, fast-user-switch, + etc.). LiteClaw's APScheduler runs inside the always-on daemon and + isn't subject to that gate — so time-critical non-LLM jobs are + better off here. + + Job schema for subprocess type (in `.cron_jobs.json`): + { + "id": "...", + "enabled": true, + "cron_expr": "0 19 * * *", + "tz": "Asia/Seoul", + "type": "subprocess", + "command": "/path/to/venv/bin/python3 -u scripts/foo.py", + "project": "~/projects/foo", # cwd + "timeout": 1200, # seconds + "notify_telegram": true, # send summary on Telegram + "notify_tail_lines": 20 # how many log tail lines + } + """ + job_id = job["id"] + if job_id in self._cron_running: + log.info(f"Subprocess cron '{job_id}' already running, skipping") + return + self._cron_running.add(job_id) + tz = ZoneInfo(job.get("tz", "Asia/Seoul")) + started = datetime.now(tz) + log.info(f"Subprocess cron '{job_id}' starting at {started.strftime('%H:%M:%S')}") + + cmd = job.get("command", "").strip() + cwd = Path(os.path.expanduser(job.get("project", "~"))) + timeout = int(job.get("timeout", 1200)) + notify = job.get("notify_telegram", True) + tail_n = int(job.get("notify_tail_lines", 20)) + + try: + if not cmd: + raise ValueError("subprocess job has empty `command`") + proc = await asyncio.create_subprocess_shell( + cmd, + cwd=str(cwd), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + try: + stdout, _ = await asyncio.wait_for( + proc.communicate(), timeout=timeout + ) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + raise TimeoutError(f"subprocess exceeded {timeout}s") + rc = proc.returncode or 0 + output = (stdout or b"").decode("utf-8", errors="replace").rstrip() + elapsed = (datetime.now(tz) - started).total_seconds() + + # Log a short tail locally; don't spam the daemon log with full output + tail = "\n".join(output.splitlines()[-tail_n:]) + log.info( + f"Subprocess cron '{job_id}' rc={rc} in {elapsed:.1f}s " + f"(tail {len(tail)} chars)" + ) + if rc != 0: + raise RuntimeError(f"rc={rc}\n{tail[-2000:]}") + + # Persist job state + job["last_run"] = started.isoformat() + job["last_status"] = "ok" + self._save_cron_jobs() + + if notify: + icon = "✅" + summary = ( + f"{icon} cron `{job_id}` · {elapsed:.0f}s · rc=0\n" + f"```\n{tail[-1500:]}\n```" + ) + try: + await bot.send_message( + chat_id=CHAT_ID, text=summary, parse_mode="Markdown", + ) + except Exception: + # Markdown sometimes chokes on script output — fall back plain. + try: + await bot.send_message(chat_id=CHAT_ID, text=summary) + except Exception as e: + log.warning(f"Subprocess cron '{job_id}' notify failed: {e}") + + except Exception as e: + log.error(f"Subprocess cron '{job_id}' failed: {e}") + job["last_run"] = datetime.now(tz).isoformat() + job["last_status"] = f"error: {e}" + self._save_cron_jobs() + try: + self._log_cron_error(job_id, job, e, session_name="(subprocess)") + except Exception: + pass + try: + await bot.send_message( + chat_id=CHAT_ID, + text=f"❌ cron `{job_id}` failed: {e}", + parse_mode="Markdown", + ) + except Exception: + pass + finally: + self._cron_running.discard(job_id) + async def _run_cron_job(self, context): - """Execute a single cron job. Called by APScheduler.""" + """Execute a single cron job. Called by APScheduler. + + Two job types: + - (default) `type: "claude"` — spawns a tmux session, runs Claude + Code, injects `job["message"]`, polls for response, summarizes + and delivers to Telegram. This is the historical LLM path. + - `type: "subprocess"` — runs `job["command"]` as a plain shell + command under `job["project"]` cwd. Captures combined stdout + +stderr, sends a compact summary to Telegram. No Claude, no + tmux — useful for jobs that used to live in a LaunchAgent but + suffered from macOS Aqua-session gating. + """ job = context.job.data job_id = job["id"] - session_name = f"cron-{job_id}" bot = context.bot + if job.get("type", "claude") == "subprocess": + return await self._run_subprocess_cron(job, bot) + session_name = f"cron-{job_id}" # Overlap prevention if job_id in self._cron_running: