diff --git a/.github/PUBLISHING.md b/.github/PUBLISHING.md new file mode 100644 index 00000000..5fe2d4a2 --- /dev/null +++ b/.github/PUBLISHING.md @@ -0,0 +1,213 @@ +# Publishing Guide + +This document explains how the automated publishing system works for `happy-next`. + +## Overview + +**Every push to the preview branch automatically publishes to npm!** No manual steps needed. + +For stable releases, the package is automatically published to npm when the version changes. + +## Automated Workflows + +### 1. Automatic Preview Publishing (`publish-preview.yml`) ⭐ NEW + +**Triggers on every push to preview branches!** + +**What it does:** +- Generates unique version: `-preview..` +- Installs dependencies +- Runs build and tests +- Publishes to npm with `@preview` tag + +**Installation:** +```bash +# Get the latest preview (recommended) +npm install -g happy-next@preview + +# Or install a specific preview version +npm install -g happy-next@0.11.3-preview.20251118120000.abc1234 +``` + +**No manual intervention needed!** Just push your changes and wait 2-3 minutes. + +### 2. Version Bumping (`bump-version.yml`) + +Manually trigger this workflow to bump the version: + +**Via GitHub UI:** +1. Go to Actions → Bump Version +2. Click "Run workflow" +3. Select bump type: `prerelease`, `patch`, `minor`, or `major` +4. Click "Run workflow" + +**What it does:** +- Bumps the version in `package.json` +- Builds the project (updates `dist/`) +- Commits changes with `[skip-build]` tag +- Creates and pushes git tag (`v0.11.3-preview.8`, etc.) +- Updates `latest-preview` tag for prerelease versions + +### 3. Publishing to NPM (`publish-npm.yml`) + +Automatically triggers when: +- Version in `package.json` changes on `main` branch +- Manually triggered via workflow_dispatch + +**What it does:** +- Detects version change +- Builds the project +- Publishes to npm with `--access public` +- Creates GitHub release +- Adds install instructions to release notes + +**Note:** For preview/development work, use the automatic preview publishing instead! + +## Publishing a New Version + +### For Preview Releases (Automatic!) ⭐ + +**Just push your changes!** That's it. + +```bash +git add . +git commit -m "your changes" +git push +# Wait 2-3 minutes - your changes are now published to npm! +``` + +Install with: `npm install -g happy-next@preview` + +### For Named Preview Releases (Manual) + +If you want a specific preview version number: + +```bash +# Option 1: Use GitHub Actions +# Go to Actions → Bump Version → Run workflow → Select "prerelease" + +# Option 2: Manually +npm version prerelease --preid=preview +git push +git push --tags +# Publishing happens automatically +``` + +### For Stable Releases + +```bash +# Option 1: Use GitHub Actions (Recommended) +# Go to Actions → Bump Version → Run workflow → Select "patch/minor/major" + +# Option 2: Manually +npm version patch # or minor, major +git push +git push --tags +# Publishing happens automatically +``` + +## Installation After Publishing + +Once published to npm, users can install with: + +```bash +# Latest stable version +npm install -g happy-next + +# Latest preview version +npm install -g happy-next@preview + +# Specific version +npm install -g happy-next@0.11.3-preview.7 +``` + +## Benefits of NPM Publishing + +✅ **No GitHub install issues** - Works perfectly with NVM +✅ **No path problems** - npm handles bin wrappers automatically +✅ **Faster installs** - Pre-built dist, no compilation needed +✅ **Better caching** - npm registry is more reliable than GitHub +✅ **Works like original** - Same experience as `happy-coder` + +## NPM Token Setup + +The `NPM_TOKEN` secret is already configured in the repository: +- **Location:** Repository Settings → Secrets and variables → Actions +- **Name:** `NPM_TOKEN` +- **Used by:** `publish-npm.yml` workflow + +To regenerate the token: +1. Go to https://www.npmjs.com/settings/YOUR_USERNAME/tokens +2. Generate new token (Automation type) +3. Update the `NPM_TOKEN` secret in GitHub + +## Distribution Tags + +The package uses npm distribution tags: + +- `latest` - Stable releases (default when running `npm install -g happy-next`) +- `preview` - Preview/beta releases (install with `npm install -g happy-next@preview`) + +Prerelease versions automatically get the `preview` tag. + +## Troubleshooting + +### Publish Failed - Version Already Exists + +If you try to publish a version that already exists on npm: +1. Bump the version: `npm version prerelease --preid=preview` +2. Commit and push +3. Publishing will retry automatically + +### Publish Failed - Authentication Error + +Check that the `NPM_TOKEN` secret is valid: +1. Go to Repository Settings → Secrets +2. Update `NPM_TOKEN` with a fresh token from npmjs.com + +### Build Failed During Publish + +The build must succeed before publishing: +1. Run `npm run build` locally to check for errors +2. Fix any TypeScript or build issues +3. Commit and push fixes +4. Workflow will retry + +## Manual Publishing (Emergency) + +If you need to publish manually: + +```bash +# Build the project +npm run build + +# Login to npm (first time only) +npm login + +# Publish +npm publish --access public + +# For preview releases, also tag as preview +npm dist-tag add happy-next@0.11.3-preview.7 preview +``` + +## Checking Published Versions + +```bash +# View all published versions +npm view happy-next versions + +# View latest version +npm view happy-next version + +# View all dist-tags +npm view happy-next dist-tags +``` + +## Next Steps + +After setting up publishing: +1. Merge this branch to `main` +2. Use "Bump Version" workflow to create first npm release +3. Update documentation to use `npm install -g happy-next` +4. Announce to users that they can now install from npm! diff --git a/.github/workflows/build-dist.yml b/.github/workflows/build-dist.yml new file mode 100644 index 00000000..f0d71bdd --- /dev/null +++ b/.github/workflows/build-dist.yml @@ -0,0 +1,79 @@ +name: Build and Commit Dist + +on: + push: + branches: + - main + - new/** + - feature/** + - fix/** + paths-ignore: + - 'dist/**' + - '**.md' + - '.github/**' + + workflow_dispatch: + +jobs: + build-dist: + runs-on: ubuntu-latest + + # Prevent infinite loops - skip if commit message contains [skip-build] + if: "!contains(github.event.head_commit.message, '[skip-build]')" + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Check for dist changes + id: check-changes + run: | + git add dist + if git diff --cached --quiet; then + echo "has_changes=false" >> $GITHUB_OUTPUT + echo "No changes to dist folder" + else + echo "has_changes=true" >> $GITHUB_OUTPUT + echo "Dist folder has changes" + fi + + - name: Commit and push dist + if: steps.check-changes.outputs.has_changes == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git add dist + git commit -m "chore: update dist folder [skip-build] + +Built from commit ${{ github.sha }} + +This commit was automatically generated by GitHub Actions." + + git push + + - name: Summary + run: | + echo "## Build Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "${{ steps.check-changes.outputs.has_changes }}" == "true" ]; then + echo "✅ Dist folder updated and committed" >> $GITHUB_STEP_SUMMARY + else + echo "ℹ️ No changes to dist folder" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml new file mode 100644 index 00000000..5193220e --- /dev/null +++ b/.github/workflows/bump-version.yml @@ -0,0 +1,91 @@ +name: Bump Version + +on: + workflow_dispatch: + inputs: + bump_type: + description: 'Version bump type' + required: true + type: choice + options: + - prerelease + - patch + - minor + - major + +jobs: + bump-version: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Bump version + id: bump + run: | + OLD_VERSION=$(node -p "require('./package.json').version") + echo "old_version=$OLD_VERSION" >> $GITHUB_OUTPUT + + # Bump version based on input + if [ "${{ inputs.bump_type }}" = "prerelease" ]; then + npm version prerelease --preid=preview --no-git-tag-version + else + npm version ${{ inputs.bump_type }} --no-git-tag-version + fi + + NEW_VERSION=$(node -p "require('./package.json').version") + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + + echo "Bumped version: $OLD_VERSION → $NEW_VERSION" + + - name: Build + run: | + npm ci + npm run build + + - name: Commit changes + run: | + git add package.json package-next.json dist + git commit -m "chore: bump version to ${{ steps.bump.outputs.new_version }} [skip-build] + + Automated version bump: ${{ steps.bump.outputs.old_version }} → ${{ steps.bump.outputs.new_version }} + + 🤖 Generated by GitHub Actions" + + git push + + - name: Create and push tag + run: | + TAG="v${{ steps.bump.outputs.new_version }}" + git tag -a "$TAG" -m "Release $TAG" + git push origin "$TAG" + + # Update latest-preview tag if this is a prerelease + if [[ "${{ steps.bump.outputs.new_version }}" == *"preview"* ]]; then + git tag -f latest-preview + git push origin latest-preview -f + fi + + - name: Summary + run: | + echo "## 🏷️ Version Bumped" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Old Version:** ${{ steps.bump.outputs.old_version }}" >> $GITHUB_STEP_SUMMARY + echo "**New Version:** ${{ steps.bump.outputs.new_version }}" >> $GITHUB_STEP_SUMMARY + echo "**Bump Type:** ${{ inputs.bump_type }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "The publish workflow will automatically publish this to npm." >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..ac3db0de --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,111 @@ +name: CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + +jobs: + build-and-test: + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + node-version: [20, 22] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run typecheck + + - name: Build + run: npm run build + + - name: Verify dist exists + shell: bash + run: | + if [ ! -d "dist" ]; then + echo "Error: dist folder not created" + exit 1 + fi + echo "✓ Dist folder exists" + + - name: Check bin files exist + shell: bash + run: | + if [ ! -f "bin/happy-next.mjs" ]; then + echo "Error: bin/happy-next.mjs not found" + exit 1 + fi + if [ ! -f "bin/happy-next-mcp.mjs" ]; then + echo "Error: bin/happy-next-mcp.mjs not found" + exit 1 + fi + echo "✓ Bin files exist" + + # Run tests only on Linux (fastest) + - name: Run tests + if: matrix.os == 'ubuntu-latest' && matrix.node-version == 20 + run: npm test + continue-on-error: true + + install-test: + runs-on: ${{ matrix.os }} + needs: build-and-test + + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Test local install + shell: bash + run: | + npm ci + npm run build + npm install -g . + + # Verify commands are available + if ! command -v happy-next &> /dev/null; then + echo "Error: happy-next command not found" + exit 1 + fi + + if ! command -v happy-next-mcp &> /dev/null; then + echo "Error: happy-next-mcp command not found" + exit 1 + fi + + # Verify version works + happy-next --version + + echo "✓ Installation successful" + + - name: Cleanup + if: always() + shell: bash + run: | + npm uninstall -g happy-next || true diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml new file mode 100644 index 00000000..30d4ff72 --- /dev/null +++ b/.github/workflows/publish-npm.yml @@ -0,0 +1,113 @@ +name: Publish to NPM + +on: + push: + branches: + - main + paths: + - 'package.json' + workflow_dispatch: + inputs: + version: + description: 'Version bump type (patch, minor, major, prerelease)' + required: false + default: 'patch' + +jobs: + check-version: + runs-on: ubuntu-latest + outputs: + should_publish: ${{ steps.check.outputs.should_publish }} + version: ${{ steps.check.outputs.version }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Check if version changed + id: check + run: | + CURRENT_VERSION=$(node -p "require('./package.json').version") + echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + + # Check if this is a manual workflow dispatch + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "should_publish=true" >> $GITHUB_OUTPUT + echo "Manual trigger - will publish" + exit 0 + fi + + # Check if package.json version changed in last commit + if git diff HEAD^ HEAD -- package.json | grep -q '"version"'; then + echo "should_publish=true" >> $GITHUB_OUTPUT + echo "Version changed to $CURRENT_VERSION - will publish" + else + echo "should_publish=false" >> $GITHUB_OUTPUT + echo "Version unchanged - skipping publish" + fi + + publish: + needs: check-version + if: needs.check-version.outputs.should_publish == 'true' + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Publish to npm + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Create GitHub Release + uses: actions/github-script@v7 + with: + script: | + const version = '${{ needs.check-version.outputs.version }}'; + const tag = `v${version}`; + + // Create a release + try { + await github.rest.repos.createRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: tag, + name: `Release ${tag}`, + body: `Published happy-next@${version} to npm\n\n` + + `Install with:\n\`\`\`bash\nnpm install -g happy-next\n\`\`\`\n\n` + + `Or install this specific version:\n\`\`\`bash\nnpm install -g happy-next@${version}\n\`\`\``, + draft: false, + prerelease: version.includes('preview') || version.includes('alpha') || version.includes('beta') + }); + console.log(`Created release for ${tag}`); + } catch (error) { + console.log(`Release might already exist: ${error.message}`); + } + + - name: Summary + run: | + echo "## 🚀 Published to NPM" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Package:** happy-next@${{ needs.check-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Install with:" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY + echo "npm install -g happy-next" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "View on npm: https://www.npmjs.com/package/happy-next" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml new file mode 100644 index 00000000..787b31e7 --- /dev/null +++ b/.github/workflows/sync-upstream.yml @@ -0,0 +1,172 @@ +name: Sync from Upstream + +on: + # Disabled automatic sync - run manually when needed + # schedule: + # - cron: '0 2 * * *' + workflow_dispatch: # Allow manual trigger only + +jobs: + sync: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Add upstream remote + run: | + git remote add upstream https://github.com/slopus/happy-cli.git || true + git fetch upstream + + - name: Check for upstream changes + id: check + run: | + # Get the latest commit from upstream main + UPSTREAM_COMMIT=$(git rev-parse upstream/main) + # Get the latest commit we've merged from upstream (if any) + LAST_SYNC=$(git log --grep="Sync from upstream" --format="%H" -n 1 || echo "") + + if [ -z "$LAST_SYNC" ]; then + echo "has_changes=true" >> $GITHUB_OUTPUT + echo "No previous sync found" + else + # Check if there are new commits + NEW_COMMITS=$(git rev-list $LAST_SYNC..upstream/main --count) + if [ "$NEW_COMMITS" -gt 0 ]; then + echo "has_changes=true" >> $GITHUB_OUTPUT + echo "Found $NEW_COMMITS new commits" + else + echo "has_changes=false" >> $GITHUB_OUTPUT + echo "No new commits" + fi + fi + + - name: Attempt automatic merge + if: steps.check.outputs.has_changes == 'true' + id: merge + run: | + # Try to merge upstream/main into current branch + if git merge upstream/main --no-edit -m "chore: sync from upstream slopus/happy-cli"; then + echo "merge_success=true" >> $GITHUB_OUTPUT + echo "Merge successful" + else + echo "merge_success=false" >> $GITHUB_OUTPUT + echo "Merge conflicts detected" + git merge --abort + fi + + - name: Push changes + if: steps.check.outputs.has_changes == 'true' && steps.merge.outputs.merge_success == 'true' + run: | + git push origin main + echo "Changes pushed successfully" + + - name: Create issue for merge conflicts + if: steps.check.outputs.has_changes == 'true' && steps.merge.outputs.merge_success == 'false' + uses: actions/github-script@v7 + with: + script: | + const { data: issues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'upstream-sync' + }); + + // Don't create duplicate issues + if (issues.length > 0) { + console.log('Issue already exists for upstream sync conflicts'); + return; + } + + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: 'Manual intervention needed: Upstream sync has conflicts', + labels: ['upstream-sync', 'needs-attention'], + body: `The automatic sync from upstream (slopus/happy-cli) has merge conflicts. + +**Manual steps required:** + +\`\`\`bash +# Fetch latest from upstream +git fetch upstream + +# Attempt merge +git merge upstream/main + +# Resolve conflicts manually, then: +git add . +git commit -m "chore: sync from upstream (resolved conflicts)" +git push origin main +\`\`\` + +**Alternative - Rebase approach:** + +\`\`\`bash +# Create a new branch for the sync +git checkout -b sync-upstream-$(date +%Y%m%d) +git fetch upstream +git rebase upstream/main + +# Resolve conflicts, then: +git rebase --continue +git push origin sync-upstream-$(date +%Y%m%d) + +# Create PR to merge into main +\`\`\` + +This issue will be automatically closed when the next automatic sync succeeds. +` + }); + + - name: Close resolved sync issues + if: steps.check.outputs.has_changes == 'true' && steps.merge.outputs.merge_success == 'true' + uses: actions/github-script@v7 + with: + script: | + const { data: issues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'upstream-sync' + }); + + for (const issue of issues) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + state: 'closed' + }); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: 'Sync from upstream succeeded. Closing this issue.' + }); + } + + - name: Summary + if: always() + run: | + echo "## Sync Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "${{ steps.check.outputs.has_changes }}" == "false" ]; then + echo "✓ No new changes from upstream" >> $GITHUB_STEP_SUMMARY + elif [ "${{ steps.merge.outputs.merge_success }}" == "true" ]; then + echo "✓ Successfully merged changes from upstream" >> $GITHUB_STEP_SUMMARY + else + echo "⚠️ Merge conflicts detected - manual intervention required" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.gitignore b/.gitignore index f33d308c..addc6567 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ *-error.log **/.DS_Store /.idea -/dist +# /dist - Committing dist for GitHub installs /tmp /node_modules oclif.manifest.json diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 00000000..11d9d080 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,153 @@ +# Installing Happy CLI + +## Quick Install (Windows) + +Install the latest preview from GitHub: + +```powershell +npm install -g happy-next +``` + +This gives you: +- `happy-next` - Main CLI command +- `happy-next-mcp` - MCP server command + +**Note:** This installs as `happy-next` so it won't conflict with the stable `happy` version from the upstream repo. + +## Install Specific Version + +```powershell +# Install specific preview version +npm install -g github:jakenuts/happy-cli#v0.11.3-preview.5 + +# Install from a specific tag +npm install -g github:jakenuts/happy-cli#latest-preview +``` + +## Verify Installation + +```powershell +# Check version +happy-next --version + +# Check daemon status +happy-next daemon status + +# Start daemon +happy-next daemon start +``` + +## Side-by-Side with Stable + +You can install both the stable upstream version and this preview fork: + +```powershell +# Install stable from upstream (slopus/happy-cli) +npm install -g github:slopus/happy-cli + +# Install preview fork (jakenuts/happy-cli) +npm install -g github:jakenuts/happy-cli#latest-preview + +# Use either version +happy --version # Stable (upstream) +happy-next --version # Preview (fork) + +happy daemon start # Stable daemon +happy-next daemon start # Preview daemon +``` + +## Troubleshooting + +### Installation Fails with EOF Error + +This is usually a transient network issue downloading binaries. Try: +1. Run the install command again +2. Clear npm cache: `npm cache clean --force` +3. Try again + +### "happy-next command not found" + +Ensure npm's global bin directory is in your PATH: + +```powershell +# Check where npm installs global packages +npm bin -g + +# On Windows, should output something like: +# C:\Users\YourName\AppData\Roaming\npm + +# Verify it's in PATH +$env:PATH -split ';' | Select-String npm +``` + +If not in PATH, add it: +1. Search for "Environment Variables" in Windows +2. Edit user or system PATH +3. Add the npm bin directory +4. Restart PowerShell + +### Update to Latest Preview + +```powershell +# Uninstall current version +npm uninstall -g happy-next + +# Install latest +npm install -g github:jakenuts/happy-cli#latest-preview +``` + +## Development Install + +If you have the repo cloned locally: + +```bash +# Build the project +npm run build + +# Install globally from local directory +npm install -g . + +# Test it works +happy-next --version +``` + +## Repository Information + +- **Fork (active development):** https://github.com/jakenuts/happy-cli +- **Upstream:** https://github.com/slopus/happy-cli +- **Server:** https://api.happy-servers.com + +## What's Installed + +When you install Happy CLI Next (preview fork), you get: + +1. **Commands:** + - `happy-next` - Main CLI + - `happy-next-mcp` - MCP server for Claude integration + +2. **Config Directory:** + - `~/.happy-dev/` (or `$HAPPY_HOME_DIR`) + - Contains logs, daemon PID, access keys + - **Shared with stable version** if both are installed + +3. **Dependencies:** + - Claude Code SDK (2.0.24) + - Platform-specific binaries (ripgrep, etc.) + +## Uninstall + +```powershell +npm uninstall -g happy-next +``` + +This removes the commands but leaves your config directory (`~/.happy-dev/`) intact. + +## Comparison: Stable vs Preview + +| Feature | Stable (`happy`) | Preview Fork (`happy-next`) | +|---------|------------------|---------------------------| +| Source | slopus/happy-cli | jakenuts/happy-cli | +| Install | `npm install -g github:slopus/happy-cli` | `npm install -g github:jakenuts/happy-cli#latest-preview` | +| Commands | `happy`, `happy-mcp` | `happy-next`, `happy-next-mcp` | +| Updates | Upstream releases | Daily synced + fork features | +| Use Case | Stable production use | Testing new features, Codex improvements | diff --git a/PROJECT.md b/PROJECT.md new file mode 100644 index 00000000..9a97af94 --- /dev/null +++ b/PROJECT.md @@ -0,0 +1,281 @@ +# Happy CLI - Technical Architecture Documentation + +## System Overview + +Happy CLI is a command-line wrapper for Claude Code and OpenAI Codex CLI that enables remote control via mobile app and session sharing. It consists of three components: + +1. **handy-cli** (this project) - CLI wrapper +2. **handy** - React Native mobile client +3. **handy-server** - Node.js server at https://api.happy-servers.com/ + +## Agent Integration Architecture + +### Supported Agents + +- **Claude Code**: Integrated via `@anthropic-ai/claude-code` SDK +- **OpenAI Codex**: Integrated via Model Context Protocol (MCP) subprocess + +### Agent Communication Flow + +``` +Mobile App (user input) + ↓ WebSocket (encrypted) +Server + ↓ WebSocket 'update' event +ApiSessionClient + ↓ onUserMessage callback +MessageQueue + ↓ batch processing +Agent MCP Client (Claude/Codex) + ↓ MCP tool calls (startSession/continueSession) +Agent subprocess + ↓ MCP event notifications +Message handlers + ↓ sendCodexMessage/sendClaudeSessionMessage +Server + ↓ WebSocket broadcast +Mobile App +``` + +## Critical Components + +### /src/api/apiSession.ts - WebSocket Session Client + +**Purpose**: Manages WebSocket connection to server with end-to-end encryption + +**Key Features**: +- Lazy connection via `ensureConnected()` to prevent race conditions +- E2E encryption using TweetNaCl (NaCl crypto library) +- Optimistic concurrency control for metadata/state updates +- RPC handler registration for bidirectional communication +- Message queue buffering during initialization + +**Connection Lifecycle**: +1. Constructor creates socket with `autoConnect: false` +2. Registers event handlers for 'update', 'connect', 'disconnect' +3. `onUserMessage(callback)` sets callback and calls `ensureConnected()` +4. `ensureConnected()` initiates connection, returns Promise +5. Socket 'connect' event resolves promise +6. Messages flow directly to registered callback + +**Race Condition Prevention**: +- Old behavior: Socket connected immediately in constructor → messages arrived before callback set +- New behavior: Connection deferred until `onUserMessage()` called → callback registered BEFORE connect + +### /src/codex/codexMcpClient.ts - Codex MCP Integration + +**Purpose**: Wrapper for Codex CLI via Model Context Protocol (stdio transport) + +**Key Features**: +- Spawns Codex as subprocess using `StdioClientTransport` +- Bidirectional MCP communication over stdio +- Session ID extraction and tracking +- Permission request handling via `ElicitRequestSchema` +- Windows console window hiding workaround + +**Windows-Specific Issue**: +- MCP SDK only sets `windowsHide: true` when `isElectron()` returns true +- Workaround: Temporarily set `process.type` during transport creation +- Result: Prevents visible CMD windows on Windows platforms + +**Session Management**: +- `startSession(config)` - Creates new Codex session via 'codex' tool +- `continueSession(prompt)` - Continues existing session via 'codex-reply' tool +- `sessionId` and `conversationId` extracted from MCP responses +- Session persistence for resume functionality + +**Permission Flow**: +1. Codex requests permission via MCP `ElicitRequestSchema` +2. CodexMcpClient receives request in `setRequestHandler` +3. Delegates to `CodexPermissionHandler.handleToolCall()` +4. Handler sends RPC request to mobile app +5. Mobile app responds with approval/denial +6. Response returned to Codex via MCP + +### /src/codex/runCodex.ts - Codex Runtime Orchestrator + +**Purpose**: Main entry point for Codex agent mode, coordinates all Codex operations + +**Key Components**: +- `MessageQueue2`: Batches user messages with mode tracking (normal/direct/plan/edit) +- `MessageBuffer`: Accumulates Codex output for streaming to mobile +- `CodexMcpClient`: MCP communication with Codex subprocess +- `CodexPermissionHandler`: Handles tool approval requests via RPC +- `ApiSessionClient`: Server communication and encryption + +**Message Flow**: +1. `session.onUserMessage()` callback pushes to `messageQueue` +2. Main loop calls `messageQueue.waitForMessagesAndGetAsString()` +3. Batched messages sent to Codex via `client.startSession()` or `continueSession()` +4. Codex events flow through MCP to `client.setHandler()` +5. Handler processes events, updates `messageBuffer` +6. Buffer periodically flushed to server via `session.sendCodexMessage()` + +**Session State Management**: +- `wasCreated`: Tracks if session was successfully created +- `storedSessionIdForResume`: Preserved session ID for crash recovery +- `currentModeHash`: Detects mode changes requiring session restart +- Resume logic: On abort/error, stores session ID for next message continuation + +**Abort Handling**: +- AbortController signal passed to MCP tool calls +- On abort: Stores session ID, marks `wasCreated = false` +- Next message resumes from stored session instead of creating new one + +### /src/codex/utils/permissionHandler.ts - Permission RPC Handler + +**Purpose**: Bridges Codex permission requests to mobile app via RPC + +**Flow**: +1. `handleToolCall(callId, toolName, args)` called by CodexMcpClient +2. Creates pending request entry in Map +3. Sends RPC request via `rpcManager.sendRequest('tool-approval-request')` +4. Waits for RPC response callback +5. Returns `{ decision: 'approved' | 'denied' }` to Codex +6. Updates agent state with permission status + +**Issue**: No timeout on permission requests - will hang indefinitely if mobile doesn't respond + +### /src/api/encryption.ts - E2E Encryption + +**Purpose**: Encrypt all data before sending to server using TweetNaCl + +**Encryption Variants**: +- `legacy`: Uses session's encryption key directly (backward compatibility) +- `dataKey`: Derives per-message key from base key + nonce (more secure) + +**Key Operations**: +- `encrypt(key, variant, data)`: Encrypts JSON object → Uint8Array +- `decrypt(key, variant, encrypted)`: Decrypts Uint8Array → JSON object +- All messages encrypted before `socket.emit('message', ...)` +- All incoming messages decrypted in 'update' event handler + +## Identified Issues & Fixes + +### Issue #1: Windows Console Windows (FIXED) +**Cause**: MCP SDK only enables `windowsHide` when running in Electron +**Impact**: Visible CMD window appears for every Codex interaction on Windows +**Fix**: Temporarily set `process.type` to trick SDK into enabling windowsHide +**Location**: `/src/codex/codexMcpClient.ts:90-126` +**Status**: ✅ Committed (3ce58f6) + +### Issue #2: Missed First Messages (FIXED) +**Cause**: WebSocket connected before `onUserMessage()` callback registered +**Impact**: First user message lost in race condition +**Fix**: Defer connection until `onUserMessage()` called, await connection +**Location**: `/src/api/apiSession.ts:161-209` +**Status**: ✅ Committed (f8a1fc2) + +### Issue #3: Agent Disconnection/Offline +**Cause**: No subprocess health monitoring, no reconnection logic +**Impact**: Codex crashes silently, agent appears offline +**Fix Needed**: +- Add heartbeat mechanism to detect subprocess death +- Implement exponential backoff retry on MCP connection failure +- Monitor subprocess exit events, auto-restart +**Location**: `/src/codex/codexMcpClient.ts`, `/src/codex/runCodex.ts` +**Status**: ⏳ Pending + +### Issue #4: Message Queue Timing +**Cause**: 400+ lines between callback registration and processing loop start +**Impact**: Messages queued but not processed immediately +**Fix Needed**: +- Start message processing loop immediately after `onUserMessage()` +- Reduce initialization gap between callback and loop +**Location**: `/src/codex/runCodex.ts:148-565` +**Status**: ⏳ Pending + +### Issue #5: Missing Timeouts +**Cause**: No timeouts on MCP operations or permission requests +**Impact**: Operations hang indefinitely on failure +**Fix Needed**: +- Reduce MCP timeout from 14 days to reasonable value (e.g., 30s) +- Add timeout to permission handler RPC calls +- Add timeout to WebSocket operations +**Location**: Multiple files +**Status**: ⏳ Pending + +## Code Patterns + +### Error Handling +- Try-catch blocks with specific error logging +- AbortController for cancellable operations +- Exponential backoff for retryable operations (metadata/state updates) + +### Logging +- All debugging via file logs (`~/.happy-dev/logs/`) +- Prevents interference with agent terminal UI +- `logger.debug()` for standard logs +- `logger.debugLargeJson()` for objects (auto-truncates) + +### Type Safety +- Zod schemas for runtime validation (`UserMessageSchema`, etc.) +- Explicit TypeScript types throughout +- No `any` types except in MCP SDK interfaces + +### Async Patterns +- Promise-based async/await throughout +- Lock mechanisms for concurrent state updates (`AsyncLock`) +- Fire-and-forget for non-critical operations (notifications) + +## Testing + +### Test Structure +- Unit tests using Vitest +- Tests colocated with source (`.test.ts`) +- No mocking - tests make real API calls +- Test authentication flow, encryption, session management + +### Running Tests +```bash +npm test +``` + +## Build & Deployment + +### Build Process +```bash +npm run build +# Uses pkgroll to bundle TypeScript +# Output: dist/ directory +``` + +### Entry Points +- CLI: `bin/happy.mjs` +- Daemon: Spawns via `src/daemon/run.ts` +- Codex: `src/codex/runCodex.ts` +- Claude: `src/claude/runClaude.ts` + +## Future Improvements + +1. **Upstream MCP SDK Fix**: Submit PR to always hide windows on Windows +2. **Heartbeat Implementation**: Monitor agent subprocess health +3. **Retry Logic**: Exponential backoff for failed agent connections +4. **Timeout Configuration**: Configurable timeouts for all operations +5. **Connection Pooling**: Reuse WebSocket connections across sessions +6. **Structured Logging**: JSON-formatted logs for better parsing + +## Debug Tips + +### Enable Debug Logging +```bash +DEBUG=1 happy codex +``` + +### View Logs +```bash +tail -f ~/.happy-dev/logs/$(ls -t ~/.happy-dev/logs/ | head -1) +``` + +### Check Daemon Status +```bash +happy daemon status +``` + +### Kill Stuck Sessions +```bash +# Find process +ps aux | grep codex +# Kill by PID +kill -9 +``` diff --git a/README.md b/README.md index 4280c213..3217f0c7 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,52 @@ -# Happy +# Happy CLI Next - Preview Fork -Code on the go controlling claude code from your mobile device. +> Code on the go controlling Claude Code from your mobile device. +> +> Free. Open source. Code anywhere. -Free. Open source. Code anywhere. +**This is a preview fork** with experimental features and improvements. For the stable version, see [slopus/happy-cli](https://github.com/slopus/happy-cli). -## Installation +## Quick Install ```bash -npm install -g happy-coder +npm install -g happy-next ``` +This installs as **`happy-next`** so it won't conflict with the stable `happy` version. + +**Preview versions (auto-published on every commit):** +```bash +npm install -g happy-next@preview # Latest preview - auto-updates on every push! +``` + +Every push to the preview branch automatically publishes to npm with a unique version like `1.0.0-preview.20251118120000.abc1234` + +📖 **[Complete Installation Guide](./INSTALL.md)** - Side-by-side setup, troubleshooting, etc. + +## What's New in This Fork + +### 🔧 Codex Integration Fixes +- Fixed race conditions in WebSocket connections +- Added subprocess health monitoring and auto-recovery +- Windows console window fixes +- Permission handling with timeouts +- Improved error recovery and reconnection logic + +### 📦 Installation & Publishing Improvements +- **Automatic preview publishing** - Every push publishes to npm as `@preview` tag +- Automatic dist building via GitHub Actions on every commit +- Cross-platform CI testing (Windows, macOS, Linux) +- No manual publishing needed - just push your changes! + +### 🔄 Upstream Sync +- Automated daily sync from upstream (slopus/happy-cli) +- Manual sync: `./scripts/sync-upstream.sh` +- Auto-merges when no conflicts + ## Usage ```bash -happy +happy-next ``` This will: @@ -23,12 +56,12 @@ This will: ## Commands -- `happy auth` – Manage authentication -- `happy codex` – Start Codex mode -- `happy connect` – Store AI vendor API keys in Happy cloud -- `happy notify` – Send a push notification to your devices -- `happy daemon` – Manage background service -- `happy doctor` – System diagnostics & troubleshooting +- `happy-next auth` – Manage authentication +- `happy-next codex` – Start Codex mode +- `happy-next connect` – Store AI vendor API keys in Happy cloud +- `happy-next notify` – Send a push notification to your devices +- `happy-next daemon` – Manage background service +- `happy-next doctor` – System diagnostics & troubleshooting ## Options @@ -47,13 +80,42 @@ This will: - `HAPPY_DISABLE_CAFFEINATE` - Disable macOS sleep prevention (set to `true`, `1`, or `yes`) - `HAPPY_EXPERIMENTAL` - Enable experimental features (set to `true`, `1`, or `yes`) +## Side-by-Side with Stable + +Run both versions simultaneously: + +```bash +# Install stable +npm install -g github:slopus/happy-cli + +# Install preview +npm install -g github:jakenuts/happy-cli#latest-preview + +# Use either +happy --version # Stable +happy-next --version # Preview +``` + +Both share the same config directory (`~/.happy-dev/`) but use different command names. + ## Requirements - Node.js >= 20.0.0 - - Required by `eventsource-parser@3.0.5`, which is required by - `@modelcontextprotocol/sdk`, which we used to implement permission forwarding - to mobile app - Claude CLI installed & logged in (`claude` command available in PATH) +- **Claude Code SDK:** Pinned to 2.0.24 (last version with SDK exports) + +## Documentation + +- 📦 [Installation Guide](./INSTALL.md) - Complete setup instructions +- 🔧 [Scripts Documentation](./scripts/README.md) - Testing, publishing, syncing +- 📚 [Codebase Overview](./CLAUDE.md) - Architecture and code style +- 🏗️ [Technical Details](./PROJECT.md) - Deep dive into implementation + +## Repository Info + +- **Fork (active):** https://github.com/jakenuts/happy-cli +- **Upstream:** https://github.com/slopus/happy-cli +- **Server:** https://api.happy-servers.com/ ## License diff --git a/bin/happy-next-mcp.cmd b/bin/happy-next-mcp.cmd new file mode 100644 index 00000000..410350c1 --- /dev/null +++ b/bin/happy-next-mcp.cmd @@ -0,0 +1,17 @@ +@ECHO off +SETLOCAL +CALL :find_dp0 + +IF EXIST "%dp0%\node.exe" ( + SET "_prog=%dp0%\node.exe" +) ELSE ( + SET "_prog=node" + SET PATHEXT=%PATHEXT:;.JS;=;% +) + +"%_prog%" "%dp0%\happy-next-mcp.mjs" %* +ENDLOCAL +EXIT /b %errorlevel% +:find_dp0 +SET dp0=%~dp0 +EXIT /b diff --git a/bin/happy-mcp.mjs b/bin/happy-next-mcp.mjs similarity index 100% rename from bin/happy-mcp.mjs rename to bin/happy-next-mcp.mjs diff --git a/bin/happy-next.cmd b/bin/happy-next.cmd new file mode 100644 index 00000000..0bb52782 --- /dev/null +++ b/bin/happy-next.cmd @@ -0,0 +1,17 @@ +@ECHO off +SETLOCAL +CALL :find_dp0 + +IF EXIST "%dp0%\node.exe" ( + SET "_prog=%dp0%\node.exe" +) ELSE ( + SET "_prog=node" + SET PATHEXT=%PATHEXT:;.JS;=;% +) + +"%_prog%" "%dp0%\happy-next.mjs" %* +ENDLOCAL +EXIT /b %errorlevel% +:find_dp0 +SET dp0=%~dp0 +EXIT /b diff --git a/bin/happy.mjs b/bin/happy-next.mjs similarity index 100% rename from bin/happy.mjs rename to bin/happy-next.mjs diff --git a/dist/codex/happyMcpStdioBridge.cjs b/dist/codex/happyMcpStdioBridge.cjs new file mode 100644 index 00000000..9af5e408 --- /dev/null +++ b/dist/codex/happyMcpStdioBridge.cjs @@ -0,0 +1,80 @@ +'use strict'; + +var mcp_js = require('@modelcontextprotocol/sdk/server/mcp.js'); +var stdio_js = require('@modelcontextprotocol/sdk/server/stdio.js'); +var index_js = require('@modelcontextprotocol/sdk/client/index.js'); +var streamableHttp_js = require('@modelcontextprotocol/sdk/client/streamableHttp.js'); +var z = require('zod'); + +function parseArgs(argv) { + let url = null; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--url" && i + 1 < argv.length) { + url = argv[i + 1]; + i++; + } + } + return { url }; +} +async function main() { + const { url: urlFromArgs } = parseArgs(process.argv.slice(2)); + const baseUrl = urlFromArgs || process.env.HAPPY_HTTP_MCP_URL || ""; + if (!baseUrl) { + process.stderr.write( + "[happy-mcp] Missing target URL. Set HAPPY_HTTP_MCP_URL or pass --url \n" + ); + process.exit(2); + } + let httpClient = null; + async function ensureHttpClient() { + if (httpClient) return httpClient; + const client = new index_js.Client( + { name: "happy-stdio-bridge", version: "1.0.0" }, + { capabilities: { tools: {} } } + ); + const transport = new streamableHttp_js.StreamableHTTPClientTransport(new URL(baseUrl)); + await client.connect(transport); + httpClient = client; + return client; + } + const server = new mcp_js.McpServer({ + name: "Happy MCP Bridge", + version: "1.0.0", + description: "STDIO bridge forwarding to Happy HTTP MCP" + }); + server.registerTool( + "change_title", + { + description: "Change the title of the current chat session", + title: "Change Chat Title", + inputSchema: { + title: z.z.string().describe("The new title for the chat session") + } + }, + async (args) => { + try { + const client = await ensureHttpClient(); + const response = await client.callTool({ name: "change_title", arguments: args }); + return response; + } catch (error) { + return { + content: [ + { type: "text", text: `Failed to change chat title: ${error instanceof Error ? error.message : String(error)}` } + ], + isError: true + }; + } + } + ); + const stdio = new stdio_js.StdioServerTransport(); + await server.connect(stdio); +} +main().catch((err) => { + try { + process.stderr.write(`[happy-mcp] Fatal: ${err instanceof Error ? err.message : String(err)} +`); + } finally { + process.exit(1); + } +}); diff --git a/dist/codex/happyMcpStdioBridge.d.cts b/dist/codex/happyMcpStdioBridge.d.cts new file mode 100644 index 00000000..04fa25ee --- /dev/null +++ b/dist/codex/happyMcpStdioBridge.d.cts @@ -0,0 +1,2 @@ + +export { }; diff --git a/dist/codex/happyMcpStdioBridge.d.mts b/dist/codex/happyMcpStdioBridge.d.mts new file mode 100644 index 00000000..04fa25ee --- /dev/null +++ b/dist/codex/happyMcpStdioBridge.d.mts @@ -0,0 +1,2 @@ + +export { }; diff --git a/dist/codex/happyMcpStdioBridge.mjs b/dist/codex/happyMcpStdioBridge.mjs new file mode 100644 index 00000000..d54117f4 --- /dev/null +++ b/dist/codex/happyMcpStdioBridge.mjs @@ -0,0 +1,78 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { z } from 'zod'; + +function parseArgs(argv) { + let url = null; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--url" && i + 1 < argv.length) { + url = argv[i + 1]; + i++; + } + } + return { url }; +} +async function main() { + const { url: urlFromArgs } = parseArgs(process.argv.slice(2)); + const baseUrl = urlFromArgs || process.env.HAPPY_HTTP_MCP_URL || ""; + if (!baseUrl) { + process.stderr.write( + "[happy-mcp] Missing target URL. Set HAPPY_HTTP_MCP_URL or pass --url \n" + ); + process.exit(2); + } + let httpClient = null; + async function ensureHttpClient() { + if (httpClient) return httpClient; + const client = new Client( + { name: "happy-stdio-bridge", version: "1.0.0" }, + { capabilities: { tools: {} } } + ); + const transport = new StreamableHTTPClientTransport(new URL(baseUrl)); + await client.connect(transport); + httpClient = client; + return client; + } + const server = new McpServer({ + name: "Happy MCP Bridge", + version: "1.0.0", + description: "STDIO bridge forwarding to Happy HTTP MCP" + }); + server.registerTool( + "change_title", + { + description: "Change the title of the current chat session", + title: "Change Chat Title", + inputSchema: { + title: z.string().describe("The new title for the chat session") + } + }, + async (args) => { + try { + const client = await ensureHttpClient(); + const response = await client.callTool({ name: "change_title", arguments: args }); + return response; + } catch (error) { + return { + content: [ + { type: "text", text: `Failed to change chat title: ${error instanceof Error ? error.message : String(error)}` } + ], + isError: true + }; + } + } + ); + const stdio = new StdioServerTransport(); + await server.connect(stdio); +} +main().catch((err) => { + try { + process.stderr.write(`[happy-mcp] Fatal: ${err instanceof Error ? err.message : String(err)} +`); + } finally { + process.exit(1); + } +}); diff --git a/dist/index-D_vLWXIM.mjs b/dist/index-D_vLWXIM.mjs new file mode 100644 index 00000000..42bff3b9 --- /dev/null +++ b/dist/index-D_vLWXIM.mjs @@ -0,0 +1,6105 @@ +import chalk from 'chalk'; +import os$1, { homedir } from 'node:os'; +import { randomUUID, randomBytes } from 'node:crypto'; +import { l as logger, p as projectPath, e as backoff, d as delay, R as RawJSONLinesSchema, f as AsyncLock, c as configuration, g as readDaemonState, h as clearDaemonState, b as packageJson, r as readSettings, i as readCredentials, j as encodeBase64, u as updateSettings, k as encodeBase64Url, m as decodeBase64, w as writeCredentialsLegacy, n as writeCredentialsDataKey, o as acquireDaemonLock, q as writeDaemonState, A as ApiClient, s as releaseDaemonLock, t as clearCredentials, v as clearMachineId, x as getLatestDaemonLog } from './types-B9QUFc40.mjs'; +import { spawn, execSync, execFileSync } from 'node:child_process'; +import { resolve, join } from 'node:path'; +import { createInterface } from 'node:readline'; +import { existsSync, readFileSync, mkdirSync, watch, readdirSync, statSync, rmSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import fs, { watch as watch$1, access } from 'fs/promises'; +import { useStdout, useInput, Box, Text, render } from 'ink'; +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { fileURLToPath } from 'node:url'; +import axios from 'axios'; +import 'node:events'; +import 'socket.io-client'; +import tweetnacl from 'tweetnacl'; +import 'expo-server-sdk'; +import { createHash, randomBytes as randomBytes$1 } from 'crypto'; +import { spawn as spawn$1, execSync as execSync$1, exec } from 'child_process'; +import { readFileSync as readFileSync$1, existsSync as existsSync$1, writeFileSync, chmodSync, unlinkSync } from 'fs'; +import { join as join$1 } from 'path'; +import psList from 'ps-list'; +import spawn$2 from 'cross-spawn'; +import os from 'os'; +import * as tmp from 'tmp'; +import qrcode from 'qrcode-terminal'; +import open from 'open'; +import fastify from 'fastify'; +import { z } from 'zod'; +import { validatorCompiler, serializerCompiler } from 'fastify-type-provider-zod'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { createServer } from 'node:http'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { createServer as createServer$1 } from 'http'; +import { promisify } from 'util'; + +class Session { + path; + logPath; + api; + client; + queue; + claudeEnvVars; + claudeArgs; + // Made mutable to allow filtering + mcpServers; + allowedTools; + _onModeChange; + sessionId; + mode = "local"; + thinking = false; + constructor(opts) { + this.path = opts.path; + this.api = opts.api; + this.client = opts.client; + this.logPath = opts.logPath; + this.sessionId = opts.sessionId; + this.queue = opts.messageQueue; + this.claudeEnvVars = opts.claudeEnvVars; + this.claudeArgs = opts.claudeArgs; + this.mcpServers = opts.mcpServers; + this.allowedTools = opts.allowedTools; + this._onModeChange = opts.onModeChange; + this.client.keepAlive(this.thinking, this.mode); + setInterval(() => { + this.client.keepAlive(this.thinking, this.mode); + }, 2e3); + } + onThinkingChange = (thinking) => { + this.thinking = thinking; + this.client.keepAlive(thinking, this.mode); + }; + onModeChange = (mode) => { + this.mode = mode; + this.client.keepAlive(this.thinking, mode); + this._onModeChange(mode); + }; + onSessionFound = (sessionId) => { + this.sessionId = sessionId; + this.client.updateMetadata((metadata) => ({ + ...metadata, + claudeSessionId: sessionId + })); + logger.debug(`[Session] Claude Code session ID ${sessionId} added to metadata`); + }; + /** + * Clear the current session ID (used by /clear command) + */ + clearSessionId = () => { + this.sessionId = null; + logger.debug("[Session] Session ID cleared"); + }; + /** + * Consume one-time Claude flags from claudeArgs after Claude spawn + * Currently handles: --resume (with or without session ID) + */ + consumeOneTimeFlags = () => { + if (!this.claudeArgs) return; + const filteredArgs = []; + for (let i = 0; i < this.claudeArgs.length; i++) { + if (this.claudeArgs[i] === "--resume") { + if (i + 1 < this.claudeArgs.length) { + const nextArg = this.claudeArgs[i + 1]; + if (!nextArg.startsWith("-") && nextArg.includes("-")) { + i++; + logger.debug(`[Session] Consumed --resume flag with session ID: ${nextArg}`); + } else { + logger.debug("[Session] Consumed --resume flag (no session ID)"); + } + } else { + logger.debug("[Session] Consumed --resume flag (no session ID)"); + } + } else { + filteredArgs.push(this.claudeArgs[i]); + } + } + this.claudeArgs = filteredArgs.length > 0 ? filteredArgs : void 0; + logger.debug(`[Session] Consumed one-time flags, remaining args:`, this.claudeArgs); + }; +} + +function getProjectPath(workingDirectory) { + const projectId = resolve(workingDirectory).replace(/[\\\/\.:]/g, "-"); + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"); + return join(claudeConfigDir, "projects", projectId); +} + +function claudeCheckSession(sessionId, path) { + const projectDir = getProjectPath(path); + const sessionFile = join(projectDir, `${sessionId}.jsonl`); + const sessionExists = existsSync(sessionFile); + if (!sessionExists) { + logger.debug(`[claudeCheckSession] Path ${sessionFile} does not exist`); + return false; + } + const sessionData = readFileSync(sessionFile, "utf-8").split("\n"); + const hasGoodMessage = !!sessionData.find((v) => { + try { + return typeof JSON.parse(v).uuid === "string"; + } catch (e) { + return false; + } + }); + return hasGoodMessage; +} + +function trimIdent(text) { + const lines = text.split("\n"); + while (lines.length > 0 && lines[0].trim() === "") { + lines.shift(); + } + while (lines.length > 0 && lines[lines.length - 1].trim() === "") { + lines.pop(); + } + const minSpaces = lines.reduce((min, line) => { + if (line.trim() === "") { + return min; + } + const leadingSpaces = line.match(/^\s*/)[0].length; + return Math.min(min, leadingSpaces); + }, Infinity); + const trimmedLines = lines.map((line) => line.slice(minSpaces)); + return trimmedLines.join("\n"); +} + +function getClaudeSettingsPath() { + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"); + return join(claudeConfigDir, "settings.json"); +} +function readClaudeSettings() { + try { + const settingsPath = getClaudeSettingsPath(); + if (!existsSync(settingsPath)) { + logger.debug(`[ClaudeSettings] No Claude settings file found at ${settingsPath}`); + return null; + } + const settingsContent = readFileSync(settingsPath, "utf-8"); + const settings = JSON.parse(settingsContent); + logger.debug(`[ClaudeSettings] Successfully read Claude settings from ${settingsPath}`); + logger.debug(`[ClaudeSettings] includeCoAuthoredBy: ${settings.includeCoAuthoredBy}`); + return settings; + } catch (error) { + logger.debug(`[ClaudeSettings] Error reading Claude settings: ${error}`); + return null; + } +} +function shouldIncludeCoAuthoredBy() { + const settings = readClaudeSettings(); + if (!settings || settings.includeCoAuthoredBy === void 0) { + return true; + } + return settings.includeCoAuthoredBy; +} + +const BASE_SYSTEM_PROMPT = (() => trimIdent(` + ALWAYS when you start a new chat - you must call a tool "mcp__happy__change_title" to set a chat title. When you think chat title is not relevant anymore - call the tool again to change it. When chat name is too generic and you have a change to make it more specific - call the tool again to change it. This title is needed to easily find the chat in the future. Help human. +`))(); +const CO_AUTHORED_CREDITS = (() => trimIdent(` + When making commit messages, instead of just giving co-credit to Claude, also give credit to Happy like so: + +
+ + Generated with [Claude Code](https://claude.ai/code) + via [Happy](https://happy.engineering) + + Co-Authored-By: Claude + Co-Authored-By: Happy +`))(); +const systemPrompt = (() => { + const includeCoAuthored = shouldIncludeCoAuthoredBy(); + if (includeCoAuthored) { + return BASE_SYSTEM_PROMPT + "\n\n" + CO_AUTHORED_CREDITS; + } else { + return BASE_SYSTEM_PROMPT; + } +})(); + +const claudeCliPath = resolve(join(projectPath(), "scripts", "claude_local_launcher.cjs")); +async function claudeLocal(opts) { + const projectDir = getProjectPath(opts.path); + mkdirSync(projectDir, { recursive: true }); + const watcher = watch(projectDir); + let resolvedSessionId = null; + const detectedIdsRandomUUID = /* @__PURE__ */ new Set(); + const detectedIdsFileSystem = /* @__PURE__ */ new Set(); + watcher.on("change", (event, filename) => { + if (typeof filename === "string" && filename.toLowerCase().endsWith(".jsonl")) { + logger.debug("change", event, filename); + const sessionId = filename.replace(".jsonl", ""); + if (detectedIdsFileSystem.has(sessionId)) { + return; + } + detectedIdsFileSystem.add(sessionId); + if (resolvedSessionId) { + return; + } + if (detectedIdsRandomUUID.has(sessionId)) { + resolvedSessionId = sessionId; + opts.onSessionFound(sessionId); + } + } + }); + let startFrom = opts.sessionId; + if (opts.sessionId && !claudeCheckSession(opts.sessionId, opts.path)) { + startFrom = null; + } + let thinking = false; + let stopThinkingTimeout = null; + const updateThinking = (newThinking) => { + if (thinking !== newThinking) { + thinking = newThinking; + logger.debug(`[ClaudeLocal] Thinking state changed to: ${thinking}`); + if (opts.onThinkingChange) { + opts.onThinkingChange(thinking); + } + } + }; + try { + process.stdin.pause(); + await new Promise((r, reject) => { + const args = []; + if (startFrom) { + args.push("--resume", startFrom); + } + args.push("--append-system-prompt", systemPrompt); + if (opts.mcpServers && Object.keys(opts.mcpServers).length > 0) { + args.push("--mcp-config", JSON.stringify({ mcpServers: opts.mcpServers })); + } + if (opts.allowedTools && opts.allowedTools.length > 0) { + args.push("--allowedTools", opts.allowedTools.join(",")); + } + if (opts.claudeArgs) { + args.push(...opts.claudeArgs); + } + if (!claudeCliPath || !existsSync(claudeCliPath)) { + throw new Error("Claude local launcher not found. Please ensure HAPPY_PROJECT_ROOT is set correctly for development."); + } + const env = { + ...process.env, + ...opts.claudeEnvVars + }; + const child = spawn("node", [claudeCliPath, ...args], { + stdio: ["inherit", "inherit", "inherit", "pipe"], + signal: opts.abort, + cwd: opts.path, + env + }); + if (child.stdio[3]) { + const rl = createInterface({ + input: child.stdio[3], + crlfDelay: Infinity + }); + const activeFetches = /* @__PURE__ */ new Map(); + rl.on("line", (line) => { + try { + const message = JSON.parse(line); + switch (message.type) { + case "uuid": + detectedIdsRandomUUID.add(message.value); + if (!resolvedSessionId && detectedIdsFileSystem.has(message.value)) { + resolvedSessionId = message.value; + opts.onSessionFound(message.value); + } + break; + case "fetch-start": + activeFetches.set(message.id, { + hostname: message.hostname, + path: message.path, + startTime: message.timestamp + }); + if (stopThinkingTimeout) { + clearTimeout(stopThinkingTimeout); + stopThinkingTimeout = null; + } + updateThinking(true); + break; + case "fetch-end": + activeFetches.delete(message.id); + if (activeFetches.size === 0 && thinking && !stopThinkingTimeout) { + stopThinkingTimeout = setTimeout(() => { + if (activeFetches.size === 0) { + updateThinking(false); + } + stopThinkingTimeout = null; + }, 500); + } + break; + default: + logger.debug(`[ClaudeLocal] Unknown message type: ${message.type}`); + } + } catch (e) { + logger.debug(`[ClaudeLocal] Non-JSON line from fd3: ${line}`); + } + }); + rl.on("error", (err) => { + console.error("Error reading from fd 3:", err); + }); + child.on("exit", () => { + if (stopThinkingTimeout) { + clearTimeout(stopThinkingTimeout); + } + updateThinking(false); + }); + } + child.on("error", (error) => { + }); + child.on("exit", (code, signal) => { + if (signal === "SIGTERM" && opts.abort.aborted) { + r(); + } else if (signal) { + reject(new Error(`Process terminated with signal: ${signal}`)); + } else { + r(); + } + }); + }); + } finally { + watcher.close(); + process.stdin.resume(); + if (stopThinkingTimeout) { + clearTimeout(stopThinkingTimeout); + stopThinkingTimeout = null; + } + updateThinking(false); + } + return resolvedSessionId; +} + +class Future { + _resolve; + _reject; + _promise; + constructor() { + this._promise = new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + }); + } + resolve(value) { + this._resolve(value); + } + reject(reason) { + this._reject(reason); + } + get promise() { + return this._promise; + } +} + +class InvalidateSync { + _invalidated = false; + _invalidatedDouble = false; + _stopped = false; + _command; + _pendings = []; + constructor(command) { + this._command = command; + } + invalidate() { + if (this._stopped) { + return; + } + if (!this._invalidated) { + this._invalidated = true; + this._invalidatedDouble = false; + this._doSync(); + } else { + if (!this._invalidatedDouble) { + this._invalidatedDouble = true; + } + } + } + async invalidateAndAwait() { + if (this._stopped) { + return; + } + await new Promise((resolve) => { + this._pendings.push(resolve); + this.invalidate(); + }); + } + stop() { + if (this._stopped) { + return; + } + this._notifyPendings(); + this._stopped = true; + } + _notifyPendings = () => { + for (let pending of this._pendings) { + pending(); + } + this._pendings = []; + }; + _doSync = async () => { + await backoff(async () => { + if (this._stopped) { + return; + } + await this._command(); + }); + if (this._stopped) { + this._notifyPendings(); + return; + } + if (this._invalidatedDouble) { + this._invalidatedDouble = false; + this._doSync(); + } else { + this._invalidated = false; + this._notifyPendings(); + } + }; +} + +function startFileWatcher(file, onFileChange) { + const abortController = new AbortController(); + void (async () => { + while (true) { + try { + logger.debug(`[FILE_WATCHER] Starting watcher for ${file}`); + const watcher = watch$1(file, { persistent: true, signal: abortController.signal }); + for await (const event of watcher) { + if (abortController.signal.aborted) { + return; + } + logger.debug(`[FILE_WATCHER] File changed: ${file}`); + onFileChange(file); + } + } catch (e) { + if (abortController.signal.aborted) { + return; + } + logger.debug(`[FILE_WATCHER] Watch error: ${e.message}, restarting watcher in a second`); + await delay(1e3); + } + } + })(); + return () => { + abortController.abort(); + }; +} + +async function createSessionScanner(opts) { + const projectDir = getProjectPath(opts.workingDirectory); + let finishedSessions = /* @__PURE__ */ new Set(); + let pendingSessions = /* @__PURE__ */ new Set(); + let currentSessionId = null; + let watchers = /* @__PURE__ */ new Map(); + let processedMessageKeys = /* @__PURE__ */ new Set(); + if (opts.sessionId) { + let messages = await readSessionLog(projectDir, opts.sessionId); + for (let m of messages) { + processedMessageKeys.add(messageKey(m)); + } + } + const sync = new InvalidateSync(async () => { + let sessions = []; + for (let p of pendingSessions) { + sessions.push(p); + } + if (currentSessionId) { + sessions.push(currentSessionId); + } + for (let session of sessions) { + for (let file of await readSessionLog(projectDir, session)) { + let key = messageKey(file); + if (processedMessageKeys.has(key)) { + continue; + } + processedMessageKeys.add(key); + opts.onMessage(file); + } + } + for (let p of sessions) { + if (pendingSessions.has(p)) { + pendingSessions.delete(p); + finishedSessions.add(p); + } + } + for (let p of sessions) { + if (!watchers.has(p)) { + watchers.set(p, startFileWatcher(join(projectDir, `${p}.jsonl`), () => { + sync.invalidate(); + })); + } + } + }); + await sync.invalidateAndAwait(); + const intervalId = setInterval(() => { + sync.invalidate(); + }, 3e3); + return { + cleanup: async () => { + clearInterval(intervalId); + for (let w of watchers.values()) { + w(); + } + watchers.clear(); + await sync.invalidateAndAwait(); + sync.stop(); + }, + onNewSession: (sessionId) => { + if (currentSessionId === sessionId) { + logger.debug(`[SESSION_SCANNER] New session: ${sessionId} is the same as the current session, skipping`); + return; + } + if (finishedSessions.has(sessionId)) { + logger.debug(`[SESSION_SCANNER] New session: ${sessionId} is already finished, skipping`); + return; + } + if (pendingSessions.has(sessionId)) { + logger.debug(`[SESSION_SCANNER] New session: ${sessionId} is already pending, skipping`); + return; + } + if (currentSessionId) { + pendingSessions.add(currentSessionId); + } + logger.debug(`[SESSION_SCANNER] New session: ${sessionId}`); + currentSessionId = sessionId; + sync.invalidate(); + } + }; +} +function messageKey(message) { + if (message.type === "user") { + return message.uuid; + } else if (message.type === "assistant") { + return message.uuid; + } else if (message.type === "summary") { + return "summary: " + message.leafUuid + ": " + message.summary; + } else if (message.type === "system") { + return message.uuid; + } else { + throw Error(); + } +} +async function readSessionLog(projectDir, sessionId) { + const expectedSessionFile = join(projectDir, `${sessionId}.jsonl`); + logger.debug(`[SESSION_SCANNER] Reading session file: ${expectedSessionFile}`); + let file; + try { + file = await readFile(expectedSessionFile, "utf-8"); + } catch (error) { + logger.debug(`[SESSION_SCANNER] Session file not found: ${expectedSessionFile}`); + return []; + } + let lines = file.split("\n"); + let messages = []; + for (let l of lines) { + try { + if (l.trim() === "") { + continue; + } + let message = JSON.parse(l); + let parsed = RawJSONLinesSchema.safeParse(message); + if (!parsed.success) { + logger.debugLargeJson(`[SESSION_SCANNER] Failed to parse message`, message); + continue; + } + messages.push(parsed.data); + } catch (e) { + logger.debug(`[SESSION_SCANNER] Error processing message: ${e}`); + continue; + } + } + return messages; +} + +async function claudeLocalLauncher(session) { + const scanner = await createSessionScanner({ + sessionId: session.sessionId, + workingDirectory: session.path, + onMessage: (message) => { + if (message.type !== "summary") { + session.client.sendClaudeSessionMessage(message); + } + } + }); + let exitReason = null; + const processAbortController = new AbortController(); + let exutFuture = new Future(); + try { + async function abort() { + if (!processAbortController.signal.aborted) { + processAbortController.abort(); + } + await exutFuture.promise; + } + async function doAbort() { + logger.debug("[local]: doAbort"); + if (!exitReason) { + exitReason = "switch"; + } + session.queue.reset(); + await abort(); + } + async function doSwitch() { + logger.debug("[local]: doSwitch"); + if (!exitReason) { + exitReason = "switch"; + } + await abort(); + } + session.client.rpcHandlerManager.registerHandler("abort", doAbort); + session.client.rpcHandlerManager.registerHandler("switch", doSwitch); + session.queue.setOnMessage((message, mode) => { + doSwitch(); + }); + if (session.queue.size() > 0) { + return "switch"; + } + const handleSessionStart = (sessionId) => { + session.onSessionFound(sessionId); + scanner.onNewSession(sessionId); + }; + while (true) { + if (exitReason) { + return exitReason; + } + logger.debug("[local]: launch"); + try { + await claudeLocal({ + path: session.path, + sessionId: session.sessionId, + onSessionFound: handleSessionStart, + onThinkingChange: session.onThinkingChange, + abort: processAbortController.signal, + claudeEnvVars: session.claudeEnvVars, + claudeArgs: session.claudeArgs, + mcpServers: session.mcpServers, + allowedTools: session.allowedTools + }); + session.consumeOneTimeFlags(); + if (!exitReason) { + exitReason = "exit"; + break; + } + } catch (e) { + logger.debug("[local]: launch error", e); + if (!exitReason) { + session.client.sendSessionEvent({ type: "message", message: "Process exited unexpectedly" }).catch((err) => logger.debug("[Claude] Failed to send exit event:", err)); + continue; + } else { + break; + } + } + logger.debug("[local]: launch done"); + } + } finally { + exutFuture.resolve(void 0); + session.client.rpcHandlerManager.registerHandler("abort", async () => { + }); + session.client.rpcHandlerManager.registerHandler("switch", async () => { + }); + session.queue.setOnMessage(null); + await scanner.cleanup(); + } + return exitReason || "exit"; +} + +class MessageBuffer { + messages = []; + listeners = []; + nextId = 1; + addMessage(content, type = "assistant") { + const message = { + id: `msg-${this.nextId++}`, + timestamp: /* @__PURE__ */ new Date(), + content, + type + }; + this.messages.push(message); + this.notifyListeners(); + } + getMessages() { + return [...this.messages]; + } + clear() { + this.messages = []; + this.nextId = 1; + this.notifyListeners(); + } + onUpdate(listener) { + this.listeners.push(listener); + return () => { + const index = this.listeners.indexOf(listener); + if (index > -1) { + this.listeners.splice(index, 1); + } + }; + } + notifyListeners() { + const messages = this.getMessages(); + this.listeners.forEach((listener) => listener(messages)); + } +} + +const RemoteModeDisplay = ({ messageBuffer, logPath, onExit, onSwitchToLocal }) => { + const [messages, setMessages] = useState([]); + const [confirmationMode, setConfirmationMode] = useState(null); + const [actionInProgress, setActionInProgress] = useState(null); + const confirmationTimeoutRef = useRef(null); + const { stdout } = useStdout(); + const terminalWidth = stdout.columns || 80; + const terminalHeight = stdout.rows || 24; + useEffect(() => { + setMessages(messageBuffer.getMessages()); + const unsubscribe = messageBuffer.onUpdate((newMessages) => { + setMessages(newMessages); + }); + return () => { + unsubscribe(); + if (confirmationTimeoutRef.current) { + clearTimeout(confirmationTimeoutRef.current); + } + }; + }, [messageBuffer]); + const resetConfirmation = useCallback(() => { + setConfirmationMode(null); + if (confirmationTimeoutRef.current) { + clearTimeout(confirmationTimeoutRef.current); + confirmationTimeoutRef.current = null; + } + }, []); + const setConfirmationWithTimeout = useCallback((mode) => { + setConfirmationMode(mode); + if (confirmationTimeoutRef.current) { + clearTimeout(confirmationTimeoutRef.current); + } + confirmationTimeoutRef.current = setTimeout(() => { + resetConfirmation(); + }, 15e3); + }, [resetConfirmation]); + useInput(useCallback(async (input, key) => { + if (actionInProgress) return; + if (key.ctrl && input === "c") { + if (confirmationMode === "exit") { + resetConfirmation(); + setActionInProgress("exiting"); + await new Promise((resolve) => setTimeout(resolve, 100)); + onExit?.(); + } else { + setConfirmationWithTimeout("exit"); + } + return; + } + if (input === " ") { + if (confirmationMode === "switch") { + resetConfirmation(); + setActionInProgress("switching"); + await new Promise((resolve) => setTimeout(resolve, 100)); + onSwitchToLocal?.(); + } else { + setConfirmationWithTimeout("switch"); + } + return; + } + if (confirmationMode) { + resetConfirmation(); + } + }, [confirmationMode, actionInProgress, onExit, onSwitchToLocal, setConfirmationWithTimeout, resetConfirmation])); + const getMessageColor = (type) => { + switch (type) { + case "user": + return "magenta"; + case "assistant": + return "cyan"; + case "system": + return "blue"; + case "tool": + return "yellow"; + case "result": + return "green"; + case "status": + return "gray"; + default: + return "white"; + } + }; + const formatMessage = (msg) => { + const lines = msg.content.split("\n"); + const maxLineLength = terminalWidth - 10; + return lines.map((line) => { + if (line.length <= maxLineLength) return line; + const chunks = []; + for (let i = 0; i < line.length; i += maxLineLength) { + chunks.push(line.slice(i, i + maxLineLength)); + } + return chunks.join("\n"); + }).join("\n"); + }; + return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", width: terminalWidth, height: terminalHeight }, /* @__PURE__ */ React.createElement( + Box, + { + flexDirection: "column", + width: terminalWidth, + height: terminalHeight - 4, + borderStyle: "round", + borderColor: "gray", + paddingX: 1, + overflow: "hidden" + }, + /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", marginBottom: 1 }, /* @__PURE__ */ React.createElement(Text, { color: "gray", bold: true }, "\u{1F4E1} Remote Mode - Claude Messages"), /* @__PURE__ */ React.createElement(Text, { color: "gray", dimColor: true }, "\u2500".repeat(Math.min(terminalWidth - 4, 60)))), + /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", height: terminalHeight - 10, overflow: "hidden" }, messages.length === 0 ? /* @__PURE__ */ React.createElement(Text, { color: "gray", dimColor: true }, "Waiting for messages...") : ( + // Show only the last messages that fit in the available space + messages.slice(-Math.max(1, terminalHeight - 10)).map((msg) => /* @__PURE__ */ React.createElement(Box, { key: msg.id, flexDirection: "column", marginBottom: 1 }, /* @__PURE__ */ React.createElement(Text, { color: getMessageColor(msg.type), dimColor: true }, formatMessage(msg)))) + )) + ), /* @__PURE__ */ React.createElement( + Box, + { + width: terminalWidth, + borderStyle: "round", + borderColor: actionInProgress ? "gray" : confirmationMode === "exit" ? "red" : confirmationMode === "switch" ? "yellow" : "green", + paddingX: 2, + justifyContent: "center", + alignItems: "center", + flexDirection: "column" + }, + /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", alignItems: "center" }, actionInProgress === "exiting" ? /* @__PURE__ */ React.createElement(Text, { color: "gray", bold: true }, "Exiting...") : actionInProgress === "switching" ? /* @__PURE__ */ React.createElement(Text, { color: "gray", bold: true }, "Switching to local mode...") : confirmationMode === "exit" ? /* @__PURE__ */ React.createElement(Text, { color: "red", bold: true }, "\u26A0\uFE0F Press Ctrl-C again to exit completely") : confirmationMode === "switch" ? /* @__PURE__ */ React.createElement(Text, { color: "yellow", bold: true }, "\u23F8\uFE0F Press space again to switch to local mode") : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Text, { color: "green", bold: true }, "\u{1F4F1} Press space to switch to local mode \u2022 Ctrl-C to exit")), process.env.DEBUG && logPath && /* @__PURE__ */ React.createElement(Text, { color: "gray", dimColor: true }, "Debug logs: ", logPath)) + )); +}; + +class Stream { + constructor(returned) { + this.returned = returned; + } + queue = []; + readResolve; + readReject; + isDone = false; + hasError; + started = false; + /** + * Implements async iterable protocol + */ + [Symbol.asyncIterator]() { + if (this.started) { + throw new Error("Stream can only be iterated once"); + } + this.started = true; + return this; + } + /** + * Gets the next value from the stream + */ + async next() { + if (this.queue.length > 0) { + return Promise.resolve({ + done: false, + value: this.queue.shift() + }); + } + if (this.isDone) { + return Promise.resolve({ done: true, value: void 0 }); + } + if (this.hasError) { + return Promise.reject(this.hasError); + } + return new Promise((resolve, reject) => { + this.readResolve = resolve; + this.readReject = reject; + }); + } + /** + * Adds a value to the stream + */ + enqueue(value) { + if (this.readResolve) { + const resolve = this.readResolve; + this.readResolve = void 0; + this.readReject = void 0; + resolve({ done: false, value }); + } else { + this.queue.push(value); + } + } + /** + * Marks the stream as complete + */ + done() { + this.isDone = true; + if (this.readResolve) { + const resolve = this.readResolve; + this.readResolve = void 0; + this.readReject = void 0; + resolve({ done: true, value: void 0 }); + } + } + /** + * Propagates an error through the stream + */ + error(error) { + this.hasError = error; + if (this.readReject) { + const reject = this.readReject; + this.readResolve = void 0; + this.readReject = void 0; + reject(error); + } + } + /** + * Implements async iterator cleanup + */ + async return() { + this.isDone = true; + if (this.returned) { + this.returned(); + } + return Promise.resolve({ done: true, value: void 0 }); + } +} + +class AbortError extends Error { + constructor(message) { + super(message); + this.name = "AbortError"; + } +} + +const __filename = fileURLToPath(import.meta.url); +const __dirname = join(__filename, ".."); +function getDefaultClaudeCodePath() { + return join(__dirname, "..", "..", "..", "node_modules", "@anthropic-ai", "claude-code", "cli.js"); +} +function logDebug(message) { + if (process.env.DEBUG) { + logger.debug(message); + console.log(message); + } +} +async function streamToStdin(stream, stdin, abort) { + for await (const message of stream) { + if (abort?.aborted) break; + stdin.write(JSON.stringify(message) + "\n"); + } + stdin.end(); +} + +class Query { + constructor(childStdin, childStdout, processExitPromise, canCallTool) { + this.childStdin = childStdin; + this.childStdout = childStdout; + this.processExitPromise = processExitPromise; + this.canCallTool = canCallTool; + this.readMessages(); + this.sdkMessages = this.readSdkMessages(); + } + pendingControlResponses = /* @__PURE__ */ new Map(); + cancelControllers = /* @__PURE__ */ new Map(); + sdkMessages; + inputStream = new Stream(); + canCallTool; + /** + * Set an error on the stream + */ + setError(error) { + this.inputStream.error(error); + } + /** + * AsyncIterableIterator implementation + */ + next(...args) { + return this.sdkMessages.next(...args); + } + return(value) { + if (this.sdkMessages.return) { + return this.sdkMessages.return(value); + } + return Promise.resolve({ done: true, value: void 0 }); + } + throw(e) { + if (this.sdkMessages.throw) { + return this.sdkMessages.throw(e); + } + return Promise.reject(e); + } + [Symbol.asyncIterator]() { + return this.sdkMessages; + } + /** + * Read messages from Claude process stdout + */ + async readMessages() { + const rl = createInterface({ input: this.childStdout }); + try { + for await (const line of rl) { + if (line.trim()) { + try { + const message = JSON.parse(line); + if (message.type === "control_response") { + const controlResponse = message; + const handler = this.pendingControlResponses.get(controlResponse.response.request_id); + if (handler) { + handler(controlResponse.response); + } + continue; + } else if (message.type === "control_request") { + await this.handleControlRequest(message); + continue; + } else if (message.type === "control_cancel_request") { + this.handleControlCancelRequest(message); + continue; + } + this.inputStream.enqueue(message); + } catch (e) { + logger.debug(line); + } + } + } + await this.processExitPromise; + } catch (error) { + this.inputStream.error(error); + } finally { + this.inputStream.done(); + this.cleanupControllers(); + rl.close(); + } + } + /** + * Async generator for SDK messages + */ + async *readSdkMessages() { + for await (const message of this.inputStream) { + yield message; + } + } + /** + * Send interrupt request to Claude + */ + async interrupt() { + if (!this.childStdin) { + throw new Error("Interrupt requires --input-format stream-json"); + } + await this.request({ + subtype: "interrupt" + }, this.childStdin); + } + /** + * Send control request to Claude process + */ + request(request, childStdin) { + const requestId = Math.random().toString(36).substring(2, 15); + const sdkRequest = { + request_id: requestId, + type: "control_request", + request + }; + return new Promise((resolve, reject) => { + this.pendingControlResponses.set(requestId, (response) => { + if (response.subtype === "success") { + resolve(response); + } else { + reject(new Error(response.error)); + } + }); + childStdin.write(JSON.stringify(sdkRequest) + "\n"); + }); + } + /** + * Handle incoming control requests for tool permissions + * Replicates the exact logic from the SDK's handleControlRequest method + */ + async handleControlRequest(request) { + if (!this.childStdin) { + logDebug("Cannot handle control request - no stdin available"); + return; + } + const controller = new AbortController(); + this.cancelControllers.set(request.request_id, controller); + try { + const response = await this.processControlRequest(request, controller.signal); + const controlResponse = { + type: "control_response", + response: { + subtype: "success", + request_id: request.request_id, + response + } + }; + this.childStdin.write(JSON.stringify(controlResponse) + "\n"); + } catch (error) { + const controlErrorResponse = { + type: "control_response", + response: { + subtype: "error", + request_id: request.request_id, + error: error instanceof Error ? error.message : String(error) + } + }; + this.childStdin.write(JSON.stringify(controlErrorResponse) + "\n"); + } finally { + this.cancelControllers.delete(request.request_id); + } + } + /** + * Handle control cancel requests + * Replicates the exact logic from the SDK's handleControlCancelRequest method + */ + handleControlCancelRequest(request) { + const controller = this.cancelControllers.get(request.request_id); + if (controller) { + controller.abort(); + this.cancelControllers.delete(request.request_id); + } + } + /** + * Process control requests based on subtype + * Replicates the exact logic from the SDK's processControlRequest method + */ + async processControlRequest(request, signal) { + if (request.request.subtype === "can_use_tool") { + if (!this.canCallTool) { + throw new Error("canCallTool callback is not provided."); + } + return this.canCallTool(request.request.tool_name, request.request.input, { + signal + }); + } + throw new Error("Unsupported control request subtype: " + request.request.subtype); + } + /** + * Cleanup method to abort all pending control requests + */ + cleanupControllers() { + for (const [requestId, controller] of this.cancelControllers.entries()) { + controller.abort(); + this.cancelControllers.delete(requestId); + } + } +} +function query(config) { + const { + prompt, + options: { + allowedTools = [], + appendSystemPrompt, + customSystemPrompt, + cwd, + disallowedTools = [], + executable = "node", + executableArgs = [], + maxTurns, + mcpServers, + pathToClaudeCodeExecutable = getDefaultClaudeCodePath(), + permissionMode = "default", + continue: continueConversation, + resume, + model, + fallbackModel, + strictMcpConfig, + canCallTool + } = {} + } = config; + if (!process.env.CLAUDE_CODE_ENTRYPOINT) { + process.env.CLAUDE_CODE_ENTRYPOINT = "sdk-ts"; + } + const args = ["--output-format", "stream-json", "--verbose"]; + if (customSystemPrompt) args.push("--system-prompt", customSystemPrompt); + if (appendSystemPrompt) args.push("--append-system-prompt", appendSystemPrompt); + if (maxTurns) args.push("--max-turns", maxTurns.toString()); + if (model) args.push("--model", model); + if (canCallTool) { + if (typeof prompt === "string") { + throw new Error("canCallTool callback requires --input-format stream-json. Please set prompt as an AsyncIterable."); + } + args.push("--permission-prompt-tool", "stdio"); + } + if (continueConversation) args.push("--continue"); + if (resume) args.push("--resume", resume); + if (allowedTools.length > 0) args.push("--allowedTools", allowedTools.join(",")); + if (disallowedTools.length > 0) args.push("--disallowedTools", disallowedTools.join(",")); + if (mcpServers && Object.keys(mcpServers).length > 0) { + args.push("--mcp-config", JSON.stringify({ mcpServers })); + } + if (strictMcpConfig) args.push("--strict-mcp-config"); + if (permissionMode) args.push("--permission-mode", permissionMode); + if (fallbackModel) { + if (model && fallbackModel === model) { + throw new Error("Fallback model cannot be the same as the main model. Please specify a different model for fallbackModel option."); + } + args.push("--fallback-model", fallbackModel); + } + if (typeof prompt === "string") { + args.push("--print", prompt.trim()); + } else { + args.push("--input-format", "stream-json"); + } + if (!existsSync(pathToClaudeCodeExecutable)) { + throw new ReferenceError(`Claude Code executable not found at ${pathToClaudeCodeExecutable}. Is options.pathToClaudeCodeExecutable set?`); + } + logDebug(`Spawning Claude Code process: ${executable} ${[...executableArgs, pathToClaudeCodeExecutable, ...args].join(" ")}`); + const child = spawn(executable, [...executableArgs, pathToClaudeCodeExecutable, ...args], { + cwd, + stdio: ["pipe", "pipe", "pipe"], + signal: config.options?.abort, + env: { + ...process.env + } + }); + let childStdin = null; + if (typeof prompt === "string") { + child.stdin.end(); + } else { + streamToStdin(prompt, child.stdin, config.options?.abort); + childStdin = child.stdin; + } + if (process.env.DEBUG) { + child.stderr.on("data", (data) => { + console.error("Claude Code stderr:", data.toString()); + }); + } + const cleanup = () => { + if (!child.killed) { + child.kill("SIGTERM"); + } + }; + config.options?.abort?.addEventListener("abort", cleanup); + process.on("exit", cleanup); + const processExitPromise = new Promise((resolve) => { + child.on("close", (code) => { + if (config.options?.abort?.aborted) { + query2.setError(new AbortError("Claude Code process aborted by user")); + } + if (code !== 0) { + query2.setError(new Error(`Claude Code process exited with code ${code}`)); + } else { + resolve(); + } + }); + }); + const query2 = new Query(childStdin, child.stdout, processExitPromise, canCallTool); + child.on("error", (error) => { + if (config.options?.abort?.aborted) { + query2.setError(new AbortError("Claude Code process aborted by user")); + } else { + query2.setError(new Error(`Failed to spawn Claude Code process: ${error.message}`)); + } + }); + processExitPromise.finally(() => { + cleanup(); + config.options?.abort?.removeEventListener("abort", cleanup); + if (process.env.CLAUDE_SDK_MCP_SERVERS) { + delete process.env.CLAUDE_SDK_MCP_SERVERS; + } + }); + return query2; +} + +function parseCompact(message) { + const trimmed = message.trim(); + if (trimmed === "/compact") { + return { + isCompact: true, + originalMessage: trimmed + }; + } + if (trimmed.startsWith("/compact ")) { + return { + isCompact: true, + originalMessage: trimmed + }; + } + return { + isCompact: false, + originalMessage: message + }; +} +function parseClear(message) { + const trimmed = message.trim(); + return { + isClear: trimmed === "/clear" + }; +} +function parseSpecialCommand(message) { + const compactResult = parseCompact(message); + if (compactResult.isCompact) { + return { + type: "compact", + originalMessage: compactResult.originalMessage + }; + } + const clearResult = parseClear(message); + if (clearResult.isClear) { + return { + type: "clear" + }; + } + return { + type: null + }; +} + +class PushableAsyncIterable { + queue = []; + waiters = []; + isDone = false; + error = null; + started = false; + constructor() { + } + /** + * Push a value to the iterable + */ + push(value) { + if (this.isDone) { + throw new Error("Cannot push to completed iterable"); + } + if (this.error) { + throw this.error; + } + const waiter = this.waiters.shift(); + if (waiter) { + waiter.resolve({ done: false, value }); + } else { + this.queue.push(value); + } + } + /** + * Mark the iterable as complete + */ + end() { + if (this.isDone) { + return; + } + this.isDone = true; + this.cleanup(); + } + /** + * Set an error on the iterable + */ + setError(err) { + if (this.isDone) { + return; + } + this.error = err; + this.isDone = true; + this.cleanup(); + } + /** + * Cleanup waiting consumers + */ + cleanup() { + while (this.waiters.length > 0) { + const waiter = this.waiters.shift(); + if (this.error) { + waiter.reject(this.error); + } else { + waiter.resolve({ done: true, value: void 0 }); + } + } + } + /** + * AsyncIterableIterator implementation + */ + async next() { + if (this.queue.length > 0) { + return { done: false, value: this.queue.shift() }; + } + if (this.isDone) { + if (this.error) { + throw this.error; + } + return { done: true, value: void 0 }; + } + return new Promise((resolve, reject) => { + this.waiters.push({ resolve, reject }); + }); + } + /** + * AsyncIterableIterator return implementation + */ + async return(_value) { + this.end(); + return { done: true, value: void 0 }; + } + /** + * AsyncIterableIterator throw implementation + */ + async throw(e) { + this.setError(e instanceof Error ? e : new Error(String(e))); + throw this.error; + } + /** + * Make this iterable + */ + [Symbol.asyncIterator]() { + if (this.started) { + throw new Error("PushableAsyncIterable can only be iterated once"); + } + this.started = true; + return this; + } + /** + * Check if the iterable is done + */ + get done() { + return this.isDone; + } + /** + * Check if the iterable has an error + */ + get hasError() { + return this.error !== null; + } + /** + * Get the current queue size + */ + get queueSize() { + return this.queue.length; + } + /** + * Get the number of waiting consumers + */ + get waiterCount() { + return this.waiters.length; + } +} + +async function awaitFileExist(file, timeout = 1e4) { + const startTime = Date.now(); + while (Date.now() - startTime < timeout) { + try { + await access(file); + return true; + } catch (e) { + await delay(1e3); + } + } + return false; +} + +async function claudeRemote(opts) { + let startFrom = opts.sessionId; + if (opts.sessionId && !claudeCheckSession(opts.sessionId, opts.path)) { + startFrom = null; + } + if (!startFrom && opts.claudeArgs) { + for (let i = 0; i < opts.claudeArgs.length; i++) { + if (opts.claudeArgs[i] === "--resume") { + if (i + 1 < opts.claudeArgs.length) { + const nextArg = opts.claudeArgs[i + 1]; + if (!nextArg.startsWith("-") && nextArg.includes("-")) { + startFrom = nextArg; + logger.debug(`[claudeRemote] Found --resume with session ID: ${startFrom}`); + break; + } else { + logger.debug("[claudeRemote] Found --resume without session ID - not supported in remote mode"); + break; + } + } else { + logger.debug("[claudeRemote] Found --resume without session ID - not supported in remote mode"); + break; + } + } + } + } + if (opts.claudeEnvVars) { + Object.entries(opts.claudeEnvVars).forEach(([key, value]) => { + process.env[key] = value; + }); + } + const initial = await opts.nextMessage(); + if (!initial) { + return; + } + const specialCommand = parseSpecialCommand(initial.message); + if (specialCommand.type === "clear") { + if (opts.onCompletionEvent) { + opts.onCompletionEvent("Context was reset"); + } + if (opts.onSessionReset) { + opts.onSessionReset(); + } + return; + } + let isCompactCommand = false; + if (specialCommand.type === "compact") { + logger.debug("[claudeRemote] /compact command detected - will process as normal but with compaction behavior"); + isCompactCommand = true; + if (opts.onCompletionEvent) { + opts.onCompletionEvent("Compaction started"); + } + } + let mode = initial.mode; + const sdkOptions = { + cwd: opts.path, + resume: startFrom ?? void 0, + mcpServers: opts.mcpServers, + permissionMode: initial.mode.permissionMode === "plan" ? "plan" : "default", + model: initial.mode.model, + fallbackModel: initial.mode.fallbackModel, + customSystemPrompt: initial.mode.customSystemPrompt ? initial.mode.customSystemPrompt + "\n\n" + systemPrompt : void 0, + appendSystemPrompt: initial.mode.appendSystemPrompt ? initial.mode.appendSystemPrompt + "\n\n" + systemPrompt : systemPrompt, + allowedTools: initial.mode.allowedTools ? initial.mode.allowedTools.concat(opts.allowedTools) : opts.allowedTools, + disallowedTools: initial.mode.disallowedTools, + canCallTool: (toolName, input, options) => opts.canCallTool(toolName, input, mode, options), + executable: "node", + abort: opts.signal, + pathToClaudeCodeExecutable: (() => { + return resolve(join(projectPath(), "scripts", "claude_remote_launcher.cjs")); + })() + }; + let thinking = false; + const updateThinking = (newThinking) => { + if (thinking !== newThinking) { + thinking = newThinking; + logger.debug(`[claudeRemote] Thinking state changed to: ${thinking}`); + if (opts.onThinkingChange) { + opts.onThinkingChange(thinking); + } + } + }; + let messages = new PushableAsyncIterable(); + messages.push({ + type: "user", + message: { + role: "user", + content: initial.message + } + }); + const response = query({ + prompt: messages, + options: sdkOptions + }); + updateThinking(true); + try { + logger.debug(`[claudeRemote] Starting to iterate over response`); + for await (const message of response) { + logger.debugLargeJson(`[claudeRemote] Message ${message.type}`, message); + opts.onMessage(message); + if (message.type === "system" && message.subtype === "init") { + updateThinking(true); + const systemInit = message; + if (systemInit.session_id) { + logger.debug(`[claudeRemote] Waiting for session file to be written to disk: ${systemInit.session_id}`); + const projectDir = getProjectPath(opts.path); + const found = await awaitFileExist(join(projectDir, `${systemInit.session_id}.jsonl`)); + logger.debug(`[claudeRemote] Session file found: ${systemInit.session_id} ${found}`); + opts.onSessionFound(systemInit.session_id); + } + } + if (message.type === "result") { + updateThinking(false); + logger.debug("[claudeRemote] Result received, exiting claudeRemote"); + if (isCompactCommand) { + logger.debug("[claudeRemote] Compaction completed"); + if (opts.onCompletionEvent) { + opts.onCompletionEvent("Compaction completed"); + } + isCompactCommand = false; + } + opts.onReady(); + const next = await opts.nextMessage(); + if (!next) { + messages.end(); + return; + } + mode = next.mode; + messages.push({ type: "user", message: { role: "user", content: next.message } }); + } + if (message.type === "user") { + const msg = message; + if (msg.message.role === "user" && Array.isArray(msg.message.content)) { + for (let c of msg.message.content) { + if (c.type === "tool_result" && c.tool_use_id && opts.isAborted(c.tool_use_id)) { + logger.debug("[claudeRemote] Tool aborted, exiting claudeRemote"); + return; + } + } + } + } + } + } catch (e) { + if (e instanceof AbortError) { + logger.debug(`[claudeRemote] Aborted`); + } else { + throw e; + } + } finally { + updateThinking(false); + } +} + +const PLAN_FAKE_REJECT = `User approved plan, but you need to be restarted. STOP IMMEDIATELY TO SWITCH FROM PLAN MODE. DO NOT REPLY TO THIS MESSAGE.`; +const PLAN_FAKE_RESTART = `PlEaZe Continue with plan.`; + +function deepEqual(a, b) { + if (a === b) return true; + if (a == null || b == null) return false; + if (typeof a !== "object" || typeof b !== "object") return false; + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + for (const key of keysA) { + if (!keysB.includes(key)) return false; + if (!deepEqual(a[key], b[key])) return false; + } + return true; +} + +const STANDARD_TOOLS = { + // File operations + "Read": "Read File", + "Write": "Write File", + "Edit": "Edit File", + "MultiEdit": "Edit File", + "NotebookEdit": "Edit Notebook", + // Search and navigation + "Glob": "Find Files", + "Grep": "Search in Files", + "LS": "List Directory", + // Command execution + "Bash": "Run Command", + "BashOutput": "Check Command Output", + "KillBash": "Stop Command", + // Task management + "TodoWrite": "Update Tasks", + "TodoRead": "Read Tasks", + "Task": "Launch Agent", + // Web tools + "WebFetch": "Fetch Web Page", + "WebSearch": "Search Web", + // Special cases + "exit_plan_mode": "Execute Plan", + "ExitPlanMode": "Execute Plan" +}; +function toTitleCase(str) { + return str.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()); +} +function getToolName(toolName) { + if (STANDARD_TOOLS[toolName]) { + return STANDARD_TOOLS[toolName]; + } + if (toolName.startsWith("mcp__")) { + const parts = toolName.split("__"); + if (parts.length >= 3) { + const server = toTitleCase(parts[1]); + const action = toTitleCase(parts.slice(2).join("_")); + return `${server}: ${action}`; + } + } + return toTitleCase(toolName); +} + +function getToolDescriptor(toolName) { + if (toolName === "exit_plan_mode" || toolName === "ExitPlanMode") { + return { edit: false, exitPlan: true }; + } + if (toolName === "Edit" || toolName === "MultiEdit" || toolName === "Write" || toolName === "NotebookEdit") { + return { edit: true, exitPlan: false }; + } + return { edit: false, exitPlan: false }; +} + +class PermissionHandler { + toolCalls = []; + responses = /* @__PURE__ */ new Map(); + pendingRequests = /* @__PURE__ */ new Map(); + session; + allowedTools = /* @__PURE__ */ new Set(); + allowedBashLiterals = /* @__PURE__ */ new Set(); + allowedBashPrefixes = /* @__PURE__ */ new Set(); + permissionMode = "default"; + onPermissionRequestCallback; + constructor(session) { + this.session = session; + this.setupClientHandler(); + } + /** + * Set callback to trigger when permission request is made + */ + setOnPermissionRequest(callback) { + this.onPermissionRequestCallback = callback; + } + handleModeChange(mode) { + this.permissionMode = mode; + } + /** + * Handler response + */ + handlePermissionResponse(response, pending) { + if (response.allowTools && response.allowTools.length > 0) { + response.allowTools.forEach((tool) => { + if (tool.startsWith("Bash(") || tool === "Bash") { + this.parseBashPermission(tool); + } else { + this.allowedTools.add(tool); + } + }); + } + if (response.mode) { + this.permissionMode = response.mode; + } + if (pending.toolName === "exit_plan_mode" || pending.toolName === "ExitPlanMode") { + logger.debug("Plan mode result received", response); + if (response.approved) { + logger.debug("Plan approved - injecting PLAN_FAKE_RESTART"); + if (response.mode && ["default", "acceptEdits", "bypassPermissions"].includes(response.mode)) { + this.session.queue.unshift(PLAN_FAKE_RESTART, { permissionMode: response.mode }); + } else { + this.session.queue.unshift(PLAN_FAKE_RESTART, { permissionMode: "default" }); + } + pending.resolve({ behavior: "deny", message: PLAN_FAKE_REJECT }); + } else { + pending.resolve({ behavior: "deny", message: response.reason || "Plan rejected" }); + } + } else { + const result = response.approved ? { behavior: "allow", updatedInput: pending.input || {} } : { behavior: "deny", message: response.reason || `The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.` }; + pending.resolve(result); + } + } + /** + * Creates the canCallTool callback for the SDK + */ + handleToolCall = async (toolName, input, mode, options) => { + if (toolName === "Bash") { + const inputObj = input; + if (inputObj?.command) { + if (this.allowedBashLiterals.has(inputObj.command)) { + return { behavior: "allow", updatedInput: input }; + } + for (const prefix of this.allowedBashPrefixes) { + if (inputObj.command.startsWith(prefix)) { + return { behavior: "allow", updatedInput: input }; + } + } + } + } else if (this.allowedTools.has(toolName)) { + return { behavior: "allow", updatedInput: input }; + } + const descriptor = getToolDescriptor(toolName); + if (this.permissionMode === "bypassPermissions") { + return { behavior: "allow", updatedInput: input }; + } + if (this.permissionMode === "acceptEdits" && descriptor.edit) { + return { behavior: "allow", updatedInput: input }; + } + let toolCallId = this.resolveToolCallId(toolName, input); + if (!toolCallId) { + await delay(1e3); + toolCallId = this.resolveToolCallId(toolName, input); + if (!toolCallId) { + throw new Error(`Could not resolve tool call ID for ${toolName}`); + } + } + return this.handlePermissionRequest(toolCallId, toolName, input, options.signal); + }; + /** + * Handles individual permission requests + */ + async handlePermissionRequest(id, toolName, input, signal) { + return new Promise((resolve, reject) => { + const abortHandler = () => { + this.pendingRequests.delete(id); + reject(new Error("Permission request aborted")); + }; + signal.addEventListener("abort", abortHandler, { once: true }); + this.pendingRequests.set(id, { + resolve: (result) => { + signal.removeEventListener("abort", abortHandler); + resolve(result); + }, + reject: (error) => { + signal.removeEventListener("abort", abortHandler); + reject(error); + }, + toolName, + input + }); + if (this.onPermissionRequestCallback) { + this.onPermissionRequestCallback(id); + } + this.session.api.push().sendToAllDevices( + "Permission Request", + `Claude wants to ${getToolName(toolName)}`, + { + sessionId: this.session.client.sessionId, + requestId: id, + tool: toolName, + type: "permission_request" + } + ); + this.session.client.updateAgentState((currentState) => ({ + ...currentState, + requests: { + ...currentState.requests, + [id]: { + tool: toolName, + arguments: input, + createdAt: Date.now() + } + } + })); + logger.debug(`Permission request sent for tool call ${id}: ${toolName}`); + }); + } + /** + * Parses Bash permission strings into literal and prefix sets + */ + parseBashPermission(permission) { + if (permission === "Bash") { + return; + } + const bashPattern = /^Bash\((.+?)\)$/; + const match = permission.match(bashPattern); + if (!match) { + return; + } + const command = match[1]; + if (command.endsWith(":*")) { + const prefix = command.slice(0, -2); + this.allowedBashPrefixes.add(prefix); + } else { + this.allowedBashLiterals.add(command); + } + } + /** + * Resolves tool call ID based on tool name and input + */ + resolveToolCallId(name, args) { + for (let i = this.toolCalls.length - 1; i >= 0; i--) { + const call = this.toolCalls[i]; + if (call.name === name && deepEqual(call.input, args)) { + if (call.used) { + return null; + } + call.used = true; + return call.id; + } + } + return null; + } + /** + * Handles messages to track tool calls + */ + onMessage(message) { + if (message.type === "assistant") { + const assistantMsg = message; + if (assistantMsg.message && assistantMsg.message.content) { + for (const block of assistantMsg.message.content) { + if (block.type === "tool_use") { + this.toolCalls.push({ + id: block.id, + name: block.name, + input: block.input, + used: false + }); + } + } + } + } + if (message.type === "user") { + const userMsg = message; + if (userMsg.message && userMsg.message.content && Array.isArray(userMsg.message.content)) { + for (const block of userMsg.message.content) { + if (block.type === "tool_result" && block.tool_use_id) { + const toolCall = this.toolCalls.find((tc) => tc.id === block.tool_use_id); + if (toolCall && !toolCall.used) { + toolCall.used = true; + } + } + } + } + } + } + /** + * Checks if a tool call is rejected + */ + isAborted(toolCallId) { + if (this.responses.get(toolCallId)?.approved === false) { + return true; + } + const toolCall = this.toolCalls.find((tc) => tc.id === toolCallId); + if (toolCall && (toolCall.name === "exit_plan_mode" || toolCall.name === "ExitPlanMode")) { + return true; + } + return false; + } + /** + * Resets all state for new sessions + */ + reset() { + this.toolCalls = []; + this.responses.clear(); + this.allowedTools.clear(); + this.allowedBashLiterals.clear(); + this.allowedBashPrefixes.clear(); + for (const [, pending] of this.pendingRequests.entries()) { + pending.reject(new Error("Session reset")); + } + this.pendingRequests.clear(); + this.session.client.updateAgentState((currentState) => { + const pendingRequests = currentState.requests || {}; + const completedRequests = { ...currentState.completedRequests }; + for (const [id, request] of Object.entries(pendingRequests)) { + completedRequests[id] = { + ...request, + completedAt: Date.now(), + status: "canceled", + reason: "Session switched to local mode" + }; + } + return { + ...currentState, + requests: {}, + // Clear all pending requests + completedRequests + }; + }); + } + /** + * Sets up the client handler for permission responses + */ + setupClientHandler() { + this.session.client.rpcHandlerManager.registerHandler("permission", async (message) => { + logger.debug(`Permission response: ${JSON.stringify(message)}`); + const id = message.id; + const pending = this.pendingRequests.get(id); + if (!pending) { + logger.debug("Permission request not found or already resolved"); + return; + } + this.responses.set(id, { ...message, receivedAt: Date.now() }); + this.pendingRequests.delete(id); + this.handlePermissionResponse(message, pending); + this.session.client.updateAgentState((currentState) => { + const request = currentState.requests?.[id]; + if (!request) return currentState; + let r = { ...currentState.requests }; + delete r[id]; + return { + ...currentState, + requests: r, + completedRequests: { + ...currentState.completedRequests, + [id]: { + ...request, + completedAt: Date.now(), + status: message.approved ? "approved" : "denied", + reason: message.reason, + mode: message.mode, + allowTools: message.allowTools + } + } + }; + }); + }); + } + /** + * Gets the responses map (for compatibility with existing code) + */ + getResponses() { + return this.responses; + } +} + +function formatClaudeMessageForInk(message, messageBuffer, onAssistantResult) { + logger.debugLargeJson("[CLAUDE INK] Message from remote mode:", message); + switch (message.type) { + case "system": { + const sysMsg = message; + if (sysMsg.subtype === "init") { + messageBuffer.addMessage("\u2500".repeat(40), "status"); + messageBuffer.addMessage(`\u{1F680} Session initialized: ${sysMsg.session_id}`, "system"); + messageBuffer.addMessage(` Model: ${sysMsg.model}`, "status"); + messageBuffer.addMessage(` CWD: ${sysMsg.cwd}`, "status"); + if (sysMsg.tools && sysMsg.tools.length > 0) { + messageBuffer.addMessage(` Tools: ${sysMsg.tools.join(", ")}`, "status"); + } + messageBuffer.addMessage("\u2500".repeat(40), "status"); + } + break; + } + case "user": { + const userMsg = message; + if (userMsg.message && typeof userMsg.message === "object" && "content" in userMsg.message) { + const content = userMsg.message.content; + if (typeof content === "string") { + messageBuffer.addMessage(`\u{1F464} User: ${content}`, "user"); + } else if (Array.isArray(content)) { + for (const block of content) { + if (block.type === "text") { + messageBuffer.addMessage(`\u{1F464} User: ${block.text}`, "user"); + } else if (block.type === "tool_result") { + messageBuffer.addMessage(`\u2705 Tool Result (ID: ${block.tool_use_id})`, "result"); + if (block.content) { + const outputStr = typeof block.content === "string" ? block.content : JSON.stringify(block.content, null, 2); + const maxLength = 200; + if (outputStr.length > maxLength) { + messageBuffer.addMessage(outputStr.substring(0, maxLength) + "... (truncated)", "result"); + } else { + messageBuffer.addMessage(outputStr, "result"); + } + } + } + } + } else { + messageBuffer.addMessage(`\u{1F464} User: ${JSON.stringify(content, null, 2)}`, "user"); + } + } + break; + } + case "assistant": { + const assistantMsg = message; + if (assistantMsg.message && assistantMsg.message.content) { + messageBuffer.addMessage("\u{1F916} Assistant:", "assistant"); + for (const block of assistantMsg.message.content) { + if (block.type === "text") { + messageBuffer.addMessage(block.text || "", "assistant"); + } else if (block.type === "tool_use") { + messageBuffer.addMessage(`\u{1F527} Tool: ${block.name}`, "tool"); + if (block.input) { + const inputStr = JSON.stringify(block.input, null, 2); + const maxLength = 500; + if (inputStr.length > maxLength) { + messageBuffer.addMessage(`Input: ${inputStr.substring(0, maxLength)}... (truncated)`, "tool"); + } else { + messageBuffer.addMessage(`Input: ${inputStr}`, "tool"); + } + } + } + } + } + break; + } + case "result": { + const resultMsg = message; + if (resultMsg.subtype === "success") { + if ("result" in resultMsg && resultMsg.result) { + messageBuffer.addMessage("\u2728 Summary:", "result"); + messageBuffer.addMessage(resultMsg.result || "", "result"); + } + if (resultMsg.usage) { + messageBuffer.addMessage("\u{1F4CA} Session Stats:", "status"); + messageBuffer.addMessage(` \u2022 Turns: ${resultMsg.num_turns}`, "status"); + messageBuffer.addMessage(` \u2022 Input tokens: ${resultMsg.usage.input_tokens}`, "status"); + messageBuffer.addMessage(` \u2022 Output tokens: ${resultMsg.usage.output_tokens}`, "status"); + if (resultMsg.usage.cache_read_input_tokens) { + messageBuffer.addMessage(` \u2022 Cache read tokens: ${resultMsg.usage.cache_read_input_tokens}`, "status"); + } + if (resultMsg.usage.cache_creation_input_tokens) { + messageBuffer.addMessage(` \u2022 Cache creation tokens: ${resultMsg.usage.cache_creation_input_tokens}`, "status"); + } + messageBuffer.addMessage(` \u2022 Cost: $${resultMsg.total_cost_usd.toFixed(4)}`, "status"); + messageBuffer.addMessage(` \u2022 Duration: ${resultMsg.duration_ms}ms`, "status"); + } + } else if (resultMsg.subtype === "error_max_turns") { + messageBuffer.addMessage("\u274C Error: Maximum turns reached", "result"); + messageBuffer.addMessage(`Completed ${resultMsg.num_turns} turns`, "status"); + } else if (resultMsg.subtype === "error_during_execution") { + messageBuffer.addMessage("\u274C Error during execution", "result"); + messageBuffer.addMessage(`Completed ${resultMsg.num_turns} turns before error`, "status"); + logger.debugLargeJson("[RESULT] Error during execution", resultMsg); + } + break; + } + default: { + if (process.env.DEBUG) { + messageBuffer.addMessage(`[Unknown message type: ${message.type}]`, "status"); + } + } + } +} + +function getGitBranch(cwd) { + try { + const branch = execSync("git rev-parse --abbrev-ref HEAD", { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"] + }).trim(); + return branch || void 0; + } catch { + return void 0; + } +} +class SDKToLogConverter { + lastUuid = null; + context; + responses; + sidechainLastUUID = /* @__PURE__ */ new Map(); + constructor(context, responses) { + this.context = { + ...context, + gitBranch: context.gitBranch ?? getGitBranch(context.cwd), + version: context.version ?? process.env.npm_package_version ?? "0.0.0", + parentUuid: null + }; + this.responses = responses; + } + /** + * Update session ID (for when session changes during resume) + */ + updateSessionId(sessionId) { + this.context.sessionId = sessionId; + } + /** + * Reset parent chain (useful when starting new conversation) + */ + resetParentChain() { + this.lastUuid = null; + this.context.parentUuid = null; + } + /** + * Convert SDK message to log format + */ + convert(sdkMessage) { + const uuid = randomUUID(); + const timestamp = (/* @__PURE__ */ new Date()).toISOString(); + let parentUuid = this.lastUuid; + let isSidechain = false; + if (sdkMessage.parent_tool_use_id) { + isSidechain = true; + parentUuid = this.sidechainLastUUID.get(sdkMessage.parent_tool_use_id) ?? null; + this.sidechainLastUUID.set(sdkMessage.parent_tool_use_id, uuid); + } + const baseFields = { + parentUuid, + isSidechain, + userType: "external", + cwd: this.context.cwd, + sessionId: this.context.sessionId, + version: this.context.version, + gitBranch: this.context.gitBranch, + uuid, + timestamp + }; + let logMessage = null; + switch (sdkMessage.type) { + case "user": { + const userMsg = sdkMessage; + logMessage = { + ...baseFields, + type: "user", + message: userMsg.message + }; + if (Array.isArray(userMsg.message.content)) { + for (const content of userMsg.message.content) { + if (content.type === "tool_result" && content.tool_use_id && this.responses?.has(content.tool_use_id)) { + const response = this.responses.get(content.tool_use_id); + if (response?.mode) { + logMessage.mode = response.mode; + } + } + } + } else if (typeof userMsg.message.content === "string") ; + break; + } + case "assistant": { + const assistantMsg = sdkMessage; + logMessage = { + ...baseFields, + type: "assistant", + message: assistantMsg.message, + // Assistant messages often have additional fields + requestId: assistantMsg.requestId + }; + break; + } + case "system": { + const systemMsg = sdkMessage; + if (systemMsg.subtype === "init" && systemMsg.session_id) { + this.updateSessionId(systemMsg.session_id); + } + logMessage = { + ...baseFields, + type: "system", + subtype: systemMsg.subtype, + model: systemMsg.model, + tools: systemMsg.tools, + // Include all other fields + ...systemMsg + }; + break; + } + case "result": { + break; + } + // Handle tool use results (often comes as user messages) + case "tool_result": { + const toolMsg = sdkMessage; + const baseLogMessage = { + ...baseFields, + type: "user", + message: { + role: "user", + content: [{ + type: "tool_result", + tool_use_id: toolMsg.tool_use_id, + content: toolMsg.content + }] + }, + toolUseResult: toolMsg.content + }; + if (toolMsg.tool_use_id && this.responses?.has(toolMsg.tool_use_id)) { + const response = this.responses.get(toolMsg.tool_use_id); + if (response?.mode) { + baseLogMessage.mode = response.mode; + } + } + logMessage = baseLogMessage; + break; + } + default: + logMessage = { + ...baseFields, + ...sdkMessage, + type: sdkMessage.type + // Override type last to ensure it's set + }; + } + if (logMessage && logMessage.type !== "summary") { + this.lastUuid = uuid; + } + return logMessage; + } + /** + * Convert multiple SDK messages to log format + */ + convertMany(sdkMessages) { + return sdkMessages.map((msg) => this.convert(msg)).filter((msg) => msg !== null); + } + /** + * Convert a simple string content to a sidechain user message + * Used for Task tool sub-agent prompts + */ + convertSidechainUserMessage(toolUseId, content) { + const uuid = randomUUID(); + const timestamp = (/* @__PURE__ */ new Date()).toISOString(); + this.sidechainLastUUID.set(toolUseId, uuid); + return { + parentUuid: null, + isSidechain: true, + userType: "external", + cwd: this.context.cwd, + sessionId: this.context.sessionId, + version: this.context.version, + gitBranch: this.context.gitBranch, + type: "user", + message: { + role: "user", + content + }, + uuid, + timestamp + }; + } + /** + * Generate an interrupted tool result message + * Used when a tool call is interrupted by the user + * @param toolUseId - The ID of the tool that was interrupted + * @param parentToolUseId - Optional parent tool ID if this is a sidechain tool + */ + generateInterruptedToolResult(toolUseId, parentToolUseId) { + const uuid = randomUUID(); + const timestamp = (/* @__PURE__ */ new Date()).toISOString(); + const errorMessage = "[Request interrupted by user for tool use]"; + let isSidechain = false; + let parentUuid = this.lastUuid; + if (parentToolUseId) { + isSidechain = true; + parentUuid = this.sidechainLastUUID.get(parentToolUseId) ?? null; + this.sidechainLastUUID.set(parentToolUseId, uuid); + } + const logMessage = { + type: "user", + isSidechain, + uuid, + message: { + role: "user", + content: [ + { + type: "tool_result", + content: errorMessage, + is_error: true, + tool_use_id: toolUseId + } + ] + }, + parentUuid, + userType: "external", + cwd: this.context.cwd, + sessionId: this.context.sessionId, + version: this.context.version, + gitBranch: this.context.gitBranch, + timestamp, + toolUseResult: `Error: ${errorMessage}` + }; + this.lastUuid = uuid; + return logMessage; + } +} + +class OutgoingMessageQueue { + constructor(sendFunction) { + this.sendFunction = sendFunction; + } + queue = []; + nextId = 1; + lock = new AsyncLock(); + processTimer; + delayTimers = /* @__PURE__ */ new Map(); + /** + * Add message to queue + */ + enqueue(logMessage, options) { + this.lock.inLock(async () => { + const item = { + id: this.nextId++, + logMessage, + delayed: !!options?.delay, + delayMs: options?.delay || 0, + toolCallIds: options?.toolCallIds, + released: !options?.delay, + // Not delayed = already released + sent: false + }; + this.queue.push(item); + if (item.delayed) { + const timer = setTimeout(() => { + this.releaseItem(item.id); + }, item.delayMs); + this.delayTimers.set(item.id, timer); + } + }); + this.scheduleProcessing(); + } + /** + * Release specific item by ID + */ + async releaseItem(itemId) { + await this.lock.inLock(async () => { + const item = this.queue.find((i) => i.id === itemId); + if (item && !item.released) { + item.released = true; + const timer = this.delayTimers.get(itemId); + if (timer) { + clearTimeout(timer); + this.delayTimers.delete(itemId); + } + } + }); + this.scheduleProcessing(); + } + /** + * Release all messages with specific tool call ID + */ + async releaseToolCall(toolCallId) { + await this.lock.inLock(async () => { + for (const item of this.queue) { + if (item.toolCallIds?.includes(toolCallId) && !item.released) { + item.released = true; + const timer = this.delayTimers.get(item.id); + if (timer) { + clearTimeout(timer); + this.delayTimers.delete(item.id); + } + } + } + }); + this.scheduleProcessing(); + } + /** + * Process queue - send messages in ID order that are released + * (Internal implementation without lock) + */ + processQueueInternal() { + this.queue.sort((a, b) => a.id - b.id); + while (this.queue.length > 0) { + const item = this.queue[0]; + if (!item.released) { + break; + } + if (!item.sent) { + if (item.logMessage.type !== "system") { + this.sendFunction(item.logMessage); + } + item.sent = true; + } + this.queue.shift(); + } + } + /** + * Process queue - send messages in ID order that are released + */ + async processQueue() { + await this.lock.inLock(async () => { + this.processQueueInternal(); + }); + } + /** + * Flush all messages immediately (for cleanup) + */ + async flush() { + await this.lock.inLock(async () => { + for (const timer of this.delayTimers.values()) { + clearTimeout(timer); + } + this.delayTimers.clear(); + for (const item of this.queue) { + item.released = true; + } + this.processQueueInternal(); + }); + } + /** + * Schedule processing on next tick + */ + scheduleProcessing() { + if (this.processTimer) { + clearTimeout(this.processTimer); + } + this.processTimer = setTimeout(() => { + this.processQueue(); + }, 0); + } + /** + * Cleanup timers and resources + */ + destroy() { + if (this.processTimer) { + clearTimeout(this.processTimer); + } + for (const timer of this.delayTimers.values()) { + clearTimeout(timer); + } + this.delayTimers.clear(); + } +} + +async function claudeRemoteLauncher(session) { + logger.debug("[claudeRemoteLauncher] Starting remote launcher"); + const hasTTY = process.stdout.isTTY && process.stdin.isTTY; + logger.debug(`[claudeRemoteLauncher] TTY available: ${hasTTY}`); + let messageBuffer = new MessageBuffer(); + let inkInstance = null; + if (hasTTY) { + console.clear(); + inkInstance = render(React.createElement(RemoteModeDisplay, { + messageBuffer, + logPath: process.env.DEBUG ? session.logPath : void 0, + onExit: async () => { + logger.debug("[remote]: Exiting client via Ctrl-C"); + if (!exitReason) { + exitReason = "exit"; + } + await abort(); + }, + onSwitchToLocal: () => { + logger.debug("[remote]: Switching to local mode via double space"); + doSwitch(); + } + }), { + exitOnCtrlC: false, + patchConsole: false + }); + } + if (hasTTY) { + process.stdin.resume(); + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + process.stdin.setEncoding("utf8"); + } + let exitReason = null; + let abortController = null; + let abortFuture = null; + async function abort() { + if (abortController && !abortController.signal.aborted) { + abortController.abort(); + } + await abortFuture?.promise; + } + async function doAbort() { + logger.debug("[remote]: doAbort"); + await abort(); + } + async function doSwitch() { + logger.debug("[remote]: doSwitch"); + if (!exitReason) { + exitReason = "switch"; + } + await abort(); + } + session.client.rpcHandlerManager.registerHandler("abort", doAbort); + session.client.rpcHandlerManager.registerHandler("switch", doSwitch); + const permissionHandler = new PermissionHandler(session); + const messageQueue = new OutgoingMessageQueue( + (logMessage) => session.client.sendClaudeSessionMessage(logMessage) + ); + permissionHandler.setOnPermissionRequest((toolCallId) => { + messageQueue.releaseToolCall(toolCallId); + }); + const sdkToLogConverter = new SDKToLogConverter({ + sessionId: session.sessionId || "unknown", + cwd: session.path, + version: process.env.npm_package_version + }, permissionHandler.getResponses()); + let planModeToolCalls = /* @__PURE__ */ new Set(); + let ongoingToolCalls = /* @__PURE__ */ new Map(); + function onMessage(message) { + formatClaudeMessageForInk(message, messageBuffer); + permissionHandler.onMessage(message); + if (message.type === "assistant") { + let umessage = message; + if (umessage.message.content && Array.isArray(umessage.message.content)) { + for (let c of umessage.message.content) { + if (c.type === "tool_use" && (c.name === "exit_plan_mode" || c.name === "ExitPlanMode")) { + logger.debug("[remote]: detected plan mode tool call " + c.id); + planModeToolCalls.add(c.id); + } + } + } + } + if (message.type === "assistant") { + let umessage = message; + if (umessage.message.content && Array.isArray(umessage.message.content)) { + for (let c of umessage.message.content) { + if (c.type === "tool_use") { + logger.debug("[remote]: detected tool use " + c.id + " parent: " + umessage.parent_tool_use_id); + ongoingToolCalls.set(c.id, { parentToolCallId: umessage.parent_tool_use_id ?? null }); + } + } + } + } + if (message.type === "user") { + let umessage = message; + if (umessage.message.content && Array.isArray(umessage.message.content)) { + for (let c of umessage.message.content) { + if (c.type === "tool_result" && c.tool_use_id) { + ongoingToolCalls.delete(c.tool_use_id); + messageQueue.releaseToolCall(c.tool_use_id); + } + } + } + } + let msg = message; + if (message.type === "user") { + let umessage = message; + if (umessage.message.content && Array.isArray(umessage.message.content)) { + msg = { + ...umessage, + message: { + ...umessage.message, + content: umessage.message.content.map((c) => { + if (c.type === "tool_result" && c.tool_use_id && planModeToolCalls.has(c.tool_use_id)) { + if (c.content === PLAN_FAKE_REJECT) { + logger.debug("[remote]: hack plan mode exit"); + logger.debugLargeJson("[remote]: hack plan mode exit", c); + return { + ...c, + is_error: false, + content: "Plan approved", + mode: c.mode + }; + } else { + return c; + } + } + return c; + }) + } + }; + } + } + const logMessage = sdkToLogConverter.convert(msg); + if (logMessage) { + if (logMessage.type === "user" && logMessage.message?.content) { + const content = Array.isArray(logMessage.message.content) ? logMessage.message.content : []; + for (let i = 0; i < content.length; i++) { + const c = content[i]; + if (c.type === "tool_result" && c.tool_use_id) { + const responses = permissionHandler.getResponses(); + const response = responses.get(c.tool_use_id); + if (response) { + const permissions = { + date: response.receivedAt || Date.now(), + result: response.approved ? "approved" : "denied" + }; + if (response.mode) { + permissions.mode = response.mode; + } + if (response.allowTools && response.allowTools.length > 0) { + permissions.allowedTools = response.allowTools; + } + content[i] = { + ...c, + permissions + }; + } + } + } + } + if (logMessage.type === "assistant" && message.type === "assistant") { + const assistantMsg = message; + const toolCallIds = []; + if (assistantMsg.message.content && Array.isArray(assistantMsg.message.content)) { + for (const block of assistantMsg.message.content) { + if (block.type === "tool_use" && block.id) { + toolCallIds.push(block.id); + } + } + } + if (toolCallIds.length > 0) { + const isSidechain = assistantMsg.parent_tool_use_id !== void 0; + if (!isSidechain) { + messageQueue.enqueue(logMessage, { + delay: 250, + toolCallIds + }); + return; + } + } + } + messageQueue.enqueue(logMessage); + } + if (message.type === "assistant") { + let umessage = message; + if (umessage.message.content && Array.isArray(umessage.message.content)) { + for (let c of umessage.message.content) { + if (c.type === "tool_use" && c.name === "Task" && c.input && typeof c.input.prompt === "string") { + const logMessage2 = sdkToLogConverter.convertSidechainUserMessage(c.id, c.input.prompt); + if (logMessage2) { + messageQueue.enqueue(logMessage2); + } + } + } + } + } + } + try { + let pending = null; + let previousSessionId = null; + while (!exitReason) { + logger.debug("[remote]: launch"); + messageBuffer.addMessage("\u2550".repeat(40), "status"); + const isNewSession = session.sessionId !== previousSessionId; + if (isNewSession) { + messageBuffer.addMessage("Starting new Claude session...", "status"); + permissionHandler.reset(); + sdkToLogConverter.resetParentChain(); + logger.debug(`[remote]: New session detected (previous: ${previousSessionId}, current: ${session.sessionId})`); + } else { + messageBuffer.addMessage("Continuing Claude session...", "status"); + logger.debug(`[remote]: Continuing existing session: ${session.sessionId}`); + } + previousSessionId = session.sessionId; + const controller = new AbortController(); + abortController = controller; + abortFuture = new Future(); + let modeHash = null; + let mode = null; + try { + const remoteResult = await claudeRemote({ + sessionId: session.sessionId, + path: session.path, + allowedTools: session.allowedTools ?? [], + mcpServers: session.mcpServers, + canCallTool: permissionHandler.handleToolCall, + isAborted: (toolCallId) => { + return permissionHandler.isAborted(toolCallId); + }, + nextMessage: async () => { + if (pending) { + let p = pending; + pending = null; + permissionHandler.handleModeChange(p.mode.permissionMode); + return p; + } + let msg = await session.queue.waitForMessagesAndGetAsString(controller.signal); + if (msg) { + if (modeHash && msg.hash !== modeHash || msg.isolate) { + logger.debug("[remote]: mode has changed, pending message"); + pending = msg; + return null; + } + modeHash = msg.hash; + mode = msg.mode; + permissionHandler.handleModeChange(mode.permissionMode); + return { + message: msg.message, + mode: msg.mode + }; + } + return null; + }, + onSessionFound: (sessionId) => { + sdkToLogConverter.updateSessionId(sessionId); + session.onSessionFound(sessionId); + }, + onThinkingChange: session.onThinkingChange, + claudeEnvVars: session.claudeEnvVars, + claudeArgs: session.claudeArgs, + onMessage, + onCompletionEvent: (message) => { + logger.debug(`[remote]: Completion event: ${message}`); + session.client.sendSessionEvent({ type: "message", message }).catch((err) => logger.debug("[Claude] Failed to send completion event:", err)); + }, + onSessionReset: () => { + logger.debug("[remote]: Session reset"); + session.clearSessionId(); + }, + onReady: () => { + if (!pending && session.queue.size() === 0) { + session.client.sendSessionEvent({ type: "ready" }).catch((err) => logger.debug("[Claude] Failed to send ready event:", err)); + session.api.push().sendToAllDevices( + "It's ready!", + `Claude is waiting for your command`, + { sessionId: session.client.sessionId } + ); + } + }, + signal: abortController.signal + }); + session.consumeOneTimeFlags(); + if (!exitReason && abortController.signal.aborted) { + session.client.sendSessionEvent({ type: "message", message: "Aborted by user" }).catch((err) => logger.debug("[Claude] Failed to send abort event:", err)); + } + } catch (e) { + logger.debug("[remote]: launch error", e); + if (!exitReason) { + session.client.sendSessionEvent({ type: "message", message: "Process exited unexpectedly" }).catch((err) => logger.debug("[Claude] Failed to send exit event:", err)); + continue; + } + } finally { + logger.debug("[remote]: launch finally"); + for (let [toolCallId, { parentToolCallId }] of ongoingToolCalls) { + const converted = sdkToLogConverter.generateInterruptedToolResult(toolCallId, parentToolCallId); + if (converted) { + logger.debug("[remote]: terminating tool call " + toolCallId + " parent: " + parentToolCallId); + session.client.sendClaudeSessionMessage(converted); + } + } + ongoingToolCalls.clear(); + logger.debug("[remote]: flushing message queue"); + await messageQueue.flush(); + messageQueue.destroy(); + logger.debug("[remote]: message queue flushed"); + abortController = null; + abortFuture?.resolve(void 0); + abortFuture = null; + logger.debug("[remote]: launch done"); + permissionHandler.reset(); + modeHash = null; + mode = null; + } + } + } finally { + permissionHandler.reset(); + process.stdin.off("data", abort); + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + if (inkInstance) { + inkInstance.unmount(); + } + messageBuffer.clear(); + if (abortFuture) { + abortFuture.resolve(void 0); + } + } + return exitReason || "exit"; +} + +async function loop(opts) { + const logPath = logger.logFilePath; + let session = new Session({ + api: opts.api, + client: opts.session, + path: opts.path, + sessionId: null, + claudeEnvVars: opts.claudeEnvVars, + claudeArgs: opts.claudeArgs, + mcpServers: opts.mcpServers, + logPath, + messageQueue: opts.messageQueue, + allowedTools: opts.allowedTools, + onModeChange: opts.onModeChange + }); + if (opts.onSessionReady) { + opts.onSessionReady(session); + } + let mode = opts.startingMode ?? "local"; + while (true) { + logger.debug(`[loop] Iteration with mode: ${mode}`); + if (mode === "local") { + let reason = await claudeLocalLauncher(session); + if (reason === "exit") { + return; + } + mode = "remote"; + if (opts.onModeChange) { + opts.onModeChange(mode); + } + continue; + } + if (mode === "remote") { + let reason = await claudeRemoteLauncher(session); + if (reason === "exit") { + return; + } + mode = "local"; + if (opts.onModeChange) { + opts.onModeChange(mode); + } + continue; + } + } +} + +class MessageQueue2 { + queue = []; + // Made public for testing + waiter = null; + closed = false; + onMessageHandler = null; + modeHasher; + constructor(modeHasher, onMessageHandler = null) { + this.modeHasher = modeHasher; + this.onMessageHandler = onMessageHandler; + logger.debug(`[MessageQueue2] Initialized`); + } + /** + * Set a handler that will be called when a message arrives + */ + setOnMessage(handler) { + this.onMessageHandler = handler; + } + /** + * Push a message to the queue with a mode. + */ + push(message, mode) { + if (this.closed) { + throw new Error("Cannot push to closed queue"); + } + const modeHash = this.modeHasher(mode); + logger.debug(`[MessageQueue2] push() called with mode hash: ${modeHash}`); + this.queue.push({ + message, + mode, + modeHash, + isolate: false + }); + if (this.onMessageHandler) { + this.onMessageHandler(message, mode); + } + if (this.waiter) { + logger.debug(`[MessageQueue2] Notifying waiter`); + const waiter = this.waiter; + this.waiter = null; + waiter(true); + } + logger.debug(`[MessageQueue2] push() completed. Queue size: ${this.queue.length}`); + } + /** + * Push a message immediately without batching delay. + * Does not clear the queue or enforce isolation. + */ + pushImmediate(message, mode) { + if (this.closed) { + throw new Error("Cannot push to closed queue"); + } + const modeHash = this.modeHasher(mode); + logger.debug(`[MessageQueue2] pushImmediate() called with mode hash: ${modeHash}`); + this.queue.push({ + message, + mode, + modeHash, + isolate: false + }); + if (this.onMessageHandler) { + this.onMessageHandler(message, mode); + } + if (this.waiter) { + logger.debug(`[MessageQueue2] Notifying waiter for immediate message`); + const waiter = this.waiter; + this.waiter = null; + waiter(true); + } + logger.debug(`[MessageQueue2] pushImmediate() completed. Queue size: ${this.queue.length}`); + } + /** + * Push a message that must be processed in complete isolation. + * Clears any pending messages and ensures this message is never batched with others. + * Used for special commands that require dedicated processing. + */ + pushIsolateAndClear(message, mode) { + if (this.closed) { + throw new Error("Cannot push to closed queue"); + } + const modeHash = this.modeHasher(mode); + logger.debug(`[MessageQueue2] pushIsolateAndClear() called with mode hash: ${modeHash} - clearing ${this.queue.length} pending messages`); + this.queue = []; + this.queue.push({ + message, + mode, + modeHash, + isolate: true + }); + if (this.onMessageHandler) { + this.onMessageHandler(message, mode); + } + if (this.waiter) { + logger.debug(`[MessageQueue2] Notifying waiter for isolated message`); + const waiter = this.waiter; + this.waiter = null; + waiter(true); + } + logger.debug(`[MessageQueue2] pushIsolateAndClear() completed. Queue size: ${this.queue.length}`); + } + /** + * Push a message to the beginning of the queue with a mode. + */ + unshift(message, mode) { + if (this.closed) { + throw new Error("Cannot unshift to closed queue"); + } + const modeHash = this.modeHasher(mode); + logger.debug(`[MessageQueue2] unshift() called with mode hash: ${modeHash}`); + this.queue.unshift({ + message, + mode, + modeHash, + isolate: false + }); + if (this.onMessageHandler) { + this.onMessageHandler(message, mode); + } + if (this.waiter) { + logger.debug(`[MessageQueue2] Notifying waiter`); + const waiter = this.waiter; + this.waiter = null; + waiter(true); + } + logger.debug(`[MessageQueue2] unshift() completed. Queue size: ${this.queue.length}`); + } + /** + * Reset the queue - clears all messages and resets to empty state + */ + reset() { + logger.debug(`[MessageQueue2] reset() called. Clearing ${this.queue.length} messages`); + this.queue = []; + this.closed = false; + this.waiter = null; + } + /** + * Close the queue - no more messages can be pushed + */ + close() { + logger.debug(`[MessageQueue2] close() called`); + this.closed = true; + if (this.waiter) { + const waiter = this.waiter; + this.waiter = null; + waiter(false); + } + } + /** + * Check if the queue is closed + */ + isClosed() { + return this.closed; + } + /** + * Get the current queue size + */ + size() { + return this.queue.length; + } + /** + * Wait for messages and return all messages with the same mode as a single string + * Returns { message: string, mode: T } or null if aborted/closed + */ + async waitForMessagesAndGetAsString(abortSignal) { + if (this.queue.length > 0) { + return this.collectBatch(); + } + if (this.closed || abortSignal?.aborted) { + return null; + } + const hasMessages = await this.waitForMessages(abortSignal); + if (!hasMessages) { + return null; + } + return this.collectBatch(); + } + /** + * Collect a batch of messages with the same mode, respecting isolation requirements + */ + collectBatch() { + if (this.queue.length === 0) { + return null; + } + const firstItem = this.queue[0]; + const sameModeMessages = []; + let mode = firstItem.mode; + let isolate = firstItem.isolate ?? false; + const targetModeHash = firstItem.modeHash; + if (firstItem.isolate) { + const item = this.queue.shift(); + sameModeMessages.push(item.message); + logger.debug(`[MessageQueue2] Collected isolated message with mode hash: ${targetModeHash}`); + } else { + while (this.queue.length > 0 && this.queue[0].modeHash === targetModeHash && !this.queue[0].isolate) { + const item = this.queue.shift(); + sameModeMessages.push(item.message); + } + logger.debug(`[MessageQueue2] Collected batch of ${sameModeMessages.length} messages with mode hash: ${targetModeHash}`); + } + const combinedMessage = sameModeMessages.join("\n"); + return { + message: combinedMessage, + mode, + hash: targetModeHash, + isolate + }; + } + /** + * Wait for messages to arrive + */ + waitForMessages(abortSignal) { + return new Promise((resolve) => { + let abortHandler = null; + if (abortSignal) { + abortHandler = () => { + logger.debug("[MessageQueue2] Wait aborted"); + if (this.waiter === waiterFunc) { + this.waiter = null; + } + resolve(false); + }; + abortSignal.addEventListener("abort", abortHandler); + } + const waiterFunc = (hasMessages) => { + if (abortHandler && abortSignal) { + abortSignal.removeEventListener("abort", abortHandler); + } + resolve(hasMessages); + }; + if (this.queue.length > 0) { + if (abortHandler && abortSignal) { + abortSignal.removeEventListener("abort", abortHandler); + } + resolve(true); + return; + } + if (this.closed || abortSignal?.aborted) { + if (abortHandler && abortSignal) { + abortSignal.removeEventListener("abort", abortHandler); + } + resolve(false); + return; + } + this.waiter = waiterFunc; + logger.debug("[MessageQueue2] Waiting for messages..."); + }); + } +} + +function deterministicStringify(obj, options = {}) { + const { + undefinedBehavior = "omit", + sortArrays = false, + replacer, + includeSymbols = false + } = options; + const seen = /* @__PURE__ */ new WeakSet(); + function processValue(value, key) { + if (replacer && key !== void 0) { + value = replacer(key, value); + } + if (value === null) return null; + if (value === void 0) { + switch (undefinedBehavior) { + case "omit": + return void 0; + case "null": + return null; + case "throw": + throw new Error(`Undefined value at key: ${key}`); + } + } + if (typeof value === "boolean" || typeof value === "number" || typeof value === "string") { + return value; + } + if (value instanceof Date) { + return value.toISOString(); + } + if (value instanceof RegExp) { + return value.toString(); + } + if (typeof value === "function") { + return void 0; + } + if (typeof value === "symbol") { + return includeSymbols ? value.toString() : void 0; + } + if (typeof value === "bigint") { + return value.toString() + "n"; + } + if (seen.has(value)) { + throw new Error("Circular reference detected"); + } + seen.add(value); + if (Array.isArray(value)) { + const processed2 = value.map((item, index) => processValue(item, String(index))).filter((item) => item !== void 0); + if (sortArrays) { + processed2.sort((a, b) => { + const aStr = JSON.stringify(processValue(a)); + const bStr = JSON.stringify(processValue(b)); + return aStr.localeCompare(bStr); + }); + } + seen.delete(value); + return processed2; + } + if (value.constructor === Object || value.constructor === void 0) { + const processed2 = {}; + const keys = Object.keys(value).sort(); + for (const k of keys) { + const processedValue = processValue(value[k], k); + if (processedValue !== void 0) { + processed2[k] = processedValue; + } + } + seen.delete(value); + return processed2; + } + try { + const plain = { ...value }; + seen.delete(value); + return processValue(plain, key); + } catch { + seen.delete(value); + return String(value); + } + } + const processed = processValue(obj); + return JSON.stringify(processed); +} +function hashObject(obj, options, encoding = "hex") { + const jsonString = deterministicStringify(obj, options); + return createHash("sha256").update(jsonString).digest(encoding); +} + +let caffeinateProcess = null; +function startCaffeinate() { + if (configuration.disableCaffeinate) { + logger.debug("[caffeinate] Caffeinate disabled via HAPPY_DISABLE_CAFFEINATE environment variable"); + return false; + } + if (process.platform !== "darwin") { + logger.debug("[caffeinate] Not on macOS, skipping caffeinate"); + return false; + } + if (caffeinateProcess && !caffeinateProcess.killed) { + logger.debug("[caffeinate] Caffeinate already running"); + return true; + } + try { + caffeinateProcess = spawn$1("caffeinate", ["-im"], { + stdio: "ignore", + detached: false + }); + caffeinateProcess.on("error", (error) => { + logger.debug("[caffeinate] Error starting caffeinate:", error); + caffeinateProcess = null; + }); + caffeinateProcess.on("exit", (code, signal) => { + logger.debug(`[caffeinate] Process exited with code ${code}, signal ${signal}`); + caffeinateProcess = null; + }); + logger.debug(`[caffeinate] Started with PID ${caffeinateProcess.pid}`); + setupCleanupHandlers(); + return true; + } catch (error) { + logger.debug("[caffeinate] Failed to start caffeinate:", error); + return false; + } +} +let isStopping = false; +async function stopCaffeinate() { + if (isStopping) { + logger.debug("[caffeinate] Already stopping, skipping"); + return; + } + if (caffeinateProcess && !caffeinateProcess.killed) { + isStopping = true; + logger.debug(`[caffeinate] Stopping caffeinate process PID ${caffeinateProcess.pid}`); + try { + caffeinateProcess.kill("SIGTERM"); + await new Promise((resolve) => setTimeout(resolve, 1e3)); + if (caffeinateProcess && !caffeinateProcess.killed) { + logger.debug("[caffeinate] Force killing caffeinate process"); + caffeinateProcess.kill("SIGKILL"); + } + caffeinateProcess = null; + isStopping = false; + } catch (error) { + logger.debug("[caffeinate] Error stopping caffeinate:", error); + isStopping = false; + } + } +} +let cleanupHandlersSet = false; +function setupCleanupHandlers() { + if (cleanupHandlersSet) { + return; + } + cleanupHandlersSet = true; + const cleanup = () => { + stopCaffeinate(); + }; + process.on("exit", cleanup); + process.on("SIGINT", cleanup); + process.on("SIGTERM", cleanup); + process.on("SIGUSR1", cleanup); + process.on("SIGUSR2", cleanup); + process.on("uncaughtException", (error) => { + logger.debug("[caffeinate] Uncaught exception, cleaning up:", error); + cleanup(); + }); + process.on("unhandledRejection", (reason, promise) => { + logger.debug("[caffeinate] Unhandled rejection, cleaning up:", reason); + cleanup(); + }); +} + +async function extractSDKMetadata() { + const abortController = new AbortController(); + try { + logger.debug("[metadataExtractor] Starting SDK metadata extraction"); + const sdkQuery = query({ + prompt: "hello", + options: { + allowedTools: ["Bash(echo)"], + maxTurns: 1, + abort: abortController.signal + } + }); + for await (const message of sdkQuery) { + if (message.type === "system" && message.subtype === "init") { + const systemMessage = message; + const metadata = { + tools: systemMessage.tools, + slashCommands: systemMessage.slash_commands + }; + logger.debug("[metadataExtractor] Captured SDK metadata:", metadata); + abortController.abort(); + return metadata; + } + } + logger.debug("[metadataExtractor] No init message received from SDK"); + return {}; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + logger.debug("[metadataExtractor] SDK query aborted after capturing metadata"); + return {}; + } + logger.debug("[metadataExtractor] Error extracting SDK metadata:", error); + return {}; + } +} +function extractSDKMetadataAsync(onComplete) { + extractSDKMetadata().then((metadata) => { + if (metadata.tools || metadata.slashCommands) { + onComplete(metadata); + } + }).catch((error) => { + logger.debug("[metadataExtractor] Async extraction failed:", error); + }); +} + +async function daemonPost(path, body) { + const state = await readDaemonState(); + if (!state?.httpPort) { + const errorMessage = "No daemon running, no state file found"; + logger.debug(`[CONTROL CLIENT] ${errorMessage}`); + return { + error: errorMessage + }; + } + try { + process.kill(state.pid, 0); + } catch (error) { + const errorMessage = "Daemon is not running, file is stale"; + logger.debug(`[CONTROL CLIENT] ${errorMessage}`); + return { + error: errorMessage + }; + } + try { + const timeout = process.env.HAPPY_DAEMON_HTTP_TIMEOUT ? parseInt(process.env.HAPPY_DAEMON_HTTP_TIMEOUT) : 1e4; + const response = await fetch(`http://127.0.0.1:${state.httpPort}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body || {}), + // Mostly increased for stress test + signal: AbortSignal.timeout(timeout) + }); + if (!response.ok) { + const errorMessage = `Request failed: ${path}, HTTP ${response.status}`; + logger.debug(`[CONTROL CLIENT] ${errorMessage}`); + return { + error: errorMessage + }; + } + return await response.json(); + } catch (error) { + const errorMessage = `Request failed: ${path}, ${error instanceof Error ? error.message : "Unknown error"}`; + logger.debug(`[CONTROL CLIENT] ${errorMessage}`); + return { + error: errorMessage + }; + } +} +async function notifyDaemonSessionStarted(sessionId, metadata) { + return await daemonPost("/session-started", { + sessionId, + metadata + }); +} +async function listDaemonSessions() { + const result = await daemonPost("/list"); + return result.children || []; +} +async function stopDaemonSession(sessionId) { + const result = await daemonPost("/stop-session", { sessionId }); + return result.success || false; +} +async function stopDaemonHttp() { + await daemonPost("/stop"); +} +async function checkIfDaemonRunningAndCleanupStaleState() { + const state = await readDaemonState(); + if (!state) { + return false; + } + try { + process.kill(state.pid, 0); + return true; + } catch { + logger.debug("[DAEMON RUN] Daemon PID not running, cleaning up state"); + await cleanupDaemonState(); + return false; + } +} +async function isDaemonRunningCurrentlyInstalledHappyVersion() { + logger.debug("[DAEMON CONTROL] Checking if daemon is running same version"); + const runningDaemon = await checkIfDaemonRunningAndCleanupStaleState(); + if (!runningDaemon) { + logger.debug("[DAEMON CONTROL] No daemon running, returning false"); + return false; + } + const state = await readDaemonState(); + if (!state) { + logger.debug("[DAEMON CONTROL] No daemon state found, returning false"); + return false; + } + try { + const packageJsonPath = join$1(projectPath(), "package.json"); + const packageJson = JSON.parse(readFileSync$1(packageJsonPath, "utf-8")); + const currentCliVersion = packageJson.version; + logger.debug(`[DAEMON CONTROL] Current CLI version: ${currentCliVersion}, Daemon started with version: ${state.startedWithCliVersion}`); + return currentCliVersion === state.startedWithCliVersion; + } catch (error) { + logger.debug("[DAEMON CONTROL] Error checking daemon version", error); + return false; + } +} +async function cleanupDaemonState() { + try { + await clearDaemonState(); + logger.debug("[DAEMON RUN] Daemon state file removed"); + } catch (error) { + logger.debug("[DAEMON RUN] Error cleaning up daemon metadata", error); + } +} +async function stopDaemon() { + try { + const state = await readDaemonState(); + if (!state) { + logger.debug("No daemon state found"); + return; + } + logger.debug(`Stopping daemon with PID ${state.pid}`); + try { + await stopDaemonHttp(); + await waitForProcessDeath(state.pid, 2e3); + logger.debug("Daemon stopped gracefully via HTTP"); + return; + } catch (error) { + logger.debug("HTTP stop failed, will force kill", error); + } + try { + process.kill(state.pid, "SIGKILL"); + logger.debug("Force killed daemon"); + } catch (error) { + logger.debug("Daemon already dead"); + } + } catch (error) { + logger.debug("Error stopping daemon", error); + } +} +async function waitForProcessDeath(pid, timeout) { + const start = Date.now(); + while (Date.now() - start < timeout) { + try { + process.kill(pid, 0); + await new Promise((resolve) => setTimeout(resolve, 100)); + } catch { + return; + } + } + throw new Error("Process did not die within timeout"); +} + +async function findAllHappyProcesses() { + try { + const processes = await psList(); + const allProcesses = []; + for (const proc of processes) { + const cmd = proc.cmd || ""; + const name = proc.name || ""; + const isHappy = name.includes("happy") || name === "node" && (cmd.includes("happy-cli") || cmd.includes("dist/index.mjs")) || cmd.includes("happy.mjs") || cmd.includes("happy-coder") || cmd.includes("tsx") && cmd.includes("src/index.ts") && cmd.includes("happy-cli"); + if (!isHappy) continue; + let type = "unknown"; + if (proc.pid === process.pid) { + type = "current"; + } else if (cmd.includes("--version")) { + type = cmd.includes("tsx") ? "dev-daemon-version-check" : "daemon-version-check"; + } else if (cmd.includes("daemon start-sync") || cmd.includes("daemon start")) { + type = cmd.includes("tsx") ? "dev-daemon" : "daemon"; + } else if (cmd.includes("--started-by daemon")) { + type = cmd.includes("tsx") ? "dev-daemon-spawned" : "daemon-spawned-session"; + } else if (cmd.includes("doctor")) { + type = cmd.includes("tsx") ? "dev-doctor" : "doctor"; + } else if (cmd.includes("--yolo")) { + type = "dev-session"; + } else { + type = cmd.includes("tsx") ? "dev-related" : "user-session"; + } + allProcesses.push({ pid: proc.pid, command: cmd || name, type }); + } + return allProcesses; + } catch (error) { + return []; + } +} +async function findRunawayHappyProcesses() { + const allProcesses = await findAllHappyProcesses(); + return allProcesses.filter( + (p) => p.pid !== process.pid && (p.type === "daemon" || p.type === "dev-daemon" || p.type === "daemon-spawned-session" || p.type === "dev-daemon-spawned" || p.type === "daemon-version-check" || p.type === "dev-daemon-version-check") + ).map((p) => ({ pid: p.pid, command: p.command })); +} +async function killRunawayHappyProcesses() { + const runawayProcesses = await findRunawayHappyProcesses(); + const errors = []; + let killed = 0; + for (const { pid, command } of runawayProcesses) { + try { + console.log(`Killing runaway process PID ${pid}: ${command}`); + if (process.platform === "win32") { + const result = spawn$2.sync("taskkill", ["/F", "/PID", pid.toString()], { stdio: "pipe" }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`taskkill exited with code ${result.status}`); + } else { + process.kill(pid, "SIGTERM"); + await new Promise((resolve) => setTimeout(resolve, 1e3)); + const processes = await psList(); + const stillAlive = processes.find((p) => p.pid === pid); + if (stillAlive) { + console.log(`Process PID ${pid} ignored SIGTERM, using SIGKILL`); + process.kill(pid, "SIGKILL"); + } + } + console.log(`Successfully killed runaway process PID ${pid}`); + killed++; + } catch (error) { + const errorMessage = error.message; + errors.push({ pid, error: errorMessage }); + console.log(`Failed to kill process PID ${pid}: ${errorMessage}`); + } + } + return { killed, errors }; +} + +function getEnvironmentInfo() { + return { + PWD: process.env.PWD, + HAPPY_HOME_DIR: process.env.HAPPY_HOME_DIR, + HAPPY_SERVER_URL: process.env.HAPPY_SERVER_URL, + HAPPY_PROJECT_ROOT: process.env.HAPPY_PROJECT_ROOT, + DANGEROUSLY_LOG_TO_SERVER_FOR_AI_AUTO_DEBUGGING: process.env.DANGEROUSLY_LOG_TO_SERVER_FOR_AI_AUTO_DEBUGGING, + NODE_ENV: process.env.NODE_ENV, + DEBUG: process.env.DEBUG, + workingDirectory: process.cwd(), + processArgv: process.argv, + happyDir: configuration?.happyHomeDir, + serverUrl: configuration?.serverUrl, + logsDir: configuration?.logsDir, + processPid: process.pid, + nodeVersion: process.version, + platform: process.platform, + arch: process.arch, + user: process.env.USER, + home: process.env.HOME, + shell: process.env.SHELL, + terminal: process.env.TERM + }; +} +function getLogFiles(logDir) { + if (!existsSync(logDir)) { + return []; + } + try { + return readdirSync(logDir).filter((file) => file.endsWith(".log")).map((file) => { + const path = join(logDir, file); + const stats = statSync(path); + return { file, path, modified: stats.mtime }; + }).sort((a, b) => b.modified.getTime() - a.modified.getTime()); + } catch { + return []; + } +} +async function runDoctorCommand(filter) { + if (!filter) { + filter = "all"; + } + console.log(chalk.bold.cyan("\n\u{1FA7A} Happy CLI Doctor\n")); + if (filter === "all") { + console.log(chalk.bold("\u{1F4CB} Basic Information")); + console.log(`Happy CLI Version: ${chalk.green(packageJson.version)}`); + console.log(`Platform: ${chalk.green(process.platform)} ${process.arch}`); + console.log(`Node.js Version: ${chalk.green(process.version)}`); + console.log(""); + console.log(chalk.bold("\u{1F527} Daemon Spawn Diagnostics")); + const projectRoot = projectPath(); + const wrapperPath = join(projectRoot, "bin", "happy.mjs"); + const cliEntrypoint = join(projectRoot, "dist", "index.mjs"); + console.log(`Project Root: ${chalk.blue(projectRoot)}`); + console.log(`Wrapper Script: ${chalk.blue(wrapperPath)}`); + console.log(`CLI Entrypoint: ${chalk.blue(cliEntrypoint)}`); + console.log(`Wrapper Exists: ${existsSync(wrapperPath) ? chalk.green("\u2713 Yes") : chalk.red("\u274C No")}`); + console.log(`CLI Exists: ${existsSync(cliEntrypoint) ? chalk.green("\u2713 Yes") : chalk.red("\u274C No")}`); + console.log(""); + console.log(chalk.bold("\u2699\uFE0F Configuration")); + console.log(`Happy Home: ${chalk.blue(configuration.happyHomeDir)}`); + console.log(`Server URL: ${chalk.blue(configuration.serverUrl)}`); + console.log(`Logs Dir: ${chalk.blue(configuration.logsDir)}`); + console.log(chalk.bold("\n\u{1F30D} Environment Variables")); + const env = getEnvironmentInfo(); + console.log(`HAPPY_HOME_DIR: ${env.HAPPY_HOME_DIR ? chalk.green(env.HAPPY_HOME_DIR) : chalk.gray("not set")}`); + console.log(`HAPPY_SERVER_URL: ${env.HAPPY_SERVER_URL ? chalk.green(env.HAPPY_SERVER_URL) : chalk.gray("not set")}`); + console.log(`DANGEROUSLY_LOG_TO_SERVER: ${env.DANGEROUSLY_LOG_TO_SERVER_FOR_AI_AUTO_DEBUGGING ? chalk.yellow("ENABLED") : chalk.gray("not set")}`); + console.log(`DEBUG: ${env.DEBUG ? chalk.green(env.DEBUG) : chalk.gray("not set")}`); + console.log(`NODE_ENV: ${env.NODE_ENV ? chalk.green(env.NODE_ENV) : chalk.gray("not set")}`); + try { + const settings = await readSettings(); + console.log(chalk.bold("\n\u{1F4C4} Settings (settings.json):")); + console.log(chalk.gray(JSON.stringify(settings, null, 2))); + } catch (error) { + console.log(chalk.bold("\n\u{1F4C4} Settings:")); + console.log(chalk.red("\u274C Failed to read settings")); + } + console.log(chalk.bold("\n\u{1F510} Authentication")); + try { + const credentials = await readCredentials(); + if (credentials) { + console.log(chalk.green("\u2713 Authenticated (credentials found)")); + } else { + console.log(chalk.yellow("\u26A0\uFE0F Not authenticated (no credentials)")); + } + } catch (error) { + console.log(chalk.red("\u274C Error reading credentials")); + } + } + console.log(chalk.bold("\n\u{1F916} Daemon Status")); + try { + const isRunning = await checkIfDaemonRunningAndCleanupStaleState(); + const state = await readDaemonState(); + if (isRunning && state) { + console.log(chalk.green("\u2713 Daemon is running")); + console.log(` PID: ${state.pid}`); + console.log(` Started: ${new Date(state.startTime).toLocaleString()}`); + console.log(` CLI Version: ${state.startedWithCliVersion}`); + if (state.httpPort) { + console.log(` HTTP Port: ${state.httpPort}`); + } + } else if (state && !isRunning) { + console.log(chalk.yellow("\u26A0\uFE0F Daemon state exists but process not running (stale)")); + } else { + console.log(chalk.red("\u274C Daemon is not running")); + } + if (state) { + console.log(chalk.bold("\n\u{1F4C4} Daemon State:")); + console.log(chalk.blue(`Location: ${configuration.daemonStateFile}`)); + console.log(chalk.gray(JSON.stringify(state, null, 2))); + } + const allProcesses = await findAllHappyProcesses(); + if (allProcesses.length > 0) { + console.log(chalk.bold("\n\u{1F50D} All Happy CLI Processes")); + const grouped = allProcesses.reduce((groups, process2) => { + if (!groups[process2.type]) groups[process2.type] = []; + groups[process2.type].push(process2); + return groups; + }, {}); + Object.entries(grouped).forEach(([type, processes]) => { + const typeLabels = { + "current": "\u{1F4CD} Current Process", + "daemon": "\u{1F916} Daemon", + "daemon-version-check": "\u{1F50D} Daemon Version Check (stuck)", + "daemon-spawned-session": "\u{1F517} Daemon-Spawned Sessions", + "user-session": "\u{1F464} User Sessions", + "dev-daemon": "\u{1F6E0}\uFE0F Dev Daemon", + "dev-daemon-version-check": "\u{1F6E0}\uFE0F Dev Daemon Version Check (stuck)", + "dev-session": "\u{1F6E0}\uFE0F Dev Sessions", + "dev-doctor": "\u{1F6E0}\uFE0F Dev Doctor", + "dev-related": "\u{1F6E0}\uFE0F Dev Related", + "doctor": "\u{1FA7A} Doctor", + "unknown": "\u2753 Unknown" + }; + console.log(chalk.blue(` +${typeLabels[type] || type}:`)); + processes.forEach(({ pid, command }) => { + const color = type === "current" ? chalk.green : type.startsWith("dev") ? chalk.cyan : type.includes("daemon") ? chalk.blue : chalk.gray; + console.log(` ${color(`PID ${pid}`)}: ${chalk.gray(command)}`); + }); + }); + } else { + console.log(chalk.red("\u274C No happy processes found")); + } + if (filter === "all" && allProcesses.length > 1) { + console.log(chalk.bold("\n\u{1F4A1} Process Management")); + console.log(chalk.gray("To clean up runaway processes: happy doctor clean")); + } + } catch (error) { + console.log(chalk.red("\u274C Error checking daemon status")); + } + if (filter === "all") { + console.log(chalk.bold("\n\u{1F4DD} Log Files")); + const allLogs = getLogFiles(configuration.logsDir); + if (allLogs.length > 0) { + const daemonLogs = allLogs.filter(({ file }) => file.includes("daemon")); + const regularLogs = allLogs.filter(({ file }) => !file.includes("daemon")); + if (regularLogs.length > 0) { + console.log(chalk.blue("\nRecent Logs:")); + const logsToShow = regularLogs.slice(0, 10); + logsToShow.forEach(({ file, path, modified }) => { + console.log(` ${chalk.green(file)} - ${modified.toLocaleString()}`); + console.log(chalk.gray(` ${path}`)); + }); + if (regularLogs.length > 10) { + console.log(chalk.gray(` ... and ${regularLogs.length - 10} more log files`)); + } + } + if (daemonLogs.length > 0) { + console.log(chalk.blue("\nDaemon Logs:")); + const daemonLogsToShow = daemonLogs.slice(0, 5); + daemonLogsToShow.forEach(({ file, path, modified }) => { + console.log(` ${chalk.green(file)} - ${modified.toLocaleString()}`); + console.log(chalk.gray(` ${path}`)); + }); + if (daemonLogs.length > 5) { + console.log(chalk.gray(` ... and ${daemonLogs.length - 5} more daemon log files`)); + } + } else { + console.log(chalk.yellow("\nNo daemon log files found")); + } + } else { + console.log(chalk.yellow("No log files found")); + } + console.log(chalk.bold("\n\u{1F41B} Support & Bug Reports")); + console.log(`Report issues: ${chalk.blue("https://github.com/slopus/happy-cli/issues")}`); + console.log(`Documentation: ${chalk.blue("https://happy.engineering/")}`); + } + console.log(chalk.green("\n\u2705 Doctor diagnosis complete!\n")); +} + +function displayQRCode(url) { + console.log("=".repeat(80)); + console.log("\u{1F4F1} To authenticate, scan this QR code with your mobile device:"); + console.log("=".repeat(80)); + qrcode.generate(url, { small: true }, (qr) => { + for (let l of qr.split("\n")) { + console.log(" ".repeat(10) + l); + } + }); + console.log("=".repeat(80)); +} + +function generateWebAuthUrl(publicKey) { + const publicKeyBase64 = encodeBase64(publicKey, "base64url"); + return `${configuration.webappUrl}/terminal/connect#key=${publicKeyBase64}`; +} + +async function openBrowser(url) { + try { + if (!process.stdout.isTTY || process.env.CI || process.env.HEADLESS) { + logger.debug("[browser] Headless environment detected, skipping browser open"); + return false; + } + logger.debug(`[browser] Attempting to open URL: ${url}`); + await open(url); + logger.debug("[browser] Browser opened successfully"); + return true; + } catch (error) { + logger.debug("[browser] Failed to open browser:", error); + return false; + } +} + +const AuthSelector = ({ onSelect, onCancel }) => { + const [selectedIndex, setSelectedIndex] = useState(0); + const options = [ + { + method: "mobile", + label: "Mobile App" + }, + { + method: "web", + label: "Web Browser" + } + ]; + useInput((input, key) => { + if (key.upArrow) { + setSelectedIndex((prev) => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setSelectedIndex((prev) => Math.min(options.length - 1, prev + 1)); + } else if (key.return) { + onSelect(options[selectedIndex].method); + } else if (key.escape || key.ctrl && input === "c") { + onCancel(); + } else if (input === "1") { + setSelectedIndex(0); + onSelect("mobile"); + } else if (input === "2") { + setSelectedIndex(1); + onSelect("web"); + } + }); + return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", paddingY: 1 }, /* @__PURE__ */ React.createElement(Box, { marginBottom: 1 }, /* @__PURE__ */ React.createElement(Text, null, "How would you like to authenticate?")), /* @__PURE__ */ React.createElement(Box, { flexDirection: "column" }, options.map((option, index) => { + const isSelected = selectedIndex === index; + return /* @__PURE__ */ React.createElement(Box, { key: option.method, marginY: 0 }, /* @__PURE__ */ React.createElement(Text, { color: isSelected ? "cyan" : "gray" }, isSelected ? "\u203A " : " ", index + 1, ". ", option.label)); + })), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "Use arrows or 1-2 to select, Enter to confirm"))); +}; + +async function doAuth() { + console.clear(); + const authMethod = await selectAuthenticationMethod(); + if (!authMethod) { + console.log("\nAuthentication cancelled.\n"); + process.exit(0); + } + const secret = new Uint8Array(randomBytes(32)); + const keypair = tweetnacl.box.keyPair.fromSecretKey(secret); + try { + console.log(`[AUTH DEBUG] Sending auth request to: ${configuration.serverUrl}/v1/auth/request`); + console.log(`[AUTH DEBUG] Public key: ${encodeBase64(keypair.publicKey).substring(0, 20)}...`); + await axios.post(`${configuration.serverUrl}/v1/auth/request`, { + publicKey: encodeBase64(keypair.publicKey), + supportsV2: true + }); + console.log(`[AUTH DEBUG] Auth request sent successfully`); + } catch (error) { + console.log(`[AUTH DEBUG] Failed to send auth request:`, error); + console.log("Failed to create authentication request, please try again later."); + return null; + } + if (authMethod === "mobile") { + return await doMobileAuth(keypair); + } else { + return await doWebAuth(keypair); + } +} +function selectAuthenticationMethod() { + return new Promise((resolve) => { + let hasResolved = false; + const onSelect = (method) => { + if (!hasResolved) { + hasResolved = true; + app.unmount(); + resolve(method); + } + }; + const onCancel = () => { + if (!hasResolved) { + hasResolved = true; + app.unmount(); + resolve(null); + } + }; + const app = render(React.createElement(AuthSelector, { onSelect, onCancel }), { + exitOnCtrlC: false, + patchConsole: false + }); + }); +} +async function doMobileAuth(keypair) { + console.clear(); + console.log("\nMobile Authentication\n"); + console.log("Scan this QR code with your Happy mobile app:\n"); + const authUrl = "happy://terminal?" + encodeBase64Url(keypair.publicKey); + displayQRCode(authUrl); + console.log("\nOr manually enter this URL:"); + console.log(authUrl); + console.log(""); + return await waitForAuthentication(keypair); +} +async function doWebAuth(keypair) { + console.clear(); + console.log("\nWeb Authentication\n"); + const webUrl = generateWebAuthUrl(keypair.publicKey); + console.log("Opening your browser..."); + const browserOpened = await openBrowser(webUrl); + if (browserOpened) { + console.log("\u2713 Browser opened\n"); + console.log("Complete authentication in your browser window."); + } else { + console.log("Could not open browser automatically."); + } + console.log("\nIf the browser did not open, please copy and paste this URL:"); + console.log(webUrl); + console.log(""); + return await waitForAuthentication(keypair); +} +async function waitForAuthentication(keypair) { + process.stdout.write("Waiting for authentication"); + let dots = 0; + let cancelled = false; + const handleInterrupt = () => { + cancelled = true; + console.log("\n\nAuthentication cancelled."); + process.exit(0); + }; + process.on("SIGINT", handleInterrupt); + try { + while (!cancelled) { + try { + const response = await axios.post(`${configuration.serverUrl}/v1/auth/request`, { + publicKey: encodeBase64(keypair.publicKey), + supportsV2: true + }); + if (response.data.state === "authorized") { + let token = response.data.token; + let r = decodeBase64(response.data.response); + let decrypted = decryptWithEphemeralKey(r, keypair.secretKey); + if (decrypted) { + if (decrypted.length === 32) { + const credentials = { + secret: decrypted, + token + }; + await writeCredentialsLegacy(credentials); + console.log("\n\n\u2713 Authentication successful\n"); + return { + encryption: { + type: "legacy", + secret: decrypted + }, + token + }; + } else { + if (decrypted[0] === 0) { + const credentials = { + publicKey: decrypted.slice(1, 33), + machineKey: randomBytes(32), + token + }; + await writeCredentialsDataKey(credentials); + console.log("\n\n\u2713 Authentication successful\n"); + return { + encryption: { + type: "dataKey", + publicKey: credentials.publicKey, + machineKey: credentials.machineKey + }, + token + }; + } else { + console.log("\n\nFailed to decrypt response. Please try again."); + return null; + } + } + } else { + console.log("\n\nFailed to decrypt response. Please try again."); + return null; + } + } + } catch (error) { + console.log("\n\nFailed to check authentication status. Please try again."); + return null; + } + process.stdout.write("\rWaiting for authentication" + ".".repeat(dots % 3 + 1) + " "); + dots++; + await delay(1e3); + } + } finally { + process.off("SIGINT", handleInterrupt); + } + return null; +} +function decryptWithEphemeralKey(encryptedBundle, recipientSecretKey) { + const ephemeralPublicKey = encryptedBundle.slice(0, 32); + const nonce = encryptedBundle.slice(32, 32 + tweetnacl.box.nonceLength); + const encrypted = encryptedBundle.slice(32 + tweetnacl.box.nonceLength); + const decrypted = tweetnacl.box.open(encrypted, nonce, ephemeralPublicKey, recipientSecretKey); + if (!decrypted) { + return null; + } + return decrypted; +} +async function authAndSetupMachineIfNeeded() { + logger.debug("[AUTH] Starting auth and machine setup..."); + let credentials = await readCredentials(); + let newAuth = false; + if (!credentials) { + logger.debug("[AUTH] No credentials found, starting authentication flow..."); + const authResult = await doAuth(); + if (!authResult) { + throw new Error("Authentication failed or was cancelled"); + } + credentials = authResult; + newAuth = true; + } else { + logger.debug("[AUTH] Using existing credentials"); + } + const settings = await updateSettings(async (s) => { + if (newAuth || !s.machineId) { + return { + ...s, + machineId: randomUUID() + }; + } + return s; + }); + logger.debug(`[AUTH] Machine ID: ${settings.machineId}`); + return { credentials, machineId: settings.machineId }; +} + +function spawnHappyCLI(args, options = {}) { + const projectRoot = projectPath(); + const entrypoint = join(projectRoot, "dist", "index.mjs"); + let directory; + if ("cwd" in options) { + directory = options.cwd; + } else { + directory = process.cwd(); + } + const fullCommand = `happy ${args.join(" ")}`; + logger.debug(`[SPAWN HAPPY CLI] Spawning: ${fullCommand} in ${directory}`); + const nodeArgs = [ + "--no-warnings", + "--no-deprecation", + entrypoint, + ...args + ]; + if (!existsSync(entrypoint)) { + const errorMessage = `Entrypoint ${entrypoint} does not exist`; + logger.debug(`[SPAWN HAPPY CLI] ${errorMessage}`); + throw new Error(errorMessage); + } + return spawn$1("node", nodeArgs, options); +} + +function startDaemonControlServer({ + getChildren, + stopSession, + spawnSession, + requestShutdown, + onHappySessionWebhook +}) { + return new Promise((resolve) => { + const app = fastify({ + logger: false + // We use our own logger + }); + app.setValidatorCompiler(validatorCompiler); + app.setSerializerCompiler(serializerCompiler); + const typed = app.withTypeProvider(); + typed.post("/session-started", { + schema: { + body: z.object({ + sessionId: z.string(), + metadata: z.any() + // Metadata type from API + }), + response: { + 200: z.object({ + status: z.literal("ok") + }) + } + } + }, async (request) => { + const { sessionId, metadata } = request.body; + logger.debug(`[CONTROL SERVER] Session started: ${sessionId}`); + onHappySessionWebhook(sessionId, metadata); + return { status: "ok" }; + }); + typed.post("/list", { + schema: { + response: { + 200: z.object({ + children: z.array(z.object({ + startedBy: z.string(), + happySessionId: z.string(), + pid: z.number() + })) + }) + } + } + }, async () => { + const children = getChildren(); + logger.debug(`[CONTROL SERVER] Listing ${children.length} sessions`); + return { + children: children.filter((child) => child.happySessionId !== void 0).map((child) => ({ + startedBy: child.startedBy, + happySessionId: child.happySessionId, + pid: child.pid + })) + }; + }); + typed.post("/stop-session", { + schema: { + body: z.object({ + sessionId: z.string() + }), + response: { + 200: z.object({ + success: z.boolean() + }) + } + } + }, async (request) => { + const { sessionId } = request.body; + logger.debug(`[CONTROL SERVER] Stop session request: ${sessionId}`); + const success = stopSession(sessionId); + return { success }; + }); + typed.post("/spawn-session", { + schema: { + body: z.object({ + directory: z.string(), + sessionId: z.string().optional() + }), + response: { + 200: z.object({ + success: z.boolean(), + sessionId: z.string().optional(), + approvedNewDirectoryCreation: z.boolean().optional() + }), + 409: z.object({ + success: z.boolean(), + requiresUserApproval: z.boolean().optional(), + actionRequired: z.string().optional(), + directory: z.string().optional() + }), + 500: z.object({ + success: z.boolean(), + error: z.string().optional() + }) + } + } + }, async (request, reply) => { + const { directory, sessionId } = request.body; + logger.debug(`[CONTROL SERVER] Spawn session request: dir=${directory}, sessionId=${sessionId || "new"}`); + const result = await spawnSession({ directory, sessionId }); + switch (result.type) { + case "success": + if (!result.sessionId) { + reply.code(500); + return { + success: false, + error: "Failed to spawn session: no session ID returned" + }; + } + return { + success: true, + sessionId: result.sessionId, + approvedNewDirectoryCreation: true + }; + case "requestToApproveDirectoryCreation": + reply.code(409); + return { + success: false, + requiresUserApproval: true, + actionRequired: "CREATE_DIRECTORY", + directory: result.directory + }; + case "error": + reply.code(500); + return { + success: false, + error: result.errorMessage + }; + } + }); + typed.post("/stop", { + schema: { + response: { + 200: z.object({ + status: z.string() + }) + } + } + }, async () => { + logger.debug("[CONTROL SERVER] Stop daemon request received"); + setTimeout(() => { + logger.debug("[CONTROL SERVER] Triggering daemon shutdown"); + requestShutdown(); + }, 50); + return { status: "stopping" }; + }); + app.listen({ port: 0, host: "127.0.0.1" }, (err, address) => { + if (err) { + logger.debug("[CONTROL SERVER] Failed to start:", err); + throw err; + } + const port = parseInt(address.split(":").pop()); + logger.debug(`[CONTROL SERVER] Started on port ${port}`); + resolve({ + port, + stop: async () => { + logger.debug("[CONTROL SERVER] Stopping server"); + await app.close(); + logger.debug("[CONTROL SERVER] Server stopped"); + } + }); + }); + }); +} + +const initialMachineMetadata = { + host: os.hostname(), + platform: os.platform(), + happyCliVersion: packageJson.version, + homeDir: os.homedir(), + happyHomeDir: configuration.happyHomeDir, + happyLibDir: projectPath() +}; +async function startDaemon() { + let requestShutdown; + let resolvesWhenShutdownRequested = new Promise((resolve) => { + requestShutdown = (source, errorMessage) => { + logger.debug(`[DAEMON RUN] Requesting shutdown (source: ${source}, errorMessage: ${errorMessage})`); + setTimeout(async () => { + logger.debug("[DAEMON RUN] Startup malfunctioned, forcing exit with code 1"); + await new Promise((resolve2) => setTimeout(resolve2, 100)); + process.exit(1); + }, 1e3); + resolve({ source, errorMessage }); + }; + }); + process.on("SIGINT", () => { + logger.debug("[DAEMON RUN] Received SIGINT"); + requestShutdown("os-signal"); + }); + process.on("SIGTERM", () => { + logger.debug("[DAEMON RUN] Received SIGTERM"); + requestShutdown("os-signal"); + }); + process.on("uncaughtException", (error) => { + logger.debug("[DAEMON RUN] FATAL: Uncaught exception", error); + logger.debug(`[DAEMON RUN] Stack trace: ${error.stack}`); + requestShutdown("exception", error.message); + }); + process.on("unhandledRejection", (reason, promise) => { + logger.debug("[DAEMON RUN] FATAL: Unhandled promise rejection", reason); + logger.debug(`[DAEMON RUN] Rejected promise:`, promise); + const error = reason instanceof Error ? reason : new Error(`Unhandled promise rejection: ${reason}`); + logger.debug(`[DAEMON RUN] Stack trace: ${error.stack}`); + requestShutdown("exception", error.message); + }); + process.on("exit", (code) => { + logger.debug(`[DAEMON RUN] Process exiting with code: ${code}`); + }); + process.on("beforeExit", (code) => { + logger.debug(`[DAEMON RUN] Process about to exit with code: ${code}`); + }); + logger.debug("[DAEMON RUN] Starting daemon process..."); + logger.debugLargeJson("[DAEMON RUN] Environment", getEnvironmentInfo()); + const runningDaemonVersionMatches = await isDaemonRunningCurrentlyInstalledHappyVersion(); + if (!runningDaemonVersionMatches) { + logger.debug("[DAEMON RUN] Daemon version mismatch detected, restarting daemon with current CLI version"); + await stopDaemon(); + } else { + logger.debug("[DAEMON RUN] Daemon version matches, keeping existing daemon"); + console.log("Daemon already running with matching version"); + process.exit(0); + } + const daemonLockHandle = await acquireDaemonLock(5, 200); + if (!daemonLockHandle) { + logger.debug("[DAEMON RUN] Daemon lock file already held, another daemon is running"); + process.exit(0); + } + try { + const caffeinateStarted = startCaffeinate(); + if (caffeinateStarted) { + logger.debug("[DAEMON RUN] Sleep prevention enabled"); + } + const { credentials, machineId } = await authAndSetupMachineIfNeeded(); + logger.debug("[DAEMON RUN] Auth and machine setup complete"); + const pidToTrackedSession = /* @__PURE__ */ new Map(); + const pidToAwaiter = /* @__PURE__ */ new Map(); + const getCurrentChildren = () => Array.from(pidToTrackedSession.values()); + const onHappySessionWebhook = (sessionId, sessionMetadata) => { + logger.debugLargeJson(`[DAEMON RUN] Session reported`, sessionMetadata); + const pid = sessionMetadata.hostPid; + if (!pid) { + logger.debug(`[DAEMON RUN] Session webhook missing hostPid for sessionId: ${sessionId}`); + return; + } + logger.debug(`[DAEMON RUN] Session webhook: ${sessionId}, PID: ${pid}, started by: ${sessionMetadata.startedBy || "unknown"}`); + logger.debug(`[DAEMON RUN] Current tracked sessions before webhook: ${Array.from(pidToTrackedSession.keys()).join(", ")}`); + const existingSession = pidToTrackedSession.get(pid); + if (existingSession && existingSession.startedBy === "daemon") { + existingSession.happySessionId = sessionId; + existingSession.happySessionMetadataFromLocalWebhook = sessionMetadata; + logger.debug(`[DAEMON RUN] Updated daemon-spawned session ${sessionId} with metadata`); + const awaiter = pidToAwaiter.get(pid); + if (awaiter) { + pidToAwaiter.delete(pid); + awaiter(existingSession); + logger.debug(`[DAEMON RUN] Resolved session awaiter for PID ${pid}`); + } + } else if (!existingSession) { + const trackedSession = { + startedBy: "happy directly - likely by user from terminal", + happySessionId: sessionId, + happySessionMetadataFromLocalWebhook: sessionMetadata, + pid + }; + pidToTrackedSession.set(pid, trackedSession); + logger.debug(`[DAEMON RUN] Registered externally-started session ${sessionId}`); + } + }; + const spawnSession = async (options) => { + logger.debugLargeJson("[DAEMON RUN] Spawning session", options); + const { directory, sessionId, machineId: machineId2, approvedNewDirectoryCreation = true } = options; + let directoryCreated = false; + try { + await fs.access(directory); + logger.debug(`[DAEMON RUN] Directory exists: ${directory}`); + } catch (error) { + logger.debug(`[DAEMON RUN] Directory doesn't exist, creating: ${directory}`); + if (!approvedNewDirectoryCreation) { + logger.debug(`[DAEMON RUN] Directory creation not approved for: ${directory}`); + return { + type: "requestToApproveDirectoryCreation", + directory + }; + } + try { + await fs.mkdir(directory, { recursive: true }); + logger.debug(`[DAEMON RUN] Successfully created directory: ${directory}`); + directoryCreated = true; + } catch (mkdirError) { + let errorMessage = `Unable to create directory at '${directory}'. `; + if (mkdirError.code === "EACCES") { + errorMessage += `Permission denied. You don't have write access to create a folder at this location. Try using a different path or check your permissions.`; + } else if (mkdirError.code === "ENOTDIR") { + errorMessage += `A file already exists at this path or in the parent path. Cannot create a directory here. Please choose a different location.`; + } else if (mkdirError.code === "ENOSPC") { + errorMessage += `No space left on device. Your disk is full. Please free up some space and try again.`; + } else if (mkdirError.code === "EROFS") { + errorMessage += `The file system is read-only. Cannot create directories here. Please choose a writable location.`; + } else { + errorMessage += `System error: ${mkdirError.message || mkdirError}. Please verify the path is valid and you have the necessary permissions.`; + } + logger.debug(`[DAEMON RUN] Directory creation failed: ${errorMessage}`); + return { + type: "error", + errorMessage + }; + } + } + try { + let extraEnv = {}; + if (options.token) { + if (options.agent === "codex") { + const codexHomeDir = tmp.dirSync(); + fs.writeFile(join$1(codexHomeDir.name, "auth.json"), options.token); + extraEnv = { + CODEX_HOME: codexHomeDir.name + }; + } else { + extraEnv = { + CLAUDE_CODE_OAUTH_TOKEN: options.token + }; + } + } + const args = [ + options.agent === "claude" ? "claude" : "codex", + "--happy-starting-mode", + "remote", + "--started-by", + "daemon" + ]; + const happyProcess = spawnHappyCLI(args, { + cwd: directory, + detached: true, + // Sessions stay alive when daemon stops + stdio: ["ignore", "pipe", "pipe"], + // Capture stdout/stderr for debugging + env: { + ...process.env, + ...extraEnv + } + }); + if (process.env.DEBUG) { + happyProcess.stdout?.on("data", (data) => { + logger.debug(`[DAEMON RUN] Child stdout: ${data.toString()}`); + }); + happyProcess.stderr?.on("data", (data) => { + logger.debug(`[DAEMON RUN] Child stderr: ${data.toString()}`); + }); + } + if (!happyProcess.pid) { + logger.debug("[DAEMON RUN] Failed to spawn process - no PID returned"); + return { + type: "error", + errorMessage: "Failed to spawn Happy process - no PID returned" + }; + } + logger.debug(`[DAEMON RUN] Spawned process with PID ${happyProcess.pid}`); + const trackedSession = { + startedBy: "daemon", + pid: happyProcess.pid, + childProcess: happyProcess, + directoryCreated, + message: directoryCreated ? `The path '${directory}' did not exist. We created a new folder and spawned a new session there.` : void 0 + }; + pidToTrackedSession.set(happyProcess.pid, trackedSession); + happyProcess.on("exit", (code, signal) => { + logger.debug(`[DAEMON RUN] Child PID ${happyProcess.pid} exited with code ${code}, signal ${signal}`); + if (happyProcess.pid) { + onChildExited(happyProcess.pid); + } + }); + happyProcess.on("error", (error) => { + logger.debug(`[DAEMON RUN] Child process error:`, error); + if (happyProcess.pid) { + onChildExited(happyProcess.pid); + } + }); + logger.debug(`[DAEMON RUN] Waiting for session webhook for PID ${happyProcess.pid}`); + return new Promise((resolve) => { + const timeout = setTimeout(() => { + pidToAwaiter.delete(happyProcess.pid); + logger.debug(`[DAEMON RUN] Session webhook timeout for PID ${happyProcess.pid}`); + resolve({ + type: "error", + errorMessage: `Session webhook timeout for PID ${happyProcess.pid}` + }); + }, 15e3); + pidToAwaiter.set(happyProcess.pid, (completedSession) => { + clearTimeout(timeout); + logger.debug(`[DAEMON RUN] Session ${completedSession.happySessionId} fully spawned with webhook`); + resolve({ + type: "success", + sessionId: completedSession.happySessionId + }); + }); + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.debug("[DAEMON RUN] Failed to spawn session:", error); + return { + type: "error", + errorMessage: `Failed to spawn session: ${errorMessage}` + }; + } + }; + const stopSession = (sessionId) => { + logger.debug(`[DAEMON RUN] Attempting to stop session ${sessionId}`); + for (const [pid, session] of pidToTrackedSession.entries()) { + if (session.happySessionId === sessionId || sessionId.startsWith("PID-") && pid === parseInt(sessionId.replace("PID-", ""))) { + if (session.startedBy === "daemon" && session.childProcess) { + try { + session.childProcess.kill("SIGTERM"); + logger.debug(`[DAEMON RUN] Sent SIGTERM to daemon-spawned session ${sessionId}`); + } catch (error) { + logger.debug(`[DAEMON RUN] Failed to kill session ${sessionId}:`, error); + } + } else { + try { + process.kill(pid, "SIGTERM"); + logger.debug(`[DAEMON RUN] Sent SIGTERM to external session PID ${pid}`); + } catch (error) { + logger.debug(`[DAEMON RUN] Failed to kill external session PID ${pid}:`, error); + } + } + pidToTrackedSession.delete(pid); + logger.debug(`[DAEMON RUN] Removed session ${sessionId} from tracking`); + return true; + } + } + logger.debug(`[DAEMON RUN] Session ${sessionId} not found`); + return false; + }; + const onChildExited = (pid) => { + logger.debug(`[DAEMON RUN] Removing exited process PID ${pid} from tracking`); + pidToTrackedSession.delete(pid); + }; + const { port: controlPort, stop: stopControlServer } = await startDaemonControlServer({ + getChildren: getCurrentChildren, + stopSession, + spawnSession, + requestShutdown: () => requestShutdown("happy-cli"), + onHappySessionWebhook + }); + const fileState = { + pid: process.pid, + httpPort: controlPort, + startTime: (/* @__PURE__ */ new Date()).toLocaleString(), + startedWithCliVersion: packageJson.version, + daemonLogPath: logger.logFilePath + }; + writeDaemonState(fileState); + logger.debug("[DAEMON RUN] Daemon state written"); + const initialDaemonState = { + status: "offline", + pid: process.pid, + httpPort: controlPort, + startedAt: Date.now() + }; + const api = await ApiClient.create(credentials); + const machine = await api.getOrCreateMachine({ + machineId, + metadata: initialMachineMetadata, + daemonState: initialDaemonState + }); + logger.debug(`[DAEMON RUN] Machine registered: ${machine.id}`); + const apiMachine = api.machineSyncClient(machine); + apiMachine.setRPCHandlers({ + spawnSession, + stopSession, + requestShutdown: () => requestShutdown("happy-app") + }); + apiMachine.connect(); + const heartbeatIntervalMs = parseInt(process.env.HAPPY_DAEMON_HEARTBEAT_INTERVAL || "60000"); + let heartbeatRunning = false; + const restartOnStaleVersionAndHeartbeat = setInterval(async () => { + if (heartbeatRunning) { + return; + } + heartbeatRunning = true; + if (process.env.DEBUG) { + logger.debug(`[DAEMON RUN] Health check started at ${(/* @__PURE__ */ new Date()).toLocaleString()}`); + } + for (const [pid, _] of pidToTrackedSession.entries()) { + try { + process.kill(pid, 0); + } catch (error) { + logger.debug(`[DAEMON RUN] Removing stale session with PID ${pid} (process no longer exists)`); + pidToTrackedSession.delete(pid); + } + } + const projectVersion = JSON.parse(readFileSync$1(join$1(projectPath(), "package.json"), "utf-8")).version; + if (projectVersion !== configuration.currentCliVersion) { + logger.debug("[DAEMON RUN] Daemon is outdated, triggering self-restart with latest version, clearing heartbeat interval"); + clearInterval(restartOnStaleVersionAndHeartbeat); + try { + spawnHappyCLI(["daemon", "start"], { + detached: true, + stdio: "ignore" + }); + } catch (error) { + logger.debug("[DAEMON RUN] Failed to spawn new daemon, this is quite likely to happen during integration tests as we are cleaning out dist/ directory", error); + } + logger.debug("[DAEMON RUN] Hanging for a bit - waiting for CLI to kill us because we are running outdated version of the code"); + await new Promise((resolve) => setTimeout(resolve, 1e4)); + process.exit(0); + } + const daemonState = await readDaemonState(); + if (daemonState && daemonState.pid !== process.pid) { + logger.debug("[DAEMON RUN] Somehow a different daemon was started without killing us. We should kill ourselves."); + requestShutdown("exception", "A different daemon was started without killing us. We should kill ourselves."); + } + try { + const updatedState = { + pid: process.pid, + httpPort: controlPort, + startTime: fileState.startTime, + startedWithCliVersion: packageJson.version, + lastHeartbeat: (/* @__PURE__ */ new Date()).toLocaleString(), + daemonLogPath: fileState.daemonLogPath + }; + writeDaemonState(updatedState); + if (process.env.DEBUG) { + logger.debug(`[DAEMON RUN] Health check completed at ${updatedState.lastHeartbeat}`); + } + } catch (error) { + logger.debug("[DAEMON RUN] Failed to write heartbeat", error); + } + heartbeatRunning = false; + }, heartbeatIntervalMs); + const cleanupAndShutdown = async (source, errorMessage) => { + logger.debug(`[DAEMON RUN] Starting proper cleanup (source: ${source}, errorMessage: ${errorMessage})...`); + if (restartOnStaleVersionAndHeartbeat) { + clearInterval(restartOnStaleVersionAndHeartbeat); + logger.debug("[DAEMON RUN] Health check interval cleared"); + } + await apiMachine.updateDaemonState((state) => ({ + ...state, + status: "shutting-down", + shutdownRequestedAt: Date.now(), + shutdownSource: source + })); + await new Promise((resolve) => setTimeout(resolve, 100)); + apiMachine.shutdown(); + await stopControlServer(); + await cleanupDaemonState(); + await stopCaffeinate(); + await releaseDaemonLock(daemonLockHandle); + logger.debug("[DAEMON RUN] Cleanup completed, exiting process"); + process.exit(0); + }; + logger.debug("[DAEMON RUN] Daemon started successfully, waiting for shutdown request"); + const shutdownRequest = await resolvesWhenShutdownRequested; + await cleanupAndShutdown(shutdownRequest.source, shutdownRequest.errorMessage); + } catch (error) { + logger.debug("[DAEMON RUN][FATAL] Failed somewhere unexpectedly - exiting with code 1", error); + process.exit(1); + } +} + +async function startHappyServer(client) { + const handler = async (title) => { + logger.debug("[happyMCP] Changing title to:", title); + try { + client.sendClaudeSessionMessage({ + type: "summary", + summary: title, + leafUuid: randomUUID() + }); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }; + const mcp = new McpServer({ + name: "Happy MCP", + version: "1.0.0", + description: "Happy CLI MCP server with chat session management tools" + }); + mcp.registerTool("change_title", { + description: "Change the title of the current chat session", + title: "Change Chat Title", + inputSchema: { + title: z.string().describe("The new title for the chat session") + } + }, async (args) => { + const response = await handler(args.title); + logger.debug("[happyMCP] Response:", response); + if (response.success) { + return { + content: [ + { + type: "text", + text: `Successfully changed chat title to: "${args.title}"` + } + ], + isError: false + }; + } else { + return { + content: [ + { + type: "text", + text: `Failed to change chat title: ${response.error || "Unknown error"}` + } + ], + isError: true + }; + } + }); + const transport = new StreamableHTTPServerTransport({ + // NOTE: Returning session id here will result in claude + // sdk spawn to fail with `Invalid Request: Server already initialized` + sessionIdGenerator: void 0 + }); + await mcp.connect(transport); + const server = createServer(async (req, res) => { + try { + await transport.handleRequest(req, res); + } catch (error) { + logger.debug("Error handling request:", error); + if (!res.headersSent) { + res.writeHead(500).end(); + } + } + }); + const baseUrl = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + resolve(new URL(`http://127.0.0.1:${addr.port}`)); + }); + }); + return { + url: baseUrl.toString(), + toolNames: ["change_title"], + stop: () => { + logger.debug("[happyMCP] Stopping server"); + mcp.close(); + server.close(); + } + }; +} + +function registerKillSessionHandler(rpcHandlerManager, killThisHappy) { + rpcHandlerManager.registerHandler("killSession", async () => { + logger.debug("Kill session request received"); + void killThisHappy(); + return { + success: true, + message: "Killing happy-cli process" + }; + }); +} + +async function runClaude(credentials, options = {}) { + const workingDirectory = process.cwd(); + const sessionTag = randomUUID(); + logger.debugLargeJson("[START] Happy process started", getEnvironmentInfo()); + logger.debug(`[START] Options: startedBy=${options.startedBy}, startingMode=${options.startingMode}`); + if (options.startedBy === "daemon" && options.startingMode === "local") { + logger.debug("Daemon spawn requested with local mode - forcing remote mode"); + options.startingMode = "remote"; + } + const api = await ApiClient.create(credentials); + let state = {}; + const settings = await readSettings(); + let machineId = settings?.machineId; + if (!machineId) { + console.error(`[START] No machine ID found in settings, which is unexepcted since authAndSetupMachineIfNeeded should have created it. Please report this issue on https://github.com/slopus/happy-cli/issues`); + process.exit(1); + } + logger.debug(`Using machineId: ${machineId}`); + await api.getOrCreateMachine({ + machineId, + metadata: initialMachineMetadata + }); + let metadata = { + path: workingDirectory, + host: os$1.hostname(), + version: packageJson.version, + os: os$1.platform(), + machineId, + homeDir: os$1.homedir(), + happyHomeDir: configuration.happyHomeDir, + happyLibDir: projectPath(), + happyToolsDir: resolve(projectPath(), "tools", "unpacked"), + startedFromDaemon: options.startedBy === "daemon", + hostPid: process.pid, + startedBy: options.startedBy || "terminal", + // Initialize lifecycle state + lifecycleState: "running", + lifecycleStateSince: Date.now(), + flavor: "claude" + }; + const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); + logger.debug(`Session created: ${response.id}`); + try { + logger.debug(`[START] Reporting session ${response.id} to daemon`); + const result = await notifyDaemonSessionStarted(response.id, metadata); + if (result.error) { + logger.debug(`[START] Failed to report to daemon (may not be running):`, result.error); + } else { + logger.debug(`[START] Reported session ${response.id} to daemon`); + } + } catch (error) { + logger.debug("[START] Failed to report to daemon (may not be running):", error); + } + extractSDKMetadataAsync(async (sdkMetadata) => { + logger.debug("[start] SDK metadata extracted, updating session:", sdkMetadata); + try { + api.sessionSyncClient(response).updateMetadata((currentMetadata) => ({ + ...currentMetadata, + tools: sdkMetadata.tools, + slashCommands: sdkMetadata.slashCommands + })); + logger.debug("[start] Session metadata updated with SDK capabilities"); + } catch (error) { + logger.debug("[start] Failed to update session metadata:", error); + } + }); + const session = api.sessionSyncClient(response); + const happyServer = await startHappyServer(session); + logger.debug(`[START] Happy MCP server started at ${happyServer.url}`); + const logPath = logger.logFilePath; + logger.infoDeveloper(`Session: ${response.id}`); + logger.infoDeveloper(`Logs: ${logPath}`); + session.updateAgentState((currentState) => ({ + ...currentState, + controlledByUser: options.startingMode !== "remote" + })); + const caffeinateStarted = startCaffeinate(); + if (caffeinateStarted) { + logger.infoDeveloper("Sleep prevention enabled (macOS)"); + } + const messageQueue = new MessageQueue2((mode) => hashObject({ + isPlan: mode.permissionMode === "plan", + model: mode.model, + fallbackModel: mode.fallbackModel, + customSystemPrompt: mode.customSystemPrompt, + appendSystemPrompt: mode.appendSystemPrompt, + allowedTools: mode.allowedTools, + disallowedTools: mode.disallowedTools + })); + let currentPermissionMode = options.permissionMode; + let currentModel = options.model; + let currentFallbackModel = void 0; + let currentCustomSystemPrompt = void 0; + let currentAppendSystemPrompt = void 0; + let currentAllowedTools = void 0; + let currentDisallowedTools = void 0; + session.onUserMessage((message) => { + let messagePermissionMode = currentPermissionMode; + if (message.meta?.permissionMode) { + const validModes = ["default", "acceptEdits", "bypassPermissions", "plan"]; + if (validModes.includes(message.meta.permissionMode)) { + messagePermissionMode = message.meta.permissionMode; + currentPermissionMode = messagePermissionMode; + logger.debug(`[loop] Permission mode updated from user message to: ${currentPermissionMode}`); + } else { + logger.debug(`[loop] Invalid permission mode received: ${message.meta.permissionMode}`); + } + } else { + logger.debug(`[loop] User message received with no permission mode override, using current: ${currentPermissionMode}`); + } + let messageModel = currentModel; + if (message.meta?.hasOwnProperty("model")) { + messageModel = message.meta.model || void 0; + currentModel = messageModel; + logger.debug(`[loop] Model updated from user message: ${messageModel || "reset to default"}`); + } else { + logger.debug(`[loop] User message received with no model override, using current: ${currentModel || "default"}`); + } + let messageCustomSystemPrompt = currentCustomSystemPrompt; + if (message.meta?.hasOwnProperty("customSystemPrompt")) { + messageCustomSystemPrompt = message.meta.customSystemPrompt || void 0; + currentCustomSystemPrompt = messageCustomSystemPrompt; + logger.debug(`[loop] Custom system prompt updated from user message: ${messageCustomSystemPrompt ? "set" : "reset to none"}`); + } else { + logger.debug(`[loop] User message received with no custom system prompt override, using current: ${currentCustomSystemPrompt ? "set" : "none"}`); + } + let messageFallbackModel = currentFallbackModel; + if (message.meta?.hasOwnProperty("fallbackModel")) { + messageFallbackModel = message.meta.fallbackModel || void 0; + currentFallbackModel = messageFallbackModel; + logger.debug(`[loop] Fallback model updated from user message: ${messageFallbackModel || "reset to none"}`); + } else { + logger.debug(`[loop] User message received with no fallback model override, using current: ${currentFallbackModel || "none"}`); + } + let messageAppendSystemPrompt = currentAppendSystemPrompt; + if (message.meta?.hasOwnProperty("appendSystemPrompt")) { + messageAppendSystemPrompt = message.meta.appendSystemPrompt || void 0; + currentAppendSystemPrompt = messageAppendSystemPrompt; + logger.debug(`[loop] Append system prompt updated from user message: ${messageAppendSystemPrompt ? "set" : "reset to none"}`); + } else { + logger.debug(`[loop] User message received with no append system prompt override, using current: ${currentAppendSystemPrompt ? "set" : "none"}`); + } + let messageAllowedTools = currentAllowedTools; + if (message.meta?.hasOwnProperty("allowedTools")) { + messageAllowedTools = message.meta.allowedTools || void 0; + currentAllowedTools = messageAllowedTools; + logger.debug(`[loop] Allowed tools updated from user message: ${messageAllowedTools ? messageAllowedTools.join(", ") : "reset to none"}`); + } else { + logger.debug(`[loop] User message received with no allowed tools override, using current: ${currentAllowedTools ? currentAllowedTools.join(", ") : "none"}`); + } + let messageDisallowedTools = currentDisallowedTools; + if (message.meta?.hasOwnProperty("disallowedTools")) { + messageDisallowedTools = message.meta.disallowedTools || void 0; + currentDisallowedTools = messageDisallowedTools; + logger.debug(`[loop] Disallowed tools updated from user message: ${messageDisallowedTools ? messageDisallowedTools.join(", ") : "reset to none"}`); + } else { + logger.debug(`[loop] User message received with no disallowed tools override, using current: ${currentDisallowedTools ? currentDisallowedTools.join(", ") : "none"}`); + } + const specialCommand = parseSpecialCommand(message.content.text); + if (specialCommand.type === "compact") { + logger.debug("[start] Detected /compact command"); + const enhancedMode2 = { + permissionMode: messagePermissionMode || "default", + model: messageModel, + fallbackModel: messageFallbackModel, + customSystemPrompt: messageCustomSystemPrompt, + appendSystemPrompt: messageAppendSystemPrompt, + allowedTools: messageAllowedTools, + disallowedTools: messageDisallowedTools + }; + messageQueue.pushIsolateAndClear(specialCommand.originalMessage || message.content.text, enhancedMode2); + logger.debugLargeJson("[start] /compact command pushed to queue:", message); + return; + } + if (specialCommand.type === "clear") { + logger.debug("[start] Detected /clear command"); + const enhancedMode2 = { + permissionMode: messagePermissionMode || "default", + model: messageModel, + fallbackModel: messageFallbackModel, + customSystemPrompt: messageCustomSystemPrompt, + appendSystemPrompt: messageAppendSystemPrompt, + allowedTools: messageAllowedTools, + disallowedTools: messageDisallowedTools + }; + messageQueue.pushIsolateAndClear(specialCommand.originalMessage || message.content.text, enhancedMode2); + logger.debugLargeJson("[start] /compact command pushed to queue:", message); + return; + } + const enhancedMode = { + permissionMode: messagePermissionMode || "default", + model: messageModel, + fallbackModel: messageFallbackModel, + customSystemPrompt: messageCustomSystemPrompt, + appendSystemPrompt: messageAppendSystemPrompt, + allowedTools: messageAllowedTools, + disallowedTools: messageDisallowedTools + }; + messageQueue.push(message.content.text, enhancedMode); + logger.debugLargeJson("User message pushed to queue:", message); + }); + const cleanup = async () => { + logger.debug("[START] Received termination signal, cleaning up..."); + try { + if (session) { + session.updateMetadata((currentMetadata) => ({ + ...currentMetadata, + lifecycleState: "archived", + lifecycleStateSince: Date.now(), + archivedBy: "cli", + archiveReason: "User terminated" + })); + session.sendSessionDeath(); + await session.flush(); + await session.close(); + } + stopCaffeinate(); + happyServer.stop(); + logger.debug("[START] Cleanup complete, exiting"); + process.exit(0); + } catch (error) { + logger.debug("[START] Error during cleanup:", error); + process.exit(1); + } + }; + process.on("SIGTERM", cleanup); + process.on("SIGINT", cleanup); + process.on("uncaughtException", (error) => { + logger.debug("[START] Uncaught exception:", error); + cleanup(); + }); + process.on("unhandledRejection", (reason) => { + logger.debug("[START] Unhandled rejection:", reason); + cleanup(); + }); + registerKillSessionHandler(session.rpcHandlerManager, cleanup); + await loop({ + path: workingDirectory, + model: options.model, + permissionMode: options.permissionMode, + startingMode: options.startingMode, + messageQueue, + api, + allowedTools: happyServer.toolNames.map((toolName) => `mcp__happy__${toolName}`), + onModeChange: (newMode) => { + session.sendSessionEvent({ type: "switch", mode: newMode }).catch((err) => logger.debug("[Claude] Failed to send mode change event:", err)); + session.updateAgentState((currentState) => ({ + ...currentState, + controlledByUser: newMode === "local" + })); + }, + onSessionReady: (_sessionInstance) => { + }, + mcpServers: { + "happy": { + type: "http", + url: happyServer.url + } + }, + session, + claudeEnvVars: options.claudeEnvVars, + claudeArgs: options.claudeArgs + }); + session.sendSessionDeath(); + logger.debug("Waiting for socket to flush..."); + await session.flush(); + logger.debug("Closing session..."); + await session.close(); + stopCaffeinate(); + logger.debug("Stopped sleep prevention"); + happyServer.stop(); + logger.debug("Stopped Happy MCP server"); + process.exit(0); +} + +const PLIST_LABEL$1 = "com.happy-cli.daemon"; +const PLIST_FILE$1 = `/Library/LaunchDaemons/${PLIST_LABEL$1}.plist`; +async function install$1() { + try { + if (existsSync$1(PLIST_FILE$1)) { + logger.info("Daemon plist already exists. Uninstalling first..."); + execSync$1(`launchctl unload ${PLIST_FILE$1}`, { stdio: "inherit" }); + } + const happyPath = process.argv[0]; + const scriptPath = process.argv[1]; + const plistContent = trimIdent(` + + + + + Label + ${PLIST_LABEL$1} + + ProgramArguments + + ${happyPath} + ${scriptPath} + happy-daemon + + + EnvironmentVariables + + HAPPY_DAEMON_MODE + true + + + RunAtLoad + + + KeepAlive + + + StandardErrorPath + ${os.homedir()}/.happy/daemon.err + + StandardOutPath + ${os.homedir()}/.happy/daemon.log + + WorkingDirectory + /tmp + + + `); + writeFileSync(PLIST_FILE$1, plistContent); + chmodSync(PLIST_FILE$1, 420); + logger.info(`Created daemon plist at ${PLIST_FILE$1}`); + execSync$1(`launchctl load ${PLIST_FILE$1}`, { stdio: "inherit" }); + logger.info("Daemon installed and started successfully"); + logger.info("Check logs at ~/.happy/daemon.log"); + } catch (error) { + logger.debug("Failed to install daemon:", error); + throw error; + } +} + +async function install() { + if (process.platform !== "darwin") { + throw new Error("Daemon installation is currently only supported on macOS"); + } + if (process.getuid && process.getuid() !== 0) { + throw new Error("Daemon installation requires sudo privileges. Please run with sudo."); + } + logger.info("Installing Happy CLI daemon for macOS..."); + await install$1(); +} + +const PLIST_LABEL = "com.happy-cli.daemon"; +const PLIST_FILE = `/Library/LaunchDaemons/${PLIST_LABEL}.plist`; +async function uninstall$1() { + try { + if (!existsSync$1(PLIST_FILE)) { + logger.info("Daemon plist not found. Nothing to uninstall."); + return; + } + try { + execSync$1(`launchctl unload ${PLIST_FILE}`, { stdio: "inherit" }); + logger.info("Daemon stopped successfully"); + } catch (error) { + logger.info("Failed to unload daemon (it might not be running)"); + } + unlinkSync(PLIST_FILE); + logger.info(`Removed daemon plist from ${PLIST_FILE}`); + logger.info("Daemon uninstalled successfully"); + } catch (error) { + logger.debug("Failed to uninstall daemon:", error); + throw error; + } +} + +async function uninstall() { + if (process.platform !== "darwin") { + throw new Error("Daemon uninstallation is currently only supported on macOS"); + } + if (process.getuid && process.getuid() !== 0) { + throw new Error("Daemon uninstallation requires sudo privileges. Please run with sudo."); + } + logger.info("Uninstalling Happy CLI daemon for macOS..."); + await uninstall$1(); +} + +async function handleAuthCommand(args) { + const subcommand = args[0]; + if (!subcommand || subcommand === "help" || subcommand === "--help" || subcommand === "-h") { + showAuthHelp(); + return; + } + switch (subcommand) { + case "login": + await handleAuthLogin(args.slice(1)); + break; + case "logout": + await handleAuthLogout(); + break; + // case 'backup': + // await handleAuthShowBackup(); + // break; + case "status": + await handleAuthStatus(); + break; + default: + console.error(chalk.red(`Unknown auth subcommand: ${subcommand}`)); + showAuthHelp(); + process.exit(1); + } +} +function showAuthHelp() { + console.log(` +${chalk.bold("happy auth")} - Authentication management + +${chalk.bold("Usage:")} + happy auth login [--force] Authenticate with Happy + happy auth logout Remove authentication and machine data + happy auth status Show authentication status + happy auth show-backup Display backup key for mobile/web clients + happy auth help Show this help message + +${chalk.bold("Options:")} + --force Clear credentials, machine ID, and stop daemon before re-auth +`); +} +async function handleAuthLogin(args) { + const forceAuth = args.includes("--force") || args.includes("-f"); + if (forceAuth) { + console.log(chalk.yellow("Force authentication requested.")); + console.log(chalk.gray("This will:")); + console.log(chalk.gray(" \u2022 Clear existing credentials")); + console.log(chalk.gray(" \u2022 Clear machine ID")); + console.log(chalk.gray(" \u2022 Stop daemon if running")); + console.log(chalk.gray(" \u2022 Re-authenticate and register machine\n")); + try { + logger.debug("Stopping daemon for force auth..."); + await stopDaemon(); + console.log(chalk.gray("\u2713 Stopped daemon")); + } catch (error) { + logger.debug("Daemon was not running or failed to stop:", error); + } + await clearCredentials(); + console.log(chalk.gray("\u2713 Cleared credentials")); + await clearMachineId(); + console.log(chalk.gray("\u2713 Cleared machine ID")); + console.log(""); + } + if (!forceAuth) { + const existingCreds = await readCredentials(); + const settings = await readSettings(); + if (existingCreds && settings?.machineId) { + console.log(chalk.green("\u2713 Already authenticated")); + console.log(chalk.gray(` Machine ID: ${settings.machineId}`)); + console.log(chalk.gray(` Host: ${os$1.hostname()}`)); + console.log(chalk.gray(` Use 'happy auth login --force' to re-authenticate`)); + return; + } else if (existingCreds && !settings?.machineId) { + console.log(chalk.yellow("\u26A0\uFE0F Credentials exist but machine ID is missing")); + console.log(chalk.gray(" This can happen if --auth flag was used previously")); + console.log(chalk.gray(" Fixing by setting up machine...\n")); + } + } + try { + const result = await authAndSetupMachineIfNeeded(); + console.log(chalk.green("\n\u2713 Authentication successful")); + console.log(chalk.gray(` Machine ID: ${result.machineId}`)); + } catch (error) { + console.error(chalk.red("Authentication failed:"), error instanceof Error ? error.message : "Unknown error"); + process.exit(1); + } +} +async function handleAuthLogout() { + const happyDir = configuration.happyHomeDir; + const credentials = await readCredentials(); + if (!credentials) { + console.log(chalk.yellow("Not currently authenticated")); + return; + } + console.log(chalk.blue("This will log you out of Happy")); + console.log(chalk.yellow("\u26A0\uFE0F You will need to re-authenticate to use Happy again")); + const rl = createInterface({ + input: process.stdin, + output: process.stdout + }); + const answer = await new Promise((resolve) => { + rl.question(chalk.yellow("Are you sure you want to log out? (y/N): "), resolve); + }); + rl.close(); + if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") { + try { + try { + await stopDaemon(); + console.log(chalk.gray("Stopped daemon")); + } catch { + } + if (existsSync(happyDir)) { + rmSync(happyDir, { recursive: true, force: true }); + } + console.log(chalk.green("\u2713 Successfully logged out")); + console.log(chalk.gray(' Run "happy auth login" to authenticate again')); + } catch (error) { + throw new Error(`Failed to logout: ${error instanceof Error ? error.message : "Unknown error"}`); + } + } else { + console.log(chalk.blue("Logout cancelled")); + } +} +async function handleAuthStatus() { + const credentials = await readCredentials(); + const settings = await readSettings(); + console.log(chalk.bold("\nAuthentication Status\n")); + if (!credentials) { + console.log(chalk.red("\u2717 Not authenticated")); + console.log(chalk.gray(' Run "happy auth login" to authenticate')); + return; + } + console.log(chalk.green("\u2713 Authenticated")); + const tokenPreview = credentials.token.substring(0, 30) + "..."; + console.log(chalk.gray(` Token: ${tokenPreview}`)); + if (settings?.machineId) { + console.log(chalk.green("\u2713 Machine registered")); + console.log(chalk.gray(` Machine ID: ${settings.machineId}`)); + console.log(chalk.gray(` Host: ${os$1.hostname()}`)); + } else { + console.log(chalk.yellow("\u26A0\uFE0F Machine not registered")); + console.log(chalk.gray(' Run "happy auth login --force" to fix this')); + } + console.log(chalk.gray(` + Data directory: ${configuration.happyHomeDir}`)); + try { + const running = await checkIfDaemonRunningAndCleanupStaleState(); + if (running) { + console.log(chalk.green("\u2713 Daemon running")); + } else { + console.log(chalk.gray("\u2717 Daemon not running")); + } + } catch { + console.log(chalk.gray("\u2717 Daemon not running")); + } +} + +const CLIENT_ID$2 = "app_EMoamEEZ73f0CkXaXp7hrann"; +const AUTH_BASE_URL = "https://auth.openai.com"; +const DEFAULT_PORT$2 = 1455; +function generatePKCE$2() { + const verifier = randomBytes$1(32).toString("base64url").replace(/[^a-zA-Z0-9\-._~]/g, ""); + const challenge = createHash("sha256").update(verifier).digest("base64url").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); + return { verifier, challenge }; +} +function generateState$2() { + return randomBytes$1(16).toString("hex"); +} +function parseJWT(token) { + const parts = token.split("."); + if (parts.length !== 3) { + throw new Error("Invalid JWT format"); + } + const payload = Buffer.from(parts[1], "base64url").toString(); + return JSON.parse(payload); +} +async function findAvailablePort$2() { + return new Promise((resolve) => { + const server = createServer$1(); + server.listen(0, "127.0.0.1", () => { + const port = server.address().port; + server.close(() => resolve(port)); + }); + }); +} +async function isPortAvailable$2(port) { + return new Promise((resolve) => { + const testServer = createServer$1(); + testServer.once("error", () => { + testServer.close(); + resolve(false); + }); + testServer.listen(port, "127.0.0.1", () => { + testServer.close(() => resolve(true)); + }); + }); +} +async function exchangeCodeForTokens$2(code, verifier, port) { + const response = await fetch(`${AUTH_BASE_URL}/oauth/token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: CLIENT_ID$2, + code, + code_verifier: verifier, + redirect_uri: `http://localhost:${port}/auth/callback` + }) + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + const data = await response.json(); + const idTokenPayload = parseJWT(data.id_token); + let accountId = idTokenPayload.chatgpt_account_id; + if (!accountId) { + const authClaim = idTokenPayload["https://api.openai.com/auth"]; + if (authClaim && typeof authClaim === "object") { + accountId = authClaim.chatgpt_account_id || authClaim.account_id; + } + } + return { + id_token: data.id_token, + access_token: data.access_token || data.id_token, + refresh_token: data.refresh_token, + account_id: accountId + }; +} +async function startCallbackServer$2(state, verifier, port) { + return new Promise((resolve, reject) => { + const server = createServer$1(async (req, res) => { + const url = new URL(req.url, `http://localhost:${port}`); + if (url.pathname === "/auth/callback") { + const code = url.searchParams.get("code"); + const receivedState = url.searchParams.get("state"); + if (receivedState !== state) { + res.writeHead(400); + res.end("Invalid state parameter"); + server.close(); + reject(new Error("Invalid state parameter")); + return; + } + if (!code) { + res.writeHead(400); + res.end("No authorization code received"); + server.close(); + reject(new Error("No authorization code received")); + return; + } + try { + const tokens = await exchangeCodeForTokens$2(code, verifier, port); + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(` + + +

\u2705 Authentication Successful!

+

You can close this window and return to your terminal.

+