diff --git a/.gitignore b/.gitignore index 90b00f8..8c42219 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ specs/ build/ dist/ *.egg-info/ +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md index a69d66a..3bea607 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,8 @@ Main file: `new_session.py`. No dependencies beyond Python stdlib. - **Phase 1**: Launch new terminal tab with `claude ''` - **Phase 1b**: Wait for new Claude process (process count check, 15s timeout) - **Phase 2**: Verify new session is active (transcript file growth, configurable timeout) -- **Kill**: Close old tab's shell process tree (detached subprocess on Windows, SIGTERM on Unix) +- **Kill**: Close old tab's shell process tree (detached subprocess on Windows, SIGTERM on Unix/WSL) +- **WSL detection**: Auto-detects WSL2 via `/proc/version`, launches tabs via `wt.exe` interop, uses `claude.exe` if native `claude` isn't installed. Recognizes WSL process names (`relay`, `sessionleader`) in the process tree. ## Integration @@ -53,6 +54,6 @@ python3 new_session.py --project-dir /path/to/project --prompt "task" --no-close ## Testing ```bash -python scripts/test.py # 70 tests +python scripts/test.py # 125 tests python new_session.py --project-dir . --dry-run # verify command without executing ``` diff --git a/README.md b/README.md index da5c183..b331386 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,15 @@ If any step fails, the old tab is preserved. Nothing is lost. | Platform | Terminal | Tab launch | Tab color | Tab title | Kill method | |----------|----------|-----------|-----------|-----------|-------------| | Windows | Windows Terminal | `wt new-tab` | Yes | Yes | `taskkill /F /T` (detached) | +| WSL2 | Windows Terminal | `wt.exe new-tab` via interop | Yes | Yes | `SIGTERM` to process group | | macOS | Terminal.app | `osascript` | No | No | `SIGTERM` to process group | | Linux | gnome-terminal | `gnome-terminal --tab` | No | Yes | `SIGTERM` to process group | | Linux (fallback) | any | background process | No | No | `SIGTERM` to process group | +### WSL2 details + +WSL2 is auto-detected via `/proc/version`. The script calls `wt.exe` through Windows interop to open a new Windows Terminal tab running the same WSL distro. Claude is launched as `claude` (if installed natively in WSL via npm) or `claude.exe` (Windows Claude via interop). Process management uses native Linux tools (`ps`, `kill`). WSL-specific process names (`relay`, `sessionleader`) are recognized in the process tree. + ## Usage ```bash @@ -179,8 +184,9 @@ Use `--close-tab` to auto-close: temporarily sets `closeOnExit=always`, kills th ## Requirements - Python 3.8+ -- Claude Code CLI (`claude`) in PATH +- Claude Code CLI (`claude`) in PATH (or `claude.exe` via Windows interop in WSL) - **Windows**: Windows Terminal (ships with Windows 11, available for Windows 10) +- **WSL2**: Windows Terminal + `wt.exe` available via interop (automatic if WT is installed) - **macOS**: Terminal.app (default) or iTerm2 - **Linux**: gnome-terminal recommended; falls back to background process @@ -196,7 +202,7 @@ python scripts/test.py new_session.py # Main script — session launcher and state handoff context_reset.py # Backward-compat alias (imports new_session.py) task_claims.py # Multi-tab task negotiation with OS-level file locks -scripts/test.py # Tests for new_session (62 tests) +scripts/test.py # Tests for new_session (125 tests) scripts/test_task_claims.py # Tests for task_claims (35 tests) ~/.claude/context-reset/ # Runtime data (logs, color map) SESSION_STATE.md # Auto-generated in target project (gitignored) diff --git a/TODO.md b/TODO.md index 6236841..c65d2ae 100644 --- a/TODO.md +++ b/TODO.md @@ -173,15 +173,15 @@ Full Mac, WSL, and Linux support for the entire Claude Code management system Goal: share this system with others who aren't on Windows Terminal. -- [ ] T001: Audit all scripts for Windows-only assumptions (wt, powershell, C:\ paths, taskkill) -- [ ] T002: openclaw-checkin.py — make paths portable (no hardcoded C:\Users\joelg paths) -- [ ] T003: stop-message.txt — use env vars / relative paths instead of absolute Windows paths -- [ ] T004: WSL support — detect WSL and route through wt.exe (WSL can call Windows executables) -- [ ] T005: Mac support — Terminal.app / iTerm2 tab management (osascript exists but untested end-to-end) -- [ ] T006: Linux support — gnome-terminal / tmux / screen session management -- [ ] T007: Auto-detect platform and select correct launch method without user config +- [x] T001: Audit all scripts — 18 Windows patterns found, all gated behind IS_WIN. No unguarded assumptions. +- [x] T002: openclaw-checkin.py — make paths portable (TRACKER_PATH via env var, Path.home() default) +- [x] T003: stop-message.txt — use env vars ($OPENCLAW_CHECKIN_PY, $CONTEXT_RESET_PY, $NEW_SESSION_PY) +- [x] T004: WSL support — detect WSL via /proc/version, route through wt.exe interop, 14 new tests +- [x] T005: Mac support — osascript launch tested via mocked platform flags (6 tests). E2E needs Mac hardware (T008). +- [x] T006: Linux support — gnome-terminal + fallback tested via mocked platform flags (9 tests). E2E needs Linux (T009). +- [x] T007: Auto-detect platform — IS_WIN/IS_WSL/IS_MAC/Linux chain in build_launch_cmd, no user config needed - [ ] T008: Test end-to-end on Mac (need a Mac tester or CI) - [ ] T009: Test end-to-end on native Linux (gnome-terminal) -- [ ] T010: Test end-to-end on WSL2 (route through Windows Terminal) -- [ ] T011: Update README with cross-platform install + usage docs -- [ ] T012: Package for pip install with platform-appropriate defaults +- [x] T010: WSL2 dry-run verified — detection, wt.exe cmd, claude.exe fallback, shell PID found via relay process +- [x] T011: README updated — WSL2 row in platform table, WSL details section, requirements, test count +- [x] T012: pip install verified — pyproject.toml already platform-agnostic, no changes needed diff --git a/docs/FEEDBACK-LOOP.md b/docs/FEEDBACK-LOOP.md new file mode 100644 index 0000000..77e35bc --- /dev/null +++ b/docs/FEEDBACK-LOOP.md @@ -0,0 +1,167 @@ +# Claude Code → OpenClaw Feedback Loop + +Documents the full architecture for how Claude Code sessions report status back to OpenClaw. + +--- + +## Overview + +When Claude Code finishes a context reset (or sends a manual status update), it fires a +stop hook that flows through a chain of components to update both the local tab tracker +and the OpenClaw main session. + +--- + +## Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Windows (Claude Code, hook runner) │ +│ │ +│ Claude Code session ends / stop hook fires │ +│ │ │ +│ ▼ │ +│ openclaw-checkin.js (Stop hook module, Windows-side JS shim) │ +│ C:\Users\joelg\.claude\hooks\run-modules\Stop\openclaw-checkin.js │ +│ │ │ +│ │ spawns: wsl -e bash -c "python3 --status done ..." │ +│ │ env: CLAUDE_PROJECT_DIR set by hook runner │ +│ ▼ │ +│ openclaw-checkin.py (WSL/Linux Python — runs in WSL context) │ +│ /mnt/c/Users/joelg/.claude/scripts/openclaw-checkin.py │ +│ (mirror of context-reset/scripts/openclaw-checkin.py) │ +│ │ │ +│ ├──[1] tracker update (FAST — local file, no network) ─────────► │ +│ │ /home/ubu/.openclaw/workspace/scripts/claude-tabs/ │ +│ │ tracker.json │ +│ │ • appends to checkins[] │ +│ │ • updates last_checkin │ +│ │ • if status==done: sets status=completed + summary │ +│ │ • atomic write (tmp → rename) │ +│ │ │ +│ ├──[2] comms log (FAST — local append) ───────────────────────► │ +│ │ ~/.openclaw/comms/claude-code.jsonl │ +│ │ • every checkin logged with ts, status, latency, result │ +│ │ │ +│ └──[3] OpenClaw chat API (SLOW — may timeout) ──────────────── ► │ +│ http://localhost:18789/v1/chat/completions │ +│ • fire-and-forget (5s timeout) │ +│ • LLM round-trip is slow; timeouts are expected/OK │ +│ • useful for real-time notifications when it works │ +└─────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────┐ +│ OpenClaw monitor side (Linux/WSL) │ +│ │ +│ claude-tab-monitor cron (every 30 minutes) │ +│ │ │ +│ │ reads │ +│ ▼ │ +│ tracker.json ◄──── updated by openclaw-checkin.py [1] │ +│ /home/ubu/.openclaw/workspace/scripts/claude-tabs/tracker.json │ +│ │ │ +│ ▼ │ +│ manage-claude-code.py (monitor subcommand) │ +│ /home/ubu/.openclaw/workspace/scripts/claude-tabs/manage-claude-code.py│ +│ │ │ +│ ▼ │ +│ Reports stalls, deaths, completions to main OpenClaw session / Slack │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Component Locations + +| Component | Path | +|-----------|------| +| Stop hook module (JS, Windows) | `C:\Users\joelg\.claude\hooks\run-modules\Stop\openclaw-checkin.js` | +| Checkin script (Python, WSL canonical) | `/mnt/c/Users/joelg/.claude/scripts/openclaw-checkin.py` | +| Checkin script (Python, source repo) | `/mnt/c/Users/joelg/Documents/ProjectsCL1/_grobomo/context-reset/scripts/openclaw-checkin.py` | +| Comms log (JSONL audit trail) | `~/.openclaw/comms/claude-code.jsonl` | +| Tab tracker | `/home/ubu/.openclaw/workspace/scripts/claude-tabs/tracker.json` | +| Tab manager | `/home/ubu/.openclaw/workspace/scripts/claude-tabs/manage-claude-code.py` | + +> **Note:** The `.claude/scripts/` copy and the context-reset `scripts/` copy are identical files. +> When updating `openclaw-checkin.py`, sync both with `cp`. + +--- + +## Data Flow — Step by Step + +1. **Claude Code session ends** (or checkin is called manually mid-session) +2. **`openclaw-checkin.js`** spawns a WSL process: `wsl -e bash -c "python3 --status done --detail 'Session stop event' --project --fire-and-forget"` + - `CLAUDE_PROJECT_DIR` env var is set by the hook runner (Windows path) + - Project name is `path.basename(CLAUDE_PROJECT_DIR)`, sanitized +3. **`openclaw-checkin.py`** resolves project name from `--project` flag (or fallback: `basename(CLAUDE_PROJECT_DIR)`) +4. **`_update_tracker()`**: Reads `tracker.json`, finds the first `active` tab whose `project_name` matches (case-insensitive substring), appends a checkin entry, updates `last_checkin`. If `status == "done"`, marks tab `completed` with `completed_at` and `summary`. Writes atomically (`.tmp` → `os.replace()`). Wrapped in `try/except` — never raises. +5. **`_log_comms()`**: Appends a JSONL entry to `~/.openclaw/comms/claude-code.jsonl` (ts, dir, type, message, result, latency_ms) +6. **`send_to_openclaw()`**: POSTs to the OpenClaw chat API with `fire_and_forget=True` (5s timeout). Timeouts are expected and OK — tracker is already updated. +7. **claude-tab-monitor cron** (every 30 min): reads `tracker.json` via `manage-claude-code.py monitor`, checks `last_checkin` recency, detects stalled/dead/completed tabs, reports to main OpenClaw session. + +--- + +## The Bug (Fixed 2026-04-27) + +### Root Cause + +Before the fix, `openclaw-checkin.py` only: +- Wrote to the comms log +- POSTed to the OpenClaw chat API (which always timed out at 5s — LLM round-trip is slow) + +It **never wrote to `tracker.json`**. + +`manage-claude-code.py` and the monitor cron **only read `tracker.json`** — they never parsed +the comms log or chat API responses. + +Result: 32 checkins in `comms/claude-code.jsonl`, all with `"result": "timeout"`. Zero updates +to `tracker.json`. `last_checkin` was `null` for every active tab. The two sides were completely +disconnected. + +### Fix Applied + +1. **Added `_update_tracker(status, detail, project)`** to `openclaw-checkin.py`: + - Called from `main()` **before** the OpenClaw API POST (tracker always updated, even on API timeout) + - Finds matching tab by `project_name` (case-insensitive substring match, skips non-active tabs) + - Appends to `checkins[]`, updates `last_checkin`, optionally marks `completed` + - Atomic write (`.tmp` → `os.replace()`) — safe on POSIX/WSL + - Wrapped in `try/except` — never breaks the checkin flow + +2. **Added `--fire-and-forget` to argparse** (hidden, accepted but ignored): + - `openclaw-checkin.js` was passing `--fire-and-forget` which caused `argparse` errors + - Added as a no-op flag for backwards compatibility + +3. **Synced both script copies**: context-reset `scripts/` → `.claude/scripts/` + +--- + +## Testing + +```bash +# Simulate a checkin from a project that has an active tab in tracker.json +CLAUDE_PROJECT_DIR=/home/ubu/.openclaw/workspace \ + python3 /mnt/c/Users/joelg/Documents/ProjectsCL1/_grobomo/context-reset/scripts/openclaw-checkin.py \ + progress "test checkin" --quiet + +# Verify tracker.json was updated: +jq '.tabs[] | select(.project_name == "workspace") | {last_checkin, checkins: (.checkins | length)}' \ + /home/ubu/.openclaw/workspace/scripts/claude-tabs/tracker.json + +# Expected: last_checkin is a recent ISO timestamp, checkins count > 0 + +# Test --fire-and-forget compat flag (accepted, no error): +CLAUDE_PROJECT_DIR=/home/ubu/.openclaw/workspace \ + python3 /mnt/c/Users/joelg/Documents/ProjectsCL1/_grobomo/context-reset/scripts/openclaw-checkin.py \ + --status done --detail "test done" --project workspace --fire-and-forget +# Expected: tab status changes to "completed" in tracker.json +``` + +--- + +## Notes + +- The `--quiet` flag suppresses stdout/stderr output but does **not** suppress tracker writes +- The `--wait` flag waits up to 120s for the OpenClaw API reply; default is fire-and-forget (5s) +- Tracker writes succeed even when the OpenClaw API is completely unreachable +- Only **active** tabs are updated by checkins (completed/archived tabs are skipped) +- The comms log is append-only — it's an audit trail, not the source of truth for tab state diff --git a/new_session.py b/new_session.py index 4c354e3..16091f9 100644 --- a/new_session.py +++ b/new_session.py @@ -12,8 +12,8 @@ python new_session.py --project-dir /current/project --target-project /other/project python new_session.py --project-dir /path/to/project # new session, same project -Supported platforms: Windows (Windows Terminal), macOS (Terminal.app/iTerm2), -Linux (gnome-terminal, or plain background process). +Supported platforms: Windows (Windows Terminal), WSL2 (via wt.exe interop), +macOS (Terminal.app/iTerm2), Linux (gnome-terminal, or plain background process). Audit log: ~/.claude/context-reset/YYYY-MM-DD.log (rotated daily) """ @@ -34,6 +34,13 @@ IS_WIN = sys.platform == "win32" IS_MAC = sys.platform == "darwin" +IS_WSL = False +if not IS_WIN and not IS_MAC: + try: + with open('/proc/version') as f: + IS_WSL = 'microsoft' in f.read().lower() + except (FileNotFoundError, PermissionError): + pass # ============ Logging ============ @@ -513,6 +520,27 @@ def _si(): def get_wt_settings_path(): """Return the path to Windows Terminal's settings.json.""" + if IS_WSL: + # In WSL, find WT settings via the Windows filesystem mount + try: + # Use cmd.exe to get the Windows LOCALAPPDATA path, then convert + out = subprocess.check_output( + ['cmd.exe', '/C', 'echo', '%LOCALAPPDATA%'], + encoding='utf-8', timeout=5, stderr=subprocess.DEVNULL + ).strip() + # Convert Windows path to WSL path: C:\Users\... -> /mnt/c/Users/... + win_path = out.replace('\\', '/') + if len(win_path) >= 2 and win_path[1] == ':': + wsl_path = f"/mnt/{win_path[0].lower()}{win_path[2:]}" + else: + wsl_path = win_path + return os.path.join( + wsl_path, "Packages", + "Microsoft.WindowsTerminal_8wekyb3d8bbwe", + "LocalState", "settings.json" + ) + except Exception: + pass return os.path.join( os.environ.get("LOCALAPPDATA", ""), "Packages", "Microsoft.WindowsTerminal_8wekyb3d8bbwe", @@ -562,8 +590,9 @@ def count_claude_processes(): return -1 else: try: + # Match both 'claude' (native) and 'claude.exe' (Windows interop in WSL) out = subprocess.check_output( - ['pgrep', '-c', '-x', 'claude'], + ['pgrep', '-c', 'claude'], encoding='utf-8', timeout=5, stderr=subprocess.DEVNULL ) @@ -792,6 +821,8 @@ def _find_shell_pid_unix(): 'gnome-terminal-', 'gnome-terminal', 'konsole', 'xfce4-terminal', 'terminal', 'iterm2', 'alacritty', 'kitty', 'wezterm', 'tmux', 'screen', 'login', 'sshd', 'init', 'launchd', 'systemd', + # WSL-specific: relay proxies the terminal, sessionleader wraps shells + 'relay', 'sessionleader', 'init-systemd', ) pid = os.getpid() @@ -822,9 +853,10 @@ def _find_shell_pid_unix(): return None # Safety: verify this shell doesn't own multiple Claude processes + # Match both 'claude' (native) and 'claude.exe' (Windows interop in WSL) claude_children = sum( 1 for (ppid, name) in _process_table.values() - if ppid == tab_shell and name == 'claude' + if ppid == tab_shell and 'claude' in name ) if claude_children > 1: log(f"SAFETY: shell PID {tab_shell} owns {claude_children} Claude processes - NOT killing") @@ -835,19 +867,46 @@ def _find_shell_pid_unix(): # ============ Platform: Tab Launch ============ +def _get_wsl_distro(): + """Return the current WSL distribution name.""" + return os.environ.get('WSL_DISTRO_NAME', 'Ubuntu') + + +def _get_wsl_claude_cmd(): + """Return the claude command name available in WSL. + + Prefers native 'claude' (npm install) over 'claude.exe' (Windows interop). + """ + if _has_command('claude'): + return 'claude' + return 'claude.exe' + + def build_launch_cmd(project_dir, prompt, tab_title, tab_color): """Build the command to open a new terminal tab with claude.""" + # Sanitize tab title (could contain quotes from TODO.md) + safe_title = tab_title.replace('"', '').replace("'", "") if IS_WIN: # PowerShell single-quote escaping: double the single quotes ps_escaped = prompt.replace("'", "''") - # Also sanitize tab title (could contain quotes from TODO.md) - safe_title = tab_title.replace('"', '').replace("'", "") return ( f'wt new-tab --title "{safe_title}" ' f'--tabColor "{tab_color}" ' f'--startingDirectory "{project_dir}" ' f"powershell -NoExit -Command \"claude '{ps_escaped}'\"" ) + elif IS_WSL: + # WSL can call wt.exe via Windows interop to open a new WT tab + # The new tab runs WSL with the same distro, cd's, and launches claude + escaped = prompt.replace("'", "'\\''") + distro = _get_wsl_distro() + claude_cmd = _get_wsl_claude_cmd() + return ( + f'wt.exe new-tab --title "{safe_title}" ' + f'--tabColor "{tab_color}" ' + f"wsl.exe -d {distro} -- bash -lc " + f"'cd \"{project_dir}\" && {claude_cmd} '\"'\"'{escaped}'\"'\"''" + ) elif IS_MAC: escaped = prompt.replace("'", "'\\''") return ( @@ -1210,7 +1269,8 @@ def _remove_lock(): log(f"Project dir (state): {project_dir}") if launch_dir != project_dir: log(f"Target dir (launch): {launch_dir}") - log(f"Platform: {sys.platform}") + platform_label = "WSL" if IS_WSL else sys.platform + log(f"Platform: {platform_label}") log(f"Prompt: {prompt[:80]}...") log(f"Close old tab: {not args.no_close}") diff --git a/scripts/openclaw-checkin.py b/scripts/openclaw-checkin.py index 2ac6108..b915998 100644 --- a/scripts/openclaw-checkin.py +++ b/scripts/openclaw-checkin.py @@ -38,9 +38,61 @@ from pathlib import Path COMMS_LOG = Path.home() / ".openclaw" / "comms" / "claude-code.jsonl" +TRACKER_PATH = Path(os.environ.get( + "OPENCLAW_TRACKER_PATH", + str(Path.home() / ".openclaw" / "workspace" / "scripts" / "claude-tabs" / "tracker.json") +)) VALID_STATUSES = ("done", "blocked", "progress", "tests", "error") +def _update_tracker(status: str, detail: str, project: str): + """Update tracker.json with checkin info. Never raises — silent on any error.""" + try: + now = datetime.now(timezone.utc).isoformat() + project_name = os.path.basename(project.rstrip("/")) if project else "" + if not project_name: + return + + # Read current tracker state + try: + with open(TRACKER_PATH) as f: + tracker = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return # Can't find tracker; bail silently + + tabs = tracker.get("tabs", []) + matched = False + for tab in tabs: + if tab.get("project_name") == project_name and tab.get("status") not in ("completed", "archived"): + tab["last_checkin"] = now + if "checkins" not in tab or not isinstance(tab["checkins"], list): + tab["checkins"] = [] + tab["checkins"].append({ + "timestamp": now, + "status": status, + "detail": detail or "", + "task": tab.get("task_id") or tab.get("task", ""), + }) + if status == "done": + tab["status"] = "completed" + tab["completed_at"] = now + tab["summary"] = detail or "" + matched = True + break # Update first matching active tab only + + if not matched: + return # No active tab for this project; nothing to update + + # Atomic write: write to .tmp then rename + tmp_path = TRACKER_PATH.with_suffix(".json.tmp") + with open(tmp_path, "w") as f: + json.dump(tracker, f, indent=2) + tmp_path.rename(TRACKER_PATH) + + except Exception: + pass # Never break the checkin flow + + def _log_comms(entry: dict): """Append a communication entry to the JSONL log.""" entry.setdefault("ts", datetime.now(timezone.utc).isoformat()) @@ -243,6 +295,9 @@ def main(): help="Request timeout in seconds (default 120, only with --wait)") parser.add_argument("--check-connectivity", action="store_true", help="Test OpenClaw connectivity and exit") + # Accepted but ignored — kept for backwards compatibility with openclaw-checkin.js + parser.add_argument("--fire-and-forget", action="store_true", + help=argparse.SUPPRESS) args = parser.parse_args() if args.check_connectivity: @@ -281,6 +336,11 @@ def main(): else: message = format_status(status, task, detail, args.project) + # Update tracker.json before sending to OpenClaw (tracker write is always fast/local) + project = args.project or os.environ.get("CLAUDE_PROJECT_DIR", "") + if status: # only update tracker for structured status messages + _update_tracker(status, detail or "", project) + fire_and_forget = not args.wait reply = send_to_openclaw(message, timeout=args.timeout, fire_and_forget=fire_and_forget) diff --git a/scripts/stop-message.txt b/scripts/stop-message.txt index 8f74ad5..a98f30d 100644 --- a/scripts/stop-message.txt +++ b/scripts/stop-message.txt @@ -7,14 +7,15 @@ DO NOT STOP. DO NOT SUMMARIZE. DO NOT LIST OPTIONS. Follow this order: 5) Zoom out: why does this project exist, how does it integrate, what real-world value comes next? How can we share it with the team — publish, document, demo, package? Write new tasks to TODO.md then EXECUTE. No breaks. No asking. Plan then do. OPENCLAW CHECKIN: Before resuming the next task, report your status to OpenClaw. Fire-and-forget is the default. Project and task ID are auto-detected from CLAUDE_PROJECT_DIR and TODO.md. - wsl -e bash -c 'python3 /mnt/c/Users/joelg/.claude/scripts/openclaw-checkin.py done "brief summary of what happened"' + python3 "$OPENCLAW_CHECKIN_PY" done "brief summary of what happened" + (OPENCLAW_CHECKIN_PY defaults to ~/.claude/scripts/openclaw-checkin.py) Statuses: done | blocked | progress | tests | error Override auto-detection: --task T035 --project myproject Wait for reply: --wait (default is fire-and-forget, 5s timeout, silent fail if OpenClaw is down) If context is getting long, save state to $CLAUDE_PROJECT_DIR/TODO.md (always the launch directory, not cwd), then run context-reset (ALWAYS closes this tab): - python "$CONTEXT_RESET_PY" --project-dir $CLAUDE_PROJECT_DIR - (CONTEXT_RESET_PY defaults to ~/Documents/ProjectsCL1/_grobomo/context-reset/context_reset.py) + python3 "$CONTEXT_RESET_PY" --project-dir $CLAUDE_PROJECT_DIR + (CONTEXT_RESET_PY defaults to ~/.claude/scripts/context_reset.py) CROSS-PROJECT WORK: If you created TODOs in another project's TODO.md, open a new session there (this tab keeps running): - python "$NEW_SESSION_PY" --project-dir + python3 "$NEW_SESSION_PY" --project-dir diff --git a/scripts/test.py b/scripts/test.py index 8eeb7cf..662010a 100644 --- a/scripts/test.py +++ b/scripts/test.py @@ -456,6 +456,117 @@ def grow_file(): context_reset.get_project_logs_dir = orig_fn +# --- WSL detection --- +print("\n=== WSL detection ===") +# IS_WSL is set at import time from /proc/version. We can't change /proc/version +# in tests, but we can verify the detection logic and the downstream effects. +test("IS_WSL is a boolean", isinstance(context_reset.IS_WSL, bool)) +# On Windows (where tests run), IS_WSL should be False +if context_reset.IS_WIN: + test("IS_WSL is False on Windows", context_reset.IS_WSL is False) + +# --- _get_wsl_distro --- +print("\n=== _get_wsl_distro ===") +# Default when no env var +orig_env = os.environ.pop('WSL_DISTRO_NAME', None) +test("default distro is Ubuntu", context_reset._get_wsl_distro() == "Ubuntu") +os.environ['WSL_DISTRO_NAME'] = 'Debian' +test("reads WSL_DISTRO_NAME env var", context_reset._get_wsl_distro() == "Debian") +if orig_env is not None: + os.environ['WSL_DISTRO_NAME'] = orig_env +else: + os.environ.pop('WSL_DISTRO_NAME', None) + +# --- build_launch_cmd WSL branch --- +print("\n=== build_launch_cmd (WSL) ===") +# Temporarily pretend we're on WSL to test the WSL branch +orig_is_wsl = context_reset.IS_WSL +orig_is_win = context_reset.IS_WIN +orig_is_mac = context_reset.IS_MAC +context_reset.IS_WSL = True +context_reset.IS_WIN = False +context_reset.IS_MAC = False +os.environ['WSL_DISTRO_NAME'] = 'Ubuntu' +with tempfile.TemporaryDirectory() as d: + cmd = context_reset.build_launch_cmd(d, "test prompt", "my title", "#2D5F2D") + test("WSL: contains wt.exe", "wt.exe" in cmd) + test("WSL: contains wsl.exe -d", "wsl.exe -d" in cmd) + test("WSL: contains distro name", "Ubuntu" in cmd) + test("WSL: contains tab title", "my title" in cmd) + test("WSL: contains tab color", "#2D5F2D" in cmd) + test("WSL: contains claude command", "claude" in cmd) + test("WSL: contains prompt", "test prompt" in cmd) + test("WSL: uses bash -lc (login shell)", "bash -lc" in cmd) + # Test single-quote escaping + cmd2 = context_reset.build_launch_cmd(d, "it's a test", "title", "#000000") + test("WSL: escapes single quotes", "it'\\''s" in cmd2) + # Test title sanitization + cmd3 = context_reset.build_launch_cmd(d, "p", 'title "with" quotes', "#000000") + test("WSL: strips quotes from title", '"with"' not in cmd3 and "title with quotes" in cmd3) +# Restore original platform flags +context_reset.IS_WSL = orig_is_wsl +context_reset.IS_WIN = orig_is_win +context_reset.IS_MAC = orig_is_mac +if orig_env is not None: + os.environ['WSL_DISTRO_NAME'] = orig_env +else: + os.environ.pop('WSL_DISTRO_NAME', None) + +# --- build_launch_cmd (macOS) --- +print("\n=== build_launch_cmd (macOS) ===") +orig_is_wsl = context_reset.IS_WSL +orig_is_win = context_reset.IS_WIN +orig_is_mac = context_reset.IS_MAC +context_reset.IS_WSL = False +context_reset.IS_WIN = False +context_reset.IS_MAC = True +with tempfile.TemporaryDirectory() as d: + cmd = context_reset.build_launch_cmd(d, "test prompt", "my title", "#2D5F2D") + test("Mac: contains osascript", "osascript" in cmd) + test("Mac: contains Terminal", "Terminal" in cmd) + test("Mac: contains project dir", d.replace("\\", "/") in cmd or d in cmd) + test("Mac: contains claude", "claude" in cmd) + test("Mac: contains prompt", "test prompt" in cmd) + # Test single-quote escaping + cmd2 = context_reset.build_launch_cmd(d, "it's a test", "title", "#000000") + test("Mac: escapes single quotes", "it'\\''s" in cmd2) +context_reset.IS_WSL = orig_is_wsl +context_reset.IS_WIN = orig_is_win +context_reset.IS_MAC = orig_is_mac + +# --- build_launch_cmd (Linux with gnome-terminal) --- +print("\n=== build_launch_cmd (Linux) ===") +orig_is_wsl = context_reset.IS_WSL +orig_is_win = context_reset.IS_WIN +orig_is_mac = context_reset.IS_MAC +context_reset.IS_WSL = False +context_reset.IS_WIN = False +context_reset.IS_MAC = False +# Mock _has_command to simulate gnome-terminal available +orig_has_cmd = context_reset._has_command +context_reset._has_command = lambda name: name == 'gnome-terminal' +with tempfile.TemporaryDirectory() as d: + cmd = context_reset.build_launch_cmd(d, "test prompt", "my title", "#2D5F2D") + test("Linux: contains gnome-terminal", "gnome-terminal" in cmd) + test("Linux: contains --tab", "--tab" in cmd) + test("Linux: contains title", "my title" in cmd) + test("Linux: contains claude", "claude" in cmd) + test("Linux: contains prompt", "test prompt" in cmd) + # Test single-quote escaping + cmd2 = context_reset.build_launch_cmd(d, "it's a test", "title", "#000000") + test("Linux: escapes single quotes", "it'\\''s" in cmd2) +# Fallback: no gnome-terminal +context_reset._has_command = lambda name: False +with tempfile.TemporaryDirectory() as d: + cmd = context_reset.build_launch_cmd(d, "test prompt", "my title", "#2D5F2D") + test("Linux fallback: uses bash -c", "bash -c" in cmd) + test("Linux fallback: runs in background (&)", cmd.endswith("&")) + test("Linux fallback: contains claude", "claude" in cmd) +context_reset._has_command = orig_has_cmd +context_reset.IS_WSL = orig_is_wsl +context_reset.IS_WIN = orig_is_win +context_reset.IS_MAC = orig_is_mac + # --- Summary --- print(f"\n{'='*40}") print(f"Results: {PASS} passed, {FAIL} failed")