From 42861ea7fa75cd8ad76fee0d199bc583d865519d Mon Sep 17 00:00:00 2001 From: kuannnn Date: Wed, 18 Mar 2026 10:24:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20ClawTap=20v0.1.0=20=E2=80=94=20initial?= =?UTF-8?q?=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 2 + .gitignore | 11 + README.md | 263 ++ bin/clawtap | 494 +++ bin/hooks-cli.mjs | 33 + bun.lock | 856 ++++ .../plans/2026-03-23-cross-ai-review.md | 690 +++ .../plans/2026-03-23-insight-block.md | 294 ++ .../2026-03-23-session-id-unification.md | 563 +++ .../2026-03-24-codex-uuid-discovery-fix.md | 477 +++ .../2026-03-24-remaining-session-fixes.md | 351 ++ .../2026-03-24-session-id-unification.md | 468 +++ .../plans/2026-03-24-window-name-to-uuid.md | 409 ++ .../plans/2026-03-25-review-panel-ux-fixes.md | 422 ++ .../2026-03-25-review-state-separation.md | 271 ++ .../plans/2026-03-25-unified-session-path.md | 532 +++ .../plans/2026-03-26-cli-multi-adapter.md | 688 +++ .../plans/2026-03-26-cross-ai-review-v2.md | 782 ++++ .../plans/2026-03-26-gemini-adapter.md | 1292 ++++++ .../plans/2026-03-26-pwa-optimization.md | 558 +++ .../plans/2026-03-26-send-to-menu-settings.md | 566 +++ .../2026-03-23-cross-ai-review-design.md | 334 ++ .../specs/2026-03-23-insight-block-design.md | 136 + ...026-03-23-session-id-unification-design.md | 256 ++ .../2026-03-24-codex-uuid-discovery-fix.md | 120 + .../2026-03-24-remaining-session-fixes.md | 104 + ...026-03-24-session-id-unification-design.md | 303 ++ ...2026-03-25-review-panel-ux-fixes-design.md | 119 + ...26-03-25-review-state-separation-design.md | 105 + .../2026-03-25-unified-session-path-design.md | 126 + .../2026-03-26-cross-ai-review-v2-design.md | 130 + .../specs/2026-03-26-gemini-adapter-design.md | 421 ++ .../2026-03-26-pwa-optimization-design.md | 163 + ...2026-03-26-send-to-menu-settings-design.md | 174 + index.html | 27 + package.json | 83 + playground-claw-logo.html | 287 ++ playground-colors.html | 121 + playground-multi-review.html | 351 ++ playground-svg-compare.html | 142 + public/apple-touch-icon.png | Bin 0 -> 29522 bytes public/claw-logo.svg | 37 + public/mascot/cat-idle.png | Bin 0 -> 57583 bytes public/mascot/cat-sleep.png | Bin 0 -> 66420 bytes public/pwa-192x192.png | Bin 0 -> 32429 bytes public/pwa-512x512.png | Bin 0 -> 147332 bytes server/adapters/claude/hook-config.ts | 216 + server/adapters/claude/index.ts | 240 ++ server/adapters/claude/jsonl-store.ts | 226 + server/adapters/claude/message-utils.ts | 105 + server/adapters/claude/pane-monitor.ts | 130 + server/adapters/claude/tmux-adapter.ts | 893 ++++ server/adapters/claude/transcript-parser.ts | 200 + server/adapters/codex/codex-tmux-adapter.ts | 822 ++++ server/adapters/codex/hook-config.ts | 250 ++ server/adapters/codex/index.ts | 197 + server/adapters/codex/jsonl-store.ts | 284 ++ server/adapters/codex/message-utils.ts | 62 + server/adapters/codex/pane-monitor.ts | 284 ++ server/adapters/codex/transcript-parser.ts | 388 ++ server/adapters/gemini/bridge.sh | 19 + server/adapters/gemini/gemini-tmux-adapter.ts | 848 ++++ server/adapters/gemini/hook-config.ts | 144 + server/adapters/gemini/index.ts | 199 + server/adapters/gemini/json-store.ts | 299 ++ server/adapters/gemini/message-utils.ts | 85 + server/adapters/gemini/pane-monitor.ts | 217 + server/adapters/gemini/transcript-parser.ts | 175 + server/adapters/init.ts | 21 + server/adapters/interface.ts | 170 + server/adapters/registry.ts | 86 + server/adapters/shared/tmux-manager.ts | 107 + server/auth.ts | 101 + server/config.ts | 79 + server/db.ts | 355 ++ server/index.ts | 481 +++ server/permission-manager.ts | 160 + server/push.ts | 127 + server/session-manager.ts | 515 +++ server/stores/json-watcher.ts | 190 + server/stores/jsonl-watcher.ts | 135 + server/transport/client-connection.ts | 37 + server/transport/websocket-connection.ts | 39 + server/transport/websocket-transport.ts | 78 + server/tsconfig.json | 19 + server/types/adapter.ts | 55 + server/types/messages.ts | 67 + server/ws-types.ts | 51 + src/App.tsx | 296 ++ src/components/AdapterIcon.tsx | 73 + src/components/AdapterSettingsSection.tsx | 97 + src/components/AdapterTabs.tsx | 41 + src/components/AskQuestion.tsx | 64 + src/components/BlockMarker.tsx | 14 + src/components/BottomSheet.tsx | 44 + src/components/ChatBody.tsx | 260 ++ src/components/ChatView.tsx | 612 +++ src/components/CollapsedReviewCard.tsx | 32 + src/components/DiffViewer.tsx | 42 + src/components/DirectoryBrowser.tsx | 135 + src/components/FloatingReviewPanel.tsx | 245 ++ src/components/LoginView.tsx | 64 + src/components/MessageBubble.tsx | 147 + src/components/NewChatView.tsx | 217 + src/components/OfflineView.tsx | 46 + src/components/PermissionOverlay.tsx | 68 + src/components/PlanMode.tsx | 94 + src/components/ReviewActionMenu.tsx | 218 + src/components/SavedInstructionsView.tsx | 129 + src/components/SendToExistingSheet.tsx | 50 + src/components/SessionsView.tsx | 458 ++ src/components/SettingsView.tsx | 123 + src/components/ShimmerInput.tsx | 295 ++ src/components/StatusBar.tsx | 149 + src/components/SubagentGroup.tsx | 62 + src/components/TaskProgress.tsx | 50 + src/components/ToolCallCard.tsx | 178 + .../adapters/claude/InsightBlock.tsx | 46 + src/components/adapters/claude/patterns.ts | 16 + src/components/ui/ClawLogo.tsx | 15 + src/components/ui/LoadingAnimation.tsx | 81 + src/components/ui/badge.tsx | 30 + src/components/ui/button.tsx | 43 + src/components/ui/pill-selector.tsx | 35 + src/components/ui/progress.tsx | 29 + src/hooks/useChat.ts | 569 +++ src/hooks/usePushNotifications.ts | 75 + src/hooks/useSessions.ts | 108 + src/hooks/useVoiceInput.ts | 66 + src/index.css | 159 + src/lib/adapter-brands.ts | 47 + src/lib/adapter-prefs.ts | 23 + src/lib/api.ts | 157 + src/lib/content-utils.ts | 9 + src/lib/storage-keys.ts | 46 + src/lib/text-transforms.ts | 53 + src/lib/utils.ts | 46 + src/lib/ws-types.ts | 49 + src/lib/ws.ts | 81 + src/main.tsx | 7 + src/sw.ts | 59 + src/types/adapter.ts | 13 + step1-login-page.png | Bin 0 -> 10293 bytes step2-main-app.png | Bin 0 -> 86071 bytes test/mvp/test-e2e.js | 218 + test/mvp/test-hooks.js | 258 ++ test/mvp/test-jsonl-watcher.js | 165 + tests/e2e-progress.md | 561 +++ tests/e2e-spec.feature | 3726 +++++++++++++++++ tsconfig.json | 20 + vite.config.ts | 67 + 151 files changed, 33897 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100755 bin/clawtap create mode 100755 bin/hooks-cli.mjs create mode 100644 bun.lock create mode 100644 docs/superpowers/plans/2026-03-23-cross-ai-review.md create mode 100644 docs/superpowers/plans/2026-03-23-insight-block.md create mode 100644 docs/superpowers/plans/2026-03-23-session-id-unification.md create mode 100644 docs/superpowers/plans/2026-03-24-codex-uuid-discovery-fix.md create mode 100644 docs/superpowers/plans/2026-03-24-remaining-session-fixes.md create mode 100644 docs/superpowers/plans/2026-03-24-session-id-unification.md create mode 100644 docs/superpowers/plans/2026-03-24-window-name-to-uuid.md create mode 100644 docs/superpowers/plans/2026-03-25-review-panel-ux-fixes.md create mode 100644 docs/superpowers/plans/2026-03-25-review-state-separation.md create mode 100644 docs/superpowers/plans/2026-03-25-unified-session-path.md create mode 100644 docs/superpowers/plans/2026-03-26-cli-multi-adapter.md create mode 100644 docs/superpowers/plans/2026-03-26-cross-ai-review-v2.md create mode 100644 docs/superpowers/plans/2026-03-26-gemini-adapter.md create mode 100644 docs/superpowers/plans/2026-03-26-pwa-optimization.md create mode 100644 docs/superpowers/plans/2026-03-26-send-to-menu-settings.md create mode 100644 docs/superpowers/specs/2026-03-23-cross-ai-review-design.md create mode 100644 docs/superpowers/specs/2026-03-23-insight-block-design.md create mode 100644 docs/superpowers/specs/2026-03-23-session-id-unification-design.md create mode 100644 docs/superpowers/specs/2026-03-24-codex-uuid-discovery-fix.md create mode 100644 docs/superpowers/specs/2026-03-24-remaining-session-fixes.md create mode 100644 docs/superpowers/specs/2026-03-24-session-id-unification-design.md create mode 100644 docs/superpowers/specs/2026-03-25-review-panel-ux-fixes-design.md create mode 100644 docs/superpowers/specs/2026-03-25-review-state-separation-design.md create mode 100644 docs/superpowers/specs/2026-03-25-unified-session-path-design.md create mode 100644 docs/superpowers/specs/2026-03-26-cross-ai-review-v2-design.md create mode 100644 docs/superpowers/specs/2026-03-26-gemini-adapter-design.md create mode 100644 docs/superpowers/specs/2026-03-26-pwa-optimization-design.md create mode 100644 docs/superpowers/specs/2026-03-26-send-to-menu-settings-design.md create mode 100644 index.html create mode 100644 package.json create mode 100644 playground-claw-logo.html create mode 100644 playground-colors.html create mode 100644 playground-multi-review.html create mode 100644 playground-svg-compare.html create mode 100644 public/apple-touch-icon.png create mode 100644 public/claw-logo.svg create mode 100644 public/mascot/cat-idle.png create mode 100644 public/mascot/cat-sleep.png create mode 100644 public/pwa-192x192.png create mode 100644 public/pwa-512x512.png create mode 100644 server/adapters/claude/hook-config.ts create mode 100644 server/adapters/claude/index.ts create mode 100644 server/adapters/claude/jsonl-store.ts create mode 100644 server/adapters/claude/message-utils.ts create mode 100644 server/adapters/claude/pane-monitor.ts create mode 100644 server/adapters/claude/tmux-adapter.ts create mode 100644 server/adapters/claude/transcript-parser.ts create mode 100644 server/adapters/codex/codex-tmux-adapter.ts create mode 100644 server/adapters/codex/hook-config.ts create mode 100644 server/adapters/codex/index.ts create mode 100644 server/adapters/codex/jsonl-store.ts create mode 100644 server/adapters/codex/message-utils.ts create mode 100644 server/adapters/codex/pane-monitor.ts create mode 100644 server/adapters/codex/transcript-parser.ts create mode 100755 server/adapters/gemini/bridge.sh create mode 100644 server/adapters/gemini/gemini-tmux-adapter.ts create mode 100644 server/adapters/gemini/hook-config.ts create mode 100644 server/adapters/gemini/index.ts create mode 100644 server/adapters/gemini/json-store.ts create mode 100644 server/adapters/gemini/message-utils.ts create mode 100644 server/adapters/gemini/pane-monitor.ts create mode 100644 server/adapters/gemini/transcript-parser.ts create mode 100644 server/adapters/init.ts create mode 100644 server/adapters/interface.ts create mode 100644 server/adapters/registry.ts create mode 100644 server/adapters/shared/tmux-manager.ts create mode 100644 server/auth.ts create mode 100644 server/config.ts create mode 100644 server/db.ts create mode 100644 server/index.ts create mode 100644 server/permission-manager.ts create mode 100644 server/push.ts create mode 100644 server/session-manager.ts create mode 100644 server/stores/json-watcher.ts create mode 100644 server/stores/jsonl-watcher.ts create mode 100644 server/transport/client-connection.ts create mode 100644 server/transport/websocket-connection.ts create mode 100644 server/transport/websocket-transport.ts create mode 100644 server/tsconfig.json create mode 100644 server/types/adapter.ts create mode 100644 server/types/messages.ts create mode 100644 server/ws-types.ts create mode 100644 src/App.tsx create mode 100644 src/components/AdapterIcon.tsx create mode 100644 src/components/AdapterSettingsSection.tsx create mode 100644 src/components/AdapterTabs.tsx create mode 100644 src/components/AskQuestion.tsx create mode 100644 src/components/BlockMarker.tsx create mode 100644 src/components/BottomSheet.tsx create mode 100644 src/components/ChatBody.tsx create mode 100644 src/components/ChatView.tsx create mode 100644 src/components/CollapsedReviewCard.tsx create mode 100644 src/components/DiffViewer.tsx create mode 100644 src/components/DirectoryBrowser.tsx create mode 100644 src/components/FloatingReviewPanel.tsx create mode 100644 src/components/LoginView.tsx create mode 100644 src/components/MessageBubble.tsx create mode 100644 src/components/NewChatView.tsx create mode 100644 src/components/OfflineView.tsx create mode 100644 src/components/PermissionOverlay.tsx create mode 100644 src/components/PlanMode.tsx create mode 100644 src/components/ReviewActionMenu.tsx create mode 100644 src/components/SavedInstructionsView.tsx create mode 100644 src/components/SendToExistingSheet.tsx create mode 100644 src/components/SessionsView.tsx create mode 100644 src/components/SettingsView.tsx create mode 100644 src/components/ShimmerInput.tsx create mode 100644 src/components/StatusBar.tsx create mode 100644 src/components/SubagentGroup.tsx create mode 100644 src/components/TaskProgress.tsx create mode 100644 src/components/ToolCallCard.tsx create mode 100644 src/components/adapters/claude/InsightBlock.tsx create mode 100644 src/components/adapters/claude/patterns.ts create mode 100644 src/components/ui/ClawLogo.tsx create mode 100644 src/components/ui/LoadingAnimation.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/button.tsx create mode 100644 src/components/ui/pill-selector.tsx create mode 100644 src/components/ui/progress.tsx create mode 100644 src/hooks/useChat.ts create mode 100644 src/hooks/usePushNotifications.ts create mode 100644 src/hooks/useSessions.ts create mode 100644 src/hooks/useVoiceInput.ts create mode 100644 src/index.css create mode 100644 src/lib/adapter-brands.ts create mode 100644 src/lib/adapter-prefs.ts create mode 100644 src/lib/api.ts create mode 100644 src/lib/content-utils.ts create mode 100644 src/lib/storage-keys.ts create mode 100644 src/lib/text-transforms.ts create mode 100644 src/lib/utils.ts create mode 100644 src/lib/ws-types.ts create mode 100644 src/lib/ws.ts create mode 100644 src/main.tsx create mode 100644 src/sw.ts create mode 100644 src/types/adapter.ts create mode 100644 step1-login-page.png create mode 100644 step2-main-app.png create mode 100644 test/mvp/test-e2e.js create mode 100644 test/mvp/test-hooks.js create mode 100644 test/mvp/test-jsonl-watcher.js create mode 100644 tests/e2e-progress.md create mode 100644 tests/e2e-spec.feature create mode 100644 tsconfig.json create mode 100644 vite.config.ts 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 0000000000000000000000000000000000000000..3a1ac42cc1d199fe205c2c98c9b8a086c3b9bba9 GIT binary patch literal 29522 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/mascot/cat-idle.png b/public/mascot/cat-idle.png new file mode 100644 index 0000000000000000000000000000000000000000..3ce0b1d5c6f690dcb71f7db01191a3b37bc1465c GIT binary patch literal 57583 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