Full Code of lcoutodemos/clui-cc for AI

main 58d18bb7a58f cached
60 files
463.4 KB
121.9k tokens
319 symbols
1 requests
Download .txt
Showing preview only (490K chars total). Download the full file or copy to clipboard to get everything.
Repository: lcoutodemos/clui-cc
Branch: main
Commit: 58d18bb7a58f
Files: 60
Total size: 463.4 KB

Directory structure:
gitextract_jg4oi3wy/

├── .gitignore
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── SECURITY.md
├── commands/
│   ├── install-app.command
│   ├── setup.command
│   ├── start.command
│   └── stop.command
├── docs/
│   ├── AGENTS.md
│   ├── ARCHITECTURE.md
│   ├── TROUBLESHOOTING.md
│   ├── oss-readiness-report.md
│   ├── release-smoke-test.md
│   └── slash-command-matrix.md
├── electron.vite.config.ts
├── install-app.command
├── package.json
├── resources/
│   ├── entitlements.mac.plist
│   └── icon.icns
├── scripts/
│   ├── doctor.sh
│   └── patch-dev-icon.sh
├── src/
│   ├── main/
│   │   ├── claude/
│   │   │   ├── control-plane.ts
│   │   │   ├── event-normalizer.ts
│   │   │   ├── pty-run-manager.ts
│   │   │   └── run-manager.ts
│   │   ├── cli-env.ts
│   │   ├── hooks/
│   │   │   └── permission-server.ts
│   │   ├── index.ts
│   │   ├── logger.ts
│   │   ├── marketplace/
│   │   │   └── catalog.ts
│   │   ├── process-manager.ts
│   │   ├── skills/
│   │   │   ├── installer.ts
│   │   │   └── manifest.ts
│   │   └── stream-parser.ts
│   ├── preload/
│   │   └── index.ts
│   ├── renderer/
│   │   ├── App.tsx
│   │   ├── components/
│   │   │   ├── AttachmentChips.tsx
│   │   │   ├── ConversationView.tsx
│   │   │   ├── HistoryPicker.tsx
│   │   │   ├── InputBar.tsx
│   │   │   ├── MarketplacePanel.tsx
│   │   │   ├── PermissionCard.tsx
│   │   │   ├── PermissionDeniedCard.tsx
│   │   │   ├── PopoverLayer.tsx
│   │   │   ├── SettingsPopover.tsx
│   │   │   ├── SlashCommandMenu.tsx
│   │   │   ├── StatusBar.tsx
│   │   │   └── TabStrip.tsx
│   │   ├── env.d.ts
│   │   ├── hooks/
│   │   │   ├── useClaudeEvents.ts
│   │   │   └── useHealthReconciliation.ts
│   │   ├── index.css
│   │   ├── index.html
│   │   ├── main.tsx
│   │   ├── stores/
│   │   │   └── sessionStore.ts
│   │   └── theme.ts
│   └── shared/
│       └── types.ts
└── tsconfig.json

================================================
FILE CONTENTS
================================================

================================================
FILE: .gitignore
================================================
# Build output
node_modules/
dist/
out/
build/
release/
*.tsbuildinfo

# OS artifacts
.DS_Store
Thumbs.db
Desktop.ini

# Editor / tool local state
.cursor/
.vscode/
.idea/
*.swp
*.swo
*~

# Claude Code project-scoped local settings
.claude/settings.local.json

# Environment (not needed for core flow, but excluded defensively)
.env
.env.*

# Logs
*.log
~/.clui-debug.log

# Runtime
.clui.pid

# Temporary files
*.tmp
*.bak


================================================
FILE: CODE_OF_CONDUCT.md
================================================
# Contributor Covenant Code of Conduct

## Our Pledge

We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.

## Our Standards

Examples of behavior that contributes to a positive environment:

- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
- Showing empathy towards other community members

Examples of unacceptable behavior:

- The use of sexualized language or imagery and unwelcome sexual attention or advances
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information without explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting

## Enforcement

Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project maintainers. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances.

## Attribution

This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1.


================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to Clui CC

Thanks for your interest in contributing! Clui CC is a desktop overlay for Claude Code, and we welcome bug reports, feature ideas, and pull requests.

## Getting Started

1. Make sure you have the [prerequisites](README.md#prerequisites) installed (macOS, Xcode CLT, Node.js 18+, Claude Code CLI 2.1+)
2. Fork and clone the repo:
   ```bash
   git clone https://github.com/<your-username>/clui-cc.git
   cd clui-cc
   ```
3. Check your environment (optional but recommended):
   ```bash
   npm run doctor
   ```
4. Install dependencies:
   ```bash
   npm install
   ```
   > If `npm install` fails, run `npm run doctor` to see which dependency is missing.
5. Start the dev server:
   ```bash
   npm run dev
   ```
6. Make your changes in `src/`
7. Verify your changes build cleanly:
   ```bash
   npm run build
   ```

## Development Tips

- **Main process** changes (`src/main/`) require a full restart (`Ctrl+C` then `npm run dev`).
- **Renderer** changes (`src/renderer/`) hot-reload automatically.
- Set `CLUI_DEBUG=1` to enable verbose main-process logging to `~/.clui-debug.log`.
- The app creates a transparent, click-through window. Use `⌥ + Space` to toggle visibility (fallback: `Cmd+Shift+K`).

## Code Style

- TypeScript strict mode is enforced.
- Use `useColors()` hook for all color references — never hardcode color values.
- Zustand selectors should be narrow and use custom equality functions for performance.
- Prefer editing existing files over creating new ones.

## Pull Requests

1. Create a feature branch from `main`.
2. Keep PRs focused — one concern per PR.
3. Include a brief description of what changed and why.
4. Ensure `npm run build` passes with zero errors.

## Reporting Bugs

Open an issue with:
- macOS version
- Node.js version (`node --version`)
- Claude Code CLI version (`claude --version`)
- Steps to reproduce
- Expected vs. actual behavior

## Security

If you discover a security vulnerability, please report it privately. See [SECURITY.md](SECURITY.md).


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2025-2026 Lucas Couto

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
# Clui CC — Command Line User Interface for Claude Code

A lightweight, transparent desktop overlay for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) on macOS. Clui CC wraps the Claude Code CLI in a floating pill interface with multi-tab sessions, a permission approval UI, voice input, and a skills marketplace.

## Demo

[![Watch the demo](https://img.youtube.com/vi/NqRBIpaA4Fk/maxresdefault.jpg)](https://www.youtube.com/watch?v=NqRBIpaA4Fk)

<p align="center"><a href="https://www.youtube.com/watch?v=NqRBIpaA4Fk">▶ Watch the full demo on YouTube</a></p>

## Features

- **Floating overlay** — transparent, click-through window that stays on top. Toggle with `⌥ + Space` (fallback: `Cmd+Shift+K`).
- **Multi-tab sessions** — each tab spawns its own `claude -p` process with independent session state.
- **Permission approval UI** — intercepts tool calls via PreToolUse HTTP hooks so you can review and approve/deny from the UI.
- **Conversation history** — browse and resume past Claude Code sessions.
- **Skills marketplace** — install plugins from Anthropic's GitHub repos without leaving Clui CC.
- **Voice input** — local speech-to-text via Whisper (required, installed automatically).
- **File & screenshot attachments** — paste images or attach files directly.
- **Dual theme** — dark/light mode with system-follow option.

## Why Clui CC

- **Claude Code, but visual** — keep CLI power while getting a fast desktop UX for approvals, history, and multitasking.
- **Human-in-the-loop safety** — tool calls are reviewed and approved in-app before execution.
- **Session-native workflow** — each tab runs an independent Claude session you can resume later.
- **Local-first** — everything runs through your local Claude CLI. No telemetry, no cloud dependency.

## How It Works

```
UI prompt → Main process spawns claude -p → NDJSON stream → live render
                                         → tool call? → permission UI → approve/deny
```

See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full deep-dive.

## Install App (Recommended)

The fastest way to get Clui CC running as a regular Mac app. This installs dependencies, voice support (Whisper), builds the app, copies it to `/Applications`, and launches it.

**1) Clone the repo**

```bash
git clone https://github.com/lcoutodemos/clui-cc.git
```

**2) Double-click `install-app.command`**

Open the `clui-cc` folder in Finder and double-click `install-app.command`.

> **First launch:** macOS may block the app because it's unsigned. Go to **System Settings → Privacy & Security → Open Anyway**. You only need to do this once.
> **Folder cleanup:** the installer removes temporary `dist/` and `release/` folders after a successful install to keep the repo tidy.

<p align="center"><img src="docs/shortcut.png" width="520" alt="Press Option + Space to show or hide Clui CC" /></p>

After the initial install, just open **Clui CC** from your Applications folder or Spotlight.

<details>
<summary><strong>Terminal / Developer Commands</strong></summary>

Only `install-app.command` is kept at root intentionally for non-technical users. Developer scripts live in `commands/`.

### Quick Start (Terminal)

```bash
git clone https://github.com/lcoutodemos/clui-cc.git
```

```bash
cd clui-cc
```

```bash
./commands/setup.command
```

```bash
./commands/start.command
```

> Press **⌥ + Space** to show/hide the overlay. If your macOS input source claims that combo, use **Cmd+Shift+K**.

To stop:

```bash
./commands/stop.command
```

### Developer Workflow

```bash
npm install
```

```bash
npm run dev
```

Renderer changes update instantly. Main-process changes require restarting `npm run dev`.

### Other Commands

| Command | Purpose |
|---------|---------|
| `./commands/setup.command` | Environment check + install dependencies |
| `./commands/start.command` | Build and launch from source |
| `./commands/stop.command` | Stop all Clui CC processes |
| `npm run build` | Production build (no packaging) |
| `npm run dist` | Package as macOS `.app` into `release/` |
| `npm run doctor` | Run environment diagnostic |

</details>

<details>
<summary><strong>Setup Prerequisites (Detailed)</strong></summary>

You need **macOS 13+**. Then install these one at a time — copy each command and paste it into Terminal.

**Step 1.** Install Xcode Command Line Tools (needed to compile native modules):

```bash
xcode-select --install
```

**Step 2.** Install Node.js (recommended: current LTS such as 20 or 22; minimum supported: 18). Download from [nodejs.org](https://nodejs.org), or use Homebrew:

```bash
brew install node
```

Verify it's on your PATH:

```bash
node --version
```

**Step 3.** Make sure Python has `setuptools` (needed by the native module compiler). On Python 3.12+ this is missing by default:

```bash
python3 -m pip install --upgrade pip setuptools
```

**Step 4.** Install Claude Code CLI:

```bash
npm install -g @anthropic-ai/claude-code
```

**Step 5.** Authenticate Claude Code (follow the prompts that appear):

```bash
claude
```

**Step 6.** Install Whisper for voice input:

```bash
# Apple Silicon (M1/M2/M3/M4) — preferred:
brew install whisperkit-cli
# Apple Silicon fallback, or Intel Mac:
brew install whisper-cpp
```

> **No API keys or `.env` file required.** Clui CC uses your existing Claude Code CLI authentication (Pro/Team/Enterprise subscription).

</details>

<details>
<summary><strong>Architecture and Internals</strong></summary>

### Project Structure

```
src/
├── main/                   # Electron main process
│   ├── claude/             # ControlPlane, RunManager, EventNormalizer
│   ├── hooks/              # PermissionServer (PreToolUse HTTP hooks)
│   ├── marketplace/        # Plugin catalog fetching + install
│   ├── skills/             # Skill auto-installer
│   └── index.ts            # Window creation, IPC handlers, tray
├── renderer/               # React frontend
│   ├── components/         # TabStrip, ConversationView, InputBar, etc.
│   ├── stores/             # Zustand session store
│   ├── hooks/              # Event listeners, health reconciliation
│   └── theme.ts            # Dual palette + CSS custom properties
├── preload/                # Secure IPC bridge (window.clui API)
└── shared/                 # Canonical types, IPC channel definitions
```

### How It Works

1. Each tab creates a `claude -p --output-format stream-json` subprocess.
2. NDJSON events are parsed by `RunManager` and normalized by `EventNormalizer`.
3. `ControlPlane` manages tab lifecycle (connecting → idle → running → completed/failed/dead).
4. Tool permission requests arrive via HTTP hooks to `PermissionServer` (localhost only).
5. The renderer polls backend health every 1.5s and reconciles tab state.
6. Sessions are resumed with `--resume <session-id>` for continuity.

### Network Behavior

Clui CC operates almost entirely offline. The only outbound network calls are:

| Endpoint | Purpose | Required |
|----------|---------|----------|
| `raw.githubusercontent.com/anthropics/*` | Marketplace catalog (cached 5 min) | No — graceful fallback |
| `api.github.com/repos/anthropics/*/tarball/*` | Skill auto-install on startup | No — skipped on failure |

No telemetry, analytics, or auto-update mechanisms. All core Claude Code interaction goes through the local CLI.

</details>

## Troubleshooting

For setup issues and recovery commands, see [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md).

Quick self-check:

```bash
npm run doctor
```

## Tested On

| Component | Version |
|-----------|---------|
| macOS | 15.x (Sequoia) |
| Node.js | 20.x LTS, 22.x |
| Python | 3.12 (with setuptools installed) |
| Electron | 33.x |
| Claude Code CLI | 2.1.71 |

## Known Limitations

- **macOS only** — transparent overlay, tray icon, and node-pty are macOS-specific. Windows/Linux support is not currently implemented.
- **Requires Claude Code CLI** — Clui CC is a UI layer, not a standalone AI client. You need an authenticated `claude` CLI.
- **Permission mode** — uses `--permission-mode default`. The PTY interactive transport is legacy and disabled by default.

## License

[MIT](LICENSE)


================================================
FILE: SECURITY.md
================================================
# Security Policy

## Reporting a Vulnerability

If you discover a security vulnerability in CLUI, please report it responsibly:

1. **Do not** open a public GitHub issue.
2. Email the maintainer directly or use GitHub's private vulnerability reporting feature.
3. Include a description of the vulnerability, steps to reproduce, and potential impact.

We will acknowledge receipt within 48 hours and aim to provide a fix or mitigation within 7 days for critical issues.

## Security Architecture

CLUI runs entirely on your local machine. Key security properties:

- **No cloud backend** — all Claude Code interaction goes through the local `claude` CLI.
- **No telemetry or analytics** — zero outbound data collection.
- **Permission hook server** binds to `127.0.0.1:19836` only (not exposed to the network).
- **Per-launch secrets** — the hook server uses a random UUID as app secret, regenerated on every launch.
- **Sensitive field masking** — tool inputs containing tokens, passwords, keys, or credentials are masked before display in the renderer.
- **CLAUDECODE env var** is explicitly removed from all spawned subprocesses to prevent credential leakage.
- **Preload isolation** — the renderer has no direct access to Node.js APIs; all IPC goes through a typed `window.clui` bridge.

## Network Surface

| Endpoint | Direction | Purpose |
|----------|-----------|---------|
| `127.0.0.1:19836` | Local only | Permission hook server (PreToolUse) |
| `raw.githubusercontent.com` | Outbound | Marketplace catalog fetch (optional) |
| `api.github.com` | Outbound | Skill tarball download (optional, pinned SHA) |

No other network connections are made by CLUI itself. The `claude` CLI may make its own connections as part of normal operation.

## Supported Versions

| Version | Supported |
|---------|-----------|
| 0.1.x   | Yes       |


================================================
FILE: commands/install-app.command
================================================
#!/bin/bash
# ──────────────────────────────────────────────────────
#  Clui CC — Install App
#
#  Double-click this file in Finder to:
#   1. Set up dependencies
#   2. Install voice support (Whisper)
#   3. Build a standalone macOS app
#   4. Copy it to /Applications
#   5. Clean temporary build files
#   6. Launch it
# ──────────────────────────────────────────────────────
set -e

# Resolve to repo root (one level up from commands/)
cd "$(dirname "$0")/.."

APP_NAME="Clui CC"
DEST="/Applications/${APP_NAME}.app"

step() { echo; echo "═══ $1 ═══"; echo; }

# ── 1. Setup ──

step "Step 1/6 — Setting up environment and dependencies"

if ! bash ./commands/setup.command; then
  echo
  echo "Setup failed. Fix the issues above, then double-click this file again."
  echo
  exit 1
fi

# ── 2. Whisper (required for voice input) ──

step "Step 2/6 — Checking voice support (Whisper)"

if command -v whisperkit-cli &>/dev/null || command -v whisper-cli &>/dev/null || command -v whisper &>/dev/null; then
  echo "Whisper is already installed."
else
  echo "Whisper is not installed. Voice input requires it."
  echo

  if ! command -v brew &>/dev/null; then
    echo "Homebrew is required to install Whisper but was not found."
    echo
    echo "  Install Homebrew first:"
    echo "    /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""
    echo
    echo "  Then double-click this file again."
    echo
    exit 1
  fi

  ARCH="$(uname -m)"
  INSTALLED=""

  if [ "$ARCH" = "arm64" ]; then
    # Apple Silicon: prefer whisperkit-cli, fall back to whisper-cpp
    echo "Installing Whisper via Homebrew (whisperkit-cli for $ARCH)..."
    echo
    if brew install whisperkit-cli; then
      INSTALLED="whisperkit-cli"
    else
      echo
      echo "whisperkit-cli failed — falling back to whisper-cpp..."
      echo
      if brew install whisper-cpp; then
        INSTALLED="whisper-cpp"
      fi
    fi
  else
    # Intel: whisper-cpp only (whisperkit-cli requires arm64)
    echo "Installing Whisper via Homebrew (whisper-cpp for $ARCH)..."
    echo
    if brew install whisper-cpp; then
      INSTALLED="whisper-cpp"
    fi
  fi

  if [ -z "$INSTALLED" ]; then
    echo
    echo "Whisper installation failed."
    echo
    echo "  Try running manually:"
    if [ "$ARCH" = "arm64" ]; then
      echo "    brew install whisperkit-cli"
      echo "  or:"
      echo "    brew install whisper-cpp"
    else
      echo "    brew install whisper-cpp"
    fi
    echo
    echo "  Then double-click this file again."
    echo
    exit 1
  fi

  # Verify — check for the executable that the installed formula provides
  if [ "$INSTALLED" = "whisperkit-cli" ]; then
    VERIFY_BIN="whisperkit-cli"
  else
    VERIFY_BIN="whisper-cli"
  fi

  if ! command -v "$VERIFY_BIN" &>/dev/null; then
    echo
    echo "Whisper was installed but the command is not available."
    echo
    echo "  Try opening a new Terminal window and running:"
    echo "    $VERIFY_BIN --help"
    echo
    echo "  If that works, double-click this file again."
    echo
    exit 1
  fi

  echo "Whisper installed successfully ($INSTALLED)."
fi

# ── 3. Build ──

step "Step 3/6 — Building ${APP_NAME}.app"

if ! npm run dist; then
  echo
  echo "Build failed."
  echo
  echo "  Try these steps one at a time:"
  echo "    rm -rf node_modules"
  echo "    npm install"
  echo "    npm run dist"
  echo
  echo "  If it still fails, see docs/TROUBLESHOOTING.md"
  echo
  exit 1
fi

# ── 4. Detect and copy ──

step "Step 4/6 — Installing to /Applications"

APP_SOURCE=""
if [ -d "release/mac-arm64/${APP_NAME}.app" ]; then
  APP_SOURCE="release/mac-arm64/${APP_NAME}.app"
elif [ -d "release/mac/${APP_NAME}.app" ]; then
  APP_SOURCE="release/mac/${APP_NAME}.app"
fi

if [ -z "$APP_SOURCE" ]; then
  echo "Could not find the built app."
  echo
  echo "  Expected one of:"
  echo "    release/mac-arm64/${APP_NAME}.app  (Apple Silicon)"
  echo "    release/mac/${APP_NAME}.app        (Intel)"
  echo
  echo "  Check what was built:"
  echo "    ls release/"
  echo
  exit 1
fi

echo "Found: $APP_SOURCE"

if [ -d "$DEST" ]; then
  echo "Replacing existing ${APP_NAME} in /Applications..."
  rm -rf "$DEST"
fi

cp -R "$APP_SOURCE" "$DEST"
echo "Copied to $DEST"

# ── 5. Cleanup ──

step "Step 5/6 — Cleaning temporary build files"

if [ "${KEEP_BUILD_ARTIFACTS:-0}" = "1" ]; then
  echo "Keeping build artifacts (KEEP_BUILD_ARTIFACTS=1)."
else
  rm -rf ./dist ./release
  echo "Removed: dist/ and release/"
fi

# ── 6. Launch ──

step "Step 6/6 — Launching ${APP_NAME}"

open "$DEST"

echo "Done! ${APP_NAME} is running."
echo
echo "  Show/hide the overlay:  ⌥ + Space  (Option + Space)"
echo "  Quit:                   Click the menu bar icon > Quit"
echo
echo "  First launch: if macOS shows a security warning, go to"
echo "  System Settings > Privacy & Security > Open Anyway"
echo "  You only need to do this once."
echo


================================================
FILE: commands/setup.command
================================================
#!/bin/bash
set -e

# Resolve to repo root (one level up from commands/)
cd "$(dirname "$0")/.."

# ── Helpers ──

fail=0
SDK_PATH=""

step() { echo; echo "--- $1"; }
pass() { echo "  OK: $1"; }
fail() { echo "  FAIL: $1"; fail=1; }
fix() {
  echo
  echo "  To fix, copy and run this command:"
  echo
  echo "    $1"
  echo
}

version_gte() {
  [ "$(printf '%s\n%s' "$1" "$2" | sort -V | head -1)" = "$2" ]
}

# ── Preflight Checks ──

step "Checking environment"

# macOS
if [ "$(uname)" != "Darwin" ]; then
  fail "Clui CC requires macOS 13+. Detected: $(uname). This project does not run on Linux or Windows."
else
  macos_ver=$(sw_vers -productVersion 2>/dev/null || echo "0")
  if version_gte "$macos_ver" "13.0"; then
    pass "macOS $macos_ver"
  else
    fail "macOS $macos_ver is too old. Clui CC requires macOS 13+."
    echo "  Update macOS in System Settings > General > Software Update."
  fi
fi

# Node
if command -v node &>/dev/null; then
  node_ver=$(node --version | sed 's/^v//')
  if version_gte "$node_ver" "18.0.0"; then
    pass "Node.js v$node_ver"
  else
    fail "Node.js v$node_ver is too old. Clui CC requires Node 18+."
    fix "brew install node"
  fi
else
  fail "Node.js is not installed."
  fix "brew install node"
fi

# npm
if command -v npm &>/dev/null; then
  pass "npm $(npm --version)"
else
  fail "npm is not installed (should come with Node.js)."
  fix "brew install node"
fi

# Python 3 + distutils
if command -v python3 &>/dev/null; then
  pass "Python $(python3 --version 2>&1 | awk '{print $2}')"

  if python3 -c "import distutils" 2>/dev/null; then
    pass "Python distutils available"
  else
    fail "Python is missing 'distutils' (needed by native module compiler)."
    fix "python3 -m pip install --upgrade pip setuptools"
  fi
else
  fail "Python 3 is not installed."
  fix "brew install python@3.11"
fi

# Xcode CLT
if xcode-select -p &>/dev/null; then
  pass "Xcode CLT at $(xcode-select -p)"
else
  fail "Xcode Command Line Tools are not installed."
  fix "xcode-select --install"
fi

# macOS SDK
if xcrun --sdk macosx --show-sdk-path &>/dev/null; then
  SDK_PATH=$(xcrun --sdk macosx --show-sdk-path)
  pass "macOS SDK at $SDK_PATH"
else
  fail "macOS SDK not found. Xcode Command Line Tools may be broken."
  echo
  echo "  Try: xcode-select --install"
  echo "  If that doesn't help:"
  echo "    sudo rm -rf /Library/Developer/CommandLineTools"
  echo "    xcode-select --install"
  echo
fi

# C++ compiler + headers
if command -v clang++ &>/dev/null; then
  pass "clang++ available"

  PROBE_DIR=$(mktemp -d)
  echo '#include <functional>' > "$PROBE_DIR/probe.cpp"
  echo 'int main() { return 0; }' >> "$PROBE_DIR/probe.cpp"
  if clang++ -std=c++17 -c "$PROBE_DIR/probe.cpp" -o "$PROBE_DIR/probe.o" 2>/dev/null; then
    pass "C++ standard headers OK"
  elif [ -n "$SDK_PATH" ] && clang++ -std=c++17 -isysroot "$SDK_PATH" -I"$SDK_PATH/usr/include/c++/v1" -c "$PROBE_DIR/probe.cpp" -o "$PROBE_DIR/probe.o" 2>/dev/null; then
    pass "C++ standard headers OK (using SDK include path)"
  else
    fail "C++ headers are broken (<functional> not found)."
    echo
    echo "  Try: xcode-select --install"
    echo "  If that doesn't help:"
    echo "    sudo rm -rf /Library/Developer/CommandLineTools"
    echo "    xcode-select --install"
    echo
  fi
  rm -rf "$PROBE_DIR"
else
  fail "clang++ not found. Xcode Command Line Tools may be broken."
  fix "xcode-select --install"
fi

# Claude CLI
if command -v claude &>/dev/null; then
  pass "Claude Code CLI found"
else
  fail "Claude Code CLI is not installed."
  fix "npm install -g @anthropic-ai/claude-code"
fi

# Bail if any check failed
if [ "$fail" -ne 0 ]; then
  echo
  echo "Some checks failed. Fix them above, then rerun:"
  echo
  echo "  ./commands/setup.command"
  echo
  exit 1
fi

echo
echo "All checks passed."

# ── Install ──

step "Installing dependencies"
if [ -n "$SDK_PATH" ]; then
  export SDKROOT="$SDK_PATH"
  export CXXFLAGS="-isysroot $SDKROOT -I$SDKROOT/usr/include/c++/v1 ${CXXFLAGS:-}"
fi
if ! npm install; then
  echo
  echo "npm install failed. Most common fixes:"
  echo
  echo "  1. xcode-select --install"
  echo "  2. python3 -m pip install --upgrade pip setuptools"
  echo "  3. Rerun: ./commands/setup.command"
  echo
  exit 1
fi

# Guard against stale lockfiles/dependency trees that keep vulnerable versions.
installed_builder=$(node -p "require('./node_modules/electron-builder/package.json').version" 2>/dev/null || echo "")
installed_electron=$(node -p "require('./node_modules/electron/package.json').version" 2>/dev/null || echo "")

if [ -z "$installed_builder" ] || [ -z "$installed_electron" ]; then
  echo
  echo "Could not verify installed Electron dependencies."
  echo "Try:"
  echo "  rm -rf node_modules package-lock.json"
  echo "  npm install"
  echo "  ./commands/setup.command"
  echo
  exit 1
fi

if ! version_gte "$installed_builder" "26.8.1" || ! version_gte "$installed_electron" "35.7.5"; then
  echo
  echo "Detected outdated install (electron-builder $installed_builder, electron $installed_electron)."
  echo "Applying required security baseline..."
  echo
  npm install -D electron-builder@^26.8.1 electron@^35.7.5
fi

final_builder=$(node -p "require('./node_modules/electron-builder/package.json').version" 2>/dev/null || echo "")
final_electron=$(node -p "require('./node_modules/electron/package.json').version" 2>/dev/null || echo "")
echo "Installed: electron-builder $final_builder, electron $final_electron"

echo
echo "Setup complete. To launch the app, run:"
echo
echo "  ./commands/start.command"
echo


================================================
FILE: commands/start.command
================================================
#!/bin/bash
set -e

# Resolve to repo root (one level up from commands/)
cd "$(dirname "$0")/.."

if [ ! -d "node_modules" ]; then
  echo "Dependencies not installed."
  echo
  echo "  If this is your first time, run:"
  echo "    ./commands/setup.command"
  echo
  echo "  Or install manually:"
  echo "    npm install"
  echo
  exit 1
fi

# Clean stale PID file
PID_FILE=".clui.pid"
if [ -f "$PID_FILE" ]; then
  old_pid=$(cat "$PID_FILE" 2>/dev/null)
  if [ -n "$old_pid" ] && ! kill -0 "$old_pid" 2>/dev/null; then
    rm -f "$PID_FILE"
  fi
fi

echo "Building Clui CC..."
if ! npx electron-vite build --mode production; then
  echo
  echo "Build failed. Try: rm -rf node_modules && npm install"
  exit 1
fi

echo "Clui CC running. ⌥ + Space to toggle. Use ./commands/stop.command or tray icon > Quit to close."

# Launch in a new process group and record the PID
npx electron . &
APP_PID=$!
echo "$APP_PID" > "$PID_FILE"

# Clean up PID file when the app exits
wait "$APP_PID" 2>/dev/null
rm -f "$PID_FILE"


================================================
FILE: commands/stop.command
================================================
#!/bin/bash

# Resolve to repo root (one level up from commands/)
cd "$(dirname "$0")/.."

REPO_DIR="$(pwd)"
PID_FILE=".clui.pid"
stopped=0

# ── 1. Try tracked PID first ──

if [ -f "$PID_FILE" ]; then
  APP_PID=$(cat "$PID_FILE" 2>/dev/null)
  if [ -n "$APP_PID" ] && kill -0 "$APP_PID" 2>/dev/null; then
    # Kill the process group (app + all child helpers)
    kill -TERM -"$APP_PID" 2>/dev/null || kill -TERM "$APP_PID" 2>/dev/null

    # Wait up to 3 seconds for graceful shutdown
    for i in 1 2 3; do
      kill -0 "$APP_PID" 2>/dev/null || break
      sleep 1
    done

    # Force kill if still alive
    if kill -0 "$APP_PID" 2>/dev/null; then
      kill -KILL -"$APP_PID" 2>/dev/null || kill -KILL "$APP_PID" 2>/dev/null
      sleep 0.5
    fi

    stopped=1
  fi
  rm -f "$PID_FILE"
fi

# ── 2. Fallback: pattern-based kill for anything missed ──

leftover_pids=$(pgrep -f "$REPO_DIR/node_modules/electron" 2>/dev/null || true)
leftover_pids="$leftover_pids $(pgrep -f "$REPO_DIR/dist/main" 2>/dev/null || true)"
leftover_pids=$(echo "$leftover_pids" | xargs)

if [ -n "$leftover_pids" ]; then
  # Graceful first
  kill -TERM $leftover_pids 2>/dev/null
  sleep 2

  # Force kill survivors
  for pid in $leftover_pids; do
    if kill -0 "$pid" 2>/dev/null; then
      kill -KILL "$pid" 2>/dev/null
    fi
  done
  stopped=1
fi

# ── 3. Verify ──

sleep 0.5
remaining=$(pgrep -f "$REPO_DIR/node_modules/electron" 2>/dev/null || true)
remaining="$remaining $(pgrep -f "$REPO_DIR/dist/main" 2>/dev/null || true)"
remaining=$(echo "$remaining" | xargs)

if [ -n "$remaining" ]; then
  echo "Warning: some processes could not be stopped:"
  echo "  PIDs: $remaining"
  echo
  echo "  To force kill manually:"
  echo "    kill -9 $remaining"
else
  if [ "$stopped" -eq 1 ]; then
    echo "Clui CC stopped."
  else
    echo "Clui CC was not running."
  fi
fi


================================================
FILE: docs/AGENTS.md
================================================
# Agent Guide — Clui CC

> This file is optimized for AI coding agents (Claude Code, Cursor, Copilot, etc.).
> For human-readable docs see [ARCHITECTURE.md](ARCHITECTURE.md) and [CONTRIBUTING.md](../CONTRIBUTING.md).

## What This Project Is

Clui CC is a **macOS-only Electron overlay** that wraps the Claude Code CLI (`claude -p --output-format stream-json`) in a floating pill UI. It is NOT a web app, NOT a VS Code extension, and does NOT call the Anthropic API directly — it spawns CLI subprocesses.

## Quick Reference

| Action | Command |
|--------|---------|
| Install deps | `npm install` |
| Dev mode (hot-reload) | `npm run dev` |
| Type-check / build | `npm run build` |
| Toggle overlay | `⌥ + Space` (fallback: `Cmd+Shift+K`) |
| Debug logging | `CLUI_DEBUG=1 npm run dev` (writes to `~/.clui-debug.log`) |

**Main process changes require full restart.** Renderer changes hot-reload.

## Architecture (3-Layer)

```
Renderer (React 19 + Zustand 5 + Tailwind CSS 4)
    ↕  contextBridge IPC (src/preload/index.ts)
Main Process (Node.js / Electron 33)
    ↕  spawns subprocess
Claude Code CLI (claude -p --output-format stream-json)
```

### Layer Responsibilities

| Layer | Directory | Manages |
|-------|-----------|---------|
| **Renderer** | `src/renderer/` | UI state, theming, user input, message display |
| **Preload** | `src/preload/` | Typed IPC bridge (`window.clui` API). Security boundary. |
| **Main** | `src/main/` | Process lifecycle, tab state machine, permission server, marketplace |

### Key Files by Concern

| Concern | File(s) |
|---------|---------|
| Tab lifecycle & state machine | `src/main/claude/control-plane.ts` |
| Spawning Claude CLI processes | `src/main/claude/run-manager.ts` |
| Raw NDJSON → canonical events | `src/main/claude/event-normalizer.ts` |
| Permission hook server | `src/main/hooks/permission-server.ts` |
| All TypeScript types & IPC channels | `src/shared/types.ts` |
| Zustand state store | `src/renderer/stores/sessionStore.ts` |
| Theme / color system | `src/renderer/theme.ts` |
| Main window & IPC handler setup | `src/main/index.ts` |
| Marketplace catalog | `src/main/marketplace/catalog.ts` |
| Skill installer | `src/main/skills/installer.ts` |

## Data Flow: Prompt → Response

```
InputBar.tsx → window.clui.prompt(tabId, requestId, opts)
  → ipcRenderer.invoke('clui:prompt')
  → ControlPlane.prompt()
  → RunManager spawns: claude -p --output-format stream-json --resume <sid>
  → stdout emits NDJSON lines
  → EventNormalizer → NormalizedEvent
  → ControlPlane broadcasts via IPC
  → useClaudeEvents hook → sessionStore.handleNormalizedEvent()
  → React re-renders
```

## Canonical Types

All IPC and event types live in `src/shared/types.ts`. Key types:

- **`NormalizedEvent`** — union of all events the main process emits to the renderer
- **`TabState`** — full state of a single tab (status, messages, permissions, session metadata)
- **`TabStatus`** — state machine: `connecting → idle → running → completed/failed/dead`
- **`IPC`** — const object with all IPC channel names (use these, never raw strings)
- **`RunOptions`** — options passed when spawning a Claude CLI run
- **`CatalogPlugin`** — marketplace plugin metadata

## Conventions & Rules

### Must Follow

1. **TypeScript strict mode** — zero errors required (`npm run build` must pass)
2. **Use `IPC.*` constants** for all IPC channel names — never hardcode strings
3. **Use `useColors()` hook** for all color references in renderer — never hardcode colors
4. **Narrow Zustand selectors** with custom equality functions for performance
5. **All new IPC channels** must be added to `src/shared/types.ts` AND wired in both `src/preload/index.ts` and `src/main/index.ts`
6. **Tab state transitions** go through `ControlPlane` only — never mutate tab state directly

### Security — Do Not Break

- **Permission server** binds to `127.0.0.1` only (never `0.0.0.0`)
- **Per-launch app secret** (random UUID) validates hook requests — do not weaken
- **Per-run tokens** route permission responses to correct tab — do not bypass
- **`CLAUDECODE` env var** is explicitly removed from spawned processes
- **Sensitive fields** (tokens, passwords, secrets, keys, auth, credentials) are masked via `maskSensitiveFields()` before display
- **5-minute auto-deny timeout** on unanswered permissions — do not remove

### Don't

- Don't import main-process modules from renderer (or vice versa) — the preload bridge is the only crossing point
- Don't add network calls — the app is designed to be nearly offline (only marketplace fetches from GitHub)
- Don't use `node-pty` for new features — it's legacy, prefer `RunManager` (stdio-based)
- Don't add Electron `remote` module usage — it's disabled for security

## Adding a New Feature — Checklist

### New IPC channel
1. Add channel name to `IPC` const in `src/shared/types.ts`
2. Add handler in `src/main/index.ts` (`ipcMain.handle` or `ipcMain.on`)
3. Expose via `contextBridge` in `src/preload/index.ts`
4. Call from renderer via `window.clui.*`

### New UI component
1. Create in `src/renderer/components/`
2. Use `useColors()` for all colors
3. Use Phosphor icons (`@phosphor-icons/react`) — not other icon libraries
4. Animations via Framer Motion

### New event type from Claude CLI
1. Add raw type to `ClaudeEvent` union in `src/shared/types.ts`
2. Add normalized form to `NormalizedEvent` union
3. Handle in `EventNormalizer.normalize()` (`src/main/claude/event-normalizer.ts`)
4. Handle in `sessionStore.handleNormalizedEvent()` (`src/renderer/stores/sessionStore.ts`)

### New tab state field
1. Add to `TabState` interface in `src/shared/types.ts`
2. Initialize in `createTab()` in both `ControlPlane` and `sessionStore`
3. Update via `ControlPlane` events — never directly from renderer

## Stack

| Layer | Tech | Version |
|-------|------|---------|
| Desktop | Electron | 33 |
| Build | electron-vite | 3 |
| UI | React | 19 |
| State | Zustand | 5 |
| Styling | Tailwind CSS | 4 |
| Animation | Framer Motion | 12 |
| Icons | Phosphor Icons | 2 |
| Markdown | react-markdown + remark-gfm | 9 / 4 |
| PTY (legacy) | node-pty | 1.1 |

## Network Surface

| Endpoint | Purpose | Required |
|----------|---------|----------|
| `raw.githubusercontent.com/anthropics/*` | Marketplace catalog (cached 5 min) | No |
| `api.github.com/repos/anthropics/*/tarball/*` | Skill auto-install | No |
| `127.0.0.1:19836` | Permission hook server (local only) | Yes |

No telemetry. No analytics. No auto-update.

## Common Pitfalls

1. **Forgetting to restart dev server** after main-process changes — renderer hot-reloads but main does not
2. **Adding raw color values** instead of using `useColors()` — breaks theming
3. **Mutating tab state from renderer** instead of going through ControlPlane events
4. **Hardcoding IPC strings** instead of using `IPC.*` constants
5. **Testing on non-macOS** — this is macOS-only (transparent windows, node-pty bindings)
6. **Not handling the `session_dead` event** — if a Claude process crashes, the tab must transition to `dead` status


================================================
FILE: docs/ARCHITECTURE.md
================================================
# CLUI Architecture

## Overview

CLUI is an Electron desktop application that provides a graphical interface for Claude Code CLI. It spawns `claude -p` subprocesses, parses their NDJSON output, and presents conversations in a floating overlay window.

```
┌──────────────────────────────────────────────────────────────┐
│                     Renderer Process                         │
│  React 19 + Zustand 5 + Tailwind CSS 4 + Framer Motion      │
│                                                              │
│  ┌──────────┐ ┌──────────────┐ ┌──────────┐ ┌────────────┐  │
│  │ TabStrip  │ │Conversation  │ │ InputBar │ │ Marketplace│  │
│  │          │ │   View       │ │          │ │   Panel    │  │
│  └──────────┘ └──────────────┘ └──────────┘ └────────────┘  │
│                         │                                    │
│                    sessionStore (Zustand)                     │
│                         │                                    │
│              window.clui (preload bridge)                     │
├──────────────────────────────────────────────────────────────┤
│                     Preload Script                            │
│  Typed IPC bridge — contextBridge.exposeInMainWorld          │
├──────────────────────────────────────────────────────────────┤
│                     Main Process                             │
│                                                              │
│  ┌──────────────────────────────────────────────────────┐    │
│  │                   ControlPlane                        │    │
│  │  Tab registry, session lifecycle, queue management    │    │
│  │                                                       │    │
│  │  ┌─────────────┐  ┌──────────────────┐               │    │
│  │  │ RunManager   │  │ EventNormalizer  │               │    │
│  │  │ Spawns       │  │ Raw stream-json  │               │    │
│  │  │ claude -p    │──│ → canonical      │               │    │
│  │  │ per prompt   │  │   events         │               │    │
│  │  └─────────────┘  └──────────────────┘               │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  ┌────────────────────┐  ┌────────────────────────────┐      │
│  │ PermissionServer   │  │ Marketplace Catalog        │      │
│  │ HTTP hooks on      │  │ GitHub raw fetch + cache   │      │
│  │ 127.0.0.1:19836    │  │ TTL: 5 minutes             │      │
│  └────────────────────┘  └────────────────────────────┘      │
└──────────────────────────────────────────────────────────────┘
         │                              │
    claude -p (NDJSON)          raw.githubusercontent.com
    (local subprocess)          (optional, cached)
```

## Main Process (`src/main/`)

### ControlPlane (`claude/control-plane.ts`)

Single authority for all tab and session lifecycle. Manages:

- **Tab registry** — maps tabId → session metadata, status, process PID.
- **State machine** — each tab transitions through: `connecting → idle → running → completed → failed → dead`.
- **Request routing** — maps requestIds to active RunManager instances.
- **Queue + backpressure** — max 32 pending requests, prompts queue behind running tasks.
- **Health reconciliation** — responds to renderer polls with tab status + process liveness.
- **Session ID tracking** — maps Claude session IDs to tabs for permission routing.

### RunManager (`claude/run-manager.ts`)

Spawns one `claude -p --output-format stream-json` process per prompt. Responsibilities:

- Constructs CLI arguments (`--resume`, `--permission-mode`, `--settings`, `--add-dir`, etc.)
- Reads NDJSON from stdout line-by-line via `StreamParser`.
- Passes raw events to `EventNormalizer` for canonicalization.
- Maintains stderr ring buffer (100 lines) for error diagnostics.
- Cleans up process on cancel, tab close, or unexpected exit.
- Removes `CLAUDECODE` from spawned environment to prevent credential leakage.

### EventNormalizer (`claude/event-normalizer.ts`)

Maps raw Claude Code stream-json events to canonical `NormalizedEvent` types:

| Raw Event | Normalized Event |
|-----------|-----------------|
| `system` (subtype: init) | `session_init` |
| `stream_event` (content_block_delta, text_delta) | `text_chunk` |
| `stream_event` (content_block_start, tool_use) | `tool_call` |
| `stream_event` (content_block_delta, input_json_delta) | `tool_call_update` |
| `stream_event` (content_block_stop) | `tool_call_complete` |
| `assistant` | `task_update` |
| `result` | `task_complete` |
| `rate_limit_event` | `rate_limit` |

### PermissionServer (`hooks/permission-server.ts`)

HTTP server that intercepts Claude Code tool calls via PreToolUse hooks:

1. ControlPlane starts PermissionServer on `127.0.0.1:19836`.
2. `generateSettingsFile()` creates a temp JSON file with hook config pointing at the server.
3. RunManager passes `--settings <path>` to each `claude -p` spawn.
4. When Claude wants to use a tool, the CLI POSTs to the hook URL.
5. PermissionServer emits a `permission-request` event to ControlPlane.
6. ControlPlane routes it to the correct tab via `_findTabBySessionId()`.
7. Renderer shows a `PermissionCard` with Allow/Deny buttons.
8. User decision flows back: IPC → ControlPlane → PermissionServer → HTTP response.
9. Claude Code proceeds or skips the tool based on the response.

Security: per-launch app secret, per-run tokens, sensitive field masking, 5-minute auto-deny timeout.

### Marketplace Catalog (`marketplace/catalog.ts`)

Fetches plugin metadata from three Anthropic GitHub repos:
- `anthropics/skills` (Agent Skills)
- `anthropics/knowledge-work-plugins` (Knowledge Work)
- `anthropics/financial-services-plugins` (Financial Services)

Uses Electron's `net.request()` with a 5-minute TTL cache. Individual fetch failures are isolated — one broken repo doesn't block others.

### Skill Installer (`skills/installer.ts`)

Auto-installs bundled skills on startup (currently: `skill-creator`). Uses pinned commit SHAs for deterministic downloads. Atomic install: validates in temp dir before swapping into `~/.claude/skills/`. Respects user-managed skills (skips if no `.clui-version` marker).

## Preload (`src/preload/`)

The preload script uses `contextBridge.exposeInMainWorld` to expose a typed `window.clui` API. This is the only communication surface between renderer and main process.

All methods map to `ipcRenderer.invoke()` (request-response) or `ipcRenderer.send()` (fire-and-forget). The full API surface is defined in `CluiAPI` interface.

## Renderer (`src/renderer/`)

### State Management

Single Zustand store (`stores/sessionStore.ts`) holds all application state:
- Tab list with full `TabState` objects (messages, status, attachments, permissions, etc.)
- Active tab selection
- Marketplace state (catalog, search, filter, install progress)
- UI state (expanded, marketplace open)

### Theme System (`theme.ts`)

Dual color palette (dark + light) defined as JS objects. `useColors()` hook returns the active palette reactively. All tokens are synced to CSS custom properties via `syncTokensToCss()` so CSS files can reference `var(--clui-*)`.

Theme mode state machine: `system | light | dark` with separate `_systemIsDark` tracking for OS value.

### Key Components

- **TabStrip** — tab bar with new tab, history picker, settings popover.
- **ConversationView** — scrollable message timeline with markdown rendering (react-markdown + remark-gfm), tool call cards, permission cards.
- **InputBar** — prompt input with attachment chips, voice recording, slash command menu, model picker.
- **MarketplacePanel** — plugin browser with search, semantic tag filters, install confirmation.

### Performance Patterns

- Narrow Zustand selectors with custom equality functions (field-level comparison) to prevent re-renders during streaming.
- RAF-throttled mousemove handler for click-through detection.
- Debounced marketplace search (200ms).
- Health reconciliation skips setState when no tabs changed.

## IPC Channel Map

All channels are defined in `src/shared/types.ts` under the `IPC` const. Events flow through a single `clui:normalized-event` channel for all Claude Code stream events, with separate channels for tab status changes and enriched errors.

## Data Flow: Prompt → Response

```
User types prompt
    → InputBar calls window.clui.prompt(tabId, requestId, options)
    → ipcRenderer.invoke('clui:prompt', ...)
    → Main: ControlPlane.prompt()
    → RunManager spawns: claude -p --output-format stream-json --resume <sid>
    → Claude CLI writes NDJSON to stdout
    → StreamParser emits lines
    → EventNormalizer maps to NormalizedEvent
    → ControlPlane updates tab state + broadcasts via IPC
    → Renderer: useClaudeEvents hook receives events
    → sessionStore.handleNormalizedEvent() updates messages
    → React re-renders ConversationView
```


================================================
FILE: docs/TROUBLESHOOTING.md
================================================
# Troubleshooting

If setup fails, run this first:

```bash
npm run doctor
```

This checks your local environment and prints pass/fail status without changing your system.

## Install Fails with "gyp" or "make" Errors

Install Xcode Command Line Tools, then retry:

```bash
xcode-select --install
```

```bash
npm install
```

## Install Fails with `ModuleNotFoundError: No module named 'distutils'`

Python 3.12+ removed `distutils`. Install `setuptools`:

```bash
python3 -m pip install --upgrade pip setuptools
```

```bash
npm install
```

If that still fails, install Python 3.11 and point npm to it:

```bash
brew install python@3.11
```

```bash
npm config set python $(brew --prefix python@3.11)/bin/python3.11
```

```bash
npm install
```

To undo that Python override later:

```bash
npm config delete python
```

## Install Fails with `fatal error: 'functional' file not found`

C++ headers are missing/broken, usually due to Xcode CLT issues.

Check toolchain first:

```bash
xcode-select -p
```

```bash
xcrun --sdk macosx --show-sdk-path
```

If either command fails (or the error persists), reinstall CLT:

```bash
sudo rm -rf /Library/Developer/CommandLineTools
```

```bash
xcode-select --install
```

Then retry:

```bash
npm install
```

If CLT is installed but the error still appears on newer macOS versions, compile explicitly against the SDK include path:

```bash
SDK=$(xcrun --sdk macosx --show-sdk-path)
clang++ -std=c++17 -isysroot "$SDK" -I"$SDK/usr/include/c++/v1" -x c++ - -o /dev/null <<'EOF'
#include <functional>
int main() { return 0; }
EOF
```

## Install Fails on `node-pty`

`node-pty` is native and requires macOS toolchains. Confirm:

- macOS 13+
- Xcode CLT installed
- Python 3 with `setuptools`/`distutils` available

Then retry `npm install`.

## App Launches but No Claude Response

Verify Claude CLI is installed and authenticated:

```bash
claude --version
```

```bash
claude
```

## `⌥ + Space` Does Not Toggle

Grant Accessibility permissions:

- System Settings -> Privacy & Security -> Accessibility

Fallback shortcut:

- `Cmd+Shift+K`

## Packaged App Won't Open (Security Warning)

The `.app` built by `npm run dist` is unsigned. macOS Gatekeeper blocks unsigned apps by default.

To allow it:

1. Open **System Settings → Privacy & Security**
2. Scroll to the security section
3. Click **Open Anyway** next to the Clui CC message

You only need to do this once. This is a local build, not App Store distribution.

## Install Fails at Whisper Step

The installer requires Whisper for voice input. If it fails:

1. Make sure Homebrew is installed:

```bash
brew --version
```

If not, install it:

```bash
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
```

2. Install Whisper manually:

```bash
# Apple Silicon (M1/M2/M3/M4) — preferred:
brew install whisperkit-cli
# Apple Silicon fallback, or Intel Mac:
brew install whisper-cpp
```

3. Rerun the installer:

```bash
./install-app.command
```

## Install Fails at Build Step

Run the steps manually to see the detailed error:

```bash
./commands/setup.command
```

```bash
npm run dist
```

If `npm run dist` fails, try a clean reinstall:

```bash
rm -rf node_modules
```

```bash
npm install
```

```bash
npm run dist
```

## Marketplace Shows "Failed to Load"

Expected when offline. Marketplace needs internet access; core app features continue to work.

## Window Is Invisible / No UI

Try:

- `⌥ + Space`
- `Cmd+Shift+K`
- Confirm app is running from the menu bar tray


================================================
FILE: docs/oss-readiness-report.md
================================================
# CLUI Open-Source Readiness Report

**Date:** 2026-03-12
**Branch:** `oss-prep`
**Assessor:** Automated scan + manual review

---

## 1. Security

### Secrets & Credentials
| Check | Result | Severity |
|-------|--------|----------|
| Hardcoded API keys/tokens | None found | Safe |
| .env files | None exist (not needed — app uses local CLI) | Safe |
| CLAUDECODE env var | Explicitly deleted from spawned processes | Safe |
| Private key / cert files | None found | Safe |
| Database connection strings | None (no DB) | Safe |

### Permission System
- HTTP hook server binds **127.0.0.1:19836 only** (not exposed externally)
- Per-launch app secret (randomUUID) prevents local spoofing
- Per-run tokens for routing
- Sensitive fields masked before sending to renderer (`/token|password|secret|key|auth|credential|api.?key/i`)
- 5-minute auto-deny timeout for unanswered permission requests

**Verdict:** No security blockers.

---

## 2. Privacy

### Hardcoded Paths
| Location | Contains User Paths | Action |
|----------|-------------------|--------|
| `src/**` | No | Safe |
| `spike/**` | No | Safe |
| `scripts/**` | No (uses `$(dirname "$0")`) | Safe |
| `docs/protocol-captures/*.jsonl` | **Yes** — `/Users/<user>/...` in session CWD fields | **Must exclude from public repo** |
| `docs/claude-permission-probe.md` | **Yes** — references local paths in examples | **Must exclude from public repo** |
| `.claude/settings.local.json` | Yes — already gitignored | Safe |

### Personal Information
| Check | Result |
|-------|--------|
| Email addresses in source | None |
| package.json author field | Not set (clean) |
| Git commit author | Will be visible in public repo history — see cutover plan |

**Verdict:** Exclude `docs/protocol-captures/` and `docs/claude-permission-probe.md` from public repo.

---

## 3. Licensing

### Project License
- **Current state:** No LICENSE file, no `license` field in package.json
- **Action:** **MUST-FIX** — Add MIT license before publishing

### Dependencies (all MIT-compatible)
| Package | License | Copyleft Risk |
|---------|---------|---------------|
| electron | MIT | None |
| react / react-dom | MIT | None |
| zustand | MIT | None |
| framer-motion | MIT | None |
| node-pty | MIT | None |
| react-markdown | MIT | None |
| remark-gfm | MIT | None |
| @phosphor-icons/react | MIT | None |
| tailwindcss | MIT | None |
| All devDependencies | MIT | None |

**No GPL, AGPL, SSPL, or BUSL dependencies detected.**

### Assets
| Asset | Provenance | Action |
|-------|-----------|--------|
| `resources/icon.*` | Original (created for project) | Document in LICENSE |
| `resources/notification.mp3` | Replaced with generated CC0 chime (embedded metadata) | Resolved |
| `resources/trayTemplate*.png` | Original | Document in LICENSE |
| Root marketing screenshots | Not included in current repo root | Optional to add later if needed for release collateral |

**Verdict:** Add LICENSE file. ~~Verify notification.mp3 provenance~~ — resolved (replaced with CC0 generated chime).

---

## 4. Developer UX

### Prerequisites for Contributors
- Node.js 18+ (for Electron 33)
- macOS (primary platform — Electron transparent window, tray, node-pty)
- `claude` CLI installed and authenticated (core dependency)
- Optional: `whisperkit-cli` (Apple Silicon preferred, CoreML) or `whisper-cpp` (Apple Silicon & Intel, ggml) or `whisper` (Python) for voice transcription

### Build System
- `npm install` → `npm run dev` (hot-reload) or `npm run build` (production)
- Zero TypeScript errors confirmed
- electron-vite handles main/preload/renderer bundling

### Missing for OSS
| Item | Status | Priority |
|------|--------|----------|
| README.md | Missing | **Must-fix** |
| CONTRIBUTING.md | Missing | Must-fix |
| SECURITY.md | Missing | Must-fix |
| CODE_OF_CONDUCT.md | Missing | Must-fix |
| Architecture docs | Missing | Must-fix |
| .env.example | Not needed | N/A — document explicitly |

---

## 5. Repository Hygiene

### Files to Exclude from Public Repo
| Path | Reason |
|------|--------|
| `docs/protocol-captures/` | Contains local paths, session data |
| `docs/claude-permission-probe.md` | Contains local path references |
| `CLUI-PRD.md` | Internal product requirements |
| `CODEX_REPORT_INTERACTIVE_COMMANDS.md` | Internal dev report |
| `spike/` | Experimental probes, not production code |
| `src/main/probe/` | Internal contract/permission test utilities |
| `soft_and_brief_notif_#2-*.mp3` | Stray temp file in root |
| `start-pty.command` | Legacy PTY mode launcher |
| `.claude/` | Project-scoped Claude settings |

### .gitignore Gaps
Current `.gitignore` is minimal. Should add:
- `out/` (electron-builder output)
- `*.log`
- `.env*`
- `*.swp`, `*.swo`
- OS artifacts beyond `.DS_Store`

---

## 6. Network Dependencies

| Endpoint | Purpose | Required | Graceful Offline |
|----------|---------|----------|-----------------|
| `raw.githubusercontent.com/anthropics/*` | Marketplace catalog | Optional | Yes — cached 5min, error state shown |
| `api.github.com/repos/anthropics/*/tarball/*` | Skill auto-install | Optional | Yes — skipped on failure |
| `127.0.0.1:19836` | Permission hook server | Required (local only) | N/A |

No telemetry, analytics, auto-updater, or CDN dependencies.

---

## 7. Release Risk Summary

| Risk | Severity | Status |
|------|----------|--------|
| No LICENSE file | **Critical** | Fix in this branch |
| No README | **Critical** | Fix in this branch |
| Protocol captures contain local paths | **High** | Exclude from public repo |
| notification.mp3 unknown provenance | **Medium** | Resolved — replaced with CC0 generated chime |
| No CONTRIBUTING/SECURITY/COC docs | **Medium** | Fix in this branch |
| Internal docs (PRD, Codex reports) | **Low** | Exclude from public repo |
| Probe utilities in src/main/probe/ | **Low** | Exclude from public repo |
| macOS-only (no Windows/Linux) | **Low** | Document as known limitation |


================================================
FILE: docs/release-smoke-test.md
================================================
# Release Smoke Test

## Build Verification

### Fresh Clone Bootstrap

```bash
git clone https://github.com/lcoutodemos/clui-cc.git
cd clui-cc
npm run doctor     # verify environment — all checks should pass
npm install        # installs deps + runs postinstall (electron-builder install-app-deps + icon patch)
npm run build      # production build — must exit 0 with no errors
```

**Prerequisites check (verified by `npm run doctor`):**
- macOS 13+
- Xcode Command Line Tools installed (`xcode-select -p` returns a path)
- macOS SDK available (`xcrun --sdk macosx --show-sdk-path` returns a path)
- clang++ available with working C++ headers
- `node --version` returns 18+
- `python3` available with `distutils` importable
- `claude --version` returns 2.1+

**Expected output:**
- `dist/main/index.js` — ~117 KB
- `dist/preload/index.js` — ~6 KB
- `dist/renderer/index.html` + `assets/index-*.js` (~1.5 MB) + `assets/index-*.css` (~25 KB)

### TypeScript

- `npm run build` — passes (uses esbuild, tolerant of some strict-mode warnings)
- `npx tsc --noEmit` — has pre-existing warnings (68 as of v0.1.0, non-blocking)
  - These are narrowing/equality warnings from Zustand selector patterns and a legacy PTY file
  - Does NOT affect runtime behavior — electron-vite builds successfully

## Runtime Smoke Test Checklist

### Prerequisites
- [ ] macOS 13+
- [ ] Xcode Command Line Tools installed (`xcode-select -p` returns a path)
- [ ] Node.js 18+
- [ ] `claude` CLI installed and authenticated (`claude --version` returns 2.1+)

### Startup
- [ ] `npm run dev` or `./commands/start.command` launches the app
- [ ] Floating pill appears at bottom-center of screen
- [ ] `⌥ + Space` toggles visibility (fallback: `Cmd+Shift+K`)
- [ ] Tray icon appears in menu bar
- [ ] Tray menu shows Quit option

### Tab Management
- [ ] Default tab created on launch
- [ ] Click `+` creates a new tab
- [ ] Clicking tab switches active tab
- [ ] Tab shows correct status dot (idle = gray, running = orange, completed = green)

### Prompt & Response
- [ ] Type a prompt and press Enter
- [ ] Tab status changes to "running" (orange dot)
- [ ] Text streams into conversation view
- [ ] Tool calls appear as expandable cards
- [ ] Task completes, status changes to "completed" (green dot)
- [ ] Cost/tokens shown in status bar

### Permission System
- [ ] When Claude tries to use a tool, a permission card appears
- [ ] "Allow" lets the tool run
- [ ] "Deny" blocks the tool
- [ ] Permission denial is reflected in task completion

### Settings
- [ ] Three-dot button in tab strip opens settings popover
- [ ] Sound toggle works (on/off)
- [ ] Theme picker works (System/Light/Dark)
- [ ] UI size toggle works (Compact/Expanded)
- [ ] Settings persist across restart (localStorage)

### History
- [ ] Clock icon opens session history picker
- [ ] Previous sessions listed with timestamps
- [ ] Clicking a session loads its messages

### Marketplace
- [ ] HeadCircuit (brain) button opens marketplace panel
- [ ] Plugins load from GitHub (requires network)
- [ ] Search filters by name/description/tags
- [ ] Filter chips narrow results by semantic tag
- [ ] "Installed" filter shows installed plugins
- [ ] Install flow shows confirmation with exact CLI commands
- [ ] Graceful error state when offline

### Voice Input (Whisper required — installed by install-app.command)
- [ ] Microphone button starts recording
- [ ] Stop button ends recording and transcribes
- [ ] Transcribed text appears in input bar

### Attachments
- [ ] Paperclip button opens file picker
- [ ] Camera button takes screenshot
- [ ] Pasting an image from clipboard works
- [ ] Attachment chips appear below input

### Theme
- [ ] Dark mode: warm dark surfaces, orange accent
- [ ] Light mode: light surfaces, same orange accent
- [ ] System mode follows OS dark/light setting

### Window Behavior
- [ ] Window is transparent (click-through on non-UI areas)
- [ ] Window stays on top of other windows
- [ ] Expanded UI mode widens the panel
- [ ] Collapsing back to compact restores original size
- [ ] No shadow clipping at window edges

## Offline Behavior

- [ ] App launches and is usable without network
- [ ] Marketplace shows error state with "Retry" button
- [ ] Skill auto-install silently skips on failure
- [ ] All prompt/response functionality works (uses local CLI)

## Last Verified

- **Date:** 2026-03-12
- **Node:** v22.x
- **Electron:** 33.x
- **Claude CLI:** 2.1.71
- **macOS:** 15.x (Sequoia)
- **Build result:** Pass (zero build errors)


================================================
FILE: docs/slash-command-matrix.md
================================================
# Slash Command Capability Matrix

CLI Version: 2.1.63 | Date: 2026-03-08
Test session: 450d2d0f-4b03-4761-8ecd-8d179998127d

## Protocol Finding

`--input-format stream-json` is **completely broken** in CLI 2.1.63 (hangs forever, 0 events).
The only working mode is one-shot `claude -p` with stdin closed + `--resume` for multi-turn.

## Command Matrix

| Command | Fresh | With Session | Events | Result Preview | Verdict |
|---------|-------|-------------|--------|---------------|---------|
| `/help` | ✅ | ✅ | system/init, result/success | Unknown skill: help | **works_native** |
| `/model` | ✅ | ✅ | system/init, result/success | Unknown skill: model | **works_native** |
| `/mcp` | ✅ | ✅ | system/init, result/success | Unknown skill: mcp | **works_native** |
| `/status` | ✅ | ✅ | system/init, result/success | Unknown skill: status | **works_native** |
| `/clear` | ✅ | ✅ | system/init, result/success | Unknown skill: clear | **works_native** |
| `/compact` | ✅ | ✅ | system/status, rate_limit_event, system/init, system/compact_boundary, user, result/success |  | **unsupported** |
| `/doctor` | ✅ | ✅ | system/init, result/success | Unknown skill: doctor | **works_native** |
| `/permissions` | ✅ | ✅ | system/init, result/success | Unknown skill: permissions | **works_native** |
| `/cost` | ✅ | ✅ | system/init, assistant, result/success | You are currently using your subscription to power | **passthrough_to_model** |

## Verdict Key

- **works_native**: CLI intercepts the command and returns structured output (no model call)
- **passthrough_to_model**: CLI sends it to the model as a regular prompt (model responds)
- **silent_exit**: CLI handles it internally but produces no result event in stream-json
- **unsupported**: Command not recognized or errors out

## Detailed Results

### `/help`
- Verdict: **works_native**
- Exit code: 0
- Events: system/init → result/success
- Is error: false
- Result text:
```
Unknown skill: help
```

### `/model`
- Verdict: **works_native**
- Exit code: 0
- Events: system/init → result/success
- Is error: false
- Result text:
```
Unknown skill: model
```

### `/mcp`
- Verdict: **works_native**
- Exit code: 0
- Events: system/init → result/success
- Is error: false
- Result text:
```
Unknown skill: mcp
```

### `/status`
- Verdict: **works_native**
- Exit code: 0
- Events: system/init → result/success
- Is error: false
- Result text:
```
Unknown skill: status
```

### `/clear`
- Verdict: **works_native**
- Exit code: 0
- Events: system/init → result/success
- Is error: false
- Result text:
```
Unknown skill: clear
```

### `/compact`
- Verdict: **unsupported**
- Exit code: 0
- Events: system/status → rate_limit_event → system/status → system/init → system/compact_boundary → user → user → result/success
- Is error: false
- Result text:
```
(empty)
```

### `/doctor`
- Verdict: **works_native**
- Exit code: 0
- Events: system/init → result/success
- Is error: false
- Result text:
```
Unknown skill: doctor
```

### `/permissions`
- Verdict: **works_native**
- Exit code: 0
- Events: system/init → result/success
- Is error: false
- Result text:
```
Unknown skill: permissions
```

### `/cost`
- Verdict: **passthrough_to_model**
- Exit code: 0
- Events: system/init → assistant → result/success
- Is error: false
- Result text:
```
You are currently using your subscription to power your Claude Code usage
```


================================================
FILE: electron.vite.config.ts
================================================
import { resolve } from 'path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  main: {
    plugins: [externalizeDepsPlugin()],
    build: {
      outDir: 'dist/main',
      rollupOptions: {
        input: {
          index: resolve(__dirname, 'src/main/index.ts')
        }
      }
    }
  },
  preload: {
    plugins: [externalizeDepsPlugin()],
    build: {
      outDir: 'dist/preload',
      rollupOptions: {
        input: {
          index: resolve(__dirname, 'src/preload/index.ts')
        }
      }
    }
  },
  renderer: {
    root: resolve(__dirname, 'src/renderer'),
    plugins: [react(), tailwindcss()],
    build: {
      outDir: resolve(__dirname, 'dist/renderer'),
      rollupOptions: {
        input: {
          index: resolve(__dirname, 'src/renderer/index.html')
        }
      }
    }
  }
})


================================================
FILE: install-app.command
================================================
#!/bin/bash
cd "$(dirname "$0")"
exec bash ./commands/install-app.command "$@"


================================================
FILE: package.json
================================================
{
  "name": "clui",
  "version": "0.1.0",
  "description": "Clui CC — Command Line User Interface for Claude Code",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "https://github.com/lcoutodemos/clui-cc.git"
  },
  "bugs": {
    "url": "https://github.com/lcoutodemos/clui-cc/issues"
  },
  "homepage": "https://github.com/lcoutodemos/clui-cc#readme",
  "main": "dist/main/index.js",
  "scripts": {
    "dev": "electron-vite dev",
    "build": "electron-vite build",
    "preview": "electron-vite preview",
    "dist": "electron-vite build --mode production && electron-builder --mac --dir",
    "doctor": "bash scripts/doctor.sh",
    "postinstall": "electron-builder install-app-deps && bash scripts/patch-dev-icon.sh"
  },
  "dependencies": {
    "@phosphor-icons/react": "^2.1.10",
    "framer-motion": "^12.35.1",
    "node-pty": "^1.1.0",
    "react-markdown": "^9.0.0",
    "remark-gfm": "^4.0.0",
    "zustand": "^5.0.0"
  },
  "build": {
    "appId": "com.clui.app",
    "productName": "Clui CC",
    "directories": {
      "output": "release"
    },
    "files": [
      "dist/main/**/*",
      "dist/preload/**/*",
      "dist/renderer/**/*",
      "resources/**/*",
      "package.json"
    ],
    "mac": {
      "icon": "resources/icon.icns",
      "entitlements": "resources/entitlements.mac.plist",
      "entitlementsInherit": "resources/entitlements.mac.plist",
      "extendInfo": {
        "NSMicrophoneUsageDescription": "Clui CC uses your microphone to transcribe voice input locally with Whisper."
      }
    }
  },
  "devDependencies": {
    "@tailwindcss/vite": "^4.2.1",
    "@types/react": "^19.0.0",
    "@types/react-dom": "^19.0.0",
    "@vitejs/plugin-react": "^4.3.0",
    "autoprefixer": "^10.4.0",
    "electron": "^35.7.5",
    "electron-builder": "^26.8.1",
    "electron-vite": "^3.0.0",
    "postcss": "^8.4.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "tailwindcss": "^4.2.1",
    "typescript": "^5.7.0",
    "vite": "^6.0.0"
  }
}


================================================
FILE: resources/entitlements.mac.plist
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <!-- Required for V8 JIT compilation (Electron / Node.js) -->
  <key>com.apple.security.cs.allow-jit</key>
  <true/>
  <!-- Required for Electron's renderer process memory model -->
  <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
  <true/>
  <!-- Required to load native Node addons (node-pty, etc.) -->
  <key>com.apple.security.cs.disable-library-validation</key>
  <true/>
  <!-- Required for microphone access (voice input via Whisper) -->
  <key>com.apple.security.device.audio-input</key>
  <true/>
</dict>
</plist>


================================================
FILE: scripts/doctor.sh
================================================
#!/bin/bash
# Clui CC environment doctor — read-only diagnostics, no installs.

echo "Clui CC Environment Check"
echo "========================="
echo

fail=0
SDK_PATH=""

# Compare two dotted versions: returns 0 if $1 >= $2
version_gte() {
  [ "$(printf '%s\n%s' "$1" "$2" | sort -V | head -1)" = "$2" ]
}

check() {
  local label="$1"
  local ok="$2"
  local detail="$3"
  if [ "$ok" = "1" ]; then
    printf "  PASS  %s — %s\n" "$label" "$detail"
  else
    printf "  FAIL  %s — %s\n" "$label" "$detail"
    fail=1
  fi
}

# macOS
if [ "$(uname)" = "Darwin" ]; then
  ver=$(sw_vers -productVersion 2>/dev/null || echo "0")
  if version_gte "$ver" "13.0"; then
    check "macOS" "1" "$ver"
  else
    check "macOS" "0" "$ver — requires 13+"
  fi
else
  check "macOS" "0" "not macOS ($(uname)) — Clui CC requires macOS"
fi

# Node
if command -v node &>/dev/null; then
  node_ver=$(node --version | sed 's/^v//')
  if version_gte "$node_ver" "18.0.0"; then
    check "Node.js" "1" "v$node_ver"
  else
    check "Node.js" "0" "v$node_ver — requires 18+ — brew install node"
  fi
else
  check "Node.js" "0" "not found — brew install node"
fi

# npm
if command -v npm &>/dev/null; then
  check "npm" "1" "$(npm --version)"
else
  check "npm" "0" "not found — brew install node"
fi

# Python
if command -v python3 &>/dev/null; then
  pyver=$(python3 --version 2>&1 | awk '{print $2}')
  check "Python 3" "1" "$pyver"
else
  check "Python 3" "0" "not found — brew install python@3.11"
fi

# distutils
if command -v python3 &>/dev/null; then
  if python3 -c "import distutils" 2>/dev/null; then
    check "distutils" "1" "importable"
  else
    check "distutils" "0" "missing — python3 -m pip install --upgrade pip setuptools"
  fi
else
  check "distutils" "0" "skipped (no python3)"
fi

# Xcode CLT
if xcode-select -p &>/dev/null; then
  check "Xcode CLT" "1" "$(xcode-select -p)"
else
  check "Xcode CLT" "0" "not installed — xcode-select --install"
fi

# macOS SDK
if xcrun --sdk macosx --show-sdk-path &>/dev/null; then
  SDK_PATH=$(xcrun --sdk macosx --show-sdk-path)
  check "macOS SDK" "1" "$SDK_PATH"
else
  check "macOS SDK" "0" "not found — reinstall Xcode CLT"
fi

# clang++
if command -v clang++ &>/dev/null; then
  cver=$(clang++ --version 2>&1 | head -1)
  check "clang++" "1" "$cver"

  # C++ headers (only probe if clang++ exists)
  PROBE_DIR=$(mktemp -d)
  echo '#include <functional>' > "$PROBE_DIR/probe.cpp"
  echo 'int main() { return 0; }' >> "$PROBE_DIR/probe.cpp"
  if clang++ -std=c++17 -c "$PROBE_DIR/probe.cpp" -o "$PROBE_DIR/probe.o" 2>/dev/null; then
    check "C++ headers" "1" "<functional> compiles"
  elif [ -n "$SDK_PATH" ] && clang++ -std=c++17 -isysroot "$SDK_PATH" -I"$SDK_PATH/usr/include/c++/v1" -c "$PROBE_DIR/probe.cpp" -o "$PROBE_DIR/probe.o" 2>/dev/null; then
    check "C++ headers" "1" "<functional> compiles (using SDK include path)"
  else
    check "C++ headers" "0" "<functional> missing — reinstall Xcode CLT"
  fi
  rm -rf "$PROBE_DIR"
else
  check "clang++" "0" "not found — xcode-select --install"
  check "C++ headers" "0" "skipped (no clang++)"
fi

# Claude CLI
if command -v claude &>/dev/null; then
  cver=$(claude --version 2>/dev/null || echo "unknown")
  check "Claude CLI" "1" "$cver"
else
  check "Claude CLI" "0" "not found — npm install -g @anthropic-ai/claude-code"
fi

echo
if [ "$fail" -ne 0 ]; then
  echo "Some checks failed. Fix them above, then rerun:"
  echo
  echo "  ./commands/setup.command"
else
  echo "Environment looks good."
fi


================================================
FILE: scripts/patch-dev-icon.sh
================================================
#!/usr/bin/env bash
set -euo pipefail

ELECTRON_APP="node_modules/electron/dist/Electron.app"
RESOURCES="$ELECTRON_APP/Contents/Resources"
ICON_SRC="resources/icon.icns"

# Only run on macOS
[[ "$(uname)" == "Darwin" ]] || exit 0

# Only run if source icon exists
[[ -f "$ICON_SRC" ]] || exit 0

# Replace the icon
cp "$ICON_SRC" "$RESOURCES/electron.icns"

# Touch the bundle to invalidate macOS icon cache
touch "$ELECTRON_APP"

# Re-sign with ad-hoc signature (required after modifying bundle contents)
codesign --force --deep --sign - "$ELECTRON_APP" 2>/dev/null || true


================================================
FILE: src/main/claude/control-plane.ts
================================================
import { EventEmitter } from 'events'
import { RunManager } from './run-manager'
import { PtyRunManager } from './pty-run-manager'
import { PermissionServer, maskSensitiveFields } from '../hooks/permission-server'
import type { HookToolRequest, PermissionOption } from '../hooks/permission-server'
import { log as _log } from '../logger'
import type {
  TabStatus,
  TabRegistryEntry,
  HealthReport,
  NormalizedEvent,
  RunOptions,
  EnrichedError,
} from '../../shared/types'

const MAX_QUEUE_DEPTH = 32

function log(msg: string): void {
  _log('ControlPlane', msg)
}

interface QueuedRequest {
  requestId: string
  tabId: string
  options: RunOptions
  resolve: (value: void) => void
  reject: (reason: Error) => void
  enqueuedAt: number
  /** Additional waiters that called submitPrompt with the same requestId */
  extraWaiters: Array<{ resolve: (value: void) => void; reject: (reason: Error) => void }>
}

interface InflightRequest {
  requestId: string
  tabId: string
  promise: Promise<void>
  resolve: (value: void) => void
  reject: (reason: Error) => void
}

/**
 * ControlPlane: the single backend authority for tab/session lifecycle.
 *
 * Responsibilities:
 *  1. Tab/session registry
 *  2. Request queue + backpressure
 *  3. RequestId idempotency
 *  4. Target session guard
 *  5. Run lifecycle state transitions
 *  6. Health reporting for renderer reconciliation
 *  7. Diagnostic data (delegated to RunManager ring buffers)
 *
 * Events emitted (forwarded from RunManager, tagged with tabId):
 *  - 'event' (tabId, NormalizedEvent)
 *  - 'tab-status-change' (tabId, newStatus, oldStatus)
 *  - 'error' (tabId, EnrichedError)
 */
export class ControlPlane extends EventEmitter {
  private tabs = new Map<string, TabRegistryEntry>()
  private inflightRequests = new Map<string, InflightRequest>()
  private requestQueue: QueuedRequest[] = []
  private runManager: RunManager
  private ptyRunManager: PtyRunManager
  /** Feature flag: use PTY transport for interactive permissions */
  private interactivePty: boolean
  /** Tracks which runs are using PTY transport (by requestId) */
  private ptyRuns = new Set<string>()
  /** Tracks requestIds that are warmup init requests (invisible to renderer) */
  private initRequestIds = new Set<string>()
  /** Permission hook server for PreToolUse HTTP hooks */
  private permissionServer: PermissionServer
  /** Per-run tokens: requestId → runToken (for cleanup on exit/error) */
  private runTokens = new Map<string, string>()
  /** Global permission mode: 'ask' shows cards, 'auto' auto-approves */
  private permissionMode: 'ask' | 'auto' = 'ask'
  /** Resolves when the permission server is ready (or failed). Dispatch awaits this. */
  private hookServerReady: Promise<void>

  constructor(interactivePty = false) {
    super()
    this.interactivePty = interactivePty
    this.runManager = new RunManager()
    this.ptyRunManager = new PtyRunManager()
    this.permissionServer = new PermissionServer()

    // Start the permission hook server. _dispatch awaits hookServerReady
    // so early prompts don't silently fall back to the --allowedTools path.
    this.hookServerReady = this.permissionServer.start()
      .then((port) => {
        log(`Permission hook server ready on port ${port}`)
      })
      .catch((err) => {
        log(`Failed to start permission hook server: ${(err as Error).message}`)
        // No hook server → dispatch falls back to --allowedTools
      })

    // Wire permission server events → normalized events for renderer.
    // 4-arg signature: (questionId, toolRequest, tabId, options)
    // tabId comes directly from per-run token registration — no session_id lookup needed.
    this.permissionServer.on('permission-request', (questionId: string, toolRequest: HookToolRequest, tabId: string, options: PermissionOption[]) => {
      // Verify tab still exists — deny immediately if closed (prevents 5-min timeout hang)
      if (!this.tabs.has(tabId)) {
        log(`Permission request for closed tab ${tabId.substring(0, 8)}… — auto-denying`)
        this.permissionServer.respondToPermission(questionId, 'deny', 'Tab closed')
        return
      }

      log(`Permission request [${questionId}]: tool=${toolRequest.tool_name} tab=${tabId.substring(0, 8)}… mode=${this.permissionMode}`)

      // Auto mode: immediately allow without showing UI
      if (this.permissionMode === 'auto') {
        this.permissionServer.respondToPermission(questionId, 'allow', 'Auto mode')
        return
      }

      // Mask sensitive fields before sending to renderer (defense-in-depth)
      const safeInput = toolRequest.tool_input
        ? maskSensitiveFields(toolRequest.tool_input)
        : undefined

      const permEvent: NormalizedEvent = {
        type: 'permission_request',
        questionId,
        toolName: toolRequest.tool_name,
        toolDescription: undefined,
        toolInput: safeInput,
        options,
      }
      this.emit('event', tabId, permEvent)
    })

    log(`Interactive PTY transport: ${interactivePty ? 'ENABLED' : 'disabled'}`)

    // ─── Wire PtyRunManager events → ControlPlane routing ───
    this._wirePtyEvents()

    // ─── Wire RunManager events → ControlPlane routing ───

    this.runManager.on('normalized', (requestId: string, event: NormalizedEvent) => {
      const tabId = this._findTabByRequest(requestId)
      if (!tabId) return

      const tab = this.tabs.get(tabId)
      if (!tab) return

      tab.lastActivityAt = Date.now()

      // Handle session init
      if (event.type === 'session_init') {
        tab.claudeSessionId = event.sessionId

        if (this.initRequestIds.has(requestId)) {
          // Warmup init — emit session_init with isWarmup flag, don't change status
          this.emit('event', tabId, { ...event, isWarmup: true })
          return
        }

        if (tab.status === 'connecting') {
          this._setTabStatus(tabId, 'running')
        }
      }

      // Suppress all events from init requests (session_init already handled above)
      if (this.initRequestIds.has(requestId)) {
        return
      }

      this.emit('event', tabId, event)
    })

    this.runManager.on('exit', (requestId: string, code: number | null, signal: string | null, sessionId: string | null) => {
      // Clean up per-run token
      const runToken = this.runTokens.get(requestId)
      if (runToken) {
        this.permissionServer.unregisterRun(runToken)
        this.runTokens.delete(requestId)
      }

      const tabId = this._findTabByRequest(requestId)

      // Always clean up inflight promise, even if tab was already closed.
      // This prevents leaked promises when closeTab() races with process exit.
      const inflight = this.inflightRequests.get(requestId)

      if (!tabId || !this.tabs.get(tabId)) {
        // Tab was already closed — just resolve/reject the orphaned promise
        if (inflight) {
          inflight.resolve()
          this.inflightRequests.delete(requestId)
        }
        return
      }

      const tab = this.tabs.get(tabId)!

      tab.activeRequestId = null
      tab.runPid = null

      if (sessionId) tab.claudeSessionId = sessionId

      // Init request: silently transition to idle
      if (this.initRequestIds.has(requestId)) {
        this.initRequestIds.delete(requestId)
        this._setTabStatus(tabId, 'idle')
        if (inflight) {
          inflight.resolve()
          this.inflightRequests.delete(requestId)
        }
        this._processQueue(tabId)
        return
      }

      if (code === 0) {
        this._setTabStatus(tabId, 'completed')
      } else if (signal === 'SIGINT' || signal === 'SIGKILL') {
        // Cancelled by user
        this._setTabStatus(tabId, 'failed')
      } else {
        // Unexpected exit — emit enriched error (includes stderr tail)
        const enriched = this.runManager.getEnrichedError(requestId, code)
        this.emit('error', tabId, enriched)
        this._setTabStatus(tabId, code === null ? 'dead' : 'failed')
      }

      // Resolve the inflight promise
      if (inflight) {
        inflight.resolve()
        this.inflightRequests.delete(requestId)
      }

      // Process next queued request for this tab
      this._processQueue(tabId)
    })

    this.runManager.on('error', (requestId: string, err: Error) => {
      // Clean up per-run token
      const runToken = this.runTokens.get(requestId)
      if (runToken) {
        this.permissionServer.unregisterRun(runToken)
        this.runTokens.delete(requestId)
      }

      const tabId = this._findTabByRequest(requestId)

      // Always clean up inflight even if tab is gone
      const inflight = this.inflightRequests.get(requestId)

      if (!tabId || !this.tabs.get(tabId)) {
        if (inflight) {
          inflight.reject(err)
          this.inflightRequests.delete(requestId)
        }
        return
      }

      const tab = this.tabs.get(tabId)!
      tab.activeRequestId = null
      tab.runPid = null

      // Init request: silently fail, go idle so user can still use the tab
      if (this.initRequestIds.has(requestId)) {
        this.initRequestIds.delete(requestId)
        log(`Init session error for tab ${tabId}: ${err.message}`)
        this._setTabStatus(tabId, 'idle')
        if (inflight) {
          inflight.reject(err)
          this.inflightRequests.delete(requestId)
        }
        this._processQueue(tabId)
        return
      }

      this._setTabStatus(tabId, 'dead')

      // Use enriched diagnostics — _finishedRuns holds the handle with
      // stderr/stdout ring buffers even after the process errored out.
      const enriched = this.runManager.getEnrichedError(requestId, null)
      enriched.message = err.message
      this.emit('error', tabId, enriched)

      if (inflight) {
        inflight.reject(err)
        this.inflightRequests.delete(requestId)
      }
    })
  }

  /**
   * Wire PtyRunManager events using the same routing logic as RunManager.
   */
  private _wirePtyEvents(): void {
    // Normalized events → same routing as RunManager
    this.ptyRunManager.on('normalized', (requestId: string, event: NormalizedEvent) => {
      const tabId = this._findTabByRequest(requestId)
      if (!tabId) return

      const tab = this.tabs.get(tabId)
      if (!tab) return

      tab.lastActivityAt = Date.now()

      // Handle session init
      if (event.type === 'session_init') {
        tab.claudeSessionId = event.sessionId

        if (this.initRequestIds.has(requestId)) {
          this.emit('event', tabId, { ...event, isWarmup: true })
          return
        }

        if (tab.status === 'connecting') {
          this._setTabStatus(tabId, 'running')
        }
      }

      // Suppress events from init requests
      if (this.initRequestIds.has(requestId)) return

      this.emit('event', tabId, event)
    })

    // Exit events
    this.ptyRunManager.on('exit', (requestId: string, code: number | null, signal: number | null, sessionId: string | null) => {
      // Clean up per-run token
      const runToken = this.runTokens.get(requestId)
      if (runToken) {
        this.permissionServer.unregisterRun(runToken)
        this.runTokens.delete(requestId)
      }

      const tabId = this._findTabByRequest(requestId)
      const inflight = this.inflightRequests.get(requestId)

      // Clean up PTY run tracking
      this.ptyRuns.delete(requestId)

      if (!tabId || !this.tabs.get(tabId)) {
        if (inflight) {
          inflight.resolve()
          this.inflightRequests.delete(requestId)
        }
        return
      }

      const tab = this.tabs.get(tabId)!
      tab.activeRequestId = null
      tab.runPid = null
      if (sessionId) tab.claudeSessionId = sessionId

      if (this.initRequestIds.has(requestId)) {
        this.initRequestIds.delete(requestId)
        this._setTabStatus(tabId, 'idle')
        if (inflight) {
          inflight.resolve()
          this.inflightRequests.delete(requestId)
        }
        this._processQueue(tabId)
        return
      }

      if (code === 0) {
        this._setTabStatus(tabId, 'completed')
      } else if (signal) {
        this._setTabStatus(tabId, 'failed')
      } else {
        const enriched = this.ptyRunManager.getEnrichedError(requestId, code)
        this.emit('error', tabId, enriched)
        this._setTabStatus(tabId, code === null ? 'dead' : 'failed')
      }

      if (inflight) {
        inflight.resolve()
        this.inflightRequests.delete(requestId)
      }

      this._processQueue(tabId)
    })

    // Error events
    this.ptyRunManager.on('error', (requestId: string, err: Error) => {
      // Clean up per-run token
      const runToken = this.runTokens.get(requestId)
      if (runToken) {
        this.permissionServer.unregisterRun(runToken)
        this.runTokens.delete(requestId)
      }

      const tabId = this._findTabByRequest(requestId)
      const inflight = this.inflightRequests.get(requestId)

      this.ptyRuns.delete(requestId)

      if (!tabId || !this.tabs.get(tabId)) {
        if (inflight) {
          inflight.reject(err)
          this.inflightRequests.delete(requestId)
        }
        return
      }

      const tab = this.tabs.get(tabId)!
      tab.activeRequestId = null
      tab.runPid = null

      if (this.initRequestIds.has(requestId)) {
        this.initRequestIds.delete(requestId)
        log(`PTY init session error for tab ${tabId}: ${err.message}`)
        this._setTabStatus(tabId, 'idle')
        if (inflight) {
          inflight.reject(err)
          this.inflightRequests.delete(requestId)
        }
        this._processQueue(tabId)
        return
      }

      this._setTabStatus(tabId, 'dead')

      const enriched = this.ptyRunManager.getEnrichedError(requestId, null)
      enriched.message = err.message
      this.emit('error', tabId, enriched)

      if (inflight) {
        inflight.reject(err)
        this.inflightRequests.delete(requestId)
      }
    })
  }

  // ─── Tab Lifecycle ───

  createTab(): string {
    const tabId = crypto.randomUUID()
    const entry: TabRegistryEntry = {
      tabId,
      claudeSessionId: null,
      status: 'idle',
      activeRequestId: null,
      runPid: null,
      createdAt: Date.now(),
      lastActivityAt: Date.now(),
      promptCount: 0,
    }
    this.tabs.set(tabId, entry)
    log(`Tab created: ${tabId}`)
    return tabId
  }

  /**
   * Eagerly initialize a session for a tab by running a minimal prompt.
   * Populates session metadata (model, MCP servers, tools) without visible messages.
   */
  initSession(tabId: string): void {
    const tab = this.tabs.get(tabId)
    if (!tab) return

    const requestId = `init-${tabId}`
    this.initRequestIds.add(requestId)

    this.submitPrompt(tabId, requestId, {
      prompt: 'hi',
      projectPath: process.cwd(),
      maxTurns: 1,
    }).catch((err) => {
      this.initRequestIds.delete(requestId)
      log(`Init session failed for tab ${tabId}: ${(err as Error).message}`)
    })
  }

  /**
   * Clear stored session ID for a tab — used when working directory changes
   * so _dispatch won't inject a stale --resume from the old directory.
   */
  resetTabSession(tabId: string): void {
    const tab = this.tabs.get(tabId)
    if (!tab) return
    log(`Resetting session for tab ${tabId} (was: ${tab.claudeSessionId})`)
    tab.claudeSessionId = null
  }

  /**
   * Set global permission mode.
   * 'ask' = show permission cards, 'auto' = auto-approve all tool calls.
   */
  setPermissionMode(mode: 'ask' | 'auto'): void {
    log(`Permission mode set to: ${mode}`)
    this.permissionMode = mode
  }

  closeTab(tabId: string): void {
    const tab = this.tabs.get(tabId)
    if (!tab) return

    // Cancel active run if any
    if (tab.activeRequestId) {
      this.cancel(tab.activeRequestId)

      // Resolve and clean up the inflight promise so it doesn't leak.
      // The exit handler may never fire for this tab since we're deleting it.
      const inflight = this.inflightRequests.get(tab.activeRequestId)
      if (inflight) {
        inflight.reject(new Error('Tab closed'))
        this.inflightRequests.delete(tab.activeRequestId)
      }
    }

    // Remove queued requests for this tab, rejecting all waiters
    this.requestQueue = this.requestQueue.filter((r) => {
      if (r.tabId === tabId) {
        const reason = new Error('Tab closed')
        r.reject(reason)
        for (const w of r.extraWaiters) w.reject(reason)
        return false
      }
      return true
    })

    this.tabs.delete(tabId)
    log(`Tab closed: ${tabId}`)
  }

  // ─── Submit Prompt ───

  /**
   * Submit a prompt to a specific tab. Returns a promise that resolves
   * when the run completes.
   *
   * Guards:
   *  - Rejects without targetSession (tabId)
   *  - Returns existing promise for duplicate requestId (idempotency)
   *  - Queues if tab is busy, rejects if queue is full
   */
  async submitPrompt(
    tabId: string,
    requestId: string,
    options: RunOptions,
  ): Promise<void> {
    // ─── Guard: target session required ───
    if (!tabId) {
      throw new Error('No targetSession (tabId) provided — rejecting to prevent misrouting')
    }

    const tab = this.tabs.get(tabId)
    if (!tab) {
      throw new Error(`Tab ${tabId} does not exist`)
    }

    // ─── Guard: requestId idempotency (check inflight AND queue) ───
    const existing = this.inflightRequests.get(requestId)
    if (existing) {
      log(`Duplicate requestId ${requestId} — returning existing inflight promise`)
      return existing.promise
    }

    const queued = this.requestQueue.find((r) => r.requestId === requestId)
    if (queued) {
      log(`Duplicate requestId ${requestId} — already queued, adding waiter`)
      return new Promise<void>((resolve, reject) => {
        queued.extraWaiters.push({ resolve, reject })
      })
    }

    // ─── If tab has an active run, queue the request ───
    if (tab.activeRequestId) {
      if (this.requestQueue.length >= MAX_QUEUE_DEPTH) {
        throw new Error('Request queue full — back-pressure')
      }

      log(`Tab ${tabId} busy — queuing request ${requestId} (queue depth: ${this.requestQueue.length + 1})`)
      return new Promise<void>((resolve, reject) => {
        this.requestQueue.push({
          requestId,
          tabId,
          options,
          resolve,
          reject,
          enqueuedAt: Date.now(),
          extraWaiters: [],
        })
      })
    }

    // ─── Dispatch immediately ───
    return this._dispatch(tabId, requestId, options)
  }

  private async _dispatch(tabId: string, requestId: string, options: RunOptions): Promise<void> {
    const tab = this.tabs.get(tabId)
    if (!tab) throw new Error(`Tab ${tabId} disappeared`)

    // Wait for the permission hook server to be ready (or failed).
    // This prevents early prompts from silently falling back to --allowedTools.
    await this.hookServerReady

    // Use stored session ID for resume if available and not overridden
    if (tab.claudeSessionId && !options.sessionId) {
      options = { ...options, sessionId: tab.claudeSessionId }
    }

    // Per-run token lifecycle: register run, generate per-run settings file
    if (this.permissionServer.getPort()) {
      const runToken = this.permissionServer.registerRun(tabId, requestId, options.sessionId || null)
      this.runTokens.set(requestId, runToken)
      const hookSettingsPath = this.permissionServer.generateSettingsFile(runToken)
      options = { ...options, hookSettingsPath }
    }

    tab.activeRequestId = requestId
    if (!this.initRequestIds.has(requestId)) tab.promptCount++
    tab.lastActivityAt = Date.now()

    // Set status to connecting (first run) or running (subsequent)
    const newStatus: TabStatus = tab.claudeSessionId ? 'running' : 'connecting'
    this._setTabStatus(tabId, newStatus)

    // ─── Pick transport ───
    // Stream-json is the stable transport for all regular messages.
    // PTY is reserved for future interactive permission handling only.
    const usePty = false

    let pid: number | null = null
    try {
      if (usePty) {
        log(`Dispatching via PTY transport: ${requestId}`)
        const handle = this.ptyRunManager.startRun(requestId, options)
        this.ptyRuns.add(requestId)
        pid = handle.pid
      } else {
        const handle = this.runManager.startRun(requestId, options)
        pid = handle.pid
      }
      tab.runPid = pid
    } catch (err) {
      // Start failure before inflight registration: rollback tab run state.
      tab.activeRequestId = null
      tab.runPid = null
      this._setTabStatus(tabId, 'failed')
      throw err
    }

    // Create inflight promise
    let resolve!: (value: void) => void
    let reject!: (reason: Error) => void
    const promise = new Promise<void>((res, rej) => {
      resolve = res
      reject = rej
    })

    this.inflightRequests.set(requestId, { requestId, tabId, promise, resolve, reject })
    return promise
  }

  // ─── Cancel ───

  cancel(requestId: string): boolean {
    // Check if it's in the queue first
    const queueIdx = this.requestQueue.findIndex((r) => r.requestId === requestId)
    if (queueIdx !== -1) {
      const req = this.requestQueue.splice(queueIdx, 1)[0]
      const reason = new Error('Request cancelled')
      req.reject(reason)
      for (const w of req.extraWaiters) w.reject(reason)
      log(`Cancelled queued request ${requestId}`)
      return true
    }

    // Cancel active run — route to correct transport
    if (this.ptyRuns.has(requestId)) {
      return this.ptyRunManager.cancel(requestId)
    }
    return this.runManager.cancel(requestId)
  }

  /**
   * Cancel active run on a tab (by tabId instead of requestId).
   */
  cancelTab(tabId: string): boolean {
    const tab = this.tabs.get(tabId)
    if (!tab?.activeRequestId) return false
    return this.cancel(tab.activeRequestId)
  }

  // ─── Retry ───

  /**
   * Retry: re-submit the same prompt on the same tab/session.
   * If the tab is dead, creates a fresh session.
   */
  async retry(tabId: string, requestId: string, options: RunOptions): Promise<void> {
    const tab = this.tabs.get(tabId)
    if (!tab) throw new Error(`Tab ${tabId} does not exist`)

    // If dead, clear session so a new one starts
    if (tab.status === 'dead') {
      tab.claudeSessionId = null
      this._setTabStatus(tabId, 'idle')
    }

    return this.submitPrompt(tabId, requestId, options)
  }

  // ─── Permission Response ───

  respondToPermission(tabId: string, questionId: string, optionId: string): boolean {
    // Route to hook server if this is a hook-based permission request.
    // Pass optionId directly — it matches the permission card option IDs
    // (allow, allow-session, allow-domain, deny).
    if (questionId.startsWith('hook-')) {
      return this.permissionServer.respondToPermission(questionId, optionId)
    }

    const tab = this.tabs.get(tabId)
    if (!tab?.activeRequestId) return false

    // Route to correct transport
    if (this.ptyRuns.has(tab.activeRequestId)) {
      return this.ptyRunManager.respondToPermission(tab.activeRequestId, questionId, optionId)
    }

    // Print-json transport: send structured permission response via stdin
    const msg = {
      type: 'permission_response',
      question_id: questionId,
      option_id: optionId,
    }

    return this.runManager.writeToStdin(tab.activeRequestId, msg)
  }

  // ─── Health ───

  getHealth(): HealthReport {
    const tabEntries: HealthReport['tabs'] = []

    for (const [tabId, tab] of this.tabs) {
      let alive = false
      if (tab.activeRequestId) {
        alive = this.runManager.isRunning(tab.activeRequestId)
          || this.ptyRunManager.isRunning(tab.activeRequestId)
      }

      tabEntries.push({
        tabId,
        status: tab.status,
        activeRequestId: tab.activeRequestId,
        claudeSessionId: tab.claudeSessionId,
        alive,
      })
    }

    return {
      tabs: tabEntries,
      queueDepth: this.requestQueue.length,
    }
  }

  getTabStatus(tabId: string): TabRegistryEntry | undefined {
    return this.tabs.get(tabId)
  }

  getEnrichedError(requestId: string, exitCode: number | null): EnrichedError {
    if (this.ptyRuns.has(requestId)) {
      return this.ptyRunManager.getEnrichedError(requestId, exitCode)
    }
    return this.runManager.getEnrichedError(requestId, exitCode)
  }

  // ─── Queue Processing ───

  private _processQueue(tabId: string): void {
    // Find next queued request for this specific tab
    const idx = this.requestQueue.findIndex((r) => r.tabId === tabId)
    if (idx === -1) return

    const req = this.requestQueue.splice(idx, 1)[0]
    log(`Processing queued request ${req.requestId} for tab ${tabId}`)

    this._dispatch(tabId, req.requestId, req.options)
      .then((v) => {
        req.resolve(v)
        for (const w of req.extraWaiters) w.resolve(v)
      })
      .catch((e) => {
        req.reject(e)
        for (const w of req.extraWaiters) w.reject(e)
      })
  }

  // ─── Internal ───

  private _findTabByRequest(requestId: string): string | null {
    const inflight = this.inflightRequests.get(requestId)
    if (inflight) return inflight.tabId

    // Also check registry entries
    for (const [tabId, tab] of this.tabs) {
      if (tab.activeRequestId === requestId) return tabId
    }

    return null
  }

  private _setTabStatus(tabId: string, newStatus: TabStatus): void {
    const tab = this.tabs.get(tabId)
    if (!tab) return

    const oldStatus = tab.status
    if (oldStatus === newStatus) return

    tab.status = newStatus
    log(`Tab ${tabId}: ${oldStatus} → ${newStatus}`)
    this.emit('tab-status-change', tabId, newStatus, oldStatus)
  }

  // ─── Shutdown ───

  shutdown(): void {
    log('Shutting down control plane')
    this.permissionServer.stop()
    for (const [tabId] of this.tabs) {
      this.closeTab(tabId)
    }
  }
}


================================================
FILE: src/main/claude/event-normalizer.ts
================================================
import type {
  ClaudeEvent,
  NormalizedEvent,
  StreamEvent,
  InitEvent,
  AssistantEvent,
  ResultEvent,
  RateLimitEvent,
  PermissionEvent,
  ContentDelta,
} from '../../shared/types'

/**
 * Maps raw Claude stream-json events to canonical CLUI events.
 *
 * The normalizer is stateless — it takes one raw event and returns
 * zero or more normalized events. The caller (RunManager) is responsible
 * for sequencing and routing.
 */
export function normalize(raw: ClaudeEvent): NormalizedEvent[] {
  switch (raw.type) {
    case 'system':
      return normalizeSystem(raw as InitEvent)

    case 'stream_event':
      return normalizeStreamEvent(raw as StreamEvent)

    case 'assistant':
      return normalizeAssistant(raw as AssistantEvent)

    case 'result':
      return normalizeResult(raw as ResultEvent)

    case 'rate_limit_event':
      return normalizeRateLimit(raw as RateLimitEvent)

    case 'permission_request':
      return normalizePermission(raw as PermissionEvent)

    default:
      // Unknown event type — skip silently (defensive)
      return []
  }
}

function normalizeSystem(event: InitEvent): NormalizedEvent[] {
  if (event.subtype !== 'init') return []

  return [{
    type: 'session_init',
    sessionId: event.session_id,
    tools: event.tools || [],
    model: event.model || 'unknown',
    mcpServers: event.mcp_servers || [],
    skills: event.skills || [],
    version: event.claude_code_version || 'unknown',
  }]
}

function normalizeStreamEvent(event: StreamEvent): NormalizedEvent[] {
  const sub = event.event
  if (!sub) return []

  switch (sub.type) {
    case 'content_block_start': {
      if (sub.content_block.type === 'tool_use') {
        return [{
          type: 'tool_call',
          toolName: sub.content_block.name || 'unknown',
          toolId: sub.content_block.id || '',
          index: sub.index,
        }]
      }
      // text block start — no event needed, text comes via deltas
      return []
    }

    case 'content_block_delta': {
      const delta = sub.delta as ContentDelta
      if (delta.type === 'text_delta') {
        return [{ type: 'text_chunk', text: delta.text }]
      }
      if (delta.type === 'input_json_delta') {
        return [{
          type: 'tool_call_update',
          toolId: '', // caller can associate via index tracking
          partialInput: delta.partial_json,
        }]
      }
      return []
    }

    case 'content_block_stop': {
      return [{
        type: 'tool_call_complete',
        index: sub.index,
      }]
    }

    case 'message_start':
    case 'message_delta':
    case 'message_stop':
      // These are structural events — the assembled `assistant` event handles message completion
      return []

    default:
      return []
  }
}

function normalizeAssistant(event: AssistantEvent): NormalizedEvent[] {
  return [{
    type: 'task_update',
    message: event.message,
  }]
}

function normalizeResult(event: ResultEvent): NormalizedEvent[] {
  if (event.is_error || event.subtype === 'error') {
    return [{
      type: 'error',
      message: event.result || 'Unknown error',
      isError: true,
      sessionId: event.session_id,
    }]
  }

  const denials = Array.isArray((event as any).permission_denials)
    ? (event as any).permission_denials.map((d: any) => ({
        toolName: d.tool_name || '',
        toolUseId: d.tool_use_id || '',
      }))
    : undefined

  return [{
    type: 'task_complete',
    result: event.result || '',
    costUsd: event.total_cost_usd || 0,
    durationMs: event.duration_ms || 0,
    numTurns: event.num_turns || 0,
    usage: event.usage || {},
    sessionId: event.session_id,
    ...(denials && denials.length > 0 ? { permissionDenials: denials } : {}),
  }]
}

function normalizeRateLimit(event: RateLimitEvent): NormalizedEvent[] {
  const info = event.rate_limit_info
  if (!info) return []

  return [{
    type: 'rate_limit',
    status: info.status,
    resetsAt: info.resetsAt,
    rateLimitType: info.rateLimitType,
  }]
}

function normalizePermission(event: PermissionEvent): NormalizedEvent[] {
  return [{
    type: 'permission_request',
    questionId: event.question_id,
    toolName: event.tool?.name || 'unknown',
    toolDescription: event.tool?.description,
    toolInput: event.tool?.input,
    options: (event.options || []).map((o) => ({
      id: o.id,
      label: o.label,
      kind: o.kind,
    })),
  }]
}


================================================
FILE: src/main/claude/pty-run-manager.ts
================================================
/**
 * PtyRunManager: Interactive PTY transport for Claude Code.
 *
 * Spawns `claude` (without -p) via node-pty to get the full interactive
 * terminal experience, including permission prompts. Parses the PTY output
 * to extract text, tool calls, and permission requests, then emits
 * normalized events identical to RunManager.
 *
 * This module is behind the `CLUI_INTERACTIVE_PERMISSIONS_PTY` feature flag.
 *
 * Known limitations:
 * - Parsing depends on Claude CLI's terminal output format (Ink-based)
 * - ANSI stripping may lose some formatting nuance
 * - Permission prompt detection uses heuristics, not a formal grammar
 * - If the CLI's UI changes significantly, the parser may break
 */

import { EventEmitter } from 'events'
import { homedir } from 'os'
import { join } from 'path'
import { execSync } from 'child_process'
import { appendFileSync, chmodSync, existsSync, statSync } from 'fs'
import type { NormalizedEvent, RunOptions, EnrichedError } from '../../shared/types'
import { getCliEnv } from '../cli-env'

// node-pty is a native module — require at runtime to avoid Vite bundling issues
// eslint-disable-next-line @typescript-eslint/no-var-requires
let pty: typeof import('node-pty')
try {
  pty = require('node-pty')
} catch (err) {
  // Will be set when first needed — fail at startRun() time, not import time
}

const LOG_FILE = join(homedir(), '.clui-debug.log')
const MAX_RING_LINES = 100
const PTY_BUFFER_SIZE = 50 // rolling window of cleaned lines for parser context
const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
const QUIESCENCE_MS = 2000

function log(msg: string): void {
  const line = `[${new Date().toISOString()}] [PtyRunManager] ${msg}\n`
  try { appendFileSync(LOG_FILE, line) } catch {}
}

// ─── ANSI Stripping ───

/**
 * Strip ANSI escape sequences (colors, cursor movement, clear line, etc.)
 */
function stripAnsi(str: string): string {
  // Covers CSI sequences including private modes like ?2004h
  return str.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
    .replace(/\x1b\][^\x07]*\x07/g, '')  // OSC sequences
    .replace(/\x1b[()][0-9A-Za-z]/g, '')  // character set selection
    .replace(/\x1b[#=>\[\]]/g, '')         // misc escapes
    .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '') // control chars except \n \r \t
}

// ─── Permission Prompt Detection ───

interface ParsedPermission {
  toolName: string
  rawPrompt: string
  options: Array<{ optionId: string; label: string; terminalValue: string }>
}

/**
 * Confidence-scored permission prompt detector.
 * Looks at a window of cleaned terminal lines and tries to identify
 * a Claude permission prompt.
 */
function detectPermissionPrompt(lines: string[]): ParsedPermission | null {
  const joined = lines.join('\n')

  // ─── Pattern 1: "Claude wants to use <ToolName>" or "Allow <ToolName>" ───
  // The interactive CLI typically shows something like:
  //   "Claude wants to use Bash"
  //   "Command: ls -la"
  //   "❯ Allow for this project  Allow once  Deny"

  let confidence = 0
  let toolName = ''
  let rawPrompt = ''

  // Check for tool permission keywords
  const toolMatch = joined.match(/(?:wants?\s+to\s+(?:use|run|execute)|Tool:\s*|tool_name:\s*)(\w+)/i)
  if (toolMatch) {
    toolName = toolMatch[1]
    confidence += 3
  }

  // Check for permission-specific keywords
  const permissionKeywords = [
    /\ballow\b/i,
    /\bdeny\b/i,
    /\breject\b/i,
    /\bpermission\b/i,
    /\bapprove\b/i,
  ]
  for (const kw of permissionKeywords) {
    if (kw.test(joined)) confidence++
  }

  // Check for option-like patterns (numbered or arrow-selected)
  const hasOptions = /(?:❯|›|>)\s*(?:Allow|Deny|Yes|No)/i.test(joined)
    || /\b(?:Allow\s+(?:once|always|for\s+(?:this\s+)?(?:project|session)))\b/i.test(joined)
  if (hasOptions) confidence += 2

  // Need at least 4 confidence to declare a permission prompt
  if (confidence < 4) return null

  // ─── Extract options ───
  const options: ParsedPermission['options'] = []

  // Try to find option labels. The interactive CLI typically shows:
  // ❯ Allow for this project  |  Allow once  |  Deny
  // Or vertically:
  // ❯ Allow for this project
  //   Allow once
  //   Deny

  // Pattern: Look for Allow/Deny variants
  const optionPatterns = [
    { pattern: /Allow\s+(?:for\s+(?:this\s+)?(?:project|session)|always)/i, label: 'Allow for this project', kind: 'allow' },
    { pattern: /Allow\s+once/i, label: 'Allow once', kind: 'allow' },
    { pattern: /\bAlways\s+allow\b/i, label: 'Always allow', kind: 'allow' },
    { pattern: /(?:^|\s)Allow(?:\s|$)/i, label: 'Allow', kind: 'allow' },
    { pattern: /\bDeny\b/i, label: 'Deny', kind: 'deny' },
    { pattern: /\bReject\b/i, label: 'Reject', kind: 'deny' },
  ]

  let optIdx = 0
  for (const op of optionPatterns) {
    if (op.pattern.test(joined)) {
      optIdx++
      options.push({
        optionId: `opt-${optIdx}`,
        label: op.label,
        // Terminal value: we'll use arrow key navigation + Enter
        // The position in the list determines how many down arrows to press
        terminalValue: String(optIdx),
      })
    }
  }

  // If we didn't find specific options but have high confidence,
  // add default Allow/Deny options
  if (options.length === 0 && confidence >= 4) {
    options.push(
      { optionId: 'opt-1', label: 'Allow', terminalValue: '1' },
      { optionId: 'opt-2', label: 'Deny', terminalValue: '2' },
    )
  }

  // Extract the raw prompt context (last 10 lines)
  rawPrompt = lines.slice(-10).join('\n')

  return { toolName: toolName || 'Unknown', rawPrompt, options }
}

/**
 * Try to extract a session ID from terminal output.
 * The interactive CLI may print session info at startup.
 */
function extractSessionId(text: string): string | null {
  // Pattern: "Session: <uuid>" or "session_id: <uuid>" or just a UUID in init context
  const match = text.match(/(?:session[_ ]?id|Session|Resuming session)[:\s]+([a-f0-9-]{36})/i)
  return match ? match[1] : null
}

/**
 * Detect if the CLI is showing its input prompt (ready for next message).
 * This indicates the current response is complete.
 *
 * The Ink-based CLI renders the prompt line as something like:
 *   "❯ "  or  "❯ ? for shortcuts"  or  "> "
 * After proper \r handling, the prompt should be a clean line.
 */
function isInputPrompt(line: string): boolean {
  const cleaned = line.trim()
  if (cleaned === '❯' || cleaned === '>' || cleaned === '$') return true
  // Match prompt with trailing hint text (e.g. "❯ ? for shortcuts")
  if (/^[❯>]\s*(?:\?\s*for\s*shortcuts)?$/.test(cleaned)) return true
  return false
}

function isUiChrome(line: string): boolean {
  const cleaned = line.trim()
  if (!cleaned) return true
  if (/^[╭│╰─┌└┃┏┗┐┘┤├┬┴┼]/.test(cleaned)) return true
  if (/^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏✢✳✶✻✽]/.test(cleaned)) return true
  if (/^\s*(?:Medium|Low|High)\s/.test(cleaned) && /model/i.test(cleaned)) return true
  if (/\/mcp|MCP server/i.test(cleaned)) return true
  if (/Claude\s*Code\s*v/i.test(cleaned) || /ClaudeCodev/i.test(cleaned)) return true
  if (/^[❯>$]\s*$/.test(cleaned)) return true
  if (/^\$[\d.]+\s+·/.test(cleaned)) return true
  if (/for\s*shortcuts/i.test(cleaned)) return true
  if (/zigzagging|thinking|processing|nebulizing|Boondoggling/i.test(cleaned)) return true
  if (/^esctointerrupt/i.test(cleaned)) return true
  // Prompt line with hint
  if (/^[❯>]\s*\?\s*for\s*shortcuts/i.test(cleaned)) return true
  // Status bar fragments: "Opus 4.6 · Claude Max" etc.
  if (/Opus\s*[\d.]+\s*·/i.test(cleaned)) return true
  if (/Claude\s*Max/i.test(cleaned)) return true
  // Settings issue / doctor notice
  if (/settings?\s*issue|\/doctor/i.test(cleaned)) return true
  // Horizontal rules (all dashes/box chars)
  if (/^[─━▪\-=]{4,}/.test(cleaned)) return true
  // Only box-drawing / decoration chars
  if (/^[▗▖▘▝▀▄▌▐█░▒▓■□▪▫●○◆◇◈]+$/.test(cleaned)) return true
  return false
}

/**
 * Detect if a line looks like a tool call header from the interactive CLI.
 * Example: "⏳ Bash ls -la" or "✓ Read file.ts"
 */
function parseToolCallLine(line: string): { toolName: string; input: string } | null {
  // Pattern: emoji/spinner + tool name + optional input
  const match = line.match(/(?:⏳|⏳|✓|✗|⚡|🔧|Running|Executing)\s+(\w+)\s*(.*)/i)
    || line.match(/(?:Tool|Using):\s*(\w+)\s*(.*)/i)
  if (match) {
    return { toolName: match[1], input: match[2].trim() }
  }
  return null
}

// ─── Run Handle ───

export interface PtyRunHandle {
  runId: string
  sessionId: string | null
  pty: import('node-pty').IPty
  pid: number
  startedAt: number
  /** Ring buffer of raw PTY output for diagnostics */
  rawOutputTail: string[]
  /** Ring buffer of stderr-like error lines */
  stderrTail: string[]
  /** Count of tool calls seen */
  toolCallCount: number
  /** Current pending permission prompt */
  pendingPermission: ParsedPermission | null
  /** Permission flow phase */
  permissionPhase: 'idle' | 'detecting' | 'waiting_user' | 'answered'
  /** Rolling window of cleaned lines for parser context */
  ptyBuffer: string[]
  /** Timer for permission timeout */
  permissionTimeout: ReturnType<typeof setTimeout> | null
  /** Accumulated text since last flush (for debounced text_chunk emission) */
  textAccumulator: string
  /** Whether we've seen the initial welcome/init output */
  pastInit: boolean
  /** Whether we've emitted session_init */
  emittedSessionInit: boolean
  /** Track which options are in the current selector for arrow-key navigation */
  selectorOptions: string[]
  /** Currently highlighted option index in the terminal selector */
  currentOptionIndex: number
  /** Whether task_complete has already been emitted for this run */
  runCompleteEmitted: boolean
  /** Quiescence timer used to avoid premature completion */
  quiescenceTimer: ReturnType<typeof setTimeout> | null
  /** Last PTY output timestamp */
  lastOutputAt: number
  /** Current prompt snippet used to detect the echoed user input */
  promptSnippet: string
  /** Whether we saw an echoed prompt for current request */
  sawPromptEcho: boolean
}

// ─── PtyRunManager ───

export class PtyRunManager extends EventEmitter {
  private activeRuns = new Map<string, PtyRunHandle>()
  private _finishedRuns = new Map<string, PtyRunHandle>()
  private claudeBinary: string

  constructor() {
    super()
    this.claudeBinary = this._findClaudeBinary()
    this._ensureSpawnHelperExecutable()
    log(`Claude binary: ${this.claudeBinary}`)
  }

  /**
   * node-pty prebuilt spawn-helper may lose execute bit depending on install/archive flow.
   * Ensure it's executable at runtime to avoid "posix_spawnp failed".
   */
  private _ensureSpawnHelperExecutable(): void {
    try {
      const pkgPath = require.resolve('node-pty/package.json')
      const path = require('path') as typeof import('path')
      const helperPath = path.join(
        path.dirname(pkgPath),
        'prebuilds',
        `${process.platform}-${process.arch}`,
        'spawn-helper',
      )
      if (!existsSync(helperPath)) return
      const st = statSync(helperPath)
      const isExecutable = (st.mode & 0o111) !== 0
      if (!isExecutable) {
        chmodSync(helperPath, 0o755)
        log(`Fixed spawn-helper permissions: ${helperPath}`)
      }
    } catch (err) {
      log(`spawn-helper permission check failed: ${(err as Error).message}`)
    }
  }

  private _findClaudeBinary(): string {
    const candidates = [
      '/usr/local/bin/claude',
      '/opt/homebrew/bin/claude',
      join(homedir(), '.npm-global/bin/claude'),
    ]

    for (const c of candidates) {
      try {
        execSync(`test -x "${c}"`, { stdio: 'ignore' })
        return c
      } catch {}
    }

    try {
      return execSync('/bin/zsh -ilc "whence -p claude"', { encoding: 'utf-8', env: getCliEnv() }).trim()
    } catch {}

    try {
      return execSync('/bin/bash -lc "which claude"', { encoding: 'utf-8', env: getCliEnv() }).trim()
    } catch {}

    return 'claude'
  }

  private _getEnv(): NodeJS.ProcessEnv {
    const env = getCliEnv()
    const binDir = this.claudeBinary.substring(0, this.claudeBinary.lastIndexOf('/'))
    if (env.PATH && !env.PATH.includes(binDir)) {
      env.PATH = `${binDir}:${env.PATH}`
    }

    return env
  }

  startRun(requestId: string, options: RunOptions): PtyRunHandle {
    if (!pty) {
      throw new Error('node-pty is not available — cannot use PTY transport')
    }

    const cwd = options.projectPath === '~' ? homedir() : options.projectPath

    // Build args for interactive mode (no -p flag)
    const args: string[] = [
      '--permission-mode', 'default',
    ]

    if (options.sessionId) {
      args.push('--resume', options.sessionId)
    }
    if (options.model) {
      args.push('--model', options.model)
    }
    if (options.allowedTools?.length) {
      args.push('--allowedTools', options.allowedTools.join(','))
    }
    if (options.systemPrompt) {
      args.push('--system-prompt', options.systemPrompt)
    }

    // Pass prompt as positional argument
    args.push(options.prompt)

    log(`Starting PTY run ${requestId}: ${this.claudeBinary} ${args.join(' ')}`)
    log(`Prompt: ${options.prompt.substring(0, 200)}`)

    const ptyProcess = pty.spawn(this.claudeBinary, args, {
      name: 'xterm-256color',
      cols: 120,
      rows: 40,
      cwd,
      env: this._getEnv(),
    })

    log(`Spawned PTY PID: ${ptyProcess.pid}`)

    const handle: PtyRunHandle = {
      runId: requestId,
      sessionId: options.sessionId || null,
      pty: ptyProcess,
      pid: ptyProcess.pid,
      startedAt: Date.now(),
      rawOutputTail: [],
      stderrTail: [],
      toolCallCount: 0,
      pendingPermission: null,
      permissionPhase: 'idle',
      ptyBuffer: [],
      permissionTimeout: null,
      textAccumulator: '',
      pastInit: false,
      emittedSessionInit: false,
      selectorOptions: [],
      currentOptionIndex: 0,
      runCompleteEmitted: false,
      quiescenceTimer: null,
      lastOutputAt: Date.now(),
      promptSnippet: options.prompt.trim().toLowerCase().slice(0, 24),
      sawPromptEcho: false,
    }

    // ─── PTY output parser pipeline ───
    let lineBuffer = ''

    ptyProcess.onData((data: string) => {
      // Raw diagnostics
      this._ringPush(handle.rawOutputTail, data.substring(0, 500))

      handle.lastOutputAt = Date.now()
      if (handle.quiescenceTimer) clearTimeout(handle.quiescenceTimer)
      handle.quiescenceTimer = setTimeout(() => this._checkQuiescenceCompletion(requestId, handle), QUIESCENCE_MS)

      // Ink/TUI uses \r to redraw the current line (cursor back to col 0).
      // PTY output commonly uses \r\r\n as line endings (Ink reset + newline).
      // Strategy: scan for \n to emit completed lines; treat \r immediately
      // before \n (or \r\n) as part of the line ending, not a redraw.
      // Only a \r followed by printable text is a true Ink redraw.
      const chars = data
      for (let ci = 0; ci < chars.length; ci++) {
        const ch = chars[ci]
        if (ch === '\n') {
          // Emit completed line (strip any trailing \r that was buffered)
          const completed = lineBuffer.endsWith('\r')
            ? lineBuffer.slice(0, -1)
            : lineBuffer
          lineBuffer = ''
          this._processLine(requestId, handle, completed)
        } else if (ch === '\r') {
          // Look ahead: if next char is \n or \r (part of \r\r\n), just
          // append \r to buffer so the \n branch can strip it.
          const next = ci + 1 < chars.length ? chars[ci + 1] : null
          if (next === '\n' || next === '\r') {
            // Part of line ending sequence — keep in buffer for \n to strip
            lineBuffer += '\r'
          } else if (next === null) {
            // End of chunk — we don't know what comes next, buffer it
            lineBuffer += '\r'
          } else {
            // \r followed by printable text → Ink redraw: reset line
            lineBuffer = ''
          }
        } else {
          lineBuffer += ch
        }
      }

      // Also process the current incomplete line for permission detection
      // (permission prompts may not end with newline)
      if (lineBuffer.length > 0) {
        const cleaned = stripAnsi(lineBuffer).trim()
        if (cleaned.length > 0) {
          this._checkPermissionInBuffer(requestId, handle, cleaned)
        }
      }
    })

    ptyProcess.onExit(({ exitCode, signal }) => {
      log(`PTY exited [${requestId}]: code=${exitCode} signal=${signal}`)

      // Clear permission timeout
      if (handle.permissionTimeout) {
        clearTimeout(handle.permissionTimeout)
        handle.permissionTimeout = null
      }
      if (handle.quiescenceTimer) {
        clearTimeout(handle.quiescenceTimer)
        handle.quiescenceTimer = null
      }

      // Flush any accumulated text
      this._flushText(requestId, handle)

      // Emit task_complete if we haven't already
      if (!handle.runCompleteEmitted) {
        handle.runCompleteEmitted = true
        this.emit('normalized', requestId, {
          type: 'task_complete',
          result: '',
          costUsd: 0,
          durationMs: Date.now() - handle.startedAt,
          numTurns: 1,
          usage: {},
          sessionId: handle.sessionId || '',
        } as NormalizedEvent)
      }

      // Move to finished runs
      this._finishedRuns.set(requestId, handle)
      this.activeRuns.delete(requestId)
      this.emit('exit', requestId, exitCode, signal, handle.sessionId)

      setTimeout(() => this._finishedRuns.delete(requestId), 5000)
    })

    this.activeRuns.set(requestId, handle)
    return handle
  }

  /**
   * Process a single line of PTY output.
   */
  private _processLine(requestId: string, handle: PtyRunHandle, rawLine: string): void {
    const cleaned = stripAnsi(rawLine).trim()
    if (cleaned.length === 0) return

    // Ignore terminal mode toggles and redraw control fragments.
    if (/^(?:\?[0-9;?]*[a-zA-Z])+$/i.test(cleaned)) return

    // Deduplicate exact redraw duplicates.
    if (handle.ptyBuffer.length > 0 && handle.ptyBuffer[handle.ptyBuffer.length - 1] === cleaned) return

    // Push to rolling buffer
    this._ringPushBuffer(handle.ptyBuffer, cleaned)

    log(`PTY line [${requestId}]: ${cleaned.substring(0, 200)}`)

    // ─── Try to extract session ID ───
    if (!handle.emittedSessionInit) {
      const sid = extractSessionId(cleaned)
      if (sid) {
        handle.sessionId = sid
        handle.emittedSessionInit = true
        this.emit('normalized', requestId, {
          type: 'session_init',
          sessionId: sid,
          tools: [],
          model: '',
          mcpServers: [],
          skills: [],
          version: '',
        } as NormalizedEvent)
      }
    }

    // ─── Skip init/welcome output ───
    if (!handle.pastInit) {
      // Wait until we see the echoed prompt for this request.
      if (/^[❯>]\s+/.test(cleaned)) {
        // Resume sessions may echo prior context, not the exact current prompt text.
        // Any echoed input prompt means init shell is ready.
        handle.sawPromptEcho = true
      }
      // Start parsing actual response only after a message bullet appears post-echo.
      if (handle.sawPromptEcho && cleaned.startsWith('⏺')) {
        handle.pastInit = true
      } else {
        return
      }
    }

    // ─── Permission phase: collecting detection context ───
    if (handle.permissionPhase === 'detecting' || handle.permissionPhase === 'idle') {
      this._checkPermissionInBuffer(requestId, handle, cleaned)
      if (handle.permissionPhase === 'waiting_user') {
        return // Permission prompt detected and emitted
      }
    }

    // ─── Detect tool calls ───
    const toolCall = parseToolCallLine(cleaned)
    if (toolCall) {
      handle.toolCallCount++
      this._flushText(requestId, handle)
      this.emit('normalized', requestId, {
        type: 'tool_call',
        toolName: toolCall.toolName,
        toolId: `pty-tool-${handle.toolCallCount}`,
        index: handle.toolCallCount - 1,
      } as NormalizedEvent)

      // Also emit tool_call_complete shortly after (we can't know exact timing from PTY)
      setTimeout(() => {
        this.emit('normalized', requestId, {
          type: 'tool_call_complete',
          index: handle.toolCallCount - 1,
        } as NormalizedEvent)
      }, 100)
      return
    }

    // ─── Accumulate text output ───
    if (isUiChrome(cleaned)) return

    // Accumulate text for debounced emission
    if (handle.textAccumulator.length > 0) {
      handle.textAccumulator += '\n'
    }
    const textLine = cleaned.startsWith('⏺') ? cleaned.replace(/^⏺\s*/, '') : cleaned
    handle.textAccumulator += textLine

    // Emit text chunks periodically (debounce 50ms)
    this._scheduleTextFlush(requestId, handle)
  }

  private _checkQuiescenceCompletion(requestId: string, handle: PtyRunHandle): void {
    if (!this.activeRuns.has(requestId)) return
    if (!handle.pastInit || handle.permissionPhase === 'waiting_user') return
    if (Date.now() - handle.lastOutputAt < QUIESCENCE_MS - 50) return

    const lastLines = handle.ptyBuffer.slice(-3)
    const hasPromptMarker = lastLines.some((l) => isInputPrompt(l))
    if (!hasPromptMarker) return

    this._flushText(requestId, handle)
    if (!handle.runCompleteEmitted) {
      handle.runCompleteEmitted = true
      this.emit('normalized', requestId, {
        type: 'task_complete',
        result: '',
        costUsd: 0,
        durationMs: Date.now() - handle.startedAt,
        numTurns: 1,
        usage: {},
        sessionId: handle.sessionId || '',
      } as NormalizedEvent)
    }

    try { handle.pty.write('/exit\n') } catch {}
    setTimeout(() => {
      if (this.activeRuns.has(requestId)) {
        try { handle.pty.kill() } catch {}
      }
    }, 3000)
  }

  private _textFlushTimers = new Map<string, ReturnType<typeof setTimeout>>()

  private _scheduleTextFlush(requestId: string, handle: PtyRunHandle): void {
    if (this._textFlushTimers.has(requestId)) return

    const timer = setTimeout(() => {
      this._textFlushTimers.delete(requestId)
      this._flushText(requestId, handle)
    }, 50)

    this._textFlushTimers.set(requestId, timer)
  }

  private _flushText(requestId: string, handle: PtyRunHandle): void {
    const timer = this._textFlushTimers.get(requestId)
    if (timer) {
      clearTimeout(timer)
      this._textFlushTimers.delete(requestId)
    }

    if (handle.textAccumulator.length > 0) {
      this.emit('normalized', requestId, {
        type: 'text_chunk',
        text: handle.textAccumulator,
      } as NormalizedEvent)
      handle.textAccumulator = ''
    }
  }

  /**
   * Check the current buffer for permission prompt patterns.
   */
  private _checkPermissionInBuffer(requestId: string, handle: PtyRunHandle, currentLine: string): void {
    // Add current line to detection context
    const detectionWindow = [...handle.ptyBuffer.slice(-10), currentLine]

    const permission = detectPermissionPrompt(detectionWindow)
    if (!permission) {
      // Check for permission-adjacent keywords to enter detecting phase
      const hasKeyword = /\b(?:permission|approve|allow|deny)\b/i.test(currentLine)
      if (hasKeyword && handle.permissionPhase === 'idle') {
        handle.permissionPhase = 'detecting'
      }
      return
    }

    // Permission prompt detected!
    log(`Permission prompt detected [${requestId}]: tool=${permission.toolName}, options=${permission.options.length}`)

    handle.pendingPermission = permission
    handle.permissionPhase = 'waiting_user'

    // Flush any accumulated text first
    this._flushText(requestId, handle)

    // Generate a unique question ID
    const questionId = `pty-perm-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`

    // Emit permission_request event
    this.emit('normalized', requestId, {
      type: 'permission_request',
      questionId,
      toolName: permission.toolName,
      toolDescription: permission.rawPrompt,
      options: permission.options.map((o) => ({
        id: o.optionId,
        label: o.label,
        kind: o.label.toLowerCase().includes('deny') || o.label.toLowerCase().includes('reject') ? 'deny' : 'allow',
      })),
    } as NormalizedEvent)

    // Set timeout for user response
    handle.permissionTimeout = setTimeout(() => {
      if (handle.permissionPhase === 'waiting_user') {
        log(`Permission timeout [${requestId}] — auto-denying`)
        this.emit('normalized', requestId, {
          type: 'text_chunk',
          text: '\n[Permission timed out — automatically denied after 5 minutes]\n',
        } as NormalizedEvent)
        // Send Escape to dismiss the prompt
        try {
          handle.pty.write('\x1b')
        } catch {}
        handle.permissionPhase = 'idle'
        handle.pendingPermission = null
      }
    }, PERMISSION_TIMEOUT_MS)
  }

  /**
   * Respond to a permission prompt by sending keystrokes to the PTY.
   */
  respondToPermission(requestId: string, _questionId: string, optionId: string): boolean {
    const handle = this.activeRuns.get(requestId)
    if (!handle) {
      log(`respondToPermission: no active run for ${requestId}`)
      return false
    }

    if (handle.permissionPhase !== 'waiting_user' || !handle.pendingPermission) {
      log(`respondToPermission: not waiting for permission (phase=${handle.permissionPhase})`)
      return false
    }

    // Clear timeout
    if (handle.permissionTimeout) {
      clearTimeout(handle.permissionTimeout)
      handle.permissionTimeout = null
    }

    const option = handle.pendingPermission.options.find((o) => o.optionId === optionId)
    if (!option) {
      log(`respondToPermission: option ${optionId} not found`)
      return false
    }

    log(`respondToPermission [${requestId}]: optionId=${optionId}, label=${option.label}`)

    // ─── Send keystrokes to PTY ───
    // The Claude interactive CLI uses Ink's Select component.
    // The first option is typically "Allow for this project" and is pre-selected.
    // To select a different option, we press Down arrow keys then Enter.

    const optionIndex = handle.pendingPermission.options.indexOf(option)
    const isAllow = option.label.toLowerCase().includes('allow') || option.label.toLowerCase().includes('yes')
    const isDeny = option.label.toLowerCase().includes('deny') || option.label.toLowerCase().includes('reject')

    try {
      if (isDeny) {
        // Try sending 'n' first (common shortcut for deny)
        // If that doesn't work, navigate with arrow keys
        // Send Escape first to clear any state, then 'n'
        handle.pty.write('n')
      } else if (isAllow && optionIndex === 0) {
        // First option (typically already selected) — just press Enter
        handle.pty.write('\r')
      } else {
        // Navigate to the option with arrow keys then press Enter
        for (let i = 0; i < optionIndex; i++) {
          handle.pty.write('\x1b[B') // Down arrow
        }
        // Small delay then Enter
        setTimeout(() => {
          try { handle.pty.write('\r') } catch {}
        }, 50)
      }
    } catch (err) {
      log(`respondToPermission: write error: ${(err as Error).message}`)
      return false
    }

    handle.permissionPhase = 'answered'
    handle.pendingPermission = null

    // After answering, reset to idle for next potential permission
    setTimeout(() => {
      if (handle.permissionPhase === 'answered') {
        handle.permissionPhase = 'idle'
      }
    }, 500)

    return true
  }

  /**
   * Cancel a running PTY process.
   */
  cancel(requestId: string): boolean {
    const handle = this.activeRuns.get(requestId)
    if (!handle) return false

    log(`Cancelling PTY run ${requestId}`)

    // Clear permission timeout
    if (handle.permissionTimeout) {
      clearTimeout(handle.permissionTimeout)
      handle.permissionTimeout = null
    }

    // Send SIGINT (Ctrl+C)
    try {
      handle.pty.write('\x03') // Ctrl+C
    } catch {}

    // Fallback: kill after 5s
    setTimeout(() => {
      if (this.activeRuns.has(requestId)) {
        log(`Force killing PTY run ${requestId}`)
        try {
          handle.pty.kill()
        } catch {}
      }
    }, 5000)

    return true
  }

  /**
   * Write arbitrary data to PTY stdin (for follow-up messages, etc.)
   */
  writeToStdin(requestId: string, message: string): boolean {
    const handle = this.activeRuns.get(requestId)
    if (!handle) return false

    log(`Writing to PTY stdin [${requestId}]: ${message.substring(0, 200)}`)
    try {
      handle.pty.write(message)
      return true
    } catch {
      return false
    }
  }

  /**
   * Get an enriched error object for a failed PTY run.
   */
  getEnrichedError(requestId: string, exitCode: number | null): EnrichedError {
    const handle = this.activeRuns.get(requestId) || this._finishedRuns.get(requestId)
    return {
      message: `PTY run failed with exit code ${exitCode}`,
      stderrTail: handle?.stderrTail.slice(-20) || [],
      stdoutTail: handle?.rawOutputTail.slice(-20) || [],
      exitCode,
      elapsedMs: handle ? Date.now() - handle.startedAt : 0,
      toolCallCount: handle?.toolCallCount || 0,
      sawPermissionRequest: handle?.permissionPhase !== 'idle' || false,
      permissionDenials: [],
    }
  }

  isRunning(requestId: string): boolean {
    return this.activeRuns.has(requestId)
  }

  getHandle(requestId: string): PtyRunHandle | undefined {
    return this.activeRuns.get(requestId)
  }

  getActiveRunIds(): string[] {
    return Array.from(this.activeRuns.keys())
  }

  private _ringPush(buffer: string[], line: string): void {
    buffer.push(line)
    if (buffer.length > MAX_RING_LINES) buffer.shift()
  }

  private _ringPushBuffer(buffer: string[], line: string): void {
    buffer.push(line)
    if (buffer.length > PTY_BUFFER_SIZE) buffer.shift()
  }
}


================================================
FILE: src/main/claude/run-manager.ts
================================================
import { spawn, execSync, ChildProcess } from 'child_process'
import { EventEmitter } from 'events'
import { homedir } from 'os'
import { join } from 'path'
import { StreamParser } from '../stream-parser'
import { normalize } from './event-normalizer'
import { log as _log } from '../logger'
import { getCliEnv } from '../cli-env'
import type { ClaudeEvent, NormalizedEvent, RunOptions, EnrichedError } from '../../shared/types'

const MAX_RING_LINES = 100
const DEBUG = process.env.CLUI_DEBUG === '1'

// Appended to Claude's default system prompt so it knows it's running inside CLUI.
// Uses --append-system-prompt (additive) not --system-prompt (replacement).
const CLUI_SYSTEM_HINT = [
  'IMPORTANT: You are NOT running in a terminal. You are running inside CLUI,',
  'a desktop chat application with a rich UI that renders full markdown.',
  'CLUI is a GUI wrapper around Claude Code — the user sees your output in a',
  'styled conversation view, not a raw terminal.',
  '',
  'Because CLUI renders markdown natively, you MUST use rich formatting when it helps:',
  '- Always use clickable markdown links: [label](https://url) — they render as real buttons.',
  '- When the user asks for images, and public web images are appropriate, proactively find and render them in CLUI.',
  '- Workflow: WebSearch for relevant public pages -> WebFetch those pages -> extract real image URLs -> render with markdown ![alt](url).',
  '- Do not guess, fabricate, or construct image URLs from memory.',
  '- Only embed images when the URL is a real publicly accessible image URL found through tools or explicitly provided by the user.',
  '- If real image URLs cannot be obtained confidently, fall back to clickable links and briefly say so.',
  '- Do not ask whether CLUI can render images; assume it can.',
  '- Use tables, bold, headers, and bullet lists freely — they all render beautifully.',
  '- Use code blocks with language tags for syntax highlighting.',
  '',
  'You are still a software engineering assistant. Keep using your tools (Read, Edit, Bash, etc.)',
  'normally. But when presenting information, links, resources, or explanations to the user,',
  'take full advantage of the rich UI. The user expects a polished chat experience, not raw terminal text.',
].join('\n')

// Tools auto-approved via --allowedTools (never trigger the permission card).
// Includes routine internal agent mechanics (Agent, Task, TaskOutput, TodoWrite,
// Notebook) — prompting for these would make UX terrible without adding meaningful
// safety. This is a deliberate CLUI policy choice, not native Claude parity.
// If runtime evidence shows any of these create real user-facing approval moments,
// they should be moved to the hook matcher in permission-server.ts instead.
const SAFE_TOOLS = [
  'Read', 'Glob', 'Grep', 'LS',
  'TodoRead', 'TodoWrite',
  'Agent', 'Task', 'TaskOutput',
  'Notebook',
  'WebSearch', 'WebFetch',
]

// All tools to pre-approve when NO hook server is available (fallback path).
// Includes safe + dangerous tools so nothing is silently denied.
const DEFAULT_ALLOWED_TOOLS = [
  'Bash', 'Edit', 'Write', 'MultiEdit',
  ...SAFE_TOOLS,
]

function log(msg: string): void {
  _log('RunManager', msg)
}

export interface RunHandle {
  runId: string
  sessionId: string | null
  process: ChildProcess
  pid: number | null
  startedAt: number
  /** Ring buffer of last N stderr lines */
  stderrTail: string[]
  /** Ring buffer of last N stdout lines */
  stdoutTail: string[]
  /** Count of tool calls seen during this run */
  toolCallCount: number
  /** Whether any permission_request event was seen during this run */
  sawPermissionRequest: boolean
  /** Permission denials from result event */
  permissionDenials: Array<{ tool_name: string; tool_use_id: string }>
}

/**
 * RunManager: spawns one `claude -p` process per run, parses NDJSON,
 * emits normalized events, handles cancel, and keeps diagnostic ring buffers.
 *
 * Events emitted:
 *  - 'normalized' (runId, NormalizedEvent)
 *  - 'raw' (runId, ClaudeEvent)  — for logging/debugging
 *  - 'exit' (runId, code, signal, sessionId)
 *  - 'error' (runId, Error)
 */
export class RunManager extends EventEmitter {
  private activeRuns = new Map<string, RunHandle>()
  /** Holds recently-finished runs so diagnostics survive past process exit */
  private _finishedRuns = new Map<string, RunHandle>()
  private claudeBinary: string

  constructor() {
    super()
    this.claudeBinary = this._findClaudeBinary()
    log(`Claude binary: ${this.claudeBinary}`)
  }

  private _findClaudeBinary(): string {
    const candidates = [
      '/usr/local/bin/claude',
      '/opt/homebrew/bin/claude',
      join(homedir(), '.npm-global/bin/claude'),
    ]

    for (const c of candidates) {
      try {
        execSync(`test -x "${c}"`, { stdio: 'ignore' })
        return c
      } catch {}
    }

    try {
      return execSync('/bin/zsh -ilc "whence -p claude"', { encoding: 'utf-8', env: getCliEnv() }).trim()
    } catch {}

    try {
      return execSync('/bin/bash -lc "which claude"', { encoding: 'utf-8', env: getCliEnv() }).trim()
    } catch {}

    return 'claude'
  }

  private _getEnv(): NodeJS.ProcessEnv {
    const env = getCliEnv()
    const binDir = this.claudeBinary.substring(0, this.claudeBinary.lastIndexOf('/'))
    if (env.PATH && !env.PATH.includes(binDir)) {
      env.PATH = `${binDir}:${env.PATH}`
    }

    return env
  }

  startRun(requestId: string, options: RunOptions): RunHandle {
    const cwd = options.projectPath === '~' ? homedir() : options.projectPath

    const args: string[] = [
      '-p',
      '--input-format', 'stream-json',
      '--output-format', 'stream-json',
      '--verbose',
      '--include-partial-messages',
      '--permission-mode', 'default',
    ]

    if (options.sessionId) {
      args.push('--resume', options.sessionId)
    }
    if (options.model) {
      args.push('--model', options.model)
    }
    if (options.addDirs && options.addDirs.length > 0) {
      for (const dir of options.addDirs) {
        args.push('--add-dir', dir)
      }
    }

    if (options.hookSettingsPath) {
      // CLUI-scoped hook settings: the PreToolUse HTTP hook handles permissions
      // for dangerous tools (Bash, Edit, Write, MultiEdit).
      // Auto-approve safe tools so they don't trigger the permission card.
      args.push('--settings', options.hookSettingsPath)
      const safeAllowed = [
        ...SAFE_TOOLS,
        ...(options.allowedTools || []),
      ]
      args.push('--allowedTools', safeAllowed.join(','))
    } else {
      // Fallback: no hook server available.
      // Pre-approve common tools so they run without being silently denied.
      const allAllowed = [
        ...DEFAULT_ALLOWED_TOOLS,
        ...(options.allowedTools || []),
      ]
      args.push('--allowedTools', allAllowed.join(','))
    }
    if (options.maxTurns) {
      args.push('--max-turns', String(options.maxTurns))
    }
    if (options.maxBudgetUsd) {
      args.push('--max-budget-usd', String(options.maxBudgetUsd))
    }
    if (options.systemPrompt) {
      args.push('--system-prompt', options.systemPrompt)
    }
    // Always tell Claude it's inside CLUI (additive, doesn't replace base prompt)
    args.push('--append-system-prompt', CLUI_SYSTEM_HINT)

    if (DEBUG) {
      log(`Starting run ${requestId}: ${this.claudeBinary} ${args.join(' ')}`)
      log(`Prompt: ${options.prompt.substring(0, 200)}`)
    } else {
      log(`Starting run ${requestId}`)
    }

    const child = spawn(this.claudeBinary, args, {
      stdio: ['pipe', 'pipe', 'pipe'],
      cwd,
      env: this._getEnv(),
    })

    log(`Spawned PID: ${child.pid}`)

    const handle: RunHandle = {
      runId: requestId,
      sessionId: options.sessionId || null,
      process: child,
      pid: child.pid || null,
      startedAt: Date.now(),
      stderrTail: [],
      stdoutTail: [],
      toolCallCount: 0,
      sawPermissionRequest: false,
      permissionDenials: [],
    }

    // ─── stdout → NDJSON parser → normalizer → events ───
    const parser = StreamParser.fromStream(child.stdout!)

    parser.on('event', (raw: ClaudeEvent) => {
      // Track session ID
      if (raw.type === 'system' && 'subtype' in raw && raw.subtype === 'init') {
        handle.sessionId = (raw as any).session_id
      }

      // Track permission_request events
      if (raw.type === 'permission_request' || (raw.type === 'system' && 'subtype' in raw && (raw as any).subtype === 'permission_request')) {
        handle.sawPermissionRequest = true
        log(`Permission request seen [${requestId}]`)
      }

      // Extract permission_denials from result event
      if (raw.type === 'result') {
        const denials = (raw as any).permission_denials
        if (Array.isArray(denials) && denials.length > 0) {
          handle.permissionDenials = denials.map((d: any) => ({
            tool_name: d.tool_name || '',
            tool_use_id: d.tool_use_id || '',
          }))
          log(`Permission denials [${requestId}]: ${JSON.stringify(handle.permissionDenials)}`)
        }
      }

      // Ring buffer stdout lines (raw JSON for diagnostics)
      this._ringPush(handle.stdoutTail, JSON.stringify(raw).substring(0, 300))

      // Emit raw event for debugging
      this.emit('raw', requestId, raw)

      // Normalize and emit canonical events
      const normalized = normalize(raw)
      for (const evt of normalized) {
        if (evt.type === 'tool_call') handle.toolCallCount++
        this.emit('normalized', requestId, evt)
      }

      // Close stdin after result event — with stream-json input the process
      // stays alive waiting for more input; closing stdin triggers clean exit.
      if (raw.type === 'result') {
        log(`Run complete [${requestId}]: sawPermissionRequest=${handle.sawPermissionRequest}, denials=${handle.permissionDenials.length}`)
        try { child.stdin?.end() } catch {}
      }
    })

    parser.on('parse-error', (line: string) => {
      log(`Parse error [${requestId}]: ${line.substring(0, 200)}`)
      this._ringPush(handle.stderrTail, `[parse-error] ${line.substring(0, 200)}`)
    })

    // ─── stderr ring buffer ───
    child.stderr?.setEncoding('utf-8')
    child.stderr?.on('data', (data: string) => {
      const lines = data.split('\n').filter((l: string) => l.trim())
      for (const line of lines) {
        this._ringPush(handle.stderrTail, line)
      }
      log(`Stderr [${requestId}]: ${data.trim().substring(0, 500)}`)
    })

    // ─── Process lifecycle ───
    // Snapshot diagnostics BEFORE deleting the handle so callers can still read them.
    child.on('close', (code, signal) => {
      log(`Process closed [${requestId}]: code=${code} signal=${signal}`)
      // Move handle to finished map so getEnrichedError still works after exit
      this._finishedRuns.set(requestId, handle)
      this.activeRuns.delete(requestId)
      this.emit('exit', requestId, code, signal, handle.sessionId)
      // Clean up finished run after a short delay (gives callers time to read diagnostics)
      setTimeout(() => this._finishedRuns.delete(requestId), 5000)
    })

    child.on('error', (err) => {
      log(`Process error [${requestId}]: ${err.message}`)
      this._finishedRuns.set(requestId, handle)
      this.activeRuns.delete(requestId)
      this.emit('error', requestId, err)
      setTimeout(() => this._finishedRuns.delete(requestId), 5000)
    })

    // ─── Write prompt to stdin (stream-json format, keep open) ───
    // Using --input-format stream-json for bidirectional communication.
    // Stdin stays open so follow-up messages can be sent.
    const userMessage = JSON.stringify({
      type: 'user',
      message: {
        role: 'user',
        content: [{ type: 'text', text: options.prompt }],
      },
    })
    child.stdin!.write(userMessage + '\n')

    this.activeRuns.set(requestId, handle)
    return handle
  }

  /**
   * Write a message to a running process's stdin (for follow-up prompts, etc.)
   */
  writeToStdin(requestId: string, message: object): boolean {
    const handle = this.activeRuns.get(requestId)
    if (!handle) return false
    if (!handle.process.stdin || handle.process.stdin.destroyed) return false

    const json = JSON.stringify(message)
    log(`Writing to stdin [${requestId}]: ${json.substring(0, 200)}`)
    handle.process.stdin.write(json + '\n')
    return true
  }

  /**
   * Cancel a running process: SIGINT, then SIGKILL after 5s.
   */
  cancel(requestId: string): boolean {
    const handle = this.activeRuns.get(requestId)
    if (!handle) return false

    log(`Cancelling run ${requestId}`)
    handle.process.kill('SIGINT')

    // Fallback: SIGKILL if process hasn't exited after 5s.
    // Only check exitCode — process.killed is set true by the SIGINT call above,
    // so checking !killed would prevent the fallback from ever firing.
    setTimeout(() => {
      if (handle.process.exitCode === null) {
        log(`Force killing run ${requestId} (SIGINT did not terminate)`)
        handle.process.kill('SIGKILL')
      }
    }, 5000)

    return true
  }

  /**
   * Get an enriched error object for a failed run.
   */
  getEnrichedError(requestId: string, exitCode: number | null): EnrichedError {
    const handle = this.activeRuns.get(requestId) || this._finishedRuns.get(requestId)
    return {
      message: `Run failed with exit code ${exitCode}`,
      stderrTail: handle?.stderrTail.slice(-20) || [],
      stdoutTail: handle?.stdoutTail.slice(-20) || [],
      exitCode,
      elapsedMs: handle ? Date.now() - handle.startedAt : 0,
      toolCallCount: handle?.toolCallCount || 0,
      sawPermissionRequest: handle?.sawPermissionRequest || false,
      permissionDenials: handle?.permissionDenials || [],
    }
  }

  isRunning(requestId: string): boolean {
    return this.activeRuns.has(requestId)
  }

  getHandle(requestId: string): RunHandle | undefined {
    return this.activeRuns.get(requestId)
  }

  getActiveRunIds(): string[] {
    return Array.from(this.activeRuns.keys())
  }

  private _ringPush(buffer: string[], line: string): void {
    buffer.push(line)
    if (buffer.length > MAX_RING_LINES) {
      buffer.shift()
    }
  }
}


================================================
FILE: src/main/cli-env.ts
================================================
import { execSync } from 'child_process'

let cachedPath: string | null = null

function appendPathEntries(target: string[], seen: Set<string>, rawPath: string | undefined): void {
  if (!rawPath) return
  for (const entry of rawPath.split(':')) {
    const p = entry.trim()
    if (!p || seen.has(p)) continue
    seen.add(p)
    target.push(p)
  }
}

export function getCliPath(): string {
  if (cachedPath) return cachedPath

  const ordered: string[] = []
  const seen = new Set<string>()

  // Start from current process PATH.
  appendPathEntries(ordered, seen, process.env.PATH)

  // Add common binary locations used on macOS (Homebrew + system).
  appendPathEntries(ordered, seen, '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin')

  // Try interactive login shell first so nvm/asdf/etc. PATH hooks are loaded.
  const pathCommands = [
    '/bin/zsh -ilc "echo $PATH"',
    '/bin/zsh -lc "echo $PATH"',
    '/bin/bash -lc "echo $PATH"',
  ]

  for (const cmd of pathCommands) {
    try {
      const discovered = execSync(cmd, { encoding: 'utf-8', timeout: 3000 }).trim()
      appendPathEntries(ordered, seen, discovered)
    } catch {
      // Keep trying fallbacks.
    }
  }

  cachedPath = ordered.join(':')
  return cachedPath
}

export function getCliEnv(extraEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
  const env: NodeJS.ProcessEnv = {
    ...process.env,
    ...extraEnv,
    PATH: getCliPath(),
  }
  delete env.CLAUDECODE
  return env
}



================================================
FILE: src/main/hooks/permission-server.ts
================================================
/**
 * Permission Hook Server
 *
 * A local HTTP server that acts as a Claude Code PreToolUse hook handler.
 * When Claude Code wants to use a tool, it POSTs the tool request here.
 * The server forwards it to the renderer (PermissionCard), waits for the
 * user's decision, and returns the structured hook response.
 *
 * This is a CLUI-owned permission broker that approximates Claude Code's
 * practical permission cadence. It does not reproduce native permission
 * semantics exactly — it intercepts the small set of tool classes that
 * map to real, user-meaningful approval moments.
 *
 * Security:
 *   - Per-launch app secret in URL path (prevents local spoofing)
 *   - Per-run token in URL path (prevents cross-run confusion)
 *   - Deny-by-default on every failure path
 *   - Per-run settings files (only CLUI-spawned sessions see the hook)
 */

import { createServer, IncomingMessage, ServerResponse } from 'http'
import { EventEmitter } from 'events'
import { writeFileSync, mkdirSync, unlinkSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { randomUUID } from 'crypto'
import { log as _log } from '../logger'
const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
const DEFAULT_PORT = 19836
const MAX_BODY_SIZE = 1024 * 1024 // 1MB

const DEBUG = process.env.CLUI_DEBUG === '1'

// Tools that need explicit user approval via the permission card.
// This is the small set of tool classes that map to real, user-meaningful
// approval moments. Routine internal agent mechanics (Read, Glob, Grep, etc.)
// are auto-approved via --allowedTools to avoid noisy UX.
const PERMISSION_REQUIRED_TOOLS = ['Bash', 'Edit', 'Write', 'MultiEdit']

// Bash commands that are clearly read-only and safe to auto-approve.
// Matches the leading command (before any pipes, semicolons, or &&).
const SAFE_BASH_COMMANDS = new Set([
  // Info / help
  'cat', 'head', 'tail', 'less', 'more', 'wc', 'file', 'stat',
  'ls', 'pwd', 'echo', 'printf', 'date', 'whoami', 'hostname', 'uname',
  'which', 'whence', 'where', 'type', 'command',
  'man', 'help', 'info',
  // Search
  'find', 'grep', 'rg', 'ag', 'ack', 'fd', 'fzf', 'locate',
  // Git read-only
  'git', // further checked: only read-only subcommands
  // Env / config
  'env', 'printenv', 'set',
  // Package info (read-only)
  'npm', 'yarn', 'pnpm', 'bun', 'cargo', 'pip', 'pip3', 'go', 'rustup',
  'node', 'python', 'python3', 'ruby', 'java', 'javac',
  // Claude CLI (read-only subcommands)
  'claude',
  // Disk / system info
  'df', 'du', 'free', 'top', 'htop', 'ps', 'uptime', 'lsof',
  'tree', 'realpath', 'dirname', 'basename',
  // macOS
  'sw_vers', 'system_profiler', 'defaults', 'mdls', 'mdfind',
  // Diff / compare
  'diff', 'cmp', 'comm', 'sort', 'uniq', 'cut', 'awk', 'sed',
  'jq', 'yq', 'xargs', 'tr',
])

// Git subcommands that mutate state (not safe to auto-approve)
const GIT_MUTATING_SUBCOMMANDS = new Set([
  'push', 'commit', 'merge', 'rebase', 'reset', 'checkout', 'switch',
  'branch', 'tag', 'stash', 'cherry-pick', 'revert', 'am', 'apply',
  'clean', 'rm', 'mv', 'restore', 'bisect', 'pull', 'fetch', 'clone',
  'init', 'submodule', 'worktree', 'gc', 'prune', 'filter-branch',
])

// Claude subcommands that mutate state
const CLAUDE_MUTATING_SUBCOMMANDS = new Set([
  'config', 'login', 'logout',
])

/** Check if a Bash command string is safe (read-only) */
function isSafeBashCommand(command: unknown): boolean {
  if (typeof command !== 'string') return false
  const trimmed = command.trim()
  if (!trimmed) return false

  // Extract the first command (before any chaining operators)
  // Split on ;, &&, ||, | and check each segment
  const segments = trimmed.split(/\s*(?:;|&&|\|\||[|])\s*/)
  for (const segment of segments) {
    const parts = segment.trim().split(/\s+/)
    const cmd = parts[0]
    if (!cmd) continue

    // Handle env prefix patterns like: VAR=val command
    const actualCmd = cmd.includes('=') ? parts[1] : cmd
    if (!actualCmd) continue

    // Strip path prefix (e.g., /usr/bin/git → git)
    const base = actualCmd.split('/').pop() || actualCmd

    if (!SAFE_BASH_COMMANDS.has(base)) return false

    // Extra check for git: only allow read-only subcommands
    if (base === 'git') {
      const subIdx = cmd.includes('=') ? 2 : 1
      const sub = parts[subIdx]
      if (sub && GIT_MUTATING_SUBCOMMANDS.has(sub)) return false
    }

    // Extra check for claude: only allow read-only subcommands
    if (base === 'claude') {
      const subIdx = cmd.includes('=') ? 2 : 1
      const sub = parts[subIdx]
      // claude mcp remove, claude config set, etc.
      if (sub && CLAUDE_MUTATING_SUBCOMMANDS.has(sub)) return false
      // claude mcp remove specifically
      if (sub === 'mcp') {
        const mcpSub = parts[subIdx + 1]
        if (mcpSub && mcpSub !== 'list' && mcpSub !== 'get' && mcpSub !== '--help') return false
      }
    }

    // Extra check for npm/yarn/pnpm/bun: block install/publish/run
    if (['npm', 'yarn', 'pnpm', 'bun'].includes(base)) {
      const subIdx = cmd.includes('=') ? 2 : 1
      const sub = parts[subIdx]
      if (sub && ['install', 'i', 'add', 'remove', 'uninstall', 'publish', 'run', 'exec', 'dlx', 'npx', 'create', 'init', 'link', 'unlink', 'pack', 'deprecate'].includes(sub)) return false
    }

    // Block redirections that write to files
    if (segment.includes('>') && !segment.includes('>/dev/null') && !segment.includes('2>/dev/null') && !segment.includes('2>&1')) return false
  }

  return true
}

// Regex matcher for the hook config — intercept dangerous tools + external MCP tools.
const HOOK_MATCHER = `^(${PERMISSION_REQUIRED_TOOLS.join('|')}|mcp__.*)$`

// Fields in tool_input that should be redacted in logs
const SENSITIVE_FIELD_RE = /token|password|secret|key|auth|credential|api.?key/i

// Exhaustive whitelist of valid decision IDs from permission card options.
// Any decision not in this set is denied (fail-closed).
const VALID_ALLOW_DECISIONS = new Set(['allow', 'allow-session', 'allow-domain'])
const VALID_DECISIONS = new Set([...VALID_ALLOW_DECISIONS, 'deny'])

function log(msg: string): void {
  _log('PermissionServer', msg)
}

/** Extract domain from a URL string, returns null on failure */
function extractDomain(url: unknown): string | null {
  if (typeof url !== 'string') return null
  try {
    return new URL(url).hostname
  } catch {
    return null
  }
}

/** Build a deny hook response */
function denyResponse(reason: string) {
  return {
    hookSpecificOutput: {
      hookEventName: 'PreToolUse',
      permissionDecision: 'deny',
      permissionDecisionReason: reason,
    },
  }
}

/** Build an allow hook response */
function allowResponse(reason: string) {
  return {
    hookSpecificOutput: {
      hookEventName: 'PreToolUse',
      permissionDecision: 'allow',
      permissionDecisionReason: reason,
    },
  }
}

export interface HookToolRequest {
  session_id: string
  transcript_path: string
  cwd: string
  permission_mode: string
  hook_event_name: string
  tool_name: string
  tool_input: Record<string, unknown>
  tool_use_id: string
}

export interface PermissionDecision {
  decision: 'allow' | 'deny'
  reason?: string
}

export interface PermissionOption {
  id: string
  label: string
  kind: 'allow' | 'deny'
}

interface PendingRequest {
  toolRequest: HookToolRequest
  resolve: (decision: PermissionDecision) => void
  timeout: ReturnType<typeof setTimeout>
  questionId: string
  runToken: string
}

interface RunRegistration {
  tabId: string
  requestId: string
  sessionId: string | null
}

/**
 * PermissionServer: HTTP server for Claude Code PreToolUse hooks.
 *
 * Events:
 *  - 'permission-request' (questionId, toolRequest, tabId, options) — forward to renderer
 */
export class PermissionServer extends EventEmitter {
  private server: ReturnType<typeof createServer> | null = null
  private pendingRequests = new Map<string, PendingRequest>()
  private port: number
  private _actualPort: number | null = null

  /** Per-launch secret — validates that requests come from our hooks */
  private appSecret: string

  /** Per-run tokens → run registration (tabId, requestId, sessionId) */
  private runTokens = new Map<string, RunRegistration>()

  /** Scoped "allow always" keys. Format varies by tool type. */
  private scopedAllows = new Set<string>()

  /** Tracked generated settings files: runToken → filePath */
  private settingsFiles = new Map<string, string>()

  constructor(port = DEFAULT_PORT) {
    super()
    this.port = port
    this.appSecret = randomUUID()
  }

  async start(): Promise<number> {
    if (this.server) {
      log('Server already running')
      return this._actualPort || this.port
    }

    return new Promise((resolve, reject) => {
      this.server = createServer((req, res) => this._handleRequest(req, res))

      this.server.on('error', (err: NodeJS.ErrnoException) => {
        if (err.code === 'EADDRINUSE') {
          log(`Port ${this.port} in use, trying ${this.port + 1}`)
          this.port++
          this.server!.listen(this.port, '127.0.0.1')
        } else {
          log(`Server error: ${err.message}`)
          reject(err)
        }
      })

      this.server.listen(this.port, '127.0.0.1', () => {
        this._actualPort = this.port
        log(`Permission server listening on 127.0.0.1:${this.port}`)
        resolve(this.port)
      })
    })
  }

  stop(): void {
    // Deny all pending requests
    for (const [qid, pending] of this.pendingRequests) {
      clearTimeout(pending.timeout)
      pending.resolve({ decision: 'deny', reason: 'Server shutting down' })
      this.pendingRequests.delete(qid)
    }

    // Clean up all remaining settings files (best-effort)
    for (const [, filePath] of this.settingsFiles) {
      try { unlinkSync(filePath) } catch {}
    }
    this.settingsFiles.clear()

    if (this.server) {
      this.server.close()
      this.server = null
      log('Permission server stopped')
    }
  }

  getPort(): number | null {
    return this._actualPort
  }

  // ─── Run Registration ───

  /**
   * Register a new run. Returns a unique run token.
   * The run token is embedded in the hook URL for per-run routing.
   */
  registerRun(tabId: string, requestId: string, sessionId: string | null): string {
    const runToken = randomUUID()
    this.runTokens.set(runToken, { tabId, requestId, sessionId })
    log(`Registered run: token=${runToken.substring(0, 8)}… tab=${tabId.substring(0, 8)}…`)
    return runToken
  }

  /**
   * Unregister a run. Denies any pending requests for this run and cleans up its settings file.
   */
  unregisterRun(runToken: string): void {
    const reg = this.runTokens.get(runToken)
    if (!reg) return

    // Deny any pending requests associated with this run
    for (const [qid, pending] of this.pendingRequests) {
      if (pending.runToken === runToken) {
        clearTimeout(pending.timeout)
        pending.resolve({ decision: 'deny', reason: 'Run ended' })
        this.pendingRequests.delete(qid)
      }
    }

    // Clean up settings file for this run
    const filePath = this.settingsFiles.get(runToken)
    if (filePath) {
      try { unlinkSync(filePath) } catch {}
      this.settingsFiles.delete(runToken)
    }

    this.runTokens.delete(runToken)
    log(`Unregistered run: token=${runToken.substring(0, 8)}…`)
  }

  // ─── Permission Response ───

  /**
   * Respond to a pending permission request.
   * decision: 'allow' (once), 'allow-session' (for session), 'allow-domain' (WebFetch domain), 'deny'
   */
  respondToPermission(questionId: string, decision: string, reason?: string): boolean {
    const pending = this.pendingRequests.get(questionId)
    if (!pending) {
      log(`respondToPermission: no pending request for ${questionId}`)
      return false
    }

    clearTimeout(pending.timeout)
    this.pendingRequests.delete(questionId)

    // Fail-closed: reject unknown decision IDs immediately
    if (!VALID_DECISIONS.has(decision)) {
      log(`Unknown decision "${decision}" for [${questionId}] — denying (fail-closed)`)
      pending.resolve({ decision: 'deny', reason: `Unknown decision: ${decision}` })
      return true
    }

    const toolName = pending.toolRequest.tool_name
    const sessionId = pending.toolRequest.session_id

    // Handle scoped "allow always" decisions
    if (decision === 'allow-session') {
      const key = `session:${sessionId}:tool:${toolName}`
      this.scopedAllows.add(key)
      log(`Session-allowed ${toolName} for session ${sessionId.substring(0, 8)}…`)
    } else if (decision === 'allow-domain') {
      const domain = extractDomain(pending.toolRequest.tool_input?.url)
      if (domain) {
        const key = `session:${sessionId}:webfetch:${domain}`
        this.scopedAllows.add(key)
        log(`Domain-allowed ${domain} for session ${sessionId.substring(0, 8)}…`)
      }
    }

    const hookDecision: 'allow' | 'deny' = VALID_ALLOW_DECISIONS.has(decision) ? 'allow' : 'deny'
    if (DEBUG) {
      log(`respondToPermission [${questionId}]: ${decision} (tool=${toolName})`)
    } else {
      log(`Permission: ${toolName} → ${hookDecision}`)
    }
    pending.resolve({ decision: hookDecision, reason })
    return true
  }

  // ─── Dynamic Options ───

  /**
   * Get permission card options for a given tool + input.
   * WebFetch gets domain-scoped options; all others get session-scoped.
   */
  getOptionsForTool(toolName: string, toolInput?: Record<string, unknown>): PermissionOption[] {
    // Bash commands are too diverse for session-scoped blanket allow —
    // each command should be individually reviewed.
    if (toolName === 'Bash') {
      return [
        { id: 'allow', label: 'Allow Once', kind: 'allow' },
        { id: 'deny', label: 'Deny', kind: 'deny' },
      ]
    }

    // Edit, Write, MultiEdit, mcp__* — session-scoped allow is safe
    return [
      { id: 'allow', label: 'Allow Once', kind: 'allow' },
      { id: 'allow-session', label: 'Allow for Session', kind: 'allow' },
      { id: 'deny', label: 'Deny', kind: 'deny' },
    ]
  }

  // ─── Settings File Generation ───

  /**
   * Generate a per-run settings file with the PreToolUse HTTP hook.
   * The URL includes both appSecret and runToken for authentication.
   */
  generateSettingsFile(runToken: string): string {
    const port = this._actualPort || this.port
    const settings = {
      hooks: {
        PreToolUse: [
          {
            matcher: HOOK_MATCHER,
            hooks: [
              {
                type: 'http',
                url: `http://127.0.0.1:${port}/hook/pre-tool-use/${this.appSecret}/${runToken}`,
                timeout: 300,
              },
            ],
          },
        ],
      },
    }

    const dir = join(tmpdir(), 'clui-hook-config')
    try { mkdirSync(dir, { recursive: true, mode: 0o700 }) } catch {}

    const filePath = join(dir, `clui-hook-${runToken}.json`)
    writeFileSync(filePath, JSON.stringify(settings, null, 2), { mode: 0o600 })
    this.settingsFiles.set(runToken, filePath)
    if (DEBUG) {
      log(`Generated settings file: ${filePath}`)
    }
    return filePath
  }

  // ─── HTTP Request Handling ───

  private async _handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
    // POST only — deny everything else
    if (req.method !== 'POST') {
      res.writeHead(404, { 'Content-Type': 'application/json' })
      res.end(JSON.stringify(denyResponse('Not found')))
      return
    }

    // Parse URL: /hook/pre-tool-use/<appSecret>/<runToken>
    const segments = (req.url || '').split('/').filter(Boolean)
    if (segments.length !== 4 || segments[0] !== 'hook' || segments[1] !== 'pre-tool-use') {
      res.writeHead(404, { 'Content-Type': 'application/json' })
      res.end(JSON.stringify(denyResponse('Invalid path')))
      return
    }

    const urlSecret = segments[2]
    const urlToken = segments[3]

    // Validate app secret
    if (urlSecret !== this.appSecret) {
      log('Rejected request: invalid app secret')
      res.writeHead(403, { 'Content-Type': 'application/json' })
      res.end(JSON.stringify(denyResponse('Invalid credentials')))
      return
    }

    // Validate run token
    const registration = this.runTokens.get(urlToken)
    if (!registration) {
      log(`Rejected request: unknown run token ${urlToken.substring(0, 8)}…`)
      res.writeHead(403, { 'Content-Type': 'application/json' })
      res.end(JSON.stringify(denyResponse('Unknown run')))
      return
    }

    // Read body with size limit
    let body = ''
    let bodySize = 0
    for await (const chunk of req) {
      bodySize += (chunk as Buffer).length
      if (bodySize > MAX_BODY_SIZE) {
        log('Rejected request: body too large')
        res.writeHead(413, { 'Content-Type': 'application/json' })
        res.end(JSON.stringify(denyResponse('Request too large')))
        return
      }
      body += chunk
    }

    // Parse JSON
    let toolRequest: HookToolRequest
    try {
      toolRequest = JSON.parse(body) as HookToolRequest
    } catch {
      log('Rejected request: invalid JSON')
      res.writeHead(400, { 'Content-Type': 'application/json' })
      res.end(JSON.stringify(denyResponse('Invalid JSON')))
      return
    }

    // Validate required fields
    if (!toolRequest.tool_name || !toolRequest.session_id || !toolRequest.hook_event_name) {
      log('Rejected request: missing required fields')
      res.writeHead(400, { 'Content-Type': 'application/json' })
      res.end(JSON.stringify(denyResponse('Missing required fields')))
      return
    }

    // Validate hook event name
    if (toolRequest.hook_event_name !== 'PreToolUse') {
      log(`Rejected request: unexpected hook event ${toolRequest.hook_event_name}`)
      res.writeHead(400, { 'Content-Type': 'application/json' })
      res.end(JSON.stringify(denyResponse('Unexpected hook event')))
      return
    }

    if (DEBUG) {
      log(`Hook request: tool=${toolRequest.tool_name} id=${toolRequest.tool_use_id} session=${toolRequest.session_id} tab=${registration.tabId.substring(0, 8)}…`)
    } else {
      log(`Hook: ${toolRequest.tool_name} → tab=${registration.tabId.substring(0, 8)}…`)
    }

    // Check scoped allows
    const sessionId = toolRequest.session_id
    const toolName = toolRequest.tool_name

    // Check session-scoped allow
    if (this.scopedAllows.has(`session:${sessionId}:tool:${toolName}`)) {
      if (DEBUG) log(`Auto-allowing ${toolName} (session-allowed)`)
      res.writeHead(200, { 'Content-Type': 'application/json' })
      res.end(JSON.stringify(allowResponse('Allowed for session by user')))
      return
    }

    // Check domain-scoped allow (WebFetch)
    if (toolName === 'WebFetch') {
      const domain = extractDomain(toolRequest.tool_input?.url)
      if (domain && this.scopedAllows.has(`session:${sessionId}:webfetch:${domain}`)) {
        if (DEBUG) log(`Auto-allowing WebFetch to ${domain} (domain-allowed)`)
        res.writeHead(200, { 'Content-Type': 'application/json' })
        res.end(JSON.stringify(allowResponse(`Domain ${domain} allowed by user`)))
        return
      }
    }

    // Auto-approve safe (read-only) Bash commands without prompting
    if (toolName === 'Bash' && isSafeBashCommand(toolRequest.tool_input?.command)) {
      if (DEBUG) log(`Auto-allowing safe Bash: ${String(toolRequest.tool_input?.command).substring(0, 80)}`)
      res.writeHead(200, { 'Content-Type': 'application/json' })
      res.end(JSON.stringify(allowResponse('Safe read-only command')))
      return
    }

    // Generate question ID and wait for user decision
    const questionId = `hook-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`

    const decision = await new Promise<PermissionDecision>((resolve) => {
      const timeout = setTimeout(() => {
        log(`Permission timeout [${questionId}] — auto-denying`)
        this.pendingRequests.delete(questionId)
        resolve({ decision: 'deny', reason: 'Permission timed out after 5 minutes' })
      }, PERMISSION_TIMEOUT_MS)

      this.pendingRequests.set(questionId, {
        toolRequest,
        resolve,
        timeout,
        questionId,
        runToken: urlToken,
      })

      // Get tool-specific options for the permission card
      const options = this.getOptionsForTool(toolName, toolRequest.tool_input)

      // Emit with direct tabId from registration — no session_id lookup needed
      this.emit('permission-request', questionId, toolRequest, registration.tabId, options)
    })

    // Return structured hook response
    const hookResponse = decision.decision === 'allow'
      ? allowResponse(decision.reason || 'Approved by user')
      : denyResponse(decision.reason || 'Denied by user')

    if (DEBUG) {
      log(`Hook response [${questionId}]: ${decision.decision}`)
    }
    res.writeHead(200, { 'Content-Type': 'application/json' })
    res.end(JSON.stringify(hookResponse))
  }
}

/** Mask sensitive fields in tool_input (recursive). Exported for defense-in-depth use by control-plane. */
export function maskSensitiveFields(input: Record<string, unknown>): Record<string, unknown> {
  const masked: Record<string, unknown> = {}
  for (const [key, value] of Object.entries(input)) {
    if (SENSITIVE_FIELD_RE.test(key)) {
      masked[key] = '***'
    } else if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
      masked[key] = maskSensitiveFields(value as Record<string, unknown>)
    } else if (Array.isArray(value)) {
      masked[key] = value.map(item =>
        item !== null && typeof item === 'object' && !Array.isArray(item)
          ? maskSensitiveFields(item as Record<string, unknown>)
          : item
      )
    } else {
      masked[key] = value
    }
  }
  return masked
}


================================================
FILE: src/main/index.ts
================================================
import { app, BrowserWindow, ipcMain, dialog, screen, globalShortcut, Tray, Menu, nativeImage, nativeTheme, shell, systemPreferences, session } from 'electron'
import { join } from 'path'
import { existsSync, readdirSync, statSync, createReadStream } from 'fs'
import { createInterface } from 'readline'
import { homedir } from 'os'
import { ControlPlane } from './claude/control-plane'
import { ensureSkills, type SkillStatus } from './skills/installer'
import { fetchCatalog, listInstalled, installPlugin, uninstallPlugin } from './marketplace/catalog'
import { log as _log, LOG_FILE, flushLogs } from './logger'
import { getCliEnv } from './cli-env'
import { IPC } from '../shared/types'
import type { RunOptions, NormalizedEvent, EnrichedError } from '../shared/types'

const DEBUG_MODE = process.env.CLUI_DEBUG === '1'
const SPACES_DEBUG = DEBUG_MODE || process.env.CLUI_SPACES_DEBUG === '1'

function getContentSecurityPolicy(): string {
  const isDev = !!process.env.ELECTRON_RENDERER_URL
  const connectSrc = isDev
    ? "connect-src 'self' ws://localhost:* http://localhost:*;"
    : "connect-src 'self';"
  const scriptSrc = isDev
    ? "script-src 'self' 'unsafe-inline' 'unsafe-eval';"
    : "script-src 'self';"

  return [
    "default-src 'none'",
    scriptSrc,
    "style-src 'self' 'unsafe-inline'",
    "img-src 'self' data: blob:",
    "media-src 'self' data: blob:",
    "font-src 'self'",
    connectSrc,
    "object-src 'none'",
    "base-uri 'none'",
    "frame-src 'none'",
  ].join('; ')
}

function installContentSecurityPolicy(): void {
  const csp = getContentSecurityPolicy()
  session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
    callback({
      responseHeaders: {
        ...details.responseHeaders,
        'Content-Security-Policy': [csp],
      },
    })
  })
}

function log(msg: string): void {
  _log('main', msg)
}

let mainWindow: BrowserWindow | null = null
let tray: Tray | null = null
let screenshotCounter = 0
let toggleSequence = 0
let lastWindowBounds: Electron.Rectangle | null = null

// Feature flag: enable PTY interactive permissions transport
const INTERACTIVE_PTY = process.env.CLUI_INTERACTIVE_PERMISSIONS_PTY === '1'

const controlPlane = new ControlPlane(INTERACTIVE_PTY)

// Keep native width fixed to avoid renderer animation vs setBounds race.
// The UI itself still launches in compact mode; extra width is transparent/click-through.
const BAR_WIDTH = 1040
const PILL_HEIGHT = 720  // Fixed native window height — extra room for expanded UI + shadow buffers
const PILL_BOTTOM_MARGIN = 24

// ─── Broadcast to renderer ───

function broadcast(channel: string, ...args: unknown[]): void {
  if (mainWindow && !mainWindow.isDestroyed()) {
    mainWindow.webContents.send(channel, ...args)
  }
}

function snapshotWindowState(reason: string): void {
  if (!SPACES_DEBUG) return
  if (!mainWindow || mainWindow.isDestroyed()) {
    log(`[spaces] ${reason} window=none`)
    return
  }

  const b = mainWindow.getBounds()
  const cursor = screen.getCursorScreenPoint()
  const display = screen.getDisplayNearestPoint(cursor)
  const visibleOnAll = mainWindow.isVisibleOnAllWorkspaces()
  const wcFocused = mainWindow.webContents.isFocused()

  log(
    `[spaces] ${reason} ` +
    `vis=${mainWindow.isVisible()} focused=${mainWindow.isFocused()} wcFocused=${wcFocused} ` +
    `alwaysOnTop=${mainWindow.isAlwaysOnTop()} allWs=${visibleOnAll} ` +
    `bounds=(${b.x},${b.y},${b.width}x${b.height}) ` +
    `cursor=(${cursor.x},${cursor.y}) display=${display.id} ` +
    `workArea=(${display.workArea.x},${display.workArea.y},${display.workArea.width}x${display.workArea.height})`
  )
}

function scheduleToggleSnapshots(toggleId: number, phase: 'show' | 'hide'): void {
  if (!SPACES_DEBUG) return
  const probes = [0, 100, 400, 1200]
  for (const delay of probes) {
    setTimeout(() => {
      snapshotWindowState(`toggle#${toggleId} ${phase} +${delay}ms`)
    }, delay)
  }
}


// ─── Wire ControlPlane events → renderer ───

controlPlane.on('event', (tabId: string, event: NormalizedEvent) => {
  broadcast('clui:normalized-event', tabId, event)
})

controlPlane.on('tab-status-change', (tabId: string, newStatus: string, oldStatus: string) => {
  broadcast('clui:tab-status-change', tabId, newStatus, oldStatus)
})

controlPlane.on('error', (tabId: string, error: EnrichedError) => {
  broadcast('clui:enriched-error', tabId, error)
})

// ─── Window Creation ───

function createWindow(): void {
  const cursor = screen.getCursorScreenPoint()
  const display = screen.getDisplayNearestPoint(cursor)
  const { width: screenWidth, height: screenHeight } = display.workAreaSize
  const { x: dx, y: dy } = display.workArea

  const x = dx + Math.round((screenWidth - BAR_WIDTH) / 2)
  const y = dy + screenHeight - PILL_HEIGHT - PILL_BOTTOM_MARGIN

  mainWindow = new BrowserWindow({
    width: BAR_WIDTH,
    height: PILL_HEIGHT,
    x,
    y,
    ...(process.platform === 'darwin' ? { type: 'panel' as const } : {}),  // NSPanel — non-activating, joins all spaces
    frame: false,
    transparent: true,
    resizable: false,
    movable: true,
    alwaysOnTop: true,
    skipTaskbar: true,
    hasShadow: false,
    roundedCorners: true,
    backgroundColor: '#00000000',
    show: false,
    icon: join(__dirname, '../../resources/icon.icns'),
    webPreferences: {
      preload: join(__dirname, '../preload/index.js'),
      sandbox: true,
      contextIsolation: true,
      nodeIntegration: false,
      webSecurity: true,
      allowRunningInsecureContent: false,
    },
  })
  lastWindowBounds = mainWindow.getBounds()

  // Belt-and-suspenders: panel already joins all spaces and floats,
  // but explicit flags ensure correct behavior on older Electron builds.
  mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
  mainWindow.setAlwaysOnTop(true, 'screen-saver')
  mainWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
  mainWindow.webContents.on('will-navigate', (event) => {
    event.preventDefault()
  })

  mainWindow.once('ready-to-show', () => {
    mainWindow?.show()
    // Enable OS-level click-through for transparent regions.
    // { forward: true } ensures mousemove events still reach the renderer
    // so it can toggle click-through off when cursor enters interactive UI.
    mainWindow?.setIgnoreMouseEvents(true, { forward: true })
    if (process.env.ELECTRON_RENDERER_URL) {
      mainWindow?.webContents.openDevTools({ mode: 'detach' })
    }
  })

  let forceQuit = false
  app.on('before-quit', () => { forceQuit = true })
  mainWindow.on('close', (e) => {
    if (!forceQuit) {
      e.preventDefault()
      mainWindow?.hide()
    }
  })

  if (process.env.ELECTRON_RENDERER_URL) {
    mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
  } else {
    mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
  }
}

function showWindow(source = 'unknown'): void {
  if (!mainWindow) return
  const toggleId = ++toggleSequence

  if (lastWindowBounds) {
    mainWindow.setBounds(lastWindowBounds)
  }

  // Always re-assert space membership — the flag can be lost after hide/show cycles
  // and must be set before show() so the window joins the active Space, not its
  // last-known Space.
  mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })

  if (SPACES_DEBUG) {
    const b = mainWindow.getBounds()
    log(`[spaces] showWindow#${toggleId} source=${source} preserve-bounds=(${b.x},${b.y},${b.width}x${b.height})`)
    snapshotWindowState(`showWindow#${toggleId} pre-show`)
  }
  // As an accessory app (app.dock.hide), show() + focus gives keyboard
  // without deactivating the active app — hover preserved everywhere.
  mainWindow.show()
  if (lastWindowBounds) {
    mainWindow.setBounds(lastWindowBounds)
  }
  mainWindow.webContents.focus()
  broadcast(IPC.WINDOW_SHOWN)
  if (SPACES_DEBUG) scheduleToggleSnapshots(toggleId, 'show')
}

function resetWindowPosition(): void {
  if (!mainWindow) return

  const cursor = screen.getCursorScreenPoint()
  const display = screen.getDisplayNearestPoint(cursor)
  const { width: sw, height: sh } = display.workAreaSize
  const { x: dx, y: dy } = display.workArea

  mainWindow.setBounds({
    x: dx + Math.round((sw - BAR_WIDTH) / 2),
    y: dy + sh - PILL_HEIGHT - PILL_BOTTOM_MARGIN,
    width: BAR_WIDTH,
    height: PILL_HEIGHT,
  })
  lastWindowBounds = mainWindow.getBounds()
}

function toggleWindow(source = 'unknown'): void {
  if (!mainWindow) return
  const toggleId = ++toggleSequence
  if (SPACES_DEBUG) {
    log(`[spaces] toggle#${toggleId} source=${source} start`)
    snapshotWindowState(`toggle#${toggleId} pre`)
  }

  if (mainWindow.isVisible()) {
    mainWindow.hide()
    if (SPACES_DEBUG) scheduleToggleSnapshots(toggleId, 'hide')
  } else {
    showWindow(source)
  }
}

// ─── Resize ───
// Fixed-height mode: ignore renderer resize events to prevent jank.
// The native window stays at PILL_HEIGHT; all expand/collapse happens inside the renderer.

ipcMain.on(IPC.RESIZE_HEIGHT, () => {
  // No-op — fixed height window, no dynamic resize
})

ipcMain.on(IPC.SET_WINDOW_WIDTH, () => {
  // No-op — native width is fixed to keep expand/collapse animation smooth.
})

ipcMain.handle(IPC.ANIMATE_HEIGHT, () => {
  // No-op — kept for API compat, animation handled purely in renderer
})

ipcMain.on(IPC.HIDE_WINDOW, () => {
  mainWindow?.hide()
})

ipcMain.handle(IPC.IS_VISIBLE, () => {
  return mainWindow?.isVisible() ?? false
})

// OS-level click-through toggle — renderer calls this on mousemove
// to enable clicks on interactive UI while passing through transparent areas
ipcMain.on(IPC.SET_IGNORE_MOUSE_EVENTS, (event, ignore: boolean, options?: { forward?: boolean }) => {
  const win = BrowserWindow.fromWebContents(event.sender)
  if (win && !win.isDestroyed()) {
    win.setIgnoreMouseEvents(ignore, options || {})
  }
})

// Manual window drag — works reliably with frameless + setIgnoreMouseEvents
ipcMain.on(IPC.START_WINDOW_DRAG, (event, deltaX: number, deltaY: number) => {
  const win = BrowserWindow.fromWebContents(event.sender)
  if (win && !win.isDestroyed()) {
    const [x, y] = win.getPosition()
    // Vertical is handled in two phases in the renderer: window first (until macOS clamps),
    // then CSS translateY within the window — so deltaY here is always within allowed range
    win.setPosition(Math.round(x + deltaX), Math.round(y + deltaY))
    lastWindowBounds = win.getBounds()
  }
})

ipcMain.on(IPC.RESET_WINDOW_POSITION, () => {
  resetWindowPosition()
})

// ─── IPC Handlers (typed, strict) ───

ipcMain.handle(IPC.START, async () => {
  log('IPC START — fetching static CLI info')
  const { execSync } = require('child_process')

  let version = 'unknown'
  try {
    version = execSync('claude -v', { encoding: 'utf-8', timeout: 5000, env: getCliEnv() }).trim()
  } catch {}

  let auth: { email?: string; subscriptionType?: string; authMethod?: string } = {}
  try {
    const raw = execSync('claude auth status', { encoding: 'utf-8', timeout: 5000, env: getCliEnv() }).trim()
    auth = JSON.parse(raw)
  } catch {}

  let mcpServers: string[] = []
  try {
    const raw = execSync('claude mcp list', { encoding: 'utf-8', timeout: 5000, env: getCliEnv() }).trim()
    if (raw) mcpServers = raw.split('\n').filter(Boolean)
  } catch {}

  return { version, auth, mcpServers, projectPath: process.cwd(), homePath: require('os').homedir() }
})

ipcMain.handle(IPC.CREATE_TAB, () => {
  const tabId = controlPlane.createTab()
  log(`IPC CREATE_TAB → ${tabId}`)
  return { tabId }
})

ipcMain.on(IPC.INIT_SESSION, (_event, tabId: string) => {
  log(`IPC INIT_SESSION: ${tabId}`)
  controlPlane.initSession(tabId)
})

ipcMain.on(IPC.RESET_TAB_SESSION, (_event, tabId: string) => {
  log(`IPC RESET_TAB_SESSION: ${tabId}`)
  controlPlane.resetTabSession(tabId)
})

ipcMain.handle(IPC.PROMPT, async (_event, { tabId, requestId, options }: { tabId: string; requestId: string; options: RunOptions }) => {
  if (DEBUG_MODE) {
    log(`IPC PROMPT: tab=${tabId} req=${requestId} prompt="${options.prompt.substring(0, 100)}"`)
  } else {
    log(`IPC PROMPT: tab=${tabId} req=${requestId}`)
  }

  if (!tabId) {
    throw new Error('No tabId provided — prompt rejected')
  }
  if (!requestId) {
    throw new Error('No requestId provided — prompt rejected')
  }

  try {
    await controlPlane.submitPrompt(tabId, requestId, options)
  } catch (err: unknown) {
    const msg = err instanceof Error ? err.message : String(err)
    log(`PROMPT error: ${msg}`)
    throw err
  }
})

ipcMain.handle(IPC.CANCEL, (_event, requestId: string) => {
  log(`IPC CANCEL: ${requestId}`)
  return controlPlane.cancel(requestId)
})

ipcMain.handle(IPC.STOP_TAB, (_event, tabId: string) => {
  log(`IPC STOP_TAB: ${tabId}`)
  return controlPlane.cancelTab(tabId)
})

ipcMain.handle(IPC.RETRY, async (_event, { tabId, requestId, options }: { tabId: string; requestId: string; options: RunOptions }) => {
  log(`IPC RETRY: tab=${tabId} req=${requestId}`)
  return controlPlane.retry(tabId, requestId, options)
})

ipcMain.handle(IPC.STATUS, () => {
  return controlPlane.getHealth()
})

ipcMain.handle(IPC.TAB_HEALTH, () => {
  return controlPlane.getHealth()
})

ipcMain.handle(IPC.CLOSE_TAB, (_event, tabId: string) => {
  log(`IPC CLOSE_TAB: ${tabId}`)
  controlPlane.closeTab(tabId)
})

ipcMain.on(IPC.SET_PERMISSION_MODE, (_event, mode: string) => {
  if (mode !== 'ask' && mode !== 'auto') {
    log(`IPC SET_PERMISSION_MODE: invalid mode "${mode}" — ignoring`)
    return
  }
  log(`IPC SET_PERMISSION_MODE: ${mode}`)
  controlPlane.setPermissionMode(mode)
})

ipcMain.handle(IPC.RESPOND_PERMISSION, (_event, { tabId, questionId, optionId }: { tabId: string; questionId: string; optionId: string }) => {
  log(`IPC RESPOND_PERMISSION: tab=${tabId} question=${questionId} option=${optionId}`)
  return controlPlane.respondToPermission(tabId, questionId, optionId)
})

ipcMain.handle(IPC.LIST_SESSIONS, async (_e, projectPath?: string) => {
  log(`IPC LIST_SESSIONS ${projectPath ? `(path=${projectPath})` : ''}`)
  try {
    const cwd = projectPath || process.cwd()
    // Validate projectPath — reject null bytes, newlines, non-absolute paths
    if (/[\0\r\n]/.test(cwd) || !cwd.startsWith('/')) {
      log(`LIST_SESSIONS: rejected invalid projectPath: ${cwd}`)
      return []
    }
    // Claude stores project sessions at ~/.claude/projects/<encoded-path>/
    // Path encoding: replace all '/' with '-' (leading '/' becomes leading '-')
    const encodedPath = cwd.replace(/\//g, '-')
    const sessionsDir = join(homedir(), '.claude', 'projects', encodedPath)
    if (!existsSync(sessionsDir)) {
      log(`LIST_SESSIONS: directory not found: ${sessionsDir}`)
      return []
    }
    const files = readdirSync(sessionsDir).filter((f: string) => f.endsWith('.jsonl'))

    const sessions: Array<{ sessionId: string; slug: string | null; firstMessage: string | null; lastTimestamp: string; size: number }> = []

    // UUID v4 regex — only consider files named as valid UUIDs
    const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i

    for (const file of files) {
      // The filename (without .jsonl) IS the canonical resume ID for `claude --resume`
      const fileSessionId = file.replace(/\.jsonl$/, '')
      if (!UUID_RE.test(fileSessionId)) continue // skip non-UUID files

      const filePath = join(sessionsDir, file)
      const stat = statSync(filePath)
      if (stat.size < 100) continue // skip trivially small files

      // Read lines to extract metadata and validate transcript schema
      const meta: { validated: boolean; slug: string | null; firstMessage: string | null; lastTimestamp: string | null } = {
        validated: false, slug: null, firstMessage: null, lastTimestamp: null,
      }

      await new Promise<void>((resolve) => {
        const rl = createInterface({ input: createReadStream(filePath) })
        rl.on('line', (line: string) => {
          try {
            const obj = JSON.parse(line)
            // Validate: must have expected Claude transcript fields
            if (!meta.validated && obj.type && obj.uuid && obj.timestamp) {
              meta.validated = true
            }
            if (obj.slug && !meta.slug) meta.slug = obj.slug
            if (obj.timestamp) meta.lastTimestamp = obj.timestamp
            if (obj.type === 'user' && !meta.firstMessage) {
              const content = obj.message?.content
              if (typeof content === 'string') {
                meta.firstMessage = content.substring(0, 100)
              } else if (Array.isArray(content)) {
                const textPart = content.find((p: any) => p.type === 'text')
                meta.firstMessage = textPart?.text?.substring(0, 100) || null
              }
            }
          } catch {}
          // Read all lines to get the last timestamp
        })
        rl.on('close', () => resolve())
      })

      if (meta.validated) {
        sessions.push({
          sessionId: fileSessionId,
          slug: meta.slug,
          firstMessage: meta.firstMessage,
          lastTimestamp: meta.lastTimestamp || stat.mtime.toISOString(),
          size: stat.size,
        })
      }
    }

    // Sort by last timestamp, most recent first
    sessions.sort((a, b) => new Date(b.lastTimestamp).getTime() - new Date(a.lastTimestamp).getTime())
    return sessions.slice(0, 20) // Return top 20
  } catch (err) {
    log(`LIST_SESSIONS error: ${err}`)
    return []
  }
})

// Load conversation history from a session's JSONL file
ipcMain.handle(IPC.LOAD_SESSION, async (_e, arg: { sessionId: string; projectPath?: string } | string) => {
  const sessionId = typeof arg === 'string' ? arg : arg.sessionId
  const projectPath = typeof arg === 'string' ? undefined : arg.projectPath
  log(`IPC LOAD_SESSION ${sessionId}${projectPath ? ` (path=${projectPath})` : ''}`)

  // Validate sessionId — must be strict UUID to prevent path traversal via crafted filenames
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
  if (!UUID_RE.test(sessionId)) {
    log(`LOAD_SESSION: rejected invalid sessionId: ${sessionId}`)
    return []
  }

  try {
    const cwd = projectPath || process.cwd()
    // Validate projectPath — reject null bytes, newlines, non-absolute paths
    if (/[\0\r\n]/.test(cwd) || !cwd.startsWith('/')) {
      log(`LOAD_SESSION: rejected invalid projectPath: ${cwd}`)
      return []
    }
    const encodedPath = cwd.replace(/\//g, '-')
    const filePath = join(homedir(), '.claude', 'projects', encodedPath, `${sessionId}.jsonl`)
    if (!existsSync(filePath)) return []

    const messages: Array<{ role: string; content: string; toolName?: string; timestamp: number }> = []
    await new Promise<void>((resolve) => {
      const rl = createInterface({ input: createReadStream(filePath) })
      rl.on('line', (line: string) => {
        try {
          const obj = JSON.parse(line)
          if (obj.type === 'user') {
            const content = obj.message?.content
            let text = ''
            if (typeof content === 'string') {
              text = content
            } else if (Array.isArray(content)) {
              text = content
                .filter((b: any) => b.type === 'text')
                .map((b: any) => b.text)
                .join('\n')
            }
            if (text) {
              messages.push({ role: 'user', content: text, timestamp: new Date(obj.timestamp).getTime() })
            }
          } else if (obj.type === 'assistant') {
            const content = obj.message?.content
            if (Array.isArray(content)) {
              for (const block of content) {
                if (block.type === 'text' && block.text) {
                  messages.push({ role: 'assistant', content: block.text, timestamp: new Date(obj.timestamp).getTime() })
                } else if (block.type === 'tool_use' && block.name) {
                  messages.push({
                    role: 'tool',
                    content: '',
                    toolName: block.name,
                    timestamp: new Date(obj.timestamp).getTime(),
                  })
                }
              }
            }
          }
        } catch {}
      })
      rl.on('close', () => resolve())
    })
    return messages
  } catch (err) {
    log(`LOAD_SESSION error: ${err}`)
    return []
  }
})

ipcMain.handle(IPC.SELECT_DIRECTORY, async () => {
  if (!mainWindow) return null
  // macOS: activate app so unparented dialog appears on top (not behind other apps).
  // Unparented avoids modal dimming on the transparent overlay.
  // Activation is fine here — user is actively interacting with CLUI.
  if (process.platform === 'darwin') app.focus()
  const options = { properties: ['openDirectory'] as const }
  const result = process.platform === 'darwin'
    ? await dialog.showOpenDialog(options)
    : await dialog.showOpenDialog(mainWindow, options)
  return result.canceled ? null : result.filePaths[0]
})

ipcMain.handle(IPC.OPEN_EXTERNAL, async (_event, url: string) => {
  try {
    // Parse with URL constructor to reject malformed/ambiguous payloads
    const parsed = new URL(url)
    if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
    if (!parsed.hostname) return false
    await shell.openExternal(parsed.href)
    return true
  } catch {
    return false
  }
})

ipcMain.handle(IPC.ATTACH_FILES, async () => {
  if (!mainWindow) return null
  // macOS: activate app so unparented dialog appears on top
  if (process.platform === 'darwin') app.focus()
  const options = {
    properties: ['openFile', 'multiSelections'],
    filters: [
      { name: 'All Files', extensions: ['*'] },
      { name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'] },
      { name: 'Code', extensions: ['ts', 'tsx', 'js', 'jsx', 'py', 'rs', 'go', 'md', 'json', 'yaml', 'toml'] },
    ],
  }
  const result = process.platform === 'darwin'
    ? await dialog.showOpenDialog(options)
    : await dialog.showOpenDialog(mainWindow, options)
  if (result.canceled || result.filePaths.length === 0) return null

  const { basename, extname } = require('path')
  const { readFileSync, statSync } = require('fs')

  const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'])
  const mimeMap: Record<string, string> = {
    '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
    '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
    '.pdf': 'application/pdf', '.txt': 'text/plain', '.md': 'text/markdown',
    '.json': 'application/json', '.yaml': 'text/yaml', '.toml': 'text/toml',
  }

  return result.filePaths.map((fp: string) => {
    const ext = extname(fp).toLowerCase()
    const mime = mimeMap[ext] || 'application/octet-stream'
    const stat = statSync(fp)
    let dataUrl: string | undefined

    // Generate preview data URL for images (max 2MB to keep IPC fast)
    if (IMAGE_EXTS.has(ext) && stat.size < 2 * 1024 * 1024) {
      try {
        const buf = readFileSync(fp)
        dataUrl = `data:${mime};base64,${buf.toString('base64')}`
      } catch {}
    }

    return {
      id: crypto.randomUUID(),
      type: IMAGE_EXTS.has(ext) ? 'image' : 'file',
      name: basename(fp),
      path: fp,
      mimeType: mime,
      dataUrl,
      size: stat.size,
    }
  })
})

ipcMain.handle(IPC.TAKE_SCREENSHOT, async () => {
  if (!mainWindow) return null

  if (SPACES_DEBUG) snapshotWindowState('screenshot pre-hide')
  mainWindow.hide()
  await new Promise((r) => setTimeout(r, 300))

  try {
    const { execSync } = require('child_process')
    const { join } = require('path')
    const { tmpdir } = require('os')
    const { readFileSync, existsSync } = require('fs')

    const timestamp = Date.now()
    const screenshotPath = join(tmpdir(), `clui-screenshot-${timestamp}.png`)

    execSync(`/usr/sbin/screencapture -i "${screenshotPath}"`, {
      timeout: 30000,
      stdio: 'ignore',
    })

    if (!existsSync(screenshotPath)) {
      return null
    }

    // Return structured attachment with data URL preview
    const buf = readFileSync(screenshotPath)
    return {
      id: crypto.randomUUID(),
      type: 'image',
      name: `screenshot ${++screenshotCounter}.png`,
      path: screenshotPath,
      mimeType: 'image/png',
      dataUrl: `data:image/png;base64,${buf.toString('base64')}`,
      size: buf.length,
    }
  } catch {
    return null
  } finally {
    if (mainWindow) {
      mainWindow.show()
      mainWindow.webContents.focus()
    }
    broadcast(IPC.WINDOW_SHOWN)
    if (SPACES_DEBUG) {
      log('[spaces] screenshot restore show+focus')
      snapshotWindowState('screenshot restore immediate')
      setTimeout(() => snapshotWindowState('screenshot restore +200ms'), 200)
    }
  }
})

let pasteCounter = 0
ipcMain.handle(IPC.PASTE_IMAGE, async (_event, dataUrl: string) => {
  try {
    const { writeFileSync } = require('fs')
    const { join } = require('path')
    const { tmpdir } = require('os')

    // Parse data URL: "data:image/png;base64,..."
    const match = dataUrl.match(/^data:(image\/(\w+));base64,(.+)$/)
    if (!match) return null

    const [, mimeType, ext, base64Data] = match
    const buf = Buffer.from(base64Data, 'base64')
    const timestamp = Date.now()
    const filePath = join(tmpdir(), `clui-paste-${timestamp}.${ext}`)
    writeFileSync(filePath, buf)

    return {
      id: crypto.randomUUID(),
      type: 'image',
      name: `pasted image ${++pasteCounter}.${ext}`,
      path: filePath,
      mimeType,
      dataUrl,
      size: buf.length,
    }
  } catch {
    return null
  }
})

ipcMain.handle(IPC.TRANSCRIBE_AUDIO, async (_event, audioBase64: string) => {
  const { writeFileSync, existsSync, unlinkSync, readFileSync } = require('fs')
  const { execFile } = require('child_process')
  const { join, basename } = require('path')
  const { tmpdir } = require('os')

  const startedAt = Date.now()
  const phaseMs: Record<string, number> = {}
  const mark = (name: string, t0: number) => { phaseMs[name] = Date.now() - t0 }

  const tmpWav = join(tmpdir(), `clui-voice-${Date.now()}.wav`)
  try {
    const runExecFile = (bin: string, args: string[], timeout: number): Promise<string> =>
      new Promise((resolve, reject) => {
        execFile(bin, args, { encoding: 'utf-8', timeout }, (err: any, stdout: string, stderr: string) => {
          if (err) {
            const detail = stderr?.trim() || stdout?.trim() || err.message
            reject(new Error(detail))
            return
          }
          resolve(stdout || '')
        })
      })

    let t0 = Date.now()
    const buf = Buffer.from(audioBase64, 'base64')
    writeFileSync(tmpWav, buf)
    mark('decode+write_wav', t0)

    // Find whisper backend in priority order: whisperkit-cli (Apple Silicon CoreML) → whisper-cli (whisper-cpp) → whisper (python)
    t0 = Date.now()
    const candidates = [
      '/opt/homebrew/bin/whisperkit-cli',
      '/usr/local/bin/whisperkit-cli',
      '/opt/homebrew/bin/whisper-cli',
      '/usr/local/bin/whisper-cli',
      '/opt/homebrew/bin/whisper',
      '/usr/local/bin/whisper',
      join(homedir(), '.local/bin/whisper'),
    ]

    let whisperBin = ''
    for (const c of candidates) {
      if (existsSync(c)) { whisperBin = c; break }
    }
    mark('probe_binary_paths', t0)

    if (!whisperBin) {
      t0 = Date.now()
      for (const name of ['whisperkit-cli', 'whisper-cli', 'whisper']) {
        try {
          whisperBin = await runExecFile('/bin/zsh', ['-lc', `whence -p ${name}`], 5000).then((s) => s.trim())
          if (whisperBin) break
        } catch {}
      }
      mark('probe_binary_whence', t0)
    }

    if (!whisperBin) {
      const hint = process.arch === 'arm64'
        ? 'brew install whisperkit-cli   (or: brew install whisper-cpp)'
        : 'brew install whisper-cpp'
      return {
        error: `Whisper not found. Install with:\n  ${hint}`,
        transcript: null,
      }
    }

    const isWhisperKit = whisperBin.includes('whisperkit-cli')
    const isWhisperCpp = !isWhisperKit && whisperBin.includes('whisper-cli')

    log(`Transcribing with: ${whisperBin} (backend: ${isWhisperKit ? 'WhisperKit' : isWhisperCpp ? 'whisper-cpp' : 'Python whisper'})`)

    let output: string
    if (isWhisperKit) {
      // WhisperKit (Apple Silicon CoreML) — auto-downloads models on first run
      // Use --report to produce a JSON file with a top-level "text" field for deterministic parsing
      const reportDir = tmpdir()
      t0 = Date.now()
      output = await runExecFile(
        whisperBin,
      
Download .txt
gitextract_jg4oi3wy/

├── .gitignore
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── SECURITY.md
├── commands/
│   ├── install-app.command
│   ├── setup.command
│   ├── start.command
│   └── stop.command
├── docs/
│   ├── AGENTS.md
│   ├── ARCHITECTURE.md
│   ├── TROUBLESHOOTING.md
│   ├── oss-readiness-report.md
│   ├── release-smoke-test.md
│   └── slash-command-matrix.md
├── electron.vite.config.ts
├── install-app.command
├── package.json
├── resources/
│   ├── entitlements.mac.plist
│   └── icon.icns
├── scripts/
│   ├── doctor.sh
│   └── patch-dev-icon.sh
├── src/
│   ├── main/
│   │   ├── claude/
│   │   │   ├── control-plane.ts
│   │   │   ├── event-normalizer.ts
│   │   │   ├── pty-run-manager.ts
│   │   │   └── run-manager.ts
│   │   ├── cli-env.ts
│   │   ├── hooks/
│   │   │   └── permission-server.ts
│   │   ├── index.ts
│   │   ├── logger.ts
│   │   ├── marketplace/
│   │   │   └── catalog.ts
│   │   ├── process-manager.ts
│   │   ├── skills/
│   │   │   ├── installer.ts
│   │   │   └── manifest.ts
│   │   └── stream-parser.ts
│   ├── preload/
│   │   └── index.ts
│   ├── renderer/
│   │   ├── App.tsx
│   │   ├── components/
│   │   │   ├── AttachmentChips.tsx
│   │   │   ├── ConversationView.tsx
│   │   │   ├── HistoryPicker.tsx
│   │   │   ├── InputBar.tsx
│   │   │   ├── MarketplacePanel.tsx
│   │   │   ├── PermissionCard.tsx
│   │   │   ├── PermissionDeniedCard.tsx
│   │   │   ├── PopoverLayer.tsx
│   │   │   ├── SettingsPopover.tsx
│   │   │   ├── SlashCommandMenu.tsx
│   │   │   ├── StatusBar.tsx
│   │   │   └── TabStrip.tsx
│   │   ├── env.d.ts
│   │   ├── hooks/
│   │   │   ├── useClaudeEvents.ts
│   │   │   └── useHealthReconciliation.ts
│   │   ├── index.css
│   │   ├── index.html
│   │   ├── main.tsx
│   │   ├── stores/
│   │   │   └── sessionStore.ts
│   │   └── theme.ts
│   └── shared/
│       └── types.ts
└── tsconfig.json
Download .txt
SYMBOL INDEX (319 symbols across 33 files)

FILE: src/main/claude/control-plane.ts
  constant MAX_QUEUE_DEPTH (line 16) | const MAX_QUEUE_DEPTH = 32
  function log (line 18) | function log(msg: string): void {
  type QueuedRequest (line 22) | interface QueuedRequest {
  type InflightRequest (line 33) | interface InflightRequest {
  class ControlPlane (line 58) | class ControlPlane extends EventEmitter {
    method constructor (line 79) | constructor(interactivePty = false) {
    method _wirePtyEvents (line 291) | private _wirePtyEvents(): void {
    method createTab (line 432) | createTab(): string {
    method initSession (line 453) | initSession(tabId: string): void {
    method resetTabSession (line 474) | resetTabSession(tabId: string): void {
    method setPermissionMode (line 485) | setPermissionMode(mode: 'ask' | 'auto'): void {
    method closeTab (line 490) | closeTab(tabId: string): void {
    method submitPrompt (line 533) | async submitPrompt(
    method _dispatch (line 587) | private async _dispatch(tabId: string, requestId: string, options: Run...
    method cancel (line 655) | cancel(requestId: string): boolean {
    method cancelTab (line 677) | cancelTab(tabId: string): boolean {
    method retry (line 689) | async retry(tabId: string, requestId: string, options: RunOptions): Pr...
    method respondToPermission (line 704) | respondToPermission(tabId: string, questionId: string, optionId: strin...
    method getHealth (line 732) | getHealth(): HealthReport {
    method getTabStatus (line 757) | getTabStatus(tabId: string): TabRegistryEntry | undefined {
    method getEnrichedError (line 761) | getEnrichedError(requestId: string, exitCode: number | null): Enriched...
    method _processQueue (line 770) | private _processQueue(tabId: string): void {
    method _findTabByRequest (line 791) | private _findTabByRequest(requestId: string): string | null {
    method _setTabStatus (line 803) | private _setTabStatus(tabId: string, newStatus: TabStatus): void {
    method shutdown (line 817) | shutdown(): void {

FILE: src/main/claude/event-normalizer.ts
  function normalize (line 20) | function normalize(raw: ClaudeEvent): NormalizedEvent[] {
  function normalizeSystem (line 46) | function normalizeSystem(event: InitEvent): NormalizedEvent[] {
  function normalizeStreamEvent (line 60) | function normalizeStreamEvent(event: StreamEvent): NormalizedEvent[] {
  function normalizeAssistant (line 111) | function normalizeAssistant(event: AssistantEvent): NormalizedEvent[] {
  function normalizeResult (line 118) | function normalizeResult(event: ResultEvent): NormalizedEvent[] {
  function normalizeRateLimit (line 147) | function normalizeRateLimit(event: RateLimitEvent): NormalizedEvent[] {
  function normalizePermission (line 159) | function normalizePermission(event: PermissionEvent): NormalizedEvent[] {

FILE: src/main/claude/pty-run-manager.ts
  constant LOG_FILE (line 35) | const LOG_FILE = join(homedir(), '.clui-debug.log')
  constant MAX_RING_LINES (line 36) | const MAX_RING_LINES = 100
  constant PTY_BUFFER_SIZE (line 37) | const PTY_BUFFER_SIZE = 50 // rolling window of cleaned lines for parser...
  constant PERMISSION_TIMEOUT_MS (line 38) | const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
  constant QUIESCENCE_MS (line 39) | const QUIESCENCE_MS = 2000
  function log (line 41) | function log(msg: string): void {
  function stripAnsi (line 51) | function stripAnsi(str: string): string {
  type ParsedPermission (line 62) | interface ParsedPermission {
  function detectPermissionPrompt (line 73) | function detectPermissionPrompt(lines: string[]): ParsedPermission | null {
  function extractSessionId (line 166) | function extractSessionId(text: string): string | null {
  function isInputPrompt (line 180) | function isInputPrompt(line: string): boolean {
  function isUiChrome (line 188) | function isUiChrome(line: string): boolean {
  function parseToolCallLine (line 219) | function parseToolCallLine(line: string): { toolName: string; input: str...
  type PtyRunHandle (line 231) | interface PtyRunHandle {
  class PtyRunManager (line 275) | class PtyRunManager extends EventEmitter {
    method constructor (line 280) | constructor() {
    method _ensureSpawnHelperExecutable (line 291) | private _ensureSpawnHelperExecutable(): void {
    method _findClaudeBinary (line 313) | private _findClaudeBinary(): string {
    method _getEnv (line 338) | private _getEnv(): NodeJS.ProcessEnv {
    method startRun (line 348) | startRun(requestId: string, options: RunOptions): PtyRunHandle {
    method _processLine (line 514) | private _processLine(requestId: string, handle: PtyRunHandle, rawLine:...
    method _checkQuiescenceCompletion (line 607) | private _checkQuiescenceCompletion(requestId: string, handle: PtyRunHa...
    method _scheduleTextFlush (line 640) | private _scheduleTextFlush(requestId: string, handle: PtyRunHandle): v...
    method _flushText (line 651) | private _flushText(requestId: string, handle: PtyRunHandle): void {
    method _checkPermissionInBuffer (line 670) | private _checkPermissionInBuffer(requestId: string, handle: PtyRunHand...
    method respondToPermission (line 730) | respondToPermission(requestId: string, _questionId: string, optionId: ...
    method cancel (line 805) | cancel(requestId: string): boolean {
    method writeToStdin (line 838) | writeToStdin(requestId: string, message: string): boolean {
    method getEnrichedError (line 854) | getEnrichedError(requestId: string, exitCode: number | null): Enriched...
    method isRunning (line 868) | isRunning(requestId: string): boolean {
    method getHandle (line 872) | getHandle(requestId: string): PtyRunHandle | undefined {
    method getActiveRunIds (line 876) | getActiveRunIds(): string[] {
    method _ringPush (line 880) | private _ringPush(buffer: string[], line: string): void {
    method _ringPushBuffer (line 885) | private _ringPushBuffer(buffer: string[], line: string): void {

FILE: src/main/claude/run-manager.ts
  constant MAX_RING_LINES (line 11) | const MAX_RING_LINES = 100
  constant DEBUG (line 12) | const DEBUG = process.env.CLUI_DEBUG === '1'
  constant CLUI_SYSTEM_HINT (line 16) | const CLUI_SYSTEM_HINT = [
  constant SAFE_TOOLS (line 44) | const SAFE_TOOLS = [
  constant DEFAULT_ALLOWED_TOOLS (line 54) | const DEFAULT_ALLOWED_TOOLS = [
  function log (line 59) | function log(msg: string): void {
  type RunHandle (line 63) | interface RunHandle {
  class RunManager (line 91) | class RunManager extends EventEmitter {
    method constructor (line 97) | constructor() {
    method _findClaudeBinary (line 103) | private _findClaudeBinary(): string {
    method _getEnv (line 128) | private _getEnv(): NodeJS.ProcessEnv {
    method startRun (line 138) | startRun(requestId: string, options: RunOptions): RunHandle {
    method writeToStdin (line 323) | writeToStdin(requestId: string, message: object): boolean {
    method cancel (line 337) | cancel(requestId: string): boolean {
    method getEnrichedError (line 360) | getEnrichedError(requestId: string, exitCode: number | null): Enriched...
    method isRunning (line 374) | isRunning(requestId: string): boolean {
    method getHandle (line 378) | getHandle(requestId: string): RunHandle | undefined {
    method getActiveRunIds (line 382) | getActiveRunIds(): string[] {
    method _ringPush (line 386) | private _ringPush(buffer: string[], line: string): void {

FILE: src/main/cli-env.ts
  function appendPathEntries (line 5) | function appendPathEntries(target: string[], seen: Set<string>, rawPath:...
  function getCliPath (line 15) | function getCliPath(): string {
  function getCliEnv (line 47) | function getCliEnv(extraEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {

FILE: src/main/hooks/permission-server.ts
  constant PERMISSION_TIMEOUT_MS (line 28) | const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
  constant DEFAULT_PORT (line 29) | const DEFAULT_PORT = 19836
  constant MAX_BODY_SIZE (line 30) | const MAX_BODY_SIZE = 1024 * 1024 // 1MB
  constant DEBUG (line 32) | const DEBUG = process.env.CLUI_DEBUG === '1'
  constant PERMISSION_REQUIRED_TOOLS (line 38) | const PERMISSION_REQUIRED_TOOLS = ['Bash', 'Edit', 'Write', 'MultiEdit']
  constant SAFE_BASH_COMMANDS (line 42) | const SAFE_BASH_COMMANDS = new Set([
  constant GIT_MUTATING_SUBCOMMANDS (line 70) | const GIT_MUTATING_SUBCOMMANDS = new Set([
  constant CLAUDE_MUTATING_SUBCOMMANDS (line 78) | const CLAUDE_MUTATING_SUBCOMMANDS = new Set([
  function isSafeBashCommand (line 83) | function isSafeBashCommand(command: unknown): boolean {
  constant HOOK_MATCHER (line 140) | const HOOK_MATCHER = `^(${PERMISSION_REQUIRED_TOOLS.join('|')}|mcp__.*)$`
  constant SENSITIVE_FIELD_RE (line 143) | const SENSITIVE_FIELD_RE = /token|password|secret|key|auth|credential|ap...
  constant VALID_ALLOW_DECISIONS (line 147) | const VALID_ALLOW_DECISIONS = new Set(['allow', 'allow-session', 'allow-...
  constant VALID_DECISIONS (line 148) | const VALID_DECISIONS = new Set([...VALID_ALLOW_DECISIONS, 'deny'])
  function log (line 150) | function log(msg: string): void {
  function extractDomain (line 155) | function extractDomain(url: unknown): string | null {
  function denyResponse (line 165) | function denyResponse(reason: string) {
  function allowResponse (line 176) | function allowResponse(reason: string) {
  type HookToolRequest (line 186) | interface HookToolRequest {
  type PermissionDecision (line 197) | interface PermissionDecision {
  type PermissionOption (line 202) | interface PermissionOption {
  type PendingRequest (line 208) | interface PendingRequest {
  type RunRegistration (line 216) | interface RunRegistration {
  class PermissionServer (line 228) | class PermissionServer extends EventEmitter {
    method constructor (line 246) | constructor(port = DEFAULT_PORT) {
    method start (line 252) | async start(): Promise<number> {
    method stop (line 280) | stop(): void {
    method getPort (line 301) | getPort(): number | null {
    method registerRun (line 311) | registerRun(tabId: string, requestId: string, sessionId: string | null...
    method unregisterRun (line 321) | unregisterRun(runToken: string): void {
    method respondToPermission (line 351) | respondToPermission(questionId: string, decision: string, reason?: str...
    method getOptionsForTool (line 401) | getOptionsForTool(toolName: string, toolInput?: Record<string, unknown...
    method generateSettingsFile (line 425) | generateSettingsFile(runToken: string): string {
    method _handleRequest (line 458) | private async _handleRequest(req: IncomingMessage, res: ServerResponse...
  function maskSensitiveFields (line 611) | function maskSensitiveFields(input: Record<string, unknown>): Record<str...

FILE: src/main/index.ts
  constant DEBUG_MODE (line 14) | const DEBUG_MODE = process.env.CLUI_DEBUG === '1'
  constant SPACES_DEBUG (line 15) | const SPACES_DEBUG = DEBUG_MODE || process.env.CLUI_SPACES_DEBUG === '1'
  function getContentSecurityPolicy (line 17) | function getContentSecurityPolicy(): string {
  function installContentSecurityPolicy (line 40) | function installContentSecurityPolicy(): void {
  function log (line 52) | function log(msg: string): void {
  constant INTERACTIVE_PTY (line 63) | const INTERACTIVE_PTY = process.env.CLUI_INTERACTIVE_PERMISSIONS_PTY ===...
  constant BAR_WIDTH (line 69) | const BAR_WIDTH = 1040
  constant PILL_HEIGHT (line 70) | const PILL_HEIGHT = 720  // Fixed native window height — extra room for ...
  constant PILL_BOTTOM_MARGIN (line 71) | const PILL_BOTTOM_MARGIN = 24
  function broadcast (line 75) | function broadcast(channel: string, ...args: unknown[]): void {
  function snapshotWindowState (line 81) | function snapshotWindowState(reason: string): void {
  function scheduleToggleSnapshots (line 104) | function scheduleToggleSnapshots(toggleId: number, phase: 'show' | 'hide...
  function createWindow (line 131) | function createWindow(): void {
  function showWindow (line 204) | function showWindow(source = 'unknown'): void {
  function resetWindowPosition (line 233) | function resetWindowPosition(): void {
  function toggleWindow (line 250) | function toggleWindow(source = 'unknown'): void {
  function requestPermissions (line 1069) | async function requestPermissions(): Promise<void> {

FILE: src/main/logger.ts
  constant LOG_FILE (line 5) | const LOG_FILE = join(homedir(), '.clui-debug.log')
  constant FLUSH_INTERVAL_MS (line 6) | const FLUSH_INTERVAL_MS = 500
  constant MAX_BUFFER_SIZE (line 7) | const MAX_BUFFER_SIZE = 64
  function flush (line 15) | function flush(): void {
  function ensureTimer (line 24) | function ensureTimer(): void {
  function log (line 32) | function log(tag: string, msg: string): void {
  function flushLogs (line 42) | function flushLogs(): void {

FILE: src/main/marketplace/catalog.ts
  constant SAFE_PLUGIN_NAME (line 13) | const SAFE_PLUGIN_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/
  constant SAFE_REPO (line 15) | const SAFE_REPO = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/
  function validatePluginName (line 17) | function validatePluginName(name: string): boolean {
  function validateRepo (line 21) | function validateRepo(repo: string): boolean {
  function validateSourcePath (line 25) | function validateSourcePath(p: string): boolean {
  function assertSkillDirContained (line 31) | function assertSkillDirContained(skillsDir: string, base: string): void {
  function log (line 38) | function log(msg: string): void {
  constant SOURCES (line 44) | const SOURCES = [
  constant CACHE_TTL (line 54) | const CACHE_TTL = 5 * 60 * 1000 // 5 minutes
  function fetchCatalog (line 61) | async function fetchCatalog(forceRefresh?: boolean): Promise<{ plugins: ...
  function listInstalled (line 231) | async function listInstalled(): Promise<string[]> {
  function installPlugin (line 271) | async function installPlugin(
  function uninstallPlugin (line 335) | async function uninstallPlugin(
  function netFetch (line 357) | function netFetch(url: string): Promise<{ ok: boolean; status: number; b...
  function parseSkillFrontmatter (line 377) | function parseSkillFrontmatter(content: string): { name: string; descrip...
  constant TAG_RULES (line 407) | const TAG_RULES: Array<{ tag: string; patterns: RegExp }> = [
  function deriveSemanticTags (line 428) | function deriveSemanticTags(name: string, description: string, skillPath...
  function execAsync (line 440) | function execAsync(cmd: string, args: string[], timeout: number): Promis...

FILE: src/main/process-manager.ts
  constant LOG_FILE (line 10) | const LOG_FILE = join(homedir(), '.clui-debug.log')
  function log (line 12) | function log(msg: string): void {
  type RunHandle (line 17) | interface RunHandle {
  class ProcessManager (line 27) | class ProcessManager extends EventEmitter {
    method constructor (line 31) | constructor() {
    method findClaudeBinary (line 38) | private findClaudeBinary(): string {
    method startRun (line 69) | startRun(options: RunOptions): RunHandle {
    method cancelRun (line 170) | cancelRun(runId: string): boolean {
    method isRunning (line 186) | isRunning(runId: string): boolean {
    method getActiveRunIds (line 190) | getActiveRunIds(): string[] {

FILE: src/main/skills/installer.ts
  constant BUNDLED_SKILLS_DIR (line 19) | const BUNDLED_SKILLS_DIR = join(__dirname, '../../skills')
  constant SKILLS_DIR (line 21) | const SKILLS_DIR = join(homedir(), '.claude', 'skills')
  constant VERSION_FILE (line 22) | const VERSION_FILE = '.clui-version'
  type SkillState (line 24) | type SkillState = 'pending' | 'downloading' | 'validating' | 'installed'...
  type SkillStatus (line 26) | interface SkillStatus {
  type VersionMeta (line 33) | interface VersionMeta {
  function log (line 40) | function log(msg: string): void {
  function readVersionFile (line 46) | function readVersionFile(skillDir: string): VersionMeta | null {
  function writeVersionFile (line 56) | function writeVersionFile(skillDir: string, entry: SkillEntry): void {
  function validateSkill (line 68) | function validateSkill(dir: string, requiredFiles: string[]): string | n...
  function installGithubSkill (line 77) | async function installGithubSkill(
  function installBundledSkill (line 142) | async function installBundledSkill(
  function installSkill (line 193) | async function installSkill(
  function ensureSkills (line 242) | async function ensureSkills(

FILE: src/main/skills/manifest.ts
  type SkillEntry (line 11) | interface SkillEntry {
  constant SKILLS (line 21) | const SKILLS: SkillEntry[] = [

FILE: src/main/stream-parser.ts
  class StreamParser (line 9) | class StreamParser extends EventEmitter {
    method feed (line 16) | feed(chunk: string): void {
    method flush (line 38) | flush(): void {
    method fromStream (line 54) | static fromStream(stream: Readable): StreamParser {

FILE: src/preload/index.ts
  type CluiAPI (line 5) | interface CluiAPI {

FILE: src/renderer/App.tsx
  constant TRANSITION (line 15) | const TRANSITION = { duration: 0.26, ease: [0.4, 0, 0.1, 1] as const }
  function App (line 17) | function App() {

FILE: src/renderer/components/AttachmentChips.tsx
  constant FILE_ICONS (line 7) | const FILE_ICONS: Record<string, React.ReactNode> = {
  function AttachmentChips (line 20) | function AttachmentChips({

FILE: src/renderer/components/ConversationView.tsx
  constant INITIAL_RENDER_CAP (line 18) | const INITIAL_RENDER_CAP = 100
  constant PAGE_SIZE (line 19) | const PAGE_SIZE = 100
  constant REMARK_PLUGINS (line 20) | const REMARK_PLUGINS = [remarkGfm] // Hoisted — prevents re-parse on eve...
  type GroupedItem (line 24) | type GroupedItem =
  function groupMessages (line 32) | function groupMessages(messages: Message[]): GroupedItem[] {
  function ConversationView (line 59) | function ConversationView() {
  function EmptyState (line 286) | function EmptyState() {
  function CopyButton (line 324) | function CopyButton({ text }: { text: string }) {
  function InterruptButton (line 359) | function InterruptButton({ tabId }: { tabId: string }) {
  function UserMessage (line 391) | function UserMessage({ message, skipMotion }: { message: Message; skipMo...
  function QueuedMessage (line 425) | function QueuedMessage({ content }: { content: string }) {
  function TableScrollWrapper (line 454) | function TableScrollWrapper({ children }: { children: React.ReactNode }) {
  function ImageCard (line 512) | function ImageCard({ src, alt, colors }: { src?: string; alt?: string; c...
  function toolSummary (line 622) | function toolSummary(tools: Message[]): string {
  function getToolDescriptionFromParsed (line 632) | function getToolDescriptionFromParsed(name: string, parsed: Record<strin...
  function getToolDescription (line 652) | function getToolDescription(name: string, input?: string): string {
  function ToolGroup (line 665) | function ToolGroup({ tools, skipMotion }: { tools: Message[]; skipMotion...
  function SystemMessage (line 873) | function SystemMessage({ message, skipMotion }: { message: Message; skip...
  function ToolIcon (line 905) | function ToolIcon({ name, size = 12 }: { name: string; size?: number }) {

FILE: src/renderer/components/HistoryPicker.tsx
  function formatTimeAgo (line 10) | function formatTimeAgo(isoDate: string): string {
  function formatSize (line 22) | function formatSize(bytes: number): string {
  function HistoryPicker (line 28) | function HistoryPicker() {

FILE: src/renderer/components/InputBar.tsx
  constant INPUT_MIN_HEIGHT (line 9) | const INPUT_MIN_HEIGHT = 20
  constant INPUT_MAX_HEIGHT (line 10) | const INPUT_MAX_HEIGHT = 140
  constant MULTILINE_ENTER_HEIGHT (line 11) | const MULTILINE_ENTER_HEIGHT = 52
  constant MULTILINE_EXIT_HEIGHT (line 12) | const MULTILINE_EXIT_HEIGHT = 50
  constant INLINE_CONTROLS_RESERVED_WIDTH (line 13) | const INLINE_CONTROLS_RESERVED_WIDTH = 104
  type VoiceState (line 15) | type VoiceState = 'idle' | 'recording' | 'transcribing'
  function InputBar (line 21) | function InputBar() {
  function VoiceButtons (line 533) | function VoiceButtons({ voiceState, isConnecting, colors, onToggle, onCa...
  function blobToWavBase64 (line 604) | async function blobToWavBase64(blob: Blob): Promise<string> {
  function mixToMono (line 620) | function mixToMono(buffer: AudioBuffer): Float32Array {
  function resampleLinear (line 634) | function resampleLinear(input: Float32Array, inRate: number, outRate: nu...
  function normalizePcm (line 649) | function normalizePcm(samples: Float32Array): Float32Array {
  function rmsLevel (line 663) | function rmsLevel(samples: Float32Array): number {
  function encodeWav (line 670) | function encodeWav(samples: Float32Array, sampleRate: number): ArrayBuff...
  function writeString (line 696) | function writeString(view: DataView, offset: number, str: string) {
  function bufferToBase64 (line 700) | function bufferToBase64(buffer: ArrayBuffer): string {

FILE: src/renderer/components/MarketplacePanel.tsx
  function MarketplacePanel (line 8) | function MarketplacePanel() {
  function PluginCard (line 278) | function PluginCard({ plugin, status, colors, expanded, onToggleExpand, ...
  function StatusButton (line 533) | function StatusButton({ status, colors, onClick, onUninstall }: {
  function Tag (line 612) | function Tag({ label, colors, emphasis }: {
  function LoadingState (line 639) | function LoadingState({ colors }: { colors: ReturnType<typeof useColors>...
  function ErrorState (line 666) | function ErrorState({ error, colors, onRetry }: {
  function EmptyState (line 692) | function EmptyState({ colors }: { colors: ReturnType<typeof useColors> }) {

FILE: src/renderer/components/PermissionCard.tsx
  type Props (line 8) | interface Props {
  constant TOOL_ICONS (line 14) | const TOOL_ICONS: Record<string, React.ReactNode> = {
  function getToolIcon (line 22) | function getToolIcon(name: string) {
  constant SENSITIVE_FIELD_RE (line 26) | const SENSITIVE_FIELD_RE = /token|password|secret|key|auth|credential|ap...
  function formatInput (line 28) | function formatInput(input?: Record<string, unknown>): string | null {
  function PermissionCard (line 47) | function PermissionCard({ tabId, permission, queueLength = 1 }: Props) {

FILE: src/renderer/components/PermissionDeniedCard.tsx
  type Props (line 6) | interface Props {
  function PermissionDeniedCard (line 13) | function PermissionDeniedCard({ tools, sessionId, projectPath, onDismiss...

FILE: src/renderer/components/PopoverLayer.tsx
  function usePopoverLayer (line 13) | function usePopoverLayer(): HTMLDivElement | null {
  function PopoverLayerProvider (line 17) | function PopoverLayerProvider({ children }: { children: React.ReactNode ...

FILE: src/renderer/components/SettingsPopover.tsx
  function RowToggle (line 10) | function RowToggle({
  function SettingsPopover (line 46) | function SettingsPopover() {

FILE: src/renderer/components/SlashCommandMenu.tsx
  type SlashCommand (line 10) | interface SlashCommand {
  constant SLASH_COMMANDS (line 16) | const SLASH_COMMANDS: SlashCommand[] = [
  type Props (line 25) | interface Props {
  function getFilteredCommands (line 33) | function getFilteredCommands(filter: string): SlashCommand[] {
  function getFilteredCommandsWithExtras (line 37) | function getFilteredCommandsWithExtras(filter: string, extraCommands: Sl...
  function SlashCommandMenu (line 48) | function SlashCommandMenu({ filter, selectedIndex, onSelect, anchorRect,...

FILE: src/renderer/components/StatusBar.tsx
  function ModelPicker (line 11) | function ModelPicker() {
  function PermissionModePicker (line 132) | function PermissionModePicker() {
  function compactPath (line 253) | function compactPath(fullPath: string): string {
  function StatusBar (line 259) | function StatusBar() {

FILE: src/renderer/components/TabStrip.tsx
  function StatusDot (line 10) | function StatusDot({ status, hasUnread, hasPermission }: { status: TabSt...
  function TabStrip (line 39) | function TabStrip() {

FILE: src/renderer/env.d.ts
  type Window (line 9) | interface Window {

FILE: src/renderer/hooks/useClaudeEvents.ts
  function useClaudeEvents (line 12) | function useClaudeEvents() {

FILE: src/renderer/hooks/useHealthReconciliation.ts
  constant HEALTH_POLL_INTERVAL_MS (line 4) | const HEALTH_POLL_INTERVAL_MS = 1500
  function useHealthReconciliation (line 13) | function useHealthReconciliation() {

FILE: src/renderer/stores/sessionStore.ts
  constant AVAILABLE_MODELS (line 8) | const AVAILABLE_MODELS = [
  function normalizeModelId (line 14) | function normalizeModelId(modelId: string): string {
  function getModelDisplayLabel (line 19) | function getModelDisplayLabel(modelId: string): string {
  type StaticInfo (line 44) | interface StaticInfo {
  type State (line 52) | interface State {
  function playNotificationIfHidden (line 113) | async function playNotificationIfHidden(): Promise<void> {
  function makeLocalTab (line 124) | function makeLocalTab(): TabState {

FILE: src/renderer/theme.ts
  type ColorPalette (line 277) | type ColorPalette = { [K in keyof typeof darkColors]: string }
  type ThemeMode (line 281) | type ThemeMode = 'system' | 'light' | 'dark'
  type ThemeState (line 283) | interface ThemeState {
  function camelToKebab (line 299) | function camelToKebab(s: string): string {
  function syncTokensToCss (line 304) | function syncTokensToCss(tokens: ColorPalette): void {
  function applyTheme (line 311) | function applyTheme(isDark: boolean): void {
  constant SETTINGS_KEY (line 317) | const SETTINGS_KEY = 'clui-settings'
  function loadSettings (line 319) | function loadSettings(): { themeMode: ThemeMode; soundEnabled: boolean; ...
  function saveSettings (line 334) | function saveSettings(s: { themeMode: ThemeMode; soundEnabled: boolean; ...
  function useColors (line 379) | function useColors(): ColorPalette {
  function getColors (line 385) | function getColors(isDark: boolean): ColorPalette {

FILE: src/shared/types.ts
  type InitEvent (line 3) | interface InitEvent {
  type StreamEvent (line 20) | interface StreamEvent {
  type StreamSubEvent (line 28) | type StreamSubEvent =
  type ContentBlock (line 36) | interface ContentBlock {
  type ContentDelta (line 44) | type ContentDelta =
  type AssistantEvent (line 48) | interface AssistantEvent {
  type AssistantMessagePayload (line 56) | interface AssistantMessagePayload {
  type RateLimitEvent (line 65) | interface RateLimitEvent {
  type ResultEvent (line 76) | interface ResultEvent {
  type UsageData (line 95) | interface UsageData {
  type PermissionEvent (line 103) | interface PermissionEvent {
  type ClaudeEvent (line 113) | type ClaudeEvent = InitEvent | StreamEvent | AssistantEvent | RateLimitE...
  type UnknownEvent (line 115) | interface UnknownEvent {
  type TabStatus (line 122) | type TabStatus = 'connecting' | 'idle' | 'running' | 'completed' | 'fail...
  type PermissionRequest (line 124) | interface PermissionRequest {
  type Attachment (line 132) | interface Attachment {
  type TabState (line 144) | interface TabState {
  type Message (line 175) | interface Message {
  type RunResult (line 185) | interface RunResult {
  type NormalizedEvent (line 195) | type NormalizedEvent =
  type RunOptions (line 211) | interface RunOptions {
  type TabRegistryEntry (line 228) | interface TabRegistryEntry {
  type HealthReport (line 239) | interface HealthReport {
  type EnrichedError (line 250) | interface EnrichedError {
  type SessionMeta (line 263) | interface SessionMeta {
  type SessionLoadMessage (line 271) | interface SessionLoadMessage {
  type PluginStatus (line 280) | type PluginStatus = 'not_installed' | 'checking' | 'installing' | 'insta...
  type CatalogPlugin (line 282) | interface CatalogPlugin {
  constant IPC (line 299) | const IPC = {
Condensed preview — 60 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (498K chars).
[
  {
    "path": ".gitignore",
    "chars": 424,
    "preview": "# Build output\nnode_modules/\ndist/\nout/\nbuild/\nrelease/\n*.tsbuildinfo\n\n# OS artifacts\n.DS_Store\nThumbs.db\nDesktop.ini\n\n#"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "chars": 1580,
    "preview": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participa"
  },
  {
    "path": "CONTRIBUTING.md",
    "chars": 2027,
    "preview": "# Contributing to Clui CC\n\nThanks for your interest in contributing! Clui CC is a desktop overlay for Claude Code, and w"
  },
  {
    "path": "LICENSE",
    "chars": 1073,
    "preview": "MIT License\n\nCopyright (c) 2025-2026 Lucas Couto\n\nPermission is hereby granted, free of charge, to any person obtaining "
  },
  {
    "path": "README.md",
    "chars": 8154,
    "preview": "# Clui CC — Command Line User Interface for Claude Code\n\nA lightweight, transparent desktop overlay for [Claude Code](ht"
  },
  {
    "path": "SECURITY.md",
    "chars": 1843,
    "preview": "# Security Policy\n\n## Reporting a Vulnerability\n\nIf you discover a security vulnerability in CLUI, please report it resp"
  },
  {
    "path": "commands/install-app.command",
    "chars": 4942,
    "preview": "#!/bin/bash\n# ──────────────────────────────────────────────────────\n#  Clui CC — Install App\n#\n#  Double-click this fil"
  },
  {
    "path": "commands/setup.command",
    "chars": 5589,
    "preview": "#!/bin/bash\nset -e\n\n# Resolve to repo root (one level up from commands/)\ncd \"$(dirname \"$0\")/..\"\n\n# ── Helpers ──\n\nfail="
  },
  {
    "path": "commands/start.command",
    "chars": 1012,
    "preview": "#!/bin/bash\nset -e\n\n# Resolve to repo root (one level up from commands/)\ncd \"$(dirname \"$0\")/..\"\n\nif [ ! -d \"node_module"
  },
  {
    "path": "commands/stop.command",
    "chars": 1866,
    "preview": "#!/bin/bash\n\n# Resolve to repo root (one level up from commands/)\ncd \"$(dirname \"$0\")/..\"\n\nREPO_DIR=\"$(pwd)\"\nPID_FILE=\"."
  },
  {
    "path": "docs/AGENTS.md",
    "chars": 7058,
    "preview": "# Agent Guide — Clui CC\n\n> This file is optimized for AI coding agents (Claude Code, Cursor, Copilot, etc.).\n> For human"
  },
  {
    "path": "docs/ARCHITECTURE.md",
    "chars": 8896,
    "preview": "# CLUI Architecture\n\n## Overview\n\nCLUI is an Electron desktop application that provides a graphical interface for Claude"
  },
  {
    "path": "docs/TROUBLESHOOTING.md",
    "chars": 3530,
    "preview": "# Troubleshooting\n\nIf setup fails, run this first:\n\n```bash\nnpm run doctor\n```\n\nThis checks your local environment and p"
  },
  {
    "path": "docs/oss-readiness-report.md",
    "chars": 5941,
    "preview": "# CLUI Open-Source Readiness Report\n\n**Date:** 2026-03-12\n**Branch:** `oss-prep`\n**Assessor:** Automated scan + manual r"
  },
  {
    "path": "docs/release-smoke-test.md",
    "chars": 4520,
    "preview": "# Release Smoke Test\n\n## Build Verification\n\n### Fresh Clone Bootstrap\n\n```bash\ngit clone https://github.com/lcoutodemos"
  },
  {
    "path": "docs/slash-command-matrix.md",
    "chars": 3385,
    "preview": "# Slash Command Capability Matrix\n\nCLI Version: 2.1.63 | Date: 2026-03-08\nTest session: 450d2d0f-4b03-4761-8ecd-8d179998"
  },
  {
    "path": "electron.vite.config.ts",
    "chars": 953,
    "preview": "import { resolve } from 'path'\nimport { defineConfig, externalizeDepsPlugin } from 'electron-vite'\nimport react from '@v"
  },
  {
    "path": "install-app.command",
    "chars": 79,
    "preview": "#!/bin/bash\ncd \"$(dirname \"$0\")\"\nexec bash ./commands/install-app.command \"$@\"\n"
  },
  {
    "path": "package.json",
    "chars": 2007,
    "preview": "{\n  \"name\": \"clui\",\n  \"version\": \"0.1.0\",\n  \"description\": \"Clui CC — Command Line User Interface for Claude Code\",\n  \"l"
  },
  {
    "path": "resources/entitlements.mac.plist",
    "chars": 715,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
  },
  {
    "path": "scripts/doctor.sh",
    "chars": 3502,
    "preview": "#!/bin/bash\n# Clui CC environment doctor — read-only diagnostics, no installs.\n\necho \"Clui CC Environment Check\"\necho \"="
  },
  {
    "path": "scripts/patch-dev-icon.sh",
    "chars": 575,
    "preview": "#!/usr/bin/env bash\nset -euo pipefail\n\nELECTRON_APP=\"node_modules/electron/dist/Electron.app\"\nRESOURCES=\"$ELECTRON_APP/C"
  },
  {
    "path": "src/main/claude/control-plane.ts",
    "chars": 25982,
    "preview": "import { EventEmitter } from 'events'\nimport { RunManager } from './run-manager'\nimport { PtyRunManager } from './pty-ru"
  },
  {
    "path": "src/main/claude/event-normalizer.ts",
    "chars": 4425,
    "preview": "import type {\n  ClaudeEvent,\n  NormalizedEvent,\n  StreamEvent,\n  InitEvent,\n  AssistantEvent,\n  ResultEvent,\n  RateLimit"
  },
  {
    "path": "src/main/claude/pty-run-manager.ts",
    "chars": 29939,
    "preview": "/**\n * PtyRunManager: Interactive PTY transport for Claude Code.\n *\n * Spawns `claude` (without -p) via node-pty to get "
  },
  {
    "path": "src/main/claude/run-manager.ts",
    "chars": 14254,
    "preview": "import { spawn, execSync, ChildProcess } from 'child_process'\nimport { EventEmitter } from 'events'\nimport { homedir } f"
  },
  {
    "path": "src/main/cli-env.ts",
    "chars": 1477,
    "preview": "import { execSync } from 'child_process'\n\nlet cachedPath: string | null = null\n\nfunction appendPathEntries(target: strin"
  },
  {
    "path": "src/main/hooks/permission-server.ts",
    "chars": 21805,
    "preview": "/**\n * Permission Hook Server\n *\n * A local HTTP server that acts as a Claude Code PreToolUse hook handler.\n * When Clau"
  },
  {
    "path": "src/main/index.ts",
    "chars": 42727,
    "preview": "import { app, BrowserWindow, ipcMain, dialog, screen, globalShortcut, Tray, Menu, nativeImage, nativeTheme, shell, syste"
  },
  {
    "path": "src/main/logger.ts",
    "chars": 1619,
    "preview": "import { appendFile, appendFileSync } from 'fs'\nimport { homedir } from 'os'\nimport { join } from 'path'\n\nconst LOG_FILE"
  },
  {
    "path": "src/main/marketplace/catalog.ts",
    "chars": 17486,
    "preview": "import { net } from 'electron'\nimport { execFile } from 'child_process'\nimport { readFile, readdir, mkdir, writeFile, rm"
  },
  {
    "path": "src/main/process-manager.ts",
    "chars": 5248,
    "preview": "import { spawn, execSync, ChildProcess } from 'child_process'\nimport { EventEmitter } from 'events'\nimport { homedir } f"
  },
  {
    "path": "src/main/skills/installer.ts",
    "chars": 8753,
    "preview": "/**\n * Skill installer — ensures manifest skills are present in ~/.claude/skills/.\n *\n * Runs on app startup (non-blocki"
  },
  {
    "path": "src/main/skills/manifest.ts",
    "chars": 1146,
    "preview": "/**\n * Skill manifest — defines which skills CLUI auto-installs into ~/.claude/skills/.\n *\n * Two source types:\n *   - g"
  },
  {
    "path": "src/main/stream-parser.ts",
    "chars": 1697,
    "preview": "import { Readable } from 'stream'\nimport { EventEmitter } from 'events'\nimport type { ClaudeEvent } from '../shared/type"
  },
  {
    "path": "src/preload/index.ts",
    "chars": 8269,
    "preview": "import { contextBridge, ipcRenderer } from 'electron'\nimport { IPC } from '../shared/types'\nimport type { RunOptions, No"
  },
  {
    "path": "src/renderer/App.tsx",
    "chars": 13770,
    "preview": "import React, { useEffect, useCallback, useRef } from 'react'\nimport { motion, AnimatePresence } from 'framer-motion'\nim"
  },
  {
    "path": "src/renderer/components/AttachmentChips.tsx",
    "chars": 2880,
    "preview": "import React from 'react'\nimport { motion, AnimatePresence } from 'framer-motion'\nimport { X, FileText, Image, FileCode,"
  },
  {
    "path": "src/renderer/components/ConversationView.tsx",
    "chars": 31934,
    "preview": "import React, { useRef, useEffect, useState, useMemo, useCallback } from 'react'\nimport { motion, AnimatePresence } from"
  },
  {
    "path": "src/renderer/components/HistoryPicker.tsx",
    "chars": 7053,
    "preview": "import React, { useState, useRef, useEffect, useCallback } from 'react'\nimport { createPortal } from 'react-dom'\nimport "
  },
  {
    "path": "src/renderer/components/InputBar.tsx",
    "chars": 27154,
    "preview": "import React, { useState, useRef, useCallback, useEffect, useLayoutEffect } from 'react'\nimport { motion, AnimatePresenc"
  },
  {
    "path": "src/renderer/components/MarketplacePanel.tsx",
    "chars": 25639,
    "preview": "import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react'\nimport { motion, AnimatePresence } from"
  },
  {
    "path": "src/renderer/components/PermissionCard.tsx",
    "chars": 6707,
    "preview": "import React from 'react'\nimport { motion } from 'framer-motion'\nimport { ShieldWarning, Terminal, PencilSimple, Globe, "
  },
  {
    "path": "src/renderer/components/PermissionDeniedCard.tsx",
    "chars": 4404,
    "preview": "import React from 'react'\nimport { motion } from 'framer-motion'\nimport { ShieldWarning, Terminal, ArrowSquareOut } from"
  },
  {
    "path": "src/renderer/components/PopoverLayer.tsx",
    "chars": 1129,
    "preview": "import React, { createContext, useContext, useState, useCallback } from 'react'\n\n/**\n * Popover layer — sits outside the"
  },
  {
    "path": "src/renderer/components/SettingsPopover.tsx",
    "chars": 7808,
    "preview": "import React, { useState, useRef, useEffect, useCallback } from 'react'\nimport { createPortal } from 'react-dom'\nimport "
  },
  {
    "path": "src/renderer/components/SlashCommandMenu.tsx",
    "chars": 4819,
    "preview": "import React, { useEffect, useRef } from 'react'\nimport { createPortal } from 'react-dom'\nimport { motion } from 'framer"
  },
  {
    "path": "src/renderer/components/StatusBar.tsx",
    "chars": 15963,
    "preview": "import React, { useState, useRef, useEffect, useCallback } from 'react'\nimport { createPortal } from 'react-dom'\nimport "
  },
  {
    "path": "src/renderer/components/TabStrip.tsx",
    "chars": 5196,
    "preview": "import React from 'react'\nimport { motion, AnimatePresence } from 'framer-motion'\nimport { Plus, X } from '@phosphor-ico"
  },
  {
    "path": "src/renderer/env.d.ts",
    "chars": 180,
    "preview": "import type { CluiAPI } from '../preload/index'\n\ndeclare module '*.mp3' {\n  const src: string\n  export default src\n}\n\nde"
  },
  {
    "path": "src/renderer/hooks/useClaudeEvents.ts",
    "chars": 3166,
    "preview": "import { useEffect, useRef } from 'react'\nimport { useSessionStore } from '../stores/sessionStore'\nimport type { Normali"
  },
  {
    "path": "src/renderer/hooks/useHealthReconciliation.ts",
    "chars": 2420,
    "preview": "import { useEffect } from 'react'\nimport { useSessionStore } from '../stores/sessionStore'\n\nconst HEALTH_POLL_INTERVAL_M"
  },
  {
    "path": "src/renderer/index.css",
    "chars": 4865,
    "preview": "@import \"tailwindcss\";\n\n/*\n * ─── Theme CSS Variables ───\n * Generated at runtime by syncTokensToCss() in theme.ts.\n * D"
  },
  {
    "path": "src/renderer/index.html",
    "chars": 348,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\" class=\"dark\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"w"
  },
  {
    "path": "src/renderer/main.tsx",
    "chars": 231,
    "preview": "import React from 'react'\nimport ReactDOM from 'react-dom/client'\nimport App from './App'\nimport './index.css'\n\nReactDOM"
  },
  {
    "path": "src/renderer/stores/sessionStore.ts",
    "chars": 30664,
    "preview": "import { create } from 'zustand'\nimport type { TabStatus, NormalizedEvent, EnrichedError, Message, TabState, Attachment,"
  },
  {
    "path": "src/renderer/theme.ts",
    "chars": 12078,
    "preview": "/**\n * CLUI Design Tokens — Dual theme (dark + light)\n * Colors derived from ChatCN oklch system and design-fixed.html r"
  },
  {
    "path": "src/shared/types.ts",
    "chars": 11168,
    "preview": "// ─── Claude Code Stream Event Types (verified from v2.1.63) ───\n\nexport interface InitEvent {\n  type: 'system'\n  subty"
  },
  {
    "path": "tsconfig.json",
    "chars": 409,
    "preview": "{\n  \"compilerOptions\": {\n    \"target\": \"ESNext\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"strict\""
  }
]

// ... and 1 more files (download for full content)

About this extraction

This page contains the full source code of the lcoutodemos/clui-cc GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 60 files (463.4 KB), approximately 121.9k tokens, and a symbol index with 319 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!