diff --git a/.envrc.example b/.envrc.example new file mode 100644 index 00000000..52287d58 --- /dev/null +++ b/.envrc.example @@ -0,0 +1,17 @@ +# Happy CLI Development Environment +# +# This file is for direnv users who want automatic environment switching +# when entering this directory. +# +# Setup: +# 1. Install direnv: https://direnv.net/ +# 2. Copy this file: cp .envrc.example .envrc +# 3. Run: direnv allow +# +# The .envrc file is gitignored, so each developer can customize it. + +export HAPPY_HOME_DIR="$HOME/.happy-dev" +export HAPPY_VARIANT="dev" +export HAPPY_SERVER_URL="${HAPPY_SERVER_URL:-https://api.cluster-fluster.com}" + +echo "šŸ”§ DEV environment activated (data: $HAPPY_HOME_DIR)" diff --git a/.gitignore b/.gitignore index 44bfd564..927a8b14 100644 --- a/.gitignore +++ b/.gitignore @@ -17,15 +17,24 @@ pnpm-lock.yaml # Environment variables .env .env*.local +.envrc # Claude code session level settings .claude/settings.local.json -# Local installation +# Local installation and data directories .happy/ +.happy-dev/ **/*.log .release-notes-temp.md +# Git worktrees for isolated branch work +.worktrees/ +bun.lock +claude-docs/ +note/ +happy-coder-*.tgz + # npm auth token (never commit) -.npmrc \ No newline at end of file +.npmrc diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..ccb1eb28 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,333 @@ +# Contributing to Happy CLI + +## Prerequisites + +- Node.js >= 20.0.0 +- Yarn (`npm install -g yarn`) +- Git +- Claude CLI installed and logged in (`claude` command available in PATH) + +## Getting Started + +```bash +git clone https://github.com/slopus/happy-cli.git +cd happy-cli +yarn install +yarn build +``` + +## Development Commands + +### Global `happy-dev` Command + +Create a global `happy-dev` command that runs your local development build: + +```bash +yarn link:dev # Create happy-dev symlink +yarn unlink:dev # Remove happy-dev symlink +``` + +This creates a `happy-dev` command in your PATH pointing to your local build, while leaving any npm-installed `happy` command untouched. + +| Command | Runs | +|---------|------| +| `happy` | Stable npm version (from `npm install -g happy-coder`) | +| `happy-dev` | Local development version (from this repo) | + +**Note:** Run `yarn build` before `yarn link:dev` to ensure the binary exists. + +### Build Commands + +```bash +yarn build # Build the project +yarn typecheck # TypeScript type checking +yarn test # Run tests +yarn dev # Run without building (uses tsx) +``` + +## Stable vs Dev Data Isolation + +The CLI supports running stable and development versions side-by-side with completely isolated data. + +### Initial Setup (Once) + +```bash +npm run setup:dev +``` + +This creates: +- `~/.happy/` - Stable version data (production-ready) +- `~/.happy-dev/` - Development version data (for testing changes) + +### Daily Usage + +**Stable (production-ready):** +```bash +npm run stable:daemon:start +``` + +**Development (testing changes):** +```bash +npm run dev:daemon:start +``` + +## Visual Indicators + +You'll always see which version you're using: +- `āœ… STABLE MODE - Data: ~/.happy` +- `šŸ”§ DEV MODE - Data: ~/.happy-dev` + +## Common Tasks + +### Authentication + +```bash +# Authenticate stable version +npm run stable auth login + +# Authenticate dev version (can use same or different account) +npm run dev auth login + +# Logout +npm run stable auth logout +npm run dev auth logout +``` + +### Daemon Management + +```bash +# Check status of both +npm run stable:daemon:status +npm run dev:daemon:status + +# Stop both +npm run stable:daemon:stop +npm run dev:daemon:stop + +# Start both simultaneously +npm run stable:daemon:start && npm run dev:daemon:start +``` + +### Running Any Command + +```bash +# Stable version +npm run stable [args...] +npm run stable notify "Test message" +npm run stable doctor + +# Dev version +npm run dev:variant [args...] +npm run dev:variant notify "Test message" +npm run dev:variant doctor +``` + +## Data Isolation + +Both versions maintain complete separation: + +| Aspect | Stable | Development | +|--------|--------|-------------| +| Data Directory | `~/.happy/` | `~/.happy-dev/` | +| Settings | `~/.happy/settings.json` | `~/.happy-dev/settings.json` | +| Auth Keys | `~/.happy/access.key` | `~/.happy-dev/access.key` | +| Daemon State | `~/.happy/daemon.state.json` | `~/.happy-dev/daemon.state.json` | +| Logs | `~/.happy/logs/` | `~/.happy-dev/logs/` | + +**No conflicts!** Both can run simultaneously with separate: +- Authentication sessions +- Server connections +- Daemon processes +- Session histories +- Configuration settings + +## Advanced: direnv Auto-Switching + +For automatic environment switching when entering directories: + +1. Install [direnv](https://direnv.net/): + ```bash + # macOS + brew install direnv + + # Add to your shell (bash/zsh) + eval "$(direnv hook bash)" # or zsh + ``` + +2. Setup direnv for this project: + ```bash + cp .envrc.example .envrc + direnv allow + ``` + +3. Now `cd` into the directory automatically sets `HAPPY_VARIANT=dev`! + +## Troubleshooting + +### Commands not working? +```bash +npm install +``` + +### Permission denied on scripts? +```bash +chmod +x scripts/*.cjs +``` + +### Data directories not created? +```bash +npm run setup:dev +``` + +### Both daemons won't start? +Check port conflicts - each daemon needs its own port. The dev daemon will automatically use a different port from stable. + +### How do I check which version is running? +Look for the visual indicator: +- `āœ… STABLE MODE` = stable version +- `šŸ”§ DEV MODE` = development version + +Or check the daemon status: +```bash +npm run stable:daemon:status # Shows ~/.happy/ data location +npm run dev:daemon:status # Shows ~/.happy-dev/ data location +``` + +### `yarn link:dev` fails with permission denied? +```bash +sudo yarn link:dev +``` + +### `happy-dev` command not found after linking? +- Ensure your global npm bin is in PATH: `npm bin -g` +- Try opening a new terminal window +- Check the symlink was created: `ls -la $(npm bin -g)/happy-dev` + +## Tips + +1. **Use stable for production work** - Your tested, reliable version +2. **Use dev for testing changes** - Test new features without breaking your workflow +3. **Run both simultaneously** - Compare behavior side-by-side +4. **Different accounts** - Use different Happy accounts for dev/stable if needed +5. **Check logs** - Logs are separated: `~/.happy/logs/` vs `~/.happy-dev/logs/` + +## Example Workflow + +```bash +# Initial setup (once) +yarn install +yarn build +yarn link:dev +npm run setup:dev + +# Authenticate both +npm run stable auth login +npm run dev:variant auth login + +# Start both daemons +npm run stable:daemon:start +npm run dev:daemon:start + +# Do your development work... +# Edit code, build, test with dev version + +# When ready, update stable version +npm run stable:daemon:stop +git pull # or your deployment process +npm run stable:daemon:start + +# Dev continues running unaffected! +``` + +## How It Works + +The system uses the built-in `HAPPY_HOME_DIR` environment variable to separate data: + +- **Stable scripts** set: `HAPPY_HOME_DIR=~/.happy` +- **Dev scripts** set: `HAPPY_HOME_DIR=~/.happy-dev` + +Everything else (auth, sessions, logs, daemon) automatically follows the `HAPPY_HOME_DIR` setting. + +Cross-platform via Node.js - works identically on Windows, macOS, and Linux! + +## Testing Profile Sync Between GUI and CLI + +Profile synchronization ensures AI backend configurations created in the Happy mobile/web GUI work seamlessly with the CLI daemon. + +### Profile Schema Validation + +The profile schema is defined in both repositories: +- **GUI:** `sources/sync/settings.ts` (AIBackendProfileSchema) +- **CLI:** `src/persistence.ts` (AIBackendProfileSchema) + +**Critical:** These schemas MUST stay in sync to prevent sync failures. + +### Testing Profile Sync + +1. **Create profile in GUI:** + ``` + - Open Happy mobile/web app + - Settings → AI Backend Profiles + - Create new profile with custom environment variables + - Note the profile ID + ``` + +2. **Verify CLI receives profile:** + ```bash + # Start daemon with dev variant + npm run dev:daemon:start + + # Check daemon logs + tail -f ~/.happy-dev/logs/*.log | grep -i profile + ``` + +3. **Test profile-based session spawning:** + ```bash + # From GUI: Start new session with custom profile + # Check CLI daemon logs for: + # - "Loaded X environment variables from profile" + # - "Using GUI-provided profile environment variables" + ``` + +4. **Verify environment variable expansion:** + ```bash + # If profile uses ${VAR} references: + # - Set reference var in daemon environment: export Z_AI_AUTH_TOKEN="sk-..." + # - Start session from GUI + # - Verify daemon logs show expansion: "${Z_AI_AUTH_TOKEN}" → "sk-..." + ``` + +### Testing Schema Compatibility + +When modifying profile schemas: + +1. **Update both repositories** - Never update one without the other +2. **Test migration** - Existing profiles should migrate gracefully +3. **Version bump** - Update `CURRENT_PROFILE_VERSION` if schema changes +4. **Test validation** - Invalid profiles should be caught with clear errors + +### Common Issues + +**"Invalid profile" warnings in logs:** +- Check profile has valid UUID (not timestamp) +- Verify environment variable names match regex: `^[A-Z_][A-Z0-9_]*$` +- Ensure compatibility.claude or compatibility.codex is true + +**Environment variables not expanding:** +- Reference variable must be set in daemon's process.env +- Check daemon logs for expansion warnings +- Verify no typos in ${VAR} references + +## Publishing to npm + +Maintainers can publish new versions: + +```bash +yarn release # Interactive version bump, changelog, publish +``` + +This runs tests, builds, and publishes to npm. The published package includes: +- `happy` - Main CLI command +- `happy-mcp` - MCP bridge command + +**Note:** `happy-dev` is intentionally excluded from the npm package - it's for local development only. diff --git a/README.md b/README.md index d8ab661a..215c895e 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,10 @@ 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`) +## Contributing + +Interested in contributing? See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines. + ## Requirements - Node.js >= 20.0.0 diff --git a/bin/happy-dev.mjs b/bin/happy-dev.mjs new file mode 100755 index 00000000..4a576e51 --- /dev/null +++ b/bin/happy-dev.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node + +import { execFileSync } from 'child_process'; +import { fileURLToPath } from 'url'; +import { join, dirname } from 'path'; +import { homedir } from 'os'; + +// Check if we're already running with the flags +const hasNoWarnings = process.execArgv.includes('--no-warnings'); +const hasNoDeprecation = process.execArgv.includes('--no-deprecation'); + +if (!hasNoWarnings || !hasNoDeprecation) { + // Re-execute with the flags + const __filename = fileURLToPath(import.meta.url); + const scriptPath = join(dirname(__filename), '../dist/index.mjs'); + + // Set development environment variables + process.env.HAPPY_HOME_DIR = join(homedir(), '.happy-dev'); + process.env.HAPPY_VARIANT = 'dev'; + + try { + execFileSync( + process.execPath, + ['--no-warnings', '--no-deprecation', scriptPath, ...process.argv.slice(2)], + { + stdio: 'inherit', + env: process.env + } + ); + } catch (error) { + // Exit with the same code as the subprocess + process.exit(error.status || 1); + } +} else { + // Already have the flags, import normally + // Set development environment variables + process.env.HAPPY_HOME_DIR = join(homedir(), '.happy-dev'); + process.env.HAPPY_VARIANT = 'dev'; + + await import('../dist/index.mjs'); +} diff --git a/docs/bug-fix-plan-2025-01-15-athundt.md b/docs/bug-fix-plan-2025-01-15-athundt.md new file mode 100644 index 00000000..488f43cb --- /dev/null +++ b/docs/bug-fix-plan-2025-01-15-athundt.md @@ -0,0 +1,337 @@ +# Minimal Fix Plan for Happy-CLI Bugs with TDD +# Date: 2025-01-15 +# Created by: Andrew Hundt +# Bugs: Session ID conflict + Server crash + +## Overview +Two targeted fixes with concrete error messages and TDD tests to verify behavior. + +## Bug 1: Session ID Conflict with --continue Flag + +**Problem**: When running `./bin/happy.mjs --continue`, Claude CLI returns error: +``` +Error: --session-id cannot be used with --continue or --resume +``` + +**Root Cause Analysis**: +- This is a Claude Code 2.0.64+ design constraint, NOT a happy-cli bug +- Happy-CLI generates a NEW session ID and adds `--session-id ` for all local sessions +- When user passes `--continue`, Claude Code sees: `--continue --session-id ` → REJECTS +- The conflict occurs ONLY in local mode (claudeLocal.ts), not remote mode + +**Two Different Pathways**: + +1. **Local Mode (Path with conflict)**: + ``` + user: happy --continue + → index.ts (claudeArgs = ["--continue"]) + → runClaude.ts + → loop.ts + → claudeLocalLauncher.ts + → claudeLocal.ts + ā”œā”€ Generates NEW session ID + ā”œā”€ Adds --session-id + └─ Claude sees both flags → ERROR + ``` + +2. **Remote Mode (No conflict)**: + ``` + user: happy --continue + → ... → claudeRemote.ts → SDK query.ts + → SDK passes --continue to Claude + → No --session-id added by happy-cli + → Works fine + ``` + +**Claude Session File Analysis**: + +- Claude creates session files at: `~/.claude/projects/{project-id}/` +- Format: `{session-id}.jsonl` with UUID or agent-* IDs +- `--continue` creates NEW session with copied history +- `--resume {id}` continues EXISTING session with same ID +- Claude 2.0.64+ rejects `--session-id` with `--continue`/`--resume` + +## Solution Approach Analysis + +| Method | Description | Upsides | Downsides | Complexity | Risk | +|--------|-------------|---------|-----------|------------|------| +| **Convert --continue → --resume** | Find last valid session, convert flag | āœ… Exact --continue behavior
āœ… Native Claude support
āœ… Simple implementation | āŒ Needs session finding logic
āŒ Fails if no sessions exist | Medium | Medium | +| Environment Variables | Set session ID via env var | āœ… Simple
āœ… No file system deps | āŒ Non-obvious to users
āŒ Hard to debug | Low | Low | +| Post-process Extraction | Run Claude, extract session ID from output | āœ… Always gets correct ID
āœ… Works with any Claude version | āŒ Complex parsing
āŒ Race conditions
āŒ High complexity | High | High | +| Hybrid | Try --continue, fallback if fails | āœ… Minimal changes
āœ… Graceful fallback | āŒ Inconsistent behavior
āŒ Two code paths | Medium | Medium | + +**Recommended Solution: Convert --continue to --resume** + +This approach: +- Uses Claude's native --resume mechanism +- Maintains exact --continue behavior (new session with copied history) +- Transparent to users +- Works with existing session infrastructure + +```typescript +// In claudeLocal.ts (around line 35, after startFrom initial check) + +// Convert --continue to --resume with last session +if (!startFrom && opts.claudeArgs?.includes('--continue')) { + const lastSession = claudeFindLastSession(opts.path); + if (lastSession) { + startFrom = lastSession; + logger.debug(`[ClaudeLocal] Converting --continue to --resume ${lastSession}`); + } else { + logger.debug('[ClaudeLocal] No sessions found for --continue, creating new session'); + } + // Remove --continue from claudeArgs since we're handling it + opts.claudeArgs = opts.claudeArgs?.filter(arg => arg !== '--continue'); +} + +// Then existing logic: +if (startFrom) { + args.push('--resume', startFrom); // Will continue the found session +} else { + args.push('--session-id', newSessionId!); // New session +} +``` + +## Bug 2: Happy Server Unavailability Crash + +**Problem**: Happy-CLI crashes when Happy API server is unreachable + +**Server Details**: +- Default server: `https://api.cluster-fluster.com` +- Environment variable: `HAPPY_SERVER_URL` (overrides default) +- Local development: `http://localhost:3005` +- The server handles session management and real-time communication for Happy CLI + +**Fixes with Clear Messages**: + +1. **apiSession.ts** (line 152) - Socket connection failure: +```typescript +try { + this.socket.connect(); +} catch (error) { + console.log('āš ļø Cannot connect to Happy server - continuing in local mode'); + logger.debug('[API] Socket connection failed:', error); + // Don't throw - continue without socket +} +``` + +2. **api.ts** (catch block around line 75) - HTTP API failure: +```typescript +} catch (error) { + if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND') { + console.log('āš ļø Happy server unreachable - working in offline mode'); + return null; // Let caller handle fallback + } + throw error; // Re-throw other errors +} +``` + +## TDD Tests (Test-First Development) + +### Test File 1: src/claude/claudeLocal.test.ts +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { claudeLocal } from './claudeLocal'; + +describe('claudeLocal --continue handling', () => { + let mockSpawn: any; + let onSessionFound: any; + + beforeEach(() => { + mockSpawn = vi.fn(); + vi.mock('child_process', () => ({ + spawn: mockSpawn + })); + onSessionFound = vi.fn(); + mockSpawn.mockReturnValue({ + stdio: [null, null, null, null], + on: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + kill: vi.fn(), + on: vi.fn(), + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + stdin: { on: vi.fn(), end: vi.fn() } + }); + }); + + it('should pass --continue to Claude without --session-id when user requests continue', async () => { + await claudeLocal({ + abort: new AbortController().signal, + sessionId: null, + path: '/tmp', + onSessionFound, + claudeArgs: ['--continue'] // User wants to continue last session + }); + + // Verify spawn was called with --continue but WITHOUT --session-id + expect(mockSpawn).toHaveBeenCalled(); + const spawnArgs = mockSpawn.mock.calls[0][2]; + + // Should contain --continue + expect(spawnArgs).toContain('--continue'); + + // Should NOT contain --session-id (this was causing the conflict) + expect(spawnArgs).not.toContain('--session-id'); + + // Should notify about continue + expect(onSessionFound).toHaveBeenCalledWith('continue-pending'); + }); + + it('should add --session-id for normal new sessions', async () => { + await claudeLocal({ + abort: new AbortController().signal, + sessionId: null, + path: '/tmp', + onSessionFound, + claudeArgs: [] // No session flags - new session + }); + + // Verify spawn was called with --session-id for new sessions + expect(mockSpawn).toHaveBeenCalled(); + const spawnArgs = mockSpawn.mock.calls[0][2]; + expect(spawnArgs).toContain('--session-id'); + expect(spawnArgs).not.toContain('--continue'); + }); + + it('should handle --resume with session ID without conflict', async () => { + await claudeLocal({ + abort: new AbortController().signal, + sessionId: 'existing-session-123', + path: '/tmp', + onSessionFound, + claudeArgs: [] // No --continue + }); + + // Should use --resume with session ID + const spawnArgs = mockSpawn.mock.calls[0][2]; + expect(spawnArgs).toContain('--resume'); + expect(spawnArgs).toContain('existing-session-123'); + expect(spawnArgs).not.toContain('--session-id'); + }); +}); +``` + +### Test File 2: src/api/apiSession.test.ts +```typescript +import { describe, it, expect } from 'vitest'; +import { ApiSessionClient } from './apiSession'; + +describe('ApiSessionClient connection handling', () => { + it('should handle socket connection failure gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Mock socket.connect() to throw + const mockSocket = { + connect: vi.fn(() => { throw new Error('ECONNREFUSED'); }), + on: vi.fn() + }; + + // Should not throw + expect(() => { + new ApiSessionClient('fake-token', { id: 'test' } as any); + }).not.toThrow(); + + // Should show user-friendly message + expect(consoleSpy).toHaveBeenCalledWith( + 'āš ļø Cannot connect to Happy server - continuing in local mode' + ); + + consoleSpy.mockRestore(); + }); +}); +``` + +### Test File 3: src/api/api.test.ts +```typescript +import { describe, it, expect, vi } from 'vitest'; +import { Api } from './api'; + +describe('Api server error handling', () => { + it('should return null when Happy server is unreachable', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Mock axios to throw connection error + vi.mock('axios', () => ({ + default: { + post: vi.fn(() => Promise.reject({ code: 'ECONNREFUSED' })) + } + })); + + const api = new Api('fake-key'); + const result = await api.getOrCreateSession({ machineId: 'test' }); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + 'āš ļø Happy server unreachable - working in offline mode' + ); + + consoleSpy.mockRestore(); + }); +}); +``` + +## Implementation Steps (TDD Flow) + +1. **Create Local Plan Copy**: + ```bash + # Copy plan with date and author to project docs + cp /Users/athundt/.claude/plans/lively-plotting-snowflake.md \ + ./docs/bug-fix-plan-2025-01-15-athundt.md + git add ./docs/bug-fix-plan-2025-01-15-athundt.md + git commit -m "docs: add bug fix plan for session conflict and server crash" + ``` + +2. **Red Phase**: + - Write the 3 test files above + - Run tests - they should fail (bugs not fixed yet) + +3. **Green Phase - Bug 1 (Session ID Conflict)**: + - Apply fix to src/claude/claudeLocal.ts (around line 35): + - Import claudeFindLastSession from src/claude/utils/claudeFindLastSession.ts + - Detect --continue flag + - Convert to --resume with last session ID using claudeFindLastSession() + - Remove --continue from claudeArgs + - Use existing logic to add --resume or --session-id + - Run tests - they should pass + +4. **Green Phase - Bug 2 (Server Crash)**: + - Apply fixes to src/api/apiSession.ts, src/api/api.ts + - Add graceful error handling with user messages + - Run tests - they should pass + +5. **Refactor Phase**: + - Add session ID extraction for --continue (future enhancement): + - Monitor Claude's session file creation + - Extract real session ID from ~/.claude/projects/*/session-id.jsonl + - Update Happy's session metadata with Claude's ID + - Ensure code is clean and minimal + +6. **Manual Verification**: + ```bash + # Test Bug 1 fix: + ./bin/happy.mjs --continue # Should work without error + # Verify mobile/daemon still work with session ID + + # Test Bug 2 fix: + HAPPY_SERVER_URL=http://invalid:9999 ./bin/happy.mjs # Should show warning, not crash + # Or test with unreachable default server: + # Temporarily block network access to test default server fallback + ``` + +## Success Criteria + +**Bug 1 Fixed**: +- Test: `./bin/happy.mjs --continue` exits with code 0 +- No "session-id cannot be used" error + +**Bug 2 Fixed**: +- Test: `HAPPY_SERVER_URL=http://invalid:9999 ./bin/happy.mjs` shows warning message +- Process continues in local mode instead of crashing +- Clear user feedback: "āš ļø Happy server unreachable - working in offline mode" + +**All Tests Pass**: +- Unit tests: 100% pass +- Integration tests: Verify actual CLI behavior +- No regression in existing functionality \ No newline at end of file diff --git a/package.json b/package.json index d152fb77..fca8547f 100644 --- a/package.json +++ b/package.json @@ -58,14 +58,33 @@ "why do we need to build before running tests / dev?": "We need the binary to be built so we run daemon commands which directly run the binary - we don't want them to go out of sync or have custom spawn logic depending how we started happy", "typecheck": "tsc --noEmit", "build": "shx rm -rf dist && npx tsc --noEmit && pkgroll", - "test": "yarn build && vitest run", - "start": "yarn build && node ./bin/happy.mjs", + "test": "$npm_execpath run build && vitest run", + "start": "$npm_execpath run build && node ./bin/happy.mjs", "dev": "tsx src/index.ts", - "dev:local-server": "yarn build && tsx --env-file .env.dev-local-server src/index.ts", - "dev:integration-test-env": "yarn build && tsx --env-file .env.integration-test src/index.ts", - "prepublishOnly": "yarn build && yarn test", - "release": "yarn install && release-it", - "postinstall": "node scripts/unpack-tools.cjs" + "dev:local-server": "$npm_execpath run build && tsx --env-file .env.dev-local-server src/index.ts", + "dev:integration-test-env": "$npm_execpath run build && tsx --env-file .env.integration-test src/index.ts", + "prepublishOnly": "$npm_execpath run build && $npm_execpath test", + "release": "$npm_execpath install && release-it", + "postinstall": "node scripts/unpack-tools.cjs", + "// ==== Dev/Stable Variant Management ====": "", + "stable": "node scripts/env-wrapper.cjs stable", + "dev:variant": "node scripts/env-wrapper.cjs dev", + "// ==== Stable Version Quick Commands ====": "", + "stable:daemon:start": "node scripts/env-wrapper.cjs stable daemon start", + "stable:daemon:stop": "node scripts/env-wrapper.cjs stable daemon stop", + "stable:daemon:status": "node scripts/env-wrapper.cjs stable daemon status", + "stable:auth": "node scripts/env-wrapper.cjs stable auth", + "// ==== Development Version Quick Commands ====": "", + "dev:daemon:start": "node scripts/env-wrapper.cjs dev daemon start", + "dev:daemon:stop": "node scripts/env-wrapper.cjs dev daemon stop", + "dev:daemon:status": "node scripts/env-wrapper.cjs dev daemon status", + "dev:auth": "node scripts/env-wrapper.cjs dev auth", + "// ==== Setup ====": "", + "setup:dev": "node scripts/setup-dev.cjs", + "doctor": "node scripts/env-wrapper.cjs stable doctor", + "// ==== Development Linking ====": "", + "link:dev": "node scripts/link-dev.cjs", + "unlink:dev": "node scripts/link-dev.cjs unlink" }, "dependencies": { "@agentclientprotocol/sdk": "^0.8.0", diff --git a/scripts/__tests__/ripgrep_launcher.test.ts b/scripts/__tests__/ripgrep_launcher.test.ts new file mode 100644 index 00000000..258dcb72 --- /dev/null +++ b/scripts/__tests__/ripgrep_launcher.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +describe('Ripgrep Launcher Runtime Compatibility', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('has correct file structure', () => { + // Test that the launcher file has the correct structure + expect(() => { + const fs = require('fs'); + const path = require('path'); + const content = fs.readFileSync(path.join(__dirname, '../ripgrep_launcher.cjs'), 'utf8'); + + // Check for required elements + expect(content).toContain('#!/usr/bin/env node'); + expect(content).toContain('ripgrepMain'); + expect(content).toContain('loadRipgrepNative'); + }).not.toThrow(); + }); + + it('handles --version argument gracefully', () => { + // Test that --version handling logic exists + expect(() => { + const fs = require('fs'); + const path = require('path'); + const content = fs.readFileSync(path.join(__dirname, '../ripgrep_launcher.cjs'), 'utf8'); + + // Check that --version handling is present + expect(content).toContain('--version'); + expect(content).toContain('ripgrepMain'); + }).not.toThrow(); + }); + + it('detects runtime correctly', () => { + // Test runtime detection function exists + expect(() => { + const fs = require('fs'); + const path = require('path'); + const content = fs.readFileSync(path.join(__dirname, '../ripgrep_launcher.cjs'), 'utf8'); + + // Check that runtime detection logic is present + expect(content).toContain('detectRuntime'); + expect(content).toContain('typeof Bun'); + expect(content).toContain('typeof Deno'); + expect(content).toContain('process?.versions'); + }).not.toThrow(); + }); + + it('contains fallback chain logic', () => { + // Test that fallback logic is present + expect(() => { + const fs = require('fs'); + const path = require('path'); + const content = fs.readFileSync(path.join(__dirname, '../ripgrep_launcher.cjs'), 'utf8'); + + // Check that fallback chain is present + expect(content).toContain('loadRipgrepNative'); + expect(content).toContain('systemRipgrep'); + expect(content).toContain('createRipgrepWrapper'); + expect(content).toContain('createMockRipgrep'); + }).not.toThrow(); + }); + + it('contains cross-platform logic', () => { + // Test that cross-platform logic is present + expect(() => { + const fs = require('fs'); + const path = require('path'); + const content = fs.readFileSync(path.join(__dirname, '../ripgrep_launcher.cjs'), 'utf8'); + + // Check for platform-specific logic + expect(content).toContain('process.platform'); + expect(content).toContain('win32'); + expect(content).toContain('darwin'); + expect(content).toContain('linux'); + expect(content).toContain('execFileSync'); + }).not.toThrow(); + }); + + it('provides helpful error messages', () => { + // Test that helpful error messages are present + expect(() => { + const fs = require('fs'); + const path = require('path'); + const content = fs.readFileSync(path.join(__dirname, '../ripgrep_launcher.cjs'), 'utf8'); + + // Check for helpful messages + expect(content).toContain('brew install ripgrep'); + expect(content).toContain('winget install BurntSushi.ripgrep'); + expect(content).toContain('Search functionality unavailable'); + }).not.toThrow(); + }); +}); \ No newline at end of file diff --git a/scripts/claude_version_utils.cjs b/scripts/claude_version_utils.cjs index 921729e5..2184917a 100644 --- a/scripts/claude_version_utils.cjs +++ b/scripts/claude_version_utils.cjs @@ -9,6 +9,7 @@ * - macOS/Linux: curl -fsSL https://claude.ai/install.sh | bash * - PowerShell: irm https://claude.ai/install.ps1 | iex * - Windows CMD: curl -fsSL https://claude.ai/install.cmd | cmd + * 4. PATH fallback: bun, pnpm, or any other package manager */ const { execSync } = require('child_process'); @@ -49,72 +50,200 @@ function findNpmGlobalCliPath() { } /** - * Find path to Homebrew installed Claude Code CLI - * @returns {string|null} Path to cli.js or binary, or null if not found + * Find Claude CLI using system PATH (which/where command) + * Respects user's configuration and works across all platforms + * @returns {{path: string, source: string}|null} Path and source, or null if not found */ -function findHomebrewCliPath() { - if (process.platform !== 'darwin' && process.platform !== 'linux') { - return null; +function findClaudeInPath() { + try { + // Cross-platform: 'where' on Windows, 'which' on Unix + const command = process.platform === 'win32' ? 'where claude' : 'which claude'; + // stdio suppression for cleaner execution (from tiann/PR#83) + const result = execSync(command, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + + const claudePath = result.split('\n')[0].trim(); // Take first match + if (!claudePath) return null; + + // Check existence BEFORE resolving (from tiann/PR#83) + if (!fs.existsSync(claudePath)) return null; + + // Resolve with fallback to original path (from tiann/PR#83) + const resolvedPath = resolvePathSafe(claudePath) || claudePath; + + if (resolvedPath) { + // Detect source from BOTH original PATH entry and resolved path + // Original path tells us HOW user accessed it (context) + // Resolved path tells us WHERE it actually lives (content) + const originalSource = detectSourceFromPath(claudePath); + const resolvedSource = detectSourceFromPath(resolvedPath); + + // Prioritize original PATH entry for context (e.g., bun vs npm access) + // Fall back to resolved path for accurate location detection + const source = originalSource !== 'PATH' ? originalSource : resolvedSource; + + return { + path: resolvedPath, + source: source + }; + } + } catch (e) { + // Command failed (claude not in PATH) } - - // Try to get Homebrew prefix via command first - let brewPrefix = null; + return null; +} + +/** + * Detect installation source from resolved path + * Uses concrete path patterns, no assumptions + * @param {string} resolvedPath - The resolved path to cli.js + * @returns {string} Installation method/source + */ +function detectSourceFromPath(resolvedPath) { + const normalized = resolvedPath.toLowerCase(); + const path = require('path'); + + // Use path.normalize() for proper cross-platform path handling + const normalizedPath = path.normalize(resolvedPath).toLowerCase(); + + // Bun: ~/.bun/bin/claude -> ../node_modules/@anthropic-ai/claude-code/cli.js + // Works on Windows too: C:\Users\[user]\.bun\bin\claude + if (normalizedPath.includes('.bun') && normalizedPath.includes('bin') || + (normalizedPath.includes('node_modules') && normalizedPath.includes('.bun'))) { + return 'Bun'; + } + + // Homebrew cask: hashed directories like .claude-code-2DTsDk1V (NOT npm installations) + // Must check before general Homebrew paths to distinguish from npm-through-Homebrew + if (normalizedPath.includes('@anthropic-ai') && normalizedPath.includes('.claude-code-')) { + return 'Homebrew'; + } + + // npm: clean claude-code directory (even through Homebrew's npm) + // Windows: %APPDATA%\npm\node_modules\@anthropic-ai\claude-code + if (normalizedPath.includes('node_modules') && normalizedPath.includes('@anthropic-ai') && normalizedPath.includes('claude-code') && + !normalizedPath.includes('.claude-code-')) { + return 'npm'; + } + + // Windows-specific detection (detect by path patterns, not current platform) + if (normalizedPath.includes('appdata') || normalizedPath.includes('program files') || normalizedPath.endsWith('.exe')) { + // Windows npm + if (normalizedPath.includes('appdata') && normalizedPath.includes('npm') && normalizedPath.includes('node_modules')) { + return 'npm'; + } + + // Windows native installer (any location ending with claude.exe) + if (normalizedPath.endsWith('claude.exe')) { + return 'native installer'; + } + + // Windows native installer in AppData + if (normalizedPath.includes('appdata') && normalizedPath.includes('claude')) { + return 'native installer'; + } + + // Windows native installer in Program Files + if (normalizedPath.includes('program files') && normalizedPath.includes('claude')) { + return 'native installer'; + } + } + + // Homebrew general paths (for non-npm installations like Cellar binaries) + // Apple Silicon: /opt/homebrew/bin/claude + // Intel Mac: /usr/local/bin/claude (ONLY on macOS, not Linux) + // Linux Homebrew: /home/linuxbrew/.linuxbrew/bin/claude or ~/.linuxbrew/bin/claude + if (normalizedPath.includes('opt/homebrew') || + normalizedPath.includes('usr/local/homebrew') || + normalizedPath.includes('home/linuxbrew') || + normalizedPath.includes('.linuxbrew') || + normalizedPath.includes('.homebrew') || + normalizedPath.includes('cellar') || + normalizedPath.includes('caskroom') || + (normalizedPath.includes('usr/local/bin/claude') && process.platform === 'darwin')) { // Intel Mac Homebrew default only on macOS + return 'Homebrew'; + } + + // Native installer: standard Unix locations and ~/.local/bin + // /usr/local/bin/claude on Linux should be native installer + if (normalizedPath.includes('.local') && normalizedPath.includes('bin') || + normalizedPath.includes('.local') && normalizedPath.includes('share') && normalizedPath.includes('claude') || + (normalizedPath.includes('usr/local/bin/claude') && process.platform === 'linux')) { // Linux native installer + return 'native installer'; + } + + // Default: we found it in PATH but can't determine source + return 'PATH'; +} + +/** + * Find path to Bun globally installed Claude Code CLI + * FIX: Check bun's bin directory, not non-existent modules directory + * @returns {string|null} Path to cli.js or null if not found + */ +function findBunGlobalCliPath() { + // First check if bun command exists (cross-platform) try { - brewPrefix = execSync('brew --prefix 2>/dev/null', { encoding: 'utf8' }).trim(); + const bunCheckCommand = process.platform === 'win32' ? 'where bun' : 'which bun'; + execSync(bunCheckCommand, { encoding: 'utf8' }); } catch (e) { - // brew command not in PATH, try standard locations + return null; // bun not installed } - - // Standard Homebrew locations to check - const possiblePrefixes = []; - if (brewPrefix) { - possiblePrefixes.push(brewPrefix); + + // Check bun's binary directory (works on both Unix and Windows) + const bunBin = path.join(os.homedir(), '.bun', 'bin', 'claude'); + const resolved = resolvePathSafe(bunBin); + + if (resolved && resolved.endsWith('cli.js') && fs.existsSync(resolved)) { + return resolved; } - - // Add standard locations based on platform - if (process.platform === 'darwin') { - // macOS: Intel (/usr/local) or Apple Silicon (/opt/homebrew) - possiblePrefixes.push('/opt/homebrew', '/usr/local'); - } else if (process.platform === 'linux') { - // Linux: system-wide or user installation - const homeDir = os.homedir(); - possiblePrefixes.push('/home/linuxbrew/.linuxbrew', path.join(homeDir, '.linuxbrew')); + + return null; +} + +/** + * Find path to Homebrew installed Claude Code CLI + * FIX: Handle hashed directory names like .claude-code-[hash] + * @returns {string|null} Path to cli.js or binary, or null if not found + */ +function findHomebrewCliPath() { + if (process.platform !== 'darwin' && process.platform !== 'linux') { + return null; } - - // Check each possible prefix + + const possiblePrefixes = [ + '/opt/homebrew', + '/usr/local', + path.join(os.homedir(), '.linuxbrew'), + path.join(os.homedir(), '.homebrew') + ].filter(fs.existsSync); + for (const prefix of possiblePrefixes) { - if (!fs.existsSync(prefix)) { - continue; - } - - // Homebrew installs claude-code as a Cask (binary) in Caskroom - const caskroomPath = path.join(prefix, 'Caskroom', 'claude-code'); - if (fs.existsSync(caskroomPath)) { - const found = findLatestVersionBinary(caskroomPath, 'claude'); - if (found) return found; + // Check for binary symlink first (most reliable) + const binPath = path.join(prefix, 'bin', 'claude'); + const resolved = resolvePathSafe(binPath); + if (resolved && fs.existsSync(resolved)) { + return resolved; } - - // Also check Cellar (for formula installations, though claude-code is usually a Cask) - const cellarPath = path.join(prefix, 'Cellar', 'claude-code'); - if (fs.existsSync(cellarPath)) { - // Cellar has different structure - check for cli.js in libexec - const entries = fs.readdirSync(cellarPath); - if (entries.length > 0) { - const sorted = entries.sort((a, b) => compareVersions(b, a)); - const latestVersion = sorted[0]; - const cliPath = path.join(cellarPath, latestVersion, 'libexec', 'lib', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'); - if (fs.existsSync(cliPath)) { - return cliPath; + + // Fallback: check for hashed directories in node_modules + const nodeModulesPath = path.join(prefix, 'lib', 'node_modules', '@anthropic-ai'); + if (fs.existsSync(nodeModulesPath)) { + // Look for both claude-code and .claude-code-[hash] + const entries = fs.readdirSync(nodeModulesPath); + for (const entry of entries) { + if (entry === 'claude-code' || entry.startsWith('.claude-code-')) { + const cliPath = path.join(nodeModulesPath, entry, 'cli.js'); + if (fs.existsSync(cliPath)) { + return cliPath; + } } } } - - // Check bin directory for symlink (most reliable) - const binPath = path.join(prefix, 'bin', 'claude'); - const resolvedBinPath = resolvePathSafe(binPath); - if (resolvedBinPath) return resolvedBinPath; } - + return null; } @@ -251,22 +380,31 @@ function findLatestVersionBinary(versionsDir, binaryName = null) { /** * Find path to globally installed Claude Code CLI - * Checks multiple installation methods in order of preference: - * 1. npm global (highest priority) - * 2. Homebrew - * 3. Native installer + * Priority: HAPPY_CLAUDE_PATH env var > PATH > npm > Bun > Homebrew > Native * @returns {{path: string, source: string}|null} Path and source, or null if not found */ function findGlobalClaudeCliPath() { - // Check npm global first (highest priority) + // 1. Environment variable (explicit override) + const envPath = process.env.HAPPY_CLAUDE_PATH; + if (envPath && fs.existsSync(envPath)) { + const resolved = resolvePathSafe(envPath) || envPath; + return { path: resolved, source: 'HAPPY_CLAUDE_PATH' }; + } + + // 2. Check PATH (respects user's shell config) + const pathResult = findClaudeInPath(); + if (pathResult) return pathResult; + + // 3. Fall back to package manager detection const npmPath = findNpmGlobalCliPath(); if (npmPath) return { path: npmPath, source: 'npm' }; - // Check Homebrew installation + const bunPath = findBunGlobalCliPath(); + if (bunPath) return { path: bunPath, source: 'Bun' }; + const homebrewPath = findHomebrewCliPath(); if (homebrewPath) return { path: homebrewPath, source: 'Homebrew' }; - // Check native installer const nativePath = findNativeInstallerCliPath(); if (nativePath) return { path: nativePath, source: 'native installer' }; @@ -367,7 +505,10 @@ function runClaudeCli(cliPath) { module.exports = { findGlobalClaudeCliPath, + findClaudeInPath, + detectSourceFromPath, findNpmGlobalCliPath, + findBunGlobalCliPath, findHomebrewCliPath, findNativeInstallerCliPath, getVersion, diff --git a/scripts/claude_version_utils.test.ts b/scripts/claude_version_utils.test.ts new file mode 100644 index 00000000..d83e16ad --- /dev/null +++ b/scripts/claude_version_utils.test.ts @@ -0,0 +1,373 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import { + findGlobalClaudeCliPath, + findClaudeInPath, + detectSourceFromPath, + findNpmGlobalCliPath, + findBunGlobalCliPath, + findHomebrewCliPath, + findNativeInstallerCliPath, + getVersion, + compareVersions +} from '../scripts/claude_version_utils.cjs'; + +describe('Claude Version Utils - Cross-Platform Detection', () => { + + describe('detectSourceFromPath', () => { + + describe('npm installations', () => { + it('should detect npm global installation on macOS/Linux', () => { + const result = detectSourceFromPath('/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js'); + expect(result).toBe('npm'); + }); + + it('should detect npm global installation on Windows with forward slashes', () => { + const result = detectSourceFromPath('C:/Users/test/AppData/Roaming/npm/node_modules/@anthropic-ai/claude-code/cli.js'); + expect(result).toBe('npm'); + }); + + it('should detect npm global installation on Windows with backslashes', () => { + const result = detectSourceFromPath('C:\\Users\\test\\AppData\\Roaming\\npm\\node_modules\\@anthropic-ai\\claude-code\\cli.js'); + expect(result).toBe('npm'); + }); + + it('should detect npm with different scoped packages', () => { + const result = detectSourceFromPath('C:/Users/test/AppData/Roaming/npm/node_modules/@babel/core/cli.js'); + expect(result).toBe('npm'); + }); + + it('should detect npm through Homebrew', () => { + const result = detectSourceFromPath('/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js'); + expect(result).toBe('npm'); + }); + + it('should NOT detect Homebrew cask as npm', () => { + const result = detectSourceFromPath('/opt/homebrew/lib/node_modules/@anthropic-ai/.claude-code-2DTsDk1V/cli.js'); + expect(result).toBe('Homebrew'); + }); + }); + + describe('Bun installations', () => { + it('should detect Bun global installation on Unix', () => { + const result = detectSourceFromPath('/Users/test/.bun/bin/claude'); + expect(result).toBe('Bun'); + }); + + it('should detect Bun global installation on Windows', () => { + const result = detectSourceFromPath('C:/Users/test/.bun/bin/claude'); + expect(result).toBe('Bun'); + }); + + it('should detect Bun with @ symbol in username', () => { + const result = detectSourceFromPath('C:/Users/@specialuser/.bun/bin/claude'); + expect(result).toBe('Bun'); + }); + + it('should detect Bun in node_modules context', () => { + const result = detectSourceFromPath('/Users/test/.bun/install/global/node_modules/@anthropic-ai/claude-code/cli.js'); + expect(result).toBe('Bun'); + }); + }); + + describe('Homebrew installations', () => { + it('should detect Homebrew on Apple Silicon macOS', () => { + const result = detectSourceFromPath('/opt/homebrew/bin/claude'); + expect(result).toBe('Homebrew'); + }); + + it('should detect Homebrew on Intel macOS', () => { + // Mock macOS platform + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); + + const result = detectSourceFromPath('/usr/local/bin/claude'); + expect(result).toBe('Homebrew'); + + // Restore original platform + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + }); + + it('should detect native installer on Linux for /usr/local/bin/claude', () => { + // Mock Linux platform + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + + const result = detectSourceFromPath('/usr/local/bin/claude'); + expect(result).toBe('native installer'); + + // Restore original platform + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + }); + + it('should detect Homebrew on Linux', () => { + const result = detectSourceFromPath('/home/linuxbrew/.linuxbrew/bin/claude'); + expect(result).toBe('Homebrew'); + }); + + it('should detect Homebrew user installation', () => { + const result = detectSourceFromPath('/Users/test/.linuxbrew/bin/claude'); + expect(result).toBe('Homebrew'); + }); + + it('should detect Homebrew cask with hashed directory', () => { + const result = detectSourceFromPath('/opt/homebrew/lib/node_modules/@anthropic-ai/.claude-code-2DTsDk1V/cli.js'); + expect(result).toBe('Homebrew'); + }); + + it('should detect Homebrew Cellar installation', () => { + const result = detectSourceFromPath('/opt/homebrew/Cellar/claude-code/1.0.0/bin/claude'); + expect(result).toBe('Homebrew'); + }); + }); + + describe('Native installer installations', () => { + it('should detect native installer on Unix ~/.local', () => { + const result = detectSourceFromPath('/Users/test/.local/bin/claude'); + expect(result).toBe('native installer'); + }); + + it('should detect native installer with versioned structure', () => { + const result = detectSourceFromPath('/Users/test/.local/share/claude/versions/2.0.69/claude'); + expect(result).toBe('native installer'); + }); + + it('should detect native installer on Windows Program Files', () => { + const result = detectSourceFromPath('C:/Program Files/Claude/claude.exe'); + expect(result).toBe('native installer'); + }); + + it('should detect native installer on Windows AppData', () => { + const result = detectSourceFromPath('C:/Users/test/AppData/Local/Claude/claude.exe'); + expect(result).toBe('native installer'); + }); + + it('should detect native installer on Windows custom location', () => { + const result = detectSourceFromPath('E:/Tools/Claude/claude.exe'); + expect(result).toBe('native installer'); + }); + + it('should detect native installer on Windows D: drive', () => { + const result = detectSourceFromPath('D:/Development/Claude/claude.exe'); + expect(result).toBe('native installer'); + }); + + it('should detect native installer in user profile', () => { + const result = detectSourceFromPath('C:/Users/test/.claude/claude.exe'); + expect(result).toBe('native installer'); + }); + }); + + describe('Edge cases and special characters', () => { + it('should handle @ symbols in paths correctly', () => { + const result = detectSourceFromPath('/Users/@developer/test/node_modules/@anthropic-ai/claude-code/cli.js'); + expect(result).toBe('npm'); + }); + + it('should handle case sensitivity variations on Windows', () => { + const result = detectSourceFromPath('C:/USERS/TEST/APPDATA/ROAMING/NPM/NODE_MODULES/@ANTHROPIC-AI/CLAUDE-CODE/CLI.JS'); + expect(result).toBe('npm'); + }); + + it('should return PATH for unrecognized paths', () => { + const result = detectSourceFromPath('/some/random/path/claude'); + expect(result).toBe('PATH'); + }); + + it('should handle empty paths', () => { + const result = detectSourceFromPath(''); + expect(result).toBe('PATH'); + }); + + it('should handle relative paths', () => { + const result = detectSourceFromPath('./local/bin/claude'); + expect(result).toBe('PATH'); + }); + }); + }); + + describe('Cross-platform compatibility', () => { + it('should handle both forward and backward slashes', () => { + const forward = detectSourceFromPath('C:/Users/test/AppData/Local/Claude/claude.exe'); + const backward = detectSourceFromPath('C:\\Users\\test\\AppData\\Local\\Claude\\claude.exe'); + + expect(forward).toBe('native installer'); + expect(backward).toBe('native installer'); + }); + + it('should handle Windows drive letters', () => { + const drives = ['C:', 'D:', 'E:', 'Z:']; + drives.forEach(drive => { + const result = detectSourceFromPath(`${drive}/Program Files/Claude/claude.exe`); + expect(result).toBe('native installer'); + }); + }); + + it('should handle Unix-style absolute paths', () => { + const unixPaths = [ + '/usr/local/bin/claude', + '/opt/homebrew/bin/claude', + '/home/user/.local/bin/claude' + ]; + + unixPaths.forEach(path => { + const result = detectSourceFromPath(path); + expect(['Homebrew', 'native installer']).toContain(result); + }); + }); + }); + + describe('Version comparison', () => { + it('should compare versions correctly', () => { + expect(compareVersions('2.0.69', '2.0.68')).toBe(1); + expect(compareVersions('2.0.68', '2.0.69')).toBe(-1); + expect(compareVersions('2.0.69', '2.0.69')).toBe(0); + expect(compareVersions('2.1.0', '2.0.69')).toBe(1); + expect(compareVersions('1.0.0', '2.0.0')).toBe(-1); + }); + + it('should handle malformed versions gracefully', () => { + expect(() => compareVersions('', '2.0.0')).not.toThrow(); + expect(() => compareVersions('invalid', '2.0.0')).not.toThrow(); + expect(() => compareVersions('2.0.0', '')).not.toThrow(); + }); + }); + + describe('Integration scenarios', () => { + it('should handle multiple installations scenario', () => { + const scenarios = [ + { path: '/Users/test/.bun/bin/claude', expected: 'Bun' }, + { path: '/opt/homebrew/bin/claude', expected: 'Homebrew' }, + { path: '/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js', expected: 'npm' }, + { path: 'C:/Program Files/Claude/claude.exe', expected: 'native installer' } + ]; + + scenarios.forEach(({ path, expected }) => { + const result = detectSourceFromPath(path); + expect(result).toBe(expected); + }); + }); + + it('should maintain 100% success rate on all standard installation patterns', () => { + const standardPatterns = [ + // npm (most common) + { path: '/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js', expected: 'npm' }, + { path: 'C:/Users/test/AppData/Roaming/npm/node_modules/@anthropic-ai/claude-code/cli.js', expected: 'npm' }, + + // bun (second most common) + { path: '/Users/test/.bun/bin/claude', expected: 'Bun' }, + { path: 'C:/Users/test/.bun/bin/claude', expected: 'Bun' }, + + // homebrew (macOS and Linux) + { path: '/opt/homebrew/bin/claude', expected: 'Homebrew' }, + { path: '/home/linuxbrew/.linuxbrew/bin/claude', expected: 'Homebrew' }, + { path: '/Users/test/.linuxbrew/bin/claude', expected: 'Homebrew' }, // LinuxBrew user installation + + // native installers + { path: 'C:/Program Files/Claude/claude.exe', expected: 'native installer' }, + { path: 'C:/Users/test/AppData/Local/Claude/claude.exe', expected: 'native installer' }, + { path: '/Users/test/.local/bin/claude', expected: 'native installer' } + ]; + + let passed = 0; + standardPatterns.forEach(({ path, expected }) => { + const result = detectSourceFromPath(path); + if (result === expected) passed++; + }); + + expect(passed).toBe(standardPatterns.length); + expect(passed / standardPatterns.length).toBe(1); // 100% success rate + }); + + it('should handle platform-specific /usr/local/bin/claude correctly', () => { + const originalPlatform = process.platform; + + // Test on macOS (should be Homebrew) + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); + const macosResult = detectSourceFromPath('/usr/local/bin/claude'); + expect(macosResult).toBe('Homebrew'); + + // Test on Linux (should be native installer) + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + const linuxResult = detectSourceFromPath('/usr/local/bin/claude'); + expect(linuxResult).toBe('native installer'); + + // Test on Windows (should fallback to PATH) + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + const windowsResult = detectSourceFromPath('/usr/local/bin/claude'); + expect(windowsResult).toBe('PATH'); + + // Restore original platform + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + }); + }); + + describe('Real-world edge cases', () => { + it('should handle complex user scenarios', () => { + const edgeCases = [ + // User with npm aliased to bun + { path: '/Users/test/node_modules/@anthropic-ai/claude-code/cli.js', expected: 'npm' }, + + // Multiple package managers + { path: '/Users/test/.bun/bin/claude', expected: 'Bun' }, + { path: '/opt/homebrew/bin/claude', expected: 'Homebrew' }, + + // Custom installations + { path: '/opt/custom/claude/bin/claude', expected: 'PATH' }, + { path: '/usr/local/custom/bin/claude', expected: 'PATH' } + ]; + + edgeCases.forEach(({ path, expected }) => { + const result = detectSourceFromPath(path); + expect(result).toBe(expected); + }); + }); + + it('should handle path traversal and normalization', () => { + const pathNormalizationTests = [ + { input: '/opt/homebrew/bin/../lib/claude', expected: 'Homebrew' }, + { input: '/Users/test/.bun/bin/./claude', expected: 'Bun' }, + { input: 'C:/Users/test/../test/AppData/Local/Claude/claude.exe', expected: 'native installer' } + ]; + + pathNormalizationTests.forEach(({ input, expected }) => { + const result = detectSourceFromPath(input); + expect(result).toBe(expected); + }); + }); + }); +}); + +describe('HAPPY_CLAUDE_PATH env var', () => { + const testClaudePath = '/tmp/test-claude-path'; + + beforeEach(() => { + // Create mock executable + fs.writeFileSync(testClaudePath, '#!/bin/bash\necho "mock"'); + fs.chmodSync(testClaudePath, 0o755); + }); + + afterEach(() => { + if (fs.existsSync(testClaudePath)) fs.unlinkSync(testClaudePath); + delete process.env.HAPPY_CLAUDE_PATH; + }); + + it('should use HAPPY_CLAUDE_PATH when set', () => { + process.env.HAPPY_CLAUDE_PATH = testClaudePath; + const result = findGlobalClaudeCliPath(); + expect(result?.source).toBe('HAPPY_CLAUDE_PATH'); + // Use realpathSync to handle macOS symlink (/tmp -> /private/tmp) + expect(fs.realpathSync(result?.path ?? '')).toBe(fs.realpathSync(testClaudePath)); + }); + + it('should fall back to auto-discovery when env var not set', () => { + const result = findGlobalClaudeCliPath(); + expect(result?.source).not.toBe('HAPPY_CLAUDE_PATH'); + }); + + it('should ignore env var if path does not exist', () => { + process.env.HAPPY_CLAUDE_PATH = '/nonexistent/path/claude'; + const result = findGlobalClaudeCliPath(); + expect(result?.source).not.toBe('HAPPY_CLAUDE_PATH'); + }); +}); \ No newline at end of file diff --git a/scripts/env-wrapper.cjs b/scripts/env-wrapper.cjs new file mode 100755 index 00000000..2ec562af --- /dev/null +++ b/scripts/env-wrapper.cjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +/** + * Cross-platform environment wrapper for happy CLI + * Sets HAPPY_HOME_DIR and provides visual feedback + * + * Usage: node scripts/env-wrapper.js [...args] + * + * Variants: + * - stable: Production-ready version using ~/.happy/ + * - dev: Development version using ~/.happy-dev/ + * + * Examples: + * node scripts/env-wrapper.js stable daemon start + * node scripts/env-wrapper.js dev auth login + */ + +const { spawn } = require('child_process'); +const path = require('path'); +const os = require('os'); +const fs = require('fs'); + +const VARIANTS = { + stable: { + homeDir: path.join(os.homedir(), '.happy'), + color: '\x1b[32m', // Green + label: 'āœ… STABLE', + serverUrl: process.env.HAPPY_SERVER_URL || 'https://api.cluster-fluster.com' + }, + dev: { + homeDir: path.join(os.homedir(), '.happy-dev'), + color: '\x1b[33m', // Yellow + label: 'šŸ”§ DEV', + serverUrl: process.env.HAPPY_SERVER_URL || 'https://api.cluster-fluster.com' + } +}; + +const variant = process.argv[2]; +const command = process.argv[3]; +const args = process.argv.slice(4); + +if (!variant || !VARIANTS[variant]) { + console.error('Usage: node scripts/env-wrapper.js [...args]'); + console.error(''); + console.error('Variants:'); + console.error(' stable - Production-ready version (data: ~/.happy/)'); + console.error(' dev - Development version (data: ~/.happy-dev/)'); + console.error(''); + console.error('Examples:'); + console.error(' node scripts/env-wrapper.js stable daemon start'); + console.error(' node scripts/env-wrapper.js dev auth login'); + process.exit(1); +} + +const config = VARIANTS[variant]; + +// Create home directory if it doesn't exist +if (!fs.existsSync(config.homeDir)) { + fs.mkdirSync(config.homeDir, { recursive: true }); +} + +// Visual feedback +console.log(`${config.color}${config.label}\x1b[0m Happy CLI (data: ${config.homeDir})`); + +// Set environment and execute command +const env = { + ...process.env, + HAPPY_HOME_DIR: config.homeDir, + HAPPY_SERVER_URL: config.serverUrl, + HAPPY_VARIANT: variant, // For internal validation +}; + +const binPath = path.join(__dirname, '..', 'bin', 'happy.mjs'); +const proc = spawn('node', [binPath, command, ...args], { + env, + stdio: 'inherit', + shell: process.platform === 'win32' +}); + +proc.on('exit', (code) => process.exit(code || 0)); diff --git a/scripts/link-dev.cjs b/scripts/link-dev.cjs new file mode 100644 index 00000000..69eb92a5 --- /dev/null +++ b/scripts/link-dev.cjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node +/** + * link-dev.cjs - Create symlink for happy-dev only + * + * This script creates a symlink for the happy-dev command pointing to the local + * development version, while leaving the stable npm version of `happy` untouched. + * + * Usage: yarn link:dev + * + * What it does: + * 1. Finds the global npm bin directory + * 2. Creates/updates a symlink: happy-dev -> ./bin/happy-dev.mjs + * + * To undo: yarn unlink:dev + */ + +const { execFileSync } = require('child_process'); +const { join, dirname } = require('path'); +const fs = require('fs'); + +const projectRoot = dirname(__dirname); +const binSource = join(projectRoot, 'bin', 'happy-dev.mjs'); + +// Get the action from command line args +const action = process.argv[2] || 'link'; + +function getGlobalBinDir() { + // Try npm global bin first using execFileSync (safer than execSync) + try { + const npmBin = execFileSync('npm', ['bin', '-g'], { encoding: 'utf8' }).trim(); + if (fs.existsSync(npmBin)) { + return npmBin; + } + } catch (e) { + // Fall through to alternatives + } + + // Common locations by platform + if (process.platform === 'darwin') { + // macOS with Homebrew Node (Apple Silicon) + const homebrewBin = '/opt/homebrew/bin'; + if (fs.existsSync(homebrewBin)) { + return homebrewBin; + } + // Intel Mac Homebrew + const homebrewUsrBin = '/usr/local/bin'; + if (fs.existsSync(homebrewUsrBin)) { + return homebrewUsrBin; + } + } + + // Fallback to /usr/local/bin + return '/usr/local/bin'; +} + +function link() { + const globalBin = getGlobalBinDir(); + const binTarget = join(globalBin, 'happy-dev'); + + console.log('Creating symlink for happy-dev...'); + console.log(` Source: ${binSource}`); + console.log(` Target: ${binTarget}`); + + // Check if source exists + if (!fs.existsSync(binSource)) { + console.error(`\nāŒ Error: ${binSource} does not exist.`); + console.error(" Run 'yarn build' first to compile the project."); + process.exit(1); + } + + // Remove existing symlink or file + try { + const stat = fs.lstatSync(binTarget); + if (stat.isSymbolicLink() || stat.isFile()) { + fs.unlinkSync(binTarget); + console.log(` Removed existing: ${binTarget}`); + } + } catch (e) { + // File doesn't exist, that's fine + } + + // Create the symlink + try { + fs.symlinkSync(binSource, binTarget); + console.log('\nāœ… Successfully linked happy-dev to local development version'); + console.log('\nNow you can use:'); + console.log(' happy → stable npm version (unchanged)'); + console.log(' happy-dev → local development version'); + console.log('\nTo undo: yarn unlink:dev'); + } catch (e) { + if (e.code === 'EACCES') { + console.error('\nāŒ Permission denied. Try running with sudo:'); + console.error(' sudo yarn link:dev'); + } else { + console.error(`\nāŒ Error creating symlink: ${e.message}`); + } + process.exit(1); + } +} + +function unlink() { + const globalBin = getGlobalBinDir(); + const binTarget = join(globalBin, 'happy-dev'); + + console.log('Removing happy-dev symlink...'); + + try { + const stat = fs.lstatSync(binTarget); + if (stat.isSymbolicLink()) { + const linkTarget = fs.readlinkSync(binTarget); + if (linkTarget === binSource || linkTarget.includes('happy-cli')) { + fs.unlinkSync(binTarget); + console.log('\nāœ… Removed happy-dev development symlink'); + console.log('\nTo restore npm version: npm install -g happy-coder'); + } else { + console.log(`\nāš ļø happy-dev symlink points elsewhere: ${linkTarget}`); + console.log(' Not removing. Remove manually if needed.'); + } + } else { + console.log(`\nāš ļø ${binTarget} exists but is not a symlink.`); + console.log(' Not removing. This may be the npm-installed version.'); + } + } catch (e) { + if (e.code === 'ENOENT') { + console.log("\nāœ… happy-dev symlink doesn't exist (already removed or never created)"); + } else if (e.code === 'EACCES') { + console.error('\nāŒ Permission denied. Try running with sudo:'); + console.error(' sudo yarn unlink:dev'); + process.exit(1); + } else { + console.error(`\nāŒ Error: ${e.message}`); + process.exit(1); + } + } +} + +// Main +if (action === 'unlink') { + unlink(); +} else { + link(); +} diff --git a/scripts/ripgrep_launcher.cjs b/scripts/ripgrep_launcher.cjs index 648277ab..ab00d9a8 100644 --- a/scripts/ripgrep_launcher.cjs +++ b/scripts/ripgrep_launcher.cjs @@ -3,13 +3,166 @@ /** * Ripgrep runner - executed as a subprocess to run the native module * This file is intentionally written in CommonJS to avoid ESM complexities + * + * Updated with graceful fallback chain for runtime compatibility: + * - Node.js: Try native addon first, fall back to binary + * - Bun: Use binary or system ripgrep directly + * - All runtimes: Cross-platform system detection + * - Fallback: Mock implementation with helpful guidance */ const path = require('path'); +const fs = require('fs'); -// Load the native module from unpacked directory -const modulePath = path.join(__dirname, '..', 'tools', 'unpacked', 'ripgrep.node'); -const ripgrepNative = require(modulePath); +// Runtime detection (minimal, focused) +function detectRuntime() { + if (typeof Bun !== 'undefined') return 'bun'; + if (typeof Deno !== 'undefined') return 'deno'; + if (process?.versions?.bun) return 'bun'; + if (process?.versions?.deno) return 'deno'; + if (process?.versions?.node) return 'node'; + return 'unknown'; +} + +// Find ripgrep in system PATH (cross-platform) +function findSystemRipgrep() { + const { execFileSync } = require('child_process'); + + // Platform-specific commands to find ripgrep + const commands = [ + // Windows: Use where command + process.platform === 'win32' && { cmd: 'where', args: ['rg'] }, + // Unix-like: Use which command + process.platform !== 'win32' && { cmd: 'which', args: ['rg'] } + ].filter(Boolean); + + for (const { cmd, args } of commands) { + try { + const result = execFileSync(cmd, args, { + encoding: 'utf8', + stdio: 'ignore' + }); + + if (result) { + const paths = result.trim().split('\n').filter(Boolean); + if (paths.length > 0) { + return paths[0].trim(); + } + } + } catch { + // Command failed, try next one + continue; + } + } + + // Fallback: Try common installation paths directly + const commonPaths = []; + if (process.platform === 'win32') { + commonPaths.push( + 'C:\\Program Files\\ripgrep\\rg.exe', + 'C:\\Program Files (x86)\\ripgrep\\rg.exe' + ); + } else if (process.platform === 'darwin') { + commonPaths.push( + '/opt/homebrew/bin/rg', + '/usr/local/bin/rg' + ); + } else if (process.platform === 'linux') { + commonPaths.push( + '/usr/bin/rg', + '/usr/local/bin/rg', + '/opt/homebrew/bin/rg' + ); + } + + for (const testPath of commonPaths) { + if (fs.existsSync(testPath)) { + return testPath; + } + } + + return null; +} + +// Create wrapper that mimics native addon interface +function createRipgrepWrapper(binaryPath) { + return { + ripgrepMain: (args) => { + const { spawnSync } = require('child_process'); + const result = spawnSync(binaryPath, args, { + stdio: 'inherit', + cwd: process.cwd() + }); + return result.status || 0; + } + }; +} + +// Create mock that doesn't crash but provides useful feedback +function createMockRipgrep() { + return { + ripgrepMain: (args) => { + if (args.includes('--version')) { + console.log('ripgrep 0.0.0 (mock)'); + return 0; + } + + console.error('Search functionality unavailable without ripgrep'); + console.error('See installation instructions above'); + return 1; + } + }; +} + +// Load ripgrep with graceful fallback chain +function loadRipgrepNative() { + const runtime = detectRuntime(); + const toolsDir = path.join(__dirname, '..', 'tools', 'unpacked'); + const nativePath = path.join(toolsDir, 'ripgrep.node'); + const binaryPath = path.join(toolsDir, 'rg'); + + // Try Node.js native addon first (preserves existing behavior) + if (runtime === 'node') { + try { + return require(nativePath); + } catch (error) { + console.warn('Failed to load ripgrep native addon:', error.message); + console.warn('Falling back to ripgrep binary...'); + // Fall through to binary fallback + } + } + + // Bun or Node.js fallback: Try system ripgrep + const systemRipgrep = findSystemRipgrep(); + if (systemRipgrep) { + console.info(`Using system ripgrep: ${systemRipgrep}`); + return createRipgrepWrapper(systemRipgrep); + } + + // Local binary fallback + if (fs.existsSync(binaryPath)) { + console.info('Using packaged ripgrep binary'); + return createRipgrepWrapper(binaryPath); + } + + // Final fallback: Return mock implementation that provides helpful guidance + console.warn('\nāš ļø ripgrep not available - search functionality limited'); + console.warn('Install ripgrep for full functionality:'); + + if (process.platform === 'win32') { + console.warn(' • Windows: winget install BurntSushi.ripgrep'); + console.warn(' • Or download from: https://github.com/BurntSushi/ripgrep/releases'); + } else { + console.warn(' • macOS/Linux: brew install ripgrep'); + console.warn(' • npm: npm install -g @silentsilas/ripgrep-bin'); + } + console.warn(''); + + return createMockRipgrep(); +} + +// Load ripgrep implementation +const ripgrepImplementation = loadRipgrepNative(); // Get arguments from command line (skip node and script name) const args = process.argv.slice(2); @@ -23,9 +176,9 @@ try { process.exit(1); } -// Run ripgrep +// Run ripgrep using the loaded implementation try { - const exitCode = ripgrepNative.ripgrepMain(parsedArgs); + const exitCode = ripgrepImplementation.ripgrepMain(parsedArgs); process.exit(exitCode); } catch (error) { console.error('Ripgrep error:', error.message); diff --git a/scripts/setup-dev.cjs b/scripts/setup-dev.cjs new file mode 100755 index 00000000..c0b2d18c --- /dev/null +++ b/scripts/setup-dev.cjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +/** + * One-command setup for development environment + * Creates directories, shows next steps + * + * Run: npm run setup:dev + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const STABLE_DIR = path.join(os.homedir(), '.happy'); +const DEV_DIR = path.join(os.homedir(), '.happy-dev'); + +console.log('šŸ”§ Setting up happy-cli development environment...\n'); + +// Create directories +[STABLE_DIR, DEV_DIR].forEach(dir => { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + console.log(`āœ… Created: ${dir}`); + } else { + console.log(`ā„¹ļø Already exists: ${dir}`); + } +}); + +// Create .envrc for direnv users (optional) +const envrcContent = `# Happy CLI environment (for direnv users) +# Automatically sets HAPPY_HOME_DIR based on directory +# +# To use: cd to happy-cli-dev directory, run: direnv allow +export HAPPY_HOME_DIR="$HOME/.happy-dev" +export HAPPY_VARIANT="dev" +`; + +const envrcPath = path.join(__dirname, '..', '.envrc.example'); +if (!fs.existsSync(envrcPath)) { + fs.writeFileSync(envrcPath, envrcContent); + console.log(`āœ… Created: .envrc.example (optional direnv configuration)`); +} else { + console.log(`ā„¹ļø Already exists: .envrc.example`); +} + +console.log('\n✨ Setup complete!\n'); +console.log('šŸ“‹ Next steps:\n'); +console.log('1. Authenticate with stable version:'); +console.log(' npm run stable auth login\n'); +console.log('2. Authenticate with dev version (can use same or different account):'); +console.log(' npm run dev auth login\n'); +console.log('3. Start daemons:'); +console.log(' npm run stable:daemon:start # Stable version'); +console.log(' npm run dev:daemon:start # Dev version\n'); +console.log('4. Check status:'); +console.log(' npm run stable:daemon:status'); +console.log(' npm run dev:daemon:status\n'); +console.log('šŸ’” All commands are in package.json scripts for easy discovery!'); diff --git a/scripts/test-continue-fix.sh b/scripts/test-continue-fix.sh new file mode 100755 index 00000000..f3735511 --- /dev/null +++ b/scripts/test-continue-fix.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "=== Testing --continue Flag Fix ===" +echo + +# Build first +echo "1. Building project..." +npm run build > /dev/null 2>&1 +echo " āœ“ Build complete" +echo + +# Test session finder logic directly +echo "2. Testing session finder with current directory..." +node -e " +const { resolve, join } = require('path'); +const { readdirSync, statSync, readFileSync } = require('fs'); +const { homedir } = require('os'); + +const workingDirectory = process.cwd(); +const projectId = resolve(workingDirectory).replace(/[\\\\\\/\.:]/g, '-'); +const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'); +const projectDir = join(claudeConfigDir, 'projects', projectId); + +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\$/i; + +const files = readdirSync(projectDir) + .filter(f => f.endsWith('.jsonl')) + .map(f => { + const sessionId = f.replace('.jsonl', ''); + if (!uuidPattern.test(sessionId)) return null; + + const sessionFile = join(projectDir, f); + const sessionData = readFileSync(sessionFile, 'utf-8').split('\\n'); + + // Use NEW validation logic (with multi-format support) + const hasGoodMessage = sessionData.some((v) => { + try { + const parsed = JSON.parse(v); + return (typeof parsed.uuid === 'string' && parsed.uuid.length > 0) || + (typeof parsed.messageId === 'string' && parsed.messageId.length > 0) || + (typeof parsed.leafUuid === 'string' && parsed.leafUuid.length > 0); + } catch (e) { + return false; + } + }); + + if (!hasGoodMessage) return null; + + return { + name: f, + sessionId: sessionId, + mtime: statSync(sessionFile).mtime.getTime() + }; + }) + .filter(f => f !== null) + .sort((a, b) => b.mtime - a.mtime); + +console.log('Valid sessions found:', files.length); +if (files.length > 0) { + console.log('Most recent session ID:', files[0].sessionId); +} else { + console.log('ERROR: No valid sessions found'); + process.exit(1); +} +" || { echo " āœ— Session finder test failed"; exit 1; } + +echo " āœ“ Session finder working correctly" +echo + +echo "=== All Checks Passed āœ“ ===" +echo +echo "Fix verified successfully!" +echo +echo "To test --continue in real usage:" +echo " happy --continue \"test continuation\"" +echo +echo "To check logs:" +echo " tail -50 ~/.happy/logs/\$(ls -t ~/.happy/logs/ | head -1) | grep -E '(session|continue|resume)'" diff --git a/src/api/api.test.ts b/src/api/api.test.ts new file mode 100644 index 00000000..ab734325 --- /dev/null +++ b/src/api/api.test.ts @@ -0,0 +1,313 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ApiClient } from './api'; +import axios from 'axios'; +import { connectionState } from '@/utils/serverConnectionErrors'; + +// Use vi.hoisted to ensure mock functions are available when vi.mock factory runs +const { mockPost, mockIsAxiosError } = vi.hoisted(() => ({ + mockPost: vi.fn(), + mockIsAxiosError: vi.fn(() => true) +})); + +vi.mock('axios', () => ({ + default: { + post: mockPost, + isAxiosError: mockIsAxiosError + }, + isAxiosError: mockIsAxiosError +})); + +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn() + } +})); + +// Mock encryption utilities +vi.mock('./encryption', () => ({ + decodeBase64: vi.fn((data: string) => data), + encodeBase64: vi.fn((data: any) => data), + decrypt: vi.fn((data: any) => data), + encrypt: vi.fn((data: any) => data) +})); + +// Mock configuration +vi.mock('./configuration', () => ({ + configuration: { + serverUrl: 'https://api.example.com' + } +})); + +// Mock libsodium encryption +vi.mock('./libsodiumEncryption', () => ({ + libsodiumEncryptForPublicKey: vi.fn((data: any) => new Uint8Array(32)) +})); + +// Global test metadata +const testMetadata = { + path: '/tmp', + host: 'localhost', + homeDir: '/home/user', + happyHomeDir: '/home/user/.happy', + happyLibDir: '/home/user/.happy/lib', + happyToolsDir: '/home/user/.happy/tools' +}; + +const testMachineMetadata = { + host: 'localhost', + platform: 'darwin', + happyCliVersion: '1.0.0', + homeDir: '/home/user', + happyHomeDir: '/home/user/.happy', + happyLibDir: '/home/user/.happy/lib' +}; + +describe('Api server error handling', () => { + let api: ApiClient; + + beforeEach(async () => { + vi.clearAllMocks(); + connectionState.reset(); // Reset offline state between tests + + // Create a mock credential + const mockCredential = { + token: 'fake-token', + encryption: { + type: 'legacy' as const, + secret: new Uint8Array(32) + } + }; + + api = await ApiClient.create(mockCredential); + }); + + describe('getOrCreateSession', () => { + it('should return null when Happy server is unreachable (ECONNREFUSED)', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Mock axios to throw connection refused error + mockPost.mockRejectedValue({ code: 'ECONNREFUSED' }); + + const result = await api.getOrCreateSession({ + tag: 'test-tag', + metadata: testMetadata, + state: null + }); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('āš ļø Happy server unreachable') + ); + + consoleSpy.mockRestore(); + }); + + it('should return null when Happy server cannot be found (ENOTFOUND)', async () => { + connectionState.reset(); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Mock axios to throw DNS resolution error + mockPost.mockRejectedValue({ code: 'ENOTFOUND' }); + + const result = await api.getOrCreateSession({ + tag: 'test-tag', + metadata: testMetadata, + state: null + }); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('āš ļø Happy server unreachable') + ); + + consoleSpy.mockRestore(); + }); + + it('should return null when Happy server times out (ETIMEDOUT)', async () => { + connectionState.reset(); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Mock axios to throw timeout error + mockPost.mockRejectedValue({ code: 'ETIMEDOUT' }); + + const result = await api.getOrCreateSession({ + tag: 'test-tag', + metadata: testMetadata, + state: null + }); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('āš ļø Happy server unreachable') + ); + + consoleSpy.mockRestore(); + }); + + it('should return null when session endpoint returns 404', async () => { + connectionState.reset(); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Mock axios to return 404 + mockPost.mockRejectedValue({ + response: { status: 404 }, + isAxiosError: true + }); + + const result = await api.getOrCreateSession({ + tag: 'test-tag', + metadata: testMetadata, + state: null + }); + + expect(result).toBeNull(); + // New unified format via connectionState.fail() + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('āš ļø Happy server unreachable') + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Session creation failed: 404') + ); + + consoleSpy.mockRestore(); + }); + + it('should return null when server returns 500 Internal Server Error', async () => { + connectionState.reset(); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Mock axios to return 500 error + mockPost.mockRejectedValue({ + response: { status: 500 }, + isAxiosError: true + }); + + const result = await api.getOrCreateSession({ + tag: 'test-tag', + metadata: testMetadata, + state: null + }); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('āš ļø Happy server unreachable') + ); + consoleSpy.mockRestore(); + }); + + it('should return null when server returns 503 Service Unavailable', async () => { + connectionState.reset(); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Mock axios to return 503 error + mockPost.mockRejectedValue({ + response: { status: 503 }, + isAxiosError: true + }); + + const result = await api.getOrCreateSession({ + tag: 'test-tag', + metadata: testMetadata, + state: null + }); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('āš ļø Happy server unreachable') + ); + consoleSpy.mockRestore(); + }); + + it('should re-throw non-connection errors', async () => { + // Mock axios to throw a different type of error (e.g., authentication error) + const authError = new Error('Invalid API key'); + (authError as any).code = 'UNAUTHORIZED'; + mockPost.mockRejectedValue(authError); + + await expect( + api.getOrCreateSession({ tag: 'test-tag', metadata: testMetadata, state: null }) + ).rejects.toThrow('Failed to get or create session: Invalid API key'); + + // Should not show the offline mode message + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + expect(consoleSpy).not.toHaveBeenCalledWith( + expect.stringContaining('āš ļø Happy server unreachable') + ); + consoleSpy.mockRestore(); + }); + }); + + describe('getOrCreateMachine', () => { + it('should return minimal machine object when server is unreachable (ECONNREFUSED)', async () => { + connectionState.reset(); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Mock axios to throw connection refused error + mockPost.mockRejectedValue({ code: 'ECONNREFUSED' }); + + const result = await api.getOrCreateMachine({ + machineId: 'test-machine', + metadata: testMachineMetadata, + daemonState: { + status: 'running', + pid: 1234 + } + }); + + expect(result).toEqual({ + id: 'test-machine', + encryptionKey: expect.any(Uint8Array), + encryptionVariant: 'legacy', + metadata: testMachineMetadata, + metadataVersion: 0, + daemonState: { + status: 'running', + pid: 1234 + }, + daemonStateVersion: 0, + }); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('āš ļø Happy server unreachable') + ); + + consoleSpy.mockRestore(); + }); + + it('should return minimal machine object when server endpoint returns 404', async () => { + connectionState.reset(); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Mock axios to return 404 + mockPost.mockRejectedValue({ + response: { status: 404 }, + isAxiosError: true + }); + + const result = await api.getOrCreateMachine({ + machineId: 'test-machine', + metadata: testMachineMetadata + }); + + expect(result).toEqual({ + id: 'test-machine', + encryptionKey: expect.any(Uint8Array), + encryptionVariant: 'legacy', + metadata: testMachineMetadata, + metadataVersion: 0, + daemonState: null, + daemonStateVersion: 0, + }); + + // New unified format via connectionState.fail() + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('āš ļø Happy server unreachable') + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Machine registration failed: 404') + ); + + consoleSpy.mockRestore(); + }); + }); +}); \ No newline at end of file diff --git a/src/api/api.ts b/src/api/api.ts index 7de458e3..fc381180 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -8,6 +8,7 @@ import { PushNotificationClient } from './pushNotifications'; import { configuration } from '@/configuration'; import chalk from 'chalk'; import { Credentials } from '@/persistence'; +import { connectionState, isNetworkError } from '@/utils/serverConnectionErrors'; export class ApiClient { @@ -30,7 +31,7 @@ export class ApiClient { tag: string, metadata: Metadata, state: AgentState | null - }): Promise { + }): Promise { // Resolve encryption key let dataEncryptionKey: Uint8Array | null = null; @@ -88,6 +89,49 @@ export class ApiClient { return session; } catch (error) { logger.debug('[API] [ERROR] Failed to get or create session:', error); + + // Check if it's a connection error + if (error && typeof error === 'object' && 'code' in error) { + const errorCode = (error as any).code; + if (isNetworkError(errorCode)) { + connectionState.fail({ + operation: 'Session creation', + caller: 'api.getOrCreateSession', + errorCode, + url: `${configuration.serverUrl}/v1/sessions` + }); + return null; + } + } + + // Handle 404 gracefully - server endpoint may not be available yet + const is404Error = ( + (axios.isAxiosError(error) && error.response?.status === 404) || + (error && typeof error === 'object' && 'response' in error && (error as any).response?.status === 404) + ); + if (is404Error) { + connectionState.fail({ + operation: 'Session creation', + errorCode: '404', + url: `${configuration.serverUrl}/v1/sessions` + }); + return null; + } + + // Handle 5xx server errors - use offline mode with auto-reconnect + if (axios.isAxiosError(error) && error.response?.status) { + const status = error.response.status; + if (status >= 500) { + connectionState.fail({ + operation: 'Session creation', + errorCode: String(status), + url: `${configuration.serverUrl}/v1/sessions`, + details: ['Server encountered an error, will retry automatically'] + }); + return null; + } + } + throw new Error(`Failed to get or create session: ${error instanceof Error ? error.message : 'Unknown error'}`); } } @@ -120,44 +164,113 @@ export class ApiClient { encryptionVariant = 'legacy'; } + // Helper to create minimal machine object for offline mode (DRY) + const createMinimalMachine = (): Machine => ({ + id: opts.machineId, + encryptionKey: encryptionKey, + encryptionVariant: encryptionVariant, + metadata: opts.metadata, + metadataVersion: 0, + daemonState: opts.daemonState || null, + daemonStateVersion: 0, + }); + // Create machine - const response = await axios.post( - `${configuration.serverUrl}/v1/machines`, - { - id: opts.machineId, - metadata: encodeBase64(encrypt(encryptionKey, encryptionVariant, opts.metadata)), - daemonState: opts.daemonState ? encodeBase64(encrypt(encryptionKey, encryptionVariant, opts.daemonState)) : undefined, - dataEncryptionKey: dataEncryptionKey ? encodeBase64(dataEncryptionKey) : undefined - }, - { - headers: { - 'Authorization': `Bearer ${this.credential.token}`, - 'Content-Type': 'application/json' + try { + const response = await axios.post( + `${configuration.serverUrl}/v1/machines`, + { + id: opts.machineId, + metadata: encodeBase64(encrypt(encryptionKey, encryptionVariant, opts.metadata)), + daemonState: opts.daemonState ? encodeBase64(encrypt(encryptionKey, encryptionVariant, opts.daemonState)) : undefined, + dataEncryptionKey: dataEncryptionKey ? encodeBase64(dataEncryptionKey) : undefined }, - timeout: 60000 // 1 minute timeout for very bad network connections + { + headers: { + 'Authorization': `Bearer ${this.credential.token}`, + 'Content-Type': 'application/json' + }, + timeout: 60000 // 1 minute timeout for very bad network connections + } + ); + + + const raw = response.data.machine; + logger.debug(`[API] Machine ${opts.machineId} registered/updated with server`); + + // Return decrypted machine like we do for sessions + const machine: Machine = { + id: raw.id, + encryptionKey: encryptionKey, + encryptionVariant: encryptionVariant, + metadata: raw.metadata ? decrypt(encryptionKey, encryptionVariant, decodeBase64(raw.metadata)) : null, + metadataVersion: raw.metadataVersion || 0, + daemonState: raw.daemonState ? decrypt(encryptionKey, encryptionVariant, decodeBase64(raw.daemonState)) : null, + daemonStateVersion: raw.daemonStateVersion || 0, + }; + return machine; + } catch (error) { + // Handle connection errors gracefully + if (axios.isAxiosError(error) && error.code && isNetworkError(error.code)) { + connectionState.fail({ + operation: 'Machine registration', + caller: 'api.getOrCreateMachine', + errorCode: error.code, + url: `${configuration.serverUrl}/v1/machines` + }); + return createMinimalMachine(); } - ); - if (response.status !== 200) { - console.error(chalk.red(`[API] Failed to create machine: ${response.statusText}`)); - console.log(chalk.yellow(`[API] Failed to create machine: ${response.statusText}, most likely you have re-authenticated, but you still have a machine associated with the old account. Now we are trying to re-associate the machine with the new account. That is not allowed. Please run 'happy doctor clean' to clean up your happy state, and try your original command again. Please create an issue on github if this is causing you problems. We apologize for the inconvenience.`)); - process.exit(1); - } + // Handle 403/409 - server rejected request due to authorization conflict + // This is NOT "server unreachable" - server responded, so don't use connectionState + if (axios.isAxiosError(error) && error.response?.status) { + const status = error.response.status; - const raw = response.data.machine; - logger.debug(`[API] Machine ${opts.machineId} registered/updated with server`); + if (status === 403 || status === 409) { + // Re-auth conflict: machine registered to old account, re-association not allowed + console.log(chalk.yellow( + `āš ļø Machine registration rejected by the server with status ${status}` + )); + console.log(chalk.yellow( + ` → This machine ID is already registered to another account on the server` + )); + console.log(chalk.yellow( + ` → This usually happens after re-authenticating with a different account` + )); + console.log(chalk.yellow( + ` → Run 'happy doctor clean' to reset local state and generate a new machine ID` + )); + console.log(chalk.yellow( + ` → Open a GitHub issue if this problem persists` + )); + return createMinimalMachine(); + } - // Return decrypted machine like we do for sessions - const machine: Machine = { - id: raw.id, - encryptionKey: encryptionKey, - encryptionVariant: encryptionVariant, - metadata: raw.metadata ? decrypt(encryptionKey, encryptionVariant, decodeBase64(raw.metadata)) : null, - metadataVersion: raw.metadataVersion || 0, - daemonState: raw.daemonState ? decrypt(encryptionKey, encryptionVariant, decodeBase64(raw.daemonState)) : null, - daemonStateVersion: raw.daemonStateVersion || 0, - }; - return machine; + // Handle 5xx - server error, use offline mode with auto-reconnect + if (status >= 500) { + connectionState.fail({ + operation: 'Machine registration', + errorCode: String(status), + url: `${configuration.serverUrl}/v1/machines`, + details: ['Server encountered an error, will retry automatically'] + }); + return createMinimalMachine(); + } + + // Handle 404 - endpoint may not be available yet + if (status === 404) { + connectionState.fail({ + operation: 'Machine registration', + errorCode: '404', + url: `${configuration.serverUrl}/v1/machines` + }); + return createMinimalMachine(); + } + } + + // For other errors, rethrow + throw error; + } } sessionSyncClient(session: Session): ApiSessionClient { diff --git a/src/api/apiMachine.ts b/src/api/apiMachine.ts index c923db04..b8e2b570 100644 --- a/src/api/apiMachine.ts +++ b/src/api/apiMachine.ts @@ -102,14 +102,14 @@ export class ApiMachineClient { }: MachineRpcHandlers) { // Register spawn session handler this.rpcHandlerManager.registerHandler('spawn-happy-session', async (params: any) => { - const { directory, sessionId, machineId, approvedNewDirectoryCreation, agent, token } = params || {}; + const { directory, sessionId, machineId, approvedNewDirectoryCreation, agent, token, environmentVariables } = params || {}; logger.debug(`[API MACHINE] Spawning session with params: ${JSON.stringify(params)}`); if (!directory) { throw new Error('Directory is required'); } - const result = await spawnSession({ directory, sessionId, machineId, approvedNewDirectoryCreation, agent, token }); + const result = await spawnSession({ directory, sessionId, machineId, approvedNewDirectoryCreation, agent, token, environmentVariables }); switch (result.type) { case 'success': diff --git a/src/api/apiSession.test.ts b/src/api/apiSession.test.ts new file mode 100644 index 00000000..977e9ee8 --- /dev/null +++ b/src/api/apiSession.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ApiSessionClient } from './apiSession'; + +// Use vi.hoisted to ensure mock function is available when vi.mock factory runs +const { mockIo } = vi.hoisted(() => ({ + mockIo: vi.fn() +})); + +vi.mock('socket.io-client', () => ({ + io: mockIo +})); + +describe('ApiSessionClient connection handling', () => { + let mockSocket: any; + let consoleSpy: any; + let mockSession: any; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Mock socket.io client + mockSocket = { + connect: vi.fn(), + on: vi.fn(), + off: vi.fn(), + disconnect: vi.fn() + }; + + mockIo.mockReturnValue(mockSocket); + + // Create a proper mock session with metadata + mockSession = { + id: 'test-session-id', + seq: 0, + metadata: { + path: '/tmp', + host: 'localhost', + homeDir: '/home/user', + happyHomeDir: '/home/user/.happy', + happyLibDir: '/home/user/.happy/lib', + happyToolsDir: '/home/user/.happy/tools' + }, + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + encryptionKey: new Uint8Array(32), + encryptionVariant: 'legacy' as const + }; + }); + + it('should handle socket connection failure gracefully', async () => { + // Should not throw during client creation + // Note: socket is created with autoConnect: false, so connection happens later + expect(() => { + new ApiSessionClient('fake-token', mockSession); + }).not.toThrow(); + }); + + it('should emit correct events on socket connection', () => { + const client = new ApiSessionClient('fake-token', mockSession); + + // Should have set up event listeners + expect(mockSocket.on).toHaveBeenCalledWith('connect', expect.any(Function)); + expect(mockSocket.on).toHaveBeenCalledWith('disconnect', expect.any(Function)); + expect(mockSocket.on).toHaveBeenCalledWith('error', expect.any(Function)); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + vi.restoreAllMocks(); + }); +}); \ No newline at end of file diff --git a/src/api/apiSession.ts b/src/api/apiSession.ts index a2805c94..e4dd4253 100644 --- a/src/api/apiSession.ts +++ b/src/api/apiSession.ts @@ -194,6 +194,12 @@ export class ApiSessionClient extends EventEmitter { logger.debugLargeJson('[SOCKET] Sending message through socket:', content) + // Check if socket is connected before sending + if (!this.socket.connected) { + logger.debug('[API] Socket not connected, cannot send Claude session message. Message will be lost:', { type: body.type }); + return; + } + const encrypted = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, content)); this.socket.emit('message', { sid: this.sessionId, diff --git a/src/api/rpc/RpcHandlerManager.ts b/src/api/rpc/RpcHandlerManager.ts index 173afe90..36ffd096 100644 --- a/src/api/rpc/RpcHandlerManager.ts +++ b/src/api/rpc/RpcHandlerManager.ts @@ -69,10 +69,13 @@ export class RpcHandlerManager { const decryptedParams = decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(request.params)); // Call the handler + this.logger('[RPC] Calling handler', { method: request.method }); const result = await handler(decryptedParams); + this.logger('[RPC] Handler returned', { method: request.method, hasResult: result !== undefined }); // Encrypt and return the response const encryptedResponse = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, result)); + this.logger('[RPC] Sending encrypted response', { method: request.method, responseLength: encryptedResponse.length }); return encryptedResponse; } catch (error) { this.logger('[RPC] [ERROR] Error handling request', { error }); diff --git a/src/api/types.ts b/src/api/types.ts index ae0147e5..e1b4878d 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,6 +1,19 @@ import { z } from 'zod' import { UsageSchema } from '@/claude/types' -import { PermissionMode } from '@/claude/loop' + +/** + * Permission mode type - includes both Claude and Codex modes + * Must match MessageMetaSchema.permissionMode enum values + * + * Claude modes: default, acceptEdits, bypassPermissions, plan + * Codex modes: read-only, safe-yolo, yolo + * + * When calling Claude SDK, Codex modes are mapped at the SDK boundary: + * - yolo → bypassPermissions + * - safe-yolo → default + * - read-only → default + */ +export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo' /** * Usage data type from Claude @@ -229,7 +242,7 @@ export type SessionMessage = z.infer */ export const MessageMetaSchema = z.object({ sentFrom: z.string().optional(), // Source identifier - permissionMode: z.string().optional(), // Permission mode for this message + permissionMode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan', 'read-only', 'safe-yolo', 'yolo']).optional(), // Permission mode for this message model: z.string().nullable().optional(), // Model name for this message (null = reset) fallbackModel: z.string().nullable().optional(), // Fallback model for this message (null = reset) customSystemPrompt: z.string().nullable().optional(), // Custom system prompt for this message (null = reset) diff --git a/src/claude/claudeLocal.test.ts b/src/claude/claudeLocal.test.ts new file mode 100644 index 00000000..b8557daf --- /dev/null +++ b/src/claude/claudeLocal.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { claudeLocal } from './claudeLocal'; + +// Use vi.hoisted to ensure mock functions are available when vi.mock factory runs +const { mockSpawn, mockClaudeFindLastSession } = vi.hoisted(() => ({ + mockSpawn: vi.fn(), + mockClaudeFindLastSession: vi.fn() +})); + +vi.mock('node:child_process', () => ({ + spawn: mockSpawn +})); + +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn() + } +})); + +vi.mock('./utils/claudeFindLastSession', () => ({ + claudeFindLastSession: mockClaudeFindLastSession +})); + +vi.mock('./utils/path', () => ({ + getProjectPath: vi.fn((path: string) => path) +})); + +vi.mock('./utils/systemPrompt', () => ({ + systemPrompt: 'test-system-prompt' +})); + +vi.mock('node:fs', () => ({ + mkdirSync: vi.fn(), + existsSync: vi.fn(() => true) +})); + +vi.mock('./utils/claudeCheckSession', () => ({ + claudeCheckSession: vi.fn(() => true) // Always return true (session exists) +})); + +describe('claudeLocal --continue handling', () => { + let onSessionFound: any; + + beforeEach(() => { + // Mock spawn to resolve immediately + mockSpawn.mockReturnValue({ + stdio: [null, null, null, null], + on: vi.fn((event, callback) => { + // Immediately call the 'exit' callback + if (event === 'exit') { + process.nextTick(() => callback(0)); + } + }), + addListener: vi.fn(), + removeListener: vi.fn(), + kill: vi.fn(), + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + stdin: { + on: vi.fn(), + end: vi.fn() + } + }); + + onSessionFound = vi.fn(); + + // Reset mocks + vi.clearAllMocks(); + }); + + it('should convert --continue to --resume with last session ID', async () => { + // Mock claudeFindLastSession to return a session ID + mockClaudeFindLastSession.mockReturnValue('123e4567-e89b-12d3-a456-426614174000'); + + await claudeLocal({ + abort: new AbortController().signal, + sessionId: null, + path: '/tmp', + onSessionFound, + claudeArgs: ['--continue'] // User wants to continue last session + }); + + // Verify spawn was called + expect(mockSpawn).toHaveBeenCalled(); + + // Get the args passed to spawn (second argument is the array) + const spawnArgs = mockSpawn.mock.calls[0][1]; + + // Should NOT contain --continue (converted to --resume) + expect(spawnArgs).not.toContain('--continue'); + + // Should NOT contain --session-id (no conflict) + expect(spawnArgs).not.toContain('--session-id'); + + // Should contain --resume with the found session ID + expect(spawnArgs).toContain('--resume'); + expect(spawnArgs).toContain('123e4567-e89b-12d3-a456-426614174000'); + + // Should notify about the session + expect(onSessionFound).toHaveBeenCalledWith('123e4567-e89b-12d3-a456-426614174000'); + }); + + it('should create new session when --continue but no sessions exist', async () => { + // Mock claudeFindLastSession to return null (no sessions) + mockClaudeFindLastSession.mockReturnValue(null); + + await claudeLocal({ + abort: new AbortController().signal, + sessionId: null, + path: '/tmp', + onSessionFound, + claudeArgs: ['--continue'] + }); + + const spawnArgs = mockSpawn.mock.calls[0][1]; + + // Should contain --session-id for new session + expect(spawnArgs).toContain('--session-id'); + + // Should not contain --resume or --continue + expect(spawnArgs).not.toContain('--resume'); + expect(spawnArgs).not.toContain('--continue'); + }); + + it('should add --session-id for normal new sessions without --continue', async () => { + mockClaudeFindLastSession.mockReturnValue(null); + + await claudeLocal({ + abort: new AbortController().signal, + sessionId: null, + path: '/tmp', + onSessionFound, + claudeArgs: [] // No session flags - new session + }); + + const spawnArgs = mockSpawn.mock.calls[0][1]; + expect(spawnArgs).toContain('--session-id'); + expect(spawnArgs).not.toContain('--continue'); + expect(spawnArgs).not.toContain('--resume'); + }); + + it('should handle --resume with specific session ID without conflict', async () => { + mockClaudeFindLastSession.mockReturnValue(null); + + await claudeLocal({ + abort: new AbortController().signal, + sessionId: 'existing-session-123', + path: '/tmp', + onSessionFound, + claudeArgs: [] // No --continue + }); + + const spawnArgs = mockSpawn.mock.calls[0][1]; + expect(spawnArgs).toContain('--resume'); + expect(spawnArgs).toContain('existing-session-123'); + expect(spawnArgs).not.toContain('--session-id'); + }); + + it('should remove --continue from claudeArgs after conversion', async () => { + mockClaudeFindLastSession.mockReturnValue('session-456'); + + const claudeArgs = ['--continue', '--other-flag']; + + await claudeLocal({ + abort: new AbortController().signal, + sessionId: null, + path: '/tmp', + onSessionFound, + claudeArgs + }); + + // Verify spawn was called without --continue (it gets converted to --resume) + const spawnArgs = mockSpawn.mock.calls[0][1]; + expect(spawnArgs).not.toContain('--continue'); + expect(spawnArgs).toContain('--other-flag'); + }); + + it('should pass --resume to Claude when no session ID provided', async () => { + const claudeArgs = ['--resume']; + + await claudeLocal({ + abort: new AbortController().signal, + sessionId: null, + path: '/tmp', + onSessionFound, + claudeArgs + }); + + // --resume should still be in spawn args (NOT extracted) + const spawnArgs = mockSpawn.mock.calls[0][1]; + expect(spawnArgs).toContain('--resume'); + // Should NOT have auto-found session ID + expect(spawnArgs).not.toContain('--session-id'); + }); + + it('should extract and use --resume when session ID is provided', async () => { + mockClaudeFindLastSession.mockReturnValue(null); + const claudeArgs = ['--resume', 'abc-123-def']; + + await claudeLocal({ + abort: new AbortController().signal, + sessionId: null, + path: '/tmp', + onSessionFound, + claudeArgs + }); + + // Should use provided ID in spawn args + const spawnArgs = mockSpawn.mock.calls[0][1]; + expect(spawnArgs).toContain('--resume'); + expect(spawnArgs).toContain('abc-123-def'); + // Should NOT add --session-id (resume takes precedence) + expect(spawnArgs).not.toContain('--session-id'); + // Should notify about the session being resumed + expect(onSessionFound).toHaveBeenCalledWith('abc-123-def'); + }); + + it('should handle -r short flag same as --resume', async () => { + const claudeArgs = ['-r']; + + await claudeLocal({ + abort: new AbortController().signal, + sessionId: null, + path: '/tmp', + onSessionFound, + claudeArgs + }); + + const spawnArgs = mockSpawn.mock.calls[0][1]; + expect(spawnArgs).toContain('-r'); + }); +}); \ No newline at end of file diff --git a/src/claude/claudeLocal.ts b/src/claude/claudeLocal.ts index 7098da35..d4f7ac0b 100644 --- a/src/claude/claudeLocal.ts +++ b/src/claude/claudeLocal.ts @@ -2,8 +2,10 @@ import { spawn } from "node:child_process"; import { resolve, join } from "node:path"; import { createInterface } from "node:readline"; import { mkdirSync, existsSync } from "node:fs"; +import { randomUUID } from "node:crypto"; import { logger } from "@/ui/logger"; import { claudeCheckSession } from "./utils/claudeCheckSession"; +import { claudeFindLastSession } from "./utils/claudeFindLastSession"; import { getProjectPath } from "./utils/path"; import { projectPath } from "@/projectPath"; import { systemPrompt } from "./utils/systemPrompt"; @@ -22,8 +24,8 @@ export async function claudeLocal(opts: { claudeEnvVars?: Record, claudeArgs?: string[], allowedTools?: string[], - /** Path to temporary settings file with SessionStart hook (required for session tracking) */ - hookSettingsPath: string + /** Path to temporary settings file with SessionStart hook (optional - for session tracking) */ + hookSettingsPath?: string }) { // Ensure project directory exists @@ -38,17 +40,113 @@ export async function claudeLocal(opts: { // Determine if we have an existing session to resume // Session ID will always be provided by hook (SessionStart) when Claude starts let startFrom = opts.sessionId; - if (opts.sessionId && !claudeCheckSession(opts.sessionId, opts.path)) { - startFrom = null; + + // Handle session-related flags from claudeArgs to ensure transparent behavior + // We intercept these flags to use happy-cli's session storage rather than Claude's default + // + // Supported patterns: + // --continue / -c : Resume last session in current directory + // --resume / -r : Resume last session (picker in Claude, but we handle) + // --resume / -r : Resume specific session by ID + // --session-id : Use specific UUID for new session + + // Helper to find and extract flag with optional value + const extractFlag = (flags: string[], withValue: boolean = false): { found: boolean; value?: string } => { + if (!opts.claudeArgs) return { found: false }; + + for (const flag of flags) { + const index = opts.claudeArgs.indexOf(flag); + if (index !== -1) { + if (withValue && index + 1 < opts.claudeArgs.length) { + const nextArg = opts.claudeArgs[index + 1]; + // Check if next arg looks like a value (doesn't start with -) + if (!nextArg.startsWith('-')) { + const value = nextArg; + // Remove both flag and value + opts.claudeArgs = opts.claudeArgs.filter((_, i) => i !== index && i !== index + 1); + return { found: true, value }; + } + } + // Don't extract if value was required but not found + if (!withValue) { + opts.claudeArgs = opts.claudeArgs.filter((_, i) => i !== index); + return { found: true }; + } + return { found: false }; + } + } + return { found: false }; + }; + + // 1. Check for --session-id (explicit new session with specific ID) + const sessionIdFlag = extractFlag(['--session-id'], true); + if (sessionIdFlag.found && sessionIdFlag.value) { + startFrom = null; // Force new session mode, will use this ID below + logger.debug(`[ClaudeLocal] Using explicit --session-id: ${sessionIdFlag.value}`); + } + + // 2. Check for --resume / -r (resume specific session) + if (!startFrom && !sessionIdFlag.value) { + const resumeFlag = extractFlag(['--resume', '-r'], true); + if (resumeFlag.found) { + if (resumeFlag.value) { + startFrom = resumeFlag.value; + logger.debug(`[ClaudeLocal] Using provided session ID from --resume: ${startFrom}`); + } else { + // --resume without value: find last session + const lastSession = claudeFindLastSession(opts.path); + if (lastSession) { + startFrom = lastSession; + logger.debug(`[ClaudeLocal] --resume: Found last session: ${lastSession}`); + } + } + } } - - // Log session strategy - if (startFrom) { - logger.debug(`[ClaudeLocal] Will resume existing session: ${startFrom}`); - } else if (hasUserSessionControl) { - logger.debug(`[ClaudeLocal] User passed ${hasContinueFlag ? '--continue' : '--resume'} flag, session ID will be determined by hook`); + + // 3. Check for --continue / -c (resume last session) + if (!startFrom && !sessionIdFlag.value) { + const continueFlag = extractFlag(['--continue', '-c'], false); + if (continueFlag.found) { + const lastSession = claudeFindLastSession(opts.path); + if (lastSession) { + startFrom = lastSession; + logger.debug(`[ClaudeLocal] --continue: Found last session: ${lastSession}`); + } + } + } + // Session ID handling depends on whether we have a hook server + // - With hookSettingsPath: Session ID comes from Claude via hook (normal mode) + // - Without hookSettingsPath: We generate session ID ourselves (offline mode) + const explicitSessionId = sessionIdFlag.value || null; + let newSessionId: string | null = null; + let effectiveSessionId: string | null = startFrom; + + if (!opts.hookSettingsPath) { + // Offline mode: Generate session ID if not resuming + // Priority: 1. startFrom (resuming), 2. explicit --session-id, 3. generate new UUID + newSessionId = startFrom ? null : (explicitSessionId || randomUUID()); + effectiveSessionId = startFrom || newSessionId!; + + // Notify about session ID immediately (we know it upfront in offline mode) + if (startFrom) { + logger.debug(`[ClaudeLocal] Resuming session: ${startFrom}`); + opts.onSessionFound(startFrom); + } else if (explicitSessionId) { + logger.debug(`[ClaudeLocal] Using explicit session ID: ${explicitSessionId}`); + opts.onSessionFound(explicitSessionId); + } else { + logger.debug(`[ClaudeLocal] Generated new session ID: ${newSessionId}`); + opts.onSessionFound(newSessionId!); + } } else { - logger.debug(`[ClaudeLocal] Fresh start, session ID will be provided by hook`); + // Normal mode with hook server: Session ID comes from Claude via hook + if (startFrom) { + logger.debug(`[ClaudeLocal] Will resume existing session: ${startFrom}`); + } else if (hasUserSessionControl) { + logger.debug(`[ClaudeLocal] User passed ${hasContinueFlag ? '--continue' : '--resume'} flag, session ID will be determined by hook`); + } else { + logger.debug(`[ClaudeLocal] Fresh start, session ID will be provided by hook`); + } } // Thinking state @@ -70,13 +168,27 @@ export async function claudeLocal(opts: { process.stdin.pause(); await new Promise((r, reject) => { const args: string[] = [] - - // Only add --resume if we have an existing session and user didn't pass their own flags - // For fresh starts, let Claude create its own session ID (reported via hook) - if (!hasUserSessionControl && startFrom) { - args.push('--resume', startFrom) + + // Session/resume args depend on whether we're in offline mode or hook mode + if (!opts.hookSettingsPath) { + // Offline mode: We control session ID + const hasResumeFlag = opts.claudeArgs?.includes('--resume') || opts.claudeArgs?.includes('-r'); + if (startFrom) { + // Resume existing session (Claude preserves the session ID) + args.push('--resume', startFrom) + } else if (!hasResumeFlag && newSessionId) { + // New session with our generated UUID + args.push('--session-id', newSessionId) + } + } else { + // Normal mode with hook: Add --resume if we found a session to resume + // (Flags have been extracted, so we re-add --resume with the session ID we found) + if (startFrom) { + args.push('--resume', startFrom); + } } - + // If hasResumeFlag && !startFrom: --resume is in claudeArgs, let Claude handle it + args.push('--append-system-prompt', systemPrompt); if (opts.mcpServers && Object.keys(opts.mcpServers).length > 0) { @@ -92,9 +204,11 @@ export async function claudeLocal(opts: { args.push(...opts.claudeArgs) } - // Add hook settings for session tracking (always passed) - args.push('--settings', opts.hookSettingsPath); - logger.debug(`[ClaudeLocal] Using hook settings: ${opts.hookSettingsPath}`); + // Add hook settings for session tracking (when available) + if (opts.hookSettingsPath) { + args.push('--settings', opts.hookSettingsPath); + logger.debug(`[ClaudeLocal] Using hook settings: ${opts.hookSettingsPath}`); + } if (!claudeCliPath || !existsSync(claudeCliPath)) { throw new Error('Claude local launcher not found. Please ensure HAPPY_PROJECT_ROOT is set correctly for development.'); @@ -208,5 +322,8 @@ export async function claudeLocal(opts: { updateThinking(false); } - return startFrom; + // Return the effective session ID (what was actually used) + // - In offline mode: Our generated or resumed session ID + // - In hook mode: The session ID from startFrom (if resuming) or null (new session - hook will report ID) + return effectiveSessionId; } diff --git a/src/claude/claudeRemote.ts b/src/claude/claudeRemote.ts index 5c9698dc..c95524a6 100644 --- a/src/claude/claudeRemote.ts +++ b/src/claude/claudeRemote.ts @@ -1,5 +1,6 @@ -import { EnhancedMode, PermissionMode } from "./loop"; -import { query, type QueryOptions as Options, type SDKMessage, type SDKSystemMessage, AbortError, SDKUserMessage } from '@/claude/sdk' +import { EnhancedMode } from "./loop"; +import { query, type QueryOptions, type SDKMessage, type SDKSystemMessage, AbortError, SDKUserMessage } from '@/claude/sdk' +import { mapToClaudeMode } from "./utils/permissionMode"; import { claudeCheckSession } from "./utils/claudeCheckSession"; import { join, resolve } from 'node:path'; import { projectPath } from "@/projectPath"; @@ -109,11 +110,11 @@ export async function claudeRemote(opts: { // Prepare SDK options let mode = initial.mode; - const sdkOptions: Options = { + const sdkOptions: QueryOptions = { cwd: opts.path, resume: startFrom ?? undefined, mcpServers: opts.mcpServers, - permissionMode: initial.mode.permissionMode === 'plan' ? 'plan' : 'default', + permissionMode: mapToClaudeMode(initial.mode.permissionMode), model: initial.mode.model, fallbackModel: initial.mode.fallbackModel, customSystemPrompt: initial.mode.customSystemPrompt ? initial.mode.customSystemPrompt + '\n\n' + systemPrompt : undefined, diff --git a/src/claude/loop.ts b/src/claude/loop.ts index b3d1f7e0..4cecb424 100644 --- a/src/claude/loop.ts +++ b/src/claude/loop.ts @@ -6,7 +6,10 @@ import { claudeLocalLauncher } from "./claudeLocalLauncher" import { claudeRemoteLauncher } from "./claudeRemoteLauncher" import { ApiClient } from "@/lib" -export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'; +// Re-export permission mode type from api/types +// Single unified type with 7 modes - Codex modes mapped at SDK boundary +export type { PermissionMode } from "@/api/types" +import type { PermissionMode } from "@/api/types" export interface EnhancedMode { permissionMode: PermissionMode; diff --git a/src/claude/runClaude.ts b/src/claude/runClaude.ts index dc124fdd..d6ab73e0 100644 --- a/src/claude/runClaude.ts +++ b/src/claude/runClaude.ts @@ -23,11 +23,14 @@ import { generateHookSettingsFile, cleanupHookSettingsFile } from '@/claude/util import { registerKillSessionHandler } from './registerKillSessionHandler'; import { projectPath } from '../projectPath'; import { resolve } from 'node:path'; +import { startOfflineReconnection, connectionState } from '@/utils/serverConnectionErrors'; +import { claudeLocal } from '@/claude/claudeLocal'; +import { createSessionScanner } from '@/claude/utils/sessionScanner'; import { Session } from './session'; export interface StartOptions { model?: string - permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' + permissionMode?: PermissionMode startingMode?: 'local' | 'remote' shouldStartDaemon?: boolean claudeEnvVars?: Record @@ -46,14 +49,14 @@ export async function runClaude(credentials: Credentials, options: StartOptions logger.debugLargeJson('[START] Happy process started', getEnvironmentInfo()); logger.debug(`[START] Options: startedBy=${options.startedBy}, startingMode=${options.startingMode}`); - // Validate daemon spawn requirements + // Validate daemon spawn requirements - fail fast on invalid config if (options.startedBy === 'daemon' && options.startingMode === 'local') { - logger.debug('Daemon spawn requested with local mode - forcing remote mode'); - options.startingMode = 'remote'; - // TODO: Eventually we should error here instead of silently switching - // throw new Error('Daemon-spawned sessions cannot use local/interactive mode'); + throw new Error('Daemon-spawned sessions cannot use local/interactive mode. Use --happy-starting-mode remote or spawn sessions directly from terminal.'); } + // Set backend for offline warnings (before any API calls) + connectionState.setBackend('Claude'); + // Create session service const api = await ApiClient.create(credentials); @@ -94,6 +97,51 @@ export async function runClaude(credentials: Credentials, options: StartOptions flavor: 'claude' }; const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); + + // Handle server unreachable case - run Claude locally with hot reconnection + // Note: connectionState.notifyOffline() was already called by api.ts with error details + if (!response) { + let offlineSessionId: string | null = null; + + const reconnection = startOfflineReconnection({ + serverUrl: configuration.serverUrl, + onReconnected: async () => { + const resp = await api.getOrCreateSession({ tag: randomUUID(), metadata, state }); + if (!resp) throw new Error('Server unavailable'); + const session = api.sessionSyncClient(resp); + const scanner = await createSessionScanner({ + sessionId: null, + workingDirectory, + onMessage: (msg) => session.sendClaudeSessionMessage(msg) + }); + if (offlineSessionId) scanner.onNewSession(offlineSessionId); + return { session, scanner }; + }, + onNotify: console.log, + onCleanup: () => { + // Scanner cleanup handled automatically when process exits + } + }); + + try { + await claudeLocal({ + path: workingDirectory, + sessionId: null, + onSessionFound: (id) => { offlineSessionId = id; }, + onThinkingChange: () => {}, + abort: new AbortController().signal, + claudeEnvVars: options.claudeEnvVars, + claudeArgs: options.claudeArgs, + mcpServers: {}, + allowedTools: [] + }); + } finally { + reconnection.cancel(); + stopCaffeinate(); + } + process.exit(0); + } + logger.debug(`Session created: ${response.id}`); // Always report to daemon if it exists @@ -186,7 +234,8 @@ export async function runClaude(credentials: Credentials, options: StartOptions })); // Forward messages to the queue - let currentPermissionMode = options.permissionMode; + // Permission modes: Use the unified 7-mode type, mapping happens at SDK boundary in claudeRemote.ts + let currentPermissionMode: PermissionMode | undefined = options.permissionMode; let currentModel = options.model; // Track current model state let currentFallbackModel: string | undefined = undefined; // Track current fallback model let currentCustomSystemPrompt: string | undefined = undefined; // Track current custom system prompt @@ -195,18 +244,12 @@ export async function runClaude(credentials: Credentials, options: StartOptions let currentDisallowedTools: string[] | undefined = undefined; // Track current disallowed tools session.onUserMessage((message) => { - // Resolve permission mode from meta - let messagePermissionMode = currentPermissionMode; + // Resolve permission mode from meta - pass through as-is, mapping happens at SDK boundary + let messagePermissionMode: PermissionMode | undefined = currentPermissionMode; if (message.meta?.permissionMode) { - const validModes: PermissionMode[] = ['default', 'acceptEdits', 'bypassPermissions', 'plan']; - if (validModes.includes(message.meta.permissionMode as PermissionMode)) { - messagePermissionMode = message.meta.permissionMode as 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}`); - } + messagePermissionMode = message.meta.permissionMode; + currentPermissionMode = messagePermissionMode; + logger.debug(`[loop] Permission mode updated from user message to: ${currentPermissionMode}`); } else { logger.debug(`[loop] User message received with no permission mode override, using current: ${currentPermissionMode}`); } @@ -335,6 +378,9 @@ export async function runClaude(credentials: Credentials, options: StartOptions archiveReason: 'User terminated' })); + // Cleanup session resources (intervals, callbacks) + currentSession?.cleanup(); + // Send session death message session.sendSessionDeath(); await session.flush(); @@ -408,6 +454,10 @@ export async function runClaude(credentials: Credentials, options: StartOptions hookSettingsPath }); + // Cleanup session resources (intervals, callbacks) - prevents memory leak + // Note: currentSession is set by onSessionReady callback during loop() + (currentSession as Session | null)?.cleanup(); + // Send session death message session.sendSessionDeath(); diff --git a/src/claude/sdk/utils.ts b/src/claude/sdk/utils.ts index 773ceb6a..0602d5a8 100644 --- a/src/claude/sdk/utils.ts +++ b/src/claude/sdk/utils.ts @@ -9,6 +9,7 @@ import { existsSync, readFileSync } from 'node:fs' import { execSync } from 'node:child_process' import { homedir } from 'node:os' import { logger } from '@/ui/logger' +import { isBun } from '@/utils/runtime' /** * Get the directory path of the current module @@ -41,16 +42,17 @@ function getGlobalClaudeVersion(): string | null { /** * Create a clean environment without local node_modules/.bin in PATH * This ensures we find the global claude, not the local one + * Also removes conflicting Bun environment variables when running in Bun */ export function getCleanEnv(): NodeJS.ProcessEnv { const env = { ...process.env } const cwd = process.cwd() const pathSep = process.platform === 'win32' ? ';' : ':' const pathKey = process.platform === 'win32' ? 'Path' : 'PATH' - + // Also check for PATH on Windows (case can vary) const actualPathKey = Object.keys(env).find(k => k.toLowerCase() === 'path') || pathKey - + if (env[actualPathKey]) { // Remove any path that contains the current working directory (local node_modules/.bin) const cleanPath = env[actualPathKey]! @@ -64,7 +66,17 @@ export function getCleanEnv(): NodeJS.ProcessEnv { env[actualPathKey] = cleanPath logger.debug(`[Claude SDK] Cleaned PATH, removed local paths from: ${cwd}`) } - + + // Remove Bun-specific environment variables that can interfere with Node.js processes + if (isBun()) { + Object.keys(env).forEach(key => { + if (key.startsWith('BUN_')) { + delete env[key] + } + }) + logger.debug('[Claude SDK] Removed Bun-specific environment variables for Node.js compatibility') + } + return env } diff --git a/src/claude/utils/claudeCheckSession.test.ts b/src/claude/utils/claudeCheckSession.test.ts new file mode 100644 index 00000000..a360354c --- /dev/null +++ b/src/claude/utils/claudeCheckSession.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { claudeCheckSession } from './claudeCheckSession'; +import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// Mock getProjectPath to use test directory +vi.mock('./path', () => ({ + getProjectPath: (path: string) => path +})); + +describe('claudeCheckSession', () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `test-session-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + describe('Claude Code 2.1.x sessions (uuid field)', () => { + it('should accept session with valid uuid', () => { + const sessionId = '12345678-1234-1234-1234-123456789abc'; + const sessionFile = join(testDir, `${sessionId}.jsonl`); + writeFileSync(sessionFile, JSON.stringify({ uuid: 'msg-123', type: 'user' }) + '\n'); + + expect(claudeCheckSession(sessionId, testDir)).toBe(true); + }); + + it('should accept session with multiple messages, first has uuid', () => { + const sessionId = '12345678-1234-1234-1234-123456789abc'; + const sessionFile = join(testDir, `${sessionId}.jsonl`); + writeFileSync(sessionFile, + JSON.stringify({ uuid: 'msg-1', type: 'user' }) + '\n' + + JSON.stringify({ uuid: 'msg-2', type: 'assistant' }) + '\n' + ); + + expect(claudeCheckSession(sessionId, testDir)).toBe(true); + }); + }); + + describe('Older Claude Code sessions (messageId field)', () => { + it('should accept session with valid messageId', () => { + const sessionId = '87654321-4321-4321-4321-210987654321'; + const sessionFile = join(testDir, `${sessionId}.jsonl`); + writeFileSync(sessionFile, JSON.stringify({ messageId: 'msg-456', type: 'user' }) + '\n'); + + expect(claudeCheckSession(sessionId, testDir)).toBe(true); + }); + + it('should accept session with messageId in second line', () => { + const sessionId = '87654321-4321-4321-4321-210987654321'; + const sessionFile = join(testDir, `${sessionId}.jsonl`); + writeFileSync(sessionFile, + JSON.stringify({ type: 'summary' }) + '\n' + + JSON.stringify({ messageId: 'msg-456', type: 'user' }) + '\n' + ); + + expect(claudeCheckSession(sessionId, testDir)).toBe(true); + }); + }); + + describe('Summary line sessions (leafUuid field)', () => { + it('should accept session with valid leafUuid', () => { + const sessionId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + const sessionFile = join(testDir, `${sessionId}.jsonl`); + writeFileSync(sessionFile, JSON.stringify({ leafUuid: 'leaf-789', type: 'summary' }) + '\n'); + + expect(claudeCheckSession(sessionId, testDir)).toBe(true); + }); + }); + + describe('Edge cases - invalid sessions', () => { + it('should reject session with empty uuid string', () => { + const sessionId = '12345678-1234-1234-1234-123456789abc'; + const sessionFile = join(testDir, `${sessionId}.jsonl`); + writeFileSync(sessionFile, JSON.stringify({ uuid: '', type: 'user' }) + '\n'); + + expect(claudeCheckSession(sessionId, testDir)).toBe(false); + }); + + it('should reject session with empty messageId string', () => { + const sessionId = '12345678-1234-1234-1234-123456789abc'; + const sessionFile = join(testDir, `${sessionId}.jsonl`); + writeFileSync(sessionFile, JSON.stringify({ messageId: '', type: 'user' }) + '\n'); + + expect(claudeCheckSession(sessionId, testDir)).toBe(false); + }); + + it('should reject session with null uuid', () => { + const sessionId = '12345678-1234-1234-1234-123456789abc'; + const sessionFile = join(testDir, `${sessionId}.jsonl`); + writeFileSync(sessionFile, JSON.stringify({ uuid: null, type: 'user' }) + '\n'); + + expect(claudeCheckSession(sessionId, testDir)).toBe(false); + }); + + it('should reject session with no ID fields', () => { + const sessionId = '12345678-1234-1234-1234-123456789abc'; + const sessionFile = join(testDir, `${sessionId}.jsonl`); + writeFileSync(sessionFile, JSON.stringify({ type: 'user', content: 'test' }) + '\n'); + + expect(claudeCheckSession(sessionId, testDir)).toBe(false); + }); + + it('should reject session with only other fields', () => { + const sessionId = '12345678-1234-1234-1234-123456789abc'; + const sessionFile = join(testDir, `${sessionId}.jsonl`); + writeFileSync(sessionFile, JSON.stringify({ role: 'user', message: 'hello' }) + '\n'); + + expect(claudeCheckSession(sessionId, testDir)).toBe(false); + }); + }); + + describe('File system edge cases', () => { + it('should reject non-existent session', () => { + expect(claudeCheckSession('nonexistent-uuid-1234', testDir)).toBe(false); + }); + + it('should reject session with malformed JSON', () => { + const sessionId = '12345678-1234-1234-1234-123456789abc'; + const sessionFile = join(testDir, `${sessionId}.jsonl`); + writeFileSync(sessionFile, '{invalid json}\n{also invalid\n'); + + expect(claudeCheckSession(sessionId, testDir)).toBe(false); + }); + + it('should reject empty session file', () => { + const sessionId = '12345678-1234-1234-1234-123456789abc'; + const sessionFile = join(testDir, `${sessionId}.jsonl`); + writeFileSync(sessionFile, ''); + + expect(claudeCheckSession(sessionId, testDir)).toBe(false); + }); + + it('should handle session with only whitespace', () => { + const sessionId = '12345678-1234-1234-1234-123456789abc'; + const sessionFile = join(testDir, `${sessionId}.jsonl`); + writeFileSync(sessionFile, ' \n \n\t\n'); + + expect(claudeCheckSession(sessionId, testDir)).toBe(false); + }); + + it('should handle large session file with valid message at end', () => { + const sessionId = '12345678-1234-1234-1234-123456789abc'; + const sessionFile = join(testDir, `${sessionId}.jsonl`); + + // Create many invalid lines followed by one valid line + const lines = []; + for (let i = 0; i < 100; i++) { + lines.push(JSON.stringify({ type: 'other', index: i })); + } + lines.push(JSON.stringify({ uuid: 'found-it', type: 'user' })); + writeFileSync(sessionFile, lines.join('\n') + '\n'); + + expect(claudeCheckSession(sessionId, testDir)).toBe(true); + }); + }); + + describe('Mixed format sessions', () => { + it('should accept session with both uuid and messageId (prefer first valid)', () => { + const sessionId = '12345678-1234-1234-1234-123456789abc'; + const sessionFile = join(testDir, `${sessionId}.jsonl`); + writeFileSync(sessionFile, + JSON.stringify({ uuid: 'msg-1', messageId: 'msg-2', type: 'user' }) + '\n' + ); + + expect(claudeCheckSession(sessionId, testDir)).toBe(true); + }); + + it('should find valid message even if first lines are invalid', () => { + const sessionId = '12345678-1234-1234-1234-123456789abc'; + const sessionFile = join(testDir, `${sessionId}.jsonl`); + writeFileSync(sessionFile, + '{}\n' + + JSON.stringify({ type: 'other' }) + '\n' + + JSON.stringify({ messageId: 'found-it', type: 'user' }) + '\n' + ); + + expect(claudeCheckSession(sessionId, testDir)).toBe(true); + }); + }); +}); diff --git a/src/claude/utils/claudeCheckSession.ts b/src/claude/utils/claudeCheckSession.ts index 27661205..384df15e 100644 --- a/src/claude/utils/claudeCheckSession.ts +++ b/src/claude/utils/claudeCheckSession.ts @@ -14,15 +14,28 @@ export function claudeCheckSession(sessionId: string, path: string) { return false; } - // Check if session contains any messages + // Check if session contains any messages with valid ID fields const sessionData = readFileSync(sessionFile, 'utf-8').split('\n'); - const hasGoodMessage = !!sessionData.find((v) => { + + const hasGoodMessage = !!sessionData.find((v, index) => { + if (!v.trim()) return false; // Skip empty lines silently (not errors) + try { - return typeof JSON.parse(v).uuid === 'string' + const parsed = JSON.parse(v); + // Accept sessions with any of these ID fields (different Claude Code versions) + // Check for non-empty strings to handle edge cases robustly + return (typeof parsed.uuid === 'string' && parsed.uuid.length > 0) || // Claude Code 2.1.x + (typeof parsed.messageId === 'string' && parsed.messageId.length > 0) || // Older Claude Code + (typeof parsed.leafUuid === 'string' && parsed.leafUuid.length > 0); // Summary lines } catch (e) { + // Log parse errors for debugging (following project convention) + logger.debug(`[claudeCheckSession] Malformed JSON at line ${index + 1}:`, e); return false; } }); + // Log final validation result for observability + logger.debug(`[claudeCheckSession] Session ${sessionId}: ${hasGoodMessage ? 'valid' : 'invalid'}`); + return hasGoodMessage; } \ No newline at end of file diff --git a/src/claude/utils/claudeFindLastSession.test.ts b/src/claude/utils/claudeFindLastSession.test.ts new file mode 100644 index 00000000..0ff35e6e --- /dev/null +++ b/src/claude/utils/claudeFindLastSession.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { claudeFindLastSession } from './claudeFindLastSession'; +import { mkdirSync, writeFileSync, rmSync, existsSync, utimesSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// Mock getProjectPath to use test directory +vi.mock('./path', () => ({ + getProjectPath: (path: string) => path +})); + +describe('claudeFindLastSession', () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `test-sessions-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + describe('Basic session finding', () => { + it('should find session with uuid field', () => { + const sessionId = '12345678-1234-1234-1234-123456789abc'; + writeFileSync( + join(testDir, `${sessionId}.jsonl`), + JSON.stringify({ uuid: 'msg-1', type: 'user' }) + '\n' + ); + + expect(claudeFindLastSession(testDir)).toBe(sessionId); + }); + + it('should find session with messageId field (older Claude Code)', () => { + const sessionId = '87654321-4321-4321-4321-210987654321'; + writeFileSync( + join(testDir, `${sessionId}.jsonl`), + JSON.stringify({ messageId: 'msg-old', type: 'user' }) + '\n' + ); + + expect(claudeFindLastSession(testDir)).toBe(sessionId); + }); + + it('should find session with leafUuid field', () => { + const sessionId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + writeFileSync( + join(testDir, `${sessionId}.jsonl`), + JSON.stringify({ leafUuid: 'leaf-123', type: 'summary' }) + '\n' + ); + + expect(claudeFindLastSession(testDir)).toBe(sessionId); + }); + + it('should return null when no valid sessions exist', () => { + expect(claudeFindLastSession(testDir)).toBe(null); + }); + + it('should return null when directory does not exist', () => { + const nonExistentDir = join(tmpdir(), 'does-not-exist-' + Date.now()); + expect(claudeFindLastSession(nonExistentDir)).toBe(null); + }); + }); + + describe('Most recent session selection', () => { + it('should find most recent session by mtime (uuid format)', async () => { + // Create older session + const oldSessionId = '11111111-1111-1111-1111-111111111111'; + const oldFile = join(testDir, `${oldSessionId}.jsonl`); + writeFileSync(oldFile, JSON.stringify({ uuid: 'msg-1', type: 'user' }) + '\n'); + + // Set old mtime + const oldTime = new Date('2025-01-01'); + utimesSync(oldFile, oldTime, oldTime); + + // Create newer session + const newSessionId = '22222222-2222-2222-2222-222222222222'; + const newFile = join(testDir, `${newSessionId}.jsonl`); + writeFileSync(newFile, JSON.stringify({ uuid: 'msg-2', type: 'user' }) + '\n'); + + // Set new mtime + const newTime = new Date('2025-12-31'); + utimesSync(newFile, newTime, newTime); + + expect(claudeFindLastSession(testDir)).toBe(newSessionId); + }); + + it('should find most recent session regardless of ID field type', () => { + // Create older session with uuid + const oldSessionId = '11111111-1111-1111-1111-111111111111'; + const oldFile = join(testDir, `${oldSessionId}.jsonl`); + writeFileSync(oldFile, JSON.stringify({ uuid: 'msg-1', type: 'user' }) + '\n'); + utimesSync(oldFile, new Date('2025-01-01'), new Date('2025-01-01')); + + // Create newer session with messageId + const newSessionId = '22222222-2222-2222-2222-222222222222'; + const newFile = join(testDir, `${newSessionId}.jsonl`); + writeFileSync(newFile, JSON.stringify({ messageId: 'msg-2', type: 'user' }) + '\n'); + utimesSync(newFile, new Date('2025-12-31'), new Date('2025-12-31')); + + expect(claudeFindLastSession(testDir)).toBe(newSessionId); + }); + }); + + describe('Session file filtering', () => { + it('should skip non-UUID session files (agent sessions)', () => { + writeFileSync( + join(testDir, 'agent-abc123.jsonl'), + JSON.stringify({ uuid: 'msg-1', type: 'user' }) + '\n' + ); + + expect(claudeFindLastSession(testDir)).toBe(null); + }); + + it('should skip sessions without valid ID fields', () => { + const sessionId = '12345678-1234-1234-1234-123456789abc'; + writeFileSync( + join(testDir, `${sessionId}.jsonl`), + JSON.stringify({ type: 'user', content: 'test' }) + '\n' + ); + + expect(claudeFindLastSession(testDir)).toBe(null); + }); + + it('should skip empty session files', () => { + const sessionId = '12345678-1234-1234-1234-123456789abc'; + writeFileSync(join(testDir, `${sessionId}.jsonl`), ''); + + expect(claudeFindLastSession(testDir)).toBe(null); + }); + + it('should skip files without .jsonl extension', () => { + const sessionId = '12345678-1234-1234-1234-123456789abc'; + writeFileSync( + join(testDir, `${sessionId}.txt`), + JSON.stringify({ uuid: 'msg-1', type: 'user' }) + '\n' + ); + + expect(claudeFindLastSession(testDir)).toBe(null); + }); + }); + + describe('Multiple sessions scenario', () => { + it('should find most recent valid session when mixed with invalid ones', () => { + // Invalid: agent session + writeFileSync( + join(testDir, 'agent-xyz.jsonl'), + JSON.stringify({ uuid: 'agent-msg', type: 'user' }) + '\n' + ); + + // Invalid: no ID fields + const invalidId = '99999999-9999-9999-9999-999999999999'; + writeFileSync( + join(testDir, `${invalidId}.jsonl`), + JSON.stringify({ type: 'other' }) + '\n' + ); + + // Valid: old session with messageId + const oldValidId = '11111111-1111-1111-1111-111111111111'; + const oldValidFile = join(testDir, `${oldValidId}.jsonl`); + writeFileSync(oldValidFile, JSON.stringify({ messageId: 'old-msg', type: 'user' }) + '\n'); + utimesSync(oldValidFile, new Date('2025-01-01'), new Date('2025-01-01')); + + // Valid: new session with uuid + const newValidId = '22222222-2222-2222-2222-222222222222'; + const newValidFile = join(testDir, `${newValidId}.jsonl`); + writeFileSync(newValidFile, JSON.stringify({ uuid: 'new-msg', type: 'user' }) + '\n'); + utimesSync(newValidFile, new Date('2025-12-31'), new Date('2025-12-31')); + + expect(claudeFindLastSession(testDir)).toBe(newValidId); + }); + + it('should handle directory with 50+ sessions efficiently', () => { + // Create 50 sessions with messageId (older format) + for (let i = 0; i < 50; i++) { + const sessionId = `${i.toString().padStart(8, '0')}-1111-1111-1111-111111111111`; + const sessionFile = join(testDir, `${sessionId}.jsonl`); + writeFileSync(sessionFile, JSON.stringify({ messageId: `msg-${i}`, type: 'user' }) + '\n'); + + // Set different mtimes + const time = new Date(2025, 0, 1 + i); + utimesSync(sessionFile, time, time); + } + + // Most recent should be the last one created + const mostRecent = '00000049-1111-1111-1111-111111111111'; + expect(claudeFindLastSession(testDir)).toBe(mostRecent); + }); + }); + + describe('UUID format validation', () => { + it('should only accept properly formatted UUIDs', () => { + // Valid UUID format + const validId = '12345678-1234-1234-1234-123456789abc'; + writeFileSync( + join(testDir, `${validId}.jsonl`), + JSON.stringify({ uuid: 'msg', type: 'user' }) + '\n' + ); + + // Invalid UUID formats (should be skipped) + writeFileSync(join(testDir, 'not-a-uuid.jsonl'), JSON.stringify({ uuid: 'msg', type: 'user' }) + '\n'); + writeFileSync(join(testDir, '12345678-12-12-12-123456789abc.jsonl'), JSON.stringify({ uuid: 'msg', type: 'user' }) + '\n'); + writeFileSync(join(testDir, 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX.jsonl'), JSON.stringify({ uuid: 'msg', type: 'user' }) + '\n'); + + expect(claudeFindLastSession(testDir)).toBe(validId); + }); + }); +}); diff --git a/src/claude/utils/claudeFindLastSession.ts b/src/claude/utils/claudeFindLastSession.ts new file mode 100644 index 00000000..efcf5698 --- /dev/null +++ b/src/claude/utils/claudeFindLastSession.ts @@ -0,0 +1,52 @@ +import { readdirSync, statSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { getProjectPath } from './path'; +import { claudeCheckSession } from './claudeCheckSession'; +import { logger } from '@/ui/logger'; + +/** + * Finds the most recently modified VALID session in the project directory. + * A valid session must: + * 1. Contain at least one message with a uuid, messageId, or leafUuid field + * 2. Have a session ID in UUID format (Claude Code v2.0.65+ requires this for --resume) + * + * Note: Agent sessions (agent-*) are excluded because --resume only accepts UUID format. + * Returns the session ID (filename without .jsonl extension) or null if no valid sessions found. + */ +export function claudeFindLastSession(workingDirectory: string): string | null { + try { + const projectDir = getProjectPath(workingDirectory); + + // UUID format pattern (8-4-4-4-12 hex digits) + const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + + const files = readdirSync(projectDir) + .filter(f => f.endsWith('.jsonl')) + .map(f => { + const sessionId = f.replace('.jsonl', ''); + + // Filter out non-UUID session IDs (e.g., agent-* sessions) + // Claude Code --resume only accepts UUID format as of v2.0.65 + if (!uuidPattern.test(sessionId)) { + return null; + } + + // Check if this is a valid session (has messages with uuid field) + if (claudeCheckSession(sessionId, workingDirectory)) { + return { + name: f, + sessionId: sessionId, + mtime: statSync(join(projectDir, f)).mtime.getTime() + }; + } + return null; + }) + .filter(f => f !== null) + .sort((a, b) => b.mtime - a.mtime); // Most recent valid session first + + return files.length > 0 ? files[0].sessionId : null; + } catch (e) { + logger.debug('[claudeFindLastSession] Error finding sessions:', e); + return null; + } +} \ No newline at end of file diff --git a/src/claude/utils/path.test.ts b/src/claude/utils/path.test.ts index 50741511..6708be26 100644 --- a/src/claude/utils/path.test.ts +++ b/src/claude/utils/path.test.ts @@ -1,39 +1,95 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { getProjectPath } from './path'; -import * as os from 'node:os'; import { join } from 'node:path'; -vi.mock('node:os'); +// Store original env +const originalEnv = { ...process.env }; describe('getProjectPath', () => { - it('should return the default project path when CLAUDE_CONFIG_DIR is not set', () => { - vi.spyOn(os, 'homedir').mockReturnValue('/home/user'); - const workingDirectory = '/path/to/project'; - const expectedPath = join('/home/user', '.claude', 'projects', '-path-to-project'); - expect(getProjectPath(workingDirectory)).toBe(expectedPath); - }); - - it('should return the project path based on CLAUDE_CONFIG_DIR when it is set', () => { - process.env.CLAUDE_CONFIG_DIR = '/custom/claude/config'; - const workingDirectory = '/path/to/project'; - const expectedPath = join('/custom/claude/config', 'projects', '-path-to-project'); - expect(getProjectPath(workingDirectory)).toBe(expectedPath); - delete process.env.CLAUDE_CONFIG_DIR; - }); - - it.skipIf(process.platform !== 'win32')('should handle windows paths correctly', () => { - vi.spyOn(os, 'homedir').mockReturnValue('C:\\Users\\user'); - const workingDirectory = 'C:\\path\\to\\project'; - const expectedPath = 'C:\\Users\\user\\.claude\\projects\\C-path-to-project'; - expect(getProjectPath(workingDirectory)).toBe(expectedPath); - }); - - it('should handle relative paths', () => { - vi.spyOn(os, 'homedir').mockReturnValue('/home/user'); - const workingDirectory = 'relative/path'; - const resolvedWorkingDirectory = join(process.cwd(), workingDirectory); - const projectId = resolvedWorkingDirectory.replace(/[\\\/.:]/g, '-'); - const expectedPath = join('/home/user', '.claude', 'projects', projectId); - expect(getProjectPath(workingDirectory)).toBe(expectedPath); - }); + beforeEach(() => { + // Reset process.env to a clean state - make a fresh copy each time + process.env = { ...originalEnv }; + delete process.env.CLAUDE_CONFIG_DIR; + }); + + afterEach(() => { + // Restore original env + process.env = { ...originalEnv }; + }); + + it('should replace slashes with hyphens in the project path', () => { + process.env.CLAUDE_CONFIG_DIR = '/test/home/.claude'; + const workingDir = '/Users/steve/projects/my-app'; + const result = getProjectPath(workingDir); + expect(result).toBe(join('/test/home/.claude', 'projects', '-Users-steve-projects-my-app')); + }); + + it('should replace dots with hyphens in the project path', () => { + process.env.CLAUDE_CONFIG_DIR = '/test/home/.claude'; + const workingDir = '/Users/steve/projects/app.test.js'; + const result = getProjectPath(workingDir); + expect(result).toBe(join('/test/home/.claude', 'projects', '-Users-steve-projects-app-test-js')); + }); + + it('should handle paths with both slashes and dots', () => { + process.env.CLAUDE_CONFIG_DIR = '/test/home/.claude'; + const workingDir = '/var/www/my.site.com/public'; + const result = getProjectPath(workingDir); + expect(result).toBe(join('/test/home/.claude', 'projects', '-var-www-my-site-com-public')); + }); + + it('should handle relative paths by resolving them first', () => { + process.env.CLAUDE_CONFIG_DIR = '/test/home/.claude'; + const workingDir = './my-project'; + const result = getProjectPath(workingDir); + expect(result).toContain(join('/test/home/.claude', 'projects')); + expect(result).toContain('my-project'); + }); + + it('should handle empty directory path', () => { + process.env.CLAUDE_CONFIG_DIR = '/test/home/.claude'; + const workingDir = ''; + const result = getProjectPath(workingDir); + expect(result).toContain(join('/test/home/.claude', 'projects')); + }); + + describe('CLAUDE_CONFIG_DIR support', () => { + it('should use default .claude directory when CLAUDE_CONFIG_DIR is not set', () => { + // When CLAUDE_CONFIG_DIR is not set, it uses homedir()/.claude + const workingDir = '/Users/steve/projects/my-app'; + const result = getProjectPath(workingDir); + expect(result).toContain('projects'); + expect(result).toContain('-Users-steve-projects-my-app'); + }); + + it('should use CLAUDE_CONFIG_DIR when set', () => { + process.env.CLAUDE_CONFIG_DIR = '/custom/claude/config'; + const workingDir = '/Users/steve/projects/my-app'; + const result = getProjectPath(workingDir); + expect(result).toBe(join('/custom/claude/config', 'projects', '-Users-steve-projects-my-app')); + }); + + it('should handle relative CLAUDE_CONFIG_DIR path', () => { + process.env.CLAUDE_CONFIG_DIR = './config/claude'; + const workingDir = '/Users/steve/projects/my-app'; + const result = getProjectPath(workingDir); + expect(result).toBe(join('./config/claude', 'projects', '-Users-steve-projects-my-app')); + }); + + it('should fallback to default when CLAUDE_CONFIG_DIR is empty string', () => { + process.env.CLAUDE_CONFIG_DIR = ''; + const workingDir = '/Users/steve/projects/my-app'; + const result = getProjectPath(workingDir); + // With empty CLAUDE_CONFIG_DIR, it uses homedir()/.claude + expect(result).toContain('projects'); + expect(result).toContain('-Users-steve-projects-my-app'); + }); + + it('should handle CLAUDE_CONFIG_DIR with trailing slash', () => { + process.env.CLAUDE_CONFIG_DIR = '/custom/claude/config/'; + const workingDir = '/Users/steve/projects/my-app'; + const result = getProjectPath(workingDir); + expect(result).toBe(join('/custom/claude/config/', 'projects', '-Users-steve-projects-my-app')); + }); + }); }); diff --git a/src/claude/utils/permissionHandler.ts b/src/claude/utils/permissionHandler.ts index b10600e6..1f8d7b8c 100644 --- a/src/claude/utils/permissionHandler.ts +++ b/src/claude/utils/permissionHandler.ts @@ -5,12 +5,12 @@ * Handles tool permission requests, responses, and state management. */ +import { isDeepStrictEqual } from 'node:util'; import { logger } from "@/lib"; import { SDKAssistantMessage, SDKMessage, SDKUserMessage } from "../sdk"; import { PermissionResult } from "../sdk/types"; import { PLAN_FAKE_REJECT, PLAN_FAKE_RESTART } from "../sdk/prompts"; import { Session } from "../session"; -import { deepEqual } from "@/utils/deepEqual"; import { getToolName } from "./getToolName"; import { EnhancedMode, PermissionMode } from "../loop"; import { getToolDescriptor } from "./getToolDescriptor"; @@ -266,7 +266,7 @@ export class PermissionHandler { // Search in reverse (most recent first) 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.name === name && isDeepStrictEqual(call.input, args)) { if (call.used) { return null; } diff --git a/src/claude/utils/permissionMode.test.ts b/src/claude/utils/permissionMode.test.ts new file mode 100644 index 00000000..526f20b9 --- /dev/null +++ b/src/claude/utils/permissionMode.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { mapToClaudeMode } from './permissionMode'; +import type { PermissionMode } from '@/api/types'; + +describe('mapToClaudeMode', () => { + describe('Codex modes are mapped to Claude equivalents', () => { + it('maps yolo → bypassPermissions', () => { + expect(mapToClaudeMode('yolo')).toBe('bypassPermissions'); + }); + + it('maps safe-yolo → default', () => { + expect(mapToClaudeMode('safe-yolo')).toBe('default'); + }); + + it('maps read-only → default', () => { + expect(mapToClaudeMode('read-only')).toBe('default'); + }); + }); + + describe('Claude modes pass through unchanged', () => { + it('passes through default', () => { + expect(mapToClaudeMode('default')).toBe('default'); + }); + + it('passes through acceptEdits', () => { + expect(mapToClaudeMode('acceptEdits')).toBe('acceptEdits'); + }); + + it('passes through bypassPermissions', () => { + expect(mapToClaudeMode('bypassPermissions')).toBe('bypassPermissions'); + }); + + it('passes through plan', () => { + expect(mapToClaudeMode('plan')).toBe('plan'); + }); + }); + + describe('all 7 PermissionMode values are handled', () => { + const allModes: PermissionMode[] = [ + 'default', 'acceptEdits', 'bypassPermissions', 'plan', // Claude modes + 'read-only', 'safe-yolo', 'yolo' // Codex modes + ]; + + it('returns a valid Claude mode for every PermissionMode', () => { + const validClaudeModes = ['default', 'acceptEdits', 'bypassPermissions', 'plan']; + + allModes.forEach(mode => { + const result = mapToClaudeMode(mode); + expect(validClaudeModes).toContain(result); + }); + }); + }); +}); diff --git a/src/claude/utils/permissionMode.ts b/src/claude/utils/permissionMode.ts new file mode 100644 index 00000000..204a7029 --- /dev/null +++ b/src/claude/utils/permissionMode.ts @@ -0,0 +1,26 @@ +import type { QueryOptions } from '@/claude/sdk'; +import type { PermissionMode } from '@/api/types'; + +/** Derived from SDK's QueryOptions - the modes Claude actually supports */ +export type ClaudeSdkPermissionMode = NonNullable; + +/** + * Map any PermissionMode (7 modes) to a Claude-compatible mode (4 modes) + * This is the ONLY place where Codex modes are mapped to Claude equivalents. + * + * Mapping: + * - yolo → bypassPermissions (both skip all permissions) + * - safe-yolo → default (ask for permissions) + * - read-only → default (Claude doesn't support read-only) + * + * Claude modes pass through unchanged: + * - default, acceptEdits, bypassPermissions, plan + */ +export function mapToClaudeMode(mode: PermissionMode): ClaudeSdkPermissionMode { + const codexToClaudeMap: Record = { + 'yolo': 'bypassPermissions', + 'safe-yolo': 'default', + 'read-only': 'default', + }; + return codexToClaudeMap[mode] ?? (mode as ClaudeSdkPermissionMode); +} diff --git a/src/codex/codexMcpClient.ts b/src/codex/codexMcpClient.ts index 0c7097d1..bba776bc 100644 --- a/src/codex/codexMcpClient.ts +++ b/src/codex/codexMcpClient.ts @@ -16,12 +16,16 @@ const DEFAULT_TIMEOUT = 14 * 24 * 60 * 60 * 1000; // 14 days, which is the half /** * Get the correct MCP subcommand based on installed codex version * Versions >= 0.43.0-alpha.5 use 'mcp-server', older versions use 'mcp' + * Returns null if codex is not installed or version cannot be determined */ -function getCodexMcpCommand(): string { +function getCodexMcpCommand(): string | null { try { const version = execSync('codex --version', { encoding: 'utf8' }).trim(); const match = version.match(/codex-cli\s+(\d+\.\d+\.\d+(?:-alpha\.\d+)?)/); - if (!match) return 'mcp-server'; // Default to newer command if we can't parse + if (!match) { + logger.debug('[CodexMCP] Could not parse codex version:', version); + return null; + } const versionStr = match[1]; const [major, minor, patch] = versionStr.split(/[-.]/).map(Number); @@ -38,8 +42,8 @@ function getCodexMcpCommand(): string { } return 'mcp'; // Older versions use mcp } catch (error) { - logger.debug('[CodexMCP] Error detecting codex version, defaulting to mcp-server:', error); - return 'mcp-server'; // Default to newer command + logger.debug('[CodexMCP] Codex CLI not found or not executable:', error); + return null; } } @@ -85,6 +89,19 @@ export class CodexMcpClient { if (this.connected) return; const mcpCommand = getCodexMcpCommand(); + + if (mcpCommand === null) { + throw new Error( + 'Codex CLI not found or not executable.\n' + + '\n' + + 'To install codex:\n' + + ' npm install -g @openai/codex\n' + + '\n' + + 'Alternatively, use Claude:\n' + + ' happy claude' + ); + } + logger.debug(`[CodexMCP] Connecting to Codex MCP server using command: codex ${mcpCommand}`); this.transport = new StdioClientTransport({ diff --git a/src/codex/runCodex.ts b/src/codex/runCodex.ts index d789bb19..df8aa6a4 100644 --- a/src/codex/runCodex.ts +++ b/src/codex/runCodex.ts @@ -8,7 +8,6 @@ import { DiffProcessor } from './utils/diffProcessor'; import { randomUUID } from 'node:crypto'; import { logger } from '@/ui/logger'; import { Credentials, readSettings } from '@/persistence'; -import { AgentState, Metadata } from '@/api/types'; import { initialMachineMetadata } from '@/daemon/run'; import { configuration } from '@/configuration'; import packageJson from '../../package.json'; @@ -17,6 +16,7 @@ import { MessageQueue2 } from '@/utils/MessageQueue2'; import { hashObject } from '@/utils/deterministicJson'; import { projectPath } from '@/projectPath'; import { resolve, join } from 'node:path'; +import { createSessionMetadata } from '@/utils/createSessionMetadata'; import fs from 'node:fs'; import { startHappyServer } from '@/claude/utils/startHappyServer'; import { MessageBuffer } from "@/ui/ink/messageBuffer"; @@ -28,6 +28,9 @@ import { notifyDaemonSessionStarted } from "@/daemon/controlClient"; import { registerKillSessionHandler } from "@/claude/registerKillSessionHandler"; import { delay } from "@/utils/time"; import { stopCaffeinate } from "@/utils/caffeinate"; +import { connectionState } from '@/utils/serverConnectionErrors'; +import { setupOfflineReconnection } from '@/utils/setupOfflineReconnection'; +import type { ApiSessionClient } from '@/api/apiSession'; type ReadyEventOptions = { pending: unknown; @@ -64,7 +67,8 @@ export async function runCodex(opts: { credentials: Credentials; startedBy?: 'daemon' | 'terminal'; }): Promise { - type PermissionMode = 'default' | 'read-only' | 'safe-yolo' | 'yolo'; + // Use shared PermissionMode type for cross-agent compatibility + type PermissionMode = import('@/api/types').PermissionMode; interface EnhancedMode { permissionMode: PermissionMode; model?: string; @@ -75,6 +79,10 @@ export async function runCodex(opts: { // const sessionTag = randomUUID(); + + // Set backend for offline warnings (before any API calls) + connectionState.setBackend('Codex'); + const api = await ApiClient.create(opts.credentials); // Log startup options @@ -100,41 +108,47 @@ export async function runCodex(opts: { // Create session // - let state: AgentState = { - controlledByUser: false, - } - let metadata: Metadata = { - path: process.cwd(), - host: os.hostname(), - version: packageJson.version, - os: os.platform(), - machineId: machineId, - homeDir: os.homedir(), - happyHomeDir: configuration.happyHomeDir, - happyLibDir: projectPath(), - happyToolsDir: resolve(projectPath(), 'tools', 'unpacked'), - startedFromDaemon: opts.startedBy === 'daemon', - hostPid: process.pid, - startedBy: opts.startedBy || 'terminal', - // Initialize lifecycle state - lifecycleState: 'running', - lifecycleStateSince: Date.now(), - flavor: 'codex' - }; + const { state, metadata } = createSessionMetadata({ + flavor: 'codex', + machineId, + startedBy: opts.startedBy + }); const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); - const session = api.sessionSyncClient(response); - // Always report to daemon if it exists - 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`); + // Handle server unreachable case - create offline stub with hot reconnection + let session: ApiSessionClient; + // Permission handler declared here so it can be updated in onSessionSwap callback + // (assigned later at line ~385 after client setup) + let permissionHandler: CodexPermissionHandler; + const { session: initialSession, reconnectionHandle } = setupOfflineReconnection({ + api, + sessionTag, + metadata, + state, + response, + onSessionSwap: (newSession) => { + session = newSession; + // Update permission handler with new session to avoid stale reference + if (permissionHandler) { + permissionHandler.updateSession(newSession); + } + } + }); + session = initialSession; + + // Always report to daemon if it exists (skip if offline) + if (response) { + 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); } - } catch (error) { - logger.debug('[START] Failed to report to daemon (may not be running):', error); } const messageQueue = new MessageQueue2((mode) => hashObject({ @@ -143,21 +157,17 @@ export async function runCodex(opts: { })); // Track current overrides to apply per message - let currentPermissionMode: PermissionMode | undefined = undefined; + // Use shared PermissionMode type from api/types for cross-agent compatibility + let currentPermissionMode: import('@/api/types').PermissionMode | undefined = undefined; let currentModel: string | undefined = undefined; session.onUserMessage((message) => { - // Resolve permission mode (validate) + // Resolve permission mode (accept all modes, will be mapped in switch statement) let messagePermissionMode = currentPermissionMode; if (message.meta?.permissionMode) { - const validModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; - if (validModes.includes(message.meta.permissionMode as PermissionMode)) { - messagePermissionMode = message.meta.permissionMode as PermissionMode; - currentPermissionMode = messagePermissionMode; - logger.debug(`[Codex] Permission mode updated from user message to: ${currentPermissionMode}`); - } else { - logger.debug(`[Codex] Invalid permission mode received: ${message.meta.permissionMode}`); - } + messagePermissionMode = message.meta.permissionMode as import('@/api/types').PermissionMode; + currentPermissionMode = messagePermissionMode; + logger.debug(`[Codex] Permission mode updated from user message to: ${currentPermissionMode}`); } else { logger.debug(`[Codex] User message received with no permission mode override, using current: ${currentPermissionMode ?? 'default (effective)'}`); } @@ -381,7 +391,7 @@ export async function runCodex(opts: { return null; } } - const permissionHandler = new CodexPermissionHandler(session); + permissionHandler = new CodexPermissionHandler(session); const reasoningProcessor = new ReasoningProcessor((message) => { // Callback to send messages directly from the processor session.sendCodexMessage(message); @@ -620,18 +630,30 @@ export async function runCodex(opts: { // Map permission mode to approval policy and sandbox for startSession const approvalPolicy = (() => { switch (message.mode.permissionMode) { - case 'default': return 'untrusted' as const; - case 'read-only': return 'never' as const; - case 'safe-yolo': return 'on-failure' as const; - case 'yolo': return 'on-failure' as const; + // Codex native modes + case 'default': return 'untrusted' as const; // Ask for non-trusted commands + case 'read-only': return 'never' as const; // Never ask, read-only enforced by sandbox + case 'safe-yolo': return 'on-failure' as const; // Auto-run, ask only on failure + case 'yolo': return 'on-failure' as const; // Auto-run, ask only on failure + // Defensive fallback for Claude-specific modes (backward compatibility) + case 'bypassPermissions': return 'on-failure' as const; // Full access: map to yolo behavior + case 'acceptEdits': return 'on-request' as const; // Let model decide (closest to auto-approve edits) + case 'plan': return 'untrusted' as const; // Conservative: ask for non-trusted + default: return 'untrusted' as const; // Safe fallback } })(); const sandbox = (() => { switch (message.mode.permissionMode) { - case 'default': return 'workspace-write' as const; - case 'read-only': return 'read-only' as const; - case 'safe-yolo': return 'workspace-write' as const; - case 'yolo': return 'danger-full-access' as const; + // Codex native modes + case 'default': return 'workspace-write' as const; // Can write in workspace + case 'read-only': return 'read-only' as const; // Read-only filesystem + case 'safe-yolo': return 'workspace-write' as const; // Can write in workspace + case 'yolo': return 'danger-full-access' as const; // Full system access + // Defensive fallback for Claude-specific modes + case 'bypassPermissions': return 'danger-full-access' as const; // Full access: map to yolo + case 'acceptEdits': return 'workspace-write' as const; // Can edit files in workspace + case 'plan': return 'workspace-write' as const; // Can write for planning + default: return 'workspace-write' as const; // Safe default } })(); @@ -726,6 +748,13 @@ export async function runCodex(opts: { // Clean up resources when main loop exits logger.debug('[codex]: Final cleanup start'); logActiveHandles('cleanup-start'); + + // Cancel offline reconnection if still running + if (reconnectionHandle) { + logger.debug('[codex]: Cancelling offline reconnection'); + reconnectionHandle.cancel(); + } + try { logger.debug('[codex]: sendSessionDeath'); session.sendSessionDeath(); diff --git a/src/codex/utils/permissionHandler.ts b/src/codex/utils/permissionHandler.ts index ff7afee5..0720211d 100644 --- a/src/codex/utils/permissionHandler.ts +++ b/src/codex/utils/permissionHandler.ts @@ -1,38 +1,31 @@ /** - * Permission Handler for Codex tool approval integration - * + * Codex Permission Handler + * * Handles tool permission requests and responses for Codex sessions. - * Simpler than Claude's permission handler since we get tool IDs directly. + * Extends BasePermissionHandler with Codex-specific configuration. */ import { logger } from "@/ui/logger"; import { ApiSessionClient } from "@/api/apiSession"; -import { AgentState } from "@/api/types"; +import { + BasePermissionHandler, + PermissionResult, + PendingRequest +} from '@/utils/BasePermissionHandler'; -interface PermissionResponse { - id: string; - approved: boolean; - decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'; -} - -interface PendingRequest { - resolve: (value: PermissionResult) => void; - reject: (error: Error) => void; - toolName: string; - input: unknown; -} - -interface PermissionResult { - decision: 'approved' | 'approved_for_session' | 'denied' | 'abort'; -} - -export class CodexPermissionHandler { - private pendingRequests = new Map(); - private session: ApiSessionClient; +// Re-export types for backwards compatibility +export type { PermissionResult, PendingRequest }; +/** + * Codex-specific permission handler. + */ +export class CodexPermissionHandler extends BasePermissionHandler { constructor(session: ApiSessionClient) { - this.session = session; - this.setupRpcHandler(); + super(session); + } + + protected getLogPrefix(): string { + return '[Codex]'; } /** @@ -56,123 +49,10 @@ export class CodexPermissionHandler { input }); - // Send push notification - // this.session.api.push().sendToAllDevices( - // 'Permission Request', - // `Codex wants to use ${toolName}`, - // { - // sessionId: this.session.sessionId, - // requestId: toolCallId, - // tool: toolName, - // type: 'permission_request' - // } - // ); - // Update agent state with pending request - this.session.updateAgentState((currentState) => ({ - ...currentState, - requests: { - ...currentState.requests, - [toolCallId]: { - tool: toolName, - arguments: input, - createdAt: Date.now() - } - } - })); - - logger.debug(`[Codex] Permission request sent for tool: ${toolName} (${toolCallId})`); - }); - } - - /** - * Setup RPC handler for permission responses - */ - private setupRpcHandler(): void { - this.session.rpcHandlerManager.registerHandler( - 'permission', - async (response) => { - // console.log(`[Codex] Permission response received:`, response); - - const pending = this.pendingRequests.get(response.id); - if (!pending) { - logger.debug('[Codex] Permission request not found or already resolved'); - return; - } + this.addPendingRequestToState(toolCallId, toolName, input); - // Remove from pending - this.pendingRequests.delete(response.id); - - // Resolve the permission request - const result: PermissionResult = response.approved - ? { decision: response.decision === 'approved_for_session' ? 'approved_for_session' : 'approved' } - : { decision: response.decision === 'denied' ? 'denied' : 'abort' }; - - pending.resolve(result); - - // Move request to completed in agent state - this.session.updateAgentState((currentState) => { - const request = currentState.requests?.[response.id]; - if (!request) return currentState; - - // console.log(`[Codex] Permission ${response.approved ? 'approved' : 'denied'} for ${pending.toolName}`); - - const { [response.id]: _, ...remainingRequests } = currentState.requests || {}; - - let res = { - ...currentState, - requests: remainingRequests, - completedRequests: { - ...currentState.completedRequests, - [response.id]: { - ...request, - completedAt: Date.now(), - status: response.approved ? 'approved' : 'denied', - decision: result.decision - } - } - } satisfies AgentState; - // console.log(`[Codex] Updated agent state:`, res); - return res; - }); - - logger.debug(`[Codex] Permission ${response.approved ? 'approved' : 'denied'} for ${pending.toolName}`); - } - ); - } - - /** - * Reset state for new sessions - */ - reset(): void { - // Reject all pending requests - for (const [id, pending] of this.pendingRequests.entries()) { - pending.reject(new Error('Session reset')); - } - this.pendingRequests.clear(); - - // Clear requests in agent state - this.session.updateAgentState((currentState) => { - const pendingRequests = currentState.requests || {}; - const completedRequests = { ...currentState.completedRequests }; - - // Move all pending to completed as canceled - for (const [id, request] of Object.entries(pendingRequests)) { - completedRequests[id] = { - ...request, - completedAt: Date.now(), - status: 'canceled', - reason: 'Session reset' - }; - } - - return { - ...currentState, - requests: {}, - completedRequests - }; + logger.debug(`${this.getLogPrefix()} Permission request sent for tool: ${toolName} (${toolCallId})`); }); - - logger.debug('[Codex] Permission handler reset'); } } \ No newline at end of file diff --git a/src/codex/utils/reasoningProcessor.ts b/src/codex/utils/reasoningProcessor.ts index f964c0c0..a655c0b0 100644 --- a/src/codex/utils/reasoningProcessor.ts +++ b/src/codex/utils/reasoningProcessor.ts @@ -1,263 +1,44 @@ /** - * Reasoning Processor - Handles streaming reasoning deltas and identifies reasoning tools - * - * This processor accumulates agent_reasoning_delta events and identifies when - * reasoning sections start with **[Title]** format, treating them as tool calls. + * Codex Reasoning Processor + * + * Handles streaming reasoning deltas and identifies reasoning tools for Codex. + * Extends BaseReasoningProcessor with Codex-specific configuration. */ -import { randomUUID } from 'node:crypto'; -import { logger } from '@/ui/logger'; +import { + BaseReasoningProcessor, + ReasoningToolCall, + ReasoningToolResult, + ReasoningMessage, + ReasoningOutput +} from '@/utils/BaseReasoningProcessor'; -export interface ReasoningToolCall { - type: 'tool-call'; - name: 'CodexReasoning'; - callId: string; - input: { - title: string; - }; - id: string; -} - -export interface ReasoningToolResult { - type: 'tool-call-result'; - callId: string; - output: { - content?: string; - status?: 'completed' | 'canceled'; - }; - id: string; -} - -export interface ReasoningMessage { - type: 'reasoning'; - message: string; - id: string; -} - -export type ReasoningOutput = ReasoningToolCall | ReasoningToolResult | ReasoningMessage; - -export class ReasoningProcessor { - private accumulator: string = ''; - private inTitleCapture: boolean = false; - private titleBuffer: string = ''; - private contentBuffer: string = ''; - private hasTitle: boolean = false; - private currentCallId: string | null = null; - private toolCallStarted: boolean = false; - private currentTitle: string | null = null; - private onMessage: ((message: any) => void) | null = null; +// Re-export types for backwards compatibility +export type { ReasoningToolCall, ReasoningToolResult, ReasoningMessage, ReasoningOutput }; - constructor(onMessage?: (message: any) => void) { - this.onMessage = onMessage || null; - this.reset(); +/** + * Codex-specific reasoning processor. + */ +export class ReasoningProcessor extends BaseReasoningProcessor { + protected getToolName(): string { + return 'CodexReasoning'; } - /** - * Set the message callback for sending messages directly - */ - setMessageCallback(callback: (message: any) => void): void { - this.onMessage = callback; + protected getLogPrefix(): string { + return '[ReasoningProcessor]'; } /** - * Process a reasoning section break - indicates a new reasoning section is starting - */ - handleSectionBreak(): void { - this.finishCurrentToolCall('canceled'); - this.resetState(); - logger.debug('[ReasoningProcessor] Section break - reset state'); - } - - /** - * Process a reasoning delta and accumulate content + * Process a reasoning delta and accumulate content. */ processDelta(delta: string): void { - this.accumulator += delta; - - // If we haven't started processing yet, check if this starts with ** - if (!this.inTitleCapture && !this.hasTitle && !this.contentBuffer) { - if (this.accumulator.startsWith('**')) { - // Start title capture - this.inTitleCapture = true; - this.titleBuffer = this.accumulator.substring(2); // Remove leading ** - logger.debug('[ReasoningProcessor] Started title capture'); - } else if (this.accumulator.length > 0) { - // This is untitled reasoning, just accumulate as content - this.contentBuffer = this.accumulator; - } - } else if (this.inTitleCapture) { - // We're capturing the title - this.titleBuffer = this.accumulator.substring(2); // Keep updating from start - - // Check if we've found the closing ** - const titleEndIndex = this.titleBuffer.indexOf('**'); - if (titleEndIndex !== -1) { - // Found the end of title - const title = this.titleBuffer.substring(0, titleEndIndex); - const afterTitle = this.titleBuffer.substring(titleEndIndex + 2); - - this.hasTitle = true; - this.inTitleCapture = false; - this.currentTitle = title; - this.contentBuffer = afterTitle; - - // Generate a call ID for this reasoning section - this.currentCallId = randomUUID(); - - logger.debug(`[ReasoningProcessor] Title captured: "${title}"`); - - // Send tool call immediately when title is detected - this.sendToolCallStart(title); - } - } else if (this.hasTitle) { - // We have a title, accumulate content after title - this.contentBuffer = this.accumulator.substring( - this.accumulator.indexOf('**') + 2 + - this.currentTitle!.length + 2 - ); - } else { - // Untitled reasoning, just accumulate - this.contentBuffer = this.accumulator; - } - } - - /** - * Send the tool call start message - */ - private sendToolCallStart(title: string): void { - if (!this.currentCallId || this.toolCallStarted) { - return; - } - - const toolCall: ReasoningToolCall = { - type: 'tool-call', - name: 'CodexReasoning', - callId: this.currentCallId, - input: { - title: title - }, - id: randomUUID() - }; - - logger.debug(`[ReasoningProcessor] Sending tool call start for: "${title}"`); - this.onMessage?.(toolCall); - this.toolCallStarted = true; + this.processInput(delta); } /** - * Complete the reasoning section with final text + * Complete the reasoning section with final text. */ complete(fullText: string): void { - // Extract title and content if present - let title: string | undefined; - let content: string = fullText; - - if (fullText.startsWith('**')) { - const titleEndIndex = fullText.indexOf('**', 2); - if (titleEndIndex !== -1) { - title = fullText.substring(2, titleEndIndex); - content = fullText.substring(titleEndIndex + 2).trim(); - } - } - - logger.debug(`[ReasoningProcessor] Complete reasoning - Title: "${title}", Has content: ${content.length > 0}`); - - if (title && !this.toolCallStarted) { - // If we have a title but haven't sent the tool call yet, send it now - this.currentCallId = this.currentCallId || randomUUID(); - this.sendToolCallStart(title); - } - - if (this.toolCallStarted && this.currentCallId) { - // Send tool call result for titled reasoning - const toolResult: ReasoningToolResult = { - type: 'tool-call-result', - callId: this.currentCallId, - output: { - content: content, - status: 'completed' - }, - id: randomUUID() - }; - logger.debug('[ReasoningProcessor] Sending tool call result'); - this.onMessage?.(toolResult); - } else { - // Send regular reasoning message for untitled reasoning - const reasoningMessage: ReasoningMessage = { - type: 'reasoning', - message: content, - id: randomUUID() - }; - logger.debug('[ReasoningProcessor] Sending reasoning message'); - this.onMessage?.(reasoningMessage); - } - - // Reset state after completion - this.resetState(); + this.completeReasoning(fullText); } - - /** - * Abort the current reasoning section - */ - abort(): void { - logger.debug('[ReasoningProcessor] Abort called'); - this.finishCurrentToolCall('canceled'); - this.resetState(); - } - - /** - * Reset the processor state - */ - reset(): void { - this.finishCurrentToolCall('canceled'); - this.resetState(); - } - - /** - * Finish current tool call if one is in progress - */ - private finishCurrentToolCall(status: 'completed' | 'canceled'): void { - if (this.toolCallStarted && this.currentCallId) { - // Send tool call result with canceled status - const toolResult: ReasoningToolResult = { - type: 'tool-call-result', - callId: this.currentCallId, - output: { - content: this.contentBuffer || '', - status: status - }, - id: randomUUID() - }; - logger.debug(`[ReasoningProcessor] Sending tool call result with status: ${status}`); - this.onMessage?.(toolResult); - } - } - - /** - * Reset internal state - */ - private resetState(): void { - this.accumulator = ''; - this.inTitleCapture = false; - this.titleBuffer = ''; - this.contentBuffer = ''; - this.hasTitle = false; - this.currentCallId = null; - this.toolCallStarted = false; - this.currentTitle = null; - } - - /** - * Get the current call ID for tool result matching - */ - getCurrentCallId(): string | null { - return this.currentCallId; - } - - /** - * Check if a tool call has been started - */ - hasStartedToolCall(): boolean { - return this.toolCallStarted; - } -} \ No newline at end of file +} diff --git a/src/commands/auth.ts b/src/commands/auth.ts index f090a65c..35cbe8a5 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -23,9 +23,6 @@ export async function handleAuthCommand(args: string[]): Promise { case 'logout': await handleAuthLogout(); break; - // case 'backup': - // await handleAuthShowBackup(); - // break; case 'status': await handleAuthStatus(); break; @@ -42,9 +39,8 @@ ${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 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:')} @@ -163,42 +159,6 @@ async function handleAuthLogout(): Promise { } } -// async function handleAuthShowBackup(): Promise { -// const credentials = await readCredentials(); -// const settings = await readSettings(); - -// if (!credentials) { -// console.log(chalk.yellow('Not authenticated')); -// console.log(chalk.gray('Run "happy auth login" to authenticate first')); -// return; -// } - -// // Format the backup key exactly like the mobile client expects -// // Mobile client uses formatSecretKeyForBackup which converts to base32 with dashes -// const formattedBackupKey = formatSecretKeyForBackup(credentials.encryption.secret); - -// console.log(chalk.bold('\nšŸ“± Backup Key\n')); - -// // Display in the format XXXXX-XXXXX-XXXXX-... that mobile expects -// console.log(chalk.cyan('Your backup key:')); -// console.log(chalk.bold(formattedBackupKey)); -// console.log(''); - -// console.log(chalk.cyan('Machine Information:')); -// console.log(` Machine ID: ${settings?.machineId || 'not set'}`); -// console.log(` Host: ${os.hostname()}`); -// console.log(''); - -// console.log(chalk.bold('How to use this backup key:')); -// console.log(chalk.gray('• In Happy mobile app: Go to restore/link device and enter this key')); -// console.log(chalk.gray('• This key format matches what the mobile app expects')); -// console.log(chalk.gray('• You can type it with or without dashes - the app will normalize it')); -// console.log(chalk.gray('• Common typos (0→O, 1→I) are automatically corrected')); -// console.log(''); - -// console.log(chalk.yellow('āš ļø Keep this key secure - it provides full access to your account')); -// } - async function handleAuthStatus(): Promise { const credentials = await readCredentials(); const settings = await readSettings(); diff --git a/src/configuration.ts b/src/configuration.ts index 5da629f5..830eb300 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -56,6 +56,23 @@ class Configuration { this.currentCliVersion = packageJson.version + // Validate variant configuration + const variant = process.env.HAPPY_VARIANT || 'stable' + if (variant === 'dev' && !this.happyHomeDir.includes('dev')) { + console.warn('āš ļø WARNING: HAPPY_VARIANT=dev but HAPPY_HOME_DIR does not contain "dev"') + console.warn(` Current: ${this.happyHomeDir}`) + console.warn(` Expected: Should contain "dev" (e.g., ~/.happy-dev)`) + } + + // Visual indicator on CLI startup (only if not daemon process to avoid log clutter) + if (!this.isDaemonProcess) { + if (variant === 'dev') { + console.log('\x1b[33mšŸ”§ DEV MODE\x1b[0m - Data: ' + this.happyHomeDir) + } else { + console.log('\x1b[32māœ… STABLE MODE\x1b[0m - Data: ' + this.happyHomeDir) + } + } + if (!existsSync(this.happyHomeDir)) { mkdirSync(this.happyHomeDir, { recursive: true }) } diff --git a/src/daemon/run.ts b/src/daemon/run.ts index ba2b2e66..75889d14 100644 --- a/src/daemon/run.ts +++ b/src/daemon/run.ts @@ -13,13 +13,15 @@ import { startCaffeinate, stopCaffeinate } from '@/utils/caffeinate'; import packageJson from '../../package.json'; import { getEnvironmentInfo } from '@/ui/doctor'; import { spawnHappyCLI } from '@/utils/spawnHappyCLI'; -import { writeDaemonState, DaemonLocallyPersistedState, readDaemonState, acquireDaemonLock, releaseDaemonLock } from '@/persistence'; +import { writeDaemonState, DaemonLocallyPersistedState, readDaemonState, acquireDaemonLock, releaseDaemonLock, readSettings, getActiveProfile, getEnvironmentVariables, validateProfileForAgent, getProfileEnvironmentVariables } from '@/persistence'; import { cleanupDaemonState, isDaemonRunningCurrentlyInstalledHappyVersion, stopDaemon } from './controlClient'; import { startDaemonControlServer } from './controlServer'; import { readFileSync } from 'fs'; import { join } from 'path'; import { projectPath } from '@/projectPath'; +import { getTmuxUtilities, isTmuxAvailable, parseTmuxSessionIdentifier, formatTmuxSessionIdentifier } from '@/utils/tmux'; +import { expandEnvironmentVariables } from '@/utils/expandEnvVars'; // Prepare initial metadata export const initialMachineMetadata: MachineMetadata = { @@ -31,6 +33,37 @@ export const initialMachineMetadata: MachineMetadata = { happyLibDir: projectPath() }; +// Get environment variables for a profile, filtered for agent compatibility +async function getProfileEnvironmentVariablesForAgent( + profileId: string, + agentType: 'claude' | 'codex' | 'gemini' +): Promise> { + try { + const settings = await readSettings(); + const profile = settings.profiles.find(p => p.id === profileId); + + if (!profile) { + logger.debug(`[DAEMON RUN] Profile ${profileId} not found`); + return {}; + } + + // Check if profile is compatible with the agent + if (!validateProfileForAgent(profile, agentType)) { + logger.debug(`[DAEMON RUN] Profile ${profileId} not compatible with agent ${agentType}`); + return {}; + } + + // Get environment variables from profile (new schema) + const envVars = getProfileEnvironmentVariables(profile); + + logger.debug(`[DAEMON RUN] Loaded ${Object.keys(envVars).length} environment variables from profile ${profileId} for agent ${agentType}`); + return envVars; + } catch (error) { + logger.debug('[DAEMON RUN] Failed to get profile environment variables:', error); + return {}; + } +} + export async function startDaemon(): Promise { // We don't have cleanup function at the time of server construction // Control flow is: @@ -234,8 +267,13 @@ export async function startDaemon(): Promise { try { - // Resolve authentication token if provided - let extraEnv: Record = {}; + // Build environment variables with explicit precedence layers: + // Layer 1 (base): Authentication tokens - protected, cannot be overridden + // Layer 2 (middle): Profile environment variables - GUI profile OR CLI local profile + // Layer 3 (top): Auth tokens again to ensure they're never overridden + + // Layer 1: Resolve authentication token if provided + const authEnv: Record = {}; if (options.token) { if (options.agent === 'codex') { @@ -246,123 +284,306 @@ export async function startDaemon(): Promise { fs.writeFile(join(codexHomeDir.name, 'auth.json'), options.token); // Set the environment variable for Codex - extraEnv = { - CODEX_HOME: codexHomeDir.name - }; + authEnv.CODEX_HOME = codexHomeDir.name; } else { // Assuming claude - extraEnv = { - CLAUDE_CODE_OAUTH_TOKEN: options.token - }; + authEnv.CLAUDE_CODE_OAUTH_TOKEN = options.token; } } - // Construct arguments for the CLI - let agentCommand: string; - switch (options.agent) { - case 'claude': - case undefined: - agentCommand = 'claude'; - break; - case 'codex': - agentCommand = 'codex'; - break; - case 'gemini': - agentCommand = 'gemini'; - break; - default: - return { - type: 'error', - errorMessage: `Unsupported agent type: '${options.agent}'. Please update your CLI to the latest version.` - }; - } - const args = [ - agentCommand, - '--happy-starting-mode', 'remote', - '--started-by', 'daemon' - ]; - - // TODO: In future, sessionId could be used with --resume to continue existing sessions - // For now, we ignore it - each spawn creates a new session - 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 + // Layer 2: Profile environment variables + // Priority: GUI-provided profile > CLI local active profile > none + let profileEnv: Record = {}; + + if (options.environmentVariables && Object.keys(options.environmentVariables).length > 0) { + // GUI provided profile environment variables - highest priority for profile settings + profileEnv = options.environmentVariables; + logger.info(`[DAEMON RUN] Using GUI-provided profile environment variables (${Object.keys(profileEnv).length} vars)`); + logger.debug(`[DAEMON RUN] GUI profile env var keys: ${Object.keys(profileEnv).join(', ')}`); + } else { + // Fallback to CLI local active profile + try { + const settings = await readSettings(); + if (settings.activeProfileId) { + logger.debug(`[DAEMON RUN] No GUI profile provided, loading CLI local active profile: ${settings.activeProfileId}`); + + // Get profile environment variables filtered for agent compatibility + profileEnv = await getProfileEnvironmentVariablesForAgent( + settings.activeProfileId, + options.agent || 'claude' + ); + + logger.debug(`[DAEMON RUN] Loaded ${Object.keys(profileEnv).length} environment variables from CLI local profile for agent ${options.agent || 'claude'}`); + logger.debug(`[DAEMON RUN] CLI profile env var keys: ${Object.keys(profileEnv).join(', ')}`); + } else { + logger.debug('[DAEMON RUN] No CLI local active profile set'); + } + } catch (error) { + logger.debug('[DAEMON RUN] Failed to load CLI local profile environment variables:', error); + // Continue without profile env vars - this is not a fatal error } + } + + // Final merge: Profile vars first, then auth (auth takes precedence to protect authentication) + let extraEnv = { ...profileEnv, ...authEnv }; + logger.debug(`[DAEMON RUN] Final environment variable keys (before expansion) (${Object.keys(extraEnv).length}): ${Object.keys(extraEnv).join(', ')}`); + + // Expand ${VAR} references from daemon's process.env + // This ensures variable substitution works in both tmux and non-tmux modes + // Example: ANTHROPIC_AUTH_TOKEN="${Z_AI_AUTH_TOKEN}" → ANTHROPIC_AUTH_TOKEN="sk-real-key" + extraEnv = expandEnvironmentVariables(extraEnv, process.env); + logger.debug(`[DAEMON RUN] After variable expansion: ${Object.keys(extraEnv).join(', ')}`); + + // Fail-fast validation: Check that any auth variables present are fully expanded + // Only validate variables that are actually set (different agents need different auth) + const potentialAuthVars = ['ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', 'OPENAI_API_KEY', 'CODEX_HOME', 'AZURE_OPENAI_API_KEY', 'TOGETHER_API_KEY']; + const unexpandedAuthVars = potentialAuthVars.filter(varName => { + const value = extraEnv[varName]; + // Only fail if variable IS SET and contains unexpanded ${VAR} references + return value && typeof value === 'string' && value.includes('${'); }); - // Log output for debugging - 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 (unexpandedAuthVars.length > 0) { + // Extract the specific missing variable names from unexpanded references + const missingVarDetails = unexpandedAuthVars.map(authVar => { + const value = extraEnv[authVar]; + const unresolvedMatch = value?.match(/\$\{([A-Z_][A-Z0-9_]*)(:-[^}]*)?\}/); + const missingVar = unresolvedMatch ? unresolvedMatch[1] : 'unknown'; + return `${authVar} references \${${missingVar}} which is not defined`; }); - } - if (!happyProcess.pid) { - logger.debug('[DAEMON RUN] Failed to spawn process - no PID returned'); + const errorMessage = `Authentication will fail - environment variables not found in daemon: ${missingVarDetails.join('; ')}. ` + + `Ensure these variables are set in the daemon's environment (not just your shell) before starting sessions.`; + logger.warn(`[DAEMON RUN] ${errorMessage}`); return { type: 'error', - errorMessage: 'Failed to spawn Happy process - no PID returned' + errorMessage }; } - logger.debug(`[DAEMON RUN] Spawned process with PID ${happyProcess.pid}`); + // Check if tmux is available and should be used + const tmuxAvailable = await isTmuxAvailable(); + let useTmux = tmuxAvailable; - const trackedSession: 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.` : undefined - }; + // Get tmux session name from environment variables (now set by profile system) + // Empty string means "use current/most recent session" (tmux default behavior) + let tmuxSessionName: string | undefined = extraEnv.TMUX_SESSION_NAME; - pidToTrackedSession.set(happyProcess.pid, trackedSession); + // If tmux is not available or session name is explicitly undefined, fall back to regular spawning + // Note: Empty string is valid (means use current/most recent tmux session) + if (!tmuxAvailable || tmuxSessionName === undefined) { + useTmux = false; + if (tmuxSessionName !== undefined) { + logger.debug(`[DAEMON RUN] tmux session name specified but tmux not available, falling back to regular spawning`); + } + } - 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); + if (useTmux && tmuxSessionName !== undefined) { + // Try to spawn in tmux session + const sessionDesc = tmuxSessionName || 'current/most recent session'; + logger.debug(`[DAEMON RUN] Attempting to spawn session in tmux: ${sessionDesc}`); + + const tmux = getTmuxUtilities(tmuxSessionName); + + // Construct command for the CLI + const cliPath = join(projectPath(), 'dist', 'index.mjs'); + // Determine agent command - support claude, codex, and gemini + const agent = options.agent === 'gemini' ? 'gemini' : (options.agent === 'codex' ? 'codex' : 'claude'); + const fullCommand = `node --no-warnings --no-deprecation ${cliPath} ${agent} --happy-starting-mode remote --started-by daemon`; + + // Spawn in tmux with environment variables + // IMPORTANT: Pass complete environment (process.env + extraEnv) because: + // 1. tmux sessions need daemon's expanded auth variables (e.g., ANTHROPIC_AUTH_TOKEN) + // 2. Regular spawn uses env: { ...process.env, ...extraEnv } + // 3. tmux needs explicit environment via -e flags to ensure all variables are available + const windowName = `happy-${Date.now()}-${agent}`; + const tmuxEnv: Record = {}; + + // Add all daemon environment variables (filtering out undefined) + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) { + tmuxEnv[key] = value; + } } - }); - happyProcess.on('error', (error) => { - logger.debug(`[DAEMON RUN] Child process error:`, error); - if (happyProcess.pid) { - onChildExited(happyProcess.pid); + // Add extra environment variables (these should already be filtered) + Object.assign(tmuxEnv, extraEnv); + + const tmuxResult = await tmux.spawnInTmux([fullCommand], { + sessionName: tmuxSessionName, + windowName: windowName, + cwd: directory + }, tmuxEnv); // Pass complete environment for tmux session + + if (tmuxResult.success) { + logger.debug(`[DAEMON RUN] Successfully spawned in tmux session: ${tmuxResult.sessionId}, PID: ${tmuxResult.pid}`); + + // Validate we got a PID from tmux + if (!tmuxResult.pid) { + throw new Error('Tmux window created but no PID returned'); + } + + // Create a tracked session for tmux windows - now we have the real PID! + const trackedSession: TrackedSession = { + startedBy: 'daemon', + pid: tmuxResult.pid, // Real PID from tmux -P flag + tmuxSessionId: tmuxResult.sessionId, + directoryCreated, + message: directoryCreated + ? `The path '${directory}' did not exist. We created a new folder and spawned a new session in tmux session '${tmuxSessionName}'. Use 'tmux attach -t ${tmuxSessionName}' to view the session.` + : `Spawned new session in tmux session '${tmuxSessionName}'. Use 'tmux attach -t ${tmuxSessionName}' to view the session.` + }; + + // Add to tracking map so webhook can find it later + pidToTrackedSession.set(tmuxResult.pid, trackedSession); + + // Wait for webhook to populate session with happySessionId (exact same as regular flow) + logger.debug(`[DAEMON RUN] Waiting for session webhook for PID ${tmuxResult.pid} (tmux)`); + + return new Promise((resolve) => { + // Set timeout for webhook (same as regular flow) + const timeout = setTimeout(() => { + pidToAwaiter.delete(tmuxResult.pid!); + logger.debug(`[DAEMON RUN] Session webhook timeout for PID ${tmuxResult.pid} (tmux)`); + resolve({ + type: 'error', + errorMessage: `Session webhook timeout for PID ${tmuxResult.pid} (tmux)` + }); + }, 15_000); // Same timeout as regular sessions + + // Register awaiter for tmux session (exact same as regular flow) + pidToAwaiter.set(tmuxResult.pid!, (completedSession) => { + clearTimeout(timeout); + logger.debug(`[DAEMON RUN] Session ${completedSession.happySessionId} fully spawned with webhook (tmux)`); + resolve({ + type: 'success', + sessionId: completedSession.happySessionId! + }); + }); + }); + } else { + logger.debug(`[DAEMON RUN] Failed to spawn in tmux: ${tmuxResult.error}, falling back to regular spawning`); + useTmux = false; } - }); + } - // Wait for webhook to populate session with happySessionId - logger.debug(`[DAEMON RUN] Waiting for session webhook for PID ${happyProcess.pid}`); + // Regular process spawning (fallback or if tmux not available) + if (!useTmux) { + logger.debug(`[DAEMON RUN] Using regular process spawning`); + + // Construct arguments for the CLI - support claude, codex, and gemini + let agentCommand: string; + switch (options.agent) { + case 'claude': + case undefined: + agentCommand = 'claude'; + break; + case 'codex': + agentCommand = 'codex'; + break; + case 'gemini': + agentCommand = 'gemini'; + break; + default: + return { + type: 'error', + errorMessage: `Unsupported agent type: '${options.agent}'. Please update your CLI to the latest version.` + }; + } + const args = [ + agentCommand, + '--happy-starting-mode', 'remote', + '--started-by', 'daemon' + ]; + + // TODO: In future, sessionId could be used with --resume to continue existing sessions + // For now, we ignore it - each spawn creates a new session + 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 + } + }); - return new Promise((resolve) => { - // Set timeout for webhook - 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}` + // Log output for debugging + if (process.env.DEBUG) { + happyProcess.stdout?.on('data', (data) => { + logger.debug(`[DAEMON RUN] Child stdout: ${data.toString()}`); }); - // 15 second timeout - I have seen timeouts on 10 seconds - // even though session was still created successfully in ~2 more seconds - }, 15_000); - - // Register awaiter - pidToAwaiter.set(happyProcess.pid!, (completedSession) => { - clearTimeout(timeout); - logger.debug(`[DAEMON RUN] Session ${completedSession.happySessionId} fully spawned with webhook`); - resolve({ - type: 'success', - sessionId: completedSession.happySessionId! + 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: 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.` : undefined + }; + + 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); + } + }); + + // Wait for webhook to populate session with happySessionId + logger.debug(`[DAEMON RUN] Waiting for session webhook for PID ${happyProcess.pid}`); + + return new Promise((resolve) => { + // Set timeout for webhook + 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}` + }); + // 15 second timeout - I have seen timeouts on 10 seconds + // even though session was still created successfully in ~2 more seconds + }, 15_000); + + // Register awaiter + pidToAwaiter.set(happyProcess.pid!, (completedSession) => { + clearTimeout(timeout); + logger.debug(`[DAEMON RUN] Session ${completedSession.happySessionId} fully spawned with webhook`); + resolve({ + type: 'success', + sessionId: completedSession.happySessionId! + }); + }); + }); + } + + // This should never be reached, but TypeScript requires a return statement + return { + type: 'error', + errorMessage: 'Unexpected error in session spawning' + }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.debug('[DAEMON RUN] Failed to spawn session:', error); diff --git a/src/daemon/types.ts b/src/daemon/types.ts index 51db1adb..ed8f08aa 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -17,4 +17,6 @@ export interface TrackedSession { error?: string; directoryCreated?: boolean; message?: string; + /** tmux session identifier (format: session:window) */ + tmuxSessionId?: string; } \ No newline at end of file diff --git a/src/gemini/runGemini.ts b/src/gemini/runGemini.ts index b89554bf..474bad2e 100644 --- a/src/gemini/runGemini.ts +++ b/src/gemini/runGemini.ts @@ -15,7 +15,7 @@ import { join, resolve } from 'node:path'; import { ApiClient } from '@/api/api'; import { logger } from '@/ui/logger'; import { Credentials, readSettings } from '@/persistence'; -import { AgentState, Metadata } from '@/api/types'; +import { createSessionMetadata } from '@/utils/createSessionMetadata'; import { initialMachineMetadata } from '@/daemon/run'; import { configuration } from '@/configuration'; import packageJson from '../../package.json'; @@ -27,6 +27,9 @@ import { MessageBuffer } from '@/ui/ink/messageBuffer'; import { notifyDaemonSessionStarted } from '@/daemon/controlClient'; import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; import { stopCaffeinate } from '@/utils/caffeinate'; +import { connectionState } from '@/utils/serverConnectionErrors'; +import { setupOfflineReconnection } from '@/utils/setupOfflineReconnection'; +import type { ApiSessionClient } from '@/api/apiSession'; import { createGeminiBackend } from '@/agent/acp/gemini'; import type { AgentBackend, AgentMessage } from '@/agent/AgentBackend'; @@ -34,7 +37,8 @@ import { GeminiDisplay } from '@/ui/ink/GeminiDisplay'; import { GeminiPermissionHandler } from '@/gemini/utils/permissionHandler'; import { GeminiReasoningProcessor } from '@/gemini/utils/reasoningProcessor'; import { GeminiDiffProcessor } from '@/gemini/utils/diffProcessor'; -import type { PermissionMode, GeminiMode, CodexMessagePayload } from '@/gemini/types'; +import type { GeminiMode, CodexMessagePayload } from '@/gemini/types'; +import type { PermissionMode } from '@/api/types'; import { GEMINI_MODEL_ENV, DEFAULT_GEMINI_MODEL, CHANGE_TITLE_INSTRUCTION } from '@/gemini/constants'; import { readGeminiLocalConfig, @@ -62,6 +66,10 @@ export async function runGemini(opts: { const sessionTag = randomUUID(); + + // Set backend for offline warnings (before any API calls) + connectionState.setBackend('Gemini'); + const api = await ApiClient.create(opts.credentials); @@ -99,40 +107,47 @@ export async function runGemini(opts: { // Create session // - const state: AgentState = { - controlledByUser: false, - }; - const metadata: Metadata = { - path: process.cwd(), - host: os.hostname(), - version: packageJson.version, - os: os.platform(), - machineId: machineId, - homeDir: os.homedir(), - happyHomeDir: configuration.happyHomeDir, - happyLibDir: projectPath(), - happyToolsDir: resolve(projectPath(), 'tools', 'unpacked'), - startedFromDaemon: opts.startedBy === 'daemon', - hostPid: process.pid, - startedBy: opts.startedBy || 'terminal', - lifecycleState: 'running', - lifecycleStateSince: Date.now(), - flavor: 'gemini' - }; + const { state, metadata } = createSessionMetadata({ + flavor: 'gemini', + machineId, + startedBy: opts.startedBy + }); const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); - const session = api.sessionSyncClient(response); - // Report to daemon - 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`); + // Handle server unreachable case - create offline stub with hot reconnection + let session: ApiSessionClient; + // Permission handler declared here so it can be updated in onSessionSwap callback + // (assigned later after Happy server setup) + let permissionHandler: GeminiPermissionHandler; + const { session: initialSession, reconnectionHandle } = setupOfflineReconnection({ + api, + sessionTag, + metadata, + state, + response, + onSessionSwap: (newSession) => { + session = newSession; + // Update permission handler with new session to avoid stale reference + if (permissionHandler) { + permissionHandler.updateSession(newSession); + } + } + }); + session = initialSession; + + // Report to daemon (only if we have a real session) + if (response) { + 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); } - } catch (error) { - logger.debug('[START] Failed to report to daemon (may not be running):', error); } const messageQueue = new MessageQueue2((mode) => hashObject({ @@ -429,8 +444,8 @@ export async function runGemini(opts: { } }; - // Create permission handler for tool approval - const permissionHandler = new GeminiPermissionHandler(session); + // Create permission handler for tool approval (variable declared earlier for onSessionSwap) + permissionHandler = new GeminiPermissionHandler(session); // Create reasoning processor for handling thinking/reasoning chunks const reasoningProcessor = new GeminiReasoningProcessor((message) => { @@ -1069,6 +1084,13 @@ export async function runGemini(opts: { } finally { // Clean up resources logger.debug('[gemini]: Final cleanup start'); + + // Cancel offline reconnection if still running + if (reconnectionHandle) { + logger.debug('[gemini]: Cancelling offline reconnection'); + reconnectionHandle.cancel(); + } + try { session.sendSessionDeath(); await session.flush(); diff --git a/src/gemini/types.ts b/src/gemini/types.ts index c557563d..71498725 100644 --- a/src/gemini/types.ts +++ b/src/gemini/types.ts @@ -1,13 +1,10 @@ /** * Gemini Types - * + * * Centralized type definitions for Gemini integration. */ -/** - * Permission mode for tool approval - */ -export type PermissionMode = 'default' | 'read-only' | 'safe-yolo' | 'yolo'; +import type { PermissionMode } from '@/api/types'; /** * Mode configuration for Gemini messages diff --git a/src/gemini/utils/permissionHandler.ts b/src/gemini/utils/permissionHandler.ts index 2267820f..4e66dd67 100644 --- a/src/gemini/utils/permissionHandler.ts +++ b/src/gemini/utils/permissionHandler.ts @@ -1,40 +1,34 @@ /** - * Permission Handler for Gemini tool approval integration - * + * Gemini Permission Handler + * * Handles tool permission requests and responses for Gemini ACP sessions. - * Similar to Codex's permission handler, but adapted for ACP protocol. + * Extends BasePermissionHandler with Gemini-specific permission mode logic. */ import { logger } from "@/ui/logger"; import { ApiSessionClient } from "@/api/apiSession"; -import { AgentState } from "@/api/types"; -import type { PermissionMode } from '@/gemini/types'; +import type { PermissionMode } from '@/api/types'; +import { + BasePermissionHandler, + PermissionResult, + PendingRequest +} from '@/utils/BasePermissionHandler'; -interface PermissionResponse { - id: string; - approved: boolean; - decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'; -} - -interface PendingRequest { - resolve: (value: PermissionResult) => void; - reject: (error: Error) => void; - toolName: string; - input: unknown; -} +// Re-export types for backwards compatibility +export type { PermissionResult, PendingRequest }; -interface PermissionResult { - decision: 'approved' | 'approved_for_session' | 'denied' | 'abort'; -} - -export class GeminiPermissionHandler { - private pendingRequests = new Map(); - private session: ApiSessionClient; +/** + * Gemini-specific permission handler with permission mode support. + */ +export class GeminiPermissionHandler extends BasePermissionHandler { private currentPermissionMode: PermissionMode = 'default'; constructor(session: ApiSessionClient) { - this.session = session; - this.setupRpcHandler(); + super(session); + } + + protected getLogPrefix(): string { + return '[Gemini]'; } /** @@ -43,7 +37,7 @@ export class GeminiPermissionHandler { */ setPermissionMode(mode: PermissionMode): void { this.currentPermissionMode = mode; - logger.debug(`[Gemini] Permission mode set to: ${mode}`); + logger.debug(`${this.getLogPrefix()} Permission mode set to: ${mode}`); } /** @@ -104,8 +98,8 @@ export class GeminiPermissionHandler { // Check if we should auto-approve based on permission mode // Pass toolCallId to check by ID (e.g., change_title-* even if toolName is "other") if (this.shouldAutoApprove(toolName, toolCallId, input)) { - logger.debug(`[Gemini] Auto-approving tool ${toolName} (${toolCallId}) in ${this.currentPermissionMode} mode`); - + logger.debug(`${this.getLogPrefix()} Auto-approving tool ${toolName} (${toolCallId}) in ${this.currentPermissionMode} mode`); + // Update agent state with auto-approved request this.session.updateAgentState((currentState) => ({ ...currentState, @@ -127,7 +121,7 @@ export class GeminiPermissionHandler { }; } - // Otherwise, ask for permission (same as before) + // Otherwise, ask for permission return new Promise((resolve, reject) => { // Store the pending request this.pendingRequests.set(toolCallId, { @@ -138,106 +132,10 @@ export class GeminiPermissionHandler { }); // Update agent state with pending request - this.session.updateAgentState((currentState) => ({ - ...currentState, - requests: { - ...currentState.requests, - [toolCallId]: { - tool: toolName, - arguments: input, - createdAt: Date.now() - } - } - })); + this.addPendingRequestToState(toolCallId, toolName, input); - logger.debug(`[Gemini] Permission request sent for tool: ${toolName} (${toolCallId}) in ${this.currentPermissionMode} mode`); + logger.debug(`${this.getLogPrefix()} Permission request sent for tool: ${toolName} (${toolCallId}) in ${this.currentPermissionMode} mode`); }); } - - /** - * Setup RPC handler for permission responses - */ - private setupRpcHandler(): void { - this.session.rpcHandlerManager.registerHandler( - 'permission', - async (response) => { - const pending = this.pendingRequests.get(response.id); - if (!pending) { - logger.debug('[Gemini] Permission request not found or already resolved'); - return; - } - - // Remove from pending - this.pendingRequests.delete(response.id); - - // Resolve the permission request - const result: PermissionResult = response.approved - ? { decision: response.decision === 'approved_for_session' ? 'approved_for_session' : 'approved' } - : { decision: response.decision === 'denied' ? 'denied' : 'abort' }; - - pending.resolve(result); - - // Move request to completed in agent state - this.session.updateAgentState((currentState) => { - const request = currentState.requests?.[response.id]; - if (!request) return currentState; - - const { [response.id]: _, ...remainingRequests } = currentState.requests || {}; - - let res = { - ...currentState, - requests: remainingRequests, - completedRequests: { - ...currentState.completedRequests, - [response.id]: { - ...request, - completedAt: Date.now(), - status: response.approved ? 'approved' : 'denied', - decision: result.decision - } - } - } satisfies AgentState; - return res; - }); - - logger.debug(`[Gemini] Permission ${response.approved ? 'approved' : 'denied'} for ${pending.toolName}`); - } - ); - } - - /** - * Reset state for new sessions - */ - reset(): void { - // Reject all pending requests - for (const [id, pending] of this.pendingRequests.entries()) { - pending.reject(new Error('Session reset')); - } - this.pendingRequests.clear(); - - // Clear requests in agent state - this.session.updateAgentState((currentState) => { - const pendingRequests = currentState.requests || {}; - const completedRequests = { ...currentState.completedRequests }; - - // Move all pending to completed as canceled - for (const [id, request] of Object.entries(pendingRequests)) { - completedRequests[id] = { - ...request, - completedAt: Date.now(), - status: 'canceled', - reason: 'Session reset' - }; - } - - return { - ...currentState, - requests: {}, - completedRequests - }; - }); - - logger.debug('[Gemini] Permission handler reset'); - } } diff --git a/src/gemini/utils/reasoningProcessor.ts b/src/gemini/utils/reasoningProcessor.ts index 0d329da8..0ec3706e 100644 --- a/src/gemini/utils/reasoningProcessor.ts +++ b/src/gemini/utils/reasoningProcessor.ts @@ -1,279 +1,47 @@ /** - * Reasoning Processor for Gemini - Handles agent_thought_chunk events - * - * This processor accumulates agent_thought_chunk events from Gemini ACP - * and identifies when reasoning sections start with **[Title]** format, - * treating them as tool calls (similar to Codex's ReasoningProcessor). + * Gemini Reasoning Processor + * + * Handles agent_thought_chunk events for Gemini ACP. + * Extends BaseReasoningProcessor with Gemini-specific configuration. */ -import { randomUUID } from 'node:crypto'; -import { logger } from '@/ui/logger'; +import { + BaseReasoningProcessor, + ReasoningToolCall, + ReasoningToolResult, + ReasoningMessage, + ReasoningOutput +} from '@/utils/BaseReasoningProcessor'; -export interface ReasoningToolCall { - type: 'tool-call'; - name: 'GeminiReasoning'; - callId: string; - input: { - title: string; - }; - id: string; -} - -export interface ReasoningToolResult { - type: 'tool-call-result'; - callId: string; - output: { - content?: string; - status?: 'completed' | 'canceled'; - }; - id: string; -} - -export interface ReasoningMessage { - type: 'reasoning'; - message: string; - id: string; -} - -export type ReasoningOutput = ReasoningToolCall | ReasoningToolResult | ReasoningMessage; +// Re-export types for backwards compatibility +export type { ReasoningToolCall, ReasoningToolResult, ReasoningMessage, ReasoningOutput }; -export class GeminiReasoningProcessor { - private accumulator: string = ''; - private inTitleCapture: boolean = false; - private titleBuffer: string = ''; - private contentBuffer: string = ''; - private hasTitle: boolean = false; - private currentCallId: string | null = null; - private toolCallStarted: boolean = false; - private currentTitle: string | null = null; - private onMessage: ((message: any) => void) | null = null; - - constructor(onMessage?: (message: any) => void) { - this.onMessage = onMessage || null; - this.reset(); - } - - /** - * Set the message callback for sending messages directly - */ - setMessageCallback(callback: (message: any) => void): void { - this.onMessage = callback; +/** + * Gemini-specific reasoning processor. + */ +export class GeminiReasoningProcessor extends BaseReasoningProcessor { + protected getToolName(): string { + return 'GeminiReasoning'; } - /** - * Process a reasoning section break - indicates a new reasoning section is starting - */ - handleSectionBreak(): void { - this.finishCurrentToolCall('canceled'); - this.resetState(); - logger.debug('[GeminiReasoningProcessor] Section break - reset state'); + protected getLogPrefix(): string { + return '[GeminiReasoningProcessor]'; } /** - * Process a reasoning chunk from agent_thought_chunk - * Gemini sends reasoning as chunks, we accumulate them similar to Codex + * Process a reasoning chunk from agent_thought_chunk. + * Gemini sends reasoning as chunks, we accumulate them similar to Codex. */ processChunk(chunk: string): void { - this.accumulator += chunk; - - // If we haven't started processing yet, check if this starts with ** - if (!this.inTitleCapture && !this.hasTitle && !this.contentBuffer) { - if (this.accumulator.startsWith('**')) { - // Start title capture - this.inTitleCapture = true; - this.titleBuffer = this.accumulator.substring(2); // Remove leading ** - logger.debug('[GeminiReasoningProcessor] Started title capture'); - } else if (this.accumulator.length > 0) { - // This is untitled reasoning, just accumulate as content - this.contentBuffer = this.accumulator; - } - } else if (this.inTitleCapture) { - // We're capturing the title - this.titleBuffer = this.accumulator.substring(2); // Keep updating from start - - // Check if we've found the closing ** - const titleEndIndex = this.titleBuffer.indexOf('**'); - if (titleEndIndex !== -1) { - // Found the end of title - const title = this.titleBuffer.substring(0, titleEndIndex); - const afterTitle = this.titleBuffer.substring(titleEndIndex + 2); - - this.hasTitle = true; - this.inTitleCapture = false; - this.currentTitle = title; - this.contentBuffer = afterTitle; - - // Generate a call ID for this reasoning section - this.currentCallId = randomUUID(); - - logger.debug(`[GeminiReasoningProcessor] Title captured: "${title}"`); - - // Send tool call immediately when title is detected - this.sendToolCallStart(title); - } - } else if (this.hasTitle) { - // We have a title, accumulate content after title - const titleStartIndex = this.accumulator.indexOf('**'); - if (titleStartIndex !== -1) { - this.contentBuffer = this.accumulator.substring( - titleStartIndex + 2 + - this.currentTitle!.length + 2 - ); - } - } else { - // Untitled reasoning, just accumulate - this.contentBuffer = this.accumulator; - } - } - - /** - * Send the tool call start message - */ - private sendToolCallStart(title: string): void { - if (!this.currentCallId || this.toolCallStarted) { - return; - } - - const toolCall: ReasoningToolCall = { - type: 'tool-call', - name: 'GeminiReasoning', - callId: this.currentCallId, - input: { - title: title - }, - id: randomUUID() - }; - - logger.debug(`[GeminiReasoningProcessor] Sending tool call start for: "${title}"`); - this.onMessage?.(toolCall); - this.toolCallStarted = true; + this.processInput(chunk); } /** - * Complete the reasoning section with final text - * Called when reasoning is complete (e.g., when status changes to idle) - * Returns true if reasoning was actually completed, false if there was nothing to complete + * Complete the reasoning section. + * Called when reasoning is complete (e.g., when status changes to idle). + * Returns true if reasoning was actually completed, false if there was nothing to complete. */ complete(): boolean { - const fullText = this.accumulator; - - // If there's no content accumulated, don't send anything - if (!fullText.trim() && !this.toolCallStarted) { - logger.debug('[GeminiReasoningProcessor] Complete called but no content accumulated, skipping'); - return false; - } - - // Extract title and content if present - let title: string | undefined; - let content: string = fullText; - - if (fullText.startsWith('**')) { - const titleEndIndex = fullText.indexOf('**', 2); - if (titleEndIndex !== -1) { - title = fullText.substring(2, titleEndIndex); - content = fullText.substring(titleEndIndex + 2).trim(); - } - } - - logger.debug(`[GeminiReasoningProcessor] Complete reasoning - Title: "${title}", Has content: ${content.length > 0}`); - - if (title && !this.toolCallStarted) { - // If we have a title but haven't sent the tool call yet, send it now - this.currentCallId = this.currentCallId || randomUUID(); - this.sendToolCallStart(title); - } - - if (this.toolCallStarted && this.currentCallId) { - // Send tool call result for titled reasoning - const toolResult: ReasoningToolResult = { - type: 'tool-call-result', - callId: this.currentCallId, - output: { - content: content, - status: 'completed' - }, - id: randomUUID() - }; - logger.debug('[GeminiReasoningProcessor] Sending tool call result'); - this.onMessage?.(toolResult); - } else if (content.trim()) { - // Send regular reasoning message for untitled reasoning (only if there's content) - const reasoningMessage: ReasoningMessage = { - type: 'reasoning', - message: content, - id: randomUUID() - }; - logger.debug('[GeminiReasoningProcessor] Sending reasoning message'); - this.onMessage?.(reasoningMessage); - } - - // Reset state after completion - this.resetState(); - return true; - } - - /** - * Abort the current reasoning section - */ - abort(): void { - logger.debug('[GeminiReasoningProcessor] Abort called'); - this.finishCurrentToolCall('canceled'); - this.resetState(); - } - - /** - * Reset the processor state - */ - reset(): void { - this.finishCurrentToolCall('canceled'); - this.resetState(); - } - - /** - * Finish current tool call if one is in progress - */ - private finishCurrentToolCall(status: 'completed' | 'canceled'): void { - if (this.toolCallStarted && this.currentCallId) { - // Send tool call result with canceled status - const toolResult: ReasoningToolResult = { - type: 'tool-call-result', - callId: this.currentCallId, - output: { - content: this.contentBuffer || '', - status: status - }, - id: randomUUID() - }; - logger.debug(`[GeminiReasoningProcessor] Sending tool call result with status: ${status}`); - this.onMessage?.(toolResult); - } - } - - /** - * Reset internal state - */ - private resetState(): void { - this.accumulator = ''; - this.inTitleCapture = false; - this.titleBuffer = ''; - this.contentBuffer = ''; - this.hasTitle = false; - this.currentCallId = null; - this.toolCallStarted = false; - this.currentTitle = null; - } - - /** - * Get the current call ID for tool result matching - */ - getCurrentCallId(): string | null { - return this.currentCallId; - } - - /** - * Check if a tool call has been started - */ - hasStartedToolCall(): boolean { - return this.toolCallStarted; + return this.completeReasoning(); } } diff --git a/src/index.ts b/src/index.ts index 94c01c0f..c05eedeb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -469,9 +469,9 @@ ${chalk.bold.cyan('Claude Code Options (from `claude --help`):')} `) // Run claude --help and display its output - // Use execFileSync with the current Node executable for cross-platform compatibility + // Use execFileSync directly with claude CLI for runtime-agnostic compatibility try { - const claudeHelp = execFileSync(process.execPath, [claudeCliPath, '--help'], { encoding: 'utf8' }) + const claudeHelp = execFileSync(claudeCliPath, ['--help'], { encoding: 'utf8' }) console.log(claudeHelp) } catch (e) { console.log(chalk.yellow('Could not retrieve claude help. Make sure claude is installed.')) diff --git a/src/modules/common/registerCommonHandlers.ts b/src/modules/common/registerCommonHandlers.ts index dcdb6a95..bd4e07a5 100644 --- a/src/modules/common/registerCommonHandlers.ts +++ b/src/modules/common/registerCommonHandlers.ts @@ -122,6 +122,19 @@ export interface SpawnSessionOptions { approvedNewDirectoryCreation?: boolean; agent?: 'claude' | 'codex' | 'gemini'; token?: string; + environmentVariables?: { + // Anthropic Claude API configuration + ANTHROPIC_BASE_URL?: string; // Custom API endpoint (overrides default) + ANTHROPIC_AUTH_TOKEN?: string; // API authentication token + ANTHROPIC_MODEL?: string; // Model to use (e.g., claude-3-5-sonnet-20241022) + + // Tmux session management environment variables + // Based on tmux(1) manual and common tmux usage patterns + TMUX_SESSION_NAME?: string; // Name for tmux session (creates/attaches to named session) + TMUX_TMPDIR?: string; // Temporary directory for tmux server socket files + // Note: TMUX_TMPDIR is used by tmux to store socket files when default /tmp is not suitable + // Common use case: When /tmp has limited space or different permissions + }; } export type SpawnSessionResult = @@ -139,7 +152,9 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, wor logger.debug('Shell command request:', data.command); // Validate cwd if provided - if (data.cwd) { + // Special case: "/" means "use shell's default cwd" (used by CLI detection) + // Security: Still validate all other paths to prevent directory traversal + if (data.cwd && data.cwd !== '/') { const validation = validatePath(data.cwd, workingDirectory); if (!validation.valid) { return { success: false, error: validation.error }; @@ -149,19 +164,29 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, wor try { // Build options with shell enabled by default // Note: ExecOptions doesn't support boolean for shell, but exec() uses the default shell when shell is undefined + // If cwd is "/", use undefined to let shell use its default (respects user's PATH) const options: ExecOptions = { - cwd: data.cwd, + cwd: data.cwd === '/' ? undefined : data.cwd, timeout: data.timeout || 30000, // Default 30 seconds timeout }; + logger.debug('Shell command executing...', { cwd: options.cwd, timeout: options.timeout }); const { stdout, stderr } = await execAsync(data.command, options); + logger.debug('Shell command executed, processing result...'); - return { + const result = { success: true, stdout: stdout ? stdout.toString() : '', stderr: stderr ? stderr.toString() : '', exitCode: 0 }; + logger.debug('Shell command result:', { + success: true, + exitCode: 0, + stdoutLen: result.stdout.length, + stderrLen: result.stderr.length + }); + return result; } catch (error) { const execError = error as NodeJS.ErrnoException & { stdout?: string; @@ -172,23 +197,37 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, wor // Check if the error was due to timeout if (execError.code === 'ETIMEDOUT' || execError.killed) { - return { + const result = { success: false, stdout: execError.stdout || '', stderr: execError.stderr || '', exitCode: typeof execError.code === 'number' ? execError.code : -1, error: 'Command timed out' }; + logger.debug('Shell command timed out:', { + success: false, + exitCode: result.exitCode, + error: 'Command timed out' + }); + return result; } // If exec fails, it includes stdout/stderr in the error - return { + const result = { success: false, stdout: execError.stdout ? execError.stdout.toString() : '', stderr: execError.stderr ? execError.stderr.toString() : execError.message || 'Command failed', exitCode: typeof execError.code === 'number' ? execError.code : 1, error: execError.message || 'Command failed' }; + logger.debug('Shell command failed:', { + success: false, + exitCode: result.exitCode, + error: result.error, + stdoutLen: result.stdout.length, + stderrLen: result.stderr.length + }); + return result; } }); diff --git a/src/persistence.ts b/src/persistence.ts index d3f4e527..0270aaf5 100644 --- a/src/persistence.ts +++ b/src/persistence.ts @@ -11,18 +11,237 @@ import { constants } from 'node:fs' import { configuration } from '@/configuration' import * as z from 'zod'; import { encodeBase64 } from '@/api/encryption'; +import { logger } from '@/ui/logger'; + +// AI backend profile schema - MUST match happy app exactly +// Using same Zod schema as GUI for runtime validation consistency + +// Environment variable schemas for different AI providers (matching GUI exactly) +const AnthropicConfigSchema = z.object({ + baseUrl: z.string().url().optional(), + authToken: z.string().optional(), + model: z.string().optional(), +}); + +const OpenAIConfigSchema = z.object({ + apiKey: z.string().optional(), + baseUrl: z.string().url().optional(), + model: z.string().optional(), +}); + +const AzureOpenAIConfigSchema = z.object({ + apiKey: z.string().optional(), + endpoint: z.string().url().optional(), + apiVersion: z.string().optional(), + deploymentName: z.string().optional(), +}); + +const TogetherAIConfigSchema = z.object({ + apiKey: z.string().optional(), + model: z.string().optional(), +}); + +// Tmux configuration schema (matching GUI exactly) +const TmuxConfigSchema = z.object({ + sessionName: z.string().optional(), + tmpDir: z.string().optional(), + updateEnvironment: z.boolean().optional(), +}); + +// Environment variables schema with validation (matching GUI exactly) +const EnvironmentVariableSchema = z.object({ + name: z.string().regex(/^[A-Z_][A-Z0-9_]*$/, 'Invalid environment variable name'), + value: z.string(), +}); + +// Profile compatibility schema (matching GUI exactly) +const ProfileCompatibilitySchema = z.object({ + claude: z.boolean().default(true), + codex: z.boolean().default(true), + gemini: z.boolean().default(true), +}); + +// AIBackendProfile schema - EXACT MATCH with GUI schema +export const AIBackendProfileSchema = z.object({ + id: z.string().uuid(), + name: z.string().min(1).max(100), + description: z.string().max(500).optional(), + + // Agent-specific configurations + anthropicConfig: AnthropicConfigSchema.optional(), + openaiConfig: OpenAIConfigSchema.optional(), + azureOpenAIConfig: AzureOpenAIConfigSchema.optional(), + togetherAIConfig: TogetherAIConfigSchema.optional(), + + // Tmux configuration + tmuxConfig: TmuxConfigSchema.optional(), + + // Environment variables (validated) + environmentVariables: z.array(EnvironmentVariableSchema).default([]), + + // Default session type for this profile + defaultSessionType: z.enum(['simple', 'worktree']).optional(), + + // Default permission mode for this profile (supports both Claude and Codex modes) + defaultPermissionMode: z.enum([ + 'default', 'acceptEdits', 'bypassPermissions', 'plan', // Claude modes + 'read-only', 'safe-yolo', 'yolo' // Codex modes + ]).optional(), + + // Default model mode for this profile + defaultModelMode: z.string().optional(), + + // Compatibility metadata + compatibility: ProfileCompatibilitySchema.default({ claude: true, codex: true, gemini: true }), + + // Built-in profile indicator + isBuiltIn: z.boolean().default(false), + + // Metadata + createdAt: z.number().default(() => Date.now()), + updatedAt: z.number().default(() => Date.now()), + version: z.string().default('1.0.0'), +}); + +export type AIBackendProfile = z.infer; + +// Helper functions matching the happy app exactly +export function validateProfileForAgent(profile: AIBackendProfile, agent: 'claude' | 'codex' | 'gemini'): boolean { + return profile.compatibility[agent]; +} + +export function getProfileEnvironmentVariables(profile: AIBackendProfile): Record { + const envVars: Record = {}; + + // Add validated environment variables + profile.environmentVariables.forEach(envVar => { + envVars[envVar.name] = envVar.value; + }); + + // Add Anthropic config + if (profile.anthropicConfig) { + if (profile.anthropicConfig.baseUrl) envVars.ANTHROPIC_BASE_URL = profile.anthropicConfig.baseUrl; + if (profile.anthropicConfig.authToken) envVars.ANTHROPIC_AUTH_TOKEN = profile.anthropicConfig.authToken; + if (profile.anthropicConfig.model) envVars.ANTHROPIC_MODEL = profile.anthropicConfig.model; + } + + // Add OpenAI config + if (profile.openaiConfig) { + if (profile.openaiConfig.apiKey) envVars.OPENAI_API_KEY = profile.openaiConfig.apiKey; + if (profile.openaiConfig.baseUrl) envVars.OPENAI_BASE_URL = profile.openaiConfig.baseUrl; + if (profile.openaiConfig.model) envVars.OPENAI_MODEL = profile.openaiConfig.model; + } + + // Add Azure OpenAI config + if (profile.azureOpenAIConfig) { + if (profile.azureOpenAIConfig.apiKey) envVars.AZURE_OPENAI_API_KEY = profile.azureOpenAIConfig.apiKey; + if (profile.azureOpenAIConfig.endpoint) envVars.AZURE_OPENAI_ENDPOINT = profile.azureOpenAIConfig.endpoint; + if (profile.azureOpenAIConfig.apiVersion) envVars.AZURE_OPENAI_API_VERSION = profile.azureOpenAIConfig.apiVersion; + if (profile.azureOpenAIConfig.deploymentName) envVars.AZURE_OPENAI_DEPLOYMENT_NAME = profile.azureOpenAIConfig.deploymentName; + } + + // Add Together AI config + if (profile.togetherAIConfig) { + if (profile.togetherAIConfig.apiKey) envVars.TOGETHER_API_KEY = profile.togetherAIConfig.apiKey; + if (profile.togetherAIConfig.model) envVars.TOGETHER_MODEL = profile.togetherAIConfig.model; + } + + // Add Tmux config + if (profile.tmuxConfig) { + // Empty string means "use current/most recent session", so include it + if (profile.tmuxConfig.sessionName !== undefined) envVars.TMUX_SESSION_NAME = profile.tmuxConfig.sessionName; + if (profile.tmuxConfig.tmpDir) envVars.TMUX_TMPDIR = profile.tmuxConfig.tmpDir; + if (profile.tmuxConfig.updateEnvironment !== undefined) { + envVars.TMUX_UPDATE_ENVIRONMENT = profile.tmuxConfig.updateEnvironment.toString(); + } + } + + return envVars; +} + +// Profile validation function using Zod schema +export function validateProfile(profile: unknown): AIBackendProfile { + const result = AIBackendProfileSchema.safeParse(profile); + if (!result.success) { + throw new Error(`Invalid profile data: ${result.error.message}`); + } + return result.data; +} + + +// Profile versioning system +// Profile version: Semver string for individual profile data compatibility (e.g., "1.0.0") +// Used to version the AIBackendProfile schema itself (anthropicConfig, tmuxConfig, etc.) +export const CURRENT_PROFILE_VERSION = '1.0.0'; + +// Settings schema version: Integer for overall Settings structure compatibility +// Incremented when Settings structure changes (e.g., adding profiles array was v1→v2) +// Used for migration logic in readSettings() +export const SUPPORTED_SCHEMA_VERSION = 2; + +// Profile version validation +export function validateProfileVersion(profile: AIBackendProfile): boolean { + // Simple semver validation for now + const semverRegex = /^\d+\.\d+\.\d+$/; + return semverRegex.test(profile.version || ''); +} + +// Profile compatibility check for version upgrades +export function isProfileVersionCompatible(profileVersion: string, requiredVersion: string = CURRENT_PROFILE_VERSION): boolean { + // For now, all 1.x.x versions are compatible + const [major] = profileVersion.split('.'); + const [requiredMajor] = requiredVersion.split('.'); + return major === requiredMajor; +} interface Settings { + // Schema version for backwards compatibility + schemaVersion: number onboardingCompleted: boolean // This ID is used as the actual database ID on the server // All machine operations use this ID machineId?: string machineIdConfirmedByServer?: boolean daemonAutoStartWhenRunningHappy?: boolean + // Profile management settings (synced with happy app) + activeProfileId?: string + profiles: AIBackendProfile[] + // CLI-local environment variable cache (not synced) + localEnvironmentVariables: Record> // profileId -> env vars } const defaultSettings: Settings = { - onboardingCompleted: false + schemaVersion: SUPPORTED_SCHEMA_VERSION, + onboardingCompleted: false, + profiles: [], + localEnvironmentVariables: {} +} + +/** + * Migrate settings from old schema versions to current + * Always backwards compatible - preserves all data + */ +function migrateSettings(raw: any, fromVersion: number): any { + let migrated = { ...raw }; + + // Migration from v1 to v2 (added profile support) + if (fromVersion < 2) { + // Ensure profiles array exists + if (!migrated.profiles) { + migrated.profiles = []; + } + // Ensure localEnvironmentVariables exists + if (!migrated.localEnvironmentVariables) { + migrated.localEnvironmentVariables = {}; + } + // Update schema version + migrated.schemaVersion = 2; + } + + // Future migrations go here: + // if (fromVersion < 3) { ... } + + return migrated; } /** @@ -44,9 +263,47 @@ export async function readSettings(): Promise { } try { + // Read raw settings const content = await readFile(configuration.settingsFile, 'utf8') - return JSON.parse(content) - } catch { + const raw = JSON.parse(content) + + // Check schema version (default to 1 if missing) + const schemaVersion = raw.schemaVersion ?? 1; + + // Warn if schema version is newer than supported + if (schemaVersion > SUPPORTED_SCHEMA_VERSION) { + logger.warn( + `āš ļø Settings schema v${schemaVersion} > supported v${SUPPORTED_SCHEMA_VERSION}. ` + + 'Update happy-cli for full functionality.' + ); + } + + // Migrate if needed + const migrated = migrateSettings(raw, schemaVersion); + + // Validate and clean profiles gracefully (don't crash on invalid profiles) + if (migrated.profiles && Array.isArray(migrated.profiles)) { + const validProfiles: AIBackendProfile[] = []; + for (const profile of migrated.profiles) { + try { + const validated = AIBackendProfileSchema.parse(profile); + validProfiles.push(validated); + } catch (error: any) { + logger.warn( + `āš ļø Invalid profile "${profile?.name || profile?.id || 'unknown'}" - skipping. ` + + `Error: ${error.message}` + ); + // Continue processing other profiles + } + } + migrated.profiles = validProfiles; + } + + // Merge with defaults to ensure all required fields exist + return { ...defaultSettings, ...migrated }; + } catch (error: any) { + logger.warn(`Failed to read settings: ${error.message}`); + // Return defaults on any error return { ...defaultSettings } } } @@ -56,7 +313,13 @@ export async function writeSettings(settings: Settings): Promise { await mkdir(configuration.happyHomeDir, { recursive: true }) } - await writeFile(configuration.settingsFile, JSON.stringify(settings, null, 2)) + // Ensure schema version is set before writing + const settingsWithVersion = { + ...settings, + schemaVersion: settings.schemaVersion ?? SUPPORTED_SCHEMA_VERSION + }; + + await writeFile(configuration.settingsFile, JSON.stringify(settingsWithVersion, null, 2)) } /** @@ -320,3 +583,124 @@ export async function releaseDaemonLock(lockHandle: FileHandle): Promise { } catch { } } +// +// Profile Management +// + +/** + * Get all profiles from settings + */ +export async function getProfiles(): Promise { + const settings = await readSettings(); + return settings.profiles || []; +} + +/** + * Get a specific profile by ID + */ +export async function getProfile(profileId: string): Promise { + const settings = await readSettings(); + return settings.profiles.find(p => p.id === profileId) || null; +} + +/** + * Get the active profile + */ +export async function getActiveProfile(): Promise { + const settings = await readSettings(); + if (!settings.activeProfileId) return null; + return settings.profiles.find(p => p.id === settings.activeProfileId) || null; +} + +/** + * Set the active profile by ID + */ +export async function setActiveProfile(profileId: string): Promise { + await updateSettings(settings => ({ + ...settings, + activeProfileId: profileId + })); +} + +/** + * Update profiles (synced from happy app) with validation + */ +export async function updateProfiles(profiles: unknown[]): Promise { + // Validate all profiles using Zod schema + const validatedProfiles = profiles.map(profile => validateProfile(profile)); + + await updateSettings(settings => { + // Preserve active profile ID if it still exists + const activeProfileId = settings.activeProfileId; + const activeProfileStillExists = activeProfileId && validatedProfiles.some(p => p.id === activeProfileId); + + return { + ...settings, + profiles: validatedProfiles, + activeProfileId: activeProfileStillExists ? activeProfileId : undefined + }; + }); +} + +/** + * Get environment variables for a profile + * Combines profile custom env vars with CLI-local cached env vars + */ +export async function getEnvironmentVariables(profileId: string): Promise> { + const settings = await readSettings(); + const profile = settings.profiles.find(p => p.id === profileId); + if (!profile) return {}; + + // Start with profile's environment variables (new schema) + const envVars: Record = {}; + if (profile.environmentVariables) { + profile.environmentVariables.forEach(envVar => { + envVars[envVar.name] = envVar.value; + }); + } + + // Override with CLI-local cached environment variables + const localEnvVars = settings.localEnvironmentVariables[profileId] || {}; + Object.assign(envVars, localEnvVars); + + return envVars; +} + +/** + * Set environment variables for a profile in CLI-local cache + */ +export async function setEnvironmentVariables(profileId: string, envVars: Record): Promise { + await updateSettings(settings => ({ + ...settings, + localEnvironmentVariables: { + ...settings.localEnvironmentVariables, + [profileId]: envVars + } + })); +} + +/** + * Get a specific environment variable for a profile + * Checks CLI-local cache first, then profile environment variables + */ +export async function getEnvironmentVariable(profileId: string, key: string): Promise { + const settings = await readSettings(); + + // Check CLI-local cache first + const localEnvVars = settings.localEnvironmentVariables[profileId] || {}; + if (localEnvVars[key] !== undefined) { + return localEnvVars[key]; + } + + // Fall back to profile environment variables (new schema) + const profile = settings.profiles.find(p => p.id === profileId); + if (profile?.environmentVariables) { + const envVar = profile.environmentVariables.find(env => env.name === key); + if (envVar) { + return envVar.value; + } + } + + return undefined; +} + diff --git a/src/ui/auth.ts b/src/ui/auth.ts index 3ea33f21..964f8ace 100644 --- a/src/ui/auth.ts +++ b/src/ui/auth.ts @@ -30,15 +30,21 @@ export async function doAuth(): Promise { // Create a new authentication request 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)}...`); + if (process.env.DEBUG) { + 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`); + if (process.env.DEBUG) { + console.log(`[AUTH DEBUG] Auth request sent successfully`); + } } catch (error) { - console.log(`[AUTH DEBUG] Failed to send auth request:`, error); + if (process.env.DEBUG) { + console.log(`[AUTH DEBUG] Failed to send auth request:`, error); + } console.log('Failed to create authentication request, please try again later.'); return null; } diff --git a/src/ui/logger.ts b/src/ui/logger.ts index ecf61936..ecf739b6 100644 --- a/src/ui/logger.ts +++ b/src/ui/logger.ts @@ -10,7 +10,8 @@ import { appendFileSync } from 'fs' import { configuration } from '@/configuration' import { existsSync, readdirSync, statSync } from 'node:fs' import { join, basename } from 'node:path' -import { readDaemonState } from '@/persistence' +// Note: readDaemonState is imported lazily inside listDaemonLogFiles() to avoid +// circular dependency: logger.ts ↔ persistence.ts /** * Consistent date/time formatting functions @@ -264,6 +265,8 @@ export async function listDaemonLogFiles(limit: number = 50): Promise void; + reject: (error: Error) => void; + toolName: string; + input: unknown; +} + +/** + * Result of a permission request. + */ +export interface PermissionResult { + decision: 'approved' | 'approved_for_session' | 'denied' | 'abort'; +} + +/** + * Abstract base class for permission handlers. + * + * Subclasses must implement: + * - `getLogPrefix()` - returns the log prefix (e.g., '[Codex]') + */ +export abstract class BasePermissionHandler { + protected pendingRequests = new Map(); + protected session: ApiSessionClient; + private isResetting = false; + + /** + * Returns the log prefix for this handler. + */ + protected abstract getLogPrefix(): string; + + constructor(session: ApiSessionClient) { + this.session = session; + this.setupRpcHandler(); + } + + /** + * Update the session reference (used after offline reconnection swaps sessions). + * This is critical for avoiding stale session references after onSessionSwap. + */ + updateSession(newSession: ApiSessionClient): void { + logger.debug(`${this.getLogPrefix()} Session reference updated`); + this.session = newSession; + // Re-setup RPC handler with new session + this.setupRpcHandler(); + } + + /** + * Setup RPC handler for permission responses. + */ + protected setupRpcHandler(): void { + this.session.rpcHandlerManager.registerHandler( + 'permission', + async (response) => { + const pending = this.pendingRequests.get(response.id); + if (!pending) { + logger.debug(`${this.getLogPrefix()} Permission request not found or already resolved`); + return; + } + + // Remove from pending + this.pendingRequests.delete(response.id); + + // Resolve the permission request + const result: PermissionResult = response.approved + ? { decision: response.decision === 'approved_for_session' ? 'approved_for_session' : 'approved' } + : { decision: response.decision === 'denied' ? 'denied' : 'abort' }; + + pending.resolve(result); + + // Move request to completed in agent state + this.session.updateAgentState((currentState) => { + const request = currentState.requests?.[response.id]; + if (!request) return currentState; + + const { [response.id]: _, ...remainingRequests } = currentState.requests || {}; + + let res = { + ...currentState, + requests: remainingRequests, + completedRequests: { + ...currentState.completedRequests, + [response.id]: { + ...request, + completedAt: Date.now(), + status: response.approved ? 'approved' : 'denied', + decision: result.decision + } + } + } satisfies AgentState; + return res; + }); + + logger.debug(`${this.getLogPrefix()} Permission ${response.approved ? 'approved' : 'denied'} for ${pending.toolName}`); + } + ); + } + + /** + * Add a pending request to the agent state. + */ + protected addPendingRequestToState(toolCallId: string, toolName: string, input: unknown): void { + this.session.updateAgentState((currentState) => ({ + ...currentState, + requests: { + ...currentState.requests, + [toolCallId]: { + tool: toolName, + arguments: input, + createdAt: Date.now() + } + } + })); + } + + /** + * Reset state for new sessions. + * This method is idempotent - safe to call multiple times. + */ + reset(): void { + // Guard against re-entrant/concurrent resets + if (this.isResetting) { + logger.debug(`${this.getLogPrefix()} Reset already in progress, skipping`); + return; + } + this.isResetting = true; + + try { + // Snapshot pending requests to avoid Map mutation during iteration + const pendingSnapshot = Array.from(this.pendingRequests.entries()); + this.pendingRequests.clear(); // Clear immediately to prevent new entries being processed + + // Reject all pending requests from snapshot + for (const [id, pending] of pendingSnapshot) { + try { + pending.reject(new Error('Session reset')); + } catch (err) { + logger.debug(`${this.getLogPrefix()} Error rejecting pending request ${id}:`, err); + } + } + + // Clear requests in agent state + this.session.updateAgentState((currentState) => { + const pendingRequests = currentState.requests || {}; + const completedRequests = { ...currentState.completedRequests }; + + // Move all pending to completed as canceled + for (const [id, request] of Object.entries(pendingRequests)) { + completedRequests[id] = { + ...request, + completedAt: Date.now(), + status: 'canceled', + reason: 'Session reset' + }; + } + + return { + ...currentState, + requests: {}, + completedRequests + }; + }); + + logger.debug(`${this.getLogPrefix()} Permission handler reset`); + } finally { + this.isResetting = false; + } + } +} diff --git a/src/utils/BaseReasoningProcessor.ts b/src/utils/BaseReasoningProcessor.ts new file mode 100644 index 00000000..db4cd370 --- /dev/null +++ b/src/utils/BaseReasoningProcessor.ts @@ -0,0 +1,306 @@ +/** + * Base Reasoning Processor + * + * Abstract base class for reasoning processors that handle streaming reasoning + * deltas/chunks and identify reasoning sections with **[Title]** format. + * + * Shared by Codex and Gemini reasoning processors. + * + * @module BaseReasoningProcessor + */ + +import { randomUUID } from 'node:crypto'; +import { logger } from '@/ui/logger'; + +/** + * Tool call for reasoning section with a title. + */ +export interface ReasoningToolCall { + type: 'tool-call'; + name: string; // 'CodexReasoning' or 'GeminiReasoning' + callId: string; + input: { + title: string; + }; + id: string; +} + +/** + * Result of a reasoning tool call. + */ +export interface ReasoningToolResult { + type: 'tool-call-result'; + callId: string; + output: { + content?: string; + status?: 'completed' | 'canceled'; + }; + id: string; +} + +/** + * Plain reasoning message without a title. + */ +export interface ReasoningMessage { + type: 'reasoning'; + message: string; + id: string; +} + +export type ReasoningOutput = ReasoningToolCall | ReasoningToolResult | ReasoningMessage; + +/** + * Abstract base class for reasoning processors. + * + * Subclasses must implement: + * - `getToolName()` - returns the tool name (e.g., 'CodexReasoning', 'GeminiReasoning') + * - `getLogPrefix()` - returns the log prefix (e.g., '[ReasoningProcessor]') + */ +export abstract class BaseReasoningProcessor { + protected accumulator: string = ''; + protected inTitleCapture: boolean = false; + protected titleBuffer: string = ''; + protected contentBuffer: string = ''; + protected hasTitle: boolean = false; + protected currentCallId: string | null = null; + protected toolCallStarted: boolean = false; + protected currentTitle: string | null = null; + protected onMessage: ((message: any) => void) | null = null; + + /** + * Returns the tool name for this processor. + */ + protected abstract getToolName(): string; + + /** + * Returns the log prefix for this processor. + */ + protected abstract getLogPrefix(): string; + + constructor(onMessage?: (message: any) => void) { + this.onMessage = onMessage || null; + this.reset(); + } + + /** + * Set the message callback for sending messages directly. + */ + setMessageCallback(callback: (message: any) => void): void { + this.onMessage = callback; + } + + /** + * Process a reasoning section break - indicates a new reasoning section is starting. + */ + handleSectionBreak(): void { + this.finishCurrentToolCall('canceled'); + this.resetState(); + logger.debug(`${this.getLogPrefix()} Section break - reset state`); + } + + /** + * Process a reasoning delta/chunk and accumulate content. + */ + protected processInput(input: string): void { + this.accumulator += input; + + // If we haven't started processing yet, check if this starts with ** + if (!this.inTitleCapture && !this.hasTitle && !this.contentBuffer) { + if (this.accumulator.startsWith('**')) { + // Start title capture + this.inTitleCapture = true; + this.titleBuffer = this.accumulator.substring(2); // Remove leading ** + logger.debug(`${this.getLogPrefix()} Started title capture`); + } else if (this.accumulator.length > 0) { + // This is untitled reasoning, just accumulate as content + this.contentBuffer = this.accumulator; + } + } else if (this.inTitleCapture) { + // We're capturing the title + this.titleBuffer = this.accumulator.substring(2); // Keep updating from start + + // Check if we've found the closing ** + const titleEndIndex = this.titleBuffer.indexOf('**'); + if (titleEndIndex !== -1) { + // Found the end of title + const title = this.titleBuffer.substring(0, titleEndIndex); + const afterTitle = this.titleBuffer.substring(titleEndIndex + 2); + + this.hasTitle = true; + this.inTitleCapture = false; + this.currentTitle = title; + this.contentBuffer = afterTitle; + + // Generate a call ID for this reasoning section + this.currentCallId = randomUUID(); + + logger.debug(`${this.getLogPrefix()} Title captured: "${title}"`); + + // Send tool call immediately when title is detected + this.sendToolCallStart(title); + } + } else if (this.hasTitle) { + // We have a title, accumulate content after title + const titleStartIndex = this.accumulator.indexOf('**'); + if (titleStartIndex !== -1) { + this.contentBuffer = this.accumulator.substring( + titleStartIndex + 2 + + this.currentTitle!.length + 2 + ); + } + } else { + // Untitled reasoning, just accumulate + this.contentBuffer = this.accumulator; + } + } + + /** + * Send the tool call start message. + */ + protected sendToolCallStart(title: string): void { + if (!this.currentCallId || this.toolCallStarted) { + return; + } + + const toolCall: ReasoningToolCall = { + type: 'tool-call', + name: this.getToolName(), + callId: this.currentCallId, + input: { + title: title + }, + id: randomUUID() + }; + + logger.debug(`${this.getLogPrefix()} Sending tool call start for: "${title}"`); + this.onMessage?.(toolCall); + this.toolCallStarted = true; + } + + /** + * Complete the reasoning section. + * Returns true if reasoning was completed, false if there was nothing to complete. + */ + protected completeReasoning(fullText?: string): boolean { + const text = fullText ?? this.accumulator; + + // If there's no content accumulated, don't send anything + if (!text.trim() && !this.toolCallStarted) { + logger.debug(`${this.getLogPrefix()} Complete called but no content accumulated, skipping`); + return false; + } + + // Extract title and content if present + let title: string | undefined; + let content: string = text; + + if (text.startsWith('**')) { + const titleEndIndex = text.indexOf('**', 2); + if (titleEndIndex !== -1) { + title = text.substring(2, titleEndIndex); + content = text.substring(titleEndIndex + 2).trim(); + } + } + + logger.debug(`${this.getLogPrefix()} Complete reasoning - Title: "${title}", Has content: ${content.length > 0}`); + + if (title && !this.toolCallStarted) { + // If we have a title but haven't sent the tool call yet, send it now + this.currentCallId = this.currentCallId || randomUUID(); + this.sendToolCallStart(title); + } + + if (this.toolCallStarted && this.currentCallId) { + // Send tool call result for titled reasoning + const toolResult: ReasoningToolResult = { + type: 'tool-call-result', + callId: this.currentCallId, + output: { + content: content, + status: 'completed' + }, + id: randomUUID() + }; + logger.debug(`${this.getLogPrefix()} Sending tool call result`); + this.onMessage?.(toolResult); + } else if (content.trim()) { + // Send regular reasoning message for untitled reasoning (only if there's content) + const reasoningMessage: ReasoningMessage = { + type: 'reasoning', + message: content, + id: randomUUID() + }; + logger.debug(`${this.getLogPrefix()} Sending reasoning message`); + this.onMessage?.(reasoningMessage); + } + + // Reset state after completion + this.resetState(); + return true; + } + + /** + * Abort the current reasoning section. + */ + abort(): void { + logger.debug(`${this.getLogPrefix()} Abort called`); + this.finishCurrentToolCall('canceled'); + this.resetState(); + } + + /** + * Reset the processor state. + */ + reset(): void { + this.finishCurrentToolCall('canceled'); + this.resetState(); + } + + /** + * Finish current tool call if one is in progress. + */ + protected finishCurrentToolCall(status: 'completed' | 'canceled'): void { + if (this.toolCallStarted && this.currentCallId) { + // Send tool call result with canceled status + const toolResult: ReasoningToolResult = { + type: 'tool-call-result', + callId: this.currentCallId, + output: { + content: this.contentBuffer || '', + status: status + }, + id: randomUUID() + }; + logger.debug(`${this.getLogPrefix()} Sending tool call result with status: ${status}`); + this.onMessage?.(toolResult); + } + } + + /** + * Reset internal state. + */ + protected resetState(): void { + this.accumulator = ''; + this.inTitleCapture = false; + this.titleBuffer = ''; + this.contentBuffer = ''; + this.hasTitle = false; + this.currentCallId = null; + this.toolCallStarted = false; + this.currentTitle = null; + } + + /** + * Get the current call ID for tool result matching. + */ + getCurrentCallId(): string | null { + return this.currentCallId; + } + + /** + * Check if a tool call has been started. + */ + hasStartedToolCall(): boolean { + return this.toolCallStarted; + } +} diff --git a/src/utils/__tests__/runtime.test.ts b/src/utils/__tests__/runtime.test.ts new file mode 100644 index 00000000..b03f701f --- /dev/null +++ b/src/utils/__tests__/runtime.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getRuntime, isNode, isBun, isDeno } from '../runtime'; + +describe('Runtime Detection', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('detects Node.js runtime correctly', () => { + // Test actual runtime detection + if (process.versions.node && !process.versions.bun && !process.versions.deno) { + expect(getRuntime()).toBe('node'); + expect(isNode()).toBe(true); + expect(isBun()).toBe(false); + expect(isDeno()).toBe(false); + } + }); + + it('detects Bun runtime correctly', () => { + if (process.versions.bun) { + expect(getRuntime()).toBe('bun'); + expect(isNode()).toBe(false); + expect(isBun()).toBe(true); + expect(isDeno()).toBe(false); + } + }); + + it('detects Deno runtime correctly', () => { + if (process.versions.deno) { + expect(getRuntime()).toBe('deno'); + expect(isNode()).toBe(false); + expect(isBun()).toBe(false); + expect(isDeno()).toBe(true); + } + }); + + it('returns valid runtime type', () => { + const runtime = getRuntime(); + expect(['node', 'bun', 'deno', 'unknown']).toContain(runtime); + }); + + it('provides consistent predicate functions', () => { + const runtime = getRuntime(); + + // Only one should be true + const trues = [isNode(), isBun(), isDeno()].filter(Boolean); + expect(trues.length).toBeLessThanOrEqual(1); + + // If runtime is not unknown, exactly one should be true + if (runtime !== 'unknown') { + expect(trues.length).toBe(1); + } + }); + + it('handles edge cases gracefully', () => { + // Should not throw + expect(() => getRuntime()).not.toThrow(); + + // Should return string + const runtime = getRuntime(); + expect(typeof runtime).toBe('string'); + }); +}); diff --git a/src/utils/__tests__/runtimeIntegration.test.ts b/src/utils/__tests__/runtimeIntegration.test.ts new file mode 100644 index 00000000..23eb44a8 --- /dev/null +++ b/src/utils/__tests__/runtimeIntegration.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; + +describe('Runtime Integration Tests', () => { + it('runtime detection is consistent across imports', async () => { + const { getRuntime } = await import('../runtime.js'); + const runtime1 = getRuntime(); + + // Re-import to test caching + const { getRuntime: getRuntime2 } = await import('../runtime.js'); + const runtime2 = getRuntime2(); + + expect(runtime1).toBe(runtime2); + expect(['node', 'bun', 'deno', 'unknown']).toContain(runtime1); + }); + + it('runtime detection works in actual execution environment', async () => { + const { getRuntime, isNode, isBun, isDeno } = await import('../runtime.js'); + + const runtime = getRuntime(); + + if (process.versions.node && !process.versions.bun && !process.versions.deno) { + expect(runtime).toBe('node'); + expect(isNode()).toBe(true); + expect(isBun()).toBe(false); + expect(isDeno()).toBe(false); + } else if (process.versions.bun) { + expect(runtime).toBe('bun'); + expect(isNode()).toBe(false); + expect(isBun()).toBe(true); + expect(isDeno()).toBe(false); + } else if (process.versions.deno) { + expect(runtime).toBe('deno'); + expect(isNode()).toBe(false); + expect(isBun()).toBe(false); + expect(isDeno()).toBe(true); + } + }); + + it('runtime utilities can be imported correctly', async () => { + const runtimeModule = await import('../runtime.js'); + + // Check that all expected exports are available + expect(typeof runtimeModule.getRuntime).toBe('function'); + expect(typeof runtimeModule.isBun).toBe('function'); + expect(typeof runtimeModule.isNode).toBe('function'); + expect(typeof runtimeModule.isDeno).toBe('function'); + expect(typeof runtimeModule.getRuntime()).toBe('string'); + }); + + it('provides correct runtime type', async () => { + const { getRuntime } = await import('../runtime.js'); + const runtime = getRuntime(); + expect(['node', 'bun', 'deno', 'unknown']).toContain(runtime); + }); +}); \ No newline at end of file diff --git a/src/utils/createSessionMetadata.ts b/src/utils/createSessionMetadata.ts new file mode 100644 index 00000000..4511b0c8 --- /dev/null +++ b/src/utils/createSessionMetadata.ts @@ -0,0 +1,89 @@ +/** + * Session Metadata Factory + * + * Creates session state and metadata objects for all backends (Claude, Codex, Gemini). + * This follows DRY principles by providing a single implementation for all backends. + * + * @module createSessionMetadata + */ + +import os from 'node:os'; +import { resolve } from 'node:path'; + +import type { AgentState, Metadata } from '@/api/types'; +import { configuration } from '@/configuration'; +import { projectPath } from '@/projectPath'; +import packageJson from '../../package.json'; + +/** + * Backend flavor identifier for session metadata. + */ +export type BackendFlavor = 'claude' | 'codex' | 'gemini'; + +/** + * Options for creating session metadata. + */ +export interface CreateSessionMetadataOptions { + /** Backend flavor (claude, codex, gemini) */ + flavor: BackendFlavor; + /** Machine ID for server identification */ + machineId: string; + /** How the session was started */ + startedBy?: 'daemon' | 'terminal'; +} + +/** + * Result containing both state and metadata for session creation. + */ +export interface SessionMetadataResult { + /** Agent state for session */ + state: AgentState; + /** Session metadata */ + metadata: Metadata; +} + +/** + * Creates session state and metadata for backend agents. + * + * This utility consolidates the common session metadata creation logic used by + * Codex and Gemini backends, ensuring consistency across all backend implementations. + * + * @param opts - Options specifying flavor, machineId, and startedBy + * @returns Object containing state and metadata for session creation + * + * @example + * ```typescript + * const { state, metadata } = createSessionMetadata({ + * flavor: 'gemini', + * machineId: settings.machineId, + * startedBy: opts.startedBy + * }); + * + * const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); + * ``` + */ +export function createSessionMetadata(opts: CreateSessionMetadataOptions): SessionMetadataResult { + const state: AgentState = { + controlledByUser: false, + }; + + const metadata: Metadata = { + path: process.cwd(), + host: os.hostname(), + version: packageJson.version, + os: os.platform(), + machineId: opts.machineId, + homeDir: os.homedir(), + happyHomeDir: configuration.happyHomeDir, + happyLibDir: projectPath(), + happyToolsDir: resolve(projectPath(), 'tools', 'unpacked'), + startedFromDaemon: opts.startedBy === 'daemon', + hostPid: process.pid, + startedBy: opts.startedBy || 'terminal', + lifecycleState: 'running', + lifecycleStateSince: Date.now(), + flavor: opts.flavor + }; + + return { state, metadata }; +} diff --git a/src/utils/deepEqual.ts b/src/utils/deepEqual.ts deleted file mode 100644 index 4331ce37..00000000 --- a/src/utils/deepEqual.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Deep equality helper for comparing tool arguments -export function deepEqual(a: any, b: any): boolean { - 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; -} \ No newline at end of file diff --git a/src/utils/expandEnvVars.test.ts b/src/utils/expandEnvVars.test.ts new file mode 100644 index 00000000..bf9c9a02 --- /dev/null +++ b/src/utils/expandEnvVars.test.ts @@ -0,0 +1,275 @@ +/** + * Unit tests for environment variable expansion utility + */ +import { describe, expect, it, vi } from 'vitest'; + +// Mock logger to avoid logger.warn/debug not being a function errors +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + error: vi.fn() + } +})); + +import { expandEnvironmentVariables } from './expandEnvVars'; + +describe('expandEnvironmentVariables', () => { + it('should expand simple ${VAR} reference', () => { + const envVars = { + TARGET: '${SOURCE}' + }; + const sourceEnv = { + SOURCE: 'value123' + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + TARGET: 'value123' + }); + }); + + it('should expand multiple ${VAR} references in same value', () => { + const envVars = { + PATH: '${BIN_DIR}:${LIB_DIR}' + }; + const sourceEnv = { + BIN_DIR: '/usr/bin', + LIB_DIR: '/usr/lib' + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + PATH: '/usr/bin:/usr/lib' + }); + }); + + it('should expand ${VAR} in middle of string', () => { + const envVars = { + MESSAGE: 'Hello ${NAME}, welcome!' + }; + const sourceEnv = { + NAME: 'World' + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + MESSAGE: 'Hello World, welcome!' + }); + }); + + it('should handle authentication token expansion pattern', () => { + const envVars = { + ANTHROPIC_AUTH_TOKEN: '${Z_AI_AUTH_TOKEN}' + }; + const sourceEnv = { + Z_AI_AUTH_TOKEN: 'sk-ant-real-key-12345' + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + ANTHROPIC_AUTH_TOKEN: 'sk-ant-real-key-12345' + }); + }); + + it('should preserve values without ${VAR} references', () => { + const envVars = { + STATIC: 'plain-value', + NUMBER: '12345', + PATH: '/usr/bin:/usr/lib' + }; + const sourceEnv = { + UNUSED: 'ignored' + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + STATIC: 'plain-value', + NUMBER: '12345', + PATH: '/usr/bin:/usr/lib' + }); + }); + + it('should leave unexpanded ${VAR} when variable not found in source', () => { + const envVars = { + TARGET: '${MISSING_VAR}' + }; + const sourceEnv = { + OTHER: 'value' + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + TARGET: '${MISSING_VAR}' + }); + }); + + it('should handle partial expansion when some variables missing', () => { + const envVars = { + MIXED: '${EXISTS}:${MISSING}:${ALSO_EXISTS}' + }; + const sourceEnv = { + EXISTS: 'found1', + ALSO_EXISTS: 'found2' + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + MIXED: 'found1:${MISSING}:found2' + }); + }); + + it('should handle empty string values in source environment', () => { + const envVars = { + TARGET: '${EMPTY_VAR}' + }; + const sourceEnv = { + EMPTY_VAR: '' + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + TARGET: '' + }); + }); + + it('should handle multiple variables with same source', () => { + const envVars = { + VAR1: '${SHARED}', + VAR2: 'prefix-${SHARED}', + VAR3: '${SHARED}-suffix' + }; + const sourceEnv = { + SHARED: 'common-value' + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + VAR1: 'common-value', + VAR2: 'prefix-common-value', + VAR3: 'common-value-suffix' + }); + }); + + it('should not modify original envVars object', () => { + const envVars = { + TARGET: '${SOURCE}' + }; + const sourceEnv = { + SOURCE: 'value' + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + + // Original should be unchanged + expect(envVars).toEqual({ + TARGET: '${SOURCE}' + }); + + // Result should be expanded + expect(result).toEqual({ + TARGET: 'value' + }); + }); + + it('should use process.env as default source when not provided', () => { + // Save original + const originalPath = process.env.PATH; + + const envVars = { + MY_PATH: '${PATH}' + }; + + const result = expandEnvironmentVariables(envVars); + expect(result.MY_PATH).toBe(originalPath); + }); + + it('should handle nested braces correctly', () => { + const envVars = { + COMPLEX: '${VAR1}/${VAR2}/literal-${}' + }; + const sourceEnv = { + VAR1: 'part1', + VAR2: 'part2' + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + COMPLEX: 'part1/part2/literal-${}' + }); + }); + + it('should handle variables with underscores and numbers', () => { + const envVars = { + TARGET: '${MY_VAR_123}' + }; + const sourceEnv = { + MY_VAR_123: 'value-with-numbers' + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + TARGET: 'value-with-numbers' + }); + }); + + it('should handle real-world profile environment variables scenario', () => { + const profileEnvVars = { + ANTHROPIC_AUTH_TOKEN: '${Z_AI_AUTH_TOKEN}', + ANTHROPIC_BASE_URL: 'https://api.anthropic.com', + OPENAI_API_KEY: '${Z_OPENAI_KEY}', + CUSTOM_PATH: '/custom:${HOME}/bin' + }; + const daemonEnv = { + Z_AI_AUTH_TOKEN: 'sk-ant-12345', + Z_OPENAI_KEY: 'sk-proj-67890', + HOME: '/Users/test' + }; + + const result = expandEnvironmentVariables(profileEnvVars, daemonEnv); + expect(result).toEqual({ + ANTHROPIC_AUTH_TOKEN: 'sk-ant-12345', + ANTHROPIC_BASE_URL: 'https://api.anthropic.com', + OPENAI_API_KEY: 'sk-proj-67890', + CUSTOM_PATH: '/custom:/Users/test/bin' + }); + }); + + it('should handle undefined source environment gracefully', () => { + const envVars = { + TARGET: '${MISSING}' + }; + + // undefined source should fall back to process.env + const result = expandEnvironmentVariables(envVars, undefined as any); + + // Should return unexpanded since variable likely not in process.env + expect(result.TARGET).toContain('${'); + }); + + it('should handle empty objects', () => { + const result = expandEnvironmentVariables({}, {}); + expect(result).toEqual({}); + }); + + it('should not expand malformed ${} references', () => { + const envVars = { + BAD1: '${', + BAD2: '${}', + BAD3: 'text-${', + GOOD: '${VALID}' + }; + const sourceEnv = { + VALID: 'expanded' + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + BAD1: '${', + BAD2: '${}', + BAD3: 'text-${', + GOOD: 'expanded' + }); + }); +}); diff --git a/src/utils/expandEnvVars.ts b/src/utils/expandEnvVars.ts new file mode 100644 index 00000000..f4e08f77 --- /dev/null +++ b/src/utils/expandEnvVars.ts @@ -0,0 +1,96 @@ +import { logger } from '@/ui/logger'; + +/** + * Expands ${VAR} references in environment variable values. + * + * CONTEXT: + * Profiles can use ${VAR} syntax to reference daemon's environment: + * Example: { ANTHROPIC_AUTH_TOKEN: "${Z_AI_AUTH_TOKEN}" } + * + * When daemon spawns sessions: + * - Tmux mode: Shell automatically expands ${VAR} + * - Non-tmux mode: Node.js spawn does NOT expand ${VAR} + * + * This utility ensures ${VAR} expansion works in both modes. + * + * @param envVars - Environment variables that may contain ${VAR} references + * @param sourceEnv - Source environment (usually process.env) to resolve references from + * @returns New object with all ${VAR} references expanded to actual values + * + * @example + * ```typescript + * const daemon_env = { Z_AI_AUTH_TOKEN: "sk-real-key" }; + * const profile_vars = { ANTHROPIC_AUTH_TOKEN: "${Z_AI_AUTH_TOKEN}" }; + * + * const expanded = expandEnvironmentVariables(profile_vars, daemon_env); + * // Result: { ANTHROPIC_AUTH_TOKEN: "sk-real-key" } + * ``` + */ +export function expandEnvironmentVariables( + envVars: Record, + sourceEnv: NodeJS.ProcessEnv = process.env +): Record { + const expanded: Record = {}; + const undefinedVars: string[] = []; + + for (const [key, value] of Object.entries(envVars)) { + // Replace all ${VAR} and ${VAR:-default} references with actual values from sourceEnv + const expandedValue = value.replace(/\$\{([^}]+)\}/g, (match, expr) => { + // Support bash parameter expansion: ${VAR:-default} + // Example: ${Z_AI_BASE_URL:-https://api.z.ai/api/anthropic} + const colonDashIndex = expr.indexOf(':-'); + let varName: string; + let defaultValue: string | undefined; + + if (colonDashIndex !== -1) { + // Split ${VAR:-default} into varName and defaultValue + varName = expr.substring(0, colonDashIndex); + defaultValue = expr.substring(colonDashIndex + 2); + } else { + // Simple ${VAR} reference + varName = expr; + } + + const resolvedValue = sourceEnv[varName]; + if (resolvedValue !== undefined) { + // Variable found in source environment - use its value + // Log for debugging (mask secret-looking values) + const isSensitive = varName.toLowerCase().includes('token') || + varName.toLowerCase().includes('key') || + varName.toLowerCase().includes('secret'); + const displayValue = isSensitive + ? (resolvedValue ? `<${resolvedValue.length} chars>` : '') + : resolvedValue; + logger.debug(`[EXPAND ENV] Expanded ${varName} from daemon env: ${displayValue}`); + + // Warn if empty string (common mistake) + if (resolvedValue === '') { + logger.warn(`[EXPAND ENV] WARNING: ${varName} is set but EMPTY in daemon environment`); + } + + return resolvedValue; + } else if (defaultValue !== undefined) { + // Variable not found but default value provided - use default + logger.debug(`[EXPAND ENV] Using default value for ${varName}: ${defaultValue}`); + return defaultValue; + } else { + // Variable not found and no default - keep placeholder and warn + undefinedVars.push(varName); + return match; + } + }); + + expanded[key] = expandedValue; + } + + // Log warning if any variables couldn't be resolved + if (undefinedVars.length > 0) { + logger.warn(`[EXPAND ENV] Undefined variables referenced in profile environment: ${undefinedVars.join(', ')}`); + logger.warn(`[EXPAND ENV] Session may fail to authenticate. Set these in daemon environment before launching:`); + undefinedVars.forEach(varName => { + logger.warn(`[EXPAND ENV] ${varName}=`); + }); + } + + return expanded; +} diff --git a/src/utils/offlineSessionStub.ts b/src/utils/offlineSessionStub.ts new file mode 100644 index 00000000..54f51a6e --- /dev/null +++ b/src/utils/offlineSessionStub.ts @@ -0,0 +1,53 @@ +/** + * Offline Session Stub Factory + * + * Creates a no-op session stub for offline mode that can be used across all backends + * (Claude, Codex, Gemini, etc.). All session methods become no-ops until reconnection. + * + * This follows DRY principles by providing a single implementation for all backends, + * satisfying REQ-8 from serverConnectionErrors.ts. + * + * @module offlineSessionStub + */ + +import type { ApiSessionClient } from '@/api/apiSession'; + +/** + * Creates a no-op session stub for offline mode. + * + * The stub implements the ApiSessionClient interface with no-op methods, + * allowing the application to continue running while offline. When reconnection + * succeeds, the real session replaces this stub. + * + * @param sessionTag - Unique session tag (used to create offline session ID) + * @returns A no-op ApiSessionClient stub + * + * @example + * ```typescript + * const offlineStub = createOfflineSessionStub(sessionTag); + * let session: ApiSessionClient = offlineStub; + * + * // When reconnected: + * session = api.sessionSyncClient(response); + * ``` + */ +export function createOfflineSessionStub(sessionTag: string): ApiSessionClient { + return { + sessionId: `offline-${sessionTag}`, + sendCodexMessage: () => {}, + sendClaudeSessionMessage: () => {}, + keepAlive: () => {}, + sendSessionEvent: () => {}, + sendSessionDeath: () => {}, + updateLifecycleState: () => {}, + requestControlTransfer: async () => {}, + flush: async () => {}, + close: async () => {}, + updateMetadata: () => {}, + updateAgentState: () => {}, + onUserMessage: () => {}, + rpcHandlerManager: { + registerHandler: () => {} + } + } as unknown as ApiSessionClient; +} diff --git a/src/utils/runtime.ts b/src/utils/runtime.ts new file mode 100644 index 00000000..018bf85e --- /dev/null +++ b/src/utils/runtime.ts @@ -0,0 +1,53 @@ +/** + * Runtime utilities - minimal, focused, testable + * Single responsibility: detect current JavaScript runtime + */ + +// Type safety with explicit union +export type Runtime = 'node' | 'bun' | 'deno' | 'unknown'; + +// Cache result after first detection (performance optimization) +let cachedRuntime: Runtime | null = null; + +/** + * Detect current runtime with fallback chain + * Most reliable detection first, falling back to less reliable methods + */ +export function getRuntime(): Runtime { + if (cachedRuntime) return cachedRuntime; + + // Method 1: Global runtime objects (most reliable) + if (typeof (globalThis as any).Bun !== 'undefined') { + cachedRuntime = 'bun'; + return cachedRuntime; + } + + if (typeof (globalThis as any).Deno !== 'undefined') { + cachedRuntime = 'deno'; + return cachedRuntime; + } + + // Method 2: Process versions (fallback) + if (process?.versions?.bun) { + cachedRuntime = 'bun'; + return cachedRuntime; + } + + if (process?.versions?.deno) { + cachedRuntime = 'deno'; + return cachedRuntime; + } + + if (process?.versions?.node) { + cachedRuntime = 'node'; + return cachedRuntime; + } + + cachedRuntime = 'unknown'; + return cachedRuntime; +} + +// Convenience predicates - single responsibility each +export const isBun = (): boolean => getRuntime() === 'bun'; +export const isNode = (): boolean => getRuntime() === 'node'; +export const isDeno = (): boolean => getRuntime() === 'deno'; \ No newline at end of file diff --git a/src/utils/serverConnectionErrors.test.ts b/src/utils/serverConnectionErrors.test.ts new file mode 100644 index 00000000..9f9e2d5e --- /dev/null +++ b/src/utils/serverConnectionErrors.test.ts @@ -0,0 +1,589 @@ +/** + * Unit tests for serverConnectionErrors utility. + * + * ## Test Coverage Strategy + * These tests exercise the real code paths with minimal mocking: + * - Only axios.isAxiosError is mocked (needed for error type detection) + * - Health check is injected for deterministic behavior + * - Real exponentialBackoffDelay is used (tests account for timing) + * + * ## Requirements Verified + * - REQ-1: Continue working when server unreachable (via graceful callback pattern) + * - REQ-3: Exponential backoff (via retry tests) + * - REQ-7: User notification (via onNotify callback verification) + * - REQ-8: DRY implementation (single utility, verified by type system) + * - REQ-9: Backend transparency (via generic TSession tests) + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { startOfflineReconnection, printOfflineWarning, connectionState, isNetworkError, NETWORK_ERROR_CODES } from './serverConnectionErrors'; + +// Mock axios - only isAxiosError needed for error type detection +vi.mock('axios', () => ({ + default: { + get: vi.fn(), + isAxiosError: (e: unknown) => { + return e !== null && typeof e === 'object' && 'isAxiosError' in e && (e as any).isAxiosError === true; + } + }, + isAxiosError: (e: unknown) => { + return e !== null && typeof e === 'object' && 'isAxiosError' in e && (e as any).isAxiosError === true; + } +})); + +// Mock logger to prevent console noise in tests +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn() + } +})); + +// ============================================================================ +// Test Helpers (DRY) +// ============================================================================ + +interface TestHandleConfig { + healthCheck?: () => Promise; + onReconnected?: () => Promise; + onNotify?: (msg: string) => void; + onCleanup?: () => void; + initialDelayMs?: number; +} + +/** + * Creates a test reconnection handle with sensible defaults. + * Reduces boilerplate in individual tests. + */ +function createTestHandle(config: TestHandleConfig = {}) { + const onReconnected = config.onReconnected ?? vi.fn().mockResolvedValue({ id: 'test-session' }); + const onNotify = config.onNotify ?? vi.fn(); + const onCleanup = config.onCleanup ?? vi.fn(); + + const handle = startOfflineReconnection({ + serverUrl: 'http://test-server', + onReconnected: onReconnected as () => Promise, + onNotify, + onCleanup, + healthCheck: config.healthCheck ?? (async () => { /* success */ }), + initialDelayMs: config.initialDelayMs ?? 1 + }); + + return { handle, onReconnected, onNotify, onCleanup }; +} + +/** + * Waits for reconnection to succeed, with timeout protection. + * Polls isReconnected() to avoid flaky timing issues. + */ +async function waitForReconnection( + handle: ReturnType, + timeoutMs: number = 15000 +): Promise { + const startTime = Date.now(); + while (Date.now() - startTime < timeoutMs) { + if (handle.isReconnected()) return true; + await new Promise(resolve => setTimeout(resolve, 100)); + } + return false; +} + +/** + * Creates an axios-style error for testing error handling paths. + */ +function createAxiosError(status: number): Error & { response: { status: number }, isAxiosError: true } { + const error = new Error(`HTTP ${status}`) as any; + error.response = { status }; + error.isAxiosError = true; + return error; +} + +// ============================================================================ +// Core Functionality Tests +// ============================================================================ + +describe('startOfflineReconnection', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('successful reconnection', () => { + it('should call onReconnected when health check succeeds', async () => { + const { handle, onReconnected, onNotify } = createTestHandle(); + + await waitForReconnection(handle); + + expect(onReconnected).toHaveBeenCalledOnce(); + expect(onNotify).toHaveBeenCalledWith('āœ… Reconnected! Session syncing in background.'); + expect(handle.isReconnected()).toBe(true); + + handle.cancel(); + }); + + it('should return session via getSession() after reconnection', async () => { + const mockSession = { id: 'session-123', metadata: { path: '/test' } }; + const { handle } = createTestHandle({ + onReconnected: vi.fn().mockResolvedValue(mockSession) + }); + + expect(handle.getSession()).toBeNull(); // Before reconnection + + await waitForReconnection(handle); + + expect(handle.getSession()).toEqual(mockSession); + expect(handle.getSession()?.id).toBe('session-123'); + + handle.cancel(); + }); + + it('should only reconnect once (idempotent)', async () => { + const { handle, onReconnected } = createTestHandle(); + + await waitForReconnection(handle); + await new Promise(resolve => setTimeout(resolve, 200)); // Extra wait + + expect(onReconnected).toHaveBeenCalledTimes(1); + + handle.cancel(); + }); + }); + + describe('retry behavior', () => { + // NOTE: Retries are UNLIMITED. The "10" in exponentialBackoffDelay(failureCount, 5000, 60000, 10) + // is the DELAY cap (delay stops growing at ~60s), NOT a retry limit. + // Servers can be down for hours; sessions can stay open for weeks. + + it('should retry when health check fails then succeeds', async () => { + let attemptCount = 0; + const healthCheck = async () => { + attemptCount++; + if (attemptCount < 2) throw new Error('ECONNREFUSED'); + }; + + const { handle, onReconnected } = createTestHandle({ healthCheck }); + + const success = await waitForReconnection(handle); + + expect(success).toBe(true); + expect(attemptCount).toBeGreaterThanOrEqual(2); + expect(onReconnected).toHaveBeenCalledOnce(); + + handle.cancel(); + }, 20000); + + it('should retry when onReconnected throws', async () => { + let callCount = 0; + const onReconnected = vi.fn().mockImplementation(async () => { + callCount++; + if (callCount === 1) throw new Error('Session creation failed'); + return { id: 'session' }; + }); + + const { handle } = createTestHandle({ onReconnected }); + + const success = await waitForReconnection(handle); + + expect(success).toBe(true); + expect(onReconnected).toHaveBeenCalledTimes(2); + + handle.cancel(); + }, 20000); + + it('should increment failure count on each retry', async () => { + let attemptCount = 0; + const healthCheck = async () => { + attemptCount++; + if (attemptCount < 3) throw new Error('Network error'); + }; + + const { handle } = createTestHandle({ healthCheck }); + + // With real exponential backoff (5s + 10s delays with jitter), + // we need ~20s to reach attempt 3 + await waitForReconnection(handle, 25000); + + expect(attemptCount).toBe(3); + + handle.cancel(); + }, 30000); + }); + + describe('cancellation', () => { + it('should stop attempts when cancelled', async () => { + let attemptCount = 0; + const healthCheck = async () => { + attemptCount++; + throw new Error('Always fail'); + }; + + const { handle, onCleanup } = createTestHandle({ healthCheck }); + + await new Promise(resolve => setTimeout(resolve, 50)); + const countBeforeCancel = attemptCount; + + handle.cancel(); + expect(onCleanup).toHaveBeenCalledOnce(); + + await new Promise(resolve => setTimeout(resolve, 200)); + expect(attemptCount).toBe(countBeforeCancel); + }); + + it('should prevent reconnection if cancelled before first attempt', async () => { + const { handle, onReconnected, onCleanup } = createTestHandle({ + initialDelayMs: 500 // Long delay to allow cancel before attempt + }); + + handle.cancel(); + expect(onCleanup).toHaveBeenCalledOnce(); + + await new Promise(resolve => setTimeout(resolve, 600)); + + expect(onReconnected).not.toHaveBeenCalled(); + expect(handle.isReconnected()).toBe(false); + }); + + it('should be safe to call cancel() multiple times', async () => { + const onCleanup = vi.fn(); + const { handle } = createTestHandle({ onCleanup }); + + handle.cancel(); + handle.cancel(); + handle.cancel(); + + // onCleanup should still only be called once per cancel() call + // (cancel sets cancelled=true, preventing further action) + expect(onCleanup).toHaveBeenCalledTimes(3); + }); + }); + + describe('error handling', () => { + it('should stop retrying on 401 authentication error', async () => { + let attemptCount = 0; + const healthCheck = async () => { + attemptCount++; + throw createAxiosError(401); + }; + + const { handle, onNotify, onReconnected } = createTestHandle({ healthCheck }); + + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(attemptCount).toBe(1); + expect(onNotify).toHaveBeenCalledWith( + 'āŒ Authentication failed. Please re-authenticate with `happy auth`.' + ); + expect(onReconnected).not.toHaveBeenCalled(); + + await new Promise(resolve => setTimeout(resolve, 200)); + expect(attemptCount).toBe(1); // No retry after auth failure + + handle.cancel(); + }); + + it('should retry on 500 server error', async () => { + let attemptCount = 0; + const healthCheck = async () => { + attemptCount++; + if (attemptCount < 2) throw createAxiosError(500); + }; + + const { handle } = createTestHandle({ healthCheck }); + + await waitForReconnection(handle); + + expect(attemptCount).toBeGreaterThanOrEqual(2); + expect(handle.isReconnected()).toBe(true); + + handle.cancel(); + }, 20000); + + it('should retry on 503 service unavailable', async () => { + let attemptCount = 0; + const healthCheck = async () => { + attemptCount++; + if (attemptCount < 2) throw createAxiosError(503); + }; + + const { handle } = createTestHandle({ healthCheck }); + + await waitForReconnection(handle); + + expect(attemptCount).toBeGreaterThanOrEqual(2); + + handle.cancel(); + }, 20000); + + it('should retry on non-axios network errors', async () => { + let attemptCount = 0; + const healthCheck = async () => { + attemptCount++; + if (attemptCount < 2) { + const error = new Error('ECONNREFUSED'); + (error as any).code = 'ECONNREFUSED'; + throw error; + } + }; + + const { handle } = createTestHandle({ healthCheck }); + + await waitForReconnection(handle); + + expect(attemptCount).toBeGreaterThanOrEqual(2); + + handle.cancel(); + }, 20000); + + it('should retry on ETIMEDOUT errors', async () => { + let attemptCount = 0; + const healthCheck = async () => { + attemptCount++; + if (attemptCount < 2) { + const error = new Error('ETIMEDOUT'); + (error as any).code = 'ETIMEDOUT'; + throw error; + } + }; + + const { handle } = createTestHandle({ healthCheck }); + + await waitForReconnection(handle); + + expect(attemptCount).toBeGreaterThanOrEqual(2); + + handle.cancel(); + }, 20000); + + it('should NOT stop retrying on 403 forbidden (not auth failure)', async () => { + let attemptCount = 0; + const healthCheck = async () => { + attemptCount++; + if (attemptCount < 2) throw createAxiosError(403); + }; + + const { handle, onNotify } = createTestHandle({ healthCheck }); + + await waitForReconnection(handle); + + expect(attemptCount).toBeGreaterThanOrEqual(2); + // Should NOT show auth failure message for 403 + expect(onNotify).not.toHaveBeenCalledWith( + expect.stringContaining('Authentication failed') + ); + expect(onNotify).toHaveBeenCalledWith('āœ… Reconnected! Session syncing in background.'); + + handle.cancel(); + }, 20000); + }); + + describe('edge cases', () => { + it('should handle race condition: cancel during async health check', async () => { + let healthCheckResolve: () => void; + const healthCheckPromise = new Promise(resolve => { + healthCheckResolve = resolve; + }); + + const { handle, onReconnected } = createTestHandle({ + healthCheck: async () => { + await healthCheckPromise; + } + }); + + // Wait for health check to start + await new Promise(resolve => setTimeout(resolve, 50)); + + // Cancel while health check is in progress + handle.cancel(); + + // Now let health check complete + healthCheckResolve!(); + + await new Promise(resolve => setTimeout(resolve, 50)); + + // onReconnected should NOT be called because cancelled flag is set + expect(onReconnected).not.toHaveBeenCalled(); + expect(handle.isReconnected()).toBe(false); + }); + + it('should handle race condition: cancel during async onReconnected', async () => { + let onReconnectedResolve: () => void; + const onReconnectedPromise = new Promise<{ id: string }>(resolve => { + onReconnectedResolve = () => resolve({ id: 'session' }); + }); + + const onReconnected = vi.fn().mockImplementation(async () => { + return onReconnectedPromise; + }); + + const { handle, onNotify } = createTestHandle({ onReconnected }); + + // Wait for onReconnected to start + await new Promise(resolve => setTimeout(resolve, 50)); + expect(onReconnected).toHaveBeenCalled(); + + // Cancel while onReconnected is in progress + handle.cancel(); + + // Now let onReconnected complete + onReconnectedResolve!(); + + await new Promise(resolve => setTimeout(resolve, 50)); + + // Session should still be set (async operation completed) + // but no further actions should occur + expect(handle.getSession()).toEqual({ id: 'session' }); + }); + + it('should handle empty/undefined session from onReconnected', async () => { + const { handle } = createTestHandle({ + onReconnected: vi.fn().mockResolvedValue(undefined) + }); + + await waitForReconnection(handle); + + expect(handle.isReconnected()).toBe(true); + expect(handle.getSession()).toBeUndefined(); + + handle.cancel(); + }); + + it('should handle null session from onReconnected', async () => { + const { handle } = createTestHandle({ + onReconnected: vi.fn().mockResolvedValue(null) + }); + + await waitForReconnection(handle); + + expect(handle.isReconnected()).toBe(true); + expect(handle.getSession()).toBeNull(); + + handle.cancel(); + }); + + it('should support generic session types (type safety)', async () => { + interface CustomSession { + sessionId: string; + metadata: { + path: string; + host: string; + }; + capabilities: string[]; + } + + const customSession: CustomSession = { + sessionId: 'custom-123', + metadata: { path: '/workspace', host: 'localhost' }, + capabilities: ['read', 'write', 'execute'] + }; + + const { handle } = createTestHandle({ + onReconnected: vi.fn().mockResolvedValue(customSession) + }); + + await waitForReconnection(handle); + + const session = handle.getSession(); + expect(session?.sessionId).toBe('custom-123'); + expect(session?.metadata.path).toBe('/workspace'); + expect(session?.capabilities).toContain('write'); + + handle.cancel(); + }); + + it('should work without optional onCleanup callback', async () => { + const handle = startOfflineReconnection({ + serverUrl: 'http://test', + onReconnected: async () => ({ id: 'session' }), + onNotify: vi.fn(), + healthCheck: async () => {}, + initialDelayMs: 1 + // onCleanup intentionally omitted + }); + + await waitForReconnection(handle); + + // Should not throw when cancelling without onCleanup + expect(() => handle.cancel()).not.toThrow(); + }); + }); +}); + +// ============================================================================ +// printOfflineWarning Tests +// ============================================================================ + +describe('printOfflineWarning', () => { + beforeEach(() => { + connectionState.reset(); // Reset singleton state between tests + }); + + it('should print offline warning with unified format', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + printOfflineWarning(); + + // New unified format via connectionState.fail() + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('āš ļø Happy server unreachable, offline mode with auto-reconnect enabled') + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Server connection failed') + ); + + consoleSpy.mockRestore(); + }); + + it('should deduplicate repeated calls', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + printOfflineWarning('Claude'); + const callCountAfterFirst = consoleSpy.mock.calls.length; + + printOfflineWarning('Claude'); // Second call should be deduplicated + const callCountAfterSecond = consoleSpy.mock.calls.length; + + // Should not print again (same call count) + expect(callCountAfterSecond).toBe(callCountAfterFirst); + + consoleSpy.mockRestore(); + }); +}); + +// ============================================================================ +// isNetworkError Tests +// ============================================================================ + +describe('isNetworkError', () => { + it('should return true for all NETWORK_ERROR_CODES', () => { + // All codes in NETWORK_ERROR_CODES should return true + expect(isNetworkError('ECONNREFUSED')).toBe(true); + expect(isNetworkError('ENOTFOUND')).toBe(true); + expect(isNetworkError('ETIMEDOUT')).toBe(true); + expect(isNetworkError('ECONNRESET')).toBe(true); + expect(isNetworkError('EHOSTUNREACH')).toBe(true); + expect(isNetworkError('ENETUNREACH')).toBe(true); + }); + + it('should return false for non-network error codes', () => { + expect(isNetworkError('UNAUTHORIZED')).toBe(false); + expect(isNetworkError('EACCES')).toBe(false); + expect(isNetworkError('ENOENT')).toBe(false); + expect(isNetworkError('UNKNOWN')).toBe(false); + }); + + it('should return false for undefined', () => { + expect(isNetworkError(undefined)).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isNetworkError('')).toBe(false); + }); + + it('should have exactly 6 network error codes', () => { + expect(NETWORK_ERROR_CODES).toHaveLength(6); + expect(NETWORK_ERROR_CODES).toContain('ECONNREFUSED'); + expect(NETWORK_ERROR_CODES).toContain('ENOTFOUND'); + expect(NETWORK_ERROR_CODES).toContain('ETIMEDOUT'); + expect(NETWORK_ERROR_CODES).toContain('ECONNRESET'); + expect(NETWORK_ERROR_CODES).toContain('EHOSTUNREACH'); + expect(NETWORK_ERROR_CODES).toContain('ENETUNREACH'); + }); +}); diff --git a/src/utils/serverConnectionErrors.ts b/src/utils/serverConnectionErrors.ts new file mode 100644 index 00000000..cd479d97 --- /dev/null +++ b/src/utils/serverConnectionErrors.ts @@ -0,0 +1,346 @@ +/** + * Offline reconnection utility for graceful server disconnection handling. + * + * Provides a backend-agnostic reconnection mechanism with exponential backoff + * that works for both Claude and Codex (and future backends). + * + * ## Requirements Satisfied + * - REQ-1: Claude/Codex keeps working when server unreachable + * - REQ-3: Exponential backoff reconnection attempts + * - REQ-4: Hot reconnection without PTY exit + * - REQ-7: Notify user when server becomes available + * - REQ-8: DRY - single shared implementation for all backends + * - REQ-9: Backend-transparent design via generic TSession type + * + * ## Key Features + * - Exponential backoff with jitter (prevents thundering herd) + * - Auth error detection (stops retrying on 401) + * - Cancellable for clean process cleanup (RAII pattern) + * - Generic session type for backend transparency + * - Dependency injection for health check (testability) + * + * ## State Machine + * ``` + * [IDLE] --initialDelay--> [ATTEMPTING] + * | + * +------------------+------------------+ + * | | | + * v v v + * [RECONNECTED] [RETRY_PENDING] [AUTH_FAILED] + * (final) | (final) + * | + * --backoff--> + * | + * v + * [ATTEMPTING] + * + * cancel() from any state --> [CANCELLED] (final) + * ``` + * + * ## Edge Cases Handled + * - Auth errors (401): Stop retrying, notify user to re-authenticate + * - Server 4xx: Treated as "server is up" (validateStatus < 500) + * - Server 5xx: Retry with backoff (server error, may recover) + * - Cancel during async: `cancelled` flag checked before state changes + * - onReconnected throws: Treated as connection error, retry with backoff + * - Multiple success attempts: `reconnected` flag prevents duplicates + * + * @module serverConnectionErrors + */ + +import axios from 'axios'; +import chalk from 'chalk'; +import { exponentialBackoffDelay } from '@/utils/time'; +import { logger } from '@/ui/logger'; + +/** + * Configuration for offline reconnection behavior. + * Uses dependency injection for testability. + */ +export interface OfflineReconnectionConfig { + /** Server URL to health-check against (e.g., 'https://api.happy-servers.com') */ + serverUrl: string; + + /** + * Called when server becomes available - should create and return session. + * If this throws, it's treated as a connection error and retried. + */ + onReconnected: () => Promise; + + /** Called to notify user of status changes (success or auth failure) */ + onNotify: (message: string) => void; + + /** Optional cleanup callback invoked when cancel() is called */ + onCleanup?: () => void; + + /** + * Optional: override the health check function. + * Injected for testing. Default uses axios.get to /v1/sessions. + * Should throw on failure, resolve on success. + */ + healthCheck?: () => Promise; + + /** + * Optional: initial delay in ms before first attempt. + * Default: 5000ms. Set to small value in tests. + */ + initialDelayMs?: number; +} + +/** + * Handle returned by startOfflineReconnection for controlling the reconnection process. + */ +export interface OfflineReconnectionHandle { + /** + * Cancel reconnection attempts and clean up timers. + * Safe to call multiple times. Invokes onCleanup if provided. + */ + cancel: () => void; + + /** Get the session if reconnection succeeded, null otherwise */ + getSession: () => TSession | null; + + /** Check if reconnection has succeeded (idempotent) */ + isReconnected: () => boolean; +} + +/** + * Starts background reconnection with exponential backoff. + * Backend-agnostic: works for Claude, Codex, or any future backend. + * + * ## Retry Behavior + * - **Retries are UNLIMITED** - will keep trying for hours/days/weeks + * - Only auth failures (401) stop retrying + * - Sessions can stay open indefinitely; server outages are expected + * + * ## Backoff Timing (via exponentialBackoffDelay from time.ts) + * - Attempt 1: ~5 seconds (min delay with jitter) + * - Attempt 5: ~30 seconds + * - Attempt 10+: ~60 seconds (delay caps here, retries continue forever) + * - Random jitter prevents thundering herd problem + * + * ## Usage Example + * ```typescript + * const handle = startOfflineReconnection({ + * serverUrl: 'https://api.example.com', + * onReconnected: async () => { + * const session = await createSession(); + * return session; + * }, + * onNotify: console.log + * }); + * + * // Later, on cleanup: + * handle.cancel(); + * ``` + * + * @param config - Reconnection configuration + * @returns Handle to control reconnection and access session + */ +export function startOfflineReconnection( + config: OfflineReconnectionConfig +): OfflineReconnectionHandle { + // State variables + let reconnected = false; // Prevents duplicate reconnections + let session: TSession | null = null; + let timeoutId: NodeJS.Timeout | null = null; + let failureCount = 0; + let cancelled = false; // Prevents action after cancel() + + /** + * Default health check: HTTP GET to /v1/sessions endpoint. + * Uses validateStatus to treat 4xx as "server is up" (client error, not server down). + * Only 5xx or network errors trigger retry. + */ + const defaultHealthCheck = async () => { + await axios.get(`${config.serverUrl}/v1/sessions`, { + timeout: 5000, + validateStatus: (status) => status < 500 // 4xx = server is up, 5xx = server error + }); + }; + + const healthCheck = config.healthCheck ?? defaultHealthCheck; + const initialDelayMs = config.initialDelayMs ?? 5000; + + /** + * Core reconnection attempt logic. + * Handles success, retryable errors, and permanent auth errors. + */ + const attemptReconnect = async () => { + // Check cancellation/success before any action (handles race conditions) + if (reconnected || cancelled) return; + + try { + // Step 1: Health check - verify server is reachable + await healthCheck(); + + // Re-check after async operation (handles cancel during health check) + if (cancelled) return; + + // Step 2: Server available - perform reconnection callback + // If onReconnected throws, we treat it as a connection error and retry + session = await config.onReconnected(); + + // Re-check after async operation (handles cancel during onReconnected) + // Note: session is set even if cancelled - the operation completed + if (cancelled) return; + + // Step 3: Mark success and notify user + reconnected = true; + config.onNotify('āœ… Reconnected! Session syncing in background.'); + logger.debug('[OfflineReconnection] Successfully reconnected'); + } catch (e: unknown) { + // Check for permanent errors that shouldn't be retried + // 401 = auth token invalid, user needs to re-authenticate + if (axios.isAxiosError(e) && e.response?.status === 401) { + logger.debug('[OfflineReconnection] Authentication error, stopping retries'); + config.onNotify('āŒ Authentication failed. Please re-authenticate with `happy auth`.'); + return; // Don't schedule retry - this is a permanent failure + } + + // Retryable error: network error, 5xx, or onReconnected failure + // Retries are UNLIMITED - only the delay caps at 60s after 10 failures + failureCount++; + const delay = exponentialBackoffDelay(failureCount, 5000, 60000, 10); // 10 = delay cap, NOT retry limit + logger.debug(`[OfflineReconnection] Attempt ${failureCount} failed, retrying in ${delay}ms`); + + // Schedule next attempt (only if not cancelled) + if (!cancelled) { + timeoutId = setTimeout(attemptReconnect, delay); + } + } + }; + + // Start first attempt after initial delay + timeoutId = setTimeout(attemptReconnect, initialDelayMs); + + // Return control handle + return { + cancel: () => { + cancelled = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + config.onCleanup?.(); + }, + getSession: () => session, + isReconnected: () => reconnected + }; +} + +// ============================================================================ +// Connection State - Simple state machine for offline status with deduplication +// ============================================================================ + +/** All network error codes that trigger offline mode */ +export const NETWORK_ERROR_CODES = [ + 'ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT', + 'ECONNRESET', 'EHOSTUNREACH', 'ENETUNREACH' +] as const; + +/** Check if error code indicates server unreachable */ +export function isNetworkError(code: string | undefined): boolean { + return code !== undefined && (NETWORK_ERROR_CODES as readonly string[]).includes(code); +} + +/** Maps error codes to human-readable descriptions - exported for discoverability */ +export const ERROR_DESCRIPTIONS: Record = { + // Network errors (Node.js) + ECONNREFUSED: 'server not accepting connections', + ENOTFOUND: 'server hostname not found', + ETIMEDOUT: 'connection timed out', + ECONNRESET: 'connection reset by server', + EHOSTUNREACH: 'server host unreachable', + ENETUNREACH: 'network unreachable', + // HTTP errors + '401': 'authentication failed - run `happy auth`', + '403': 'access forbidden', + '404': 'endpoint not found, check server deployment', + '500': 'server internal error', + '502': 'bad gateway', + '503': 'service unavailable', +}; + +/** Failure context for accumulating multiple failures into one warning */ +export type OfflineFailure = { + operation: string; + caller?: string; + errorCode?: string; + url?: string; + details?: string[]; // Additional context lines, each printed on new line with arrow +}; + +/** + * Coordinates offline warnings across multiple API callers. + * + * When server goes down, session + machine API calls both fail. This class + * consolidates those into one clear message with all failure details, then + * suppresses duplicates until recovery. Call recover() when back online to + * re-enable warnings for future disconnections. + */ +class OfflineState { + private state: 'online' | 'offline' = 'online'; + private failures = new Map(); // Dedupe by operation + private backend = 'Claude'; + + /** Report failure - accumulates context, prints once on first offline transition */ + fail(failure: OfflineFailure): void { + this.failures.set(failure.operation, failure); + if (this.state === 'online') { + this.state = 'offline'; + this.print(); + } + } + + /** Reset on reconnection */ + recover(): void { + this.state = 'online'; + this.failures.clear(); + } + + /** Set backend name before API calls */ + setBackend(name: string): void { this.backend = name; } + + /** Check current state */ + isOffline(): boolean { return this.state === 'offline'; } + + /** Reset for testing - clears all state */ + reset(): void { + this.state = 'online'; + this.failures.clear(); + this.backend = 'Claude'; + } + + private print(): void { + const summary = [...this.failures.values()] + .map(f => { + const desc = f.errorCode + ? `${f.errorCode} - ${ERROR_DESCRIPTIONS[f.errorCode] || 'unknown error'}` + : 'unknown error'; + const url = f.url ? ` at ${f.url}` : ''; + return `${f.operation} failed: ${desc}${url}`; + }) + .join('; '); + console.log(`āš ļø Happy server unreachable, offline mode with auto-reconnect enabled - error details: ${summary}`); + + // Print detail lines if present - consistent 3-space indent with arrow + const allDetails = [...this.failures.values()] + .flatMap(f => f.details || []); + allDetails.forEach(line => console.log(chalk.yellow(` → ${line}`))); + } +} + +/** + * Shared singleton - call setBackend() before API calls, fail() on errors, + * recover() on successful reconnection. + */ +export const connectionState = new OfflineState(); + +/** + * @deprecated Use connectionState.fail() for deduplication and context tracking + */ +export function printOfflineWarning(backendName: string = 'Claude'): void { + connectionState.setBackend(backendName); + connectionState.fail({ operation: 'Server connection' }); +} diff --git a/src/utils/setupOfflineReconnection.ts b/src/utils/setupOfflineReconnection.ts new file mode 100644 index 00000000..97ea3d00 --- /dev/null +++ b/src/utils/setupOfflineReconnection.ts @@ -0,0 +1,110 @@ +/** + * Offline Reconnection Setup + * + * Handles the common pattern of creating an offline session stub with + * automatic background reconnection for all backends (Codex, Gemini). + * + * @module setupOfflineReconnection + */ + +import type { ApiClient } from '@/api/api'; +import type { ApiSessionClient } from '@/api/apiSession'; +import type { AgentState, Metadata, Session } from '@/api/types'; +import { configuration } from '@/configuration'; +import { createOfflineSessionStub } from '@/utils/offlineSessionStub'; +import { startOfflineReconnection } from '@/utils/serverConnectionErrors'; + +/** + * Options for setting up offline reconnection. + */ +export interface SetupOfflineReconnectionOptions { + /** API client instance */ + api: ApiClient; + /** Unique session tag */ + sessionTag: string; + /** Session metadata */ + metadata: Metadata; + /** Agent state */ + state: AgentState; + /** Initial API response (null if server unreachable) */ + response: Session | null; + /** + * Callback invoked when session is swapped after reconnection. + * Use this to update the session reference in the calling code. + */ + onSessionSwap: (newSession: ApiSessionClient) => void; +} + +/** + * Result from setupOfflineReconnection. + */ +export interface SetupOfflineReconnectionResult { + /** The session client (stub if offline, real if connected) */ + session: ApiSessionClient; + /** Handle to the reconnection process, null if connected */ + reconnectionHandle: ReturnType> | null; + /** Whether we're in offline mode */ + isOffline: boolean; +} + +/** + * Sets up offline session handling with automatic background reconnection. + * + * If the server is unreachable (response is null), this creates an offline + * session stub and starts background reconnection. When reconnection succeeds, + * the `onSessionSwap` callback is invoked with the new real session. + * + * @param opts - Options including api, sessionTag, metadata, state, response, onSessionSwap + * @returns Result with session, reconnectionHandle, and isOffline flag + * + * @example + * ```typescript + * let session: ApiSessionClient; + * + * const result = setupOfflineReconnection({ + * api, + * sessionTag, + * metadata, + * state, + * response, + * onSessionSwap: (newSession) => { session = newSession; } + * }); + * + * session = result.session; + * const reconnectionHandle = result.reconnectionHandle; + * ``` + */ +export function setupOfflineReconnection(opts: SetupOfflineReconnectionOptions): SetupOfflineReconnectionResult { + const { api, sessionTag, metadata, state, response, onSessionSwap } = opts; + + let session: ApiSessionClient; + let reconnectionHandle: ReturnType> | null = null; + + // Note: connectionState.notifyOffline() was already called by api.ts with error details + if (!response) { + // Create a no-op session stub for offline mode using shared utility + session = createOfflineSessionStub(sessionTag); + + // Start background reconnection + reconnectionHandle = startOfflineReconnection({ + serverUrl: configuration.serverUrl, + onReconnected: async () => { + const resp = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); + if (!resp) throw new Error('Server unavailable'); + const realSession = api.sessionSyncClient(resp); + // Notify caller to swap the session reference + onSessionSwap(realSession); + return realSession; + }, + onNotify: (msg) => { + // Log to console - this matches Claude's behavior + console.log(msg); + } + }); + + return { session, reconnectionHandle, isOffline: true }; + } else { + session = api.sessionSyncClient(response); + return { session, reconnectionHandle: null, isOffline: false }; + } +} diff --git a/src/utils/spawnHappyCLI.ts b/src/utils/spawnHappyCLI.ts index 1ed7d2d7..560633ff 100644 --- a/src/utils/spawnHappyCLI.ts +++ b/src/utils/spawnHappyCLI.ts @@ -54,6 +54,7 @@ import { join } from 'node:path'; import { projectPath } from '@/projectPath'; import { logger } from '@/ui/logger'; import { existsSync } from 'node:fs'; +import { isBun } from './runtime'; /** * Spawn the Happy CLI with the given arguments in a cross-platform way. @@ -99,5 +100,6 @@ export function spawnHappyCLI(args: string[], options: SpawnOptions = {}): Child throw new Error(errorMessage); } - return spawn('node', nodeArgs, options); + const runtime = isBun() ? 'bun' : 'node'; + return spawn(runtime, nodeArgs, options); } diff --git a/src/utils/tmux.test.ts b/src/utils/tmux.test.ts new file mode 100644 index 00000000..c5628e98 --- /dev/null +++ b/src/utils/tmux.test.ts @@ -0,0 +1,456 @@ +/** + * Unit tests for tmux utilities + * + * NOTE: These are pure unit tests that test parsing and validation logic. + * They do NOT require tmux to be installed on the system. + * All tests mock environment variables and test string parsing only. + */ +import { describe, expect, it } from 'vitest'; +import { + parseTmuxSessionIdentifier, + formatTmuxSessionIdentifier, + validateTmuxSessionIdentifier, + buildTmuxSessionIdentifier, + TmuxSessionIdentifierError, + TmuxUtilities, + type TmuxSessionIdentifier, +} from './tmux'; + +describe('parseTmuxSessionIdentifier', () => { + it('should parse session-only identifier', () => { + const result = parseTmuxSessionIdentifier('my-session'); + expect(result).toEqual({ + session: 'my-session' + }); + }); + + it('should parse session:window identifier', () => { + const result = parseTmuxSessionIdentifier('my-session:window-1'); + expect(result).toEqual({ + session: 'my-session', + window: 'window-1' + }); + }); + + it('should parse session:window.pane identifier', () => { + const result = parseTmuxSessionIdentifier('my-session:window-1.2'); + expect(result).toEqual({ + session: 'my-session', + window: 'window-1', + pane: '2' + }); + }); + + it('should handle session names with dots, hyphens, and underscores', () => { + const result = parseTmuxSessionIdentifier('my.test_session-1'); + expect(result).toEqual({ + session: 'my.test_session-1' + }); + }); + + it('should handle window names with hyphens and underscores', () => { + const result = parseTmuxSessionIdentifier('session:my_test-window-1'); + expect(result).toEqual({ + session: 'session', + window: 'my_test-window-1' + }); + }); + + it('should throw on empty string', () => { + expect(() => parseTmuxSessionIdentifier('')).toThrow(TmuxSessionIdentifierError); + expect(() => parseTmuxSessionIdentifier('')).toThrow('Session identifier must be a non-empty string'); + }); + + it('should throw on null/undefined', () => { + expect(() => parseTmuxSessionIdentifier(null as any)).toThrow(TmuxSessionIdentifierError); + expect(() => parseTmuxSessionIdentifier(undefined as any)).toThrow(TmuxSessionIdentifierError); + }); + + it('should throw on invalid session name characters', () => { + expect(() => parseTmuxSessionIdentifier('invalid session')).toThrow(TmuxSessionIdentifierError); + expect(() => parseTmuxSessionIdentifier('invalid session')).toThrow('Only alphanumeric characters, dots, hyphens, and underscores are allowed'); + }); + + it('should throw on special characters in session name', () => { + expect(() => parseTmuxSessionIdentifier('session@name')).toThrow(TmuxSessionIdentifierError); + expect(() => parseTmuxSessionIdentifier('session#name')).toThrow(TmuxSessionIdentifierError); + expect(() => parseTmuxSessionIdentifier('session$name')).toThrow(TmuxSessionIdentifierError); + }); + + it('should throw on invalid window name characters', () => { + expect(() => parseTmuxSessionIdentifier('session:invalid window')).toThrow(TmuxSessionIdentifierError); + expect(() => parseTmuxSessionIdentifier('session:invalid window')).toThrow('Only alphanumeric characters, dots, hyphens, and underscores are allowed'); + }); + + it('should throw on non-numeric pane identifier', () => { + expect(() => parseTmuxSessionIdentifier('session:window.abc')).toThrow(TmuxSessionIdentifierError); + expect(() => parseTmuxSessionIdentifier('session:window.abc')).toThrow('Only numeric values are allowed'); + }); + + it('should throw on pane identifier with special characters', () => { + expect(() => parseTmuxSessionIdentifier('session:window.1a')).toThrow(TmuxSessionIdentifierError); + expect(() => parseTmuxSessionIdentifier('session:window.-1')).toThrow(TmuxSessionIdentifierError); + }); + + it('should trim whitespace from components', () => { + const result = parseTmuxSessionIdentifier('session : window . 2'); + expect(result).toEqual({ + session: 'session', + window: 'window', + pane: '2' + }); + }); +}); + +describe('formatTmuxSessionIdentifier', () => { + it('should format session-only identifier', () => { + const identifier: TmuxSessionIdentifier = { session: 'my-session' }; + expect(formatTmuxSessionIdentifier(identifier)).toBe('my-session'); + }); + + it('should format session:window identifier', () => { + const identifier: TmuxSessionIdentifier = { + session: 'my-session', + window: 'window-1' + }; + expect(formatTmuxSessionIdentifier(identifier)).toBe('my-session:window-1'); + }); + + it('should format session:window.pane identifier', () => { + const identifier: TmuxSessionIdentifier = { + session: 'my-session', + window: 'window-1', + pane: '2' + }; + expect(formatTmuxSessionIdentifier(identifier)).toBe('my-session:window-1.2'); + }); + + it('should ignore pane when window is not provided', () => { + const identifier: TmuxSessionIdentifier = { + session: 'my-session', + pane: '2' + }; + expect(formatTmuxSessionIdentifier(identifier)).toBe('my-session'); + }); + + it('should throw when session is missing', () => { + const identifier: TmuxSessionIdentifier = { session: '' }; + expect(() => formatTmuxSessionIdentifier(identifier)).toThrow(TmuxSessionIdentifierError); + expect(() => formatTmuxSessionIdentifier(identifier)).toThrow('Session identifier must have a session name'); + }); + + it('should handle complex valid names', () => { + const identifier: TmuxSessionIdentifier = { + session: 'my.test_session-1', + window: 'my_test-window-2', + pane: '3' + }; + expect(formatTmuxSessionIdentifier(identifier)).toBe('my.test_session-1:my_test-window-2.3'); + }); +}); + +describe('validateTmuxSessionIdentifier', () => { + it('should return valid:true for valid session-only identifier', () => { + const result = validateTmuxSessionIdentifier('my-session'); + expect(result).toEqual({ valid: true }); + }); + + it('should return valid:true for valid session:window identifier', () => { + const result = validateTmuxSessionIdentifier('my-session:window-1'); + expect(result).toEqual({ valid: true }); + }); + + it('should return valid:true for valid session:window.pane identifier', () => { + const result = validateTmuxSessionIdentifier('my-session:window-1.2'); + expect(result).toEqual({ valid: true }); + }); + + it('should return valid:false for empty string', () => { + const result = validateTmuxSessionIdentifier(''); + expect(result.valid).toBe(false); + expect(result.error).toBeDefined(); + }); + + it('should return valid:false for invalid session characters', () => { + const result = validateTmuxSessionIdentifier('invalid session'); + expect(result.valid).toBe(false); + expect(result.error).toContain('Only alphanumeric characters'); + }); + + it('should return valid:false for invalid window characters', () => { + const result = validateTmuxSessionIdentifier('session:invalid window'); + expect(result.valid).toBe(false); + expect(result.error).toContain('Only alphanumeric characters'); + }); + + it('should return valid:false for invalid pane identifier', () => { + const result = validateTmuxSessionIdentifier('session:window.abc'); + expect(result.valid).toBe(false); + expect(result.error).toContain('Only numeric values are allowed'); + }); + + it('should handle complex valid identifiers', () => { + const result = validateTmuxSessionIdentifier('my.test_session-1:my_test-window-2.3'); + expect(result).toEqual({ valid: true }); + }); + + it('should not throw exceptions', () => { + expect(() => validateTmuxSessionIdentifier('')).not.toThrow(); + expect(() => validateTmuxSessionIdentifier('invalid session')).not.toThrow(); + expect(() => validateTmuxSessionIdentifier(null as any)).not.toThrow(); + }); +}); + +describe('buildTmuxSessionIdentifier', () => { + it('should build session-only identifier', () => { + const result = buildTmuxSessionIdentifier({ session: 'my-session' }); + expect(result).toEqual({ + success: true, + identifier: 'my-session' + }); + }); + + it('should build session:window identifier', () => { + const result = buildTmuxSessionIdentifier({ + session: 'my-session', + window: 'window-1' + }); + expect(result).toEqual({ + success: true, + identifier: 'my-session:window-1' + }); + }); + + it('should build session:window.pane identifier', () => { + const result = buildTmuxSessionIdentifier({ + session: 'my-session', + window: 'window-1', + pane: '2' + }); + expect(result).toEqual({ + success: true, + identifier: 'my-session:window-1.2' + }); + }); + + it('should return error for empty session name', () => { + const result = buildTmuxSessionIdentifier({ session: '' }); + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid session name'); + }); + + it('should return error for invalid session characters', () => { + const result = buildTmuxSessionIdentifier({ session: 'invalid session' }); + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid session name'); + }); + + it('should return error for invalid window characters', () => { + const result = buildTmuxSessionIdentifier({ + session: 'session', + window: 'invalid window' + }); + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid window name'); + }); + + it('should return error for invalid pane identifier', () => { + const result = buildTmuxSessionIdentifier({ + session: 'session', + window: 'window', + pane: 'abc' + }); + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid pane identifier'); + }); + + it('should handle complex valid inputs', () => { + const result = buildTmuxSessionIdentifier({ + session: 'my.test_session-1', + window: 'my_test-window-2', + pane: '3' + }); + expect(result).toEqual({ + success: true, + identifier: 'my.test_session-1:my_test-window-2.3' + }); + }); + + it('should not throw exceptions for invalid inputs', () => { + expect(() => buildTmuxSessionIdentifier({ session: '' })).not.toThrow(); + expect(() => buildTmuxSessionIdentifier({ session: 'invalid session' })).not.toThrow(); + expect(() => buildTmuxSessionIdentifier({ session: null as any })).not.toThrow(); + }); +}); + +describe('TmuxUtilities.detectTmuxEnvironment', () => { + const originalTmuxEnv = process.env.TMUX; + + // Helper to set and restore environment + const withTmuxEnv = (value: string | undefined, fn: () => void) => { + process.env.TMUX = value; + try { + fn(); + } finally { + if (originalTmuxEnv !== undefined) { + process.env.TMUX = originalTmuxEnv; + } else { + delete process.env.TMUX; + } + } + }; + + it('should return null when TMUX env is not set', () => { + withTmuxEnv(undefined, () => { + const utils = new TmuxUtilities(); + const result = utils.detectTmuxEnvironment(); + expect(result).toBeNull(); + }); + }); + + it('should parse valid TMUX environment variable', () => { + withTmuxEnv('/tmp/tmux-1000/default,4219,0', () => { + const utils = new TmuxUtilities(); + const result = utils.detectTmuxEnvironment(); + expect(result).toEqual({ + session: '4219', + window: '0', + pane: '0', + socket_path: '/tmp/tmux-1000/default' + }); + }); + }); + + it('should parse TMUX env with session.window format', () => { + withTmuxEnv('/tmp/tmux-1000/default,mysession.mywindow,2', () => { + const utils = new TmuxUtilities(); + const result = utils.detectTmuxEnvironment(); + expect(result).toEqual({ + session: 'mysession', + window: 'mywindow', + pane: '2', + socket_path: '/tmp/tmux-1000/default' + }); + }); + }); + + it('should handle TMUX env without session.window format', () => { + withTmuxEnv('/tmp/tmux-1000/default,session123,1', () => { + const utils = new TmuxUtilities(); + const result = utils.detectTmuxEnvironment(); + expect(result).toEqual({ + session: 'session123', + window: '0', + pane: '1', + socket_path: '/tmp/tmux-1000/default' + }); + }); + }); + + it('should handle complex socket paths correctly', () => { + // CRITICAL: Test that path parsing works with the fixed array indexing + withTmuxEnv('/tmp/tmux-1000/my-socket,5678,3', () => { + const utils = new TmuxUtilities(); + const result = utils.detectTmuxEnvironment(); + expect(result).toEqual({ + session: '5678', + window: '0', + pane: '3', + socket_path: '/tmp/tmux-1000/my-socket' + }); + }); + }); + + it('should handle socket path with multiple slashes', () => { + // Test the array indexing fix - ensure we get the last component correctly + withTmuxEnv('/var/run/tmux/1000/default,session.window,0', () => { + const utils = new TmuxUtilities(); + const result = utils.detectTmuxEnvironment(); + expect(result).toEqual({ + session: 'session', + window: 'window', + pane: '0', + socket_path: '/var/run/tmux/1000/default' + }); + }); + }); + + it('should return null for malformed TMUX env (too few parts)', () => { + withTmuxEnv('/tmp/tmux-1000/default,4219', () => { + const utils = new TmuxUtilities(); + const result = utils.detectTmuxEnvironment(); + expect(result).toBeNull(); + }); + }); + + it('should return null for malformed TMUX env (empty string)', () => { + withTmuxEnv('', () => { + const utils = new TmuxUtilities(); + const result = utils.detectTmuxEnvironment(); + expect(result).toBeNull(); + }); + }); + + it('should handle TMUX env with extra parts (more than 3 comma-separated values)', () => { + withTmuxEnv('/tmp/tmux-1000/default,4219,0,extra', () => { + const utils = new TmuxUtilities(); + const result = utils.detectTmuxEnvironment(); + // Should still parse the first 3 parts correctly + expect(result).toEqual({ + session: '4219', + window: '0', + pane: '0', + socket_path: '/tmp/tmux-1000/default' + }); + }); + }); + + it('should handle edge case with dots in session identifier', () => { + withTmuxEnv('/tmp/tmux-1000/default,my.session.name.5,2', () => { + const utils = new TmuxUtilities(); + const result = utils.detectTmuxEnvironment(); + // Split on dot, so my.session becomes session=my, window=session + expect(result).toEqual({ + session: 'my', + window: 'session', + pane: '2', + socket_path: '/tmp/tmux-1000/default' + }); + }); + }); +}); + +describe('Round-trip consistency', () => { + it('should parse and format consistently for session-only', () => { + const original = 'my-session'; + const parsed = parseTmuxSessionIdentifier(original); + const formatted = formatTmuxSessionIdentifier(parsed); + expect(formatted).toBe(original); + }); + + it('should parse and format consistently for session:window', () => { + const original = 'my-session:window-1'; + const parsed = parseTmuxSessionIdentifier(original); + const formatted = formatTmuxSessionIdentifier(parsed); + expect(formatted).toBe(original); + }); + + it('should parse and format consistently for session:window.pane', () => { + const original = 'my-session:window-1.2'; + const parsed = parseTmuxSessionIdentifier(original); + const formatted = formatTmuxSessionIdentifier(parsed); + expect(formatted).toBe(original); + }); + + it('should build and parse consistently', () => { + const params = { + session: 'my-session', + window: 'window-1', + pane: '2' + }; + const built = buildTmuxSessionIdentifier(params); + expect(built.success).toBe(true); + const parsed = parseTmuxSessionIdentifier(built.identifier!); + expect(parsed).toEqual(params); + }); +}); diff --git a/src/utils/tmux.ts b/src/utils/tmux.ts new file mode 100644 index 00000000..f0958358 --- /dev/null +++ b/src/utils/tmux.ts @@ -0,0 +1,1052 @@ +/** + * TypeScript tmux utilities adapted from Python reference + * + * Copyright 2025 Andrew Hundt + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Centralized tmux utilities with control sequence support and session management + * Ensures consistent tmux handling across happy-cli with proper session naming + */ + +import { spawn, SpawnOptions } from 'child_process'; +import { promisify } from 'util'; +import { logger } from '@/ui/logger'; + +export enum TmuxControlState { + /** Normal text processing mode */ + NORMAL = "normal", + /** Escape to tmux control mode */ + ESCAPE = "escape", + /** Literal character mode */ + LITERAL = "literal" +} + +/** Union type of valid tmux control sequences for better type safety */ +export type TmuxControlSequence = + | 'C-m' | 'C-c' | 'C-l' | 'C-u' | 'C-w' | 'C-a' | 'C-b' | 'C-d' | 'C-e' | 'C-f' + | 'C-g' | 'C-h' | 'C-i' | 'C-j' | 'C-k' | 'C-n' | 'C-o' | 'C-p' | 'C-q' | 'C-r' + | 'C-s' | 'C-t' | 'C-v' | 'C-x' | 'C-y' | 'C-z' | 'C-\\' | 'C-]' | 'C-[' | 'C-]'; + +/** Union type of valid tmux window operations for better type safety */ +export type TmuxWindowOperation = + // Navigation and window management + | 'new-window' | 'new' | 'nw' + | 'select-window' | 'sw' | 'window' | 'w' + | 'next-window' | 'n' | 'prev-window' | 'p' | 'pw' + // Pane management + | 'split-window' | 'split' | 'sp' | 'vsplit' | 'vsp' + | 'select-pane' | 'pane' + | 'next-pane' | 'np' | 'prev-pane' | 'pp' + // Session management + | 'new-session' | 'ns' | 'new-sess' + | 'attach-session' | 'attach' | 'as' + | 'detach-client' | 'detach' | 'dc' + // Layout and display + | 'select-layout' | 'layout' | 'sl' + | 'clock-mode' | 'clock' + | 'copy-mode' | 'copy' + | 'search-forward' | 'search-backward' + // Misc operations + | 'list-windows' | 'lw' | 'list-sessions' | 'ls' | 'list-panes' | 'lp' + | 'rename-window' | 'rename' | 'kill-window' | 'kw' + | 'kill-pane' | 'kp' | 'kill-session' | 'ks' + // Display and info + | 'display-message' | 'display' | 'dm' + | 'show-options' | 'show' | 'so' + // Control and scripting + | 'send-keys' | 'send' | 'sk' + | 'capture-pane' | 'capture' | 'cp' + | 'pipe-pane' | 'pipe' + // Buffer operations + | 'list-buffers' | 'lb' | 'save-buffer' | 'sb' + | 'delete-buffer' | 'db' + // Advanced operations + | 'resize-pane' | 'resize' | 'rp' + | 'swap-pane' | 'swap' + | 'join-pane' | 'join' | 'break-pane' | 'break'; + +export interface TmuxEnvironment { + session: string; + window: string; + pane: string; + socket_path?: string; +} + +export interface TmuxCommandResult { + returncode: number; + stdout: string; + stderr: string; + command: string[]; +} + +export interface TmuxSessionInfo { + target_session: string; + session: string; + window: string; + pane: string; + socket_path?: string; + tmux_active: boolean; + current_session?: string; + env_session?: string; + env_window?: string; + env_pane?: string; + available_sessions: string[]; +} + +// Strongly typed tmux session identifier with validation +export interface TmuxSessionIdentifier { + session: string; + window?: string; + pane?: string; +} + +/** Validation error for tmux session identifiers */ +export class TmuxSessionIdentifierError extends Error { + constructor(message: string) { + super(message); + this.name = 'TmuxSessionIdentifierError'; + } +} + +// Helper to parse tmux session identifier from string with validation +export function parseTmuxSessionIdentifier(identifier: string): TmuxSessionIdentifier { + if (!identifier || typeof identifier !== 'string') { + throw new TmuxSessionIdentifierError('Session identifier must be a non-empty string'); + } + + // Format: session:window or session:window.pane or just session + const parts = identifier.split(':'); + if (parts.length === 0 || !parts[0]) { + throw new TmuxSessionIdentifierError('Invalid session identifier: missing session name'); + } + + const result: TmuxSessionIdentifier = { + session: parts[0].trim() + }; + + // Validate session name (tmux has restrictions on session names) + if (!/^[a-zA-Z0-9._-]+$/.test(result.session)) { + throw new TmuxSessionIdentifierError(`Invalid session name: "${result.session}". Only alphanumeric characters, dots, hyphens, and underscores are allowed.`); + } + + if (parts.length > 1) { + const windowAndPane = parts[1].split('.'); + result.window = windowAndPane[0]?.trim(); + + if (result.window && !/^[a-zA-Z0-9._-]+$/.test(result.window)) { + throw new TmuxSessionIdentifierError(`Invalid window name: "${result.window}". Only alphanumeric characters, dots, hyphens, and underscores are allowed.`); + } + + if (windowAndPane.length > 1) { + result.pane = windowAndPane[1]?.trim(); + if (result.pane && !/^[0-9]+$/.test(result.pane)) { + throw new TmuxSessionIdentifierError(`Invalid pane identifier: "${result.pane}". Only numeric values are allowed.`); + } + } + } + + return result; +} + +// Helper to format tmux session identifier to string +export function formatTmuxSessionIdentifier(identifier: TmuxSessionIdentifier): string { + if (!identifier.session) { + throw new TmuxSessionIdentifierError('Session identifier must have a session name'); + } + + let result = identifier.session; + if (identifier.window) { + result += `:${identifier.window}`; + if (identifier.pane) { + result += `.${identifier.pane}`; + } + } + return result; +} + +// Helper to extract session and window from tmux output with improved validation +export function extractSessionAndWindow(tmuxOutput: string): { session: string; window: string } | null { + if (!tmuxOutput || typeof tmuxOutput !== 'string') { + return null; + } + + // Look for session:window patterns in tmux output + const lines = tmuxOutput.split('\n'); + + for (const line of lines) { + const match = line.match(/^([a-zA-Z0-9._-]+):([a-zA-Z0-9._-]+)(?:\.([0-9]+))?/); + if (match) { + return { + session: match[1], + window: match[2] + }; + } + } + + return null; +} + +export interface TmuxSpawnOptions extends Omit { + /** Target tmux session name */ + sessionName?: string; + /** Custom tmux socket path */ + socketPath?: string; + /** Create new window in existing session */ + createWindow?: boolean; + /** Window name for new windows */ + windowName?: string; + // Note: env is intentionally excluded from this interface. + // It's passed as a separate parameter to spawnInTmux() for clarity + // and efficiency - only variables that differ from the tmux server + // environment need to be passed via -e flags. +} + +/** + * Complete WIN_OPS dispatch dictionary for tmux operations + * Maps operation names to tmux commands with proper typing + */ +const WIN_OPS: Record = { + // Navigation and window management + 'new-window': 'new-window', + 'new': 'new-window', + 'nw': 'new-window', + + 'select-window': 'select-window -t', + 'sw': 'select-window -t', + 'window': 'select-window -t', + 'w': 'select-window -t', + + 'next-window': 'next-window', + 'n': 'next-window', + 'prev-window': 'previous-window', + 'p': 'previous-window', + 'pw': 'previous-window', + + // Pane management + 'split-window': 'split-window', + 'split': 'split-window', + 'sp': 'split-window', + 'vsplit': 'split-window -h', + 'vsp': 'split-window -h', + + 'select-pane': 'select-pane -t', + 'pane': 'select-pane -t', + + 'next-pane': 'select-pane -t :.+', + 'np': 'select-pane -t :.+', + 'prev-pane': 'select-pane -t :.-', + 'pp': 'select-pane -t :.-', + + // Session management + 'new-session': 'new-session', + 'ns': 'new-session', + 'new-sess': 'new-session', + + 'attach-session': 'attach-session -t', + 'attach': 'attach-session -t', + 'as': 'attach-session -t', + + 'detach-client': 'detach-client', + 'detach': 'detach-client', + 'dc': 'detach-client', + + // Layout and display + 'select-layout': 'select-layout', + 'layout': 'select-layout', + 'sl': 'select-layout', + + 'clock-mode': 'clock-mode', + 'clock': 'clock-mode', + + // Copy mode + 'copy-mode': 'copy-mode', + 'copy': 'copy-mode', + + // Search and navigation in copy mode + 'search-forward': 'search-forward', + 'search-backward': 'search-backward', + + // Misc operations + 'list-windows': 'list-windows', + 'lw': 'list-windows', + 'list-sessions': 'list-sessions', + 'ls': 'list-sessions', + 'list-panes': 'list-panes', + 'lp': 'list-panes', + + 'rename-window': 'rename-window', + 'rename': 'rename-window', + + 'kill-window': 'kill-window', + 'kw': 'kill-window', + 'kill-pane': 'kill-pane', + 'kp': 'kill-pane', + 'kill-session': 'kill-session', + 'ks': 'kill-session', + + // Display and info + 'display-message': 'display-message', + 'display': 'display-message', + 'dm': 'display-message', + + 'show-options': 'show-options', + 'show': 'show-options', + 'so': 'show-options', + + // Control and scripting + 'send-keys': 'send-keys', + 'send': 'send-keys', + 'sk': 'send-keys', + + 'capture-pane': 'capture-pane', + 'capture': 'capture-pane', + 'cp': 'capture-pane', + + 'pipe-pane': 'pipe-pane', + 'pipe': 'pipe-pane', + + // Buffer operations + 'list-buffers': 'list-buffers', + 'lb': 'list-buffers', + 'save-buffer': 'save-buffer', + 'sb': 'save-buffer', + 'delete-buffer': 'delete-buffer', + 'db': 'delete-buffer', + + // Advanced operations + 'resize-pane': 'resize-pane', + 'resize': 'resize-pane', + 'rp': 'resize-pane', + + 'swap-pane': 'swap-pane', + 'swap': 'swap-pane', + + 'join-pane': 'join-pane', + 'join': 'join-pane', + 'break-pane': 'break-pane', + 'break': 'break-pane', +}; + +// Commands that support session targeting +const COMMANDS_SUPPORTING_TARGET = new Set([ + 'send-keys', 'capture-pane', 'new-window', 'kill-window', + 'select-window', 'split-window', 'select-pane', 'kill-pane', + 'select-layout', 'display-message', 'attach-session', 'detach-client', + 'new-session', 'kill-session', 'list-windows', 'list-panes' +]); + +// Control sequences that must be separate arguments with proper typing +const CONTROL_SEQUENCES: Set = new Set([ + 'C-m', 'C-c', 'C-l', 'C-u', 'C-w', 'C-a', 'C-b', 'C-d', 'C-e', 'C-f', + 'C-g', 'C-h', 'C-i', 'C-j', 'C-k', 'C-n', 'C-o', 'C-p', 'C-q', 'C-r', + 'C-s', 'C-t', 'C-v', 'C-x', 'C-y', 'C-z', 'C-\\', 'C-]', 'C-[', 'C-]' +]); + +export class TmuxUtilities { + /** Default session name to prevent interference */ + public static readonly DEFAULT_SESSION_NAME = "happy"; + + private controlState: TmuxControlState = TmuxControlState.NORMAL; + public readonly sessionName: string; + + constructor(sessionName?: string) { + this.sessionName = sessionName || TmuxUtilities.DEFAULT_SESSION_NAME; + } + + /** + * Detect tmux environment from TMUX environment variable + */ + detectTmuxEnvironment(): TmuxEnvironment | null { + const tmuxEnv = process.env.TMUX; + if (!tmuxEnv) { + return null; + } + + // Parse TMUX environment: /tmp/tmux-1000/default,4219,0 + try { + const parts = tmuxEnv.split(','); + if (parts.length >= 3) { + const socketPath = parts[0]; + // Extract last component from path (JavaScript doesn't support negative array indexing) + const pathParts = parts[1].split('/'); + const sessionAndWindow = pathParts[pathParts.length - 1] || parts[1]; + const pane = parts[2]; + + // Extract session name from session.window format + let session: string; + let window: string; + if (sessionAndWindow.includes('.')) { + const parts = sessionAndWindow.split('.', 2); + session = parts[0]; + window = parts[1] || "0"; + } else { + session = sessionAndWindow; + window = "0"; + } + + return { + session, + window, + pane, + socket_path: socketPath + }; + } + } catch (error) { + logger.debug('[TMUX] Failed to parse TMUX environment variable:', error); + } + + return null; + } + + /** + * Execute tmux command with proper session targeting and socket handling + */ + async executeTmuxCommand( + cmd: string[], + session?: string, + window?: string, + pane?: string, + socketPath?: string + ): Promise { + const targetSession = session || this.sessionName; + + // Build command array + let baseCmd = ['tmux']; + + // Add socket specification if provided + if (socketPath) { + baseCmd = ['tmux', '-S', socketPath]; + } + + // Handle send-keys with proper target specification + if (cmd.length > 0 && cmd[0] === 'send-keys') { + const fullCmd = [...baseCmd, cmd[0]]; + + // Add target specification immediately after send-keys + let target = targetSession; + if (window) target += `:${window}`; + if (pane) target += `.${pane}`; + fullCmd.push('-t', target); + + // Add keys and control sequences + fullCmd.push(...cmd.slice(1)); + + return this.executeCommand(fullCmd); + } else { + // Non-send-keys commands + const fullCmd = [...baseCmd, ...cmd]; + + // Add target specification for commands that support it + if (cmd.length > 0 && COMMANDS_SUPPORTING_TARGET.has(cmd[0])) { + let target = targetSession; + if (window) target += `:${window}`; + if (pane) target += `.${pane}`; + fullCmd.push('-t', target); + } + + return this.executeCommand(fullCmd); + } + } + + /** + * Execute command with subprocess and return result + */ + private async executeCommand(cmd: string[]): Promise { + try { + const result = await this.runCommand(cmd); + return { + returncode: result.exitCode, + stdout: result.stdout || '', + stderr: result.stderr || '', + command: cmd + }; + } catch (error) { + logger.debug('[TMUX] Command execution failed:', error); + return null; + } + } + + /** + * Run command using Node.js child_process.spawn + */ + private runCommand(args: string[], options: SpawnOptions = {}): Promise<{ exitCode: number; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(args[0], args.slice(1), { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000, + shell: false, + ...options + }); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('close', (code) => { + resolve({ + exitCode: code || 0, + stdout, + stderr + }); + }); + + child.on('error', (error) => { + reject(error); + }); + }); + } + + /** + * Parse control sequences in text (^ for escape, ^^ for literal ^) + */ + parseControlSequences(text: string): [string, TmuxControlState] { + const result: string[] = []; + let i = 0; + let localState = this.controlState; + + while (i < text.length) { + const char = text[i]; + + if (localState === TmuxControlState.NORMAL) { + if (char === '^') { + if (i + 1 < text.length && text[i + 1] === '^') { + // Literal ^ + result.push('^'); + i += 2; + } else { + // Escape to normal tmux + localState = TmuxControlState.ESCAPE; + i += 1; + } + } else { + result.push(char); + i += 1; + } + } else if (localState === TmuxControlState.ESCAPE) { + // In escape mode - pass through to tmux directly + result.push(char); + i += 1; + localState = TmuxControlState.NORMAL; + } else { + result.push(char); + i += 1; + } + } + + this.controlState = localState; + return [result.join(''), localState]; + } + + /** + * Execute window operation using WIN_OPS dispatch with type safety + */ + async executeWinOp( + operation: TmuxWindowOperation, + args: string[] = [], + session?: string, + window?: string, + pane?: string + ): Promise { + const tmuxCmd = WIN_OPS[operation]; + if (!tmuxCmd) { + logger.debug(`[TMUX] Unknown operation: ${operation}`); + return false; + } + + const cmdParts = tmuxCmd.split(' '); + cmdParts.push(...args); + + const result = await this.executeTmuxCommand(cmdParts, session, window, pane); + return result !== null && result.returncode === 0; + } + + /** + * Ensure session exists, create if needed + */ + async ensureSessionExists(sessionName?: string): Promise { + const targetSession = sessionName || this.sessionName; + + // Check if session exists + const result = await this.executeTmuxCommand(['has-session', '-t', targetSession]); + if (result && result.returncode === 0) { + return true; + } + + // Create session if it doesn't exist + const createResult = await this.executeTmuxCommand(['new-session', '-d', '-s', targetSession]); + return createResult !== null && createResult.returncode === 0; + } + + /** + * Capture current input from tmux pane + */ + async captureCurrentInput( + session?: string, + window?: string, + pane?: string + ): Promise { + const result = await this.executeTmuxCommand(['capture-pane', '-p'], session, window, pane); + if (result && result.returncode === 0) { + const lines = result.stdout.trim().split('\n'); + return lines[lines.length - 1] || ''; + } + return ''; + } + + /** + * Check if user is actively typing + */ + async isUserTyping( + checkInterval: number = 500, + maxChecks: number = 3, + session?: string, + window?: string, + pane?: string + ): Promise { + const initialInput = await this.captureCurrentInput(session, window, pane); + + for (let i = 0; i < maxChecks - 1; i++) { + await new Promise(resolve => setTimeout(resolve, checkInterval)); + const currentInput = await this.captureCurrentInput(session, window, pane); + if (currentInput !== initialInput) { + return true; + } + } + + return false; + } + + /** + * Send keys to tmux pane with proper control sequence handling and type safety + */ + async sendKeys( + keys: string | TmuxControlSequence, + session?: string, + window?: string, + pane?: string + ): Promise { + // Validate input + if (!keys || typeof keys !== 'string') { + logger.debug('[TMUX] Invalid keys provided to sendKeys'); + return false; + } + + // Handle control sequences that must be separate arguments + if (CONTROL_SEQUENCES.has(keys as TmuxControlSequence)) { + const result = await this.executeTmuxCommand(['send-keys', keys], session, window, pane); + return result !== null && result.returncode === 0; + } else { + // Regular text + const result = await this.executeTmuxCommand(['send-keys', keys], session, window, pane); + return result !== null && result.returncode === 0; + } + } + + /** + * Send multiple keys to tmux pane with proper control sequence handling + */ + async sendMultipleKeys( + keys: Array, + session?: string, + window?: string, + pane?: string + ): Promise { + if (!Array.isArray(keys) || keys.length === 0) { + logger.debug('[TMUX] Invalid keys array provided to sendMultipleKeys'); + return false; + } + + for (const key of keys) { + const success = await this.sendKeys(key, session, window, pane); + if (!success) { + return false; + } + } + + return true; + } + + /** + * Get comprehensive session information + */ + async getSessionInfo(sessionName?: string): Promise { + const targetSession = sessionName || this.sessionName; + const envInfo = this.detectTmuxEnvironment(); + + const info: TmuxSessionInfo = { + target_session: targetSession, + session: targetSession, + window: "unknown", + pane: "unknown", + socket_path: undefined, + tmux_active: envInfo !== null, + current_session: envInfo?.session, + available_sessions: [] + }; + + // Update with environment info if it matches our target session + if (envInfo && envInfo.session === targetSession) { + info.window = envInfo.window; + info.pane = envInfo.pane; + info.socket_path = envInfo.socket_path; + } else if (envInfo) { + // Add environment info as separate fields + info.env_session = envInfo.session; + info.env_window = envInfo.window; + info.env_pane = envInfo.pane; + } + + // Get available sessions + const result = await this.executeTmuxCommand(['list-sessions']); + if (result && result.returncode === 0) { + info.available_sessions = result.stdout + .trim() + .split('\n') + .filter(line => line.trim()) + .map(line => line.split(':')[0]); + } + + return info; + } + + /** + * Spawn process in tmux session with environment variables. + * + * IMPORTANT: Unlike Node.js spawn(), env is a separate parameter. + * This is intentional because: + * - Tmux windows inherit environment from the tmux server + * - Only NEW or DIFFERENT variables need to be set via -e flag + * - Passing all of process.env would create 50+ unnecessary -e flags + * + * @param args - Command and arguments to execute (as array, will be joined) + * @param options - Spawn options (tmux-specific, excludes env) + * @param env - Environment variables to set in window (only pass what's different!) + * @returns Result with success status and session identifier + */ + async spawnInTmux( + args: string[], + options: TmuxSpawnOptions = {}, + env?: Record + ): Promise<{ success: boolean; sessionId?: string; pid?: number; error?: string }> { + try { + // Check if tmux is available + const tmuxCheck = await this.executeTmuxCommand(['list-sessions']); + if (!tmuxCheck) { + throw new Error('tmux not available'); + } + + // Handle session name resolution + // - undefined: Use first existing session or create "happy" + // - empty string: Use first existing session or create "happy" + // - specific name: Use that session (create if doesn't exist) + let sessionName = options.sessionName !== undefined && options.sessionName !== '' + ? options.sessionName + : null; + + // If no specific session name, try to use first existing session + if (!sessionName) { + const listResult = await this.executeTmuxCommand(['list-sessions', '-F', '#{session_name}']); + if (listResult && listResult.returncode === 0 && listResult.stdout.trim()) { + // Use first session from list + const firstSession = listResult.stdout.trim().split('\n')[0]; + sessionName = firstSession; + logger.debug(`[TMUX] Using first existing session: ${sessionName}`); + } else { + // No sessions exist, create "happy" + sessionName = 'happy'; + logger.debug(`[TMUX] No existing sessions, using default: ${sessionName}`); + } + } + + const windowName = options.windowName || `happy-${Date.now()}`; + + // Ensure session exists + await this.ensureSessionExists(sessionName); + + // Build command to execute in the new window + const fullCommand = args.join(' '); + + // Create new window in session with command and environment variables + // IMPORTANT: Don't manually add -t here - executeTmuxCommand handles it via parameters + const createWindowArgs = ['new-window', '-n', windowName]; + + // Add working directory if specified + if (options.cwd) { + const cwdPath = typeof options.cwd === 'string' ? options.cwd : options.cwd.pathname; + createWindowArgs.push('-c', cwdPath); + } + + // Add environment variables using -e flag (sets them in the window's environment) + // Note: tmux windows inherit environment from tmux server, but we need to ensure + // the daemon's environment variables (especially expanded auth variables) are available + if (env && Object.keys(env).length > 0) { + for (const [key, value] of Object.entries(env)) { + // Skip undefined/null values with warning + if (value === undefined || value === null) { + logger.warn(`[TMUX] Skipping undefined/null environment variable: ${key}`); + continue; + } + + // Validate variable name (tmux accepts standard env var names) + if (!/^[A-Z_][A-Z0-9_]*$/i.test(key)) { + logger.warn(`[TMUX] Skipping invalid environment variable name: ${key}`); + continue; + } + + // Escape value for shell safety + // Must escape: backslashes, double quotes, dollar signs, backticks + const escapedValue = value + .replace(/\\/g, '\\\\') // Backslash first! + .replace(/"/g, '\\"') // Double quotes + .replace(/\$/g, '\\$') // Dollar signs + .replace(/`/g, '\\`'); // Backticks + + createWindowArgs.push('-e', `${key}="${escapedValue}"`); + } + logger.debug(`[TMUX] Setting ${Object.keys(env).length} environment variables in tmux window`); + } + + // Add the command to run in the window (runs immediately when window is created) + createWindowArgs.push(fullCommand); + + // Add -P flag to print the pane PID immediately + createWindowArgs.push('-P'); + createWindowArgs.push('-F', '#{pane_pid}'); + + // Create window with command and get PID immediately + const createResult = await this.executeTmuxCommand(createWindowArgs, sessionName); + + if (!createResult || createResult.returncode !== 0) { + throw new Error(`Failed to create tmux window: ${createResult?.stderr}`); + } + + // Extract the PID from the output + const panePid = parseInt(createResult.stdout.trim()); + if (isNaN(panePid)) { + throw new Error(`Failed to extract PID from tmux output: ${createResult.stdout}`); + } + + logger.debug(`[TMUX] Spawned command in tmux session ${sessionName}, window ${windowName}, PID ${panePid}`); + + // Return tmux session info and PID + const sessionIdentifier: TmuxSessionIdentifier = { + session: sessionName, + window: windowName + }; + + return { + success: true, + sessionId: formatTmuxSessionIdentifier(sessionIdentifier), + pid: panePid + }; + } catch (error) { + logger.debug('[TMUX] Failed to spawn in tmux:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } + } + + /** + * Get session info for a given session identifier string + */ + async getSessionInfoFromString(sessionIdentifier: string): Promise { + try { + const parsed = parseTmuxSessionIdentifier(sessionIdentifier); + const info = await this.getSessionInfo(parsed.session); + return info; + } catch (error) { + if (error instanceof TmuxSessionIdentifierError) { + logger.debug(`[TMUX] Invalid session identifier: ${error.message}`); + } else { + logger.debug('[TMUX] Error getting session info:', error); + } + return null; + } + } + + /** + * Kill a tmux window safely with proper error handling + */ + async killWindow(sessionIdentifier: string): Promise { + try { + const parsed = parseTmuxSessionIdentifier(sessionIdentifier); + if (!parsed.window) { + throw new TmuxSessionIdentifierError(`Window identifier required: ${sessionIdentifier}`); + } + + const result = await this.executeWinOp('kill-window', [parsed.window], parsed.session); + return result; + } catch (error) { + if (error instanceof TmuxSessionIdentifierError) { + logger.debug(`[TMUX] Invalid window identifier: ${error.message}`); + } else { + logger.debug('[TMUX] Error killing window:', error); + } + return false; + } + } + + /** + * List windows in a session + */ + async listWindows(sessionName?: string): Promise { + const targetSession = sessionName || this.sessionName; + const result = await this.executeTmuxCommand(['list-windows', '-t', targetSession]); + + if (!result || result.returncode !== 0) { + return []; + } + + // Parse window names from tmux output + const windows: string[] = []; + const lines = result.stdout.trim().split('\n'); + + for (const line of lines) { + const match = line.match(/^\d+:\s+(\w+)/); + if (match) { + windows.push(match[1]); + } + } + + return windows; + } +} + +// Global instance for consistent usage +let _tmuxUtils: TmuxUtilities | null = null; + +export function getTmuxUtilities(sessionName?: string): TmuxUtilities { + if (!_tmuxUtils || (sessionName && sessionName !== _tmuxUtils.sessionName)) { + _tmuxUtils = new TmuxUtilities(sessionName); + } + return _tmuxUtils; +} + +export async function isTmuxAvailable(): Promise { + try { + const utils = new TmuxUtilities(); + const result = await utils.executeTmuxCommand(['list-sessions']); + return result !== null; + } catch { + return false; + } +} + +/** + * Create a new tmux session with proper typing and validation + */ +export async function createTmuxSession( + sessionName: string, + options?: { + windowName?: string; + detached?: boolean; + attach?: boolean; + } +): Promise<{ success: boolean; sessionIdentifier?: string; error?: string }> { + try { + if (!sessionName || !/^[a-zA-Z0-9._-]+$/.test(sessionName)) { + throw new TmuxSessionIdentifierError(`Invalid session name: "${sessionName}"`); + } + + const utils = new TmuxUtilities(sessionName); + const windowName = options?.windowName || 'main'; + + const cmd = ['new-session']; + if (options?.detached !== false) { + cmd.push('-d'); + } + cmd.push('-s', sessionName); + cmd.push('-n', windowName); + + const result = await utils.executeTmuxCommand(cmd); + if (result && result.returncode === 0) { + const sessionIdentifier: TmuxSessionIdentifier = { + session: sessionName, + window: windowName + }; + return { + success: true, + sessionIdentifier: formatTmuxSessionIdentifier(sessionIdentifier) + }; + } else { + return { + success: false, + error: result?.stderr || 'Failed to create tmux session' + }; + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } +} + +/** + * Validate a tmux session identifier without throwing + */ +export function validateTmuxSessionIdentifier(identifier: string): { valid: boolean; error?: string } { + try { + parseTmuxSessionIdentifier(identifier); + return { valid: true }; + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : 'Unknown validation error' + }; + } +} + +/** + * Build a tmux session identifier with validation + */ +export function buildTmuxSessionIdentifier(params: { + session: string; + window?: string; + pane?: string; +}): { success: boolean; identifier?: string; error?: string } { + try { + if (!params.session || !/^[a-zA-Z0-9._-]+$/.test(params.session)) { + throw new TmuxSessionIdentifierError(`Invalid session name: "${params.session}"`); + } + + if (params.window && !/^[a-zA-Z0-9._-]+$/.test(params.window)) { + throw new TmuxSessionIdentifierError(`Invalid window name: "${params.window}"`); + } + + if (params.pane && !/^[0-9]+$/.test(params.pane)) { + throw new TmuxSessionIdentifierError(`Invalid pane identifier: "${params.pane}"`); + } + + const identifier: TmuxSessionIdentifier = params; + return { + success: true, + identifier: formatTmuxSessionIdentifier(identifier) + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } +} \ No newline at end of file