commit 42861ea7fa75cd8ad76fee0d199bc583d865519d Author: kuannnn Date: Wed Mar 18 10:24:45 2026 +0800 feat: ClawTap v0.1.0 — initial release Multi-adapter mobile UI for AI coding assistants. Supports Claude Code, Codex CLI, and Gemini CLI through one interface. Features: - Real-time bidirectional sync via tmux + WebSocket - Cross-AI review (send one AI's output to another for review) - Multi-review tabs with minimize/expand - Push notifications (PWA) with smart session-aware filtering - Three-channel event system (hooks, file watcher, pane monitor) - Voice input, image paste, draft persistence - Terminal-native design (JetBrains Mono, dark theme, pixel art claw) - CLI with --adapter flag on every command - Zero-overhead fire-and-forget hooks diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0e96e71 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +CLAUDE_UI_PASSWORD=your-password-here +PORT=3456 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5260ec2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ +.env +.worktrees/ +.superpowers/ +.DS_Store +*.log +tests/screenshots/ +package-lock.json +docs/ +.server.pid diff --git a/README.md b/README.md new file mode 100644 index 0000000..b0d137f --- /dev/null +++ b/README.md @@ -0,0 +1,263 @@ +
+ +

+ ClawTap  ClawTap +

+ +### Your AI coding sessions, in your pocket. + +One mobile interface for Claude Code, Codex CLI, and Gemini CLI. +Real-time sync. Cross-AI review. Push notifications. + +[![npm version](https://img.shields.io/npm/v/@kuannnn/clawtap.svg)](https://www.npmjs.com/package/@kuannnn/clawtap) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![Node.js](https://img.shields.io/badge/Node.js-18+-339933.svg)](https://nodejs.org/) + +--- + +**[Quick Start](#-quick-start)** · **[Adapters](#-multi-adapter-support)** · **[Cross-AI Review](#-cross-ai-review)** · **[CLI](#-cli)** · **[PWA & Notifications](#-pwa--push-notifications)** · **[Architecture](#-architecture)** + +
+ +
+ +> Walk away from your desk while your AI is working. Get a push notification when it finishes. Approve a file edit from the couch. Send Claude's code to Gemini for a second opinion. Start a task on the train. Your phone and terminal share the exact same AI session — no duplicates, no sync issues, no matter which AI you're using. + +``` +📱 Phone (PWA) ◄── WebSocket ──► 🖥 Server ◄── tmux ──► 🤖 Claude / Codex / Gemini + ▲ │ ▲ + Push Notify HTTPS 💻 Terminal + (Web Push) (Tailscale) +``` + +
+ +## 🚀 Quick Start + +```bash +npm install -g @kuannnn/clawtap + +export CLAUDE_UI_PASSWORD=your-password +clawtap +``` + +Open the URL on your phone. That's it. + +ClawTap auto-detects which AI CLIs you have installed (`claude`, `codex`, `gemini`) and enables them automatically. + +
+📦 Install from source +
+ +```bash +git clone https://github.com/kuan0808/clawtap.git +cd clawtap && npm install && npm run build && npm link +``` + +
+ +
+ +## 🤖 Multi-Adapter Support + +ClawTap works with three AI coding assistants through a unified interface: + +| Adapter | CLI | Models | Context | Permission Modes | +|---------|-----|--------|---------|-----------------| +| **Claude Code** | `claude` | Sonnet, Opus, Haiku, Opus 1M, Sonnet 1M | 200K–1M | Normal, Auto-edit, Plan, YOLO | +| **Codex CLI** | `codex` | GPT-5.4, GPT-5.3 Codex, GPT-5.2 series, and more | 258K | Suggest, Full Auto, Untrusted, YOLO | +| **Gemini CLI** | `gemini` | Auto, Gemini Pro, Gemini Flash, Flash Lite | 1M | Default, Auto Edit, Plan, YOLO | + +Each adapter auto-detects at startup. Start a session from your phone's **New Chat** screen — tap the adapter icon to switch between available AIs, pick a model, and go. Or from the terminal: + +```bash +clawtap new # Claude (default) +clawtap new --adapter codex # Codex +clawtap new --adapter gemini # Gemini +``` + +The UI adapts to each adapter — different models, permission modes, effort levels, and branding. But the workflow is identical: send a message, see the response stream in, approve or deny tool calls, switch modes. + +
+ +## 🔀 Cross-AI Review + +Send any AI's response to a different AI for a second opinion — the killer feature that makes multi-adapter worth it. + +**How it works:** + +1. Tap **↗ Send to** on any assistant message +2. Pick a target adapter (e.g., send Claude's code to Codex) +3. Choose a model and optionally attach instructions ("Review for security issues") +4. A review panel slides up with the child AI's conversation +5. The child AI can **send back** its findings to the parent chat + +**Multi-review tabs:** Run multiple reviews simultaneously. Each review gets its own tab in the floating panel — switch between them, minimize to a compact bar, or expand to see the full conversation. Each tab maintains its own independent WebSocket connection. + +**Send to existing review:** When reviews are already active, tapping Send to shows a shortcut sheet — send directly to a running review or start a new one. + +**Review markers:** Visual timeline markers show exactly where each review started in the parent chat, with a collapsed card to tap and view the review history. "Review ended" appears at the position in the chat where you pressed End. + +
+ +## ✨ Features + +### Live Streaming + +See AI responses **as they're being written** — not after they finish. Thinking indicators update in real-time. Tool calls appear as inline cards with live status transitions. Context usage and cost stream to your status bar. + +### Mobile Permission Control + +Slide-up overlay with the exact tool name, file path, and command. Allow, Allow All, or Deny with a 120-second countdown. Switch between permission modes mid-session with a single tap on the status bar. Your terminal shows the same prompt — answer from whichever device is closer. + +### Rich Tool Visualization + +Every tool call renders as an expandable card: + +| Tool | What You See | +|------|-------------| +| **Edit** | Inline red/green diff preview → full-screen diff viewer | +| **Write** | File path + content preview with line count | +| **Read / Bash / Grep** | Input/output with syntax highlighting | +| **Agent** | Collapsible group with progress indicators | + +### Queue & Continue + +Send follow-up messages while the AI is still responding. They appear as "Queued" with Edit/Cancel and auto-send when the AI finishes. Paste images from clipboard with thumbnail preview. + +### Voice Input + +Tap the mic icon to dictate coding instructions. Uses the Web Speech API with real-time interim transcription. Works in any language. + +### Smart Input + +Type `ultrathink` or `megathink` and watch the rainbow shimmer animation. Drafts auto-save to localStorage. Images can be attached from gallery or clipboard. + +### Session Management + +Browse projects by directory. Filter by adapter (Claude / Codex / Gemini tabs). See session previews with first message, timestamps, and active indicators. The **Active** tab shows running sessions across all projects with real-time refresh, client count, and notification badges. + +
+ +## 💻 CLI + +```bash +clawtap # Start server, show URLs +clawtap new [--adapter codex|gemini] # New session +clawtap --continue [--adapter gemini] # Resume most recent session +clawtap --resume # Resume specific session +clawtap -a [--adapter codex] # Active sessions (current project) +clawtap -A [--adapter gemini] # Active sessions (all projects) +clawtap hooks install [--adapter claude] # Install hooks (all or one adapter) +clawtap hooks uninstall [--adapter gemini] # Remove hooks +clawtap cert # Generate HTTPS certificate +clawtap stop # Graceful shutdown +``` + +The `--adapter` flag works with every command. Session lists show colored `[Claude]`/`[Codex]`/`[Gemini]` labels with first-prompt previews. + +Auto-starts the server on first use. Sessions are instantly visible on mobile. + +
+ +## 🔔 PWA & Push Notifications + +### Setup + +**1. Enable HTTPS** (required for push): + +```bash +# Option A: Tailscale (recommended — zero cert management) +tailscale serve --bg 3456 + +# Option B: Self-signed certificate +clawtap cert +``` + +**2. Install PWA:** Open the URL in Safari → Share → **Add to Home Screen**. + +**3. Enable notifications:** Open ClawTap from home screen → tap the **bell icon** → Allow. + +### Smart Notifications + +| Event | When | Notification | +|-------|------|-------------| +| AI finishes | Only if you're NOT viewing that session | "Turn complete in project-name" | +| Permission needed | Only if you're NOT viewing that session | Tool name + project | +| Question asked | Only if you're NOT viewing that session | "Waiting for answer" | + +The app icon badge shows how many sessions have unread notifications. Entering a session clears its count. + +
+ +## ⚙️ Configuration + +| Variable | Default | Description | +|----------|---------|-------------| +| `CLAUDE_UI_PASSWORD` | *(required)* | Login password | +| `PORT` | `3456` | Server port | + +HTTPS is enabled automatically when `~/.clawtap/cert.pem` and `~/.clawtap/key.pem` exist. Otherwise the server runs on HTTP. Tailscale Serve is the easiest path to HTTPS. + +Hooks auto-configure on startup and clean up on `clawtap stop`. If you have existing hooks (e.g., custom statusLine), ClawTap wraps them — both coexist. + +
+ +## 🏗 Architecture + +### Three-Channel Event System + +Each adapter feeds the UI through three independent, non-blocking channels: + +| Channel | Latency | Role | +|---------|---------|------| +| **Hooks** | ~1ms | Tool events, permissions, session lifecycle — fire-and-forget, never blocks the AI | +| **File Watcher** | ~2s | Message transcripts (JSONL for Claude/Codex, JSON for Gemini) — single source of truth | +| **Pane Monitor** | 500ms | Streaming text preview, thinking detection — ephemeral UX signals via tmux | + +### Adapter Plugin Architecture + +``` +IAdapter (EventEmitter) + ├── ClaudeAdapter ← HTTP hooks, JSONL watcher, pane monitor + ├── CodexAdapter ← Command hooks, JSONL watcher, pane monitor + └── GeminiAdapter ← Shell bridge hooks, JSON watcher, pane monitor +``` + +Each adapter is self-contained: hook configuration, session file discovery, transcript parsing, tmux lifecycle, permission management. Adding a new AI CLI means implementing one class — the server, WebSocket protocol, and frontend work unchanged. + +### Data Flow + +``` +AI CLI (Claude/Codex/Gemini) + │ + ├── Hooks → POST /api/hooks/:adapter/:event → SessionManager → WebSocket → Phone + ├── Session Files → Watcher → new-messages event → WebSocket → Phone + └── tmux pane → PaneMonitor → text-delta/thinking → WebSocket → Phone + +Phone + └── Send message → WebSocket → SessionManager → tmux sendKeys → AI CLI +``` + +### Storage + +SQLite with WAL mode. Stores review history, push subscriptions, rate limiting, saved instructions, and session stats. Session messages live in the AI CLI's own files — ClawTap reads them, never writes. + +
+ +## 🔧 Troubleshooting + +| Problem | Solution | +|---------|----------| +| No response after sending | `tmux list-windows -t clawtap` — check if the AI process is alive | +| `clawtap` not found | `npm install -g @kuannnn/clawtap` | +| Stale sessions | `tmux kill-session -t clawtap` | +| Port in use | `lsof -i :3456` then `kill ` | +| Hooks not cleaned after crash | `clawtap hooks uninstall` or `clawtap stop` | +| Push notifications not working | Ensure HTTPS + PWA installed from home screen | +| Adapter not showing | Check that the CLI is installed: `which claude`, `which codex`, `which gemini` | +| Gemini hooks failing | Verify timeout is in milliseconds (5000, not 5) | + +
+ +## 📄 License + +MIT diff --git a/bin/clawtap b/bin/clawtap new file mode 100755 index 0000000..1764a13 --- /dev/null +++ b/bin/clawtap @@ -0,0 +1,494 @@ +#!/bin/bash +# clawtap — CLI for managing AI coding sessions (Claude, Codex, Gemini) +# +# Usage: +# clawtap # Start server, show connection URLs +# clawtap new # New Claude session in tmux +# clawtap new --adapter codex # New Codex session +# clawtap -a # List active sessions (current project) +# clawtap -a --adapter gemini # List only Gemini sessions (current project) +# clawtap -A # List ALL active sessions (all projects) +# clawtap --continue # Resume most recent session +# clawtap --continue --adapter codex # Resume most recent Codex session +# clawtap --resume # Resume a specific session +# clawtap hooks install --adapter claude # Install hooks for Claude only +# clawtap stop # Stop the server +# +# Sessions run inside tmux "clawtap". +# Mobile app auto-connects for real-time sync. + +TMUX_SESSION="clawtap" +YOLO="--dangerously-skip-permissions" +SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)" +SERVER_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +PORT="${PORT:-3456}" +PID_FILE="$HOME/.clawtap/server.pid" + +# --- Parse --adapter flag (before any command handling) --- +# Helper: set adapter variables from adapter name +set_adapter() { + case "$1" in + claude) ADAPTER="claude"; ADAPTER_CMD="claude"; YOLO="--dangerously-skip-permissions" ;; + codex) ADAPTER="codex"; ADAPTER_CMD="codex"; YOLO="--dangerously-bypass-approvals-and-sandbox" ;; + gemini) ADAPTER="gemini"; ADAPTER_CMD="gemini"; YOLO="--approval-mode yolo" ;; + esac +} + +ADAPTER="claude" +ADAPTER_CMD="claude" +ADAPTER_EXPLICIT=false +prev_arg="" +for arg in "$@"; do + if [ "$prev_arg" = "--adapter" ]; then + ADAPTER_EXPLICIT=true + case "$arg" in + claude) set_adapter claude ;; + codex) set_adapter codex ;; + gemini) set_adapter gemini ;; + *) echo "Unknown adapter: $arg"; exit 1 ;; + esac + fi + prev_arg="$arg" +done +# Strip --adapter and its value from positional args +CLEANED_ARGS=() +skip_next=false +for arg in "$@"; do + if $skip_next; then skip_next=false; continue; fi + if [ "$arg" = "--adapter" ]; then skip_next=true; continue; fi + CLEANED_ARGS+=("$arg") +done +set -- "${CLEANED_ARGS[@]}" + +# --- CLI flags (no server needed) --- +case "$1" in + --version|-v) + printf 'clawtap v%s\n' "$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' "$SERVER_DIR/package.json" | head -1)" + exit 0 ;; + --help|-h) + cat << 'HELP' +Usage: clawtap [options] [cli args...] + +Commands: + new Start a new AI coding session in tmux + stop Stop the server (graceful cleanup) + hooks install Install hooks (all adapters, or use --adapter) + hooks uninstall Remove hooks (all adapters, or use --adapter) + cert Generate self-signed HTTPS certificate + +Options: + -v, --version Show version + -h, --help Show this help + -a List active sessions (current project) + -A List ALL active sessions (all projects) + --adapter Adapter: claude (default), codex, gemini + --resume Resume a specific session by ID + --continue Resume the most recent session + Pass through to the adapter CLI + +Examples: + clawtap new # New Claude session + clawtap new --adapter codex # New Codex session + clawtap -a --adapter gemini # List Gemini sessions (this project) + clawtap --continue --adapter codex # Resume latest Codex session + clawtap hooks install --adapter claude # Install hooks for Claude only +HELP + exit 0 ;; + stop) + if [ ! -f "$PID_FILE" ]; then + echo "Server is not running." + # Safety net: clean up hooks in case of previous ungraceful shutdown + npx tsx "$SCRIPT_DIR/hooks-cli.mjs" uninstall 2>/dev/null + exit 0 + fi + PID=$(cat "$PID_FILE") + if ! kill -0 "$PID" 2>/dev/null; then + echo "Server process (PID $PID) not running. Cleaning up stale PID file." + rm -f "$PID_FILE" + npx tsx "$SCRIPT_DIR/hooks-cli.mjs" uninstall 2>/dev/null + exit 0 + fi + echo "Stopping ClawTap server (PID $PID)..." + kill "$PID" + # Wait for graceful shutdown (up to 5s) + for i in $(seq 1 10); do + if ! kill -0 "$PID" 2>/dev/null; then + echo "Server stopped." + # Safety net: ensure hooks are cleaned up even if server didn't do it + npx tsx "$SCRIPT_DIR/hooks-cli.mjs" uninstall 2>/dev/null + exit 0 + fi + sleep 0.5 + done + echo "Server didn't stop gracefully, forcing..." + kill -9 "$PID" 2>/dev/null + rm -f "$PID_FILE" + # Graceful shutdown failed — force cleanup hooks + npx tsx "$SCRIPT_DIR/hooks-cli.mjs" uninstall 2>/dev/null + echo "Server killed." + exit 0 ;; + hooks) + if [ "$ADAPTER_EXPLICIT" = true ]; then + npx tsx "$SCRIPT_DIR/hooks-cli.mjs" "$2" "$ADAPTER" + else + npx tsx "$SCRIPT_DIR/hooks-cli.mjs" "$2" + fi + exit 0 ;; + cert) + CERT_DIR="$HOME/.clawtap" + CERT_FILE="$CERT_DIR/cert.pem" + KEY_FILE="$CERT_DIR/key.pem" + mkdir -p "$CERT_DIR" + if [ -f "$CERT_FILE" ] && [ -f "$KEY_FILE" ]; then + echo "Certificate already exists at $CERT_DIR/" + echo " cert.pem $(openssl x509 -in "$CERT_FILE" -noout -enddate 2>/dev/null | sed 's/notAfter=/Expires: /')" + echo "" + read -p "Regenerate? (y/N) " REGEN + [ "$REGEN" != "y" ] && [ "$REGEN" != "Y" ] && exit 0 + fi + echo "Generating self-signed certificate..." + openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout "$KEY_FILE" \ + -out "$CERT_FILE" \ + -days 365 \ + -subj "/CN=ClawTap" \ + -addext "subjectAltName=IP:$(ipconfig getifaddr en0 2>/dev/null || echo '0.0.0.0')" \ + 2>/dev/null + if [ $? -ne 0 ]; then + echo "Failed to generate certificate. Is openssl installed?" + exit 1 + fi + chmod 600 "$KEY_FILE" + echo "" + echo "Certificate generated:" + echo " $CERT_FILE" + echo " $KEY_FILE" + echo "" + echo "Restart the server to use HTTPS:" + echo " clawtap stop && clawtap" + echo "" + echo "To trust on your phone (iOS):" + echo " 1. Send ~/.clawtap/cert.pem to your phone (AirDrop, email, etc.)" + echo " 2. Open it → Install Profile" + echo " 3. Settings → General → About → Certificate Trust Settings" + echo " → Enable full trust for 'ClawTap'" + echo "" + echo "To trust on your phone (Android):" + echo " 1. Send ~/.clawtap/cert.pem to your phone" + echo " 2. Settings → Security → Install certificate → CA certificate" + exit 0 ;; +esac + +# --- Server management --- + +# Detect HTTPS mode +if [ -f "$HOME/.clawtap/cert.pem" ] && [ -f "$HOME/.clawtap/key.pem" ]; then + PROTOCOL="https" + CURL_OPTS="-k" # allow self-signed certs +else + PROTOCOL="http" + CURL_OPTS="" +fi + +ensure_server() { + # Check if server is already running + if curl -sf $CURL_OPTS --connect-timeout 2 "$PROTOCOL://127.0.0.1:$PORT/health" >/dev/null 2>&1; then + return 0 + fi + + # Fallback: check PID file (written by server on startup) + if [ -f "$PID_FILE" ]; then + SAVED_PID=$(cat "$PID_FILE") + if kill -0 "$SAVED_PID" 2>/dev/null; then + # Server process alive — retry health check with patience + for i in $(seq 1 5); do + if curl -sf $CURL_OPTS --connect-timeout 2 "$PROTOCOL://127.0.0.1:$PORT/health" >/dev/null 2>&1; then + return 0 + fi + sleep 0.5 + done + echo "Server process running (PID $SAVED_PID) but not responding on port $PORT" + echo "Check: $HOME/.clawtap/server.log" + exit 1 + else + # Stale PID file — process dead, clean up + rm -f "$PID_FILE" + fi + fi + + # Password is required + if [ -z "$CLAUDE_UI_PASSWORD" ]; then + echo "ClawTap server not running." + echo "" + echo "Set a password and try again:" + echo " export CLAUDE_UI_PASSWORD=your-password" + echo " clawtap" + echo "" + echo "Or start the server separately:" + echo " CLAUDE_UI_PASSWORD=your-password npm start" + exit 1 + fi + + echo "Starting ClawTap server on port $PORT..." + CLAUDE_UI_PASSWORD="$CLAUDE_UI_PASSWORD" PORT="$PORT" \ + nohup npx tsx "$SERVER_DIR/server/index.ts" >"$HOME/.clawtap/server.log" 2>&1 & + SERVER_PID=$! + + # Wait for server to be ready (up to 10s) + for i in $(seq 1 20); do + if curl -sf $CURL_OPTS --connect-timeout 2 "$PROTOCOL://127.0.0.1:$PORT/health" >/dev/null 2>&1; then + echo "ClawTap running on $PROTOCOL://0.0.0.0:$PORT (pid $SERVER_PID)" + return 0 + fi + sleep 0.5 + done + + echo "Server failed to start. Check $HOME/.clawtap/server.log" + exit 1 +} + +ensure_server + +# Authenticate with the ClawTap server API +get_auth_token() { + local BODY + BODY=$(printf '%s' "$CLAUDE_UI_PASSWORD" | python3 -c 'import sys,json; print(json.dumps({"password": sys.stdin.read()}))' 2>/dev/null) + curl -sk -X POST "${PROTOCOL}://localhost:${PORT}/api/auth/login" \ + -H "Content-Type: application/json" \ + -d "$BODY" 2>/dev/null | \ + python3 -c 'import sys,json; print(json.load(sys.stdin).get("token",""))' 2>/dev/null +} + +require_auth() { + AUTH_TOKEN=$(get_auth_token) + if [ -z "$AUTH_TOKEN" ]; then + echo "Error: Failed to authenticate with ClawTap server" + exit 1 + fi +} + +# No args → just start server, print URLs, exit +if [ $# -eq 0 ]; then + LAN_IP=$(ipconfig getifaddr en0 2>/dev/null || echo "") + TS_HOST=$(tailscale status --self --json 2>/dev/null | \ + python3 -c "import sys,json; print(json.load(sys.stdin)['Self']['DNSName'].rstrip('.'))" 2>/dev/null || echo "") + + echo "" + echo "ClawTap server is running on port $PORT" + echo "" + echo " Open on your phone:" + if [ -n "$TS_HOST" ]; then echo " https://${TS_HOST} (Tailscale)"; fi + if [ -n "$LAN_IP" ]; then echo " ${PROTOCOL}://${LAN_IP}:${PORT} (LAN)"; fi + echo " http://localhost:${PORT} (this machine)" + echo "" + echo " New session: clawtap new [--adapter claude|codex|gemini]" + echo " List sessions: clawtap -a" + echo " Stop server: clawtap stop" + echo "" + exit 0 +fi + +# "new" subcommand → create tmux session (like old no-args behavior) +if [ "$1" = "new" ]; then + shift +fi + +# Ensure tmux session exists +tmux has-session -t "$TMUX_SESSION" 2>/dev/null || \ + tmux new-session -d -s "$TMUX_SESSION" -n main + +# --- Attach mode (query server API for accurate adapter info) --- +if [ "$1" = "--attach" ] || [ "$1" = "-a" ] || [ "$1" = "-A" ]; then + ALL_MODE=false + [ "$1" = "-A" ] && ALL_MODE=true + + require_auth + + SESSIONS_JSON=$(curl -s $CURL_OPTS "$PROTOCOL://localhost:$PORT/api/active-sessions" \ + -H "Authorization: Bearer $AUTH_TOKEN") + + if ! echo "$SESSIONS_JSON" | python3 -c "import sys,json; json.load(sys.stdin)" 2>/dev/null; then + echo "Error: Failed to fetch sessions from server" + exit 1 + fi + + # Filter with Python: by adapter (if explicit), by cwd (if -a mode) + FILTERED=$(CWD="$(pwd)" ADAPTER="$ADAPTER" ADAPTER_EXPLICIT="$ADAPTER_EXPLICIT" ALL_MODE="$ALL_MODE" python3 -c " +import sys, json, os +sessions = json.load(sys.stdin) +adapter_filter = os.environ.get('ADAPTER') if os.environ.get('ADAPTER_EXPLICIT') == 'true' else None +cwd_filter = os.environ.get('CWD') if os.environ.get('ALL_MODE') == 'false' else None +results = [] +for s in sessions: + if adapter_filter and s.get('adapter','') != adapter_filter: + continue + if cwd_filter and s.get('cwd','') != cwd_filter: + continue + results.append(s) +# Sort by lastActivity descending +results.sort(key=lambda x: x.get('lastActivity',''), reverse=True) +json.dump(results, sys.stdout) +" <<< "$SESSIONS_JSON" 2>/dev/null) + + COUNT=$(echo "$FILTERED" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null) + + if [ "$COUNT" = "0" ] || [ -z "$COUNT" ]; then + if [ "$ALL_MODE" = true ]; then + echo "No active sessions." + else + PROJECT_NAME=$(basename "$(pwd)") + HINT="" + [ "$ADAPTER_EXPLICIT" = true ] && HINT=" ($ADAPTER)" + echo "No active sessions${HINT} for project '$PROJECT_NAME'." + echo "Run 'clawtap -A' to see all projects, or 'clawtap new' to start a new session." + fi + exit 0 + fi + + if [ "$ALL_MODE" = true ]; then + echo "Active sessions (all projects):" + else + echo "Active sessions for $(basename "$(pwd)"):" + fi + [ "$ADAPTER_EXPLICIT" = true ] && echo " (filtered: $ADAPTER)" + echo "" + + # Display sessions + DISPLAY_OUTPUT=$(ALL_MODE="$ALL_MODE" python3 -c " +import sys, json, os +sessions = json.load(sys.stdin) +home = os.path.expanduser('~') +colors = {'claude': '\033[33m[Claude]\033[0m', 'codex': '\033[32m[Codex]\033[0m', 'gemini': '\033[34m[Gemini]\033[0m'} +all_mode = os.environ.get('ALL_MODE') == 'true' +for i, s in enumerate(sessions, 1): + label = colors.get(s.get('adapter',''), '\033[90m[?]\033[0m') + sid = s.get('sessionId','?') + prompt = (s.get('firstPrompt','') or '')[:60] + cwd = s.get('cwd','') + if cwd.startswith(home): + cwd = '~' + cwd[len(home):] + print(f' {i}) {label} {sid}') + if all_mode and cwd: + print(f' Dir: {cwd}') + if prompt: + print(f' {prompt}') + print() +" <<< "$FILTERED" 2>/dev/null) + echo "$DISPLAY_OUTPUT" + + # Build session ID array for selection + declare -a SESS_IDS + while IFS= read -r sid; do + [ -z "$sid" ] && continue + SESS_IDS+=("$sid") + done < <(echo "$FILTERED" | python3 -c "import sys,json; [print(s['sessionId']) for s in json.load(sys.stdin)]" 2>/dev/null) + + read -p "Select (1-${#SESS_IDS[@]}): " CHOICE + if [ -n "$CHOICE" ] && [ "$CHOICE" -ge 1 ] 2>/dev/null && [ "$CHOICE" -le "${#SESS_IDS[@]}" ] 2>/dev/null; then + SELECTED="${SESS_IDS[$((CHOICE-1))]}" + tmux select-window -t "$TMUX_SESSION:$SELECTED" + tmux attach -t "$TMUX_SESSION" + else + echo "Cancelled." + fi + exit 0 +fi + +# --- Resume mode --- +if [ "$1" = "--resume" ] && [ -n "$2" ]; then + RESUME_ID="$2" + shift 2 + + require_auth + + BODY=$(printf '%s\n%s\n%s' "$RESUME_ID" "$ADAPTER" "$(pwd)" | python3 -c 'import sys,json; s,a,c=sys.stdin.read().strip().split("\n"); print(json.dumps({"sessionId":s,"adapter":a,"cwd":c}))' 2>/dev/null) + RESULT=$(curl -s $CURL_OPTS -X POST "$PROTOCOL://localhost:$PORT/api/sessions/resume" \ + -H "Authorization: Bearer $AUTH_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$BODY") + SESSION_ID=$(echo "$RESULT" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("sessionId",""))' 2>/dev/null) + + if [ -z "$SESSION_ID" ] || [ "$SESSION_ID" = "null" ]; then + echo "Error: Failed to resume session" + echo "$RESULT" + exit 1 + fi + + # Check if window already exists (session might already be active) + if tmux list-windows -t "$TMUX_SESSION" -F '#{window_name}' 2>/dev/null | grep -q "^${SESSION_ID}$"; then + tmux select-window -t "$TMUX_SESSION:$SESSION_ID" + else + echo "Session resumed but window not found. Try refreshing." + fi + + tmux attach -t "$TMUX_SESSION" + exit 0 + +# --- Continue mode --- +elif [ "$1" = "--continue" ]; then + shift + + require_auth + + # When --adapter is explicit, query API and pick most recent for that adapter + if [ "$ADAPTER_EXPLICIT" = true ]; then + SESSIONS_JSON=$(curl -s $CURL_OPTS "$PROTOCOL://localhost:$PORT/api/active-sessions?adapter=$ADAPTER" \ + -H "Authorization: Bearer $AUTH_TOKEN") + LATEST=$(echo "$SESSIONS_JSON" | python3 -c " +import sys, json +sessions = json.load(sys.stdin) +if not sessions: + sys.exit(1) +sessions.sort(key=lambda x: x.get('lastActivity',''), reverse=True) +print(sessions[0]['sessionId']) +" 2>/dev/null) + if [ -z "$LATEST" ]; then + echo "No active $ADAPTER sessions to continue." + echo "Run 'clawtap -a --adapter $ADAPTER' to check, or 'clawtap new --adapter $ADAPTER' to start one." + exit 1 + fi + else + LATEST=$(tmux list-windows -t "$TMUX_SESSION" -F '#{window_activity} #{window_name}' 2>/dev/null | grep -v " main$" | sort -rn | head -1 | awk '{print $2}') + fi + + if [ -n "$LATEST" ]; then + # Check if the process in the pane is still running + PANE_CMD=$(tmux display -t "$TMUX_SESSION:$LATEST" -p '#{pane_current_command}' 2>/dev/null) + if [ "$PANE_CMD" = "zsh" ] || [ "$PANE_CMD" = "bash" ]; then + # CLI process exited, shell is showing — resume via API + BODY=$(printf '%s\n%s\n%s' "$LATEST" "$ADAPTER" "$(pwd)" | python3 -c 'import sys,json; s,a,c=sys.stdin.read().strip().split("\n"); print(json.dumps({"sessionId":s,"adapter":a,"cwd":c}))' 2>/dev/null) + curl -s $CURL_OPTS -X POST "${PROTOCOL}://localhost:${PORT}/api/sessions/resume" \ + -H "Authorization: Bearer $AUTH_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$BODY" >/dev/null 2>&1 + fi + tmux select-window -t "$TMUX_SESSION:$LATEST" + else + echo "No active sessions to continue." + echo "Run 'clawtap new' to start a new session." + exit 1 + fi + + tmux attach -t "$TMUX_SESSION" + exit 0 + +# --- New session --- +else + require_auth + + BODY=$(printf '%s\n%s' "$ADAPTER" "$(pwd)" | python3 -c 'import sys,json; a,c=sys.stdin.read().strip().split("\n"); print(json.dumps({"adapter":a,"cwd":c}))' 2>/dev/null) + RESULT=$(curl -s $CURL_OPTS -X POST "$PROTOCOL://localhost:$PORT/api/sessions/start" \ + -H "Authorization: Bearer $AUTH_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$BODY") + SESSION_ID=$(echo "$RESULT" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("sessionId",""))' 2>/dev/null) + + if [ -z "$SESSION_ID" ] || [ "$SESSION_ID" = "null" ]; then + echo "Error: Failed to create session" + echo "$RESULT" + exit 1 + fi + + tmux select-window -t "$TMUX_SESSION:$SESSION_ID" + tmux attach -t "$TMUX_SESSION" +fi diff --git a/bin/hooks-cli.mjs b/bin/hooks-cli.mjs new file mode 100755 index 0000000..a71f926 --- /dev/null +++ b/bin/hooks-cli.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +// Standalone hook management — no server needed. +// Usage: node hooks-cli.mjs install|uninstall [adapter] +// adapter: claude, codex, gemini (optional — defaults to all) +import { ClaudeHookConfig } from '../server/adapters/claude/hook-config.js'; +import { CodexHookConfig } from '../server/adapters/codex/hook-config.js'; +import { GeminiHookConfig } from '../server/adapters/gemini/hook-config.js'; + +const cmd = process.argv[2]; +const adapterArg = process.argv[3] || null; // optional: claude, codex, gemini + +if (!cmd || !['install', 'uninstall'].includes(cmd)) { + console.error('Usage: hooks-cli.mjs install|uninstall [claude|codex|gemini]'); + process.exit(1); +} + +if (adapterArg && !['claude', 'codex', 'gemini'].includes(adapterArg)) { + console.error(`Unknown adapter: ${adapterArg}. Use: claude, codex, gemini`); + process.exit(1); +} + +const adapters = { + claude: new ClaudeHookConfig(), + codex: new CodexHookConfig(), + gemini: new GeminiHookConfig(), +}; + +// If adapter specified, only operate on that one; otherwise all +const targets = adapterArg ? { [adapterArg]: adapters[adapterArg] } : adapters; + +for (const [name, hook] of Object.entries(targets)) { + hook[cmd](); +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..7a378fd --- /dev/null +++ b/bun.lock @@ -0,0 +1,856 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "codetap", + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.1.0", + "@tailwindcss/typography": "^0.5.19", + "bcrypt": "^6.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "express": "^5.1.0", + "jsonwebtoken": "^9.0.2", + "lucide-react": "^0.577.0", + "multer": "^2.1.1", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-markdown": "^10.1.0", + "react-syntax-highlighter": "^15.6.1", + "tailwind-merge": "^3.5.0", + "ws": "^8.18.0", + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.3", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@types/react-syntax-highlighter": "^15.5.13", + "@vitejs/plugin-react": "^4.4.1", + "concurrently": "^9.1.2", + "tailwindcss": "^4.1.3", + "typescript": "^5.8.3", + "vite": "^6.3.2", + }, + }, + }, + "packages": { + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.1.77", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ZEjWQtkoB2MEY6K16DWMmF+8OhywAynH0m08V265cerbZ8xPD/2Ng2jPzbbO40mPeFSsMDJboShL+a3aObP0Jg=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], + + "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + + "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="], + + "@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@types/react-syntax-highlighter": ["@types/react-syntax-highlighter@15.5.13", "", { "dependencies": { "@types/react": "*" } }, "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "append-field": ["append-field@1.0.0", "", {}, "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="], + + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.7", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw=="], + + "bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="], + + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001778", "", {}, "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg=="], + + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "character-entities": ["character-entities@1.2.4", "", {}, "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@1.1.4", "", {}, "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA=="], + + "character-reference-invalid": ["character-reference-invalid@1.1.4", "", {}, "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="], + + "concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="], + + "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fault": ["fault@1.0.4", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hast-util-parse-selector": ["hast-util-parse-selector@2.2.5", "", {}, "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ=="], + + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "hastscript": ["hastscript@6.0.0", "", { "dependencies": { "@types/hast": "^2.0.0", "comma-separated-tokens": "^1.0.0", "hast-util-parse-selector": "^2.0.0", "property-information": "^5.0.0", "space-separated-tokens": "^1.0.0" } }, "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w=="], + + "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], + + "highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="], + + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-alphabetical": ["is-alphabetical@1.0.4", "", {}, "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg=="], + + "is-alphanumerical": ["is-alphanumerical@1.0.4", "", { "dependencies": { "is-alphabetical": "^1.0.0", "is-decimal": "^1.0.0" } }, "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A=="], + + "is-decimal": ["is-decimal@1.0.4", "", {}, "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-hexadecimal": ["is-hexadecimal@1.0.4", "", {}, "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], + + "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], + + "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], + + "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="], + + "lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="], + + "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], + + "lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="], + + "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], + + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + + "lowlight": ["lowlight@1.20.0", "", { "dependencies": { "fault": "^1.0.0", "highlight.js": "~10.7.0" } }, "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "lucide-react": ["lucide-react@0.577.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "multer": ["multer@2.1.1", "", { "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "concat-stream": "^2.0.0", "type-is": "^1.6.18" } }, "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "node-addon-api": ["node-addon-api@8.6.0", "", {}, "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q=="], + + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + + "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parse-entities": ["parse-entities@2.0.0", "", { "dependencies": { "character-entities": "^1.0.0", "character-entities-legacy": "^1.0.0", "character-reference-invalid": "^1.0.0", "is-alphanumerical": "^1.0.0", "is-decimal": "^1.0.0", "is-hexadecimal": "^1.0.0" } }, "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + + "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], + + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], + + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], + + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + + "react-syntax-highlighter": ["react-syntax-highlighter@15.6.6", "", { "dependencies": { "@babel/runtime": "^7.3.1", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", "refractor": "^3.6.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "refractor": ["refractor@3.6.0", "", { "dependencies": { "hastscript": "^6.0.0", "parse-entities": "^2.0.0", "prismjs": "~1.27.0" } }, "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + + "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + + "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], + + "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], + + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + + "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "decode-named-character-reference/character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "hastscript/@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="], + + "hastscript/comma-separated-tokens": ["comma-separated-tokens@1.0.8", "", {}, "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw=="], + + "hastscript/property-information": ["property-information@5.6.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA=="], + + "hastscript/space-separated-tokens": ["space-separated-tokens@1.1.5", "", {}, "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA=="], + + "mdast-util-mdx-jsx/parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + + "multer/type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], + + "refractor/prismjs": ["prismjs@1.27.0", "", {}, "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA=="], + + "stringify-entities/character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "hastscript/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + + "mdast-util-mdx-jsx/parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + + "mdast-util-mdx-jsx/parse-entities/character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "mdast-util-mdx-jsx/parse-entities/character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + + "mdast-util-mdx-jsx/parse-entities/is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + + "mdast-util-mdx-jsx/parse-entities/is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + + "mdast-util-mdx-jsx/parse-entities/is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + + "multer/type-is/media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], + + "multer/type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "mdast-util-mdx-jsx/parse-entities/is-alphanumerical/is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "multer/type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + } +} diff --git a/docs/superpowers/plans/2026-03-23-cross-ai-review.md b/docs/superpowers/plans/2026-03-23-cross-ai-review.md new file mode 100644 index 0000000..6ec40b3 --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-cross-ai-review.md @@ -0,0 +1,690 @@ +# Cross-AI Review Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enable sending messages between CLI sessions (e.g., Claude to Codex) for cross-AI review, with a floating panel UI inside ChatView. + +**Architecture:** Three phases: (1) backend infrastructure (DB, tmux, WS events, message IDs), (2) review session lifecycle (create, send-back, end, reconnect), (3) frontend UI (floating panel, action buttons, history view, remove old components). + +**Tech Stack:** TypeScript, SQLite (better-sqlite3), tmux, React, WebSocket + +**Spec:** `docs/superpowers/specs/2026-03-23-cross-ai-review-design.md` + +--- + +## Phase 1: Backend Infrastructure + +### Task 1: Add `session_reviews` DB table + +**Files:** +- Modify: `server/db.ts` + +- [ ] **Step 1: Add CREATE TABLE in initDB()** + +In `server/db.ts`, inside `initDB()` after the existing `CREATE TABLE` statements (~line 58), add: + +```sql +CREATE TABLE IF NOT EXISTS session_reviews ( + id TEXT PRIMARY KEY, + parent_cli_session_id TEXT NOT NULL, + child_cli_session_id TEXT NOT NULL, + child_adapter TEXT NOT NULL, + anchor_message_id TEXT, + review_prompt TEXT, + review_title TEXT, + message_count INTEGER DEFAULT 0, + started_at TEXT DEFAULT (datetime('now')), + ended_at TEXT DEFAULT NULL +); +CREATE INDEX IF NOT EXISTS idx_reviews_parent ON session_reviews(parent_cli_session_id); +``` + +- [ ] **Step 2: Add SessionReviewRow interface** + +After the `SessionRow` interface (~line 249): + +```typescript +export interface SessionReviewRow { + id: string; + parent_cli_session_id: string; + child_cli_session_id: string; + child_adapter: string; + anchor_message_id: string | null; + review_prompt: string | null; + review_title: string | null; + message_count: number; + started_at: string; + ended_at: string | null; +} +``` + +- [ ] **Step 3: Add prepared statements to PreparedStatements interface and stmts()** + +Add five statements: `reviewCreate`, `reviewGetById`, `reviewGetActiveForParent`, `reviewGetAllForParent`, `reviewGetAllChildIds`, `reviewEnd`. + +- [ ] **Step 4: Add sessionReviews operations export** + +```typescript +export const sessionReviews = { + create(id, parentCliId, childCliId, childAdapter, anchorMsgId?, prompt?, title?): void, + getById(reviewId): SessionReviewRow | undefined, + getActiveForParent(parentCliSessionId): SessionReviewRow[], + getAllForParent(parentCliSessionId): SessionReviewRow[], + getAllChildIds(): Set, + endReview(reviewId, messageCount?): void, + updateChildCliId(internalId, cliId): void, +}; +``` + +- [ ] **Step 5: Verify DB loads without errors** + +Run: `CLAUDE_UI_PASSWORD=test npx tsx server/index.ts` +Expected: `[db] SQLite database initialized` with no errors. + +- [ ] **Step 6: Commit** + +```bash +git add server/db.ts +git commit -m "feat: add session_reviews DB table for cross-AI review tracking" +``` + +--- + +### Task 2: Add `pasteBuffer()` to TmuxManager + `pasteToSession()` to IAdapter + +**Files:** +- Modify: `server/adapters/claude/tmux-manager.ts` +- Modify: `server/adapters/interface.ts` +- Modify: `server/adapters/claude/index.ts` +- Modify: `server/adapters/claude/tmux-adapter.ts` +- Modify: `server/adapters/codex/index.ts` +- Modify: `server/adapters/codex/codex-tmux-adapter.ts` + +- [ ] **Step 1: Add pasteBuffer method to TmuxManager** + +Add imports for `writeFileSync`, `unlinkSync` from `fs` and `randomUUID` from `crypto`. Then add after `sendControl()` (~line 48): + +```typescript +async pasteBuffer(windowId: string, content: string): Promise { + const tmpFile = `/tmp/codetap-buf-${randomUUID()}.txt`; + writeFileSync(tmpFile, content); + const target = `${SESSION_NAME}:${windowId}`; + try { + await exec(TMUX, ['load-buffer', tmpFile]); + await exec(TMUX, ['paste-buffer', '-t', target]); + await exec(TMUX, ['send-keys', '-t', target, 'Enter']); + } finally { + try { unlinkSync(tmpFile); } catch {} + } +} +``` + +Note: `exec` here is the existing `promisify(execFile)` wrapper already in the file. Not `child_process.exec`. + +- [ ] **Step 2: Add `pasteToSession()` to IAdapter interface** + +In `server/adapters/interface.ts`, add to the IAdapter class: + +```typescript +async pasteToSession(sessionId: string, content: string): Promise { + throw new Error('Not implemented'); +} +``` + +- [ ] **Step 3: Implement in both adapters** + +In Claude's `tmux-adapter.ts`: + +```typescript +async pasteToSession(sessionId: string, content: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) throw new Error(`Session ${sessionId} not found`); + await tmuxManager.pasteBuffer(session.windowId, content); +} +``` + +In Claude's `index.ts`, delegate: `async pasteToSession(sid: string, content: string) { return this._tmux.pasteToSession(sid, content); }` + +In Codex's `codex-tmux-adapter.ts`, same pattern. In Codex's `index.ts`, delegate. + +This keeps `tmuxManager` as an internal detail. `server/index.ts` only calls `adapter.pasteToSession()`. + +- [ ] **Step 4: Commit** + +```bash +git add server/adapters/claude/tmux-manager.ts server/adapters/interface.ts server/adapters/claude/index.ts server/adapters/claude/tmux-adapter.ts server/adapters/codex/index.ts server/adapters/codex/codex-tmux-adapter.ts +git commit -m "feat: add pasteBuffer to TmuxManager and pasteToSession to IAdapter" +``` + +--- + +### Task 3: Add WS event types + +**Files:** +- Modify: `server/ws-types.ts` +- Modify: `src/lib/ws-types.ts` +- Modify: `server/types/messages.ts` + +- [ ] **Step 1: Add REVIEW_STARTED and REVIEW_ENDED to both ws-types files** + +In both `server/ws-types.ts` and `src/lib/ws-types.ts`, add to the `WS` object: + +```typescript +REVIEW_STARTED: 'review-started', +REVIEW_ENDED: 'review-ended', +``` + +- [ ] **Step 2: Update ServerMessageType** + +In `server/types/messages.ts`, add `| 'review-started' | 'review-ended'` to `ServerMessageType`. + +- [ ] **Step 3: Commit** + +```bash +git add server/ws-types.ts src/lib/ws-types.ts server/types/messages.ts +git commit -m "feat: add REVIEW_STARTED and REVIEW_ENDED WS event types" +``` + +--- + +### Task 4: Add deterministic message IDs to parsers + +**Files:** +- Modify: `server/adapters/claude/transcript-parser.ts` +- Modify: `server/adapters/codex/transcript-parser.ts` +- Modify: `src/hooks/useChat.ts` + +**Critical design note:** IDs must be **deterministic** — the same JSONL entry must produce the same ID every time it's parsed (across reconnects, server restarts, page refreshes). Using `randomUUID()` would break `anchor_message_id` lookups in history. + +**Approach:** Use a monotonic counter per parser instance. Each parser tracks an `_entryIndex` that increments for every message. The ID is `msg-{entryIndex}` (e.g., `msg-0`, `msg-1`, `msg-5`). Since JSONL is append-only, the same entries always produce the same indices. + +- [ ] **Step 1: Add `id` field to Claude's ParsedMessage interface** + +In `server/adapters/claude/transcript-parser.ts` (~line 15), add `id: string` to `ParsedMessage`. + +- [ ] **Step 2: Generate deterministic IDs in Claude parser** + +Add a counter `private _msgIndex = 0` to the `TranscriptParser` class. In `_parseUserEntry()` and `_parseAssistantEntry()`, set `id: \`msg-${this._msgIndex++}\`` on each returned message. Reset counter in constructor or when `parse()` is called fresh for history. + +- [ ] **Step 3: Generate deterministic IDs in Codex parser** + +Same pattern in `server/adapters/codex/transcript-parser.ts`. Add counter, generate `msg-{index}` IDs. The `ChatMessage` type already has `id?: string`. + +- [ ] **Step 4: Thread IDs through useChat** + +In `src/hooks/useChat.ts`: +- Add `id?: string` to the local `ChatMessage` type (~line 14) +- In `convertMessages()` (~line 68), preserve `id` from incoming messages: `{ id: msg.id, role: ..., content: ... }` +- In the `MESSAGE_COMPLETE` handler, preserve `id` when converting messages +- In the `HISTORY_LOAD` handler, preserve `id` when converting messages + +- [ ] **Step 5: Commit** + +```bash +git add server/adapters/claude/transcript-parser.ts server/adapters/codex/transcript-parser.ts src/hooks/useChat.ts +git commit -m "feat: add deterministic message IDs to parsers for stable anchor references" +``` + +--- + +### Task 5: Filter child sessions from API endpoints + +**Files:** +- Modify: `server/index.ts` + +- [ ] **Step 1: Import sessionReviews** + +Add `import { sessionReviews } from './db.js';` at top. + +- [ ] **Step 2: Filter /api/sessions** + +After aggregating sessions from all adapters, filter out children: + +```typescript +const childIds = sessionReviews.getAllChildIds(); +const filtered = allSessions.filter((s: any) => !childIds.has(s.sessionId)); +res.json(filtered); +``` + +- [ ] **Step 3: Filter /api/active-sessions** + +After building `allActiveSessions`, filter: + +```typescript +const childIds = sessionReviews.getAllChildIds(); +const filtered = allActiveSessions.filter((s: any) => !childIds.has(s.cliSessionId)); +res.json(filtered); +``` + +- [ ] **Step 4: Commit** + +```bash +git add server/index.ts +git commit -m "feat: filter child review sessions from session list and active sessions" +``` + +--- + +## Phase 2: Review Session Lifecycle + +### Task 6: Add review API endpoints + +**Files:** +- Modify: `server/index.ts` + +All endpoints use `authMiddleware`, following the existing pattern. + +- [ ] **Step 1: Add POST /api/reviews** + +Creates a child CLI session, saves review to DB, pastes context, returns review metadata. + +Key logic: +- Check for existing active review (`sessionReviews.getActiveForParent`) — return 409 if active +- Look up parent's `cwd` from DB (`dbSessions.findByCliSession`) +- Call `adapter.startSession(cwd, { permissionMode: 'bypassPermissions' })` +- **Codex UUID timing issue:** For Claude, `cliSessionId` is available immediately after `startSession()`. For Codex, it's empty until `SessionStart` hook fires. **Workaround:** Create the `session_reviews` row with the internal session ID as a temporary `child_cli_session_id`. Add a step in Codex's `handleSessionStart` hook to update the review row once the real UUID is known. Add `sessionReviews.updateChildCliId(reviewId, newCliId)` method. +- Paste context via `adapter.pasteToSession(childSessionId, context)` (NOT tmuxManager directly) +- Context truncation: cap at last 50 messages or 30KB, whichever is smaller +- Return `{ reviewId, childSessionId, childCliSessionId, childAdapter }` + +- [ ] **Step 2: Add DELETE /api/reviews/:id** + +Ends review: sets `ended_at`, destroys child tmux window, broadcasts `REVIEW_ENDED`. + +Key logic: +- `sessionReviews.getById(reviewId)` to find the review +- `sessionReviews.endReview(reviewId)` +- Find child adapter via `getAdapter(review.child_adapter)` +- Resolve child CLI UUID to internal ID, call `adapter.destroySession(childSessionId)` +- Broadcast `REVIEW_ENDED` to parent session clients + +- [ ] **Step 3: Add POST /api/reviews/:id/send-back** + +Sends a child message back to parent. + +Key logic: +- Look up review, find parent session via `dbSessions.findByCliSession(review.parent_cli_session_id)` +- Resolve parent internal ID from DB row +- **Guard:** check `adapter.isProcessing(parentInternalId)` — return 409 if busy with toast message +- Format message: `[Review feedback from {childAdapter}]:\n{message}` +- Call `parentAdapter.pasteToSession(parentInternalId, formatted)` (NOT tmuxManager directly) + +- [ ] **Step 4: Add GET /api/reviews** + +Returns reviews for a parent session (for history rendering): + +``` +GET /api/reviews?parentCliSessionId=xxx +``` + +Returns `SessionReviewRow[]` from `sessionReviews.getAllForParent(parentCliSessionId)`. + +- [ ] **Step 5: Add sessionReviews.updateChildCliId() to db.ts** + +For the Codex UUID timing issue: + +```typescript +updateChildCliId(internalId: string, cliId: string): void { + stmts().reviewUpdateChildCliId.run(cliId, internalId); +} +``` + +With prepared statement: `UPDATE session_reviews SET child_cli_session_id = ? WHERE child_cli_session_id = ?` + +- [ ] **Step 6: Commit** + +```bash +git add server/index.ts server/db.ts +git commit -m "feat: add review API endpoints (create, end, send-back, list)" +``` + +--- + +### Task 7: Broadcast review events, reconnect, cascade cleanup, push suppression + +**Files:** +- Modify: `server/session-manager.ts` + +- [ ] **Step 1: Import sessionReviews and add broadcast helpers** + +```typescript +import { sessionReviews } from './db.js'; +``` + +Add `broadcastReviewStarted()` and `broadcastReviewEnded()` helper functions that call the existing `broadcast()` function with `WS.REVIEW_STARTED` / `WS.REVIEW_ENDED` payloads. + +Export them so `server/index.ts` can call them from review API endpoints. + +- [ ] **Step 2: Add child session restore to handleReconnect** + +After existing reconnect logic, query `sessionReviews.getActiveForParent(cliSessionId)`. For each active child: +- Resolve child CLI UUID to internal ID +- If session not managed: call `resumeSession` to recreate tmux window +- Send `REVIEW_STARTED` event to the reconnecting client + +- [ ] **Step 3: Add cascade cleanup on parent session destruction** + +In `setupSessionManager()`, inside the `session-ended` event handler for each adapter, add: + +```typescript +adapter.on('session-ended', (sessionId: string) => { + // existing: broadcast SESSION_ENDED, clean up maps + + // NEW: cascade-end any active child reviews + const session = adapter.getSession(sessionId) as { cliSessionId?: string } | null; + const parentCliId = session?.cliSessionId; + if (parentCliId) { + const activeChildren = sessionReviews.getActiveForParent(parentCliId); + for (const child of activeChildren) { + sessionReviews.endReview(child.id); + const childAdapter = getAdapter(child.child_adapter); + if (childAdapter) { + const childInternalId = /* resolve from child_cli_session_id */; + childAdapter.destroySession(childInternalId).catch(() => {}); + } + broadcast(sessionId, { type: WS.REVIEW_ENDED, reviewId: child.id }); + } + } +}); +``` + +- [ ] **Step 4: Suppress push notifications for child sessions** + +In the `triggerPush()` function at the top of `session-manager.ts`, add a guard: + +```typescript +function triggerPush(adapter: IAdapter, sessionId: string, opts: PushOptions): void { + // existing: skip if clients connected + + // NEW: skip push for child review sessions + const session = adapter.getSession(sessionId) as { cliSessionId?: string } | null; + if (session?.cliSessionId && sessionReviews.getAllChildIds().has(session.cliSessionId)) return; + + // existing push logic... +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add server/session-manager.ts +git commit -m "feat: review events, reconnect restore, cascade cleanup, push suppression" +``` + +--- + +## Phase 3: Frontend UI + +### Task 8: Add review API methods and state to frontend + +**Files:** +- Modify: `src/lib/api.ts` +- Modify: `src/hooks/useChat.ts` + +- [ ] **Step 1: Add review API methods to api.ts** + +Add `createReview`, `endReview`, `sendBackToParent`, `getReviews` methods. + +- [ ] **Step 2: Add review state to useChat** + +Add `activeReview` state (object with reviewId, childSessionId, childAdapter, etc.) and `reviewPanelState` ('expanded'|'minimized'|'hidden'). + +- [ ] **Step 3: Handle REVIEW_STARTED and REVIEW_ENDED in WS handler** + +In the `handleWsMessage` switch, add cases for `WS.REVIEW_STARTED` (set activeReview + expand panel) and `WS.REVIEW_ENDED` (clear activeReview + hide panel). + +- [ ] **Step 4: Remove old crossAdapterFlow state, methods, and imports** + +Remove from useChat.ts: +- `import { getQuickCommand } from '../lib/quick-commands'` (line 5) — this file will be deleted in Task 13 +- `CrossAdapterFlowState` type and export +- `crossAdapterFlow` state and `crossAdapterFlowRef` +- `startCrossAdapterFlow` and `completeCrossAdapterFlow` callbacks +- The `crossAdapterFlow` check in `TURN_COMPLETE` handler +- All related entries in the return statement + +**Note:** Task 13 Step 6 deletes `quick-commands.ts`. If this import isn't removed first, the build breaks. + +- [ ] **Step 5: Export new review state and actions** + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/api.ts src/hooks/useChat.ts +git commit -m "feat: add review API methods and state to useChat, remove crossAdapterFlow" +``` + +--- + +### Task 9: Add action buttons to MessageBubble + +**Files:** +- Modify: `src/components/MessageBubble.tsx` + +- [ ] **Step 1: Add props for messageId, showActions, otherAdapterName, onSendTo** + +- [ ] **Step 2: Render Copy and "Send to [Adapter]" buttons after assistant message content** + +Only show when `showActions && !isStreaming && otherAdapterName` is truthy. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/MessageBubble.tsx +git commit -m "feat: add Copy and Send-to action buttons to MessageBubble" +``` + +--- + +### Task 10: Create ReviewActionMenu component + +**Files:** +- Create: `src/components/ReviewActionMenu.tsx` + +- [ ] **Step 1: Create component** + +Modal overlay with 4 options: Direct send, Code Review, Suggest alternatives, Custom instruction. +Custom shows an inline text input. +Props: `visible`, `adapterName`, `onSelect(templateId, customPrompt?)`, `onClose`. + +- [ ] **Step 2: Commit** + +```bash +git add src/components/ReviewActionMenu.tsx +git commit -m "feat: create ReviewActionMenu for prompt template selection" +``` + +--- + +### Task 11: Create FloatingReviewPanel component + +**Files:** +- Create: `src/components/FloatingReviewPanel.tsx` + +- [ ] **Step 1: Create component** + +Key details: +- Uses its own `useChat(childSessionId, undefined, childAdapter)` hook instance +- Three states: expanded (55% height), minimized (pill button), hidden +- Shows child session messages with MessageBubble (including "Send to [Parent]" buttons) +- Has ShimmerInput for user input to child session +- Header shows adapter brand color, review title, End button +- End button calls `onEnd` callback + +- [ ] **Step 2: Commit** + +```bash +git add src/components/FloatingReviewPanel.tsx +git commit -m "feat: create FloatingReviewPanel with independent useChat" +``` + +--- + +### Task 12: Create CollapsedReviewCard and BlockMarker + +**Files:** +- Create: `src/components/CollapsedReviewCard.tsx` +- Create: `src/components/BlockMarker.tsx` + +- [ ] **Step 1: Create BlockMarker** + +Simple divider line with a centered label pill. Props: `label`, `color`. + +- [ ] **Step 2: Create CollapsedReviewCard** + +Card showing adapter name, title, message count, summary. Props: `adapter`, `title`, `messageCount`, `summary`, `onClick`. Uses adapter brand colors. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/CollapsedReviewCard.tsx src/components/BlockMarker.tsx +git commit -m "feat: create CollapsedReviewCard and BlockMarker components" +``` + +--- + +### Task 13: Integrate into ChatView + remove old components + +**Files:** +- Modify: `src/components/ChatView.tsx` +- Delete: `src/components/QuickActionCards.tsx` +- Delete: `src/components/CrossAdapterFlow.tsx` +- Delete: `src/lib/quick-commands.ts` + +- [ ] **Step 1: Remove old imports and JSX** + +Remove `QuickActionCards`, `CrossAdapterFlow` imports and their JSX. Remove `crossAdapterFlow` related destructuring from useChat. + +- [ ] **Step 2: Add new imports** + +Import `FloatingReviewPanel`, `ReviewActionMenu`, `CollapsedReviewCard`, `BlockMarker`. + +- [ ] **Step 3: Fetch review history on mount** + +On mount (and on `cliSessionId` change), fetch reviews for this session: + +```typescript +const [reviews, setReviews] = useState([]); + +useEffect(() => { + if (!cliSessionId) return; + api.getReviews(cliSessionId).then(setReviews).catch(() => {}); +}, [cliSessionId]); +``` + +Build a lookup map for rendering: + +```typescript +const reviewsByAnchor = useMemo(() => { + const map = new Map(); + for (const r of reviews) { + if (r.anchor_message_id) map.set(r.anchor_message_id, r); + } + return map; +}, [reviews]); +``` + +- [ ] **Step 4: Add review trigger logic** + +Add `reviewMenuMessageId` state, `handleSendTo` callback (opens menu for a message), `handleReviewSelect` callback (calls `api.createReview` with context built from messages). + +Context building: slice messages up to anchor, format as text with 50-message / 30KB cap. Append highlighted message and prompt template. + +- [ ] **Step 5: Render messages with block markers and review cards** + +In the `messages.map()` loop, after rendering each message, check if it's an anchor: + +```tsx +{messages.map((msg, i) => ( + + + {msg.id && reviewsByAnchor.has(msg.id) && (() => { + const review = reviewsByAnchor.get(msg.id)!; + return ( + <> + + { /* open read-only panel */ }} + /> + {review.ended_at && ( + + )} + + ); + })()} + +))} +``` + +- [ ] **Step 6: Add new components to JSX (FloatingReviewPanel + ReviewActionMenu)** + +Replace old `QuickActionCards` / `CrossAdapterFlow` with `FloatingReviewPanel` (conditional on `activeReview`) and `ReviewActionMenu` (conditional on `reviewMenuMessageId`). + +- [ ] **Step 7: Pass action props to MessageBubble** + +Add `messageId`, `showActions`, `otherAdapterName`, `onSendTo` props. Only show actions when `availableAdapters.length > 1`. + +- [ ] **Step 8: Delete old files** + +```bash +rm src/components/QuickActionCards.tsx src/components/CrossAdapterFlow.tsx src/lib/quick-commands.ts +``` + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "feat: integrate Cross-AI Review into ChatView, remove old QuickActionCards" +``` + +--- + +## Edge Cases Handled in Plan + +The following edge cases were identified and addressed across the tasks above: + +### Session State Edge Cases +- **Active review should NOT show CollapsedReviewCard** (Task 13 Step 5): When rendering, check `review.ended_at` — only show collapsed card for ended reviews. Active reviews are shown via the floating panel, not inline. +- **Multiple reviews on same anchor message** (Task 13 Step 3): Use `Map` (array), not `Map`, to support multiple reviews anchored to the same message. +- **CollapsedReviewCard message count** (Task 1): Add `message_count INTEGER DEFAULT 0` column to `session_reviews`. Set it when the review ends (`endReview` method also stores the count). Avoids needing to read child JSONL at render time. + +### User Action Edge Cases +- **409 when review already active** (Task 13 Step 4): `handleReviewSelect` should catch 409 from `api.createReview()`, show a confirmation dialog "End current review and start new one?", and if confirmed, call `endReview` then retry. +- **"Send to Parent" while parent is busy** (Task 11): Pass `parentStreaming` state as a prop to `FloatingReviewPanel`. Disable the "Send to Parent" button when `parentStreaming` is true. The parent's `useChat` `streaming` state is available in ChatView and can be passed down. +- **Input focus confusion** (Task 11): Use distinct placeholder text ("Message Claude..." vs "Message Codex reviewer...") and border colors on the two input fields to prevent accidentally typing in the wrong one. + +### Connection/Lifecycle Edge Cases +- **Stale review (server restarted, review never ended)** (Task 7 Step 2): During reconnect, if the child CLI UUID cannot be resolved AND no tmux window exists, mark the review as ended (`sessionReviews.endReview(review.id)`) instead of trying to resume. Show it as a collapsed card. +- **Parent tmux crashes → cascade cleanup timing** (Task 7 Step 3): The `session-ended` event fires AFTER `sessions.delete()`, so `adapter.getSession()` returns null. Save `cliSessionId` BEFORE the session is deleted by looking it up in DB (`dbSessions.findByCliSession`) as a fallback. +- **Codex UUID window** (Task 6 Step 5): Between `startSession()` and `handleSessionStart` hook, the child CLI UUID is unknown. During this brief window (~1-3 seconds), the child session may appear in session list. Accept this as a known limitation for v1 — the cleanup interval will correct it. +- **Child tmux crashes during review** (Task 7 Step 3): Add the same `session-ended` cascade handler for child sessions — when a child session ends unexpectedly, mark the review as ended and broadcast `REVIEW_ENDED`. + +### History Edge Cases +- **Keep reviews state in sync via WS** (Task 8 Step 3): On `REVIEW_STARTED`, append to local `reviews` state array. On `REVIEW_ENDED`, update the matching review's `ended_at`. Avoid re-fetching from API on every event. +- **Anchor message compacted away** (Task 13 Step 5): If the anchor message ID is not found in the rendered messages, render the review card at the END of the message list as a fallback (with a note "Original message no longer available"). + +### Multi-Client Edge Cases +- **Two tabs open** (Task 7 Step 1): `broadcastReviewStarted` and `broadcastReviewEnded` are broadcast to ALL clients on the parent session. Both tabs receive the events and update independently. Tab A gets the API response directly; Tab B gets the WS event. Both converge. + +--- + +## Verification + +After all tasks: + +1. `CLAUDE_UI_PASSWORD=test npm run dev` +2. Open `http://localhost:5173` +3. **Open a Claude session** -- assistant messages show "Copy" and "Send to Codex" buttons +4. **Tap "Send to Codex"** -- ReviewActionMenu appears with template options +5. **Select "Code Review"** -- floating panel opens, Codex session starts, context pasted +6. **Chat with Codex** in floating panel -- messages appear with streaming +7. **Tap "Send to Claude"** on a Codex response -- content injected into Claude's tmux +8. **Minimize panel** -- pill button appears, tap to re-expand +9. **End review** -- panel disappears, tmux window killed +10. **Session list** -- child session NOT visible in project sessions or active sessions +11. **Reconnect** -- refresh page, active review panel restores +12. **Scroll history** -- block markers and collapsed review card visible at anchor position diff --git a/docs/superpowers/plans/2026-03-23-insight-block.md b/docs/superpowers/plans/2026-03-23-insight-block.md new file mode 100644 index 0000000..b3acec0 --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-insight-block.md @@ -0,0 +1,294 @@ +# InsightBlock Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Render Claude Code's Insight blocks as collapsible cards instead of ugly inline code elements. + +**Architecture:** Frontend-only text transform. A generic segment splitter in `src/lib/` accepts adapter-scoped regex patterns. Claude-specific patterns and UI live in `src/components/adapters/claude/`. MessageBubble splits text into segments and renders InsightBlocks for matched segments. No server changes. + +**Tech Stack:** React, ReactMarkdown, TypeScript, Tailwind CSS, lucide-react icons + +--- + +### Task 1: Generic Text Segment Splitter + +**Files:** +- Create: `src/lib/text-transforms.ts` + +- [ ] **Step 1: Create the text-transforms module** + +```typescript +// src/lib/text-transforms.ts + +export interface TextPattern { + type: string; + regex: RegExp; +} + +export interface TextSegment { + type: string; + text: string; +} + +/** + * Split text into typed segments based on regex patterns. + * Unmatched regions become { type: 'markdown' } segments. + * Fast path: returns single markdown segment when no patterns match. + */ +export function splitTextSegments(text: string, patterns: TextPattern[]): TextSegment[] { + if (!text || patterns.length === 0) return [{ type: 'markdown', text }]; + + // Collect all matches from all patterns with their positions + const matches: { type: string; start: number; end: number; captured: string }[] = []; + for (const pattern of patterns) { + const re = new RegExp(pattern.regex.source, pattern.regex.flags); + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + matches.push({ + type: pattern.type, + start: m.index, + end: m.index + m[0].length, + captured: m[1] ?? m[0], + }); + } + } + + if (matches.length === 0) return [{ type: 'markdown', text }]; + + matches.sort((a, b) => a.start - b.start); + const segments: TextSegment[] = []; + let cursor = 0; + + for (const match of matches) { + if (match.start < cursor) continue; + if (match.start > cursor) { + const before = text.slice(cursor, match.start).trim(); + if (before) segments.push({ type: 'markdown', text: before }); + } + segments.push({ type: match.type, text: match.captured.trim() }); + cursor = match.end; + } + + if (cursor < text.length) { + const after = text.slice(cursor).trim(); + if (after) segments.push({ type: 'markdown', text: after }); + } + + return segments; +} +``` + +- [ ] **Step 2: Verify build passes** + +- [ ] **Step 3: Commit** + +--- + +### Task 2: Claude Adapter Patterns + +**Files:** +- Create: `src/components/adapters/claude/patterns.ts` + +- [ ] **Step 1: Create Claude patterns module** + +```typescript +// src/components/adapters/claude/patterns.ts +import type { TextPattern } from '@/lib/text-transforms'; + +/** + * Claude Code text patterns for special content rendering. + * + * Insight format: + * `★ Insight ─────────────────────────────────────` + * [content lines] + * `─────────────────────────────────────────────────` + */ +export const CLAUDE_PATTERNS: TextPattern[] = [ + { + type: 'insight', + regex: /`[★✦]?\s*Insight\s*[─\-]+`\n([\s\S]*?)\n`[─\-]+[.。]?`/g, + }, +]; +``` + +- [ ] **Step 2: Verify build passes** + +- [ ] **Step 3: Commit** + +--- + +### Task 3: InsightBlock Collapsible Component + +**Files:** +- Create: `src/components/adapters/claude/InsightBlock.tsx` + +**Reference:** Follow `src/components/ToolCallCard.tsx` expand/collapse pattern (useState, ChevronDown/Up icons). + +- [ ] **Step 1: Create InsightBlock component** + +```tsx +// src/components/adapters/claude/InsightBlock.tsx +import { useState } from 'react'; +import { ChevronDown, ChevronUp } from 'lucide-react'; +import ReactMarkdown from 'react-markdown'; +import { cn } from '@/lib/utils'; + +export function InsightBlock({ text }: { text: string }) { + const [expanded, setExpanded] = useState(false); + const summary = text.split('\n').find(l => l.trim())?.trim() || 'Insight'; + const truncated = summary.length > 80 ? summary.slice(0, 80) + '...' : summary; + + return ( +
+ + {expanded && ( +
+ {text} +
+ )} +
+ ); +} +``` + +- [ ] **Step 2: Verify build passes** + +- [ ] **Step 3: Commit** + +--- + +### Task 4: Integrate into MessageBubble + +**Files:** +- Modify: `src/components/MessageBubble.tsx` + +- [ ] **Step 1: Add imports and segment splitting** + +Add imports at top of file: +```typescript +import { splitTextSegments } from '@/lib/text-transforms'; +import { CLAUDE_PATTERNS } from './adapters/claude/patterns'; +import { InsightBlock } from './adapters/claude/InsightBlock'; +``` + +In the assistant message render block, replace lines 64-66: + +Before: +```tsx + + {textContent} + +``` + +After: +```tsx +{(() => { + const segments = splitTextSegments(textContent, CLAUDE_PATTERNS); + return segments.map((seg, i) => + seg.type === 'insight' + ? + : {seg.text} + ); +})()} +``` + +- [ ] **Step 2: Verify build passes** + +- [ ] **Step 3: Manual verification** + +1. Start server: `CLAUDE_UI_PASSWORD=test npx tsx server/index.ts` +2. Open app, find or create a session with an Insight block +3. Verify: collapsed card with ★ label, expand/collapse works, surrounding markdown intact, messages without insights unaffected + +- [ ] **Step 4: Commit** + +--- + +### Task 5: E2E Test Specs + +**Files:** +- Modify: `tests/e2e-spec.feature` +- Modify: `tests/e2e-progress.md` + +- [ ] **Step 1: Add E2E scenarios to e2e-spec.feature** + +Append to the end of the file: + +```gherkin +# ============================================================================= +# Feature: Insight Block Rendering +# ============================================================================= + +Feature: Insight Block Display + + Scenario: Insight block renders as collapsible card + Given I have an active chat session with an Insight block in the response + Then the Insight block shows as a collapsed card + And the card shows "★ Insight" label with a summary + And a chevron icon is visible + + Scenario: Insight block expands on tap + Given I see a collapsed Insight card + When I tap the Insight card + Then the card expands to show full markdown content + And the chevron changes to up arrow + + Scenario: Insight block collapses on second tap + Given I see an expanded Insight card + When I tap the Insight card again + Then the card collapses back to summary view + + Scenario: Multiple Insight blocks in one message + Given I have a response with two Insight blocks separated by text + Then both render as separate collapsible cards + And the text between them renders as normal markdown + + Scenario: Message without Insight blocks renders normally + Given I have a response with no Insight delimiters + Then the message renders as plain markdown + + Scenario: Insight block in reconnected session history + Given I reconnect to a session that had Insight blocks + Then the Insight blocks render correctly as collapsible cards +``` + +- [ ] **Step 2: Add progress entries to e2e-progress.md** + +Add at end of Progress section: + +```markdown +### Feature 54: Insight Block Display — NOT STARTED (0/6) +Scenarios: +- [ ] Insight block renders as collapsible card +- [ ] Insight block expands on tap +- [ ] Insight block collapses on second tap +- [ ] Multiple Insight blocks in one message +- [ ] Message without Insight blocks renders normally +- [ ] Insight block in reconnected session history +``` + +- [ ] **Step 3: Commit** diff --git a/docs/superpowers/plans/2026-03-23-session-id-unification.md b/docs/superpowers/plans/2026-03-23-session-id-unification.md new file mode 100644 index 0000000..ff46744 --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-session-id-unification.md @@ -0,0 +1,563 @@ +# Session ID Unification — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Unify session ID management across all adapters — single storage (SQLite), adapter-prefixed internal IDs, CLI UUID in chat header, and real-time session discovery via API-based SessionStart hook. + +**Architecture:** Bottom-up: DB schema migration → server adapter changes (Claude, Codex) → session-manager protocol update → client UI → CLI script → E2E spec updates. Each task produces a committable, non-breaking state. + +**Tech Stack:** TypeScript, SQLite (better-sqlite3), React, Bash (CLI), Gherkin (E2E specs) + +**Spec:** `docs/superpowers/specs/2026-03-23-session-id-unification-design.md` + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|----------------| +| `server/db.ts` | Modify | Schema migration, rename columns, add `clearAll()`, remove session-map migration | +| `server/config.ts` | Modify | Remove `sessionMap` path | +| `server/index.ts` | Modify | Call `clearAll()` on shutdown | +| `server/adapters/interface.ts` | Modify | Add `adapter`, rename `claudeSessionId` → `cliSessionId` in `ActiveSessionInfo` | +| `server/adapters/claude/hook-config.ts` | Modify | SessionStart → `fireAndForget` | +| `server/adapters/claude/index.ts` | Modify | Add `session-start` hook route | +| `server/adapters/claude/tmux-adapter.ts` | Modify | `claude-` prefix, remove `desktop-`, add `handleSessionStart`, update `resolveSessionId` recovery | +| `server/adapters/codex/codex-tmux-adapter.ts` | Modify | `codex-` prefix, remove `desktop-`, align with Claude pattern | +| `server/adapters/codex/index.ts` | Verify | Ensure `session-start` hook route exists | +| `server/session-manager.ts` | Modify | `SESSION_CREATED` includes `cliSessionId` | +| `src/hooks/useChat.ts` | Modify | Store `cliSessionId` from `SESSION_CREATED` | +| `src/components/ChatView.tsx` | Modify | Header shows CLI UUID (primary) + internal ID (secondary) | +| `bin/codetap` | Modify | `--adapter` flag, window naming, resume/continue logic, `-a`/`-A` display | +| `bin/codetap-hook` | Delete | Replaced by API POST | +| `tests/e2e-spec.feature` | Modify | 9 scenario updates for new session ID architecture | + +--- + +### Task 1: DB Schema Migration + +**Files:** +- Modify: `server/db.ts:19-29` (CREATE TABLE), `server/db.ts:105-130` (prepared statements), `server/db.ts:252-287` (operations) + +- [ ] **Step 1: Update CREATE TABLE for fresh installs (line 19-29)** + +Change `claude_session` → `cli_session`, add `adapter`, remove `is_active`: + +```sql +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + cli_session TEXT NOT NULL, + adapter TEXT DEFAULT 'claude', + cwd TEXT NOT NULL, + window_id TEXT, + permission_mode TEXT DEFAULT 'default', + created_at TEXT DEFAULT (datetime('now')), + last_activity TEXT DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_sessions_cli ON sessions(cli_session); +CREATE INDEX IF NOT EXISTS idx_sessions_adapter ON sessions(adapter); +``` + +- [ ] **Step 2: Add migration logic after CREATE TABLE block** + +After line 59, add migration for existing databases: + +```typescript +try { + const tableInfo = d.prepare("PRAGMA table_info('sessions')").all() as { name: string }[]; + const hasOldColumn = tableInfo.some(c => c.name === 'claude_session'); + const hasNewColumn = tableInfo.some(c => c.name === 'cli_session'); + const hasAdapter = tableInfo.some(c => c.name === 'adapter'); + + if (hasOldColumn && !hasNewColumn) { + d.exec(`ALTER TABLE sessions RENAME COLUMN claude_session TO cli_session`); + console.log('[db] Migrated: claude_session → cli_session'); + } + if (!hasAdapter) { + d.exec(`ALTER TABLE sessions ADD COLUMN adapter TEXT DEFAULT 'claude'`); + d.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_adapter ON sessions(adapter)`); + console.log('[db] Migrated: added adapter column'); + } +} catch (e) { + console.warn('[db] Migration check:', (e as Error).message); +} +``` + +- [ ] **Step 3: Update `SessionRow` interface (line 252-261)** + +```typescript +export interface SessionRow { + id: string; + cli_session: string; + adapter: string; + cwd: string; + window_id: string | null; + permission_mode: string; + created_at: string; + last_activity: string; +} +``` + +- [ ] **Step 4: Update prepared statements (lines 105-130)** + +All SQL: `claude_session` → `cli_session`, remove `is_active` references. Add `adapter` to upsert. Rename `sessionsFindByClaudeSession` → `sessionsFindByCliSession`. Change `sessionsRemove` from `UPDATE SET is_active=0` to `DELETE`. Remove `sessionsCleanupStale`. + +- [ ] **Step 5: Update `sessions` operations object (line 263-287)** + +```typescript +export const sessions = { + upsert(id: string, cliSession: string, cwd: string, windowId?: string, adapter?: string): void { + stmts().sessionsUpsert.run(id, cliSession, cwd, windowId ?? null, adapter ?? 'claude'); + }, + findByCliSession(cliSession: string): SessionRow | undefined { + return stmts().sessionsFindByCliSession.get(cliSession) as SessionRow | undefined; + }, + findByWindowId(windowId: string): SessionRow | undefined { + return stmts().sessionsFindByWindowId.get(windowId) as SessionRow | undefined; + }, + remove(id: string): void { stmts().sessionsRemove.run(id); }, + getAll(): SessionRow[] { return stmts().sessionsGetAll.all() as SessionRow[]; }, + clearAll(): void { getDB().exec('DELETE FROM sessions'); }, +}; +``` + +- [ ] **Step 6: Remove session-map.json migration (lines 196-219)** + +Delete the session-map section of `migrateJsonToSqlite`. Keep the push-subscriptions migration. Remove `SessionMapJsonEntry` interface. + +- [ ] **Step 7: Commit** + +```bash +git add server/db.ts +git commit -m "refactor: migrate session DB schema — cli_session, adapter column, remove is_active" +``` + +--- + +### Task 2: Remove `sessionMap` from config + add `clearAll()` to shutdown + +**Files:** +- Modify: `server/config.ts:18,58` +- Modify: `server/index.ts:245-253` + +- [ ] **Step 1: Remove `sessionMap` from config** + +In `AppConfig.paths` (line 18), remove `sessionMap: string;`. +In `loadConfig()` (line 58), remove `sessionMap: path.join(CODETAP_DIR, 'session-map.json'),`. + +- [ ] **Step 2: Add `sessions.clearAll()` to shutdown** + +In `shutdown()` (line 245-253), before `closeDB()`: + +```typescript +import { sessions as dbSessions } from './db.js'; +// ... +dbSessions.clearAll(); +closeDB(); +``` + +- [ ] **Step 3: Commit** + +```bash +git add server/config.ts server/index.ts +git commit -m "refactor: remove sessionMap config, clear sessions on shutdown" +``` + +--- + +### Task 3: Update `ActiveSessionInfo` — rename `claudeSessionId` → `cliSessionId` + +**Files:** +- Modify: `server/adapters/interface.ts:18-28` +- Modify: all files referencing `claudeSessionId` + +- [ ] **Step 1: Update interface** + +In `ActiveSessionInfo` (line 18-28), rename `claudeSessionId` → `cliSessionId`, add `adapter`: + +```typescript +export interface ActiveSessionInfo { + sessionId: string; + cwd: string; + cliSessionId: string; + adapter: string; + permissionMode: string; + lastActivity: number | null; + hasClients: boolean; + hasDesktop: boolean; + isNonInteractive: boolean; + firstPrompt: string | null; +} +``` + +- [ ] **Step 2: Find and fix all `claudeSessionId` references** + +```bash +grep -rn 'claudeSessionId' server/ src/ --include='*.ts' --include='*.tsx' +``` + +Replace `claudeSessionId` → `cliSessionId` in ALL files: +- `server/index.ts` (active-sessions endpoint) +- `server/session-manager.ts` (push notifications, pending sessions) +- `server/adapters/claude/tmux-adapter.ts` (`SessionState` interface field, `getActiveSessions`, `_createSession`, all usages) +- `server/adapters/codex/codex-tmux-adapter.ts` (same: `SessionState` field → rename to `cliSessionId`) +- `src/hooks/useSessions.ts` (activeSessionIds set) +- `src/components/SessionsView.tsx` (pending badge) + +Note: The `SessionState` interfaces in both adapter files have a `claudeSessionId` / `codexSessionId` field that stores the CLI UUID. Rename both to `cliSessionId` for consistency across adapters. + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "refactor: rename claudeSessionId → cliSessionId across codebase" +``` + +--- + +### Task 4: Claude adapter — SessionStart hook + internal ID format + +**Files:** +- Modify: `server/adapters/claude/hook-config.ts:174,197` +- Modify: `server/adapters/claude/index.ts:113-147` +- Modify: `server/adapters/claude/tmux-adapter.ts:130,858` + +- [ ] **Step 1: SessionStart hook → `fireAndForget` (hook-config.ts)** + +Line 197: change `hookPath` to `fireAndForget('session-start')`. +Remove `hookPath` from `_hookIdentifiers()` (line 174). Update `_isOurHookEntry` to only check `portTag`. + +- [ ] **Step 2: Add `session-start` route (index.ts)** + +After line 147, add: +```typescript +hookRoute(`${prefix}/session-start`, (body) => { + this._tmux.handleSessionStart(body); +}); +``` + +- [ ] **Step 3: Add `handleSessionStart` method (tmux-adapter.ts)** + +New method. Algorithm: + +```typescript +async handleSessionStart(body: HookBody): Promise { + const cliUuid = body.session_id; + if (!cliUuid) return; + + // 1. Already known? (idempotent — safe if hook fires twice) + const cached = this.claudeToSessionId.get(cliUuid); + if (cached && this.sessions.has(cached)) { + this.sessions.get(cached)!.lastActivity = Date.now(); + return; + } + + const windows = await tmuxManager.listWindows(); + const cwd = body.cwd || process.cwd(); + + // 2. Recovery: check DB for original internal ID (non-graceful restart) + const dbRow = dbSessions.findByCliSession(cliUuid); + if (dbRow?.window_id && windows.some(w => w.id === dbRow.window_id)) { + const sessionId = dbRow.id; // Restore ORIGINAL internal ID + if (!this.sessions.has(sessionId)) { + this.sessions.set(sessionId, this._createSession(dbRow.window_id, cwd, cliUuid, dbRow.permission_mode || 'default')); + this._startMonitor(sessionId, dbRow.window_id); + this._ensureWatcher(sessionId); + } + this.claudeToSessionId.set(cliUuid, sessionId); + return; + } + + // 3. New session: find unmanaged tmux window with claude- prefix + // The hook body doesn't contain the window name, but the tmux window + // was created by bin/codetap with name "claude-{timestamp}". + // We find the first claude-* window that isn't already managed. + for (const w of windows) { + if (w.name.startsWith('claude-') && !this.sessions.has(w.name)) { + const alreadyManaged = [...this.sessions.values()].some(s => s.windowId === w.id); + if (!alreadyManaged) { + const sessionId = w.name; + this.sessions.set(sessionId, this._createSession(w.id, cwd, cliUuid, 'default')); + this.claudeToSessionId.set(cliUuid, sessionId); + dbSessions.upsert(sessionId, cliUuid, cwd, w.id, 'claude'); + this._startMonitor(sessionId, w.id); + this._ensureWatcher(sessionId); + this.emit('session-discovered', sessionId); + return; + } + } + } +} +``` + +- [ ] **Step 4: Change `startSession` ID format (tmux-adapter.ts:130)** + +```typescript +const windowName = `claude-${Date.now()}`; +``` + +Update `dbSessions.upsert` to pass `'claude'` as adapter. + +- [ ] **Step 5: Update `resolveSessionId` — remove `desktop-` prefix entirely (tmux-adapter.ts:858)** + +The `desktop-` prefix logic is no longer needed. Change line 858 from: +```typescript +const sessionId = `desktop-${claudeSessionId.slice(0, 8)}`; +``` +to: +```typescript +const sessionId = dbRow.id; // Restore original internal ID from DB (e.g., claude-1774210269126) +``` + +The DB row's `id` field will now always be in `{adapter}-{timestamp}` format. No new ID is generated — we reuse what was stored. + +- [ ] **Step 6: Rename `findByClaudeSession` → `findByCliSession` in all calls** + +- [ ] **Step 7: Update `getActiveSessions` — add `adapter: 'claude'`, rename field** + +- [ ] **Step 8: Commit** + +```bash +git add server/adapters/claude/ +git commit -m "feat: Claude adapter — session-start API hook, claude- prefix, remove desktop-" +``` + +--- + +### Task 5: Codex adapter — align with unified schema + +**Files:** +- Modify: `server/adapters/codex/codex-tmux-adapter.ts:121,242` +- Modify: `server/adapters/codex/index.ts` + +- [ ] **Step 1: Change `startSession` ID to `codex-` prefix (line 121)** + +- [ ] **Step 2: Remove `desktop-` in `handleSessionStart` (line 242) — use DB original ID** + +- [ ] **Step 3: Update `getActiveSessions` — add `adapter: 'codex'`, rename field** + +- [ ] **Step 4: Rename `findByClaudeSession` → `findByCliSession` in all calls** + +- [ ] **Step 5: Commit** + +```bash +git add server/adapters/codex/ +git commit -m "feat: Codex adapter — codex- prefix, remove desktop-, align with unified schema" +``` + +--- + +### Task 6: `SESSION_CREATED` includes `cliSessionId` + +**Files:** +- Modify: `server/session-manager.ts:198,266` + +- [ ] **Step 1: Update `handleQuery` SESSION_CREATED (line 198)** + +```typescript +// After Task 3 rename, SessionState.claudeSessionId → cliSessionId +const sessionObj = adapter.getSession(handle.sessionId) as { cliSessionId?: string } | null; +send(conn, { + type: WS.SESSION_CREATED, + sessionId: handle.sessionId, + cliSessionId: sessionObj?.cliSessionId || handle.sessionId, +}); +``` + +- [ ] **Step 2: Update `handleReconnect` SESSION_CREATED (line 266)** + +Same pattern — cast `getSession()` result and read `cliSessionId`. + +- [ ] **Step 3: Commit** + +```bash +git add server/session-manager.ts +git commit -m "feat: SESSION_CREATED includes cliSessionId for chat header" +``` + +--- + +### Task 7: Client — store `cliSessionId` + update chat header + +**Files:** +- Modify: `src/hooks/useChat.ts:95,143` +- Modify: `src/components/ChatView.tsx:54-88,230` + +- [ ] **Step 1: Add `cliSessionId` state in useChat (line 95)** + +```typescript +const [cliSessionId, setCliSessionId] = useState(null); +``` + +Update SESSION_CREATED handler (line 143): +```typescript +case WS.SESSION_CREATED: + setSessionId(msg.sessionId); + if (msg.cliSessionId) setCliSessionId(msg.cliSessionId); + break; +``` + +Add `cliSessionId` to the returned object. + +- [ ] **Step 2: Update ChatHeader component (ChatView.tsx:54-88)** + +Accept `cliSessionId` prop. Display CLI UUID as primary (truncated, with copy), internal ID as secondary line below. + +- [ ] **Step 3: Update ChatHeader usage (ChatView.tsx:230)** + +```tsx + +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/hooks/useChat.ts src/components/ChatView.tsx +git commit -m "feat: chat header shows CLI UUID (primary) + internal ID (secondary)" +``` + +--- + +### Task 8: CLI — `--adapter` flag + window naming + enhanced display + +**Files:** +- Modify: `bin/codetap` +- Delete: `bin/codetap-hook` + +- [ ] **Step 1: Add `--adapter` flag parsing** + +Insert before the resume mode section (around line 304). This parses `--adapter` from anywhere in the args: + +```bash +# --- Parse --adapter flag --- +ADAPTER="claude" +ADAPTER_CMD="claude" +prev_arg="" +for arg in "$@"; do + if [ "$prev_arg" = "--adapter" ]; then + case "$arg" in + claude) ADAPTER="claude"; ADAPTER_CMD="claude" ;; + codex) ADAPTER="codex"; ADAPTER_CMD="codex" ;; + *) echo "Unknown adapter: $arg"; exit 1 ;; + esac + fi + prev_arg="$arg" +done +# Strip --adapter and its value from positional args +CLEANED_ARGS=() +skip_next=false +for arg in "$@"; do + if $skip_next; then skip_next=false; continue; fi + if [ "$arg" = "--adapter" ]; then skip_next=true; continue; fi + CLEANED_ARGS+=("$arg") +done +set -- "${CLEANED_ARGS[@]}" +``` + +- [ ] **Step 2: Update window naming and commands** + +Replace the resume/continue/new block (lines 305-316): + +```bash +if [ "$1" = "--resume" ] && [ -n "$2" ]; then + WINDOW_NAME="$2" + COMMAND="$ADAPTER_CMD $YOLO --resume $2" + shift 2 +elif [ "$1" = "--continue" ]; then + WINDOW_NAME="${ADAPTER}-$(date +%s)" + case "$ADAPTER" in + claude) COMMAND="$ADAPTER_CMD $YOLO --continue" ;; + codex) COMMAND="$ADAPTER_CMD resume --last" ;; + *) COMMAND="$ADAPTER_CMD --continue" ;; + esac + shift +else + WINDOW_NAME="${ADAPTER}-$(date +%s)" + COMMAND="$ADAPTER_CMD $YOLO $*" +fi +``` + +- [ ] **Step 3: Enhance `-a`/`-A` display** + +Update the session listing loop to query the server API for UUID: + +```bash +# Fetch session details from running server +SESSION_DATA=$(curl -sf $CURL_OPTS \ + "$PROTOCOL://127.0.0.1:$PORT/api/active-sessions" 2>/dev/null) + +# In the listing loop, extract UUID per window name: +UUID=$(echo "$SESSION_DATA" | python3 -c " +import json, sys +try: + for s in json.load(sys.stdin): + if s.get('sessionId') == '$NAME': + print(s.get('cliSessionId', '')); break +except: pass +" 2>/dev/null) + +echo " $i) $NAME" +[ -n "$UUID" ] && echo " UUID: $UUID" +``` + +- [ ] **Step 4: Delete `bin/codetap-hook`** + +- [ ] **Step 5: Commit** + +```bash +git add bin/codetap +git rm bin/codetap-hook +git commit -m "feat: CLI — --adapter flag, adapter-prefixed windows, enhanced display" +``` + +--- + +### Task 9: Update E2E Specs + +**Files:** +- Modify: `tests/e2e-spec.feature` + +- [ ] **Step 1: Chat header display (L247)** — CLI UUID primary + internal ID secondary +- [ ] **Step 2: CLI `--adapter` scenarios (after L1238)** — `codetap new --adapter codex` +- [ ] **Step 3: `-a`/`-A` display format (L1212)** — UUID + internal ID +- [ ] **Step 4: Remove session-map.json refs (L1308)** — DB-based recovery +- [ ] **Step 5: Session Dedup regression (L1829)** — updated root cause +- [ ] **Step 6: SessionStart hook scenario** — API POST flow +- [ ] **Step 7: tmux window naming (L1176)** — `{adapter}-{timestamp}` format +- [ ] **Step 8: Non-graceful restart recovery (after L1325)** — restore from DB +- [ ] **Step 9: Active session card UUID (L1548)** — clarify display locations +- [ ] **Step 10: Commit** + +```bash +git add tests/e2e-spec.feature +git commit -m "test: update E2E specs for session ID unification" +``` + +--- + +### Task 10: End-to-End Verification + +- [ ] **Step 1: Build and start server** + +```bash +npm run build && CLAUDE_UI_PASSWORD=test npx tsx server/index.ts +``` + +Verify: No migration errors in console. + +- [ ] **Step 2: Web UI — new session** + +Open CodeTap → New → send message. +Verify: Header shows CLI UUID (primary) + `claude-{timestamp}` (secondary). + +- [ ] **Step 3: Active tab — no duplicates** + +Verify: Only 1 session, no duplicates. Connect button works. + +- [ ] **Step 4: CLI — codetap new** + +Verify: tmux window named `claude-{timestamp}`, session appears immediately in Active tab. + +- [ ] **Step 5: Server shutdown** + +Verify: `codetap stop` clears sessions table and kills tmux windows. diff --git a/docs/superpowers/plans/2026-03-24-codex-uuid-discovery-fix.md b/docs/superpowers/plans/2026-03-24-codex-uuid-discovery-fix.md new file mode 100644 index 0000000..10de81e --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-codex-uuid-discovery-fix.md @@ -0,0 +1,477 @@ +# Codex UUID Discovery Fix + Session Architecture Cleanup — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix Codex startSession deadlock, replace guess-based matching with JSONL marker matching, kill tmux windows on shutdown, remove DB sessions table. + +**Architecture:** 6 tasks: (1) remove _waitForCliUUID + add marker matching in Codex adapter, (2) inject marker in session-manager + index.ts, (3) filter marker in frontend, (4) shutdown kills tmux + remove _findAndAttachWindow, (5) remove DB sessions table, (6) simplify handleReconnect + review endpoints. + +**Spec:** `docs/superpowers/specs/2026-03-24-codex-uuid-discovery-fix.md` + +--- + +### Task 1: Codex adapter — remove deadlock, add marker matching + +**Files:** +- Modify: `server/adapters/codex/codex-tmux-adapter.ts` + +- [ ] **Step 1: Delete `_waitForCliUUID` method** + +Remove the entire method. Also remove the call to it in `startSession()` and the `renameWindow` call after it. `startSession` now returns temp key immediately: + +```typescript +return { sessionId: tempName }; +``` + +- [ ] **Step 2: Delete `_rekeySession` method (if still exists from earlier)** + +This was added in a previous fix. Will be replaced by `_rekeyAndRename`. + +- [ ] **Step 3: Add `_rekeyAndRename` method** + +```typescript +private async _rekeyAndRename(tempKey: string, cliUuid: string): Promise { + const session = this.sessions.get(tempKey); + if (!session) return; + session.cliSessionId = cliUuid; + session._watcherPending = false; + this.sessions.delete(tempKey); + this.sessions.set(cliUuid, session); + await tmuxManager.renameWindow(session.windowId, cliUuid); + if (session.monitor) { + (session.monitor as any).sessionId = cliUuid; + } +} +``` + +Note: NO `dbSessions` calls here — DB sessions table will be removed in Task 5. + +- [ ] **Step 4: Add `_matchByTranscriptMarker` method** + +Reads JSONL at given path, finds `[CODETAP_REF:xxx]` in first user message, returns `xxx` if it's a key in `this.sessions`: + +```typescript +private _matchByTranscriptMarker(transcriptPath: string): string | null { + try { + const content = readFileSync(transcriptPath, 'utf8'); + const lines = content.split('\n').filter(Boolean); + for (const line of lines) { + try { + const entry = JSON.parse(line); + // Check for user message content containing marker + // NOTE: Read codex transcript-parser.ts to get correct field names + const text = this._extractTextFromEntry(entry); + if (text) { + const match = text.match(/\[CODETAP_REF:([^\]]+)\]/); + if (match && this.sessions.has(match[1])) return match[1]; + } + } catch {} + } + } catch {} + return null; +} +``` + +Add a helper `_extractTextFromEntry` that handles the Codex JSONL format (check `transcript-parser.ts` for the actual field structure). + +- [ ] **Step 5: Rewrite `handleSessionStart` matching** + +Replace `pendingSessions.length === 1` logic: + +``` +1. Direct lookup: this.sessions.has(codexUuid) → already managed → update state, return +2. Marker matching: _matchByTranscriptMarker(body.transcript_path) → found tempKey → _rekeyAndRename(tempKey, codexUuid), start watcher, return +3. Fallback pending scan: pendingSessions.length === 1 → legacy (kept for sessions without marker) +4. No match: create session entry for this UUID (desktop/unknown origin) +``` + +In step 4, do NOT call `_findAndAttachWindow` (will be deleted in Task 4). Instead, try matching by tmux window name: + +```typescript +const windows = await tmuxManager.listWindows(); +const match = windows.find(w => w.name === codexUuid); +if (match) { + session.windowId = match.id; + this._startMonitor(codexUuid, match.id); +} +``` + +- [ ] **Step 6: Update `_watchForTranscript` with marker verification** + +In `scanOnce`, after finding a JSONL file candidate, verify it belongs to this session: + +```typescript +const firstChunk = readFileSync(fullPath, 'utf8').slice(0, 2000); +if (!firstChunk.includes(`CODETAP_REF:${sessionId}`)) continue; +``` + +When match confirmed, call `_rekeyAndRename(sessionId, uuid)`. + +- [ ] **Step 7: Commit** + +```bash +git add server/adapters/codex/codex-tmux-adapter.ts +git commit -m "fix: remove _waitForCliUUID deadlock, add marker-based matching" +``` + +--- + +### Task 2: Inject CODETAP_REF marker in session-manager + index.ts + +**Files:** +- Modify: `server/session-manager.ts` +- Modify: `server/index.ts` + +- [ ] **Step 1: Inject marker in handleQuery for new sessions** + +In `handleQuery()`, after `startSession` returns and before `sendMessage`: + +```typescript +let messageText = prompt; +if (!sessionId) { + // New session — prepend marker for Codex UUID matching + messageText = `[CODETAP_REF:${handle.sessionId}]\n${prompt}`; +} +await adapter.sendMessage(handle.sessionId, messageText, { clientId: conn.clientId }); +``` + +For Claude, `handle.sessionId` is already a UUID — marker is harmless, just filtered out in ChatView. + +- [ ] **Step 2: Inject marker in POST /api/reviews context** + +```typescript +if (context) { + const markerContext = `[CODETAP_REF:${handle.sessionId}]\n${context}`; + await adapter.pasteToSession(handle.sessionId, markerContext); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add server/session-manager.ts server/index.ts +git commit -m "feat: inject CODETAP_REF marker in first message for UUID matching" +``` + +--- + +### Task 3: Filter marker in frontend + +**Files:** +- Modify: `src/lib/content-utils.ts` +- Modify: `src/hooks/useChat.ts` + +- [ ] **Step 1: Add stripMarker to content-utils.ts** + +```typescript +const CODETAP_REF_REGEX = /^\[CODETAP_REF:[^\]]+\]\n?/; + +export function stripMarker(text: string): string { + return text.replace(CODETAP_REF_REGEX, ''); +} +``` + +- [ ] **Step 2: Strip marker in convertMessages (useChat.ts)** + +Import `stripMarker` from `@/lib/content-utils`. In `convertMessages()`, when processing user messages, strip marker from text content blocks: + +```typescript +if (msg.role === 'user') { + const content = typeof msg.content === 'string' + ? [{ type: 'text', text: stripMarker(msg.content) }] + : (msg.content || []).map((b: any) => + b.type === 'text' ? { ...b, text: stripMarker(b.text || '') } : b + ); + // ... rest of processing +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/content-utils.ts src/hooks/useChat.ts +git commit -m "feat: strip CODETAP_REF marker from user messages in ChatView" +``` + +--- + +### Task 4: Shutdown kills tmux + remove _findAndAttachWindow + +**Files:** +- Modify: `server/adapters/claude/tmux-adapter.ts` +- Modify: `server/adapters/codex/codex-tmux-adapter.ts` + +- [ ] **Step 1: Codex destroy() kills tmux session** + +Claude's `destroy()` already has `tmuxManager.killSession()` (added in earlier task). Add the same to Codex's `destroy()`: + +```typescript +await tmuxManager.killSession(); +``` + +Note: Both adapters share the same tmuxManager. Calling `killSession()` twice is harmless (catch block swallows error). + +- [ ] **Step 3: Delete _findAndAttachWindow from Codex adapter** + +Remove the entire `_findAndAttachWindow` method. Remove all call sites (in `handleSessionStart` fallback path). + +- [ ] **Step 4: Commit** + +```bash +git add server/adapters/claude/tmux-adapter.ts server/adapters/codex/codex-tmux-adapter.ts +git commit -m "feat: shutdown kills tmux windows, remove _findAndAttachWindow" +``` + +--- + +### Task 5: Remove DB sessions table + +**Files:** +- Modify: `server/db.ts` +- Modify: `server/adapters/claude/tmux-adapter.ts` +- Modify: `server/adapters/codex/codex-tmux-adapter.ts` +- Modify: `server/session-manager.ts` +- Modify: `server/index.ts` +- Modify: `bin/codetap` + +- [ ] **Step 1: Add parent_adapter to session_reviews table** + +In `server/db.ts`, add `parent_adapter TEXT NOT NULL DEFAULT 'claude'` to the `session_reviews` CREATE TABLE. + +Add migration: if `parent_adapter` column doesn't exist, add it: +```sql +ALTER TABLE session_reviews ADD COLUMN parent_adapter TEXT NOT NULL DEFAULT 'claude'; +``` + +Update `SessionReviewRow` interface to include `parent_adapter: string`. + +Update `sessionReviews.create()` to accept and store `parentAdapter`. + +Update `POST /api/reviews` in `server/index.ts` to pass the parent's adapter name when creating a review. + +- [ ] **Step 2: Remove sessions table from db.ts** + +Delete: +- `CREATE TABLE IF NOT EXISTS sessions` from initDB +- Old schema detection/drop logic (`PRAGMA table_info`, `hasOldColumns`) +- `sessionsUpsert`, `sessionsGet`, `sessionsFindByWindowId`, `sessionsRemove`, `sessionsGetAll` prepared statements from `PreparedStatements` interface and `stmts()` function +- `SessionRow` interface +- `sessions` export object +- `CREATE INDEX idx_sessions_window` + +Keep: everything related to `session_reviews`. + +- [ ] **Step 2: Remove all dbSessions calls from Claude adapter** + +Grep for `dbSessions` in `tmux-adapter.ts`. Remove every call (9 sites): +- `dbSessions.upsert(...)` in startSession (line 131), attachSession (line 181), resumeSession (line 224), handleSessionStart (line 547) +- `dbSessions.remove(...)` in handleSessionEnd (line 500), cleanup loop (line 707) +- `dbSessions.get(...)` in handleSessionStart (line 532), _findWindowForSession (line 875) + +Also remove the `import { sessions as dbSessions } from '../../db.js'` line. + +NOTE: `_findWindowForSession` currently does `dbSessions.get(sessionId)` first, then falls back to `windows.find(w => w.name === sessionId)`. After removing DB, keep ONLY the window name matching fallback. + +- [ ] **Step 3: Remove all dbSessions calls from Codex adapter** + +Same pattern. Grep and remove all `dbSessions.*` calls. Remove import. + +- [ ] **Step 4: Remove dbSessions from session-manager.ts** + +Remove: +- `import { sessions as dbSessions } from './db.js'` (keep `sessionReviews` import) +- `dbSessions.get(sessionId)` in handleReconnect (cwd lookup — no longer needed) +- `dbSessions.get(review.parent_cli_session_id)` in review restoration (Task 6 changes this) + +- [ ] **Step 5: Remove dbSessions from index.ts** + +Remove: +- `dbSessions.clearAll()` from shutdown handler +- `dbSessions.get(parentCliSessionId)` from review endpoints (Task 6 changes these) +- Import of `sessions as dbSessions` + +- [ ] **Step 6: Update bin/codetap** + +Remove all SQL queries that reference the `sessions` table: +- `get_project_sessions()` function — queries sessions by cwd +- `-a` listing block — queries sessions by window name +- `--resume` block — queries sessions by id + +These CLI features will stop working without the DB. Options: +a) Remove these features from bin/codetap (they depend on DB) +b) Use tmux list-windows directly instead of DB queries + +Recommended: option (b) — replace DB queries with tmux-based queries: + +`get_project_sessions()`: +```bash +tmux list-windows -t codetap -F '#{window_name}\t#{pane_current_path}' 2>/dev/null | \ + awk -F'\t' -v cwd="$CWD" '$2 == cwd { print $1 }' +``` + +`-a` listing: +```bash +tmux list-windows -t codetap -F '#{window_name}\t#{pane_current_command}\t#{pane_current_path}' 2>/dev/null +# window_name = UUID, pane_current_command = "claude" or "codex" (adapter detection) +``` + +`--resume`: +```bash +# Check if UUID exists as a tmux window name +tmux list-windows -t codetap -F '#{window_name}' 2>/dev/null | grep -q "^${RESUME_ID}$" +# Detect adapter from pane command +ADAPTER=$(tmux display -t "codetap:${RESUME_ID}" -p '#{pane_current_command}' 2>/dev/null) +``` + +- [ ] **Step 7: TypeScript compilation check** + +`npx tsc --noEmit` — zero errors. + +- [ ] **Step 8: Commit** + +```bash +git add server/db.ts server/adapters/claude/tmux-adapter.ts server/adapters/codex/codex-tmux-adapter.ts server/session-manager.ts server/index.ts bin/codetap +git commit -m "refactor: remove DB sessions table — in-memory Map is sole source of truth" +``` + +--- + +### Task 6: Simplify handleReconnect + review endpoints use Map for cwd + +**Files:** +- Modify: `server/session-manager.ts` +- Modify: `server/index.ts` + +- [ ] **Step 1: Simplify handleReconnect** + +Remove the entire `hasActiveWindow` + `resumeSession` block: + +```typescript +// BEFORE: +if (!adapter.getSession(sessionId)) { + const hasWindow = await adapter.hasActiveWindow(sessionId); + if (hasWindow) { + const dbRow = dbSessions.get(sessionId); + try { await adapter.resumeSession(sessionId, dbRow?.cwd || ''); } catch {} + } +} + +// AFTER: +// (deleted — handleReconnect only loads history, handleQuery builds windows) +``` + +- [ ] **Step 2: Review endpoints use adapter.getSession for cwd** + +In POST /api/reviews: +```typescript +// BEFORE: +const parentRow = dbSessions.get(parentCliSessionId); +const cwd = parentRow?.cwd || process.cwd(); + +// AFTER: +const parentSession = adapter.getSession(parentCliSessionId) as { cwd?: string } | null; +const cwd = parentSession?.cwd || process.cwd(); +``` + +In POST /api/reviews/:id/send-back and DELETE /api/reviews/:id: +```typescript +// BEFORE: +const parentRow = dbSessions.get(review.parent_cli_session_id); +const parentAdapter = getAdapter(parentRow?.adapter || DEFAULT_ADAPTER); + +// AFTER: parent_adapter is now stored in session_reviews +const parentAdapter = getAdapter(review.parent_adapter); +``` + +No need to iterate adapters — `parent_adapter` is directly in the review row. + +In handleReconnect review restoration: +```typescript +// BEFORE: +const parentRow = dbSessions.get(review.parent_cli_session_id); +const cwd = parentRow?.cwd || ''; +await childAdapterObj.resumeSession(review.child_cli_session_id, cwd); + +// AFTER: don't call resumeSession — if server didn't restart, child is still in Map. +// If server restarted, windows are dead, review should be marked ended. +if (!childAdapterObj.getSession(review.child_cli_session_id)) { + // Child session gone (server restarted + windows killed) → mark review ended + sessionReviews.endReview(review.id); + continue; +} +// Child still alive → just send REVIEW_STARTED event, child's useChat reconnects itself +``` + +- [ ] **Step 3: Commit** + +```bash +git add server/session-manager.ts server/index.ts +git commit -m "refactor: handleReconnect simplified, review endpoints use in-memory Map" +``` + +--- + +## Self-Review Checklist + +### Compilation Safety +- Task 1 removes `_waitForCliUUID` calls → `startSession` returns temp key → compiles ✅ +- Task 5 removes dbSessions → all callers must be updated in same task → grep to verify zero remaining references ✅ +- Task 6 depends on Task 5 (dbSessions already removed) → correct ordering ✅ + +### Codex UUID Discovery Flow (after fix) +``` +1. startSession → return tempKey immediately +2. handleQuery/reviews → paste [CODETAP_REF:tempKey] + prompt +3. Codex processes → SessionStart hook fires (or JSONL appears) +4. handleSessionStart → read JSONL → find CODETAP_REF:tempKey → match +5. _rekeyAndRename(tempKey, uuid) → Map re-key + tmux rename +6. From now on: session ID = UUID = tmux window name +``` + +### handleReconnect Flow (after fix) +``` +User clicks session → RECONNECT +1. registerClient(conn, sessionId) +2. load JSONL history → HISTORY_LOAD +3. replay pending state +4. NO resumeSession, NO cwd lookup, NO DB query +5. If user sends message → handleQuery → resumeSession → builds tmux window +``` + +### Review Endpoints (after fix) +- POST /api/reviews: `cwd` from `adapter.getSession(parentId).cwd` (parent is active, must be in Map) +- send-back: find parent adapter by iterating `getAllAdapters()`, check `adapter.getSession(reviewParentId)` +- delete: same pattern + +### bin/codetap (after fix) +- `-a` listing: `tmux list-windows` directly instead of DB query +- `--resume`: `tmux list-windows` to find window by name (= UUID) +- `get_project_sessions`: `tmux list-windows` + filter by `pane_current_path` + +### Things NOT changed +- `session_reviews` DB table — stays (Cross-AI Review needs it) +- In-memory `sessions` Map — stays (runtime state store) +- JSONL files — untouched (historical record) +- Push notifications — untouched +- Permission manager — untouched + +### Issues Found in Self-Review (all addressed in plan) +- Claude's `destroy()` already has `killSession()` — only Codex needs it added (Task 4 updated) +- Claude's `_findWindowForSession` has DB-first check — keep only name-matching fallback (Task 5 Step 2 noted) +- Review restoration in handleReconnect: don't call `resumeSession`, just check if child exists or mark ended (Task 6 Step 2 updated) +- `send-back`/`delete` review endpoints: added `parent_adapter` column to `session_reviews` — direct lookup, no iteration (Task 5 Step 1) +- `bin/codetap --resume` detects adapter from `pane_current_command` (Task 5 Step 6 updated) +- dbSessions has 26 call sites across 4 files — all enumerated in Task 5 + +## Verification + +1. Server starts without sessions table in DB +2. New Claude session from Web UI → works (marker injected, UUID known immediately) +3. New Codex session from Web UI → works (marker injected, UUID discovered via hook, tmux renamed) +4. Cross-AI Review → works (marker in context, child session matched, floating panel opens) +5. Server shutdown → all tmux windows killed +6. Server restart → clean state, no stale windows +7. `bin/codetap -a` → lists sessions from tmux directly +8. `bin/codetap --resume ` → works +9. Historical session click → loads history (no 30s wait, no resumeSession) +10. ChatView shows no `[CODETAP_REF:...]` markers diff --git a/docs/superpowers/plans/2026-03-24-remaining-session-fixes.md b/docs/superpowers/plans/2026-03-24-remaining-session-fixes.md new file mode 100644 index 0000000..3317b87 --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-remaining-session-fixes.md @@ -0,0 +1,351 @@ +# Remaining Session Fixes — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Complete session architecture cleanup: remove pending guessing, remove desktop-discovery, add session API endpoints, update bin/codetap. + +**Spec:** `docs/superpowers/specs/2026-03-24-remaining-session-fixes.md` + +--- + +### Task 1: Codex handleSessionStart — remove pending matching, add _pendingHookBodies + +**Files:** `server/adapters/codex/codex-tmux-adapter.ts` + +- [ ] **Step 1: Add _pendingHookBodies field** + +```typescript +private _pendingHookBodies: Map = new Map(); +``` + +- [ ] **Step 2: Rewrite handleSessionStart (line 275)** + +Replace the entire method body: + +```typescript +handleSessionStart(body: CodexHookBody): void { + const codexUuid = body.session_id; + if (!codexUuid) return; + + // 1. Already managed + if (this.sessions.has(codexUuid)) { + this._applySessionStartBody(codexUuid, body); + return; + } + + // 2. Has pending sessions → store hook body, let _watchForTranscript match later + const hasPending = [...this.sessions.values()].some(s => s._watcherPending); + if (hasPending) { + this._pendingHookBodies.set(codexUuid, body); + return; + } + + // 3. Not our session → ignore +} +``` + +Remove the old `pendingSessions.length === 1` block (lines 297-308). +Remove the "desktop/unknown origin" block (lines 309-330). + +- [ ] **Step 3: Update _watchForTranscript to read _pendingHookBodies after rekey** + +In the `scanOnce` function, after `_rekeyAndRename(sessionId, uuid)` succeeds, check for stored hook body: + +```typescript +const hookBody = this._pendingHookBodies.get(uuid); +if (hookBody) { + this._applySessionStartBody(uuid, hookBody); + this._pendingHookBodies.delete(uuid); +} +``` + +- [ ] **Step 4: Add cleanup for _pendingHookBodies** + +In `_startSessionCleanup` interval, add a sweep: + +```typescript +// Clean up stale pending hook bodies (older than 60s) +const now = Date.now(); +for (const [uuid, body] of this._pendingHookBodies) { + // Use a timestamp field or just clean up all entries periodically + this._pendingHookBodies.delete(uuid); +} +``` + +Actually simpler: clean up in _pendingHookBodies when adding — if size > 10, delete oldest. Or just clear all entries older than 60s by storing a timestamp alongside. + +- [ ] **Step 5: Commit** + +```bash +git commit -m "refactor: Codex handleSessionStart uses _pendingHookBodies, no pending guessing" +``` + +--- + +### Task 2: Remove desktop-discovery from both adapters + +**Files:** `server/adapters/claude/tmux-adapter.ts`, `server/adapters/codex/codex-tmux-adapter.ts` + +- [ ] **Step 1: Claude — simplify handleSessionStart (line 512)** + +Current code (lines 512-539) does: +1. `sessions.has(cliUuid)` → update lastActivity → return +2. List tmux windows → search for `w.command.includes('claude') && !sessions.has(w.name)` → create session + +Remove step 2 entirely. The method becomes: + +```typescript +async handleSessionStart(body: HookBody): Promise { + const cliUuid = body.session_id; + if (!cliUuid) return; + + if (this.sessions.has(cliUuid)) { + this.sessions.get(cliUuid)!.lastActivity = Date.now(); + return; + } + + // Unknown UUID — not our session, ignore +} +``` + +Also remove the `await tmuxManager.listWindows()` call (no longer needed). + +- [ ] **Step 2: Codex — verify desktop-discovery already removed in Task 1** + +Check that Task 1's rewrite of `handleSessionStart` has no desktop-discovery path. + +- [ ] **Step 3: Commit** + +```bash +git commit -m "refactor: remove desktop-discovery from both adapters" +``` + +--- + +### Task 3: Add POST /api/sessions/start and /resume endpoints + +**Files:** `server/index.ts` + +- [ ] **Step 1: Add POST /api/sessions/start** + +Place after the existing session endpoints (after DELETE /api/active-sessions/:id): + +```typescript +app.post('/api/sessions/start', authMiddleware, async (req: Request, res: Response) => { + try { + const { adapter: adapterName, cwd, model, permissionMode } = req.body; + if (!cwd) return res.status(400).json({ error: 'cwd required' }); + + const adapter = getAdapter(adapterName || DEFAULT_ADAPTER); + if (!adapter) return res.status(400).json({ error: `Unknown adapter: ${adapterName}` }); + + const handle = await adapter.startSession(cwd, { model, permissionMode }); + + // Register in sessionAdapterMap so events route correctly + sessionAdapterMap.set(handle.sessionId, adapterName || DEFAULT_ADAPTER); + + res.json({ sessionId: handle.sessionId }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); +``` + +Note: import `sessionAdapterMap` — check if it's already accessible. It's a module-level variable in `session-manager.ts`. May need to export a helper function `registerSessionAdapter(sessionId, adapterName)` from session-manager. + +Actually, looking at the code: `sessionAdapterMap` is defined in `session-manager.ts` as a module-level const. It's NOT exported. The `handleQuery` function accesses it directly because it's in the same file. + +For `server/index.ts` to set it, we need either: +a) Export `sessionAdapterMap` from session-manager.ts +b) Add a `registerSessionAdapter(id, name)` helper exported from session-manager.ts +c) Have `startSession` trigger an event that session-manager listens to + +Option (b) is cleanest. + +- [ ] **Step 2: Add POST /api/sessions/resume** + +```typescript +app.post('/api/sessions/resume', authMiddleware, async (req: Request, res: Response) => { + try { + const { sessionId, adapter: adapterName, cwd } = req.body; + if (!sessionId) return res.status(400).json({ error: 'sessionId required' }); + + // Determine adapter if not provided + let resolvedAdapterName = adapterName; + if (!resolvedAdapterName) { + // Try to detect from JSONL file location + // ... (use existing findSessionFile logic from each adapter's jsonl-store) + resolvedAdapterName = DEFAULT_ADAPTER; + } + + const adapter = getAdapter(resolvedAdapterName); + if (!adapter) return res.status(400).json({ error: `Unknown adapter: ${resolvedAdapterName}` }); + + const handle = await adapter.resumeSession(sessionId, cwd || process.cwd()); + + registerSessionAdapter(handle.sessionId, resolvedAdapterName); + + res.json({ sessionId: handle.sessionId }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); +``` + +- [ ] **Step 3: Export registerSessionAdapter from session-manager.ts** + +```typescript +export function registerSessionAdapter(sessionId: string, adapterName: string): void { + sessionAdapterMap.set(sessionId, adapterName); +} +``` + +- [ ] **Step 4: Commit** + +```bash +git commit -m "feat: add POST /api/sessions/start and /resume endpoints" +``` + +--- + +### Task 4: Update bin/codetap to use API endpoints + +**Files:** `bin/codetap` + +NOTE: `sqlite3` references were already removed in Fix 5. This task replaces direct `tmux new-window` calls with API calls. + +- [ ] **Step 1: Add authentication function** + +Near the top of the script (after the server-running check): + +```bash +get_auth_token() { + curl -sk -X POST "https://localhost:$PORT/api/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"password\":\"$CLAUDE_UI_PASSWORD\"}" 2>/dev/null | \ + python3 -c 'import sys,json; print(json.load(sys.stdin).get("token",""))' 2>/dev/null +} +``` + +- [ ] **Step 2: Replace `new` session creation** + +Find the block that does `tmux new-window ... "$COMMAND"`. Replace with: + +```bash +AUTH_TOKEN=$(get_auth_token) +if [ -z "$AUTH_TOKEN" ]; then + echo "Error: Failed to authenticate with CodeTap server" + exit 1 +fi + +RESULT=$(curl -sk -X POST "https://localhost:$PORT/api/sessions/start" \ + -H "Authorization: Bearer $AUTH_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"adapter\":\"$ADAPTER\",\"cwd\":\"$(pwd)\"}") +SESSION_ID=$(echo "$RESULT" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("sessionId",""))' 2>/dev/null) + +if [ -z "$SESSION_ID" ] || [ "$SESSION_ID" = "null" ]; then + echo "Error: Failed to create session" + echo "$RESULT" + exit 1 +fi + +tmux select-window -t "$TMUX_SESSION:$SESSION_ID" +``` + +- [ ] **Step 3: Replace `--resume` with API call** + +```bash +AUTH_TOKEN=$(get_auth_token) +RESULT=$(curl -sk -X POST "https://localhost:$PORT/api/sessions/resume" \ + -H "Authorization: Bearer $AUTH_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"sessionId\":\"$RESUME_ID\",\"adapter\":\"$ADAPTER\",\"cwd\":\"$(pwd)\"}") +SESSION_ID=$(echo "$RESULT" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("sessionId",""))' 2>/dev/null) +tmux select-window -t "$TMUX_SESSION:$SESSION_ID" +``` + +- [ ] **Step 4: Replace `--continue`** + +Find most recent tmux window, resume it: + +```bash +LATEST=$(tmux list-windows -t "$TMUX_SESSION" -F '#{window_activity} #{window_name}' 2>/dev/null | sort -rn | head -1 | awk '{print $2}') +if [ -n "$LATEST" ] && [ "$LATEST" != "main" ]; then + tmux select-window -t "$TMUX_SESSION:$LATEST" +else + echo "No active sessions to continue" + exit 1 +fi +``` + +- [ ] **Step 5: Verify -a listing uses tmux directly** + +Should already be tmux-based (from Fix 5). Verify no remaining sqlite3 references. + +- [ ] **Step 6: Commit** + +```bash +git commit -m "refactor: bin/codetap uses API endpoints for session creation" +``` + +--- + +## Self-Review Checklist + +### Compilation safety +- Task 1 changes only Codex adapter internals → compiles independently ✅ +- Task 2 simplifies Claude handleSessionStart → compiles independently ✅ +- Task 3 adds new endpoints, needs `registerSessionAdapter` export → export first, then add endpoints ✅ +- Task 4 is shell script only → no compilation ✅ + +### Codex _watchForTranscript flow after Task 1 +``` +1. startSession → temp key in Map, _watcherPending = true +2. pasteToSession → marker + prompt pasted +3. SessionStart hook fires → has pending → stored in _pendingHookBodies +4. _watchForTranscript detects JSONL → reads marker → matches temp key +5. _rekeyAndRename(tempKey, uuid) → rekey + rename +6. Read _pendingHookBodies(uuid) → apply transcript_path, cwd +7. Start JSONL watcher +``` +All steps covered ✅ + +### Claude handleSessionStart after Task 2 +``` +handleSessionStart(body): + sessions.has(uuid) → true → update → return + → false → ignore +``` +Two lines. Very simple. No matching, no discovery. ✅ + +### bin/codetap after Task 4 +- `new`: API call → tmux select-window ✅ +- `--resume`: API call → tmux select-window ✅ +- `--continue`: tmux list-windows → select most recent ✅ +- `-a`: tmux list-windows directly ✅ +- No sqlite3 references ✅ +- Requires server running + password (already a requirement) ✅ + +### Edge cases +- **bin/codetap when server is down:** API calls fail → script shows error → user knows server needs to be running. This is acceptable since CodeTap server is required for all functionality. +- **Multiple pending sessions with same UUID in _pendingHookBodies:** Won't happen — UUIDs are unique per CLI session. +- **_pendingHookBodies grows unbounded:** Mitigated by cleanup in _startSessionCleanup (60s sweep). +- **bin/codetap Codex new session — temp key returned:** Script does `tmux select-window -t codetap:codex-{timestamp}`. After rekey, window renamed to UUID. User is already inside — unaffected. + +### No changes needed +- `server/session-manager.ts` — only needs `registerSessionAdapter` export (Task 3) +- `server/db.ts` — no changes +- Frontend — no changes +- `server/adapters/claude/tmux-manager.ts` — no changes + +## Verification + +1. Server starts cleanly +2. New Codex session from Web UI → hook stored in _pendingHookBodies → _watchForTranscript matches → rekey +3. New Claude session from Web UI → works (no matching needed) +4. `bin/codetap new --adapter claude` → API call → session created → window selected +5. `bin/codetap new --adapter codex` → API call → session created → window selected +6. `bin/codetap --resume UUID` → API call → session resumed +7. `bin/codetap -a` → lists sessions from tmux +8. Desktop-started sessions (user runs `claude`/`codex` directly) → hooks ignored by CodeTap (expected) diff --git a/docs/superpowers/plans/2026-03-24-session-id-unification.md b/docs/superpowers/plans/2026-03-24-session-id-unification.md new file mode 100644 index 0000000..901972e --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-session-id-unification.md @@ -0,0 +1,468 @@ +# Session ID Unification Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eliminate the dual session ID system (internal ID + CLI UUID) and unify on CLI UUID as the single source of truth across the entire codebase. + +**Architecture:** 5 phases -- (1) DB schema migration, (2) adapter internals (both Claude + Codex), (3) session manager + permissions + push, (4) server endpoints + frontend, (5) CLI script + cleanup. Each phase builds on the previous. + +**Tech Stack:** TypeScript, SQLite (better-sqlite3), tmux, React, WebSocket, Shell (bin/codetap) + +**Spec:** `docs/superpowers/specs/2026-03-24-session-id-unification-design.md` + +--- + +## Phase 1: DB Schema + +### Task 1: Migrate sessions table -- CLI UUID as primary key + +**Files:** +- Modify: `server/db.ts` + +- [ ] **Step 1: Update SessionRow interface (line 284)** + +Remove `cli_session` field, add `window_name`: + +```typescript +export interface SessionRow { + id: string; // CLI UUID (was internal ID) + cwd: string; + window_id: string | null; + window_name: string | null; // tmux window name for debug + adapter: string; + permission_mode: string; + created_at: string; + last_activity: string; +} +``` + +- [ ] **Step 2: Add schema migration in initDB() (after line 85)** + +Detect old `cli_session` column and rebuild table: + +```typescript +const hasCliSession = tableInfo.some((c: any) => c.name === 'cli_session'); +const hasWindowName = tableInfo.some((c: any) => c.name === 'window_name'); +if (hasCliSession && !hasWindowName) { + d.exec(` + CREATE TABLE IF NOT EXISTS sessions_new ( + id TEXT PRIMARY KEY, + cwd TEXT NOT NULL, + window_id TEXT, + window_name TEXT, + adapter TEXT DEFAULT 'claude', + permission_mode TEXT DEFAULT 'default', + created_at TEXT DEFAULT (datetime('now')), + last_activity TEXT DEFAULT (datetime('now')) + ); + INSERT OR IGNORE INTO sessions_new (id, cwd, window_id, window_name, adapter, permission_mode, created_at, last_activity) + SELECT + CASE WHEN cli_session IS NOT NULL AND cli_session != '' AND cli_session != id THEN cli_session ELSE id END, + cwd, window_id, id, adapter, permission_mode, created_at, last_activity + FROM sessions; + DROP TABLE sessions; + ALTER TABLE sessions_new RENAME TO sessions; + CREATE INDEX IF NOT EXISTS idx_sessions_window ON sessions(window_id); + `); + console.log('[db] Migrated sessions table: CLI UUID as primary key'); +} +``` + +- [ ] **Step 3: Update CREATE TABLE for fresh installs (line 20)** + +```sql +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + cwd TEXT NOT NULL, + window_id TEXT, + window_name TEXT, + adapter TEXT DEFAULT 'claude', + permission_mode TEXT DEFAULT 'default', + created_at TEXT DEFAULT (datetime('now')), + last_activity TEXT DEFAULT (datetime('now')) +); +``` + +- [ ] **Step 4: Update prepared statements** + +Replace `sessionsUpsert` SQL: +```sql +INSERT INTO sessions (id, cwd, window_id, window_name, adapter) +VALUES (?, ?, ?, ?, ?) +ON CONFLICT(id) DO UPDATE SET + cwd = excluded.cwd, + window_id = excluded.window_id, + window_name = excluded.window_name, + last_activity = datetime('now') +``` + +Replace `sessionsFindByCliSession` with `sessionsGet`: +```typescript +sessionsGet: d.prepare('SELECT * FROM sessions WHERE id = ?'), +``` + +- [ ] **Step 5: Update sessions operations export (line 308)** + +```typescript +export const sessions = { + upsert(id: string, cwd: string, windowId?: string, windowName?: string, adapter?: string): void { + stmts().sessionsUpsert.run(id, cwd, windowId || null, windowName || null, adapter || 'claude'); + }, + get(id: string): SessionRow | undefined { + return stmts().sessionsGet.get(id) as SessionRow | undefined; + }, + findByWindowId(windowId: string): SessionRow | undefined { + return stmts().sessionsFindByWindowId.get(windowId) as SessionRow | undefined; + }, + remove(id: string): void { stmts().sessionsRemove.run(id); }, + getAll(): SessionRow[] { return stmts().sessionsGetAll.all() as SessionRow[]; }, + clearAll(): void { getDB().exec('DELETE FROM sessions'); }, +}; +``` + +Key: `upsert` signature changes from `(id, cliSession, cwd, windowId, adapter)` to `(id, cwd, windowId, windowName, adapter)`. `findByCliSession` replaced by `get` (PK lookup). + +**IMPORTANT — Backward compatibility:** To allow each task to compile independently, KEEP the old methods as deprecated aliases alongside the new ones: + +```typescript +/** @deprecated Use get() instead */ +findByCliSession(cliSession: string): SessionRow | undefined { + return this.get(cliSession); // After migration, cli_session IS the id +}, +/** @deprecated Use upsert(id, cwd, windowId, windowName, adapter) instead */ +upsertLegacy(id: string, cliSession: string, cwd: string, windowId?: string, adapter?: string): void { + // During transition: use cliSession as new id if it looks like a UUID, else use id + const effectiveId = (cliSession && cliSession !== id && cliSession.includes('-')) ? cliSession : id; + this.upsert(effectiveId, cwd, windowId, id, adapter); +}, +``` + +These aliases are removed in Task 8 (cleanup). This allows Tasks 2-5 to compile at each intermediate step. + +- [ ] **Step 6: Wrap migration in transaction** + +Ensure the migration SQL in Step 2 is wrapped in a transaction: + +```typescript +d.transaction(() => { + d.exec(` ... migration SQL ... `); +})(); +``` + +- [ ] **Step 7: Remove old index creation** + +At lines 94-98 of db.ts, the `CREATE INDEX idx_sessions_cli ON sessions(cli_session)` must be removed or guarded (column no longer exists after migration). + +- [ ] **Step 8: Commit** + +```bash +git add server/db.ts +git commit -m "refactor: migrate sessions table -- CLI UUID as primary key" +``` + +--- + +## Phase 2: Adapter Internals + +### Task 2: Unify Claude adapter to CLI UUID + +**Files:** +- Modify: `server/adapters/claude/tmux-adapter.ts` +- Modify: `server/adapters/claude/index.ts` +- Modify: `server/adapters/interface.ts` + +- [ ] **Step 1: Remove translation infrastructure** + +In `tmux-adapter.ts`: +- Remove `cliToSessionId: Map` field (line 88) +- Remove ALL `this.cliToSessionId.set(...)` calls +- Remove `resolveSessionId()` method (lines 912-970) +- Remove `_registerCliUUID()` (lines 797-805) -- also fixes `claude_session` bug +- Remove `_remapCliSession()` (lines 779-785) +- Remove `_removeCliMapping()` (lines 792-793) + +In `interface.ts`: remove `resolveSessionId` method from IAdapter base class. +In `claude/index.ts`: remove `resolveSessionId` delegation (line 219). + +- [ ] **Step 2: Change sessions Map key to CLI UUID** + +In `startSession()`: +- `const sessionId = cliSessionId` (was `windowName`) +- `this.sessions.set(sessionId, ...)` keyed by CLI UUID +- `dbSessions.upsert(sessionId, cwd, windowId, windowName, 'claude')` -- new signature +- Return `{ sessionId }` -- now CLI UUID + +In `resumeSession()`: +- `const newSessionId = cliUuid` (was `'claude-${Date.now()}'`) +- Keep `const windowName = 'claude-${Date.now()}'` for tmux display +- `this.sessions.set(newSessionId, ...)` keyed by CLI UUID +- `dbSessions.upsert(newSessionId, cwd, windowId, windowName, 'claude')` +- Return `{ sessionId: newSessionId }` + +In `attachSession()`: same pattern -- use CLI UUID as Map key. + +In `handleSessionStart()`: use CLI UUID from hook body directly as Map key. + +- [ ] **Step 3: Update _findWindowForSession (line 986)** + +Replace window-name matching AND the `findByCliSession` fallback (line 994) with DB PK lookup: +```typescript +const dbRow = dbSessions.get(sessionId); +if (dbRow?.window_id) return dbRow.window_id; +``` + +Remove `windows.find(w => w.name === sessionId)` -- no longer matching by window name. + +- [ ] **Step 4: Update all dbSessions.upsert calls to new signature** + +Enumerate ALL call sites in this file and update each from `(id, cliSession, cwd, windowId, adapter)` to `(id, cwd, windowId, windowName, adapter)`: +- Line 136 (startSession) +- Line 192 (attachSession) +- Line 239 (resumeSession) +- Line 572 (handleSessionStart) +- Line 946 (inside resolveSessionId -- goes away when method is deleted) + +- [ ] **Step 5: Commit** + +```bash +git add server/adapters/claude/tmux-adapter.ts server/adapters/claude/index.ts server/adapters/interface.ts +git commit -m "refactor: Claude adapter uses CLI UUID as session key" +``` + +--- + +### Task 3: Unify Codex adapter to CLI UUID + _waitForCliUUID + +**Files:** +- Modify: `server/adapters/codex/codex-tmux-adapter.ts` +- Modify: `server/adapters/codex/index.ts` +- Modify: `server/adapters/codex/pane-monitor.ts` + +- [ ] **Step 1: Remove translation infrastructure** + +Same as Claude: remove `cliToSessionId` Map, `resolveSessionId()`, `_removeCliMapping()`. +In `codex/index.ts`: remove `resolveSessionId` delegation. + +- [ ] **Step 2: Add _waitForCliUUID method** + +New method that polls `session.cliSessionId` every 500ms (max 15s). When UUID discovered: re-key session in Map, upsert DB, remove temp key. On timeout: kill tmux window, remove temp session, throw error. + +- [ ] **Step 3: Update startSession** + +Store session under temp `windowName` key initially. After `_waitForReady`, call `await this._waitForCliUUID(windowName)` which returns CLI UUID. Return `{ sessionId: cliUUID }`. + +- [ ] **Step 4: Update resumeSession + handleSessionStart + _watchForTranscript** + +All use CLI UUID as Map key directly. `handleSessionStart` and `_watchForTranscript` set `session.cliSessionId` (which `_waitForCliUUID` polls for). + +- [ ] **Step 5: Update all dbSessions.upsert calls to new signature** + +Enumerate ALL call sites in this file: +- Line 135 (startSession) +- Line 189 (resumeSession) +- Line 337 (handleSessionStart) +- Line 753 (_watchForTranscript scanOnce lambda) +- Line 861 (_findAndAttachWindow) + +- [ ] **Step 6: Commit** + +```bash +git add server/adapters/codex/codex-tmux-adapter.ts server/adapters/codex/index.ts server/adapters/codex/pane-monitor.ts +git commit -m "refactor: Codex adapter uses CLI UUID, add _waitForCliUUID" +``` + +--- + +## Phase 3: Session Manager + +### Task 4: Unify session-manager to CLI UUID + +**Files:** +- Modify: `server/session-manager.ts` + +- [ ] **Step 1: Remove all resolveSessionId calls** + +In `handleQuery`: remove resolution block. `sessionId` from client is CLI UUID. +In `handleReconnect`: remove resolution block. Use `sessionId` directly as `effectiveId`. +Remove all `(adapter as ...).resolveSessionId?.(...)` casts. + +- [ ] **Step 2: Simplify sendSessionCreated** + +Send single `sessionId` (CLI UUID). Remove `cliSessionId` field. + +- [ ] **Step 3: Simplify handleReconnect** + +Preserve all 11 steps. Key changes: +- Step 6: add `hasActiveWindow` guard (prevent creating unwanted tmux windows) +- Step 8: use `sessionId` directly for `getMessages()` (CLI UUID = JSONL key) +- Step 11: use `sessionId` directly for `getActiveForParent()` +- Replace `dbSessions.findByCliSession` with `dbSessions.get` +- Remove dynamic `import('./db.js')` -- use static import + +- [ ] **Step 4: Simplify triggerPush** + +Single `getSession()` call. Use `sessionId` directly for child review check. + +- [ ] **Step 5: Simplify session-ended handler** + +`sessionId` IS CLI UUID. Remove convoluted DB lookup for `endedCliId`. Use directly for review cascade. + +- [ ] **Step 6: Update dbSessions calls** + +Replace all `dbSessions.findByCliSession(...)` with `dbSessions.get(...)`. + +- [ ] **Step 7: Commit** + +```bash +git add server/session-manager.ts +git commit -m "refactor: session-manager uses CLI UUID for broadcast and registration" +``` + +--- + +## Phase 4: Server Endpoints + Frontend + +### Task 5: Update server/index.ts + +**Files:** +- Modify: `server/index.ts` + +- [ ] **Step 1: Replace dbSessions.findByCliSession with dbSessions.get** + +All `dbSessions.findByCliSession(...)` calls become `dbSessions.get(...)`. + +- [ ] **Step 2: Simplify active-sessions client count** + +Replace `getClientCount(s.sessionId) || getClientCount(s.cliSessionId)` with `getClientCount(s.sessionId)`. + +- [ ] **Step 3: Update review endpoints to use dbSessions.get** + +- [ ] **Step 4: Commit** + +```bash +git add server/index.ts +git commit -m "refactor: server endpoints use CLI UUID, remove dual-ID lookups" +``` + +--- + +### Task 6: Unify frontend + +**Files:** +- Modify: `src/hooks/useChat.ts` +- Modify: `src/components/ChatView.tsx` +- Modify: `src/hooks/useSessions.ts` +- Modify: `src/components/SessionsView.tsx` + +- [ ] **Step 1: Merge sessionId + cliSessionId in useChat** + +Remove `cliSessionId` state. Keep only `sessionId` (CLI UUID). Remove `setCliSessionId(msg.cliSessionId)` from SESSION_CREATED handler. Remove `cliSessionId` from return. + +- [ ] **Step 2: Update ChatView** + +Remove `cliSessionId` from useChat destructuring. Use `sessionId` for ChatHeader display and review API calls. + +- [ ] **Step 3: Update useSessions** + +Change `s.cliSessionId` to `s.sessionId` in `activeSessionIds` builder. + +- [ ] **Step 4: Update SessionsView** + +Change `pending[session.cliSessionId]` to `pending[session.sessionId]` for notification badges. + +- [ ] **Step 5: Commit** + +```bash +git add src/hooks/useChat.ts src/components/ChatView.tsx src/hooks/useSessions.ts src/components/SessionsView.tsx +git commit -m "refactor: frontend uses single sessionId (CLI UUID)" +``` + +--- + +## Phase 5: CLI + Cleanup + +### Task 7: Update bin/codetap + +**Files:** +- Modify: `bin/codetap` + +- [ ] **Step 1: Update get_project_sessions() SQL (line 239)** + +Change `SELECT id FROM sessions` to `SELECT window_name FROM sessions` -- returns tmux window names for matching. + +- [ ] **Step 2: Update -a listing SQL (line 290)** + +Change to `SELECT id, adapter, window_name, cwd FROM sessions WHERE window_name IN (...)`. + +- [ ] **Step 3: Update --resume SQL (line 382)** + +Change to `WHERE id='${SAFE_ID}' OR window_name='${SAFE_ID}'` -- accepts both CLI UUID and window name (backwards compatible for users who may pass old-style IDs). + +- [ ] **Step 4: Commit** + +```bash +git add bin/codetap +git commit -m "refactor: bin/codetap uses new DB schema" +``` + +--- + +### Task 8: Final cleanup -- remove deprecated aliases, verify all files + +**Files:** +- Modify: `server/db.ts` +- Modify: `server/adapters/interface.ts` +- Modify: `tests/e2e-spec.feature` + +- [ ] **Step 1: Remove deprecated DB method aliases** + +In `server/db.ts`, remove `findByCliSession` and `upsertLegacy` deprecated aliases added in Task 1. Grep to verify zero remaining callers. + +- [ ] **Step 2: Deprecate ActiveSessionInfo.cliSessionId** + +In `server/adapters/interface.ts`, add `/** @deprecated Use sessionId instead */` comment. + +- [ ] **Step 3: Verify no-op files from spec** + +These files are listed in the spec as MODIFY but require no code changes (already use generic string params). Verify each with grep: +- `server/push.ts` -- callers now pass CLI UUID. NO CHANGE needed. +- `server/permission-manager.ts` -- callers now pass CLI UUID. NO CHANGE needed. +- `server/types/messages.ts` -- `QueryOptions.sessionId` is generic. NO CHANGE. +- `server/types/adapter.ts` -- `SessionInfo.sessionId` already CLI UUID. NO CHANGE. +- `src/lib/ws.ts`, `src/lib/api.ts`, `src/sw.ts`, `src/App.tsx`, `src/components/FloatingReviewPanel.tsx` -- NO CHANGE. + +- [ ] **Step 4: Verify session_stats table** + +Confirm `session_stats` table is never written to (no INSERT statements). No migration needed. + +- [ ] **Step 5: Update e2e specs** + +Remove references to dual-ID system, `resolveSessionId`, `cliSessionId` as separate concept. + +- [ ] **Step 6: TypeScript compilation check** + +`npx tsc --noEmit` -- zero errors. + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "refactor: cleanup -- remove deprecated aliases, verify all files, update e2e specs" +``` + +--- + +## Verification + +After all tasks: + +1. Server starts, migration runs without errors +2. Open historical session from project list -- history loads immediately (no 30s wait) +3. Open session from active list -- connects correctly +4. Send message -- goes to correct tmux window +5. Desktop opens same session -- mobile receives streaming events in real-time +6. Push notification click -- navigates to correct session +7. Cross-AI Review -- create, chat, send-back, end -- all work +8. `bin/codetap -a` -- lists sessions correctly +9. `bin/codetap --resume ` -- resumes correctly +10. Server restart -- sessions re-discovered, reviews survive diff --git a/docs/superpowers/plans/2026-03-24-window-name-to-uuid.md b/docs/superpowers/plans/2026-03-24-window-name-to-uuid.md new file mode 100644 index 0000000..88b3d70 --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-window-name-to-uuid.md @@ -0,0 +1,409 @@ +# Window Name to CLI UUID + Backward Compat Cleanup Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Two things: (1) Use CLI UUID as tmux window name, eliminating `window_name` column and all name-mapping. (2) Delete all backward-compat code (old migrations, deprecated aliases) since the app is pre-release. + +**Architecture:** 5 tasks: (1) add renameWindow to TmuxManager, (2) update adapters to use CLI UUID as window name, (3) clean up DB — remove window_name + delete old migrations + simplify schema, (4) remove ActiveSessionInfo.cliSessionId from public API, (5) update bin/codetap. + +**Tech Stack:** TypeScript, SQLite, tmux, Shell + +**After this plan completes, the session ID system is fully clean:** +- Single ID everywhere: CLI UUID +- tmux window name = CLI UUID +- No translations, no mappings, no deprecated aliases +- DB has minimal schema with no migration chain + +--- + +### Task 1: Add renameWindow to TmuxManager + +**Files:** +- Modify: `server/adapters/claude/tmux-manager.ts` + +- [ ] **Step 1: Add renameWindow method** + +After `killWindow()`, add: + +```typescript +async renameWindow(windowId: string, newName: string): Promise { + const target = `${SESSION_NAME}:${windowId}`; + await exec(TMUX, ['rename-window', '-t', target, newName]); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add server/adapters/claude/tmux-manager.ts +git commit -m "feat: add renameWindow to TmuxManager" +``` + +--- + +### Task 2: Use CLI UUID as tmux window name in both adapters + +**Files:** +- Modify: `server/adapters/claude/tmux-adapter.ts` +- Modify: `server/adapters/codex/codex-tmux-adapter.ts` + +**Claude adapter:** + +- [ ] **Step 1: startSession — use CLI UUID as window name** + +Remove `const windowName = ...`. Pass `sessionId` (CLI UUID) directly: + +```typescript +const windowId = await tmuxManager.createWindow(sessionId, cwd, parts.join(' ')); +``` + +Update `dbSessions.upsert` — pass `undefined` for windowName (removed in Task 3): + +```typescript +dbSessions.upsert(sessionId, cwd, windowId, undefined, 'claude'); +``` + +- [ ] **Step 2: resumeSession — use CLI UUID as window name** + +```typescript +const windowId = await tmuxManager.createWindow(cliUuid, cwd, command); +``` + +- [ ] **Step 3: attachSession — same pattern** + +Remove windowName from upsert calls, pass `undefined`. + +- [ ] **Step 4: Update _handleSessionStart discovery** + +Replace `w.name.startsWith('claude-')` with: + +```typescript +if (w.command.includes('claude') && !this.sessions.has(w.name)) { +``` + +This works because: CodeTap-created windows have CLI UUID names (which are in the sessions Map if managed). Desktop-started windows have arbitrary names (not in the Map). Either way, checking `!this.sessions.has(w.name)` correctly identifies unmanaged windows. + +- [ ] **Step 5: Simplify _findWindowForSession** + +```typescript +private async _findWindowForSession(sessionId: string, windowList?: TmuxWindow[]): Promise { + const windows = windowList || await tmuxManager.listWindows(); + // Primary: check DB for stored window_id + const dbRow = dbSessions.get(sessionId); + if (dbRow?.window_id && windows.some(w => w.id === dbRow.window_id)) { + return dbRow.window_id; + } + // Fallback: match by name (window name = CLI UUID = sessionId) + const match = windows.find(w => w.name === sessionId); + return match?.id || null; +} +``` + +Note: `listWindows()` is called once and reused for both checks. + +**Codex adapter:** + +- [ ] **Step 6: startSession — temp name, then rename** + +```typescript +const tempName = `codex-${Date.now()}`; +const windowId = await tmuxManager.createWindow(tempName, cwd, parts.join(' ')); +// ... _waitForReady, _watchForTranscript ... +const cliUUID = await this._waitForCliUUID(tempName); +// Rename tmux window to CLI UUID +const session = this.sessions.get(cliUUID); +if (session?.windowId) { + await tmuxManager.renameWindow(session.windowId, cliUUID); +} +return { sessionId: cliUUID }; +``` + +- [ ] **Step 7: resumeSession — use CLI UUID as window name** + +```typescript +const windowId = await tmuxManager.createWindow(codexUuid, cwd, parts.join(' ')); +``` + +- [ ] **Step 8: Pass undefined for windowName in all upsert calls** + +Both adapters: `dbSessions.upsert(id, cwd, windowId, undefined, adapter)`. + +- [ ] **Step 9: Commit** + +```bash +git add server/adapters/claude/tmux-adapter.ts server/adapters/codex/codex-tmux-adapter.ts +git commit -m "refactor: use CLI UUID as tmux window name" +``` + +--- + +### Task 3: Clean up DB — remove window_name, delete old migrations, simplify + +**Files:** +- Modify: `server/db.ts` + +This task does 3 things: (a) remove `window_name` column, (b) delete ALL old schema migrations, (c) delete `migrateJsonToSqlite`. Since the app is pre-release, no backward compat needed. + +- [ ] **Step 1: Replace entire initDB() migration section with a single clean schema** + +Delete ALL migration code in initDB(): +- `claude_session → cli_session` rename (~line 83-85) +- `cli_session → id + window_name` table rebuild (~line 93-117) +- Any `PRAGMA table_info` checks + +Replace the CREATE TABLE with the FINAL clean schema: + +```sql +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + cwd TEXT NOT NULL, + window_id TEXT, + adapter TEXT DEFAULT 'claude', + permission_mode TEXT DEFAULT 'default', + created_at TEXT DEFAULT (datetime('now')), + last_activity TEXT DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_sessions_window ON sessions(window_id); +``` + +No `cli_session`, no `window_name`, no `claude_session`. Just the final schema. + +- [ ] **Step 2: Delete migrateJsonToSqlite function** + +Remove the entire `migrateJsonToSqlite` function (~line 282-309) and its exported types (`JsonPushSub`, etc.). Also remove its caller — grep for `migrateJsonToSqlite` in `server/index.ts`. + +- [ ] **Step 3: Update SessionRow interface** + +```typescript +export interface SessionRow { + id: string; // CLI UUID + cwd: string; + window_id: string | null; + adapter: string; + permission_mode: string; + created_at: string; + last_activity: string; +} +``` + +Remove `window_name` and `cli_session` fields entirely. + +- [ ] **Step 4: Update upsert signature and SQL** + +```typescript +upsert(id: string, cwd: string, windowId?: string, adapter?: string): void { + stmts().sessionsUpsert.run(id, cwd, windowId || null, adapter || 'claude'); +}, +``` + +SQL: +```sql +INSERT INTO sessions (id, cwd, window_id, adapter) +VALUES (?, ?, ?, ?) +ON CONFLICT(id) DO UPDATE SET + cwd = excluded.cwd, + window_id = excluded.window_id, + last_activity = datetime('now') +``` + +- [ ] **Step 5: Update ALL dbSessions.upsert callers** + +Grep entire codebase. Change from 5-param to 4-param signature. Locations: +- `server/adapters/claude/tmux-adapter.ts` — all upsert calls (~4-5 sites) +- `server/adapters/codex/codex-tmux-adapter.ts` — all upsert calls (~4-5 sites) +- `server/adapters/codex/codex-tmux-adapter.ts` — `_waitForCliUUID` upsert + +- [ ] **Step 6: Handle existing DB with old schema** + +Since we deleted all migrations, if an old DB exists with `cli_session` or `window_name` columns, it will be incompatible. Add a simple destructive migration: + +```typescript +// If old schema detected, just drop and recreate +const tableInfo = d.prepare("PRAGMA table_info('sessions')").all() as { name: string }[]; +const hasOldColumns = tableInfo.some(c => c.name === 'cli_session' || c.name === 'window_name' || c.name === 'claude_session'); +if (hasOldColumns) { + d.exec('DROP TABLE sessions'); + // Table will be recreated by the CREATE TABLE IF NOT EXISTS above + d.exec(`CREATE TABLE sessions (...final schema...)`); + console.log('[db] Dropped old sessions table (pre-release cleanup)'); +} +``` + +This is safe because the app is pre-release and `clearAll()` deletes all rows on shutdown anyway. + +- [ ] **Step 7: Remove migrateJsonToSqlite caller from server/index.ts** + +Grep for `migrateJsonToSqlite` in `server/index.ts` and remove the call. + +- [ ] **Step 8: Commit** + +```bash +git add server/db.ts server/index.ts server/adapters/claude/tmux-adapter.ts server/adapters/codex/codex-tmux-adapter.ts +git commit -m "refactor: clean DB schema — remove window_name, delete old migrations" +``` + +--- + +### Task 4: Remove ActiveSessionInfo.cliSessionId from public API + +**Files:** +- Modify: `server/adapters/interface.ts` +- Modify: `server/adapters/claude/tmux-adapter.ts` +- Modify: `server/adapters/codex/codex-tmux-adapter.ts` +- Modify: `src/hooks/useSessions.ts` +- Modify: `src/components/SessionsView.tsx` +- Modify: `server/index.ts` + +- [ ] **Step 1: Remove cliSessionId from ActiveSessionInfo** + +In `server/adapters/interface.ts`, remove: + +```typescript +/** @deprecated Use sessionId instead — same value after unification */ +cliSessionId: string; +``` + +- [ ] **Step 2: Remove cliSessionId from getActiveSessions in both adapters** + +In Claude's `getActiveSessions()`: remove `cliSessionId: session.cliSessionId` from the returned object. +In Codex's `getActiveSessions()`: same. + +- [ ] **Step 3: Update frontend useSessions.ts** + +Change `if (s.cliSessionId) ids.add(s.cliSessionId)` to `if (s.sessionId) ids.add(s.sessionId)`. (May already be done — verify.) + +- [ ] **Step 4: Update SessionsView.tsx** + +Remove any remaining `session.cliSessionId` references. Use `session.sessionId` everywhere. + +- [ ] **Step 5: Update server/index.ts active-sessions endpoint** + +The active-sessions handler may still reference `s.cliSessionId` for child filtering. Change to `s.sessionId`. + +- [ ] **Step 6: TypeScript compilation check** + +`npx tsc --noEmit` — zero errors. + +- [ ] **Step 7: Commit** + +```bash +git add server/adapters/interface.ts server/adapters/claude/tmux-adapter.ts server/adapters/codex/codex-tmux-adapter.ts src/hooks/useSessions.ts src/components/SessionsView.tsx server/index.ts +git commit -m "refactor: remove deprecated cliSessionId from public API" +``` + +--- + +### Task 5: Update bin/codetap + +**Files:** +- Modify: `bin/codetap` + +- [ ] **Step 1: get_project_sessions() — query id** + +```bash +# Before: SELECT window_name FROM sessions WHERE cwd=... +# After: SELECT id FROM sessions WHERE cwd=... +``` + +tmux window names are now CLI UUIDs = DB `id`. + +- [ ] **Step 2: -a listing — match by id** + +```bash +# Before: SELECT id, adapter, window_name, cwd FROM sessions WHERE window_name IN (...) +# After: SELECT id, adapter, cwd FROM sessions WHERE id IN (...) +``` + +- [ ] **Step 3: --resume — simplified** + +```bash +# Before: WHERE id='...' OR window_name='...' +# After: WHERE id='...' +``` + +- [ ] **Step 4: Window name generation for new/continue** + +Generate UUID for Claude (use `--session-id` value): + +```bash +SESSION_UUID=$(python3 -c 'import uuid; print(uuid.uuid4())') +WINDOW_NAME="$SESSION_UUID" +# For Claude: pass --session-id $SESSION_UUID +``` + +For Codex: use temp name, server will rename after UUID discovery. + +- [ ] **Step 5: Remove any window_name references** + +Grep entire script for `window_name` — should be zero after above changes. + +- [ ] **Step 6: Commit** + +```bash +git add bin/codetap +git commit -m "refactor: bin/codetap uses CLI UUID as tmux window name" +``` + +--- + +## Self-Review Checklist + +### Compilation Safety +- Task 2 passes `undefined` for windowName param → Task 3 removes the param. Between Tasks 2 and 3, the code compiles because `undefined` is valid for an optional `string?` param. ✅ +- Task 4 removes `cliSessionId` from `ActiveSessionInfo`. All consumers updated in same task. ✅ + +### Codex _waitForCliUUID Flow +- Session starts under temp name `codex-{timestamp}` → stored in Map under temp key +- Hook/watcher sets `session.cliSessionId` → `_waitForCliUUID` polls and detects it +- `_waitForCliUUID` re-keys Map: delete temp key, set CLI UUID key +- **NEW**: `renameWindow(windowId, cliUUID)` renames the tmux window +- After this: window name = Map key = DB id = CLI UUID ✅ +- `session.cliSessionId` field kept in `CodexSessionState` (needed for _waitForCliUUID polling). Not exposed publicly. ✅ + +### handleReconnect +- User clicks session → `registerClient(conn, sessionId)` where sessionId = CLI UUID +- `hasActiveWindow(sessionId)` checks if tmux window exists for this session +- After window name change: `_findWindowForSession(sessionId)` finds by `w.name === sessionId` (window name = CLI UUID) ✅ +- Desktop later opens same session → events broadcast to CLI UUID → mobile receives ✅ + +### DB Schema Final State +```sql +sessions: id(PK/UUID), cwd, window_id(@N), adapter, permission_mode, created_at, last_activity +session_reviews: id, parent_cli_session_id, child_cli_session_id, child_adapter, ... +``` +No `cli_session`, no `window_name`, no `claude_session`. Clean. ✅ + +### Old DB Handling +- If old DB exists with legacy columns → DROP TABLE + recreate. Data loss is fine (pre-release). ✅ +- `session_reviews` table is not touched — it was created with the correct schema. ✅ + +### bin/codetap +- `-a` mode: tmux window names are now UUIDs, DB `id` is UUID → direct IN clause match ✅ +- `--resume`: accepts UUID → `WHERE id='...'` ✅ +- `new` mode: generates UUID as window name ✅ +- `--continue`: queries most recent session by `id` → resume it ✅ + +### Things NOT changed (correct to leave alone) +- `SessionState.cliSessionId` in both adapters — needed internally for Codex UUID discovery. Not exposed publicly after Task 4. ✅ +- `session_reviews` table column names (`parent_cli_session_id`, `child_cli_session_id`) — these are just column names, not related to the internal ID concept. They store CLI UUIDs. ✅ +- `tmux-manager.ts` `TmuxWindow.name` field — still populated from `#{window_name}` tmux format. Now contains CLI UUID. ✅ + +### Potential Issues +- **UUID as tmux tab name is long (36 chars)** — cosmetic only, tmux truncates display. Not a functional issue. +- **Desktop-started sessions (not via CodeTap)** — their tmux window name won't be a UUID. But `handleSessionStart` uses `w.command.includes('claude')` for discovery, not window name format. Hook body provides the CLI UUID. ✅ +- **`python3 -c 'import uuid; print(uuid.uuid4())'` in bin/codetap** — requires Python 3. Could use `uuidgen` instead (available on macOS). Safer: `uuidgen | tr '[:upper:]' '[:lower:]'` + +--- + +## Verification + +1. Delete `~/.codetap/codetap.db` to start fresh (or let migration drop old table) +2. `CLAUDE_UI_PASSWORD=test npm run dev` — server starts cleanly +3. `tmux list-windows -t codetap` — windows named with CLI UUIDs +4. Click historical session → history loads immediately +5. New Claude session → window named with UUID, messages work +6. New Codex session → starts with temp name, renamed to UUID +7. `bin/codetap -a` → lists sessions +8. `bin/codetap --resume ` → works +9. Active sessions tab → shows sessions, no `cliSessionId` references +10. `grep -rn "window_name\|cli_session\|cliSessionId\|claude_session" server/ src/` → zero results (except internal `SessionState.cliSessionId` in adapters) diff --git a/docs/superpowers/plans/2026-03-25-review-panel-ux-fixes.md b/docs/superpowers/plans/2026-03-25-review-panel-ux-fixes.md new file mode 100644 index 0000000..68e7fe9 --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-review-panel-ux-fixes.md @@ -0,0 +1,422 @@ +# Review Panel UX Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix Cross-AI Review UX issues: marker leaks, panel minimize/expand, send-back button, icon polish, read-only history, adapter icons. + +**Spec:** `docs/superpowers/specs/2026-03-25-review-panel-ux-fixes-design.md` + +--- + +### Task 1: Fix marker bugs (Session List + trailing `\\n`) + +**Files:** +- `server/adapters/codex/codex-tmux-adapter.ts` +- `src/lib/content-utils.ts` + +- [ ] **Step 1: Fix `stripMarker` regex to handle literal `\\n`** + +In `src/lib/content-utils.ts` line 5, change: +```typescript +const CODETAP_REF_REGEX = /^\[CODETAP_REF:[^\]]+\]\n?/; +``` +To: +```typescript +const CODETAP_REF_REGEX = /^\[CODETAP_REF:[^\]]+\](?:\\n|\n)?/; +``` + +This matches both real newline (`\n`) and literal two-char `\\n` (which Codex sendMessage produces). + +- [ ] **Step 2: Strip marker from `firstPrompt` in Codex adapter** + +In `server/adapters/codex/codex-tmux-adapter.ts` around line 445, after extracting text: +```typescript +if (text) session.firstPrompt = text.substring(0, 200); +``` + +Change to: +```typescript +if (text) { + // Strip CODETAP_REF marker if present + const stripped = text.replace(/^\[CODETAP_REF:[^\]]+\](?:\\n|\n)?/, ''); + session.firstPrompt = stripped.substring(0, 200); +} +``` + +- [ ] **Step 3: Verify + commit** + +```bash +npx tsc --noEmit +git add src/lib/content-utils.ts server/adapters/codex/codex-tmux-adapter.ts +git commit -m "fix: strip CODETAP_REF marker from session list + handle literal \\n" +``` + +--- + +### Task 2: Fix send-back button + icon polish + copy feedback + +**Files:** +- `src/components/ChatBody.tsx` +- `src/components/MessageBubble.tsx` + +- [ ] **Step 1: Fix `showActions` to include `onSendBack` (ChatBody.tsx line 199)** + +**ROOT CAUSE:** `showActions` requires `sendTargets` but FloatingReviewPanel only passes `onSendBack`. + +Change line 199 from: +```typescript +showActions={msg.role === 'assistant' && !streaming && !!sendTargets && sendTargets.length > 0} +``` +To: +```typescript +showActions={msg.role === 'assistant' && !streaming && (!!onSendBack || (!!sendTargets && sendTargets.length > 0))} +``` + +- [ ] **Step 2: Remove border from icon buttons (MessageBubble.tsx lines 186-210)** + +Change copy button className (line 188) from: +``` +"flex items-center justify-center w-7 h-7 text-text-dim border border-border rounded-md hover:bg-white/5 transition-colors" +``` +To: +``` +"flex items-center justify-center w-6 h-6 text-text-dim/40 hover:text-text-dim hover:bg-white/5 rounded transition-colors" +``` + +Change send-back button className (line 196) from: +``` +"flex items-center justify-center w-7 h-7 text-green-400 border border-green-400/30 rounded-md hover:bg-green-400/10 transition-colors" +``` +To: +``` +"flex items-center justify-center w-6 h-6 text-green-400/40 hover:text-green-400 hover:bg-green-400/10 rounded transition-colors" +``` + +Apply similar change to the SendDropdown button if it has border. + +- [ ] **Step 3: Reduce icon size and stroke width (MessageBubble.tsx lines 34-59)** + +In all three icon components (CopyIcon, SendIcon, SendBackIcon), change: +``` +width="14" height="14" ... strokeWidth="2" +``` +To: +``` +width="12" height="12" ... strokeWidth="1.5" +``` + +- [ ] **Step 4: Add copy feedback — checkmark confirmation** + +Add `useState` import. Add state inside `MessageBubble`: +```typescript +const [copied, setCopied] = useState(false); +``` + +Change the copy button onClick (line 187): +```typescript +onClick={() => { + navigator.clipboard.writeText(extractTextFromBlocks(content)); + setCopied(true); + setTimeout(() => setCopied(false), 2000); +}} +``` + +Change the copy button icon rendering: +```tsx +{copied ? : } +``` + +Add CheckIcon component: +```typescript +function CheckIcon() { + return ( + + + + ); +} +``` + +When `copied` is true, change button color to green briefly: +```typescript +className={`flex items-center justify-center w-6 h-6 rounded transition-colors ${ + copied ? 'text-green-400' : 'text-text-dim/40 hover:text-text-dim hover:bg-white/5' +}`} +``` + +- [ ] **Step 5: Verify + commit** + +```bash +npx tsc --noEmit +git add src/components/ChatBody.tsx src/components/MessageBubble.tsx +git commit -m "fix: send-back button visible, icon polish (no border, smaller), copy checkmark feedback" +``` + +--- + +### Task 3: Panel minimize — thin bar above input + +**Files:** +- `src/components/FloatingReviewPanel.tsx` +- `src/components/ChatBody.tsx` (placeholder prop) + +- [ ] **Step 1: Minimized bar rendered by ChatView (NOT FloatingReviewPanel)** + +The minimized bar must sit between the message scroll area and the input — in the normal document flow. FloatingReviewPanel can't do this because it renders as an overlay. Solution: ChatView renders the bar directly. FloatingReviewPanel returns `null` when `panelState === 'minimized'`. + +In FloatingReviewPanel, change the minimized block (lines 57-67) to: +```tsx +if (panelState === 'minimized') return null; +``` + +In ChatView, add a `ReviewMinimizedBar` inline component (or extract to a small file). Render it between ChatBody and the footer, using `renderAboveInput` slot on ChatBody: + +```tsx +// In ChatView's renderAboveInput callback: +renderAboveInput={() => ( + <> + {activeReview && reviewPanelState === 'minimized' && ( +
+
+ + + {getBrand(activeReview.childAdapter).displayName} + + {activeReview.reviewTitle || 'review'} · active +
+
+ + +
+
+ )} + + +)} +``` + +Note: no message count shown — parent doesn't have access to child message count. Show "active" instead. + +- [ ] **Step 2: Add ▼ Minimize button to expanded panel header (lines 98-113)** + +In the expanded panel header, add a minimize button next to End: + +```tsx + +``` + +- [ ] **Step 3: Update child input placeholder** + +In FloatingReviewPanel, pass a custom placeholder to ChatBody. Add `inputPlaceholder` prop to ChatBody: + +```typescript +// ChatBody props +inputPlaceholder?: string; +``` + +In ChatBody, pass to ShimmerInput: +```tsx + +``` + +FloatingReviewPanel passes: +```tsx +inputPlaceholder={`Reply to ${brand.displayName} review...`} +``` + +- [ ] **Step 4: Verify + commit** + +```bash +npx tsc --noEmit +git add src/components/FloatingReviewPanel.tsx src/components/ChatView.tsx src/components/ChatBody.tsx +git commit -m "feat: review panel minimizes to thin bar above input, custom placeholder" +``` + +--- + +### Task 4: CollapsedReviewCard onClick + read-only panel + +**Files:** +- `src/components/CollapsedReviewCard.tsx` +- `src/components/ChatView.tsx` +- `src/components/FloatingReviewPanel.tsx` + +- [ ] **Step 1: Pass `childSessionId` to CollapsedReviewCard** + +In ChatView `renderReviewMarkers` (line 268), the review object has `child_cli_session_id`. Pass it: + +```tsx + handleOpenReadOnlyReview(review)} +/> +``` + +Add handler in ChatView: +```typescript +const handleOpenReadOnlyReview = useCallback((review: any) => { + setActiveReview({ + reviewId: review.id, + childSessionId: review.child_cli_session_id, + childCliSessionId: review.child_cli_session_id, + childAdapter: review.child_adapter, + anchorMessageId: review.anchor_message_id, + reviewTitle: review.review_title, + }); + setReviewPanelState('expanded'); + setReadOnlyReview(true); // NEW state +}, []); +``` + +Add state: +```typescript +const [readOnlyReview, setReadOnlyReview] = useState(false); +``` + +- [ ] **Step 2: Add `readOnly` prop to FloatingReviewPanel** + +```typescript +interface FloatingReviewPanelProps { + // ... existing props + readOnly?: boolean; +} +``` + +When `readOnly`: +- Header: gray instead of green, "ended" label, ✕ Close instead of End +- No ShimmerInput — show "Review ended — read only" text +- No send-back action + +Pass to ChatBody: +```tsx + +``` + +If readOnly, don't render ShimmerInput in ChatBody. Add a `hideInput` prop to ChatBody: +```typescript +hideInput?: boolean; +``` + +- [ ] **Step 3: Update onEnd for read-only panel** + +FloatingReviewPanel uses the existing `onEnd` callback. The `readOnly` prop controls what the button says: +```tsx + +``` + +In ChatView's `onEnd` handler, check `readOnlyReview`: +```tsx +onEnd={async () => { + if (!readOnlyReview && activeReview.reviewId) { + try { await api.endReview(activeReview.reviewId); } catch {} + } + setActiveReview(null); + setReviewPanelState('hidden'); + setReviewInitialPrompt(null); + setReviewCwd(null); + setReadOnlyReview(false); +}} +``` + +Also reset `readOnlyReview` in `handleReviewSelect` (when opening a new active review): +```typescript +setReadOnlyReview(false); +``` + +- [ ] **Step 4: Verify + commit** + +```bash +npx tsc --noEmit +git add src/components/CollapsedReviewCard.tsx src/components/ChatView.tsx src/components/FloatingReviewPanel.tsx src/components/ChatBody.tsx +git commit -m "feat: collapsed review card opens read-only panel with child session history" +``` + +--- + +### Task 5: Adapter icons from thesvg.org + +**Files:** +- `src/components/AdapterIcon.tsx` + +- [ ] **Step 1: Fetch SVGs from thesvg.org** + +Visit https://www.thesvg.org/ and search for: +- "Anthropic" or "Claude" → get the official Anthropic logo SVG +- "OpenAI" → get the official OpenAI logo SVG + +- [ ] **Step 2: Update ClaudeIcon and CodexIcon** + +Replace the SVG paths in `AdapterIcon.tsx` (lines 10-37) with the official ones from thesvg.org. Keep: +- `fill="currentColor"` for color control +- `viewBox` matching the original SVG +- `width={size} height={size}` props + +- [ ] **Step 3: Verify + commit** + +```bash +npx tsc --noEmit +git add src/components/AdapterIcon.tsx +git commit -m "feat: use official adapter icons from thesvg.org" +``` + +--- + +### Task 6: E2E Verification + +- [ ] **Step 1:** Start server, create Codex session → verify no marker in session list +- [ ] **Step 2:** Open Codex session → verify no `\\n` at start of first message +- [ ] **Step 3:** Create Claude session → send message → Click send icon → Direct send → verify panel opens with Codex response +- [ ] **Step 4:** Verify send-back ↩ icon appears on child responses +- [ ] **Step 5:** Verify copy icon → click → ✓ checkmark appears → reverts after 2s +- [ ] **Step 6:** Verify icon buttons have no border, smaller size +- [ ] **Step 7:** Click ▼ minimize → verify thin bar appears above input → parent input usable +- [ ] **Step 8:** Click ▲ Expand → panel opens again +- [ ] **Step 9:** Click End → verify panel closes → review markers appear in history +- [ ] **Step 10:** Click collapsed review card → verify read-only panel opens (no input, gray header) +- [ ] **Step 11:** Close read-only panel → verify return to normal chat + +--- + +## Self-Review + +### showActions bug fix +Before: `showActions = assistant && !streaming && !!sendTargets && sendTargets.length > 0` +After: `showActions = assistant && !streaming && (!!onSendBack || (!!sendTargets && sendTargets.length > 0))` +FloatingReviewPanel passes `onSendBack` but not `sendTargets` → now shows action buttons ✅ + +### Minimized bar placement +Renders as normal flow element (not absolute) between ChatBody and input. Parent chat is fully scrollable and input is fully usable. ✅ + +### Read-only panel +Uses same FloatingReviewPanel with `readOnly` flag. RECONNECT to child session for history. No input, no send-back. ✅ + +### Files changed +| File | Changes | +|------|---------| +| `src/lib/content-utils.ts` | Fix stripMarker regex | +| `server/adapters/codex/codex-tmux-adapter.ts` | Strip marker from firstPrompt | +| `src/components/ChatBody.tsx` | Fix showActions, add inputPlaceholder/hideInput | +| `src/components/MessageBubble.tsx` | Icon polish, copy feedback, no border | +| `src/components/FloatingReviewPanel.tsx` | Thin bar minimize, readOnly, custom placeholder | +| `src/components/ChatView.tsx` | Minimized bar, read-only review handler | +| `src/components/CollapsedReviewCard.tsx` | Pass onClick with review data | +| `src/components/AdapterIcon.tsx` | Official SVGs from thesvg.org | diff --git a/docs/superpowers/plans/2026-03-25-review-state-separation.md b/docs/superpowers/plans/2026-03-25-review-state-separation.md new file mode 100644 index 0000000..4fbcae2 --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-review-state-separation.md @@ -0,0 +1,271 @@ +# Review State Separation + Session List Cleanup Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Separate active review and history review states so viewing historical reviews doesn't conflict with active reviews, fix marker in session list, hide child sessions from session list. + +**Spec:** `docs/superpowers/specs/2026-03-25-review-state-separation-design.md` + +--- + +### Task 1: Separate activeReview and historyReview states + +**Files:** +- `src/hooks/useChat.ts` +- `src/components/ChatView.tsx` +- `src/components/FloatingReviewPanel.tsx` + +- [ ] **Step 1: Add `historyReview` state to useChat, rename `reviewPanelState` → `activeReviewPanel`** + +In `src/hooks/useChat.ts`: + +Change state declarations (around line 147): +```typescript +// OLD: +const [reviewPanelState, setReviewPanelState] = useState<'expanded' | 'minimized'>('expanded'); + +// NEW: +const [activeReviewPanel, setActiveReviewPanel] = useState<'expanded' | 'minimized'>('expanded'); +const [historyReview, setHistoryReview] = useState(null); +``` + +Export `historyReview`, `setHistoryReview`, `activeReviewPanel`, `setActiveReviewPanel` in the return value. Remove old `reviewPanelState`, `setReviewPanelState` exports. + +- [ ] **Step 2: Remove `readOnlyReview` state from ChatView** + +In `src/components/ChatView.tsx`, remove: +```typescript +const [readOnlyReview, setReadOnlyReview] = useState(false); +``` + +Replace all `readOnlyReview` references with `!!historyReview` (from useChat). + +Replace all `setReadOnlyReview(...)` calls — remove them (historyReview existence replaces the boolean). + +- [ ] **Step 3: Update `handleOpenReadOnlyReview` to use `historyReview`** + +Change from: +```typescript +setActiveReview({ ...review data... }); +setReviewPanelState('expanded'); +setReadOnlyReview(true); +``` + +To: +```typescript +setHistoryReview({ ...review data... }); +if (activeReview) setActiveReviewPanel('minimized'); // minimize active if exists +``` + +- [ ] **Step 4: Update `closeReview` to clear both states** + +```typescript +const closeReview = useCallback(async () => { + if (activeReview?.reviewId) { + try { await api.endReview(activeReview.reviewId); } catch {} + } + setActiveReview(null); + setHistoryReview(null); + setReviewInitialPrompt(null); + setReviewCwd(null); +}, [activeReview]); +``` + +No more `readOnlyReview` check — `closeReview` always ends the active review. + +Add a separate `closeHistoryPanel`: +```typescript +const closeHistoryPanel = useCallback(() => { + setHistoryReview(null); +}, []); +``` + +- [ ] **Step 5: Update `handleReviewSelect` (start new review)** + +Add: `setHistoryReview(null)` to clear any open history panel. +Change: `setReviewPanelState('expanded')` → `setActiveReviewPanel('expanded')` + +- [ ] **Step 6: Update FloatingReviewPanel rendering in ChatView** + +Compute panel review outside JSX (not in IIFE): + +```typescript +// Near other memos/derived state +const panelReview = historyReview || (activeReviewPanel === 'expanded' ? activeReview : null); +const isHistoryPanel = !!historyReview; +``` + +Replace the current conditional rendering with: + +```tsx +{panelReview && ( + setActiveReviewPanel('minimized')} + readOnly={isHistoryPanel} + initialPrompt={!isHistoryPanel ? (reviewInitialPrompt || undefined) : undefined} + cwd={!isHistoryPanel ? (reviewCwd || undefined) : undefined} + onSessionCreated={!isHistoryPanel ? onSessionCreatedCallback : undefined} + /> +)} +``` + +Note: `panelState` and `onPanelStateChange` props removed (Step 8). + +- [ ] **Step 7: Update minimized bar in `renderAboveInput`** + +Show minimized bar when: `activeReview && (activeReviewPanel === 'minimized' || historyReview)` + +Update ▲ Expand button: +```typescript +onClick={() => { setHistoryReview(null); setActiveReviewPanel('expanded'); }} +``` + +The minimized bar is for the ACTIVE review only. It always shows active review info, never history info. + +``` +Bar shows when: activeReview !== null AND (activeReviewPanel === 'minimized' OR historyReview !== null) +Bar content: always shows activeReview info +Bar label: always "active" (not "ended") +Bar buttons: ▲ Expand (closes history + expands active) | End (ends active review) +``` + +Remove all `readOnlyReview` / `historyReview` checks from the bar rendering — bar is purely about active review. + +- [ ] **Step 8: Update FloatingReviewPanel type — remove `panelState` prop** + +Since FloatingReviewPanel is only rendered when it should be visible (expanded), the `panelState` prop is no longer needed. The parent (ChatView) controls visibility. + +Remove from interface: +```typescript +panelState: 'expanded' | 'minimized'; +onPanelStateChange: (state: 'expanded' | 'minimized') => void; +``` + +Remove the `if (panelState === 'minimized') return null;` check. + +Keep the ▼ minimize button in the header — it calls a new `onMinimize` prop: +```typescript +onMinimize?: () => void; // only for active (non-readOnly) panel +``` + +ChatView passes: `onMinimize={() => setActiveReviewPanel('minimized')}` + +- [ ] **Step 9: Verify + commit** + +```bash +npx tsc --noEmit +git add src/hooks/useChat.ts src/components/ChatView.tsx src/components/FloatingReviewPanel.tsx +git commit -m "refactor: separate activeReview and historyReview states, mutual exclusion" +``` + +--- + +### Task 2: Fix marker in session list (Codex getSessions) + +**Files:** +- `server/adapters/codex/jsonl-store.ts` + +- [ ] **Step 1: Strip marker in `getSessions` (line 204)** + +Change: +```typescript +firstPrompt: entry.text ? entry.text.slice(0, 200) : null, +``` + +To: +```typescript +firstPrompt: entry.text + ? entry.text.replace(/^\[CODETAP_REF:[^\]]+\](?:\\n|\n)?/, '').slice(0, 200) + : null, +``` + +- [ ] **Step 2: Verify + commit** + +```bash +npx tsc --noEmit +git add server/adapters/codex/jsonl-store.ts +git commit -m "fix: strip CODETAP_REF marker from Codex getSessions firstPrompt" +``` + +--- + +### Task 3: Hide child sessions from session list + +**Files:** +- `server/index.ts` + +- [ ] **Step 1: Filter child sessions from project session list** + +Find the GET endpoint that returns sessions for a project (search for `getSessions` calls in `server/index.ts`). After getting the sessions array, filter out child session IDs: + +```typescript +const childIds = sessionReviews.getAllChildIds(); +const filtered = sessions.filter(s => !childIds.has(s.sessionId)); +``` + +`getAllChildIds()` already exists in `server/db.ts` — it returns a `Set` of child CLI session IDs. Verify it includes ALL child IDs (both active and ended reviews), not just active ones. Ended child sessions should also be hidden from the session list. + +- [ ] **Step 2: Filter child sessions from active sessions list** + +Find the GET endpoint for active sessions. Apply the same filter: + +```typescript +const childIds = sessionReviews.getAllChildIds(); +const filtered = activeSessions.filter(s => !childIds.has(s.sessionId)); +``` + +- [ ] **Step 3: Verify + commit** + +```bash +npx tsc --noEmit +git add server/index.ts +git commit -m "fix: hide child review sessions from project and active session lists" +``` + +--- + +### Task 4: E2E Verification + +- [ ] **Step 1:** Start server, create Claude session, send message +- [ ] **Step 2:** Send to Codex → verify panel opens with response +- [ ] **Step 3:** Click ▼ minimize → verify thin bar appears, parent input usable +- [ ] **Step 4:** Click ▲ expand → verify panel opens again +- [ ] **Step 5:** Click End → verify panel closes, review markers appear +- [ ] **Step 6:** Click collapsed review card → verify read-only panel opens (history) +- [ ] **Step 7:** Verify minimized bar still shows active review info (if active review exists) +- [ ] **Step 8:** Close read-only panel → verify return to normal +- [ ] **Step 9:** Check session list → verify no CODETAP_REF marker, no child sessions visible + +--- + +## Self-Review + +### State model after Task 1 +``` +activeReview — ongoing review (null if none) +historyReview — historical review being viewed (null if none) +activeReviewPanel — 'expanded' | 'minimized' + +Panel: historyReview || (expanded activeReview) || nothing +Bar: activeReview && (minimized || historyReview) +``` + +### Mutual exclusion +- Open history → minimize active ✅ +- Expand active → close history ✅ +- End active → close both ✅ +- Start new → close history + expand ✅ + +### Files changed +| File | Changes | +|------|---------| +| `src/hooks/useChat.ts` | Add historyReview state, rename reviewPanelState → activeReviewPanel | +| `src/components/ChatView.tsx` | Remove readOnlyReview, use historyReview, update closeReview/bar/panel rendering | +| `src/components/FloatingReviewPanel.tsx` | Remove panelState prop, add onMinimize | +| `server/adapters/codex/jsonl-store.ts` | Strip marker in getSessions | +| `server/index.ts` | Filter child sessions from session/active lists | diff --git a/docs/superpowers/plans/2026-03-25-unified-session-path.md b/docs/superpowers/plans/2026-03-25-unified-session-path.md new file mode 100644 index 0000000..7048bc8 --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-unified-session-path.md @@ -0,0 +1,532 @@ +# Unified Session Creation Path Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Unify Cross-AI Review child session creation to use the same WS QUERY path as normal sessions, eliminating the HTTP-creates-session / WS-reconnects split. + +**Spec:** `docs/superpowers/specs/2026-03-25-unified-session-path-design.md` + +**Architecture:** Merge `sendMessage` and `pasteToSession` in BOTH adapters (Codex + Claude) so QUERY handles any content size. Move session creation from POST /api/reviews to FloatingReviewPanel's useChat QUERY. POST /api/reviews becomes a registration-only endpoint called after the session exists. + +--- + +## Edge Cases & Scenarios + +Before reading the tasks, understand all scenarios this plan must handle: + +| # | Scenario | Path | Notes | +|---|----------|------|-------| +| A | Normal Codex session from WebUI | QUERY → handleQuery → startSession → registerClient → sendMessage | ✅ Already works | +| B | Cross-AI Review child (same device) | QUERY → handleQuery (same as A) → then POST /api/reviews/register | ✅ New unified path | +| C | Multi-device: other device connects to parent with active review | RECONNECT → handleReconnect loads active reviews → REVIEW_STARTED → FloatingReviewPanel mounts → RECONNECT to child | ⚠️ RECONNECT path must be preserved | +| D | Page refresh: reconnect to parent + active review | Same as C | ⚠️ RECONNECT path must be preserved | +| E | registerReview POST fails after session created | Session exists but no DB record → retry or show error | ⚠️ Error handling needed | +| F | User clicks End before registerReview completes | reviewId is empty → must not call endReview('') | ⚠️ Guard needed | +| G | Send-back to Claude parent | Claude sendMessage must handle large multiline text | ⚠️ Claude merge needed | +| H | Send-back to Codex parent | Codex sendMessage already handles (Task 1) | ✅ | +| I | CODETAP_REF marker injection | handleQuery injects for non-Claude → sendMessage auto-splits | ✅ | + +**Key constraint: RECONNECT path must be preserved** for scenarios C and D. FloatingReviewPanel must support BOTH: +- New path: `initialPrompt` provided, no `childSessionId` → useChat QUERY (creates session) +- Reconnect path: `childSessionId` provided, no `initialPrompt` → useChat RECONNECT (joins existing session) + +--- + +### Task 1: Merge sendMessage and pasteToSession in BOTH adapters + +**Files:** +- `server/adapters/codex/codex-tmux-adapter.ts` +- `server/adapters/codex/index.ts` +- `server/adapters/claude/tmux-adapter.ts` +- `server/adapters/claude/index.ts` + +This task is standalone — makes `sendMessage` handle all content sizes in both adapters without breaking anything. + +- [ ] **Step 1: Rewrite Codex `sendMessage()` (lines 204-221)** + +Merge the logic from `pasteToSession()` (lines 223-258) into `sendMessage()`: + +```typescript +async sendMessage(sessionId: string, text: string, options: QueryOptions = {}): Promise { + const session = this.sessions.get(sessionId); + if (!session) throw new Error(`Session ${sessionId} not found`); + + session._promptSenderClientId = options.clientId || null; + session.isProcessing = true; + + // Restart pane monitor if it was stopped + if (!session.monitor) { + this._startMonitor(sessionId, session.windowId); + } + + // Large or multiline content: use pasteBuffer (fast, handles newlines) + if (text.length > 500 || text.includes('\n')) { + const singleLine = text.replace(/\n/g, '\\n'); + + // Fresh Codex sessions have TUI placeholder text. If content starts with + // CODETAP_REF marker, send marker via sendKeys first (clears placeholder), + // then pasteBuffer the rest. + const markerMatch = singleLine.match(/^\[CODETAP_REF:[^\]]+\]/); + if (markerMatch) { + const marker = markerMatch[0]; + const rest = singleLine.substring(marker.length); + await tmuxManager.sendKeys(session.windowId, marker, false); + await new Promise(r => setTimeout(r, 200)); + if (rest) { + await tmuxManager.pasteBuffer(session.windowId, rest, false); + } + } else { + await tmuxManager.pasteBuffer(session.windowId, singleLine, false); + } + await new Promise(r => setTimeout(r, 300)); + await tmuxManager.sendControl(session.windowId, 'Enter'); + } else { + // Short text: sendKeys (character-by-character) + await tmuxManager.sendKeys(session.windowId, text, false); + await new Promise(r => setTimeout(r, 200)); + await tmuxManager.sendControl(session.windowId, 'Enter'); + } + + // If there are pending hook bodies waiting for marker matching, try now + if (this._pendingHookBodies.size > 0 && session._watcherPending) { + this._tryMatchPending(sessionId); + } +} +``` + +- [ ] **Step 2: Remove Codex `pasteToSession()` method (lines 223-258)** + +Delete the entire method from `CodexTmuxAdapter`. + +- [ ] **Step 3: Update `CodexAdapter.pasteToSession` in `server/adapters/codex/index.ts`** + +Delegate to sendMessage (keeps public API working until Task 3 removes callers): + +```typescript +async pasteToSession(sid: string, content: string): Promise { + return this._tmux.sendMessage(sid, content); +} +``` + +- [ ] **Step 4: Update Claude `sendMessage()` in `server/adapters/claude/tmux-adapter.ts`** + +Currently Claude's `sendMessage` always uses `sendKeys(text, true)`. Add large content handling: + +```typescript +async sendMessage(sessionId: string, text: string, options: QueryOptions = {}): Promise { + const session = this.sessions.get(sessionId); + if (!session) throw new Error(`Session ${sessionId} not found`); + session._promptSenderClientId = options.clientId || null; + if (!session.monitor) { + this._startMonitor(sessionId, session.windowId); + } + + // Large or multiline content: use pasteBuffer (fast) + if (text.length > 500 || text.includes('\n')) { + await tmuxManager.pasteBuffer(session.windowId, text); + } else { + await tmuxManager.sendKeys(session.windowId, text, true); + } +} +``` + +Note: Claude's `pasteBuffer` already handles Enter (sendEnter defaults to true in tmux-manager). Claude doesn't need `\n` → `\\n` replacement or CODETAP_REF marker splitting (Claude generates its own UUID upfront, no placeholder issue). + +- [ ] **Step 5: Update `ClaudeAdapter.pasteToSession` in `server/adapters/claude/index.ts`** + +Delegate to sendMessage: + +```typescript +async pasteToSession(sid: string, content: string): Promise { + return this._tmux.sendMessage(sid, content); +} +``` + +- [ ] **Step 6: Verify TypeScript compilation** + +```bash +npx tsc --noEmit +``` + +- [ ] **Step 7: Commit** + +```bash +git add server/adapters/codex/codex-tmux-adapter.ts server/adapters/codex/index.ts server/adapters/claude/tmux-adapter.ts server/adapters/claude/index.ts +git commit -m "refactor: merge sendMessage and pasteToSession in both adapters — auto-detect large content" +``` + +--- + +### Task 2: Add registerReview API endpoint + update frontend + +**Files:** +- `server/index.ts` — add POST /api/reviews/register +- `src/lib/api.ts` — add `registerReview()` function +- `src/components/ChatView.tsx` — handleReviewSelect uses local state, calls registerReview after session created +- `src/components/FloatingReviewPanel.tsx` — accept `initialPrompt`, auto-send via QUERY, support RECONNECT for multi-device +- `src/hooks/useChat.ts` — support `initialPrompt` for auto-sending first message + +All files change together to maintain compilation. + +- [ ] **Step 1: Add `registerReview` to `api.ts`** + +```typescript +registerReview: (parentCliSessionId: string, childSessionId: string, targetAdapter: string, anchorMessageId: string, prompt: string, title: string) => + request<{ reviewId: string }>('/api/reviews/register', { + method: 'POST', + body: JSON.stringify({ parentCliSessionId, childSessionId, targetAdapter, anchorMessageId, prompt, title }), + }), +``` + +- [ ] **Step 2: Add POST /api/reviews/register endpoint in `server/index.ts`** + +```typescript +app.post('/api/reviews/register', authMiddleware, async (req: Request, res: Response) => { + try { + const { parentCliSessionId, childSessionId, targetAdapter, anchorMessageId, prompt, title } = req.body; + if (!parentCliSessionId || !childSessionId) { + return res.status(400).json({ error: 'parentCliSessionId and childSessionId required' }); + } + + const parentAdapterName = sessionAdapterMap.get(parentCliSessionId) || DEFAULT_ADAPTER; + const reviewId = crypto.randomUUID(); + sessionReviews.create(reviewId, parentCliSessionId, childSessionId, targetAdapter, parentAdapterName, anchorMessageId, prompt, title); + + if (!sessionAdapterMap.has(childSessionId)) { + sessionAdapterMap.set(childSessionId, targetAdapter); + } + + broadcastReviewStarted(parentCliSessionId, { + reviewId, childSessionId, childCliSessionId: childSessionId, + childAdapter: targetAdapter, anchorMessageId, reviewTitle: title, + }); + + res.json({ reviewId }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); +``` + +- [ ] **Step 3: Update FloatingReviewPanel — dual-path support** + +**File:** `src/components/FloatingReviewPanel.tsx` + +Update interface to support both paths: + +```typescript +interface FloatingReviewPanelProps { + reviewId?: string; // empty until registerReview completes (new path) + childSessionId?: string; // empty for new session (QUERY), set for reconnect (RECONNECT) + childAdapter: string; + reviewTitle?: string; + panelState: 'expanded' | 'minimized' | 'hidden'; + onPanelStateChange: (state: 'expanded' | 'minimized' | 'hidden') => void; + onEnd: () => void; + // New path only: + initialPrompt?: string; // review context to auto-send as first QUERY + cwd?: string; + onSessionCreated?: (childSessionId: string) => void; +} +``` + +useChat call: + +```typescript +const { + messages, streaming, liveStatus, toolStatuses, + sendMessage: chatSendMessage, abort, sessionId: chatSessionId, +} = useChat( + childSessionId || undefined, // undefined → new session (QUERY); set → reconnect + initialPrompt, // auto-send as first message (new path only) + childAdapter, + cwd, +); + +// Notify parent when session is created via QUERY (new path) +const notifiedRef = useRef(false); +useEffect(() => { + if (chatSessionId && !childSessionId && onSessionCreated && !notifiedRef.current) { + notifiedRef.current = true; + onSessionCreated(chatSessionId); + } +}, [chatSessionId, childSessionId, onSessionCreated]); +``` + +- [ ] **Step 4: Update useChat — support `initialPrompt` parameter** + +**File:** `src/hooks/useChat.ts` + +Update signature: + +```typescript +export function useChat( + existingSessionId?: string, + initialPrompt?: string, + adapterOverride?: string, + cwdOverride?: string, +) { +``` + +Add ref and auto-send in WS onopen: + +```typescript +const initialPromptSent = useRef(false); + +// In the WS onopen handler, after connection established: +if (initialPrompt && !existingSessionId && !initialPromptSent.current) { + initialPromptSent.current = true; + actualSend(initialPrompt); +} +``` + +**Important:** `actualSend` must pass `adapter: adapterOverride` and `cwd: cwdOverride` in the QUERY options so handleQuery uses the correct adapter and directory. + +- [ ] **Step 5: Update ChatView `handleReviewSelect` — local mount + registerReview** + +**File:** `src/components/ChatView.tsx` + +Add state: + +```typescript +const [reviewInitialPrompt, setReviewInitialPrompt] = useState(null); +const [reviewCwd, setReviewCwd] = useState(null); +``` + +Replace `api.createReview()` call in handleReviewSelect: + +```typescript +// Instead of api.createReview, set local state to mount panel +setActiveReview({ + reviewId: '', + childSessionId: '', + childCliSessionId: '', + childAdapter: targetAdapter, + anchorMessageId: anchorMsgId, + reviewTitle: title, +}); +setReviewInitialPrompt(cappedContext); +setReviewCwd(/* parent session's cwd from adapterConfig or session state */); +setReviewPanelState('expanded'); +``` + +Update FloatingReviewPanel props: + +```tsx + { + // Guard: only call endReview if reviewId exists (edge case F) + if (activeReview.reviewId) { + try { await api.endReview(activeReview.reviewId); } catch {} + } + // Always destroy child session if it exists + if (activeReview.childSessionId) { + // session cleanup happens server-side when session ends + } + setActiveReview(null); + setReviewPanelState('hidden'); + setReviewInitialPrompt(null); + }} + initialPrompt={reviewInitialPrompt || undefined} + cwd={reviewCwd || undefined} + onSessionCreated={async (childSid) => { + try { + const result = await api.registerReview( + sessionId, childSid, activeReview.childAdapter, + activeReview.anchorMessageId, activeReview.reviewTitle || '', '' + ); + setActiveReview(prev => prev ? { + ...prev, + reviewId: result.reviewId, + childSessionId: childSid, + childCliSessionId: childSid, + } : null); + } catch (err) { + // Edge case E: registerReview failed + console.error('Failed to register review:', err); + // Session exists but no DB record — user can still chat, just won't persist + } + setReviewInitialPrompt(null); + }} +/> +``` + +- [ ] **Step 6: Verify RECONNECT path still works (scenarios C/D)** + +The RECONNECT path is preserved because: +- When `childSessionId` is provided (from REVIEW_STARTED broadcast on reconnect), useChat sends RECONNECT +- When `initialPrompt` is NOT provided, no auto-send happens +- FloatingReviewPanel renders ChatBody normally with messages from HISTORY_LOAD + +Verify by checking: `handleReconnect` in session-manager.ts sends active reviews → useChat REVIEW_STARTED handler sets `activeReview` with `childSessionId` → FloatingReviewPanel mounts with childSessionId → useChat RECONNECT. + +- [ ] **Step 7: Verify TypeScript compilation** + +```bash +npx tsc --noEmit +``` + +- [ ] **Step 8: Commit** + +```bash +git add server/index.ts src/lib/api.ts src/components/ChatView.tsx src/components/FloatingReviewPanel.tsx src/hooks/useChat.ts +git commit -m "feat: unified session path — review child uses QUERY, registerReview after session created" +``` + +--- + +### Task 3: Clean up — remove old review session creation + pasteToSession + +**Files:** +- `server/index.ts` +- `src/lib/api.ts` +- `server/adapters/interface.ts` +- `server/adapters/codex/index.ts` +- `server/adapters/claude/index.ts` +- `server/adapters/claude/tmux-adapter.ts` + +- [ ] **Step 1: Remove old POST /api/reviews session creation logic** + +In `server/index.ts` POST /api/reviews handler (lines 249-319): +- Remove `adapter.startSession()` call +- Remove `adapter.pasteToSession()` call +- Remove marker injection logic +- Keep only: DB record creation + broadcast (same as /api/reviews/register) +- Or remove the entire endpoint and redirect to /api/reviews/register + +Check frontend callers: +```bash +grep -rn "createReview\|/api/reviews'" src/ --include="*.ts" --include="*.tsx" +``` + +Remove `createReview` from `api.ts` if no longer called. + +- [ ] **Step 2: Update send-back to use sendMessage** + +In `POST /api/reviews/:id/send-back` (server/index.ts lines 369-371): + +```typescript +// OLD: +await parentAdapter.pasteToSession(parentSessionId, formatted); + +// NEW: +await parentAdapter.sendMessage(parentSessionId, formatted); +``` + +Both Claude and Codex `sendMessage` now handle large content (Task 1). + +- [ ] **Step 3: Remove `pasteToSession` from adapter interface** + +Check remaining callers: +```bash +grep -rn "pasteToSession" server/ --include="*.ts" +``` + +If no remaining callers after Steps 1-2, remove from: +- `server/adapters/interface.ts` — base class method +- `server/adapters/codex/index.ts` — delegation +- `server/adapters/codex/codex-tmux-adapter.ts` — if any leftover +- `server/adapters/claude/index.ts` — delegation +- `server/adapters/claude/tmux-adapter.ts` — implementation + +- [ ] **Step 4: Verify TypeScript compilation** + +```bash +npx tsc --noEmit +``` + +- [ ] **Step 5: Commit** + +```bash +git add server/ src/lib/api.ts +git commit -m "refactor: remove old review session creation and pasteToSession from adapter interface" +``` + +--- + +### Task 4: E2E Verification + +- [ ] **Step 1: Start server** +```bash +CLAUDE_UI_PASSWORD=TEST npm run dev +``` + +- [ ] **Step 2: Test normal Codex session (scenario A)** +New Project → code-tap → Codex → send message → verify response + icon buttons. + +- [ ] **Step 3: Test normal Claude session** +New Project → code-tap → Claude → send message → verify response. + +- [ ] **Step 4: Test Cross-AI Review unified path (scenario B)** +1. Claude session → send message → get response +2. Click send icon → select "Direct send" +3. Verify FloatingReviewPanel opens +4. Verify panel shows Codex response (via QUERY, same as normal) +5. Verify session ID updates to real UUID + +- [ ] **Step 5: Test send-back (scenario H)** +In review panel, click send-back icon → verify message appears in parent chat. + +- [ ] **Step 6: Test end review** +Click "End" → verify panel closes, markers appear. + +- [ ] **Step 7: Test end review before registerReview (scenario F)** +Quick-click End immediately after review starts (before Codex responds) → verify no crash. + +- [ ] **Step 8: Test page refresh reconnect (scenario D)** +1. Start a review +2. Refresh page +3. Reconnect to parent session +4. Verify FloatingReviewPanel re-appears with child session (RECONNECT path) + +--- + +## Self-Review Checklist + +### Flow comparison after all tasks + +``` +Normal session (Codex or Claude): + useChat.actualSend("Hi") → WS QUERY → handleQuery → startSession → registerClient → sendMessage + +Review child (same device, scenario B): + useChat.actualSend(reviewContext) → WS QUERY → handleQuery → startSession → registerClient → sendMessage + → SESSION_CREATED → POST /api/reviews/register → DB record + broadcast + +Review child (other device/reconnect, scenarios C/D): + REVIEW_STARTED from server → FloatingReviewPanel mounts with childSessionId + → useChat RECONNECT → handleReconnect → registerClient → HISTORY_LOAD + +All three paths work. Scenarios B and normal use IDENTICAL QUERY flow. +``` + +### Adapter sendMessage unification +| Adapter | Short text | Long/multiline text | +|---------|-----------|-------------------| +| Codex | sendKeys | `\n`→`\\n` + pasteBuffer (with CODETAP_REF marker split) | +| Claude | sendKeys | pasteBuffer (no `\n` replacement needed, no marker split) | + +### Error handling +- registerReview failure → catch, log, session continues (no DB record but chat works) ✅ +- End with empty reviewId → guard, skip endReview API call ✅ +- initialPrompt double-send → ref guard prevents ✅ + +### Files changed +| File | Change | +|------|--------| +| `server/adapters/codex/codex-tmux-adapter.ts` | Merge sendMessage + pasteToSession | +| `server/adapters/codex/index.ts` | pasteToSession delegates to sendMessage | +| `server/adapters/claude/tmux-adapter.ts` | sendMessage handles large content | +| `server/adapters/claude/index.ts` | pasteToSession delegates to sendMessage | +| `server/adapters/interface.ts` | Remove pasteToSession (Task 3) | +| `server/index.ts` | Add /api/reviews/register, remove old POST /api/reviews session creation | +| `src/lib/api.ts` | Add registerReview(), remove createReview() | +| `src/components/ChatView.tsx` | handleReviewSelect → local state + registerReview callback | +| `src/components/FloatingReviewPanel.tsx` | Dual-path: initialPrompt (QUERY) or childSessionId (RECONNECT) | +| `src/hooks/useChat.ts` | Support initialPrompt auto-send | diff --git a/docs/superpowers/plans/2026-03-26-cli-multi-adapter.md b/docs/superpowers/plans/2026-03-26-cli-multi-adapter.md new file mode 100644 index 0000000..fb4f4f3 --- /dev/null +++ b/docs/superpowers/plans/2026-03-26-cli-multi-adapter.md @@ -0,0 +1,688 @@ +# CLI Multi-Adapter Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make every `codetap` CLI command support `--adapter` filtering and fix all outdated descriptions that only reference Claude. + +**Architecture:** Move `--adapter` flag parsing to the top of the script (before any command handlers), so all commands can access `$ADAPTER`. Update `-a`/`-A` to filter by adapter, `--continue` to filter by adapter, and `hooks` to target specific adapters. Fix all help text and comments. + +**Tech Stack:** Bash, Node.js (hooks-cli.mjs) + +--- + +## Complete Issue List + +| # | Issue | Type | +|---|---|---| +| 1 | `--adapter` parsed AFTER `-a`/`-A` exits — impossible to combine | Bug | +| 2 | `-a`/`-A` can't filter by adapter | Feature gap | +| 3 | `-a`/`-A` adapter detection uses `pane_current_command` → shows `node` not the adapter name | Bug | +| 4 | `--continue` doesn't pass adapter to resume API | Feature gap | +| 5 | `--continue` doesn't filter by adapter (always picks most recent) | Feature gap | +| 6 | `hooks install/uninstall` can't target specific adapter | Feature gap | +| 7 | Help text says "(Claude or Codex)" — missing Gemini | Text | +| 8 | Header comment says "runs Claude/Codex" — missing Gemini | Text | +| 9 | Header comment missing `--adapter` in usage examples | Text | +| 10 | No-args output doesn't mention `--adapter` | Text | +| 11 | Comment "Claude stores sessions by project dir" is outdated | Text | +| 12 | `` description unclear | Text | + +## File Map + +| File | Action | Responsibility | +|---|---|---| +| `bin/codetap` | Modify | Move `--adapter` parsing to top, update all commands, fix all text | +| `bin/hooks-cli.mjs` | Modify | Accept optional adapter name argument | + +--- + +### Task 1: Move `--adapter` Parsing Before All Command Handlers + +**Files:** +- Modify: `bin/codetap:24-370` (restructure flag parsing order) + +The core structural fix: `--adapter` must be parsed BEFORE any command handler (including `-a`, `-A`, `--version`, `--help`), so all commands can access `$ADAPTER`. + +- [ ] **Step 1: Move adapter parsing to right after variable declarations (before the `case` block)** + +Move the `--adapter` parsing block (currently at lines 336-370) to immediately after line 22 (`PID_FILE=...`), before the `case "$1" in` block at line 25. + +The block to move: + +```bash +# --- Parse --adapter flag (before any command handlers) --- +set_adapter() { + case "$1" in + claude) ADAPTER="claude"; ADAPTER_CMD="claude"; YOLO="--dangerously-skip-permissions" ;; + codex) ADAPTER="codex"; ADAPTER_CMD="codex"; YOLO="--dangerously-bypass-approvals-and-sandbox" ;; + gemini) ADAPTER="gemini"; ADAPTER_CMD="gemini"; YOLO="--approval-mode yolo" ;; + esac +} + +ADAPTER="claude" +ADAPTER_CMD="claude" +ADAPTER_EXPLICIT=false +prev_arg="" +for arg in "$@"; do + if [ "$prev_arg" = "--adapter" ]; then + ADAPTER_EXPLICIT=true + case "$arg" in + claude) set_adapter claude ;; + codex) set_adapter codex ;; + gemini) set_adapter gemini ;; + *) echo "Unknown adapter: $arg"; exit 1 ;; + esac + fi + prev_arg="$arg" +done + +# Strip --adapter and its value from positional args +CLEANED_ARGS=() +skip_next=false +for arg in "$@"; do + if $skip_next; then skip_next=false; continue; fi + if [ "$arg" = "--adapter" ]; then skip_next=true; continue; fi + CLEANED_ARGS+=("$arg") +done +set -- "${CLEANED_ARGS[@]}" +``` + +Delete the old copy of this block from its current location (lines 336-370). + +- [ ] **Step 2: Verify `--version` and `--help` still work** + +Run: +```bash +codetap --version +codetap --help +codetap --adapter gemini --version +``` +Expected: version prints, help prints, `--adapter gemini --version` still prints version (adapter is parsed but irrelevant for --version). + +- [ ] **Step 3: Commit** + +```bash +git add bin/codetap +git commit -m "refactor(cli): move --adapter parsing before all command handlers" +``` + +--- + +### Task 2: `-a`/`-A` Support `--adapter` Filter + Fix Adapter Detection + +**Files:** +- Modify: `bin/codetap` (the `-a`/`-A` handler, lines 249-334) + +- [ ] **Step 1: Add adapter filter to the session list** + +The current adapter detection (lines 302-307) uses `pane_current_command` which shows `node` for all adapters — broken for detection. Fix by querying the server's `/api/active-sessions` API which has accurate adapter info. + +**Note:** The `-a`/`-A` handler is already positioned AFTER `ensure_server()` and `get_auth_token()` (line 249 is after line 200/203). After Task 1 moves `--adapter` parsing to the top, `$ADAPTER` and `$ADAPTER_EXPLICIT` will be available here. No handler relocation needed. + +**API note:** `/api/active-sessions` supports `?adapter=` query param but NOT `?cwd=`. For project-level filtering (`-a`), fetch all sessions and filter by `cwd` field client-side in Python. + +Replace the entire `-a`/`-A` handler (lines 249-334) with: + +```bash +# --- List active sessions --- +if [ "$1" = "--attach" ] || [ "$1" = "-a" ] || [ "$1" = "-A" ]; then + ALL_MODE=false + [ "$1" = "-A" ] && ALL_MODE=true + + # Get sessions from the server API (has accurate adapter info) + AUTH_TOKEN=$(get_auth_token) + if [ -n "$AUTH_TOKEN" ]; then + SESSIONS_JSON=$(curl -s $CURL_OPTS "$PROTOCOL://localhost:$PORT/api/active-sessions" \ + -H "Authorization: Bearer $AUTH_TOKEN" 2>/dev/null) + else + SESSIONS_JSON="[]" + fi + + # Filter by adapter and/or cwd client-side + SESSIONS_JSON=$(echo "$SESSIONS_JSON" | python3 -c " +import sys, json +sessions = json.load(sys.stdin) +adapter_filter = '$ADAPTER' if '$ADAPTER_EXPLICIT' == 'true' else None +cwd_filter = '$(pwd)' if '$ALL_MODE' == 'false' else None +if adapter_filter: + sessions = [s for s in sessions if s.get('adapter') == adapter_filter] +if cwd_filter: + sessions = [s for s in sessions if s.get('cwd') == cwd_filter] +json.dump(sessions, sys.stdout) +" 2>/dev/null) + + # Parse and display + COUNT=$(echo "$SESSIONS_JSON" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null) + + if [ "$COUNT" = "0" ] || [ -z "$COUNT" ]; then + if [ "$ADAPTER_EXPLICIT" = true ]; then + echo "No active $ADAPTER sessions." + elif [ "$ALL_MODE" = true ]; then + echo "No active sessions." + else + echo "No active sessions for project '$(basename "$(pwd)")'." + echo "Run 'codetap -A' to see all projects, or 'codetap new' to start a new session." + fi + exit 0 + fi + + if [ "$ALL_MODE" = true ]; then + HEADER="Active sessions (all projects)" + else + HEADER="Active sessions for $(basename "$(pwd)")" + fi + [ "$ADAPTER_EXPLICIT" = true ] && HEADER="$HEADER — $ADAPTER only" + echo "$HEADER:" + echo "" + + # Render each session + echo "$SESSIONS_JSON" | python3 -c " +import sys, json + +sessions = json.load(sys.stdin) +colors = {'claude': '\033[33m', 'codex': '\033[32m', 'gemini': '\033[34m'} +reset = '\033[0m' +home = '$HOME' + +for i, s in enumerate(sessions, 1): + adapter = s.get('adapter', '?') + sid = s.get('sessionId', '?') + cwd = s.get('cwd', '') + first = s.get('firstPrompt', '') + color = colors.get(adapter, '\033[90m') + label = f'{color}[{adapter.capitalize()}]{reset}' + + print(f' {i}) {label} {sid}') + if $ALL_MODE and cwd: + print(f' Dir: {cwd.replace(home, \"~\")}') + if first: + print(f' {first[:60]}') + print() +" 2>/dev/null + + # Interactive selection + read -p "Select (1-$COUNT), or Enter to cancel: " CHOICE + if [ -n "$CHOICE" ]; then + TARGET=$(echo "$SESSIONS_JSON" | python3 -c " +import sys, json +sessions = json.load(sys.stdin) +idx = int('$CHOICE') - 1 +if 0 <= idx < len(sessions): + print(sessions[idx]['sessionId']) +" 2>/dev/null) + if [ -n "$TARGET" ]; then + tmux select-window -t "$TMUX_SESSION:$TARGET" 2>/dev/null + tmux attach -t "$TMUX_SESSION" + fi + else + echo "Cancelled." + fi + exit 0 +fi +``` + +- [ ] **Step 2: Verify** + +Run: +```bash +# Start a Gemini and Claude session first, then: +codetap -a # Current project sessions +codetap -A # All sessions +codetap -a --adapter gemini # Only Gemini sessions for current project +codetap -A --adapter codex # Only Codex sessions across all projects +``` + +Expected: Sessions show correct adapter labels from server (not from pane_current_command). Filter works. + +- [ ] **Step 3: Commit** + +```bash +git add bin/codetap +git commit -m "feat(cli): -a/-A uses server API for accurate adapter info + --adapter filter" +``` + +--- + +### Task 3: `--continue` Support `--adapter` Filter + +**Files:** +- Modify: `bin/codetap` (`--continue` handler) + +- [ ] **Step 1: Update `--continue` to pass adapter to API and filter by adapter** + +Replace the `--continue` handler with: + +```bash +elif [ "$1" = "--continue" ]; then + shift + + # If adapter specified, find most recent session for that adapter + if [ "$ADAPTER_EXPLICIT" = true ]; then + AUTH_TOKEN=$(get_auth_token) + SESSIONS_JSON=$(curl -s $CURL_OPTS "$PROTOCOL://localhost:$PORT/api/active-sessions" \ + -H "Authorization: Bearer $AUTH_TOKEN" 2>/dev/null) + LATEST=$(echo "$SESSIONS_JSON" | python3 -c " +import sys, json +sessions = json.load(sys.stdin) +filtered = [s for s in sessions if s.get('adapter') == '$ADAPTER'] +if filtered: + # Sort by lastActivity descending + filtered.sort(key=lambda s: s.get('lastActivity', 0), reverse=True) + print(filtered[0]['sessionId']) +" 2>/dev/null) + else + # No adapter specified — find most recent tmux window + LATEST=$(tmux list-windows -t "$TMUX_SESSION" -F '#{window_activity} #{window_name}' 2>/dev/null | grep -v " main$" | sort -rn | head -1 | awk '{print $2}') + fi + + if [ -n "$LATEST" ]; then + # Check if the process in the pane is still running + PANE_CMD=$(tmux display -t "$TMUX_SESSION:$LATEST" -p '#{pane_current_command}' 2>/dev/null) + if [ "$PANE_CMD" = "zsh" ] || [ "$PANE_CMD" = "bash" ]; then + # CLI process exited, shell is showing — resume via API + AUTH_TOKEN="${AUTH_TOKEN:-$(get_auth_token)}" + BODY=$(printf '%s\n%s\n%s' "$LATEST" "$ADAPTER" "$(pwd)" | python3 -c 'import sys,json; s,a,c=sys.stdin.read().strip().split("\n"); print(json.dumps({"sessionId":s,"adapter":a,"cwd":c}))' 2>/dev/null) + curl -s $CURL_OPTS -X POST "${PROTOCOL}://localhost:${PORT}/api/sessions/resume" \ + -H "Authorization: Bearer $AUTH_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$BODY" >/dev/null 2>&1 + fi + tmux select-window -t "$TMUX_SESSION:$LATEST" + else + if [ "$ADAPTER_EXPLICIT" = true ]; then + echo "No active $ADAPTER sessions to continue" + else + echo "No active sessions to continue" + fi + exit 1 + fi + + tmux attach -t "$TMUX_SESSION" + exit 0 +``` + +- [ ] **Step 2: Verify** + +Run: +```bash +codetap --continue # Resume most recent (any adapter) +codetap --continue --adapter gemini # Resume most recent Gemini +codetap --continue --adapter codex # Resume most recent Codex +``` + +- [ ] **Step 3: Commit** + +```bash +git add bin/codetap +git commit -m "feat(cli): --continue supports --adapter filter + passes adapter to resume API" +``` + +--- + +### Task 4: `hooks install/uninstall` Support `--adapter` Filter + +**Files:** +- Modify: `bin/codetap:84-86` (hooks handler) +- Modify: `bin/hooks-cli.mjs` (accept adapter argument) + +- [ ] **Step 1: Update hooks-cli.mjs to accept optional adapter** + +Replace `bin/hooks-cli.mjs`: + +```javascript +#!/usr/bin/env node +// Standalone hook management — no server needed. +// Usage: node hooks-cli.mjs install|uninstall [adapter] +// adapter: claude, codex, gemini, or omit for all +import { ClaudeHookConfig } from '../server/adapters/claude/hook-config.js'; +import { CodexHookConfig } from '../server/adapters/codex/hook-config.js'; +import { GeminiHookConfig } from '../server/adapters/gemini/hook-config.js'; + +const cmd = process.argv[2]; +const adapterArg = process.argv[3]; // optional: claude, codex, gemini + +if (!cmd || !['install', 'uninstall'].includes(cmd)) { + console.error('Usage: hooks-cli.mjs install|uninstall [claude|codex|gemini]'); + process.exit(1); +} + +const adapters = { + claude: new ClaudeHookConfig(), + codex: new CodexHookConfig(), + gemini: new GeminiHookConfig(), +}; + +const targets = adapterArg ? { [adapterArg]: adapters[adapterArg] } : adapters; + +if (adapterArg && !adapters[adapterArg]) { + console.error(`Unknown adapter: ${adapterArg}. Use: claude, codex, gemini`); + process.exit(1); +} + +for (const [name, config] of Object.entries(targets)) { + if (cmd === 'install') { + config.install(); + } else { + config.uninstall(); + } +} +``` + +- [ ] **Step 2: Update bin/codetap hooks handler to pass adapter** + +Replace lines 84-86: + +```bash + hooks) + if [ "$ADAPTER_EXPLICIT" = true ]; then + node "$SCRIPT_DIR/hooks-cli.mjs" "$2" "$ADAPTER" + else + node "$SCRIPT_DIR/hooks-cli.mjs" "$2" + fi + exit 0 ;; +``` + +- [ ] **Step 3: Verify** + +Run: +```bash +codetap hooks install # Install all +codetap hooks uninstall # Uninstall all +codetap hooks install --adapter gemini # Install Gemini only +codetap hooks uninstall --adapter claude # Uninstall Claude only +``` + +Wait — `--adapter` is parsed before `hooks` command, but the `hooks` case is in the early `case "$1" in` block which runs before `ensure_server`. Need to check if `--adapter` parsing happens before the case block after Task 1 moves it. + +After Task 1, the parsing order is: +1. `--adapter` parsed and stripped (lines 23-55 after move) +2. `case "$1" in` — now `$1` is `hooks` (not `--adapter`) + +So `codetap --adapter gemini hooks install` works. But `codetap hooks install --adapter gemini` needs the adapter parsing to handle args in any order. After Task 1's `set --` cleanup, `$1` would be `hooks` and `$2` would be `install` — correct. + +But what about `codetap hooks --adapter gemini install`? The `--adapter` stripping would remove `--adapter gemini`, leaving `hooks install`. That works too. + +- [ ] **Step 4: Commit** + +```bash +git add bin/codetap bin/hooks-cli.mjs +git commit -m "feat(cli): hooks install/uninstall supports --adapter for single-adapter targeting" +``` + +--- + +### Task 5: Fix All Help Text and Comments + +**Files:** +- Modify: `bin/codetap` (header comments, help text, no-args output) + +- [ ] **Step 1: Fix header comment (lines 1-14)** + +Replace with: + +```bash +#!/bin/bash +# codetap — CLI wrapper that runs AI coding assistants in tmux for mobile sync +# +# Usage: +# codetap # Start server, show URLs +# codetap new # New session (default: claude) +# codetap new --adapter gemini # New Gemini session +# codetap --resume # Resume a specific session +# codetap --continue # Resume the most recent session +# codetap --continue --adapter codex # Resume most recent Codex session +# codetap -a # List active sessions (current project) +# codetap -a --adapter gemini # List Gemini sessions only +# codetap -A # List ALL active sessions (all projects) +# codetap stop # Stop the server (graceful cleanup) +# codetap hooks install # Install hooks for all adapters +# codetap hooks install --adapter gemini # Install hooks for Gemini only +# codetap cert # Generate self-signed HTTPS cert +# +# Adapters: claude (default), codex, gemini +# Sessions run inside tmux session "codetap". +# Mobile app auto-connects for real-time sync. +``` + +- [ ] **Step 2: Fix help text (lines 30-49)** + +Replace with: + +```bash + cat << 'HELP' +Usage: codetap [options] [command] + +Commands: + new Start a new session (default: Claude) + stop Stop the server (graceful cleanup) + hooks install Install hooks (all adapters, or use --adapter) + hooks uninstall Remove hooks (all adapters, or use --adapter) + cert Generate self-signed HTTPS certificate + +Options: + -v, --version Show version + -h, --help Show this help + -a List active sessions (current project) + -A List ALL active sessions (all projects) + --adapter Adapter: claude (default), codex, gemini + --resume Resume a specific session + --continue Resume most recent session + +Examples: + codetap new --adapter gemini Start a Gemini session + codetap -a --adapter codex List active Codex sessions + codetap --continue --adapter gemini Continue most recent Gemini session + codetap hooks install --adapter claude Install Claude hooks only +HELP +``` + +- [ ] **Step 3: Fix no-args output (lines 218-229)** + +Replace with: + +```bash + echo "" + echo "CodeTap server is running on port $PORT" + echo "" + echo " Open on your phone:" + if [ -n "$TS_HOST" ]; then echo " https://${TS_HOST} (Tailscale)"; fi + if [ -n "$LAN_IP" ]; then echo " ${PROTOCOL}://${LAN_IP}:${PORT} (LAN)"; fi + echo " http://localhost:${PORT} (this machine)" + echo "" + echo " New session: codetap new [--adapter codex|gemini]" + echo " Continue: codetap --continue" + echo " List sessions: codetap -a" + echo " Stop server: codetap stop" + echo "" +``` + +- [ ] **Step 4: Remove outdated comment on line 18** + +Delete line 18: `# Claude stores sessions by project dir; Codex uses date-based dirs (handled in get_project_sessions)` + +- [ ] **Step 5: Verify all text output** + +Run: +```bash +codetap --help +codetap --version +codetap 2>&1 | head -15 +``` + +Verify no mention of "Claude" in contexts where it should say "adapter", and Gemini is listed everywhere. + +- [ ] **Step 6: Commit** + +```bash +git add bin/codetap +git commit -m "fix(cli): update all help text, comments, and output for multi-adapter support" +``` + +--- + +### Task 6: E2E Verification — All Commands × All Flags + +**Files:** None (testing only) + +- [ ] **Step 1: Restart server fresh** + +```bash +lsof -ti :3456 | xargs kill -9 2>/dev/null +tmux kill-session -t codetap 2>/dev/null +sleep 2 +export CLAUDE_UI_PASSWORD=test +CLAUDE_UI_PASSWORD=test npx tsx server/index.ts > /tmp/codetap-cli-test.log 2>&1 & +sleep 6 +curl -sk https://localhost:3456/health +``` + +- [ ] **Step 2: Test `--version` and `--help`** + +```bash +codetap --version +# Expected: codetap v1.3.2 + +codetap --help +# Expected: Multi-adapter help text with Examples section +# Verify: "claude (default), codex, gemini" appears +# Verify: No "Claude-only" language +``` + +- [ ] **Step 3: Test `codetap` (no args)** + +```bash +codetap +# Expected: Server URLs + multi-adapter usage hints +# Verify: "codetap new [--adapter codex|gemini]" in output +``` + +- [ ] **Step 4: Test `codetap new` (all 3 adapters)** + +```bash +# Create sessions using the API (non-interactive, can't attach tmux from subagent) +TOKEN=$(curl -sk -X POST "https://localhost:3456/api/auth/login" \ + -H 'Content-Type: application/json' \ + -d '{"password":"test"}' | python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])') + +# Claude +RESULT=$(curl -sk -X POST "https://localhost:3456/api/sessions/start" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"adapter\":\"claude\",\"cwd\":\"$(pwd)\"}") +echo "Claude: $RESULT" + +# Codex +RESULT=$(curl -sk -X POST "https://localhost:3456/api/sessions/start" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"adapter\":\"codex\",\"cwd\":\"$(pwd)\"}") +echo "Codex: $RESULT" + +# Gemini +RESULT=$(curl -sk -X POST "https://localhost:3456/api/sessions/start" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"adapter\":\"gemini\",\"cwd\":\"$(pwd)\"}") +echo "Gemini: $RESULT" + +# Verify tmux windows +tmux list-windows -t codetap -F '#{window_name}' | grep -v main +``` + +Expected: 3 session IDs returned, 3 tmux windows created. + +- [ ] **Step 5: Test `-a` and `-A` with and without `--adapter`** + +```bash +echo "" | codetap -a 2>&1 +# Expected: Lists sessions for current project with [Claude], [Codex], [Gemini] labels + +echo "" | codetap -A 2>&1 +# Expected: Lists ALL sessions with Dir: info + +echo "" | codetap -a --adapter gemini 2>&1 +# Expected: Only Gemini sessions listed + +echo "" | codetap -A --adapter codex 2>&1 +# Expected: Only Codex sessions across all projects + +echo "" | codetap -a --adapter claude 2>&1 +# Expected: Only Claude sessions for current project +``` + +- [ ] **Step 6: Test `--continue` with and without `--adapter`** + +Can't test tmux attach non-interactively, but verify the session selection logic: + +```bash +# Check which session --continue would pick +LATEST=$(tmux list-windows -t codetap -F '#{window_activity} #{window_name}' | grep -v " main$" | sort -rn | head -1 | awk '{print $2}') +echo "Most recent (any adapter): $LATEST" + +# With --adapter, verify API query works +TOKEN=$(curl -sk -X POST "https://localhost:3456/api/auth/login" \ + -H 'Content-Type: application/json' \ + -d '{"password":"test"}' | python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])') +curl -sk "https://localhost:3456/api/active-sessions" \ + -H "Authorization: Bearer $TOKEN" | python3 -c " +import sys, json +for s in json.load(sys.stdin): + print(f'{s[\"adapter\"]:8s} {s[\"sessionId\"][:12]}... last={s.get(\"lastActivity\",0)}') +" +``` + +- [ ] **Step 7: Test `hooks install/uninstall` with and without `--adapter`** + +```bash +codetap hooks uninstall +# Expected: All 3 adapters' hooks removed + +codetap hooks install --adapter gemini +# Expected: Only Gemini hooks installed +cat ~/.gemini/settings.json | python3 -c "import sys,json; print('hooks' in json.load(sys.stdin))" +# Expected: True + +cat ~/.claude/settings.json | python3 -c "import sys,json; d=json.load(sys.stdin); print('hooks' in d and any('codetap' in str(v).lower() for v in d.get('hooks',{}).values()))" +# Expected: False (Claude hooks not installed) + +codetap hooks install +# Expected: All 3 adapters' hooks installed + +codetap hooks uninstall --adapter claude +# Expected: Only Claude hooks removed +``` + +- [ ] **Step 8: Test `--resume`** + +```bash +# Get a session ID from the list +SID=$(tmux list-windows -t codetap -F '#{window_name}' | grep -v main | head -1) +echo "Resuming: $SID" +# Can't test tmux attach, but verify API: +TOKEN=$(curl -sk -X POST "https://localhost:3456/api/auth/login" \ + -H 'Content-Type: application/json' \ + -d '{"password":"test"}' | python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])') +curl -sk -X POST "https://localhost:3456/api/sessions/resume" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"sessionId\":\"$SID\",\"adapter\":\"claude\",\"cwd\":\"$(pwd)\"}" +# Expected: {"sessionId":"..."} +``` + +- [ ] **Step 9: Test `stop` and `cert`** + +```bash +codetap stop +# Expected: "Stopping CodeTap server..." → "Server stopped." + +# Cert already exists, just verify the command runs +echo "n" | codetap cert +# Expected: "Certificate already exists..." prompt, then exits +``` + +- [ ] **Step 10: Commit test results as verification log** + +```bash +git add -f docs/superpowers/plans/ +git commit -m "docs: CLI multi-adapter verification complete" +``` diff --git a/docs/superpowers/plans/2026-03-26-cross-ai-review-v2.md b/docs/superpowers/plans/2026-03-26-cross-ai-review-v2.md new file mode 100644 index 0000000..563de76 --- /dev/null +++ b/docs/superpowers/plans/2026-03-26-cross-ai-review-v2.md @@ -0,0 +1,782 @@ +# Cross-AI Review v2 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix review-ended marker position, support multi-review with tabbed panel UI, and improve send-to UX when active reviews exist. + +**Architecture:** Convert `activeReview` (single object) to `activeReviews` (array) throughout useChat and ChatView. Split review markers into start-anchor and end-anchor maps. Add "send to existing review" path in the send-to flow. Refactor FloatingReviewPanel to render tabs for multiple reviews with independent useChat hooks per tab. + +**Tech Stack:** React, TypeScript, SQLite (better-sqlite3), WebSocket, Tailwind CSS + +**Spec:** `docs/superpowers/specs/2026-03-26-cross-ai-review-v2-design.md` + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|----------------| +| `server/db.ts` | Modify | Add `end_anchor_message_id` column, update `endReview()` signature | +| `server/index.ts` | Modify | Pass `endAnchorMessageId` to `endReview()` from DELETE handler | +| `src/hooks/useChat.ts` | Modify | `activeReview` → `activeReviews` (array), update WS handlers | +| `src/components/ChatView.tsx` | Modify | Split marker maps, new send-to-existing flow, multi-review state wiring | +| `src/components/FloatingReviewPanel.tsx` | Modify → Rename to `ReviewPanelManager.tsx` | Manage array of child chats, render tabs, minimize/expand | +| `src/components/ReviewActionMenu.tsx` | Modify | Add "send to existing review" options when active reviews exist | +| `src/components/SendToExistingSheet.tsx` | Create | Simple bottom sheet for "send to active review" quick action | +| `src/index.css` | Modify | Add review panel textarea font-size override | +| `src/lib/api.ts` | Modify | Update `endReview()` to accept `endAnchorMessageId` param | + +--- + +### Task 1: DB Schema — Add `end_anchor_message_id` Column + +**Files:** +- Modify: `server/db.ts:48-60` (CREATE TABLE), `server/db.ts:206-218` (SessionReviewRow type), `server/db.ts:325-328` (endReview method) + +- [ ] **Step 1: Add column to CREATE TABLE** + +In `server/db.ts`, add `end_anchor_message_id TEXT DEFAULT NULL` after the `ended_at` line in the CREATE TABLE statement (around line 59): + +```sql +ended_at TEXT DEFAULT NULL, +end_anchor_message_id TEXT DEFAULT NULL +``` + +- [ ] **Step 2: Update SessionReviewRow type** + +In the `SessionReviewRow` interface (around line 206), add: + +```typescript +end_anchor_message_id: string | null; +``` + +- [ ] **Step 3: Update endReview() to accept endAnchorMessageId** + +Replace the `endReview` method (lines 325-328) with: + +```typescript +endReview(id: string, messageCount = 0, endAnchorMessageId?: string): void { + this.db.prepare( + `UPDATE session_reviews SET ended_at = datetime('now'), message_count = ?, end_anchor_message_id = ? WHERE id = ?` + ).run(messageCount, endAnchorMessageId || null, id); +} +``` + +- [ ] **Step 4: Run TypeScript check** + +Run: `npx tsc --noEmit 2>&1 | grep db.ts` +Expected: No errors in db.ts + +- [ ] **Step 5: Commit** + +```bash +git add server/db.ts +git commit -m "feat(db): add end_anchor_message_id to session_reviews" +``` + +--- + +### Task 2: Server API — Pass endAnchorMessageId on Review End + +**Files:** +- Modify: `server/index.ts:284-308` (DELETE /api/reviews/:id) + +- [ ] **Step 1: Update DELETE handler to accept endAnchorMessageId from request body** + +In `server/index.ts`, update the DELETE endpoint (around line 284). Express DELETE can have a body. Read `endAnchorMessageId` from `req.body`: + +```typescript +app.delete('/api/reviews/:id', authMiddleware, async (req: Request, res: Response) => { + try { + const review = sessionReviews.getById(req.params.id); + if (!review) return res.status(404).json({ error: 'Review not found' }); + + const { endAnchorMessageId } = req.body || {}; + sessionReviews.endReview(review.id, 0, endAnchorMessageId); + + broadcastReviewEnded(review.parent_cli_session_id, review.id); + + const childAdapter = getAdapter(review.child_adapter); + if (childAdapter) { + try { + await childAdapter.destroySession(review.child_cli_session_id); + } catch (err) { + console.error('[review] Failed to destroy child session:', (err as Error).message); + } + } + res.json({ ok: true }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); +``` + +- [ ] **Step 2: Update frontend api.ts endReview() to send endAnchorMessageId** + +In `src/lib/api.ts`, find the `endReview` function and update it to accept and send `endAnchorMessageId`: + +```typescript +endReview: (reviewId: string, endAnchorMessageId?: string) => + request(`/api/reviews/${reviewId}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ endAnchorMessageId }), + }), +``` + +- [ ] **Step 3: TypeScript check** + +Run: `npx tsc --noEmit 2>&1 | grep -E "index.ts|api.ts" | head -5` +Expected: No new errors + +- [ ] **Step 4: Commit** + +```bash +git add server/index.ts src/lib/api.ts +git commit -m "feat(api): pass endAnchorMessageId when ending review" +``` + +--- + +### Task 3: useChat — Convert activeReview to activeReviews Array + +**Files:** +- Modify: `src/hooks/useChat.ts:129-136` (state), `src/hooks/useChat.ts:293-307` (WS handlers), return object + +- [ ] **Step 1: Define the ReviewInfo type and change state from single to array** + +Replace the `activeReview` state (lines 129-136) with: + +```typescript +export interface ReviewInfo { + reviewId: string; + childSessionId: string; + childCliSessionId: string; + childAdapter: string; + anchorMessageId?: string; + reviewTitle?: string; +} + +const [activeReviews, setActiveReviews] = useState([]); +``` + +- [ ] **Step 2: Update REVIEW_STARTED handler to push to array** + +Replace the WS.REVIEW_STARTED case (lines 293-303): + +```typescript +case WS.REVIEW_STARTED: + setActiveReviews(prev => { + if (prev.some(r => r.reviewId === msg.reviewId)) return prev; + return [...prev, { + reviewId: msg.reviewId, + childSessionId: msg.childSessionId, + childCliSessionId: msg.childCliSessionId, + childAdapter: msg.childAdapter, + anchorMessageId: msg.anchorMessageId, + reviewTitle: msg.reviewTitle, + }]; + }); + setActiveReviewPanel('expanded'); + break; +``` + +- [ ] **Step 3: Update REVIEW_ENDED handler to remove from array** + +Replace the WS.REVIEW_ENDED case (lines 305-307): + +```typescript +case WS.REVIEW_ENDED: + setActiveReviews(prev => prev.filter(r => r.reviewId !== msg.reviewId)); + break; +``` + +- [ ] **Step 4: Update the return object** + +In the return statement, replace `activeReview, setActiveReview` with `activeReviews, setActiveReviews`. Keep `activeReviewPanel, setActiveReviewPanel` unchanged. + +- [ ] **Step 5: TypeScript check — expect errors in ChatView (will fix in Task 4)** + +Run: `npx tsc --noEmit 2>&1 | grep -c "error"` +Expected: Errors in ChatView.tsx and FloatingReviewPanel.tsx (they still reference `activeReview`) + +- [ ] **Step 6: Commit** + +```bash +git add src/hooks/useChat.ts +git commit -m "refactor: activeReview → activeReviews array in useChat" +``` + +--- + +### Task 4: ChatView — Wire Up Multi-Review State + Fix Marker Position + +**Files:** +- Modify: `src/components/ChatView.tsx` (multiple sections) + +- [ ] **Step 1: Update destructuring from useChat** + +Replace `activeReview, setActiveReview` with `activeReviews, setActiveReviews` in the useChat destructuring (around line 141). + +- [ ] **Step 2: Replace the reviews sync useEffect** + +Replace the `prevActiveReviewRef` / `useEffect([activeReview])` block (lines 202-222) with a multi-review version: + +```typescript +const prevActiveReviewsRef = useRef(activeReviews); +useEffect(() => { + const prevIds = new Set(prevActiveReviewsRef.current.map(r => r.reviewId)); + const currIds = new Set(activeReviews.map(r => r.reviewId)); + + // New reviews added — merge into reviews state + for (const review of activeReviews) { + if (!review.reviewId) continue; // skip placeholders + if (!prevIds.has(review.reviewId)) { + setReviews(prev => { + if (prev.some(r => r.id === review.reviewId)) return prev; + const cleaned = prev.filter(r => r.id); // remove placeholders + return [...cleaned, { + id: review.reviewId, + child_adapter: review.childAdapter, + anchor_message_id: review.anchorMessageId, + review_title: review.reviewTitle, + ended_at: null, + end_anchor_message_id: null, + }]; + }); + } + } + + // Reviews removed — re-fetch from server to get ended_at + end_anchor_message_id + for (const prevId of prevIds) { + if (!currIds.has(prevId)) { + if (sessionId) { + api.getReviews(sessionId).then(setReviews).catch(() => {}); + } + break; // one fetch is enough + } + } + + prevActiveReviewsRef.current = activeReviews; +}, [activeReviews, sessionId]); +``` + +- [ ] **Step 3: Split reviewsByAnchor into start and end maps** + +Replace the `reviewsByAnchor` useMemo (lines 229-239): + +```typescript +const { startMarkersByAnchor, endMarkersByAnchor } = useMemo(() => { + const startMap = new Map(); + const endMap = new Map(); + for (const r of reviews) { + if (r.anchor_message_id) { + const existing = startMap.get(r.anchor_message_id) || []; + existing.push(r); + startMap.set(r.anchor_message_id, existing); + } + if (r.ended_at) { + // Use end_anchor_message_id if available, fall back to anchor_message_id + // (for reviews ended before this feature was added) + const endKey = r.end_anchor_message_id || r.anchor_message_id; + if (endKey) { + const existing = endMap.get(endKey) || []; + existing.push(r); + endMap.set(endKey, existing); + } + } + } + return { startMarkersByAnchor: startMap, endMarkersByAnchor: endMap }; +}, [reviews]); +``` + +- [ ] **Step 4: Update renderReviewMarkers to use split maps** + +Replace the `renderReviewMarkers` callback (lines 283-312): + +```typescript +const renderReviewMarkers = useCallback((messageId: string, _index: number): React.ReactNode => { + const startReviews = startMarkersByAnchor.get(messageId); + const endReviews = endMarkersByAnchor.get(messageId); + if (!startReviews && !endReviews) return null; + + return ( + <> + {startReviews?.map((review: any) => ( + + + {review.ended_at ? ( + handleOpenReadOnlyReview(review)} + /> + ) : ( + + )} + + ))} + {endReviews?.map((review: any) => ( + + ))} + + ); +}, [startMarkersByAnchor, endMarkersByAnchor, handleOpenReadOnlyReview]); +``` + +- [ ] **Step 5: Update closeReview to pass endAnchorMessageId** + +Replace the `closeReview` callback (lines 180-188): + +```typescript +const closeReview = useCallback(async (reviewId?: string) => { + const targetId = reviewId || activeReviews[0]?.reviewId; + if (!targetId) return; + + // Find last message ID in parent chat for end marker positioning + const lastMsg = messages[messages.length - 1]; + const endAnchorMessageId = lastMsg?.id || undefined; + + try { await api.endReview(targetId, endAnchorMessageId); } catch {} + + setActiveReviews(prev => prev.filter(r => r.reviewId !== targetId)); + setHistoryReview(null); + setReviewInitialPrompt(null); + setReviewCwd(null); +}, [activeReviews, messages]); +``` + +- [ ] **Step 6: Update openReview to push placeholder to array** + +Replace the `openReview` callback (around lines 247-260). Instead of `setActiveReview({...})`, push to the array: + +```typescript +const openReview = useCallback((adapter: string, model: string, prompt: string, title: string) => { + const anchorId = reviewMenuMessageId; + setReviewMenuMessageId(null); + if (!anchorId) return; + patchAdapterPrefs(adapter, { model }); + setHistoryReview(null); + setActiveReviews(prev => [...prev, { + reviewId: '', childSessionId: '', childCliSessionId: '', + childAdapter: adapter, anchorMessageId: anchorId, reviewTitle: title, + }]); + setReviewInitialPrompt(prompt); + setReviewCwd(cwd || null); + setActiveReviewPanel('expanded'); +}, [reviewMenuMessageId, cwd]); +``` + +- [ ] **Step 7: TypeScript check** + +Run: `npx tsc --noEmit 2>&1 | grep ChatView` +Expected: May have errors related to FloatingReviewPanel props (fixed in Task 5) + +- [ ] **Step 8: Commit** + +```bash +git add src/components/ChatView.tsx +git commit -m "feat: multi-review state, split start/end markers in ChatView" +``` + +--- + +### Task 5: ReviewPanelManager — Tabbed Multi-Review Panel + +**Files:** +- Modify: `src/components/FloatingReviewPanel.tsx` → heavy refactor (rename conceptually to ReviewPanelManager) +- Modify: `src/components/ChatView.tsx` (update the FloatingReviewPanel usage) + +- [ ] **Step 1: Refactor FloatingReviewPanel to accept an array of reviews** + +Update the props interface in `FloatingReviewPanel.tsx`: + +```typescript +interface ReviewPanelProps { + reviews: { + reviewId: string; + childSessionId: string; + childAdapter: string; + reviewTitle?: string; + }[]; + onEnd: (reviewId: string) => void; + onMinimize: () => void; + initialPrompt?: string; // only for the latest (newly created) review + cwd?: string; + onSessionCreated?: (childSessionId: string) => void; + onSendToReview?: (reviewId: string, text: string) => void; +} +``` + +- [ ] **Step 2: Implement tabbed panel with per-review useChat** + +The component needs one `useChat` hook per review. Since React hooks can't be called conditionally, use a child component pattern — create a `ReviewTab` component that each renders its own `useChat`: + +```typescript +function ReviewTab({ review, cwd, initialPrompt, onSessionCreated, isActive, onSendBack }: { + review: ReviewPanelProps['reviews'][0]; + cwd?: string; + initialPrompt?: string; + onSessionCreated?: (sid: string) => void; + isActive: boolean; + onSendBack?: (text: string) => void; +}) { + const { + messages, streaming, liveStatus, toolStatuses, + sendMessage, abort, sessionId: chatSessionId, + } = useChat( + review.childSessionId || undefined, + cwd, + review.childAdapter, + initialPrompt, + ); + + // Notify parent when child session is created + useEffect(() => { + if (chatSessionId && !review.childSessionId && onSessionCreated) { + onSessionCreated(chatSessionId); + } + }, [chatSessionId, review.childSessionId, onSessionCreated]); + + // Expose sendMessage to parent for "send to existing review" + const sendRef = useRef(sendMessage); + sendRef.current = sendMessage; + + // IMPORTANT: Do NOT return null — hooks must stay mounted. + // Hide inactive tabs with CSS instead of unmounting. + // The outer div controls visibility. + + const brand = getBrand(review.childAdapter); + + return ( + { + const msg = messages.find(m => m.id === msgId); + if (msg) onSendBack(extractTextFromBlocks(msg.content)); + } : undefined} + inputPlaceholder={`Reply to ${brand.displayName} review...`} + className="flex-1" + /> + ); +} +``` + +**Important**: Each `ReviewTab` must always render (to keep hooks alive). Wrap each in a div with `style={{ display: isActive ? 'flex' : 'none' }}` so inactive tabs are hidden but hooks stay mounted. Do NOT conditionally return null — that unmounts the hook and loses the child session's WS connection. + +- [ ] **Step 3: Implement the outer panel with tab bar and minimize** + +The outer `FloatingReviewPanel` component renders: +- Handle bar (click to minimize) +- Tab bar (if multiple reviews) with ▼ minimize button, or single-review header +- Active tab's `ReviewTab` component +- Hidden inactive tabs (hooks stay alive) + +Key structure: +```typescript +export function FloatingReviewPanel({ reviews, onEnd, onMinimize, initialPrompt, cwd, onSessionCreated }: ReviewPanelProps) { + const [activeTabIndex, setActiveTabIndex] = useState(reviews.length - 1); + // ... tab bar rendering + ReviewTab for each review +} +``` + +- [ ] **Step 4: Update ChatView to pass reviews array to FloatingReviewPanel** + +In ChatView, replace the single `FloatingReviewPanel` render with the new array-based version. Filter out placeholder reviews (reviewId === ''): + +```typescript +{activeReviewPanel === 'expanded' && activeReviews.length > 0 && ( + r.reviewId || r === activeReviews[activeReviews.length - 1])} + onEnd={(reviewId) => closeReview(reviewId)} + onMinimize={() => setActiveReviewPanel('minimized')} + initialPrompt={reviewInitialPrompt || undefined} + cwd={reviewCwd || undefined} + onSessionCreated={onSessionCreatedCallback} + /> +)} +``` + +- [ ] **Step 5: Implement minimized bar for multi-review** + +When `activeReviewPanel === 'minimized'`, render the combined minimized bar: + +```typescript +{activeReviewPanel === 'minimized' && activeReviews.filter(r => r.reviewId).length > 0 && ( +
setActiveReviewPanel('expanded')}> + {activeReviews.filter(r => r.reviewId).map(r => ( + + ))} + + {activeReviews.filter(r => r.reviewId).length} review{activeReviews.filter(r => r.reviewId).length > 1 ? 's' : ''}: {activeReviews.filter(r => r.reviewId).map(r => getBrand(r.childAdapter).displayName).join(' · ')} + + ▲ Expand +
+)} +``` + +- [ ] **Step 6: TypeScript check** + +Run: `npx tsc --noEmit 2>&1 | head -10` +Expected: Clean or minor issues only + +- [ ] **Step 7: Commit** + +```bash +git add src/components/FloatingReviewPanel.tsx src/components/ChatView.tsx +git commit -m "feat: tabbed multi-review panel with minimize/expand" +``` + +--- + +### Task 6: Send-To Existing Review Bottom Sheet + +**Files:** +- Create: `src/components/SendToExistingSheet.tsx` +- Modify: `src/components/ChatView.tsx` (handleSendTo logic) + +- [ ] **Step 1: Create SendToExistingSheet component** + +Create `src/components/SendToExistingSheet.tsx`: + +```typescript +import { getBrand } from '../lib/adapters'; +import type { ReviewInfo } from '../hooks/useChat'; + +interface SendToExistingSheetProps { + visible: boolean; + activeReviews: ReviewInfo[]; + onSendToExisting: (reviewId: string) => void; + onStartNew: () => void; + onClose: () => void; +} + +export function SendToExistingSheet({ visible, activeReviews, onSendToExisting, onStartNew, onClose }: SendToExistingSheetProps) { + if (!visible) return null; + + return ( +
+
+
e.stopPropagation()} + > +
+

Send to active review

+ + {activeReviews.map(r => { + const brand = getBrand(r.childAdapter); + return ( + + ); + })} + +
+ +
+
+
+ ); +} +``` + +- [ ] **Step 2: Update handleSendTo in ChatView** + +Replace the `handleSendTo` callback to check for active reviews: + +```typescript +const handleSendTo = useCallback((messageId: string, _adapter?: string) => { + const validReviews = activeReviews.filter(r => r.reviewId); + if (validReviews.length > 0) { + // Show the "send to existing" sheet + setSendToMessageId(messageId); + } else { + // No active reviews — go straight to ReviewActionMenu + setReviewMenuMessageId(messageId); + } +}, [activeReviews]); +``` + +Add new state: +```typescript +const [sendToMessageId, setSendToMessageId] = useState(null); +``` + +- [ ] **Step 3: Add handlers for send-to-existing and start-new** + +```typescript +const handleSendToExisting = useCallback((reviewId: string) => { + if (!sendToMessageId) return; + const msg = messages.find(m => m.id === sendToMessageId); + if (!msg) return; + const text = extractTextFromBlocks(msg.content); + + // TODO: send text to the review's child session + // This requires accessing the ReviewTab's sendMessage — use a ref map + // exposed by FloatingReviewPanel (see Task 5 onSendToReview prop) + reviewPanelRef.current?.sendToReview(reviewId, text); + + setSendToMessageId(null); + setActiveReviewPanel('expanded'); +}, [sendToMessageId, messages]); + +const handleStartNewFromSheet = useCallback(() => { + if (sendToMessageId) { + setReviewMenuMessageId(sendToMessageId); + setSendToMessageId(null); + } +}, [sendToMessageId]); +``` + +- [ ] **Step 4: Render SendToExistingSheet in ChatView** + +Add the sheet render near the ReviewActionMenu render: + +```typescript + r.reviewId)} + onSendToExisting={handleSendToExisting} + onStartNew={handleStartNewFromSheet} + onClose={() => setSendToMessageId(null)} +/> +``` + +- [ ] **Step 5: Expose sendToReview from FloatingReviewPanel via ref** + +In `FloatingReviewPanel.tsx`, use `useImperativeHandle` to expose a `sendToReview(reviewId, text)` method. Each `ReviewTab` registers its `sendMessage` in a ref map. The parent component looks up the right tab and calls `sendMessage(text)`. + +- [ ] **Step 6: TypeScript check** + +Run: `npx tsc --noEmit 2>&1 | head -10` +Expected: Clean + +- [ ] **Step 7: Commit** + +```bash +git add src/components/SendToExistingSheet.tsx src/components/ChatView.tsx src/components/FloatingReviewPanel.tsx +git commit -m "feat: send-to-existing-review bottom sheet + direct message routing" +``` + +--- + +### Task 7: Placeholder Font Size Fix + +**Files:** +- Modify: `src/index.css:83-85` +- Modify: `src/components/FloatingReviewPanel.tsx` (textarea class) + +- [ ] **Step 1: Add review panel textarea override in CSS** + +In `src/index.css`, after the existing `input, textarea, select { font-size: 16px; }` rule (line 85), add: + +```css +/* Review panel uses smaller text to fit the compact layout. + 16px stays on main input to prevent iOS Safari auto-zoom. */ +.review-panel-compact textarea { + font-size: 14px; +} +``` + +- [ ] **Step 2: Add the class to FloatingReviewPanel wrapper** + +In `FloatingReviewPanel.tsx`, add `review-panel-compact` class to the panel's outer div: + +```typescript +
+``` + +- [ ] **Step 3: Verify visually** + +Build and check that the review panel placeholder is now 14px while the main chat input remains 16px. + +- [ ] **Step 4: Commit** + +```bash +git add src/index.css src/components/FloatingReviewPanel.tsx +git commit -m "fix: review panel textarea uses 14px to fit compact layout" +``` + +--- + +### Task 8: Integration Test + Cleanup + +**Files:** +- Modify: `src/components/ChatView.tsx` (remove any dead code from old single-review pattern) +- Modify: `src/hooks/useChat.ts` (clean up old exports) + +- [ ] **Step 1: Remove old single-review exports from useChat** + +Ensure `activeReview` (singular) and `setActiveReview` (singular) are completely removed from the return object. Only `activeReviews` and `setActiveReviews` should be exported. + +- [ ] **Step 2: Search for any remaining references to old single-review pattern** + +Run: `grep -rn "activeReview[^s]" src/ --include="*.tsx" --include="*.ts" | grep -v node_modules | grep -v ".d.ts"` + +Fix any remaining references. + +- [ ] **Step 3: Build and verify** + +Run: `npm run build 2>&1 | tail -5` +Expected: Clean build + +- [ ] **Step 4: Manual E2E verification checklist** + +1. Start a Gemini session from UI → send "Hi" → get response +2. Click "↗ Send to" on the response → should show ReviewActionMenu (no active reviews) +3. Select Codex → Direct Send → child session starts → panel shows with single-review header +4. Click "↗ Send to" on another message → should show SendToExistingSheet with "Send to Codex review" option +5. Click "Start new review..." → ReviewActionMenu opens → select Claude → second tab appears in panel +6. Switch between Codex and Claude tabs +7. Click ▼ to minimize → combined bar shows "2 reviews: Codex · Claude" +8. Click bar to expand → tabs restored +9. Click ✕ on Codex tab → Codex review ends, Claude tab remains +10. Click End on Claude → panel disappears +11. Verify "Review ended" markers appear at the correct positions (not at anchor) +12. Verify CollapsedReviewCards appear at the start anchor positions + +- [ ] **Step 5: Final commit** + +```bash +git add -A +git commit -m "refactor: clean up old single-review references, verify multi-review integration" +``` diff --git a/docs/superpowers/plans/2026-03-26-gemini-adapter.md b/docs/superpowers/plans/2026-03-26-gemini-adapter.md new file mode 100644 index 0000000..3657292 --- /dev/null +++ b/docs/superpowers/plans/2026-03-26-gemini-adapter.md @@ -0,0 +1,1292 @@ +# Gemini CLI Adapter Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a full-featured Gemini CLI adapter to code-tap, providing bidirectional mobile control identical to the existing Claude and Codex adapters. + +**Architecture:** Pluggable adapter extending `IAdapter` with three event channels (HTTP hooks via bridge script, JSON file watcher, tmux pane monitor). Shared `tmux-manager.ts` extracted to `server/adapters/shared/`. New `JsonWatcher` for Gemini's single-JSON session format. + +**Tech Stack:** TypeScript, Express, fs.watch, tmux, bash (bridge script) + +**Spec:** `docs/superpowers/specs/2026-03-26-gemini-adapter-design.md` + +**Note:** This project has no test runner configured. Steps marked "verify" use manual checks (`npm run dev` + curl). Unit tests are noted as follow-up. + +--- + +### Task 1: Shared Layer — Move tmux-manager.ts + +**Files:** +- Create: `server/adapters/shared/tmux-manager.ts` (copy from claude/) +- Delete: `server/adapters/claude/tmux-manager.ts` +- Modify: `server/adapters/claude/tmux-adapter.ts` (import path) +- Modify: `server/adapters/claude/pane-monitor.ts` (import path) +- Modify: `server/adapters/codex/codex-tmux-adapter.ts` (import path) + +- [ ] **Step 1: Create shared/ directory and move the file** + +```bash +mkdir -p server/adapters/shared +git mv server/adapters/claude/tmux-manager.ts server/adapters/shared/tmux-manager.ts +``` + +- [ ] **Step 2: Update import in `server/adapters/claude/tmux-adapter.ts`** + +Change: +```typescript +import { tmuxManager } from './tmux-manager.js'; +import type { TmuxWindow } from './tmux-manager.js'; +``` +To: +```typescript +import { tmuxManager } from '../shared/tmux-manager.js'; +import type { TmuxWindow } from '../shared/tmux-manager.js'; +``` + +- [ ] **Step 3: Update import in `server/adapters/claude/pane-monitor.ts`** + +Change: +```typescript +import { tmuxManager } from './tmux-manager.js'; +``` +To: +```typescript +import { tmuxManager } from '../shared/tmux-manager.js'; +``` + +- [ ] **Step 4: Update import in `server/adapters/codex/codex-tmux-adapter.ts`** + +Change: +```typescript +import { tmuxManager } from '../claude/tmux-manager.js'; +``` +To: +```typescript +import { tmuxManager } from '../shared/tmux-manager.js'; +``` + +- [ ] **Step 5: Verify — server starts without errors** + +```bash +npx tsx server/index.ts +``` +Expected: Server starts, no import errors. Ctrl+C to stop. + +- [ ] **Step 6: Commit** + +```bash +git add -A && git commit -m "refactor: move tmux-manager.ts to shared/" +``` + +--- + +### Task 2: JsonWatcher — New File Watcher for JSON Sessions + +**Files:** +- Create: `server/stores/json-watcher.ts` + +- [ ] **Step 1: Create `server/stores/json-watcher.ts`** + +```typescript +// server/stores/json-watcher.ts +// +// Watches a single JSON session file for new messages. +// Unlike JsonlWatcher (byte-offset for append-only JSONL), this handles +// Gemini's single-JSON format where the entire file is rewritten on each update. +// +// Strategy: fs.watch + stat() size guard + message count/ID tracking + debounce. + +import fs from 'fs'; + +/** A single message from a Gemini session JSON file */ +export interface GeminiSessionMessage { + id: string; + timestamp: string; + type: 'user' | 'gemini' | 'error' | 'info'; + content: unknown; + thoughts?: unknown[]; + tokens?: Record; + model?: string; + toolCalls?: unknown[]; +} + +/** Top-level structure of a Gemini session JSON file */ +interface GeminiSessionFile { + sessionId: string; + projectHash?: string; + startTime: string; + lastUpdated: string; + messages: GeminiSessionMessage[]; + kind?: string; + summary?: string; +} + +export interface JsonWatcherStartOptions { + skipExisting?: boolean; + fallbackIntervalMs?: number; + debounceMs?: number; +} + +const SIZE_WARNING_THRESHOLD = 2 * 1024 * 1024; // 2MB + +export class JsonWatcher { + filePath: string; + private _lastSize: number = 0; + private _lastMessageCount: number = 0; + private _lastMessageId: string | null = null; + private _fsWatcher: fs.FSWatcher | null = null; + private _fallbackInterval: ReturnType | null = null; + private _debounceTimer: ReturnType | null = null; + private _debounceMs: number = 50; + private _polling: boolean = false; + private _onMessages: ((messages: GeminiSessionMessage[]) => void) | null = null; + private _onError: ((err: Error) => void) | null = null; + + constructor(filePath: string) { + this.filePath = filePath; + } + + start({ skipExisting = true, fallbackIntervalMs = 2000, debounceMs = 50 }: JsonWatcherStartOptions = {}): void { + this._debounceMs = debounceMs; + + if (skipExisting) { + // Read current state so we only emit future messages + try { + const content = fs.readFileSync(this.filePath, 'utf-8'); + const session: GeminiSessionFile = JSON.parse(content); + this._lastSize = fs.statSync(this.filePath).size; + this._lastMessageCount = session.messages.length; + if (session.messages.length > 0) { + this._lastMessageId = session.messages[session.messages.length - 1]!.id; + } + } catch {} + } + + // Primary: fs.watch for instant change notification + try { + this._fsWatcher = fs.watch(this.filePath, () => this._scheduleDebounce()); + } catch { + // fs.watch may fail — fallback polling handles it + } + + // Fallback: poll every N ms + this._fallbackInterval = setInterval(() => this._poll(), fallbackIntervalMs); + + // Immediate first poll (catches messages if skipExisting=false) + if (!skipExisting) this._poll(); + } + + stop(): void { + if (this._fsWatcher) { this._fsWatcher.close(); this._fsWatcher = null; } + if (this._fallbackInterval) { clearInterval(this._fallbackInterval); this._fallbackInterval = null; } + if (this._debounceTimer) { clearTimeout(this._debounceTimer); this._debounceTimer = null; } + } + + onNewMessages(cb: (messages: GeminiSessionMessage[]) => void): void { this._onMessages = cb; } + onError(cb: (err: Error) => void): void { this._onError = cb; } + + /** Force an immediate poll (used by hooks to ensure latest state is read) */ + pollNow(): void { this._poll(); } + + /** Mark current file position — subsequent polls only return content after this point. */ + markCurrentPosition(): void { + try { + const content = fs.readFileSync(this.filePath, 'utf-8'); + const session: GeminiSessionFile = JSON.parse(content); + this._lastSize = fs.statSync(this.filePath).size; + this._lastMessageCount = session.messages.length; + if (session.messages.length > 0) { + this._lastMessageId = session.messages[session.messages.length - 1]!.id; + } + } catch {} + } + + private _scheduleDebounce(): void { + if (this._debounceTimer) clearTimeout(this._debounceTimer); + this._debounceTimer = setTimeout(() => this._poll(), this._debounceMs); + } + + private _poll(): void { + if (this._polling) return; + this._polling = true; + try { + const stats = fs.statSync(this.filePath); + + // File size unchanged — skip (filters fs.watch false positives) + if (stats.size === this._lastSize) { + this._polling = false; + return; + } + + // Performance warning + if (stats.size > SIZE_WARNING_THRESHOLD) { + console.warn(`[json-watcher] Session file is ${(stats.size / 1024 / 1024).toFixed(1)}MB: ${this.filePath}`); + } + + const content = fs.readFileSync(this.filePath, 'utf-8'); + const session: GeminiSessionFile = JSON.parse(content); + const messages = session.messages; + + // No new messages + if (messages.length <= this._lastMessageCount) { + // File was rewritten but no new messages (metadata update only) + this._lastSize = stats.size; + this._polling = false; + return; + } + + // Verify continuity: if _lastMessageId is set, find its index + let startIndex = this._lastMessageCount; + if (this._lastMessageId) { + // Check if the message at our expected position still matches + const expectedMsg = messages[this._lastMessageCount - 1]; + if (expectedMsg && expectedMsg.id !== this._lastMessageId) { + // Messages were reordered/deleted — find actual position + const foundIndex = messages.findIndex(m => m.id === this._lastMessageId); + startIndex = foundIndex >= 0 ? foundIndex + 1 : 0; + } + } + + const newMessages = messages.slice(startIndex); + + // Update tracking state + this._lastSize = stats.size; + this._lastMessageCount = messages.length; + if (messages.length > 0) { + this._lastMessageId = messages[messages.length - 1]!.id; + } + + if (newMessages.length > 0 && this._onMessages) { + this._onMessages(newMessages); + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT' && this._onError) { + this._onError(err as Error); + } + } finally { + this._polling = false; + } + } +} +``` + +- [ ] **Step 2: Verify — TypeScript compiles** + +```bash +npx tsc --noEmit server/stores/json-watcher.ts 2>&1 | head -20 +``` +Expected: No errors (or only errors from missing sibling imports, not from this file). + +- [ ] **Step 3: Commit** + +```bash +git add server/stores/json-watcher.ts && git commit -m "feat(gemini): add JsonWatcher for single-JSON session files" +``` + +--- + +### Task 3: Gemini Types & Message Utils + +**Files:** +- Create: `server/adapters/gemini/message-utils.ts` + +- [ ] **Step 1: Create `server/adapters/gemini/message-utils.ts`** + +This file defines Gemini-specific types and content extraction helpers. Reference `server/adapters/claude/message-utils.ts` for the `ContentBlock` type — import it from there or from `server/types/messages.ts`. + +```typescript +// server/adapters/gemini/message-utils.ts +// +// Gemini-specific types and content extraction helpers. +// Converts Gemini's JSON message format to the shared ContentBlock format. + +import type { GeminiSessionMessage } from '../../stores/json-watcher.js'; + +// Import ContentBlock from Claude's message-utils (shared type used by all adapters). +// Do NOT re-declare — use the canonical definition to avoid type divergence. +import type { ContentBlock } from '../claude/message-utils.js'; + +/** Gemini tool call from session JSON */ +export interface GeminiToolCall { + id: string; + name: string; + args: Record; + result?: Array<{ + functionResponse: { + id: string; + name: string; + response: { output?: string; error?: string }; + }; + }>; + status: 'success' | 'cancelled' | 'error'; + timestamp: string; + displayName?: string; + description?: string; + renderOutputAsMarkdown?: boolean; +} + +/** Extract plain text from Gemini user message content */ +export function extractUserText(content: unknown): string { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .filter((c: any) => c && typeof c.text === 'string') + .map((c: any) => c.text) + .join('\n'); + } + return ''; +} + +/** Extract plain text from Gemini assistant message content */ +export function extractGeminiText(content: unknown): string { + if (typeof content === 'string') return content; + return ''; +} + +/** Convert Gemini toolCalls to ContentBlock[] (tool_use + tool_result pairs) */ +export function toolCallsToContentBlocks(toolCalls: GeminiToolCall[]): ContentBlock[] { + const blocks: ContentBlock[] = []; + for (const tc of toolCalls) { + // tool_use block + blocks.push({ + type: 'tool_use', + id: tc.id, + name: tc.name, + input: tc.args, + }); + // tool_result block (if result exists) + if (tc.result && tc.result.length > 0) { + const resp = tc.result[0]!.functionResponse.response; + blocks.push({ + type: 'tool_result', + tool_use_id: tc.id, + content: resp.output || resp.error || '', + is_error: tc.status === 'error' || tc.status === 'cancelled' || !!resp.error, + }); + } + } + return blocks; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add server/adapters/gemini/message-utils.ts && git commit -m "feat(gemini): add message-utils with types and content extraction" +``` + +--- + +### Task 4: Transcript Parser + +**Files:** +- Create: `server/adapters/gemini/transcript-parser.ts` + +- [ ] **Step 1: Create `server/adapters/gemini/transcript-parser.ts`** + +Reference `server/adapters/claude/transcript-parser.ts` for the `ParsedMessage` / `ParseResult` types. The Gemini parser is simpler because tool calls and thinking are embedded in the message JSON. + +```typescript +// server/adapters/gemini/transcript-parser.ts +// +// Converts Gemini JSON session messages to the shared ParsedMessage format. +// Much simpler than Claude's parser because Gemini embeds tool calls and +// thinking directly in each message (no cross-entry tracking needed). + +import type { GeminiSessionMessage } from '../../stores/json-watcher.js'; +import type { ContentBlock } from '../claude/message-utils.js'; +import { + extractUserText, extractGeminiText, toolCallsToContentBlocks, + type GeminiToolCall, +} from './message-utils.js'; + +/** Parsed message for frontend rendering (shared format across adapters) */ +export interface ParsedMessage { + id: string; + role: 'user' | 'assistant' | 'plan'; + content: ContentBlock[]; // Always array — never string (consistent with Claude/Codex) + adapter?: string; +} + +/** Result of parse() */ +export interface ParseResult { + messages: ParsedMessage[]; + errors: string[]; // Error messages to emit as session-error events +} + +/** Token/model info extracted from gemini messages */ +export interface StatusInfo { + model: string | null; + tokens: Record | null; +} + +/** Thinking entry from Gemini message */ +export interface ThoughtEntry { + subject: string; + description: string; + timestamp: string; +} + +export class GeminiTranscriptParser { + private _msgIndex: number = 0; + + /** + * Parse Gemini session messages into frontend-ready format. + * Called incrementally — _msgIndex is NOT reset between calls. + */ + parse(messages: GeminiSessionMessage[]): ParseResult { + const parsed: ParsedMessage[] = []; + const errors: string[] = []; + + for (const msg of messages) { + switch (msg.type) { + case 'user': { + const text = extractUserText(msg.content); + if (!text.trim()) continue; + const userContent: ContentBlock[] = Array.isArray(msg.content) + ? (msg.content as ContentBlock[]) + : [{ type: 'text', text }]; + parsed.push({ + id: `msg-${this._msgIndex++}`, + role: 'user', + content: userContent, + adapter: 'gemini', + }); + break; + } + case 'gemini': { + const textContent = extractGeminiText(msg.content); + const blocks: ContentBlock[] = []; + + // Add text block if present + if (textContent) { + blocks.push({ type: 'text', text: textContent }); + } + + // Convert embedded toolCalls to ContentBlocks + if (msg.toolCalls && Array.isArray(msg.toolCalls)) { + blocks.push(...toolCallsToContentBlocks(msg.toolCalls as GeminiToolCall[])); + } + + if (blocks.length === 0) continue; + + parsed.push({ + id: `msg-${this._msgIndex++}`, + role: 'assistant', + content: blocks, + adapter: 'gemini', + }); + break; + } + case 'error': { + const errorText = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content); + errors.push(errorText); + break; + } + case 'info': + // Skip internal CLI info messages + break; + } + } + + return { messages: parsed, errors }; + } + + /** Extract thinking entries from a gemini message */ + static extractThoughts(msg: GeminiSessionMessage): ThoughtEntry[] { + if (msg.type !== 'gemini' || !msg.thoughts || !Array.isArray(msg.thoughts)) return []; + return msg.thoughts as ThoughtEntry[]; + } + + /** Extract status info (model, tokens) from a gemini message */ + static extractStatus(msg: GeminiSessionMessage): StatusInfo | null { + if (msg.type !== 'gemini') return null; + if (!msg.model && !msg.tokens) return null; + return { + model: (msg.model as string) || null, + tokens: (msg.tokens as Record) || null, + }; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add server/adapters/gemini/transcript-parser.ts && git commit -m "feat(gemini): add transcript parser (JSON -> ParsedMessage)" +``` + +--- + +### Task 5: Json Store — Session Discovery + +**Files:** +- Create: `server/adapters/gemini/json-store.ts` + +- [ ] **Step 1: Create `server/adapters/gemini/json-store.ts`** + +Reference `server/adapters/claude/jsonl-store.ts` for the `SessionInfo` type from `server/types/adapter.ts`. Gemini uses `~/.gemini/projects.json` for project mapping and `~/.gemini/tmp//chats/` for session files. + +```typescript +// server/adapters/gemini/json-store.ts +// +// Session discovery for Gemini CLI sessions. +// Sessions are stored as single JSON files in ~/.gemini/tmp//chats/ + +import { readFileSync, readdirSync, statSync } from 'fs'; +import { join, basename } from 'path'; +import { homedir } from 'os'; +import type { SessionInfo } from '../../types/adapter.js'; +import { extractUserText } from './message-utils.js'; + +const GEMINI_DIR = join(homedir(), '.gemini'); +const TMP_DIR = join(GEMINI_DIR, 'tmp'); +const PROJECTS_JSON = join(GEMINI_DIR, 'projects.json'); + +/** Read ~/.gemini/projects.json -> { "/abs/path": "project-name" } */ +function readProjectsMapping(): Record { + try { + const data = JSON.parse(readFileSync(PROJECTS_JSON, 'utf-8')); + return data.projects || {}; + } catch { return {}; } +} + +/** Get project name for a given directory path */ +export function getProjectName(dir: string): string | null { + const mapping = readProjectsMapping(); + return mapping[dir] || null; +} + +/** Get project root path from .project_root file */ +function getProjectRoot(projectName: string): string | null { + try { + return readFileSync(join(TMP_DIR, projectName, '.project_root'), 'utf-8').trim(); + } catch { return null; } +} + +/** List all project directories in ~/.gemini/tmp/ */ +function listProjectDirs(): string[] { + try { + return readdirSync(TMP_DIR).filter(name => { + try { + return statSync(join(TMP_DIR, name)).isDirectory() && name !== 'bin'; + } catch { return false; } + }); + } catch { return []; } +} + +/** List session files for a specific project */ +function listSessionFiles(projectName: string): string[] { + const chatsDir = join(TMP_DIR, projectName, 'chats'); + try { + return readdirSync(chatsDir) + .filter(f => f.startsWith('session-') && f.endsWith('.json')) + .map(f => join(chatsDir, f)); + } catch { return []; } +} + +/** Read a session file and extract metadata */ +function readSessionMeta(filePath: string): SessionInfo | null { + try { + const content = readFileSync(filePath, 'utf-8'); + const session = JSON.parse(content); + const messages = session.messages || []; + + // Find first user message text + const firstUser = messages.find((m: any) => m.type === 'user'); + const firstPrompt = firstUser ? extractUserText(firstUser.content) : null; + + // Find latest model from last gemini message + let model: string | null = null; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].type === 'gemini' && messages[i].model) { + model = messages[i].model; + break; + } + } + + const stat = statSync(filePath); + + return { + sessionId: session.sessionId, + cwd: '', // Will be set by caller from project root + lastModified: session.lastUpdated || stat.mtime.toISOString(), + firstPrompt: firstPrompt ? firstPrompt.slice(0, 200) : null, + model, + // NOTE: SessionInfo type does NOT have an 'adapter' field. + // Adapter identification happens at the API layer, not in the store. + }; + } catch { return null; } +} + +/** Get sessions for a specific directory (or all projects if dir is omitted) */ +export function getSessions(dir?: string, limit: number = 50): SessionInfo[] { + const sessions: SessionInfo[] = []; + + if (dir) { + const projectName = getProjectName(dir); + if (!projectName) return []; + const files = listSessionFiles(projectName); + for (const file of files) { + const meta = readSessionMeta(file); + if (meta) { meta.cwd = dir; sessions.push(meta); } + } + } else { + // All projects + for (const projectName of listProjectDirs()) { + const projectRoot = getProjectRoot(projectName); + const files = listSessionFiles(projectName); + for (const file of files) { + const meta = readSessionMeta(file); + if (meta) { meta.cwd = projectRoot || ''; sessions.push(meta); } + } + } + } + + sessions.sort((a, b) => { + const ta = a.lastModified ? new Date(a.lastModified).getTime() : 0; + const tb = b.lastModified ? new Date(b.lastModified).getTime() : 0; + return tb - ta; + }); + return sessions.slice(0, limit); +} + +/** Find a session file by sessionId across all projects */ +export function findSessionFile(sessionId: string): string | null { + for (const projectName of listProjectDirs()) { + const files = listSessionFiles(projectName); + for (const file of files) { + try { + const content = readFileSync(file, 'utf-8'); + const session = JSON.parse(content); + if (session.sessionId === sessionId) return file; + } catch { continue; } + } + } + return null; +} + +/** Get all messages from a session file */ +export function getSessionMessages(sessionId: string, dir?: string): { messages: unknown[]; lastModified: string | null } { + let filePath: string | null = null; + + if (dir) { + const projectName = getProjectName(dir); + if (projectName) { + const files = listSessionFiles(projectName); + for (const file of files) { + try { + const content = readFileSync(file, 'utf-8'); + const session = JSON.parse(content); + if (session.sessionId === sessionId) { filePath = file; break; } + } catch { continue; } + } + } + } + + if (!filePath) filePath = findSessionFile(sessionId); + if (!filePath) return { messages: [], lastModified: null }; + + try { + const content = readFileSync(filePath, 'utf-8'); + const session = JSON.parse(content); + return { + messages: session.messages || [], + lastModified: session.lastUpdated || null, + }; + } catch { return { messages: [], lastModified: null }; } +} +``` + +**Important notes for implementer:** +- `getSessionMessages()` maps to `IAdapter.getMessages()` — the GeminiAdapter (Task 9) calls `getSessionMessages` internally +- Add `listDirectory(path?)` export — reuse the pattern from Claude's `jsonl-store.ts` (reads a directory and returns `DirectoryEntry[]`). Gemini projects are rooted in whatever `.project_root` says. +- All `readFileSync` calls are acceptable for <34KB files. For `findSessionFile()` which scans all projects, consider capping at 100 files. + +- [ ] **Step 2: Commit** + +```bash +git add server/adapters/gemini/json-store.ts && git commit -m "feat(gemini): add json-store for session discovery" +``` + +--- + +### Task 6: Hook Config & Bridge Script + +**Files:** +- Create: `server/adapters/gemini/hook-config.ts` +- Create: `server/adapters/gemini/bridge.sh` + +- [ ] **Step 1: Create `server/adapters/gemini/bridge.sh`** + +Copy the bridge script from the spec verbatim. Make it executable. + +```bash +cat > server/adapters/gemini/bridge.sh << 'BRIDGE' +#!/bin/bash +# Reads JSON from stdin (Gemini hook protocol), POSTs to code-tap server. +# +# IMPORTANT: Gemini hooks expect a JSON response on stdout. We must write +# a response BEFORE backgrounding the curl POST, or Gemini will hang. +# Exit code 0 = allow (continue), exit code 2 = block. +ENDPOINT="$1" +PORT="${CODETAP_PORT:-3456}" +PROTOCOL="${CODETAP_PROTOCOL:-http}" +CURL_K="" +[ "$PROTOCOL" = "https" ] && CURL_K="-k" + +# Read stdin (Gemini hook JSON payload) +input=$(cat) + +# Respond to Gemini immediately +printf '{}' + +# Port check: skip curl if server isn't listening +(echo >/dev/tcp/localhost/$PORT) 2>/dev/null || exit 0 + +# Forward payload to code-tap server asynchronously +printf '%s' "$input" | curl -sf $CURL_K --connect-timeout 2 --max-time 5 \ + -X POST -H 'Content-Type:application/json' -d @- \ + "${PROTOCOL}://localhost:${PORT}/api/hooks/gemini/${ENDPOINT}" &>/dev/null & +BRIDGE +chmod +x server/adapters/gemini/bridge.sh +``` + +- [ ] **Step 2: Create `server/adapters/gemini/hook-config.ts`** + +Follow the pattern from `server/adapters/claude/hook-config.ts` — read/write `~/.gemini/settings.json`, use portTag for ownership identification, wrap (don't replace) existing hooks. + +The hook-config must: +- Set `CODETAP_PORT` and `CODETAP_PROTOCOL` env vars in the command string +- Use absolute path to bridge.sh +- Install hooks for: `BeforeTool`, `AfterTool`, `BeforeAgent`, `AfterAgent`, `SessionStart`, `SessionEnd` + +```typescript +// server/adapters/gemini/hook-config.ts +// +// Pure filesystem operations for Gemini CLI hook management. +// Zero runtime dependencies — no EventEmitter, no tmux, no sessions. +// +// Key differences from Claude/Codex: +// - Hooks live in ~/.gemini/settings.json under the "hooks" key +// - Uses bridge.sh (stdin JSON -> curl POST) instead of direct curl +// - No statusLine wrapping (Gemini has no statusLine hook) + +import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs'; +import { join, dirname, resolve } from 'path'; +import { homedir } from 'os'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +interface HookAction { + type?: string; + command?: string; + timeout?: number; +} + +interface HookEntry { + matcher?: string; + hooks: HookAction[]; +} + +interface GeminiSettings { + hooks?: Record; + [key: string]: unknown; +} + +export class GeminiHookConfig { + port: number | string; + useHttps: boolean; + + constructor(port?: number | string, useHttps?: boolean) { + this.port = port || process.env.PORT || 3456; + if (useHttps !== undefined) { + this.useHttps = useHttps; + } else { + const codetapDir = join(homedir(), '.codetap'); + this.useHttps = existsSync(join(codetapDir, 'cert.pem')) && existsSync(join(codetapDir, 'key.pem')); + } + } + + install(): void { + const settingsDir = join(homedir(), '.gemini'); + const settingsPath = join(settingsDir, 'settings.json'); + const { portTag } = this._hookIdentifiers(); + const desiredHooks = this._buildDesiredHooks(); + + try { + mkdirSync(settingsDir, { recursive: true }); + let existing: GeminiSettings = {}; + try { existing = JSON.parse(readFileSync(settingsPath, 'utf-8')) as GeminiSettings; } catch {} + + if (!existing.hooks) existing.hooks = {}; + + for (const [event, configs] of Object.entries(desiredHooks)) { + const existingEntries = existing.hooks[event] || []; + const filtered = existingEntries.filter(entry => !this._isOurHookEntry(entry, portTag)); + existing.hooks[event] = [...filtered, ...configs]; + } + + writeFileSync(settingsPath, JSON.stringify(existing, null, 2)); + console.log(`[hooks:gemini] Auto-configured hooks in ${settingsPath}`); + } catch (err) { + console.warn(`[hooks:gemini] Failed to auto-configure hooks: ${(err as Error).message}`); + } + } + + uninstall(): void { + const { portTag } = this._hookIdentifiers(); + const settingsPath = join(homedir(), '.gemini', 'settings.json'); + + try { + const existing: GeminiSettings = JSON.parse(readFileSync(settingsPath, 'utf-8')) as GeminiSettings; + + if (existing.hooks) { + const hookKeys = Object.keys(this._buildDesiredHooks()); + for (const key of hookKeys) { + const entries = existing.hooks[key]; + if (!Array.isArray(entries)) continue; + const filtered = entries.filter(entry => !this._isOurHookEntry(entry, portTag)); + if (filtered.length === 0) { + delete existing.hooks[key]; + } else { + existing.hooks[key] = filtered; + } + } + if (Object.keys(existing.hooks).length === 0) delete existing.hooks; + } + + writeFileSync(settingsPath, JSON.stringify(existing, null, 2)); + console.log(`[hooks:gemini] Removed CodeTap hooks from ${settingsPath}`); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return; + console.warn(`[hooks:gemini] Failed to remove hooks: ${(err as Error).message}`); + } + } + + private _hookIdentifiers(): { portTag: string } { + return { portTag: `CODETAP_PORT=${this.port}` }; + } + + private _isOurHookEntry(entry: HookEntry, portTag: string): boolean { + return (entry.hooks || []).some(h => h.command && h.command.includes(portTag)); + } + + private _buildDesiredHooks(): Record { + const bridgePath = resolve(__dirname, 'bridge.sh'); + const protocol = this.useHttps ? 'https' : 'http'; + const envPrefix = `CODETAP_PORT=${this.port} CODETAP_PROTOCOL=${protocol}`; + const cmd = (endpoint: string): string => `${envPrefix} ${bridgePath} ${endpoint}`; + + return { + SessionStart: [{ hooks: [{ type: 'command', command: cmd('session-start'), timeout: 3 }] }], + SessionEnd: [{ hooks: [{ type: 'command', command: cmd('session-end'), timeout: 3 }] }], + BeforeTool: [{ matcher: '*', hooks: [{ type: 'command', command: cmd('before-tool'), timeout: 3 }] }], + AfterTool: [{ matcher: '*', hooks: [{ type: 'command', command: cmd('after-tool'), timeout: 3 }] }], + BeforeAgent: [{ hooks: [{ type: 'command', command: cmd('before-agent'), timeout: 3 }] }], + AfterAgent: [{ hooks: [{ type: 'command', command: cmd('after-agent'), timeout: 3 }] }], + }; + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add server/adapters/gemini/hook-config.ts server/adapters/gemini/bridge.sh && git commit -m "feat(gemini): add hook-config and bridge script" +``` + +--- + +### Task 7: Pane Monitor + +**Files:** +- Create: `server/adapters/gemini/pane-monitor.ts` + +- [ ] **Step 1: Create `server/adapters/gemini/pane-monitor.ts`** + +Follow the pattern from `server/adapters/codex/pane-monitor.ts` — takes sessionId, windowId, tmux manager, and EventEmitter. Gemini TUI patterns will need empirical refinement, so start with conservative placeholder patterns similar to Codex. + +The key difference from Claude/Codex: Gemini already provides thinking in the JSON, so the pane monitor's thinking detection is supplementary (for real-time streaming before JSON is written). + +```typescript +// server/adapters/gemini/pane-monitor.ts +// +// Polls tmux pane for real-time streaming output from Gemini CLI. +// Detects: streaming text, thinking indicators. +// Note: Gemini provides thinking in JSON (thoughts[]) — pane monitor +// provides real-time streaming BEFORE JSON is written to disk. + +import { EventEmitter } from 'events'; + +interface TmuxCapture { + capturePane(windowId: string, lines?: number): Promise; +} + +export interface ThinkingInfo { + text: string; + detail: string | null; +} + +export class GeminiPaneMonitor { + private sessionId: string; + private windowId: string; + private tmux: TmuxCapture; + private emitter: EventEmitter; + private interval: ReturnType | null = null; + private _lastContent: string = ''; + private _lastResponseText: string = ''; + + constructor( + sessionId: string, + windowId: string, + tmuxManager: TmuxCapture, + emitter: EventEmitter, + ) { + this.sessionId = sessionId; + this.windowId = windowId; + this.tmux = tmuxManager; + this.emitter = emitter; + } + + start(): void { + if (this.interval) return; + this.interval = setInterval(() => this._poll(), 500); + } + + stop(): void { + if (this.interval) { clearInterval(this.interval); this.interval = null; } + } + + async pollNow(): Promise { await this._poll(); } + + private async _poll(): Promise { + try { + const content = await this.tmux.capturePane(this.windowId); + if (content === this._lastContent) return; + this._lastContent = content; + + // 1. Check for thinking indicator + const thinking = detectThinking(content); + if (thinking) { + this.emitter.emit('thinking', this.sessionId, thinking); + return; + } + + // 2. Extract streaming response text + const text = extractResponseText(content); + if (text && text !== this._lastResponseText) { + this._lastResponseText = text; + this.emitter.emit('streaming-text', this.sessionId, text); + } + } catch { + // Silently ignore — tmux window may have been killed + } + } +} + +// --- Detection functions (exported for testing) --- + +/** Detect Gemini CLI thinking/processing indicators */ +export function detectThinking(content: string): ThinkingInfo | null { + const lines = content.split('\n'); + const tail = lines.slice(-15); + for (const line of tail) { + if (/completed|finished|done|exited/i.test(line)) continue; + // Gemini uses braille spinners and "Thinking..." text + const brailleMatch = line.match(/^\s*([⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏])\s+(.+?)\s*$/); + if (brailleMatch) return { text: brailleMatch[2]!, detail: null }; + const thinkingMatch = line.match(/^\s*(Thinking|Reasoning|Processing|Searching)(\.\.\.)?\s*(?:\((.+?)\))?\s*$/i); + if (thinkingMatch) return { text: `${thinkingMatch[1]}...`, detail: thinkingMatch[3] || null }; + } + return null; +} + +/** Extract streaming response text from Gemini pane content */ +export function extractResponseText(content: string): string { + const lines = content.split('\n'); + // Find last user prompt (Gemini uses > or ❯) + let lastUserPrompt = -1; + for (let i = lines.length - 1; i >= 0; i--) { + if (/^\s*[>❯]\s+\S/.test(lines[i]!)) { lastUserPrompt = i; break; } + } + if (lastUserPrompt === -1) return ''; + + let responseStart = lastUserPrompt + 1; + while (responseStart < lines.length && lines[responseStart]!.trim() === '') responseStart++; + if (responseStart >= lines.length) return ''; + + const responseLines: string[] = []; + for (let i = responseStart; i < lines.length; i++) { + const line = lines[i]!; + if (/^[─━═\-]{5,}/.test(line.trim()) || + /^\s*[>❯]\s+\S/.test(line) || + /^\s*[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s+/.test(line)) break; + responseLines.push(line); + } + return responseLines.join('\n').trim(); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add server/adapters/gemini/pane-monitor.ts && git commit -m "feat(gemini): add pane monitor for streaming text detection" +``` + +--- + +### Task 8: GeminiTmuxAdapter — Session Lifecycle + +**Files:** +- Create: `server/adapters/gemini/gemini-tmux-adapter.ts` + +- [ ] **Step 1: Create `server/adapters/gemini/gemini-tmux-adapter.ts`** + +This is the largest file. Model it closely on `server/adapters/codex/codex-tmux-adapter.ts` since both share the same pattern: session ID discovered from hook, `_pendingHookBodies` for race conditions, JSONL/JSON watcher started on SessionStart hook. + +Key differences from Codex: +- Uses `JsonWatcher` instead of `JsonlWatcher` +- Uses `GeminiTranscriptParser` instead of `CodexTranscriptParser` +- More hook events (BeforeTool, AfterTool, BeforeAgent, AfterAgent) +- Permission toggle via Ctrl+Y (binary YOLO toggle) +- Model switch via `/model ` slash command + +This file is large (~400-500 lines). The implementer should: +1. Read `server/adapters/codex/codex-tmux-adapter.ts` in full +2. Copy its structure, adapting for Gemini's specifics +3. Key methods: `startSession`, `resumeSession`, `sendMessage`, `interrupt`, `switchPermissionMode`, `switchModel`, `handleSessionStart`, `handleBeforeTool`, `handleAfterTool`, `handleBeforeAgent`, `handleAfterAgent`, `handleSessionEnd` + +The complete implementation is too large for inline code in this plan. The implementer should use the Codex adapter as a template and the spec's Section 7 (Session Lifecycle) for Gemini-specific behavior. + +- [ ] **Step 2: Verify — file compiles** + +```bash +npx tsc --noEmit server/adapters/gemini/gemini-tmux-adapter.ts 2>&1 | head -20 +``` + +- [ ] **Step 3: Commit** + +```bash +git add server/adapters/gemini/gemini-tmux-adapter.ts && git commit -m "feat(gemini): add tmux adapter for session lifecycle" +``` + +--- + +### Task 9: GeminiAdapter — Main Entry Point + +**Files:** +- Create: `server/adapters/gemini/index.ts` + +- [ ] **Step 1: Create `server/adapters/gemini/index.ts`** + +Model on `server/adapters/codex/index.ts`. Wires the GeminiTmuxAdapter, GeminiHookConfig, and json-store together. Registers HTTP hook routes via `setup(app)`. + +Key aspects: +- `static id = 'gemini'`, `static displayName = 'Gemini CLI'`, `static command = 'gemini'` +- `setup(app)`: register routes for `/api/hooks/gemini/session-start`, `before-tool`, `after-tool`, `before-agent`, `after-agent`, `session-end` +- Delegate all IAdapter methods to GeminiTmuxAdapter +- `getModels()`, `getPermissionModes()`, `getCapabilities()` as defined in spec Section 6 +- `installHooks()` / `uninstallHooks()` delegate to GeminiHookConfig + +Again, too large for inline code. The implementer should use `server/adapters/codex/index.ts` as a template. + +- [ ] **Step 2: Verify — server starts with gemini adapter loaded** + +```bash +npx tsx server/index.ts 2>&1 | head -20 +``` +Expected: Should see `[init] Loaded adapter: gemini` (or no error if gemini CLI not installed — registry skips it). + +- [ ] **Step 3: Commit** + +```bash +git add server/adapters/gemini/index.ts && git commit -m "feat(gemini): add GeminiAdapter main entry point" +``` + +--- + +### Task 10: Registration & CLI Integration + +**Files:** +- Modify: `server/adapters/init.ts` +- Modify: `server/adapters/registry.ts` +- Modify: `bin/hooks-cli.mjs` +- Modify: `bin/codetap` + +- [ ] **Step 1: Update `server/adapters/init.ts` — add gemini loader** + +Add to `LOADERS`: +```typescript +gemini: () => import('./gemini/index.js').then(m => m.GeminiAdapter), +``` + +- [ ] **Step 2: Update `server/adapters/registry.ts` — add gemini to defaults** + +Change line 29 from: +```typescript +: ['claude', 'codex']; +``` +To: +```typescript +: ['claude', 'codex', 'gemini']; +``` + +- [ ] **Step 3: Update `bin/hooks-cli.mjs` — add GeminiHookConfig** + +Add import: +```javascript +import { GeminiHookConfig } from '../server/adapters/gemini/hook-config.js'; +``` +Add instance: +```javascript +const gemini = new GeminiHookConfig(); +``` +Add to install/uninstall: +```javascript +if (cmd === 'install') { + claude.install(); + codex.install(); + gemini.install(); +} else { + claude.uninstall(); + codex.uninstall(); + gemini.uninstall(); +} +``` + +- [ ] **Step 4: Update `bin/codetap` — add gemini support** + +Five changes: +1. `set_adapter()`: add `gemini) ADAPTER="gemini"; ADAPTER_CMD="gemini"; YOLO="--approval-mode yolo" ;;` +2. Adapter detection: add `*gemini*) SESS_ADAPTER="gemini" ;;` +3. ANSI label: add `gemini) LABEL="\033[34m[Gemini]\033[0m" ;;` +4. `--adapter` validation: add `gemini) set_adapter gemini ;;` +5. Help text (line ~45): update `--adapter ` description to `(claude, codex, gemini)` + +- [ ] **Step 5: Verify — `codetap --help` shows gemini, hooks install works** + +```bash +./bin/codetap --help +node bin/hooks-cli.mjs install 2>&1 | grep gemini +node bin/hooks-cli.mjs uninstall 2>&1 | grep gemini +``` + +- [ ] **Step 6: Commit** + +```bash +git add server/adapters/init.ts server/adapters/registry.ts bin/hooks-cli.mjs bin/codetap && git commit -m "feat(gemini): wire up registration, CLI, and hook management" +``` + +--- + +### Task 11: Frontend — Adapter Brand & Icon + +**Files:** +- Modify: `src/lib/adapter-brands.ts` +- Modify: `src/components/AdapterIcon.tsx` + +- [ ] **Step 1: Update `src/lib/adapter-brands.ts`** + +Extend the `AdapterBrand` type to include `'gemini'` in `iconType`: +```typescript +iconType: 'claude' | 'codex' | 'gemini'; +``` + +Add gemini brand to `ADAPTER_BRANDS`: +```typescript +gemini: { + id: 'gemini', + displayName: 'Gemini', + provider: 'Google', + color: '#4285f4', + colorBg: '#4285f422', + gradient: 'linear-gradient(135deg, #4285f4, #1a73e8)', + glow: 'rgba(66,133,244,0.3)', + iconType: 'gemini', +}, +``` + +- [ ] **Step 2: Update `src/components/AdapterIcon.tsx`** + +1. Add `GeminiIcon` component — find the official Google Gemini star SVG from thesvg.org +2. Refactor the icon selection from if/else to a map: + +```tsx +const ICON_MAP: Record> = { + claude: ClaudeIcon, + codex: CodexIcon, + gemini: GeminiIcon, +}; + +// In AdapterIcon: +const Icon = ICON_MAP[brand.iconType] || ClaudeIcon; +return ; +``` + +- [ ] **Step 3: Verify — `npm run dev` builds, open browser, check adapter selector shows Gemini** + +```bash +npm run dev +``` +Open http://localhost:5173, check settings → adapter list shows Gemini with blue icon. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/adapter-brands.ts src/components/AdapterIcon.tsx && git commit -m "feat(gemini): add Gemini brand and icon to frontend" +``` + +--- + +### Task 12: End-to-End Verification + +- [ ] **Step 1: Start code-tap server** + +```bash +CLAUDE_UI_PASSWORD=test npm run dev +``` + +- [ ] **Step 2: Verify adapter registration** + +```bash +curl -s http://localhost:3456/health | python3 -m json.tool +curl -sk -X POST http://localhost:3456/api/auth/login -H 'Content-Type: application/json' -d '{"password":"test"}' | python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])' +# Use token for: +curl -s http://localhost:3456/api/adapters -H 'Authorization: Bearer ' | python3 -m json.tool +``` +Expected: Response includes `{ id: "gemini", displayName: "Gemini CLI", available: true/false, capabilities: {...} }` + +- [ ] **Step 3: Verify hooks install/uninstall** + +```bash +node bin/hooks-cli.mjs install +cat ~/.gemini/settings.json | python3 -m json.tool +# Should show hooks.BeforeTool, hooks.AfterTool, etc. with bridge.sh paths +node bin/hooks-cli.mjs uninstall +cat ~/.gemini/settings.json | python3 -m json.tool +# Hooks should be removed +``` + +- [ ] **Step 4: Verify session listing** + +```bash +curl -s http://localhost:3456/api/sessions?adapter=gemini -H 'Authorization: Bearer ' | python3 -m json.tool +``` +Expected: Returns existing Gemini sessions from `~/.gemini/tmp/*/chats/` (if any exist). + +- [ ] **Step 5: Manual test — full flow (if Gemini CLI is working)** + +1. Open phone browser → code-tap +2. Select Gemini adapter +3. Start new session +4. Send a prompt +5. Verify: streaming text appears, thinking shows, tool calls render +6. Resume session works + +- [ ] **Step 6: Final commit (if any fixes needed)** + +```bash +git add -A && git commit -m "fix(gemini): end-to-end verification fixes" +``` diff --git a/docs/superpowers/plans/2026-03-26-pwa-optimization.md b/docs/superpowers/plans/2026-03-26-pwa-optimization.md new file mode 100644 index 0000000..032ccf7 --- /dev/null +++ b/docs/superpowers/plans/2026-03-26-pwa-optimization.md @@ -0,0 +1,558 @@ +# PWA Optimization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bring CodeTap's PWA to production-grade quality with proper viewport handling, splash screens, install prompts, SW updates, badge management, draft persistence, navigation history, and social meta tags. + +**Architecture:** All changes are additive — native Web APIs with feature detection, no new dependencies. App.tsx gains PWA lifecycle effects. ShimmerInput gains draft persistence. Manifest and HTML get richer metadata. + +**Tech Stack:** Vite + vite-plugin-pwa + native Web APIs (beforeinstallprompt, History API, Network Information API) + +**Spec:** `docs/superpowers/specs/2026-03-26-pwa-optimization-design.md` + +--- + +## File Structure + +| File | Responsibility | Action | +|------|---------------|--------| +| `index.html` | Viewport, splash images, OG tags | Modify | +| `vite.config.ts` | Manifest shortcuts, screenshots | Modify | +| `src/App.tsx` | Install prompt, SW update, badge clear, history API | Modify | +| `src/components/SessionsView.tsx` | Install banner UI | Modify | +| `src/components/ShimmerInput.tsx` | Draft auto-save | Modify | +| `src/components/StatusBar.tsx` | Slow network indicator | Modify | +| `src/index.css` | Safe area utilities | Modify | +| `public/splash/` | iOS splash screen images | Create | +| `public/screenshots/` | Manifest screenshots | Create | + +--- + +## Task 1: Viewport & Safe Areas + +**Files:** +- Modify: `index.html` +- Modify: `src/index.css` + +- [ ] **Step 1: Add viewport-fit=cover to index.html** + +Change the viewport meta tag: +```html + +``` + +- [ ] **Step 2: Add safe area utilities to index.css** + +After the existing `.safe-bottom` rule: +```css +.safe-top { + padding-top: env(safe-area-inset-top); +} + +.safe-x { + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); +} +``` + +- [ ] **Step 3: Verify — open in iOS simulator or DevTools, check notch area** + +- [ ] **Step 4: Commit** +```bash +git add index.html src/index.css +git commit -m "feat(pwa): viewport-fit=cover and full safe area utilities" +``` + +--- + +## Task 2: iOS Splash Screens + +**Files:** +- Create: `public/splash/` directory +- Modify: `index.html` + +- [ ] **Step 1: Create splash screen SVG generator script** + +Create a simple inline SVG splash as a data URI approach in index.html. This avoids needing to generate multiple PNGs. Use `apple-mobile-web-app-startup-image` with media queries for major iPhone sizes. + +Add to `` in index.html, after the apple-touch-icon link: +```html + + + + + + + + + +``` + +- [ ] **Step 2: Generate splash screen PNGs via Node.js canvas script** + +Create `scripts/generate-splash.mjs` that uses the built-in `node:canvas` or a simple HTML-to-PNG approach. Simplest method: create a single-use Node script that writes minimal HTML to a temp file and uses `sharp` or pure SVG-to-PNG. Actually, the most practical approach for a CLI tool: use a simple Node script that generates SVG strings and writes them as `.svg` files that Safari can use (Safari accepts SVG for startup images). If SVG doesn't work, use a single high-res PNG and reference it without media queries as a universal fallback. + +Practical fallback: Create `public/splash/` with a single `splash.svg` (dark bg + centered "CodeTap" text), referenced without media queries. Remove per-device media queries from Step 1 and use a single universal link tag instead: +```html + +``` +If SVG is not supported by Safari for startup images (it isn't), generate a single 1290x2796 PNG using a canvas script at build time, or manually create one. The key requirement is: dark background (#09090b), centered CodeTap text or mascot. + +- [ ] **Step 3: Verify — add to home screen on iOS, check splash appears** + +- [ ] **Step 4: Commit** +```bash +git add -f public/splash/ index.html +git commit -m "feat(pwa): iOS splash screens for major iPhone sizes" +``` + +--- + +## Task 3: Android Install Prompt + +**Files:** +- Modify: `src/App.tsx` +- Modify: `src/components/SessionsView.tsx` + +- [ ] **Step 1: Add install prompt state to App.tsx** + +Add state and effect near the top of `App()`: +```tsx +const [installPrompt, setInstallPrompt] = useState(null); +const [installDismissed, setInstallDismissed] = useState( + () => localStorage.getItem('codetap:install-dismissed') === 'true' +); + +useEffect(() => { + const handler = (e: Event) => { + e.preventDefault(); + setInstallPrompt(e); + }; + window.addEventListener('beforeinstallprompt', handler); + const installedHandler = () => { + setInstallPrompt(null); + setInstallDismissed(true); + localStorage.setItem('codetap:install-dismissed', 'true'); + }; + window.addEventListener('appinstalled', installedHandler); + return () => { + window.removeEventListener('beforeinstallprompt', handler); + window.removeEventListener('appinstalled', installedHandler); + }; +}, []); +``` + +- [ ] **Step 2: Pass install props to SessionsView** + +Update the SessionsView rendering in App.tsx: +```tsx + setView({ name: 'settings' })} + installPrompt={!installDismissed ? installPrompt : null} + onInstall={async () => { + if (installPrompt) { + installPrompt.prompt(); + const result = await installPrompt.userChoice; + if (result.outcome === 'accepted') { + setInstallPrompt(null); + setInstallDismissed(true); + localStorage.setItem('codetap:install-dismissed', 'true'); + } + } + }} + onDismissInstall={() => { + setInstallDismissed(true); + localStorage.setItem('codetap:install-dismissed', 'true'); + }} +/> +``` + +- [ ] **Step 3: Add install banner to SessionsView** + +Add to SessionsView props interface and render a banner below the header when `installPrompt` is truthy: +```tsx +// Add to props +installPrompt?: any; +onInstall?: () => void; +onDismissInstall?: () => void; + +// Render below the header, before the tab bar +{installPrompt && ( +
+ Install CodeTap for a better experience + + +
+)} +``` + +- [ ] **Step 4: Verify — open in Chrome Android (or DevTools Application panel), check install banner appears** + +- [ ] **Step 5: Commit** +```bash +git add src/App.tsx src/components/SessionsView.tsx +git commit -m "feat(pwa): Android install prompt with dismissible banner" +``` + +--- + +## Task 4: Service Worker Update Notification + +**Files:** +- Modify: `src/App.tsx` + +- [ ] **Step 1: Add SW update detection and toast state** + +Add near other effects in App(): +```tsx +const [swUpdateAvailable, setSwUpdateAvailable] = useState(false); + +useEffect(() => { + const handleControllerChange = () => setSwUpdateAvailable(true); + navigator.serviceWorker?.addEventListener('controllerchange', handleControllerChange); + return () => navigator.serviceWorker?.removeEventListener('controllerchange', handleControllerChange); +}, []); +``` + +- [ ] **Step 2: Render update toast** + +Add before the closing `
` or at the bottom of the main render: +```tsx +{swUpdateAvailable && ( +
+ New version available +
+ + +
+
+)} +``` + +- [ ] **Step 3: Verify — modify SW file, rebuild, check toast appears** + +- [ ] **Step 4: Commit** +```bash +git add src/App.tsx +git commit -m "feat(pwa): service worker update notification toast" +``` + +--- + +## Task 5: Badge Clear on Focus + +**Files:** +- Modify: `src/App.tsx` + +- [ ] **Step 1: Add visibility change listener** + +Add with other effects in App(): +```tsx +useEffect(() => { + const handleVisibility = () => { + if (document.visibilityState === 'visible') { + navigator.clearAppBadge?.(); + } + }; + document.addEventListener('visibilitychange', handleVisibility); + return () => document.removeEventListener('visibilitychange', handleVisibility); +}, []); +``` + +- [ ] **Step 2: Commit** +```bash +git add src/App.tsx +git commit -m "feat(pwa): clear app badge when app becomes visible" +``` + +--- + +## Task 6: Manifest Shortcuts & Screenshots + +**Files:** +- Modify: `vite.config.ts` +- Create: `public/screenshots/` (placeholder) + +- [ ] **Step 1: Add shortcuts to manifest in vite.config.ts** + +Add after the `icons` array in the manifest config: +```ts +shortcuts: [ + { + name: 'New Chat', + short_name: 'New', + url: '/?action=newchat', + icons: [{ src: '/pwa-192x192.png', sizes: '192x192' }], + }, +], +categories: ['developer-tools', 'productivity'], +``` + +- [ ] **Step 2: Handle ?action=newchat in App.tsx** + +Add after the existing `?session=` URL handler: +```tsx +useEffect(() => { + const params = new URLSearchParams(window.location.search); + if (params.get('action') === 'newchat' && authed) { + window.history.replaceState({}, '', '/'); + // Shortcut just brings user to sessions view — they pick a project from there + setView({ name: 'sessions' }); + } +}, [authed]); +``` + +- [ ] **Step 3: Add screenshots placeholder to manifest** + +Add to manifest in vite.config.ts: +```ts +screenshots: [ + { + src: '/screenshots/narrow.png', + sizes: '1080x1920', + type: 'image/png', + form_factor: 'narrow', + label: 'CodeTap Chat View', + }, + { + src: '/screenshots/wide.png', + sizes: '1920x1080', + type: 'image/png', + form_factor: 'wide', + label: 'CodeTap Sessions View', + }, +], +``` + +Create `public/screenshots/` directory with placeholder images (can be actual screenshots later). + +- [ ] **Step 4: Commit** +```bash +git add vite.config.ts src/App.tsx +git add -f public/screenshots/ 2>/dev/null || true +git commit -m "feat(pwa): manifest shortcuts, categories, and screenshots config" +``` + +--- + +## Task 7: Input Draft Auto-Save + +**Files:** +- Modify: `src/components/ShimmerInput.tsx` + +- [ ] **Step 1: Add draft persistence to ShimmerInput** + +ShimmerInput doesn't receive a sessionId prop, so use a global draft key. Add after the existing state declarations: + +```tsx +const DRAFT_KEY = 'codetap:draft'; + +// Restore draft on mount +useEffect(() => { + if (!initialText) { + const saved = localStorage.getItem(DRAFT_KEY); + if (saved) setText(saved); + } +}, []); + +// Debounce-save draft on text change +const saveTimer = useRef>(); +useEffect(() => { + clearTimeout(saveTimer.current); + if (text.trim()) { + saveTimer.current = setTimeout(() => { + localStorage.setItem(DRAFT_KEY, text); + }, 500); + } else { + localStorage.removeItem(DRAFT_KEY); + } + return () => clearTimeout(saveTimer.current); +}, [text]); +``` + +- [ ] **Step 2: Clear draft on send** + +In the existing send handler, add `localStorage.removeItem(DRAFT_KEY)` after `onSend(...)`: +```tsx +// Find the handleSend function and add after onSend call: +localStorage.removeItem(DRAFT_KEY); +``` + +- [ ] **Step 3: Verify — type text, close tab, reopen, check draft restored** + +- [ ] **Step 4: Commit** +```bash +git add src/components/ShimmerInput.tsx +git commit -m "feat(pwa): auto-save input draft to localStorage with debounce" +``` + +--- + +## Task 8: Slow Network Detection + +**Files:** +- Modify: `src/components/StatusBar.tsx` + +- [ ] **Step 1: Add network quality detection** + +Add a hook at the top of StatusBar component: +```tsx +const [slowNetwork, setSlowNetwork] = useState(false); + +useEffect(() => { + const conn = (navigator as any).connection; + if (!conn) return; + const check = () => { + setSlowNetwork(conn.effectiveType === '2g' || conn.effectiveType === 'slow-2g'); + }; + check(); + conn.addEventListener('change', check); + return () => conn.removeEventListener('change', check); +}, []); +``` + +- [ ] **Step 2: Render slow network indicator** + +Add inside the status bar, before or after the model display: +```tsx +{slowNetwork && ( + Slow +)} +``` + +- [ ] **Step 3: Commit** +```bash +git add src/components/StatusBar.tsx +git commit -m "feat(pwa): slow network indicator via Network Information API" +``` + +--- + +## Task 9: History API Navigation + +**Files:** +- Modify: `src/App.tsx` + +- [ ] **Step 1: Push state on view changes** + +Modify the `saveView` function to also push history state: +```tsx +function saveView(view: View) { + sessionStorage.setItem('currentView', JSON.stringify(view)); + const url = view.name === 'chat' && view.sessionId + ? `/?view=chat&session=${view.sessionId}` + : view.name === 'settings' + ? '/?view=settings' + : '/'; + window.history.pushState({ view }, '', url); +} +``` + +- [ ] **Step 2: Listen for popstate (back button/gesture)** + +Add effect in App(): +```tsx +useEffect(() => { + const handlePopState = (event: PopStateEvent) => { + if (event.state?.view) { + setView(event.state.view); + sessionStorage.setItem('currentView', JSON.stringify(event.state.view)); + } else { + setView({ name: 'sessions' }); + sessionStorage.setItem('currentView', JSON.stringify({ name: 'sessions' })); + } + }; + window.addEventListener('popstate', handlePopState); + return () => window.removeEventListener('popstate', handlePopState); +}, []); +``` + +- [ ] **Step 3: Use replaceState for initial load (avoid double entry)** + +In the existing `loadView()` function, after loading the view, replace the current history entry: +```tsx +// At the end of App() initialization, after first render: +useEffect(() => { + window.history.replaceState({ view }, '', window.location.pathname + window.location.search); +}, []); // only on mount +``` + +- [ ] **Step 4: Verify — navigate sessions → chat → back gesture returns to sessions** + +- [ ] **Step 5: Commit** +```bash +git add src/App.tsx +git commit -m "feat(pwa): History API navigation for back gesture support" +``` + +--- + +## Task 10: OpenGraph Meta Tags + +**Files:** +- Modify: `index.html` + +- [ ] **Step 1: Add OG and Twitter meta tags to index.html** + +Add before ``: +```html + + + + + + + +``` + +- [ ] **Step 2: Commit** +```bash +git add index.html +git commit -m "feat(pwa): OpenGraph and Twitter Card meta tags" +``` + +--- + +## Verification + +1. Run `npm run dev` in the worktree +2. Open in Chrome DevTools → Application panel: + - Check manifest loads correctly with shortcuts, screenshots, categories + - Check service worker is registered +3. Mobile testing (iOS): + - Add to Home Screen → check splash screen appears + - Verify content extends properly behind notch (viewport-fit=cover) + - Test back gesture navigates correctly +4. Mobile testing (Android / Chrome): + - Check install banner appears in SessionsView + - Dismiss and verify it doesn't reappear + - Install and verify banner disappears +5. Draft persistence: + - Type text in input, close tab, reopen → text should be restored + - Send message → draft should be cleared +6. Badge: + - Receive push notification with badge → switch to app → badge clears +7. Network: + - Throttle to 2G in DevTools → "Slow" indicator appears in StatusBar diff --git a/docs/superpowers/plans/2026-03-26-send-to-menu-settings.md b/docs/superpowers/plans/2026-03-26-send-to-menu-settings.md new file mode 100644 index 0000000..3833a17 --- /dev/null +++ b/docs/superpowers/plans/2026-03-26-send-to-menu-settings.md @@ -0,0 +1,566 @@ +# Send-to Menu Redesign + Settings Page Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Redesign the Send-to menu as a two-step bottom sheet with model selection, add a Settings page with saved instructions management and per-adapter preferences. + +**Architecture:** Three layers of changes: (1) Server — new `saved_instructions` DB table + API endpoints, (2) Client API — new instruction CRUD methods, (3) UI — rewritten ReviewActionMenu, new SettingsView with sub-pages, updated App routing and SessionsView header. + +**Tech Stack:** React + TypeScript + Vite (client), Express + SQLite + better-sqlite3 (server), Tailwind CSS (styling) + +**Spec:** `docs/superpowers/specs/2026-03-26-send-to-menu-settings-design.md` + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|---------------| +| `server/db.ts` | Modify | Add `saved_instructions` table + prepared statements + `savedInstructions` operations | +| `server/index.ts` | Modify | Add 3 instruction API endpoints | +| `src/lib/api.ts` | Modify | Add instruction CRUD client methods | +| `src/components/ReviewActionMenu.tsx` | Rewrite | Two-step bottom sheet with adapter/model/instructions | +| `src/components/ChatView.tsx` | Modify | Simplify `handleReviewSelect` — no more context assembly | +| `src/App.tsx` | Modify | Add `settings` view to routing | +| `src/components/SessionsView.tsx` | Modify | Add settings icon in header | +| `src/components/SettingsView.tsx` | Create | Main settings page with sections | +| `src/components/SavedInstructionsView.tsx` | Create | Instruction list with add/delete | +| `src/components/AdapterSettingsSection.tsx` | Create | Per-adapter model/permission/effort dropdowns | + +--- + +## Task 1: Saved Instructions — Server DB + API + +**Files:** +- Modify: `server/db.ts` +- Modify: `server/index.ts` + +- [ ] **Step 1: Add `saved_instructions` table to DB** + +In `server/db.ts`, add to the `db.exec()` block (after `session_reviews` table): + +```sql +CREATE TABLE IF NOT EXISTS saved_instructions ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + instruction TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) +); +``` + +- [ ] **Step 2: Add prepared statements** + +In the `PreparedStatements` interface, add: +```typescript +instructionCreate: BetterSqlite3.Statement; +instructionGetAll: BetterSqlite3.Statement; +instructionDelete: BetterSqlite3.Statement; +``` + +In the `stmts()` function, add: +```typescript +instructionCreate: d.prepare( + `INSERT INTO saved_instructions (id, label, instruction) VALUES (?, ?, ?)` +), +instructionGetAll: d.prepare( + `SELECT * FROM saved_instructions ORDER BY created_at ASC` +), +instructionDelete: d.prepare( + `DELETE FROM saved_instructions WHERE id = ?` +), +``` + +- [ ] **Step 3: Add `savedInstructions` export object** + +After the `sessionReviews` export, add: +```typescript +export const savedInstructions = { + create(id: string, label: string, instruction: string): void { + stmts().instructionCreate.run(id, label, instruction); + }, + getAll(): { id: string; label: string; instruction: string; created_at: string }[] { + return stmts().instructionGetAll.all() as any[]; + }, + delete(id: string): void { + stmts().instructionDelete.run(id); + }, +}; +``` + +- [ ] **Step 4: Add API endpoints in `server/index.ts`** + +Import `savedInstructions` from `./db.js`. Add 3 routes after the review routes: + +```typescript +// --- Saved Instructions API --- + +app.get('/api/instructions', authMiddleware, (_req: Request, res: Response) => { + try { + res.json(savedInstructions.getAll()); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +app.post('/api/instructions', authMiddleware, (req: Request, res: Response) => { + try { + const { label, instruction } = req.body; + if (!label || !instruction) return res.status(400).json({ error: 'label and instruction required' }); + const id = randomUUID(); + savedInstructions.create(id, label, instruction); + res.json({ id, label, instruction }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +app.delete('/api/instructions/:id', authMiddleware, (req: Request, res: Response) => { + try { + savedInstructions.delete(req.params.id); + res.json({ ok: true }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); +``` + +Note: `randomUUID` is already imported in `server/index.ts`. + +- [ ] **Step 5: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 6: Commit** + +```bash +git add server/db.ts server/index.ts +git commit -m "feat: add saved_instructions DB table and API endpoints" +``` + +--- + +## Task 2: Client API — Instruction Methods + +**Files:** +- Modify: `src/lib/api.ts` + +- [ ] **Step 1: Add instruction API methods** + +Add to the `api` object, following the existing pattern (see `registerReview`, `endReview` for reference): + +```typescript +async getInstructions(): Promise<{ id: string; label: string; instruction: string; created_at: string }[]> { + return request('/api/instructions'); +}, + +async createInstruction(label: string, instruction: string): Promise<{ id: string; label: string; instruction: string }> { + return request('/api/instructions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ label, instruction }), + }); +}, + +async deleteInstruction(id: string): Promise { + return request(`/api/instructions/${id}`, { method: 'DELETE' }); +}, +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/api.ts +git commit -m "feat: add instruction CRUD methods to client API" +``` + +--- + +## Task 3: ReviewActionMenu — Two-Step Bottom Sheet + +**Files:** +- Rewrite: `src/components/ReviewActionMenu.tsx` + +This is the core UI change. The component goes from a simple template picker to a two-step bottom sheet with adapter selection, model picker, and expandable instructions panel. + +- [ ] **Step 1: Rewrite ReviewActionMenu.tsx** + +New props interface: +```typescript +interface ReviewActionMenuProps { + visible: boolean; + adapters: { id: string; displayName: string }[]; + onDirectSend: (adapter: string, model: string) => void; + onSendWithInstruction: (adapter: string, model: string, instruction: string, isCustom: boolean) => void; + onClose: () => void; +} +``` + +Component state: +- `step`: `'adapter' | 'action'` — which step is shown +- `selectedAdapter`: `string | null` — chosen adapter ID +- `adapterConfig`: loaded from `api.adapterConfig(selectedAdapter)` when adapter is chosen +- `selectedModel`: `string` — from adapterConfig.models, default to first item +- `instructionsExpanded`: `boolean` — toggle for With Instructions section +- `savedInstructions`: loaded from `api.getInstructions()` on mount +- `customText`: `string` — free text input value + +**Step 1 UI** (adapter selection): +- Backdrop overlay (click to close) +- Bottom sheet with drag handle +- "Send to…" title +- Adapter rows: `` + adapter name, no model text, tap → set selectedAdapter + go to step 2 + +**Step 2 UI** (action selection): +- `‹ {AdapterName}` header with colored adapter name (back arrow returns to step 1) +- Model: `` dropdowns, each with a label: + - "Model" → options from `config.models` (each has `value` + `label`) + - "Permission Mode" → options from `config.permissionModes` + - Effort label from `config.effortLabel` (e.g. "Thinking" for Claude, "Effort" for Codex) → options from `config.effortLevels` +- On change: `patchAdapterPrefs(adapter, { [field]: value })` to persist to localStorage + +Import `loadAdapterPrefs`, `patchAdapterPrefs` from `@/lib/adapter-prefs`. +Import `getBrand` from `@/lib/adapter-brands`. +Import `AdapterIcon` from `./AdapterIcon`. + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` + +- [ ] **Step 3: Commit** + +```bash +git add src/components/AdapterSettingsSection.tsx +git commit -m "feat: create AdapterSettingsSection with per-adapter dropdowns" +``` + +--- + +## Task 10: E2E Verification + +- [ ] **Step 1: Full TypeScript check** + +Run: `npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 2: Start server (without watch mode) and Vite** + +Start server and Vite in separate processes. Ensure tmux session exists first. + +- [ ] **Step 3: Visual verification in browser** + +Verify the following scenarios: +1. Settings icon visible in project list header +2. Settings page loads with all sections +3. Saved Instructions: add an instruction, verify it appears in list, delete it +4. Adapter settings: all dropdowns populate with correct adapter-specific options +5. In a chat, click send icon → two-step menu opens +6. Step 1 shows adapter icons + names (no model text) +7. Step 2 shows model dropdown + Direct Send + With Instructions +8. With Instructions has expand/collapse chevron +9. Direct Send sends only raw response text +10. With Instructions sends instruction + raw text +11. Save toast appears and works after custom instruction send + +- [ ] **Step 4: Final commit if any fixes needed** diff --git a/docs/superpowers/specs/2026-03-23-cross-ai-review-design.md b/docs/superpowers/specs/2026-03-23-cross-ai-review-design.md new file mode 100644 index 0000000..e2b0444 --- /dev/null +++ b/docs/superpowers/specs/2026-03-23-cross-ai-review-design.md @@ -0,0 +1,334 @@ +# Cross-AI Review + +## Overview + +A mechanism to send messages from one CLI session (e.g., Claude) to another adapter's CLI session (e.g., Codex) for review, all within the same ChatView. The child session runs in its own tmux window with full codebase access, and its UI is presented as a floating panel over the parent chat. + +## Concepts + +### Parent Session +The main CLI session the user is working in. Appears in session list and active sessions as normal. + +### Child Session (Review Session) +A secondary CLI session triggered from a specific message in the parent. It: +- Runs a different adapter (e.g., parent is Claude, child is Codex) +- Opens in the same `cwd` as the parent (read from parent's DB row, not from client) +- Is a real tmux window, supports full CLI interaction with codebase access +- Does NOT appear in the session list or active sessions +- Is tracked via a separate `session_reviews` DB table +- Its UI appears as a floating panel inside the parent's ChatView +- Uses its own `useChat` hook instance for independent WebSocket connection and message handling + +### Relationship +- One parent can have one active (non-ended) child at a time +- If the user tries to start a second review while one is active, show a confirmation: "End current review to start a new one?" +- Each child has an `anchor_message_id` — the ID of the specific assistant message that triggered the review +- Ended reviews remain as collapsed cards in the history; a parent can have many ended reviews + +## Naming Conventions + +All adapter references in the UI are **dynamic**, never hardcoded: +- "Send to [Codex]" / "Send to [Claude]" — resolved from available adapters via `/api/adapters` +- In child session: "Send to [Parent Adapter Name]" — resolved from parent's adapter +- The "Send to" button is only shown when at least one other adapter is available (`/api/adapters` filtered to `available: true`) +- Review session title is dynamic based on the prompt template selected: "Code Review", "Suggest Alternatives", "Direct Send", or the user's custom instruction (truncated) + +## User Flow + +### 1. Triggering a Review + +Every assistant message in the parent chat has action buttons: +- **Copy** — copy message content +- **Send to [Adapter]** — triggers the cross-AI review flow (adapter name is dynamic) + +When the user taps "Send to [Adapter]", a popup menu appears with prompt template options: +- **Direct send** — send the message as-is +- **Code Review** — attach a "please review this code" instruction +- **Suggest alternatives** — ask for different approaches +- **Custom instruction...** — user types their own prompt + +After selection: +1. Server creates a new CLI session for the target adapter in tmux (same `cwd` as parent) +2. Context (parent conversation history + selected message + prompt template) is pasted into the child CLI via `tmux load-buffer` + `paste-buffer` (see "Context Passing" section) +3. A floating panel appears at the bottom of the screen +4. Server broadcasts `REVIEW_STARTED` to all parent session clients +5. A `session_reviews` row is created in the DB + +### 2. Interacting with the Child Session + +The floating panel has three states: + +**Expanded** — takes up ~55% of the screen from the bottom. Shows: +- Header with dynamic title: "[Adapter] [Template Name]" (e.g., "Codex Code Review") +- "End" button to close the review +- Chat messages from the child session (with streaming — uses its own `useChat` hook) +- Each child assistant message has: + - **Copy** button + - **Send to [Parent Adapter]** button — dynamically named based on parent's adapter +- Input field for the user to ask follow-up questions to the child AI + +**Minimized (Pill)** — a small floating pill button in the bottom-right corner. Shows adapter name + template name with a pulsing dot. Tap to expand. + +**Hidden** — the floating panel is dismissed but the tmux session continues running in the background. + +Users can switch between states by tapping the handle bar (to minimize) or the pill (to expand). + +### 3. Sending Results Back to Parent + +Each assistant message in the child session has a **"Send to [Parent Adapter]"** button. When tapped: +- **Guard**: if the parent session is currently processing (`isProcessing`), show a toast: "Wait for the current turn to complete" +- Otherwise: the message content is prefixed with "[Review feedback from [Child Adapter]:]" and injected into the parent's tmux session via `sendMessage()` +- The parent AI sees it as a normal user message and can respond +- The user continues working with the parent AI, informed by the review + +This is a manual, explicit action. There is no automatic injection. + +For long messages, use `tmux load-buffer` + `paste-buffer` (see "Context Passing & Message Delivery" section). + +### 4. Ending a Review + +The user taps the **"End"** button on the floating panel: +- `ended_at` is set in the `session_reviews` table +- The child's tmux window is killed +- The floating panel disappears +- Server broadcasts `REVIEW_ENDED` to all parent session clients +- The child session's JSONL file is preserved for history + +### 5. Viewing History + +When the user re-opens the parent session and scrolls through message history: +- At the `anchor_message_id` position, a **block-start marker** is rendered: "[Adapter] [Template] started" +- Immediately after the block-start marker, a **collapsed review card** appears showing: + - Adapter name, template name, and message count + - A brief summary (first line of the child AI's first response) + - "Tap to expand" hint — opens the full child conversation in a read-only panel +- The block-end marker renders immediately after the collapsed review card: "Review ended" +- Parent messages that occurred during the review period continue normally in the chat flow (they are NOT inside the collapsed card — they are separate messages below it) + +The block markers are NOT stored in JSONL. They are rendered dynamically by ChatView based on `session_reviews` DB metadata, keyed by `anchor_message_id`. + +## Context Passing & Message Delivery + +### tmux buffer (unified approach) + +All text delivery to CLI sessions uses `tmux load-buffer` + `paste-buffer` instead of `send-keys`. This applies to: +- Initial context sent to child session +- Messages sent back from child to parent ("Send to [Parent Adapter]") +- Any other long-form text injection + +**Why not `send-keys`:** +- Special characters (quotes, backslashes, newlines) get interpreted by the shell +- `send-keys -l` processes text character-by-character, slow for large content +- Practical input length limits + +**Why not file-based (write file + "read /tmp/xxx.md"):** +- Requires an extra tool call round trip (AI has to read the file) +- Requires file cleanup +- Less direct + +**Buffer mechanism:** + +```bash +# 1. Write content to a temp file +echo "$content" > /tmp/codetap-buf-{id}.txt + +# 2. Load into tmux buffer +tmux load-buffer /tmp/codetap-buf-{id}.txt + +# 3. Paste into target pane +tmux paste-buffer -t codetap:{windowId} + +# 4. Press Enter to submit +tmux send-keys -t codetap:{windowId} Enter + +# 5. Clean up temp file +rm /tmp/codetap-buf-{id}.txt +``` + +The CLI receives the pasted text as a single multi-line prompt. Both Claude Code and Codex handle pasted multi-line input natively. + +### Initial context format + +When creating a child session, the context pasted as the first prompt: + +``` +The following is a conversation between a user and [Parent Adapter]. +Please review the highlighted response below. + +[Conversation History] +User: [message 1] +[Parent Adapter]: [message 2] +... + +>>> REVIEW THIS RESPONSE <<< +[Parent Adapter]: [the selected message content] + +[Instruction] +[Prompt template text, e.g., "Please perform a code review..."] +``` + +Maximum context: last 50 messages or 30KB of text (whichever is smaller). If truncated, prepend "[Earlier conversation omitted]". + +### Send-back format + +When "Send to [Parent Adapter]" is tapped on a child message, the content pasted to the parent: + +``` +[Review feedback from [Child Adapter]]: +[message content] +``` + +## Data Model + +### New table: `session_reviews` + +```sql +CREATE TABLE IF NOT EXISTS session_reviews ( + id TEXT PRIMARY KEY, + parent_cli_session_id TEXT NOT NULL, + child_cli_session_id TEXT NOT NULL, + child_adapter TEXT NOT NULL, + anchor_message_id TEXT, + review_prompt TEXT, + review_title TEXT, + started_at TEXT DEFAULT (datetime('now')), + ended_at TEXT DEFAULT NULL +); + +CREATE INDEX IF NOT EXISTS idx_reviews_parent ON session_reviews(parent_cli_session_id); +``` + +**All session IDs stored are native CLI UUIDs** (e.g., `019d1956-2941-7360-b313-0610d98ee150` for Codex, `4ac007a8-7b04-4646-8f0c-a74845aa01bf` for Claude), NOT internal IDs (e.g., `codex-1774267440443` or `claude-1774269874387`). Internal IDs change on server restart when sessions are recreated; CLI UUIDs are permanent. + +This table is **NOT cleared** by `clearAll()` — it survives server restarts. The `sessions` table continues to be cleared as before. + +### Filtering child sessions + +Child sessions still appear in the `sessions` table (for tmux window tracking). Filtering is done at the **API layer** in `server/index.ts`, not in adapters or JSONL stores. This is because: +- `getSessions()` in both adapters reads from JSONL files, not the DB -- it has no access to `session_reviews` +- `getActiveSessions()` reads from in-memory Maps -- same issue +- The API endpoints already aggregate across adapters, so filtering here is natural + +**Implementation:** + +For `/api/sessions` and `/api/active-sessions` endpoints in `server/index.ts`: +1. Query `session_reviews` for all `child_cli_session_id` values +2. Build a `Set` of child CLI UUIDs +3. Filter the results: exclude any session whose `cliSessionId` (CLI UUID) is in the set + +This keeps adapter code untouched and centralizes the filtering logic. + +### Message IDs + +The spec uses `anchor_message_id` to identify which message triggered a review. Currently: +- `ChatMessage` type has an optional `id` field, but it is never populated +- JSONL entries from both Claude and Codex do not have dedicated message IDs + +**Solution:** Generate synthetic UUIDs for each message at parse time in `TranscriptParser` (Claude) and `CodexTranscriptParser` (Codex). These IDs are: +- Generated deterministically or at parse time +- Threaded through to `ChatMessage.id` in the React state +- Passed to `MessageBubble` as a prop for action button callbacks +- Stored as `anchor_message_id` in `session_reviews` when a review is triggered + +### DB operations + +Add a `sessionReviews` operation set to `server/db.ts`: + +- `create(id, parentCliId, childCliId, childAdapter, anchorMsgId, prompt, title)` -- insert new review +- `getActiveForParent(parentCliSessionId)` -- active reviews for reconnect +- `getAllChildIds()` -- all child CLI UUIDs for session list filtering +- `endReview(reviewId)` -- sets ended_at +- `getForParent(parentCliSessionId)` -- all reviews including ended (for history rendering) + +### TmuxManager changes + +Add a `pasteBuffer(windowId, content)` method to `server/adapters/claude/tmux-manager.ts`: +1. Write content to a temp file +2. `tmux load-buffer ` +3. `tmux paste-buffer -t codetap:` +4. `tmux send-keys -t codetap: Enter` +5. Clean up temp file + +Uses `execFile` (not `exec`) for safety, consistent with existing TmuxManager methods. + +This replaces `sendKeys()` for all cross-AI review text delivery (initial context + send-back). Regular `sendMessage()` in adapters continues to use `sendKeys()` for short user prompts. + +## WebSocket Events + +Two new event types for review lifecycle: + +```typescript +// server → client: a review session was created +REVIEW_STARTED = 'review-started' +{ + type: 'review-started', + reviewId: string, + childSessionId: string, // internal session ID for useChat connection + childCliSessionId: string, // CLI UUID + childAdapter: string, + anchorMessageId: string, + reviewTitle: string, +} + +// server → client: a review session was ended +REVIEW_ENDED = 'review-ended' +{ + type: 'review-ended', + reviewId: string, +} +``` + +These are broadcast to all clients connected to the parent session. On receiving `REVIEW_STARTED`, the client creates a `FloatingReviewPanel` with its own `useChat` hook instance pointing to the child session. + +## Reconnect / Server Restart + +### Reconnecting to a parent session with active child + +When a user opens a parent session: +1. Query DB: `SELECT * FROM session_reviews WHERE parent_cli_session_id = ? AND ended_at IS NULL` +2. For each active child (at most one, enforced by the one-active-child rule): + - Resolve the child CLI UUID to an internal session ID + - Check if the tmux window still exists + - If yes: re-attach (start monitoring events), show floating panel + - If no (e.g., server restarted): use `resumeSession` with the child CLI UUID to create a new tmux window, show floating panel +3. For ended children: do nothing at connect time. ChatView renders collapsed review cards when scrolling through history by querying `session_reviews` for this parent. + +### clearAll() behavior + +`clearAll()` clears the `sessions` table on shutdown. The `session_reviews` table is NOT cleared. On next startup: +- `session_reviews` still has the parent-child relationships (keyed by CLI UUIDs) +- Child session JSONL files still exist on disk +- History view works correctly + +## UI Components + +### New Components +- **FloatingReviewPanel** — the expandable/minimizable floating panel for child session interaction. Uses its own `useChat` hook instance with the child's session ID. +- **FloatingReviewPill** — the minimized pill button +- **ReviewActionMenu** — the popup menu when "Send to [Adapter]" is tapped (prompt template selection) +- **CollapsedReviewCard** — the folded review card shown in history view +- **BlockMarker** — the "Review started" / "Review ended" divider lines + +### Modified Components +- **MessageBubble** — add "Copy" and "Send to [Adapter]" action buttons to each assistant message. Adapter name is dynamic. +- **ChatView** — integrate FloatingReviewPanel, render block markers and collapsed cards in history, manage child session lifecycle +- **useChat hook** — add review state management (active review ID, floating panel visibility). Does NOT handle child session messages — that is delegated to the child's own `useChat` instance inside `FloatingReviewPanel`. + +### Removed Components +- **QuickActionCards** — replaced by per-message action buttons +- **CrossAdapterFlow** — replaced by the new review mechanism +- **quick-commands.ts** — prompt templates moved into ReviewActionMenu +- `crossAdapterFlow` state, `startCrossAdapterFlow`, `completeCrossAdapterFlow` in useChat — all removed + +## Scope Boundaries (NOT included in v1) + +- **Auto N-round debate** — two AIs automatically going back and forth. Deferred due to JSONL duplication complexity. +- **Multi-child sessions** — only one active child at a time. Multiple ended reviews are fine. +- **More than 2 adapters** — the architecture supports it (dynamic naming, adapter list from API), but UI is only tested with Claude + Codex. + +## Interactive Mockup + +A visual mockup is available at `/tmp/cross-ai-review-mockup.html` showing three views: +1. **Live view** — review in progress with floating panel (expanded + minimized pill states) +2. **History view** — review ended with collapsed card + block markers + interleaved parent messages +3. **Menu view** — the prompt template selection popup diff --git a/docs/superpowers/specs/2026-03-23-insight-block-design.md b/docs/superpowers/specs/2026-03-23-insight-block-design.md new file mode 100644 index 0000000..e76f499 --- /dev/null +++ b/docs/superpowers/specs/2026-03-23-insight-block-design.md @@ -0,0 +1,136 @@ +# InsightBlock — Adapter-Specific Content Rendering + +## Problem + +Claude Code produces "Insight" blocks in its markdown responses: + +``` +`★ Insight ─────────────────────────────────────` +[educational content] +`─────────────────────────────────────────────────` +``` + +These currently render as ugly inline `` elements in ReactMarkdown. Need a dedicated, collapsible UI component — while keeping the architecture extensible for other adapters (Gemini, Codex) that may have their own text patterns. + +## Design + +### Approach: Frontend Text Transform with Adapter-Scoped Patterns + +- **No server changes.** The Insight text flows through the existing pipeline as `{ type: 'text' }` content blocks. +- **Regex patterns** defined per-adapter in adapter-scoped files. +- **Generic splitter** in `src/lib/` accepts patterns as parameters — no coupling to any specific adapter. +- **Collapsible card UI** matching ToolCallCard's expand/collapse pattern. + +### File Structure + +``` +src/ +├── lib/ +│ └── text-transforms.ts # Generic: splitTextSegments(text, patterns) +├── components/ +│ ├── adapters/ +│ │ └── claude/ +│ │ ├── InsightBlock.tsx # Collapsible insight card +│ │ └── patterns.ts # INSIGHT_RE regex + segment type +│ ├── MessageBubble.tsx # Modified: split → map → render +│ └── ... +``` + +**Dependency direction:** +``` +MessageBubble → text-transforms (generic) +MessageBubble → adapters/claude/patterns (adapter-specific) +MessageBubble → adapters/claude/InsightBlock (adapter-specific) +``` + +Generic code never imports adapter-specific code. MessageBubble is the composition root. + +### `src/components/adapters/claude/patterns.ts` + +Exports Claude-specific text patterns: + +```typescript +import type { TextPattern } from '@/lib/text-transforms'; + +export const CLAUDE_PATTERNS: TextPattern[] = [ + { + type: 'insight', + regex: /`[★✦]?\s*Insight[─\-\s]*`\n([\s\S]*?)\n`[─\-]+[.。]?`/g, + }, +]; +``` + +### `src/lib/text-transforms.ts` + +Generic segment splitter — adapter-agnostic: + +```typescript +export interface TextPattern { + type: string; + regex: RegExp; +} + +export type TextSegment = { + type: string; // 'markdown' | 'insight' | future types + text: string; +}; + +export function splitTextSegments(text: string, patterns: TextPattern[]): TextSegment[] { + // Fast path: no patterns or no text → return as-is + // For each pattern, find all matches and record their positions + // Split text into alternating markdown/matched segments + // Streaming safety: unmatched opening fence → treat as plain markdown +} +``` + +### `src/components/adapters/claude/InsightBlock.tsx` + +Collapsible card — Style C from brainstorming: + +- **Collapsed (default):** `★` icon + "Insight" label + first-line summary (truncated) + `▼` chevron +- **Expanded:** Full content rendered via ReactMarkdown with prose styling +- **Visual:** `bg-surface/30 border border-border/50 rounded-lg` — subtle card with accent star + +### `src/components/MessageBubble.tsx` Changes + +```diff ++ import { splitTextSegments } from '@/lib/text-transforms'; ++ import { CLAUDE_PATTERNS } from './adapters/claude/patterns'; ++ import { InsightBlock } from './adapters/claude/InsightBlock'; + + // In assistant message render: + const textContent = content.filter(...).map(...).join(''); ++ const segments = splitTextSegments(textContent, CLAUDE_PATTERNS); + +- {textContent} ++ {segments.map((seg, i) => ++ seg.type === 'insight' ++ ? ++ : {seg.text} ++ )} +``` + +## Extensibility + +When Gemini adds "Analysis" blocks: + +1. `src/components/adapters/gemini/patterns.ts` — export `GEMINI_PATTERNS` +2. `src/components/adapters/gemini/AnalysisBlock.tsx` — new component +3. `MessageBubble.tsx` — merge patterns: `[...CLAUDE_PATTERNS, ...GEMINI_PATTERNS]` +4. Add one more segment type case in the render map + +No server changes. No refactoring. Explicit additions only. + +## Edge Cases + +- **Streaming:** Partial insight (opening fence without closing) → `splitTextSegments` treats as plain markdown. InsightBlock only renders when both fences are present. +- **No insights:** Fast path — `splitTextSegments` returns `[{ type: 'markdown', text }]`, single ReactMarkdown render. No performance regression. +- **Multiple insights:** Each becomes its own InsightBlock in the segments array, with markdown segments between them. +- **Nested markdown in insight body:** ReactMarkdown inside InsightBlock handles bullet points, code, links. + +## Not Changing + +- Server pipeline (TranscriptParser, session-manager, IAdapter) +- ChatView.tsx (Insight is text-layer, not content-block-layer) +- useChat.ts +- WS protocol diff --git a/docs/superpowers/specs/2026-03-23-session-id-unification-design.md b/docs/superpowers/specs/2026-03-23-session-id-unification-design.md new file mode 100644 index 0000000..4df6c9d --- /dev/null +++ b/docs/superpowers/specs/2026-03-23-session-id-unification-design.md @@ -0,0 +1,256 @@ +# Session ID Unification Design Spec + +## Problem + +CodeTap's session management has grown organically and now has several issues: + +1. **Dual ID system** — Each session has an "internal ID" (`session-{timestamp}` or `desktop-{uuid前8字}`) and a "CLI UUID". The internal ID is meaningless to users but is what the UI displays. +2. **Dual storage** — `session-map.json` (file) and SQLite (DB) both store session mappings. The file is written by the SessionStart hook but only read on server startup, causing `codetap new` sessions to not appear in Active Sessions until server restart. +3. **`desktop-` prefix confusion** — Sessions that are "rediscovered" after a non-graceful server restart get a new `desktop-` internal ID, losing their original ID. +4. **No adapter awareness in IDs** — Internal IDs don't indicate which adapter (Claude/Codex/Gemini) the session belongs to. +5. **User can't resume from desktop** — The chat header shows the internal ID which can't be used with `claude --resume`. + +## Design Decisions + +| Topic | Decision | +|-------|----------| +| Scope | All adapters (Claude, Codex, future) | +| Storage | SQLite only — remove session-map.json | +| SessionStart hook | POST to server API (like all other hooks) | +| Internal ID format | `{adapter}-{timestamp}` (e.g., `claude-1774210269126`) | +| `desktop-` prefix | Removed entirely | +| Non-graceful restart recovery | Read original internal ID from DB | +| User-facing display | Chat header: CLI UUID (primary) + internal ID (secondary) | +| Active Sessions list | Keep showing `firstPrompt` (no change) | +| DB on shutdown | Clear `sessions` table (tmux windows are killed, records are useless) | +| CLI `--adapter` flag | Added to `codetap new` and `codetap --continue` | +| CLI `--resume` | Accepts internal ID or CLI UUID; scans JSONL dirs to detect adapter | +| CLI `--continue` | Pass through to adapter CLI's native continue command | +| Adapter selector UI | Not in scope (future work) | + +## Architecture + +### Internal ID Format + +``` +{adapter}-{timestamp} + +Examples: + claude-1774210269126 + codex-1774210345678 + gemini-1774210500000 +``` + +Produced by: +- `startSession()` in each adapter (Web UI new session) +- `bin/codetap` CLI script (`codetap new`, `codetap --resume`, `codetap --continue`) + +### Single Source of Truth: SQLite + +All session mappings go through SQLite. No file-based storage. + +**SessionStart hook flow (unified for all entry points):** + +``` +Claude/Codex CLI starts → SessionStart hook fires + ↓ +POST /api/hooks/{adapter}/session-start +body: { session_id: "", cwd: "/path", ... } + ↓ +Server handler: + 1. Find tmux window for this session (by window name or DB lookup) + 2. If session already in memory → update mapping (e.g., /resume changed UUID) + 3. If session NOT in memory → create new entry: + - Internal ID from tmux window name (e.g., claude-1774210269126) + - Map CLI UUID → internal ID + - Write to DB + 4. Session appears in Active Sessions immediately +``` + +**Shutdown flow:** + +``` +SIGTERM/SIGINT received + ↓ +1. adapter.destroy() → tmuxManager.killSession() → all tmux windows killed +2. dbSessions.clearAll() → clear sessions table +3. closeDB() +``` + +### DB Schema + +```sql +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, -- internal ID (claude-1774210269126) + cli_session TEXT, -- CLI native UUID + adapter TEXT, -- 'claude' / 'codex' / 'gemini' + cwd TEXT, + window_id TEXT, + permission_mode TEXT, + created_at DATETIME DEFAULT (datetime('now')), + last_activity DATETIME DEFAULT (datetime('now')) +); + +CREATE INDEX idx_sessions_cli ON sessions(cli_session); +CREATE INDEX idx_sessions_adapter ON sessions(adapter); +``` + +Migration: rename `claude_session` → `cli_session`, add `adapter` column (default `'claude'` for existing rows), remove `is_active` column. + +### Chat Header Display + +``` +┌──────────────────────────────────────────────────┐ +│ ← code-tap 625c60d0-aedb-4e0b... [copy icon] │ +│ claude-1774210269126 │ +└──────────────────────────────────────────────────┘ +``` + +- Primary: CLI UUID (truncated), click copy icon to copy full UUID → usable with `claude --resume ` +- Secondary: internal ID → usable with `tmux select-window -t codetap:claude-1774210269126` + +`SESSION_CREATED` message updated to include both `sessionId` (internal) and `cliSessionId` (UUID). + +### Hook Config Change + +In `hook-config.ts`, SessionStart changes from file-writing script to `fireAndForget` API POST (same pattern as all other hooks): + +```typescript +// Before: +SessionStart: [{ hooks: [{ type: 'command', command: hookPath, timeout: 2 }] }] + +// After: +SessionStart: [{ hooks: [{ type: 'command', command: fireAndForget('session-start'), timeout: 2 }] }] +``` + +### SESSION_CREATED Message Payload + +```typescript +// Before: +{ type: 'session-created', sessionId: string } + +// After: +{ type: 'session-created', sessionId: string, cliSessionId: string } +``` + +`useChat` hook stores both IDs. `ChatView` header displays `cliSessionId` (primary) and `sessionId` (secondary). + +### Recovery from Non-Graceful Shutdown + +If the server crashes (kill -9, power loss) without running the shutdown flow: + +1. Tmux session `codetap` may still be alive with running CLI instances +2. On next server start, DB still has session records (SQLite persists) +3. When hooks fire from surviving CLI instances → `resolveSessionId`: + - Finds the session in DB by `cli_session` UUID + - Restores the **original** internal ID from DB (e.g., `claude-1774210269126`) + - Re-creates in-memory mapping + - Session reappears in Active Sessions with its original ID + +If the CLI session hasn't fired a SessionStart hook yet (e.g., Codex before first interaction): +- Session stays in DB with `cli_session = NULL` +- Once hook fires → DB record updated with CLI UUID +- UI shows UUID after hook fires (brief grace period showing internal ID only) + +### CLI Changes (`bin/codetap`) + +**`codetap new [--adapter ]`** + +```bash +codetap new # WINDOW_NAME="claude-$(date +%s)", runs: claude +codetap new --adapter codex # WINDOW_NAME="codex-$(date +%s)", runs: codex +codetap new --adapter gemini # WINDOW_NAME="gemini-$(date +%s)", runs: gemini +``` + +Default adapter: `claude`. The `--adapter` flag determines both the window name prefix and the CLI command to run. + +**`codetap --resume `** + +``` +Input: internal ID or CLI UUID + ↓ +Is it internal ID format? ({adapter}-{digits}) +├─ Yes → extract adapter from prefix, query DB for CLI UUID +│ ├─ Found → run: {adapter} --resume {uuid} +│ └─ Not found → error +└─ No (UUID format) → query DB by cli_session + ├─ Found → get adapter from DB → run: {adapter} --resume {uuid} + └─ Not found → scan JSONL directories per adapter + ├─ Found → detected adapter → run: {adapter} --resume {uuid} + └─ Not found → error: "Session not found" +``` + +**`codetap --continue [--adapter ]`** + +Pass through to adapter CLI's native continue command: +- `claude --continue` +- `codex resume --last` + +Window name: `{adapter}-{timestamp}` (same format as `new`). + +SessionStart hook handles the mapping automatically when the CLI starts — even if the continued session was never managed by CodeTap before. + +Default adapter: `claude`. With `--adapter codex`, runs codex's native continue command instead. + +**`codetap -a / -A`** + +Enhanced display: + +``` +Active sessions for code-tap: + + 1) claude-1774210269126 + UUID: 625c60d0-aedb-4e0b-b78e-c9fbf0405e67 + reply pong... + + 2) codex-1774210345678 + UUID: abc12345-xxxx-xxxx-xxxx-xxxxxxxxxxxx + fix the login bug... + +Select (1-2): +``` + +### Files to Modify + +**Server:** +- `server/db.ts` — Schema migration, rename column, add `adapter` field, add `clearAll()`, remove session-map.json migration +- `server/adapters/claude/tmux-adapter.ts` — Remove `desktop-` logic from `resolveSessionId`, change `session-` to `claude-` in `startSession`, add `session-start` handler, restore original ID on recovery +- `server/adapters/claude/hook-config.ts` — Change SessionStart from `hookPath` script to `fireAndForget('session-start')` +- `server/adapters/claude/index.ts` — Add `session-start` hook route +- `server/adapters/codex/codex-tmux-adapter.ts` — Same pattern: `codex-` prefix, unified session-start handling +- `server/adapters/codex/index.ts` — Add `session-start` hook route if missing +- `server/adapters/interface.ts` — Add `adapter` field to `ActiveSessionInfo` +- `server/session-manager.ts` — Pass `cliSessionId` in `SESSION_CREATED` message +- `server/index.ts` — Call `dbSessions.clearAll()` in shutdown +- `server/config.ts` — Remove `sessionMap` path config + +**Client:** +- `src/hooks/useChat.ts` — Store `cliSessionId` from `SESSION_CREATED` +- `src/components/ChatView.tsx` — Header shows CLI UUID (primary) + internal ID (secondary) +- `src/components/SessionsView.tsx` — No change (already shows `firstPrompt`) + +**CLI:** +- `bin/codetap` — Add `--adapter` flag, change window naming, update resume/continue logic, enhance `-a`/`-A` display +- `bin/codetap-hook` — Delete (replaced by API POST) + +### E2E Spec Updates (`tests/e2e-spec.feature`) + +The following scenarios need to be updated to reflect the new session ID architecture: + +1. **Chat header display** (L247): Update to show CLI UUID (primary) + internal ID (secondary) with copy icon +2. **CLI `--adapter` flag** (L1168-1475): Add scenarios for `codetap new --adapter`, `codetap --continue --adapter` +3. **Active sessions `-a`/`-A` display** (L1212): Update to show UUID + internal ID format +4. **session-map.json references** (L1308): Remove; update to DB-based recovery +5. **Session Deduplication regression** (L1829): Update to reflect Connect button fix (claudeSessionId → sessionId) +6. **SessionStart hook**: Add scenario documenting API POST flow (replaces file-writing script) +7. **tmux window naming** (L1176): Specify `{adapter}-{timestamp}` format +8. **Non-graceful restart recovery** (L1308): Add scenario for restoring original ID from DB +9. **Active session card UUID field** (L1548): Clarify where UUIDs appear (title vs expanded view) + +### What Gets Removed + +- `bin/codetap-hook` script +- `session-map.json` mechanism (writing, reading, migration) +- `desktop-` prefix logic in `resolveSessionId` +- `is_active` column from sessions table +- `sessionMap` path in config diff --git a/docs/superpowers/specs/2026-03-24-codex-uuid-discovery-fix.md b/docs/superpowers/specs/2026-03-24-codex-uuid-discovery-fix.md new file mode 100644 index 0000000..eb4dc7d --- /dev/null +++ b/docs/superpowers/specs/2026-03-24-codex-uuid-discovery-fix.md @@ -0,0 +1,120 @@ +# Codex UUID Discovery Fix + Session Architecture Cleanup + +## Problems Found + +### 1. Deadlock: `_waitForCliUUID` blocks `startSession` (Critical) + +`startSession()` calls `_waitForCliUUID()` which polls for `session.cliSessionId` to be set. But the UUID is only set when `handleSessionStart` hook fires, which requires Codex to process a prompt. The prompt is sent AFTER `startSession` returns. Deadlock: 15-second timeout, session creation fails. + +Affects both `handleQuery` (new Codex chat from Web UI) and `POST /api/reviews` (Cross-AI Review child session). + +### 2. Pending Session Matching by Count (Medium) + +`handleSessionStart` matches hook to pending session by checking `pendingSessions.length === 1`. If 0 pending: treated as desktop-started. If 2 pending: neither matches, hook creates a spurious session entry. This is a guess, not a precise match. + +### 3. `_findAndAttachWindow` uses `command.includes('codex')` (Medium) + +Grabs the first tmux window whose command contains `codex`. If multiple codex windows exist, picks the wrong one. After Session ID Unification, window names are UUIDs, so this method is both incorrect and unnecessary (see solution). + +### 4. `_watchForTranscript` matches by recency (Low) + +Scans the day directory for JSONL files modified within 120 seconds, picks the first match. If two Codex sessions start simultaneously, can pick the wrong file. + +### 5. Server shutdown leaves tmux windows running (Resource waste) + +`adapter.destroy()` cleans up monitors and watchers but does NOT kill tmux windows. After server stops, CLI processes continue running in tmux, consuming resources. + +### 6. DB sessions table is unnecessary (Complexity) + +The `sessions` DB table stores `id`, `cwd`, `window_id`, `adapter`. After the Session ID Unification, all runtime data is in the in-memory `sessions` Map. The DB was used for: +- `_findAndAttachWindow` window recovery after restart: unnecessary if windows are killed on shutdown +- `handleReconnect` cwd lookup for resumeSession: unnecessary if handleReconnect doesn't resume +- Review endpoints cwd lookup: can use in-memory Map instead + +## Solution + +### A. Remove `_waitForCliUUID` entirely + +`startSession()` returns the temp key immediately. UUID discovery happens asynchronously via `handleSessionStart` or `_watchForTranscript`. + +### B. CODETAP_REF marker for precise matching + +Every first message sent to a new Codex session includes a marker: + +``` +[CODETAP_REF:codex-1774316492094] +actual prompt or context here... +``` + +Where `codex-1774316492094` is the temp key (tmux window name at creation time). + +**Injection points:** +- `handleQuery` in session-manager.ts: when creating a new session (no existing sessionId), prepend marker to the prompt +- `POST /api/reviews` in index.ts: prepend marker to the context + +**Matching in `handleSessionStart`:** +1. Read the JSONL file at `body.transcript_path` +2. Find the first user message +3. Extract `CODETAP_REF:xxx` marker +4. Match `xxx` to a pending session's temp key +5. Call `_rekeyAndRename` to finalize + +**Matching in `_watchForTranscript`:** +- After finding a candidate JSONL file, verify it contains `CODETAP_REF:tempKey` + +**Frontend filtering:** +- Strip `[CODETAP_REF:...]` from user messages in `convertMessages` (useChat.ts) + +### C. `_rekeyAndRename` — finalize UUID discovery + +New method called when UUID is discovered (by handleSessionStart or _watchForTranscript): +- Delete temp key from sessions Map +- Set CLI UUID as new key +- Rename tmux window from temp name to CLI UUID +- Update monitor's sessionId + +### D. Server shutdown kills all tmux windows + +`adapter.destroy()` calls `tmuxManager.killSession()` to kill the entire codetap tmux session. No resource leaks. + +### E. Remove `_findAndAttachWindow` + +With shutdown killing all windows, no tmux windows survive restart. No need to rediscover windows. Delete the method and all call sites. + +### F. Remove DB sessions table + +The `sessions` table serves no purpose after changes D and G: +- `_findAndAttachWindow` (deleted in E) was the main consumer +- `handleReconnect` no longer calls `resumeSession` (changed in G) +- Review endpoints get `cwd` from in-memory Map (changed in H) + +Delete: CREATE TABLE, prepared statements, SessionRow interface, `sessions` export, all `dbSessions.*` calls across the codebase. + +DB retains only `session_reviews` table (for Cross-AI Review). + +### G. Simplify `handleReconnect` + +Remove the `hasActiveWindow` + `resumeSession` block. After shutdown kills windows, there is no scenario where a session is not in the Map but has an active tmux window. + +`handleReconnect` becomes: register client, load JSONL history, replay pending state. Building tmux windows is `handleQuery`'s job (when the user sends a message). + +### H. Review endpoints get cwd from Map + add parent_adapter to session_reviews + +Replace `dbSessions.get(parentCliSessionId)` with `adapter.getSession(parentCliSessionId)` to get `cwd` from the in-memory Map. The parent session is always active (user is interacting with it) so it is always in the Map. + +Add `parent_adapter TEXT NOT NULL` column to `session_reviews` table. Store it when creating a review. This way `send-back` and `delete` endpoints can find the correct adapter directly from the review row, without needing to iterate all adapters or query the sessions DB. + +## Files Affected + +| File | Changes | +|------|---------| +| `server/adapters/codex/codex-tmux-adapter.ts` | A: remove _waitForCliUUID. B: add _matchByTranscriptMarker. C: add _rekeyAndRename. D: destroy calls killSession. E: remove _findAndAttachWindow. F: remove all dbSessions calls | +| `server/adapters/claude/tmux-adapter.ts` | D: destroy calls killSession. F: remove all dbSessions calls | +| `server/adapters/claude/index.ts` | No change (doesn't use dbSessions directly) | +| `server/adapters/codex/index.ts` | No change | +| `server/session-manager.ts` | B: inject marker in handleQuery. F: remove dbSessions import and calls. G: simplify handleReconnect. H: review restoration uses adapter.getSession for cwd | +| `server/index.ts` | B: inject marker in POST /api/reviews. F: remove dbSessions import, clearAll call in shutdown. H: review endpoints use adapter.getSession for cwd | +| `server/db.ts` | F: delete sessions table schema, SessionRow, prepared statements, sessions export. Keep session_reviews | +| `src/lib/content-utils.ts` | B: add stripMarker function | +| `src/hooks/useChat.ts` | B: strip marker in convertMessages | +| `bin/codetap` | F: remove SQL queries that reference sessions table (get_project_sessions, -a listing, --resume lookup) | diff --git a/docs/superpowers/specs/2026-03-24-remaining-session-fixes.md b/docs/superpowers/specs/2026-03-24-remaining-session-fixes.md new file mode 100644 index 0000000..a7c2a90 --- /dev/null +++ b/docs/superpowers/specs/2026-03-24-remaining-session-fixes.md @@ -0,0 +1,104 @@ +# Remaining Session Fixes + +## Context + +Items A, B, C, F, G, H, J, K, L, M were already implemented in earlier commits. The following 4 items remain. CLI internal `/resume` command handling is deferred (Codex doesn't support it, Claude's case is rare). + +## D. handleSessionStart — remove pending matching, add _pendingHookBodies + +**Current:** `handleSessionStart` has `pendingSessions.length === 1` guessing logic to match a hook to a pending session. + +**Problem:** This fails with multiple pending sessions. The marker matching also can't work here because `SessionStart` hook fires at CLI startup, BEFORE the marker is pasted into the JSONL. + +**Fix:** `handleSessionStart` does NOT match pending sessions: + +``` +handleSessionStart(body): + 1. sessions.has(uuid) → already managed → update state → return + 2. has pending sessions → store hook body in _pendingHookBodies Map → return + 3. no pending sessions → ignore → return +``` + +New `_pendingHookBodies: Map` stores hook info (uuid, transcript_path, cwd). When `_watchForTranscript` later matches via marker and calls `_rekeyAndRename`, it reads `_pendingHookBodies.get(uuid)` to get the stored info. + +**Cleanup:** `_pendingHookBodies` entries should be cleaned up after 60 seconds if unmatched (timer per entry, or sweep in `_startSessionCleanup`). + +**Files:** `server/adapters/codex/codex-tmux-adapter.ts` + +## E. Remove desktop-discovery from BOTH adapters + +**Current:** Both adapters' `handleSessionStart` create session entries for unknown UUIDs. +- Claude: searches for "unmanaged tmux window running claude" (`w.command.includes('claude')`) +- Codex: creates entry and calls `_findAndAttachWindow` (already removed but fallback path remains) + +**Why remove:** With server shutdown killing all tmux windows, and `bin/codetap` moving to API calls, there are no "desktop-started" sessions in the codetap tmux session. Every session should go through `startSession` or `resumeSession`. + +**Fix:** +- Claude `handleSessionStart`: remove the "find unmanaged tmux window" block. Keep only `sessions.has(uuid) → update → return`. Unknown UUIDs are ignored. +- Codex `handleSessionStart`: the "desktop-started" branch becomes "ignore" (Task D already handles this). + +**Files:** `server/adapters/claude/tmux-adapter.ts`, `server/adapters/codex/codex-tmux-adapter.ts` + +## I. New API endpoints for bin/codetap + +**Current:** `bin/codetap` creates tmux windows directly, bypassing the server. Sessions it creates don't appear in the Map. + +**Fix:** Add two REST endpoints: + +``` +POST /api/sessions/start + Body: { adapter, cwd, model?, permissionMode? } + → adapter.startSession(cwd, options) + → Returns: { sessionId } + +POST /api/sessions/resume + Body: { sessionId, adapter?, cwd } + → adapter.resumeSession(sessionId, cwd) + → Returns: { sessionId } +``` + +Both require `authMiddleware`. + +For `/resume`, if `adapter` is not provided, detect from JSONL file location: +- `~/.claude/projects/.../{UUID}.jsonl` → claude +- `~/.codex/sessions/.../*-{UUID}.jsonl` → codex + +**Authentication for bin/codetap:** The script needs a token. It can get one via: +```bash +TOKEN=$(curl -sk -X POST https://localhost:$PORT/api/auth/login \ + -H "Content-Type: application/json" \ + -d "{\"password\":\"$CLAUDE_UI_PASSWORD\"}") +``` + +`CLAUDE_UI_PASSWORD` is already required as an env var. + +**Files:** `server/index.ts` + +## N. Update bin/codetap to use API endpoints + +**Fix:** + +- `bin/codetap new` → authenticate → `POST /api/sessions/start` → `tmux select-window` +- `bin/codetap --resume UUID` → authenticate → `POST /api/sessions/resume` → `tmux select-window` +- `bin/codetap --continue` → find most recent window from tmux → resume via API +- `bin/codetap -a` → `tmux list-windows` directly (adapter detected from `pane_current_command`) +- Remove ALL `sqlite3` references and `CODETAP_DB` variable + +**Note for Codex sessions:** `POST /api/sessions/start` returns temp key (`codex-{timestamp}`). The script does `tmux select-window -t codetap:codex-{timestamp}`. The user is in the window. After rekey, the window name changes to UUID, but the user is unaffected (already inside). + +**Files:** `bin/codetap` + +## Files Affected + +| File | Changes | +|------|---------| +| `server/adapters/codex/codex-tmux-adapter.ts` | D: _pendingHookBodies + rewrite handleSessionStart | +| `server/adapters/claude/tmux-adapter.ts` | E: remove desktop-discovery from handleSessionStart | +| `server/index.ts` | I: add session start/resume endpoints | +| `bin/codetap` | N: use API calls, remove sqlite3 | + +## Not Included + +- CLI internal `/resume` handling — Codex doesn't support it, Claude's case is rare and non-breaking +- Shared `TmuxAdapterBase` class — deferred to future refactor +- `childCliSessionId` removal from WS protocol — deferred (TODO in code) diff --git a/docs/superpowers/specs/2026-03-24-session-id-unification-design.md b/docs/superpowers/specs/2026-03-24-session-id-unification-design.md new file mode 100644 index 0000000..bf50ca2 --- /dev/null +++ b/docs/superpowers/specs/2026-03-24-session-id-unification-design.md @@ -0,0 +1,303 @@ +# Session ID Unification — CLI UUID as Single Source of Truth + +## Problem + +The codebase has two session ID systems that create bugs and complexity: + +1. **Internal ID** (format `claude-1774300056705` / `codex-1774300056705`): tmux window name, in-memory Map key, DB primary key +2. **CLI UUID** (format `d6d56787-bfaf-4312-ae4d-99683ba45459`): permanent ID from the CLI tool, JSONL filename + +These require constant translation via `resolveSessionId()` and `cliToSessionId` Maps. When translation fails: + +- Mobile can not receive desktop events (registered under CLI UUID, events broadcast under internal ID) +- handleReconnect creates unwanted tmux windows (lost hasActiveWindow guard) +- SessionsView passes different ID types (project list = CLI UUID, active list = internal ID) +- Latent bug: `_registerCliUUID()` references renamed column `claude_session` (should be `cli_session`) + +## Solution + +Eliminate internal ID as a session identifier. Use CLI UUID everywhere. Internal ID becomes just a tmux window display name with no programmatic significance. + +## Design + +### Layer 1: Adapter Internals + +**Files:** `server/adapters/claude/tmux-adapter.ts`, `server/adapters/codex/codex-tmux-adapter.ts`, `server/adapters/codex/pane-monitor.ts`, `server/adapters/interface.ts` + +**`this.sessions` Map key**: internal ID changes to CLI UUID. + +**Eliminated entirely:** +- `cliToSessionId: Map` -- no translation needed +- `resolveSessionId()` method -- no translation needed +- `_registerCliUUID()` -- no mapping to maintain (also fixes `claude_session` column bug) +- `_remapCliSession()` -- no remapping needed +- `_removeCliMapping()` -- no mapping to clean up + +**`startSession()` returns CLI UUID.** The tmux window name remains `{adapter}-{timestamp}` for tmux display but is not used as an identifier. + +**`resumeSession()` / `attachSession()`** take CLI UUID as parameter. + +**All event emits** use CLI UUID as first argument. + +**`getActiveSessions()`** returns CLI UUID as `sessionId`. The `cliSessionId` field kept for compatibility (same value). + +**`_findWindowForSession()`** looks up `window_id` from DB by CLI UUID instead of matching tmux window names. + +**`handleSessionStart()` pattern matching**: `w.name.startsWith('claude-')` still works because tmux window names retain the `{adapter}-{timestamp}` format. + +**Codex `startSession()` UUID timing**: Codex CLI generates its own UUID (arrives via SessionStart hook or JSONL filename). Add `_waitForCliUUID()` that waits for `session.cliSessionId` to be populated (max 15 seconds). Session initially stored under a temporary key (the window name), re-keyed once UUID is known. + +During the temp-key window: +- Hooks arriving from CLI use the `_watcherPending` scanning pattern (already exists) to find the session +- Events are emitted under the temp key (few clients would be registered under it yet) +- Once UUID arrives: session is re-keyed in the Map, DB is upserted, `sessionAdapterMap` updated + +If `_waitForCliUUID` times out: +- Kill the tmux window (`tmuxManager.killWindow`) +- Remove the temp session from the Map +- Throw error (propagates to client as WS error message) + +**Codex `CodexPaneMonitor`**: stores `sessionId` for event emission -- changes from internal ID to CLI UUID. + +### Layer 2: DB Schema + +**Files:** `server/db.ts` + +**`sessions` table migration:** + +Before: +- `id` (PRIMARY KEY) = internal ID +- `cli_session` = CLI UUID + +After: +- `id` (PRIMARY KEY) = CLI UUID +- `cli_session` column removed +- `window_name` column added (stores old internal ID for tmux display/debug) + +**`SessionRow` interface:** + +```typescript +export interface SessionRow { + id: string; // CLI UUID (was internal ID) + cwd: string; + window_id: string | null; // tmux window ID (@N) + window_name: string | null; // tmux window name for debug + adapter: string; + permission_mode: string; + created_at: string; + last_activity: string; +} +``` + +**Prepared statements:** +- `sessionsUpsert(id=cliUUID, cwd, windowId, windowName, adapter)` -- `id` is CLI UUID +- `sessionsFindByCliSession` -- REMOVED (use primary key lookup) +- `sessionsFindByWindowId` -- unchanged +- `sessionsRemove(id)` -- `id` is now CLI UUID +- Add `sessionsGet(id)` -- simple primary key lookup + +**`session_reviews` table**: No changes (already uses CLI UUIDs). + +**`session_stats` table**: Verify `session_id` uses CLI UUID. Migrate if needed. + +**Migration SQL** (SQLite table rebuild pattern): + +```sql +-- Step 1: Create new table with new schema +CREATE TABLE IF NOT EXISTS sessions_new ( + id TEXT PRIMARY KEY, + cwd TEXT NOT NULL, + window_id TEXT, + window_name TEXT, + adapter TEXT DEFAULT 'claude', + permission_mode TEXT DEFAULT 'default', + created_at TEXT DEFAULT (datetime('now')), + last_activity TEXT DEFAULT (datetime('now')) +); + +-- Step 2: Copy data, swapping id and cli_session +-- Skip rows where cli_session is empty, equals the internal ID, or matches {adapter}-{timestamp} pattern +INSERT OR IGNORE INTO sessions_new (id, cwd, window_id, window_name, adapter, permission_mode, created_at, last_activity) +SELECT + CASE + WHEN cli_session IS NOT NULL AND cli_session != '' AND cli_session != id THEN cli_session + ELSE id + END, + cwd, window_id, id, adapter, permission_mode, created_at, last_activity +FROM sessions; + +-- Step 3: Drop old table and rename +DROP TABLE sessions; +ALTER TABLE sessions_new RENAME TO sessions; + +-- Step 4: Recreate indexes +CREATE INDEX IF NOT EXISTS idx_sessions_window ON sessions(window_id); +``` + +Migration is wrapped in a transaction and runs inside `initDB()` before any adapter initialization. Detection: check if the old `cli_session` column exists (`PRAGMA table_info(sessions)`). + +**Handling rows where `cli_session` = internal ID**: Some Codex sessions store the internal ID as both `id` and `cli_session` (when UUID wasn't yet known). The migration uses `CASE WHEN cli_session != id THEN cli_session ELSE id END` — these rows keep their internal ID as `id`. They will be orphaned (no JSONL match) and cleaned up naturally by `clearAll()` on next shutdown. + +**`session_stats` table**: This table exists in the schema but is never written to by any code in the codebase (no INSERT statements found). It can be left as-is or dropped. No migration needed. + +### Layer 3: Session Manager + +**Files:** `server/session-manager.ts` + +**`sessionClients` Map key**: CLI UUID (was internal ID). +**`sessionAdapterMap` Map key**: CLI UUID (was internal ID). + +**`broadcast(sessionId, message)`**: `sessionId` is CLI UUID. This is the core fix -- adapter events emitted with CLI UUID now match client registration keys. + +**`sendSessionCreated()`**: Sends single `sessionId` (CLI UUID). Remove `cliSessionId` field. + +**`handleQuery()`**: No `resolveSessionId` call. `options.sessionId` from client is CLI UUID directly. + +**`handleReconnect()`**: Greatly simplified. No `resolveSessionId` needed. All 11 existing steps preserved: + +1. ~~Resolve internal ID via resolveSessionId~~ REMOVED (no translation needed) +2. Register client under CLI UUID +3. Clear push pending notifications (keyed by CLI UUID) +4. Send SESSION_CREATED (single ID) +5. Send cached status +6. Resume if not in memory — **with `hasActiveWindow` guard**: + ``` + if session not in memory: + if tmux window exists: resumeSession (attach to monitor events) + else: do nothing (just load history from JSONL) + ``` +7. Sync watcher position +8. Load JSONL history (`adapter.getMessages(sessionId)` — CLI UUID directly, no cliSessionId extraction needed) +9. Send streaming state (SESSION_STATE) +10. Replay pending tools and permissions +11. Restore active child reviews (`sessionReviews.getActiveForParent(sessionId)` — CLI UUID directly) + +Key simplification in step 11: no longer needs `findByCliSession` to look up parent CWD for child session resume — can use `sessions.get(sessionId)` primary key lookup directly (since `id` is now CLI UUID). + +**`triggerPush()`**: Simplified — `sessionId` IS CLI UUID, so child review check uses `sessionId` directly (no need to look up `sessionObj.cliSessionId`). Single `getSession()` call replaces the current two calls with different casts. Uses CLI UUID for sessionClients lookup, push pending, and push payload. + +**`session-ended` handler**: `sessionId` parameter IS CLI UUID. The entire convoluted lookup (`findByWindowId` fallback + `getAll().find()`) to extract `cli_session` is eliminated — `sessionId` is already the CLI UUID. Cascade cleanup calls `sessionReviews.getActiveForParent(sessionId)` directly. `sessionClients.delete` and `sessionAdapterMap.delete` remain synchronous (before any async cascade work). + +**`server/index.ts` active sessions endpoint**: The dual lookup `getClientCount(s.sessionId) || getClientCount(s.cliSessionId)` simplifies to `getClientCount(s.sessionId)` since both are the same CLI UUID. + +**`broadcastReviewStarted/Ended`**: `parentSessionId` is CLI UUID. + +### Layer 4: Frontend + +**Files:** `src/hooks/useChat.ts`, `src/lib/ws.ts`, `src/components/ChatView.tsx`, `src/components/SessionsView.tsx`, `src/components/FloatingReviewPanel.tsx`, `src/lib/api.ts` + +**`useChat.ts`**: Merge `sessionId` and `cliSessionId` states into single `sessionId` (always CLI UUID). `SESSION_CREATED` handler sets one state. All outgoing WS messages (QUERY, ABORT, RECONNECT, SET_MODEL, SET_PERMISSION_MODE, PLAN_RESPONSE) send this single `sessionId`. + +**`ws.ts`**: `activeSessionId` stores CLI UUID from `SESSION_CREATED`. + +**`ChatView.tsx`**: Header shows single `sessionId` (CLI UUID). Review API calls use `sessionId` directly. Remove `cliSessionId` from ChatHeader props. + +**`SessionsView.tsx`**: Both session lists (project + active) now use CLI UUID for `session.sessionId`. `onOpenChat(session.sessionId)` is consistent. `destroySession(session.sessionId)` passes CLI UUID. Push pending lookup `pending[session.sessionId]` uses CLI UUID. + +**`FloatingReviewPanel.tsx`**: `childSessionId` prop is CLI UUID. + +**`ActiveSessionInfo` interface**: `sessionId` becomes CLI UUID. `cliSessionId` kept as deprecated alias (same value). + +### Layer 5: Push Notifications + Permissions + +**Files:** `server/push.ts`, `server/permission-manager.ts`, `src/sw.ts`, `src/App.tsx` + +**`push.ts`**: `pendingSessions` Map keyed by CLI UUID. + +**`permission-manager.ts`**: `PendingPermission.sessionId` is CLI UUID. `sessionPendingIds` Map keyed by CLI UUID. + +**`sw.ts`**: Push payload `sessionId` is CLI UUID. Notification click URL `/?session=${cliUUID}`. + +**`App.tsx`**: URL parameter `?session=` is CLI UUID. Service worker `OPEN_SESSION` message carries CLI UUID. + +### Layer 6: CLI (`bin/codetap`) + +**File:** `bin/codetap` + +DB queries updated to use new schema (no `cli_session` column, `id` is CLI UUID): + +- Line 239 `get_project_sessions()`: Change `SELECT id FROM sessions` to `SELECT window_name FROM sessions` — the function returns IDs to match against tmux window names, which are `{adapter}-{timestamp}` format (stored in `window_name`, not `id`) +- Line 290: Change `SELECT id, adapter, cli_session, cwd` to `SELECT id, adapter, window_name, cwd` — display CLI UUID as `id`, use `window_name` for tmux matching +- Line 260: Match tmux window names against `window_name` column (not `id`) +- Line 382 `--resume`: Simplified to `WHERE id='${SAFE_ID}'` since `id` is now CLI UUID +- Window name generation unchanged (`{adapter}-{timestamp}`) + +**Critical**: The `-a` listing mode joins tmux window names against DB session IDs. After migration, this join key changes from `id` to `window_name`. The `IN (...)` SQL clause must query `window_name` column. + +## Codex UUID Discovery Flow + +``` +1. handleQuery() -> adapter.startSession(cwd, options) +2. Codex startSession(): + a. Generate window name "codex-{timestamp}" + b. Create tmux window + c. Wait for CLI ready (_waitForReady) + d. Start _watchForTranscript() (sets up FSWatcher) + e. NEW: _waitForCliUUID() -- wait for session.cliSessionId to be populated + - Source 1: handleSessionStart hook fires with session_id + - Source 2: _watchForTranscript detects JSONL file, extracts UUID from filename + f. Once UUID known: set as Map key, upsert DB + g. Return { sessionId: cliUUID } +3. handleQuery() continues with cliUUID +4. registerClient, sendSessionCreated, sendMessage -- all use cliUUID +``` + +Timeout: 15 seconds. If UUID not discovered, startSession fails with error. + +## Files Affected (Complete List) + +| File | Change Type | Summary | +|------|------------|---------| +| `server/db.ts` | MODIFY | Schema migration, remove cli_session, add window_name, update SessionRow | +| `server/session-manager.ts` | MODIFY | All Map keys to CLI UUID, simplify handleReconnect, fix triggerPush | +| `server/adapters/claude/tmux-adapter.ts` | MODIFY | sessions Map key, eliminate cliToSessionId/resolveSessionId, events use CLI UUID | +| `server/adapters/codex/codex-tmux-adapter.ts` | MODIFY | Same as Claude adapter, add _waitForCliUUID | +| `server/adapters/codex/pane-monitor.ts` | MODIFY | sessionId is CLI UUID | +| `server/adapters/interface.ts` | MODIFY | Remove resolveSessionId, update ActiveSessionInfo | +| `server/adapters/claude/index.ts` | MODIFY | Remove resolveSessionId delegation | +| `server/adapters/codex/index.ts` | MODIFY | Remove resolveSessionId delegation | +| `server/push.ts` | MODIFY | pendingSessions keyed by CLI UUID | +| `server/permission-manager.ts` | MODIFY | All session indices use CLI UUID | +| `server/transport/client-connection.ts` | NO CHANGE | sessionId field semantics change (now CLI UUID) | +| `server/index.ts` | MODIFY | Remove dual-ID lookups in active-sessions, simplify review endpoints | +| `server/types/messages.ts` | MODIFY | QueryOptions.sessionId is CLI UUID | +| `server/types/adapter.ts` | MODIFY | SessionInfo.sessionId is CLI UUID (already was) | +| `server/ws-types.ts` | NO CHANGE | Just message type constants | +| `src/hooks/useChat.ts` | MODIFY | Merge sessionId + cliSessionId into one | +| `src/lib/ws.ts` | MODIFY | activeSessionId stores CLI UUID | +| `src/lib/api.ts` | MODIFY | destroySession passes CLI UUID | +| `src/components/ChatView.tsx` | MODIFY | Single ID in header, remove cliSessionId usage | +| `src/components/SessionsView.tsx` | MODIFY | Consistent CLI UUID for both lists | +| `src/components/FloatingReviewPanel.tsx` | MODIFY | childSessionId is CLI UUID | +| `src/sw.ts` | MODIFY | Push sessionId is CLI UUID | +| `src/App.tsx` | MODIFY | URL param and SW message use CLI UUID | +| `bin/codetap` | MODIFY | DB queries use new schema | +| `server/adapters/claude/jsonl-store.ts` | NO CHANGE | Already uses CLI UUID | +| `server/adapters/codex/jsonl-store.ts` | NO CHANGE | Already uses CLI UUID | +| `server/adapters/claude/pane-monitor.ts` | NO CHANGE | Uses windowId (tmux ID), not session ID | +| `server/adapters/claude/hook-config.ts` | NO CHANGE | No session IDs | +| `server/adapters/codex/hook-config.ts` | NO CHANGE | No session IDs | +| `server/adapters/registry.ts` | NO CHANGE | No session IDs | +| `server/config.ts` | NO CHANGE | No session IDs | +| `server/stores/jsonl-watcher.ts` | NO CHANGE | File path based | +| `src/hooks/useSessions.ts` | MODIFY | `s.cliSessionId` for green dots → `s.sessionId` (same value after unification) | +| `tests/e2e-spec.feature` | MODIFY | Remove references to `resolveSessionId`, `cliSessionId` dual-ID | + +## What This Fixes + +1. Mobile receives desktop events in real-time (same broadcast key) +2. handleReconnect does not create unwanted tmux windows (hasActiveWindow guard) +3. SessionsView uses consistent IDs (both lists pass CLI UUID) +4. No more resolveSessionId translation failures +5. Fixes _registerCliUUID bug (references renamed column) +6. Eliminates ~200 lines of translation/mapping code +7. Push notifications navigate to correct session +8. Active session client count lookup simplified (no dual-ID check) + +## Scope Boundaries + +NOT included in this refactor: +- Changing tmux window names (they remain `{adapter}-{timestamp}` for display) +- Changing JSONL file paths (already CLI UUID based) +- Changing the Cross-AI Review feature (already uses CLI UUIDs) +- Auto N-round debate or other deferred features diff --git a/docs/superpowers/specs/2026-03-25-review-panel-ux-fixes-design.md b/docs/superpowers/specs/2026-03-25-review-panel-ux-fixes-design.md new file mode 100644 index 0000000..e1d3344 --- /dev/null +++ b/docs/superpowers/specs/2026-03-25-review-panel-ux-fixes-design.md @@ -0,0 +1,119 @@ +# Cross-AI Review Panel UX Fixes + +## Context + +E2E testing revealed several UX issues with Cross-AI Review: marker text leaking into UI, panel blocking parent interaction, and incomplete features (collapsed card onClick, read-only mode). + +## Issues & Fixes + +### A. Marker Bugs + +**A1. Session List shows marker** +`firstPrompt` in Codex adapter extracts raw text from JSONL without stripping `[CODETAP_REF:xxx]`. Session list displays it. + +Fix: Strip marker when setting `firstPrompt` in Codex adapter's `_processWatcherEntries`. + +**A2. Marker trailing `\\n` residue** +`handleQuery` injects `[CODETAP_REF:xxx]\n{prompt}`. Codex `sendMessage` replaces `\n` → `\\n`. JSONL stores `[CODETAP_REF:xxx]\\nHello`. `stripMarker` regex `\n?` matches real newline but not literal `\\n`. + +Fix: Update `stripMarker` regex to `^\[CODETAP_REF:[^\]]+\](?:\\\\n|\n)?` — matches both real newline and literal `\\n`. + +**Files:** `server/adapters/codex/codex-tmux-adapter.ts`, `src/lib/content-utils.ts` + +### B. Panel Minimize / Expand UX + +**B1. Minimized state: thin bar above input** +When minimized, show a full-width bar between the message area and parent input: +- Left: pulsing green dot + adapter badge ("Codex") + status ("review in progress · 3 messages") +- Right: ▲ Expand button + End button +- Bar has subtle green top border + +**B2. Expanded state: clear minimize button** +Panel header gets a ▼ Minimize icon button (in addition to handle bar). Header shows: adapter badge + review title + ▼ Minimize + End. + +**B3. Input distinction** +When panel is expanded, the child input shows: +- Adapter badge (small Codex icon) to the left of the input +- Placeholder: "Reply to Codex review..." (not generic "Send a message...") +- Panel has green top border separating it from parent chat + +**B4. Panel covers parent input** +When expanded, parent input is hidden (covered by panel). Only child input visible. This is intentional — user must minimize to chat with parent. + +**Files:** `src/components/FloatingReviewPanel.tsx`, `src/components/ChatBody.tsx` (placeholder prop) + +### C. Review History Markers + +**C1. Start/End markers wrap all content** +Review Start and End markers appear in parent chat history. Everything between them (including parent messages exchanged while review was minimized) is wrapped. This shows "review was happening during this time period." + +``` +[parent message] +──── Codex review started ──── +[collapsed review card: "5 messages · tap to view"] +[parent message during review] +[parent message during review] +──── Codex review ended ──── +[parent message after review] +``` + +**C2. CollapsedReviewCard onClick → read-only panel** +Currently onClick is a TODO. Implement: clicking opens FloatingReviewPanel in read-only mode with child session's history via RECONNECT. + +Card receives `childSessionId` from the review record. Click sets a `readOnlyReview` state in ChatView → mounts FloatingReviewPanel with `readOnly` flag. + +**C3. Read-only panel** +Same layout as active panel but: +- Gray header (not green) — "Codex | code review · ended" +- ✕ Close button (not End) +- No input — bottom shows "Review ended — read only" +- Messages loaded via RECONNECT + HISTORY_LOAD + +**Files:** `src/components/CollapsedReviewCard.tsx`, `src/components/ChatView.tsx`, `src/components/FloatingReviewPanel.tsx` + +### D. Send-back Button Missing in Child Panel + +**Problem:** Child session's assistant responses should show a ↩ send-back icon, but it's not visible. After the ChatBody refactor, `onSendBack` is passed from FloatingReviewPanel → ChatBody → MessageBubble. But `showActions` may not be correctly evaluated, or the prop chain is broken. + +**Fix:** Verify and fix the prop chain: +1. FloatingReviewPanel passes `onSendBack` to ChatBody ✓ (confirmed in code) +2. ChatBody passes `onSendBack` to MessageBubble — check `showActions` logic +3. MessageBubble renders ↩ icon when `onSendBack` is provided and `showActions` is true + +If `showActions` is computed inside ChatBody (not passed as prop), verify it evaluates to `true` for assistant messages when not streaming. + +**Files:** `src/components/ChatBody.tsx`, `src/components/MessageBubble.tsx` + +### E. Message Action Icons Polish + +**E1. Icons too large / too bold / have border** +Current icon buttons have `border border-border rounded-md` (visible outline box), `w-7 h-7` (28px), and SVG `strokeWidth="2"`. + +Fix: +- Remove `border` from button — no outline box, just the icon +- Reduce button size from `w-7 h-7` to `w-6 h-6` (24px) +- Reduce SVG from `width/height="14"` to `"12"` +- Reduce SVG `strokeWidth` from `"2"` to `"1.5"` +- Keep hover background (`hover:bg-white/5`) for touch feedback + +**E2. Copy feedback — checkmark confirmation** +Copy icon should show a ✓ checkmark for ~2 seconds after clicking, then revert to the copy icon. Confirms the clipboard action succeeded. + +Implementation: `useState` for `copied` state, `setTimeout` to reset after 2s. + +**Files:** `src/components/MessageBubble.tsx` + +### F. Adapter Icons — Use SVGs from thesvg.org + +Current `AdapterIcon.tsx` has hand-drawn SVG paths for Claude (Anthropic "A") and Codex (OpenAI knot). Replace with official SVGs from https://www.thesvg.org/ for better accuracy. + +- Search for "Anthropic" / "Claude" → get official Anthropic logo SVG +- Search for "OpenAI" / "Codex" → get official OpenAI logo SVG +- Update `ClaudeIcon` and `CodexIcon` components in `src/components/AdapterIcon.tsx` +- Keep the same `size` prop interface and `fill="currentColor"` for color control + +**Files:** `src/components/AdapterIcon.tsx` + +## Not Changed +- Review session creation flow (already unified via QUERY in previous spec) +- Server-side review lifecycle diff --git a/docs/superpowers/specs/2026-03-25-review-state-separation-design.md b/docs/superpowers/specs/2026-03-25-review-state-separation-design.md new file mode 100644 index 0000000..74197d1 --- /dev/null +++ b/docs/superpowers/specs/2026-03-25-review-state-separation-design.md @@ -0,0 +1,105 @@ +# Review State Separation + Session List Cleanup + +## Context + +Three issues to fix together: + +1. **activeReview / historyReview state conflict** — Viewing a historical review overwrites the active review state, losing its panel +2. **Session list shows CODETAP_REF marker** — firstPrompt not stripped (screenshot confirms markers visible in session list) +3. **Child sessions visible in session list** — review child sessions should not appear in project session list or active sessions + +## A. Separate activeReview and historyReview States + +### Problem +`activeReview` state serves double duty (active + read-only viewing). Viewing history replaces active review. + +### Design + +**State model:** +```typescript +activeReview: ReviewInfo | null // ongoing active review +historyReview: ReviewInfo | null // historical review being viewed (read-only) +activeReviewPanel: 'expanded' | 'minimized' // renamed from reviewPanelState +``` + +**Remove:** `readOnlyReview: boolean` — replaced by `historyReview !== null` + +**Panel display (mutual exclusion — only one panel at a time):** +``` +historyReview !== null → read-only panel +activeReview && activeReviewPanel === 'expanded' → active panel +otherwise → no panel +``` + +**Minimized bar shows when:** +``` +activeReview !== null AND (activeReviewPanel === 'minimized' OR historyReview !== null) +``` + +**Interactions:** + +| Action | Effect | +|--------|--------| +| Click collapsed card (history) | `setHistoryReview(review)` + `setActiveReviewPanel('minimized')` | +| ✕ Close history panel | `setHistoryReview(null)` | +| ▲ Expand minimized bar | `setHistoryReview(null)` + `setActiveReviewPanel('expanded')` | +| ▼ Minimize active | `setActiveReviewPanel('minimized')` | +| End active review | `setActiveReview(null)` + `setHistoryReview(null)` | +| Start new review | `setActiveReview(...)` + `setActiveReviewPanel('expanded')` + `setHistoryReview(null)` | + +**FloatingReviewPanel receives:** +```typescript +const panelReview = historyReview || (activeReviewPanel === 'expanded' ? activeReview : null); +const isReadOnly = !!historyReview; + +{panelReview && ( + setHistoryReview(null) : closeReview} + ... + /> +)} +``` + +**Files:** `src/hooks/useChat.ts`, `src/components/ChatView.tsx`, `src/components/FloatingReviewPanel.tsx` + +## B. Session List Marker Strip + +### Problem +Screenshot shows `[CODETAP_REF:codex-1774412730686]\nHi` in session list. The earlier fix (strip marker in `firstPrompt`) may not have been applied in all code paths, or the sessions were created before the fix. + +### Design + +Marker stripping is Codex-specific behavior (Codex's `sendMessage` does `\n` → `\\n` replacement). Fix in the Codex adapter only — not client-side. + +**Two Codex-side locations to strip:** + +1. **`codex/jsonl-store.ts` `getSessions()` line 204** — `firstPrompt` from `history.jsonl` entry. This is the session list source for ALL sessions (including historical). Strip `[CODETAP_REF:...](\\n|\n)?` from `entry.text` before slicing. + +2. **`codex/codex-tmux-adapter.ts` `_processWatcherEntries()`** — `firstPrompt` for active sessions (already fixed in earlier commit, but verify it covers all paths). + +**Files:** `server/adapters/codex/jsonl-store.ts`, `server/adapters/codex/codex-tmux-adapter.ts` + +## C. Hide Child Sessions from Session List + +### Problem +Cross-AI Review child sessions appear in the project session list and active sessions list. They should be hidden — they're child sessions owned by a parent. + +### Design + +**Server-side filtering:** When returning sessions (both project sessions and active sessions), exclude sessions whose ID appears as `child_cli_session_id` in the `session_reviews` table. + +- `GET /api/sessions/:dir` — filter out child session IDs +- Active sessions list — filter out child session IDs from `getActiveSessions()` + +**How to identify child sessions:** +- `sessionReviews.getAllChildIds()` already exists (returns Set of child CLI session IDs) +- Use this to filter in both endpoints + +**Files:** `server/index.ts` (session endpoints), `server/db.ts` (getAllChildIds) + +## Not Changed +- Review creation flow (already unified via QUERY) +- Send-back mechanism +- FloatingReviewPanel component structure (still uses ChatBody) diff --git a/docs/superpowers/specs/2026-03-25-unified-session-path-design.md b/docs/superpowers/specs/2026-03-25-unified-session-path-design.md new file mode 100644 index 0000000..253b2cc --- /dev/null +++ b/docs/superpowers/specs/2026-03-25-unified-session-path-design.md @@ -0,0 +1,126 @@ +# Unified Session Creation Path for Cross-AI Review + +## Context + +Cross-AI Review child sessions currently use a different creation path than normal sessions: + +- **Normal session**: WebUI sends WS `QUERY` → `handleQuery` → `startSession` + `registerClient` + `sendMessage` — all in one handler, atomically. +- **Review child**: HTTP `POST /api/reviews` → `startSession` + `pasteToSession` on server → broadcast `REVIEW_STARTED` → FloatingReviewPanel mounts → `useChat` sends WS `RECONNECT` → `registerClient` — split across HTTP and WS. + +This split causes race conditions (rekey happens before WS client connects) and requires defensive mechanisms (`rekeyAliases`, `session-rekeyed` event forwarding) that wouldn't be needed if both paths were the same. + +**Insight**: A review child session IS a normal new session. The only difference is the first message is review context instead of user-typed text. It should go through the same QUERY flow. + +## Design + +### 1. Codex `sendMessage` — auto-handle large/multiline content + +Currently Codex adapter has two methods: +- `sendMessage` — uses `sendKeys` (character-by-character, doesn't handle newlines) +- `pasteToSession` — uses `pasteBuffer` (bulk paste, replaces `\n` with `\\n`) + +Review context is large (30KB+) and multiline. If it goes through `sendMessage` via QUERY, `sendKeys` would be extremely slow and newlines would be treated as separate message submissions. + +**Fix**: Make `sendMessage` auto-detect and use `pasteBuffer` for large/multiline content. Transparent to all callers. + +**Important**: Fresh Codex sessions have TUI placeholder text (e.g., "Use /skills to list available skills"). Pasting via `pasteBuffer` appends to the placeholder, truncating the first ~20 chars. The existing fix (from this session) splits the paste: send the `[CODETAP_REF:...]` marker via `sendKeys` first (triggers TUI to clear placeholder), wait 200ms, then `pasteBuffer` the rest. The unified `sendMessage` must preserve this behavior. + +``` +sendMessage(sessionId, text): + if text.length > 500 || text.includes('\n'): + singleLine = text.replace(/\n/g, '\\n') + // Check for CODETAP_REF marker at start (fresh session with placeholder) + markerMatch = singleLine.match(/^\[CODETAP_REF:[^\]]+\]/) + if markerMatch: + sendKeys(marker) // clears TUI placeholder + wait 200ms + pasteBuffer(rest) // fast, placeholder already cleared + else: + pasteBuffer(singleLine) // existing session, no placeholder issue + wait 300ms + sendControl('Enter') + else: + sendKeys(text) // character-by-character, fine for short text + wait 200ms + sendControl('Enter') +``` + +This merges `sendMessage` and `pasteToSession` into one method that handles all cases. + +**Files**: `server/adapters/codex/codex-tmux-adapter.ts` + +### 2. Frontend — review child uses QUERY, not RECONNECT + +**Current flow**: +``` +POST /api/reviews → server creates session + DB record → broadcast REVIEW_STARTED +→ parent useChat sets activeReview → FloatingReviewPanel mounts +→ useChat(childSessionId) → WS RECONNECT → handleReconnect +``` + +**New flow**: +``` +User clicks "Send to Codex" → selects template +→ ChatView locally sets activeReview state (no server call) +→ FloatingReviewPanel mounts with { context, targetAdapter, cwd } +→ FloatingReviewPanel's useChat auto-sends context as first WS QUERY +→ handleQuery → startSession → registerClient → sendMessage (same as normal!) +→ SESSION_CREATED received → useChat has childSessionId +→ POST /api/reviews { parentSessionId, childSessionId, ... } → DB record created +``` + +Key changes: +- `ChatView.handleReviewSelect`: instead of calling `api.createReview()`, locally mount FloatingReviewPanel with review props +- `FloatingReviewPanel`: receives `initialPrompt` prop, useChat auto-sends it as first QUERY +- After `SESSION_CREATED`, call `api.registerReview()` to persist the DB record + +**Files**: `src/components/ChatView.tsx`, `src/components/FloatingReviewPanel.tsx`, `src/hooks/useChat.ts`, `src/lib/api.ts` + +### 3. Server — POST /api/reviews simplified + +From: +- `adapter.startSession(cwd)` — REMOVE +- `adapter.pasteToSession(childSessionId, markerContext)` — REMOVE +- `sessionReviews.create(...)` — KEEP +- `broadcastReviewStarted(...)` — KEEP (for multi-device sync) +- Returns `{ reviewId, childSessionId }` — childSessionId now comes from client + +To: +``` +POST /api/reviews (renamed or new endpoint: POST /api/reviews/register) + Body: { parentSessionId, childSessionId, targetAdapter, anchorMessageId, prompt, title } + → sessionReviews.create(...) + → broadcastReviewStarted(parentSessionId, { reviewId, childSessionId, ... }) + → Returns { reviewId } +``` + +**Files**: `server/index.ts` + +### 4. CODETAP_REF marker — already handled + +`handleQuery` in `session-manager.ts` already injects `[CODETAP_REF:tempKey]` for non-Claude new sessions. No change needed — the marker injection works naturally through the QUERY flow. + +### 5. `pasteToSession` — can be removed from Codex adapter public API + +After `sendMessage` handles all content sizes, `pasteToSession` is no longer needed as a separate public method. It can be: +- Removed from the adapter interface +- Or kept as internal helper called by `sendMessage` + +The only remaining caller is `POST /api/reviews/:id/send-back` (sends feedback to parent). This also goes through `sendMessage` if we update it. + +**Files**: `server/adapters/codex/codex-tmux-adapter.ts`, `server/adapters/codex/index.ts`, `server/adapters/interface.ts` + +## Not Changed + +- `POST /api/reviews/:id/send-back` — still HTTP (different concern: sending message to an existing session) +- `POST /api/reviews/:id/end` — still HTTP +- `rekeyAliases` — kept as defensive mechanism (handleQuery's registerClient vs hook timing) +- `session-rekeyed` forwarding — kept (still needed for handleQuery flow) + +## Verification + +1. New Codex session from WebUI — send message, verify response appears +2. Cross-AI Review: click "Send to Codex" → panel opens → Codex responds in panel (same QUERY flow) +3. Send back to parent — verify message appears +4. End review — verify markers appear +5. Reconnect — verify active review restored diff --git a/docs/superpowers/specs/2026-03-26-cross-ai-review-v2-design.md b/docs/superpowers/specs/2026-03-26-cross-ai-review-v2-design.md new file mode 100644 index 0000000..df79711 --- /dev/null +++ b/docs/superpowers/specs/2026-03-26-cross-ai-review-v2-design.md @@ -0,0 +1,130 @@ +# Cross-AI Review v2 — Multi-Review, Marker Position, Send-To UX + +**Date**: 2026-03-26 +**Status**: Approved + +## Problem + +Three issues with the current cross-AI review system: + +1. **"Review ended" marker position** — rendered at the anchor message (where "Send to" was clicked), not at the bottom of the chat where the user actually pressed End. Misleading timeline. +2. **"Send to" ignores active reviews** — always opens the full adapter/model selection flow, even when there's already an active review that should receive the message. +3. **Single active review limit** — frontend state (`activeReview`) is a single object. Cannot run multiple reviews simultaneously (e.g., send one message to Codex and another to Claude). + +Additionally: **textarea placeholder 16px override** — global CSS `input, textarea, select { font-size: 16px }` (iOS zoom prevention) overrides Tailwind `text-sm` in the review panel, making the placeholder disproportionately large. + +## Design + +### 1. Review Ended Marker Position + +**Current**: "started", "in progress", and "ended" markers are all rendered by `renderReviewMarkers`, keyed by `anchor_message_id`. They all appear after the anchor message. + +**New**: Split markers into two locations: +- **"started" + CollapsedReviewCard** — at anchor message (shows where the review was initiated; card lets user tap to view the review conversation) +- **"in progress"** — at anchor message (only for active reviews, replaces CollapsedReviewCard) +- **"ended"** — rendered after the last message in parent chat at the time End was pressed (shows where in the timeline the review concluded) + +**Implementation**: +- Add `end_anchor_message_id TEXT` column to `session_reviews` table +- When `endReview()` is called, set `end_anchor_message_id` to the ID of the last message currently in the parent session's message history +- Server: `GET /api/reviews` response already returns all review columns — no API change needed +- Frontend: `renderReviewMarkers` uses two maps: + - `startMarkersByAnchor` — keyed by `anchor_message_id`: + - Active review: "started" marker + "in progress" marker + - Ended review: "started" marker + CollapsedReviewCard (tap to view) + - `endMarkersByAnchor` — keyed by `end_anchor_message_id`: + - Ended review only: "Review ended" marker + +### 2. Send-To with Active Review + +**Current**: Clicking "↗ Send to" always sets `reviewMenuMessageId` → opens `ReviewActionMenu` bottom sheet → full adapter/model flow. + +**New**: Two paths based on whether active reviews exist: + +**Path A — No active reviews**: Same as current. Full adapter/model selection → create new child session. + +**Path B — Active review(s) exist**: Show a simplified bottom sheet with options: +- One button per active review: **"Send to {Adapter} review"** (with adapter badge + color). Clicking sends the message text directly to that child session as a new prompt. +- A divider line +- **"Start new review..."** button at the bottom → opens the current full flow + +**Sending to existing review**: +- Extract text from the clicked message +- Call `childChat.sendMessage(text)` on the corresponding review's `useChat` instance +- Auto-expand and switch tab to that review's tab +- No new DB row, no new session — just a follow-up message in the existing child session + +### 3. Multi-Review UI (Design D) + +**State change**: `activeReview` (single object) → `activeReviews` (array of review objects). Each entry has: `{ reviewId, childSessionId, childCliSessionId, childAdapter, anchorMessageId, reviewTitle }`. + +**Minimized state** (all reviews collapsed): +- Single compact bar above the input: colored dots for each review + "{N} reviews: Codex · Claude" + "▲ Expand" +- Clicking the bar expands to the tabbed panel + +**Expanded state** (panel visible): +- 50% height bottom panel with: + - **Handle bar** at top (drag/click to minimize) + - **Tab bar**: one tab per active review, each showing adapter color dot + name. Active tab underlined with adapter color. Each tab has ✕ to end that review. Right side has ▼ minimize button. + - **Chat area**: messages for the focused tab's child session + - **Input**: "Reply to {Adapter} review..." placeholder + +**Single review special case**: When only 1 active review, show header (badge + title + ▼ + End) instead of tab bar. Same as current design. + +**Each tab is an independent `useChat` hook**. The `FloatingReviewPanel` component manages an array of child chat instances, renders only the active tab's messages, but keeps all hooks alive for background message receipt. + +**Tab lifecycle**: +- New review → push to `activeReviews`, add tab, auto-focus it +- End review (✕ or "End" button) → call `api.endReview(reviewId)`, remove from `activeReviews`, focus adjacent tab +- All reviews ended → panel disappears, minimized bar disappears + +### 4. Placeholder Font Size Fix + +**Root cause**: `src/index.css` line 83: `input, textarea, select { font-size: 16px }` overrides `text-sm` (14px). + +**Fix**: Keep the 16px rule for iOS zoom prevention, but add a specific override for the review panel textarea: + +```css +.review-panel-input textarea { font-size: 14px !important; } +``` + +Or use Tailwind's `!text-sm` on the textarea in FloatingReviewPanel. The main chat input stays at 16px (looks fine at full width); only the cramped review panel gets the smaller size. + +## Data Flow Changes + +### End Review (updated) + +``` +User taps "End" on tab / End button + → Frontend: get last message ID from parent chat messages array + → api.endReview(reviewId, { endAnchorMessageId: lastMsgId }) + → Server: UPDATE session_reviews SET ended_at=NOW(), end_anchor_message_id=? + → Server: broadcast WS REVIEW_ENDED { reviewId } + → Server: destroySession(childCliSessionId) + → Frontend: remove from activeReviews array + → Frontend: reviews re-fetched → endMarkersByAnchor updated + → "ended" marker + CollapsedReviewCard appear after the correct message +``` + +### Send-To Existing Review + +``` +User taps "↗ Send to" on assistant message (with active reviews present) + → Simplified bottom sheet: [Send to Codex review] [Send to Claude review] [Start new...] + → User taps "Send to Codex review" + → Extract text from the anchor message + → Find the Codex review's useChat sendMessage function + → sendMessage(text) → message sent to child session + → Auto-expand panel, switch to Codex tab +``` + +## Migration + +- New column `end_anchor_message_id` on `session_reviews`: nullable, no migration needed for existing rows (they will show "ended" at anchor position as fallback) + +## Scope Exclusions + +- Drag-to-reorder tabs: not needed +- Resize panel height: not needed (fixed 50%) +- Review notifications/badges on minimized bar: nice-to-have, not in v2 +- Persist expanded/minimized state across page refreshes: not needed diff --git a/docs/superpowers/specs/2026-03-26-gemini-adapter-design.md b/docs/superpowers/specs/2026-03-26-gemini-adapter-design.md new file mode 100644 index 0000000..48c68b6 --- /dev/null +++ b/docs/superpowers/specs/2026-03-26-gemini-adapter-design.md @@ -0,0 +1,421 @@ +# Gemini CLI Adapter Design + +**Date:** 2026-03-26 +**Status:** Draft +**Approach:** B — Shared layer extraction + Gemini adapter + +## Overview + +Add a third adapter to code-tap for Google's Gemini CLI (v0.34.0+), providing full bidirectional control from the mobile PWA — identical feature parity with the existing Claude and Codex adapters. + +## Scope + +- Full Gemini adapter: tmux session management, prompt sending, streaming, tool tracking, permission approval, thinking display, model/permission mode switching +- New `JsonWatcher` for Gemini's single-JSON session format +- Bridge script for Gemini's stdin/stdout hook protocol +- Shared layer: move `tmux-manager.ts` to `server/adapters/shared/` +- CLI, registry, and frontend integration + +## Research Findings + +### Gemini CLI Architecture + +| Aspect | Detail | +|---|---| +| **Version** | 0.34.0 | +| **Config dir** | `~/.gemini/` | +| **Settings** | `~/.gemini/settings.json` | +| **Session files** | `~/.gemini/tmp//chats/session-*.json` (single JSON, not JSONL) | +| **Project mapping** | `~/.gemini/projects.json` maps abs paths to project names | +| **Project root** | `~/.gemini/tmp//.project_root` contains abs path | +| **Hook protocol** | stdin/stdout JSON (not HTTP like Claude) | +| **Hook events** | BeforeTool, AfterTool, BeforeAgent, AfterAgent, SessionStart, SessionEnd, + more | +| **Models** | auto, pro (2.5 Pro), flash (2.5 Flash), flash-lite | +| **Permission modes** | default, auto_edit, yolo, plan | +| **Resume** | `gemini --resume ` | +| **GEMINI.md** | Yes, analogous to CLAUDE.md | + +### Session File Format (JSON, not JSONL) + +```json +{ + "sessionId": "uuid", + "projectHash": "sha256", + "startTime": "ISO 8601", + "lastUpdated": "ISO 8601", + "messages": [ + { + "id": "uuid", + "timestamp": "ISO 8601", + "type": "user", + "content": [{ "text": "..." }] + }, + { + "id": "uuid", + "timestamp": "ISO 8601", + "type": "gemini", + "content": "markdown string", + "thoughts": [{ "subject": "...", "description": "...", "timestamp": "..." }], + "tokens": { "input": N, "output": N, "cached": N, "thoughts": N, "tool": N, "total": N }, + "model": "gemini-3.1-pro-preview", + "toolCalls": [{ + "id": "string", + "name": "tool_name", + "args": {}, + "result": [{ "functionResponse": { "id": "...", "name": "...", "response": { "output": "..." } } }], + "status": "success|cancelled", + "timestamp": "ISO 8601", + "displayName": "Human-readable name", + "description": "Tool description" + }] + }, + { + "id": "uuid", + "type": "error", + "content": "error string" + }, + { + "id": "uuid", + "type": "info", + "content": "info string" + } + ], + "kind": "main", + "summary": "Session summary" +} +``` + +### Key Differences from Claude/Codex + +| Aspect | Claude | Codex | Gemini | +|---|---|---|---| +| Session format | JSONL (append-only) | JSONL (append-only) | Single JSON (rewritten) | +| Watcher strategy | Byte offset tracking | Byte offset tracking | File size guard + message ID tracking | +| Hook protocol | HTTP POST (url-based) | HTTP POST (command curl) | stdin/stdout JSON (needs bridge script) | +| Tool tracking | Separate tool_use/tool_result entries | JSONL entries | Embedded in gemini message as toolCalls[] | +| Thinking | Pane monitor detection | Pane monitor detection | In JSON (thoughts[]) + pane monitor | +| Token/model info | statusLine hook | JSONL entries | In JSON (tokens{}, model field) | +| Session ID | Pre-assigned via --session-id | Discovered from SessionStart hook | Discovered from SessionStart hook | +| Permission toggle | Shift+Tab cycles 4 modes | N/A | Ctrl+Y toggles YOLO on/off | +| Model switch | /model slash command | N/A | /model slash command | + +## File Structure + +### New Files + +``` +server/adapters/shared/ + tmux-manager.ts # Moved from claude/ (shared by all 3 adapters) + +server/adapters/gemini/ + index.ts # GeminiAdapter (extends IAdapter) + gemini-tmux-adapter.ts # Session lifecycle, hook handling + pane-monitor.ts # Gemini TUI streaming/thinking detection + transcript-parser.ts # JSON session -> ParsedMessage[] + json-store.ts # Session discovery from ~/.gemini/tmp/ + message-utils.ts # Gemini content block extraction + hook-config.ts # GeminiHookConfig (install/uninstall hooks) + bridge.sh # stdin JSON -> curl POST bridge script + +server/stores/ + json-watcher.ts # New: JSON file watcher (alongside existing jsonl-watcher.ts) +``` + +### Modified Files + +``` +server/adapters/shared/tmux-manager.ts # Moved from server/adapters/claude/tmux-manager.ts +server/adapters/claude/tmux-adapter.ts # Update import path -> ../shared/tmux-manager.js +server/adapters/codex/codex-tmux-adapter.ts # Update import path -> ../shared/tmux-manager.js +server/adapters/init.ts # Add gemini loader +server/adapters/registry.ts # Add 'gemini' to default enabled list +bin/hooks-cli.mjs # Add GeminiHookConfig +bin/codetap # Add gemini to set_adapter, detection, labels, validation +src/lib/adapter-brands.ts # Add gemini brand + extend iconType union to include 'gemini' +src/components/AdapterIcon.tsx # Add GeminiIcon (SVG from thesvg.org), refactor to switch/map +``` + +## Component Designs + +### 1. Bridge Script (`bridge.sh`) + +Gemini hooks communicate via stdin JSON / stdout JSON. The bridge reads stdin and POSTs to the code-tap server, matching the existing HTTP-based pattern. + +```bash +#!/bin/bash +# Reads JSON from stdin (Gemini hook protocol), POSTs to code-tap server. +# +# IMPORTANT: Gemini hooks expect a JSON response on stdout. We must write +# a response BEFORE backgrounding the curl POST, or Gemini will hang. +# Exit code 0 = allow (continue), exit code 2 = block. +# +# Shell compatibility: Uses #!/bin/bash for /dev/tcp port check. +# If Gemini executes hooks with zsh (which lacks /dev/tcp), fall back to +# curl's --connect-timeout instead. Validated against Gemini CLI v0.34.0. +ENDPOINT="$1" +PORT="${CODETAP_PORT:-3456}" +PROTOCOL="${CODETAP_PROTOCOL:-http}" +CURL_K="" +[ "$PROTOCOL" = "https" ] && CURL_K="-k" + +# Read stdin (Gemini hook JSON payload) +input=$(cat) + +# Respond to Gemini immediately — must happen BEFORE backgrounding curl. +# Empty JSON object = "no modifications, continue normally". +printf '{}' + +# Port check: skip curl if server isn't listening (fail-fast <1ms) +(echo >/dev/tcp/localhost/$PORT) 2>/dev/null || exit 0 + +# Forward payload to code-tap server asynchronously +printf '%s' "$input" | curl -sf $CURL_K --connect-timeout 2 --max-time 5 \ + -X POST -H 'Content-Type:application/json' -d @- \ + "${PROTOCOL}://localhost:${PORT}/api/hooks/gemini/${ENDPOINT}" &>/dev/null & +``` + +### 2. GeminiHookConfig (`hook-config.ts`) + +Installs hooks into `~/.gemini/settings.json` under the `hooks` key. Follows the same wrap pattern as Claude/Codex — preserves existing hooks, identifies our entries by portTag for clean uninstall. + +**Hook mapping:** + +| Gemini Event | Bridge Endpoint | Purpose | +|---|---|---| +| `BeforeTool` | `before-tool` | tool-start event | +| `AfterTool` | `after-tool` | tool-done event | +| `BeforeAgent` | `before-agent` | processing-started | +| `AfterAgent` | `after-agent` | session-idle (stop) | +| `SessionStart` | `session-start` | Session registration, watcher setup | +| `SessionEnd` | `session-end` | Cleanup | + +**Hook command format:** +```json +{ + "hooks": { + "BeforeTool": [{ + "matcher": "*", + "hooks": [{ + "type": "command", + "command": "/abs/path/to/bridge.sh before-tool", + "timeout": 2 + }] + }] + } +} +``` + +Environment variables `CODETAP_PORT` and `CODETAP_PROTOCOL` are set in the command string so the bridge knows where to POST. + +### 3. JsonWatcher (`server/stores/json-watcher.ts`) + +Watches a single JSON session file for new messages. Cannot use byte-offset tracking (file is rewritten entirely on each update), so uses file-size guard + message ID tracking. + +**Algorithm:** +1. `fs.watch()` triggers on file change (+ fallback polling every 2s) +2. `stat()` checks if file size changed — skip if same (filters false positives) +3. Read entire file, `JSON.parse()` +4. Compare `messages.length` vs `_lastMessageCount` +5. Find new messages by scanning from `_lastMessageCount` index +6. Verify with `_lastMessageId` (guard against message deletion/modification edge case) +7. Emit only new messages via `onNewMessages()` callback +8. Update `_lastSize`, `_lastMessageCount`, `_lastMessageId` + +**Debounce:** 50ms after `fs.watch` fires before polling. Chosen to balance latency (streaming UX) vs coalescing (Gemini rewrites the file on each message). The existing `JsonlWatcher` uses no debounce because JSONL appends are atomic; JSON rewrites are not. + +**Performance:** Observed session files up to ~34KB in practice. `JSON.parse()` of 34KB takes <1ms. As a safeguard: if file size exceeds 2MB, log a warning. The in-memory parsed result is NOT cached between polls (file is always re-read on size change) — this keeps the watcher stateless and avoids stale-cache bugs. + +**API (consistent with JsonlWatcher):** +```typescript +start(options?: { skipExisting?: boolean }): void +stop(): void +pollNow(): void +onNewMessages(cb: (messages: GeminiSessionMessage[]) => void): void +onError(cb: (err: Error) => void): void +``` + +### 4. GeminiTranscriptParser + +Converts Gemini JSON messages to the shared `ParsedMessage` format used by the frontend. + +**Type mapping:** +- `type: "user"` -> `role: "user"`, content normalized to `ContentBlock[]` +- `type: "gemini"` -> `role: "assistant"`, content + toolCalls merged into `ContentBlock[]` +- `type: "error"` -> emitted as `session-error` event (visible to user — rate limits, API key issues, etc.) +- `type: "info"` -> skipped (internal CLI messages like "Press F12 for diagnostics") + +**Tool call conversion:** +Gemini embeds tool calls in the gemini message as `toolCalls[]`. Each tool call has `id`, `name`, `args`, `result`, `status`. These are converted to standard `tool_use` + `tool_result` ContentBlocks to match the Claude adapter's output format. + +**Thinking extraction:** +Gemini includes `thoughts[]` in the JSON. These are emitted as `thinking` events and optionally included in the message content as thinking blocks. + +**Token/model extraction:** +`tokens` and `model` fields are extracted and emitted as `status-update` events, providing context%, model, and cost info without needing a statusLine hook. + +### 5. GeminiJsonStore (`json-store.ts`) + +Session discovery for Gemini's file structure. Maps to `SessionInfo` interface. + +**Discovery algorithm:** +1. Read `~/.gemini/projects.json` to get `{ projects: { "/abs/path": "project-name" } }` +2. For a given `dir` (cwd), find matching project name from the mapping +3. List `~/.gemini/tmp//chats/session-*.json` files +4. For each file: read JSON, extract `sessionId`, `startTime`, `lastUpdated`, `summary`, first user message text, model from latest gemini message +5. Return `SessionInfo[]` sorted by `lastUpdated` descending + +**Key functions:** +- `getSessions(dir?, limit?)` — List sessions for a project (or all projects) +- `getMessages(sessionId, dir?)` — Read and parse a session file, return `ParsedMessage[]` +- `findSessionFile(sessionId)` — Scan all project dirs to locate a session file by UUID +- `getProjectName(dir)` — Look up project name from `projects.json` + +**Project root resolution:** +Each `~/.gemini/tmp//.project_root` file contains the absolute path. Use this to map back from project-name to cwd for display. + +### 6. GeminiAdapter Capabilities + +```typescript +{ + supportsPlanMode: true, // --approval-mode plan + supportsPermissionModes: true, // default, auto_edit, yolo, plan + supportsInterrupt: true, // Ctrl+C in tmux + supportsResume: true, // gemini --resume + supportsAttach: false, // TBD + supportsStatusLine: false, // No statusLine hook (token info from JSON) + supportsImages: true, + supportsStreaming: true, + maxContextWindow: 1000000, // 1M tokens + permissionModeType: 'toggle', // Ctrl+Y toggles YOLO (not cycle like Claude) +} +``` + +**Effort levels:** Gemini CLI does not expose a reasoning effort parameter. `getEffortLevels()` returns `[]`. + +**Permission mode runtime behavior:** +- `auto_edit` and `plan` can only be set at session launch via `--approval-mode` +- At runtime, Ctrl+Y is a binary toggle: `default` <-> `yolo` +- `switchPermissionMode()` for `auto_edit`/`plan` mid-session: not supported, returns `false` +``` + +**Models:** +- `auto` — Dynamic resolution (default) +- `pro` — Gemini 2.5 Pro (complex reasoning) +- `flash` — Gemini 2.5 Flash (fast, balanced) +- `flash-lite` — Gemini 2.5 Flash Lite (fastest) + +**Permission modes:** +- `default` — Prompts for each tool call +- `auto_edit` — Auto-approves file edits +- `yolo` — Auto-approves everything +- `plan` — Read-only (experimental) + +### 7. Session Lifecycle + +**Start:** +``` +gemini --approval-mode -m -i "" +``` +- Session ID discovered from SessionStart hook's `session_id` field +- Uses `_pendingHookBodies` pattern (same as Codex) to handle race condition +- Must emit `'session-rekeyed'` event when temp session key is replaced with real UUID from hook (same as Codex's `session-rekeyed` pattern — SessionManager re-registers WS clients under new ID) + +**Resume:** +``` +gemini --resume +``` + +**Permission mode switch:** +- Ctrl+Y in tmux toggles YOLO on/off +- Only binary toggle (not 4-way cycle like Claude's Shift+Tab) + +**Model switch:** +- `/model ` slash command via tmux sendKeys + +### 8. CLI & Frontend Changes + +**`bin/codetap`:** +- `set_adapter()`: add `gemini` case with `YOLO="--approval-mode yolo"` +- Adapter detection: add `*gemini*` pattern +- ANSI label: `\033[34m[Gemini]\033[0m` (blue) +- `--adapter` validation: add `gemini` case + +**`bin/hooks-cli.mjs`:** +- Import and instantiate `GeminiHookConfig` +- Add to install/uninstall calls + +**`server/adapters/init.ts` + `server/adapters/registry.ts`** (atomic — must land together): +- `init.ts`: Add `gemini` loader in `LOADERS` map +- `registry.ts`: Add `'gemini'` to default `enabledAdapters` list +- If one changes without the other, the adapter either loads but isn't enabled, or is enabled but fails to load + +**`src/lib/adapter-brands.ts`:** +```typescript +gemini: { + id: 'gemini', + displayName: 'Gemini', + provider: 'Google', + color: '#4285f4', + colorBg: '#4285f422', + gradient: 'linear-gradient(135deg, #4285f4, #1a73e8)', + glow: 'rgba(66,133,244,0.3)', + iconType: 'gemini', +} +``` + +**`src/components/AdapterIcon.tsx`:** +- Add `GeminiIcon` component with official Google Gemini SVG from thesvg.org +- Add `'gemini'` case to iconType switch + +### 9. Shared Layer Refactor + +**Move `tmux-manager.ts`:** +- From: `server/adapters/claude/tmux-manager.ts` +- To: `server/adapters/shared/tmux-manager.ts` +- Update imports in: + - `server/adapters/claude/tmux-adapter.ts` + - `server/adapters/claude/pane-monitor.ts` + - `server/adapters/codex/codex-tmux-adapter.ts` + - `server/adapters/gemini/gemini-tmux-adapter.ts` + +No logic changes — pure file move + import path updates. + +## Data Flow + +``` +Gemini CLI (tmux) + | + +-- Hook (stdin JSON) --> bridge.sh --> POST /api/hooks/gemini/ + | | + | GeminiTmuxAdapter.handle{Event}() + | | + | emit('tool-start', 'tool-done', 'session-idle', etc.) + | + +-- Session JSON file (~/.gemini/tmp//chats/session-*.json) + | | + | JsonWatcher detects file change (fs.watch + polling) + | | + | Reads JSON, diffs messages by count + ID + | | + | GeminiTranscriptParser.parse(newMessages) + | | + | emit('new-messages', messages[]) + | emit('status-update', { model, tokens }) + | emit('thinking', { thoughts[] }) + | + +-- tmux pane output (streaming) + | + GeminiPaneMonitor detects changes + | + emit('streaming-text') + +All events --> SessionManager --> WebSocket --> React frontend +``` + +## Testing Strategy + +- Unit tests for `GeminiTranscriptParser` (convert JSON messages to ParsedMessage[]) +- Unit tests for `JsonWatcher` (file size guard, message ID tracking, debounce) +- Unit tests for `GeminiHookConfig` (install/uninstall preserves existing hooks) +- Integration test: start Gemini session via API, verify WebSocket events +- Manual test: full flow on phone (start, send prompt, see streaming, approve tool, resume) diff --git a/docs/superpowers/specs/2026-03-26-pwa-optimization-design.md b/docs/superpowers/specs/2026-03-26-pwa-optimization-design.md new file mode 100644 index 0000000..a95d672 --- /dev/null +++ b/docs/superpowers/specs/2026-03-26-pwa-optimization-design.md @@ -0,0 +1,163 @@ +# PWA Optimization Design Spec + +**Date:** 2026-03-26 +**Goal:** Bring CodeTap's PWA to production-grade quality — proper viewport handling, splash screens, install prompts, SW updates, badge management, draft persistence, and navigation history. + +--- + +## Current State + +CodeTap already has solid PWA foundations: +- Service Worker with Workbox precaching (vite-plugin-pwa, injectManifest) +- Web App Manifest (standalone, portrait, dark theme) +- Push notifications with badge support +- iOS meta tags (capable, black-translucent status bar) +- Offline detection + OfflineView +- overscroll-behavior: none, 16px inputs, h-dvh, safe-bottom + +## What's Missing + +### High Priority + +#### 1. Viewport & Safe Areas +**Problem:** Missing `viewport-fit=cover`. Only bottom safe area handled — notch/Dynamic Island area not accounted for. + +**Solution:** +- Add `viewport-fit=cover` to viewport meta tag in `index.html` +- Add CSS for top safe area: headers get `padding-top: env(safe-area-inset-top)` +- The standalone PWA mode on iOS with `black-translucent` status bar needs the content to extend behind the status bar — `viewport-fit=cover` enables this + +**Files:** `index.html`, `src/index.css` + +#### 2. Splash Screen / Launch Images +**Problem:** White flash on app startup — no branded loading experience. + +**Solution:** +- Add `apple-mobile-web-app-startup-image` meta tags covering major iPhone sizes +- Use `media` attribute with `device-width`, `device-height`, and `device-pixel-ratio` queries +- Background: `#09090b` (matches theme), centered CodeTap mascot/logo +- Generate splash images as data URIs or static PNGs in `/public/splash/` +- Minimum coverage: iPhone SE, iPhone 14/15, iPhone 14/15 Pro Max, iPhone 16 Pro Max + +**Files:** `index.html`, `public/splash/` (new directory) + +#### 3. Android Install Prompt +**Problem:** No handling of `beforeinstallprompt` event — Android users never see install prompt. + +**Solution:** +- Listen for `beforeinstallprompt` in App.tsx, store the event in state +- Show a dismissible install banner in SessionsView (below header) +- Banner text: "Install CodeTap for a better experience" with Install/Dismiss buttons +- On Install click: call `event.prompt()`, hide banner +- On Dismiss: hide banner, store dismissal in `localStorage` so it doesn't reappear +- After successful install (`appinstalled` event): hide banner permanently + +**Files:** `src/App.tsx`, `src/components/SessionsView.tsx` + +#### 4. Service Worker Update Notification +**Problem:** SW updates silently — user doesn't know a new version is available. + +**Solution:** +- Listen for `controllerchange` on `navigator.serviceWorker` in App.tsx +- When detected, show a toast at bottom: "New version available" with Refresh button +- On click: `window.location.reload()` +- Toast auto-dismisses after 10s but can be manually dismissed + +**Files:** `src/App.tsx` + +### Medium Priority + +#### 5. Badge Clear on Focus +**Problem:** App badge persists even when user is actively looking at the app. + +**Solution:** +- In App.tsx, listen for `visibilitychange` event +- When `document.visibilityState === 'visible'`: call `navigator.clearAppBadge()` (with feature check) +- This ensures badge is cleared whenever user switches back to the app + +**Files:** `src/App.tsx` + +#### 6. Manifest Shortcuts +**Problem:** No quick actions from home screen long-press. + +**Solution:** +- Add `shortcuts` array to manifest in vite.config.ts: + - "New Chat" — url: `/?action=newchat`, icon: `chat-bubble-icon` +- In App.tsx, check for `?action=newchat` param and navigate accordingly + +**Files:** `vite.config.ts`, `src/App.tsx` + +#### 7. Input Draft Auto-Save +**Problem:** Typed text lost if app is backgrounded or crashes. + +**Solution:** +- In ShimmerInput: on every input change, debounce-save to `localStorage` with key `codetap:draft:{sessionId}` +- On mount: restore draft from localStorage if present +- On successful send or explicit clear: delete the draft +- Debounce: 500ms to avoid excessive writes + +**Files:** `src/components/ShimmerInput.tsx` + +#### 8. Manifest Screenshots +**Problem:** Missing screenshots for app stores and install prompts. + +**Solution:** +- Add `screenshots` array to manifest in vite.config.ts +- Provide at minimum: + - 1 narrow screenshot (phone, 1080x1920) — chat view + - 1 wide screenshot (tablet/desktop, 1920x1080) — sessions view +- Store in `public/screenshots/` + +**Files:** `vite.config.ts`, `public/screenshots/` (new directory) + +### Low Priority + +#### 9. Slow Network Detection +**Problem:** No feedback when on slow connection. + +**Solution:** +- Check `navigator.connection?.effectiveType` (with feature detection) +- When `2g` or `slow-2g`: show a subtle indicator in StatusBar ("Slow connection") +- Re-check on `change` event of `navigator.connection` + +**Files:** `src/components/StatusBar.tsx` + +#### 10. History API Navigation +**Problem:** Browser back gesture doesn't work — app uses `sessionStorage` for view state, no history stack. + +**Solution:** +- In App.tsx, use `history.pushState()` when navigating between views +- Listen for `popstate` event to handle back navigation +- Map each view to a history entry: `sessions`, `chat/{sessionId}`, `settings`, `newchat/{cwd}` +- This enables iOS swipe-back gesture and Android back button in standalone mode + +**Files:** `src/App.tsx` + +#### 11. OpenGraph Meta Tags +**Problem:** No social sharing metadata. + +**Solution:** +- Add to `index.html`: + - `og:title`: "CodeTap" + - `og:description`: "Use Claude Code from your phone" + - `og:image`: link to a social card image + - `og:type`: "website" + - `twitter:card`: "summary" + +**Files:** `index.html` + +--- + +## Design Principles + +1. **Progressive enhancement** — All PWA features use feature detection. App works fine without them. +2. **No new dependencies** — Everything is native Web APIs or existing vite-plugin-pwa config. +3. **Minimal UI additions** — Install banner and SW update toast are the only new UI elements. +4. **Respect user choice** — Install prompt is dismissible and remembers dismissal. + +## Out of Scope + +- Full offline-first with background sync (current offline detection is sufficient) +- Push notification permission prompt UI (current flow works) +- Image compression before upload +- Orientation lock via Screen Orientation API diff --git a/docs/superpowers/specs/2026-03-26-send-to-menu-settings-design.md b/docs/superpowers/specs/2026-03-26-send-to-menu-settings-design.md new file mode 100644 index 0000000..3595cfd --- /dev/null +++ b/docs/superpowers/specs/2026-03-26-send-to-menu-settings-design.md @@ -0,0 +1,174 @@ +# Send-to Menu Redesign + Settings Page + +## Overview + +Redesign the "Send to Other AI" menu for cross-AI review, add a Settings page for managing preferences and saved instructions. + +Three deliverables: +1. **Send-to Menu** — Two-step bottom sheet with adapter selection, model picker, Direct Send / With Instructions +2. **Settings Page** — Centralized preferences: saved instructions, per-adapter defaults, notifications, about +3. **Saved Instructions DB** — Server-side storage for reusable instruction templates + +## Part 1: Send-to Menu + +### Layout: Two-Step Bottom Sheet + +**Step 1 — Adapter Selection:** +- Bottom sheet titled "Send to…" +- Lists all available adapters (excluding current) +- Each row: adapter icon (official SVG from AdapterIcon.tsx) + adapter name +- No model shown here (model is selected in step 2) +- Tap row → navigates to step 2 + +**Step 2 — Action Selection:** +- Header: `‹ {AdapterName}` (back arrow + colored adapter name) +- Model dropdown: `Model: [gpt-5.4 ▾]` — uses native `
+
+
+
100%
+
+
+ +
+ + + + \ No newline at end of file diff --git a/playground-multi-review.html b/playground-multi-review.html new file mode 100644 index 0000000..eb3b93f --- /dev/null +++ b/playground-multi-review.html @@ -0,0 +1,351 @@ + + + + + +Multi-Review Panel — Design Playground + + + + +
+
+ + + + diff --git a/playground-svg-compare.html b/playground-svg-compare.html new file mode 100644 index 0000000..66c4783 --- /dev/null +++ b/playground-svg-compare.html @@ -0,0 +1,142 @@ + + + + +SVG vs ASCII — Pixel Comparison + + + + + + + +
+
ASCII (reference)
+
▐▌    ▐▌
+▐█    █▌
+▐██▄▄██▌
+ ▀████▀
+   ██
+
+ +
+
+
SVG (should match ↑)
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
ASCII (same size)
+
▐▌    ▐▌
+▐█    █▌
+▐██▄▄██▌
+ ▀████▀
+   ██
+
+
+ +
+
README title preview
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + ClawTap +
+
+ + + diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..3a1ac42 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/claw-logo.svg b/public/claw-logo.svg new file mode 100644 index 0000000..720fc61 --- /dev/null +++ b/public/claw-logo.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/mascot/cat-idle.png b/public/mascot/cat-idle.png new file mode 100644 index 0000000..3ce0b1d Binary files /dev/null and b/public/mascot/cat-idle.png differ diff --git a/public/mascot/cat-sleep.png b/public/mascot/cat-sleep.png new file mode 100644 index 0000000..8af5798 Binary files /dev/null and b/public/mascot/cat-sleep.png differ diff --git a/public/pwa-192x192.png b/public/pwa-192x192.png new file mode 100644 index 0000000..71ded0e Binary files /dev/null and b/public/pwa-192x192.png differ diff --git a/public/pwa-512x512.png b/public/pwa-512x512.png new file mode 100644 index 0000000..ed711ab Binary files /dev/null and b/public/pwa-512x512.png differ diff --git a/server/adapters/claude/hook-config.ts b/server/adapters/claude/hook-config.ts new file mode 100644 index 0000000..2be4c54 --- /dev/null +++ b/server/adapters/claude/hook-config.ts @@ -0,0 +1,216 @@ +// server/adapters/claude/hook-config.ts +// +// Pure filesystem operations for Claude hook management. +// Zero runtime dependencies — no EventEmitter, no tmux, no sessions. + +import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +/** Individual hook action (command or url based) */ +interface HookAction { + type?: string; + command?: string; + url?: string; + timeout?: number; +} + +/** A hook entry within a hook event */ +interface HookEntry { + matcher?: string; + hooks: HookAction[]; +} + +/** Hook identifiers for matching our entries */ +interface HookIdentifiers { + portTag: string; +} + +/** The structure of Claude's settings.json (partial) */ +interface ClaudeSettings { + hooks?: Record; + statusLine?: { type: string; command: string }; + _clawtapOriginalStatusLine?: string; + [key: string]: unknown; +} + +export class ClaudeHookConfig { + /** Shared between install() wrapper construction and _extractOriginalFromWrapper() */ + private static readonly WRAPPER_TAIL = `fi; printf '%s' "$input" | `; + + port: number | string; + useHttps: boolean; + + constructor(port?: number | string, useHttps?: boolean) { + this.port = port || process.env.PORT || 3456; + if (useHttps !== undefined) { + this.useHttps = useHttps; + } else { + // Auto-detect from cert files + const clawtapDir = join(homedir(), '.clawtap'); + this.useHttps = existsSync(join(clawtapDir, 'cert.pem')) && existsSync(join(clawtapDir, 'key.pem')); + } + } + + /** Install ClawTap hooks into ~/.claude/settings.json */ + install(): void { + const port = this.port; + const settingsDir = join(homedir(), '.claude'); + const settingsPath = join(settingsDir, 'settings.json'); + + const { portTag } = this._hookIdentifiers(); + const protocol = this.useHttps ? 'https' : 'http'; + const hookUrl = `${protocol}://localhost:${port}/api/hooks/claude`; + const desiredHooks = this._buildDesiredHooks(hookUrl); + const statuslineUrl = `${hookUrl}/statusline`; + + try { + mkdirSync(settingsDir, { recursive: true }); + let existing: ClaudeSettings = {}; + try { existing = JSON.parse(readFileSync(settingsPath, 'utf-8')) as ClaudeSettings; } catch {} + + // Replace our hooks on every startup (handles HTTP → command upgrade). + // Preserves other tools' hooks by filtering only ClawTap entries. + if (!existing.hooks) existing.hooks = {}; + + for (const [event, configs] of Object.entries(desiredHooks)) { + const existingEntries = existing.hooks[event] || []; + const filtered = existingEntries.filter(entry => !this._isOurHookEntry(entry, portTag)); + existing.hooks[event] = [...filtered, ...configs]; + } + + // Wrap statusLine to also POST to our server (non-blocking). + // - Has custom statusLine → wrap it (POST + original coexist) + // - No custom statusLine → don't touch it, preserve Claude Code built-in + const existingCmd = existing.statusLine?.command || ''; + if (existingCmd && !existingCmd.includes(`:${port}/api/hooks/claude/statusline`)) { + existing._clawtapOriginalStatusLine = existingCmd; + const portCheck = this._portCheckCmd(); + const curlK = this.useHttps ? ' -k' : ''; + const wrapperCmd = `input=$(cat); if ${portCheck}; then printf '%s' "$input" | curl -sf${curlK} -X POST -H 'Content-Type:application/json' -d @- ${statuslineUrl} &>/dev/null & ${ClaudeHookConfig.WRAPPER_TAIL}${existingCmd}`; + existing.statusLine = { type: 'command', command: wrapperCmd }; + console.log(`[hooks] Wrapped statusLine to POST to ${statuslineUrl}`); + } + + writeFileSync(settingsPath, JSON.stringify(existing, null, 2)); + console.log(`[hooks] Auto-configured HTTP hooks in ${settingsPath}`); + } catch (err) { + console.warn(`[hooks] Failed to auto-configure hooks: ${(err as Error).message}`); + } + } + + /** + * Remove ClawTap hooks from ~/.claude/settings.json. + * Leaves other user settings intact. Only removes hooks owned by this port. + */ + uninstall(): void { + const { portTag } = this._hookIdentifiers(); + const settingsPath = join(homedir(), '.claude', 'settings.json'); + + try { + const existing: ClaudeSettings = JSON.parse(readFileSync(settingsPath, 'utf-8')) as ClaudeSettings; + + // --- Clean up hooks (independent of statusLine) --- + if (existing.hooks) { + const hookKeys = Object.keys(this._buildDesiredHooks('')); + for (const key of hookKeys) { + const entries = existing.hooks[key]; + if (!Array.isArray(entries)) continue; + + const filtered = entries.filter(entry => !this._isOurHookEntry(entry, portTag)); + + if (filtered.length === 0) { + delete existing.hooks[key]; + } else { + existing.hooks[key] = filtered; + } + } + + if (Object.keys(existing.hooks).length === 0) delete existing.hooks; + } + + // --- Restore statusLine (independent of hooks) --- + // Restore original statusLine: try extraction from wrapper first (most reliable), + // then fall back to backup field, then delete only if truly no original existed. + if (existing.statusLine?.command?.includes(portTag)) { + const original = this._extractOriginalFromWrapper(existing.statusLine.command); + if (original) { + existing.statusLine = { type: 'command', command: original }; + } else if (existing._clawtapOriginalStatusLine) { + existing.statusLine = { type: 'command', command: existing._clawtapOriginalStatusLine }; + } else { + delete existing.statusLine; + } + } + delete existing._clawtapOriginalStatusLine; + + writeFileSync(settingsPath, JSON.stringify(existing, null, 2)); + console.log(`[hooks] Removed ClawTap hooks from ${settingsPath}`); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return; + console.warn(`[hooks] Failed to remove hooks: ${(err as Error).message}`); + } + } + + // --- Internal helpers --- + + private _hookIdentifiers(): HookIdentifiers { + return { + portTag: `:${this.port}/api/hooks/claude`, + }; + } + + /** Extract the original statusLine command from our wrapper using WRAPPER_TAIL. */ + private _extractOriginalFromWrapper(cmd: string): string | null { + const tail = ClaudeHookConfig.WRAPPER_TAIL; + const idx = cmd.lastIndexOf(tail); + if (idx < 0) return null; + const original = cmd.substring(idx + tail.length).trim(); + if (!original || original.includes('/api/hooks/claude')) return null; + return original; + } + + private _isOurHookEntry(entry: HookEntry, portTag: string): boolean { + const hooks = entry.hooks || []; + return hooks.some(h => + (h.url && h.url.includes(portTag)) || + (h.command && h.command.includes(portTag)) + ); + } + + private _buildDesiredHooks(hookUrl: string): Record { + // Fire-and-forget: read stdin, background curl, exit immediately. + // Zero blocking — Claude Code never waits for ClawTap. + // /dev/tcp check: fails instantly (<1ms) if server isn't listening, avoiding 2s curl timeout + // --connect-timeout 2: give up if server unreachable + // --max-time 5: give up if server hangs after accepting connection + const portCheck = this._portCheckCmd(); + const curlInsecure = this.useHttps ? ' -k' : ''; + const fireAndForget = (endpoint: string): string => + `${portCheck} || exit 0; input=$(cat); printf '%s' "$input" | curl -sf${curlInsecure} --connect-timeout 2 --max-time 5 -X POST -H 'Content-Type:application/json' -d @- ${hookUrl}/${endpoint} &>/dev/null &`; + return { + SessionStart: [{ hooks: [{ type: 'command', command: fireAndForget('session-start'), timeout: 2 }] }], + UserPromptSubmit: [{ hooks: [{ type: 'command', command: fireAndForget('user-prompt-submit'), timeout: 2 }] }], + PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: fireAndForget('pre-tool-use'), timeout: 2 }] }], + PostToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: fireAndForget('post-tool-use'), timeout: 2 }] }], + PostToolUseFailure: [{ matcher: '*', hooks: [{ type: 'command', command: fireAndForget('post-tool-use-failure'), timeout: 2 }] }], + Stop: [{ hooks: [{ type: 'command', command: fireAndForget('stop'), timeout: 2 }] }], + StopFailure: [{ hooks: [{ type: 'command', command: fireAndForget('stop-failure'), timeout: 2 }] }], + SubagentStop: [{ hooks: [{ type: 'command', command: fireAndForget('stop'), timeout: 2 }] }], + PermissionRequest: [{ matcher: '*', hooks: [{ type: 'command', command: fireAndForget('permission-request'), timeout: 2 }] }], + SessionEnd: [{ hooks: [{ type: 'command', command: fireAndForget('session-end'), timeout: 2 }] }], + PreCompact: [ + { matcher: 'auto', hooks: [{ type: 'command', command: fireAndForget('pre-compact'), timeout: 2 }] }, + { matcher: 'manual', hooks: [{ type: 'command', command: fireAndForget('pre-compact'), timeout: 2 }] }, + ], + PostCompact: [ + { matcher: 'auto', hooks: [{ type: 'command', command: fireAndForget('post-compact'), timeout: 2 }] }, + { matcher: 'manual', hooks: [{ type: 'command', command: fireAndForget('post-compact'), timeout: 2 }] }, + ], + }; + } + + private _portCheckCmd(): string { + return `(echo >/dev/tcp/localhost/${this.port}) 2>/dev/null`; + } +} diff --git a/server/adapters/claude/index.ts b/server/adapters/claude/index.ts new file mode 100644 index 0000000..c12f277 --- /dev/null +++ b/server/adapters/claude/index.ts @@ -0,0 +1,240 @@ +// server/adapters/claude/index.ts +import { IAdapter } from '../interface.js'; +import type { DirectoryEntry, ActiveSessionInfo, MessagesResult, CachedStatus } from '../interface.js'; +import { TmuxAdapter } from './tmux-adapter.js'; +import type { SessionState, HookBody } from './tmux-adapter.js'; +import { ClaudeHookConfig } from './hook-config.js'; +import { + getSessions, getMessages, listDirectory, +} from './jsonl-store.js'; +import type { SessionHeaderResult, GetMessagesResult } from './jsonl-store.js'; +import type { QueryOptions, PermissionBehavior } from '../../types/messages.js'; +import type { AdapterCapabilities, ModelInfo, PermissionModeInfo, EffortLevelInfo, ReconnectState, SessionInfo } from '../../types/adapter.js'; +import type { Express } from 'express'; + +/** Statusline body from Claude CLI */ +interface StatusLineBody { + session_id?: string; + permission_mode?: string; + context_window?: { used_percentage?: number }; + model?: { display_name?: string }; + cost?: { total_cost_usd?: number }; + [key: string]: unknown; +} + + + +const MODELS: ModelInfo[] = [ + { value: 'sonnet', label: 'Sonnet', contextWindow: 200000 }, + { value: 'opus', label: 'Opus', contextWindow: 200000 }, + { value: 'haiku', label: 'Haiku', contextWindow: 200000 }, + { value: 'opus[1m]', label: 'Opus 1M', contextWindow: 1000000 }, + { value: 'sonnet[1m]', label: 'Sonnet 1M', contextWindow: 1000000 }, +]; + +const PERMISSION_MODES: PermissionModeInfo[] = [ + { value: 'default', label: 'Normal' }, + { value: 'acceptEdits', label: 'Auto-edit' }, + { value: 'plan', label: 'Plan' }, + { value: 'bypassPermissions', label: 'YOLO' }, +]; + +const EFFORT_LEVELS: EffortLevelInfo[] = [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'max', label: 'Max' }, +]; + +export class ClaudeAdapter extends IAdapter { + static id: string = 'claude'; + static displayName: string = 'Claude Code'; + static command: string = 'claude'; + + private _tmux: TmuxAdapter; + private _hookConfig: ClaudeHookConfig; + private _lastStatus: Map; // sessionId → { contextPercent, model, cost } + + constructor() { + super(); + this._tmux = new TmuxAdapter(); + this._hookConfig = new ClaudeHookConfig(); + this._lastStatus = new Map(); + // Forward all events from internal tmux adapter + const events: string[] = [ + 'streaming-text', 'thinking', 'tool-start', 'tool-done', + 'tool-updates', 'new-messages', 'session-idle', + 'permission-request', 'ask-question', 'mode-changed', + 'session-ended', 'session-error', 'compacting', 'compact-done', + 'processing-started', + ]; + for (const event of events) { + this._tmux.on(event, (...args: unknown[]) => this.emit(event, ...args)); + } + + // Clean up statusline dedup cache when session ends + this._tmux.on('session-ended', (sessionId: string) => { + this._lastStatus.delete(sessionId); + }); + } + + setup(app: Express): void { + this.installHooks(); + this._registerHookRoutes(app); + } + + installHooks(): void { this._hookConfig.install(); } + uninstallHooks(): void { this._hookConfig.uninstall(); } + + async cleanup(): Promise { + this.uninstallHooks(); + await this._tmux.destroy(); + } + + /** + * Register Express routes for Claude-specific hooks. + * These are called by the Claude CLI from localhost (no auth needed). + */ + private _registerHookRoutes(app: Express): void { + // All hooks are fire-and-forget notifications — no return value used. + // Handlers are called for side effects only (emit events, update state). + const hookRoute = (path: string, handler: (body: HookBody) => void | Promise): void => { + const label = path.split('/').pop(); + app.post(path, (req: any, res: any) => { + const sid = req.body.session_id?.substring(0, 8) || '?'; + const toolInfo = req.body.tool_name ? ` ${req.body.tool_name}` : ''; + console.log(`[hook] ${label}:${toolInfo} sid=${sid}`); + try { + const result = handler(req.body); + if (result instanceof Promise) result.catch((e: Error) => console.error(`[hook] ${label} error:`, e.message)); + } catch (e) { console.error(`[hook] ${label} error:`, (e as Error).message); } + res.json({}); + }); + }; + + const prefix = this.getHookPrefix(); // /api/hooks/claude + + hookRoute(`${prefix}/pre-tool-use`, (body) => { + this._tmux.handlePreToolUse(body); + }); + hookRoute(`${prefix}/post-tool-use`, (body) => { + this._tmux.handlePostToolUse(body); + }); + hookRoute(`${prefix}/stop`, (body) => { + this._tmux.handleStop(body); + }); + hookRoute(`${prefix}/permission-request`, (body) => { + this._tmux.handlePermissionRequest(body); + }); + hookRoute(`${prefix}/user-prompt-submit`, (body) => { + this._tmux.handleUserPromptSubmit(body); + }); + hookRoute(`${prefix}/session-end`, (body) => { + this._tmux.handleSessionEnd(body); + }); + hookRoute(`${prefix}/post-tool-use-failure`, (body) => { + this._tmux.handlePostToolUseFailure(body); + }); + hookRoute(`${prefix}/stop-failure`, (body) => { + this._tmux.handleStopFailure(body); + }); + hookRoute(`${prefix}/pre-compact`, (body) => { + this._tmux.handlePreCompact(body); + }); + hookRoute(`${prefix}/post-compact`, (body) => { + this._tmux.handlePostCompact(body); + }); + hookRoute(`${prefix}/session-start`, (body) => { + this._tmux.handleSessionStart(body); + }); + hookRoute(`${prefix}/statusline`, (body) => { + this._handleStatusLine(body as StatusLineBody); + }); + } + + /** + * Handle statusline hook — extract metrics, sync permission mode, + * deduplicate, and emit 'status-update' event. + */ + private _handleStatusLine(body: StatusLineBody): void { + const sessionId = body.session_id; + if (!sessionId || !this._tmux.getSession(sessionId)) return; + + // Sync permission mode from statusline — catches desktop Shift+Tab changes + // that don't trigger other hooks (PreToolUse, Stop, etc.) + this._tmux.syncPermissionMode(sessionId, body); + + const contextPercent = body.context_window?.used_percentage ?? null; + const model = body.model?.display_name ?? null; + const cost = body.cost?.total_cost_usd ?? null; + + // Deduplicate — skip if nothing changed + const prev = this._lastStatus.get(sessionId); + if (prev && + prev.contextPercent === contextPercent && + prev.model === model && + prev.cost === cost) return; + + const status: CachedStatus = { contextPercent, model, cost }; + this._lastStatus.set(sessionId, status); + this.emit('status-update', sessionId, status); + } + + setClientChecker(fn: (sessionId: string) => boolean): void { + this._tmux.setClientChecker(fn); + } + + // Lifecycle — delegate to tmux adapter + async startSession(cwd: string, options?: QueryOptions): Promise<{ sessionId: string }> { return this._tmux.startSession(cwd, options); } + async resumeSession(sid: string, cwd: string, options?: QueryOptions): Promise<{ sessionId: string }> { return this._tmux.resumeSession(sid, cwd, options); } + async attachSession(sid: string, cwd: string, options?: QueryOptions): Promise<{ sessionId: string }> { return this._tmux.attachSession(sid, cwd, options); } + async destroySession(sid: string): Promise { return this._tmux.destroySession(sid); } + async sendMessage(sid: string, text: string, options?: QueryOptions): Promise { return this._tmux.sendMessage(sid, text, options); } + async respondPlan(sid: string, optionIndex: number, text?: string): Promise { return this._tmux.respondPlan(sid, optionIndex, text); } + async switchModel(sid: string, model: string): Promise { return this._tmux.switchModel(sid, model); } + async interrupt(sid: string): Promise { return this._tmux.interrupt(sid); } + flushMessages(sid: string): void { this._tmux.flushMessages(sid); } + syncWatcherPosition(sid: string): void { this._tmux.syncWatcherPosition(sid); } + getReconnectState(sid: string): ReconnectState { return this._tmux.getReconnectState(sid); } + + // Store — delegate to jsonl-store + async getSessions(dir?: string, limit?: number): Promise { return getSessions(dir, limit); } + async getMessages(sid: string, dir?: string): Promise { return getMessages(sid, dir); } + async listDirectory(path?: string): Promise { return listDirectory(path); } + + // Permissions — delegate to tmux adapter + async switchPermissionMode(sid: string, mode: string): Promise { return this._tmux.switchPermissionMode(sid, mode); } + respondPermission(reqId: string, behavior: PermissionBehavior): void { this._tmux.respondPermission(reqId, behavior); } + async respondQuestion(reqId: string, answer: string): Promise { return this._tmux.respondQuestion(reqId, answer); } + releaseAllPending(sid: string): void { this._tmux.releaseAllPending(sid); } + resolveAllPendingAs(sid: string, behavior: PermissionBehavior): void { this._tmux.resolveAllPendingAs(sid, behavior); } + + // Query + isProcessing(sid: string): boolean { return this._tmux.isProcessing(sid); } + getSession(sid: string): SessionState | undefined { return this._tmux.getSession(sid); } + getLastStatus(sid: string) { return this._lastStatus.get(sid) || null; } + async hasActiveWindow(sid: string): Promise { return this._tmux.hasActiveWindow(sid); } + getActiveSessions(): ActiveSessionInfo[] { return this._tmux.getActiveSessions(); } + + // Capabilities + getModels(): ModelInfo[] { return MODELS; } + getPermissionModes(): PermissionModeInfo[] { return PERMISSION_MODES; } + getEffortLevels(): EffortLevelInfo[] { return EFFORT_LEVELS; } + getEffortLabel(): string { return 'Thinking'; } + + getCapabilities(): AdapterCapabilities { + return { + supportsPlanMode: true, + supportsPermissionModes: true, + supportsInterrupt: true, + supportsResume: true, + supportsAttach: true, + supportsStatusLine: true, + supportsImages: true, + supportsStreaming: true, + maxContextWindow: 1_000_000, + permissionModeType: 'cycle', + }; + } + +} diff --git a/server/adapters/claude/jsonl-store.ts b/server/adapters/claude/jsonl-store.ts new file mode 100644 index 0000000..e1119f2 --- /dev/null +++ b/server/adapters/claude/jsonl-store.ts @@ -0,0 +1,226 @@ +import { readdir, stat } from 'fs/promises'; +import { join } from 'path'; +import { homedir } from 'os'; +import { createReadStream } from 'fs'; +import { createInterface } from 'readline'; +import { extractText, isSystemMessage, extractPlanContent, isNoResponseMessage, extractSubTools } from './message-utils.js'; +import type { JsonlEntry, ContentBlock, SubToolBlock } from './message-utils.js'; +import type { DirectoryEntry } from '../interface.js'; + +// --- Constants --- +export const PROJECTS_DIR: string = join(homedir(), '.claude', 'projects'); + +// --- Helpers --- + +interface SessionDirEntry { + path: string; + cwd: string | null; +} + +interface SessionFileInfo { + filePath: string; + sessionId: string; + cwd: string | null; + mtime: Date; +} + +export interface SessionHeaderResult { + sessionId: string; + cwd: string | null; + lastModified: string; + firstPrompt: string | null; + model: string | null; + version: string | null; +} + +export interface GetMessagesResult { + messages: unknown[]; + lastModified: string | null; +} + +export function encodeDirName(dir: string): string { + return dir.replace(/[\/ .]/g, '-'); +} + +export async function getSessionDirs(dir?: string): Promise { + if (dir) { + const encoded = encodeDirName(dir); + return [{ path: join(PROJECTS_DIR, encoded), cwd: dir }]; + } + try { + const entries = await readdir(PROJECTS_DIR, { withFileTypes: true }); + return entries + .filter((e) => e.isDirectory()) + .map((e) => ({ path: join(PROJECTS_DIR, e.name), cwd: null })); + } catch { + return []; + } +} + +// --- Cross-device continuity --- + +// --- Session Listing (file-based) --- + +export async function parseSessionHeader( + filePath: string, + sessionId: string, + { cwd, mtime }: { cwd?: string | null; mtime?: Date } = {} +): Promise { + const fileMtime = mtime || (await stat(filePath)).mtime; + const stream = createReadStream(filePath); + const rl = createInterface({ input: stream, crlfDelay: Infinity }); + let sessionCwd: string | null = null; + let firstPrompt: string | null = null; + let sessionModel: string | null = null; + let sessionVersion: string | null = null; + try { + for await (const line of rl) { + if (!line.trim()) continue; + try { + const entry: JsonlEntry = JSON.parse(line); + if (!sessionCwd && entry.cwd) sessionCwd = entry.cwd as string; + if (!sessionModel && entry.model) sessionModel = entry.model as string; + if (!sessionVersion && entry.version) sessionVersion = entry.version as string; + if (!firstPrompt && entry.type === 'user' && entry.message?.content) { + firstPrompt = extractText(entry.message.content); + } + if (sessionCwd && firstPrompt) break; + } catch {} + } + } finally { + rl.close(); + stream.destroy(); + } + return { + sessionId, + cwd: sessionCwd || cwd || null, + lastModified: fileMtime.toISOString(), + firstPrompt: firstPrompt ? firstPrompt.slice(0, 200) : null, + model: sessionModel, + version: sessionVersion, + }; +} + +export async function getSessions(dir?: string, limit?: number): Promise { + const sessionDirs = await getSessionDirs(dir); + const allFiles: SessionFileInfo[] = []; + + for (const { path: dirPath, cwd } of sessionDirs) { + let files: string[]; + try { + files = await readdir(dirPath); + } catch { + continue; + } + const jsonlFiles = files.filter(f => f.endsWith('.jsonl')); + const statResults = await Promise.all( + jsonlFiles.map(async (file): Promise => { + const filePath = join(dirPath, file); + const s = await stat(filePath).catch(() => null); + return s ? { filePath, sessionId: file.replace('.jsonl', ''), cwd, mtime: s.mtime } : null; + }) + ); + allFiles.push(...statResults.filter((r): r is SessionFileInfo => r !== null)); + } + + // Sort by mtime first (cheap), then only parse top N headers + allFiles.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); + const toParse = limit ? allFiles.slice(0, limit) : allFiles; + const sessions = await Promise.all( + toParse.map(f => parseSessionHeader(f.filePath, f.sessionId, { cwd: f.cwd, mtime: f.mtime }).catch(() => null)) + ); + return sessions.filter((s): s is SessionHeaderResult => s !== null); +} + +export async function getMessages(sessionId: string, dir?: string): Promise { + const sessionDirs = await getSessionDirs(dir); + for (const { path: dirPath } of sessionDirs) { + const filePath = join(dirPath, `${sessionId}.jsonl`); + try { + const messages: unknown[] = []; + const subToolMap: Map = new Map(); // parentToolUseId → sub-tool blocks + const stream = createReadStream(filePath); + const rl = createInterface({ input: stream, crlfDelay: Infinity }); + try { + for await (const line of rl) { + if (!line.trim()) continue; + try { + const entry: JsonlEntry = JSON.parse(line); + // Track agent sub-tools from progress entries (for SubagentGroup display) + if (entry.type === 'progress' && entry.data?.type === 'agent_progress') { + const result = extractSubTools(entry); + if (result) { + if (!subToolMap.has(result.parentId)) subToolMap.set(result.parentId, []); + subToolMap.get(result.parentId)!.push(...result.subTools); + } + continue; + } + if (!entry.message) continue; + const content = entry.message.content; + const text = extractText(content); + + if (entry.type === 'assistant') { + if (isNoResponseMessage(text)) continue; + messages.push(entry.message); + } else if (entry.type === 'user') { + // Skip messages containing tool results (not needed for display) + if (Array.isArray(content) && content.some((b: ContentBlock) => b.type === 'tool_result')) continue; + // Skip system/CLI messages (empty text, system patterns) + if (isSystemMessage(text, content)) continue; + // Convert "Implement the following plan:" messages to plan type + const planBody = extractPlanContent(text); + if (planBody !== null) { + messages.push({ role: 'plan', content: planBody }); + continue; + } + messages.push(entry.message); + } + } catch {} + } + } finally { + rl.close(); + stream.destroy(); + } + // Inject accumulated sub-tool blocks into their parent Agent messages + for (const msg of messages) { + const m = msg as { content?: ContentBlock[] }; + if (!Array.isArray(m.content)) continue; + for (const block of m.content) { + if (block.type !== 'tool_use') continue; + const subTools = subToolMap.get(block.id!); + if (subTools && subTools.length > 0) { + m.content.push(...(subTools as unknown as ContentBlock[])); + subToolMap.delete(block.id!); + } + } + } + const fileMtime = await stat(filePath); + return { messages, lastModified: fileMtime.mtime.toISOString() }; + } catch { + continue; + } + } + return { messages: [], lastModified: null }; +} + +// --- Directory Browser --- + +export async function listDirectory(dirPath?: string): Promise { + const target = dirPath || homedir(); + const entries = await readdir(target, { withFileTypes: true }); + const visible = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.')); + + const dirs = await Promise.all( + visible.map(async (entry): Promise => { + const fullPath = join(target, entry.name); + let hasChildren = false; + try { + const children = await readdir(fullPath, { withFileTypes: true }); + hasChildren = children.some((c) => c.isDirectory() && !c.name.startsWith('.')); + } catch {} + return { name: entry.name, path: fullPath, hasChildren }; + }) + ); + + return dirs.sort((a, b) => a.name.localeCompare(b.name)); +} diff --git a/server/adapters/claude/message-utils.ts b/server/adapters/claude/message-utils.ts new file mode 100644 index 0000000..72cf815 --- /dev/null +++ b/server/adapters/claude/message-utils.ts @@ -0,0 +1,105 @@ +/** A content block within a Claude message */ +export interface ContentBlock { + type: string; + text?: string; + id?: string; + name?: string; + input?: Record; + tool_use_id?: string; + content?: string; + is_error?: boolean; + [key: string]: unknown; +} + +/** A sub-tool block extracted from agent_progress entries */ +export interface SubToolBlock { + type: 'tool_use'; + id: string; + name: string; + input: Record; + parent_tool_use_id: string; +} + +/** Result of extractSubTools */ +export interface SubToolsResult { + parentId: string; + subTools: SubToolBlock[]; +} + +// TODO: type properly — JSONL entries have various shapes +export interface JsonlEntry { + type?: string; + message?: { + role?: string; + content?: string | ContentBlock[]; + [key: string]: unknown; + }; + content?: string | ContentBlock[]; + data?: { + type?: string; + message?: { + message?: { + role?: string; + content?: ContentBlock[]; + [key: string]: unknown; + }; + [key: string]: unknown; + }; + [key: string]: unknown; + }; + parentToolUseID?: string; + cwd?: string; + model?: string; + version?: string; + [key: string]: unknown; +} + +const PLAN_PREFIX = /^Implement the following plan:\s*/i; + +export const SYSTEM_PATTERNS: RegExp[] = [ + /^(Base directory for this skill:|Continue from where you left off)/i, + /<(command-message|command-name|command-args|local-command|task-notification|system-reminder)/i, +]; + +export function extractText(content: string | ContentBlock[] | unknown): string { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .filter((b: ContentBlock) => b.type === 'text') + .map((b: ContentBlock) => b.text) + .join('\n'); + } + return ''; +} + +export function isSystemMessage(text: string, content: string | ContentBlock[] | unknown): boolean { + if (!text.trim()) return true; + if (Array.isArray(content) && content.every((b: ContentBlock) => b.type === 'tool_result')) return true; + for (const pattern of SYSTEM_PATTERNS) { + if (pattern.test(text)) return true; + } + return false; +} + +/** Returns plan body text if this is a plan message, null otherwise. */ +export function extractPlanContent(text: string): string | null { + return PLAN_PREFIX.test(text) ? text.replace(PLAN_PREFIX, '') : null; +} + +export function isNoResponseMessage(text: string): boolean { + return /^No response requested/i.test(text.trim()); +} + +/** Extract sub-tool blocks from an agent_progress JSONL entry. */ +export function extractSubTools(progressEntry: JsonlEntry): SubToolsResult | null { + const parentId = progressEntry.parentToolUseID; + const msg = progressEntry.data?.message?.message; + if (!parentId || !msg || msg.role !== 'assistant' || !Array.isArray(msg.content)) return null; + const subTools: SubToolBlock[] = []; + for (const block of msg.content) { + if (block.type === 'tool_use') { + subTools.push({ type: 'tool_use', id: block.id!, name: block.name!, input: block.input as Record, parent_tool_use_id: parentId }); + } + } + return subTools.length > 0 ? { parentId, subTools } : null; +} diff --git a/server/adapters/claude/pane-monitor.ts b/server/adapters/claude/pane-monitor.ts new file mode 100644 index 0000000..be65b32 --- /dev/null +++ b/server/adapters/claude/pane-monitor.ts @@ -0,0 +1,130 @@ +import { tmuxManager } from '../shared/tmux-manager.js'; + +/** Thinking indicator detected from pane content */ +export interface ThinkingInfo { + text: string; + detail: string | null; +} + +/** + * Simplified PaneMonitor — only detects: + * 1. Thinking indicator (spinner + verb) + * 2. Streaming response text (text after ⏺ marker) + * + * Permission mode detection removed — handled by syncPermissionMode() in + * tmux-adapter via hook body's permission_mode field (including statusline). + * Permission, question, and idle detection handled by HTTP hooks + * (PreToolUse, PostToolUse, PermissionRequest, Stop). + */ +export class PaneMonitor { + windowId: string; + lastContent: string; + interval: ReturnType | null; + private _lastResponseText: string; + private _onThinking: ((thinking: ThinkingInfo) => void) | null; + private _onStreamingText: ((text: string) => void) | null; + + constructor(windowId: string) { + this.windowId = windowId; + this.lastContent = ''; + this.interval = null; + this._lastResponseText = ''; + this._onThinking = null; + this._onStreamingText = null; + } + + start(): void { + this.interval = setInterval(async () => { + try { + const content = await tmuxManager.capturePane(this.windowId); + if (content === this.lastContent) return; + this.lastContent = content; + + // 1. Check thinking (spinner in status area) + const thinking = detectThinking(content); + if (thinking && this._onThinking) { + this._onThinking(thinking); + } + + // 2. Extract streaming response text + if (this._onStreamingText && !thinking) { + const text = extractResponseText(content); + if (text && text !== this._lastResponseText) { + this._lastResponseText = text; + this._onStreamingText(text); + } + } + } catch (err) { + // Silently ignore — window may have been killed + } + }, 500); + } + + stop(): void { + if (this.interval) { clearInterval(this.interval); this.interval = null; } + } + + onThinking(cb: (thinking: ThinkingInfo) => void): void { this._onThinking = cb; } + onStreamingText(cb: (text: string) => void): void { this._onStreamingText = cb; } +} + +// --- Detection functions --- + +export function detectThinking(content: string): ThinkingInfo | null { + const lines = content.split('\n'); + const tail = lines.slice(-15); + for (const line of tail) { + // Match: spinner char + word ending in "…", with optional (detail) + // But NOT "Worked for" (completion summary) + if (/Worked for|completed|Done/i.test(line)) continue; + const match = line.match(/^\s*([✶✻·✽✳✢])\s+(\S+…)\s*(?:\((.+?)\))?\s*$/); + if (match) { + return { text: match[2]!, detail: match[3] || null }; + } + } + return null; +} + +export function extractResponseText(content: string): string { + const lines = content.split('\n'); + + // Find the LAST user prompt (❯ with text) — only look for responses AFTER it + let lastUserPrompt = -1; + for (let i = lines.length - 1; i >= 0; i--) { + if (/^\s*❯\s+\S/.test(lines[i]!)) { + lastUserPrompt = i; + break; + } + } + + // Find the response ⏺ AFTER the last user prompt + let lastResponseStart = -1; + const searchStart = lastUserPrompt >= 0 ? lastUserPrompt : 0; + for (let i = lines.length - 1; i >= searchStart; i--) { + const line = lines[i]!; + // Skip tool calls: ⏺ CapitalWord( or ⏺ Read/Write N file + if (/^\s*⏺\s+[A-Z]\w*[\(]/.test(line)) continue; + if (/^\s*⏺\s+[A-Z]\w+\s+\d+\s+file/.test(line)) continue; + if (/^\s*⏺\s+/.test(line)) { + lastResponseStart = i; + break; + } + } + + if (lastResponseStart === -1) return ''; + + const responseLines = [lines[lastResponseStart]!.replace(/^\s*⏺\s?/, '')]; + for (let i = lastResponseStart + 1; i < lines.length; i++) { + const line = lines[i]!; + if (/^[─━═]{5,}/.test(line.trim()) || + /^\s*❯/.test(line) || + /^\s*⎿/.test(line) || + /^\s*⏺/.test(line) || + /^\s*[✶✻·✽✳✢]\s+/.test(line)) { + break; + } + responseLines.push(line); + } + + return responseLines.join('\n').trim(); +} diff --git a/server/adapters/claude/tmux-adapter.ts b/server/adapters/claude/tmux-adapter.ts new file mode 100644 index 0000000..aa73c68 --- /dev/null +++ b/server/adapters/claude/tmux-adapter.ts @@ -0,0 +1,893 @@ +import { EventEmitter } from 'events'; +import { tmuxManager } from '../shared/tmux-manager.js'; +import type { TmuxWindow } from '../shared/tmux-manager.js'; +import { PaneMonitor } from './pane-monitor.js'; +import { JsonlWatcher } from '../../stores/jsonl-watcher.js'; +import { TranscriptParser } from './transcript-parser.js'; +import type { ParsedMessage } from './transcript-parser.js'; +import { readdir, stat } from 'fs/promises'; +import { join } from 'path'; +import crypto from 'crypto'; +import { PROJECTS_DIR, encodeDirName, parseSessionHeader } from './jsonl-store.js'; +import { extractText } from './message-utils.js'; +import type { JsonlEntry } from './message-utils.js'; +import type { PermissionBehavior, QueryOptions } from '../../types/messages.js'; +import type { ReconnectState } from '../../types/adapter.js'; +import type { ActiveSessionInfo } from '../interface.js'; +import { isLargeContent } from '../interface.js'; +import { PermissionManager } from '../../permission-manager.js'; +import { PLAN_OPTION } from '../../ws-types.js'; + +const MODE_CYCLE: string[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; +/** Internal session state for a managed tmux session */ +export interface SessionState { + windowId: string; + monitor: PaneMonitor | null; + watcher: JsonlWatcher | null; + parser: TranscriptParser | null; + cwd: string; + cliSessionId: string; + permissionMode: string; + lastActivity: number; + firstPrompt: string | null; + isProcessing: boolean; + isNonInteractive: boolean; + _interactiveChecked: boolean; + _promptSenderClientId: string | null; + _modeTransitionDeadline: number; + _watcherPending: boolean; +} + +/** Hook body payload from Claude CLI */ +export interface HookBody { + session_id?: string; + permission_mode?: string; + tool_use_id?: string; + tool_name?: string; + tool_input?: Record; + tool_response?: unknown; + error?: string; + error_details?: string; + is_interrupt?: boolean; + [key: string]: unknown; +} + +/** Resolved session context from _resolveAndTouch */ +interface ResolvedContext { + sessionId: string; + session: SessionState | undefined; +} + +/** + * TmuxAdapter — manages Claude Code sessions via tmux. + * + * Three channels provide events to the SessionManager: + * 1. HTTP Hooks (structured): tool-start, tool-done, session-idle, permission-request + * 2. JSONL Watcher (messages): new-messages (single source of truth) + * 3. PaneMonitor (ephemeral): streaming-text, thinking + * + * Events emitted: + * streaming-text(sessionId, text) + * thinking(sessionId, { text, detail }) + * tool-start(sessionId, { toolId, toolName, input }) + * tool-done(sessionId, { toolId, toolName, result }) + * new-messages(sessionId, messages[]) + * session-idle(sessionId) + * session-error(sessionId, { errorType, errorDetails }) + * permission-request(sessionId, { requestId, toolName, input }) + * ask-question(sessionId, { requestId, toolName, input }) + * mode-changed(sessionId, mode) + * session-ended(sessionId) + * compacting(sessionId) + * compact-done(sessionId) + * processing-started(sessionId) + */ +export class TmuxAdapter extends EventEmitter { + // sessionId (CLI UUID) -> { windowId, monitor, watcher, parser, cwd, cliSessionId, permissionMode } + sessions: Map; + // Centralized pending permissions/questions manager + private _permissions: PermissionManager; + // Set by SessionManager to check if WS clients are connected + private _clientChecker: ((sessionId: string) => boolean) | null; + private _cleanupInterval: ReturnType | null; + + // CLI permission prompt option layout (Claude CLI v2.x): + // 0: "Yes" + // 1: "Yes, allow all edits during this session (shift+tab)" + // 2: "No" + static PERMISSION_DENY_INDEX: number = 2; + + constructor() { + super(); + this.sessions = new Map(); + this._permissions = new PermissionManager(); + this._clientChecker = null; + this._cleanupInterval = null; + this._startSessionCleanup(); + } + + /** Set a function that checks if WS clients are connected for a session */ + setClientChecker(fn: (sessionId: string) => boolean): void { this._clientChecker = fn; } + + // === Session Lifecycle === + + async startSession(cwd: string, options: QueryOptions = {}): Promise<{ sessionId: string }> { + // Generate UUID upfront — no guessing needed + const cliSessionId = crypto.randomUUID(); + + const mode = options.permissionMode || 'default'; + const parts = ['claude', '--session-id', cliSessionId]; + // Always start with bypass so all 4 modes are reachable mid-session via Shift+Tab + parts.push('--dangerously-skip-permissions'); + if (options.model) parts.push('--model', `'${options.model}'`); + if (options.effort) parts.push('--effort', options.effort); + + const sessionId = cliSessionId; + const windowId = await tmuxManager.createWindow(sessionId, cwd, parts.join(' ')); + + // Register session BEFORE _waitForReady — SessionStart hook fires during the wait, + // and needs the session in the Map to avoid creating a duplicate session/watcher. + this.sessions.set(sessionId, this._createSession(windowId, cwd, cliSessionId, mode)); + + await this._waitForReady(windowId); + + this._startMonitor(sessionId, windowId); + this._ensureWatcher(sessionId); + + // Switch to user's desired mode (if not already bypassPermissions) + if (mode && mode !== 'bypassPermissions') { + await this.switchPermissionMode(sessionId, mode); + } + + return { sessionId }; + } + + async attachSession(sessionId: string, cwd: string, options: QueryOptions = {}): Promise<{ sessionId: string }> { + const existing = this.sessions.get(sessionId); + + // If already attached with a watcher, don't recreate + if (existing?.watcher) { + if (!existing.monitor) this._startMonitor(sessionId, existing.windowId); + if (options.permissionMode) existing.permissionMode = options.permissionMode; + return { sessionId }; + } + + const windowId = await this._findWindowForSession(sessionId); + if (!windowId) throw new Error(`No tmux window found for session ${sessionId}`); + + // Defensive: if another session already manages this tmux window, + // redirect to it instead of creating a duplicate entry. + // Each tmux window runs exactly one Claude CLI — same window = same session. + if (!existing) { + for (const [existingId, existingSession] of this.sessions) { + if (existingSession.windowId === windowId) { + if (!existingSession.monitor) this._startMonitor(existingId, windowId); + return { sessionId: existingId }; + } + } + } + + // Preserve existing watcher/parser if session entry exists + if (existing) { + existing.windowId = windowId; + existing.lastActivity = Date.now(); + if (options.permissionMode) existing.permissionMode = options.permissionMode; + if (!existing.monitor) this._startMonitor(sessionId, windowId); + } else { + this.sessions.set(sessionId, this._createSession(windowId, cwd, sessionId, options.permissionMode || 'default')); + this._startMonitor(sessionId, windowId); + } + + await this._ensureWatcher(sessionId); + return { sessionId }; + } + + async resumeSession(sessionId: string, cwd: string, options: QueryOptions = {}): Promise<{ sessionId: string }> { + const mode = options.permissionMode || 'default'; + const windows = await tmuxManager.listWindows(); + + // Extract CLI UUID before potentially deleting the session + const existingSession = this.sessions.get(sessionId); + const cliUuid = existingSession?.cliSessionId || sessionId; + + // Check if session already managed and tmux window still exists + if (existingSession) { + if (await this._windowExists(existingSession.windowId, windows)) { + if (!existingSession.monitor) this._startMonitor(sessionId, existingSession.windowId); + existingSession.permissionMode = mode; + existingSession.lastActivity = Date.now(); + await this._ensureWatcher(sessionId); + return { sessionId }; + } + // Window gone — stop old watcher before replacing + this._teardownSession(existingSession); + this.sessions.delete(sessionId); + } + + // Check for existing tmux window (e.g., started from Desktop) + const existingWindowId = await this._findWindowForSession(cliUuid, windows); + if (existingWindowId) { + return this.attachSession(sessionId, cwd, options); + } + + // No existing window — create new with --resume + const modeFlag = '--dangerously-skip-permissions'; + let command = `claude ${modeFlag} --resume ${cliUuid}`; + if (options.effort) command += ` --effort ${options.effort}`; + const newSessionId = cliUuid; + const windowId = await tmuxManager.createWindow(cliUuid, cwd || process.cwd(), command); + + // Register before _waitForReady (same pattern as startSession) + this.sessions.set(newSessionId, this._createSession(windowId, cwd, cliUuid, mode)); + + await this._waitForReady(windowId); + + this._startMonitor(newSessionId, windowId); + await this._ensureWatcher(newSessionId); + return { sessionId: newSessionId }; + } + + async sendMessage(sessionId: string, text: string, options: QueryOptions = {}): Promise { + const session = this.sessions.get(sessionId); + if (!session) throw new Error(`Session ${sessionId} not found`); + session._promptSenderClientId = options.clientId || null; + // Restart pane monitor if it was stopped (e.g., after turn-complete) + if (!session.monitor) { + this._startMonitor(sessionId, session.windowId); + } + if (isLargeContent(text)) { + // Large/multiline content: use pasteBuffer for speed. + // Claude CLI handles multiline input natively — no \n replacement needed. + // pasteBuffer defaults sendEnter=true, so Enter is sent automatically. + await tmuxManager.pasteBuffer(session.windowId, text); + } else { + await tmuxManager.sendKeys(session.windowId, text, true); + } + } + + async switchModel(sessionId: string, model: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return; + await tmuxManager.sendKeys(session.windowId, `/model ${model}`, true); + } + + async interrupt(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return; + await tmuxManager.sendControl(session.windowId, 'C-c'); + } + + async destroySession(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return; + this._teardownSession(session); + await tmuxManager.killWindow(session.windowId); + this.sessions.delete(sessionId); + this.emit('session-ended', sessionId); + } + + getSession(sessionId: string): SessionState | undefined { + return this.sessions.get(sessionId); + } + + /** Force an immediate JSONL poll for a session */ + flushMessages(sessionId: string): void { + const session = this.sessions.get(sessionId); + if (session?.watcher) session.watcher.pollNow(); + } + + /** Advance watcher past current file position without emitting entries */ + syncWatcherPosition(sessionId: string): void { + const session = this.sessions.get(sessionId); + if (session?.watcher) session.watcher.markCurrentPosition(); + } + + /** Get pending state for reconnecting clients (tools, permissions, questions) */ + getReconnectState(sessionId: string): ReconnectState { + const session = this.sessions.get(sessionId); + const state: ReconnectState = { tools: {}, pendingRequests: [] }; + + if (session?.parser) { + const tools = session.parser.getPendingTools(); + if (tools.size > 0) { + // PendingTool is a superset of ToolStatus — cast is safe for reconnect replay + state.tools = Object.fromEntries(tools) as unknown as Record; + } + } + + for (const perm of this._permissions.getPendingForSession(sessionId)) { + state.pendingRequests.push({ type: 'permission', requestId: perm.requestId, toolName: perm.toolName, input: perm.input }); + } + for (const q of this._permissions.getQuestionsForSession(sessionId)) { + state.pendingRequests.push({ type: 'question', requestId: q.requestId, toolName: 'AskUserQuestion', input: q.originalInput }); + } + return state; + } + + async hasActiveWindow(sessionId: string): Promise { + const windows = await tmuxManager.listWindows(); + const session = this.sessions.get(sessionId); + if (session) return this._windowExists(session.windowId, windows); + + // Check if a tmux window exists for this session + return !!(await this._findWindowForSession(sessionId, windows)); + } + + // === Permission Mode === + + setPermissionMode(sessionId: string, mode: string): boolean { + const session = this.sessions.get(sessionId); + if (!session) return false; + session.permissionMode = mode; + return true; + } + + async switchPermissionMode(sessionId: string, targetMode: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return false; + + const currentMode = session.permissionMode || 'default'; + if (currentMode === targetMode) return true; + + const currentIdx = MODE_CYCLE.indexOf(currentMode); + const targetIdx = MODE_CYCLE.indexOf(targetMode); + if (currentIdx < 0 || targetIdx < 0) return false; + + const presses = (targetIdx - currentIdx + MODE_CYCLE.length) % MODE_CYCLE.length; + + // Set target BEFORE sending keys — prevents syncPermissionMode + // from overwriting with intermediate modes during the Shift+Tab transition + session.permissionMode = targetMode; + session._modeTransitionDeadline = Date.now() + presses * 200 + 500; + + for (let i = 0; i < presses; i++) { + await tmuxManager.sendControl(session.windowId, 'BTab'); + await new Promise(r => setTimeout(r, 150)); + } + + return true; + } + + // Permission mode precedence (highest → lowest): + // 1. switchPermissionMode() — user-initiated from ClawTap UI, sets target immediately + // 2. syncPermissionMode() — CLI reports its mode via hook body (authoritative) + // 3. Client localStorage — persists user preference across sessions + + /** + * Sync permission mode from CLI hook body. Called by hook handlers + * (via _resolveAndTouch) and by statusline handler to catch desktop + * Shift+Tab changes that don't trigger tool-use hooks. + */ + syncPermissionMode(sessionId: string, body: HookBody): void { + if (!body.permission_mode) return; + const session = this.sessions.get(sessionId); + if (!session) return; + // Skip sync while ClawTap-initiated Shift+Tab mode transition is in flight + if (session._modeTransitionDeadline && Date.now() < session._modeTransitionDeadline) return; + const cliMode = body.permission_mode === 'dontAsk' ? 'bypassPermissions' : body.permission_mode; + if (session.permissionMode !== cliMode) { + session.permissionMode = cliMode; + this.emit('mode-changed', sessionId, cliMode); + } + } + + // === Hook Handlers (called from Express endpoints) === + // + // Common preamble extracted into _resolveAndTouch(): + // resolve session from body.session_id → syncPermissionMode → update lastActivity + // handleSessionEnd bypasses the helper (needs different teardown logic). + + /** + * Resolve hook body to internal session, sync permission mode, touch lastActivity. + * Returns { sessionId, session } or null if session cannot be resolved. + */ + private _resolveAndTouch(body: HookBody): ResolvedContext | null { + const sessionId = body.session_id; + if (!sessionId || !this.sessions.has(sessionId)) return null; + this.syncPermissionMode(sessionId, body); + const session = this.sessions.get(sessionId); + if (session) session.lastActivity = Date.now(); + return { sessionId, session }; + } + + /** Shared by handlePostToolUse and handlePostToolUseFailure. */ + private _emitToolDone(sessionId: string, body: HookBody, result: unknown): void { + this.emit('tool-done', sessionId, { + toolId: body.tool_use_id, + toolName: body.tool_name, + input: body.tool_input, + result, + }); + this._permissions.dismissAll(sessionId); + } + + /** Shared by handleStop and handleStopFailure. */ + private _endTurn(sessionId: string): void { + const session = this.sessions.get(sessionId); + if (session) { + session.isProcessing = false; + if (session.monitor) { + session.monitor.stop(); + session.monitor = null; + } + } + this.emit('session-idle', sessionId); + this._permissions.dismissAll(sessionId); + } + + async handlePreToolUse(body: HookBody): Promise { + const ctx = this._resolveAndTouch(body); + if (!ctx) return; + + // AskUserQuestion: emit for Mobile picker UI. CLI shows terminal prompt, + // mobile answers via tmux send-keys. + if (body.tool_name === 'AskUserQuestion') { + const requestId = `ask-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + this._permissions.addQuestion(requestId, ctx.sessionId, { originalInput: body.tool_input || {} }); + this.emit('ask-question', ctx.sessionId, { + requestId, + toolName: 'AskUserQuestion', + input: body.tool_input, + }); + return; + } + + this.emit('tool-start', ctx.sessionId, { + toolId: body.tool_use_id, + toolName: body.tool_name, + input: body.tool_input, + }); + } + + async handlePostToolUse(body: HookBody): Promise { + const ctx = this._resolveAndTouch(body); + if (!ctx) return; + this._emitToolDone(ctx.sessionId, body, body.tool_response); + } + + async handlePostToolUseFailure(body: HookBody): Promise { + const ctx = this._resolveAndTouch(body); + if (!ctx) return; + this._emitToolDone(ctx.sessionId, body, { + content: body.error, is_error: true, is_interrupt: body.is_interrupt, + }); + } + + async handleUserPromptSubmit(body: HookBody): Promise { + const ctx = this._resolveAndTouch(body); + if (!ctx) return; + + const { sessionId, session } = ctx; + if (session) { + session.isProcessing = true; + this.emit('processing-started', sessionId); + // Do NOT markCurrentPosition() here — other mobile clients need to see the user message via JSONL. + // The sender deduplicates via senderClientId on the client side. + if (!session.monitor) this._startMonitor(sessionId, session.windowId); + } + + this._detectNonInteractive(sessionId); + } + + async handleStop(body: HookBody): Promise { + const ctx = this._resolveAndTouch(body); + if (!ctx) return; + this._endTurn(ctx.sessionId); + } + + async handleStopFailure(body: HookBody): Promise { + const ctx = this._resolveAndTouch(body); + if (!ctx) return; + this.emit('session-error', ctx.sessionId, { + errorType: body.error, + errorDetails: body.error_details, + }); + this._endTurn(ctx.sessionId); + } + + async handleSessionEnd(body: HookBody): Promise { + const sessionId = body.session_id; + if (!sessionId) return; + + const session = this.sessions.get(sessionId); + if (session) { + this._teardownSession(session); + this.sessions.delete(sessionId); + } + + this.emit('session-ended', sessionId); + } + + async handlePreCompact(body: HookBody): Promise { + const ctx = this._resolveAndTouch(body); + if (!ctx) return; + this.emit('compacting', ctx.sessionId); + } + + async handlePostCompact(body: HookBody): Promise { + const ctx = this._resolveAndTouch(body); + if (!ctx) return; + this.emit('compact-done', ctx.sessionId); + } + + /** Handle real-time session discovery when CLI starts (SessionStart hook). */ + async handleSessionStart(body: HookBody): Promise { + const cliUuid = body.session_id; + if (!cliUuid) return; + + if (this.sessions.has(cliUuid)) { + this.sessions.get(cliUuid)!.lastActivity = Date.now(); + return; + } + // Unknown UUID — not our session, ignore + } + + /** + * Fire-and-forget notification — no return value. + * YOLO/Auto-edit: CLI handles auto-allow via Shift+Tab, skip mobile overlay. + * Normal: emit permission-request for mobile overlay. User answers via + * tmux send-keys ('y'/'n'), not via hook response. + */ + async handlePermissionRequest(body: HookBody): Promise { + const ctx = this._resolveAndTouch(body); + if (!ctx) return; + const { sessionId, session } = ctx; + const mode = session?.permissionMode || 'default'; + + // YOLO/Auto-edit: CLI already auto-allows via Shift+Tab — skip mobile overlay + if (mode === 'bypassPermissions') return; + if (mode === 'acceptEdits' && ['Write', 'Edit', 'MultiEdit', 'NotebookEdit'].includes(body.tool_name!)) return; + // Plan tools have their own approval UI (PlanMode card) — skip generic overlay. + // AskUserQuestion is handled by PreToolUse (question overlay, not permission overlay). + if (['ExitPlanMode', 'EnterPlanMode', 'AskUserQuestion'].includes(body.tool_name!)) return; + + // Normal mode: notify mobile to show permission overlay + const requestId = crypto.randomUUID(); + // Store truncated input for reconnect replay — full payload already broadcast via emit below + const inputSummary: Record = body.tool_input ? Object.fromEntries( + Object.entries(body.tool_input).map(([k, v]) => [k, typeof v === 'string' && v.length > 500 ? v.substring(0, 500) + '\u2026' : v]) + ) : {}; + this._permissions.addPermission(requestId, sessionId, { toolName: body.tool_name!, input: inputSummary }); + this.emit('permission-request', sessionId, { + requestId, + toolName: body.tool_name, + input: body.tool_input, + }); + } + + async respondPermission(requestId: string, behavior: PermissionBehavior): Promise { + const pending = this._permissions.resolvePermission(requestId); + if (!pending) return; + + const session = this.sessions.get(pending.sessionId); + if (!session) return; + + const optionIndex = behavior === 'allow' ? 0 + : behavior === 'allow_session' ? 1 + : TmuxAdapter.PERMISSION_DENY_INDEX; + await this._selectOption(session.windowId, optionIndex); + } + + /** + * Release all pending requests for a session (e.g., when Mobile disconnects). + * Just clears pending state — CLI prompt remains on terminal. + */ + releaseAllPending(sessionId: string): void { + this._permissions.dismissAll(sessionId); + } + + resolveAllPendingAs(sessionId: string, behavior: PermissionBehavior | string): void { + const resolvedIds = this._permissions.resolveAllAs(sessionId, behavior as string); + if (behavior === 'allow') { + const session = this.sessions.get(sessionId); + if (session) { + for (const _reqId of resolvedIds) { + this._selectOption(session.windowId, 0).catch(() => {}); + } + } + } + } + + async respondQuestion(requestId: string, answer: string): Promise { + const pending = this._permissions.resolveQuestion(requestId); + if (!pending) return; + + const input = pending.originalInput || {}; + const questions = (input.questions as Array<{ options?: Array<{ label?: string; value?: string }> }>) || []; + const options = questions[0]?.options || []; + const optionIndex = options.findIndex(o => o.label === answer || o.value === answer); + + const session = this.sessions.get(pending.sessionId); + if (!session) return; + + if (optionIndex >= 0) { + // Matched a predefined option — select it directly + await this._selectOption(session.windowId, optionIndex); + } else { + // Free-form answer — select "Type something" (at index options.length) then type answer + await this._selectOption(session.windowId, options.length); + await new Promise(r => setTimeout(r, 200)); + await tmuxManager.sendKeys(session.windowId, answer, true); + } + } + + /** + * Respond to the CLI's plan approval selector. + * Options: 0=bypass (auto-accept edits), 1=manually approve, 2=text feedback + */ + async respondPlan(sessionId: string, optionIndex: number, text?: string): Promise { + const session = this.sessions.get(sessionId); + if (!session || optionIndex < 0 || optionIndex > PLAN_OPTION.TEXT_FEEDBACK) return; + if (optionIndex === PLAN_OPTION.TEXT_FEEDBACK && text) { + await this._selectOption(session.windowId, PLAN_OPTION.TEXT_FEEDBACK); + await new Promise(r => setTimeout(r, 200)); + await tmuxManager.sendKeys(session.windowId, text, true); + } else { + await this._selectOption(session.windowId, optionIndex); + } + } + + /** + * Navigate a CLI interactive selector by pressing Down `index` times, then Enter. + * Cursor starts on option 0 (first item), so index=0 just presses Enter. + */ + private async _selectOption(windowId: string, index: number): Promise { + for (let i = 0; i < index; i++) { + await tmuxManager.sendControl(windowId, 'Down'); + await new Promise(r => setTimeout(r, 100)); + } + await tmuxManager.sendControl(windowId, 'Enter'); + } + + getActiveSessions(): ActiveSessionInfo[] { + const sessions: ActiveSessionInfo[] = []; + for (const [sessionId, session] of this.sessions) { + sessions.push({ + sessionId, + cwd: session.cwd, + adapter: 'claude', + permissionMode: session.permissionMode, + lastActivity: session.lastActivity || null, + hasClients: false, + hasDesktop: !!(session.lastActivity && (Date.now() - session.lastActivity < 120000)), + isNonInteractive: session.isNonInteractive || false, + firstPrompt: session.firstPrompt || null, + }); + } + return sessions; + } + + isProcessing(sessionId: string): boolean { + const session = this.sessions.get(sessionId); + return !!(session?.isProcessing); + } + + private _startSessionCleanup(): void { + this._cleanupInterval = setInterval(async () => { + const windows = await tmuxManager.listWindows(); + const liveWindowIds = new Set(windows.map(w => w.id)); + + for (const [sessionId, session] of this.sessions) { + if (!liveWindowIds.has(session.windowId)) { + console.log(`[tmux] Stale session ${sessionId} — tmux window gone, cleaning up`); + this._teardownSession(session); + this.sessions.delete(sessionId); + this.emit('session-ended', sessionId); + } + } + + if (this.sessions.size > 10) { + const sorted = [...this.sessions.entries()] + .sort((a, b) => (b[1].lastActivity || 0) - (a[1].lastActivity || 0)); + for (const [id] of sorted.slice(10)) { + const s = this.sessions.get(id); + if (s) this._teardownSession(s); + this.sessions.delete(id); + this.emit('session-ended', id); + } + } + }, 60000); + // Don't keep the process alive just for cleanup — allows hooks-cli + // and other short-lived consumers to exit naturally after their work. + this._cleanupInterval.unref(); + } + + // === Helpers === + + private _createSession(windowId: string, cwd: string, cliSessionId: string, permissionMode: string): SessionState { + return { + windowId, + monitor: null, + watcher: null, + parser: null, + cwd, + cliSessionId, + permissionMode, + lastActivity: Date.now(), + firstPrompt: null, + isProcessing: false, + isNonInteractive: false, + _interactiveChecked: false, + _promptSenderClientId: null, + _modeTransitionDeadline: 0, + _watcherPending: false, + }; + } + + private _teardownSession(session: SessionState): void { + if (session.monitor) { session.monitor.stop(); session.monitor = null; } + if (session.watcher) { session.watcher.stop(); session.watcher = null; session.parser = null; } + } + + async destroy(): Promise { + if (this._cleanupInterval) { + clearInterval(this._cleanupInterval); + this._cleanupInterval = null; + } + for (const [, session] of this.sessions) { + this._teardownSession(session); + } + this.sessions.clear(); + await tmuxManager.killSession(); + } + + // === Internal === + + private _startMonitor(sessionId: string, windowId: string): void { + const monitor = new PaneMonitor(windowId); + monitor.onThinking((thinking) => { + this.emit('thinking', sessionId, thinking); + }); + monitor.onStreamingText((text) => { + this.emit('streaming-text', sessionId, text); + }); + monitor.start(); + const session = this.sessions.get(sessionId); + if (session) session.monitor = monitor; + } + + private async _ensureWatcher(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session || session.watcher || session._watcherPending) return; + session._watcherPending = true; + + const cliId = sessionId; + + // Construct path directly (we know the UUID and cwd) + let jsonlPath: string | null = null; + if (session.cwd && cliId) { + const encoded = encodeDirName(session.cwd); + const directPath = join(PROJECTS_DIR, encoded, `${cliId}.jsonl`); + // Wait for file to appear (Claude creates it on first write) + // First 25 iterations at 200ms (5s), then 1s intervals for remaining time + for (let i = 0; i < 50; i++) { + try { + await stat(directPath); + jsonlPath = directPath; + break; + } catch { + await new Promise(r => setTimeout(r, i < 25 ? 200 : 1000)); + } + } + } + // Fallback: search all project dirs + if (!jsonlPath) jsonlPath = await this._findJsonlPath(cliId); + if (!jsonlPath) { + session._watcherPending = false; // Allow retry + return; + } + + const parser = new TranscriptParser(); + const watcher = new JsonlWatcher(jsonlPath); + + watcher.onNewEntries((entries) => { + const { messages, interrupted } = parser.parse(entries as JsonlEntry[]); + if (messages.length > 0) { + // Capture first user prompt for active sessions list + if (!session.firstPrompt) { + const userMsg = messages.find(m => m.role === 'user'); + if (userMsg) session.firstPrompt = (extractText(userMsg.content) || '').substring(0, 200); + } + + // Tag user messages with sender's client ID so only the sender skips (dedup) + for (const msg of messages) { + if (msg.role === 'user' && session._promptSenderClientId) { + msg.senderClientId = session._promptSenderClientId; + session._promptSenderClientId = null; + } + } + + this.emit('new-messages', sessionId, messages); + } + if (interrupted) { + this.emit('session-idle', sessionId); + } + const tools = parser.getPendingTools(); + if (tools.size > 0) { + this.emit('tool-updates', sessionId, Object.fromEntries(tools)); + } + }); + + watcher.start({ skipExisting: true }); + session.watcher = watcher; + session.parser = parser; + session._watcherPending = false; + + // Backfill firstPrompt from JSONL header (handles race where watcher + // starts after first user message was already written) + if (!session.firstPrompt && jsonlPath) { + try { + const { firstPrompt } = await parseSessionHeader(jsonlPath, sessionId); + if (firstPrompt) session.firstPrompt = firstPrompt; + } catch {} + } + } + + private async _findJsonlPath(sessionId: string): Promise { + try { + const dirs = await readdir(PROJECTS_DIR); + for (const dir of dirs) { + const filePath = join(PROJECTS_DIR, dir, `${sessionId}.jsonl`); + try { + await stat(filePath); + return filePath; + } catch {} + } + } catch {} + return null; + } + + private async _findWindowForSession(sessionId: string, windowList?: TmuxWindow[]): Promise { + const windows = windowList || await tmuxManager.listWindows(); + // Search tmux windows by sessionId (window name = CLI UUID) + const match = windows.find(w => w.name === sessionId); + return match?.id || null; + } + + private async _detectNonInteractive(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session || session._interactiveChecked) return; + session._interactiveChecked = true; + + try { + const content = await tmuxManager.capturePane(session.windowId); + if (content.includes('claude -p ') || content.includes('claude --print')) { + session.isNonInteractive = true; + console.log(`[tmux] Session ${sessionId} detected as non-interactive (claude -p)`); + } + } catch {} + } + + private async _windowExists(windowId: string, windowList?: TmuxWindow[]): Promise { + const windows = windowList || await tmuxManager.listWindows(); + return windows.some(w => w.id === windowId); + } + + private async _waitForReady(windowId: string, timeoutMs: number = 30000): Promise { + const start = Date.now(); + let attempt = 0; + while (Date.now() - start < timeoutMs) { + attempt++; + try { + const content = await tmuxManager.capturePane(windowId); + const lines = content.split('\n'); + const hasPrompt = lines.some(l => /^\s*❯/.test(l)); + const lineCount = lines.filter(l => l.trim()).length; + if (attempt <= 3 || attempt % 5 === 0) { + console.log(`[adapter] waitForReady #${attempt}: window=${windowId} prompt=${hasPrompt} lines=${lineCount}`); + } + if (hasPrompt && lineCount >= 3) { + console.log(`[adapter] CLI ready for ${windowId} in ${Date.now() - start}ms`); + await new Promise(r => setTimeout(r, 300)); + return; + } + } catch (err) { + console.log(`[adapter] waitForReady #${attempt}: ERROR ${(err as Error).message}`); + } + await new Promise(r => setTimeout(r, 1000)); + } + console.warn(`[adapter] CLI ready timeout for ${windowId} after ${attempt} attempts`); + } +} + +export const tmuxAdapter = new TmuxAdapter(); diff --git a/server/adapters/claude/transcript-parser.ts b/server/adapters/claude/transcript-parser.ts new file mode 100644 index 0000000..e169910 --- /dev/null +++ b/server/adapters/claude/transcript-parser.ts @@ -0,0 +1,200 @@ +import { extractText, isSystemMessage, extractPlanContent, isNoResponseMessage, extractSubTools } from './message-utils.js'; +import type { JsonlEntry, ContentBlock, SubToolBlock } from './message-utils.js'; + +/** Pending tool tracking entry */ +export interface PendingTool { + toolUseId: string; + name: string; + input: Record; + status: 'running' | 'success' | 'error'; + result: ContentBlock | null; + parentToolUseId?: string; +} + +/** Parsed message from transcript */ +export interface ParsedMessage { + id: string; + role: 'user' | 'assistant' | 'plan'; + content: ContentBlock[] | string; + senderClientId?: string | null; + adapter?: string; +} + +/** Result of parse() */ +export interface ParseResult { + messages: ParsedMessage[]; + interrupted: boolean; +} + +export class TranscriptParser { + pendingTools: Map; // tool_use_id → { name, input, status } + private _pendingSubTools: Map; // parentToolUseId → [tool_use blocks with parent_tool_use_id] + private _msgIndex: number = 0; + + constructor() { + this.pendingTools = new Map(); + this._pendingSubTools = new Map(); + } + + /** + * Parse new JSONL entries into frontend-ready messages. + * Only processes user/assistant type entries. + * Returns array of { role, content, tools? } + */ + parse(entries: JsonlEntry[]): ParseResult { + // NOTE: Do NOT reset _msgIndex here — parse() is called incrementally via + // watcher.onNewEntries(). Resetting would restart IDs at msg-0, causing + // React key collisions. _msgIndex accumulates across incremental batches. + const messages: ParsedMessage[] = []; + let interrupted = false; + + for (const entry of entries) { + // Process agent_progress entries for sub-tool tracking + if (entry.type === 'progress' && entry.data?.type === 'agent_progress') { + this._processAgentProgress(entry); + continue; + } + + if (!entry.message) continue; + + // Detect user interruption marker + if (!interrupted && entry.type === 'user') { + const text = extractText(entry.message.content); + if (text.includes('[Request interrupted by user')) { + interrupted = true; + } + } + + if (entry.type === 'user') { + const msg = this._parseUserEntry(entry); + if (msg) messages.push(msg); + } else if (entry.type === 'assistant') { + const msg = this._parseAssistantEntry(entry); + if (msg) messages.push(msg); + } + } + + // Inject accumulated sub-tool blocks into assistant messages containing their parent Agent tool + // This handles history load and same-batch scenarios where Agent + progress arrive together + for (const msg of messages) { + if (msg.role !== 'assistant' || !Array.isArray(msg.content)) continue; + for (const block of msg.content) { + if (block.type !== 'tool_use') continue; + const subTools = this._pendingSubTools.get(block.id!); + if (subTools && subTools.length > 0) { + (msg.content as ContentBlock[]).push(...(subTools as unknown as ContentBlock[])); + this._pendingSubTools.delete(block.id!); + } + } + } + + return { messages, interrupted }; + } + + /** Get current pending tool statuses (only running tools — completed sub-tools are excluded) */ + getPendingTools(): Map { + const filtered = new Map(); + for (const [id, tool] of this.pendingTools) { + if (tool.status === 'running') filtered.set(id, tool); + } + return filtered; + } + + private _parseUserEntry(entry: JsonlEntry): ParsedMessage | null { + const content = entry.message!.content; + const text = extractText(content); + + // Skip system/CLI messages + if (isSystemMessage(text, content)) return null; + + // "Implement the following plan:" → plan type + const planBody = extractPlanContent(text); + if (planBody !== null) { + return { id: `msg-${this._msgIndex++}`, role: 'plan', content: planBody, adapter: 'claude' }; + } + + // Process tool_result blocks (pair with pending tool_use) + if (Array.isArray(content)) { + let hasToolResult = false; + for (const block of content) { + if (block.type === 'tool_result' && block.tool_use_id) { + hasToolResult = true; + const pending = this.pendingTools.get(block.tool_use_id); + if (pending) { + pending.status = block.is_error ? 'error' : 'success'; + pending.result = block; + this.pendingTools.delete(block.tool_use_id); + } + } + } + // If message is ONLY tool results, don't emit as chat message + if (hasToolResult && !text.trim()) return null; + } + + // Normal user message — normalize content to array format + const userContent: ContentBlock[] = typeof content === 'string' + ? [{ type: 'text', text: content }] + : Array.isArray(content) + ? content + : [{ type: 'text', text: String(content) }]; + return { id: `msg-${this._msgIndex++}`, role: 'user', content: userContent, adapter: 'claude' }; + } + + private _parseAssistantEntry(entry: JsonlEntry): ParsedMessage | null { + const content = entry.message?.content || entry.content; + if (!content) return null; + + // Track pending tool_use blocks + if (Array.isArray(content)) { + for (const block of content as ContentBlock[]) { + if (block.type === 'tool_use') { + this.pendingTools.set(block.id!, { + toolUseId: block.id!, + name: block.name!, + input: block.input as Record, + status: 'running', + result: null, + }); + } + } + } + + // Skip "No response requested" type messages + const text = extractText(content); + if (isNoResponseMessage(text)) return null; + + // Return content array directly (not the message wrapper) + const asstContent: ContentBlock[] = Array.isArray(content) ? content as ContentBlock[] : [{ type: 'text', text: String(content) }]; + return { id: `msg-${this._msgIndex++}`, role: 'assistant', content: asstContent, adapter: 'claude' }; + } + + private _processAgentProgress(entry: JsonlEntry): void { + const result = extractSubTools(entry); + if (result) { + for (const subTool of result.subTools) { + // Track in pendingTools with parent reference + this.pendingTools.set(subTool.id, { + toolUseId: subTool.id, name: subTool.name, input: subTool.input, + status: 'running', result: null, parentToolUseId: result.parentId, + }); + } + // Accumulate for injection into parent message content (same-batch / history) + if (!this._pendingSubTools.has(result.parentId)) this._pendingSubTools.set(result.parentId, []); + this._pendingSubTools.get(result.parentId)!.push(...result.subTools); + } + + // Process tool_result entries (update status of pending sub-tools) + const msg = entry.data?.message?.message; + if (msg?.role === 'user' && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type !== 'tool_result' || !block.tool_use_id) continue; + const pending = this.pendingTools.get(block.tool_use_id); + if (pending) { + pending.status = block.is_error ? 'error' : 'success'; + pending.result = block; + } + } + } + } + +} diff --git a/server/adapters/codex/codex-tmux-adapter.ts b/server/adapters/codex/codex-tmux-adapter.ts new file mode 100644 index 0000000..45cb37f --- /dev/null +++ b/server/adapters/codex/codex-tmux-adapter.ts @@ -0,0 +1,822 @@ +// server/adapters/codex/codex-tmux-adapter.ts +// +// Session lifecycle management for Codex CLI sessions running in tmux. +// +// Key difference from Claude's TmuxAdapter: +// - Claude has many hook events (PreToolUse, PostToolUse, etc.) for tool lifecycle +// - Codex only has 3 hooks: SessionStart, UserPromptSubmit, Stop +// - All tool events come from JSONL watching (via CodexTranscriptParser) +// - JSONL watcher starts when SessionStart hook fires (provides transcript_path) + +import { EventEmitter } from 'events'; +import { tmuxManager } from '../shared/tmux-manager.js'; +import { CodexPaneMonitor } from './pane-monitor.js'; +import { JsonlWatcher } from '../../stores/jsonl-watcher.js'; +import { CodexTranscriptParser } from './transcript-parser.js'; +import type { CodexJsonlEntry } from './transcript-parser.js'; +import type { PermissionBehavior, QueryOptions } from '../../types/messages.js'; +import type { ReconnectState } from '../../types/adapter.js'; +import type { ActiveSessionInfo } from '../interface.js'; +import { isLargeContent } from '../interface.js'; +import { PermissionManager } from '../../permission-manager.js'; +import { readFile } from 'fs/promises'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Hook body payload from the Codex CLI */ +export interface CodexHookBody { + session_id: string; + cwd: string; + model: string; + permission_mode: string; + source: string; // 'startup' | 'resume' | 'clear' + transcript_path: string | null; + hook_event_name: string; + [key: string]: unknown; +} + +/** Internal session state for a managed tmux session */ +export interface CodexSessionState { + windowId: string; + monitor: CodexPaneMonitor | null; + watcher: JsonlWatcher | null; + parser: CodexTranscriptParser | null; + cwd: string; + cliSessionId: string; // UUID from hook session_id + transcriptPath: string | null; // from SessionStart hook — path to JSONL file + approvalPolicy: string; // 'on-request', 'never', 'untrusted' + lastActivity: number; + firstPrompt: string | null; + isProcessing: boolean; + _promptSenderClientId: string | null; + _watcherPending: boolean; // true until SessionStart hook provides transcript_path + _matchRetryTimer: ReturnType | null; +} + +/** Hook body with timestamp for age-based cleanup */ +type PendingHookBody = CodexHookBody & { _storedAt: number }; + +/** Resolved session context from _resolveAndTouch */ +interface ResolvedContext { + sessionId: string; + session: CodexSessionState | undefined; +} + +// --------------------------------------------------------------------------- +// CodexTmuxAdapter +// --------------------------------------------------------------------------- + +/** + * CodexTmuxAdapter — manages Codex CLI sessions via tmux. + * + * Three channels provide events to the SessionManager: + * 1. HTTP Hooks (lifecycle): SessionStart, UserPromptSubmit, Stop + * 2. JSONL Watcher (messages + tools): tool-start, tool-done, new-messages, status-update + * 3. PaneMonitor (ephemeral): streaming-text, thinking, approval-prompt + * + * Events emitted: + * streaming-text(sessionId, text) + * thinking(sessionId, { text, detail }) + * tool-start(sessionId, { toolId, toolName, input }) + * tool-done(sessionId, { toolId, toolName, result }) + * tool-updates(sessionId, toolsMap) + * new-messages(sessionId, messages[]) + * session-idle(sessionId) + * processing-started(sessionId) + * status-update(sessionId, { contextPercent, model, cost }) + * approval-prompt(sessionId, { command, explanation }) + * session-ended(sessionId) + */ +export class CodexTmuxAdapter extends EventEmitter { + // sessionId (CLI UUID) -> session state + sessions: Map; + // Centralized pending permissions/questions manager + private _permissions: PermissionManager; + // Set by SessionManager to check if WS clients are connected + private _clientChecker: ((sessionId: string) => boolean) | null; + private _cleanupInterval: ReturnType | null; + private _pendingHookBodies: Map = new Map(); + + constructor() { + super(); + this.sessions = new Map(); + this._permissions = new PermissionManager(); + this._clientChecker = null; + this._cleanupInterval = null; + this._startSessionCleanup(); + } + + /** Set a function that checks if WS clients are connected for a session */ + setClientChecker(fn: (sessionId: string) => boolean): void { + this._clientChecker = fn; + } + + // === Session Lifecycle === + + async startSession(cwd: string, options: QueryOptions = {}): Promise<{ sessionId: string }> { + const parts = ['codex', '--no-alt-screen', '-C', cwd]; + const mode = options.permissionMode; + this._appendPermissionFlags(parts, mode); + if (options.model) parts.push('-m', options.model); + if (options.effort) parts.push('-c', `model_reasoning_effort=${options.effort}`); + + const tempName = `codex-${Date.now()}`; + const windowId = await tmuxManager.createWindow(tempName, cwd, parts.join(' ')); + + // Register session BEFORE _waitForReady — SessionStart hook fires during + // CLI startup and needs to find this session in the Map for matching. + const tempKey = tempName; + const approvalPolicy = mode || 'default'; + this.sessions.set(tempKey, this._createSession(windowId, cwd, '', approvalPolicy)); + + await this._waitForReady(windowId); + + // After _waitForReady, SessionStart hook may have fired and rekeyed + // the session from tempKey to the real CLI UUID. Return the current key. + // (Currently Codex fires SessionStart after first prompt, so rekey doesn't + // happen here — but this guards against future CLI timing changes.) + let finalId = tempKey; + if (!this.sessions.has(tempKey)) { + const rekeyed = [...this.sessions.entries()].find(([, s]) => s.windowId === windowId)?.[0]; + if (rekeyed) { + finalId = rekeyed; + } else { + console.warn(`[codex-tmux] Session ${tempKey} vanished during startup (windowId=${windowId})`); + } + } + + this._startMonitor(finalId, windowId); + + return { sessionId: finalId }; + } + + async resumeSession(sessionId: string, cwd: string, options: QueryOptions = {}): Promise<{ sessionId: string }> { + const session = this.sessions.get(sessionId); + const codexUuid = session?.cliSessionId || sessionId; + const mode = options.permissionMode || session?.approvalPolicy || 'default'; + const approvalPolicy = mode; + + // Check if tmux window still alive + if (session) { + const windows = await tmuxManager.listWindows(); + if (windows.some(w => w.id === session.windowId)) { + if (!session.monitor) this._startMonitor(sessionId, session.windowId); + session.approvalPolicy = approvalPolicy; + session.lastActivity = Date.now(); + return { sessionId }; + } + // Window gone — teardown old + this._teardownSession(session); + } + + const cliUuid = codexUuid; // CLI UUID for `codex resume ` + const parts = ['codex', 'resume', cliUuid, '--no-alt-screen', '-C', cwd]; + this._appendPermissionFlags(parts, mode); + if (options.model) parts.push('-m', options.model); + if (options.effort) parts.push('-c', `model_reasoning_effort=${options.effort}`); + + const newSessionId = codexUuid; // Key by CLI UUID + const windowId = await tmuxManager.createWindow(codexUuid, cwd, parts.join(' ')); + + // Register before _waitForReady — same pattern as startSession + if (session) { + // Session exists under old key — move to new key (may be same if already CLI UUID) + if (sessionId !== newSessionId) this.sessions.delete(sessionId); + session.windowId = windowId; + session.lastActivity = Date.now(); + session.approvalPolicy = approvalPolicy; + session._watcherPending = true; + session.transcriptPath = null; + session.watcher = null; + session.parser = null; + this.sessions.set(newSessionId, session); + } else { + this.sessions.set(newSessionId, this._createSession(windowId, cwd, cliUuid, approvalPolicy)); + } + + await this._waitForReady(windowId); + + this._startMonitor(newSessionId, windowId); + return { sessionId: newSessionId }; + } + + /** + * Toggle Plan Mode ↔ current mode via Shift+Tab. + * Codex only supports 2-mode toggle (not Claude's 4-mode cycle). + */ + async switchPermissionMode(sessionId: string, targetMode: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return false; + // One Shift+Tab press toggles Plan ↔ current + await tmuxManager.sendControl(session.windowId, 'BTab'); + // Update local state — toggle is deterministic (frontend sends correct target) + session.approvalPolicy = targetMode; + return true; + } + + async sendMessage(sessionId: string, text: string, options: QueryOptions = {}): Promise { + const session = this.sessions.get(sessionId); + if (!session) throw new Error(`Session ${sessionId} not found`); + + session._promptSenderClientId = options.clientId || null; + session.isProcessing = true; + + // Restart pane monitor if it was stopped + if (!session.monitor) { + this._startMonitor(sessionId, session.windowId); + } + + if (isLargeContent(text)) { + // Large/multiline content: replace newlines with literal \\n so Codex TUI + // treats it as one message, then use pasteBuffer for speed. + const singleLine = text.replace(/\n/g, '\\n'); + + // Codex TUI shows placeholder text on fresh sessions. pasteBuffer appends + // to the placeholder, truncating the first ~20 chars. Fix: if content starts + // with CLAWTAP_REF marker, send it via sendKeys first (clears placeholder), + // then pasteBuffer the rest. + const markerMatch = singleLine.match(/^\[CLAWTAP_REF:[^\]]+\]/); + if (markerMatch) { + const marker = markerMatch[0]; + const rest = singleLine.substring(marker.length); + await tmuxManager.sendKeys(session.windowId, marker, false); + await new Promise(r => setTimeout(r, 200)); + if (rest) { + await tmuxManager.pasteBuffer(session.windowId, rest, false); + } + } else { + await tmuxManager.pasteBuffer(session.windowId, singleLine, false); + } + await new Promise(r => setTimeout(r, 300)); + await tmuxManager.sendControl(session.windowId, 'Enter'); + } else { + // Short text: send character-by-character via sendKeys + await tmuxManager.sendKeys(session.windowId, text, false); + await new Promise(r => setTimeout(r, 200)); + await tmuxManager.sendControl(session.windowId, 'Enter'); + } + + // If there are pending hook bodies waiting for marker matching, try now + if (this._pendingHookBodies.size > 0 && session._watcherPending) { + this._tryMatchPending(sessionId); + } + } + + async switchModel(sessionId: string, model: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return; + await tmuxManager.sendKeys(session.windowId, `/model ${model}`, false); + await new Promise(r => setTimeout(r, 200)); + await tmuxManager.sendControl(session.windowId, 'Enter'); + } + + async interrupt(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return; + + await tmuxManager.sendControl(session.windowId, 'C-c'); + session.isProcessing = false; + if (session.monitor) { + session.monitor.stop(); + session.monitor = null; + } + } + + async destroySession(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return; + + this._teardownSession(session); + await tmuxManager.killWindow(session.windowId); + this.sessions.delete(sessionId); + this.emit('session-ended', sessionId); + } + + // === Hook Handlers === + + /** + * Handle the SessionStart hook from Codex CLI. + * + * This is the moment we learn the transcript_path and can start the JSONL watcher. + * It may also be the first time we see the Codex UUID for sessions started via startSession(). + */ + handleSessionStart(body: CodexHookBody): void { + const codexUuid = body.session_id; + if (!codexUuid) return; + + // 1. Already managed (resume, or session with known UUID) + if (this.sessions.has(codexUuid)) { + this._applySessionStartBody(codexUuid, body); + return; + } + + // 2. Find pending sessions (_watcherPending === true) + const pending = [...this.sessions.entries()].filter(([, s]) => s._watcherPending); + if (pending.length === 0) return; // Not our session + + // 3. Exactly 1 pending → direct match (no marker needed) + if (pending.length === 1) { + const [tempKey] = pending[0]; + console.log(`[codex-tmux] Direct match: ${tempKey} → ${codexUuid}`); + this._rekeyAndRename(tempKey, codexUuid); + this._applySessionStartBody(codexUuid, body); + return; + } + + // 4. Multiple pending → store, wait for sendMessage to disambiguate via marker + this._pendingHookBodies.set(codexUuid, { ...body, _storedAt: Date.now() }); + } + + /** + * Called after sendMessage when _pendingHookBodies has entries. + * Reads each pending hook body's transcript_path to find the CLAWTAP_REF marker. + */ + private async _tryMatchPending(tempKey: string): Promise { + if (await this._scanPendingForMarker(tempKey)) return; + + // Marker not found yet — Codex may still be writing. Retry once after 2s. + const session = this.sessions.get(tempKey); + if (!session) return; + if (session._matchRetryTimer) clearTimeout(session._matchRetryTimer); + session._matchRetryTimer = setTimeout(async () => { + const s = this.sessions.get(tempKey); + if (!s || !s._watcherPending || !this._pendingHookBodies.size) return; + await this._scanPendingForMarker(tempKey); + }, 2000); + } + + /** Scan _pendingHookBodies for a transcript containing CLAWTAP_REF:{tempKey}. */ + private async _scanPendingForMarker(tempKey: string): Promise { + for (const [uuid, body] of this._pendingHookBodies) { + if (!body.transcript_path) continue; + try { + const content = await readFile(body.transcript_path, 'utf8'); + if (!content.includes(`CLAWTAP_REF:${tempKey}`)) continue; + console.log(`[codex-tmux] Marker match: ${tempKey} → ${uuid}`); + this._pendingHookBodies.delete(uuid); + this._rekeyAndRename(tempKey, uuid); + this._applySessionStartBody(uuid, body); + return true; + } catch { continue; } + } + return false; + } + + /** Apply hook body state and start watcher — shared by all handleSessionStart branches */ + private _applySessionStartBody(sessionId: string, body: CodexHookBody): void { + const session = this.sessions.get(sessionId); + if (!session) return; + if (!session.cliSessionId) session.cliSessionId = body.session_id; + if (body.cwd) session.cwd = body.cwd; + if (body.permission_mode) session.approvalPolicy = body.permission_mode; + session.lastActivity = Date.now(); + if (body.transcript_path && !session.transcriptPath) { + session.transcriptPath = body.transcript_path; + } + + // Start JSONL watcher if we have a transcript path and watcher isn't already running + if (session.transcriptPath && !session.watcher) { + const skipExisting = session.isProcessing !== false; + this._startWatcher(sessionId, session, skipExisting); + } + session._watcherPending = false; + } + + /** + * Handle the UserPromptSubmit hook from Codex CLI. + */ + handleUserPromptSubmit(body: CodexHookBody): void { + const ctx = this._resolveAndTouch(body); + if (!ctx) return; + + const { sessionId, session } = ctx; + if (session) { + session.isProcessing = true; + if (!session.monitor && session.windowId) { + this._startMonitor(sessionId, session.windowId); + } + } + + this.emit('processing-started', sessionId); + } + + /** + * Handle the Stop hook from Codex CLI. + */ + handleStop(body: CodexHookBody): void { + const ctx = this._resolveAndTouch(body); + if (!ctx) return; + + const { sessionId, session } = ctx; + if (session) { + session.isProcessing = false; + if (session.monitor) { + session.monitor.stop(); + session.monitor = null; + } + // Flush JSONL watcher to get final entries + if (session.watcher) { + session.watcher.pollNow(); + } + } + + this.emit('session-idle', sessionId); + this._permissions.dismissAll(sessionId); + } + + // === JSONL Watcher === + + /** + * Process raw JSONL entries through the transcript parser and emit events. + */ + private _processWatcherEntries(sessionId: string, rawEntries: unknown[]): void { + const session = this.sessions.get(sessionId); + if (!session?.parser) return; + + const entries = rawEntries as CodexJsonlEntry[]; + const result = session.parser.processNewEntries(entries); + + // Emit tool lifecycle events + for (const ts of result.toolStarts) { + this.emit('tool-start', sessionId, ts); + } + for (const td of result.toolDones) { + this.emit('tool-done', sessionId, td); + } + + // Emit messages + if (result.messages.length > 0) { + // Capture first user prompt for active sessions list + if (!session.firstPrompt) { + const userMsg = result.messages.find(m => m.role === 'user'); + if (userMsg) { + const text = userMsg.content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map(c => c.text) + .join('\n'); + if (text) { + const stripped = text.replace(/^(?:\[CLAWTAP_REF:[^\]]+\]|\d+\])(?:\\n|\n)?/, ''); + session.firstPrompt = stripped.substring(0, 200); + } + } + } + + // Tag user messages with sender's client ID so only the sender skips (dedup) + for (const msg of result.messages) { + if (msg.role === 'user' && session._promptSenderClientId) { + msg.senderClientId = session._promptSenderClientId; + session._promptSenderClientId = null; + } + } + + this.emit('new-messages', sessionId, result.messages); + } + + // Emit tool updates map + if (result.toolUpdates) { + this.emit('tool-updates', sessionId, result.toolUpdates); + } + + // Emit status update + if (result.statusUpdate) { + this.emit('status-update', sessionId, result.statusUpdate); + } + + // Handle turn completion from JSONL (task_complete/turn_aborted). + // Only emit if session is still processing — prevents duplicate session-idle + // when the Stop hook already fired (hook sets isProcessing=false first). + if (result.turnComplete && session.isProcessing) { + session.isProcessing = false; + if (session.monitor) { + session.monitor.stop(); + session.monitor = null; + } + this.emit('session-idle', sessionId); + } + } + + // === Query Methods === + + getSession(sessionId: string): CodexSessionState | undefined { + return this.sessions.get(sessionId); + } + + getActiveSessions(): ActiveSessionInfo[] { + const result: ActiveSessionInfo[] = []; + for (const [sessionId, session] of this.sessions) { + result.push({ + sessionId, + cwd: session.cwd, + adapter: 'codex', + permissionMode: session.approvalPolicy, + lastActivity: session.lastActivity || null, + hasClients: this._clientChecker ? this._clientChecker(sessionId) : false, + hasDesktop: !!(session.lastActivity && (Date.now() - session.lastActivity < 120_000)), + isNonInteractive: false, // Codex doesn't have non-interactive mode detection + firstPrompt: session.firstPrompt || null, + }); + } + return result; + } + + async hasActiveWindow(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return false; + const windows = await tmuxManager.listWindows(); + return windows.some(w => w.id === session.windowId); + } + + isProcessing(sessionId: string): boolean { + const session = this.sessions.get(sessionId); + return !!(session?.isProcessing); + } + + /** Force an immediate JSONL poll for a session */ + flushMessages(sessionId: string): void { + const session = this.sessions.get(sessionId); + if (session?.watcher) session.watcher.pollNow(); + } + + /** Advance watcher past current file position without emitting entries */ + syncWatcherPosition(sessionId: string): void { + const session = this.sessions.get(sessionId); + if (session?.watcher) session.watcher.markCurrentPosition(); + } + + /** Get pending state for reconnecting clients (tools, permissions, questions) */ + getReconnectState(sessionId: string): ReconnectState { + const session = this.sessions.get(sessionId); + const state: ReconnectState = { tools: {}, pendingRequests: [] }; + + if (session?.parser) { + const tools = session.parser.getPendingTools(); + if (tools.size > 0) { + state.tools = Object.fromEntries(tools) as unknown as Record; + } + } + + for (const perm of this._permissions.getPendingForSession(sessionId)) { + state.pendingRequests.push({ + type: 'permission', + requestId: perm.requestId, + toolName: perm.toolName, + input: perm.input, + }); + } + for (const q of this._permissions.getQuestionsForSession(sessionId)) { + state.pendingRequests.push({ + type: 'question', + requestId: q.requestId, + toolName: 'AskUserQuestion', + input: q.originalInput, + }); + } + + return state; + } + + // === Permission Methods === + + respondPermission(requestId: string, behavior: PermissionBehavior): void { + const pending = this._permissions.resolvePermission(requestId); + if (!pending) return; + + const session = this.sessions.get(pending.sessionId); + if (!session) return; + + // Codex approval via tmux keystroke + if (behavior === 'allow' || behavior === 'allow_session') { + // Send 'y' to approve + tmuxManager.sendKeys(session.windowId, 'y', true).catch(() => {}); + } else { + // Send 'n' to deny + tmuxManager.sendKeys(session.windowId, 'n', true).catch(() => {}); + } + } + + async respondQuestion(requestId: string, answer: string): Promise { + const pending = this._permissions.resolveQuestion(requestId); + if (!pending) return; + + const session = this.sessions.get(pending.sessionId); + if (!session) return; + + await tmuxManager.sendKeys(session.windowId, answer, true); + } + + /** Release all pending requests for a session (e.g., when Mobile disconnects). */ + releaseAllPending(sessionId: string): void { + this._permissions.dismissAll(sessionId); + } + + resolveAllPendingAs(sessionId: string, behavior: PermissionBehavior | string): void { + const resolvedIds = this._permissions.resolveAllAs(sessionId, behavior as string); + if (behavior === 'allow') { + const session = this.sessions.get(sessionId); + if (session) { + for (const _reqId of resolvedIds) { + tmuxManager.sendKeys(session.windowId, 'y', true).catch(() => {}); + } + } + } + } + + // === Cleanup === + + async destroy(): Promise { + if (this._cleanupInterval) { + clearInterval(this._cleanupInterval); + this._cleanupInterval = null; + } + for (const [, session] of this.sessions) { + this._teardownSession(session); + } + this.sessions.clear(); + await tmuxManager.killSession(); + } + + // === Internal Helpers === + + /** Append the correct permission flags based on the permission mode string. */ + private _appendPermissionFlags(parts: string[], mode?: string): void { + if (mode === 'bypassPermissions') { + parts.push('--dangerously-bypass-approvals-and-sandbox'); + } else if (mode === 'fullAuto') { + parts.push('--full-auto'); + } else if (mode === 'untrusted') { + parts.push('-a', 'untrusted'); + } else { + parts.push('-a', 'on-request'); + } + } + + /** Resolve hook body to internal session, touch lastActivity */ + private _resolveAndTouch(body: CodexHookBody): ResolvedContext | null { + const sessionId = body.session_id; + if (!sessionId || !this.sessions.has(sessionId)) return null; + + const session = this.sessions.get(sessionId); + if (session) session.lastActivity = Date.now(); + + return { sessionId, session }; + } + + private _createSession( + windowId: string, + cwd: string, + cliSessionId: string, + approvalPolicy: string, + ): CodexSessionState { + return { + windowId, + monitor: null, + watcher: null, + parser: null, + cwd, + cliSessionId, + transcriptPath: null, + approvalPolicy, + lastActivity: Date.now(), + firstPrompt: null, + isProcessing: false, + _promptSenderClientId: null, + _watcherPending: true, + _matchRetryTimer: null, + }; + } + + /** + * Wait for Codex CLI to be ready (show the › prompt). + * Polls tmux pane content until the prompt indicator appears. + */ + private async _waitForReady(windowId: string, timeoutMs: number = 30000): Promise { + const start = Date.now(); + let attempt = 0; + while (Date.now() - start < timeoutMs) { + attempt++; + try { + const content = await tmuxManager.capturePane(windowId); + const lines = content.split('\n'); + // Codex shows › as the input prompt + const hasPrompt = lines.some(l => /^\s*›/.test(l)); + const lineCount = lines.filter(l => l.trim()).length; + if (attempt <= 3 || attempt % 5 === 0) { + console.log(`[codex-tmux] waitForReady #${attempt}: window=${windowId} prompt=${hasPrompt} lines=${lineCount}`); + } + if (hasPrompt && lineCount >= 3) { + console.log(`[codex-tmux] CLI ready for ${windowId} in ${Date.now() - start}ms`); + // Extra settle time for Codex TUI to fully render after prompt appears + await new Promise(r => setTimeout(r, 300)); + return; + } + } catch (err) { + console.log(`[codex-tmux] waitForReady #${attempt}: ERROR ${(err as Error).message}`); + } + await new Promise(r => setTimeout(r, 500)); + } + console.warn(`[codex-tmux] Timed out waiting for CLI ready on ${windowId}`); + } + + /** + * Re-key a session from tempKey to the real CLI UUID and rename the tmux window. + */ + private _rekeyAndRename(tempKey: string, cliUuid: string): void { + const session = this.sessions.get(tempKey); + if (!session) return; + session.cliSessionId = cliUuid; + session._watcherPending = false; + this.sessions.delete(tempKey); + this.sessions.set(cliUuid, session); + tmuxManager.renameWindow(session.windowId, cliUuid).catch(() => {}); + if (session.monitor) { + (session.monitor as any).sessionId = cliUuid; + } + // Notify session-manager to re-register clients under the new key + this.emit('session-rekeyed', tempKey, cliUuid); + } + + private _startMonitor(sessionId: string, windowId: string): void { + const session = this.sessions.get(sessionId); + if (!session) return; + + // Stop existing monitor if any + if (session.monitor) { + session.monitor.stop(); + } + + const monitor = new CodexPaneMonitor(sessionId, windowId, tmuxManager, this); + monitor.start(); + session.monitor = monitor; + } + + private _startWatcher(sessionId: string, session: CodexSessionState, skipExisting = true): void { + if (!session.transcriptPath) return; + if (session.watcher) return; + + const parser = new CodexTranscriptParser(); + const watcher = new JsonlWatcher(session.transcriptPath); + + watcher.onNewEntries((entries) => { + this._processWatcherEntries(sessionId, entries); + }); + + watcher.start({ skipExisting, fallbackIntervalMs: 1000 }); + session.watcher = watcher; + session.parser = parser; + session._watcherPending = false; + } + + private _teardownSession(session: CodexSessionState): void { + if (session.monitor) { + session.monitor.stop(); + session.monitor = null; + } + if (session.watcher) { + session.watcher.stop(); + session.watcher = null; + session.parser = null; + } + if (session._matchRetryTimer) { + clearTimeout(session._matchRetryTimer); + session._matchRetryTimer = null; + } + } + + private _startSessionCleanup(): void { + this._cleanupInterval = setInterval(async () => { + const windows = await tmuxManager.listWindows(); + const liveWindowIds = new Set(windows.map(w => w.id)); + + for (const [sessionId, session] of this.sessions) { + if (session.windowId && !liveWindowIds.has(session.windowId)) { + console.log(`[codex-tmux] Stale session ${sessionId} — tmux window gone, cleaning up`); + this._teardownSession(session); + this.sessions.delete(sessionId); + this.emit('session-ended', sessionId); + } + } + + // Cap at 10 managed sessions + if (this.sessions.size > 10) { + const sorted = [...this.sessions.entries()] + .sort((a, b) => (b[1].lastActivity || 0) - (a[1].lastActivity || 0)); + for (const [id] of sorted.slice(10)) { + const s = this.sessions.get(id); + if (s) this._teardownSession(s); + this.sessions.delete(id); + this.emit('session-ended', id); + } + } + + // Clean up stale pending hook bodies (age-based sweep) + for (const [uuid, body] of this._pendingHookBodies) { + const age = Date.now() - body._storedAt; + if (age > 60_000) this._pendingHookBodies.delete(uuid); + } + }, 60_000); + // Don't keep the process alive just for cleanup + this._cleanupInterval.unref(); + } +} diff --git a/server/adapters/codex/hook-config.ts b/server/adapters/codex/hook-config.ts new file mode 100644 index 0000000..c1e9c21 --- /dev/null +++ b/server/adapters/codex/hook-config.ts @@ -0,0 +1,250 @@ +// server/adapters/codex/hook-config.ts +// +// Pure filesystem operations for Codex hook management. +// Zero runtime dependencies — no EventEmitter, no tmux, no sessions. +// +// Key differences from Claude's hook-config: +// - Hooks live in ~/.codex/hooks.json (dedicated file, not mixed with other settings) +// - Only 3 hook events: SessionStart, UserPromptSubmit, Stop +// - No statusLine wrapping (Codex has no statusLine hook) +// - Additionally manages codex_hooks feature flag in ~/.codex/config.toml + +import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { homedir } from 'os'; +import { fileURLToPath } from 'url'; +import { parse as parseTOML, stringify as stringifyTOML } from 'smol-toml'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** Individual hook action (command or url based) */ +interface HookAction { + type?: string; + command?: string; + url?: string; + timeout?: number; +} + +/** A hook entry within a hook event */ +interface HookEntry { + matcher?: string; + hooks: HookAction[]; +} + +/** Hook identifiers for matching our entries */ +interface HookIdentifiers { + portTag: string; + hookPath: string; +} + +/** The structure of Codex's hooks.json */ +interface CodexHooksFile { + hooks?: Record; +} + +export class CodexHookConfig { + port: number | string; + useHttps: boolean; + + constructor(port?: number | string, useHttps?: boolean) { + this.port = port || process.env.PORT || 3456; + if (useHttps !== undefined) { + this.useHttps = useHttps; + } else { + // Auto-detect from cert files + const clawtapDir = join(homedir(), '.clawtap'); + this.useHttps = existsSync(join(clawtapDir, 'cert.pem')) && existsSync(join(clawtapDir, 'key.pem')); + } + } + + /** Install ClawTap hooks into ~/.codex/hooks.json and enable feature flag in config.toml */ + install(): void { + const port = this.port; + const codexDir = join(homedir(), '.codex'); + const hooksPath = join(codexDir, 'hooks.json'); + const configTomlPath = join(codexDir, 'config.toml'); + + const { portTag, hookPath } = this._hookIdentifiers(); + const protocol = this.useHttps ? 'https' : 'http'; + const hookUrl = `${protocol}://localhost:${port}/api/hooks/codex`; + const desiredHooks = this._buildDesiredHooks(hookUrl, hookPath); + + try { + mkdirSync(codexDir, { recursive: true }); + + // --- 1. Write hooks.json --- + let existing: CodexHooksFile = {}; + try { existing = JSON.parse(readFileSync(hooksPath, 'utf-8')) as CodexHooksFile; } catch {} + + if (!existing.hooks) existing.hooks = {}; + + for (const [event, configs] of Object.entries(desiredHooks)) { + const existingEntries = existing.hooks[event] || []; + const filtered = existingEntries.filter(entry => !this._isOurHookEntry(entry, portTag, hookPath)); + existing.hooks[event] = [...filtered, ...configs]; + } + + writeFileSync(hooksPath, JSON.stringify(existing, null, 2)); + console.log(`[hooks:codex] Auto-configured hooks in ${hooksPath}`); + + // --- 2. Enable codex_hooks feature flag in config.toml --- + this._setFeatureFlag(configTomlPath, true); + } catch (err) { + console.warn(`[hooks:codex] Failed to auto-configure hooks: ${(err as Error).message}`); + } + } + + /** + * Remove ClawTap hooks from ~/.codex/hooks.json. + * Optionally remove the codex_hooks feature flag from config.toml. + */ + uninstall(): void { + const { portTag, hookPath } = this._hookIdentifiers(); + const codexDir = join(homedir(), '.codex'); + const hooksPath = join(codexDir, 'hooks.json'); + const configTomlPath = join(codexDir, 'config.toml'); + + try { + const existing: CodexHooksFile = JSON.parse(readFileSync(hooksPath, 'utf-8')) as CodexHooksFile; + + if (existing.hooks) { + const hookKeys = Object.keys(this._buildDesiredHooks('', '')); + for (const key of hookKeys) { + const entries = existing.hooks[key]; + if (!Array.isArray(entries)) continue; + + const filtered = entries.filter(entry => !this._isOurHookEntry(entry, portTag, hookPath)); + + if (filtered.length === 0) { + delete existing.hooks[key]; + } else { + existing.hooks[key] = filtered; + } + } + + if (Object.keys(existing.hooks).length === 0) delete existing.hooks; + } + + writeFileSync(hooksPath, JSON.stringify(existing, null, 2)); + console.log(`[hooks:codex] Removed ClawTap hooks from ${hooksPath}`); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + // hooks.json doesn't exist — nothing to clean up + } else { + console.warn(`[hooks:codex] Failed to remove hooks: ${(err as Error).message}`); + } + } + + // Remove the feature flag + try { + this._setFeatureFlag(join(homedir(), '.codex', 'config.toml'), false); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn(`[hooks:codex] Failed to update config.toml: ${(err as Error).message}`); + } + } + } + + // --- Internal helpers --- + + private _hookIdentifiers(): HookIdentifiers { + return { + portTag: `:${this.port}/api/hooks/codex`, + hookPath: join(__dirname, '..', '..', '..', 'bin', 'clawtap-hook'), + }; + } + + private _isOurHookEntry(entry: HookEntry, portTag: string, hookPath: string): boolean { + const hooks = entry.hooks || []; + return hooks.some(h => + (h.url && h.url.includes(portTag)) || + (h.command && (h.command === hookPath || h.command.includes(portTag))) + ); + } + + private _buildDesiredHooks(hookUrl: string, hookPath: string): Record { + // Fire-and-forget: read stdin, background curl, exit immediately. + // Zero blocking — Codex never waits for ClawTap. + // NOTE: No /dev/tcp port check — Codex executes hooks with zsh, which doesn't + // support /dev/tcp (bash-only). curl's --connect-timeout handles the "not listening" case. + const curlInsecure = this.useHttps ? ' -k' : ''; + const fireAndForget = (endpoint: string): string => + `input=$(cat); printf '%s' "$input" | curl -sf${curlInsecure} --connect-timeout 2 --max-time 5 -X POST -H 'Content-Type:application/json' -d @- ${hookUrl}/${endpoint} &>/dev/null &`; + + return { + SessionStart: [{ hooks: [{ type: 'command', command: fireAndForget('session-start'), timeout: 2 }] }], + UserPromptSubmit: [{ hooks: [{ type: 'command', command: fireAndForget('user-prompt-submit'), timeout: 2 }] }], + Stop: [{ hooks: [{ type: 'command', command: fireAndForget('stop'), timeout: 2 }] }], + }; + } + + /** + * Auto-trust a directory in config.toml so Codex doesn't show an interactive + * trust prompt that blocks _waitForReady. + */ + trustDirectory(dirPath: string): void { + const configPath = join(homedir(), '.codex', 'config.toml'); + let config: Record = {}; + + try { + config = parseTOML(readFileSync(configPath, 'utf-8')) as Record; + } catch { + if (existsSync(configPath)) return; // Corrupted file — don't touch + } + + if (!config.project) config.project = {}; + const projects = config.project as Record; + + // Already trusted + if (projects[dirPath]?.trust_level === 'trusted') return; + + // Add trust + projects[dirPath] = { ...(projects[dirPath] || {}), trust_level: 'trusted' }; + + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, stringifyTOML(config)); + } + + /** + * Set or remove the codex_hooks feature flag in config.toml. + * + * Uses smol-toml parser to safely modify the TOML file without corrupting + * other sections (e.g., [project."..."] with paths containing special chars). + */ + private _setFeatureFlag(configPath: string, enable: boolean): void { + let config: Record = {}; + + // Parse existing config (if any) + try { + const content = readFileSync(configPath, 'utf-8'); + config = parseTOML(content) as Record; + } catch (err) { + if (existsSync(configPath)) { + // File exists but has invalid TOML — don't overwrite it + console.warn(`[hooks:codex] Warning: ${configPath} has invalid TOML, skipping modification`); + return; + } + // File doesn't exist — start fresh + } + + if (enable) { + if (!config.features) config.features = {}; + (config.features as Record).codex_hooks = true; + } else { + if (config.features && typeof config.features === 'object') { + delete (config.features as Record).codex_hooks; + // Remove empty [features] section + if (Object.keys(config.features as object).length === 0) { + delete config.features; + } + } + } + + // Only write if there's content or we're enabling + if (enable || Object.keys(config).length > 0) { + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, stringifyTOML(config)); + console.log(`[hooks:codex] ${enable ? 'Enabled' : 'Disabled'} codex_hooks in ${configPath}`); + } + } +} diff --git a/server/adapters/codex/index.ts b/server/adapters/codex/index.ts new file mode 100644 index 0000000..7af52c0 --- /dev/null +++ b/server/adapters/codex/index.ts @@ -0,0 +1,197 @@ +// server/adapters/codex/index.ts +import { IAdapter } from '../interface.js'; +import type { DirectoryEntry, ActiveSessionInfo, MessagesResult, CachedStatus } from '../interface.js'; +import { CodexTmuxAdapter } from './codex-tmux-adapter.js'; +import type { CodexSessionState, CodexHookBody } from './codex-tmux-adapter.js'; +import { CodexHookConfig } from './hook-config.js'; +import { + getSessions, getMessages, listDirectory, +} from './jsonl-store.js'; +import type { QueryOptions, PermissionBehavior } from '../../types/messages.js'; +import type { AdapterCapabilities, ModelInfo, PermissionModeInfo, EffortLevelInfo, ReconnectState, SessionInfo } from '../../types/adapter.js'; +import type { Express } from 'express'; + + + +const MODELS: ModelInfo[] = [ + { value: 'gpt-5.4', label: 'GPT-5.4', contextWindow: 258400 }, + { value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini', contextWindow: 258400 }, + { value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex', contextWindow: 258400 }, + { value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex', contextWindow: 258400 }, + { value: 'gpt-5.2', label: 'GPT-5.2', contextWindow: 258400 }, + { value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max', contextWindow: 258400 }, + { value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex', contextWindow: 258400 }, + { value: 'gpt-5.1', label: 'GPT-5.1', contextWindow: 258400 }, + { value: 'gpt-5-codex', label: 'GPT-5 Codex', contextWindow: 258400 }, + { value: 'gpt-5', label: 'GPT-5', contextWindow: 258400 }, + { value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini', contextWindow: 258400 }, + { value: 'gpt-5-codex-mini', label: 'GPT-5 Codex Mini', contextWindow: 258400 }, +]; + +const PERMISSION_MODES: PermissionModeInfo[] = [ + { value: 'default', label: 'Suggest' }, + { value: 'fullAuto', label: 'Full Auto' }, + { value: 'untrusted', label: 'Untrusted' }, + { value: 'bypassPermissions', label: 'YOLO' }, +]; + +const EFFORT_LEVELS: EffortLevelInfo[] = [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'xhigh', label: 'X-High' }, +]; + +export class CodexAdapter extends IAdapter { + static id: string = 'codex'; + static displayName: string = 'Codex CLI'; + static command: string = 'codex'; + + private _tmux: CodexTmuxAdapter; + private _hookConfig: CodexHookConfig; + private _lastStatus: Map; // sessionId → { contextPercent, model, cost } + + constructor() { + super(); + this._tmux = new CodexTmuxAdapter(); + this._hookConfig = new CodexHookConfig(); + this._lastStatus = new Map(); + + // Forward all events from internal tmux adapter + const events: string[] = [ + 'streaming-text', 'thinking', 'tool-start', 'tool-done', + 'tool-updates', 'new-messages', 'session-idle', + 'permission-request', 'ask-question', 'mode-changed', + 'session-ended', 'session-error', 'compacting', 'compact-done', + 'processing-started', 'session-rekeyed', + ]; + for (const event of events) { + this._tmux.on(event, (...args: unknown[]) => this.emit(event, ...args)); + } + + // Don't forward status-update blindly — deduplicate first + this._tmux.on('status-update', (sessionId: string, status: any) => { + const prev = this._lastStatus.get(sessionId); + if (prev && + prev.contextPercent === status.contextPercent && + prev.model === status.model && + prev.cost === status.cost) return; + this._lastStatus.set(sessionId, status); + this.emit('status-update', sessionId, status); + }); + + // Clean up status dedup cache when session ends + this._tmux.on('session-ended', (sessionId: string) => { + this._lastStatus.delete(sessionId); + }); + } + + setup(app: Express): void { + this.installHooks(); + this._registerHookRoutes(app); + } + + installHooks(): void { this._hookConfig.install(); } + uninstallHooks(): void { this._hookConfig.uninstall(); } + + async cleanup(): Promise { + this.uninstallHooks(); + await this._tmux.destroy(); + } + + /** + * Register Express routes for Codex-specific hooks. + * These are called by the Codex CLI from localhost (no auth needed). + */ + private _registerHookRoutes(app: Express): void { + // All hooks are fire-and-forget notifications — no return value used. + // Handlers are called for side effects only (emit events, update state). + const hookRoute = (path: string, handler: (body: CodexHookBody) => void | Promise): void => { + const label = path.split('/').pop(); + app.post(path, (req: any, res: any) => { + const sid = req.body.session_id?.substring(0, 8) || '?'; + const toolInfo = req.body.tool_name ? ` ${req.body.tool_name}` : ''; + console.log(`[hook] ${label}:${toolInfo} sid=${sid}`); + try { + const result = handler(req.body); + if (result instanceof Promise) result.catch((e: Error) => console.error(`[hook] ${label} error:`, e.message)); + } catch (e) { console.error(`[hook] ${label} error:`, (e as Error).message); } + res.json({}); + }); + }; + + const prefix = this.getHookPrefix(); // /api/hooks/codex + + hookRoute(`${prefix}/session-start`, (body) => { + this._tmux.handleSessionStart(body); + }); + hookRoute(`${prefix}/user-prompt-submit`, (body) => { + this._tmux.handleUserPromptSubmit(body); + }); + hookRoute(`${prefix}/stop`, (body) => { + this._tmux.handleStop(body); + }); + } + + setClientChecker(fn: (sessionId: string) => boolean): void { + this._tmux.setClientChecker(fn); + } + + // Lifecycle — delegate to tmux adapter + async startSession(cwd: string, options?: QueryOptions): Promise<{ sessionId: string }> { + try { this._hookConfig.trustDirectory(cwd); } catch {} // Auto-trust cwd to prevent interactive prompt + return this._tmux.startSession(cwd, options); + } + async resumeSession(sid: string, cwd: string, options?: QueryOptions): Promise<{ sessionId: string }> { + if (cwd) try { this._hookConfig.trustDirectory(cwd); } catch {} + return this._tmux.resumeSession(sid, cwd, options); + } + async attachSession(_sid: string, _cwd: string, _options?: QueryOptions): Promise<{ sessionId: string }> { throw new Error('Codex does not support attach'); } + async destroySession(sid: string): Promise { return this._tmux.destroySession(sid); } + async sendMessage(sid: string, text: string, options?: QueryOptions): Promise { return this._tmux.sendMessage(sid, text, options); } + async switchModel(sid: string, model: string): Promise { return this._tmux.switchModel(sid, model); } + async interrupt(sid: string): Promise { return this._tmux.interrupt(sid); } + flushMessages(sid: string): void { this._tmux.flushMessages(sid); } + syncWatcherPosition(sid: string): void { this._tmux.syncWatcherPosition(sid); } + getReconnectState(sid: string): ReconnectState { return this._tmux.getReconnectState(sid); } + + // Store — delegate to jsonl-store + async getSessions(dir?: string, limit?: number): Promise { return getSessions(dir, limit); } + async getMessages(sid: string, dir?: string): Promise { return getMessages(sid, dir); } + async listDirectory(path?: string): Promise { return listDirectory(path); } + + // Permissions — delegate to tmux adapter + async switchPermissionMode(sid: string, mode: string): Promise { return this._tmux.switchPermissionMode(sid, mode); } + respondPermission(reqId: string, behavior: PermissionBehavior): void { this._tmux.respondPermission(reqId, behavior); } + async respondQuestion(reqId: string, answer: string): Promise { return this._tmux.respondQuestion(reqId, answer); } + releaseAllPending(sid: string): void { this._tmux.releaseAllPending(sid); } + resolveAllPendingAs(sid: string, behavior: PermissionBehavior): void { this._tmux.resolveAllPendingAs(sid, behavior); } + + // Query + isProcessing(sid: string): boolean { return this._tmux.isProcessing(sid); } + getSession(sid: string): CodexSessionState | undefined { return this._tmux.getSession(sid); } + getLastStatus(sid: string) { return this._lastStatus.get(sid) || null; } + async hasActiveWindow(sid: string): Promise { return this._tmux.hasActiveWindow(sid); } + getActiveSessions(): ActiveSessionInfo[] { return this._tmux.getActiveSessions(); } + + // Capabilities + getModels(): ModelInfo[] { return MODELS; } + getPermissionModes(): PermissionModeInfo[] { return PERMISSION_MODES; } + getEffortLevels(): EffortLevelInfo[] { return EFFORT_LEVELS; } + getEffortLabel(): string { return 'Effort'; } + + getCapabilities(): AdapterCapabilities { + return { + supportsPlanMode: false, + supportsPermissionModes: true, + supportsInterrupt: true, + supportsResume: true, + supportsAttach: false, + supportsStatusLine: true, + supportsImages: true, + supportsStreaming: true, + maxContextWindow: 258_400, + permissionModeType: 'toggle', + }; + } +} diff --git a/server/adapters/codex/jsonl-store.ts b/server/adapters/codex/jsonl-store.ts new file mode 100644 index 0000000..0c3b5e3 --- /dev/null +++ b/server/adapters/codex/jsonl-store.ts @@ -0,0 +1,284 @@ +import { readdir, stat } from 'fs/promises'; +import { join } from 'path'; +import { homedir } from 'os'; +import { createReadStream } from 'fs'; +import { createInterface } from 'readline'; +import { CodexTranscriptParser } from './transcript-parser.js'; +import type { CodexJsonlEntry } from './transcript-parser.js'; +import type { DirectoryEntry, MessagesResult } from '../interface.js'; +import type { SessionInfo } from '../../types/adapter.js'; + +// --- Constants --- + +export const CODEX_DIR: string = join(homedir(), '.codex'); +export const SESSIONS_DIR: string = join(CODEX_DIR, 'sessions'); +export const HISTORY_FILE: string = join(CODEX_DIR, 'history.jsonl'); + +// --- History index entry --- + +interface HistoryEntry { + session_id: string; + ts: number; + text: string; +} + +// --- Helpers --- + +/** + * Scan ~/.codex/sessions/YYYY/MM/DD/ directories from newest to oldest. + * Match filename containing the session UUID. + * Filename pattern: rollout-YYYY-MM-DDTHH-MM-SS-{uuid}.jsonl + * + * Strategy: list year/month/day dirs in descending order so the newest + * match is found first — most session lookups are for recent sessions. + */ +export async function findSessionFile(sessionId: string, sessionsDir?: string): Promise { + const baseDir = sessionsDir || SESSIONS_DIR; + + let years: string[]; + try { + years = await readdir(baseDir); + } catch { + return null; + } + + // Sort descending (newest first) + years.sort((a, b) => b.localeCompare(a)); + + for (const year of years) { + const yearPath = join(baseDir, year); + const yearStat = await stat(yearPath).catch(() => null); + if (!yearStat?.isDirectory()) continue; + + let months: string[]; + try { + months = await readdir(yearPath); + } catch { + continue; + } + months.sort((a, b) => b.localeCompare(a)); + + for (const month of months) { + const monthPath = join(yearPath, month); + const monthStat = await stat(monthPath).catch(() => null); + if (!monthStat?.isDirectory()) continue; + + let days: string[]; + try { + days = await readdir(monthPath); + } catch { + continue; + } + days.sort((a, b) => b.localeCompare(a)); + + for (const day of days) { + const dayPath = join(monthPath, day); + const dayStat = await stat(dayPath).catch(() => null); + if (!dayStat?.isDirectory()) continue; + + let files: string[]; + try { + files = await readdir(dayPath); + } catch { + continue; + } + + // Look for a file that contains the session UUID + for (const file of files) { + if (file.endsWith('.jsonl') && file.includes(sessionId)) { + return join(dayPath, file); + } + } + } + } + } + + return null; +} + +// --- Session meta parsing --- + +interface SessionMeta { + cwd: string | null; + model: string | null; +} + +async function parseSessionMeta(filePath: string): Promise { + const stream = createReadStream(filePath); + const rl = createInterface({ input: stream, crlfDelay: Infinity }); + let cwd: string | null = null; + let model: string | null = null; + + try { + for await (const line of rl) { + if (!line.trim()) continue; + try { + const entry: CodexJsonlEntry = JSON.parse(line); + if (entry.type === 'session_meta') { + cwd = entry.payload?.cwd ?? null; + model = entry.payload?.model_provider ?? entry.payload?.model ?? null; + break; + } + // Also check turn_context for model info + if (entry.type === 'turn_context' && entry.payload?.model) { + model = entry.payload.model; + } + } catch { + // Skip unparseable lines + } + } + } finally { + rl.close(); + stream.destroy(); + } + + return { cwd, model }; +} + +// --- Session Listing --- + +export async function getSessions(dir?: string, limit?: number): Promise { + // Read history.jsonl for fast session index + let historyEntries: HistoryEntry[] = []; + + try { + const stream = createReadStream(HISTORY_FILE); + const rl = createInterface({ input: stream, crlfDelay: Infinity }); + try { + for await (const line of rl) { + if (!line.trim()) continue; + try { + const entry = JSON.parse(line) as HistoryEntry; + if (entry.session_id && entry.ts) { + historyEntries.push(entry); + } + } catch { + // Skip unparseable lines + } + } + } finally { + rl.close(); + stream.destroy(); + } + } catch { + // history.jsonl doesn't exist or is unreadable — return empty + return []; + } + + // Sort by timestamp descending (newest first) + historyEntries.sort((a, b) => b.ts - a.ts); + + // Deduplicate by session_id — history.jsonl has one entry per user message, + // keep only the first (newest) entry per session + const seen = new Set(); + historyEntries = historyEntries.filter(e => { + if (seen.has(e.session_id)) return false; + seen.add(e.session_id); + return true; + }); + + // Apply limit + if (limit && limit > 0) { + historyEntries = historyEntries.slice(0, limit); + } + + // For each history entry, find the JSONL file and parse session_meta + const sessions = await Promise.all( + historyEntries.map(async (entry): Promise => { + try { + const filePath = await findSessionFile(entry.session_id); + + let cwd: string | null = null; + let model: string | null = null; + + if (filePath) { + const meta = await parseSessionMeta(filePath); + cwd = meta.cwd; + model = meta.model; + } + + return { + sessionId: entry.session_id, + cwd, + lastModified: entry.ts * 1000, // Convert to ms timestamp + firstPrompt: entry.text + ? entry.text.replace(/^(?:\[CLAWTAP_REF:[^\]]+\]|\d+\])(?:\\n|\n)?/, '').slice(0, 200) + : null, + model, + }; + } catch { + return null; + } + }) + ); + + let result = sessions.filter((s): s is SessionInfo => s !== null); + + // Filter by directory if provided (same behavior as Claude's per-project filtering) + if (dir) { + result = result.filter(s => s.cwd === dir); + } + + return result; +} + +// --- Message Reading --- + +export async function getMessages(sessionId: string, dir?: string): Promise { + const filePath = await findSessionFile(sessionId); + + if (!filePath) { + return { messages: [], lastModified: null }; + } + + try { + const entries: CodexJsonlEntry[] = []; + const stream = createReadStream(filePath); + const rl = createInterface({ input: stream, crlfDelay: Infinity }); + + try { + for await (const line of rl) { + if (!line.trim()) continue; + try { + const entry: CodexJsonlEntry = JSON.parse(line); + entries.push(entry); + } catch { + // Skip unparseable lines + } + } + } finally { + rl.close(); + stream.destroy(); + } + + const parser = new CodexTranscriptParser(); + const messages = parser.parseForHistory(entries); + + const fileMtime = await stat(filePath); + return { messages, lastModified: fileMtime.mtime.toISOString() }; + } catch { + return { messages: [], lastModified: null }; + } +} + +// --- Directory Browser --- + +export async function listDirectory(dirPath?: string): Promise { + const target = dirPath || homedir(); + const entries = await readdir(target, { withFileTypes: true }); + const visible = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.')); + + const dirs = await Promise.all( + visible.map(async (entry): Promise => { + const fullPath = join(target, entry.name); + let hasChildren = false; + try { + const children = await readdir(fullPath, { withFileTypes: true }); + hasChildren = children.some((c) => c.isDirectory() && !c.name.startsWith('.')); + } catch {} + return { name: entry.name, path: fullPath, hasChildren }; + }) + ); + + return dirs.sort((a, b) => a.name.localeCompare(b.name)); +} diff --git a/server/adapters/codex/message-utils.ts b/server/adapters/codex/message-utils.ts new file mode 100644 index 0000000..5f929e8 --- /dev/null +++ b/server/adapters/codex/message-utils.ts @@ -0,0 +1,62 @@ +/** A content block within a Codex message */ +export interface CodexContentBlock { + type: 'input_text' | 'output_text' | 'input_image' | string; + text?: string; + image?: { url: string }; + [key: string]: unknown; +} + +/** Standard normalized content block (matches claw-tap frontend format) */ +export interface NormalizedBlock { + type: string; + text?: string; + [key: string]: unknown; +} + +const SYSTEM_PATTERNS: RegExp[] = [ + //i, + //i, + /AGENTS\.md/, +]; + +/** + * Convert a Codex content block to the standard format used by claw-tap. + * + * - `input_text` → `{ type: 'text', text }` + * - `output_text` → `{ type: 'text', text }` + * - Unknown types are passed through as-is. + */ +export function normalizeContentBlock(block: CodexContentBlock): NormalizedBlock { + if (block.type === 'input_text' || block.type === 'output_text') { + return { type: 'text', text: block.text }; + } + return { ...block } as NormalizedBlock; +} + +/** + * Extract readable text from an array of Codex content blocks. + * Concatenates all text-bearing blocks (input_text / output_text) with newlines. + */ +export function extractText(content: CodexContentBlock[]): string { + return content + .filter((b) => b.type === 'input_text' || b.type === 'output_text') + .map((b) => b.text ?? '') + .join('\n'); +} + +/** + * Return true if this message should be filtered out as a system message. + * + * Matches: + * - `role === 'developer'` + * - Text containing ``, ``, or `AGENTS.md` + */ +export function isSystemMessage(role: string, content: CodexContentBlock[]): boolean { + if (role === 'developer') return true; + + const text = extractText(content); + for (const pattern of SYSTEM_PATTERNS) { + if (pattern.test(text)) return true; + } + return false; +} diff --git a/server/adapters/codex/pane-monitor.ts b/server/adapters/codex/pane-monitor.ts new file mode 100644 index 0000000..ae29da2 --- /dev/null +++ b/server/adapters/codex/pane-monitor.ts @@ -0,0 +1,284 @@ +// server/adapters/codex/pane-monitor.ts +// +// Polls a tmux pane every 500ms to capture real-time streaming output from +// the Codex CLI running in --no-alt-screen mode. +// +// Detects: +// 1. Streaming response text (new text since last poll) +// 2. Thinking indicators (spinner / processing patterns) +// 3. Approval prompts (Codex waiting for user to approve a command) +// +// Modelled after server/adapters/claude/pane-monitor.ts but with +// Codex-specific regex patterns. Patterns are conservative placeholders +// that will be refined through empirical testing with the actual Codex TUI. + +import { EventEmitter } from 'events'; + +/** Minimal interface for the tmux manager dependency */ +interface TmuxCapture { + capturePane(windowId: string, lines?: number): Promise; +} + +/** Thinking indicator detected from pane content */ +export interface ThinkingInfo { + text: string; + detail: string | null; +} + +/** + * CodexPaneMonitor — polls a tmux pane to detect streaming text, + * thinking indicators, and approval prompts from the Codex CLI. + * + * Events emitted via the injected EventEmitter: + * - 'streaming-text' (sessionId, newText) + * - 'thinking' (sessionId, { text, detail }) + * - 'approval-prompt' (sessionId, { command, explanation }) + */ +export class CodexPaneMonitor { + private sessionId: string; + private windowId: string; + private tmux: TmuxCapture; + private emitter: EventEmitter; + private interval: ReturnType | null = null; + private _lastContent: string = ''; + private _lastResponseText: string = ''; + + constructor( + sessionId: string, + windowId: string, + tmuxManager: TmuxCapture, + emitter: EventEmitter, + ) { + this.sessionId = sessionId; + this.windowId = windowId; + this.tmux = tmuxManager; + this.emitter = emitter; + } + + /** Begin polling the tmux pane at 500ms intervals */ + start(): void { + if (this.interval) return; + this.interval = setInterval(() => this._poll(), 500); + } + + /** Stop polling and clear the interval */ + stop(): void { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + } + + /** Force an immediate poll (useful on hook receipt) */ + async pollNow(): Promise { + await this._poll(); + } + + // --------------------------------------------------------------------------- + // Internal + // --------------------------------------------------------------------------- + + private async _poll(): Promise { + try { + const content = await this.tmux.capturePane(this.windowId); + if (content === this._lastContent) return; + this._lastContent = content; + + // 1. Check for approval prompt (highest priority — blocks everything) + const approval = detectApprovalPrompt(content); + if (approval) { + this.emitter.emit('approval-prompt', this.sessionId, approval); + return; + } + + // 2. Check for thinking indicator + const thinking = detectThinking(content); + if (thinking) { + this.emitter.emit('thinking', this.sessionId, thinking); + return; + } + + // 3. Extract streaming response text + const text = extractResponseText(content); + if (text && text !== this._lastResponseText) { + this._lastResponseText = text; + this.emitter.emit('streaming-text', this.sessionId, text); + } + } catch { + // Silently ignore — tmux window may have been killed + } + } +} + +// ============================================================================= +// Detection functions (exported for unit testing) +// ============================================================================= + +/** + * Detect Codex thinking/processing indicators. + * + * Codex CLI shows various spinner/processing patterns while reasoning. + * In --no-alt-screen mode these appear as inline text in the pane. + * + * Placeholder patterns — will be refined through empirical testing: + * - "Thinking..." or "Reasoning..." text + * - Spinner characters (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ braille spinner set) + * - "Loading..." or processing indicators + */ +export function detectThinking(content: string): ThinkingInfo | null { + const lines = content.split('\n'); + // Only check the tail of the pane (last 15 lines) + const tail = lines.slice(-15); + + for (const line of tail) { + // Skip completion/summary lines + if (/completed|finished|done|exited/i.test(line)) continue; + + // Pattern 1: Braille spinner followed by descriptive text + // e.g. "⠙ Thinking..." or "⠹ Processing..." + const brailleMatch = line.match(/^\s*([⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏])\s+(.+?)\s*$/); + if (brailleMatch) { + return { text: brailleMatch[2]!, detail: null }; + } + + // Pattern 2: Explicit "Thinking..." or "Reasoning..." text + const thinkingMatch = line.match(/^\s*(Thinking|Reasoning|Processing)(\.\.\.)?\s*(?:\((.+?)\))?\s*$/i); + if (thinkingMatch) { + return { + text: `${thinkingMatch[1]}...`, + detail: thinkingMatch[3] || null, + }; + } + + // Pattern 3: Dash/line spinner (e.g. "- thinking" or "| working") + const dashSpinner = line.match(/^\s*[|/\-\\]\s+(thinking|reasoning|working)\b/i); + if (dashSpinner) { + return { text: `${dashSpinner[1]}...`, detail: null }; + } + } + + return null; +} + +/** + * Extract the current streaming response text from pane content. + * + * Codex in --no-alt-screen mode writes responses inline. We look for text + * after the last user input marker and collect lines until we hit a + * boundary indicator. + * + * Placeholder patterns — will be refined through empirical testing: + * - User input prompt: ">" or "❯" followed by user text + * - Response boundary: horizontal rules, new prompts, tool output markers + */ +export function extractResponseText(content: string): string { + const lines = content.split('\n'); + + // Find the LAST user prompt line — responses appear after it + let lastUserPrompt = -1; + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]!; + // Codex user prompt patterns (conservative): + // - ">" or "❯" at start of line followed by user text + // - "user:" prefix + if (/^\s*[>❯]\s+\S/.test(line) || /^\s*user:\s/i.test(line)) { + lastUserPrompt = i; + break; + } + } + + if (lastUserPrompt === -1) return ''; + + // Collect response lines after the user prompt + // Skip the prompt line itself and any blank lines immediately after + let responseStart = lastUserPrompt + 1; + while (responseStart < lines.length && lines[responseStart]!.trim() === '') { + responseStart++; + } + + if (responseStart >= lines.length) return ''; + + const responseLines: string[] = []; + for (let i = responseStart; i < lines.length; i++) { + const line = lines[i]!; + + // Stop at boundary markers + if ( + // Horizontal rules + /^[─━═\-]{5,}/.test(line.trim()) || + // New user prompt + /^\s*[>❯]\s+\S/.test(line) || + // Spinner/thinking indicators (braille set) + /^\s*[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s+/.test(line) || + // Tool execution markers (Codex shows commands it wants to run) + /^\s*\$\s+/.test(line) || + // Approval prompt boundary + /approve|deny|allow|reject/i.test(line) && /\?\s*$/.test(line.trim()) + ) { + break; + } + responseLines.push(line); + } + + return responseLines.join('\n').trim(); +} + +/** + * Detect an approval prompt — Codex waiting for user to approve a command. + * + * When Codex wants to execute a command (shell, file write, etc.) and + * requires approval, it displays the command and waits for input. + * + * Placeholder patterns — will be refined through empirical testing: + * - "Run command?" or "Execute?" prompts + * - Command display followed by [y/n] or approve/deny prompt + */ +export function detectApprovalPrompt( + content: string, +): { command: string; explanation: string } | null { + const lines = content.split('\n'); + // Only check the tail of the pane (last 20 lines) for approval prompts + const tail = lines.slice(-20); + const tailText = tail.join('\n'); + + // Pattern 1: "Run ? [y/n]" style + const runMatch = tailText.match( + /(?:Run|Execute|Allow)\s+(?:command\s*)?[:\-]?\s*[`"]?(.+?)[`"]?\s*\?\s*(?:\[([yYnN/]+)\])?\s*$/m, + ); + if (runMatch) { + return { + command: runMatch[1]!.trim(), + explanation: 'Codex is requesting approval to run a command', + }; + } + + // Pattern 2: Command displayed in a block followed by approval prompt + // e.g.: + // $ some-command --flag + // Approve? (y/n) + const blockMatch = tailText.match( + /\$\s+(.+)\n[\s\S]*?(?:Approve|Allow|Confirm)\s*\?\s*(?:\(([yYnN/]+)\))?\s*$/m, + ); + if (blockMatch) { + return { + command: blockMatch[1]!.trim(), + explanation: 'Codex is requesting approval to execute a command', + }; + } + + // Pattern 3: Generic approval/permission prompt at the end of pane + // Catches "Do you want to proceed?" style prompts + const genericMatch = tail.slice(-5).join('\n').match( + /(?:proceed|continue|approve|allow)\s*\?\s*(?:\(([yYnN/]+)\))?\s*$/im, + ); + if (genericMatch) { + // Try to extract the command from lines above the prompt + const commandLine = tail.slice(-10, -3).find((l) => /^\s*\$\s+\S/.test(l)); + return { + command: commandLine ? commandLine.replace(/^\s*\$\s+/, '').trim() : '(unknown)', + explanation: 'Codex is requesting approval to proceed', + }; + } + + return null; +} diff --git a/server/adapters/codex/transcript-parser.ts b/server/adapters/codex/transcript-parser.ts new file mode 100644 index 0000000..bb57c90 --- /dev/null +++ b/server/adapters/codex/transcript-parser.ts @@ -0,0 +1,388 @@ +import { + normalizeContentBlock, + extractText, + isSystemMessage, +} from './message-utils.js'; +import type { CodexContentBlock, NormalizedBlock } from './message-utils.js'; +import type { ChatMessage, MessageContent } from '../../types/messages.js'; + +// --------------------------------------------------------------------------- +// Codex JSONL entry shape +// --------------------------------------------------------------------------- + +export interface CodexJsonlEntry { + timestamp: string; // ISO 8601 + type: + | 'session_meta' + | 'response_item' + | 'event_msg' + | 'turn_context' + | 'compacted'; + payload: any; +} + +// --------------------------------------------------------------------------- +// Pending tool tracking +// --------------------------------------------------------------------------- + +export interface PendingTool { + toolUseId: string; + name: string; + input: Record; + status: 'running' | 'success' | 'error'; + result: string | null; +} + +// --------------------------------------------------------------------------- +// Parse result types +// --------------------------------------------------------------------------- + +export interface ToolStartEvent { + toolId: string; + toolName: string; + input: Record; +} + +export interface ToolDoneEvent { + toolId: string; + toolName: string; + input: Record; + result: string; +} + +export interface StatusUpdate { + contextPercent: number | null; + model: string | null; + cost: number | null; +} + +export interface ProcessResult { + messages: ChatMessage[]; + toolStarts: ToolStartEvent[]; + toolDones: ToolDoneEvent[]; + statusUpdate: StatusUpdate | null; + toolUpdates: Record | null; + turnComplete: boolean; // true when task_complete or turn_aborted is seen +} + +// --------------------------------------------------------------------------- +// CodexTranscriptParser +// --------------------------------------------------------------------------- + +export class CodexTranscriptParser { + pendingTools: Map; + private _model: string | null; + private _msgIndex: number = 0; + + constructor() { + this.pendingTools = new Map(); + this._model = null; + } + + /** + * Process new JSONL entries into frontend-ready events. + * + * Returns messages, tool lifecycle events, and status updates. + */ + processNewEntries(entries: CodexJsonlEntry[]): ProcessResult { + const messages: ChatMessage[] = []; + const toolStarts: ToolStartEvent[] = []; + const toolDones: ToolDoneEvent[] = []; + let statusUpdate: StatusUpdate | null = null; + let turnComplete = false; + + for (const entry of entries) { + switch (entry.type) { + case 'response_item': + this._processResponseItem(entry.payload, messages, toolStarts, toolDones); + break; + + case 'event_msg': { + const result = this._processEventMsg(entry.payload, statusUpdate); + statusUpdate = result.status; + if (result.turnComplete) turnComplete = true; + break; + } + + case 'turn_context': + if (entry.payload?.model) { + this._model = entry.payload.model; + // Ensure statusUpdate reflects the newly-seen model + if (!statusUpdate) { + statusUpdate = { contextPercent: null, model: this._model, cost: null }; + } else { + statusUpdate.model = this._model; + } + } + break; + + case 'session_meta': + case 'compacted': + // Skip — metadata / compaction markers + break; + } + } + + // Build toolUpdates map if any pending tools changed + let toolUpdates: Record | null = null; + if (toolStarts.length > 0 || toolDones.length > 0) { + const running = this.getPendingTools(); + if (running.size > 0) { + toolUpdates = Object.fromEntries(running); + } + } + + return { messages, toolStarts, toolDones, statusUpdate, toolUpdates, turnComplete }; + } + + /** + * Parse entries for history view — returns only messages (with tool blocks inlined). + */ + parseForHistory(entries: CodexJsonlEntry[]): ChatMessage[] { + this._msgIndex = 0; + const messages: ChatMessage[] = []; + + for (const entry of entries) { + if (entry.type !== 'response_item') continue; + + const payload = entry.payload; + if (!payload) continue; + + const itemType = payload.type; + + if (itemType === 'message') { + const role = payload.role; + if (role === 'developer') continue; + + const content = Array.isArray(payload.content) ? payload.content : []; + + if (role === 'user') { + if (isSystemMessage(role, content)) continue; + const normalized = this._normalizeContentBlocks(content); + if (normalized.length > 0) { + messages.push({ id: `msg-${this._msgIndex++}`, role: 'user', content: normalized, adapter: 'codex' }); + } + } else if (role === 'assistant') { + const normalized = this._normalizeContentBlocks(content); + if (normalized.length > 0) { + messages.push({ id: `msg-${this._msgIndex++}`, role: 'assistant', content: normalized, adapter: 'codex' }); + } + } + } else if (itemType === 'function_call' || itemType === 'custom_tool_call') { + // Inline as tool_use block in the last assistant message, or create one + const toolBlock: MessageContent = { + type: 'tool_use', + id: payload.call_id || '', + name: payload.name || 'unknown', + input: this._safeParseInput(payload.arguments || payload.input), + }; + this._appendToLastAssistant(messages, toolBlock); + } else if (itemType === 'function_call_output' || itemType === 'custom_tool_call_output') { + // Inline as tool_result block + const resultBlock: MessageContent = { + type: 'tool_result', + tool_use_id: payload.call_id || '', + content: typeof payload.output === 'string' ? payload.output : JSON.stringify(payload.output ?? ''), + }; + this._appendToLastAssistant(messages, resultBlock); + } + // reasoning, web_search_call — skip for history + } + + return messages; + } + + /** Return only tools with status 'running'. */ + getPendingTools(): Map { + const filtered = new Map(); + for (const [id, tool] of this.pendingTools) { + if (tool.status === 'running') filtered.set(id, tool); + } + return filtered; + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private _processResponseItem( + payload: any, + messages: ChatMessage[], + toolStarts: ToolStartEvent[], + toolDones: ToolDoneEvent[], + ): void { + if (!payload) return; + + const itemType = payload.type; + + switch (itemType) { + case 'message': + this._processMessage(payload, messages); + break; + + case 'function_call': + case 'custom_tool_call': + this._processToolCall(payload, toolStarts); + break; + + case 'function_call_output': + case 'custom_tool_call_output': + this._processToolOutput(payload, toolDones); + break; + + case 'reasoning': + case 'web_search_call': + // Skip + break; + } + } + + private _processMessage(payload: any, messages: ChatMessage[]): void { + const role = payload.role; + const content: CodexContentBlock[] = Array.isArray(payload.content) ? payload.content : []; + + if (role === 'developer') return; + + if (role === 'user') { + if (isSystemMessage(role, content)) return; + const normalized = this._normalizeContentBlocks(content); + if (normalized.length > 0) { + messages.push({ id: `msg-${this._msgIndex++}`, role: 'user', content: normalized, adapter: 'codex' }); + } + return; + } + + if (role === 'assistant') { + const normalized = this._normalizeContentBlocks(content); + if (normalized.length > 0) { + messages.push({ id: `msg-${this._msgIndex++}`, role: 'assistant', content: normalized, adapter: 'codex' }); + } + } + } + + private _processToolCall(payload: any, toolStarts: ToolStartEvent[]): void { + const callId = payload.call_id || ''; + const name = payload.name || 'unknown'; + const input = this._safeParseInput(payload.arguments || payload.input); + + // Track as pending + this.pendingTools.set(callId, { + toolUseId: callId, + name, + input, + status: 'running', + result: null, + }); + + toolStarts.push({ toolId: callId, toolName: name, input }); + } + + private _processToolOutput(payload: any, toolDones: ToolDoneEvent[]): void { + const callId = payload.call_id || ''; + const output = typeof payload.output === 'string' + ? payload.output + : JSON.stringify(payload.output ?? ''); + + const pending = this.pendingTools.get(callId); + if (pending) { + pending.status = 'success'; + pending.result = output; + this.pendingTools.delete(callId); + } + + toolDones.push({ + toolId: callId, + toolName: pending?.name || 'unknown', + input: pending?.input || {}, + result: output, + }); + } + + private _processEventMsg( + payload: any, + current: StatusUpdate | null, + ): { status: StatusUpdate | null; turnComplete: boolean } { + if (!payload) return { status: current, turnComplete: false }; + + if (payload.type === 'token_count') { + const contextPercent = payload.rate_limits?.primary?.used_percent ?? null; + const status: StatusUpdate = { + contextPercent: contextPercent != null ? Math.round(contextPercent) : null, + model: this._model, + cost: null, + }; + return { status, turnComplete: false }; + } + + if (payload.type === 'task_complete' || payload.type === 'turn_aborted') { + return { status: current, turnComplete: true }; + } + + // agent_message, task_started — skip + return { status: current, turnComplete: false }; + } + + /** + * Normalize Codex content blocks to the standard MessageContent format. + */ + private _normalizeContentBlocks(blocks: CodexContentBlock[]): MessageContent[] { + const result: MessageContent[] = []; + for (const block of blocks) { + const normalized = normalizeContentBlock(block); + // Only include blocks that carry meaningful content + if (normalized.type === 'text' && normalized.text != null) { + result.push({ type: 'text', text: normalized.text }); + } else if (normalized.type === 'tool_use') { + result.push({ + type: 'tool_use', + id: (normalized as any).id || '', + name: (normalized as any).name || 'unknown', + input: (normalized as any).input || {}, + }); + } else if (normalized.type === 'tool_result') { + result.push({ + type: 'tool_result', + tool_use_id: (normalized as any).tool_use_id || '', + content: typeof (normalized as any).content === 'string' + ? (normalized as any).content + : JSON.stringify((normalized as any).content ?? ''), + }); + } + // Other block types (input_image, etc.) can be added later + } + return result; + } + + /** + * Safely parse a tool input that may be a JSON string or already an object. + */ + private _safeParseInput(input: unknown): Record { + if (input == null) return {}; + if (typeof input === 'object' && !Array.isArray(input)) { + return input as Record; + } + if (typeof input === 'string') { + try { + const parsed = JSON.parse(input); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + return parsed; + } + } catch {} + } + return { _raw: input }; + } + + /** + * Append a content block to the last assistant message, creating one if needed. + * Used by parseForHistory to inline tool_use / tool_result blocks. + */ + private _appendToLastAssistant(messages: ChatMessage[], block: MessageContent): void { + const last = messages.length > 0 ? messages[messages.length - 1] : null; + if (last && last.role === 'assistant') { + last.content.push(block); + } else { + // Create a new assistant message to hold this block + messages.push({ id: `msg-${this._msgIndex++}`, role: 'assistant', content: [block], adapter: 'codex' }); + } + } +} diff --git a/server/adapters/gemini/bridge.sh b/server/adapters/gemini/bridge.sh new file mode 100755 index 0000000..b763243 --- /dev/null +++ b/server/adapters/gemini/bridge.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Reads JSON from stdin (Gemini hook protocol), POSTs to claw-tap server. +# IMPORTANT: Gemini hooks expect a JSON response on stdout. +# Must write response BEFORE backgrounding curl, or Gemini hangs. +# +# Usage: bridge.sh +# e.g.: bridge.sh session-start 3456 https +ENDPOINT="$1" +PORT="${2:-3456}" +PROTOCOL="${3:-http}" +CURL_K="" +[ "$PROTOCOL" = "https" ] && CURL_K="-k" + +input=$(cat) +printf '{}' + +printf '%s' "$input" | curl -sf $CURL_K --connect-timeout 2 --max-time 5 \ + -X POST -H 'Content-Type:application/json' -d @- \ + "${PROTOCOL}://localhost:${PORT}/api/hooks/gemini/${ENDPOINT}" &>/dev/null & diff --git a/server/adapters/gemini/gemini-tmux-adapter.ts b/server/adapters/gemini/gemini-tmux-adapter.ts new file mode 100644 index 0000000..e1fa234 --- /dev/null +++ b/server/adapters/gemini/gemini-tmux-adapter.ts @@ -0,0 +1,848 @@ +// server/adapters/gemini/gemini-tmux-adapter.ts +// +// Session lifecycle management for Gemini CLI sessions running in tmux. +// +// Key difference from Codex's CodexTmuxAdapter: +// - Gemini has 6 hooks: SessionStart, SessionEnd, BeforeTool, AfterTool, BeforeAgent, AfterAgent +// - Tool lifecycle comes from hooks (BeforeTool/AfterTool), not just JSON watching +// - Uses JsonWatcher (full JSON reparse) instead of JsonlWatcher (append-only) +// - Permission mode uses Ctrl+Y for default <-> yolo toggle + +import { EventEmitter } from 'events'; +import { tmuxManager } from '../shared/tmux-manager.js'; +import { GeminiPaneMonitor } from './pane-monitor.js'; +import { JsonWatcher } from '../../stores/json-watcher.js'; +import { GeminiTranscriptParser } from './transcript-parser.js'; +import type { GeminiSessionMessage } from '../../stores/json-watcher.js'; +import type { PermissionBehavior, QueryOptions } from '../../types/messages.js'; +import type { ReconnectState } from '../../types/adapter.js'; +import type { ActiveSessionInfo } from '../interface.js'; +import { isLargeContent } from '../interface.js'; +import { PermissionManager } from '../../permission-manager.js'; +import { readFile } from 'fs/promises'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Hook body payload from the Gemini CLI */ +export interface GeminiHookBody { + session_id?: string; + cwd?: string; + model?: string; + hook_event_name?: string; + transcript_path?: string; + tool_name?: string; + tool_input?: Record; + tool_response?: unknown; + [key: string]: unknown; +} + +/** Internal session state for a managed tmux session */ +export interface GeminiSessionState { + windowId: string; + monitor: GeminiPaneMonitor | null; + watcher: JsonWatcher | null; + parser: GeminiTranscriptParser | null; + cwd: string; + cliSessionId: string; + transcriptPath: string | null; + permissionMode: string; + lastActivity: number; + firstPrompt: string | null; + isProcessing: boolean; + _promptSenderClientId: string | null; + _watcherPending: boolean; + _matchRetryTimer: ReturnType | null; +} + +/** Hook body with timestamp for age-based cleanup */ +type PendingHookBody = GeminiHookBody & { _storedAt: number }; + +/** Resolved session context from _resolveAndTouch */ +interface ResolvedContext { + sessionId: string; + session: GeminiSessionState | undefined; +} + +// --------------------------------------------------------------------------- +// GeminiTmuxAdapter +// --------------------------------------------------------------------------- + +/** + * GeminiTmuxAdapter — manages Gemini CLI sessions via tmux. + * + * Three channels provide events to the SessionManager: + * 1. HTTP Hooks (lifecycle): SessionStart, SessionEnd, BeforeTool, AfterTool, BeforeAgent, AfterAgent + * 2. JSON Watcher (messages): new-messages, thinking, status-update + * 3. PaneMonitor (ephemeral): streaming-text, thinking + * + * Events emitted: + * streaming-text(sessionId, text) + * thinking(sessionId, { text, detail }) + * tool-start(sessionId, { toolId, toolName, input }) + * tool-done(sessionId, { toolId, toolName, result }) + * new-messages(sessionId, messages[]) + * session-idle(sessionId) + * processing-started(sessionId) + * status-update(sessionId, { model, tokens }) + * session-error(sessionId, { errorType, errorDetails }) + * session-ended(sessionId) + * session-rekeyed(oldKey, newKey) + */ +export class GeminiTmuxAdapter extends EventEmitter { + // sessionId -> session state + sessions: Map; + private _permissions: PermissionManager; + private _clientChecker: ((sessionId: string) => boolean) | null; + private _cleanupInterval: ReturnType | null; + private _pendingHookBodies: Map = new Map(); + // Track tool IDs from BeforeTool → AfterTool so events correlate + private _activeToolId: string | null = null; + + constructor() { + super(); + this.sessions = new Map(); + this._permissions = new PermissionManager(); + this._clientChecker = null; + this._cleanupInterval = null; + this._startSessionCleanup(); + } + + /** Set a function that checks if WS clients are connected for a session */ + setClientChecker(fn: (sessionId: string) => boolean): void { + this._clientChecker = fn; + } + + // === Session Lifecycle === + + async startSession(cwd: string, options: QueryOptions = {}): Promise<{ sessionId: string }> { + const mode = options.permissionMode || 'default'; + const parts = ['gemini', '--approval-mode', this._toCliApprovalMode(mode)]; + if (options.model) parts.push('-m', options.model); + + const tempName = `gemini-${Date.now()}`; + const windowId = await tmuxManager.createWindow(tempName, cwd, parts.join(' ')); + + // Register session BEFORE _waitForReady — SessionStart hook fires during + // CLI startup and needs to find this session in the Map for matching. + this.sessions.set(tempName, this._createSession(windowId, cwd, '', mode)); + + await this._waitForReady(windowId); + + // After _waitForReady, SessionStart hook may have fired and rekeyed + // the session from tempName to the real CLI UUID. Return the current key. + let finalId = tempName; + if (!this.sessions.has(tempName)) { + const rekeyed = [...this.sessions.entries()].find(([, s]) => s.windowId === windowId)?.[0]; + if (rekeyed) { + finalId = rekeyed; + } else { + console.warn(`[gemini-tmux] Session ${tempName} vanished during startup (windowId=${windowId})`); + } + } + + this._startMonitor(finalId, windowId); + + return { sessionId: finalId }; + } + + async resumeSession(sessionId: string, cwd: string, options: QueryOptions = {}): Promise<{ sessionId: string }> { + const session = this.sessions.get(sessionId); + const geminiUuid = session?.cliSessionId || sessionId; + const mode = options.permissionMode || session?.permissionMode || 'default'; + + // Check if tmux window still alive + if (session) { + const windows = await tmuxManager.listWindows(); + if (windows.some(w => w.id === session.windowId)) { + if (!session.monitor) this._startMonitor(sessionId, session.windowId); + session.permissionMode = mode; + session.lastActivity = Date.now(); + return { sessionId }; + } + // Window gone — teardown old + this._teardownSession(session); + } + + const parts = ['gemini', '--resume', geminiUuid, '--approval-mode', this._toCliApprovalMode(mode)]; + if (options.model) parts.push('-m', options.model); + + const newSessionId = geminiUuid; + const windowId = await tmuxManager.createWindow(geminiUuid, cwd, parts.join(' ')); + + // Register before _waitForReady — same pattern as startSession + if (session) { + if (sessionId !== newSessionId) this.sessions.delete(sessionId); + session.windowId = windowId; + session.lastActivity = Date.now(); + session.permissionMode = mode; + session._watcherPending = true; + session.transcriptPath = null; + session.watcher = null; + session.parser = null; + this.sessions.set(newSessionId, session); + } else { + this.sessions.set(newSessionId, this._createSession(windowId, cwd, geminiUuid, mode)); + } + + await this._waitForReady(windowId); + + this._startMonitor(newSessionId, windowId); + return { sessionId: newSessionId }; + } + + /** + * Toggle permission mode via Ctrl+Y. + * Only supports binary toggle: default <-> yolo at runtime. + * auto_edit and plan are only settable at session launch. + */ + async switchPermissionMode(sessionId: string, targetMode: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return false; + // Ctrl+Y toggles default <-> yolo + await tmuxManager.sendControl(session.windowId, 'C-y'); + session.permissionMode = targetMode; + return true; + } + + async sendMessage(sessionId: string, text: string, options: QueryOptions = {}): Promise { + const session = this.sessions.get(sessionId); + if (!session) throw new Error(`Session ${sessionId} not found`); + + session._promptSenderClientId = options.clientId || null; + session.isProcessing = true; + + // Restart pane monitor if it was stopped + if (!session.monitor) { + this._startMonitor(sessionId, session.windowId); + } + + if (isLargeContent(text)) { + const singleLine = text.replace(/\n/g, '\\n'); + + const markerMatch = singleLine.match(/^\[CLAWTAP_REF:[^\]]+\]/); + if (markerMatch) { + const marker = markerMatch[0]; + const rest = singleLine.substring(marker.length); + await tmuxManager.sendKeys(session.windowId, marker, false); + await new Promise(r => setTimeout(r, 200)); + if (rest) { + await tmuxManager.pasteBuffer(session.windowId, rest, false); + } + } else { + await tmuxManager.pasteBuffer(session.windowId, singleLine, false); + } + await new Promise(r => setTimeout(r, 300)); + await tmuxManager.sendControl(session.windowId, 'Enter'); + } else { + await tmuxManager.sendKeys(session.windowId, text, false); + await new Promise(r => setTimeout(r, 200)); + await tmuxManager.sendControl(session.windowId, 'Enter'); + } + + // If there are pending hook bodies waiting for marker matching, try now + if (this._pendingHookBodies.size > 0 && session._watcherPending) { + this._tryMatchPending(sessionId); + } + } + + async switchModel(sessionId: string, model: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return; + await tmuxManager.sendKeys(session.windowId, `/model ${model}`, false); + await new Promise(r => setTimeout(r, 200)); + await tmuxManager.sendControl(session.windowId, 'Enter'); + } + + async interrupt(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return; + + await tmuxManager.sendControl(session.windowId, 'C-c'); + session.isProcessing = false; + if (session.monitor) { + session.monitor.stop(); + session.monitor = null; + } + } + + async destroySession(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return; + + this._teardownSession(session); + await tmuxManager.killWindow(session.windowId); + this.sessions.delete(sessionId); + this.emit('session-ended', sessionId); + } + + // === Hook Handlers === + + /** + * Handle the SessionStart hook from Gemini CLI. + * + * This is the moment we learn the transcript_path and can start the JSON watcher. + * It may also be the first time we see the Gemini session UUID for sessions started via startSession(). + */ + handleSessionStart(body: GeminiHookBody): void { + const geminiUuid = body.session_id; + if (!geminiUuid) return; + + // 1. Already managed (resume, or session with known UUID) + if (this.sessions.has(geminiUuid)) { + this._applySessionStartBody(geminiUuid, body); + return; + } + + // 2. Find pending sessions (_watcherPending === true) + const pending = [...this.sessions.entries()].filter(([, s]) => s._watcherPending); + if (pending.length === 0) return; // Not our session + + // 3. Exactly 1 pending -> direct match (no marker needed) + if (pending.length === 1) { + const [tempKey] = pending[0]; + console.log(`[gemini-tmux] Direct match: ${tempKey} -> ${geminiUuid}`); + this._rekeyAndRename(tempKey, geminiUuid); + this._applySessionStartBody(geminiUuid, body); + return; + } + + // 4. Multiple pending -> store, wait for sendMessage to disambiguate via marker + this._pendingHookBodies.set(geminiUuid, { ...body, _storedAt: Date.now() }); + } + + /** + * Handle the BeforeTool hook from Gemini CLI. + * Emits tool-start for the tool about to run. + */ + handleBeforeTool(body: GeminiHookBody): void { + const ctx = this._resolveAndTouch(body); + if (!ctx) return; + + const { sessionId } = ctx; + const toolId = body.tool_use_id || `${body.tool_name}-${Date.now()}`; + this._activeToolId = toolId; + this.emit('tool-start', sessionId, { + toolId, + toolName: body.tool_name || 'unknown', + input: body.tool_input || {}, + }); + } + + /** + * Handle the AfterTool hook from Gemini CLI. + * Emits tool-done for the tool that just finished. + */ + handleAfterTool(body: GeminiHookBody): void { + const ctx = this._resolveAndTouch(body); + if (!ctx) return; + + const { sessionId } = ctx; + // Use the toolId from BeforeTool if available, ensuring start/done events correlate + const toolId = this._activeToolId || body.tool_use_id || `${body.tool_name}-${Date.now()}`; + this._activeToolId = null; + + let resultStr = ''; + if (body.tool_response !== undefined && body.tool_response !== null) { + resultStr = typeof body.tool_response === 'string' + ? body.tool_response + : JSON.stringify(body.tool_response); + } + + this.emit('tool-done', sessionId, { + toolId, + toolName: body.tool_name || 'unknown', + result: resultStr, + }); + } + + /** + * Handle the BeforeAgent hook from Gemini CLI. + * Signals that the agent is starting to process. + */ + handleBeforeAgent(body: GeminiHookBody): void { + const ctx = this._resolveAndTouch(body); + if (!ctx) return; + + const { sessionId, session } = ctx; + if (session) { + session.isProcessing = true; + if (!session.monitor && session.windowId) { + this._startMonitor(sessionId, session.windowId); + } + } + + this.emit('processing-started', sessionId); + } + + /** + * Handle the AfterAgent hook from Gemini CLI. + * Signals that the agent has finished processing (turn complete). + */ + handleAfterAgent(body: GeminiHookBody): void { + const ctx = this._resolveAndTouch(body); + if (!ctx) return; + + const { sessionId, session } = ctx; + if (session) { + session.isProcessing = false; + if (session.monitor) { + session.monitor.stop(); + session.monitor = null; + } + // Flush JSON watcher to get final entries + if (session.watcher) { + session.watcher.pollNow(); + } + } + + this.emit('session-idle', sessionId); + this._permissions.dismissAll(sessionId); + } + + /** + * Handle the SessionEnd hook from Gemini CLI. + * Cleans up the session. + */ + handleSessionEnd(body: GeminiHookBody): void { + const sessionId = body.session_id; + if (!sessionId) return; + + const session = this.sessions.get(sessionId); + if (!session) return; + + this._teardownSession(session); + this.sessions.delete(sessionId); + this.emit('session-ended', sessionId); + } + + // === JSON Watcher === + + /** + * Process new JSON messages through the transcript parser and emit events. + */ + private _processWatcherMessages(sessionId: string, rawMessages: GeminiSessionMessage[]): void { + const session = this.sessions.get(sessionId); + if (!session?.parser) return; + + const result = session.parser.parse(rawMessages); + + // Emit errors as session-error events + for (const errText of result.errors) { + this.emit('session-error', sessionId, { + errorType: 'gemini_error', + errorDetails: errText, + }); + } + + // Single pass: extract thoughts + status from gemini/info messages + for (const msg of rawMessages) { + if (msg.type === 'gemini') { + const thoughts = GeminiTranscriptParser.extractThoughts(msg); + for (const thought of thoughts) { + this.emit('thinking', sessionId, { + text: thought.subject || 'Thinking...', + detail: thought.description || null, + }); + } + const status = GeminiTranscriptParser.extractStatus(msg); + if (status) this.emit('status-update', sessionId, status); + } else if (msg.type === 'info') { + const status = GeminiTranscriptParser.extractStatus(msg); + if (status) this.emit('status-update', sessionId, status); + } + } + + // Emit messages + if (result.messages.length > 0) { + // Capture first user prompt for active sessions list + if (!session.firstPrompt) { + const userMsg = result.messages.find(m => m.role === 'user'); + if (userMsg) { + const text = userMsg.content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map(c => c.text) + .join('\n'); + if (text) { + const stripped = text.replace(/^(?:\[CLAWTAP_REF:[^\]]+\]|\d+\])(?:\\n|\n)?/, ''); + session.firstPrompt = stripped.substring(0, 200); + } + } + } + + // Tag user messages with sender's client ID so only the sender skips (dedup) + for (const msg of result.messages) { + if (msg.role === 'user' && session._promptSenderClientId) { + msg.senderClientId = session._promptSenderClientId; + session._promptSenderClientId = null; + } + } + + this.emit('new-messages', sessionId, result.messages); + } + } + + // === Query Methods === + + getSession(sessionId: string): GeminiSessionState | undefined { + return this.sessions.get(sessionId); + } + + getActiveSessions(): ActiveSessionInfo[] { + const result: ActiveSessionInfo[] = []; + for (const [sessionId, session] of this.sessions) { + result.push({ + sessionId, + cwd: session.cwd, + adapter: 'gemini', + permissionMode: session.permissionMode, + lastActivity: session.lastActivity || null, + hasClients: this._clientChecker ? this._clientChecker(sessionId) : false, + hasDesktop: !!(session.lastActivity && (Date.now() - session.lastActivity < 120_000)), + isNonInteractive: false, + firstPrompt: session.firstPrompt || null, + }); + } + return result; + } + + async hasActiveWindow(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return false; + const windows = await tmuxManager.listWindows(); + return windows.some(w => w.id === session.windowId); + } + + isProcessing(sessionId: string): boolean { + const session = this.sessions.get(sessionId); + return !!(session?.isProcessing); + } + + /** Force an immediate JSON poll for a session */ + flushMessages(sessionId: string): void { + const session = this.sessions.get(sessionId); + if (session?.watcher) session.watcher.pollNow(); + } + + /** Advance watcher past current file position without emitting messages */ + syncWatcherPosition(sessionId: string): void { + const session = this.sessions.get(sessionId); + if (session?.watcher) session.watcher.markCurrentPosition(); + } + + /** Get pending state for reconnecting clients (tools, permissions, questions) */ + getReconnectState(sessionId: string): ReconnectState { + const state: ReconnectState = { tools: {} as Record, pendingRequests: [] }; + + for (const perm of this._permissions.getPendingForSession(sessionId)) { + state.pendingRequests.push({ + type: 'permission', + requestId: perm.requestId, + toolName: perm.toolName, + input: perm.input, + }); + } + for (const q of this._permissions.getQuestionsForSession(sessionId)) { + state.pendingRequests.push({ + type: 'question', + requestId: q.requestId, + toolName: 'AskUserQuestion', + input: q.originalInput, + }); + } + + return state; + } + + // === Permission Methods === + + respondPermission(requestId: string, behavior: PermissionBehavior): void { + const pending = this._permissions.resolvePermission(requestId); + if (!pending) return; + + const session = this.sessions.get(pending.sessionId); + if (!session) return; + + if (behavior === 'allow' || behavior === 'allow_session') { + tmuxManager.sendKeys(session.windowId, 'y', true).catch(() => {}); + } else { + tmuxManager.sendKeys(session.windowId, 'n', true).catch(() => {}); + } + } + + async respondQuestion(requestId: string, answer: string): Promise { + const pending = this._permissions.resolveQuestion(requestId); + if (!pending) return; + + const session = this.sessions.get(pending.sessionId); + if (!session) return; + + await tmuxManager.sendKeys(session.windowId, answer, true); + } + + /** Release all pending requests for a session */ + releaseAllPending(sessionId: string): void { + this._permissions.dismissAll(sessionId); + } + + resolveAllPendingAs(sessionId: string, behavior: PermissionBehavior | string): void { + const resolvedIds = this._permissions.resolveAllAs(sessionId, behavior as string); + if (behavior === 'allow') { + const session = this.sessions.get(sessionId); + if (session) { + for (const _reqId of resolvedIds) { + tmuxManager.sendKeys(session.windowId, 'y', true).catch(() => {}); + } + } + } + } + + // === Cleanup === + + async destroy(): Promise { + if (this._cleanupInterval) { + clearInterval(this._cleanupInterval); + this._cleanupInterval = null; + } + for (const [, session] of this.sessions) { + this._teardownSession(session); + } + this.sessions.clear(); + await tmuxManager.killSession(); + } + + // === Internal Helpers === + + /** Map permission mode string to Gemini CLI --approval-mode value */ + private _toCliApprovalMode(mode: string): string { + switch (mode) { + case 'yolo': return 'yolo'; + case 'auto_edit': return 'auto_edit'; + case 'plan': return 'plan'; + default: return 'default'; + } + } + + /** Resolve hook body to internal session, touch lastActivity */ + private _resolveAndTouch(body: GeminiHookBody): ResolvedContext | null { + const sessionId = body.session_id; + if (!sessionId) return null; + const session = this.sessions.get(sessionId); + if (!session) return null; + session.lastActivity = Date.now(); + return { sessionId, session }; + } + + private _createSession( + windowId: string, + cwd: string, + cliSessionId: string, + permissionMode: string, + ): GeminiSessionState { + return { + windowId, + monitor: null, + watcher: null, + parser: null, + cwd, + cliSessionId, + transcriptPath: null, + permissionMode, + lastActivity: Date.now(), + firstPrompt: null, + isProcessing: false, + _promptSenderClientId: null, + _watcherPending: true, + _matchRetryTimer: null, + }; + } + + /** + * Wait for Gemini CLI to be ready. + * Polls tmux pane content until a prompt indicator appears. + */ + private async _waitForReady(windowId: string, timeoutMs: number = 30000): Promise { + const start = Date.now(); + let attempt = 0; + while (Date.now() - start < timeoutMs) { + attempt++; + try { + const content = await tmuxManager.capturePane(windowId); + const lines = content.split('\n'); + // Gemini shows > or similar prompt indicator + const hasPrompt = lines.some(l => /^\s*[>❯]/.test(l)); + const lineCount = lines.filter(l => l.trim()).length; + if (attempt <= 3 || attempt % 5 === 0) { + console.log(`[gemini-tmux] waitForReady #${attempt}: window=${windowId} prompt=${hasPrompt} lines=${lineCount}`); + } + if (hasPrompt && lineCount >= 2) { + console.log(`[gemini-tmux] CLI ready for ${windowId} in ${Date.now() - start}ms`); + await new Promise(r => setTimeout(r, 300)); + return; + } + } catch (err) { + console.log(`[gemini-tmux] waitForReady #${attempt}: ERROR ${(err as Error).message}`); + } + await new Promise(r => setTimeout(r, 500)); + } + console.warn(`[gemini-tmux] Timed out waiting for CLI ready on ${windowId}`); + } + + /** Apply hook body state and start watcher — shared by all handleSessionStart branches */ + private _applySessionStartBody(sessionId: string, body: GeminiHookBody): void { + const session = this.sessions.get(sessionId); + if (!session) return; + if (!session.cliSessionId) session.cliSessionId = body.session_id || ''; + if (body.cwd) session.cwd = body.cwd; + if (body.model) { + // Emit initial model as status update + this.emit('status-update', sessionId, { model: body.model, tokens: null }); + } + session.lastActivity = Date.now(); + if (body.transcript_path && !session.transcriptPath) { + session.transcriptPath = body.transcript_path; + } + + // Start JSON watcher if we have a transcript path and watcher isn't already running + if (session.transcriptPath && !session.watcher) { + const skipExisting = session.isProcessing !== false; + this._startWatcher(sessionId, session, skipExisting); + } + session._watcherPending = false; + } + + /** + * Called after sendMessage when _pendingHookBodies has entries. + * Reads each pending hook body's transcript_path to find the CLAWTAP_REF marker. + */ + private async _tryMatchPending(tempKey: string): Promise { + if (await this._scanPendingForMarker(tempKey)) return; + + // Marker not found yet — Gemini may still be writing. Retry once after 2s. + const session = this.sessions.get(tempKey); + if (!session) return; + if (session._matchRetryTimer) clearTimeout(session._matchRetryTimer); + session._matchRetryTimer = setTimeout(async () => { + const s = this.sessions.get(tempKey); + if (!s || !s._watcherPending || !this._pendingHookBodies.size) return; + await this._scanPendingForMarker(tempKey); + }, 2000); + } + + /** Scan _pendingHookBodies for a transcript containing CLAWTAP_REF:{tempKey}. */ + private async _scanPendingForMarker(tempKey: string): Promise { + for (const [uuid, body] of this._pendingHookBodies) { + if (!body.transcript_path) continue; + try { + const content = await readFile(body.transcript_path, 'utf8'); + if (!content.includes(`CLAWTAP_REF:${tempKey}`)) continue; + console.log(`[gemini-tmux] Marker match: ${tempKey} -> ${uuid}`); + this._pendingHookBodies.delete(uuid); + this._rekeyAndRename(tempKey, uuid); + this._applySessionStartBody(uuid, body); + return true; + } catch { continue; } + } + return false; + } + + /** + * Re-key a session from tempKey to the real CLI UUID and rename the tmux window. + */ + private _rekeyAndRename(tempKey: string, cliUuid: string): void { + const session = this.sessions.get(tempKey); + if (!session) return; + session.cliSessionId = cliUuid; + session._watcherPending = false; + this.sessions.delete(tempKey); + this.sessions.set(cliUuid, session); + tmuxManager.renameWindow(session.windowId, cliUuid).catch(() => {}); + if (session.monitor) { + (session.monitor as any).sessionId = cliUuid; + } + // Notify session-manager to re-register clients under the new key + this.emit('session-rekeyed', tempKey, cliUuid); + } + + private _startMonitor(sessionId: string, windowId: string): void { + const session = this.sessions.get(sessionId); + if (!session) return; + + if (session.monitor) { + session.monitor.stop(); + } + + const monitor = new GeminiPaneMonitor(sessionId, windowId, tmuxManager, this); + monitor.start(); + session.monitor = monitor; + } + + private _startWatcher(sessionId: string, session: GeminiSessionState, skipExisting = true): void { + if (!session.transcriptPath) return; + if (session.watcher) return; + + const parser = new GeminiTranscriptParser(); + const watcher = new JsonWatcher(session.transcriptPath); + + watcher.onNewMessages((messages) => { + this._processWatcherMessages(sessionId, messages); + }); + + watcher.start({ skipExisting, fallbackIntervalMs: 1000 }); + session.watcher = watcher; + session.parser = parser; + session._watcherPending = false; + } + + private _teardownSession(session: GeminiSessionState): void { + if (session.monitor) { + session.monitor.stop(); + session.monitor = null; + } + if (session.watcher) { + session.watcher.stop(); + session.watcher = null; + session.parser = null; + } + if (session._matchRetryTimer) { + clearTimeout(session._matchRetryTimer); + session._matchRetryTimer = null; + } + } + + private _startSessionCleanup(): void { + this._cleanupInterval = setInterval(async () => { + const windows = await tmuxManager.listWindows(); + const liveWindowIds = new Set(windows.map(w => w.id)); + + for (const [sessionId, session] of this.sessions) { + if (session.windowId && !liveWindowIds.has(session.windowId)) { + console.log(`[gemini-tmux] Stale session ${sessionId} — tmux window gone, cleaning up`); + this._teardownSession(session); + this.sessions.delete(sessionId); + this.emit('session-ended', sessionId); + } + } + + // Cap at 10 managed sessions + if (this.sessions.size > 10) { + const sorted = [...this.sessions.entries()] + .sort((a, b) => (b[1].lastActivity || 0) - (a[1].lastActivity || 0)); + for (const [id] of sorted.slice(10)) { + const s = this.sessions.get(id); + if (s) this._teardownSession(s); + this.sessions.delete(id); + this.emit('session-ended', id); + } + } + + // Clean up stale pending hook bodies (age-based sweep) + for (const [uuid, body] of this._pendingHookBodies) { + const age = Date.now() - body._storedAt; + if (age > 60_000) this._pendingHookBodies.delete(uuid); + } + }, 60_000); + this._cleanupInterval.unref(); + } +} diff --git a/server/adapters/gemini/hook-config.ts b/server/adapters/gemini/hook-config.ts new file mode 100644 index 0000000..3187022 --- /dev/null +++ b/server/adapters/gemini/hook-config.ts @@ -0,0 +1,144 @@ +// server/adapters/gemini/hook-config.ts +// +// Pure filesystem operations for Gemini hook management. +// Zero runtime dependencies — no EventEmitter, no tmux, no sessions. + +import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs'; +import { join, dirname, resolve } from 'path'; +import { homedir } from 'os'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** Individual hook action (command or url based) */ +interface HookAction { + type?: string; + command?: string; + url?: string; + timeout?: number; +} + +/** A hook entry within a hook event */ +interface HookEntry { + matcher?: string; + hooks: HookAction[]; +} + +/** The structure of Gemini's settings.json (partial) */ +interface GeminiSettings { + hooks?: Record; + [key: string]: unknown; +} + +export class GeminiHookConfig { + port: number | string; + useHttps: boolean; + + constructor(port?: number | string, useHttps?: boolean) { + this.port = port || process.env.PORT || 3456; + if (useHttps !== undefined) { + this.useHttps = useHttps; + } else { + // Auto-detect from cert files + const clawtapDir = join(homedir(), '.clawtap'); + this.useHttps = existsSync(join(clawtapDir, 'cert.pem')) && existsSync(join(clawtapDir, 'key.pem')); + } + } + + /** Install ClawTap hooks into ~/.gemini/settings.json */ + install(): void { + const port = this.port; + const settingsDir = join(homedir(), '.gemini'); + const settingsPath = join(settingsDir, 'settings.json'); + + const protocol = this.useHttps ? 'https' : 'http'; + const desiredHooks = this._buildDesiredHooks(protocol); + + try { + mkdirSync(settingsDir, { recursive: true }); + let existing: GeminiSettings = {}; + try { existing = JSON.parse(readFileSync(settingsPath, 'utf-8')) as GeminiSettings; } catch {} + + // Replace our hooks on every startup. + // Preserves other tools' hooks by filtering only ClawTap entries. + if (!existing.hooks) existing.hooks = {}; + + for (const [event, configs] of Object.entries(desiredHooks)) { + const existingEntries = existing.hooks[event] || []; + const filtered = existingEntries.filter(entry => !this._isOurHookEntry(entry)); + existing.hooks[event] = [...filtered, ...configs]; + } + + writeFileSync(settingsPath, JSON.stringify(existing, null, 2)); + console.log(`[hooks:gemini] Auto-configured hooks in ${settingsPath}`); + } catch (err) { + console.warn(`[hooks:gemini] Failed to auto-configure hooks: ${(err as Error).message}`); + } + } + + /** + * Remove ClawTap hooks from ~/.gemini/settings.json. + * Leaves other user settings intact. Only removes hooks owned by this port. + */ + uninstall(): void { + const settingsPath = join(homedir(), '.gemini', 'settings.json'); + + try { + const existing: GeminiSettings = JSON.parse(readFileSync(settingsPath, 'utf-8')) as GeminiSettings; + + if (existing.hooks) { + const hookKeys = Object.keys(this._buildDesiredHooks('http')); + for (const key of hookKeys) { + const entries = existing.hooks[key]; + if (!Array.isArray(entries)) continue; + + const filtered = entries.filter(entry => !this._isOurHookEntry(entry)); + + if (filtered.length === 0) { + delete existing.hooks[key]; + } else { + existing.hooks[key] = filtered; + } + } + + if (Object.keys(existing.hooks).length === 0) delete existing.hooks; + } + + writeFileSync(settingsPath, JSON.stringify(existing, null, 2)); + console.log(`[hooks:gemini] Removed ClawTap hooks from ${settingsPath}`); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return; + console.warn(`[hooks:gemini] Failed to remove hooks: ${(err as Error).message}`); + } + } + + // --- Internal helpers --- + + private _isOurHookEntry(entry: HookEntry): boolean { + const hooks = entry.hooks || []; + return hooks.some(h => + h.command != null && h.command.includes('bridge.sh') && h.command.includes(String(this.port)) + ); + } + + private _buildDesiredHooks(protocol: string): Record { + const port = this.port; + const bridgePath = resolve(__dirname, 'bridge.sh'); + // Pass port and protocol as positional args (not env vars). + // Gemini CLI may use execFile instead of shell, so inline VAR=val doesn't work. + const mkCmd = (endpoint: string): string => + `${bridgePath} ${endpoint} ${port} ${protocol}`; + + // IMPORTANT: Gemini CLI timeout is in MILLISECONDS (not seconds like Claude Code). + // 5000ms = 5 seconds — enough for bridge.sh to read stdin, printf '{}', and background curl. + const timeout = 5000; + return { + SessionStart: [{ hooks: [{ type: 'command', command: mkCmd('session-start'), timeout }] }], + SessionEnd: [{ hooks: [{ type: 'command', command: mkCmd('session-end'), timeout }] }], + BeforeTool: [{ matcher: '*', hooks: [{ type: 'command', command: mkCmd('before-tool'), timeout }] }], + AfterTool: [{ matcher: '*', hooks: [{ type: 'command', command: mkCmd('after-tool'), timeout }] }], + BeforeAgent: [{ hooks: [{ type: 'command', command: mkCmd('before-agent'), timeout }] }], + AfterAgent: [{ hooks: [{ type: 'command', command: mkCmd('after-agent'), timeout }] }], + }; + } +} diff --git a/server/adapters/gemini/index.ts b/server/adapters/gemini/index.ts new file mode 100644 index 0000000..7acd523 --- /dev/null +++ b/server/adapters/gemini/index.ts @@ -0,0 +1,199 @@ +// server/adapters/gemini/index.ts +import { IAdapter } from '../interface.js'; +import type { DirectoryEntry, ActiveSessionInfo, MessagesResult, CachedStatus } from '../interface.js'; +import { GeminiTmuxAdapter } from './gemini-tmux-adapter.js'; +import type { GeminiSessionState, GeminiHookBody } from './gemini-tmux-adapter.js'; +import { GeminiHookConfig } from './hook-config.js'; +import { + getSessions, getSessionMessages, listDirectory, +} from './json-store.js'; +import { GeminiTranscriptParser } from './transcript-parser.js'; +import type { QueryOptions, PermissionBehavior } from '../../types/messages.js'; +import type { AdapterCapabilities, ModelInfo, PermissionModeInfo, EffortLevelInfo, ReconnectState, SessionInfo } from '../../types/adapter.js'; +import type { Express } from 'express'; + + + +const MODELS: ModelInfo[] = [ + { value: 'auto', label: 'Auto' }, + { value: 'pro', label: 'Gemini Pro' }, + { value: 'flash', label: 'Gemini Flash' }, + { value: 'flash-lite', label: 'Flash Lite' }, +]; + +const PERMISSION_MODES: PermissionModeInfo[] = [ + { value: 'default', label: 'Default' }, + { value: 'auto_edit', label: 'Auto Edit' }, + { value: 'yolo', label: 'YOLO' }, + { value: 'plan', label: 'Plan' }, +]; + +export class GeminiAdapter extends IAdapter { + static id: string = 'gemini'; + static displayName: string = 'Gemini CLI'; + static command: string = 'gemini'; + + private _tmux: GeminiTmuxAdapter; + private _hookConfig: GeminiHookConfig; + private _lastStatus: Map; // sessionId -> { contextPercent, model, cost } + + constructor() { + super(); + this._tmux = new GeminiTmuxAdapter(); + this._hookConfig = new GeminiHookConfig(); + this._lastStatus = new Map(); + + // Forward all events from internal tmux adapter + const events: string[] = [ + 'streaming-text', 'thinking', 'tool-start', 'tool-done', + 'tool-updates', 'new-messages', 'session-idle', + 'permission-request', 'ask-question', 'mode-changed', + 'session-ended', 'session-error', 'compacting', 'compact-done', + 'processing-started', 'session-rekeyed', + ]; + for (const event of events) { + this._tmux.on(event, (...args: unknown[]) => this.emit(event, ...args)); + } + + // Don't forward status-update blindly — deduplicate first + this._tmux.on('status-update', (sessionId: string, status: any) => { + const prev = this._lastStatus.get(sessionId); + if (prev && + prev.contextPercent === status.contextPercent && + prev.model === status.model && + prev.cost === status.cost) return; + this._lastStatus.set(sessionId, status); + this.emit('status-update', sessionId, status); + }); + + // Clean up status dedup cache when session ends + this._tmux.on('session-ended', (sessionId: string) => { + this._lastStatus.delete(sessionId); + }); + } + + setup(app: Express): void { + this.installHooks(); + this._registerHookRoutes(app); + } + + installHooks(): void { this._hookConfig.install(); } + uninstallHooks(): void { this._hookConfig.uninstall(); } + + async cleanup(): Promise { + this.uninstallHooks(); + await this._tmux.destroy(); + } + + /** + * Register Express routes for Gemini-specific hooks. + * These are called by the Gemini CLI bridge script from localhost (no auth needed). + */ + private _registerHookRoutes(app: Express): void { + // All hooks are fire-and-forget notifications — no return value used. + // Handlers are called for side effects only (emit events, update state). + const hookRoute = (path: string, handler: (body: GeminiHookBody) => void | Promise): void => { + const label = path.split('/').pop(); + app.post(path, (req: any, res: any) => { + const sid = req.body.session_id?.substring(0, 8) || '?'; + const toolInfo = req.body.tool_name ? ` ${req.body.tool_name}` : ''; + console.log(`[hook] ${label}:${toolInfo} sid=${sid}`); + try { + const result = handler(req.body); + if (result instanceof Promise) result.catch((e: Error) => console.error(`[hook] ${label} error:`, e.message)); + } catch (e) { console.error(`[hook] ${label} error:`, (e as Error).message); } + res.json({}); + }); + }; + + const prefix = this.getHookPrefix(); // /api/hooks/gemini + + hookRoute(`${prefix}/session-start`, (body) => { + this._tmux.handleSessionStart(body); + }); + hookRoute(`${prefix}/session-end`, (body) => { + this._tmux.handleSessionEnd(body); + }); + hookRoute(`${prefix}/before-tool`, (body) => { + this._tmux.handleBeforeTool(body); + }); + hookRoute(`${prefix}/after-tool`, (body) => { + this._tmux.handleAfterTool(body); + }); + hookRoute(`${prefix}/before-agent`, (body) => { + this._tmux.handleBeforeAgent(body); + }); + hookRoute(`${prefix}/after-agent`, (body) => { + this._tmux.handleAfterAgent(body); + }); + } + + setClientChecker(fn: (sessionId: string) => boolean): void { + this._tmux.setClientChecker(fn); + } + + // Lifecycle — delegate to tmux adapter + async startSession(cwd: string, options?: QueryOptions): Promise<{ sessionId: string }> { + return this._tmux.startSession(cwd, options); + } + async resumeSession(sid: string, cwd: string, options?: QueryOptions): Promise<{ sessionId: string }> { + return this._tmux.resumeSession(sid, cwd, options); + } + async attachSession(_sid: string, _cwd: string, _options?: QueryOptions): Promise<{ sessionId: string }> { throw new Error('Gemini does not support attach'); } + async destroySession(sid: string): Promise { return this._tmux.destroySession(sid); } + async sendMessage(sid: string, text: string, options?: QueryOptions): Promise { return this._tmux.sendMessage(sid, text, options); } + async switchModel(sid: string, model: string): Promise { return this._tmux.switchModel(sid, model); } + async interrupt(sid: string): Promise { return this._tmux.interrupt(sid); } + flushMessages(sid: string): void { this._tmux.flushMessages(sid); } + syncWatcherPosition(sid: string): void { this._tmux.syncWatcherPosition(sid); } + getReconnectState(sid: string): ReconnectState { return this._tmux.getReconnectState(sid); } + + // Store — delegate to json-store, parse through transcript parser for getMessages + async getSessions(dir?: string, limit?: number): Promise { return getSessions(dir, limit); } + + async getMessages(sid: string, dir?: string): Promise { + const { messages: rawMessages, lastModified } = getSessionMessages(sid, dir); + if (rawMessages.length === 0) return { messages: [], lastModified }; + + // Parse raw Gemini messages through the transcript parser + const parser = new GeminiTranscriptParser(); + const { messages } = parser.parse(rawMessages as import('../../stores/json-watcher.js').GeminiSessionMessage[]); + return { messages, lastModified }; + } + + async listDirectory(path?: string): Promise { return listDirectory(path); } + + // Permissions — delegate to tmux adapter + async switchPermissionMode(sid: string, mode: string): Promise { return this._tmux.switchPermissionMode(sid, mode); } + respondPermission(reqId: string, behavior: PermissionBehavior): void { this._tmux.respondPermission(reqId, behavior); } + async respondQuestion(reqId: string, answer: string): Promise { return this._tmux.respondQuestion(reqId, answer); } + releaseAllPending(sid: string): void { this._tmux.releaseAllPending(sid); } + resolveAllPendingAs(sid: string, behavior: PermissionBehavior): void { this._tmux.resolveAllPendingAs(sid, behavior); } + + // Query + isProcessing(sid: string): boolean { return this._tmux.isProcessing(sid); } + getSession(sid: string): GeminiSessionState | undefined { return this._tmux.getSession(sid); } + getLastStatus(sid: string) { return this._lastStatus.get(sid) || null; } + async hasActiveWindow(sid: string): Promise { return this._tmux.hasActiveWindow(sid); } + getActiveSessions(): ActiveSessionInfo[] { return this._tmux.getActiveSessions(); } + + // Capabilities + getModels(): ModelInfo[] { return MODELS; } + getPermissionModes(): PermissionModeInfo[] { return PERMISSION_MODES; } + getEffortLevels(): EffortLevelInfo[] { return []; } + + getCapabilities(): AdapterCapabilities { + return { + supportsPlanMode: true, + supportsPermissionModes: true, + supportsInterrupt: true, + supportsResume: true, + supportsAttach: false, + supportsStatusLine: false, + supportsImages: true, + supportsStreaming: true, + maxContextWindow: 1_000_000, + permissionModeType: 'toggle', + }; + } +} diff --git a/server/adapters/gemini/json-store.ts b/server/adapters/gemini/json-store.ts new file mode 100644 index 0000000..e068203 --- /dev/null +++ b/server/adapters/gemini/json-store.ts @@ -0,0 +1,299 @@ +import { readdirSync, readFileSync, statSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; +import { extractUserText } from './message-utils.js'; +import type { DirectoryEntry } from '../interface.js'; +import type { SessionInfo } from '../../types/adapter.js'; + +// --- Constants --- +export const GEMINI_DIR: string = join(homedir(), '.gemini'); +export const GEMINI_TMP_DIR: string = join(GEMINI_DIR, 'tmp'); +export const GEMINI_PROJECTS_FILE: string = join(GEMINI_DIR, 'projects.json'); + +// --- Types --- + +interface GeminiProjectsFile { + projects?: Record; +} + +interface GeminiSessionFile { + sessionId?: string; + startTime?: string; + lastUpdated?: string; + messages?: unknown[]; + summary?: string; +} + +interface GeminiMessage { + type?: string; // 'user' | 'gemini' | 'error' | 'info' | 'warning' + content?: unknown; + model?: string; +} + +// --- Helpers --- + +function readProjectsJson(): GeminiProjectsFile { + try { + const raw = readFileSync(GEMINI_PROJECTS_FILE, 'utf-8'); + return JSON.parse(raw) as GeminiProjectsFile; + } catch { + return {}; + } +} + +// --- Exported Functions --- + +/** + * Look up the Gemini project name for a given absolute directory path. + * Reads ~/.gemini/projects.json which maps "/abs/path" → "project-name". + */ +export function getProjectName(dir: string): string | null { + try { + const data = readProjectsJson(); + return data.projects?.[dir] ?? null; + } catch { + return null; + } +} + +/** + * List all project directories under ~/.gemini/tmp/. + * If dir is provided, only return the matching project directory. + */ +function getProjectDirs(dir?: string): Array<{ projectDir: string; cwd: string | null }> { + if (dir) { + const projectName = getProjectName(dir); + if (!projectName) return []; + return [{ projectDir: join(GEMINI_TMP_DIR, projectName), cwd: dir }]; + } + + try { + const entries = readdirSync(GEMINI_TMP_DIR, { withFileTypes: true }); + return entries + .filter((e) => e.isDirectory()) + .map((e) => { + const projectDir = join(GEMINI_TMP_DIR, e.name); + // Attempt to read .project_root for the cwd + let cwd: string | null = null; + try { + cwd = readFileSync(join(projectDir, '.project_root'), 'utf-8').trim() || null; + } catch { + // no .project_root — cwd stays null + } + return { projectDir, cwd }; + }); + } catch { + return []; + } +} + +/** + * List sessions for a project (or all projects if dir is omitted). + * Returns SessionInfo[] sorted by lastModified descending. + */ +export function getSessions(dir?: string, limit?: number): SessionInfo[] { + const projectDirs = getProjectDirs(dir); + const sessions: SessionInfo[] = []; + + for (const { projectDir, cwd } of projectDirs) { + const chatsDir = join(projectDir, 'chats'); + let files: string[]; + try { + files = readdirSync(chatsDir); + } catch { + continue; + } + + const jsonFiles = files.filter((f) => f.endsWith('.json')); + + for (const file of jsonFiles) { + const filePath = join(chatsDir, file); + try { + const raw = readFileSync(filePath, 'utf-8'); + const data = JSON.parse(raw) as GeminiSessionFile; + + const sessionId = data.sessionId ?? file.replace('.json', ''); + const lastModified = data.lastUpdated ?? data.startTime ?? null; + + // Extract firstPrompt from first user message + let firstPrompt: string | null = null; + if (Array.isArray(data.messages)) { + for (const msg of data.messages) { + const m = msg as GeminiMessage; + if (m.type === 'user' && m.content != null) { + const text = extractUserText(m.content); + if (text.trim()) { + firstPrompt = text.slice(0, 200); + break; + } + } + } + } + + // Extract model from last gemini message + let model: string | null = null; + if (Array.isArray(data.messages)) { + for (let i = data.messages.length - 1; i >= 0; i--) { + const m = data.messages[i] as GeminiMessage; + if (m.type === 'gemini' && m.model) { + model = m.model; + break; + } + } + } + + sessions.push({ + sessionId, + cwd, + lastModified: lastModified ?? undefined, + firstPrompt, + model, + }); + } catch { + // skip malformed files + } + } + } + + // Sort by lastModified descending, null-safe + sessions.sort((a, b) => { + const ta = a.lastModified ? new Date(a.lastModified).getTime() : 0; + const tb = b.lastModified ? new Date(b.lastModified).getTime() : 0; + return tb - ta; + }); + + return limit ? sessions.slice(0, limit) : sessions; +} + +/** + * Find the absolute path of a session file by UUID across all project dirs. + * Returns null if not found. + */ +export function findSessionFile(sessionId: string): string | null { + let projectDirs: string[]; + try { + const entries = readdirSync(GEMINI_TMP_DIR, { withFileTypes: true }); + projectDirs = entries + .filter((e) => e.isDirectory()) + .map((e) => join(GEMINI_TMP_DIR, e.name)); + } catch { + return null; + } + + for (const projectDir of projectDirs) { + const chatsDir = join(projectDir, 'chats'); + let files: string[]; + try { + files = readdirSync(chatsDir); + } catch { + continue; + } + + for (const file of files) { + if (!file.endsWith('.json')) continue; + const filePath = join(chatsDir, file); + // Fast path: check if session UUID appears in filename before parsing + if (file.includes(sessionId)) return filePath; + // Slow path: parse JSON to check sessionId field + try { + const raw = readFileSync(filePath, 'utf-8'); + const data = JSON.parse(raw) as GeminiSessionFile; + if (data.sessionId === sessionId) return filePath; + } catch { + // skip malformed files + } + } + } + + return null; +} + +/** + * Read all messages from a session file. + * If dir is provided, search only in that project's chats dir. + */ +export function getSessionMessages( + sessionId: string, + dir?: string +): { messages: unknown[]; lastModified: string | null } { + // Determine candidate file paths + let filePath: string | null = null; + + if (dir) { + const projectName = getProjectName(dir); + if (projectName) { + const chatsDir = join(GEMINI_TMP_DIR, projectName, 'chats'); + // Try exact match first, then scan + try { + const files = readdirSync(chatsDir); + for (const file of files) { + if (!file.endsWith('.json')) continue; + const fp = join(chatsDir, file); + try { + const raw = readFileSync(fp, 'utf-8'); + const data = JSON.parse(raw) as GeminiSessionFile; + const fileSessionId = data.sessionId ?? file.replace('.json', ''); + if (fileSessionId === sessionId) { + filePath = fp; + break; + } + } catch { + // skip + } + } + } catch { + // chats dir not readable + } + } + } else { + filePath = findSessionFile(sessionId); + } + + if (!filePath) return { messages: [], lastModified: null }; + + try { + const raw = readFileSync(filePath, 'utf-8'); + const data = JSON.parse(raw) as GeminiSessionFile; + const messages = Array.isArray(data.messages) ? data.messages : []; + + let lastModified: string | null = null; + try { + const s = statSync(filePath); + lastModified = s.mtime.toISOString(); + } catch { + lastModified = data.lastUpdated ?? data.startTime ?? null; + } + + return { messages, lastModified }; + } catch { + return { messages: [], lastModified: null }; + } +} + +/** + * List directory entries (non-hidden subdirectories) for the directory browser. + * If path is omitted, defaults to the user's home directory. + */ +export function listDirectory(dirPath?: string): DirectoryEntry[] { + const target = dirPath || homedir(); + try { + const entries = readdirSync(target, { withFileTypes: true }); + const visible = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.')); + + const dirs: DirectoryEntry[] = visible.map((entry) => { + const fullPath = join(target, entry.name); + let hasChildren = false; + try { + const children = readdirSync(fullPath, { withFileTypes: true }); + hasChildren = children.some((c) => c.isDirectory() && !c.name.startsWith('.')); + } catch { + // no access + } + return { name: entry.name, path: fullPath, hasChildren }; + }); + + return dirs.sort((a, b) => a.name.localeCompare(b.name)); + } catch { + return []; + } +} diff --git a/server/adapters/gemini/message-utils.ts b/server/adapters/gemini/message-utils.ts new file mode 100644 index 0000000..40affde --- /dev/null +++ b/server/adapters/gemini/message-utils.ts @@ -0,0 +1,85 @@ +import type { ContentBlock } from '../claude/message-utils.js'; + +export type { ContentBlock }; + +/** A tool call embedded in a Gemini session message */ +export interface GeminiToolCall { + id: string; + name: string; + args: Record; + result?: unknown[]; + status: 'running' | 'success' | 'error' | 'cancelled'; + timestamp?: string; + displayName?: string; + description?: string; +} + +/** + * Extract plain text from a user message's content. + * Gemini user content is either an array of { text } objects or a plain string. + */ +export function extractUserText(content: unknown): string { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .filter((item): item is { text: string } => item != null && typeof item.text === 'string') + .map((item) => item.text) + .join('\n'); + } + return ''; +} + +/** Alias — Gemini assistant content is always a string, but use same extractor for safety */ +export const extractGeminiText = extractUserText; + +/** + * Convert Gemini's embedded toolCalls array into standard tool_use + tool_result ContentBlock pairs. + * + * Each GeminiToolCall becomes: + * - A tool_use block (id, name, input = args) + * - Optionally a tool_result block if a result is present (tool_use_id, content, is_error) + */ +export function toolCallsToContentBlocks(toolCalls: GeminiToolCall[]): ContentBlock[] { + const blocks: ContentBlock[] = []; + + for (const tc of toolCalls) { + // tool_use block + blocks.push({ + type: 'tool_use', + id: tc.id, + name: tc.name, + input: tc.args, + }); + + // tool_result block — only if result data is present + if (tc.result !== undefined && tc.result !== null) { + const isError = tc.status === 'error' || tc.status === 'cancelled'; + + // Extract the output or error string from the function response structure + let resultContent = ''; + if (Array.isArray(tc.result) && tc.result.length > 0) { + const firstResult = tc.result[0] as Record | null; + const functionResponse = firstResult?.functionResponse as Record | undefined; + const response = functionResponse?.response as Record | undefined; + if (isError) { + resultContent = typeof response?.error === 'string' + ? response.error + : JSON.stringify(response?.error ?? ''); + } else { + resultContent = typeof response?.output === 'string' + ? response.output + : JSON.stringify(response?.output ?? ''); + } + } + + blocks.push({ + type: 'tool_result', + tool_use_id: tc.id, + content: resultContent, + is_error: isError, + }); + } + } + + return blocks; +} diff --git a/server/adapters/gemini/pane-monitor.ts b/server/adapters/gemini/pane-monitor.ts new file mode 100644 index 0000000..6cd2629 --- /dev/null +++ b/server/adapters/gemini/pane-monitor.ts @@ -0,0 +1,217 @@ +// server/adapters/gemini/pane-monitor.ts +// +// Polls a tmux pane every 500ms to capture real-time streaming output from +// the Gemini CLI. +// +// Detects: +// 1. Streaming response text (new text since last poll) +// 2. Thinking indicators (spinner / processing patterns) +// +// Note: Gemini already provides thinking content in JSON (thoughts[]), so +// pane-level thinking detection is supplementary — it provides real-time +// feedback before the JSON response is written to disk. +// +// Modelled after server/adapters/codex/pane-monitor.ts but with +// Gemini-specific regex patterns. Patterns are conservative placeholders +// that will be refined through empirical testing with the actual Gemini CLI. + +import { EventEmitter } from 'events'; + +/** Minimal interface for the tmux manager dependency */ +interface TmuxCapture { + capturePane(windowId: string, lines?: number): Promise; +} + +/** Thinking indicator detected from pane content */ +export interface ThinkingInfo { + text: string; + detail: string | null; +} + +/** + * GeminiPaneMonitor — polls a tmux pane to detect streaming text and + * thinking indicators from the Gemini CLI. + * + * Events emitted via the injected EventEmitter: + * - 'streaming-text' (sessionId, newText) + * - 'thinking' (sessionId, { text, detail }) + */ +export class GeminiPaneMonitor { + private sessionId: string; + private windowId: string; + private tmux: TmuxCapture; + private emitter: EventEmitter; + private interval: ReturnType | null = null; + private _lastContent: string = ''; + private _lastResponseText: string = ''; + + constructor( + sessionId: string, + windowId: string, + tmuxManager: TmuxCapture, + emitter: EventEmitter, + ) { + this.sessionId = sessionId; + this.windowId = windowId; + this.tmux = tmuxManager; + this.emitter = emitter; + } + + /** Begin polling the tmux pane at 500ms intervals */ + start(): void { + if (this.interval) return; + this.interval = setInterval(() => this._poll(), 500); + } + + /** Stop polling and clear the interval */ + stop(): void { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + } + + /** Force an immediate poll (useful on hook receipt) */ + async pollNow(): Promise { + await this._poll(); + } + + // --------------------------------------------------------------------------- + // Internal + // --------------------------------------------------------------------------- + + private async _poll(): Promise { + try { + const content = await this.tmux.capturePane(this.windowId); + if (content === this._lastContent) return; + this._lastContent = content; + + // 1. Check for thinking indicator + const thinking = detectThinking(content); + if (thinking) { + this.emitter.emit('thinking', this.sessionId, thinking); + return; + } + + // 2. Extract streaming response text + const text = extractResponseText(content); + if (text && text !== this._lastResponseText) { + this._lastResponseText = text; + this.emitter.emit('streaming-text', this.sessionId, text); + } + } catch { + // Silently ignore — tmux window may have been killed + } + } +} + +// ============================================================================= +// Detection functions (exported for unit testing) +// ============================================================================= + +/** + * Detect Gemini thinking/processing indicators. + * + * Gemini CLI shows various spinner/processing patterns while reasoning. + * In non-alt-screen mode these appear as inline text in the pane. + * + * Placeholder patterns — will be refined through empirical testing: + * - "Thinking..." text (Gemini's native thinking label) + * - Spinner characters (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ braille spinner set) + * - "Generating..." or processing indicators + */ +export function detectThinking(content: string): ThinkingInfo | null { + const lines = content.split('\n'); + // Only check the tail of the pane (last 15 lines) + const tail = lines.slice(-15); + + for (const line of tail) { + // Skip completion/summary lines + if (/completed|finished|done|exited/i.test(line)) continue; + + // Pattern 1: Braille spinner followed by descriptive text + // e.g. "⠙ Thinking..." or "⠹ Generating..." + const brailleMatch = line.match(/^\s*([⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏])\s+(.+?)\s*$/); + if (brailleMatch) { + return { text: brailleMatch[2]!, detail: null }; + } + + // Pattern 2: Explicit "Thinking..." or "Generating..." text + // Gemini CLI commonly shows "Thinking..." during reasoning + const thinkingMatch = line.match( + /^\s*(Thinking|Generating|Processing|Working)(\.\.\.)?\s*(?:\((.+?)\))?\s*$/i, + ); + if (thinkingMatch) { + return { + text: `${thinkingMatch[1]}...`, + detail: thinkingMatch[3] || null, + }; + } + + // Pattern 3: Braille spinner on its own (Gemini may render bare spinner) + const bareSpinner = line.match(/^\s*[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s*$/); + if (bareSpinner) { + return { text: 'Thinking...', detail: null }; + } + } + + return null; +} + +/** + * Extract the current streaming response text from pane content. + * + * Gemini CLI writes responses inline. We look for text after the last + * user input marker and collect lines until we hit a boundary indicator. + * + * Placeholder patterns — will be refined through empirical testing: + * - User input prompt: ">" or "❯" followed by user text + * - Response boundary: horizontal rules, new prompts, spinner indicators + */ +export function extractResponseText(content: string): string { + const lines = content.split('\n'); + + // Find the LAST user prompt line — responses appear after it + let lastUserPrompt = -1; + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]!; + // Gemini user prompt patterns (conservative): + // - ">" or "❯" at start of line followed by user text + // - "user:" prefix + if (/^\s*[>❯]\s+\S/.test(line) || /^\s*user:\s/i.test(line)) { + lastUserPrompt = i; + break; + } + } + + if (lastUserPrompt === -1) return ''; + + // Collect response lines after the user prompt + // Skip the prompt line itself and any blank lines immediately after + let responseStart = lastUserPrompt + 1; + while (responseStart < lines.length && lines[responseStart]!.trim() === '') { + responseStart++; + } + + if (responseStart >= lines.length) return ''; + + const responseLines: string[] = []; + for (let i = responseStart; i < lines.length; i++) { + const line = lines[i]!; + + // Stop at boundary markers + if ( + // Horizontal rules + /^[─━═\-]{5,}/.test(line.trim()) || + // New user prompt + /^\s*[>❯]\s+\S/.test(line) || + // Spinner/thinking indicators (braille set) + /^\s*[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s*/.test(line) + ) { + break; + } + responseLines.push(line); + } + + return responseLines.join('\n').trim(); +} diff --git a/server/adapters/gemini/transcript-parser.ts b/server/adapters/gemini/transcript-parser.ts new file mode 100644 index 0000000..b8a93ff --- /dev/null +++ b/server/adapters/gemini/transcript-parser.ts @@ -0,0 +1,175 @@ +import type { ContentBlock } from '../claude/message-utils.js'; +import type { GeminiSessionMessage } from '../../stores/json-watcher.js'; +import { + extractUserText, + extractGeminiText, + toolCallsToContentBlocks, +} from './message-utils.js'; +import type { GeminiToolCall } from './message-utils.js'; + +/** Parsed message ready for the frontend */ +export interface ParsedMessage { + id: string; + role: 'user' | 'assistant' | 'plan'; + /** Always ContentBlock[] — never a plain string, for consistency with Claude/Codex */ + content: ContentBlock[]; + adapter?: string; + senderClientId?: string | null; +} + +/** Result returned by parse() */ +export interface ParseResult { + messages: ParsedMessage[]; + errors: string[]; +} + +/** Model/token status extracted from an info message */ +export interface StatusInfo { + model: string | null; + tokens: Record | null; +} + +/** A single thought entry from a gemini message */ +export interface ThoughtEntry { + subject: string; + description: string; + timestamp: string; +} + +export class GeminiTranscriptParser { + /** Monotonically increasing message index — NOT reset between parse() calls */ + private _msgIndex: number = 0; + + /** + * Parse an incremental batch of GeminiSessionMessages into frontend-ready ParsedMessages. + * + * NOTE: _msgIndex is intentionally NOT reset here. parse() is called incrementally via + * JsonWatcher.onNewMessages(). Resetting would restart IDs at msg-0, causing React key + * collisions across batches. + */ + parse(messages: GeminiSessionMessage[]): ParseResult { + const result: ParsedMessage[] = []; + const errors: string[] = []; + + for (const msg of messages) { + switch (msg.type) { + case 'user': { + const parsed = this._parseUserMessage(msg); + if (parsed) result.push(parsed); + break; + } + case 'gemini': { + const parsed = this._parseGeminiMessage(msg); + if (parsed) result.push(parsed); + break; + } + case 'error': { + // Collect errors to emit as session-error events; don't add as chat messages + const errText = extractUserText(msg.content) || String(msg.content ?? 'Unknown error'); + errors.push(errText); + break; + } + case 'info': + // Info messages carry metadata (model, tokens) — skip as chat messages + break; + default: + // Unknown type — skip silently + break; + } + } + + return { messages: result, errors }; + } + + /** + * Extract thought entries from a gemini message's thoughts array. + * Returns an empty array if none are present. + */ + static extractThoughts(msg: GeminiSessionMessage): ThoughtEntry[] { + if (!Array.isArray(msg.thoughts) || msg.thoughts.length === 0) return []; + return msg.thoughts + .filter((t): t is Record => t != null && typeof t === 'object') + .map((t) => ({ + subject: typeof t['subject'] === 'string' ? t['subject'] : '', + description: typeof t['description'] === 'string' ? t['description'] : '', + timestamp: typeof t['timestamp'] === 'string' ? t['timestamp'] : (msg.timestamp ?? ''), + })); + } + + /** + * Extract model/token status from an info-type message. + * Returns null if the message carries no relevant status data. + */ + static extractStatus(msg: GeminiSessionMessage): StatusInfo | null { + const model = msg.model ?? null; + let tokens: Record | null = null; + + if (msg.tokens && typeof msg.tokens === 'object') { + const raw = msg.tokens as Record; + const parsed: Record = {}; + for (const [key, val] of Object.entries(raw)) { + if (typeof val === 'number') parsed[key] = val; + } + if (Object.keys(parsed).length > 0) tokens = parsed; + } + + if (model === null && tokens === null) return null; + return { model, tokens }; + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private _parseUserMessage(msg: GeminiSessionMessage): ParsedMessage | null { + const text = extractUserText(msg.content); + if (!text.trim()) return null; + + const content: ContentBlock[] = [{ type: 'text', text }]; + return { + id: `msg-${this._msgIndex++}`, + role: 'user', + content, + adapter: 'gemini', + }; + } + + private _parseGeminiMessage(msg: GeminiSessionMessage): ParsedMessage | null { + const text = extractGeminiText(msg.content); + const toolBlocks = this._extractToolBlocks(msg); + + // Skip completely empty messages + if (!text.trim() && toolBlocks.length === 0) return null; + + const content: ContentBlock[] = []; + + // Text block first (if present) + if (text.trim()) { + content.push({ type: 'text', text }); + } + + // Tool call blocks after the text + content.push(...toolBlocks); + + return { + id: `msg-${this._msgIndex++}`, + role: 'assistant', + content, + adapter: 'gemini', + }; + } + + private _extractToolBlocks(msg: GeminiSessionMessage): ContentBlock[] { + if (!Array.isArray(msg.toolCalls) || msg.toolCalls.length === 0) return []; + + const toolCalls = (msg.toolCalls as unknown[]).filter( + (tc): tc is GeminiToolCall => + tc != null && + typeof tc === 'object' && + typeof (tc as Record)['id'] === 'string' && + typeof (tc as Record)['name'] === 'string', + ); + + return toolCallsToContentBlocks(toolCalls); + } +} diff --git a/server/adapters/init.ts b/server/adapters/init.ts new file mode 100644 index 0000000..4f93fee --- /dev/null +++ b/server/adapters/init.ts @@ -0,0 +1,21 @@ +// server/adapters/init.ts +import { register, getAdapterConfig } from './registry.js'; + +const LOADERS: Record Promise> = { + claude: () => import('./claude/index.js').then(m => m.ClaudeAdapter), + codex: () => import('./codex/index.js').then(m => m.CodexAdapter), + gemini: () => import('./gemini/index.js').then(m => m.GeminiAdapter), +}; + +const { enabledAdapters } = getAdapterConfig(); + +for (const id of enabledAdapters) { + const loader = LOADERS[id]; + if (!loader) { console.warn(`[init] Unknown adapter: ${id}`); continue; } + try { + const AdapterClass = await loader(); + register(AdapterClass); + } catch (err) { + console.warn(`[init] Failed to load adapter ${id}: ${(err as Error).message}`); + } +} diff --git a/server/adapters/interface.ts b/server/adapters/interface.ts new file mode 100644 index 0000000..e874e35 --- /dev/null +++ b/server/adapters/interface.ts @@ -0,0 +1,170 @@ +// server/adapters/interface.ts +import { EventEmitter } from 'events'; +import type { Express } from 'express'; +import type { QueryOptions, PermissionBehavior, PermissionMode, ChatMessage } from '../types/messages.js'; +import type { + AdapterCapabilities, SessionInfo, ModelInfo, + PermissionModeInfo, EffortLevelInfo, ReconnectState, +} from '../types/adapter.js'; + +/** Threshold for switching from sendKeys (character-by-character) to pasteBuffer (bulk paste) */ +export const PASTE_THRESHOLD = 500; + +/** Check if text should be sent via pasteBuffer instead of sendKeys */ +export function isLargeContent(text: string): boolean { + return text.length > PASTE_THRESHOLD || text.includes('\n'); +} + +/** Cached session status for deduplication and reconnect */ +export interface CachedStatus { + contextPercent: number | null; + model: string | null; + cost: number | null; +} + +/** Directory entry returned by listDirectory */ +export interface DirectoryEntry { + name: string; + path: string; + hasChildren: boolean; +} + +/** Active session info returned by getActiveSessions */ +export interface ActiveSessionInfo { + sessionId: string; + cwd: string; + adapter: string; + permissionMode: string; + lastActivity: number | null; + hasClients: boolean; + hasDesktop: boolean; + isNonInteractive: boolean; + firstPrompt: string | null; +} + +/** Messages result from getMessages */ +export interface MessagesResult { + messages: unknown[]; + lastModified: string | null; +} + +/** + * IAdapter — Base class for CLI agent adapters. + * + * Each adapter is a self-contained plugin that: + * - Manages CLI process lifecycle (start, resume, attach, destroy) + * - Registers its own HTTP hook routes via setup(app) + * - Owns its session store (getSessions, getMessages) + * - Emits standardized events for the session manager + * + * Events (all emit sessionId as first arg): + * streaming-text(sessionId, text) + * thinking(sessionId, { text, detail }) + * tool-start(sessionId, { toolId, toolName, input }) + * tool-done(sessionId, { toolId, toolName, result }) + * tool-updates(sessionId, toolsMap) + * new-messages(sessionId, messages[]) + * session-idle(sessionId) + * permission-request(sessionId, { requestId, toolName, input }) + * ask-question(sessionId, { requestId, toolName, input }) + * status-update(sessionId, { contextPercent, model, cost }) + */ +export class IAdapter extends EventEmitter { + /** Unique adapter identifier (e.g. 'claude', 'codex') */ + static id: string = ''; + /** Human-readable name (e.g. 'Claude Code') */ + static displayName: string = ''; + /** CLI binary name for auto-detection (e.g. 'claude') */ + static command: string = ''; + + protected _clientChecker: ((sessionId: string) => boolean) | null = null; + + /** + * Register adapter-specific HTTP routes and configure CLI hooks. + * Called once during server startup. + */ + setup(app: Express): void { throw new Error('Not implemented: setup'); } + + /** + * Set a function that checks if WS clients are connected for a session. + */ + setClientChecker(fn: (sessionId: string) => boolean): void { this._clientChecker = fn; } + + // --- Session Lifecycle --- + + async startSession(cwd: string, options?: QueryOptions): Promise<{ sessionId: string }> { throw new Error('Not implemented: startSession'); } + async resumeSession(sessionId: string, cwd: string, options?: QueryOptions): Promise<{ sessionId: string }> { throw new Error('Not implemented: resumeSession'); } + async attachSession(sessionId: string, cwd: string, options?: QueryOptions): Promise<{ sessionId: string }> { throw new Error('Not implemented: attachSession'); } + async destroySession(sessionId: string): Promise { throw new Error('Not implemented: destroySession'); } + + // --- Messaging --- + + async sendMessage(sessionId: string, text: string, options?: QueryOptions): Promise { throw new Error('Not implemented: sendMessage'); } + async respondPlan(sessionId: string, optionIndex: number, text?: string): Promise {} + async interrupt(sessionId: string): Promise { throw new Error('Not implemented: interrupt'); } + async switchModel(sessionId: string, model: string): Promise {} + flushMessages(sessionId: string): void {} + syncWatcherPosition(sessionId: string): void {} + getReconnectState(sessionId: string): ReconnectState { return { tools: {} as Record, pendingRequests: [] }; } + + // --- Session Store --- + + async getSessions(dir?: string, limit?: number): Promise { throw new Error('Not implemented: getSessions'); } + async getMessages(sessionId: string, dir?: string): Promise { throw new Error('Not implemented: getMessages'); } + async listDirectory(path?: string): Promise { throw new Error('Not implemented: listDirectory'); } + + // --- Permissions --- + + async switchPermissionMode(sessionId: string, mode: string): Promise { return false; } + respondPermission(requestId: string, behavior: PermissionBehavior): void {} + async respondQuestion(requestId: string, answer: string): Promise {} + releaseAllPending(sessionId: string): void {} + resolveAllPendingAs(sessionId: string, behavior: PermissionBehavior): void {} + + // --- Query --- + + getSession(sessionId: string): unknown { return null; } + getLastStatus(sessionId: string): { contextPercent: number | null; model: string | null; cost: number | null } | null { return null; } + getActiveSessions(): ActiveSessionInfo[] { return []; } + async hasActiveWindow(sessionId: string): Promise { return false; } + + // --- Capabilities --- + + getModels(): ModelInfo[] { return []; } + getPermissionModes(): PermissionModeInfo[] { return []; } + getEffortLevels(): EffortLevelInfo[] { return []; } + getEffortLabel(): string { return 'Effort'; } + + getCapabilities(): AdapterCapabilities { + return { + supportsPlanMode: false, + supportsPermissionModes: false, + supportsInterrupt: false, + supportsResume: false, + supportsAttach: false, + supportsStatusLine: false, + supportsImages: false, + supportsStreaming: true, + maxContextWindow: 0, + }; + } + + getHookPrefix(): string { + return `/api/hooks/${(this.constructor as any).id}`; + } + + // --- Hooks --- + + /** Install adapter-specific hooks (e.g., write to CLI settings). No server needed. */ + installHooks(): void {} + /** Remove adapter-specific hooks. No server needed. */ + uninstallHooks(): void {} + + // --- Lifecycle --- + + /** Called on server shutdown to clean up external config (e.g. CLI hooks). */ + async cleanup(): Promise {} + + /** Check if a session is actively processing a request. */ + isProcessing(sessionId: string): boolean { return false; } +} diff --git a/server/adapters/registry.ts b/server/adapters/registry.ts new file mode 100644 index 0000000..9440132 --- /dev/null +++ b/server/adapters/registry.ts @@ -0,0 +1,86 @@ +// server/adapters/registry.ts +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import type { Express } from 'express'; +import type { IAdapter } from './interface.js'; +import type { AdapterInfo } from '../types/adapter.js'; + +/** Constructor type for adapter classes that extend IAdapter */ +interface AdapterConstructor { + new (): IAdapter; + id: string; + displayName: string; + command: string; +} + +const configPath = path.join(os.homedir(), '.clawtap', 'config.json'); +let userConfig: { defaultAdapter?: string; adapters?: Record } = {}; +try { userConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8')); } catch {} +export const DEFAULT_ADAPTER: string = userConfig.defaultAdapter || 'claude'; + +/** Return adapter config parsed from ~/.clawtap/config.json */ +export function getAdapterConfig(): { defaultAdapter: string; enabledAdapters: string[] } { + // If no adapters config, enable all known adapters by default. + // Registry's listAvailable() will check `which ` for actual availability. + const enabledAdapters = userConfig.adapters + ? Object.entries(userConfig.adapters).filter(([, v]) => v.enabled).map(([k]) => k) + : ['claude', 'codex', 'gemini']; + return { defaultAdapter: DEFAULT_ADAPTER, enabledAdapters }; +} + +const adapters: Map = new Map(); // id → adapter instance +let cachedAvailable: AdapterInfo[] | null = null; // cached result of listAvailable() + +export function register(AdapterClass: AdapterConstructor): IAdapter { + const instance = new AdapterClass(); + adapters.set(AdapterClass.id, instance); + cachedAvailable = null; // invalidate cache + return instance; +} + +export function get(id: string): IAdapter | undefined { + return adapters.get(id); +} + +export function getDefault(): IAdapter | null { + return adapters.get(DEFAULT_ADAPTER) || adapters.values().next().value || null; +} + +export function listAvailable(): AdapterInfo[] { + if (cachedAvailable) return cachedAvailable; + cachedAvailable = [...adapters.values()].map(adapter => { + const Cls = adapter.constructor as unknown as AdapterConstructor; + let available = false; + try { + execFileSync('which', [Cls.command], { stdio: 'ignore' }); + available = true; + } catch {} + return { + id: Cls.id, + displayName: Cls.displayName, + available, + capabilities: adapter.getCapabilities(), + }; + }); + return cachedAvailable; +} + +export function initAll(app: Express): Map { + for (const [, adapter] of adapters) { + adapter.setup(app); + } + listAvailable(); // Pre-cache — sync execFileSync runs once at startup, not per-request + return adapters; +} + +export function getAll(): Map { + return adapters; +} + +export async function cleanupAll(): Promise { + for (const [, adapter] of adapters) { + await adapter.cleanup(); + } +} diff --git a/server/adapters/shared/tmux-manager.ts b/server/adapters/shared/tmux-manager.ts new file mode 100644 index 0000000..5d50d62 --- /dev/null +++ b/server/adapters/shared/tmux-manager.ts @@ -0,0 +1,107 @@ +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { unlinkSync } from 'fs'; +import { writeFile } from 'fs/promises'; +import { randomUUID } from 'crypto'; + +const exec = promisify(execFile); +const TMUX = 'tmux'; +const SESSION_NAME = 'clawtap'; + +/** A tmux window entry from listWindows */ +export interface TmuxWindow { + id: string; + name: string; + command: string; + cwd: string; +} + +export class TmuxManager { + async ensureSession(): Promise { + try { + await exec(TMUX, ['has-session', '-t', SESSION_NAME]); + } catch { + await exec(TMUX, ['new-session', '-d', '-s', SESSION_NAME, '-n', 'main']); + } + } + + async createWindow(name: string, cwd: string, command: string): Promise { + await this.ensureSession(); + await exec(TMUX, [ + 'new-window', '-t', SESSION_NAME, '-n', name, '-c', cwd, command + ]); + const { stdout } = await exec(TMUX, [ + 'list-windows', '-t', SESSION_NAME, '-F', '#{window_name}\t#{window_id}' + ]); + const line = stdout.trim().split('\n').find(l => l.startsWith(name + '\t')); + return line ? line.split('\t')[1]! : name; + } + + async sendKeys(windowId: string, text: string, enter: boolean = true): Promise { + const target = `${SESSION_NAME}:${windowId}`; + await exec(TMUX, ['send-keys', '-t', target, '-l', text]); + if (enter) { + await exec(TMUX, ['send-keys', '-t', target, 'Enter']); + } + } + + async sendControl(windowId: string, key: string): Promise { + const target = `${SESSION_NAME}:${windowId}`; + await exec(TMUX, ['send-keys', '-t', target, key]); + } + + async pasteBuffer(windowId: string, content: string, sendEnter: boolean = true): Promise { + const id = randomUUID(); + const tmpFile = `/tmp/clawtap-buf-${id}.txt`; + const bufName = `ct-${id.slice(0, 8)}`; + await writeFile(tmpFile, content); + const target = `${SESSION_NAME}:${windowId}`; + try { + await exec(TMUX, ['load-buffer', '-b', bufName, tmpFile]); + await exec(TMUX, ['paste-buffer', '-b', bufName, '-t', target]); + if (sendEnter) { + await exec(TMUX, ['send-keys', '-t', target, 'Enter']); + } + } finally { + exec(TMUX, ['delete-buffer', '-b', bufName]).catch(() => {}); + try { unlinkSync(tmpFile); } catch {} + } + } + + async capturePane(windowId: string, lines: number = 200): Promise { + const target = `${SESSION_NAME}:${windowId}`; + const { stdout } = await exec(TMUX, [ + 'capture-pane', '-t', target, '-p', '-S', `-${lines}` + ]); + return stdout; + } + + async killWindow(windowId: string): Promise { + const target = `${SESSION_NAME}:${windowId}`; + try { await exec(TMUX, ['kill-window', '-t', target]); } catch {} + } + + async renameWindow(windowId: string, newName: string): Promise { + const target = `${SESSION_NAME}:${windowId}`; + await exec(TMUX, ['rename-window', '-t', target, newName]); + } + + async killSession(): Promise { + try { await exec(TMUX, ['kill-session', '-t', SESSION_NAME]); } catch {} + } + + async listWindows(): Promise { + try { + const { stdout } = await exec(TMUX, [ + 'list-windows', '-t', SESSION_NAME, '-F', + '#{window_id}\t#{window_name}\t#{pane_current_command}\t#{pane_current_path}' + ]); + return stdout.trim().split('\n').filter(Boolean).map(line => { + const [id, name, command, cwd] = line.split('\t'); + return { id: id!, name: name!, command: command!, cwd: cwd! }; + }); + } catch { return []; } + } +} + +export const tmuxManager = new TmuxManager(); diff --git a/server/auth.ts b/server/auth.ts new file mode 100644 index 0000000..0e54652 --- /dev/null +++ b/server/auth.ts @@ -0,0 +1,101 @@ +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcrypt'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import type { Request, Response, NextFunction } from 'express'; +import type { AppConfig } from './config.js'; +import { rateLimit } from './db.js'; + +const SALT_ROUNDS = 10; +const JWT_EXPIRY = '7d'; +const MAX_ATTEMPTS = 5; +const WINDOW_SECONDS = 60; + +let jwtSecret: string; +let passwordHash: string; + +// Must be called before any other function +export async function initAuth(config: AppConfig): Promise { + const { password } = config; + const AUTH_FILE = config.paths.auth; + + // Load or generate JWT secret + if (existsSync(AUTH_FILE)) { + try { + const data = JSON.parse(readFileSync(AUTH_FILE, 'utf8')); + jwtSecret = data.jwtSecret; + } catch {} + } + if (!jwtSecret) { + const { randomBytes } = await import('crypto'); + jwtSecret = randomBytes(64).toString('hex'); + writeFileSync(AUTH_FILE, JSON.stringify({ jwtSecret }), { mode: 0o600 }); + } + + passwordHash = await bcrypt.hash(password, SALT_ROUNDS); +} + +export async function login( + password: string, + ip: string +): Promise<{ token: string } | { error: string; status: number }> { + // Check rate limit from SQLite + const recentAttempts = rateLimit.countRecent(ip, WINDOW_SECONDS); + if (recentAttempts >= MAX_ATTEMPTS) { + return { error: 'Too many login attempts. Try again later.', status: 429 }; + } + + // Record this attempt + rateLimit.record(ip); + + const valid = await bcrypt.compare(password, passwordHash); + if (!valid) { + return { error: 'Invalid password', status: 401 }; + } + + // Periodically clean up old attempts + rateLimit.cleanup(); + + const token = jwt.sign({ user: 'admin' }, jwtSecret, { expiresIn: JWT_EXPIRY }); + return { token }; +} + +export function verifyToken(token: string): Record | null { + try { + return jwt.verify(token, jwtSecret) as Record; + } catch { + return null; + } +} + +export function authMiddleware(req: Request, res: Response, next: NextFunction): void { + const authHeader = req.headers['authorization']; + const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null; + + if (!token) { + res.status(401).json({ error: 'No token provided' }); + return; + } + + const decoded = verifyToken(token); + if (!decoded) { + res.status(403).json({ error: 'Invalid token' }); + return; + } + + // Auto-refresh: if past halfway, issue new token + if (decoded.exp && decoded.iat) { + const now = Math.floor(Date.now() / 1000); + const halfLife = ((decoded.exp as number) - (decoded.iat as number)) / 2; + if (now > (decoded.iat as number) + halfLife) { + const newToken = jwt.sign({ user: 'admin' }, jwtSecret, { expiresIn: JWT_EXPIRY }); + res.setHeader('X-Refreshed-Token', newToken); + } + } + + (req as Request & { user: Record }).user = decoded; + next(); +} + +export function verifyWebSocketToken(token: string): boolean { + return verifyToken(token) !== null; +} diff --git a/server/config.ts b/server/config.ts new file mode 100644 index 0000000..00c09a7 --- /dev/null +++ b/server/config.ts @@ -0,0 +1,79 @@ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +const CLAWTAP_DIR = path.join(os.homedir(), '.clawtap'); + +export interface AppConfig { + password: string; + port: number; + clawtapDir: string; + https: { cert: Buffer; key: Buffer } | null; + transcription: { provider: 'whisper'; apiKey: string } | null; + gitBranch: string; + paths: { + auth: string; + vapidKeys: string; + pushSubs: string; + pid: string; + uploads: string; + db: string; + }; +} + +export function loadConfig(): AppConfig { + const password = process.env.CLAUDE_UI_PASSWORD; + if (!password) { + throw new Error( + 'CLAUDE_UI_PASSWORD is required.\n' + + 'Set it and try again:\n' + + ' export CLAUDE_UI_PASSWORD=your-password' + ); + } + + const port = parseInt(process.env.PORT || '', 10) || 3456; + + const certPath = path.join(CLAWTAP_DIR, 'cert.pem'); + const keyPath = path.join(CLAWTAP_DIR, 'key.pem'); + const httpsConfig = (fs.existsSync(certPath) && fs.existsSync(keyPath)) + ? { cert: fs.readFileSync(certPath), key: fs.readFileSync(keyPath) } + : null; + + const transcription = process.env.OPENAI_API_KEY + ? { provider: 'whisper' as const, apiKey: process.env.OPENAI_API_KEY } + : null; + + const config: AppConfig = { + password, + port, + clawtapDir: CLAWTAP_DIR, + https: httpsConfig, + transcription, + gitBranch: process.env.GIT_BRANCH || 'unknown', + paths: { + auth: path.join(os.homedir(), '.clawtap-auth.json'), + vapidKeys: path.join(CLAWTAP_DIR, 'vapid-keys.json'), + pushSubs: path.join(CLAWTAP_DIR, 'push-subscriptions.json'), + pid: path.join(CLAWTAP_DIR, 'server.pid'), + uploads: path.join(os.tmpdir(), 'clawtap-uploads'), + db: path.join(CLAWTAP_DIR, 'clawtap.db'), + }, + }; + + printFeatureStatus(config); + return config; +} + +function printFeatureStatus(config: AppConfig): void { + const features: [string, string][] = [ + ['HTTPS', config.https ? '✓ enabled' : '✗ disabled (no certs)'], + ['Voice Transcription', config.transcription ? `✓ ${config.transcription.provider}` : '✗ disabled (no OPENAI_API_KEY)'], + ['Port', String(config.port)], + ]; + + console.log('\n ClawTap Configuration:'); + for (const [name, status] of features) { + console.log(` ${name}: ${status}`); + } + console.log(''); +} diff --git a/server/db.ts b/server/db.ts new file mode 100644 index 0000000..90f6acd --- /dev/null +++ b/server/db.ts @@ -0,0 +1,355 @@ +import Database from 'better-sqlite3'; +import type BetterSqlite3 from 'better-sqlite3'; +import { mkdirSync } from 'fs'; +import { dirname } from 'path'; +import type { AppConfig } from './config.js'; + +let db: BetterSqlite3.Database | null = null; + +// --- Lifecycle --- + +export function initDB(config: AppConfig): void { + const dbPath = config.paths.db; + mkdirSync(dirname(dbPath), { recursive: true }); + + db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + + db.exec(` + CREATE TABLE IF NOT EXISTS push_subscriptions ( + endpoint TEXT PRIMARY KEY, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + last_used TEXT + ); + + CREATE TABLE IF NOT EXISTS login_attempts ( + ip TEXT NOT NULL, + attempted_at TEXT DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_login_ip ON login_attempts(ip); + + CREATE TABLE IF NOT EXISTS user_preferences ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS session_stats ( + session_id TEXT NOT NULL, + event_type TEXT NOT NULL, + event_data TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_stats_session ON session_stats(session_id); + + CREATE TABLE IF NOT EXISTS session_reviews ( + id TEXT PRIMARY KEY, + parent_cli_session_id TEXT NOT NULL, + child_cli_session_id TEXT NOT NULL, + child_adapter TEXT NOT NULL, + parent_adapter TEXT NOT NULL DEFAULT 'claude', + anchor_message_id TEXT, + review_prompt TEXT, + review_title TEXT, + message_count INTEGER DEFAULT 0, + started_at TEXT DEFAULT (datetime('now')), + ended_at TEXT DEFAULT NULL, + end_anchor_message_id TEXT DEFAULT NULL + ); + CREATE INDEX IF NOT EXISTS idx_reviews_parent ON session_reviews(parent_cli_session_id); + + CREATE TABLE IF NOT EXISTS saved_instructions ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + instruction TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + `); + + // Migration: add parent_adapter column to session_reviews if missing + const reviewInfo = db.prepare("PRAGMA table_info('session_reviews')").all() as { name: string }[]; + if (!reviewInfo.some(c => c.name === 'parent_adapter')) { + db.exec("ALTER TABLE session_reviews ADD COLUMN parent_adapter TEXT NOT NULL DEFAULT 'claude'"); + } + + // Migration: add end_anchor_message_id column to session_reviews if missing + if (!reviewInfo.some(c => c.name === 'end_anchor_message_id')) { + db.exec("ALTER TABLE session_reviews ADD COLUMN end_anchor_message_id TEXT DEFAULT NULL"); + } + + // Drop legacy sessions table (no longer used — adapters use in-memory Maps) + db.exec('DROP TABLE IF EXISTS sessions'); + + console.log('[db] SQLite database initialized at', dbPath); +} + +export function closeDB(): void { + _stmts = null; + if (db) { + db.close(); + db = null; + console.log('[db] Database closed'); + } +} + +function getDB(): BetterSqlite3.Database { + if (!db) throw new Error('Database not initialized — call initDB() first'); + return db; +} + +// --- Cached Prepared Statements --- + +interface PreparedStatements { + pushSubsSave: BetterSqlite3.Statement; + pushSubsRemove: BetterSqlite3.Statement; + pushSubsGetAll: BetterSqlite3.Statement; + pushSubsMarkUsed: BetterSqlite3.Statement; + rateLimitRecord: BetterSqlite3.Statement; + rateLimitCountRecent: BetterSqlite3.Statement; + rateLimitCleanup: BetterSqlite3.Statement; + preferencesGet: BetterSqlite3.Statement; + preferencesSet: BetterSqlite3.Statement; + reviewCreate: BetterSqlite3.Statement; + reviewGetById: BetterSqlite3.Statement; + reviewGetActiveForParent: BetterSqlite3.Statement; + reviewGetAllForParent: BetterSqlite3.Statement; + reviewGetAllChildIds: BetterSqlite3.Statement; + reviewEnd: BetterSqlite3.Statement; + reviewUpdateChildCliId: BetterSqlite3.Statement; + instructionCreate: BetterSqlite3.Statement; + instructionGetAll: BetterSqlite3.Statement; + instructionDelete: BetterSqlite3.Statement; +} + +let _stmts: PreparedStatements | null = null; + +function stmts(): PreparedStatements { + if (!_stmts) { + const d = getDB(); + _stmts = { + // push_subscriptions + pushSubsSave: d.prepare(` + INSERT INTO push_subscriptions (endpoint, p256dh, auth) + VALUES (?, ?, ?) + ON CONFLICT(endpoint) DO UPDATE SET + p256dh = excluded.p256dh, + auth = excluded.auth + `), + pushSubsRemove: d.prepare( + `DELETE FROM push_subscriptions WHERE endpoint = ?` + ), + pushSubsGetAll: d.prepare( + `SELECT * FROM push_subscriptions` + ), + pushSubsMarkUsed: d.prepare( + `UPDATE push_subscriptions SET last_used = datetime('now') WHERE endpoint = ?` + ), + // rate_limit + rateLimitRecord: d.prepare( + `INSERT INTO login_attempts (ip) VALUES (?)` + ), + rateLimitCountRecent: d.prepare( + `SELECT COUNT(*) AS cnt FROM login_attempts + WHERE ip = ? AND attempted_at > datetime('now', '-' || ? || ' seconds')` + ), + rateLimitCleanup: d.prepare( + `DELETE FROM login_attempts WHERE attempted_at < datetime('now', '-1 hour')` + ), + // preferences + preferencesGet: d.prepare( + `SELECT value FROM user_preferences WHERE key = ?` + ), + preferencesSet: d.prepare(` + INSERT INTO user_preferences (key, value) + VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = datetime('now') + `), + // session_reviews + reviewCreate: d.prepare( + `INSERT INTO session_reviews (id, parent_cli_session_id, child_cli_session_id, child_adapter, parent_adapter, anchor_message_id, review_prompt, review_title) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ), + reviewGetById: d.prepare( + `SELECT * FROM session_reviews WHERE id = ?` + ), + reviewGetActiveForParent: d.prepare( + `SELECT * FROM session_reviews WHERE parent_cli_session_id = ? AND ended_at IS NULL` + ), + reviewGetAllForParent: d.prepare( + `SELECT * FROM session_reviews WHERE parent_cli_session_id = ? ORDER BY started_at` + ), + reviewGetAllChildIds: d.prepare( + `SELECT DISTINCT child_cli_session_id FROM session_reviews` + ), + reviewEnd: d.prepare( + `UPDATE session_reviews SET ended_at = datetime('now'), message_count = ?, end_anchor_message_id = ? WHERE id = ?` + ), + reviewUpdateChildCliId: d.prepare( + `UPDATE session_reviews SET child_cli_session_id = ? WHERE child_cli_session_id = ?` + ), + // saved_instructions + instructionCreate: d.prepare( + `INSERT INTO saved_instructions (id, label, instruction) VALUES (?, ?, ?)` + ), + instructionGetAll: d.prepare( + `SELECT * FROM saved_instructions ORDER BY created_at ASC` + ), + instructionDelete: d.prepare( + `DELETE FROM saved_instructions WHERE id = ?` + ), + }; + } + return _stmts; +} + +// --- Session Review Types --- + +export interface SessionReviewRow { + id: string; + parent_cli_session_id: string; + child_cli_session_id: string; + child_adapter: string; + parent_adapter: string; + anchor_message_id: string | null; + review_prompt: string | null; + review_title: string | null; + message_count: number; + started_at: string; + ended_at: string | null; + end_anchor_message_id: string | null; +} + +// --- Push Subscription Operations --- + +export interface PushSubRow { + endpoint: string; + p256dh: string; + auth: string; + created_at: string; + last_used: string | null; +} + +export const pushSubs = { + save(endpoint: string, p256dh: string, auth: string): void { + stmts().pushSubsSave.run(endpoint, p256dh, auth); + }, + + remove(endpoint: string): void { + stmts().pushSubsRemove.run(endpoint); + }, + + getAll(): PushSubRow[] { + return stmts().pushSubsGetAll.all() as PushSubRow[]; + }, + + markUsed(endpoint: string): void { + stmts().pushSubsMarkUsed.run(endpoint); + }, +}; + +// --- Rate Limit Operations --- + +export const rateLimit = { + record(ip: string): void { + stmts().rateLimitRecord.run(ip); + }, + + countRecent(ip: string, windowSeconds: number = 60): number { + const row = stmts().rateLimitCountRecent.get(ip, windowSeconds) as { cnt: number }; + return row.cnt; + }, + + cleanup(): void { + stmts().rateLimitCleanup.run(); + }, +}; + +// --- User Preferences Operations --- + +export const preferences = { + get(key: string): string | undefined { + const row = stmts().preferencesGet.get(key) as { value: string } | undefined; + return row?.value; + }, + + set(key: string, value: string): void { + stmts().preferencesSet.run(key, value); + }, +}; + +// --- Session Review Operations --- + +let _childIdCache: Set | null = null; + +export const sessionReviews = { + create( + id: string, + parentCliId: string, + childCliId: string, + childAdapter: string, + parentAdapter: string, + anchorMsgId?: string, + prompt?: string, + title?: string + ): void { + stmts().reviewCreate.run( + id, + parentCliId, + childCliId, + childAdapter, + parentAdapter, + anchorMsgId ?? null, + prompt ?? null, + title ?? null + ); + _childIdCache = null; // invalidate cache + }, + + getById(reviewId: string): SessionReviewRow | undefined { + return stmts().reviewGetById.get(reviewId) as SessionReviewRow | undefined; + }, + + getActiveForParent(parentCliSessionId: string): SessionReviewRow[] { + return stmts().reviewGetActiveForParent.all(parentCliSessionId) as SessionReviewRow[]; + }, + + getAllForParent(parentCliSessionId: string): SessionReviewRow[] { + return stmts().reviewGetAllForParent.all(parentCliSessionId) as SessionReviewRow[]; + }, + + getAllChildIds(): Set { + if (_childIdCache) return _childIdCache; + const rows = stmts().reviewGetAllChildIds.all() as { child_cli_session_id: string }[]; + _childIdCache = new Set(rows.map(r => r.child_cli_session_id)); + return _childIdCache; + }, + + endReview(reviewId: string, messageCount: number = 0, endAnchorMessageId?: string): void { + stmts().reviewEnd.run(messageCount, endAnchorMessageId || null, reviewId); + _childIdCache = null; // invalidate cache + }, + + updateChildCliId(currentId: string, newCliId: string): void { + stmts().reviewUpdateChildCliId.run(newCliId, currentId); + _childIdCache = null; // invalidate cache + }, +}; + +// --- Saved Instructions Operations --- + +export const savedInstructions = { + create(id: string, label: string, instruction: string): void { + stmts().instructionCreate.run(id, label, instruction); + }, + getAll(): { id: string; label: string; instruction: string; created_at: string }[] { + return stmts().instructionGetAll.all() as any[]; + }, + delete(id: string): void { + stmts().instructionDelete.run(id); + }, +}; diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..c7f2dbe --- /dev/null +++ b/server/index.ts @@ -0,0 +1,481 @@ +import express from 'express'; +import type { Request, Response } from 'express'; +import { createServer } from 'http'; +import { createServer as createHttpsServer } from 'https'; +import { fileURLToPath } from 'url'; +import { dirname, join, resolve } from 'path'; +import { homedir } from 'os'; +import { readFileSync } from 'fs'; +import { + initAuth, + login, + authMiddleware, +} from './auth.js'; +import './adapters/init.js'; +import { initAll, listAvailable, get as getAdapter, getAll as getAllAdapters, cleanupAll, DEFAULT_ADAPTER } from './adapters/registry.js'; +import { initPush, getVapidPublicKey, saveSubscription, removeSubscription, getPendingSessions } from './push.js'; +import { + setupSessionManager, + handleIncomingMessage, + getClientCount, + broadcastReviewStarted, + broadcastReviewEnded, + registerSessionAdapter, +} from './session-manager.js'; +import { WebSocketTransport } from './transport/websocket-transport.js'; +import { loadConfig } from './config.js'; +import type { AppConfig } from './config.js'; +import { initDB, closeDB, sessionReviews, savedInstructions } from './db.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +import multer from 'multer'; +import { randomUUID } from 'crypto'; +import { mkdirSync, writeFileSync, unlinkSync } from 'fs'; +import type { Server as HttpServer } from 'http'; +import type { Server as HttpsServer } from 'https'; + +// --- Start --- + +async function start(): Promise { + const config = loadConfig(); + initDB(config); + + const app = express(); + app.use(express.json({ limit: '10mb' })); + + // Image upload config + const uploadDir = config.paths.uploads; + mkdirSync(uploadDir, { recursive: true }); + const upload = multer({ + storage: multer.diskStorage({ + destination: uploadDir, + filename: (_req: Request, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) => { + const ext = file.originalname.split('.').pop() || 'png'; + cb(null, `${randomUUID()}.${ext}`); + }, + }), + limits: { fileSize: 10 * 1024 * 1024 }, // 10MB + fileFilter: (_req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => { + if (file.mimetype.startsWith('image/')) cb(null, true); + else cb(new Error('Only images allowed')); + }, + }); + + // Serve built frontend in production + const distPath: string = join(__dirname, '..', 'dist'); + app.use(express.static(distPath)); + + // --- REST Routes --- + + app.get('/health', (_req: Request, res: Response) => { + const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')); + res.json({ status: 'ok', branch: config.gitBranch, version: pkg.version }); + }); + + app.post('/api/auth/login', async (req: Request, res: Response) => { + const { password } = req.body as { password?: string }; + if (!password) { + return res.status(400).json({ error: 'Password required' }); + } + const ip: string = req.ip || (req as any).connection?.remoteAddress || 'unknown'; + const result = await login(password, ip); + if ('error' in result) { + return res.status((result as { error: string; status: number }).status).json({ error: result.error }); + } + res.json({ token: (result as { token: string }).token }); + }); + + app.get('/api/sessions', authMiddleware, async (req: Request, res: Response) => { + try { + const { dir, limit } = req.query as { dir?: string; limit?: string }; + const parsedLimit = limit ? parseInt(limit) : 0; + const adapters = getAllAdapters(); + const results = await Promise.all( + [...adapters.entries()].map(([name, adapter]) => + adapter.getSessions(dir, parsedLimit || undefined) + .then(sessions => sessions.map(s => ({ ...s, adapter: name }))) + .catch(err => { console.warn(`[sessions] Failed to get sessions from ${name}:`, (err as Error).message); return [] as any[]; }) + ) + ); + const allSessions = results.flat(); + allSessions.sort((a, b) => { + const aTime = typeof a.lastModified === 'number' ? a.lastModified : new Date(a.lastModified || 0).getTime(); + const bTime = typeof b.lastModified === 'number' ? b.lastModified : new Date(b.lastModified || 0).getTime(); + return bTime - aTime; + }); + if (parsedLimit > 0) allSessions.splice(parsedLimit); + const childIds = sessionReviews.getAllChildIds(); + const filtered = allSessions.filter((s: any) => !childIds.has(s.sessionId)); + res.json(filtered); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + app.post('/api/upload', authMiddleware, upload.single('image'), (req: Request, res: Response) => { + if (!req.file) return res.status(400).json({ error: 'No image uploaded' }); + res.json({ path: req.file.path, filename: req.file.filename }); + }); + + app.get('/api/sessions/:id/messages', authMiddleware, async (req: Request, res: Response) => { + try { + const { adapter: adapterName, dir } = req.query as { adapter?: string; dir?: string }; + const adapter = getAdapter(adapterName || DEFAULT_ADAPTER); + const messages = await adapter!.getMessages(req.params.id as string, dir); + res.json(messages); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + app.get('/api/browse', authMiddleware, async (req: Request, res: Response) => { + try { + const { path: queryPath, adapter: adapterName } = req.query as { path?: string; adapter?: string }; + const requestedPath = queryPath ? resolve(queryPath) : undefined; + const home = homedir(); + if (requestedPath && !requestedPath.startsWith(home)) { + return res.status(403).json({ error: 'Browsing restricted to home directory' }); + } + const adapter = getAdapter(adapterName || DEFAULT_ADAPTER); + const dirs = await adapter!.listDirectory(requestedPath); + res.json(dirs); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + app.get('/api/active-sessions', authMiddleware, (req: Request, res: Response) => { + try { + const { adapter: adapterName } = req.query as { adapter?: string }; + + let allActiveSessions: any[] = []; + + if (adapterName) { + const adapter = getAdapter(adapterName); + if (adapter?.getActiveSessions) { + allActiveSessions = adapter.getActiveSessions(); + } + } else { + // No adapter specified — aggregate all adapters + const adapters = getAllAdapters(); + for (const [, adapter] of adapters) { + if (adapter.getActiveSessions) { + allActiveSessions.push(...adapter.getActiveSessions()); + } + } + } + + for (const s of allActiveSessions) { + const count = getClientCount(s.sessionId); + const totalCount = count + (s.hasDesktop ? 1 : 0); + s.hasClients = totalCount > 0; + (s as any).clientCount = totalCount; + } + const childIds = sessionReviews.getAllChildIds(); + const filteredActive = allActiveSessions.filter((s: any) => !childIds.has(s.sessionId)); + res.json(filteredActive); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + // --- Adapter Discovery Endpoints --- + + app.get('/api/adapters', authMiddleware, (_req: Request, res: Response) => { + res.json(listAvailable()); + }); + + app.get('/api/adapter/:name/config', authMiddleware, (req: Request, res: Response) => { + const adapter = getAdapter(req.params.name as string); + if (!adapter) return res.status(404).json({ error: 'Adapter not found' }); + res.json({ + models: adapter.getModels(), + permissionModes: adapter.getPermissionModes(), + effortLevels: adapter.getEffortLevels(), + effortLabel: adapter.getEffortLabel(), + capabilities: adapter.getCapabilities(), + }); + }); + + // --- Session Management --- + + app.delete('/api/active-sessions/:id', authMiddleware, async (req: Request, res: Response) => { + try { + const { adapter: adapterName } = req.query as { adapter?: string }; + const adapter = getAdapter(adapterName || DEFAULT_ADAPTER); + await adapter!.destroySession(req.params.id as string); + res.json({ ok: true }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + app.post('/api/sessions/start', authMiddleware, async (req: Request, res: Response) => { + try { + const { adapter: adapterName, cwd, model, permissionMode } = req.body; + if (!cwd) return res.status(400).json({ error: 'cwd required' }); + + const adapter = getAdapter(adapterName || DEFAULT_ADAPTER); + if (!adapter) return res.status(400).json({ error: `Unknown adapter: ${adapterName}` }); + + const handle = await adapter.startSession(cwd, { model, permissionMode }); + registerSessionAdapter(handle.sessionId, adapterName || DEFAULT_ADAPTER); + + res.json({ sessionId: handle.sessionId }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + app.post('/api/sessions/resume', authMiddleware, async (req: Request, res: Response) => { + try { + const { sessionId, adapter: adapterName, cwd } = req.body; + if (!sessionId) return res.status(400).json({ error: 'sessionId required' }); + + const resolvedAdapter = adapterName || DEFAULT_ADAPTER; + const adapter = getAdapter(resolvedAdapter); + if (!adapter) return res.status(400).json({ error: `Unknown adapter: ${resolvedAdapter}` }); + + const handle = await adapter.resumeSession(sessionId, cwd || process.cwd()); + registerSessionAdapter(handle.sessionId, resolvedAdapter); + + res.json({ sessionId: handle.sessionId }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + // --- Review API --- + + // Register a review after the child session is already created via QUERY + app.post('/api/reviews/register', authMiddleware, async (req: Request, res: Response) => { + try { + const { parentCliSessionId, childSessionId, targetAdapter, anchorMessageId, prompt, title } = req.body; + if (!parentCliSessionId || !childSessionId) { + return res.status(400).json({ error: 'parentCliSessionId and childSessionId required' }); + } + + // Find which adapter owns the parent session + let parentAdapterName = DEFAULT_ADAPTER; + for (const [name, a] of getAllAdapters()) { + if (a.getSession(parentCliSessionId)) { parentAdapterName = name; break; } + } + + const reviewId = randomUUID(); + sessionReviews.create(reviewId, parentCliSessionId, childSessionId, targetAdapter, parentAdapterName, anchorMessageId, prompt, title); + + // Ensure adapter mapping exists for the child session + registerSessionAdapter(childSessionId, targetAdapter); + + broadcastReviewStarted(parentCliSessionId, { + reviewId, + childSessionId, + childCliSessionId: childSessionId, + childAdapter: targetAdapter, + anchorMessageId, + reviewTitle: title, + }); + + res.json({ reviewId }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + app.delete('/api/reviews/:id', authMiddleware, async (req: Request, res: Response) => { + try { + const review = sessionReviews.getById(req.params.id); + if (!review) return res.status(404).json({ error: 'Review not found' }); + + const { endAnchorMessageId } = req.body || {}; + sessionReviews.endReview(review.id, 0, endAnchorMessageId); + + // Broadcast to parent WS clients + broadcastReviewEnded(review.parent_cli_session_id, review.id); + + // Try to destroy child tmux session + const childAdapter = getAdapter(review.child_adapter); + if (childAdapter) { + try { + await childAdapter.destroySession(review.child_cli_session_id); + } catch (err) { + console.error('[review] Failed to destroy child session:', (err as Error).message); + } + } + + res.json({ ok: true }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + app.post('/api/reviews/:id/send-back', authMiddleware, async (req: Request, res: Response) => { + try { + const review = sessionReviews.getById(req.params.id); + if (!review) return res.status(404).json({ error: 'Review not found' }); + + const { message } = req.body; + if (!message) return res.status(400).json({ error: 'message required' }); + + // Find parent session from adapter's in-memory Map + const parentAdapter = getAdapter(review.parent_adapter || DEFAULT_ADAPTER); + if (!parentAdapter) return res.status(400).json({ error: 'Parent adapter not found' }); + + const parentSessionId = review.parent_cli_session_id; + if (!parentAdapter.getSession(parentSessionId)) { + return res.status(404).json({ error: 'Parent session not found' }); + } + + // Check if parent is busy + if (parentAdapter.isProcessing(parentSessionId)) { + return res.status(409).json({ error: 'Parent session is busy. Wait for the current turn to complete.' }); + } + + // Format and send + const formatted = `[Review feedback from ${review.child_adapter}]:\n${message}`; + await parentAdapter.sendMessage(parentSessionId, formatted); + + res.json({ ok: true }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + app.get('/api/reviews', authMiddleware, (req: Request, res: Response) => { + try { + const { parentCliSessionId } = req.query as { parentCliSessionId?: string }; + if (!parentCliSessionId) return res.status(400).json({ error: 'parentCliSessionId required' }); + + const reviews = sessionReviews.getAllForParent(parentCliSessionId); + res.json(reviews); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + // --- Saved Instructions API --- + + app.get('/api/instructions', authMiddleware, (_req: Request, res: Response) => { + try { + res.json(savedInstructions.getAll()); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + app.post('/api/instructions', authMiddleware, (req: Request, res: Response) => { + try { + const { label, instruction } = req.body; + if (!label || !instruction) return res.status(400).json({ error: 'label and instruction required' }); + const id = randomUUID(); + savedInstructions.create(id, label, instruction); + res.json({ id, label, instruction }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + app.delete('/api/instructions/:id', authMiddleware, (req: Request, res: Response) => { + try { + savedInstructions.delete(req.params.id); + res.json({ ok: true }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + // --- Push Notification API --- + app.get('/api/push/vapid-public-key', authMiddleware, (_req: Request, res: Response) => { + res.json({ publicKey: getVapidPublicKey() }); + }); + + app.post('/api/push/subscribe', authMiddleware, (req: Request, res: Response) => { + const { subscription } = req.body as { subscription?: { endpoint?: string } }; + if (!subscription?.endpoint) return res.status(400).json({ error: 'Missing subscription' }); + saveSubscription(subscription as any); + res.json({ ok: true }); + }); + + app.post('/api/push/unsubscribe', authMiddleware, (req: Request, res: Response) => { + const { endpoint } = req.body as { endpoint?: string }; + if (!endpoint) return res.status(400).json({ error: 'Missing endpoint' }); + removeSubscription(endpoint); + res.json({ ok: true }); + }); + + app.get('/api/push/pending', authMiddleware, (_req: Request, res: Response) => { + res.json(getPendingSessions()); + }); + + // SPA fallback + app.get('*path', (_req: Request, res: Response) => { + res.sendFile(join(distPath, 'index.html')); + }); + + // --- Server + WebSocket --- + + let server: HttpServer | HttpsServer; + if (config.https) { + server = createHttpsServer({ cert: config.https.cert, key: config.https.key }, app); + } else { + server = createServer(app); + } + + // --- WebSocket Transport --- + const wsTransport = new WebSocketTransport(); + wsTransport.setup(server); + wsTransport.on('connection', (conn) => { + conn.send({ type: 'client-id', clientId: conn.clientId }); + }); + wsTransport.on('message', async (conn, msg) => { + try { + await handleIncomingMessage(conn, msg); + } catch (err) { + conn.send({ type: 'error', error: (err as Error).message }); + } + }); + + // Initialize all adapters (registers hook routes, configures CLI hooks) + initAll(app); + + setupSessionManager(); + + // --- Initialize and Listen --- + + await initAuth(config); + initPush(config); + writeFileSync(config.paths.pid, String(process.pid)); + const protocol = config.https ? 'https' : 'http'; + server.listen(config.port, '0.0.0.0', () => { + console.log(`ClawTap running on ${protocol}://0.0.0.0:${config.port}${config.https ? ' (HTTPS)' : ''}`); + }); + + // --- Graceful Shutdown --- + + async function shutdown(signal: string): Promise { + console.log(`\n[shutdown] ${signal} received, cleaning up...`); + await cleanupAll(); + wsTransport.destroy(); + closeDB(); + try { unlinkSync(config.paths.pid); } catch {} + server.close(() => process.exit(0)); + setTimeout(() => process.exit(0), 3000); // Force exit after 3s + } + + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('uncaughtException', (err: Error) => { + console.error('[fatal] uncaught exception:', err); + cleanupAll().catch((e: unknown) => console.error('[cleanup]', e)).finally(() => process.exit(1)); + }); + process.on('unhandledRejection', (err: unknown) => { + console.error('[fatal] unhandled rejection:', err); + cleanupAll().catch((e: unknown) => console.error('[cleanup]', e)).finally(() => process.exit(1)); + }); +} + +start().catch((err: unknown) => { + console.error('Failed to start:', err); + cleanupAll().catch((e: unknown) => console.error('[cleanup]', e)).finally(() => process.exit(1)); +}); diff --git a/server/permission-manager.ts b/server/permission-manager.ts new file mode 100644 index 0000000..5bbd9da --- /dev/null +++ b/server/permission-manager.ts @@ -0,0 +1,160 @@ +/** + * PermissionManager — manages pending permission and question requests. + * + * Extracted from TmuxAdapter to allow reuse across adapters. + * Handles timeouts, session-scoped indexing, and bulk operations. + */ + +export interface PendingPermission { + sessionId: string; + requestId: string; + toolName: string; + input: Record; + timer: ReturnType; +} + +export interface PendingQuestion { + sessionId: string; + requestId: string; + originalInput: Record; + timer: ReturnType; +} + +export class PermissionManager { + private pendingPermissions = new Map(); + private pendingQuestions = new Map(); + /** sessionId -> Set for fast session-scoped lookups */ + private sessionPendingIds = new Map>(); + private timeoutMs: number; + + constructor(timeoutMs = 120_000) { + this.timeoutMs = timeoutMs; + } + + // === Permissions === + + addPermission(requestId: string, sessionId: string, data: { toolName: string; input: Record }): void { + const timer = setTimeout(() => { + this.pendingPermissions.delete(requestId); + this._removePendingId(sessionId, requestId); + }, this.timeoutMs); + + this.pendingPermissions.set(requestId, { + sessionId, + requestId, + toolName: data.toolName, + input: data.input, + timer, + }); + this._trackPendingId(sessionId, requestId); + } + + resolvePermission(requestId: string): PendingPermission | undefined { + const pending = this.pendingPermissions.get(requestId); + if (!pending) return undefined; + clearTimeout(pending.timer); + this.pendingPermissions.delete(requestId); + this._removePendingId(pending.sessionId, requestId); + return pending; + } + + // === Questions === + + addQuestion(requestId: string, sessionId: string, data: { originalInput: Record }): void { + const timer = setTimeout(() => { + this.pendingQuestions.delete(requestId); + this._removePendingId(sessionId, requestId); + }, this.timeoutMs); + + this.pendingQuestions.set(requestId, { + sessionId, + requestId, + originalInput: data.originalInput, + timer, + }); + this._trackPendingId(sessionId, requestId); + } + + resolveQuestion(requestId: string): PendingQuestion | undefined { + const pending = this.pendingQuestions.get(requestId); + if (!pending) return undefined; + clearTimeout(pending.timer); + this.pendingQuestions.delete(requestId); + this._removePendingId(pending.sessionId, requestId); + return pending; + } + + // === Session-scoped Operations === + + /** Get all pending permissions for a session (for reconnect replay). */ + getPendingForSession(sessionId: string): PendingPermission[] { + const ids = this.sessionPendingIds.get(sessionId); + if (!ids) return []; + const result: PendingPermission[] = []; + for (const reqId of ids) { + const perm = this.pendingPermissions.get(reqId); + if (perm) result.push(perm); + } + return result; + } + + /** Get all pending questions for a session (for reconnect replay). */ + getQuestionsForSession(sessionId: string): PendingQuestion[] { + const ids = this.sessionPendingIds.get(sessionId); + if (!ids) return []; + const result: PendingQuestion[] = []; + for (const reqId of ids) { + const q = this.pendingQuestions.get(reqId); + if (q) result.push(q); + } + return result; + } + + /** Clear all pending requests for a session (e.g., when all clients disconnect or turn ends). */ + dismissAll(sessionId: string): void { + const ids = this.sessionPendingIds.get(sessionId); + if (!ids) return; + for (const reqId of ids) { + const perm = this.pendingPermissions.get(reqId); + if (perm) { clearTimeout(perm.timer); this.pendingPermissions.delete(reqId); } + const q = this.pendingQuestions.get(reqId); + if (q) { clearTimeout(q.timer); this.pendingQuestions.delete(reqId); } + } + this.sessionPendingIds.delete(sessionId); + } + + /** + * Resolve all pending permissions for a session as a given behavior. + * Returns the list of resolved permission requestIds (caller handles the actual response). + */ + resolveAllAs(sessionId: string, _behavior: string): string[] { + const ids = this.sessionPendingIds.get(sessionId); + if (!ids) return []; + const resolved: string[] = []; + for (const reqId of ids) { + const perm = this.pendingPermissions.get(reqId); + if (perm) { + clearTimeout(perm.timer); + this.pendingPermissions.delete(reqId); + resolved.push(reqId); + } + const q = this.pendingQuestions.get(reqId); + if (q) { clearTimeout(q.timer); this.pendingQuestions.delete(reqId); } + } + this.sessionPendingIds.delete(sessionId); + return resolved; + } + + // === Internal === + + private _trackPendingId(sessionId: string, requestId: string): void { + let ids = this.sessionPendingIds.get(sessionId); + if (!ids) { ids = new Set(); this.sessionPendingIds.set(sessionId, ids); } + ids.add(requestId); + } + + private _removePendingId(sessionId: string, requestId: string): void { + const ids = this.sessionPendingIds.get(sessionId); + if (ids) { ids.delete(requestId); if (ids.size === 0) this.sessionPendingIds.delete(sessionId); } + } +} diff --git a/server/push.ts b/server/push.ts new file mode 100644 index 0000000..3174380 --- /dev/null +++ b/server/push.ts @@ -0,0 +1,127 @@ +import webpush from 'web-push'; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import type { AppConfig } from './config.js'; +import { pushSubs as dbPushSubs, type PushSubRow } from './db.js'; + +interface PushSubscriptionEntry { + endpoint: string; + subscription: { + endpoint: string; + keys: { p256dh: string; auth: string }; + }; +} + +// In-memory cache (populated from SQLite on init) +let subscriptions: PushSubscriptionEntry[] = []; +let cachedVapidPublicKey: string | null = null; + +// Pending session notification counts (in-memory, resets on restart) +const pendingSessions = new Map(); // sessionId -> count + +export function initPush(config: AppConfig): void { + mkdirSync(config.clawtapDir, { recursive: true }); + + const vapidPath = config.paths.vapidKeys; + + // Load or generate VAPID keys + let vapidKeys: { publicKey: string; privateKey: string }; + if (existsSync(vapidPath)) { + vapidKeys = JSON.parse(readFileSync(vapidPath, 'utf-8')); + } else { + vapidKeys = webpush.generateVAPIDKeys(); + writeFileSync(vapidPath, JSON.stringify(vapidKeys, null, 2)); + console.log('[push] Generated new VAPID keys'); + } + + const email = process.env.VAPID_EMAIL || 'noreply@clawtap.local'; + cachedVapidPublicKey = vapidKeys.publicKey; + webpush.setVapidDetails(`mailto:${email}`, vapidKeys.publicKey, vapidKeys.privateKey); + + // Load subscriptions from SQLite into in-memory cache + const rows = dbPushSubs.getAll(); + subscriptions = rows.map(row => ({ + endpoint: row.endpoint, + subscription: { + endpoint: row.endpoint, + keys: { p256dh: row.p256dh, auth: row.auth }, + }, + })); + + console.log(`[push] Initialized with ${subscriptions.length} subscription(s)`); +} + +export function getVapidPublicKey(): string | null { + return cachedVapidPublicKey; +} + +export function saveSubscription(subscription: PushSubscriptionEntry['subscription']): void { + // Save to SQLite + dbPushSubs.save(subscription.endpoint, subscription.keys.p256dh, subscription.keys.auth); + // Update in-memory cache + subscriptions = subscriptions.filter(s => s.endpoint !== subscription.endpoint); + subscriptions.push({ endpoint: subscription.endpoint, subscription }); +} + +export function removeSubscription(endpoint: string): void { + // Remove from SQLite + dbPushSubs.remove(endpoint); + // Update in-memory cache + subscriptions = subscriptions.filter(s => s.endpoint !== endpoint); +} + +export function incrementPending(sessionId: string): number { + const count = (pendingSessions.get(sessionId) || 0) + 1; + pendingSessions.set(sessionId, count); + return _totalPending(); +} + +export function clearPending(sessionId: string): number { + pendingSessions.delete(sessionId); + return _totalPending(); +} + +export function getPendingSessions(): Record { + const result: Record = {}; + for (const [sid, count] of pendingSessions) { + result[sid] = count; + } + return result; +} + +export async function sendPush(payload: unknown): Promise { + if (subscriptions.length === 0) return; + + const body = JSON.stringify(payload); + const expired: string[] = []; + + await Promise.allSettled( + subscriptions.map(async ({ endpoint, subscription }) => { + try { + await webpush.sendNotification(subscription, body); + } catch (err) { + const e = err as { statusCode?: number; message?: string }; + if (e.statusCode === 410 || e.statusCode === 404) { + // Subscription expired — mark for removal + expired.push(endpoint); + } else { + console.error(`[push] Failed to send to ${endpoint.slice(0, 50)}:`, e.message); + } + } + }) + ); + + // Clean up expired subscriptions + if (expired.length > 0) { + for (const ep of expired) { + dbPushSubs.remove(ep); + } + subscriptions = subscriptions.filter(s => !expired.includes(s.endpoint)); + console.log(`[push] Removed ${expired.length} expired subscription(s)`); + } +} + +function _totalPending(): number { + let total = 0; + for (const count of pendingSessions.values()) total += count; + return total; +} diff --git a/server/session-manager.ts b/server/session-manager.ts new file mode 100644 index 0000000..2c92d18 --- /dev/null +++ b/server/session-manager.ts @@ -0,0 +1,515 @@ +import { get as getAdapter, getAll as getAllAdapters, DEFAULT_ADAPTER } from './adapters/registry.js'; +import type { IAdapter } from './adapters/interface.js'; +import { WS, PLAN_OPTION } from './ws-types.js'; +import type { ClientMessage, QueryOptions, PermissionBehavior } from './types/messages.js'; +import { sendPush, incrementPending, clearPending, getPendingSessions } from './push.js'; +import { basename } from 'path'; +import type { ClientConnection } from './transport/client-connection.js'; +import { sessionReviews } from './db.js'; + +/** Push notification options */ +interface PushOptions { + title: string; + body: string; + tagPrefix: string; +} + +/** Send a push notification for a session event — only if nobody is viewing this session. */ +function triggerPush(adapter: IAdapter, sessionId: string, { title, body, tagPrefix }: PushOptions): void { + const clients = sessionClients.get(sessionId); + if (clients && clients.size > 0) return; + + // Skip push for child review sessions + if (sessionReviews.getAllChildIds().has(sessionId)) return; + + const session = adapter.getSession(sessionId) as { cwd?: string } | null; + const projectName = basename(session?.cwd || '') || 'Unknown'; + const badge = incrementPending(sessionId); + sendPush({ + title, + body: `${body} in ${projectName}`, + tag: `${tagPrefix}-${sessionId}`, + data: { sessionId, badge }, + }).catch((err: Error) => console.error('[push]', err.message)); +} + +/** + * SessionManager — bridges adapter events to connected clients. + * + * Responsibilities: + * - Client lifecycle: register, unregister, reconnect + * - Event routing: adapter events -> client broadcasts + * - Session routing: new/resume/reconnect + * + * Transport-agnostic: works with ClientConnection, never raw WebSocket. + * Adapter-generic: no direct imports of any specific adapter. + */ + +const sessionClients = new Map>(); // sessionId -> Set +const sessionAdapterMap = new Map(); // sessionId -> adapterName +// Codex sessions rekey from temp key to real UUID. If rekey happens before the +// WS client connects (race condition with fast direct-match), the client registers +// under the old key. This alias map resolves old → new so late-connecting clients +// find the correct session. +const rekeyAliases = new Map(); // oldKey -> newKey + +export function setupSessionManager(): void { + const adapters = getAllAdapters(); + for (const [name, adapter] of adapters) { + // Bridge adapter events -> client broadcasts (identical for every adapter) + adapter.on('streaming-text', (sessionId: string, text: string) => { + broadcast(sessionId, { type: WS.TEXT_DELTA, text }); + }); + + adapter.on('thinking', (sessionId: string, thinking: { text: string; detail?: string }) => { + broadcast(sessionId, { type: WS.THINKING, text: thinking.text, detail: thinking.detail }); + }); + + adapter.on('tool-start', (sessionId: string, data: { toolName: string; [key: string]: unknown }) => { + console.log(`[mgr] tool-start: ${data.toolName} for ${sessionId}`); + broadcast(sessionId, { type: WS.TOOL_START, ...data }); + }); + + adapter.on('tool-done', (sessionId: string, data: { toolName: string; [key: string]: unknown }) => { + console.log(`[mgr] tool-done: ${data.toolName} for ${sessionId}`); + broadcast(sessionId, { type: WS.TOOL_DONE, ...data }); + }); + + adapter.on('new-messages', (sessionId: string, messages: Array<{ role: string; [key: string]: unknown }>) => { + console.log(`[mgr] new-messages: ${messages.length} msgs (roles: ${messages.map(m => m.role).join(',')}) for ${sessionId}`); + broadcast(sessionId, { type: WS.MESSAGE_COMPLETE, messages }); + }); + + adapter.on('tool-updates', (sessionId: string, tools: Record) => { + broadcast(sessionId, { type: WS.TOOL_UPDATES, tools }); + }); + + adapter.on('session-idle', (sessionId: string) => { + // Stop hook fired — do a final poll before broadcasting turn-complete + adapter.flushMessages(sessionId); + // Small delay to ensure the pollNow result is broadcast first + setTimeout(() => { + broadcast(sessionId, { type: WS.TURN_COMPLETE, sessionId }); + }, 100); + triggerPush(adapter, sessionId, { title: 'Claude finished', body: 'Turn complete', tagPrefix: 'idle' }); + }); + + adapter.on('permission-request', (sessionId: string, data: { toolName?: string; [key: string]: unknown }) => { + broadcast(sessionId, { type: WS.PERMISSION_REQUEST, ...data }); + triggerPush(adapter, sessionId, { title: 'Permission needed', body: data.toolName || 'tool', tagPrefix: 'perm' }); + }); + + adapter.on('ask-question', (sessionId: string, data: { toolName?: string; [key: string]: unknown }) => { + broadcast(sessionId, { type: WS.PERMISSION_REQUEST, ...data }); + triggerPush(adapter, sessionId, { title: 'Question from Claude', body: 'Waiting for answer', tagPrefix: 'ask' }); + }); + + adapter.on('status-update', (sessionId: string, status: Record) => { + // Dedup is handled by the adapter — just broadcast + broadcast(sessionId, { type: WS.STATUS_UPDATE, ...status }); + }); + + adapter.on('mode-changed', (sessionId: string, mode: string) => { + console.log(`[mgr] mode-changed: ${mode} for ${sessionId}`); + broadcast(sessionId, { type: WS.MODE_UPDATED, mode }); + }); + + adapter.on('session-ended', (sessionId: string) => { + broadcast(sessionId, { type: WS.SESSION_ENDED }); + + // Cascade child reviews — BEFORE deleting client set so broadcasts reach clients + const activeChildren = sessionReviews.getActiveForParent(sessionId); + for (const child of activeChildren) { + sessionReviews.endReview(child.id); + broadcast(sessionId, { type: WS.REVIEW_ENDED, reviewId: child.id }); + const childAdapterObj = getAdapter(child.child_adapter); + if (childAdapterObj) { + childAdapterObj.destroySession(child.child_cli_session_id).catch(() => {}); + } + } + + // THEN clean up maps + sessionClients.delete(sessionId); + sessionAdapterMap.delete(sessionId); + // Clean rekey alias pointing to this session + for (const [oldKey, newKey] of rekeyAliases) { + if (newKey === sessionId) rekeyAliases.delete(oldKey); + } + }); + + adapter.on('session-error', (sessionId: string, data: { errorType?: string; errorDetails?: string; [key: string]: unknown }) => { + broadcast(sessionId, { type: WS.SESSION_ERROR, ...data }); + triggerPush(adapter, sessionId, { + title: 'Session Error', + body: data.errorType === 'rate_limit' ? 'Rate limited' : (data.errorDetails || data.errorType || 'Unknown error'), + tagPrefix: 'error', + }); + }); + + adapter.on('compacting', (sessionId: string) => { + broadcast(sessionId, { type: WS.COMPACTING }); + }); + + adapter.on('compact-done', (sessionId: string) => { + broadcast(sessionId, { type: WS.COMPACT_DONE }); + }); + + adapter.on('processing-started', (sessionId: string) => { + broadcast(sessionId, { type: WS.SESSION_STATE, streaming: true }); + }); + + // When Codex re-keys a session from temp key to real CLI UUID, + // move clients and adapter mapping to the new key + adapter.on('session-rekeyed', (oldKey: string, newKey: string) => { + // Store alias so late-connecting clients can resolve the old key + rekeyAliases.set(oldKey, newKey); + // Move clients from old key to new key + const clients = sessionClients.get(oldKey); + if (clients) { + sessionClients.delete(oldKey); + sessionClients.set(newKey, clients); + // Update each client's sessionId + for (const conn of clients) { + conn.sessionId = newKey; + } + } + // Move adapter mapping + const adapterName = sessionAdapterMap.get(oldKey); + if (adapterName) { + sessionAdapterMap.delete(oldKey); + sessionAdapterMap.set(newKey, adapterName); + } + // Update any active reviews that reference the old key as child (FIX 3) + sessionReviews.updateChildCliId(oldKey, newKey); + // Send updated SESSION_CREATED so frontend knows the real ID + const resolvedAdapter = getAdapter(adapterName || DEFAULT_ADAPTER); + if (resolvedAdapter && clients) { + for (const conn of clients) { + send(conn, { + type: WS.SESSION_CREATED, + sessionId: newKey, + permissionMode: (resolvedAdapter.getSession(newKey) as any)?.permissionMode || (resolvedAdapter.getSession(newKey) as any)?.approvalPolicy, + }); + } + } + }); + + // Set client checker so adapter can decide whether to intercept hooks + adapter.setClientChecker((sessionId: string) => { + const clients = sessionClients.get(sessionId); + return !!(clients && clients.size > 0); + }); + } +} + +// === Helper: resolve adapter for a session === + +function getAdapterForSession(conn: ClientConnection, sessionId?: string): { adapter: IAdapter | undefined; sid: string } { + const sid = sessionId || conn.sessionId || ''; + const name = sessionAdapterMap.get(sid) || DEFAULT_ADAPTER; + return { adapter: getAdapter(name), sid }; +} + +function sendSessionCreated(conn: ClientConnection, adapter: IAdapter, sessionId: string): void { + const sessionObj = adapter.getSession(sessionId) as { + permissionMode?: string; + approvalPolicy?: string; + } | null; + send(conn, { + type: WS.SESSION_CREATED, + sessionId, + permissionMode: sessionObj?.permissionMode || sessionObj?.approvalPolicy, + }); +} + +// === Centralized Message Router === + +export async function handleIncomingMessage(conn: ClientConnection, msg: ClientMessage): Promise { + switch (msg.type) { + case WS.QUERY: + return handleQuery(conn, msg.prompt as string, (msg.options as QueryOptions) || {}); + case WS.PERMISSION_RESPONSE: + return handlePermissionResponse(conn, msg.requestId as string, { + behavior: msg.behavior as string, + alwaysAllow: msg.alwaysAllow as boolean | undefined, + }); + case WS.ASK_RESPONSE: + return handleAskResponse(conn, msg.requestId as string, msg.response as string); + case WS.ABORT: + return handleAbort(conn, msg.sessionId as string | undefined); + case WS.RECONNECT: + return handleReconnect(conn, msg.sessionId as string | undefined, msg.adapter as string | undefined); + case WS.SET_PERMISSION_MODE: + return handleSetPermissionMode(conn, msg.sessionId as string, msg.mode as string); + case WS.SET_MODEL: + return handleSetModel(conn, msg.sessionId as string, msg.model as string); + case WS.PLAN_RESPONSE: + return handlePlanResponse(conn, msg.sessionId as string, msg.optionIndex as number, msg.text as string | undefined); + default: + conn.send({ type: 'error', error: `Unknown message type: ${msg.type}` }); + } +} + +// === Message Handlers === + +export async function handleQuery(conn: ClientConnection, prompt: string, options: QueryOptions): Promise { + const { cwd, model, sessionId, permissionMode, images, adapter: adapterName } = options; + const adapter = getAdapter(adapterName || DEFAULT_ADAPTER); + if (!adapter) throw new Error(`Unknown adapter: ${adapterName}`); + + let handle: { sessionId: string }; + if (sessionId) { + handle = await adapter.resumeSession(sessionId, cwd as string, { permissionMode }); + } else { + handle = await adapter.startSession(cwd || process.cwd(), { model, permissionMode }); + } + + sessionAdapterMap.set(handle.sessionId, adapterName || DEFAULT_ADAPTER); + registerClient(conn, handle.sessionId); + sendSessionCreated(conn, adapter, handle.sessionId); + + // Send the message (images sent as text description for now) + let messageText = prompt; + if (!sessionId && adapterName !== 'claude') { + // New session — prepend marker for Codex UUID matching (Claude already knows its UUID) + messageText = `[CLAWTAP_REF:${handle.sessionId}]\n${prompt}`; + } + if (images && images.length > 0) { + messageText = messageText + '\n\n' + images.map((img: string) => `[Image: ${img}]`).join('\n'); + } + await adapter.sendMessage(handle.sessionId, messageText, { clientId: conn.clientId }); +} + +export function handlePermissionResponse(conn: ClientConnection, requestId: string, response: { behavior: PermissionBehavior; alwaysAllow?: boolean }): void { + const { adapter, sid } = getAdapterForSession(conn); + if (adapter) { + adapter.respondPermission(requestId, response.behavior); + broadcast(sid, { type: WS.PERMISSION_DISMISSED, requestId }); + } +} + +export async function handleAskResponse(conn: ClientConnection, requestId: string, answers: string): Promise { + const { adapter, sid } = getAdapterForSession(conn); + if (adapter) { + adapter.respondQuestion(requestId, answers); + broadcast(sid, { type: WS.PERMISSION_DISMISSED, requestId }); + } +} + +export async function handleAbort(conn: ClientConnection, sessionId?: string): Promise { + const { adapter, sid } = getAdapterForSession(conn, sessionId); + if (sid && adapter) await adapter.interrupt(sid); +} + +export async function handlePlanResponse(conn: ClientConnection, sessionId: string, optionIndex: number, text?: string): Promise { + const { adapter, sid } = getAdapterForSession(conn, sessionId); + if (!sid || !adapter) return; + await adapter.respondPlan(sid, optionIndex, text); + // Broadcast synthetic user message so plan card transitions to read-only on ALL clients + // Options: 0=bypass (YOLO), 1=manually approve, 2=text feedback + const labels = ['Plan approved (YOLO).', 'Plan approved.']; + const msg = optionIndex === PLAN_OPTION.TEXT_FEEDBACK ? (text || 'Rejected.') : (labels[optionIndex] || 'Plan approved.'); + broadcast(sid, { type: WS.MESSAGE_COMPLETE, messages: [{ role: 'user', content: msg }] }); +} + +export async function handleReconnect(conn: ClientConnection, sessionId?: string, adapterHint?: string): Promise { + if (!sessionId) return; + + // Resolve rekey alias (Codex temp key → real UUID) + const resolvedId = rekeyAliases.get(sessionId) || sessionId; + + const adapterName = sessionAdapterMap.get(resolvedId) || adapterHint || DEFAULT_ADAPTER; + const adapter = getAdapter(adapterName); + if (!adapter) return; + + registerClient(conn, sessionId); // registerClient also resolves alias internally + sessionAdapterMap.set(resolvedId, adapterName); + + // Clear pending push notifications for this session and update badge (only if there were pending) + if (getPendingSessions()[resolvedId]) { + const remaining = clearPending(resolvedId); + sendPush({ data: { badge: remaining } }).catch(() => {}); + } + // Always send SESSION_CREATED on reconnect — includes permissionMode + sendSessionCreated(conn, adapter, resolvedId); + + // Send cached status (context %, model, cost) if available + const lastStatus = adapter.getLastStatus(resolvedId); + if (lastStatus) { + send(conn, { type: WS.STATUS_UPDATE, ...lastStatus }); + } + + // Advance watcher past current file position BEFORE loading history — + // prevents watcher from emitting entries during the async getMessages() read + // that would duplicate what HISTORY_LOAD delivers + adapter.syncWatcherPosition(resolvedId); + + // Send current messages from store (full history for reconnection) + try { + const { messages } = await adapter.getMessages(resolvedId); + if (messages.length > 0) { + send(conn, { type: WS.HISTORY_LOAD, messages }); + } + } catch {} + + // Notify client if session is actively processing + if (adapter.isProcessing(resolvedId)) { + send(conn, { type: WS.SESSION_STATE, streaming: true }); + } + + // Replay pending state (running tools, permission/question overlays) + const pending = adapter.getReconnectState(resolvedId); + if (pending.tools) { + send(conn, { type: WS.TOOL_UPDATES, tools: pending.tools }); + } + for (const req of pending.pendingRequests) { + const { type: _type, ...rest } = req as Record; + send(conn, { type: WS.PERMISSION_REQUEST, ...rest }); + } + + // Restore active child reviews + try { + const activeReviews = sessionReviews.getActiveForParent(resolvedId); + for (const review of activeReviews) { + const childAdapterObj = getAdapter(review.child_adapter); + if (!childAdapterObj) continue; + + // Check if child session still exists in adapter's in-memory Map. + // If not (server restarted or windows killed), mark review as ended. + if (!childAdapterObj.getSession(review.child_cli_session_id)) { + sessionReviews.endReview(review.id); + continue; + } + + send(conn, { + type: WS.REVIEW_STARTED, + reviewId: review.id, + childSessionId: review.child_cli_session_id, + childCliSessionId: review.child_cli_session_id, + childAdapter: review.child_adapter, + anchorMessageId: review.anchor_message_id, + reviewTitle: review.review_title, + }); + } + } catch (err) { + console.error('[handleReconnect] Failed to restore child reviews:', err); + } +} + +export async function handleSetModel(conn: ClientConnection, sessionId: string, model: string): Promise { + const { adapter, sid } = getAdapterForSession(conn, sessionId); + if (adapter && sid) { + await adapter.switchModel(sid, model); + } +} + +export async function handleSetPermissionMode(conn: ClientConnection, sessionId: string, mode: string): Promise { + const { adapter, sid } = getAdapterForSession(conn, sessionId); + if (!sid || !adapter) return; + + const success = await adapter.switchPermissionMode(sid, mode); + if (success) { + broadcast(sid, { type: WS.MODE_UPDATED, mode }); + // Only auto-resolve permissions for cycle-type adapters where we know the exact mode + const capabilities = adapter.getCapabilities(); + if (capabilities.permissionModeType !== 'toggle') { + if (mode === 'bypassPermissions') { + adapter.resolveAllPendingAs(sid, 'allow'); + } else { + adapter.releaseAllPending(sid); + } + } + } +} + +// === Client Management === + +function registerClient(conn: ClientConnection, sessionId: string): void { + // Resolve rekey alias: if sessionId was a temp key that's been re-keyed, use the new key + const resolvedId = rekeyAliases.get(sessionId) || sessionId; + if (resolvedId !== sessionId) { + send(conn, { type: WS.SESSION_CREATED, sessionId: resolvedId }); + } + + const existingSession = conn.sessionId; + if (existingSession === resolvedId) { + const set = sessionClients.get(resolvedId); + if (set) set.add(conn); + return; + } + + // Remove from old session if switching + if (existingSession) { + const oldSet = sessionClients.get(existingSession); + if (oldSet) oldSet.delete(conn); + } + + conn.sessionId = resolvedId; + let clients = sessionClients.get(resolvedId); + if (!clients) { + clients = new Set(); + sessionClients.set(resolvedId, clients); + } + clients.add(conn); + + // Set up disconnect handler (idempotent — ClientConnection fires 'close' once) + conn.onDisconnect = (c: ClientConnection) => { + const sid = c.sessionId; + if (!sid) return; + const set = sessionClients.get(sid); + if (set) { + set.delete(c); + if (set.size === 0) { + const adapterName = sessionAdapterMap.get(sid) || DEFAULT_ADAPTER; + const adapter = getAdapter(adapterName); + // Only release pending permissions if session is idle — if processing, + // the client may be refreshing and will reconnect shortly to see the overlay + if (adapter && !adapter.isProcessing(sid)) { + adapter.releaseAllPending(sid); + } + console.log(`[session-mgr] All clients disconnected from ${sid}, session persists`); + } + } + }; +} + +// === Broadcasting === + +function send(conn: ClientConnection, message: Record): void { + if (conn.shouldReceive(message as any)) conn.send(message as any); +} + +function broadcast(sessionId: string, message: Record): void { + const clients = sessionClients.get(sessionId); + if (!clients || clients.size === 0) return; + const json = JSON.stringify(message); + for (const conn of clients) { + if (conn.shouldReceive(message as any)) conn.sendRaw(json); + } +} + +// TODO: childCliSessionId is redundant with childSessionId (they are always the same +// CLI UUID now). Remove childCliSessionId from WS protocol and frontend state types. +export function broadcastReviewStarted(parentSessionId: string, review: { + reviewId: string; + childSessionId: string; + childCliSessionId: string; + childAdapter: string; + anchorMessageId?: string; + reviewTitle?: string; +}): void { + broadcast(parentSessionId, { type: WS.REVIEW_STARTED, ...review }); +} + +export function broadcastReviewEnded(parentSessionId: string, reviewId: string): void { + broadcast(parentSessionId, { type: WS.REVIEW_ENDED, reviewId }); +} + +export function getClientCount(sessionId: string): number { + const clients = sessionClients.get(sessionId); + return clients ? clients.size : 0; +} + +export function registerSessionAdapter(sessionId: string, adapterName: string): void { + sessionAdapterMap.set(sessionId, adapterName); +} diff --git a/server/stores/json-watcher.ts b/server/stores/json-watcher.ts new file mode 100644 index 0000000..6681cfe --- /dev/null +++ b/server/stores/json-watcher.ts @@ -0,0 +1,190 @@ +import fs from 'fs'; + +/** + * JSON Watcher for Gemini CLI session files. + * + * Gemini CLI stores sessions as single JSON files that are rewritten entirely + * on each update (unlike Claude/Codex JSONL files that are append-only). + * Byte-offset tracking does not work here — instead we track message count + * and last message ID to detect and emit only new messages. + * + * Uses fs.watch() for instant change notification + fallback polling + debounce. + */ + +export interface GeminiSessionMessage { + id: string; + type: 'user' | 'gemini' | 'error' | 'info'; + content: Array<{ text: string }> | string; + timestamp?: string; + thoughts?: unknown[]; + tokens?: Record; + model?: string; + toolCalls?: unknown[]; +} + +export interface JsonWatcherStartOptions { + skipExisting?: boolean; + fallbackIntervalMs?: number; + debounceMs?: number; +} + +const SIZE_WARNING_BYTES = 2 * 1024 * 1024; // 2MB + +export class JsonWatcher { + filePath: string; + private _lastMessageCount: number; + private _lastMessageId: string | null; + private _lastFileSize: number; + private _onMessages: ((messages: GeminiSessionMessage[]) => void) | null; + private _onError: ((err: Error) => void) | null; + private _fsWatcher: fs.FSWatcher | null; + private _fallbackInterval: ReturnType | null; + private _debounceTimer: ReturnType | null; + private _debounceMs: number; + private _polling: boolean; + + constructor(filePath: string) { + this.filePath = filePath; + this._lastMessageCount = 0; + this._lastMessageId = null; + this._lastFileSize = 0; + this._onMessages = null; + this._onError = null; + this._fsWatcher = null; + this._fallbackInterval = null; + this._debounceTimer = null; + this._debounceMs = 50; + this._polling = false; + } + + start({ skipExisting = true, fallbackIntervalMs = 2000, debounceMs = 50 }: JsonWatcherStartOptions = {}): void { + this._debounceMs = debounceMs; + + if (skipExisting) { + try { + const stats = fs.statSync(this.filePath); + this._lastFileSize = stats.size; + // Read existing messages to set baseline counts without emitting them + const raw = fs.readFileSync(this.filePath, 'utf-8'); + const parsed = JSON.parse(raw) as { messages?: GeminiSessionMessage[] }; + const messages = parsed.messages ?? []; + this._lastMessageCount = messages.length; + this._lastMessageId = messages.length > 0 ? messages[messages.length - 1].id : null; + } catch { + // File may not exist yet — that's fine + } + } + + // Primary: fs.watch() for instant change notification (~1-3ms latency) + try { + this._fsWatcher = fs.watch(this.filePath, () => this._schedulePoll()); + } catch { + // fs.watch may fail on some systems — fallback polling handles it + } + + // Fallback: poll every N ms in case fs.watch misses events + this._fallbackInterval = setInterval(() => this._poll(), fallbackIntervalMs); + + // Immediate first poll (only meaningful when skipExisting = false) + if (!skipExisting) { + this._poll(); + } + } + + stop(): void { + if (this._fsWatcher) { this._fsWatcher.close(); this._fsWatcher = null; } + if (this._fallbackInterval) { clearInterval(this._fallbackInterval); this._fallbackInterval = null; } + if (this._debounceTimer) { clearTimeout(this._debounceTimer); this._debounceTimer = null; } + } + + onNewMessages(cb: (messages: GeminiSessionMessage[]) => void): void { this._onMessages = cb; } + onError(cb: (err: Error) => void): void { this._onError = cb; } + + /** Force an immediate poll (used by Stop hook to ensure final messages are read) */ + pollNow(): void { this._poll(); } + + /** Mark current file position — subsequent polls only return messages after current state. */ + markCurrentPosition(): void { + try { + const stats = fs.statSync(this.filePath); + this._lastFileSize = stats.size; + const raw = fs.readFileSync(this.filePath, 'utf-8'); + const parsed = JSON.parse(raw) as { messages?: GeminiSessionMessage[] }; + const messages = parsed.messages ?? []; + this._lastMessageCount = messages.length; + this._lastMessageId = messages.length > 0 ? messages[messages.length - 1].id : null; + } catch {} + } + + private _schedulePoll(): void { + // Debounce: coalesce rapid rewrites within debounceMs window + if (this._debounceTimer) clearTimeout(this._debounceTimer); + this._debounceTimer = setTimeout(() => { + this._debounceTimer = null; + this._poll(); + }, this._debounceMs); + } + + private _poll(): void { + if (this._polling) return; // Prevent re-entrant polls + this._polling = true; + try { + const stats = fs.statSync(this.filePath); + + // File size guard: skip if unchanged (filters false-positive fs.watch events) + if (stats.size === this._lastFileSize) { + this._polling = false; + return; + } + + if (stats.size > SIZE_WARNING_BYTES) { + console.warn(`[JsonWatcher] File exceeds 2MB (${stats.size} bytes): ${this.filePath}`); + } + + this._lastFileSize = stats.size; + + const raw = fs.readFileSync(this.filePath, 'utf-8'); + const parsed = JSON.parse(raw) as { messages?: GeminiSessionMessage[] }; + const messages = parsed.messages ?? []; + + if (messages.length <= this._lastMessageCount) { + // No new messages (or messages were deleted — reset baseline) + if (messages.length < this._lastMessageCount) { + this._lastMessageCount = messages.length; + this._lastMessageId = messages.length > 0 ? messages[messages.length - 1].id : null; + } + this._polling = false; + return; + } + + // Verify continuity: check that the last known message still exists at its index + if (this._lastMessageId !== null && this._lastMessageCount > 0) { + const anchorMsg = messages[this._lastMessageCount - 1]; + if (!anchorMsg || anchorMsg.id !== this._lastMessageId) { + // Message history was modified — reset baseline to current state without emitting + this._lastMessageCount = messages.length; + this._lastMessageId = messages.length > 0 ? messages[messages.length - 1].id : null; + this._polling = false; + return; + } + } + + // Extract new messages starting from last known count + const newMessages = messages.slice(this._lastMessageCount); + + // Advance position + this._lastMessageCount = messages.length; + this._lastMessageId = messages[messages.length - 1].id; + + if (newMessages.length > 0 && this._onMessages) { + this._onMessages(newMessages); + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT' && this._onError) { + this._onError(err as Error); + } + } finally { + this._polling = false; + } + } +} diff --git a/server/stores/jsonl-watcher.ts b/server/stores/jsonl-watcher.ts new file mode 100644 index 0000000..80dbf18 --- /dev/null +++ b/server/stores/jsonl-watcher.ts @@ -0,0 +1,135 @@ +import fs from 'fs'; + +/** + * Improved JSONL Watcher + * + * Uses fs.watch() for instant change notification + fallback polling. + * Reads only new bytes from file using byte offset tracking. + */ + +export interface JsonlWatcherStartOptions { + skipExisting?: boolean; + fallbackIntervalMs?: number; +} + +export class JsonlWatcher { + filePath: string; + lastByteOffset: number; + private _onEntries: ((entries: unknown[]) => void) | null; + private _onError: ((err: Error) => void) | null; + private _fsWatcher: fs.FSWatcher | null; + private _fallbackInterval: ReturnType | null; + private _polling: boolean; + + constructor(filePath: string) { + this.filePath = filePath; + this.lastByteOffset = 0; + this._onEntries = null; + this._onError = null; + this._fsWatcher = null; + this._fallbackInterval = null; + this._polling = false; + } + + start({ skipExisting = true, fallbackIntervalMs = 2000 }: JsonlWatcherStartOptions = {}): void { + if (skipExisting) { + try { + this.lastByteOffset = fs.statSync(this.filePath).size; + } catch {} + } + + // Primary: fs.watch() for instant change notification (~1-3ms latency) + try { + this._fsWatcher = fs.watch(this.filePath, () => this._poll()); + } catch { + // fs.watch may fail on some systems — fallback polling handles it + } + + // Fallback: poll every N ms in case fs.watch misses events + this._fallbackInterval = setInterval(() => this._poll(), fallbackIntervalMs); + + // Immediate first poll + this._poll(); + } + + stop(): void { + if (this._fsWatcher) { this._fsWatcher.close(); this._fsWatcher = null; } + if (this._fallbackInterval) { clearInterval(this._fallbackInterval); this._fallbackInterval = null; } + } + + onNewEntries(cb: (entries: unknown[]) => void): void { this._onEntries = cb; } + onError(cb: (err: Error) => void): void { this._onError = cb; } + + /** Force an immediate poll (used by Stop hook to ensure final entries are read) */ + pollNow(): void { this._poll(); } + + /** Mark current file position — subsequent polls only return content after this point. */ + markCurrentPosition(): void { + try { + this.lastByteOffset = fs.statSync(this.filePath).size; + } catch {} + } + + private _poll(): void { + if (this._polling) return; // Prevent re-entrant polls + this._polling = true; + try { + const stats = fs.statSync(this.filePath); + + // Detect truncation (/clear command) + if (this.lastByteOffset > stats.size) { + this.lastByteOffset = 0; + } + + if (stats.size <= this.lastByteOffset) { + this._polling = false; + return; + } + + const newSize = stats.size - this.lastByteOffset; + const buffer = Buffer.alloc(newSize); + const fd = fs.openSync(this.filePath, 'r'); + try { + fs.readSync(fd, buffer, 0, newSize, this.lastByteOffset); + } finally { + fs.closeSync(fd); + } + + const text = buffer.toString('utf-8'); + const lines = text.split('\n'); + // Remove trailing empty string from split (artifact of text ending with \n) + // Without this fix, bytesConsumed overshoots by 1, corrupting subsequent reads. + if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); + + const entries: unknown[] = []; + let bytesConsumed = 0; + + for (const line of lines) { + const lineBytes = Buffer.byteLength(line + '\n', 'utf-8'); + if (!line.trim()) { + bytesConsumed += lineBytes; + continue; + } + try { + entries.push(JSON.parse(line)); + bytesConsumed += lineBytes; + } catch { + // Partial JSON line — don't advance offset, retry next poll + break; + } + } + + this.lastByteOffset += bytesConsumed; + + if (entries.length > 0 && this._onEntries) { + this._onEntries(entries); + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT' && this._onError) { + this._onError(err as Error); + } + } finally { + this._polling = false; + } + } +} diff --git a/server/transport/client-connection.ts b/server/transport/client-connection.ts new file mode 100644 index 0000000..1fc544f --- /dev/null +++ b/server/transport/client-connection.ts @@ -0,0 +1,37 @@ +import crypto from 'crypto'; +import type { ServerMessage } from '../types/messages.js'; + +/** + * ClientConnection — abstract base class for transport-agnostic client connections. + * + * Each connected client (WebSocket, SSE, etc.) is represented by a ClientConnection. + * SessionManager works exclusively with this abstraction — never with raw WebSocket. + */ +export abstract class ClientConnection { + readonly clientId: string = crypto.randomUUID(); + readonly transportName: string; + sessionId: string | null = null; + onDisconnect: ((conn: ClientConnection) => void) | null = null; + + constructor(transportName: string) { + this.transportName = transportName; + } + + /** Send a message to this client. Implementation handles serialization. */ + abstract send(message: ServerMessage): void; + + /** Check if the connection is still alive. */ + abstract isAlive(): boolean; + + /** Close the connection. */ + close(): void {} + + /** Filter: should this client receive a given message? Default: yes. */ + shouldReceive(_message: ServerMessage): boolean { return true; } + + /** Send a pre-serialized JSON string. Default fallback parses and calls send(). */ + sendRaw(json: string): void { this.send(JSON.parse(json)); } + + /** Notify the disconnect handler (called by subclass when connection drops). */ + notifyDisconnect(): void { this.onDisconnect?.(this); } +} diff --git a/server/transport/websocket-connection.ts b/server/transport/websocket-connection.ts new file mode 100644 index 0000000..1404b09 --- /dev/null +++ b/server/transport/websocket-connection.ts @@ -0,0 +1,39 @@ +import type WebSocket from 'ws'; +import { ClientConnection } from './client-connection.js'; +import type { ServerMessage } from '../types/messages.js'; + +/** + * WebSocketConnection — wraps a raw ws.WebSocket as a ClientConnection. + */ +export class WebSocketConnection extends ClientConnection { + private ws: WebSocket; + + constructor(ws: WebSocket) { + super('websocket'); + this.ws = ws; + ws.on('close', () => this.notifyDisconnect()); + } + + send(message: ServerMessage): void { + try { + if (this.ws.readyState === 1) this.ws.send(JSON.stringify(message)); + } catch {} + } + + sendRaw(json: string): void { + try { + if (this.ws.readyState === 1) this.ws.send(json); + } catch {} + } + + isAlive(): boolean { + return this.ws.readyState === 1; + } + + close(): void { + this.ws.close(); + } + + /** Access the underlying WebSocket (for ping/pong, etc.) */ + get rawWs(): WebSocket { return this.ws; } +} diff --git a/server/transport/websocket-transport.ts b/server/transport/websocket-transport.ts new file mode 100644 index 0000000..5ce1d4b --- /dev/null +++ b/server/transport/websocket-transport.ts @@ -0,0 +1,78 @@ +import { EventEmitter } from 'events'; +import { WebSocketServer } from 'ws'; +import type WebSocket from 'ws'; +import type { Server as HttpServer } from 'http'; +import type { Server as HttpsServer } from 'https'; +import { verifyWebSocketToken } from '../auth.js'; +import { WebSocketConnection } from './websocket-connection.js'; +import type { ClientConnection } from './client-connection.js'; +import type { ClientMessage } from '../types/messages.js'; + +/** + * WebSocketTransport — manages the WebSocket server lifecycle. + * + * Emits: + * 'connection' (conn: ClientConnection) — new authenticated client connected + * 'message' (conn: ClientConnection, msg: ClientMessage) — parsed message from client + */ +export class WebSocketTransport extends EventEmitter { + private wss: WebSocketServer | null = null; + private pingInterval: ReturnType | null = null; + + /** Create WebSocketServer on /ws path with JWT verification and ping/pong keepalive. */ + setup(server: HttpServer | HttpsServer): void { + this.wss = new WebSocketServer({ + server, + path: '/ws', + verifyClient: ({ req }, cb) => { + const url = new URL(req.url!, `http://${req.headers.host}`); + const token = url.searchParams.get('token'); + if (!token || !verifyWebSocketToken(token)) { + cb(false, 401, 'Unauthorized'); + return; + } + cb(true); + }, + }); + + this.wss.on('connection', (ws: WebSocket) => { + const conn = new WebSocketConnection(ws); + + this.emit('connection', conn); + + ws.on('message', (raw: Buffer | ArrayBuffer | Buffer[]) => { + let msg: ClientMessage; + try { + msg = JSON.parse(raw.toString()) as ClientMessage; + } catch { + conn.send({ type: 'error', error: 'Invalid JSON' }); + return; + } + this.emit('message', conn, msg); + }); + }); + + // Ping/pong keepalive every 30s + this.pingInterval = setInterval(() => { + if (!this.wss) return; + for (const ws of this.wss.clients) { + if (ws.readyState === 1) { + ws.ping(); + } + } + }, 30_000); + this.pingInterval.unref(); + } + + /** Shut down the WebSocket server and stop keepalive. */ + destroy(): void { + if (this.pingInterval) { + clearInterval(this.pingInterval); + this.pingInterval = null; + } + if (this.wss) { + this.wss.close(); + this.wss = null; + } + } +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..3a3071a --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "../dist/server", + "rootDir": ".", + "declaration": true, + "sourceMap": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "noUncheckedIndexedAccess": true + }, + "include": ["./**/*.ts"], + "exclude": ["../node_modules"] +} diff --git a/server/types/adapter.ts b/server/types/adapter.ts new file mode 100644 index 0000000..983d69e --- /dev/null +++ b/server/types/adapter.ts @@ -0,0 +1,55 @@ +import type { ToolStatus } from './messages.js'; + +export interface AdapterCapabilities { + supportsPlanMode: boolean; + supportsPermissionModes: boolean; + supportsInterrupt: boolean; + supportsResume: boolean; + supportsAttach: boolean; + supportsStatusLine: boolean; + supportsImages: boolean; + supportsStreaming: boolean; + maxContextWindow: number; + permissionModeType?: 'cycle' | 'toggle'; +} + +export interface SessionInfo { + sessionId: string; + cwd: string | null; + lastModified?: number | string; + firstPrompt?: string | null; + model?: string | null; +} + +export interface ModelInfo { + value: string; + label: string; + contextWindow?: number; +} + +export interface PermissionModeInfo { + value: string; + label: string; +} + +export interface EffortLevelInfo { + value: string; + label: string; +} + +export interface AdapterInfo { + id: string; + displayName: string; + available: boolean; + capabilities?: AdapterCapabilities; +} + +export interface ReconnectState { + tools: Record; + pendingRequests: Array<{ + type: 'permission' | 'question'; + requestId: string; + toolName?: string; + input?: Record; + }>; +} diff --git a/server/types/messages.ts b/server/types/messages.ts new file mode 100644 index 0000000..893b619 --- /dev/null +++ b/server/types/messages.ts @@ -0,0 +1,67 @@ +export type ServerMessageType = + | 'session-created' | 'session-state' | 'text-delta' | 'thinking' + | 'tool-start' | 'tool-done' | 'tool-updates' | 'message-complete' + | 'permission-request' | 'permission-dismissed' | 'history-load' + | 'turn-complete' | 'status-update' | 'mode-updated' + | 'compacting' | 'compact-done' | 'session-error' | 'session-ended' + | 'client-id' | 'pending-notifications' | 'error' + | 'review-started' | 'review-ended'; + +export interface ServerMessage { + type: ServerMessageType; + [key: string]: unknown; +} + +export type ClientMessageType = + | 'query' | 'permission-response' | 'ask-response' | 'abort' + | 'reconnect' | 'set-permission-mode' | 'plan-response'; + +export interface ClientMessage { + type: ClientMessageType; + [key: string]: unknown; +} + +export interface QueryOptions { + adapter?: string; + cwd?: string; + model?: string; + sessionId?: string; + permissionMode?: string; + effort?: string; + images?: string[]; + clientId?: string; +} + +export type PermissionBehavior = 'allow' | 'allow_session' | 'deny'; + +export type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions'; + +export interface ChatMessage { + role: 'user' | 'assistant'; + content: MessageContent[]; + interrupted?: boolean; + plan?: string; + senderClientId?: string; + id?: string; // unique message ID + adapter?: string; // 'claude' | 'codex' + timestamp?: string; // ISO 8601 +} + +export type MessageContent = + | { type: 'text'; text: string } + | { type: 'tool_use'; id: string; name: string; input: Record } + | { type: 'tool_result'; tool_use_id: string; content: string }; + +export interface ToolStatus { + name: string; + input?: Record; + status: 'running' | 'success' | 'error' | 'interrupted'; + result?: string; + error?: string; +} + +export interface SessionStatus { + contextPercent: number; + model: string; + cost: number; +} diff --git a/server/ws-types.ts b/server/ws-types.ts new file mode 100644 index 0000000..2f902ac --- /dev/null +++ b/server/ws-types.ts @@ -0,0 +1,51 @@ +export const WS = { + // Client → Server + QUERY: 'query', + PERMISSION_RESPONSE: 'permission-response', + ASK_RESPONSE: 'ask-response', + ABORT: 'abort', + RECONNECT: 'reconnect', + SET_PERMISSION_MODE: 'set-permission-mode', + SET_MODEL: 'set-model', + PLAN_RESPONSE: 'plan-response', + // Server → Client + SESSION_STATE: 'session-state', + SESSION_CREATED: 'session-created', + TEXT_DELTA: 'text-delta', + THINKING: 'thinking', + TOOL_START: 'tool-start', + TOOL_DONE: 'tool-done', + MESSAGE_COMPLETE: 'message-complete', + TOOL_UPDATES: 'tool-updates', + TURN_COMPLETE: 'turn-complete', + PERMISSION_REQUEST: 'permission-request', + PERMISSION_DISMISSED: 'permission-dismissed', + HISTORY_LOAD: 'history-load', + STATUS_UPDATE: 'status-update', + MODE_UPDATED: 'mode-updated', + COMPACTING: 'compacting', + COMPACT_DONE: 'compact-done', + SESSION_ERROR: 'session-error', + SESSION_ENDED: 'session-ended', + CLIENT_ID: 'client-id', + PENDING_NOTIFICATIONS: 'pending-notifications', + ERROR: 'error', + // Cross-AI Review + REVIEW_STARTED: 'review-started', + REVIEW_ENDED: 'review-ended', +} as const; + +export type WsType = typeof WS[keyof typeof WS]; + +/** + * CLI plan approval selector option indices (ExitPlanMode). + * Claude Code v2.1.x shows 3 options: + * 0: "Yes, auto-accept edits" → BYPASS + * 1: "Yes, manually approve edits" → MANUALLY_APPROVE + * 2: "Type here to tell Claude what to change" → TEXT_FEEDBACK + */ +export const PLAN_OPTION = { + BYPASS: 0, + MANUALLY_APPROVE: 1, + TEXT_FEEDBACK: 2, +} as const; diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..52f7aa9 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,296 @@ +import { useState, useCallback, useEffect, useRef } from 'react'; +import { STORAGE } from './lib/storage-keys'; +import { isAuthenticated, clearToken } from './lib/api'; +import { LoginView } from './components/LoginView'; +import { SessionsView } from './components/SessionsView'; +import { ChatView } from './components/ChatView'; +import { SettingsView } from './components/SettingsView'; +import { NewChatView } from './components/NewChatView'; +import { OfflineView } from './components/OfflineView'; +import { LoadingAnimation } from './components/ui/LoadingAnimation'; + +interface BeforeInstallPromptEvent extends Event { + prompt(): Promise; + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>; +} + +type View = + | { name: 'sessions' } + | { name: 'newchat'; cwd: string } + | { name: 'chat'; sessionId?: string; cwd?: string; initialPrompt?: string; adapter?: string } + | { name: 'settings' }; + +function loadView(): View { + try { + const saved = sessionStorage.getItem('currentView'); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed.name === 'chat' && parsed.sessionId) return parsed; + if (parsed.name === 'newchat' && parsed.cwd) return parsed; + } + } catch {} + return { name: 'sessions' }; +} + +function persistView(view: View) { + sessionStorage.setItem('currentView', JSON.stringify(view)); +} + +function navigateTo(view: View) { + persistView(view); + const url = view.name === 'chat' && view.sessionId + ? `/?view=chat&session=${view.sessionId}` + : view.name === 'settings' + ? '/?view=settings' + : '/'; + window.history.pushState({ view }, '', url); +} + +export function App() { + const [authed, setAuthed] = useState(isAuthenticated()); + const [view, setView] = useState(loadView); + const [serverOnline, setServerOnline] = useState(null); + const [deviceOnline, setDeviceOnline] = useState(navigator.onLine); + const consecutiveFails = useRef(0); + const initialized = useRef(false); + + const [installPrompt, setInstallPrompt] = useState(null); + const [installDismissed, setInstallDismissed] = useState( + () => localStorage.getItem(STORAGE.INSTALL_DISMISSED) === 'true' + ); + const dismissInstall = useCallback(() => { + setInstallPrompt(null); + setInstallDismissed(true); + localStorage.setItem(STORAGE.INSTALL_DISMISSED, 'true'); + }, []); + + const [swUpdateAvailable, setSwUpdateAvailable] = useState(false); + + const handleInstall = useCallback(async () => { + if (!installPrompt) return; + installPrompt.prompt(); + const result = await installPrompt.userChoice; + if (result.outcome === 'accepted') dismissInstall(); + }, [installPrompt, dismissInstall]); + + const handleLogin = useCallback(() => setAuthed(true), []); + + const handleLogout = useCallback(() => { + clearToken(); + sessionStorage.removeItem('currentView'); + setAuthed(false); + }, []); + + const openChat = useCallback((sessionId?: string, cwd?: string, adapter?: string) => { + if (!sessionId && cwd) { + const v: View = { name: 'newchat', cwd }; + navigateTo(v); + setView(v); + } else { + const v: View = { name: 'chat', sessionId, cwd, adapter }; + navigateTo(v); + setView(v); + } + }, []); + + // PWA: Android install prompt + useEffect(() => { + const handler = (e: Event) => { + e.preventDefault(); + setInstallPrompt(e as BeforeInstallPromptEvent); + }; + window.addEventListener('beforeinstallprompt', handler); + window.addEventListener('appinstalled', dismissInstall); + return () => { + window.removeEventListener('beforeinstallprompt', handler); + window.removeEventListener('appinstalled', dismissInstall); + }; + }, [dismissInstall]); + + // PWA: Service worker update notification + useEffect(() => { + const handleControllerChange = () => setSwUpdateAvailable(true); + navigator.serviceWorker?.addEventListener('controllerchange', handleControllerChange); + return () => navigator.serviceWorker?.removeEventListener('controllerchange', handleControllerChange); + }, []); + + // PWA: Clear app badge on focus + useEffect(() => { + const handleVisibility = () => { + if (document.visibilityState === 'visible') { + navigator.clearAppBadge?.(); + } + }; + document.addEventListener('visibilitychange', handleVisibility); + return () => document.removeEventListener('visibilitychange', handleVisibility); + }, []); + + // Handle browser back/forward navigation + useEffect(() => { + const handlePopState = (event: PopStateEvent) => { + if (event.state?.view) { + setView(event.state.view); + persistView(event.state.view); + } else { + setView({ name: 'sessions' }); + persistView({ name: 'sessions' }); + } + }; + window.addEventListener('popstate', handlePopState); + return () => window.removeEventListener('popstate', handlePopState); + }, []); + + // Set initial history state (replaceState, not pushState, to avoid double entry) + useEffect(() => { + window.history.replaceState({ view }, '', window.location.pathname + window.location.search); + }, []); + + const backToSessions = useCallback(() => { + const v: View = { name: 'sessions' }; + navigateTo(v); + setView(v); + }, []); + + // Layer 1: Device network (instant) + useEffect(() => { + const goOnline = () => setDeviceOnline(true); + const goOffline = () => setDeviceOnline(false); + window.addEventListener('online', goOnline); + window.addEventListener('offline', goOffline); + return () => { + window.removeEventListener('online', goOnline); + window.removeEventListener('offline', goOffline); + }; + }, []); + + // Layer 2: Server health check + const checkHealth = useCallback(() => { + fetch('/health', { signal: AbortSignal.timeout(5000) }) + .then(res => { + if (res.ok) { + consecutiveFails.current = 0; + setServerOnline(true); + } else { + consecutiveFails.current++; + if (!initialized.current || consecutiveFails.current >= 2) setServerOnline(false); + } + }) + .catch(() => { + consecutiveFails.current++; + if (!initialized.current || consecutiveFails.current >= 2) setServerOnline(false); + }); + }, []); + + useEffect(() => { + checkHealth(); + initialized.current = true; + const interval = setInterval(checkHealth, 15000); + return () => clearInterval(interval); + }, [checkHealth]); + + // Handle OPEN_SESSION messages from service worker (push notification clicks) + useEffect(() => { + const handler = (event: MessageEvent) => { + if (event.data?.type === 'OPEN_SESSION' && event.data.sessionId) { + openChat(event.data.sessionId); + } + }; + navigator.serviceWorker?.addEventListener('message', handler); + return () => navigator.serviceWorker?.removeEventListener('message', handler); + }, [openChat]); + + // Handle URL parameters (?session= from notification click, ?action=newchat from PWA shortcut) + const urlParamsHandled = useRef(false); + useEffect(() => { + if (urlParamsHandled.current || !authed) return; + const params = new URLSearchParams(window.location.search); + const sessionId = params.get('session'); + const action = params.get('action'); + if (sessionId) { + urlParamsHandled.current = true; + openChat(sessionId); + window.history.replaceState({}, '', '/'); + } else if (action === 'newchat') { + urlParamsHandled.current = true; + window.history.replaceState({}, '', '/'); + } + }, [authed, openChat]); + + const startChat = useCallback((options: { adapter: string; model: string; permissionMode: string; effort: string; prompt: string }) => { + // NewChatView.handleSend already saved adapter prefs via saveAdapterPrefs — just set the active adapter + localStorage.setItem(STORAGE.ADAPTER, options.adapter); + // Navigate to chat view with cwd — ChatView will pick up globals and send the prompt + const chatCwd = view.name === 'newchat' ? view.cwd : undefined; + const v: View = { name: 'chat', cwd: chatCwd, initialPrompt: options.prompt, adapter: options.adapter }; + navigateTo(v); + setView(v); + }, [view]); + + const isOffline = !deviceOnline || serverOnline === false; + + // Splash screen while first health check is pending + if (serverOnline === null) { + return ( +
+ +
+ ); + } + + // Offline screen + if (isOffline) { + return ; + } + + if (!authed) { + return ; + } + + if (view.name === 'newchat') { + return ( + + ); + } + + if (view.name === 'settings') { + return setView({ name: 'sessions' })} />; + } + + if (view.name === 'chat') { + return ( + + ); + } + + return ( + <> + setView({ name: 'settings' })} + installPrompt={!installDismissed ? installPrompt : null} + onInstall={handleInstall} + onDismissInstall={dismissInstall} + /> + {swUpdateAvailable && ( +
+ New version available +
+ + +
+
+ )} + + ); +} diff --git a/src/components/AdapterIcon.tsx b/src/components/AdapterIcon.tsx new file mode 100644 index 0000000..a29e2b6 --- /dev/null +++ b/src/components/AdapterIcon.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { getBrand } from '@/lib/adapter-brands'; + +interface AdapterIconProps { + adapterId: string; + size?: number; + className?: string; +} + +/** Anthropic "A" lettermark — official brand icon from thesvg.org */ +function ClaudeIcon({ size }: { size: number }) { + return ( + + + + ); +} + +/** Google Gemini sparkle — official brand icon from thesvg.org */ +function GeminiIcon({ size }: { size: number }) { + return ( + + + + ); +} + +/** OpenAI knot/flower logo — official brand icon from thesvg.org, used for Codex */ +function CodexIcon({ size }: { size: number }) { + return ( + + + + ); +} + +const ICON_MAP: Record> = { + claude: ClaudeIcon, + codex: CodexIcon, + gemini: GeminiIcon, +}; + +export function AdapterIcon({ adapterId, size = 24, className }: AdapterIconProps) { + const brand = getBrand(adapterId); + const Icon = ICON_MAP[brand.iconType] || ClaudeIcon; + + return ( + + + + ); +} + +export default AdapterIcon; diff --git a/src/components/AdapterSettingsSection.tsx b/src/components/AdapterSettingsSection.tsx new file mode 100644 index 0000000..7dff7b7 --- /dev/null +++ b/src/components/AdapterSettingsSection.tsx @@ -0,0 +1,97 @@ +import { useState, useEffect } from 'react'; +import { ChevronLeft } from 'lucide-react'; +import { api } from '@/lib/api'; +import { loadAdapterPrefs, patchAdapterPrefs } from '@/lib/adapter-prefs'; +import { getBrand } from '@/lib/adapter-brands'; +import { AdapterIcon } from './AdapterIcon'; +import type { AdapterConfig } from '@/types/adapter'; + +export function AdapterSettingsSection({ adapter, onBack }: { adapter: string; onBack: () => void }) { + const [config, setConfig] = useState(null); + const [prefs, setPrefs] = useState(() => loadAdapterPrefs(adapter)); + const [error, setError] = useState(null); + + const brand = getBrand(adapter); + + useEffect(() => { + let cancelled = false; + api.adapterConfig(adapter) + .then((cfg) => { + if (!cancelled) setConfig(cfg); + }) + .catch((err) => { + if (!cancelled) setError(err.message ?? 'Failed to load config'); + }); + return () => { cancelled = true; }; + }, [adapter]); + + function handleChange(field: 'model' | 'permissionMode' | 'effort', value: string) { + const updated = { ...prefs, [field]: value }; + setPrefs(updated); + patchAdapterPrefs(adapter, { [field]: value }); + } + + const selectClass = 'bg-surface border border-border rounded-md text-text px-3 py-2 w-full appearance-none outline-none focus:border-accent font-mono'; + const labelClass = 'text-text-dim text-xs uppercase tracking-wider mb-1.5 font-mono'; + + return ( +
+
+ + + {brand.displayName} Settings +
+ + {error && ( +
{error}
+ )} + + {!config && !error && ( +
Loading…
+ )} + + {config && ( +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ )} +
+ ); +} diff --git a/src/components/AdapterTabs.tsx b/src/components/AdapterTabs.tsx new file mode 100644 index 0000000..39f9851 --- /dev/null +++ b/src/components/AdapterTabs.tsx @@ -0,0 +1,41 @@ +import { ADAPTER_BRANDS, type AdapterBrand } from '@/lib/adapter-brands'; + +const TABS: { id: string; label: string; brand: AdapterBrand | null }[] = [ + { id: 'all', label: 'All', brand: null }, + ...Object.values(ADAPTER_BRANDS).map(b => ({ id: b.id, label: b.displayName, brand: b })), +]; + +export function AdapterTabs({ + active, + onChange, +}: { + active: string; + onChange: (tab: string) => void; +}) { + return ( +
+ {TABS.map((tab) => { + const isActive = active === tab.id; + return ( + + ); + })} +
+ ); +} diff --git a/src/components/AskQuestion.tsx b/src/components/AskQuestion.tsx new file mode 100644 index 0000000..5a0a82c --- /dev/null +++ b/src/components/AskQuestion.tsx @@ -0,0 +1,64 @@ +import { useState } from 'react'; +import { Button } from './ui/button'; +import { Send } from 'lucide-react'; + +export function AskQuestion({ toolUseId, input, onRespond }: { + toolUseId: string; input: any; onRespond: (toolUseId: string, response: string) => void; +}) { + const [customText, setCustomText] = useState(''); + const [showCustom, setShowCustom] = useState(false); + const [answered, setAnswered] = useState(false); + // SDK AskUserQuestion uses questions[0].question/options structure + const firstQ = input?.questions?.[0]; + const question = firstQ?.question || input?.question || input?.text || 'Choose an option'; + const options: Array<{ value: string; label: string; description?: string }> = firstQ?.options || input?.options || input?.choices || []; + + function select(value: string) { if (answered) return; setAnswered(true); onRespond(toolUseId, value); } + function submitCustom() { if (!customText.trim() || answered) return; setAnswered(true); onRespond(toolUseId, customText.trim()); } + + if (answered) return ( +
+

Question answered

+
+ ); + + return ( +
+

{question}

+
+ {options.map((opt, i) => ( + + ))} + {!showCustom ? ( + + ) : ( +
+ setCustomText(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && submitCustom()} + placeholder="Type your answer..." + className="flex-1 bg-bg border border-border rounded-md px-3 py-2 text-text text-sm focus:outline-none focus:border-accent" + autoFocus + /> + +
+ )} +
+
+ ); +} diff --git a/src/components/BlockMarker.tsx b/src/components/BlockMarker.tsx new file mode 100644 index 0000000..dd84a83 --- /dev/null +++ b/src/components/BlockMarker.tsx @@ -0,0 +1,14 @@ +export function BlockMarker({ label, color = '#86efac' }: { label: string; color?: string }) { + return ( +
+
+ + {label} + +
+
+ ); +} diff --git a/src/components/BottomSheet.tsx b/src/components/BottomSheet.tsx new file mode 100644 index 0000000..d8cf1cf --- /dev/null +++ b/src/components/BottomSheet.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from 'react'; +import { cn } from '@/lib/utils'; + +interface BottomSheetProps { + visible: boolean; + onClose?: () => void; + children: ReactNode; + className?: string; + /** Extra classes for the backdrop overlay */ + backdropClassName?: string; + /** z-index class (default: z-50) */ + zIndex?: 'z-30' | 'z-40' | 'z-50' | 'z-60'; + /** Show drag handle bar at top (default: true when onClose provided) */ + showHandle?: boolean; +} + +/** + * Shared bottom sheet — backdrop + slide-up panel. + * Replaces the repeated pattern across SendToExistingSheet, PermissionOverlay, + * StatusBar pickers, ReviewActionMenu, etc. + */ +export function BottomSheet({ visible, onClose, children, className, backdropClassName, zIndex = 'z-50', showHandle }: BottomSheetProps) { + if (!visible) return null; + + const shouldShowHandle = showHandle ?? !!onClose; + + return ( +
+
+
e.stopPropagation()} + > + {shouldShowHandle && ( +
+ )} + {children} +
+
+ ); +} diff --git a/src/components/ChatBody.tsx b/src/components/ChatBody.tsx new file mode 100644 index 0000000..ff6d402 --- /dev/null +++ b/src/components/ChatBody.tsx @@ -0,0 +1,260 @@ +import React, { useRef, useState, useEffect, useMemo, Fragment } from 'react'; +import type { ChatMessage, ToolStatus } from '../hooks/useChat'; +import { MessageBubble } from './MessageBubble'; +import { ToolCallCard } from './ToolCallCard'; +import { TaskProgress } from './TaskProgress'; +import { SubagentGroup } from './SubagentGroup'; +import { ShimmerInput } from './ShimmerInput'; + +export type LiveStatus = { type: 'thinking'; text: string } | { type: 'streaming'; text: string }; + +export interface ChatBodyProps { + messages: ChatMessage[]; + streaming: boolean; + pendingResponse?: boolean; + liveStatus: LiveStatus | null; + toolStatuses: Map; + onSend: (text: string) => void; + onStop: () => void; + disabled: boolean; + interrupted: boolean; + sendTargets?: { adapter: string; label: string }[]; + onSendTo?: (messageId: string, adapter?: string) => void; + onSendBack?: (messageId: string) => void; + className?: string; + /** Optional extra content rendered after specific messages (e.g., review markers) */ + renderAfterMessage?: (messageId: string, index: number) => React.ReactNode; + /** Optional extra content rendered before the input (e.g., queued messages) */ + renderBeforeInput?: () => React.ReactNode; + /** Optional plan rendering — ChatView supplies PlanMode with respondPlan callbacks */ + renderPlanBlock?: (planInput: any, hasUserAfter: boolean, key: string | number) => React.ReactNode; + /** Pre-filled text for the input (e.g., when editing a queued message) */ + initialInputText?: string; + /** Content rendered between the scroll area and input (e.g., StatusBar) */ + renderAboveInput?: () => React.ReactNode; + /** Custom placeholder text for the input */ + inputPlaceholder?: string; + /** Hide the input area and show a read-only notice instead */ + hideInput?: boolean; + /** External ref to the scroll container (for auto-hide header etc.) */ + scrollContainerRef?: React.RefObject; +} + +export function ChatBody({ + messages, + streaming, + pendingResponse = false, + liveStatus, + toolStatuses, + onSend, + onStop, + disabled, + interrupted, + sendTargets, + onSendTo, + onSendBack, + className, + renderAfterMessage, + renderBeforeInput, + renderPlanBlock, + initialInputText, + renderAboveInput, + inputPlaceholder, + hideInput, + scrollContainerRef, +}: ChatBodyProps) { + const internalRef = useRef(null); + const scrollRef = scrollContainerRef || internalRef; + const [userScrolled, setUserScrolled] = useState(false); + + // Auto-scroll to bottom when new messages arrive, unless user scrolled up + useEffect(() => { + if (!userScrolled && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [messages, userScrolled]); + + function handleScroll() { + const el = scrollRef.current; + if (!el) return; + const scrolled = el.scrollHeight - el.scrollTop - el.clientHeight >= 100; + setUserScrolled((prev) => prev !== scrolled ? scrolled : prev); + } + + const lastUserIdx = useMemo( + () => messages.reduce((acc, m, i) => m.role === 'user' ? i : acc, -1), + [messages], + ); + + function renderContentBlocks(content: any[], isLastAssistant: boolean, hasPlanResponse: boolean) { + const elements: React.JSX.Element[] = []; + const toolBlocks = content.filter((b: any) => b.type === 'tool_use'); + // Build a set of tool IDs that have a tool_result in this message's content + // — these tools have definitely completed and should show 'success' even during streaming + const completedToolIds = content.some((b: any) => b.type === 'tool_result') + ? new Set(content.filter((b: any) => b.type === 'tool_result').map((b: any) => b.tool_use_id)) + : null; + + const taskBlocks = toolBlocks.filter((b: any) => b.name === 'TodoWrite'); + const planBlocks = toolBlocks.filter((b: any) => b.name === 'ExitPlanMode' && b.input?.plan); + const regularTools = toolBlocks.filter( + (b: any) => !['TodoWrite', 'EnterPlanMode', 'ExitPlanMode'].includes(b.name), + ); + + const subagentGroups = new Map(); + const topLevelTools: any[] = []; + for (const tool of regularTools) { + if (tool.parent_tool_use_id) { + const group = subagentGroups.get(tool.parent_tool_use_id) || []; + group.push(tool); + subagentGroups.set(tool.parent_tool_use_id, group); + } else { + topLevelTools.push(tool); + } + } + + // Also gather sub-tools from toolStatuses (live streaming path — + // progress entries arrive in later batches after the Agent message was already sent) + for (const [id, status] of toolStatuses) { + if (!status.parentToolUseId) continue; + // Skip if already added from content blocks + const existing = subagentGroups.get(status.parentToolUseId); + if (existing?.some((t: any) => t.id === id)) continue; + const group = existing || []; + group.push({ + type: 'tool_use', + id: status.toolUseId, + name: status.toolName, + input: status.input, + parent_tool_use_id: status.parentToolUseId, + }); + subagentGroups.set(status.parentToolUseId, group); + } + + for (const tool of topLevelTools) { + const status = toolStatuses.get(tool.id); + const subTools = subagentGroups.get(tool.id); + // A tool with a matching tool_result is definitively complete — don't show 'running' + const hasResult = completedToolIds?.has(tool.id) ?? false; + if ((tool.name === 'Agent' || tool.name === 'Task') && subTools) { + elements.push( + , + ); + } else { + const fallbackStatus = hasResult ? 'success' + : isLastAssistant && streaming ? 'running' + : isLastAssistant && interrupted ? 'interrupted' + : 'success'; + elements.push( + , + ); + } + } + + for (const task of taskBlocks) { + elements.push(); + } + + for (const plan of planBlocks) { + if (renderPlanBlock) { + const node = renderPlanBlock(plan, hasPlanResponse, plan.id); + if (node) elements.push(node as React.JSX.Element); + } + } + + return elements; + } + + return ( +
+ {/* Scroll container */} +
+ {messages.length === 0 && !streaming && ( +
Send a message to start
+ )} + + {messages.map((msg, i) => { + if (msg.role === 'interrupted') { + return ( +
+
+ {'\u238F'} + Interrupted · What should Claude do instead? +
+
+ ); + } + if (msg.role === 'plan') { + const planText = msg.content?.find((b: any) => b.type === 'text')?.text || ''; + if (renderPlanBlock) { + return {renderPlanBlock({ plan: planText }, false, i)}; + } + return null; + } + // An assistant message is "last" if it's at the end, or if the only thing after it is an interrupt marker + const isLastAssistant = msg.role === 'assistant' && ( + i === messages.length - 1 || + (i === messages.length - 2 && messages[messages.length - 1]?.role === 'interrupted') + ); + const hasUserAfter = msg.role === 'assistant' && i < lastUserIdx; + const toolElements = msg.role === 'assistant' ? renderContentBlocks(msg.content, isLastAssistant, hasUserAfter) : []; + return ( + +
+ 0))} + sendTargets={sendTargets} + onSendTo={onSendTo} + onSendBack={onSendBack} + /> + {toolElements} +
+ {msg.id && renderAfterMessage?.(msg.id, i)} +
+ ); + })} + + {renderBeforeInput?.()} + + {streaming && pendingResponse && ( +
+
+
+ + + {liveStatus?.type === 'thinking' + ? liveStatus.text + : liveStatus?.type === 'streaming' + ? 'Responding...' + : 'Working...'} + +
+ {liveStatus?.type === 'streaming' && liveStatus.text && ( +

+ {liveStatus.text.substring(0, 200)} +

+ )} +
+
+ )} +
+ + {renderAboveInput?.()} + + {/* Input */} +
+ {!hideInput ? ( + + ) : ( +
+ Review ended — read only +
+ )} +
+
+ ); +} diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx new file mode 100644 index 0000000..6a3367b --- /dev/null +++ b/src/components/ChatView.tsx @@ -0,0 +1,612 @@ +import React, { useState, useRef, useEffect, useCallback, useMemo, Fragment, type RefObject } from 'react'; +import { useChat } from '../hooks/useChat'; +import { PLAN_OPTION } from '../lib/ws-types'; +import { PermissionOverlay } from './PermissionOverlay'; +import { StatusBar } from './StatusBar'; +import { AskQuestion } from './AskQuestion'; +import { PlanMode } from './PlanMode'; +import { ChatBody } from './ChatBody'; +import { FloatingReviewPanel, type ReviewPanelHandle } from './FloatingReviewPanel'; +import { ReviewActionMenu } from './ReviewActionMenu'; +import { SendToExistingSheet } from './SendToExistingSheet'; +import { CollapsedReviewCard } from './CollapsedReviewCard'; +import { BlockMarker } from './BlockMarker'; +import { BottomSheet } from './BottomSheet'; +import { api } from '../lib/api'; +import { getBrand } from '../lib/adapter-brands'; +import { extractTextFromBlocks } from '../lib/content-utils'; +import { patchAdapterPrefs } from '../lib/adapter-prefs'; +import { Button } from './ui/button'; +import { Badge } from './ui/badge'; +import { ChevronLeft, Copy, Check, X } from 'lucide-react'; +import ReactMarkdown from 'react-markdown'; + + +function PlanViewer({ plan }: { plan: string }) { + const [expanded, setExpanded] = useState(false); + + if (expanded) { + return ( +
+
+ PLAN + +
+
+ {plan} +
+
+ ); + } + + return ( +
+ +
+ ); +} + +function ChatHeader({ sessionId, cwd }: { sessionId?: string; cwd?: string }) { + const [copied, setCopied] = useState(false); + const handleCopy = useCallback(() => { + if (!sessionId) return; + navigator.clipboard.writeText(sessionId); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }, [sessionId]); + + const projectName = cwd ? cwd.split('/').filter(Boolean).pop() : null; + const truncatedId = sessionId && sessionId.length > 16 + ? sessionId.slice(0, 16) + '...' + : sessionId; + + return ( +
+
+ + {projectName || (sessionId ? 'Session' : 'New Chat')} + + {sessionId && ( + + )} +
+
+ ); +} + + +/** Tracks scroll direction inside a child scroll container to auto-hide/show the header. */ +function useAutoHideHeader(scrollRef: RefObject) { + const [hidden, setHidden] = useState(false); + const lastScrollTop = useRef(0); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + function onScroll() { + const st = el!.scrollTop; + const delta = st - lastScrollTop.current; + // delta > 0 means scrollTop increased (user scrolled toward bottom/latest) + // delta < 0 means scrollTop decreased (user scrolled toward top/history) + if (delta > 8) setHidden(false); // toward latest → show header + else if (delta < -8) setHidden(true); // toward history → hide header + lastScrollTop.current = st; + } + el.addEventListener('scroll', onScroll, { passive: true }); + return () => el.removeEventListener('scroll', onScroll); + }, [scrollRef]); + + return hidden; +} + +export function ChatView({ + sessionId: initialSessionId, + cwd, + initialPrompt, + adapter, + onBack, +}: { + sessionId?: string; + cwd?: string; + initialPrompt?: string; + adapter?: string; + onBack: () => void; +}) { + const chatScrollRef = useRef(null); + const reviewPanelRef = useRef(null); + const reviewRefetchTimer = useRef | null>(null); + const headerHidden = useAutoHideHeader(chatScrollRef); + + const { + messages, toolStatuses, streaming, pendingResponse, wsStatus, sessionId, liveStatus, + interrupted, sessionStatus, adapterConfig, selectedAdapter, permissionRequest, model, permissionMode, + queuedMessage, clearQueuedMessage, + activeReviews, setActiveReviews, activeReviewPanel, setActiveReviewPanel, + historyReview, setHistoryReview, + sendMessage, respondPermission, respondAsk, respondPlan, abort, + updateModel, updatePermissionMode, + } = useChat(initialSessionId, cwd, adapter, initialPrompt); + + const [availableAdapters, setAvailableAdapters] = useState([]); + useEffect(() => { + api.adapters() + .then(adapters => setAvailableAdapters(adapters.map(a => a.id))) + .catch(console.error); + }, []); + + const [editText, setEditText] = useState(''); + + const handleEditQueued = useCallback(() => { + if (queuedMessage) { + setEditText(queuedMessage); + clearQueuedMessage(); + } + }, [queuedMessage, clearQueuedMessage]); + + const sendTargets = useMemo(() => { + return availableAdapters + .filter(a => a !== selectedAdapter) + .map(a => ({ adapter: a, label: getBrand(a).displayName })); + }, [availableAdapters, selectedAdapter]); + + const sendTargetMenuEntries = useMemo(() => { + return sendTargets.map(t => ({ id: t.adapter, displayName: t.label })); + }, [sendTargets]); + + const [reviewMenuMessageId, setReviewMenuMessageId] = useState(null); + const [sendToMessageId, setSendToMessageId] = useState(null); + + interface ReviewRecord { + id: string; + child_adapter: string; + anchor_message_id: string | null; + review_title: string | null; + ended_at: string | null; + end_anchor_message_id: string | null; + } + const [reviews, setReviews] = useState([]); + + // Shared cleanup for ending/closing an active review + const closeReview = useCallback(async (reviewId?: string) => { + // Empty reviewId means the pending tab's close button — just cancel it + if (reviewId === '') { + setPendingReview(null); + return; + } + const targetId = reviewId || activeReviews[0]?.reviewId; + if (!targetId) return; + + const lastMsg = messages[messages.length - 1]; + const endAnchorMessageId = lastMsg?.id || undefined; + + try { await api.endReview(targetId, endAnchorMessageId); } catch {} + + setActiveReviews(prev => prev.filter(r => r.reviewId !== targetId)); + setHistoryReview(null); + setPendingReview(null); + }, [activeReviews, messages]); + + // Close history panel only (does not affect active review) + const closeHistoryPanel = useCallback(() => { + setHistoryReview(null); + }, []); + + // Fetch review history for this session + useEffect(() => { + if (!sessionId) return; + api.getReviews(sessionId).then(setReviews).catch(() => {}); + }, [sessionId]); + + // Keep reviews in sync with WS events + const prevActiveReviewsRef = useRef(activeReviews); + useEffect(() => { + const prevIds = new Set(prevActiveReviewsRef.current.map(r => r.reviewId)); + const currIds = new Set(activeReviews.map(r => r.reviewId)); + + // New reviews added — batch into a single setReviews call + const newReviews = activeReviews.filter(r => !prevIds.has(r.reviewId)); + if (newReviews.length > 0) { + setReviews(prev => { + const existingIds = new Set(prev.map(r => r.id)); + const toAdd = newReviews + .filter(r => !existingIds.has(r.reviewId)) + .map(r => ({ + id: r.reviewId, + child_adapter: r.childAdapter, + anchor_message_id: r.anchorMessageId ?? null, + review_title: r.reviewTitle ?? null, + ended_at: null, + end_anchor_message_id: null, + })); + return toAdd.length > 0 ? [...prev, ...toAdd] : prev; + }); + } + + // Reviews removed — debounced re-fetch to get ended_at + end_anchor_message_id + const hasRemoved = [...prevIds].some(id => !currIds.has(id)); + if (hasRemoved && sessionId) { + if (reviewRefetchTimer.current) clearTimeout(reviewRefetchTimer.current); + reviewRefetchTimer.current = setTimeout(() => { + api.getReviews(sessionId).then(setReviews).catch(() => {}); + }, 500); + } + + prevActiveReviewsRef.current = activeReviews; + }, [activeReviews, sessionId]); + + const { startMarkersByAnchor, endMarkersByAnchor } = useMemo(() => { + const startMap = new Map(); + const endMap = new Map(); + for (const r of reviews) { + if (r.anchor_message_id) { + const existing = startMap.get(r.anchor_message_id) || []; + existing.push(r); + startMap.set(r.anchor_message_id, existing); + } + if (r.ended_at) { + const endKey = r.end_anchor_message_id || r.anchor_message_id; + if (endKey) { + const existing = endMap.get(endKey) || []; + existing.push(r); + endMap.set(endKey, existing); + } + } + } + return { startMarkersByAnchor: startMap, endMarkersByAnchor: endMap }; + }, [reviews]); + + const handleSendTo = useCallback((messageId: string, _adapter?: string) => { + if (activeReviews.length > 0) { + setSendToMessageId(messageId); + } else { + setReviewMenuMessageId(messageId); + } + }, [activeReviews]); + + const handleSendToExisting = useCallback((reviewId: string) => { + if (!sendToMessageId) return; + const msg = messages.find(m => m.id === sendToMessageId); + if (!msg) return; + const text = extractTextFromBlocks(msg.content); + reviewPanelRef.current?.sendToReview(reviewId, text); + setSendToMessageId(null); + setActiveReviewPanel('expanded'); + }, [sendToMessageId, messages]); + + const handleStartNewFromSheet = useCallback(() => { + if (sendToMessageId) { + setReviewMenuMessageId(sendToMessageId); + setSendToMessageId(null); + } + }, [sendToMessageId]); + + const [saveToast, setSaveToast] = useState<{ instruction: string; label: string } | null>(null); + + // Pending review: waiting for child session to be created (not yet in activeReviews) + const [pendingReview, setPendingReview] = useState<{ + childAdapter: string; + anchorMessageId: string; + reviewTitle: string; + prompt: string; + } | null>(null); + + const openReview = useCallback((adapter: string, model: string, prompt: string, title: string) => { + const anchorId = reviewMenuMessageId; + setReviewMenuMessageId(null); + if (!anchorId) return; + patchAdapterPrefs(adapter, { model }); + setHistoryReview(null); + setPendingReview({ childAdapter: adapter, anchorMessageId: anchorId, reviewTitle: title, prompt }); + setActiveReviewPanel('expanded'); + }, [reviewMenuMessageId, cwd]); + + const handleDirectSend = useCallback((adapter: string, model: string) => { + const anchorMsg = messages.find(m => m.id === reviewMenuMessageId); + const rawText = anchorMsg ? extractTextFromBlocks(anchorMsg.content) : ''; + openReview(adapter, model, rawText, 'direct'); + }, [reviewMenuMessageId, messages, openReview]); + + const handleSendWithInstruction = useCallback((adapter: string, model: string, instruction: string, isCustom: boolean) => { + const anchorMsg = messages.find(m => m.id === reviewMenuMessageId); + const rawText = anchorMsg ? extractTextFromBlocks(anchorMsg.content) : ''; + openReview(adapter, model, `${instruction}\n\n${rawText}`, instruction.substring(0, 30)); + if (isCustom) { + setSaveToast({ instruction, label: instruction.substring(0, 30) }); + setTimeout(() => setSaveToast(null), 3000); + } + }, [reviewMenuMessageId, messages, openReview]); + + const handleOpenReadOnlyReview = useCallback((review: ReviewRecord) => { + setHistoryReview(review); + if (activeReviews.length > 0) setActiveReviewPanel('minimized'); + }, [activeReviews]); + + const renderReviewMarkers = useCallback((messageId: string, _index: number): React.ReactNode => { + const startReviews = startMarkersByAnchor.get(messageId); + const endReviews = endMarkersByAnchor.get(messageId); + if (!startReviews && !endReviews) return null; + + return ( + <> + {startReviews?.map((review) => ( + + + {review.ended_at ? ( + handleOpenReadOnlyReview(review)} + /> + ) : ( + + )} + + ))} + {endReviews?.map((review) => ( + + ))} + + ); + }, [startMarkersByAnchor, endMarkersByAnchor, handleOpenReadOnlyReview]); + + const renderPlanBlock = useCallback((planInput: any, hasUserAfter: boolean, key: string | number): React.ReactNode => { + if (!hasUserAfter) { + return ( + respondPlan(PLAN_OPTION.MANUALLY_APPROVE)} + onApproveYolo={() => respondPlan(PLAN_OPTION.BYPASS)} + onReject={(feedback: string) => respondPlan(PLAN_OPTION.TEXT_FEEDBACK, feedback || 'Rejected')} + onSendFeedback={(feedback: string) => respondPlan(PLAN_OPTION.TEXT_FEEDBACK, feedback)} + /> + ); + } + const planText = planInput.input?.plan || planInput.plan || ''; + return ; + }, [respondPlan]); + + const renderBeforeInput = useCallback((): React.ReactNode => { + if (!queuedMessage) return null; + return ( +
+
+
+ Queued +
+
{queuedMessage}
+
+ + +
+
+
+ ); + }, [queuedMessage, handleEditQueued, clearQueuedMessage]); + + const renderAboveInput = useCallback((): React.ReactNode => ( + <> + {activeReviews.length > 0 && (activeReviewPanel === 'minimized' || historyReview) && ( +
{ setHistoryReview(null); setActiveReviewPanel('expanded'); }} + > + {activeReviews.map(r => ( + + ))} + + {activeReviews.length} review{activeReviews.length > 1 ? 's' : ''}: {activeReviews.map(r => getBrand(r.childAdapter).displayName).join(' \u00B7 ')} + + {'\u25B2'} Expand +
+ )} + + + ), [activeReviews, activeReviewPanel, historyReview, model, permissionMode, sessionStatus, adapterConfig, selectedAdapter, streaming, updateModel, updatePermissionMode]); + + const isHistoryPanel = !!historyReview; + + // Use ref so onSessionCreatedCallback always reads the latest pendingReview + // (prevents stale closure if a second review is opened while the first is still pending) + const pendingReviewRef = useRef(pendingReview); + pendingReviewRef.current = pendingReview; + + const onSessionCreatedCallback = useCallback(async (childSid: string) => { + const pending = pendingReviewRef.current; + if (!sessionId || !pending) return; + try { + const result = await api.registerReview( + sessionId, + childSid, + pending.childAdapter, + pending.anchorMessageId, + pending.prompt, + pending.reviewTitle, + ); + setActiveReviews(prev => { + if (prev.some(r => r.reviewId === result.reviewId)) return prev; + return [...prev, { + reviewId: result.reviewId, + childSessionId: childSid, + childCliSessionId: childSid, + childAdapter: pending.childAdapter, + anchorMessageId: pending.anchorMessageId, + reviewTitle: pending.reviewTitle, + }]; + }); + } catch (err) { + console.error('Failed to register review:', err); + } + setPendingReview(null); + }, [sessionId]); + + return ( +
+ {/* Header — auto-hides when scrolling up to view history */} +
+ + + {wsStatus === 'reconnecting' && ( + Reconnecting... + )} +
+ + {/* Chat body — messages, tools, input */} + + + {/* Floating review panel — active reviews (tabbed) + pending review */} + {activeReviewPanel === 'expanded' && (activeReviews.length > 0 || pendingReview) && ( + ({ + reviewId: r.reviewId, + childSessionId: r.childSessionId, + childAdapter: r.childAdapter, + reviewTitle: r.reviewTitle, + })), + // Pending review: no reviewId yet, triggers session creation in ReviewTab + ...(pendingReview ? [{ + reviewId: '', + childSessionId: '', + childAdapter: pendingReview.childAdapter, + reviewTitle: pendingReview.reviewTitle, + }] : []), + ]} + onEnd={(reviewId) => closeReview(reviewId)} + onMinimize={() => setActiveReviewPanel('minimized')} + initialPrompt={pendingReview?.prompt || undefined} + cwd={cwd} + onSessionCreated={onSessionCreatedCallback} + /> + )} + + {/* Floating review panel — read-only history view */} + {historyReview && ( + closeHistoryPanel()} + onMinimize={() => closeHistoryPanel()} + readOnly + /> + )} + + {/* Send-to-existing bottom sheet — shown when active reviews exist */} + setSendToMessageId(null)} + /> + + {/* Review action menu — two-step bottom sheet for adapter + action selection */} + { setReviewMenuMessageId(null); }} + /> + + {/* Save-as-instruction toast */} + {saveToast && ( +
+ 存成常用? + +
+ )} + + {/* Permission / Ask overlays */} + {permissionRequest && permissionRequest.toolName === 'AskUserQuestion' ? ( + + respondAsk(requestId, response)} + /> + + ) : permissionRequest ? ( + respondPermission(permissionRequest.requestId, 'allow')} + onAllowAll={() => respondPermission(permissionRequest.requestId, 'allow_session')} + onDeny={(msg?: string) => respondPermission(permissionRequest.requestId, 'deny', msg)} + /> + ) : null} +
+ ); +} diff --git a/src/components/CollapsedReviewCard.tsx b/src/components/CollapsedReviewCard.tsx new file mode 100644 index 0000000..a94bfc8 --- /dev/null +++ b/src/components/CollapsedReviewCard.tsx @@ -0,0 +1,32 @@ +import { getBrand } from '@/lib/adapter-brands'; + +export function CollapsedReviewCard({ + adapter, + title, + summary, + onClick, +}: { + adapter: string; + title?: string; + summary: string; + onClick: () => void; +}) { + const brand = getBrand(adapter); + return ( + + ); +} diff --git a/src/components/DiffViewer.tsx b/src/components/DiffViewer.tsx new file mode 100644 index 0000000..071d090 --- /dev/null +++ b/src/components/DiffViewer.tsx @@ -0,0 +1,42 @@ +import { X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +export function DiffViewer({ filePath, oldString, newString, onClose }: { + filePath: string; oldString: string; newString: string; onClose: () => void; +}) { + const oldLines = oldString.split('\n'); + const newLines = newString.split('\n'); + return ( +
+
+
+ + {filePath} +
+
+ -{oldLines.length} + +{newLines.length} +
+
+
+
+ {oldLines.map((line, i) => ( +
+ {i + 1} + - {line} +
+ ))} +
+ {newLines.map((line, i) => ( +
+ {i + 1} + + {line} +
+ ))} +
+
+
+ ); +} diff --git a/src/components/DirectoryBrowser.tsx b/src/components/DirectoryBrowser.tsx new file mode 100644 index 0000000..f17cc77 --- /dev/null +++ b/src/components/DirectoryBrowser.tsx @@ -0,0 +1,135 @@ +import { useState, useEffect, useCallback, Fragment } from 'react'; +import { api } from '../lib/api'; +import { Button } from './ui/button'; +import { X, Folder, ChevronRight } from 'lucide-react'; + +interface DirEntry { + name: string; + path: string; + hasChildren: boolean; +} + +export function DirectoryBrowser({ + onSelect, + onClose, +}: { + onSelect: (path: string) => void; + onClose: () => void; +}) { + const [currentPath, setCurrentPath] = useState(''); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + const browse = useCallback(async (path?: string) => { + setLoading(true); + setError(''); + try { + const dirs = await api.browse(path); + setEntries(dirs); + // Derive current path from first entry's parent, or use the explicit path + if (path) { + setCurrentPath(path); + } else if (dirs.length > 0) { + const first = dirs[0].path; + setCurrentPath(first.substring(0, first.lastIndexOf('/'))); + } + } catch (err: any) { + setError(err.message || 'Failed to browse directory'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + browse(); + }, [browse]); + + const breadcrumbs = currentPath + ? currentPath.split('/').filter(Boolean) + : []; + + const navigateToBreadcrumb = (index: number) => { + const path = '/' + breadcrumbs.slice(0, index + 1).join('/'); + browse(path); + }; + + return ( +
+
+ {/* Header */} +
+

Select Directory

+ +
+ + {/* Breadcrumbs */} +
+ + {breadcrumbs.map((part, i) => ( + + / + + + ))} +
+ + {/* Directory list */} +
+ {loading ? ( +
Loading...
+ ) : error ? ( +
{error}
+ ) : entries.length === 0 ? ( +
No subdirectories
+ ) : ( + entries.map((entry) => ( + + )) + )} +
+ + {/* Footer */} +
+ + {currentPath || '~'} + +
+ + +
+
+
+
+ ); +} diff --git a/src/components/FloatingReviewPanel.tsx b/src/components/FloatingReviewPanel.tsx new file mode 100644 index 0000000..5630e1c --- /dev/null +++ b/src/components/FloatingReviewPanel.tsx @@ -0,0 +1,245 @@ +import React, { useState, useEffect, useRef, useCallback, forwardRef, useImperativeHandle } from 'react'; +import { useChat } from '../hooks/useChat'; +import { ChatBody } from './ChatBody'; +import { getBrand } from '@/lib/adapter-brands'; +import { extractTextFromBlocks } from '@/lib/content-utils'; +import { api } from '@/lib/api'; + +// ===== Types ===== + +export interface ReviewEntry { + reviewId: string; + childSessionId: string; + childAdapter: string; + reviewTitle?: string; +} + +interface ReviewPanelProps { + reviews: ReviewEntry[]; + onEnd: (reviewId: string) => void; + onMinimize: () => void; + initialPrompt?: string; + cwd?: string; + onSessionCreated?: (childSessionId: string) => void; + readOnly?: boolean; +} + +export interface ReviewPanelHandle { + sendToReview: (reviewId: string, text: string) => void; +} + +// ===== ReviewTab (one per review, keeps useChat hook alive) ===== + +const ReviewTab = React.memo(function ReviewTab({ review, cwd, initialPrompt, onSessionCreated, isActive, readOnly, sendRef }: { + review: ReviewEntry; + cwd?: string; + initialPrompt?: string; + onSessionCreated?: (sid: string) => void; + isActive: boolean; + readOnly?: boolean; + sendRef?: React.MutableRefObject void>>; +}) { + const { + messages, streaming, liveStatus, toolStatuses, + sendMessage, abort, sessionId: chatSessionId, + } = useChat( + review.childSessionId || undefined, + cwd, + review.childAdapter, + initialPrompt, + ); + + // Notify parent when child session is created + const sessionCreatedRef = useRef(false); + useEffect(() => { + if (chatSessionId && !review.childSessionId && onSessionCreated && !sessionCreatedRef.current) { + sessionCreatedRef.current = true; + onSessionCreated(chatSessionId); + } + }, [chatSessionId, review.childSessionId, onSessionCreated]); + + // Register sendMessage in parent's ref map for sendToReview + useEffect(() => { + if (sendRef && review.reviewId) { + sendRef.current.set(review.reviewId, sendMessage); + return () => { sendRef.current.delete(review.reviewId); }; + } + }, [sendRef, review.reviewId, sendMessage]); + + const brand = getBrand(review.childAdapter); + + // Send-back handler: extract text from message and send to parent via API + const handleSendBack = useCallback(async (messageId: string) => { + const msg = messages.find(m => m.id === messageId); + if (!msg) return; + const text = extractTextFromBlocks(msg.content); + if (!review.reviewId) { + console.warn('Send back unavailable: review not yet registered'); + return; + } + try { + await api.sendBackToParent(review.reviewId, text); + } catch (err: any) { + console.error('Send back failed:', err.message || err); + } + }, [messages, review.reviewId]); + + return ( +
+ +
+ ); +}); + +// ===== Main Panel ===== + +export const FloatingReviewPanel = forwardRef( + function FloatingReviewPanel({ reviews, onEnd, onMinimize, initialPrompt, cwd, onSessionCreated, readOnly }, ref) { + const [activeTabIndex, setActiveTabIndex] = useState(Math.max(0, reviews.length - 1)); + + // Keep activeTabIndex in bounds + useEffect(() => { + if (activeTabIndex >= reviews.length) { + setActiveTabIndex(Math.max(0, reviews.length - 1)); + } + }, [reviews.length, activeTabIndex]); + + // Auto-focus newest tab when a review is added + const prevCountRef = useRef(reviews.length); + useEffect(() => { + if (reviews.length > prevCountRef.current) { + setActiveTabIndex(reviews.length - 1); + } + prevCountRef.current = reviews.length; + }, [reviews.length]); + + // Ref map: reviewId → sendMessage function (populated by each ReviewTab) + const sendRefs = useRef void>>(new Map()); + + useImperativeHandle(ref, () => ({ + sendToReview(reviewId: string, text: string) { + const send = sendRefs.current.get(reviewId); + if (send) send(text); + const idx = reviews.findIndex(r => r.reviewId === reviewId); + if (idx >= 0) setActiveTabIndex(idx); + }, + }), [reviews]); + + const activeReview = reviews[activeTabIndex] || reviews[0]; + if (!activeReview) return null; + + const brand = getBrand(activeReview.childAdapter); + + return ( +
+ {/* Handle bar */} +
+
+
+ + {/* Tab bar (multiple reviews) or single-review header */} + {reviews.length > 1 ? ( +
+
+ {reviews.map((r, i) => { + const b = getBrand(r.childAdapter); + const tabActive = i === activeTabIndex; + return ( +
+ + {!readOnly && ( + + )} +
+ ); + })} +
+ {!readOnly && ( + + )} +
+ ) : ( +
+ + {brand.displayName} + + + {activeReview.reviewTitle || 'Review Session'} + {readOnly && (ended)} + + + +
+ )} + + {/* Tabs — ALL rendered to keep hooks alive, only active one visible via CSS */} + {reviews.map((r, i) => ( + + ))} +
+ ); + } +); diff --git a/src/components/LoginView.tsx b/src/components/LoginView.tsx new file mode 100644 index 0000000..c8ee0eb --- /dev/null +++ b/src/components/LoginView.tsx @@ -0,0 +1,64 @@ +import { useState, type FormEvent } from 'react'; +import { api, setToken } from '../lib/api'; +import { Button } from './ui/button'; +import { ClawAscii } from './ui/ClawLogo'; + +export function LoginView({ onLogin }: { onLogin: () => void }) { + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(''); + setLoading(true); + try { + const { token } = await api.login(password); + setToken(token); + onLogin(); + } catch (err: any) { + setError(err.message || 'Login failed'); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+ +

ClawTap

+

remote terminal

+
+ +
+ password: + setPassword(e.target.value)} + placeholder="Enter password" + className="w-full bg-surface border border-border rounded-md px-3 py-2 text-sm text-text font-mono placeholder-text-dim focus:outline-none focus:border-accent focus:shadow-[0_0_8px_var(--color-accent-glow)]" + autoFocus + /> +
+ + {error && ( +

{error}

+ )} + + +
+
+ ); +} diff --git a/src/components/MessageBubble.tsx b/src/components/MessageBubble.tsx new file mode 100644 index 0000000..a0b1846 --- /dev/null +++ b/src/components/MessageBubble.tsx @@ -0,0 +1,147 @@ +import { useState } from 'react'; +import ReactMarkdown from 'react-markdown'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { cn } from '@/lib/utils'; +import { splitTextSegments } from '@/lib/text-transforms'; +import { extractTextFromBlocks } from '@/lib/content-utils'; +import { Copy, Check, Send, CornerDownLeft } from 'lucide-react'; +import { CLAUDE_PATTERNS } from './adapters/claude/patterns'; +import { InsightBlock } from './adapters/claude/InsightBlock'; + +// Hoisted to module scope to avoid recreating on every render (ReactMarkdown is expensive) +const markdownComponents = { + code({ className, children, ...props }: any) { + const match = /language-(\w+)/.exec(className || ''); + const code = String(children).replace(/\n$/, ''); + if (match) { + return ( + + {code} + + ); + } + return {children}; + }, +}; + +// --- SendDropdown component --- + +/** Send-to button — always opens ReviewActionMenu (which handles adapter selection) */ +function SendButton({ + messageId, + onSendTo, +}: { + messageId: string; + onSendTo: (messageId: string, adapter?: string) => void; +}) { + return ( + + ); +} + +// --- MessageBubble --- + +export function MessageBubble({ + role, + content, + isStreaming = false, + messageId, + showActions = false, + sendTargets, + onSendTo, + onSendBack, +}: { + role: 'user' | 'assistant'; + content: any[]; + isStreaming?: boolean; + messageId?: string; + showActions?: boolean; + sendTargets?: { adapter: string; label: string }[]; + onSendTo?: (messageId: string, adapter?: string) => void; + onSendBack?: (messageId: string) => void; +}) { + const [copied, setCopied] = useState(false); + + const textContent = content + .filter((b: any) => b.type === 'text') + .map((b: any) => b.text) + .join(''); + + const segments = role === 'assistant' ? splitTextSegments(textContent, CLAUDE_PATTERNS) : null; + + if (!textContent && !isStreaming) return null; + + if (role === 'user') { + return ( +
+
+ {textContent} +
+
+ ); + } + + return ( +
+
+
+
+ {segments!.map((seg, i) => + seg.type === 'insight' + ?
+ : {seg.text} + )} +
+ {isStreaming && } +
+ {showActions && !isStreaming && ( +
+ + {messageId && onSendBack && ( + + )} + {messageId && !onSendBack && sendTargets && sendTargets.length > 0 && onSendTo && ( + + )} +
+ )} +
+
+ ); +} diff --git a/src/components/NewChatView.tsx b/src/components/NewChatView.tsx new file mode 100644 index 0000000..89f3f92 --- /dev/null +++ b/src/components/NewChatView.tsx @@ -0,0 +1,217 @@ +import { useState, useEffect, useCallback } from 'react'; +import { ChevronLeft } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { ShimmerInput } from './ShimmerInput'; +import { AdapterIcon } from './AdapterIcon'; +import { getBrand, ADAPTER_BRANDS } from '@/lib/adapter-brands'; +import { api } from '@/lib/api'; +import { MODELS, PERMISSION_MODES, dirName } from '@/lib/utils'; +import { loadAdapterPrefs, saveAdapterPrefs } from '@/lib/adapter-prefs'; +import { STORAGE } from '@/lib/storage-keys'; + +type AdapterConfig = { + models: { value: string; label: string; contextWindow: number }[]; + permissionModes: { value: string; label: string }[]; + effortLevels: { value: string; label: string }[]; + effortLabel: string; +}; + +function SettingCard({ label, value, color, onClick }: { label: string; value: string; color: string; onClick: () => void }) { + return ( + + ); +} + +export function NewChatView({ + cwd, + onStartChat, + onBack, +}: { + cwd: string; + onStartChat: (options: { adapter: string; model: string; permissionMode: string; effort: string; prompt: string }) => void; + onBack: () => void; +}) { + const [availableAdapters, setAvailableAdapters] = useState<{ id: string; displayName: string; available: boolean }[]>([]); + const [selectedAdapter, setSelectedAdapter] = useState( + () => localStorage.getItem(STORAGE.ADAPTER) || 'claude' + ); + const [adapterConfig, setAdapterConfig] = useState(null); + const initPrefs = loadAdapterPrefs(selectedAdapter); + const [model, setModel] = useState(initPrefs.model); + const [permissionMode, setPermissionMode] = useState(initPrefs.permissionMode); + const [effort, setEffort] = useState(initPrefs.effort || 'high'); + + const brand = getBrand(selectedAdapter); + const projectName = dirName(cwd); + + // Fetch available adapters on mount + useEffect(() => { + api.adapters().then(setAvailableAdapters).catch(console.error); + }, []); + + // Fetch adapter config when adapter changes + useEffect(() => { + api.adapterConfig(selectedAdapter).then((config) => { + setAdapterConfig(config); + const prefs = loadAdapterPrefs(selectedAdapter); + // If current model is not valid for this adapter, pick the first + const validModel = config.models.some((m) => m.value === prefs.model); + if (!validModel && config.models.length > 0) { + const fallback = config.models[0].value; + setModel(fallback); + saveAdapterPrefs(selectedAdapter, { ...prefs, model: fallback }); + } + // If current effort is not valid for this adapter, pick the first + if (config.effortLevels.length > 0) { + const validEffort = config.effortLevels.some((e) => e.value === prefs.effort); + if (!validEffort) { + const fallback = config.effortLevels[0].value; + setEffort(fallback); + saveAdapterPrefs(selectedAdapter, { ...prefs, effort: fallback }); + } + } + }).catch(console.error); + }, [selectedAdapter]); + + // Switch adapter + const switchAdapter = useCallback(() => { + const adapterIds = availableAdapters.filter((a) => a.available).map((a) => a.id); + if (adapterIds.length <= 1) return; + const idx = adapterIds.indexOf(selectedAdapter); + const nextId = adapterIds[(idx + 1) % adapterIds.length]; + setSelectedAdapter(nextId); + localStorage.setItem(STORAGE.ADAPTER, nextId); + // Load saved prefs for the new adapter + const prefs = loadAdapterPrefs(nextId); + setModel(prefs.model); + setPermissionMode(prefs.permissionMode); + setEffort(prefs.effort || 'high'); + }, [availableAdapters, selectedAdapter]); + + // Settings: cycle model + const models = adapterConfig?.models ?? MODELS; + const permissionModes = adapterConfig?.permissionModes ?? PERMISSION_MODES; + + const cycleModel = useCallback(() => { + const idx = models.findIndex((m) => m.value === model); + const next = models[(idx + 1) % models.length]; + setModel(next.value); + saveAdapterPrefs(selectedAdapter, { model: next.value, permissionMode, effort }); + }, [models, model, selectedAdapter, permissionMode, effort]); + + const cyclePermission = useCallback(() => { + const idx = permissionModes.findIndex((m) => m.value === permissionMode); + const next = permissionModes[(idx + 1) % permissionModes.length]; + setPermissionMode(next.value); + saveAdapterPrefs(selectedAdapter, { model, permissionMode: next.value, effort }); + }, [permissionModes, permissionMode, selectedAdapter, model, effort]); + + const effortLevels = adapterConfig?.effortLevels ?? []; + const effortLabel = adapterConfig?.effortLabel ?? 'Effort'; + + const cycleEffort = useCallback(() => { + if (effortLevels.length === 0) return; + const idx = effortLevels.findIndex((e) => e.value === effort); + const next = effortLevels[(idx + 1) % effortLevels.length]; + setEffort(next.value); + saveAdapterPrefs(selectedAdapter, { model, permissionMode, effort: next.value }); + }, [effortLevels, effort, selectedAdapter, model, permissionMode]); + + const effortValue = effortLevels.find((e) => e.value === effort)?.label || effort; + + const modelLabel = models.find((m) => m.value === model)?.label || model; + const modeLabel = permissionModes.find((m) => m.value === permissionMode)?.label || permissionMode; + + // Other adapters to switch to + const otherAdapters = availableAdapters.filter((a) => a.available && a.id !== selectedAdapter); + + const handleSend = useCallback((prompt: string) => { + if (!prompt.trim()) return; + // Save current prefs + saveAdapterPrefs(selectedAdapter, { model, permissionMode, effort }); + localStorage.setItem(STORAGE.ADAPTER, selectedAdapter); + onStartChat({ adapter: selectedAdapter, model, permissionMode, effort, prompt }); + }, [selectedAdapter, model, permissionMode, effort, onStartChat]); + + return ( +
+ {/* Header */} +
+ + {projectName} +
+ + {/* Body — centered content */} +
+ {/* Hero Icon */} +
+ +
+ + {/* Adapter name + provider */} +

{brand.displayName}

+

by {brand.provider}

+ + {/* Switch adapter */} + {otherAdapters.length > 0 && ( +
+ Switch to + {otherAdapters.map((a) => { + const b = getBrand(a.id); + return ( + + ); + })} +
+ )} + + {/* Settings cards */} +
0 ? 'grid-cols-3' : 'grid-cols-2'} gap-3 mt-8 w-full max-w-sm`}> + + {effortLevels.length > 0 && ( + + )} + +
+
+ + {/* Input area */} +
+ +
+
+ ); +} diff --git a/src/components/OfflineView.tsx b/src/components/OfflineView.tsx new file mode 100644 index 0000000..4f61780 --- /dev/null +++ b/src/components/OfflineView.tsx @@ -0,0 +1,46 @@ +import { LoadingAnimation } from './ui/LoadingAnimation'; +import { Button } from './ui/button'; +import { useState } from 'react'; + +interface Props { + onRetry: () => void; +} + +export function OfflineView({ onRetry }: Props) { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + navigator.clipboard?.writeText('clawtap').then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + + return ( +
+ + +
+

ClawTap

+

Server not reachable

+
+ + + +

+ Run this command on your computer to start the ClawTap server. +

+ + +
+ ); +} diff --git a/src/components/PermissionOverlay.tsx b/src/components/PermissionOverlay.tsx new file mode 100644 index 0000000..bbb0e13 --- /dev/null +++ b/src/components/PermissionOverlay.tsx @@ -0,0 +1,68 @@ +import { useState, useEffect, useRef } from 'react'; +import type { PermissionRequest } from '../hooks/useChat'; +import { Badge } from './ui/badge'; +import { Button } from './ui/button'; +import { BottomSheet } from './BottomSheet'; + +function formatInput(toolName: string, input: any): string { + if (toolName === 'Bash' && input?.command) return input.command; + if (input?.file_path) return input.file_path; + if (input?.pattern) return input.pattern; + if (input?.path) return input.path; + if (input?.command) return input.command; + return JSON.stringify(input, null, 2).slice(0, 300); +} + +export function PermissionOverlay({ request, onAllow, onAllowAll, onDeny }: { + request: PermissionRequest; onAllow: () => void; onAllowAll: () => void; onDeny: (message?: string) => void; +}) { + const [countdown, setCountdown] = useState(120); + const onDenyRef = useRef(onDeny); + onDenyRef.current = onDeny; + + useEffect(() => { + setCountdown(120); + const timer = setInterval(() => { + setCountdown((c) => { + if (c <= 1) { onDenyRef.current('Permission timed out'); return 0; } + return c - 1; + }); + }, 1000); + return () => clearInterval(timer); + }, [request.requestId]); + + return ( + onDeny('Dismissed')} + zIndex="z-40" + backdropClassName="backdrop-blur-sm" + className="p-5" + showHandle={false} + > +
+ {request.toolName} + {countdown}s +
+ {request.decisionReason && ( +

{request.decisionReason}

+ )} +
+
+          {formatInput(request.toolName, request.input)}
+        
+
+
+ + + +
+
+ ); +} diff --git a/src/components/PlanMode.tsx b/src/components/PlanMode.tsx new file mode 100644 index 0000000..a636de8 --- /dev/null +++ b/src/components/PlanMode.tsx @@ -0,0 +1,94 @@ +import { useState } from 'react'; +import ReactMarkdown from 'react-markdown'; +import { Badge } from './ui/badge'; +import { Button } from './ui/button'; +import { X, Send } from 'lucide-react'; + +export function PlanMode({ input, onApprove, onApproveYolo, onReject, onSendFeedback }: { + input: any; onApprove: () => void; onApproveYolo?: () => void; onReject: (feedback: string) => void; onSendFeedback?: (feedback: string) => void; +}) { + const [showFull, setShowFull] = useState(false); + const [feedback, setFeedback] = useState(''); + const planText = input?.plan || input?.content || input?.text || ''; + const truncated = planText.length > 500 ? planText.slice(0, 500) + '...' : planText; + + const feedbackInput = ( +
+ setFeedback(e.target.value)} + placeholder="Feedback..." + className="flex-1 bg-bg border border-border rounded-md px-3 py-2 text-text text-sm focus:outline-none focus:border-accent" + /> + {onSendFeedback && ( + + )} +
+ ); + + const controls = ( +
+
+ + +
+ {onApproveYolo && ( + + )} +
+ ); + + if (showFull) { + return ( +
+
+ PLAN + +
+
+
+ {planText} +
+
+
+ {feedbackInput} + {controls} +
+
+ ); + } + + return ( +
+
+
+ PLAN + +
+
+ {truncated} +
+ {feedbackInput} + {controls} +
+
+ ); +} diff --git a/src/components/ReviewActionMenu.tsx b/src/components/ReviewActionMenu.tsx new file mode 100644 index 0000000..676ec66 --- /dev/null +++ b/src/components/ReviewActionMenu.tsx @@ -0,0 +1,218 @@ +import { useState, useEffect, useCallback } from 'react'; +import { AdapterIcon } from './AdapterIcon'; +import { BottomSheet } from './BottomSheet'; +import { getBrand } from '@/lib/adapter-brands'; +import { api } from '@/lib/api'; +import type { AdapterConfig, SavedInstruction } from '@/types/adapter'; + +interface ReviewActionMenuProps { + visible: boolean; + adapters: { id: string; displayName: string }[]; + onDirectSend: (adapter: string, model: string) => void; + onSendWithInstruction: (adapter: string, model: string, instruction: string, isCustom: boolean) => void; + onClose: () => void; +} + +export function ReviewActionMenu({ + visible, + adapters, + onDirectSend, + onSendWithInstruction, + onClose, +}: ReviewActionMenuProps) { + const [step, setStep] = useState<'adapter' | 'action'>('adapter'); + const [selectedAdapter, setSelectedAdapter] = useState(null); + const [adapterConfig, setAdapterConfig] = useState(null); + const [selectedModel, setSelectedModel] = useState(''); + const [instructionsExpanded, setInstructionsExpanded] = useState(false); + const [savedInstructions, setSavedInstructions] = useState([]); + const [customText, setCustomText] = useState(''); + + const resetState = useCallback(() => { + setStep('adapter'); + setSelectedAdapter(null); + setAdapterConfig(null); + setSelectedModel(''); + setInstructionsExpanded(false); + setCustomText(''); + }, []); + + useEffect(() => { + if (visible) { + resetState(); + api.getInstructions().then(setSavedInstructions).catch(() => {}); + } + }, [visible, resetState]); + + useEffect(() => { + if (!selectedAdapter) return; + let cancelled = false; + api.adapterConfig(selectedAdapter).then((config) => { + if (cancelled) return; + setAdapterConfig(config); + if (config.models.length > 0) { + setSelectedModel(config.models[0].value); + } + }).catch(() => {}); + return () => { cancelled = true; }; + }, [selectedAdapter]); + + const handleAdapterSelect = useCallback((adapterId: string) => { + setSelectedAdapter(adapterId); + setStep('action'); + }, []); + + const handleBack = useCallback(() => { + resetState(); + }, [resetState]); + + const handleDirectSend = useCallback(() => { + if (!selectedAdapter) return; + onDirectSend(selectedAdapter, selectedModel); + onClose(); + }, [selectedAdapter, selectedModel, onDirectSend, onClose]); + + const handleSavedInstruction = useCallback((instruction: string) => { + if (!selectedAdapter) return; + onSendWithInstruction(selectedAdapter, selectedModel, instruction, false); + onClose(); + }, [selectedAdapter, selectedModel, onSendWithInstruction, onClose]); + + const handleCustomSend = useCallback(() => { + if (!selectedAdapter || !customText.trim()) return; + onSendWithInstruction(selectedAdapter, selectedModel, customText.trim(), true); + onClose(); + }, [selectedAdapter, selectedModel, customText, onSendWithInstruction, onClose]); + + const adapterBrand = selectedAdapter ? getBrand(selectedAdapter) : null; + + return ( + + + {step === 'adapter' ? ( + /* Step 1: Adapter Selection */ +
+

Send to…

+
+ {adapters.map((adapter) => ( + + ))} +
+
+ ) : ( + /* Step 2: Action Selection */ +
+ {/* Back header */} + + + {/* Model selector */} + {adapterConfig && adapterConfig.models.length > 0 && ( +
+ + +
+ )} + + {/* Direct Send */} + + + {/* With Instructions toggle */} + + + {/* Expanded instructions */} + {instructionsExpanded && ( +
+ {/* Saved instructions list */} + {savedInstructions.map((item) => ( + + ))} + + {/* Divider */} +
+
+ 或輸入新的 +
+
+ + {/* Custom text input */} +
+ setCustomText(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleCustomSend(); + }} + placeholder="Your instruction..." + className="flex-1 bg-bg border border-border rounded-md px-3 py-2 text-sm text-text outline-none focus:border-accent" + autoFocus + /> + +
+
+ )} +
+ )} + + ); +} diff --git a/src/components/SavedInstructionsView.tsx b/src/components/SavedInstructionsView.tsx new file mode 100644 index 0000000..a676fe6 --- /dev/null +++ b/src/components/SavedInstructionsView.tsx @@ -0,0 +1,129 @@ +import { useState, useEffect } from 'react'; +import { ChevronLeft, X } from 'lucide-react'; +import { api } from '@/lib/api'; +import type { SavedInstruction } from '@/types/adapter'; + +export function SavedInstructionsView({ onBack }: { onBack: () => void }) { + const [instructions, setInstructions] = useState([]); + const [showAddForm, setShowAddForm] = useState(false); + const [newLabel, setNewLabel] = useState(''); + const [newInstruction, setNewInstruction] = useState(''); + + useEffect(() => { + api.getInstructions().then(setInstructions).catch(() => {}); + }, []); + + const handleDelete = async (id: string) => { + if (!window.confirm('Delete this instruction?')) return; + try { + await api.deleteInstruction(id); + setInstructions((prev) => prev.filter((i) => i.id !== id)); + } catch { + // ignore + } + }; + + const handleSave = async () => { + if (!newLabel.trim() || !newInstruction.trim()) return; + try { + const created = await api.createInstruction(newLabel.trim(), newInstruction.trim()); + setInstructions((prev) => [ + ...prev, + { ...created, created_at: new Date().toISOString() }, + ]); + setNewLabel(''); + setNewInstruction(''); + setShowAddForm(false); + } catch { + // ignore + } + }; + + return ( +
+ {/* Header */} +
+ + Saved Instructions +
+ +
+ + {/* Add form */} + {showAddForm && ( +
+ setNewLabel(e.target.value)} + className="w-full bg-surface border border-border rounded-md px-3 py-2 text-text text-sm outline-none focus:border-accent" + /> +