diff --git a/.claude/rules/docs.md b/.claude/rules/docs.md new file mode 100644 index 0000000000..b01fa94646 --- /dev/null +++ b/.claude/rules/docs.md @@ -0,0 +1,6 @@ +--- +paths: + - docs/** +--- + +Never commit changes under `docs/` — this directory is local reference only, not shipped with code. diff --git a/.claude/rules/happy-app.md b/.claude/rules/happy-app.md new file mode 100644 index 0000000000..ab3ada929e --- /dev/null +++ b/.claude/rules/happy-app.md @@ -0,0 +1,36 @@ +--- +paths: + - packages/happy-app/** +--- + +## Gotchas + +- Never use `Alert` from React Native — always use `@/modal` instead +- Never use Unistyles for expo-image — use plain inline styles +- Use `useUnistyles` (NOT `useStyles`) when you need theme/runtime values outside `StyleSheet.create` +- Custom header: `createHeader` from `@/components/navigation/Header` (not `NavigationHeader`) +- Screen params ALWAYS in `_layout.tsx`, not in individual pages — avoids layout shifts +- Always use expo-router API, not react-navigation API + +## Patterns to Follow + +- `useHappyAction` from `@/hooks/useHappyAction` for async operations — errors handled automatically +- `t()` from `@/text` for ALL user-visible strings — must add to all 10 languages: `en`, `ru`, `pl`, `es`, `ca`, `it`, `pt`, `ja`, `zh-Hans`, `zh-Hant` +- Dev/debug pages can skip i18n +- `ItemList` for most list containers, `Avatar` for avatars, `AsyncLock` for exclusive async locks +- `useGlobalKeyboard` for hotkeys (Web only, don't modify it) +- Wrap pages in `memo`, store them in `sources/app/(app)/` +- Styles at the very end of component/page files +- Layout width constraints from `@/components/layout` on full-screen ScrollViews +- Non-trivial hooks → dedicated file in `hooks/` folder with a comment explaining logic + +## Core Principles + +- Never show loading errors — always retry +- No backward compatibility unless explicitly asked +- Web is secondary platform — avoid web-specific implementations unless requested +- Always show header on all screens + +## Changelog + +When adding features/fixes, update `/CHANGELOG.md` then run `npx tsx sources/scripts/parseChangelog.ts` to regenerate `sources/changelog/changelog.json`. Auto-parsed during `yarn ota`. diff --git a/.claude/rules/happy-cli.md b/.claude/rules/happy-cli.md new file mode 100644 index 0000000000..47ef080e6a --- /dev/null +++ b/.claude/rules/happy-cli.md @@ -0,0 +1,18 @@ +--- +paths: + - packages/happy-cli/** +--- + +## Gotchas + +- ALL imports must be at the top of the file — never import mid-code +- All debugging goes through file-based logger — never use console for debug output (interferes with agent terminal UI) +- Dual mode architecture: interactive mode spawns CLI process (child_process.spawn, NOT node-pty), remote mode uses internal SDK (`src/claude/sdk/`) +- Claude SDK is internal (`src/claude/sdk/`), not the `@anthropic-ai/claude-code` npm package + +## Patterns to Follow + +- Named exports only (no default exports) +- As few `if` statements as possible — prefer better design over control flow branching +- Don't create trivial small functions / getters / setters +- Daemon logs default to `~/.happy/logs/` (or `$HAPPY_HOME_DIR/logs/`) diff --git a/.claude/rules/happy-server.md b/.claude/rules/happy-server.md new file mode 100644 index 0000000000..341219a4d0 --- /dev/null +++ b/.claude/rules/happy-server.md @@ -0,0 +1,22 @@ +--- +paths: + - packages/happy-server/** +--- + +## Gotchas + +- Create Prisma migrations when schema changes — follow existing naming convention (`YYYYMMDDHHMMSS_description`). Run `yarn generate` when new types are needed +- Avoid enums — use maps instead + +## Patterns to Follow + +- Use `inTx` for database transactions, `afterTx` to emit events after commit succeeds +- Don't run non-transactional things (like file uploads) inside transactions +- Use `eventRouter.emitUpdate()` for persistent events, `emitEphemeral()` for transient +- Use `privacyKit.decodeBase64` / `privacyKit.encodeBase64` from `privacy-kit` — never use Buffer +- DB operation files: dedicated file in relevant `@/app/` subfolder, prefix with entity then action (e.g. `friendAdd`) +- After writing an action, add a documentation comment explaining the logic; keep it in sync +- All operations must be idempotent — clients may retry automatically +- Don't return values from action functions "just in case" — only essential returns +- Don't add logging unless asked +- Always use GitHub usernames diff --git a/.claude/skills/deploy-cli.md b/.claude/skills/deploy-cli.md new file mode 100644 index 0000000000..977997e310 --- /dev/null +++ b/.claude/skills/deploy-cli.md @@ -0,0 +1,54 @@ +--- +name: deploy-cli +description: 构建并全局安装 happy-next-cli,重启 daemon +--- + +# CLI 工具部署 + +构建 happy-cli 包,全局安装,并重启 daemon。 + +## 执行步骤 + +逐步执行 `deploy/build-cli.sh` 的内容,提供进度反馈: + +### 步骤 1:清理旧构建产物 + +```bash +cd /home/coder/workspaces/happy/packages/happy-cli +rm -f happy-next-cli-*.tgz +``` + +### 步骤 2:构建 CLI + +```bash +cd /home/coder/workspaces/happy/packages/happy-cli +npm run build +``` + +完成后反馈:CLI 构建成功 + +### 步骤 3:打包并全局安装 + +```bash +cd /home/coder/workspaces/happy/packages/happy-cli +TARBALL=$(npm pack) +npm install -g "./$TARBALL" +``` + +完成后反馈:已全局安装,展示安装的包名和版本 + +### 步骤 4:重启 daemon + +```bash +happy daemon stop && happy daemon start +``` + +完成后反馈:daemon 已重启 + +## 返回格式 + +向用户展示: + +- **构建结果**: 成功/失败 +- **安装包**: 包名和版本 +- **Daemon 状态**: 重启结果 diff --git a/.claude/skills/deploy-server.md b/.claude/skills/deploy-server.md new file mode 100644 index 0000000000..782f99fc89 --- /dev/null +++ b/.claude/skills/deploy-server.md @@ -0,0 +1,75 @@ +--- +name: deploy-server +description: 部署主服务(server + webapp + voice),包含前置检查和构建进度反馈 +--- + +# 主服务部署 + +构建并部署 happy-server、happy-app(webapp)、happy-voice 三个 Docker 镜像,然后用 docker compose 拉起所有服务。 + +## 前置检查 + +部署前必须执行以下检查,任一不通过则**暂停并提示用户**: + +1. **未提交更改检查**:运行 `git status --porcelain`,如果有未提交的更改,警告用户 `git reset --hard origin/dev` 会丢失这些更改,询问是否继续 +2. **分支状态检查**:运行 `git fetch origin && git log HEAD..origin/dev --oneline`,展示即将拉取的新提交,让用户确认 +3. **Docker 可用性检查**:运行 `docker info`,确认 Docker daemon 正在运行 + +## 执行步骤 + +通过逐步执行 `deploy/build-server-web-voice-up.sh` 的内容来提供进度反馈,而不是直接运行整个脚本: + +### 步骤 1:同步代码 + +```bash +cd /home/coder/workspaces/happy +git fetch origin +git reset --hard origin/dev +``` + +完成后反馈:代码已同步到 origin/dev 最新提交 `` + +### 步骤 2:构建 happy-server 镜像 + +```bash +cd /home/coder/workspaces/happy +docker build -f Dockerfile.server -t happy-server:latest . +``` + +完成后反馈:happy-server 镜像构建成功 + +### 步骤 3:构建 happy-app(webapp)镜像 + +```bash +cd /home/coder/workspaces/happy +docker build -f Dockerfile.webapp -t happy-app:latest . +``` + +完成后反馈:happy-app 镜像构建成功 + +### 步骤 4:构建 happy-voice 镜像 + +```bash +cd /home/coder/workspaces/happy/packages/happy-voice +docker build -f Dockerfile -t happy-voice:latest . +``` + +完成后反馈:happy-voice 镜像构建成功 + +### 步骤 5:启动服务 + +```bash +cd /home/coder/workspaces/happy/deploy +docker compose up -d +``` + +完成后反馈:所有服务已启动,运行 `docker compose ps` 展示服务状态 + +## 返回格式 + +向用户展示部署摘要: + +- **同步提交**: ` ` +- **镜像构建**: server / webapp / voice 各自的构建结果 +- **服务状态**: docker compose ps 输出 +- **耗时**: 总部署时间 diff --git a/.claude/skills/expo-coder.md b/.claude/skills/expo-coder.md new file mode 100644 index 0000000000..cac9059aa0 --- /dev/null +++ b/.claude/skills/expo-coder.md @@ -0,0 +1,33 @@ +--- +name: expo-coder +description: 通过 code-server 端口转发启动 Expo 开发模式,无需 ngrok tunnel +--- + +# Expo Code-Server 开发启动器 + +通过 code-server 内置端口转发启动 happy-app,速度比 ngrok tunnel 快。 + +## 执行步骤 + +1. 先结束已有的 Expo/Metro 进程 +2. 启动 Expo 并设置 `EXPO_PACKAGER_PROXY_URL` 为 code-server 代理地址 +3. 返回连接信息给用户 + +## 操作流程 + +```bash +# 1. 清理旧进程 +pkill -f "expo start" 2>/dev/null +pkill -f "metro" 2>/dev/null +sleep 1 + +# 2. 启动 Expo(通过 code-server 端口转发) +EXPO_PACKAGER_PROXY_URL=https://8081--main--dootask--kuaifan.coder.hitosea.com yarn workspace happy-app start +``` + +## 返回格式 + +向用户展示: + +- **Code-Server URL**: `https://8081--main--dootask--kuaifan.coder.hitosea.com` +- **提示**: 在 Expo Go 中手动输入上面的 URL 连接 diff --git a/.claude/skills/expo-tunnel.md b/.claude/skills/expo-tunnel.md new file mode 100644 index 0000000000..8897b5bd6d --- /dev/null +++ b/.claude/skills/expo-tunnel.md @@ -0,0 +1,61 @@ +--- +name: expo-tunnel +description: 启动 Expo tunnel 开发模式,返回公网 URL 供手机连接 +--- + +# Expo Tunnel 开发启动器 + +启动 happy-app 的 Expo tunnel 模式,获取公网 URL。 + +## 执行步骤 + +1. 先结束已有的 Expo/Metro 进程 +2. 后台启动 `yarn workspace happy-app start --tunnel` +3. 等待 tunnel 连接成功(轮询 ngrok API) +4. 从 ngrok API 获取公网 URL +5. 保存 URL 到 `.expo-tunnel` 文件 +6. 返回连接信息给用户 + +## 操作流程 + +```bash +# 1. 清理旧进程 +pkill -f "expo start" 2>/dev/null +pkill -f "metro" 2>/dev/null +sleep 1 + +# 2. 后台启动 tunnel +yarn workspace happy-app start --tunnel & + +# 3. 轮询等待 tunnel 就绪(最多 60 秒) +for i in {1..12}; do + URL=$(curl -s http://localhost:4040/api/tunnels 2>/dev/null | jq -r '.tunnels[] | select(.proto=="https") | .public_url' 2>/dev/null) + if [ -n "$URL" ] && [ "$URL" != "null" ]; then + break + fi + sleep 5 +done + +# 4. 保存到文件 +echo "$URL" > .expo-tunnel + +# 5. 显示结果 +cat .expo-tunnel +``` + +## 输出文件 + +URL 会保存到项目根目录的 `.expo-tunnel` 文件,方便随时查看: + +```bash +cat .expo-tunnel +``` + +## 返回格式 + +向用户展示: + +- **Tunnel URL**: `https://xxx.exp.direct` +- **Expo Go 连接**: `exp://xxx.exp.direct` +- **文件位置**: `.expo-tunnel` +- **提示**: tunnel 模式热更新稍慢,摇一摇手机打开开发者菜单 diff --git a/.claude/skills/release-changelog.md b/.claude/skills/release-changelog.md new file mode 100644 index 0000000000..9f19532693 --- /dev/null +++ b/.claude/skills/release-changelog.md @@ -0,0 +1,93 @@ +--- +name: release-changelog +description: 准备新版本更新日志,更新 CHANGELOG、README、docs 共 6 个文件 +--- + +# 版本更新日志 + +准备新版本的更新日志,涉及 6 个文件的更新。 + +## 前置准备 + +### 确认上一版本 + +```bash +# 读取当前最新版本号 +cat packages/happy-app/sources/changelog/changelog.json | head -5 +``` + +### 整理提交记录 + +```bash +# 找到上一版本的 changelog commit +git log --all --oneline --grep="Version N" + +# 列出自上一版本以来的所有提交 +git log <上版本commit>..HEAD --oneline --no-merges +``` + +按功能领域分类提交,向用户展示分类结果,等待确认后再开始写入。 + +## 更新步骤 + +### 步骤 1:编写 CHANGELOG.md + +文件:`packages/happy-app/CHANGELOG.md` + +在文件顶部(`# Changelog` 之后、上一版本之前)插入新版本条目。 + +**格式要求**(参考已有版本风格): +- 标题:`## Version N - YYYY-MM-DD` +- 一句话摘要(英文) +- Bullet points,每条以功能领域开头,简洁描述用户可感知的变化 +- 不写技术实现细节,面向用户 + +### 步骤 2:生成 changelog.json + +```bash +cd packages/happy-app +npx tsx sources/scripts/parseChangelog.ts +``` + +自动生成 `packages/happy-app/sources/changelog/changelog.json`,确认 `latestVersion` 已更新。 + +### 步骤 3:更新 README(中英文) + +文件:`README.md` + `README.zh-CN.md` + +**关键原则**:这两个文件展示的是「Happy Next 相比 Happy 的完整功能」,**不按版本分**。 + +- 将新功能合并进已有章节(如 DooTask 新功能并入「DooTask Integration」章节) +- 全新功能领域加为新的独立章节(不加版本标签) +- 同步更新 "Why Happy Next" 亮点列表(带 emoji 的那段) +- 中英文内容保持一致 + +### 步骤 4:更新 changes-from-happy 文档(中英文) + +文件:`docs/changes-from-happy.md` + `docs/changes-from-happy.zh-CN.md` + +**关键原则**:同样**不按版本分**,是 Happy Next 相对 Happy 的完整变更记录。 + +- 更新顶部 TL;DR 概览表格 +- 将新功能合并进已有章节 +- 全新功能领域加为独立章节(不加版本标签) +- 更新 bug 修复计数 +- 中英文内容保持一致 + +## 文件清单 + +| # | 文件 | 方式 | +|---|------|------| +| 1 | `packages/happy-app/CHANGELOG.md` | 手写新版本条目 | +| 2 | `packages/happy-app/sources/changelog/changelog.json` | 运行脚本自动生成 | +| 3 | `README.md` | 合并新功能进已有章节 | +| 4 | `README.zh-CN.md` | 合并新功能进已有章节 | +| 5 | `docs/changes-from-happy.md` | 合并新功能进已有章节 | +| 6 | `docs/changes-from-happy.zh-CN.md` | 合并新功能进已有章节 | + +## 注意事项 + +- CHANGELOG.md 是唯一按版本记录的文件,其余 4 个 md 文件都是功能总览 +- 先向用户展示 CHANGELOG 草稿内容,确认后再写入全部文件 +- 中英文文档结构和内容必须对齐 +- 版本号中提到的依赖版本(如 Codex vX.Y.Z)需确认是最新的 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..a2c1f6775b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.DS_Store +node_modules +**/node_modules +**/dist +**/.turbo +**/.next diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..750d3bd75e --- /dev/null +++ b/.env.example @@ -0,0 +1,99 @@ +# Happy Next - docker-compose environment +# +# Usage: +# 1) Copy this file to `.env` +# 2) Fill in required values +# 3) Run: `docker-compose up -d` +# +# Notes: +# - Do NOT commit `.env` (it is gitignored) +# - This file is safe to commit: it contains placeholders only +# +# Ports (from docker-compose.yml): +# - Web app (Nginx): http://localhost:3030 +# - API server: http://localhost:3031 +# - Voice gateway: http://localhost:3040 +# - MinIO console: http://localhost:3050 + +# === Server (happy-server) === +# Required. Used to sign auth tokens and seed server-side crypto primitives. +# Generate with a password manager or: `openssl rand -hex 32` +HANDY_MASTER_SECRET=changeme-32-bytes-hex + +# Optional. Public URL where the web app is served (used in some connect flows). +APP_URL=http://localhost:3030 + +# === Database (Postgres) === +POSTGRES_DB=happy +POSTGRES_USER=happy +POSTGRES_PASSWORD=changeme-postgres-password + +# === Redis === +# Optional for docker-compose: Redis runs in-compose and happy-server uses it via service name. +# REDIS_URL=redis://redis:6379 + +# === S3 / MinIO (happy-server) === +# Required for uploads/artifacts. MinIO runs in-compose. +S3_ACCESS_KEY=minioadmin +S3_SECRET_KEY=changeme-minio-secret +S3_BUCKET=happy + +# Required: must be reachable by *clients* (browser/mobile), not just containers. +# For local compose, MinIO is exposed on port 3050. +S3_PUBLIC_URL=http://localhost:3050 + +# === GitHub OAuth/App (optional) === +# Leave empty to disable GitHub integration features. +GITHUB_APP_ID= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +# Canonical name used by happy-server (alias: GITHUB_REDIRECT_URI). +# Example (local): http://localhost:3031/v1/connect/github/callback +GITHUB_REDIRECT_URL= + +GITHUB_WEBHOOK_SECRET= + +# Private key PEM as a single line with \n escapes, or mount via other secret manager. +GITHUB_PRIVATE_KEY= + +## === Web app (happy-app, served by Nginx) === +# Self-host uses separate origins (no path reverse proxy). +EXPO_PUBLIC_HAPPY_SERVER_URL=http://localhost:3031 +EXPO_PUBLIC_VOICE_BASE_URL=http://localhost:3040 + +# For docker-compose, happy-voice must call happy-server by container DNS, not host localhost. +EXPO_PUBLIC_VOICE_TOOL_BRIDGE_BASE_URL=http://happy-server:3031 + +# Required if using Happy Voice. +VOICE_PUBLIC_KEY=dev-voice-public-key + +# Voice provider selection used by the app. +EXPO_PUBLIC_VOICE_PROVIDER=happy-voice + +# Optional (used by app features that talk to ElevenLabs directly). +ELEVENLABS_AGENT_ID= + +# === Voice gateway (happy-voice) === +# Required for Happy Voice. +LIVEKIT_URL=http://localhost:7880 +LIVEKIT_WS_URL=ws://localhost:7880 +LIVEKIT_API_KEY=devkey +LIVEKIT_API_SECRET=devsecret + +# Voice gateway model/provider configuration (examples). +AGENT_STT=assemblyai/universal-streaming:en +AGENT_LLM=openai/gpt-4.1-mini +AGENT_TTS=cartesia/sonic-3:9626c31c-bec5-4cca-baa8-f8ba9e84c8bc +AGENT_WELCOME_MESSAGE=Say hello and ask what the user wants to build. + +# Required for voice providers used by happy-voice. +OPENAI_API_KEY= +CARTESIA_API_KEY= + +# Optional logs. +HAPPY_VOICE_WORKER_LOG_LEVEL=info + +# === happy-server <-> happy-voice tool bridge (optional but recommended) === +# If set, happy-server will require this key for voice tool bridge endpoints. +VOICE_TOOL_BRIDGE_KEY=dev-voice-bridge-key diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000..b432e5ba09 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug Report +about: Report a bug in Happy Next +labels: bug +--- + +## Describe the bug + +A clear description of the problem. + +## Steps to reproduce + +1. ... +2. ... + +## Expected behavior + +What you expected to happen. + +## Environment + +- **OS**: (e.g., macOS 15, Ubuntu 24.04, Windows 11) +- **Component**: CLI / Web App / Self-hosted Server +- **CLI version**: (`happy --version`) +- **Deployment**: Hosted (`api.happy.hitosea.com`) / Self-hosted (`docker-compose`) + +## Logs / Screenshots + +Paste relevant logs or attach screenshots. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000000..9c748e21fb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,21 @@ +--- +name: Feature Request +about: Suggest an idea for Happy Next +labels: enhancement +--- + +## Problem + +What problem does this feature solve? + +## Proposed solution + +Describe the solution you'd like. + +## Alternatives considered + +Any alternative approaches you've considered. + +## Additional context + +Any other context, screenshots, or references. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..adea57e94f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,19 @@ +## Summary + +Brief description of what this PR does. + +## Changes + +- ... + +## Testing + +How was this tested? + +- [ ] Tested locally +- [ ] Existing tests pass +- [ ] New tests added (if applicable) + +## Related issues + +Closes # diff --git a/.github/logotype-dark.png b/.github/logotype-dark.png index bc98a39f7f..5431118daf 100644 Binary files a/.github/logotype-dark.png and b/.github/logotype-dark.png differ diff --git a/.github/mascot.png b/.github/mascot.png index 837839674e..f34fa1cb36 100644 Binary files a/.github/mascot.png and b/.github/mascot.png differ diff --git a/.github/workflows/cli-smoke-test.yml b/.github/workflows/cli-smoke-test.yml index 4be63b9790..2d9ee703fb 100644 --- a/.github/workflows/cli-smoke-test.yml +++ b/.github/workflows/cli-smoke-test.yml @@ -2,14 +2,14 @@ name: CLI Smoke Test on: push: - branches: [ main ] + branches: [ main, next ] paths: - - 'cli/**' + - 'packages/happy-cli/**' - '.github/workflows/cli-smoke-test.yml' pull_request: - branches: [ main ] + branches: [ main, next ] paths: - - 'cli/**' + - 'packages/happy-cli/**' - '.github/workflows/cli-smoke-test.yml' workflow_dispatch: @@ -29,23 +29,23 @@ jobs: with: node-version: ${{ matrix.node-version }} cache: 'yarn' - cache-dependency-path: cli/yarn.lock + cache-dependency-path: yarn.lock - name: Install dependencies - run: yarn install --immutable - working-directory: cli + run: yarn install --frozen-lockfile + + - name: Build dependency (happy-wire) + run: yarn workspace happy-wire build - name: Build package - run: yarn build - working-directory: cli + run: yarn workspace happy-next-cli build - name: Pack package - run: yarn pack - working-directory: cli + run: yarn workspace happy-next-cli pack - name: Install packed package globally run: | - PACKAGE_FILE=$(ls cli/*.tgz) + PACKAGE_FILE=$(ls packages/happy-cli/*.tgz) npm install -g "./$PACKAGE_FILE" - name: Test binary execution @@ -92,24 +92,24 @@ jobs: with: node-version: ${{ matrix.node-version }} cache: 'yarn' - cache-dependency-path: cli/yarn.lock + cache-dependency-path: yarn.lock - name: Install dependencies - run: yarn install --immutable - working-directory: cli + run: yarn install --frozen-lockfile + + - name: Build dependency (happy-wire) + run: yarn workspace happy-wire build - name: Build package - run: yarn build - working-directory: cli + run: yarn workspace happy-next-cli build - name: Pack package - run: yarn pack - working-directory: cli + run: yarn workspace happy-next-cli pack - name: Install packed package globally shell: cmd run: | - for %%f in (cli\*.tgz) do npm install -g "%%f" + for %%f in (packages\happy-cli\*.tgz) do npm install -g "%%f" - name: Debug npm global installation structure shell: cmd @@ -124,20 +124,20 @@ jobs: echo Found node_modules directory dir "%NPM_PREFIX%\node_modules" 2>nul echo. - echo Checking for happy-coder package: - if exist "%NPM_PREFIX%\node_modules\happy-coder" ( - echo Found happy-coder package - dir "%NPM_PREFIX%\node_modules\happy-coder" 2>nul + echo Checking for happy-next-cli package: + if exist "%NPM_PREFIX%\node_modules\happy-next-cli" ( + echo Found happy-next-cli package + dir "%NPM_PREFIX%\node_modules\happy-next-cli" 2>nul echo. echo Checking for dist directory: - if exist "%NPM_PREFIX%\node_modules\happy-coder\dist" ( + if exist "%NPM_PREFIX%\node_modules\happy-next-cli\dist" ( echo Found dist directory - dir "%NPM_PREFIX%\node_modules\happy-coder\dist" 2>nul + dir "%NPM_PREFIX%\node_modules\happy-next-cli\dist" 2>nul ) else ( echo No dist directory found ) ) else ( - echo No happy-coder package found + echo No happy-next-cli package found ) ) else ( echo No node_modules directory found diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..398ab908d8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,210 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + # ── Tag push: build APK + AAB + IPA → GitHub Release ── + + build-android: + name: Build Android ${{ matrix.type }} + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + matrix: + include: + - type: APK + buildType: apk + ext: apk + - type: AAB + buildType: app-bundle + ext: aab + defaults: + run: + working-directory: packages/happy-app + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: yarn + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Install dependencies + run: yarn install --frozen-lockfile + working-directory: . + + - name: Setup EAS + uses: expo/expo-github-action@v8 + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: Disable lint for CI + run: | + mkdir -p ~/.gradle/init.d + cat > ~/.gradle/init.d/disable-lint.gradle << 'GRADLE' + allprojects { + afterEvaluate { + tasks.matching { it.name.startsWith("lintVital") }.configureEach { + enabled = false + } + } + } + GRADLE + + - name: Override buildType to ${{ matrix.buildType }} + run: | + jq '.build.production.android.buildType = "${{ matrix.buildType }}"' eas.json > eas.tmp.json && mv eas.tmp.json eas.json + + - name: Build ${{ matrix.type }} + env: + APP_VERSION: ${{ github.ref_name }} + run: eas build --local -p android --profile production --non-interactive --output ./happy-${{ github.ref_name }}.${{ matrix.ext }} + + - uses: actions/upload-artifact@v4 + with: + name: android-${{ matrix.ext }} + path: packages/happy-app/happy-${{ github.ref_name }}.${{ matrix.ext }} + + build-ios: + name: Build iOS IPA + if: startsWith(github.ref, 'refs/tags/v') + runs-on: macos-15 + timeout-minutes: 90 + defaults: + run: + working-directory: packages/happy-app + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: yarn + + - name: Select Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '16.2' + + - name: Install iOS platform + run: xcodebuild -downloadPlatform iOS + + - name: Install dependencies + run: yarn install --frozen-lockfile + working-directory: . + + - name: Setup EAS + uses: expo/expo-github-action@v8 + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: Install CocoaPods + run: gem install cocoapods + + - name: Build IPA + env: + APP_VERSION: ${{ github.ref_name }} + run: eas build --local -p ios --profile production --non-interactive --output ./happy-${{ github.ref_name }}.ipa + + - uses: actions/upload-artifact@v4 + with: + name: ios-ipa + path: packages/happy-app/happy-${{ github.ref_name }}.ipa + + create-release: + name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + needs: [build-android, build-ios] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - name: Create Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${{ github.ref_name }}" \ + --title "Release ${{ github.ref_name }}" \ + --generate-notes \ + artifacts/* + + # ── Manual trigger: iOS → App Store ── + + submit-ios: + name: iOS → App Store + if: github.event_name == 'workflow_dispatch' + runs-on: macos-15 + timeout-minutes: 90 + defaults: + run: + working-directory: packages/happy-app + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: yarn + + - name: Select Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '16.2' + + - name: Install iOS platform + run: xcodebuild -downloadPlatform iOS + + - name: Install dependencies + run: yarn install --frozen-lockfile + working-directory: . + + - name: Setup EAS + uses: expo/expo-github-action@v8 + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: Install CocoaPods + run: gem install cocoapods + + - name: Build iOS + run: eas build --local -p ios --profile production --non-interactive --output ./happy.ipa + + - name: Prepare ASC API Key + run: echo "${{ secrets.ASC_API_KEY_P8_BASE64 }}" | base64 -d > /tmp/AuthKey.p8 + + - name: Submit to App Store + env: + ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + run: | + jq --arg keyId "$ASC_API_KEY_ID" --arg issuerId "$ASC_ISSUER_ID" \ + '.submit.production.ios += { + "ascApiKeyPath": "/tmp/AuthKey.p8", + "ascApiKeyId": $keyId, + "ascApiKeyIssuerId": $issuerId + }' eas.json > eas.tmp.json && mv eas.tmp.json eas.json + + eas submit -p ios --profile production --path ./happy.ipa --non-interactive diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 531a09d9d2..d657a0ca6b 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -3,13 +3,14 @@ name: Expo App TypeScript typecheck on: pull_request: paths: - - 'expo-app/**' + - 'packages/happy-app/**' - '.github/workflows/typecheck.yml' push: branches: - main + - next paths: - - 'expo-app/**' + - 'packages/happy-app/**' - '.github/workflows/typecheck.yml' jobs: @@ -28,8 +29,9 @@ jobs: - name: Install dependencies run: yarn install --frozen-lockfile - working-directory: expo-app + + - name: Build happy-wire + run: yarn workspace happy-wire build - name: TypeScript typecheck - run: yarn typecheck - working-directory: expo-app + run: yarn workspace happy-app typecheck diff --git a/.gitignore b/.gitignore index a4abbf5dc3..24b29bf35b 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ yarn-error.* # local env files .env*.local .env +.npmrc # typescript *.tsbuildinfo @@ -47,4 +48,13 @@ CLAUDE.local.md .dev/worktree/* # Development planning notes (keep local, don't commit) -notes/ \ No newline at end of file +notes/ + +# tmp +tmp/ + +# Expo tunnel URL (generated by /expo-tunnel skill) +.expo-tunnel + +# Plans (keep local, don't commit) +docs/plans/* \ No newline at end of file diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000000..2e510aff58 --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000000..65b7a26e3c --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,135 @@ +# the name by which the project can be referenced within Serena +project_name: "happy" + + +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp +# csharp_omnisharp dart elixir elm erlang +# fortran fsharp go groovy haskell +# java julia kotlin lua markdown +# matlab nix pascal perl php +# php_phpactor powershell python python_jedi r +# rego ruby ruby_solargraph rust scala +# swift terraform toml typescript typescript_vts +# vue yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- typescript + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default) +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +fixed_tools: [] + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this setting overrides the global configuration. +# Set this to [] to disable base modes for this project. +# Set this to a list of mode names to always include the respective modes for this project. +base_modes: + +# list of mode names that are to be activated by default. +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# This setting can, in turn, be overridden by CLI parameters (--mode). +default_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] diff --git a/cli/.cursorrules b/AGENTS.md similarity index 100% rename from cli/.cursorrules rename to AGENTS.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..26776d3fa2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,29 @@ +## Interaction Rules + +- **Always suggest options when asking questions** — when clarifying requirements, provide concrete recommendations or choices to help the user decide quickly, rather than open-ended questions + +## Language Preference + +- Use Simplified Chinese for technical summaries and key conclusions, unless the user explicitly requests another language + +## Repository Overview + +Yarn 1.x monorepo (`yarn@1.22.22`). Default dev branch: `next`; `main` is for upstream alignment. + +| Package | Purpose | +|---------|---------| +| **happy-app** | Expo/React Native mobile + web client | +| **happy-cli** | CLI wrapper for Claude Code, Codex, Gemini | +| **happy-server** | Fastify backend (API + Socket.IO) | +| **happy-voice** | LiveKit-based voice gateway | +| **happy-wire** | Shared Zod schemas and wire types | + +## Type Checking (run after all changes) + +Run `yarn typecheck` in the package you modified. In happy-server the command is `yarn build` (which is `tsc --noEmit`, not a real build step). + +## Running a Single Test (Vitest) + +```bash +npx vitest run path/to/file.test.ts +``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..d1f7595b19 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,14 @@ +# Contributing + +Thanks for your interest in contributing to Happy Next. + +## Quick links + +- **App (Expo / Web):** `packages/happy-app/CONTRIBUTING.md` +- **CLI (happy-next-cli):** `packages/happy-cli/CONTRIBUTING.md` + +## Repo notes + +- Default development branch: `next` +- `main` is kept for upstream alignment + diff --git a/Dockerfile.server b/Dockerfile.server new file mode 100644 index 0000000000..266718648a --- /dev/null +++ b/Dockerfile.server @@ -0,0 +1,57 @@ +# syntax=docker/dockerfile:1.7 + +# Stage 1: install dependencies with workspace context +FROM node:20 AS deps + +# Install build dependencies +RUN apt-get update && apt-get install -y python3 ffmpeg make g++ build-essential && rm -rf /var/lib/apt/lists/* + +WORKDIR /repo + +COPY package.json yarn.lock ./ + +# Keep dependency installation scoped to server-only workspaces to avoid +# pulling the entire monorepo dependency graph (notably happy-app/expo). +RUN node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));if(typeof p.workspaces==='object'&&p.workspaces){p.workspaces={...p.workspaces,packages:['packages/happy-server','packages/happy-wire']};}fs.writeFileSync('package.json',JSON.stringify(p,null,2));" + +RUN mkdir -p packages/happy-server packages/happy-wire + +COPY packages/happy-server/package.json packages/happy-server/ +COPY packages/happy-wire/package.json packages/happy-wire/ + +# Workspace postinstall requirements +COPY packages/happy-server/prisma packages/happy-server/prisma + +RUN --mount=type=cache,id=happy-yarn-cache-server,target=/usr/local/share/.cache/yarn,sharing=locked \ + yarn install --frozen-lockfile --ignore-engines + +# Stage 2: build the server +FROM deps AS builder + +COPY packages/happy-wire ./packages/happy-wire +RUN yarn workspace happy-wire build + +COPY packages/happy-server ./packages/happy-server +RUN yarn workspace happy-server build + +# Stage 3: runtime +FROM node:20 AS runner + +WORKDIR /repo + +# Runtime dependencies +RUN apt-get update && apt-get install -y python3 ffmpeg && rm -rf /var/lib/apt/lists/* + +# Set environment to production +ENV NODE_ENV=production + +# Copy necessary files from the builder stage +COPY --from=builder /repo/node_modules /repo/node_modules +COPY --from=builder /repo/packages/happy-server /repo/packages/happy-server +COPY --from=builder /repo/packages/happy-wire /repo/packages/happy-wire + +# Expose the port the app will run on +EXPOSE 3000 + +# Command to run the application +CMD ["yarn", "--cwd", "packages/happy-server", "start"] diff --git a/Dockerfile.webapp b/Dockerfile.webapp new file mode 100644 index 0000000000..6c8e34bee8 --- /dev/null +++ b/Dockerfile.webapp @@ -0,0 +1,106 @@ +# syntax=docker/dockerfile:1.7 + +# Stage 1: install dependencies with workspace context +FROM node:20-alpine AS deps + +WORKDIR /repo + +COPY package.json yarn.lock ./ + +# Keep dependency installation scoped to web-only workspaces to avoid +# pulling the entire monorepo dependency graph. +RUN node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));if(typeof p.workspaces==='object'&&p.workspaces){p.workspaces={...p.workspaces,packages:['packages/happy-app','packages/happy-wire']};}fs.writeFileSync('package.json',JSON.stringify(p,null,2));" + +RUN mkdir -p packages/happy-app packages/happy-wire + +COPY packages/happy-app/package.json packages/happy-app/ +COPY packages/happy-wire/package.json packages/happy-wire/ + +# Workspace postinstall requirements +COPY packages/happy-app/patches packages/happy-app/patches + +RUN --mount=type=cache,id=happy-yarn-cache-webapp,target=/usr/local/share/.cache/yarn,sharing=locked \ + yarn install --frozen-lockfile --ignore-engines + +# Stage 2: build the web app +FROM deps AS builder + +ARG POSTHOG_API_KEY="" +ARG REVENUE_CAT_STRIPE="" + +ENV NODE_ENV=production +ENV APP_ENV=production +ENV EXPO_PUBLIC_POSTHOG_API_KEY=$POSTHOG_API_KEY +ENV EXPO_PUBLIC_REVENUE_CAT_STRIPE=$REVENUE_CAT_STRIPE +ENV EXPO_PUBLIC_ELEVENLABS_AGENT_ID=__RT_ELEVENLABS_AGENT_ID__ +ENV EXPO_PUBLIC_HAPPY_SERVER_URL=__RT_HAPPY_SERVER_URL__ +ENV EXPO_PUBLIC_VOICE_PROVIDER=__RT_VOICE_PROVIDER__ +ENV EXPO_PUBLIC_VOICE_BASE_URL=__RT_VOICE_BASE_URL__ +ENV EXPO_PUBLIC_VOICE_PUBLIC_KEY=__RT_VOICE_PUBLIC_KEY__ +ENV EXPO_PUBLIC_VOICE_TOOL_BRIDGE_BASE_URL=__RT_VOICE_TOOL_BRIDGE_BASE_URL__ + +COPY packages/happy-wire ./packages/happy-wire +RUN yarn workspace happy-wire build + +COPY packages/happy-app ./packages/happy-app +RUN yarn workspace happy-app expo export --platform web --output-dir dist + +# Stage 3: runtime with Nginx +FROM nginx:alpine AS runner + +COPY --from=builder /repo/packages/happy-app/dist /usr/share/nginx/html +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Remove default nginx configuration +RUN rm /etc/nginx/conf.d/default.conf + +# Create custom nginx configuration directly in the Dockerfile +RUN echo 'server { \ + listen 80; \ + \ + location /_expo/ { \ + root /usr/share/nginx/html; \ + try_files $uri =404; \ + } \ + \ + location /assets/ { \ + root /usr/share/nginx/html; \ + try_files $uri =404; \ + } \ + \ + location /.well-known/ { \ + root /usr/share/nginx/html; \ + try_files $uri =404; \ + } \ + \ + location / { \ + root /usr/share/nginx/html; \ + index index.html index.htm; \ + try_files $uri $uri.html $uri/index.html $uri/index.htm $uri/ /index.html /index.htm =404; \ + } \ + \ + error_page 500 502 503 504 /50x.html; \ + location = /50x.html { \ + root /usr/share/nginx/html; \ + try_files $uri @redirect_to_index; \ + internal; \ + } \ + \ + error_page 404 = @handle_404; \ + \ + location @handle_404 { \ + root /usr/share/nginx/html; \ + try_files /404.html @redirect_to_index; \ + internal; \ + } \ + \ + location @redirect_to_index { \ + return 302 /; \ + } \ +}' > /etc/nginx/conf.d/default.conf + +# Expose the standard nginx port +EXPOSE 80 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/LICENCE b/LICENCE deleted file mode 100644 index da0b4ae30a..0000000000 --- a/LICENCE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Happy Coder Contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..ec7357c926 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 Happy Next Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md index 4abdd1c7ed..d97dc9098c 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ -
Happy Coder
+
Happy Next

- Mobile and Web Client for Claude Code & Codex + Mobile and Web Client for Claude Code, Codex & Gemini

-Use Claude Code or Codex from anywhere with end-to-end encryption. +Use Claude Code, Codex, or Gemini from anywhere with end-to-end encryption.

-[📱 **iOS App**](https://apps.apple.com/us/app/happy-claude-code-client/id6748571505) • [🤖 **Android App**](https://play.google.com/store/apps/details?id=com.ex3ndr.happy) • [🌐 **Web App**](https://app.happy.engineering) • [🎥 **See a Demo**](https://youtu.be/GCS0OG9QMSE) • [📚 **Documentation**](https://happy.engineering/docs/) • [💬 **Discord**](https://discord.gg/fX9WBAhyfD) +[🌐 **GitHub**](https://github.com/hitosea/happy-next) • [🖥️ **Web App**](https://happy.hitosea.com/) • [📚 **Documentation**](docs/README.md) • [🇨🇳 **中文**](README.zh-CN.md)
@@ -18,23 +18,15 @@ Use Claude Code or Codex from anywhere with end-to-end encryption.

-Step 1: Download App -

- -
-appstore     googleplay -
- -

-Step 2: Install CLI on your computer +Step 1: Install CLI on your computer

```bash -npm install -g happy-coder +npm install -g happy-next-cli ```

-Step 3: Start using `happy` instead of `claude` or `codex` +Step 2: Start using `happy` instead of `claude`, `codex`, or `gemini`

```bash @@ -49,37 +41,198 @@ happy happy codex +# Instead of: gemini +# Use: happy gemini + +happy gemini + +``` + +Running `happy` prints a QR code for device pairing. + +- Open `https://happy.hitosea.com/` and scan the QR code (or follow the link shown in your terminal). +- Prerequisite: install the vendor CLI(s) you want to control (`claude`, `codex`, and/or `gemini`). + +
Happy Next
+ +## Self-host (Docker Compose) + +Happy Next works with the hosted server by default (`https://api.happy.hitosea.com`) and the hosted web app at `https://happy.hitosea.com/`. + +If you want to self-host: + +```bash +cp .env.example .env +# edit .env + +docker-compose up -d +``` + +Note: this default stack also starts `happy-voice`. You must configure LiveKit + provider keys (OpenAI/Cartesia/etc.) in `.env`. See [docs/self-host.md](docs/self-host.md). + +First run only (apply DB migrations): + +```bash +docker-compose exec happy-server yarn --cwd packages/happy-server prisma migrate deploy +``` + +Open the web app at `http://localhost:3030`. + +Full guide: [docs/self-host.md](docs/self-host.md) + +To point the CLI at your self-hosted API: + +```bash +HAPPY_SERVER_URL=http://localhost:3031 HAPPY_WEBAPP_URL=http://localhost:3030 happy ``` -
Happy Coder
+## Compatibility note + +Happy Next intentionally changed client KDF labels as part of the rebrand. Treat this as a **new generation**: do not expect encrypted data created by older clients to be readable by Happy Next (and vice versa). + +## What’s new in Happy Next + +Happy Next is a major evolution of the original Happy. Here are the highlights: + +### Orchestrator +- Define task dependency graphs (DAGs) with per-task model and working directory +- Auto-schedule execution across Claude, Codex, and Gemini agents +- Real-time status badges, activity count, and status-colored progress bars +- Follow up on completed tasks via session resume +- MCP tool integration with auto-filled working directory + +### Pending Message Queue +- Messages sent while the CLI is busy are queued server-side and auto-dispatched +- Queue panel UI with image count badges and send-now option +- Reconnect sync and concurrent dispatch safety + +### Multi-Agent (Claude Code + Codex + Gemini) +- All three agents are first-class citizens with session resume, duplicate/fork, and history +- Multi-agent history page with per-provider tabs, device and agent filter dropdowns +- Per-agent model selection, cost tracking, and context window display +- ACP and App-Server (JSON-RPC) backends for Codex, Codex v0.116.0 with fast mode +- AI backend profiles with presets for DeepSeek, Z.AI, OpenAI, Azure, and Google AI + +### Voice Assistant (Happy Voice) +- LiveKit-based voice gateway with pluggable STT/LLM/TTS providers +- Microphone mute, voice message send confirmation, "thinking" indicator +- Context-aware voice: app state is injected into the voice LLM automatically +- Auto-switch providers by prefix (e.g. `openai/gpt-4.1-mini`, `cartesia/sonic-3`) + +### Multi-Repo Worktree Workspaces +- Create, switch, and archive multi-repo workspaces from the app +- Per-repo branch selection, settings, and scripts +- Aggregated git status across repos +- Auto-generate workspace `CLAUDE.md` / `AGENTS.md` with `@import` refs +- Worktree merge and PR creation with target branch selection +- AI-powered PR code review with results posted as GitHub comments + +### Code Browser & Git Management +- Full file browser with search, Monaco editor viewing/editing +- Commit history with branch selector (local + remote) +- Git changes page: stage, unstage, commit, discard +- Per-file diff stats (+N/-N) for Claude, Codex, and Gemini +- Image preview with sharing support + +### Session Sharing +- Share sessions with friends via direct invite or public link +- End-to-end encrypted: NaCl Box (direct) and token-derived keys (public links) +- Real-time sync of messages, git status, and voice chat across shared users +- Access control with view, edit, and admin permission levels +- "All / Shared with me / Shared by me" filter tabs and share indicator in session list +- Public share web viewer for link-based access + +### OpenClaw Gateway +- Connect to external AI machines via relay tunnel or direct WebSocket +- Machine pairing with Ed25519 key exchange +- Chat interface with real-time streaming and session management + +### DooTask Integration +- Task list with filters, search, pagination, and status workflows +- Task detail with HTML rendering, assignees, files, sub-tasks +- Real-time WebSocket chat (Slack-style layout, emoji reactions, voice playback, images/video) +- One-click AI session launch from any task (MCP server passthrough) +- Create tasks and projects directly from the app with cross-platform date picker +- Globalized WebSocket connection with real-time task updates and persistent server-side connection + +### Self-Hosting +- One-command `docker-compose up` (Web + API + Voice + Postgres + Redis + MinIO) +- Separate origins architecture (no path reverse proxy) +- `.env.example` with full configuration reference +- Runtime env var injection for Docker builds + +### Sync & Reliability +- v3 messages API with seq-based sync, batch writes, and cursor pagination +- HTTP outbox for reliable delivery when WebSocket is unavailable +- Server-confirmed message sending with retry and message receipt tracking +- Fixes for cursor skip, outbox race, message duplication/loss + +### Chat & Session UX +- Image attachment and clipboard paste (web), image support in drafts +- `/duplicate` command to fork a session from any message +- Message pagination, unread blue dot indicator, compact list view +- Active/Inactive tab filter, session preview expand/collapse, metadata caching +- Session rename with lock (prevent AI auto-update), search in history +- Options click-to-send / long-press-to-fill, scroll-to-bottom button +- Pull-to-refresh, inset dividers, Agent tool display with robot icon +- Tool input/output formatted as key-value pairs instead of raw JSON +- `preview_html` tool for full-page HTML preview, colon-separated MCP tool naming +- CLI hot-upgrade support mid-session + +### CLI +- `happy update` self-upgrade, `happy --version` with all agent versions +- Daemon auto-start on boot (`happy daemon enable/disable`), restart command +- Unified system prompt injection for Codex and Gemini +- Message receipt tracking with legacy compatibility + +### Bug Fixes & Stability +- 250+ bug fixes: message sending reliability, session lifecycle, Markdown rendering, navigation, voice, DooTask, sharing +- Security: shell command injection fix, plan mode permission handling +- Performance: payload trimming for mobile, lazy-load diffs, rendering optimization + +### UI & Polish +- Dark mode fixes throughout the app +- i18n improvements (Chinese Simplified/Traditional, CJK input handling) +- Markdown rendering: tables, inline code, nested fences, clickable file paths +- Keyboard handling, loading states, navigation stability, icon font preloading + +Full changelog: [docs/changes-from-happy.md](docs/changes-from-happy.md) ## How does it work? -On your computer, run `happy` instead of `claude` or `happy codex` instead of `codex` to start your AI through our wrapper. When you want to control your coding agent from your phone, it restarts the session in remote mode. To switch back to your computer, just press any key on your keyboard. +On your computer, run `happy` instead of `claude`, `happy codex` instead of `codex`, or `happy gemini` instead of `gemini` to start your AI through our wrapper. When you want to control your coding agent from your phone, it restarts the session in remote mode. To switch back to your computer, just press any key on your keyboard. -## 🔥 Why Happy Coder? +## 🔥 Why Happy Next? -- 📱 **Mobile access to Claude Code and Codex** - Check what your AI is building while away from your desk -- 🔔 **Push notifications** - Get alerted when Claude Code and Codex needs permission or encounters errors -- ⚡ **Switch devices instantly** - Take control from phone or desktop with one keypress -- 🔐 **End-to-end encrypted** - Your code never leaves your devices unencrypted -- 🛠️ **Open source** - Audit the code yourself. No telemetry, no tracking +- 🎛️ **Remote control for Claude, Codex & Gemini** - All three agents as first-class citizens +- 🤖 **Orchestrator** - Define multi-agent task DAGs and auto-schedule execution +- ⚡ **Instant device handoff** - Take back control with a single keypress +- 🔔 **Push notifications** - Know when your agent needs attention +- 🔐 **E2EE + self-host option** - Encrypted by default, one-command Docker deployment +- 🎙️ **Voice assistant** - LiveKit-based voice gateway with pluggable STT/LLM/TTS providers +- 🧰 **Multi-repo workspaces** - Worktree-based multi-repo flows with branch selection and PR creation +- 📁 **Code browser & git management** - Browse files, view diffs, stage/commit/discard from your phone +- 📋 **DooTask integration** - Task management with real-time chat and one-click AI sessions +- 📨 **Pending message queue** - Messages queued and auto-dispatched when CLI is ready ## 📦 Project Components -- **[happy UI](https://github.com/slopus/happy/tree/main/expo-app)** - Web UI + mobile client (Expo) -- **[happy-cli](https://github.com/slopus/happy/tree/main/cli)** - Command-line interface for Claude Code and Codex -- **[happy-server](https://github.com/slopus/happy/tree/main/server)** - Backend server for encrypted sync +- **[Happy App](packages/happy-app)** - Web UI + mobile client (Expo) +- **[Happy CLI](packages/happy-cli)** - Command-line interface for Claude Code, Codex, and Gemini +- **[Happy Server](packages/happy-server)** - Backend server for encrypted sync +- **[Happy Voice](packages/happy-voice)** - Voice gateway (LiveKit-based) +- **[Happy Wire](packages/happy-wire)** - Shared wire types and schemas ## 🏠 Who We Are -We're engineers scattered across Bay Area coffee shops and hacker houses, constantly checking how our AI coding agents are progressing on our pet projects during lunch breaks. Happy Coder was born from the frustration of not being able to peek at our AI coding tools building our side hustles while we're away from our keyboards. We believe the best tools come from scratching your own itch and sharing with the community. +We build Happy Next because we want to supervise coding agents from anywhere (web/mobile) without giving up control, privacy, or the option to self-host. ## 📚 Documentation & Contributing -- **[Documentation Website](https://happy.engineering/docs/)** - Learn how to use Happy Coder effectively -- **[CONTRIBUTING.md](CONTRIBUTING.md)** - Development setup including iOS, Android, and macOS desktop variant builds -- **[Edit docs at github.com/slopus/slopus.github.io](https://github.com/slopus/slopus.github.io)** - Help improve our documentation and guides +- **[Documentation](docs/README.md)** - Learn how Happy Next works (protocol, deployment, self-host, architecture) +- **[CONTRIBUTING.md](CONTRIBUTING.md)** - Development setup and contributing guidelines +- **[SECURITY.md](SECURITY.md)** - Security vulnerability reporting policy +- **[SUPPORT.md](SUPPORT.md)** - Support and troubleshooting ## License diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000000..de8382b82b --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,239 @@ +
Happy Next
+ +

+ Claude Code、Codex 和 Gemini 的移动端和 Web 客户端 +

+ +

+随时随地使用 Claude Code、Codex 或 Gemini,端到端加密。 +

+ +
+ +[🌐 **GitHub**](https://github.com/hitosea/happy-next) • [🖥️ **Web 应用**](https://happy.hitosea.com/) • [📚 **文档**](docs/README.md) • [🇬🇧 **English**](README.md) + +
+ +github + + +

+第一步:在你的电脑上安装 CLI +

+ +```bash +npm install -g happy-next-cli +``` + +

+第二步:用 `happy` 代替 `claude`、`codex` 或 `gemini` +

+ +```bash + +# 原来用: claude +# 现在用: happy + +happy + +# 原来用: codex +# 现在用: happy codex + +happy codex + +# 原来用: gemini +# 现在用: happy gemini + +happy gemini + +``` + +运行 `happy` 会打印一个二维码用于设备配对。 + +- 打开 `https://happy.hitosea.com/` 扫描二维码(或点击终端中显示的链接)。 +- 前提:安装你想要控制的供应商 CLI(`claude`、`codex` 和/或 `gemini`)。 + +
Happy Next
+ +## 自托管(Docker Compose) + +Happy Next 默认使用托管服务器(`https://api.happy.hitosea.com`)和托管 Web 应用(`https://happy.hitosea.com/`)。 + +如果你想自托管: + +```bash +cp .env.example .env +# 编辑 .env + +docker-compose up -d +``` + +注意:默认的 stack 也会启动 `happy-voice`。你必须在 `.env` 中配置 LiveKit + 供应商密钥(OpenAI/Cartesia 等)。详见 [docs/self-host.zh-CN.md](docs/self-host.zh-CN.md)。 + +首次运行(执行数据库迁移): + +```bash +docker-compose exec happy-server yarn --cwd packages/happy-server prisma migrate deploy +``` + +打开 Web 应用:`http://localhost:3030`。 + +完整指南:[docs/self-host.zh-CN.md](docs/self-host.zh-CN.md) + +将 CLI 指向你自托管的 API: + +```bash +HAPPY_SERVER_URL=http://localhost:3031 HAPPY_WEBAPP_URL=http://localhost:3030 happy +``` + +## 兼容性说明 + +Happy Next 在品牌重塑中有意更改了客户端 KDF 标签。请将其视为**全新一代**:不要期望旧客户端创建的加密数据能被 Happy Next 读取(反之亦然)。 + +## Happy Next 新特性 + +Happy Next 是原版 Happy 的重大演进,以下是亮点: + +### 编排器(Orchestrator) +- 定义任务依赖图(DAG),支持按任务指定模型和工作目录 +- 跨 Claude、Codex 和 Gemini 自动调度执行 +- 实时状态徽章、活动计数和状态颜色进度条 +- 通过会话恢复跟进已完成任务 +- MCP 工具集成,自动填充工作目录 + +### 待发消息队列 +- CLI 繁忙时消息在服务端排队,就绪后自动分发 +- 队列面板 UI,支持图片数量徽章和立即发送 +- 重连同步和并发分发安全 + +### 多 Agent 支持(Claude Code + Codex + Gemini) +- 三个 Agent 均为一等公民,支持会话恢复、复制/分叉和历史记录 +- 多 Agent 历史页面,按供应商分标签页,支持设备和 Agent 类型筛选 +- 按 Agent 选择模型、费用追踪和上下文窗口显示 +- Codex 支持 ACP 和 App-Server(JSON-RPC)两种后端,Codex v0.116.0 支持 fast mode +- AI 后端配置文件,内置 DeepSeek、Z.AI、OpenAI、Azure 和 Google AI 预设 + +### 语音助手(Happy Voice) +- 基于 LiveKit 的语音网关,支持可插拔的 STT/LLM/TTS 供应商 +- 麦克风静音、语音消息发送确认、"思考中"指示器 +- 上下文感知语音:应用状态自动注入到语音 LLM +- 按前缀自动切换供应商(如 `openai/gpt-4.1-mini`、`cartesia/sonic-3`) + +### 多仓库工作树工作区 +- 从应用中创建、切换和归档多仓库工作区 +- 按仓库选择分支、设置和脚本 +- 跨仓库聚合 git 状态 +- 自动生成工作区 `CLAUDE.md` / `AGENTS.md`(含 `@import` 引用) +- 工作树合并和 PR 创建,支持目标分支选择 +- AI 驱动的 PR 代码审查,结果发布为 GitHub 评论 + +### 代码浏览器和 Git 管理 +- 完整的文件浏览器,支持搜索、Monaco 编辑器查看/编辑 +- 提交历史,支持分支选择器(本地 + 远程) +- Git 变更页面:暂存、取消暂存、提交、丢弃 +- 按文件差异统计(+N/-N),支持 Claude、Codex 和 Gemini +- 图片预览,支持分享 + +### 会话共享 +- 直接邀请好友或通过公开链接分享会话 +- 端到端加密:直接分享使用 NaCl Box,公开链接使用 token 派生密钥 +- 实时同步消息、git 状态和语音聊天 +- 按访问级别(查看/编辑/管理)控制权限 +- 会话列表"全部/共享给我/我分享的"过滤标签和共享指示器 +- 公开分享网页查看器,无需安装应用即可访问 + +### OpenClaw 网关 +- 通过中继隧道或直连 WebSocket 连接外部 AI 机器 +- Ed25519 密钥交换进行机器配对 +- 聊天界面,支持实时流式传输和会话管理 + +### DooTask 集成 +- 任务列表,支持过滤、搜索、分页和状态工作流 +- 任务详情,支持 HTML 渲染、负责人、文件、子任务 +- 实时 WebSocket 聊天(Slack 风格布局、表情回应、语音回放、图片/视频) +- 从任一任务一键启动 AI 会话(MCP 服务透传) +- 在应用内直接创建任务和项目,跨平台日期选择器 +- 全局化 WebSocket 连接,实时任务更新,持久化服务端连接 + +### 自托管 +- 一条命令 `docker-compose up`(Web + API + Voice + Postgres + Redis + MinIO) +- 独立源架构(无路径反向代理) +- `.env.example` 包含完整配置参考 +- Docker 构建的运行时环境变量注入 + +### 同步和可靠性 +- v3 消息 API,基于 seq 的同步、批量写入和游标分页 +- WebSocket 不可用时的 HTTP 发件箱可靠投递 +- 服务端确认消息发送,支持重试和消息接收追踪 +- 修复游标跳过、发件箱竞争、消息重复/丢失 + +### 聊天和会话体验 +- 图片附件和剪贴板粘贴(Web),草稿支持图片 +- `/duplicate` 命令从任意消息分叉会话 +- 消息分页、未读蓝点指示器、紧凑列表视图 +- Active/Inactive 标签页过滤器、会话预览展开/折叠、元数据缓存 +- 会话重命名并锁定(防止 AI 自动更新)、历史搜索 +- 选项点击发送 / 长按填充、滚动到底部按钮 +- 下拉刷新、内嵌分隔线、Agent tool 展示(机器人图标) +- 工具输入/输出格式化为 key-value 对(替代原始 JSON) +- `preview_html` 工具全页面 HTML 预览,冒号分隔 MCP 工具命名 +- CLI 会话中途热升级 + +### CLI +- `happy update` 自更新、`happy --version` 显示所有 Agent 版本 +- 守护进程开机自启动(`happy daemon enable/disable`)、重启命令 +- 统一 Codex 和 Gemini 系统提示注入 +- 消息接收追踪,兼容旧版本 + +### Bug 修复和稳定性 +- 250+ Bug 修复:消息发送可靠性、会话生命周期、Markdown 渲染、导航、语音、DooTask、共享 +- 安全:Shell 命令注入修复、计划模式权限处理 +- 性能:移动端载荷精简、延迟加载 diff、渲染优化 + +### UI 和打磨 +- 全应用暗色模式修复 +- i18n 改进(简体中文/繁体中文、CJK 输入处理) +- Markdown 渲染:表格、内联代码、嵌套代码块、可点击文件路径 +- 键盘处理、加载状态、导航稳定性、图标字体预加载 + +完整变更日志:[docs/changes-from-happy.zh-CN.md](docs/changes-from-happy.zh-CN.md) + +## 工作原理 + +在电脑上运行 `happy` 代替 `claude`,`happy codex` 代替 `codex`,或 `happy gemini` 代替 `gemini`,通过我们的包装器启动你的 AI。当你想从手机上控制编码 Agent 时,它会以远程模式重启会话。要切换回电脑,只需按键盘上的任意键。 + +## 为什么选择 Happy Next? + +- 🎛️ **Claude、Codex 和 Gemini 的远程控制** — 三个 Agent 均为一等公民 +- 🤖 **编排器** — 定义多 Agent 任务 DAG 并自动调度执行 +- ⚡ **即时设备切换** — 一键夺回控制权 +- 🔔 **推送通知** — 随时知道你的 Agent 需要关注 +- 🔐 **端到端加密 + 可自托管** — 默认加密,一条命令 Docker 部署 +- 🎙️ **语音助手** — 基于 LiveKit 的语音网关,可插拔 STT/LLM/TTS 供应商 +- 🧰 **多仓库工作区** — 基于工作树的多仓库工作流,支持分支选择和 PR 创建 +- 📁 **代码浏览器和 Git 管理** — 从手机浏览文件、查看 diff、暂存/提交/丢弃 +- 📋 **DooTask 集成** — 任务管理,实时聊天,一键 AI 会话 +- 📨 **待发消息队列** — CLI 繁忙时消息排队,就绪后自动分发 + +## 项目组件 + +- **[Happy App](packages/happy-app)** — Web UI + 移动客户端(Expo) +- **[Happy CLI](packages/happy-cli)** — Claude Code、Codex 和 Gemini 的命令行界面 +- **[Happy Server](packages/happy-server)** — 加密同步后端服务器 +- **[Happy Voice](packages/happy-voice)** — 语音网关(基于 LiveKit) +- **[Happy Wire](packages/happy-wire)** — 共享线路类型和 Schema + +## 关于我们 + +我们开发 Happy Next,是因为我们想在任何地方(Web/移动端)监控编码 Agent,同时不放弃控制权、隐私或自托管的选择。 + +## 文档和贡献 + +- **[文档](docs/README.md)** — 了解 Happy Next 的工作原理(协议、部署、自托管、架构) +- **[CONTRIBUTING.md](CONTRIBUTING.md)** — 开发环境搭建和贡献指南 +- **[SECURITY.md](SECURITY.md)** — 安全漏洞报告政策 +- **[SUPPORT.md](SUPPORT.md)** — 支持与故障排查 + +## 许可证 + +MIT 许可证 — 详见 [LICENSE](LICENSE)。 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..cd8e57eff4 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,28 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report security vulnerabilities via **GitHub Security Advisories**: + +- Go to the repository on GitHub +- Click **Security** +- Click **Report a vulnerability** + +Do not open a public GitHub Issue for security-sensitive reports. + +## Scope + +This policy covers: +- `packages/happy-cli` +- `packages/happy-app` +- `packages/happy-server` +- `packages/happy-voice` +- `packages/happy-wire` + +## What to Include + +- A clear description of the vulnerability and impact +- Steps to reproduce +- A minimal proof of concept if possible +- Affected versions/commits if known + diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000000..8de9b55305 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,28 @@ +# Support + +## Where to Ask for Help + +- **Bug reports / feature requests:** GitHub Issues +- **Security reports:** see `SECURITY.md` (use GitHub Security Advisories) + +## Before Filing an Issue + +Please include: + +- Your OS and environment (macOS/Linux/Windows, Docker, Node version if relevant) +- Which components you are using: + - Hosted (`https://api.happy.hitosea.com/`) + - Self-hosted (`docker-compose`) +- Versions: + - CLI: `happy --version` + - Server image/commit (if self-hosted) +- Logs or error messages + +## Common Diagnostics + +- CLI: `happy doctor` +- Self-hosted: + - `docker-compose ps` + - `docker-compose logs -f happy-server` + - `docker-compose logs -f happy-voice` + diff --git a/cli/.gitignore b/cli/.gitignore deleted file mode 100644 index 927a8b1481..0000000000 --- a/cli/.gitignore +++ /dev/null @@ -1,40 +0,0 @@ -*-debug.log -*-error.log -**/.DS_Store -/.idea -/dist -/tmp -/node_modules -oclif.manifest.json - -# Unpacked binaries (keep archives) -/tools/unpacked/ - - - -pnpm-lock.yaml - -# Environment variables -.env -.env*.local -.envrc - -# Claude code session level settings -.claude/settings.local.json - -# 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 diff --git a/cli/CLAUDE.md b/cli/CLAUDE.md deleted file mode 100644 index 3f07517b98..0000000000 --- a/cli/CLAUDE.md +++ /dev/null @@ -1,228 +0,0 @@ -# Happy CLI Codebase Overview - -## Project Overview - -Happy CLI (`handy-cli`) is a command-line tool that wraps Claude Code to enable remote control and session sharing. It's part of a three-component system: - -1. **handy-cli** (this project) - CLI wrapper for Claude Code -2. **handy** - React Native mobile client -3. **handy-server** - Node.js server with Prisma (hosted at https://api.happy-servers.com/) - -## Code Style Preferences - -### TypeScript Conventions -- **Strict typing**: No untyped code ("I despise untyped code") -- **Clean function signatures**: Explicit parameter and return types -- **As little as possible classes** -- **Comprehensive JSDoc comments**: Each file includes header comments explaining responsibilities. -- **Import style**: Uses `@/` alias for src imports, e.g., `import { logger } from '@/ui/logger'` -- **File extensions**: Uses `.ts` for TypeScript files -- **Export style**: Named exports preferred, with occasional default exports for main functions - -### DO NOT - -- Create stupid small functions / getters / setters -- Excessive use of `if` statements - especially if you can avoid control flow changes with a better design -- **NEVER import modules mid-code** - ALL imports must be at the top of the file - -### Error Handling -- Graceful error handling with proper error messages -- Use of `try-catch` blocks with specific error logging -- Abort controllers for cancellable operations -- Careful handling of process lifecycle and cleanup - -### Testing -- Unit tests using Vitest -- No mocking - tests make real API calls -- Test files colocated with source files (`.test.ts`) -- Descriptive test names and proper async handling - -### Logging -- All debugging through file logs to avoid disturbing Claude sessions -- Console output only for user-facing messages -- Special handling for large JSON objects with truncation - -## Architecture & Key Components - -### 1. API Module (`/src/api/`) -Handles server communication and encryption. - -- **`api.ts`**: Main API client class for session management -- **`apiSession.ts`**: WebSocket-based real-time session client with RPC support -- **`auth.ts`**: Authentication flow using TweetNaCl for cryptographic signatures -- **`encryption.ts`**: End-to-end encryption utilities using TweetNaCl -- **`types.ts`**: Zod schemas for type-safe API communication - -**Key Features:** -- End-to-end encryption for all communications -- Socket.IO for real-time messaging -- Optimistic concurrency control for state updates -- RPC handler registration for remote procedure calls - -### 2. Claude Integration (`/src/claude/`) -Core Claude Code integration layer. - -- **`loop.ts`**: Main control loop managing interactive/remote modes -- **`types.ts`**: Claude message type definitions with parsers - -- **`claudeSdk.ts`**: Direct SDK integration using `@anthropic-ai/claude-code` -- **`interactive.ts`**: **LIKELY WILL BE DEPRECATED in favor of running through SDK** PTY-based interactive Claude sessions -- **`watcher.ts`**: File system watcher for Claude session files (for interactive mode snooping) - -- **`mcp/startPermissionServer.ts`**: MCP (Model Context Protocol) permission server - -**Key Features:** -- Dual mode operation: interactive (terminal) and remote (mobile control) -- Session persistence and resumption -- Real-time message streaming -- Permission intercepting via MCP [Permission checking not implemented yet] - -### 3. UI Module (`/src/ui/`) -User interface components. - -- **`logger.ts`**: Centralized logging system with file output -- **`qrcode.ts`**: QR code generation for mobile authentication -- **`start.ts`**: Main application startup and orchestration - -**Key Features:** -- Clean console UI with chalk styling -- QR code display for easy mobile connection -- Graceful mode switching between interactive and remote - -### 4. Core Files - -- **`index.ts`**: CLI entry point with argument parsing -- **`persistence.ts`**: Local storage for settings and keys -- **`utils/time.ts`**: Exponential backoff utilities - -## Data Flow - -1. **Authentication**: - - Generate/load secret key → Create signature challenge → Get auth token - -2. **Session Creation**: - - Create encrypted session with server → Establish WebSocket connection - -3. **Message Flow**: - - Interactive mode: User input → PTY → Claude → File watcher → Server - - Remote mode: Mobile app → Server → Claude SDK → Server → Mobile app - -4. **Permission Handling**: - - Claude requests permission → MCP server intercepts → Sends to mobile → Mobile responds → MCP approves/denies - -## Key Design Decisions - -1. **File-based logging**: Prevents interference with Claude's terminal UI -2. **Dual Claude integration**: Process spawning for interactive, SDK for remote -3. **End-to-end encryption**: All data encrypted before leaving the device -4. **Session persistence**: Allows resuming sessions across restarts -5. **Optimistic concurrency**: Handles distributed state updates gracefully - -## Security Considerations - -- Private keys stored in `~/.handy/access.key` with restricted permissions -- All communications encrypted using TweetNaCl -- Challenge-response authentication prevents replay attacks -- Session isolation through unique session IDs - -## Dependencies - -- Core: Node.js, TypeScript -- Claude: `@anthropic-ai/claude-code` SDK -- Networking: Socket.IO client, Axios -- Crypto: TweetNaCl -- Terminal: node-pty, chalk, qrcode-terminal -- Validation: Zod -- Testing: Vitest - - -# Running the Daemon - -## Starting the Daemon -```bash -# From the happy-cli directory: -./bin/happy.mjs daemon start - -# With custom server URL (for local development): -HAPPY_SERVER_URL=http://localhost:3005 ./bin/happy.mjs daemon start - -# Stop the daemon: -./bin/happy.mjs daemon stop - -# Check daemon status: -./bin/happy.mjs daemon status -``` - -## Daemon Logs -- Daemon logs are stored in `~/.happy-dev/logs/` (or `$HAPPY_HOME_DIR/logs/`) -- Named with format: `YYYY-MM-DD-HH-MM-SS-daemon.log` - -# Session Forking `claude` and sdk behavior - -## Commands Run - -### Initial Session -```bash -claude --print --output-format stream-json --verbose 'list files in this directory' -``` -- Original Session ID: `aada10c6-9299-4c45-abc4-91db9c0f935d` -- Created file: `~/.claude/projects/.../aada10c6-9299-4c45-abc4-91db9c0f935d.jsonl` - -### Resume with --resume flag -```bash -claude --print --output-format stream-json --verbose --resume aada10c6-9299-4c45-abc4-91db9c0f935d 'what file did we just see?' -``` -- New Session ID: `1433467f-ff14-4292-b5b2-2aac77a808f0` -- Created file: `~/.claude/projects/.../1433467f-ff14-4292-b5b2-2aac77a808f0.jsonl` - -## Key Findings for --resume - -### 1. Session File Behavior -- Creates a NEW session file with NEW session ID -- Original session file remains unchanged -- Two separate files exist after resumption - -### 2. History Preservation -- The new session file contains the COMPLETE history from the original session -- History is prefixed at the beginning of the new file -- Includes a summary line at the very top - -### 3. Session ID Rewriting -- **CRITICAL FINDING**: All historical messages have their sessionId field UPDATED to the new session ID -- Original messages from session `aada10c6-9299-4c45-abc4-91db9c0f935d` now show `sessionId: "1433467f-ff14-4292-b5b2-2aac77a808f0"` -- This creates a unified session history under the new ID - -### 4. Message Structure in New File -``` -Line 1: Summary of previous conversation -Lines 2-6: Complete history from original session (with updated session IDs) -Lines 7-8: New messages from current interaction -``` - -### 5. Context Preservation -- Claude successfully maintains full context -- Can answer questions about previous interactions -- Behaves as if it's a continuous conversation - -## Technical Details - -### Original Session File Structure -- Contains only messages from the original session -- All messages have original session ID -- Remains untouched after resume - -### New Session File Structure After Resume -```json -{"type":"summary","summary":"Listing directory files in current location","leafUuid":"..."} -{"parentUuid":null,"sessionId":"1433467f-ff14-4292-b5b2-2aac77a808f0","message":{"role":"user","content":[{"type":"text","text":"list files in this directory"}]},...} -// ... all historical messages with NEW session ID ... -{"parentUuid":"...","sessionId":"1433467f-ff14-4292-b5b2-2aac77a808f0","message":{"role":"user","content":"what file did we just see?"},...} -``` - -## Implications for handy-cli - -When using --resume: -1. Must handle new session ID in responses -2. Original session remains as historical record -3. All context preserved but under new session identity -4. Session ID in stream-json output will be the new one, not the resumed one \ No newline at end of file diff --git a/cli/CONTRIBUTING.md b/cli/CONTRIBUTING.md deleted file mode 100644 index ccb1eb2862..0000000000 --- a/cli/CONTRIBUTING.md +++ /dev/null @@ -1,333 +0,0 @@ -# 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/cli/README.md b/cli/README.md deleted file mode 100644 index c0dd89ae1b..0000000000 --- a/cli/README.md +++ /dev/null @@ -1,155 +0,0 @@ -# Happy - -Code on the go — control AI coding agents from your mobile device. - -Free. Open source. Code anywhere. - -## Installation - -```bash -npm install -g happy-coder -``` - -## Usage - -### Claude (default) - -```bash -happy -``` - -This will: -1. Start a Claude Code session -2. Display a QR code to connect from your mobile device -3. Allow real-time session sharing between Claude Code and your mobile app - -### Gemini - -```bash -happy gemini -``` - -Start a Gemini CLI session with remote control capabilities. - -**First time setup:** -```bash -# Authenticate with Google -happy connect gemini -``` - -## Commands - -### Main Commands - -- `happy` – Start Claude Code session (default) -- `happy gemini` – Start Gemini CLI session -- `happy codex` – Start Codex mode - -### Utility Commands - -- `happy auth` – Manage authentication -- `happy connect` – Store AI vendor API keys in Happy cloud -- `happy notify` – Send a push notification to your devices -- `happy daemon` – Manage background service -- `happy doctor` – System diagnostics & troubleshooting - -### Connect Subcommands - -```bash -happy connect gemini # Authenticate with Google for Gemini -happy connect claude # Authenticate with Anthropic -happy connect codex # Authenticate with OpenAI -happy connect status # Show connection status for all vendors -``` - -### Gemini Subcommands - -```bash -happy gemini # Start Gemini session -happy gemini model set # Set default model -happy gemini model get # Show current model -happy gemini project set # Set Google Cloud Project ID (for Workspace accounts) -happy gemini project get # Show current Google Cloud Project ID -``` - -**Available models:** `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` - -## Options - -### Claude Options - -- `-m, --model ` - Claude model to use (default: sonnet) -- `-p, --permission-mode ` - Permission mode: auto, default, or plan -- `--claude-env KEY=VALUE` - Set environment variable for Claude Code -- `--claude-arg ARG` - Pass additional argument to Claude CLI - -### Global Options - -- `-h, --help` - Show help -- `-v, --version` - Show version - -## Environment Variables - -### Happy Configuration - -- `HAPPY_SERVER_URL` - Custom server URL (default: https://api.cluster-fluster.com) -- `HAPPY_WEBAPP_URL` - Custom web app URL (default: https://app.happy.engineering) -- `HAPPY_HOME_DIR` - Custom home directory for Happy data (default: ~/.happy) -- `HAPPY_DISABLE_CAFFEINATE` - Disable macOS sleep prevention (set to `true`, `1`, or `yes`) -- `HAPPY_EXPERIMENTAL` - Enable experimental features (set to `true`, `1`, or `yes`) - -### Gemini Configuration - -- `GEMINI_MODEL` - Override default Gemini model -- `GOOGLE_CLOUD_PROJECT` - Google Cloud Project ID (required for Workspace accounts) - -## Gemini Authentication - -### Personal Google Account - -Personal Gmail accounts work out of the box: - -```bash -happy connect gemini -happy gemini -``` - -### Google Workspace Account - -Google Workspace (organization) accounts require a Google Cloud Project: - -1. Create a project in [Google Cloud Console](https://console.cloud.google.com/) -2. Enable the Gemini API -3. Set the project ID: - -```bash -happy gemini project set your-project-id -``` - -Or use environment variable: -```bash -GOOGLE_CLOUD_PROJECT=your-project-id happy gemini -``` - -**Guide:** https://goo.gle/gemini-cli-auth-docs#workspace-gca - -## Contributing - -Interested in contributing? See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines. - -## Requirements - -- Node.js >= 20.0.0 - -### For Claude - -- Claude CLI installed & logged in (`claude` command available in PATH) - -### For Gemini - -- Gemini CLI installed (`npm install -g @google/gemini-cli`) -- Google account authenticated via `happy connect gemini` - -## License - -MIT diff --git a/cli/package.json b/cli/package.json deleted file mode 100644 index 87040fab00..0000000000 --- a/cli/package.json +++ /dev/null @@ -1,144 +0,0 @@ -{ - "name": "happy-coder", - "version": "0.14.0-0", - "description": "Mobile and Web client for Claude Code and Codex", - "author": "Kirill Dubovitskiy", - "license": "MIT", - "type": "module", - "homepage": "https://github.com/slopus/happy-cli", - "bugs": "https://github.com/slopus/happy-cli/issues", - "repository": "slopus/happy-cli", - "bin": { - "happy": "./bin/happy.mjs", - "happy-mcp": "./bin/happy-mcp.mjs" - }, - "main": "./dist/index.cjs", - "module": "./dist/index.mjs", - "types": "./dist/index.d.cts", - "exports": { - ".": { - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - }, - "import": { - "types": "./dist/index.d.mts", - "default": "./dist/index.mjs" - } - }, - "./lib": { - "require": { - "types": "./dist/lib.d.cts", - "default": "./dist/lib.cjs" - }, - "import": { - "types": "./dist/lib.d.mts", - "default": "./dist/lib.mjs" - } - }, - "./codex/happyMcpStdioBridge": { - "require": { - "types": "./dist/codex/happyMcpStdioBridge.d.cts", - "default": "./dist/codex/happyMcpStdioBridge.cjs" - }, - "import": { - "types": "./dist/codex/happyMcpStdioBridge.d.mts", - "default": "./dist/codex/happyMcpStdioBridge.mjs" - } - } - }, - "files": [ - "dist", - "bin", - "scripts", - "tools", - "package.json" - ], - "scripts": { - "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": "$npm_execpath run build && vitest run", - "start": "$npm_execpath run build && node ./bin/happy.mjs", - "dev": "tsx src/index.ts", - "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", - "@modelcontextprotocol/sdk": "^1.22.0", - "@stablelib/base64": "^2.0.1", - "@stablelib/hex": "^2.0.1", - "@types/cross-spawn": "^6.0.6", - "@types/http-proxy": "^1.17.17", - "@types/ps-list": "^6.2.1", - "@types/qrcode-terminal": "^0.12.2", - "@types/react": "^19.2.7", - "@types/tmp": "^0.2.6", - "ai": "^5.0.107", - "axios": "^1.13.2", - "chalk": "^5.6.2", - "cross-spawn": "^7.0.6", - "expo-server-sdk": "^3.15.0", - "fastify": "^5.6.2", - "fastify-type-provider-zod": "4.0.2", - "http-proxy": "^1.18.1", - "http-proxy-middleware": "^3.0.5", - "ink": "^6.5.1", - "open": "^10.2.0", - "ps-list": "^8.1.1", - "qrcode-terminal": "^0.12.0", - "react": "^19.2.0", - "socket.io-client": "^4.8.1", - "tar": "^7.5.2", - "tmp": "^0.2.5", - "tweetnacl": "^1.0.3", - "zod": "^3.23.8" - }, - "devDependencies": { - "@eslint/compat": "^1", - "@types/node": ">=20", - "cross-env": "^10.1.0", - "dotenv": "^16.6.1", - "eslint": "^9", - "eslint-config-prettier": "^10", - "pkgroll": "^2.14.2", - "release-it": "^19.0.6", - "shx": "^0.3.3", - "ts-node": "^10", - "tsx": "^4.20.6", - "typescript": "^5", - "vitest": "^3.2.4" - }, - "resolutions": { - "whatwg-url": "14.2.0", - "parse-path": "7.0.3", - "@types/parse-path": "7.0.3" - }, - "publishConfig": { - "registry": "https://registry.npmjs.org" - }, - "packageManager": "yarn@1.22.22" -} diff --git a/cli/src/agent/acp/AcpBackend.ts b/cli/src/agent/acp/AcpBackend.ts deleted file mode 100644 index b44e91e7c2..0000000000 --- a/cli/src/agent/acp/AcpBackend.ts +++ /dev/null @@ -1,1082 +0,0 @@ -/** - * AcpBackend - Agent Client Protocol backend using official SDK - * - * This module provides a universal backend implementation using the official - * @agentclientprotocol/sdk. Agent-specific behavior (timeouts, filtering, - * error handling) is delegated to TransportHandler implementations. - */ - -import { spawn, type ChildProcess } from 'node:child_process'; -import { Readable, Writable } from 'node:stream'; -import { - ClientSideConnection, - ndJsonStream, - type Client, - type Agent, - type SessionNotification, - type RequestPermissionRequest, - type RequestPermissionResponse, - type InitializeRequest, - type NewSessionRequest, - type PromptRequest, - type ContentBlock, -} from '@agentclientprotocol/sdk'; -import { randomUUID } from 'node:crypto'; -import type { - AgentBackend, - AgentMessage, - AgentMessageHandler, - SessionId, - StartSessionResult, - McpServerConfig, -} from '../core'; -import { logger } from '@/ui/logger'; -import { delay } from '@/utils/time'; -import packageJson from '../../../package.json'; - -/** - * Retry configuration for ACP operations - */ -const RETRY_CONFIG = { - /** Maximum number of retry attempts for init/newSession */ - maxAttempts: 3, - /** Base delay between retries in ms */ - baseDelayMs: 1000, - /** Maximum delay between retries in ms */ - maxDelayMs: 5000, -} as const; -import { - type TransportHandler, - type StderrContext, - type ToolNameContext, - DefaultTransport, -} from '../transport'; -import { - type SessionUpdate, - type HandlerContext, - DEFAULT_IDLE_TIMEOUT_MS, - DEFAULT_TOOL_CALL_TIMEOUT_MS, - handleAgentMessageChunk, - handleAgentThoughtChunk, - handleToolCallUpdate, - handleToolCall, - handleLegacyMessageChunk, - handlePlanUpdate, - handleThinkingUpdate, -} from './sessionUpdateHandlers'; - -/** - * Extended RequestPermissionRequest with additional fields that may be present - */ -type ExtendedRequestPermissionRequest = RequestPermissionRequest & { - toolCall?: { - id?: string; - kind?: string; - toolName?: string; - input?: Record; - arguments?: Record; - content?: Record; - }; - kind?: string; - input?: Record; - arguments?: Record; - content?: Record; - options?: Array<{ - optionId?: string; - name?: string; - kind?: string; - }>; -}; - -/** - * Extended SessionNotification with additional fields - */ -type ExtendedSessionNotification = SessionNotification & { - update?: { - sessionUpdate?: string; - toolCallId?: string; - status?: string; - kind?: string | unknown; - content?: { - text?: string; - error?: string | { message?: string }; - [key: string]: unknown; - } | string | unknown; - locations?: unknown[]; - messageChunk?: { - textDelta?: string; - }; - plan?: unknown; - thinking?: unknown; - [key: string]: unknown; - }; -} - -/** - * Permission handler interface for ACP backends - */ -export interface AcpPermissionHandler { - /** - * Handle a tool permission request - * @param toolCallId - The unique ID of the tool call - * @param toolName - The name of the tool being called - * @param input - The input parameters for the tool - * @returns Promise resolving to permission result with decision - */ - handleToolCall( - toolCallId: string, - toolName: string, - input: unknown - ): Promise<{ decision: 'approved' | 'approved_for_session' | 'denied' | 'abort' }>; -} - -/** - * Configuration for AcpBackend - */ -export interface AcpBackendOptions { - /** Agent name for identification */ - agentName: string; - - /** Working directory for the agent */ - cwd: string; - - /** Command to spawn the ACP agent */ - command: string; - - /** Arguments for the agent command */ - args?: string[]; - - /** Environment variables to pass to the agent */ - env?: Record; - - /** MCP servers to make available to the agent */ - mcpServers?: Record; - - /** Optional permission handler for tool approval */ - permissionHandler?: AcpPermissionHandler; - - /** Transport handler for agent-specific behavior (timeouts, filtering, etc.) */ - transportHandler?: TransportHandler; - - /** Optional callback to check if prompt has change_title instruction */ - hasChangeTitleInstruction?: (prompt: string) => boolean; -} - -/** - * Convert Node.js streams to Web Streams for ACP SDK - * - * NOTE: This function registers event handlers on stdout. If you also register - * handlers directly on stdout (e.g., for logging), both will fire. - */ -function nodeToWebStreams( - stdin: Writable, - stdout: Readable -): { writable: WritableStream; readable: ReadableStream } { - // Convert Node writable to Web WritableStream - const writable = new WritableStream({ - write(chunk) { - return new Promise((resolve, reject) => { - const ok = stdin.write(chunk, (err) => { - if (err) { - logger.debug(`[AcpBackend] Error writing to stdin:`, err); - reject(err); - } - }); - if (ok) { - resolve(); - } else { - stdin.once('drain', resolve); - } - }); - }, - close() { - return new Promise((resolve) => { - stdin.end(resolve); - }); - }, - abort(reason) { - stdin.destroy(reason instanceof Error ? reason : new Error(String(reason))); - } - }); - - // Convert Node readable to Web ReadableStream - // Filter out non-JSON debug output from gemini CLI (experiments, flags, etc.) - const readable = new ReadableStream({ - start(controller) { - stdout.on('data', (chunk: Buffer) => { - controller.enqueue(new Uint8Array(chunk)); - }); - stdout.on('end', () => { - controller.close(); - }); - stdout.on('error', (err) => { - logger.debug(`[AcpBackend] Stdout error:`, err); - controller.error(err); - }); - }, - cancel() { - stdout.destroy(); - } - }); - - return { writable, readable }; -} - -/** - * Helper to run an async operation with retry logic - */ -async function withRetry( - operation: () => Promise, - options: { - operationName: string; - maxAttempts: number; - baseDelayMs: number; - maxDelayMs: number; - onRetry?: (attempt: number, error: Error) => void; - } -): Promise { - let lastError: Error | null = null; - - for (let attempt = 1; attempt <= options.maxAttempts; attempt++) { - try { - return await operation(); - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - - if (attempt < options.maxAttempts) { - // Calculate delay with exponential backoff - const delayMs = Math.min( - options.baseDelayMs * Math.pow(2, attempt - 1), - options.maxDelayMs - ); - - logger.debug(`[AcpBackend] ${options.operationName} failed (attempt ${attempt}/${options.maxAttempts}): ${lastError.message}. Retrying in ${delayMs}ms...`); - options.onRetry?.(attempt, lastError); - - await delay(delayMs); - } - } - } - - throw lastError; -} - -/** - * ACP backend using the official @agentclientprotocol/sdk - */ -export class AcpBackend implements AgentBackend { - private listeners: AgentMessageHandler[] = []; - private process: ChildProcess | null = null; - private connection: ClientSideConnection | null = null; - private acpSessionId: string | null = null; - private disposed = false; - /** Track active tool calls to prevent duplicate events */ - private activeToolCalls = new Set(); - private toolCallTimeouts = new Map(); - /** Track tool call start times for performance monitoring */ - private toolCallStartTimes = new Map(); - /** Pending permission requests that need response */ - private pendingPermissions = new Map void>(); - - /** Map from permission request ID to real tool call ID for tracking */ - private permissionToToolCallMap = new Map(); - - /** Map from real tool call ID to tool name for auto-approval */ - private toolCallIdToNameMap = new Map(); - - /** Track if we just sent a prompt with change_title instruction */ - private recentPromptHadChangeTitle = false; - - /** Track tool calls count since last prompt (to identify first tool call) */ - private toolCallCountSincePrompt = 0; - /** Timeout for emitting 'idle' status after last message chunk */ - private idleTimeout: NodeJS.Timeout | null = null; - - /** Transport handler for agent-specific behavior */ - private readonly transport: TransportHandler; - - constructor(private options: AcpBackendOptions) { - this.transport = options.transportHandler ?? new DefaultTransport(options.agentName); - } - - onMessage(handler: AgentMessageHandler): void { - this.listeners.push(handler); - } - - offMessage(handler: AgentMessageHandler): void { - const index = this.listeners.indexOf(handler); - if (index !== -1) { - this.listeners.splice(index, 1); - } - } - - private emit(msg: AgentMessage): void { - if (this.disposed) return; - for (const listener of this.listeners) { - try { - listener(msg); - } catch (error) { - logger.warn('[AcpBackend] Error in message handler:', error); - } - } - } - - async startSession(initialPrompt?: string): Promise { - if (this.disposed) { - throw new Error('Backend has been disposed'); - } - - const sessionId = randomUUID(); - this.emit({ type: 'status', status: 'starting' }); - - try { - logger.debug(`[AcpBackend] Starting session: ${sessionId}`); - // Spawn the ACP agent process - const args = this.options.args || []; - - // On Windows, spawn via cmd.exe to handle .cmd files and PATH resolution - // This ensures proper stdio piping without shell buffering - if (process.platform === 'win32') { - const fullCommand = [this.options.command, ...args].join(' '); - this.process = spawn('cmd.exe', ['/c', fullCommand], { - cwd: this.options.cwd, - env: { ...process.env, ...this.options.env }, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }); - } else { - this.process = spawn(this.options.command, args, { - cwd: this.options.cwd, - env: { ...process.env, ...this.options.env }, - // Use 'pipe' for all stdio to capture output without printing to console - // stdout and stderr will be handled by our event listeners - stdio: ['pipe', 'pipe', 'pipe'], - }); - } - - // Ensure stderr doesn't leak to console - redirect to logger only - // This prevents gemini CLI debug output from appearing in user's console - if (this.process.stderr) { - // stderr is already handled by the event listener below - // but we ensure it doesn't go to parent's stderr - } - - if (!this.process.stdin || !this.process.stdout || !this.process.stderr) { - throw new Error('Failed to create stdio pipes'); - } - - // Handle stderr output via transport handler - this.process.stderr.on('data', (data: Buffer) => { - const text = data.toString(); - if (!text.trim()) return; - - // Build context for transport handler - const hasActiveInvestigation = this.transport.isInvestigationTool - ? Array.from(this.activeToolCalls).some(id => this.transport.isInvestigationTool!(id)) - : false; - - const context: StderrContext = { - activeToolCalls: this.activeToolCalls, - hasActiveInvestigation, - }; - - // Log to file (not console) - if (hasActiveInvestigation) { - logger.debug(`[AcpBackend] 🔍 Agent stderr (during investigation): ${text.trim()}`); - } else { - logger.debug(`[AcpBackend] Agent stderr: ${text.trim()}`); - } - - // Let transport handler process stderr and optionally emit messages - if (this.transport.handleStderr) { - const result = this.transport.handleStderr(text, context); - if (result.message) { - this.emit(result.message); - } - } - }); - - this.process.on('error', (err) => { - // Log to file only, not console - logger.debug(`[AcpBackend] Process error:`, err); - this.emit({ type: 'status', status: 'error', detail: err.message }); - }); - - this.process.on('exit', (code, signal) => { - if (!this.disposed && code !== 0 && code !== null) { - logger.debug(`[AcpBackend] Process exited with code ${code}, signal ${signal}`); - this.emit({ type: 'status', status: 'stopped', detail: `Exit code: ${code}` }); - } - }); - - // Create Web Streams from Node streams - const streams = nodeToWebStreams( - this.process.stdin, - this.process.stdout - ); - const writable = streams.writable; - const readable = streams.readable; - - // Filter stdout via transport handler before ACP parsing - // Some agents output debug info that breaks JSON-RPC parsing - const transport = this.transport; - const filteredReadable = new ReadableStream({ - async start(controller) { - const reader = readable.getReader(); - const decoder = new TextDecoder(); - const encoder = new TextEncoder(); - let buffer = ''; - let filteredCount = 0; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) { - // Flush any remaining buffer - if (buffer.trim()) { - const filtered = transport.filterStdoutLine?.(buffer); - if (filtered === undefined) { - controller.enqueue(encoder.encode(buffer)); - } else if (filtered !== null) { - controller.enqueue(encoder.encode(filtered)); - } else { - filteredCount++; - } - } - if (filteredCount > 0) { - logger.debug(`[AcpBackend] Filtered out ${filteredCount} non-JSON lines from ${transport.agentName} stdout`); - } - controller.close(); - break; - } - - // Decode and accumulate data - buffer += decoder.decode(value, { stream: true }); - - // Process line by line (ndJSON is line-delimited) - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; // Keep last incomplete line in buffer - - for (const line of lines) { - if (!line.trim()) continue; - - // Use transport handler to filter lines - // Note: filterStdoutLine returns null to filter out, string to keep - // If method not implemented (undefined), pass through original line - const filtered = transport.filterStdoutLine?.(line); - if (filtered === undefined) { - // Method not implemented, pass through - controller.enqueue(encoder.encode(line + '\n')); - } else if (filtered !== null) { - // Method returned transformed line - controller.enqueue(encoder.encode(filtered + '\n')); - } else { - // Method returned null, filter out - filteredCount++; - } - } - } - } catch (error) { - logger.debug(`[AcpBackend] Error filtering stdout stream:`, error); - controller.error(error); - } finally { - reader.releaseLock(); - } - } - }); - - // Create ndJSON stream for ACP - const stream = ndJsonStream(writable, filteredReadable); - - // Create Client implementation - const client: Client = { - sessionUpdate: async (params: SessionNotification) => { - this.handleSessionUpdate(params); - }, - requestPermission: async (params: RequestPermissionRequest): Promise => { - - const extendedParams = params as ExtendedRequestPermissionRequest; - const toolCall = extendedParams.toolCall; - let toolName = toolCall?.kind || toolCall?.toolName || extendedParams.kind || 'Unknown tool'; - // Use toolCallId as the single source of truth for permission ID - // This ensures mobile app sends back the same ID that we use to store pending requests - const toolCallId = toolCall?.id || randomUUID(); - const permissionId = toolCallId; // Use same ID for consistency! - - // Extract input/arguments from various possible locations FIRST (before checking toolName) - let input: Record = {}; - if (toolCall) { - input = toolCall.input || toolCall.arguments || toolCall.content || {}; - } else { - // If no toolCall, try to extract from params directly - input = extendedParams.input || extendedParams.arguments || extendedParams.content || {}; - } - - // If toolName is "other" or "Unknown tool", try to determine real tool name - const context: ToolNameContext = { - recentPromptHadChangeTitle: this.recentPromptHadChangeTitle, - toolCallCountSincePrompt: this.toolCallCountSincePrompt, - }; - toolName = this.transport.determineToolName?.(toolName, toolCallId, input, context) ?? toolName; - - if (toolName !== (toolCall?.kind || toolCall?.toolName || extendedParams.kind || 'Unknown tool')) { - logger.debug(`[AcpBackend] Detected tool name: ${toolName} from toolCallId: ${toolCallId}`); - } - - // Increment tool call counter for context tracking - this.toolCallCountSincePrompt++; - - const options = extendedParams.options || []; - - // Log permission request for debugging (include full params to understand structure) - logger.debug(`[AcpBackend] Permission request: tool=${toolName}, toolCallId=${toolCallId}, input=`, JSON.stringify(input)); - logger.debug(`[AcpBackend] Permission request params structure:`, JSON.stringify({ - hasToolCall: !!toolCall, - toolCallKind: toolCall?.kind, - toolCallId: toolCall?.id, - paramsKind: extendedParams.kind, - paramsKeys: Object.keys(params), - }, null, 2)); - - // Emit permission request event for UI/mobile handling - this.emit({ - type: 'permission-request', - id: permissionId, - reason: toolName, - payload: { - ...params, - permissionId, - toolCallId, - toolName, - input, - options: options.map((opt) => ({ - id: opt.optionId, - name: opt.name, - kind: opt.kind, - })), - }, - }); - - // Use permission handler if provided, otherwise auto-approve - if (this.options.permissionHandler) { - try { - const result = await this.options.permissionHandler.handleToolCall( - toolCallId, - toolName, - input - ); - - // Map permission decision to ACP response - // ACP uses optionId from the request options - let optionId = 'cancel'; // Default to cancel/deny - - if (result.decision === 'approved' || result.decision === 'approved_for_session') { - // Find the appropriate optionId from the request options - // Look for 'proceed_once' or 'proceed_always' in options - const proceedOnceOption = options.find((opt: any) => - opt.optionId === 'proceed_once' || opt.name?.toLowerCase().includes('once') - ); - const proceedAlwaysOption = options.find((opt: any) => - opt.optionId === 'proceed_always' || opt.name?.toLowerCase().includes('always') - ); - - if (result.decision === 'approved_for_session' && proceedAlwaysOption) { - optionId = proceedAlwaysOption.optionId || 'proceed_always'; - } else if (proceedOnceOption) { - optionId = proceedOnceOption.optionId || 'proceed_once'; - } else if (options.length > 0) { - // Fallback to first option if no specific match - optionId = options[0].optionId || 'proceed_once'; - } - - // Emit tool-result with permissionId so UI can close the timer - // This is needed because tool_call_update comes with a different ID - this.emit({ - type: 'tool-result', - toolName, - result: { status: 'approved', decision: result.decision }, - callId: permissionId, - }); - } else { - // Denied or aborted - find cancel option - const cancelOption = options.find((opt: any) => - opt.optionId === 'cancel' || opt.name?.toLowerCase().includes('cancel') - ); - if (cancelOption) { - optionId = cancelOption.optionId || 'cancel'; - } - - // Emit tool-result for denied/aborted - this.emit({ - type: 'tool-result', - toolName, - result: { status: 'denied', decision: result.decision }, - callId: permissionId, - }); - } - - return { outcome: { outcome: 'selected', optionId } }; - } catch (error) { - // Log to file only, not console - logger.debug('[AcpBackend] Error in permission handler:', error); - // Fallback to deny on error - return { outcome: { outcome: 'selected', optionId: 'cancel' } }; - } - } - - // Auto-approve with 'proceed_once' if no permission handler - // optionId must match one from the request options (e.g., 'proceed_once', 'proceed_always', 'cancel') - const proceedOnceOption = options.find((opt) => - opt.optionId === 'proceed_once' || (typeof opt.name === 'string' && opt.name.toLowerCase().includes('once')) - ); - const defaultOptionId = proceedOnceOption?.optionId || (options.length > 0 && options[0].optionId ? options[0].optionId : 'proceed_once'); - return { outcome: { outcome: 'selected', optionId: defaultOptionId } }; - }, - }; - - // Create ClientSideConnection - this.connection = new ClientSideConnection( - (agent: Agent) => client, - stream - ); - - // Initialize the connection with timeout and retry - const initRequest: InitializeRequest = { - protocolVersion: 1, - clientCapabilities: { - fs: { - readTextFile: false, - writeTextFile: false, - }, - }, - clientInfo: { - name: 'happy-cli', - version: packageJson.version, - }, - }; - - const initTimeout = this.transport.getInitTimeout(); - logger.debug(`[AcpBackend] Initializing connection (timeout: ${initTimeout}ms)...`); - - await withRetry( - async () => { - let timeoutHandle: NodeJS.Timeout | null = null; - try { - const result = await Promise.race([ - this.connection!.initialize(initRequest).then((res) => { - if (timeoutHandle) { - clearTimeout(timeoutHandle); - timeoutHandle = null; - } - return res; - }), - new Promise((_, reject) => { - timeoutHandle = setTimeout(() => { - reject(new Error(`Initialize timeout after ${initTimeout}ms - ${this.transport.agentName} did not respond`)); - }, initTimeout); - }), - ]); - return result; - } finally { - if (timeoutHandle) { - clearTimeout(timeoutHandle); - } - } - }, - { - operationName: 'Initialize', - maxAttempts: RETRY_CONFIG.maxAttempts, - baseDelayMs: RETRY_CONFIG.baseDelayMs, - maxDelayMs: RETRY_CONFIG.maxDelayMs, - } - ); - logger.debug(`[AcpBackend] Initialize completed`); - - // Create a new session with retry - const mcpServers = this.options.mcpServers - ? Object.entries(this.options.mcpServers).map(([name, config]) => ({ - name, - command: config.command, - args: config.args || [], - env: config.env - ? Object.entries(config.env).map(([envName, envValue]) => ({ name: envName, value: envValue })) - : [], - })) - : []; - - const newSessionRequest: NewSessionRequest = { - cwd: this.options.cwd, - mcpServers: mcpServers as unknown as NewSessionRequest['mcpServers'], - }; - - logger.debug(`[AcpBackend] Creating new session...`); - - const sessionResponse = await withRetry( - async () => { - let timeoutHandle: NodeJS.Timeout | null = null; - try { - const result = await Promise.race([ - this.connection!.newSession(newSessionRequest).then((res) => { - if (timeoutHandle) { - clearTimeout(timeoutHandle); - timeoutHandle = null; - } - return res; - }), - new Promise((_, reject) => { - timeoutHandle = setTimeout(() => { - reject(new Error(`New session timeout after ${initTimeout}ms - ${this.transport.agentName} did not respond`)); - }, initTimeout); - }), - ]); - return result; - } finally { - if (timeoutHandle) { - clearTimeout(timeoutHandle); - } - } - }, - { - operationName: 'NewSession', - maxAttempts: RETRY_CONFIG.maxAttempts, - baseDelayMs: RETRY_CONFIG.baseDelayMs, - maxDelayMs: RETRY_CONFIG.maxDelayMs, - } - ); - this.acpSessionId = sessionResponse.sessionId; - logger.debug(`[AcpBackend] Session created: ${this.acpSessionId}`); - - this.emitIdleStatus(); - - // Send initial prompt if provided - if (initialPrompt) { - this.sendPrompt(sessionId, initialPrompt).catch((error) => { - // Log to file only, not console - logger.debug('[AcpBackend] Error sending initial prompt:', error); - this.emit({ type: 'status', status: 'error', detail: String(error) }); - }); - } - - return { sessionId }; - - } catch (error) { - // Log to file only, not console - logger.debug('[AcpBackend] Error starting session:', error); - this.emit({ - type: 'status', - status: 'error', - detail: error instanceof Error ? error.message : String(error) - }); - throw error; - } - } - - /** - * Create handler context for session update processing - */ - private createHandlerContext(): HandlerContext { - return { - transport: this.transport, - activeToolCalls: this.activeToolCalls, - toolCallStartTimes: this.toolCallStartTimes, - toolCallTimeouts: this.toolCallTimeouts, - toolCallIdToNameMap: this.toolCallIdToNameMap, - idleTimeout: this.idleTimeout, - toolCallCountSincePrompt: this.toolCallCountSincePrompt, - emit: (msg) => this.emit(msg), - emitIdleStatus: () => this.emitIdleStatus(), - clearIdleTimeout: () => { - if (this.idleTimeout) { - clearTimeout(this.idleTimeout); - this.idleTimeout = null; - } - }, - setIdleTimeout: (callback, ms) => { - this.idleTimeout = setTimeout(() => { - callback(); - this.idleTimeout = null; - }, ms); - }, - }; - } - - private handleSessionUpdate(params: SessionNotification): void { - const notification = params as ExtendedSessionNotification; - const update = notification.update; - - if (!update) { - logger.debug('[AcpBackend] Received session update without update field:', params); - return; - } - - const sessionUpdateType = update.sessionUpdate; - - // Log session updates for debugging (but not every chunk to avoid log spam) - if (sessionUpdateType !== 'agent_message_chunk') { - logger.debug(`[AcpBackend] Received session update: ${sessionUpdateType}`, JSON.stringify({ - sessionUpdate: sessionUpdateType, - toolCallId: update.toolCallId, - status: update.status, - kind: update.kind, - hasContent: !!update.content, - hasLocations: !!update.locations, - }, null, 2)); - } - - const ctx = this.createHandlerContext(); - - // Dispatch to appropriate handler based on update type - if (sessionUpdateType === 'agent_message_chunk') { - handleAgentMessageChunk(update as SessionUpdate, ctx); - return; - } - - if (sessionUpdateType === 'tool_call_update') { - const result = handleToolCallUpdate(update as SessionUpdate, ctx); - if (result.toolCallCountSincePrompt !== undefined) { - this.toolCallCountSincePrompt = result.toolCallCountSincePrompt; - } - return; - } - - if (sessionUpdateType === 'agent_thought_chunk') { - handleAgentThoughtChunk(update as SessionUpdate, ctx); - return; - } - - if (sessionUpdateType === 'tool_call') { - handleToolCall(update as SessionUpdate, ctx); - return; - } - - // Handle legacy and auxiliary update types - handleLegacyMessageChunk(update as SessionUpdate, ctx); - handlePlanUpdate(update as SessionUpdate, ctx); - handleThinkingUpdate(update as SessionUpdate, ctx); - - // Log unhandled session update types for debugging - // Cast to string to avoid TypeScript errors (SDK types don't include all Gemini-specific update types) - const updateTypeStr = sessionUpdateType as string; - const handledTypes = ['agent_message_chunk', 'tool_call_update', 'agent_thought_chunk', 'tool_call']; - if (updateTypeStr && - !handledTypes.includes(updateTypeStr) && - !update.messageChunk && - !update.plan && - !update.thinking) { - logger.debug(`[AcpBackend] Unhandled session update type: ${updateTypeStr}`, JSON.stringify(update, null, 2)); - } - } - - // Promise resolver for waitForIdle - set when waiting for response to complete - private idleResolver: (() => void) | null = null; - private waitingForResponse = false; - - async sendPrompt(sessionId: SessionId, prompt: string): Promise { - // Check if prompt contains change_title instruction (via optional callback) - const promptHasChangeTitle = this.options.hasChangeTitleInstruction?.(prompt) ?? false; - - // Reset tool call counter and set flag - this.toolCallCountSincePrompt = 0; - this.recentPromptHadChangeTitle = promptHasChangeTitle; - - if (promptHasChangeTitle) { - logger.debug('[AcpBackend] Prompt contains change_title instruction - will auto-approve first "other" tool call if it matches pattern'); - } - if (this.disposed) { - throw new Error('Backend has been disposed'); - } - - if (!this.connection || !this.acpSessionId) { - throw new Error('Session not started'); - } - - this.emit({ type: 'status', status: 'running' }); - this.waitingForResponse = true; - - try { - logger.debug(`[AcpBackend] Sending prompt (length: ${prompt.length}): ${prompt.substring(0, 100)}...`); - logger.debug(`[AcpBackend] Full prompt: ${prompt}`); - - const contentBlock: ContentBlock = { - type: 'text', - text: prompt, - }; - - const promptRequest: PromptRequest = { - sessionId: this.acpSessionId, - prompt: [contentBlock], - }; - - logger.debug(`[AcpBackend] Prompt request:`, JSON.stringify(promptRequest, null, 2)); - await this.connection.prompt(promptRequest); - logger.debug('[AcpBackend] Prompt request sent to ACP connection'); - - // Don't emit 'idle' here - it will be emitted after all message chunks are received - // The idle timeout in handleSessionUpdate will emit 'idle' after the last chunk - - } catch (error) { - logger.debug('[AcpBackend] Error sending prompt:', error); - this.waitingForResponse = false; - - // Extract error details for better error handling - let errorDetail: string; - if (error instanceof Error) { - errorDetail = error.message; - } else if (typeof error === 'object' && error !== null) { - const errObj = error as Record; - // Try to extract structured error information - const fallbackMessage = (typeof errObj.message === 'string' ? errObj.message : undefined) || String(error); - if (errObj.code !== undefined) { - errorDetail = JSON.stringify({ code: errObj.code, message: fallbackMessage }); - } else if (typeof errObj.message === 'string') { - errorDetail = errObj.message; - } else { - errorDetail = String(error); - } - } else { - errorDetail = String(error); - } - - this.emit({ - type: 'status', - status: 'error', - detail: errorDetail - }); - throw error; - } - } - - /** - * Wait for the response to complete (idle status after all chunks received) - * Call this after sendPrompt to wait for Gemini to finish responding - */ - async waitForResponseComplete(timeoutMs: number = 120000): Promise { - if (!this.waitingForResponse) { - return; // Already completed or no prompt sent - } - - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.idleResolver = null; - this.waitingForResponse = false; - reject(new Error('Timeout waiting for response to complete')); - }, timeoutMs); - - this.idleResolver = () => { - clearTimeout(timeout); - this.idleResolver = null; - this.waitingForResponse = false; - resolve(); - }; - }); - } - - /** - * Helper to emit idle status and resolve any waiting promises - */ - private emitIdleStatus(): void { - this.emit({ type: 'status', status: 'idle' }); - // Resolve any waiting promises - if (this.idleResolver) { - logger.debug('[AcpBackend] Resolving idle waiter'); - this.idleResolver(); - } - } - - async cancel(sessionId: SessionId): Promise { - if (!this.connection || !this.acpSessionId) { - return; - } - - try { - await this.connection.cancel({ sessionId: this.acpSessionId }); - this.emit({ type: 'status', status: 'stopped', detail: 'Cancelled by user' }); - } catch (error) { - // Log to file only, not console - logger.debug('[AcpBackend] Error cancelling:', error); - } - } - - /** - * Emit permission response event for UI/logging purposes. - * - * **IMPORTANT:** For ACP backends, this method does NOT send the actual permission - * response to the agent. The ACP protocol requires synchronous permission handling, - * which is done inside the `requestPermission` RPC handler via `this.options.permissionHandler`. - * - * This method only emits a `permission-response` event for: - * - UI updates (e.g., closing permission dialogs) - * - Logging and debugging - * - Other parts of the CLI that need to react to permission decisions - * - * @param requestId - The ID of the permission request - * @param approved - Whether the permission was granted - */ - async respondToPermission(requestId: string, approved: boolean): Promise { - logger.debug(`[AcpBackend] Permission response event (UI only): ${requestId} = ${approved}`); - this.emit({ type: 'permission-response', id: requestId, approved }); - } - - async dispose(): Promise { - if (this.disposed) return; - - logger.debug('[AcpBackend] Disposing backend'); - this.disposed = true; - - // Try graceful shutdown first - if (this.connection && this.acpSessionId) { - try { - // Send cancel to stop any ongoing work - await Promise.race([ - this.connection.cancel({ sessionId: this.acpSessionId }), - new Promise((resolve) => setTimeout(resolve, 2000)), // 2s timeout for graceful shutdown - ]); - } catch (error) { - logger.debug('[AcpBackend] Error during graceful shutdown:', error); - } - } - - // Kill the process - if (this.process) { - // Try SIGTERM first, then SIGKILL after timeout - this.process.kill('SIGTERM'); - - // Give process 1 second to terminate gracefully - await new Promise((resolve) => { - const timeout = setTimeout(() => { - if (this.process) { - logger.debug('[AcpBackend] Force killing process'); - this.process.kill('SIGKILL'); - } - resolve(); - }, 1000); - - this.process?.once('exit', () => { - clearTimeout(timeout); - resolve(); - }); - }); - - this.process = null; - } - - // Clear timeouts - if (this.idleTimeout) { - clearTimeout(this.idleTimeout); - this.idleTimeout = null; - } - - // Clear state - this.listeners = []; - this.connection = null; - this.acpSessionId = null; - this.activeToolCalls.clear(); - // Clear all tool call timeouts - for (const timeout of this.toolCallTimeouts.values()) { - clearTimeout(timeout); - } - this.toolCallTimeouts.clear(); - this.toolCallStartTimes.clear(); - this.pendingPermissions.clear(); - } -} diff --git a/cli/src/agent/core/index.ts b/cli/src/agent/core/index.ts deleted file mode 100644 index 434764d6a6..0000000000 --- a/cli/src/agent/core/index.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Core Agent Types and Interfaces - * - * Re-exports all core agent abstractions. - * - * @module core - */ - -// ============================================================================ -// AgentBackend - Core interface and types -// ============================================================================ - -export type { - SessionId, - ToolCallId, - AgentMessage, - AgentMessageHandler, - AgentBackend, - AgentBackendConfig, - AcpAgentConfig, - McpServerConfig, - AgentTransport, - AgentId, - StartSessionResult, -} from './AgentBackend'; - -// ============================================================================ -// AgentRegistry - Factory registry -// ============================================================================ - -export { - AgentRegistry, - agentRegistry, -} from './AgentRegistry'; - -export type { - AgentFactory, - AgentFactoryOptions, -} from './AgentRegistry'; - -// ============================================================================ -// AgentMessage - Detailed message types with type guards -// ============================================================================ - -export type { - AgentStatus, - ModelOutputMessage, - StatusMessage, - ToolCallMessage, - ToolResultMessage, - PermissionRequestMessage, - PermissionResponseMessage, - FsEditMessage, - TerminalOutputMessage, - EventMessage, - TokenCountMessage, - ExecApprovalRequestMessage, - PatchApplyBeginMessage, - PatchApplyEndMessage, -} from './AgentMessage'; - -export { - isModelOutputMessage, - isStatusMessage, - isToolCallMessage, - isToolResultMessage, - isPermissionRequestMessage, - getMessageText, -} from './AgentMessage'; diff --git a/cli/src/agent/factories/index.ts b/cli/src/agent/factories/index.ts deleted file mode 100644 index b653073ac7..0000000000 --- a/cli/src/agent/factories/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Agent Factories - * - * Factory functions for creating agent backends with proper configuration. - * Each factory includes the appropriate transport handler for the agent. - * - * @module factories - */ - -// Gemini factory -export { - createGeminiBackend, - registerGeminiAgent, - type GeminiBackendOptions, - type GeminiBackendResult, -} from './gemini'; - -// Future factories: -// export { createCodexBackend, registerCodexAgent, type CodexBackendOptions } from './codex'; -// export { createClaudeBackend, registerClaudeAgent, type ClaudeBackendOptions } from './claude'; -// export { createOpenCodeBackend, registerOpenCodeAgent, type OpenCodeBackendOptions } from './opencode'; diff --git a/cli/src/agent/index.ts b/cli/src/agent/index.ts deleted file mode 100644 index e350ad4f69..0000000000 --- a/cli/src/agent/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Agent Module - Universal agent backend abstraction - * - * This module provides the core abstraction layer for different AI agents - * (Claude, Codex, Gemini, OpenCode, etc.) that can be controlled through - * the Happy CLI and mobile app. - */ - -// Core types, interfaces, and registry - re-export from core/ -export type { - AgentMessage, - AgentMessageHandler, - AgentBackend, - AgentBackendConfig, - AcpAgentConfig, - McpServerConfig, - AgentTransport, - AgentId, - SessionId, - ToolCallId, - StartSessionResult, - AgentFactory, - AgentFactoryOptions, -} from './core'; - -export { AgentRegistry, agentRegistry } from './core'; - -// ACP backend (low-level) -export * from './acp'; - -// Agent factories (high-level, recommended) -export * from './factories'; - -/** - * Initialize all agent backends and register them with the global registry. - * - * Call this function during application startup to make all agents available. - */ -export function initializeAgents(): void { - // Import and register agents from factories - const { registerGeminiAgent } = require('./factories/gemini'); - registerGeminiAgent(); -} - diff --git a/cli/src/agent/transport/handlers/index.ts b/cli/src/agent/transport/handlers/index.ts deleted file mode 100644 index 8c75763db2..0000000000 --- a/cli/src/agent/transport/handlers/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Transport Handler Implementations - * - * Agent-specific transport handlers for different CLI agents. - * - * @module handlers - */ - -export { GeminiTransport, geminiTransport } from './GeminiTransport'; - -// Future handlers: -// export { CodexTransport, codexTransport } from './CodexTransport'; -// export { ClaudeTransport, claudeTransport } from './ClaudeTransport'; -// export { OpenCodeTransport, openCodeTransport } from './OpenCodeTransport'; diff --git a/cli/src/api/api.ts b/cli/src/api/api.ts deleted file mode 100644 index fc38118043..0000000000 --- a/cli/src/api/api.ts +++ /dev/null @@ -1,401 +0,0 @@ -import axios from 'axios' -import { logger } from '@/ui/logger' -import type { AgentState, CreateSessionResponse, Metadata, Session, Machine, MachineMetadata, DaemonState } from '@/api/types' -import { ApiSessionClient } from './apiSession'; -import { ApiMachineClient } from './apiMachine'; -import { decodeBase64, encodeBase64, getRandomBytes, encrypt, decrypt, libsodiumEncryptForPublicKey } from './encryption'; -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 { - - static async create(credential: Credentials) { - return new ApiClient(credential); - } - - private readonly credential: Credentials; - private readonly pushClient: PushNotificationClient; - - private constructor(credential: Credentials) { - this.credential = credential - this.pushClient = new PushNotificationClient(credential.token, configuration.serverUrl) - } - - /** - * Create a new session or load existing one with the given tag - */ - async getOrCreateSession(opts: { - tag: string, - metadata: Metadata, - state: AgentState | null - }): Promise { - - // Resolve encryption key - let dataEncryptionKey: Uint8Array | null = null; - let encryptionKey: Uint8Array; - let encryptionVariant: 'legacy' | 'dataKey'; - if (this.credential.encryption.type === 'dataKey') { - - // Generate new encryption key - encryptionKey = getRandomBytes(32); - encryptionVariant = 'dataKey'; - - // Derive and encrypt data encryption key - // const contentDataKey = await deriveKey(this.secret, 'Happy EnCoder', ['content']); - // const publicKey = libsodiumPublicKeyFromSecretKey(contentDataKey); - let encryptedDataKey = libsodiumEncryptForPublicKey(encryptionKey, this.credential.encryption.publicKey); - dataEncryptionKey = new Uint8Array(encryptedDataKey.length + 1); - dataEncryptionKey.set([0], 0); // Version byte - dataEncryptionKey.set(encryptedDataKey, 1); // Data key - } else { - encryptionKey = this.credential.encryption.secret; - encryptionVariant = 'legacy'; - } - - // Create session - try { - const response = await axios.post( - `${configuration.serverUrl}/v1/sessions`, - { - tag: opts.tag, - metadata: encodeBase64(encrypt(encryptionKey, encryptionVariant, opts.metadata)), - agentState: opts.state ? encodeBase64(encrypt(encryptionKey, encryptionVariant, opts.state)) : null, - dataEncryptionKey: dataEncryptionKey ? encodeBase64(dataEncryptionKey) : null, - }, - { - headers: { - 'Authorization': `Bearer ${this.credential.token}`, - 'Content-Type': 'application/json' - }, - timeout: 60000 // 1 minute timeout for very bad network connections - } - ) - - logger.debug(`Session created/loaded: ${response.data.session.id} (tag: ${opts.tag})`) - let raw = response.data.session; - let session: Session = { - id: raw.id, - seq: raw.seq, - metadata: decrypt(encryptionKey, encryptionVariant, decodeBase64(raw.metadata)), - metadataVersion: raw.metadataVersion, - agentState: raw.agentState ? decrypt(encryptionKey, encryptionVariant, decodeBase64(raw.agentState)) : null, - agentStateVersion: raw.agentStateVersion, - encryptionKey: encryptionKey, - encryptionVariant: encryptionVariant - } - 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'}`); - } - } - - /** - * Register or update machine with the server - * Returns the current machine state from the server with decrypted metadata and daemonState - */ - async getOrCreateMachine(opts: { - machineId: string, - metadata: MachineMetadata, - daemonState?: DaemonState, - }): Promise { - - // Resolve encryption key - let dataEncryptionKey: Uint8Array | null = null; - let encryptionKey: Uint8Array; - let encryptionVariant: 'legacy' | 'dataKey'; - if (this.credential.encryption.type === 'dataKey') { - // Encrypt data encryption key - encryptionVariant = 'dataKey'; - encryptionKey = this.credential.encryption.machineKey; - let encryptedDataKey = libsodiumEncryptForPublicKey(this.credential.encryption.machineKey, this.credential.encryption.publicKey); - dataEncryptionKey = new Uint8Array(encryptedDataKey.length + 1); - dataEncryptionKey.set([0], 0); // Version byte - dataEncryptionKey.set(encryptedDataKey, 1); // Data key - } else { - // Legacy encryption - encryptionKey = this.credential.encryption.secret; - 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 - 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 - }, - { - 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(); - } - - // 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; - - 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(); - } - - // 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 { - return new ApiSessionClient(this.credential.token, session); - } - - machineSyncClient(machine: Machine): ApiMachineClient { - return new ApiMachineClient(this.credential.token, machine); - } - - push(): PushNotificationClient { - return this.pushClient; - } - - /** - * Register a vendor API token with the server - * The token is sent as a JSON string - server handles encryption - */ - async registerVendorToken(vendor: 'openai' | 'anthropic' | 'gemini', apiKey: any): Promise { - try { - const response = await axios.post( - `${configuration.serverUrl}/v1/connect/${vendor}/register`, - { - token: JSON.stringify(apiKey) - }, - { - headers: { - 'Authorization': `Bearer ${this.credential.token}`, - 'Content-Type': 'application/json' - }, - timeout: 5000 - } - ); - - if (response.status !== 200 && response.status !== 201) { - throw new Error(`Server returned status ${response.status}`); - } - - logger.debug(`[API] Vendor token for ${vendor} registered successfully`); - } catch (error) { - logger.debug(`[API] [ERROR] Failed to register vendor token:`, error); - throw new Error(`Failed to register vendor token: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - } - - /** - * Get vendor API token from the server - * Returns the token if it exists, null otherwise - */ - async getVendorToken(vendor: 'openai' | 'anthropic' | 'gemini'): Promise { - try { - const response = await axios.get( - `${configuration.serverUrl}/v1/connect/${vendor}/token`, - { - headers: { - 'Authorization': `Bearer ${this.credential.token}`, - 'Content-Type': 'application/json' - }, - timeout: 5000 - } - ); - - if (response.status === 404) { - logger.debug(`[API] No vendor token found for ${vendor}`); - return null; - } - - if (response.status !== 200) { - throw new Error(`Server returned status ${response.status}`); - } - - // Log raw response for debugging - logger.debug(`[API] Raw vendor token response:`, { - status: response.status, - dataKeys: Object.keys(response.data || {}), - hasToken: 'token' in (response.data || {}), - tokenType: typeof response.data?.token, - }); - - // Token is returned as JSON string, parse it - let tokenData: any = null; - if (response.data?.token) { - if (typeof response.data.token === 'string') { - try { - tokenData = JSON.parse(response.data.token); - } catch (parseError) { - logger.debug(`[API] Failed to parse token as JSON, using as string:`, parseError); - tokenData = response.data.token; - } - } else if (response.data.token !== null) { - // Token exists and is not null - tokenData = response.data.token; - } else { - // Token is explicitly null - treat as not found - logger.debug(`[API] Token is null for ${vendor}, treating as not found`); - return null; - } - } else if (response.data && typeof response.data === 'object') { - // Maybe the token is directly in response.data - // But check if it's { token: null } - treat as not found - if (response.data.token === null && Object.keys(response.data).length === 1) { - logger.debug(`[API] Response contains only null token for ${vendor}, treating as not found`); - return null; - } - tokenData = response.data; - } - - // Final check: if tokenData is null or { token: null }, return null - if (tokenData === null || (tokenData && typeof tokenData === 'object' && tokenData.token === null && Object.keys(tokenData).length === 1)) { - logger.debug(`[API] Token data is null for ${vendor}`); - return null; - } - - logger.debug(`[API] Vendor token for ${vendor} retrieved successfully`, { - tokenDataType: typeof tokenData, - tokenDataKeys: tokenData && typeof tokenData === 'object' ? Object.keys(tokenData) : 'not an object', - }); - return tokenData; - } catch (error: any) { - if (error.response?.status === 404) { - logger.debug(`[API] No vendor token found for ${vendor}`); - return null; - } - logger.debug(`[API] [ERROR] Failed to get vendor token:`, error); - return null; - } - } -} diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts deleted file mode 100644 index b8e2b570d2..0000000000 --- a/cli/src/api/apiMachine.ts +++ /dev/null @@ -1,330 +0,0 @@ -/** - * WebSocket client for machine/daemon communication with Happy server - * Similar to ApiSessionClient but for machine-scoped connections - */ - -import { io, Socket } from 'socket.io-client'; -import { logger } from '@/ui/logger'; -import { configuration } from '@/configuration'; -import { MachineMetadata, DaemonState, Machine, Update, UpdateMachineBody } from './types'; -import { registerCommonHandlers, SpawnSessionOptions, SpawnSessionResult } from '../modules/common/registerCommonHandlers'; -import { encodeBase64, decodeBase64, encrypt, decrypt } from './encryption'; -import { backoff } from '@/utils/time'; -import { RpcHandlerManager } from './rpc/RpcHandlerManager'; - -interface ServerToDaemonEvents { - update: (data: Update) => void; - 'rpc-request': (data: { method: string, params: string }, callback: (response: string) => void) => void; - 'rpc-registered': (data: { method: string }) => void; - 'rpc-unregistered': (data: { method: string }) => void; - 'rpc-error': (data: { type: string, error: string }) => void; - auth: (data: { success: boolean, user: string }) => void; - error: (data: { message: string }) => void; -} - -interface DaemonToServerEvents { - 'machine-alive': (data: { - machineId: string; - time: number; - }) => void; - - 'machine-update-metadata': (data: { - machineId: string; - metadata: string; // Encrypted MachineMetadata - expectedVersion: number - }, cb: (answer: { - result: 'error' - } | { - result: 'version-mismatch' - version: number, - metadata: string - } | { - result: 'success', - version: number, - metadata: string - }) => void) => void; - - 'machine-update-state': (data: { - machineId: string; - daemonState: string; // Encrypted DaemonState - expectedVersion: number - }, cb: (answer: { - result: 'error' - } | { - result: 'version-mismatch' - version: number, - daemonState: string - } | { - result: 'success', - version: number, - daemonState: string - }) => void) => void; - - 'rpc-register': (data: { method: string }) => void; - 'rpc-unregister': (data: { method: string }) => void; - 'rpc-call': (data: { method: string, params: any }, callback: (response: { - ok: boolean - result?: any - error?: string - }) => void) => void; -} - -type MachineRpcHandlers = { - spawnSession: (options: SpawnSessionOptions) => Promise; - stopSession: (sessionId: string) => boolean; - requestShutdown: () => void; -} - -export class ApiMachineClient { - private socket!: Socket; - private keepAliveInterval: NodeJS.Timeout | null = null; - private rpcHandlerManager: RpcHandlerManager; - - constructor( - private token: string, - private machine: Machine - ) { - // Initialize RPC handler manager - this.rpcHandlerManager = new RpcHandlerManager({ - scopePrefix: this.machine.id, - encryptionKey: this.machine.encryptionKey, - encryptionVariant: this.machine.encryptionVariant, - logger: (msg, data) => logger.debug(msg, data) - }); - - registerCommonHandlers(this.rpcHandlerManager, process.cwd()); - } - - setRPCHandlers({ - spawnSession, - stopSession, - requestShutdown - }: MachineRpcHandlers) { - // Register spawn session handler - this.rpcHandlerManager.registerHandler('spawn-happy-session', async (params: any) => { - 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, environmentVariables }); - - switch (result.type) { - case 'success': - logger.debug(`[API MACHINE] Spawned session ${result.sessionId}`); - return { type: 'success', sessionId: result.sessionId }; - - case 'requestToApproveDirectoryCreation': - logger.debug(`[API MACHINE] Requesting directory creation approval for: ${result.directory}`); - return { type: 'requestToApproveDirectoryCreation', directory: result.directory }; - - case 'error': - throw new Error(result.errorMessage); - } - }); - - // Register stop session handler - this.rpcHandlerManager.registerHandler('stop-session', (params: any) => { - const { sessionId } = params || {}; - - if (!sessionId) { - throw new Error('Session ID is required'); - } - - const success = stopSession(sessionId); - if (!success) { - throw new Error('Session not found or failed to stop'); - } - - logger.debug(`[API MACHINE] Stopped session ${sessionId}`); - return { message: 'Session stopped' }; - }); - - // Register stop daemon handler - this.rpcHandlerManager.registerHandler('stop-daemon', () => { - logger.debug('[API MACHINE] Received stop-daemon RPC request'); - - // Trigger shutdown callback after a delay - setTimeout(() => { - logger.debug('[API MACHINE] Initiating daemon shutdown from RPC'); - requestShutdown(); - }, 100); - - return { message: 'Daemon stop request acknowledged, starting shutdown sequence...' }; - }); - } - - /** - * Update machine metadata - * Currently unused, changes from the mobile client are more likely - * for example to set a custom name. - */ - async updateMachineMetadata(handler: (metadata: MachineMetadata | null) => MachineMetadata): Promise { - await backoff(async () => { - const updated = handler(this.machine.metadata); - - const answer = await this.socket.emitWithAck('machine-update-metadata', { - machineId: this.machine.id, - metadata: encodeBase64(encrypt(this.machine.encryptionKey, this.machine.encryptionVariant, updated)), - expectedVersion: this.machine.metadataVersion - }); - - if (answer.result === 'success') { - this.machine.metadata = decrypt(this.machine.encryptionKey, this.machine.encryptionVariant, decodeBase64(answer.metadata)); - this.machine.metadataVersion = answer.version; - logger.debug('[API MACHINE] Metadata updated successfully'); - } else if (answer.result === 'version-mismatch') { - if (answer.version > this.machine.metadataVersion) { - this.machine.metadataVersion = answer.version; - this.machine.metadata = decrypt(this.machine.encryptionKey, this.machine.encryptionVariant, decodeBase64(answer.metadata)); - } - throw new Error('Metadata version mismatch'); // Triggers retry - } - }); - } - - /** - * Update daemon state (runtime info) - similar to session updateAgentState - * Simplified without lock - relies on backoff for retry - */ - async updateDaemonState(handler: (state: DaemonState | null) => DaemonState): Promise { - await backoff(async () => { - const updated = handler(this.machine.daemonState); - - const answer = await this.socket.emitWithAck('machine-update-state', { - machineId: this.machine.id, - daemonState: encodeBase64(encrypt(this.machine.encryptionKey, this.machine.encryptionVariant, updated)), - expectedVersion: this.machine.daemonStateVersion - }); - - if (answer.result === 'success') { - this.machine.daemonState = decrypt(this.machine.encryptionKey, this.machine.encryptionVariant, decodeBase64(answer.daemonState)); - this.machine.daemonStateVersion = answer.version; - logger.debug('[API MACHINE] Daemon state updated successfully'); - } else if (answer.result === 'version-mismatch') { - if (answer.version > this.machine.daemonStateVersion) { - this.machine.daemonStateVersion = answer.version; - this.machine.daemonState = decrypt(this.machine.encryptionKey, this.machine.encryptionVariant, decodeBase64(answer.daemonState)); - } - throw new Error('Daemon state version mismatch'); // Triggers retry - } - }); - } - - connect() { - const serverUrl = configuration.serverUrl.replace(/^http/, 'ws'); - logger.debug(`[API MACHINE] Connecting to ${serverUrl}`); - - this.socket = io(serverUrl, { - transports: ['websocket'], - auth: { - token: this.token, - clientType: 'machine-scoped' as const, - machineId: this.machine.id - }, - path: '/v1/updates', - reconnection: true, - reconnectionDelay: 1000, - reconnectionDelayMax: 5000 - }); - - this.socket.on('connect', () => { - logger.debug('[API MACHINE] Connected to server'); - - // Update daemon state to running - // We need to override previous state because the daemon (this process) - // has restarted with new PID & port - this.updateDaemonState((state) => ({ - ...state, - status: 'running', - pid: process.pid, - httpPort: this.machine.daemonState?.httpPort, - startedAt: Date.now() - })); - - - // Register all handlers - this.rpcHandlerManager.onSocketConnect(this.socket); - - // Start keep-alive - this.startKeepAlive(); - }); - - this.socket.on('disconnect', () => { - logger.debug('[API MACHINE] Disconnected from server'); - this.rpcHandlerManager.onSocketDisconnect(); - this.stopKeepAlive(); - }); - - // Single consolidated RPC handler - this.socket.on('rpc-request', async (data: { method: string, params: string }, callback: (response: string) => void) => { - logger.debugLargeJson(`[API MACHINE] Received RPC request:`, data); - callback(await this.rpcHandlerManager.handleRequest(data)); - }); - - // Handle update events from server - this.socket.on('update', (data: Update) => { - // Machine clients should only care about machine updates - if (data.body.t === 'update-machine' && (data.body as UpdateMachineBody).machineId === this.machine.id) { - // Handle machine metadata or daemon state updates from other clients (e.g., mobile app) - const update = data.body as UpdateMachineBody; - - if (update.metadata) { - logger.debug('[API MACHINE] Received external metadata update'); - this.machine.metadata = decrypt(this.machine.encryptionKey, this.machine.encryptionVariant, decodeBase64(update.metadata.value)); - this.machine.metadataVersion = update.metadata.version; - } - - if (update.daemonState) { - logger.debug('[API MACHINE] Received external daemon state update'); - this.machine.daemonState = decrypt(this.machine.encryptionKey, this.machine.encryptionVariant, decodeBase64(update.daemonState.value)); - this.machine.daemonStateVersion = update.daemonState.version; - } - } else { - logger.debug(`[API MACHINE] Received unknown update type: ${(data.body as any).t}`); - } - }); - - this.socket.on('connect_error', (error) => { - logger.debug(`[API MACHINE] Connection error: ${error.message}`); - }); - - this.socket.io.on('error', (error: any) => { - logger.debug('[API MACHINE] Socket error:', error); - }); - } - - private startKeepAlive() { - this.stopKeepAlive(); - this.keepAliveInterval = setInterval(() => { - const payload = { - machineId: this.machine.id, - time: Date.now() - }; - if (process.env.DEBUG) { // too verbose for production - logger.debugLargeJson(`[API MACHINE] Emitting machine-alive`, payload); - } - this.socket.emit('machine-alive', payload); - }, 20000); - logger.debug('[API MACHINE] Keep-alive started (20s interval)'); - } - - private stopKeepAlive() { - if (this.keepAliveInterval) { - clearInterval(this.keepAliveInterval); - this.keepAliveInterval = null; - logger.debug('[API MACHINE] Keep-alive stopped'); - } - } - - shutdown() { - logger.debug('[API MACHINE] Shutting down'); - this.stopKeepAlive(); - if (this.socket) { - this.socket.close(); - logger.debug('[API MACHINE] Socket closed'); - } - } -} \ No newline at end of file diff --git a/cli/src/api/apiSession.test.ts b/cli/src/api/apiSession.test.ts deleted file mode 100644 index 977e9ee843..0000000000 --- a/cli/src/api/apiSession.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -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/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts deleted file mode 100644 index 187ce5b82b..0000000000 --- a/cli/src/api/apiSession.ts +++ /dev/null @@ -1,461 +0,0 @@ -import { logger } from '@/ui/logger' -import { EventEmitter } from 'node:events' -import { io, Socket } from 'socket.io-client' -import { AgentState, ClientToServerEvents, MessageContent, Metadata, ServerToClientEvents, Session, Update, UserMessage, UserMessageSchema, Usage } from './types' -import { decodeBase64, decrypt, encodeBase64, encrypt } from './encryption'; -import { backoff } from '@/utils/time'; -import { configuration } from '@/configuration'; -import { RawJSONLines } from '@/claude/types'; -import { randomUUID } from 'node:crypto'; -import { AsyncLock } from '@/utils/lock'; -import { RpcHandlerManager } from './rpc/RpcHandlerManager'; -import { registerCommonHandlers } from '../modules/common/registerCommonHandlers'; - -/** - * ACP (Agent Communication Protocol) message data types. - * This is the unified format for all agent messages - CLI adapts each provider's format to ACP. - */ -export type ACPMessageData = - // Core message types - | { type: 'message'; message: string } - | { type: 'reasoning'; message: string } - | { type: 'thinking'; text: string } - // Tool interactions - | { type: 'tool-call'; callId: string; name: string; input: unknown; id: string } - | { type: 'tool-result'; callId: string; output: unknown; id: string; isError?: boolean } - // File operations - | { type: 'file-edit'; description: string; filePath: string; diff?: string; oldContent?: string; newContent?: string; id: string } - // Terminal/command output - | { type: 'terminal-output'; data: string; callId: string } - // Task lifecycle events - | { type: 'task_started'; id: string } - | { type: 'task_complete'; id: string } - | { type: 'turn_aborted'; id: string } - // Permissions - | { type: 'permission-request'; permissionId: string; toolName: string; description: string; options?: unknown } - // Usage/metrics - | { type: 'token_count'; [key: string]: unknown }; - -export type ACPProvider = 'gemini' | 'codex' | 'claude' | 'opencode'; - -export class ApiSessionClient extends EventEmitter { - private readonly token: string; - readonly sessionId: string; - private metadata: Metadata | null; - private metadataVersion: number; - private agentState: AgentState | null; - private agentStateVersion: number; - private socket: Socket; - private pendingMessages: UserMessage[] = []; - private pendingMessageCallback: ((message: UserMessage) => void) | null = null; - readonly rpcHandlerManager: RpcHandlerManager; - private agentStateLock = new AsyncLock(); - private metadataLock = new AsyncLock(); - private encryptionKey: Uint8Array; - private encryptionVariant: 'legacy' | 'dataKey'; - - constructor(token: string, session: Session) { - super() - this.token = token; - this.sessionId = session.id; - this.metadata = session.metadata; - this.metadataVersion = session.metadataVersion; - this.agentState = session.agentState; - this.agentStateVersion = session.agentStateVersion; - this.encryptionKey = session.encryptionKey; - this.encryptionVariant = session.encryptionVariant; - - // Initialize RPC handler manager - this.rpcHandlerManager = new RpcHandlerManager({ - scopePrefix: this.sessionId, - encryptionKey: this.encryptionKey, - encryptionVariant: this.encryptionVariant, - logger: (msg, data) => logger.debug(msg, data) - }); - registerCommonHandlers(this.rpcHandlerManager, this.metadata.path); - - // - // Create socket - // - - this.socket = io(configuration.serverUrl, { - auth: { - token: this.token, - clientType: 'session-scoped' as const, - sessionId: this.sessionId - }, - path: '/v1/updates', - reconnection: true, - reconnectionAttempts: Infinity, - reconnectionDelay: 1000, - reconnectionDelayMax: 5000, - transports: ['websocket'], - withCredentials: true, - autoConnect: false - }); - - // - // Handlers - // - - this.socket.on('connect', () => { - logger.debug('Socket connected successfully'); - this.rpcHandlerManager.onSocketConnect(this.socket); - }) - - // Set up global RPC request handler - this.socket.on('rpc-request', async (data: { method: string, params: string }, callback: (response: string) => void) => { - callback(await this.rpcHandlerManager.handleRequest(data)); - }) - - this.socket.on('disconnect', (reason) => { - logger.debug('[API] Socket disconnected:', reason); - this.rpcHandlerManager.onSocketDisconnect(); - }) - - this.socket.on('connect_error', (error) => { - logger.debug('[API] Socket connection error:', error); - this.rpcHandlerManager.onSocketDisconnect(); - }) - - // Server events - this.socket.on('update', (data: Update) => { - try { - logger.debugLargeJson('[SOCKET] [UPDATE] Received update:', data); - - if (!data.body) { - logger.debug('[SOCKET] [UPDATE] [ERROR] No body in update!'); - return; - } - - if (data.body.t === 'new-message' && data.body.message.content.t === 'encrypted') { - const body = decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(data.body.message.content.c)); - - logger.debugLargeJson('[SOCKET] [UPDATE] Received update:', body) - - // Try to parse as user message first - const userResult = UserMessageSchema.safeParse(body); - if (userResult.success) { - // Server already filtered to only our session - if (this.pendingMessageCallback) { - this.pendingMessageCallback(userResult.data); - } else { - this.pendingMessages.push(userResult.data); - } - } else { - // If not a user message, it might be a permission response or other message type - this.emit('message', body); - } - } else if (data.body.t === 'update-session') { - if (data.body.metadata && data.body.metadata.version > this.metadataVersion) { - this.metadata = decrypt(this.encryptionKey, this.encryptionVariant,decodeBase64(data.body.metadata.value)); - this.metadataVersion = data.body.metadata.version; - } - if (data.body.agentState && data.body.agentState.version > this.agentStateVersion) { - this.agentState = data.body.agentState.value ? decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(data.body.agentState.value)) : null; - this.agentStateVersion = data.body.agentState.version; - } - } else if (data.body.t === 'update-machine') { - // Session clients shouldn't receive machine updates - log warning - logger.debug(`[SOCKET] WARNING: Session client received unexpected machine update - ignoring`); - } else { - // If not a user message, it might be a permission response or other message type - this.emit('message', data.body); - } - } catch (error) { - logger.debug('[SOCKET] [UPDATE] [ERROR] Error handling update', { error }); - } - }); - - // DEATH - this.socket.on('error', (error) => { - logger.debug('[API] Socket error:', error); - }); - - // - // Connect (after short delay to give a time to add handlers) - // - - this.socket.connect(); - } - - onUserMessage(callback: (data: UserMessage) => void) { - this.pendingMessageCallback = callback; - while (this.pendingMessages.length > 0) { - callback(this.pendingMessages.shift()!); - } - } - - /** - * Send message to session - * @param body - Message body (can be MessageContent or raw content for agent messages) - */ - sendClaudeSessionMessage(body: RawJSONLines) { - let content: MessageContent; - - // Check if body is already a MessageContent (has role property) - if (body.type === 'user' && typeof body.message.content === 'string' && body.isSidechain !== true && body.isMeta !== true) { - content = { - role: 'user', - content: { - type: 'text', - text: body.message.content - }, - meta: { - sentFrom: 'cli' - } - } - } else { - // Wrap Claude messages in the expected format - content = { - role: 'agent', - content: { - type: 'output', - data: body // This wraps the entire Claude message - }, - meta: { - sentFrom: 'cli' - } - }; - } - - 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, - message: encrypted - }); - - // Track usage from assistant messages - if (body.type === 'assistant' && body.message?.usage) { - try { - this.sendUsageData(body.message.usage); - } catch (error) { - logger.debug('[SOCKET] Failed to send usage data:', error); - } - } - - // Update metadata with summary if this is a summary message - if (body.type === 'summary' && 'summary' in body && 'leafUuid' in body) { - this.updateMetadata((metadata) => ({ - ...metadata, - summary: { - text: body.summary, - updatedAt: Date.now() - } - })); - } - } - - sendCodexMessage(body: any) { - let content = { - role: 'agent', - content: { - type: 'codex', - data: body // This wraps the entire Claude message - }, - meta: { - sentFrom: 'cli' - } - }; - const encrypted = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, content)); - - // Check if socket is connected before sending - if (!this.socket.connected) { - logger.debug('[API] Socket not connected, cannot send message. Message will be lost:', { type: body.type }); - // TODO: Consider implementing message queue or HTTP fallback for reliability - } - - this.socket.emit('message', { - sid: this.sessionId, - message: encrypted - }); - } - - /** - * Send a generic agent message to the session using ACP (Agent Communication Protocol) format. - * Works for any agent type (Gemini, Codex, Claude, etc.) - CLI normalizes to unified ACP format. - * - * @param provider - The agent provider sending the message (e.g., 'gemini', 'codex', 'claude') - * @param body - The message payload (type: 'message' | 'reasoning' | 'tool-call' | 'tool-result') - */ - sendAgentMessage(provider: 'gemini' | 'codex' | 'claude' | 'opencode', body: ACPMessageData) { - let content = { - role: 'agent', - content: { - type: 'acp', - provider, - data: body - }, - meta: { - sentFrom: 'cli' - } - }; - - logger.debug(`[SOCKET] Sending ACP message from ${provider}:`, { type: body.type, hasMessage: 'message' in body }); - - const encrypted = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, content)); - this.socket.emit('message', { - sid: this.sessionId, - message: encrypted - }); - } - - sendSessionEvent(event: { - type: 'switch', mode: 'local' | 'remote' - } | { - type: 'message', message: string - } | { - type: 'permission-mode-changed', mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' - } | { - type: 'ready' - }, id?: string) { - let content = { - role: 'agent', - content: { - id: id ?? randomUUID(), - type: 'event', - data: event - } - }; - const encrypted = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, content)); - this.socket.emit('message', { - sid: this.sessionId, - message: encrypted - }); - } - - /** - * Send a ping message to keep the connection alive - */ - keepAlive(thinking: boolean, mode: 'local' | 'remote') { - if (process.env.DEBUG) { // too verbose for production - logger.debug(`[API] Sending keep alive message: ${thinking}`); - } - this.socket.volatile.emit('session-alive', { - sid: this.sessionId, - time: Date.now(), - thinking, - mode - }); - } - - /** - * Send session death message - */ - sendSessionDeath() { - this.socket.emit('session-end', { sid: this.sessionId, time: Date.now() }); - } - - /** - * Send usage data to the server - */ - sendUsageData(usage: Usage) { - // Calculate total tokens - const totalTokens = usage.input_tokens + usage.output_tokens + (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0); - - // Transform Claude usage format to backend expected format - const usageReport = { - key: 'claude-session', - sessionId: this.sessionId, - tokens: { - total: totalTokens, - input: usage.input_tokens, - output: usage.output_tokens, - cache_creation: usage.cache_creation_input_tokens || 0, - cache_read: usage.cache_read_input_tokens || 0 - }, - cost: { - // TODO: Calculate actual costs based on pricing - // For now, using placeholder values - total: 0, - input: 0, - output: 0 - } - } - logger.debugLargeJson('[SOCKET] Sending usage data:', usageReport) - this.socket.emit('usage-report', usageReport); - } - - /** - * Update session metadata - * @param handler - Handler function that returns the updated metadata - */ - updateMetadata(handler: (metadata: Metadata) => Metadata) { - this.metadataLock.inLock(async () => { - await backoff(async () => { - let updated = handler(this.metadata!); // Weird state if metadata is null - should never happen but here we are - const answer = await this.socket.emitWithAck('update-metadata', { sid: this.sessionId, expectedVersion: this.metadataVersion, metadata: encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, updated)) }); - if (answer.result === 'success') { - this.metadata = decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(answer.metadata)); - this.metadataVersion = answer.version; - } else if (answer.result === 'version-mismatch') { - if (answer.version > this.metadataVersion) { - this.metadataVersion = answer.version; - this.metadata = decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(answer.metadata)); - } - throw new Error('Metadata version mismatch'); - } else if (answer.result === 'error') { - // Hard error - ignore - } - }); - }); - } - - /** - * Update session agent state - * @param handler - Handler function that returns the updated agent state - */ - updateAgentState(handler: (metadata: AgentState) => AgentState) { - logger.debugLargeJson('Updating agent state', this.agentState); - this.agentStateLock.inLock(async () => { - await backoff(async () => { - let updated = handler(this.agentState || {}); - const answer = await this.socket.emitWithAck('update-state', { sid: this.sessionId, expectedVersion: this.agentStateVersion, agentState: updated ? encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, updated)) : null }); - if (answer.result === 'success') { - this.agentState = answer.agentState ? decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(answer.agentState)) : null; - this.agentStateVersion = answer.version; - logger.debug('Agent state updated', this.agentState); - } else if (answer.result === 'version-mismatch') { - if (answer.version > this.agentStateVersion) { - this.agentStateVersion = answer.version; - this.agentState = answer.agentState ? decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(answer.agentState)) : null; - } - throw new Error('Agent state version mismatch'); - } else if (answer.result === 'error') { - // console.error('Agent state update error', answer); - // Hard error - ignore - } - }); - }); - } - - /** - * Wait for socket buffer to flush - */ - async flush(): Promise { - if (!this.socket.connected) { - return; - } - return new Promise((resolve) => { - this.socket.emit('ping', () => { - resolve(); - }); - setTimeout(() => { - resolve(); - }, 10000); - }); - } - - async close() { - logger.debug('[API] socket.close() called'); - this.socket.close(); - } -} diff --git a/cli/src/api/auth.ts b/cli/src/api/auth.ts deleted file mode 100644 index 642cb8a259..0000000000 --- a/cli/src/api/auth.ts +++ /dev/null @@ -1,43 +0,0 @@ -import axios from 'axios'; -import { encodeBase64, encodeBase64Url, authChallenge } from './encryption'; -import { configuration } from '@/configuration'; - -/** - * Note: This function is deprecated. Use readPrivateKey/writePrivateKey from persistence module instead. - * Kept for backward compatibility only. - */ -export async function getOrCreateSecretKey(): Promise { - throw new Error('getOrCreateSecretKey is deprecated. Use readPrivateKey/writePrivateKey from persistence module.'); -} - -/** - * Authenticate with the server and obtain an auth token - * @param serverUrl - The URL of the server to authenticate with - * @param secret - The secret key to use for authentication - * @returns The authentication token - */ -export async function authGetToken(secret: Uint8Array): Promise { - const { challenge, publicKey, signature } = authChallenge(secret); - - const response = await axios.post(`${configuration.serverUrl}/v1/auth`, { - challenge: encodeBase64(challenge), - publicKey: encodeBase64(publicKey), - signature: encodeBase64(signature) - }); - - if (!response.data.success || !response.data.token) { - throw new Error('Authentication failed'); - } - - return response.data.token; -} - -/** - * Generate a URL for the mobile app to connect to the server - * @param secret - The secret key to use for authentication - * @returns The URL for the mobile app to connect to the server - */ -export function generateAppUrl(secret: Uint8Array): string { - const secretBase64Url = encodeBase64Url(secret); - return `handy://${secretBase64Url}`; -} \ No newline at end of file diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts deleted file mode 100644 index e1b4878d52..0000000000 --- a/cli/src/api/types.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { z } from 'zod' -import { UsageSchema } from '@/claude/types' - -/** - * 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 - */ -export type Usage = z.infer - -/** - * Base message content structure for encrypted messages - */ -export const SessionMessageContentSchema = z.object({ - c: z.string(), // Base64 encoded encrypted content - t: z.literal('encrypted') -}) - -export type SessionMessageContent = z.infer - -/** - * Update body for new messages - */ -export const UpdateBodySchema = z.object({ - message: z.object({ - id: z.string(), - seq: z.number(), - content: SessionMessageContentSchema - }), - sid: z.string(), // Session ID - t: z.literal('new-message') -}) - -export type UpdateBody = z.infer - -export const UpdateSessionBodySchema = z.object({ - t: z.literal('update-session'), - sid: z.string(), - metadata: z.object({ - version: z.number(), - value: z.string() - }).nullish(), - agentState: z.object({ - version: z.number(), - value: z.string() - }).nullish() -}) - -export type UpdateSessionBody = z.infer - -/** - * Update body for machine updates - */ -export const UpdateMachineBodySchema = z.object({ - t: z.literal('update-machine'), - machineId: z.string(), - metadata: z.object({ - version: z.number(), - value: z.string() - }).nullish(), - daemonState: z.object({ - version: z.number(), - value: z.string() - }).nullish() -}) - -export type UpdateMachineBody = z.infer - -/** - * Update event from server - */ -export const UpdateSchema = z.object({ - id: z.string(), - seq: z.number(), - body: z.union([ - UpdateBodySchema, - UpdateSessionBodySchema, - UpdateMachineBodySchema, - ]), - createdAt: z.number() -}) - -export type Update = z.infer - -/** - * Socket events from server to client - */ -export interface ServerToClientEvents { - update: (data: Update) => void - 'rpc-request': (data: { method: string, params: string }, callback: (response: string) => void) => void - 'rpc-registered': (data: { method: string }) => void - 'rpc-unregistered': (data: { method: string }) => void - 'rpc-error': (data: { type: string, error: string }) => void - ephemeral: (data: { type: 'activity', id: string, active: boolean, activeAt: number, thinking: boolean }) => void - auth: (data: { success: boolean, user: string }) => void - error: (data: { message: string }) => void -} - - -/** - * Socket events from client to server - */ -export interface ClientToServerEvents { - message: (data: { sid: string, message: any }) => void - 'session-alive': (data: { - sid: string; - time: number; - thinking: boolean; - mode?: 'local' | 'remote'; - }) => void - 'session-end': (data: { sid: string, time: number }) => void, - 'update-metadata': (data: { sid: string, expectedVersion: number, metadata: string }, cb: (answer: { - result: 'error' - } | { - result: 'version-mismatch' - version: number, - metadata: string - } | { - result: 'success', - version: number, - metadata: string - }) => void) => void, - 'update-state': (data: { sid: string, expectedVersion: number, agentState: string | null }, cb: (answer: { - result: 'error' - } | { - result: 'version-mismatch' - version: number, - agentState: string | null - } | { - result: 'success', - version: number, - agentState: string | null - }) => void) => void, - 'ping': (callback: () => void) => void - 'rpc-register': (data: { method: string }) => void - 'rpc-unregister': (data: { method: string }) => void - 'rpc-call': (data: { method: string, params: string }, callback: (response: { - ok: boolean - result?: string - error?: string - }) => void) => void - 'usage-report': (data: { - key: string - sessionId: string - tokens: { - total: number - [key: string]: number - } - cost: { - total: number - [key: string]: number - } - }) => void -} - -/** - * Session information - */ -export type Session = { - id: string, - seq: number, - encryptionKey: Uint8Array; - encryptionVariant: 'legacy' | 'dataKey'; - metadata: Metadata, - metadataVersion: number, - agentState: AgentState | null, - agentStateVersion: number, -} - -/** - * Machine metadata - static information (rarely changes) - */ -export const MachineMetadataSchema = z.object({ - host: z.string(), - platform: z.string(), - happyCliVersion: z.string(), - homeDir: z.string(), - happyHomeDir: z.string(), - happyLibDir: z.string() -}) - -export type MachineMetadata = z.infer - -/** - * Daemon state - dynamic runtime information (frequently updated) - */ -export const DaemonStateSchema = z.object({ - status: z.union([ - z.enum(['running', 'shutting-down']), - z.string() // Forward compatibility - ]), - pid: z.number().optional(), - httpPort: z.number().optional(), - startedAt: z.number().optional(), - shutdownRequestedAt: z.number().optional(), - shutdownSource: - z.union([ - z.enum(['mobile-app', 'cli', 'os-signal', 'unknown']), - z.string() // Forward compatibility - ]).optional() -}) - -export type DaemonState = z.infer - -export type Machine = { - id: string, - encryptionKey: Uint8Array; - encryptionVariant: 'legacy' | 'dataKey'; - metadata: MachineMetadata, - metadataVersion: number, - daemonState: DaemonState | null, - daemonStateVersion: number, -} - -/** - * Session message from API - */ -export const SessionMessageSchema = z.object({ - content: SessionMessageContentSchema, - createdAt: z.number(), - id: z.string(), - seq: z.number(), - updatedAt: z.number() -}) - -export type SessionMessage = z.infer - -/** - * Message metadata schema - */ -export const MessageMetaSchema = z.object({ - sentFrom: z.string().optional(), // Source identifier - 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) - appendSystemPrompt: z.string().nullable().optional(), // Append to system prompt for this message (null = reset) - allowedTools: z.array(z.string()).nullable().optional(), // Allowed tools for this message (null = reset) - disallowedTools: z.array(z.string()).nullable().optional() // Disallowed tools for this message (null = reset) -}) - -export type MessageMeta = z.infer - -/** - * API response types - */ -export const CreateSessionResponseSchema = z.object({ - session: z.object({ - id: z.string(), - tag: z.string(), - seq: z.number(), - createdAt: z.number(), - updatedAt: z.number(), - metadata: z.string(), - metadataVersion: z.number(), - agentState: z.string().nullable(), - agentStateVersion: z.number() - }) -}) - -export type CreateSessionResponse = z.infer - -export const UserMessageSchema = z.object({ - role: z.literal('user'), - content: z.object({ - type: z.literal('text'), - text: z.string() - }), - localKey: z.string().optional(), // Mobile messages include this - meta: MessageMetaSchema.optional() -}) - -export type UserMessage = z.infer - -export const AgentMessageSchema = z.object({ - role: z.literal('agent'), - content: z.object({ - type: z.literal('output'), - data: z.any() - }), - meta: MessageMetaSchema.optional() -}) - -export type AgentMessage = z.infer - -export const MessageContentSchema = z.union([UserMessageSchema, AgentMessageSchema]) - -export type MessageContent = z.infer - -export type Metadata = { - path: string, - host: string, - version?: string, - name?: string, - os?: string, - summary?: { - text: string, - updatedAt: number - }, - machineId?: string, - claudeSessionId?: string, // Claude Code session ID - tools?: string[], - slashCommands?: string[], - homeDir: string, - happyHomeDir: string, - happyLibDir: string, - happyToolsDir: string, - startedFromDaemon?: boolean, - hostPid?: number, - startedBy?: 'daemon' | 'terminal', - // Lifecycle state management - lifecycleState?: 'running' | 'archiveRequested' | 'archived' | string, - lifecycleStateSince?: number, - archivedBy?: string, - archiveReason?: string, - flavor?: string -}; - -export type AgentState = { - controlledByUser?: boolean | null | undefined - requests?: { - [id: string]: { - tool: string, - arguments: any, - createdAt: number - } - } - completedRequests?: { - [id: string]: { - tool: string, - arguments: any, - createdAt: number, - completedAt: number, - status: 'canceled' | 'denied' | 'approved', - reason?: string, - mode?: PermissionMode, - decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort', - allowTools?: string[] - } - } -} diff --git a/cli/src/claude/claudeRemote.ts b/cli/src/claude/claudeRemote.ts deleted file mode 100644 index d93215c8cf..0000000000 --- a/cli/src/claude/claudeRemote.ts +++ /dev/null @@ -1,242 +0,0 @@ -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"; -import { parseSpecialCommand } from "@/parsers/specialCommands"; -import { logger } from "@/lib"; -import { PushableAsyncIterable } from "@/utils/PushableAsyncIterable"; -import { getProjectPath } from "./utils/path"; -import { awaitFileExist } from "@/modules/watcher/awaitFileExist"; -import { systemPrompt } from "./utils/systemPrompt"; -import { PermissionResult } from "./sdk/types"; -import type { JsRuntime } from "./runClaude"; - -export async function claudeRemote(opts: { - - // Fixed parameters - sessionId: string | null, - path: string, - mcpServers?: Record, - claudeEnvVars?: Record, - claudeArgs?: string[], - allowedTools: string[], - signal?: AbortSignal, - canCallTool: (toolName: string, input: unknown, mode: EnhancedMode, options: { signal: AbortSignal }) => Promise, - /** Path to temporary settings file with SessionStart hook (required for session tracking) */ - hookSettingsPath: string, - /** JavaScript runtime to use for spawning Claude Code (default: 'node') */ - jsRuntime?: JsRuntime, - - // Dynamic parameters - nextMessage: () => Promise<{ message: string, mode: EnhancedMode } | null>, - onReady: () => void, - isAborted: (toolCallId: string) => boolean, - - // Callbacks - onSessionFound: (id: string) => void, - onThinkingChange?: (thinking: boolean) => void, - onMessage: (message: SDKMessage) => void, - onCompletionEvent?: (message: string) => void, - onSessionReset?: () => void -}) { - - // Check if session is valid - let startFrom = opts.sessionId; - if (opts.sessionId && !claudeCheckSession(opts.sessionId, opts.path)) { - startFrom = null; - } - - // Extract --resume from claudeArgs if present (for first spawn) - if (!startFrom && opts.claudeArgs) { - for (let i = 0; i < opts.claudeArgs.length; i++) { - if (opts.claudeArgs[i] === '--resume') { - // Check if next arg exists and looks like a session ID - if (i + 1 < opts.claudeArgs.length) { - const nextArg = opts.claudeArgs[i + 1]; - // If next arg doesn't start with dash and contains dashes, it's likely a UUID - if (!nextArg.startsWith('-') && nextArg.includes('-')) { - startFrom = nextArg; - logger.debug(`[claudeRemote] Found --resume with session ID: ${startFrom}`); - break; - } else { - // Just --resume without UUID - SDK doesn't support this - logger.debug('[claudeRemote] Found --resume without session ID - not supported in remote mode'); - break; - } - } else { - // --resume at end of args - SDK doesn't support this - logger.debug('[claudeRemote] Found --resume without session ID - not supported in remote mode'); - break; - } - } - } - } - - // Set environment variables for Claude Code SDK - if (opts.claudeEnvVars) { - Object.entries(opts.claudeEnvVars).forEach(([key, value]) => { - process.env[key] = value; - }); - } - - // Get initial message - const initial = await opts.nextMessage(); - if (!initial) { // No initial message - exit - return; - } - - // Handle special commands - const specialCommand = parseSpecialCommand(initial.message); - - // Handle /clear command - if (specialCommand.type === 'clear') { - if (opts.onCompletionEvent) { - opts.onCompletionEvent('Context was reset'); - } - if (opts.onSessionReset) { - opts.onSessionReset(); - } - return; - } - - // Handle /compact command - let isCompactCommand = false; - if (specialCommand.type === 'compact') { - logger.debug('[claudeRemote] /compact command detected - will process as normal but with compaction behavior'); - isCompactCommand = true; - if (opts.onCompletionEvent) { - opts.onCompletionEvent('Compaction started'); - } - } - - // Prepare SDK options - let mode = initial.mode; - const sdkOptions: QueryOptions = { - cwd: opts.path, - resume: startFrom ?? undefined, - mcpServers: opts.mcpServers, - permissionMode: mapToClaudeMode(initial.mode.permissionMode), - model: initial.mode.model, - fallbackModel: initial.mode.fallbackModel, - customSystemPrompt: initial.mode.customSystemPrompt ? initial.mode.customSystemPrompt + '\n\n' + systemPrompt : undefined, - appendSystemPrompt: initial.mode.appendSystemPrompt ? initial.mode.appendSystemPrompt + '\n\n' + systemPrompt : systemPrompt, - allowedTools: initial.mode.allowedTools ? initial.mode.allowedTools.concat(opts.allowedTools) : opts.allowedTools, - disallowedTools: initial.mode.disallowedTools, - canCallTool: (toolName: string, input: unknown, options: { signal: AbortSignal }) => opts.canCallTool(toolName, input, mode, options), - executable: opts.jsRuntime ?? 'node', - abort: opts.signal, - pathToClaudeCodeExecutable: (() => { - return resolve(join(projectPath(), 'scripts', 'claude_remote_launcher.cjs')); - })(), - settingsPath: opts.hookSettingsPath, - } - - // Track thinking state - let thinking = false; - const updateThinking = (newThinking: boolean) => { - if (thinking !== newThinking) { - thinking = newThinking; - logger.debug(`[claudeRemote] Thinking state changed to: ${thinking}`); - if (opts.onThinkingChange) { - opts.onThinkingChange(thinking); - } - } - }; - - // Push initial message - let messages = new PushableAsyncIterable(); - messages.push({ - type: 'user', - message: { - role: 'user', - content: initial.message, - }, - }); - - // Start the loop - const response = query({ - prompt: messages, - options: sdkOptions, - }); - - updateThinking(true); - try { - logger.debug(`[claudeRemote] Starting to iterate over response`); - - for await (const message of response) { - logger.debugLargeJson(`[claudeRemote] Message ${message.type}`, message); - - // Handle messages - opts.onMessage(message); - - // Handle special system messages - if (message.type === 'system' && message.subtype === 'init') { - // Start thinking when session initializes - updateThinking(true); - - const systemInit = message as SDKSystemMessage; - - // Session id is still in memory, wait until session file is written to disk - // Start a watcher for to detect the session id - if (systemInit.session_id) { - logger.debug(`[claudeRemote] Waiting for session file to be written to disk: ${systemInit.session_id}`); - const projectDir = getProjectPath(opts.path); - const found = await awaitFileExist(join(projectDir, `${systemInit.session_id}.jsonl`)); - logger.debug(`[claudeRemote] Session file found: ${systemInit.session_id} ${found}`); - opts.onSessionFound(systemInit.session_id); - } - } - - // Handle result messages - if (message.type === 'result') { - updateThinking(false); - logger.debug('[claudeRemote] Result received, exiting claudeRemote'); - - // Send completion messages - if (isCompactCommand) { - logger.debug('[claudeRemote] Compaction completed'); - if (opts.onCompletionEvent) { - opts.onCompletionEvent('Compaction completed'); - } - isCompactCommand = false; - } - - // Send ready event - opts.onReady(); - - // Push next message - const next = await opts.nextMessage(); - if (!next) { - messages.end(); - return; - } - mode = next.mode; - messages.push({ type: 'user', message: { role: 'user', content: next.message } }); - } - - // Handle tool result - if (message.type === 'user') { - const msg = message as SDKUserMessage; - if (msg.message.role === 'user' && Array.isArray(msg.message.content)) { - for (let c of msg.message.content) { - if (c.type === 'tool_result' && c.tool_use_id && opts.isAborted(c.tool_use_id)) { - logger.debug('[claudeRemote] Tool aborted, exiting claudeRemote'); - return; - } - } - } - } - } - } catch (e) { - if (e instanceof AbortError) { - logger.debug(`[claudeRemote] Aborted`); - // Ignore - } else { - throw e; - } - } finally { - updateThinking(false); - } -} \ No newline at end of file diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts deleted file mode 100644 index 81e6454ab9..0000000000 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ /dev/null @@ -1,461 +0,0 @@ -import { render } from "ink"; -import { Session } from "./session"; -import { MessageBuffer } from "@/ui/ink/messageBuffer"; -import { RemoteModeDisplay } from "@/ui/ink/RemoteModeDisplay"; -import React from "react"; -import { claudeRemote } from "./claudeRemote"; -import { PermissionHandler } from "./utils/permissionHandler"; -import { Future } from "@/utils/future"; -import { SDKAssistantMessage, SDKMessage, SDKUserMessage } from "./sdk"; -import { formatClaudeMessageForInk } from "@/ui/messageFormatterInk"; -import { logger } from "@/ui/logger"; -import { SDKToLogConverter } from "./utils/sdkToLogConverter"; -import { PLAN_FAKE_REJECT } from "./sdk/prompts"; -import { EnhancedMode } from "./loop"; -import { RawJSONLines } from "@/claude/types"; -import { OutgoingMessageQueue } from "./utils/OutgoingMessageQueue"; -import { getToolName } from "./utils/getToolName"; - -interface PermissionsField { - date: number; - result: 'approved' | 'denied'; - mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'; - allowedTools?: string[]; -} - -export async function claudeRemoteLauncher(session: Session): Promise<'switch' | 'exit'> { - logger.debug('[claudeRemoteLauncher] Starting remote launcher'); - - // Check if we have a TTY for UI rendering - const hasTTY = process.stdout.isTTY && process.stdin.isTTY; - logger.debug(`[claudeRemoteLauncher] TTY available: ${hasTTY}`); - - // Configure terminal - let messageBuffer = new MessageBuffer(); - let inkInstance: any = null; - - if (hasTTY) { - console.clear(); - inkInstance = render(React.createElement(RemoteModeDisplay, { - messageBuffer, - logPath: process.env.DEBUG ? session.logPath : undefined, - onExit: async () => { - // Exit the entire client - logger.debug('[remote]: Exiting client via Ctrl-C'); - if (!exitReason) { - exitReason = 'exit'; - } - await abort(); - }, - onSwitchToLocal: () => { - // Switch to local mode - logger.debug('[remote]: Switching to local mode via double space'); - doSwitch(); - } - }), { - exitOnCtrlC: false, - patchConsole: false - }); - } - - if (hasTTY) { - process.stdin.resume(); - if (process.stdin.isTTY) { - process.stdin.setRawMode(true); - } - process.stdin.setEncoding("utf8"); - } - - // Handle abort - let exitReason: 'switch' | 'exit' | null = null; - let abortController: AbortController | null = null; - let abortFuture: Future | null = null; - - async function abort() { - if (abortController && !abortController.signal.aborted) { - abortController.abort(); - } - await abortFuture?.promise; - } - - async function doAbort() { - logger.debug('[remote]: doAbort'); - await abort(); - } - - async function doSwitch() { - logger.debug('[remote]: doSwitch'); - if (!exitReason) { - exitReason = 'switch'; - } - await abort(); - } - - // When to abort - session.client.rpcHandlerManager.registerHandler('abort', doAbort); // When abort clicked - session.client.rpcHandlerManager.registerHandler('switch', doSwitch); // When switch clicked - // Removed catch-all stdin handler - now handled by RemoteModeDisplay keyboard handlers - - // Create permission handler - const permissionHandler = new PermissionHandler(session); - - // Create outgoing message queue - const messageQueue = new OutgoingMessageQueue( - (logMessage) => session.client.sendClaudeSessionMessage(logMessage) - ); - - // Set up callback to release delayed messages when permission is requested - permissionHandler.setOnPermissionRequest((toolCallId: string) => { - messageQueue.releaseToolCall(toolCallId); - }); - - // Create SDK to Log converter (pass responses from permissions) - const sdkToLogConverter = new SDKToLogConverter({ - sessionId: session.sessionId || 'unknown', - cwd: session.path, - version: process.env.npm_package_version - }, permissionHandler.getResponses()); - - - // Handle messages - let planModeToolCalls = new Set(); - let ongoingToolCalls = new Map(); - - function onMessage(message: SDKMessage) { - - // Write to message log - formatClaudeMessageForInk(message, messageBuffer); - - // Write to permission handler for tool id resolving - permissionHandler.onMessage(message); - - // Detect plan mode tool call - if (message.type === 'assistant') { - let umessage = message as SDKAssistantMessage; - if (umessage.message.content && Array.isArray(umessage.message.content)) { - for (let c of umessage.message.content) { - if (c.type === 'tool_use' && (c.name === 'exit_plan_mode' || c.name === 'ExitPlanMode')) { - logger.debug('[remote]: detected plan mode tool call ' + c.id!); - planModeToolCalls.add(c.id! as string); - } - } - } - } - - // Track active tool calls - if (message.type === 'assistant') { - let umessage = message as SDKAssistantMessage; - if (umessage.message.content && Array.isArray(umessage.message.content)) { - for (let c of umessage.message.content) { - if (c.type === 'tool_use') { - logger.debug('[remote]: detected tool use ' + c.id! + ' parent: ' + umessage.parent_tool_use_id); - ongoingToolCalls.set(c.id!, { parentToolCallId: umessage.parent_tool_use_id ?? null }); - } - } - } - } - if (message.type === 'user') { - let umessage = message as SDKUserMessage; - if (umessage.message.content && Array.isArray(umessage.message.content)) { - for (let c of umessage.message.content) { - if (c.type === 'tool_result' && c.tool_use_id) { - ongoingToolCalls.delete(c.tool_use_id); - - // When tool result received, release any delayed messages for this tool call - messageQueue.releaseToolCall(c.tool_use_id); - } - } - } - } - - // Convert SDK message to log format and send to client - let msg = message; - - // Hack plan mode exit - if (message.type === 'user') { - let umessage = message as SDKUserMessage; - if (umessage.message.content && Array.isArray(umessage.message.content)) { - msg = { - ...umessage, - message: { - ...umessage.message, - content: umessage.message.content.map((c) => { - if (c.type === 'tool_result' && c.tool_use_id && planModeToolCalls.has(c.tool_use_id!)) { - if (c.content === PLAN_FAKE_REJECT) { - logger.debug('[remote]: hack plan mode exit'); - logger.debugLargeJson('[remote]: hack plan mode exit', c); - return { - ...c, - is_error: false, - content: 'Plan approved', - mode: c.mode - } - } else { - return c; - } - } - return c; - }) - } - } - } - } - - const logMessage = sdkToLogConverter.convert(msg); - if (logMessage) { - // Add permissions field to tool result content - if (logMessage.type === 'user' && logMessage.message?.content) { - const content = Array.isArray(logMessage.message.content) - ? logMessage.message.content - : []; - - // Modify the content array to add permissions to each tool_result - for (let i = 0; i < content.length; i++) { - const c = content[i]; - if (c.type === 'tool_result' && c.tool_use_id) { - const responses = permissionHandler.getResponses(); - const response = responses.get(c.tool_use_id); - - if (response) { - const permissions: PermissionsField = { - date: response.receivedAt || Date.now(), - result: response.approved ? 'approved' : 'denied' - }; - - // Add optional fields if they exist - if (response.mode) { - permissions.mode = response.mode; - } - - if (response.allowTools && response.allowTools.length > 0) { - permissions.allowedTools = response.allowTools; - } - - // Add permissions directly to the tool_result content object - content[i] = { - ...c, - permissions - }; - } - } - } - } - - // Queue message with optional delay for tool calls - if (logMessage.type === 'assistant' && message.type === 'assistant') { - const assistantMsg = message as SDKAssistantMessage; - const toolCallIds: string[] = []; - - if (assistantMsg.message.content && Array.isArray(assistantMsg.message.content)) { - for (const block of assistantMsg.message.content) { - if (block.type === 'tool_use' && block.id) { - toolCallIds.push(block.id); - } - } - } - - if (toolCallIds.length > 0) { - // Check if this is a sidechain tool call (has parent_tool_use_id) - const isSidechain = assistantMsg.parent_tool_use_id !== undefined; - - if (!isSidechain) { - // Top-level tool call - queue with delay - messageQueue.enqueue(logMessage, { - delay: 250, - toolCallIds - }); - return; // Don't queue again below - } - } - } - - // Queue all other messages immediately (no delay) - messageQueue.enqueue(logMessage); - } - - // Insert a fake message to start the sidechain - if (message.type === 'assistant') { - let umessage = message as SDKAssistantMessage; - if (umessage.message.content && Array.isArray(umessage.message.content)) { - for (let c of umessage.message.content) { - if (c.type === 'tool_use' && c.name === 'Task' && c.input && typeof (c.input as any).prompt === 'string') { - const logMessage2 = sdkToLogConverter.convertSidechainUserMessage(c.id!, (c.input as any).prompt); - if (logMessage2) { - messageQueue.enqueue(logMessage2); - } - } - } - } - } - } - - try { - let pending: { - message: string; - mode: EnhancedMode; - } | null = null; - - // Track session ID to detect when it actually changes - // This prevents context loss when mode changes (permission mode, model, etc.) - // without starting a new session. Only reset parent chain when session ID - // actually changes (e.g., new session started or /clear command used). - // See: https://github.com/anthropics/happy-cli/issues/143 - let previousSessionId: string | null = null; - while (!exitReason) { - logger.debug('[remote]: launch'); - messageBuffer.addMessage('═'.repeat(40), 'status'); - - // Only reset parent chain and show "new session" message when session ID actually changes - const isNewSession = session.sessionId !== previousSessionId; - if (isNewSession) { - messageBuffer.addMessage('Starting new Claude session...', 'status'); - permissionHandler.reset(); // Reset permissions before starting new session - sdkToLogConverter.resetParentChain(); // Reset parent chain for new conversation - logger.debug(`[remote]: New session detected (previous: ${previousSessionId}, current: ${session.sessionId})`); - } else { - messageBuffer.addMessage('Continuing Claude session...', 'status'); - logger.debug(`[remote]: Continuing existing session: ${session.sessionId}`); - } - - previousSessionId = session.sessionId; - const controller = new AbortController(); - abortController = controller; - abortFuture = new Future(); - let modeHash: string | null = null; - let mode: EnhancedMode | null = null; - try { - const remoteResult = await claudeRemote({ - sessionId: session.sessionId, - path: session.path, - allowedTools: session.allowedTools ?? [], - mcpServers: session.mcpServers, - hookSettingsPath: session.hookSettingsPath, - jsRuntime: session.jsRuntime, - canCallTool: permissionHandler.handleToolCall, - isAborted: (toolCallId: string) => { - return permissionHandler.isAborted(toolCallId); - }, - nextMessage: async () => { - if (pending) { - let p = pending; - pending = null; - permissionHandler.handleModeChange(p.mode.permissionMode); - return p; - } - - let msg = await session.queue.waitForMessagesAndGetAsString(controller.signal); - - // Check if mode has changed - if (msg) { - if ((modeHash && msg.hash !== modeHash) || msg.isolate) { - logger.debug('[remote]: mode has changed, pending message'); - pending = msg; - return null; - } - modeHash = msg.hash; - mode = msg.mode; - permissionHandler.handleModeChange(mode.permissionMode); - return { - message: msg.message, - mode: msg.mode - } - } - - // Exit - return null; - }, - onSessionFound: (sessionId) => { - // Update converter's session ID when new session is found - sdkToLogConverter.updateSessionId(sessionId); - session.onSessionFound(sessionId); - }, - onThinkingChange: session.onThinkingChange, - claudeEnvVars: session.claudeEnvVars, - claudeArgs: session.claudeArgs, - onMessage, - onCompletionEvent: (message: string) => { - logger.debug(`[remote]: Completion event: ${message}`); - session.client.sendSessionEvent({ type: 'message', message }); - }, - onSessionReset: () => { - logger.debug('[remote]: Session reset'); - session.clearSessionId(); - }, - onReady: () => { - if (!pending && session.queue.size() === 0) { - session.client.sendSessionEvent({ type: 'ready' }); - session.api.push().sendToAllDevices( - 'It\'s ready!', - `Claude is waiting for your command`, - { sessionId: session.client.sessionId } - ); - } - }, - signal: abortController.signal, - }); - - // Consume one-time Claude flags after spawn - session.consumeOneTimeFlags(); - - if (!exitReason && abortController.signal.aborted) { - session.client.sendSessionEvent({ type: 'message', message: 'Aborted by user' }); - } - } catch (e) { - logger.debug('[remote]: launch error', e); - if (!exitReason) { - session.client.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' }); - continue; - } - } finally { - - logger.debug('[remote]: launch finally'); - - // Terminate all ongoing tool calls - for (let [toolCallId, { parentToolCallId }] of ongoingToolCalls) { - const converted = sdkToLogConverter.generateInterruptedToolResult(toolCallId, parentToolCallId); - if (converted) { - logger.debug('[remote]: terminating tool call ' + toolCallId + ' parent: ' + parentToolCallId); - session.client.sendClaudeSessionMessage(converted); - } - } - ongoingToolCalls.clear(); - - // Flush any remaining messages in the queue - logger.debug('[remote]: flushing message queue'); - await messageQueue.flush(); - messageQueue.destroy(); - logger.debug('[remote]: message queue flushed'); - - // Reset abort controller and future - abortController = null; - abortFuture?.resolve(undefined); - abortFuture = null; - logger.debug('[remote]: launch done'); - permissionHandler.reset(); - modeHash = null; - mode = null; - } - } - } finally { - - // Clean up permission handler - permissionHandler.reset(); - - // Reset Terminal - process.stdin.off('data', abort); - if (process.stdin.isTTY) { - process.stdin.setRawMode(false); - } - if (inkInstance) { - inkInstance.unmount(); - } - messageBuffer.clear(); - - // Resolve abort future - if (abortFuture) { // Just in case of error - abortFuture.resolve(undefined); - } - } - - return exitReason || 'exit'; -} \ No newline at end of file diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts deleted file mode 100644 index 5e157bd451..0000000000 --- a/cli/src/claude/runClaude.ts +++ /dev/null @@ -1,493 +0,0 @@ -import os from 'node:os'; -import { randomUUID } from 'node:crypto'; - -import { ApiClient } from '@/api/api'; -import { logger } from '@/ui/logger'; -import { loop } from '@/claude/loop'; -import { AgentState, Metadata } from '@/api/types'; -import packageJson from '../../package.json'; -import { Credentials, readSettings } from '@/persistence'; -import { EnhancedMode, PermissionMode } from './loop'; -import { MessageQueue2 } from '@/utils/MessageQueue2'; -import { hashObject } from '@/utils/deterministicJson'; -import { startCaffeinate, stopCaffeinate } from '@/utils/caffeinate'; -import { extractSDKMetadataAsync } from '@/claude/sdk/metadataExtractor'; -import { parseSpecialCommand } from '@/parsers/specialCommands'; -import { getEnvironmentInfo } from '@/ui/doctor'; -import { configuration } from '@/configuration'; -import { notifyDaemonSessionStarted } from '@/daemon/controlClient'; -import { initialMachineMetadata } from '@/daemon/run'; -import { startHappyServer } from '@/claude/utils/startHappyServer'; -import { startHookServer } from '@/claude/utils/startHookServer'; -import { generateHookSettingsFile, cleanupHookSettingsFile } from '@/claude/utils/generateHookSettings'; -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'; - -/** JavaScript runtime to use for spawning Claude Code */ -export type JsRuntime = 'node' | 'bun' - -export interface StartOptions { - model?: string - permissionMode?: PermissionMode - startingMode?: 'local' | 'remote' - shouldStartDaemon?: boolean - claudeEnvVars?: Record - claudeArgs?: string[] - startedBy?: 'daemon' | 'terminal' - /** JavaScript runtime to use for spawning Claude Code (default: 'node') */ - jsRuntime?: JsRuntime -} - -export async function runClaude(credentials: Credentials, options: StartOptions = {}): Promise { - logger.debug(`[CLAUDE] ===== CLAUDE MODE STARTING =====`); - logger.debug(`[CLAUDE] This is the Claude agent, NOT Gemini`); - - const workingDirectory = process.cwd(); - const sessionTag = randomUUID(); - - // Log environment info at startup - logger.debugLargeJson('[START] Happy process started', getEnvironmentInfo()); - logger.debug(`[START] Options: startedBy=${options.startedBy}, startingMode=${options.startingMode}`); - - // Validate daemon spawn requirements - fail fast on invalid config - if (options.startedBy === 'daemon' && options.startingMode === 'local') { - 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); - - // Create a new session - let state: AgentState = {}; - - // Get machine ID from settings (should already be set up) - const settings = await readSettings(); - let machineId = settings?.machineId - if (!machineId) { - console.error(`[START] No machine ID found in settings, which is unexpected since authAndSetupMachineIfNeeded should have created it. Please report this issue on https://github.com/slopus/happy-cli/issues`); - process.exit(1); - } - logger.debug(`Using machineId: ${machineId}`); - - // Create machine if it doesn't exist - await api.getOrCreateMachine({ - machineId, - metadata: initialMachineMetadata - }); - - let metadata: Metadata = { - path: workingDirectory, - 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: options.startedBy === 'daemon', - hostPid: process.pid, - startedBy: options.startedBy || 'terminal', - // Initialize lifecycle state - lifecycleState: 'running', - lifecycleStateSince: Date.now(), - flavor: 'claude' - }; - const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); - - // 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 - 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); - } - - // Extract SDK metadata in background and update session when ready - extractSDKMetadataAsync(async (sdkMetadata) => { - logger.debug('[start] SDK metadata extracted, updating session:', sdkMetadata); - try { - // Update session metadata with tools and slash commands - api.sessionSyncClient(response).updateMetadata((currentMetadata) => ({ - ...currentMetadata, - tools: sdkMetadata.tools, - slashCommands: sdkMetadata.slashCommands - })); - logger.debug('[start] Session metadata updated with SDK capabilities'); - } catch (error) { - logger.debug('[start] Failed to update session metadata:', error); - } - }); - - // Create realtime session - const session = api.sessionSyncClient(response); - - // Start Happy MCP server - const happyServer = await startHappyServer(session); - logger.debug(`[START] Happy MCP server started at ${happyServer.url}`); - - // Variable to track current session instance (updated via onSessionReady callback) - // Used by hook server to notify Session when Claude changes session ID - let currentSession: Session | null = null; - - // Start Hook server for receiving Claude session notifications - const hookServer = await startHookServer({ - onSessionHook: (sessionId, data) => { - logger.debug(`[START] Session hook received: ${sessionId}`, data); - - // Update session ID in the Session instance - if (currentSession) { - const previousSessionId = currentSession.sessionId; - if (previousSessionId !== sessionId) { - logger.debug(`[START] Claude session ID changed: ${previousSessionId} -> ${sessionId}`); - currentSession.onSessionFound(sessionId); - } - } - } - }); - logger.debug(`[START] Hook server started on port ${hookServer.port}`); - - // Generate hook settings file for Claude - const hookSettingsPath = generateHookSettingsFile(hookServer.port); - logger.debug(`[START] Generated hook settings file: ${hookSettingsPath}`); - - // Print log file path - const logPath = logger.logFilePath; - logger.infoDeveloper(`Session: ${response.id}`); - logger.infoDeveloper(`Logs: ${logPath}`); - - // Set initial agent state - session.updateAgentState((currentState) => ({ - ...currentState, - controlledByUser: options.startingMode !== 'remote' - })); - - // Start caffeinate to prevent sleep on macOS - const caffeinateStarted = startCaffeinate(); - if (caffeinateStarted) { - logger.infoDeveloper('Sleep prevention enabled (macOS)'); - } - - // Import MessageQueue2 and create message queue - const messageQueue = new MessageQueue2(mode => hashObject({ - isPlan: mode.permissionMode === 'plan', - model: mode.model, - fallbackModel: mode.fallbackModel, - customSystemPrompt: mode.customSystemPrompt, - appendSystemPrompt: mode.appendSystemPrompt, - allowedTools: mode.allowedTools, - disallowedTools: mode.disallowedTools - })); - - // Forward messages to the queue - // 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 - let currentAppendSystemPrompt: string | undefined = undefined; // Track current append system prompt - let currentAllowedTools: string[] | undefined = undefined; // Track current allowed tools - let currentDisallowedTools: string[] | undefined = undefined; // Track current disallowed tools - session.onUserMessage((message) => { - - // Resolve permission mode from meta - pass through as-is, mapping happens at SDK boundary - let messagePermissionMode: PermissionMode | undefined = currentPermissionMode; - if (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}`); - } - - // Resolve model - use message.meta.model if provided, otherwise use current model - let messageModel = currentModel; - if (message.meta?.hasOwnProperty('model')) { - messageModel = message.meta.model || undefined; // null becomes undefined - currentModel = messageModel; - logger.debug(`[loop] Model updated from user message: ${messageModel || 'reset to default'}`); - } else { - logger.debug(`[loop] User message received with no model override, using current: ${currentModel || 'default'}`); - } - - // Resolve custom system prompt - use message.meta.customSystemPrompt if provided, otherwise use current - let messageCustomSystemPrompt = currentCustomSystemPrompt; - if (message.meta?.hasOwnProperty('customSystemPrompt')) { - messageCustomSystemPrompt = message.meta.customSystemPrompt || undefined; // null becomes undefined - currentCustomSystemPrompt = messageCustomSystemPrompt; - logger.debug(`[loop] Custom system prompt updated from user message: ${messageCustomSystemPrompt ? 'set' : 'reset to none'}`); - } else { - logger.debug(`[loop] User message received with no custom system prompt override, using current: ${currentCustomSystemPrompt ? 'set' : 'none'}`); - } - - // Resolve fallback model - use message.meta.fallbackModel if provided, otherwise use current fallback model - let messageFallbackModel = currentFallbackModel; - if (message.meta?.hasOwnProperty('fallbackModel')) { - messageFallbackModel = message.meta.fallbackModel || undefined; // null becomes undefined - currentFallbackModel = messageFallbackModel; - logger.debug(`[loop] Fallback model updated from user message: ${messageFallbackModel || 'reset to none'}`); - } else { - logger.debug(`[loop] User message received with no fallback model override, using current: ${currentFallbackModel || 'none'}`); - } - - // Resolve append system prompt - use message.meta.appendSystemPrompt if provided, otherwise use current - let messageAppendSystemPrompt = currentAppendSystemPrompt; - if (message.meta?.hasOwnProperty('appendSystemPrompt')) { - messageAppendSystemPrompt = message.meta.appendSystemPrompt || undefined; // null becomes undefined - currentAppendSystemPrompt = messageAppendSystemPrompt; - logger.debug(`[loop] Append system prompt updated from user message: ${messageAppendSystemPrompt ? 'set' : 'reset to none'}`); - } else { - logger.debug(`[loop] User message received with no append system prompt override, using current: ${currentAppendSystemPrompt ? 'set' : 'none'}`); - } - - // Resolve allowed tools - use message.meta.allowedTools if provided, otherwise use current - let messageAllowedTools = currentAllowedTools; - if (message.meta?.hasOwnProperty('allowedTools')) { - messageAllowedTools = message.meta.allowedTools || undefined; // null becomes undefined - currentAllowedTools = messageAllowedTools; - logger.debug(`[loop] Allowed tools updated from user message: ${messageAllowedTools ? messageAllowedTools.join(', ') : 'reset to none'}`); - } else { - logger.debug(`[loop] User message received with no allowed tools override, using current: ${currentAllowedTools ? currentAllowedTools.join(', ') : 'none'}`); - } - - // Resolve disallowed tools - use message.meta.disallowedTools if provided, otherwise use current - let messageDisallowedTools = currentDisallowedTools; - if (message.meta?.hasOwnProperty('disallowedTools')) { - messageDisallowedTools = message.meta.disallowedTools || undefined; // null becomes undefined - currentDisallowedTools = messageDisallowedTools; - logger.debug(`[loop] Disallowed tools updated from user message: ${messageDisallowedTools ? messageDisallowedTools.join(', ') : 'reset to none'}`); - } else { - logger.debug(`[loop] User message received with no disallowed tools override, using current: ${currentDisallowedTools ? currentDisallowedTools.join(', ') : 'none'}`); - } - - // Check for special commands before processing - const specialCommand = parseSpecialCommand(message.content.text); - - if (specialCommand.type === 'compact') { - logger.debug('[start] Detected /compact command'); - const enhancedMode: EnhancedMode = { - permissionMode: messagePermissionMode || 'default', - model: messageModel, - fallbackModel: messageFallbackModel, - customSystemPrompt: messageCustomSystemPrompt, - appendSystemPrompt: messageAppendSystemPrompt, - allowedTools: messageAllowedTools, - disallowedTools: messageDisallowedTools - }; - messageQueue.pushIsolateAndClear(specialCommand.originalMessage || message.content.text, enhancedMode); - logger.debugLargeJson('[start] /compact command pushed to queue:', message); - return; - } - - if (specialCommand.type === 'clear') { - logger.debug('[start] Detected /clear command'); - const enhancedMode: EnhancedMode = { - permissionMode: messagePermissionMode || 'default', - model: messageModel, - fallbackModel: messageFallbackModel, - customSystemPrompt: messageCustomSystemPrompt, - appendSystemPrompt: messageAppendSystemPrompt, - allowedTools: messageAllowedTools, - disallowedTools: messageDisallowedTools - }; - messageQueue.pushIsolateAndClear(specialCommand.originalMessage || message.content.text, enhancedMode); - logger.debugLargeJson('[start] /compact command pushed to queue:', message); - return; - } - - // Push with resolved permission mode, model, system prompts, and tools - const enhancedMode: EnhancedMode = { - permissionMode: messagePermissionMode || 'default', - model: messageModel, - fallbackModel: messageFallbackModel, - customSystemPrompt: messageCustomSystemPrompt, - appendSystemPrompt: messageAppendSystemPrompt, - allowedTools: messageAllowedTools, - disallowedTools: messageDisallowedTools - }; - messageQueue.push(message.content.text, enhancedMode); - logger.debugLargeJson('User message pushed to queue:', message) - }); - - // Setup signal handlers for graceful shutdown - const cleanup = async () => { - logger.debug('[START] Received termination signal, cleaning up...'); - - try { - // Update lifecycle state to archived before closing - if (session) { - session.updateMetadata((currentMetadata) => ({ - ...currentMetadata, - lifecycleState: 'archived', - lifecycleStateSince: Date.now(), - archivedBy: 'cli', - archiveReason: 'User terminated' - })); - - // Cleanup session resources (intervals, callbacks) - currentSession?.cleanup(); - - // Send session death message - session.sendSessionDeath(); - await session.flush(); - await session.close(); - } - - // Stop caffeinate - stopCaffeinate(); - - // Stop Happy MCP server - happyServer.stop(); - - // Stop Hook server and cleanup settings file - hookServer.stop(); - cleanupHookSettingsFile(hookSettingsPath); - - logger.debug('[START] Cleanup complete, exiting'); - process.exit(0); - } catch (error) { - logger.debug('[START] Error during cleanup:', error); - process.exit(1); - } - }; - - // Handle termination signals - process.on('SIGTERM', cleanup); - process.on('SIGINT', cleanup); - - // Handle uncaught exceptions and rejections - process.on('uncaughtException', (error) => { - logger.debug('[START] Uncaught exception:', error); - cleanup(); - }); - - process.on('unhandledRejection', (reason) => { - logger.debug('[START] Unhandled rejection:', reason); - cleanup(); - }); - - registerKillSessionHandler(session.rpcHandlerManager, cleanup); - - // Create claude loop - const exitCode = await loop({ - path: workingDirectory, - model: options.model, - permissionMode: options.permissionMode, - startingMode: options.startingMode, - messageQueue, - api, - allowedTools: happyServer.toolNames.map(toolName => `mcp__happy__${toolName}`), - onModeChange: (newMode) => { - session.sendSessionEvent({ type: 'switch', mode: newMode }); - session.updateAgentState((currentState) => ({ - ...currentState, - controlledByUser: newMode === 'local' - })); - }, - onSessionReady: (sessionInstance) => { - // Store reference for hook server callback - currentSession = sessionInstance; - }, - mcpServers: { - 'happy': { - type: 'http' as const, - url: happyServer.url, - } - }, - session, - claudeEnvVars: options.claudeEnvVars, - claudeArgs: options.claudeArgs, - hookSettingsPath, - jsRuntime: options.jsRuntime - }); - - // 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(); - - // Wait for socket to flush - logger.debug('Waiting for socket to flush...'); - await session.flush(); - - // Close session - logger.debug('Closing session...'); - await session.close(); - - // Stop caffeinate before exiting - stopCaffeinate(); - logger.debug('Stopped sleep prevention'); - - // Stop Happy MCP server - happyServer.stop(); - logger.debug('Stopped Happy MCP server'); - - // Stop Hook server and cleanup settings file - hookServer.stop(); - cleanupHookSettingsFile(hookSettingsPath); - logger.debug('Stopped Hook server and cleaned up settings file'); - - // Exit with the code from Claude - process.exit(exitCode); -} \ No newline at end of file diff --git a/cli/src/claude/sdk/index.ts b/cli/src/claude/sdk/index.ts deleted file mode 100644 index 226aa8dce3..0000000000 --- a/cli/src/claude/sdk/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Claude Code SDK integration for Happy CLI - * Provides clean TypeScript implementation without Bun support - */ - -export { query } from './query' -export { AbortError } from './types' -export type { - QueryOptions, - QueryPrompt, - SDKMessage, - SDKUserMessage, - SDKAssistantMessage, - SDKSystemMessage, - SDKResultMessage, - SDKControlResponse, - ControlRequest, - InterruptRequest, - SDKControlRequest, - CanCallToolCallback, - PermissionResult -} from './types' \ No newline at end of file diff --git a/cli/src/claude/sdk/types.ts b/cli/src/claude/sdk/types.ts deleted file mode 100644 index de4cba7c3d..0000000000 --- a/cli/src/claude/sdk/types.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * Type definitions for Claude Code SDK integration - * Provides type-safe interfaces for all SDK communication - */ - -import type { Readable } from 'node:stream' - -/** - * SDK message types - */ -export interface SDKMessage { - type: string - [key: string]: unknown -} - -export interface SDKUserMessage extends SDKMessage { - type: 'user' - parent_tool_use_id?: string - message: { - role: 'user' - content: string | Array<{ - type: string - text?: string - tool_use_id?: string - content?: unknown - [key: string]: unknown - }> - } -} - -export interface SDKAssistantMessage extends SDKMessage { - type: 'assistant' - parent_tool_use_id?: string - message: { - role: 'assistant' - content: Array<{ - type: string - text?: string - id?: string - name?: string - input?: unknown - [key: string]: unknown - }> - } -} - -export interface SDKSystemMessage extends SDKMessage { - type: 'system' - subtype: string - session_id?: string - model?: string - cwd?: string - tools?: string[] - slash_commands?: string[] -} - -export interface SDKResultMessage extends SDKMessage { - type: 'result' - subtype: 'success' | 'error_max_turns' | 'error_during_execution' - result?: string - num_turns: number - usage?: { - input_tokens: number - output_tokens: number - cache_read_input_tokens?: number - cache_creation_input_tokens?: number - } - total_cost_usd: number - duration_ms: number - duration_api_ms: number - is_error: boolean - session_id: string -} - -export interface SDKControlResponse extends SDKMessage { - type: 'control_response' - response: { - request_id: string - subtype: 'success' | 'error' - error?: string - } -} - -export interface SDKLog extends SDKMessage { - type: 'log' - log: { - level: 'debug' | 'info' | 'warn' | 'error' - message: string - } -} - -/** - * Control request types - */ -export interface ControlRequest { - subtype: string -} - -export interface InterruptRequest extends ControlRequest { - subtype: 'interrupt' -} - -export interface CanUseToolRequest extends ControlRequest { - subtype: 'can_use_tool' - tool_name: string - input: unknown -} - -export interface CanUseToolControlRequest { - type: 'control_request' - request_id: string - request: CanUseToolRequest -} - -export interface CanUseToolControlResponse { - type: 'control_response' - response: { - subtype: 'success' | 'error' - request_id: string - response?: PermissionResult - error?: string - } -} - -export interface ControlCancelRequest { - type: 'control_cancel_request' - request_id: string -} - -export interface SDKControlRequest { - request_id: string - type: 'control_request' - request: ControlRequest -} - -/** - * Permission result type for tool calls - */ -export type PermissionResult = { - behavior: 'allow' - updatedInput: Record -} | { - behavior: 'deny' - message: string -} - -/** - * Callback function for tool permission checks - */ -export interface CanCallToolCallback { - (toolName: string, input: unknown, options: { signal: AbortSignal }): Promise -} - -/** - * Query options - */ -export interface QueryOptions { - abort?: AbortSignal - allowedTools?: string[] - appendSystemPrompt?: string - customSystemPrompt?: string - cwd?: string - disallowedTools?: string[] - executable?: string - executableArgs?: string[] - maxTurns?: number - mcpServers?: Record - pathToClaudeCodeExecutable?: string - permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' - continue?: boolean - resume?: string - model?: string - fallbackModel?: string - strictMcpConfig?: boolean - canCallTool?: CanCallToolCallback - /** Path to a settings JSON file to pass to Claude via --settings */ - settingsPath?: string -} - -/** - * Query prompt types - */ -export type QueryPrompt = string | AsyncIterable - -/** - * Control response handlers - */ -export type ControlResponseHandler = (response: SDKControlResponse['response']) => void - -/** - * Error types - */ -export class AbortError extends Error { - constructor(message: string) { - super(message) - this.name = 'AbortError' - } -} \ No newline at end of file diff --git a/cli/src/claude/types.ts b/cli/src/claude/types.ts deleted file mode 100644 index a875bbc907..0000000000 --- a/cli/src/claude/types.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Simplified schema that only validates fields actually used in the codebase - * while preserving all other fields through passthrough() - */ - -import { z } from "zod"; - -// Usage statistics for assistant messages - used in apiSession.ts -export const UsageSchema = z.object({ - input_tokens: z.number().int().nonnegative(), - cache_creation_input_tokens: z.number().int().nonnegative().optional(), - cache_read_input_tokens: z.number().int().nonnegative().optional(), - output_tokens: z.number().int().nonnegative(), - service_tier: z.string().optional(), -}).passthrough(); - -// Main schema with minimal validation for only the fields we use -// NOTE: Schema is intentionally lenient to handle various Claude Code message formats -// including synthetic error messages, API errors, and different SDK versions -export const RawJSONLinesSchema = z.discriminatedUnion("type", [ - // User message - validates uuid and message.content - z.object({ - type: z.literal("user"), - isSidechain: z.boolean().optional(), - isMeta: z.boolean().optional(), - uuid: z.string(), // Used in getMessageKey() - message: z.object({ - content: z.union([z.string(), z.any()]) // Used in sessionScanner.ts - }).passthrough() - }).passthrough(), - - // Assistant message - only validates uuid and type - // message object is optional to handle synthetic error messages (isApiErrorMessage: true) - // which may have different structure than normal assistant messages - z.object({ - uuid: z.string(), - type: z.literal("assistant"), - message: z.object({ - usage: UsageSchema.optional(), // Used in apiSession.ts - }).passthrough().optional() - }).passthrough(), - - // Summary message - validates summary and leafUuid - z.object({ - type: z.literal("summary"), - summary: z.string(), // Used in apiSession.ts - leafUuid: z.string() // Used in getMessageKey() - }).passthrough(), - - // System message - validates uuid - z.object({ - type: z.literal("system"), - uuid: z.string() // Used in getMessageKey() - }).passthrough() -]); - -export type RawJSONLines = z.infer diff --git a/cli/src/claude/utils/__fixtures__/task_non_sdk.jsonl b/cli/src/claude/utils/__fixtures__/task_non_sdk.jsonl deleted file mode 100644 index 56b265aad8..0000000000 --- a/cli/src/claude/utils/__fixtures__/task_non_sdk.jsonl +++ /dev/null @@ -1,13 +0,0 @@ -{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/Users/steve/Develop/slopus/happy-cli","sessionId":"a4e5b271-6948-4581-893b-4595c9ec41ea","version":"1.0.71","gitBranch":"main","type":"user","message":{"role":"user","content":"Show me a sample of using Task tool, I am testing this feature, run some web search with it."},"uuid":"2d64eabf-65cf-4a75-b266-5a9befc3957c","timestamp":"2025-08-11T01:02:59.544Z"} -{"parentUuid":"2d64eabf-65cf-4a75-b266-5a9befc3957c","isSidechain":false,"userType":"external","cwd":"/Users/steve/Develop/slopus/happy-cli","sessionId":"a4e5b271-6948-4581-893b-4595c9ec41ea","version":"1.0.71","gitBranch":"main","message":{"id":"msg_01RVVmQ4TCqYPZZknHHnqSL3","type":"message","role":"assistant","model":"claude-opus-4-1-20250805","content":[{"type":"text","text":"I'll demonstrate the Task tool by launching an agent to search for recent information about TypeScript 5.6 features."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":6406,"cache_read_input_tokens":16245,"output_tokens":3,"service_tier":"standard"}},"requestId":"req_011CS12BpdbdVKWxorCX68Vq","type":"assistant","uuid":"83ef1116-6e0d-401b-a201-e49d3cf9e744","timestamp":"2025-08-11T01:03:02.712Z"} -{"parentUuid":"83ef1116-6e0d-401b-a201-e49d3cf9e744","isSidechain":false,"userType":"external","cwd":"/Users/steve/Develop/slopus/happy-cli","sessionId":"a4e5b271-6948-4581-893b-4595c9ec41ea","version":"1.0.71","gitBranch":"main","message":{"id":"msg_01RVVmQ4TCqYPZZknHHnqSL3","type":"message","role":"assistant","model":"claude-opus-4-1-20250805","content":[{"type":"tool_use","id":"toolu_01EmKA8FJ7B2Ah9seGxK1Wct","name":"Task","input":{"description":"Search TypeScript 5.6 features","prompt":"Search the web for information about TypeScript 5.6 new features and improvements. Find out what are the key highlights and new capabilities introduced in this version. Provide a summary of the most important features with brief explanations.","subagent_type":"general-purpose"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":6406,"cache_read_input_tokens":16245,"output_tokens":170,"service_tier":"standard"}},"requestId":"req_011CS12BpdbdVKWxorCX68Vq","type":"assistant","uuid":"5d5f20fb-8a18-4c39-8651-561b34ed3e4b","timestamp":"2025-08-11T01:03:05.077Z"} -{"parentUuid":null,"isSidechain":true,"userType":"external","cwd":"/Users/steve/Develop/slopus/happy-cli","sessionId":"a4e5b271-6948-4581-893b-4595c9ec41ea","version":"1.0.71","gitBranch":"main","type":"user","message":{"role":"user","content":"Search the web for information about TypeScript 5.6 new features and improvements. Find out what are the key highlights and new capabilities introduced in this version. Provide a summary of the most important features with brief explanations."},"uuid":"7e733248-6fc5-4581-b3d3-db3741158fdd","timestamp":"2025-08-11T01:03:05.099Z"} -{"parentUuid":"7e733248-6fc5-4581-b3d3-db3741158fdd","isSidechain":true,"userType":"external","cwd":"/Users/steve/Develop/slopus/happy-cli","sessionId":"a4e5b271-6948-4581-893b-4595c9ec41ea","version":"1.0.71","gitBranch":"main","message":{"id":"msg_01GHtjVrgRYhNbHtpzYt2QuD","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"I'll search for information about TypeScript 5.6 new features and improvements."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":18894,"cache_read_input_tokens":0,"output_tokens":85,"service_tier":"standard"}},"requestId":"req_011CS12CEDvR8btnHCyDAcJE","type":"assistant","uuid":"317a6037-41ef-4625-8d61-df44e398f533","timestamp":"2025-08-11T01:03:07.831Z"} -{"parentUuid":"317a6037-41ef-4625-8d61-df44e398f533","isSidechain":true,"userType":"external","cwd":"/Users/steve/Develop/slopus/happy-cli","sessionId":"a4e5b271-6948-4581-893b-4595c9ec41ea","version":"1.0.71","gitBranch":"main","message":{"id":"msg_01GHtjVrgRYhNbHtpzYt2QuD","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"tool_use","id":"toolu_01NeNNHZD5QYiBQCRYTUwhME","name":"WebSearch","input":{"query":"TypeScript 5.6 new features improvements release highlights 2024"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":18894,"cache_read_input_tokens":0,"output_tokens":85,"service_tier":"standard"}},"requestId":"req_011CS12CEDvR8btnHCyDAcJE","type":"assistant","uuid":"db1d46f7-8509-4cac-a19d-091d8be624a9","timestamp":"2025-08-11T01:03:07.996Z"} -{"parentUuid":"db1d46f7-8509-4cac-a19d-091d8be624a9","isSidechain":true,"userType":"external","cwd":"/Users/steve/Develop/slopus/happy-cli","sessionId":"a4e5b271-6948-4581-893b-4595c9ec41ea","version":"1.0.71","gitBranch":"main","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NeNNHZD5QYiBQCRYTUwhME","type":"tool_result","content":"Web search results for query: \"TypeScript 5.6 new features improvements release highlights 2024\"\n\nI'll search for information about TypeScript 5.6's new features and improvements.\n\nLinks: [{\"title\":\"Announcing TypeScript 5.6 - TypeScript\",\"url\":\"https://devblogs.microsoft.com/typescript/announcing-typescript-5-6/\"},{\"title\":\"Releases · microsoft/TypeScript\",\"url\":\"https://github.com/microsoft/typescript/releases\"},{\"title\":\"Microsoft Releases TypeScript 5.6: Enhancing JavaScript and Programming Languages with New Features | by ReiserX | Medium\",\"url\":\"https://medium.com/@reiserx/microsoft-releases-typescript-5-6-enhancing-javascript-and-programming-languages-with-new-features-68d0b2182c0c\"},{\"title\":\"What’s new in TypeScript 5.6. TypeScript 5.6 beta has been released… | by Onix React | Medium\",\"url\":\"https://medium.com/@onix_react/whats-new-in-typescript-5-6-99ba92b8c503\"},{\"title\":\"Effective TypeScript › TypeScript 5.5: A Blockbuster Release\",\"url\":\"https://effectivetypescript.com/2024/07/02/ts-55/\"},{\"title\":\"Announcing TypeScript 5.5 - TypeScript\",\"url\":\"https://devblogs.microsoft.com/typescript/announcing-typescript-5-5/\"},{\"title\":\"Roadmap · microsoft/TypeScript Wiki · GitHub\",\"url\":\"https://github.com/microsoft/TypeScript/wiki/Roadmap\"},{\"title\":\"TypeScript 5.6 vs TypeScript 5.7: What’s New and What’s Changed? | by Zhibek Kamalbek | Medium\",\"url\":\"https://zhibeck.medium.com/typescript-5-6-vs-beta-ts-5-7-version-3238e5960e27\"},{\"title\":\"Effective TypeScript › Notes on TypeScript 5.6\",\"url\":\"https://effectivetypescript.com/2024/09/30/ts-56/\"},{\"title\":\"Microsoft launches TypeScript 5.6 with a number of new improvements - Neowin\",\"url\":\"https://www.neowin.net/news/microsoft-launches-typescript-56-with-a-number-of-new-improvements/\"}]\n\nBased on the search results, here are the key features and improvements in TypeScript 5.6, which was released in September 2024:\n\n## Major Features and Improvements in TypeScript 5.6\n\n### 1. **Improved Truthy and Nullish Checks**\nTypeScript 5.6 improved how the compiler handles truthy and nullish checks. Previously, certain logical errors in these checks could pass as valid JavaScript code. Now, the TypeScript compiler can syntactically determine when these checks will always evaluate a specific way, flagging them as errors.\n\n### 2. **Arbitrary Module Identifiers**\nTypeScript 5.6 allows developers to use arbitrary module identifiers, offering greater flexibility in how code modules are imported and exported. This feature makes it easier to integrate TypeScript with other languages and platforms like WebAssembly.\n\n### 3. **The --noCheck Compiler Option**\nA new compiler option, --noCheck, allows developers to skip type checking for all input files, significantly speeding up the build process. This is particularly useful for large-scale projects when you need faster builds during development.\n\n### 4. **Iterator Helper Methods**\nTypeScript 5.6 now supports Iterator Helper methods like map and take. If you've ever used Python's itertools package, this will be familiar. The appeal of iterators is that you can apply a series of operations to an array, for example, without constructing all the intermediate arrays. This reduces memory usage and should improve cache efficiency and performance.\n\n### 5. **Enhanced Auto-completion with Commit Characters**\nTypeScript now explicitly lists which characters are safe to commit for each completion item. While this won't immediately change your day-to-day experience, editors that support these commit characters should see behavioral improvements over time.\n\n### 6. **Auto-import Filtering**\nTypeScript's language service now allows you to specify a list of regular expression patterns which will filter away auto-import suggestions from certain specifiers.\n\n### 7. **--noUncheckedSideEffectImports Option**\nTypeScript 5.6 introduces the --noUncheckedSideEffectImports option to catch these cases. When enabled, TypeScript will throw an error if it cannot find a source file for a side effect import. This change ensures that all imports are valid, reducing the chances of silent failures due to typos.\n\n### 8. **Always Emit .tsbuildinfo Files**\nTo enable --build to continue building projects even if there are intermediate errors in dependencies, and to support --noCheck on the command line, TypeScript now always emits a .tsbuildinfo file for any project in a --build invocation. This happens regardless of whether --incremental is actually on.\n\n## Performance and Build Improvements\nTypeScript 5.6 brings significant improvements in code safety, build efficiency, and diagnostics responsiveness. These updates help developers catch errors early, work more efficiently with iterables, and maintain productivity during large-scale builds.\n\n## Availability\nFor developers eager to start using TypeScript 5.6, it's available via npm with the command npm install -D typescript, or you can download it through NuGet.\n\n## Future Release\nMicrosoft plans to release TypeScript 5.7 on November 21, 2024.\n\nTypeScript 5.6 represents a significant update focused on improving developer productivity, catching potential errors earlier, and enhancing build performance for large-scale applications."}]},"uuid":"75985b89-6e87-4d54-a564-00ee9e5fc77f","timestamp":"2025-08-11T01:03:38.715Z","toolUseResult":{"query":"TypeScript 5.6 new features improvements release highlights 2024","results":["I'll search for information about TypeScript 5.6's new features and improvements.",{"tool_use_id":"srvtoolu_011c4qAYoogJCFLbjz4N6YX8","content":[{"title":"Announcing TypeScript 5.6 - TypeScript","url":"https://devblogs.microsoft.com/typescript/announcing-typescript-5-6/"},{"title":"Releases · microsoft/TypeScript","url":"https://github.com/microsoft/typescript/releases"},{"title":"Microsoft Releases TypeScript 5.6: Enhancing JavaScript and Programming Languages with New Features | by ReiserX | Medium","url":"https://medium.com/@reiserx/microsoft-releases-typescript-5-6-enhancing-javascript-and-programming-languages-with-new-features-68d0b2182c0c"},{"title":"What’s new in TypeScript 5.6. TypeScript 5.6 beta has been released… | by Onix React | Medium","url":"https://medium.com/@onix_react/whats-new-in-typescript-5-6-99ba92b8c503"},{"title":"Effective TypeScript › TypeScript 5.5: A Blockbuster Release","url":"https://effectivetypescript.com/2024/07/02/ts-55/"},{"title":"Announcing TypeScript 5.5 - TypeScript","url":"https://devblogs.microsoft.com/typescript/announcing-typescript-5-5/"},{"title":"Roadmap · microsoft/TypeScript Wiki · GitHub","url":"https://github.com/microsoft/TypeScript/wiki/Roadmap"},{"title":"TypeScript 5.6 vs TypeScript 5.7: What’s New and What’s Changed? | by Zhibek Kamalbek | Medium","url":"https://zhibeck.medium.com/typescript-5-6-vs-beta-ts-5-7-version-3238e5960e27"},{"title":"Effective TypeScript › Notes on TypeScript 5.6","url":"https://effectivetypescript.com/2024/09/30/ts-56/"},{"title":"Microsoft launches TypeScript 5.6 with a number of new improvements - Neowin","url":"https://www.neowin.net/news/microsoft-launches-typescript-56-with-a-number-of-new-improvements/"}]},"Based on the search results, here are the key features and improvements in TypeScript 5.6, which was released in September 2024:\n\n## Major Features and Improvements in TypeScript 5.6\n\n### 1. **Improved Truthy and Nullish Checks**\nTypeScript 5.6 improved how the compiler handles truthy and nullish checks. Previously, certain logical errors in these checks could pass as valid JavaScript code. Now, the TypeScript compiler can syntactically determine when these checks will always evaluate a specific way, flagging them as errors.\n\n### 2. **Arbitrary Module Identifiers**\nTypeScript 5.6 allows developers to use arbitrary module identifiers, offering greater flexibility in how code modules are imported and exported. This feature makes it easier to integrate TypeScript with other languages and platforms like WebAssembly.\n\n### 3. **The --noCheck Compiler Option**\nA new compiler option, --noCheck, allows developers to skip type checking for all input files, significantly speeding up the build process. This is particularly useful for large-scale projects when you need faster builds during development.\n\n### 4. **Iterator Helper Methods**\nTypeScript 5.6 now supports Iterator Helper methods like map and take. If you've ever used Python's itertools package, this will be familiar. The appeal of iterators is that you can apply a series of operations to an array, for example, without constructing all the intermediate arrays. This reduces memory usage and should improve cache efficiency and performance.\n\n### 5. **Enhanced Auto-completion with Commit Characters**\nTypeScript now explicitly lists which characters are safe to commit for each completion item. While this won't immediately change your day-to-day experience, editors that support these commit characters should see behavioral improvements over time.\n\n### 6. **Auto-import Filtering**\nTypeScript's language service now allows you to specify a list of regular expression patterns which will filter away auto-import suggestions from certain specifiers.\n\n### 7. **--noUncheckedSideEffectImports Option**\nTypeScript 5.6 introduces the --noUncheckedSideEffectImports option to catch these cases. When enabled, TypeScript will throw an error if it cannot find a source file for a side effect import. This change ensures that all imports are valid, reducing the chances of silent failures due to typos.\n\n### 8. **Always Emit .tsbuildinfo Files**\nTo enable --build to continue building projects even if there are intermediate errors in dependencies, and to support --noCheck on the command line, TypeScript now always emits a .tsbuildinfo file for any project in a --build invocation. This happens regardless of whether --incremental is actually on.\n\n## Performance and Build Improvements\nTypeScript 5.6 brings significant improvements in code safety, build efficiency, and diagnostics responsiveness. These updates help developers catch errors early, work more efficiently with iterables, and maintain productivity during large-scale builds.\n\n## Availability\nFor developers eager to start using TypeScript 5.6, it's available via npm with the command npm install -D typescript, or you can download it through NuGet.\n\n## Future Release\nMicrosoft plans to release TypeScript 5.7 on November 21, 2024.\n\nTypeScript 5.6 represents a significant update focused on improving developer productivity, catching potential errors earlier, and enhancing build performance for large-scale applications."],"durationSeconds":30.681327166999996}} -{"parentUuid":"75985b89-6e87-4d54-a564-00ee9e5fc77f","isSidechain":true,"userType":"external","cwd":"/Users/steve/Develop/slopus/happy-cli","sessionId":"a4e5b271-6948-4581-893b-4595c9ec41ea","version":"1.0.71","gitBranch":"main","message":{"id":"msg_01HXw84yHVnkmNw7Xv4pZ6U2","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Let me get more specific details from the official TypeScript announcement:"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":1409,"cache_read_input_tokens":18894,"output_tokens":127,"service_tier":"standard"}},"requestId":"req_011CS12EiBEgPBizPLWjhB77","type":"assistant","uuid":"9d9924dd-46b9-4d3e-abcf-082625b8c226","timestamp":"2025-08-11T01:03:41.597Z"} -{"parentUuid":"9d9924dd-46b9-4d3e-abcf-082625b8c226","isSidechain":true,"userType":"external","cwd":"/Users/steve/Develop/slopus/happy-cli","sessionId":"a4e5b271-6948-4581-893b-4595c9ec41ea","version":"1.0.71","gitBranch":"main","message":{"id":"msg_01HXw84yHVnkmNw7Xv4pZ6U2","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"tool_use","id":"toolu_01St6YuC4MdxA9A9NXuBDKVY","name":"WebFetch","input":{"url":"https://devblogs.microsoft.com/typescript/announcing-typescript-5-6/","prompt":"Extract the key new features and improvements in TypeScript 5.6. Focus on the main highlights with brief technical explanations."}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":1409,"cache_read_input_tokens":18894,"output_tokens":127,"service_tier":"standard"}},"requestId":"req_011CS12EiBEgPBizPLWjhB77","type":"assistant","uuid":"e2f3b977-843a-49a5-8dc8-47f1d2a8f6c5","timestamp":"2025-08-11T01:03:42.800Z"} -{"parentUuid":"e2f3b977-843a-49a5-8dc8-47f1d2a8f6c5","isSidechain":true,"userType":"external","cwd":"/Users/steve/Develop/slopus/happy-cli","sessionId":"a4e5b271-6948-4581-893b-4595c9ec41ea","version":"1.0.71","gitBranch":"main","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01St6YuC4MdxA9A9NXuBDKVY","type":"tool_result","content":"Here are the key highlights of TypeScript 5.6:\n\n1. Disallowed Nullish and Truthy Checks\n- Introduces new compiler errors for syntactically suspicious expressions\n- Catches potential logical errors like always-truthy regex checks or misplaced comparison operators\n- Helps prevent unintended code behavior\n\n2. Iterator Helper Methods\n- Adds support for new iterator methods like `map()`, `filter()`, and `take()`\n- Enables more functional-style operations on iterators and generators\n- Aligns with upcoming ECMAScript iterator helpers proposal\n\n3. Strict Builtin Iterator Checks\n- Introduces `IteratorObject` type to model built-in iterators\n- Adds `--strictBuiltinIteratorReturn` flag to improve type safety\n- Provides more precise type checking for iterator return values\n\n4. Arbitrary Module Identifiers\n- Allows importing and exporting modules with non-standard identifiers\n- Supports emoji and special character module names\n- Improves interoperability with other languages\n\n5. New Compiler Options\n- `--noUncheckedSideEffectImports`: Catches unresolved side effect imports\n- `--noCheck`: Skips type checking for faster compilation\n- `--build` now continues building projects with intermediate errors\n\n6. Editor Improvements\n- Region-prioritized diagnostics for faster error checking\n- Granular commit characters for more intelligent autocomplete\n- Exclude patterns for auto-imports\n\nThese features aim to improve type safety, developer productivity, and TypeScript's overall language capabilities."}]},"uuid":"5e2ca7ac-2163-4fe8-841f-882cf75815ca","timestamp":"2025-08-11T01:04:03.828Z","toolUseResult":{"bytes":240313,"code":200,"codeText":"OK","result":"Here are the key highlights of TypeScript 5.6:\n\n1. Disallowed Nullish and Truthy Checks\n- Introduces new compiler errors for syntactically suspicious expressions\n- Catches potential logical errors like always-truthy regex checks or misplaced comparison operators\n- Helps prevent unintended code behavior\n\n2. Iterator Helper Methods\n- Adds support for new iterator methods like `map()`, `filter()`, and `take()`\n- Enables more functional-style operations on iterators and generators\n- Aligns with upcoming ECMAScript iterator helpers proposal\n\n3. Strict Builtin Iterator Checks\n- Introduces `IteratorObject` type to model built-in iterators\n- Adds `--strictBuiltinIteratorReturn` flag to improve type safety\n- Provides more precise type checking for iterator return values\n\n4. Arbitrary Module Identifiers\n- Allows importing and exporting modules with non-standard identifiers\n- Supports emoji and special character module names\n- Improves interoperability with other languages\n\n5. New Compiler Options\n- `--noUncheckedSideEffectImports`: Catches unresolved side effect imports\n- `--noCheck`: Skips type checking for faster compilation\n- `--build` now continues building projects with intermediate errors\n\n6. Editor Improvements\n- Region-prioritized diagnostics for faster error checking\n- Granular commit characters for more intelligent autocomplete\n- Exclude patterns for auto-imports\n\nThese features aim to improve type safety, developer productivity, and TypeScript's overall language capabilities.","durationMs":12306,"url":"https://devblogs.microsoft.com/typescript/announcing-typescript-5-6/"}} -{"parentUuid":"5e2ca7ac-2163-4fe8-841f-882cf75815ca","isSidechain":true,"userType":"external","cwd":"/Users/steve/Develop/slopus/happy-cli","sessionId":"a4e5b271-6948-4581-893b-4595c9ec41ea","version":"1.0.71","gitBranch":"main","message":{"id":"msg_01UA9puBKTRgBTWQBBdyFHzF","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"# TypeScript 5.6 New Features and Improvements Summary\n\nBased on my research, here are the key highlights and new capabilities introduced in TypeScript 5.6, released in September 2024:\n\n## Major New Features\n\n### 1. **Disallowed Nullish and Truthy Checks**\nTypeScript 5.6 introduces stricter checking for potentially erroneous conditional expressions. The compiler now flags syntactically suspicious code that will always evaluate to true or false, helping catch logical errors like:\n- Always-truthy regex checks\n- Misplaced comparison operators\n- Unintended nullish coalescing patterns\n\n### 2. **Iterator Helper Methods**\nFull support for ECMAScript iterator helper methods including:\n- `map()`, `filter()`, `take()`, `drop()`, `forEach()`, `reduce()`\n- Enables functional-style operations on iterators and generators\n- Improves memory efficiency by avoiding intermediate array creation\n- Better cache performance for large data processing\n\n### 3. **Arbitrary Module Identifiers**\nEnhanced module system flexibility allowing:\n- Non-standard module identifiers including emoji and special characters\n- Better interoperability with WebAssembly and other languages\n- More flexible import/export patterns\n\n### 4. **Strict Builtin Iterator Checks**\n- New `IteratorObject` type for modeling built-in iterators\n- `--strictBuiltinIteratorReturn` flag for enhanced type safety\n- More precise type checking for iterator return values\n\n## Compiler and Build Improvements\n\n### 5. **New Compiler Options**\n- **`--noUncheckedSideEffectImports`**: Catches unresolved side effect imports to prevent silent failures\n- **`--noCheck`**: Skips type checking for significantly faster builds during development\n- **Enhanced `--build`**: Continues building projects even with intermediate dependency errors\n\n### 6. **Build System Enhancements**\n- Always emits `.tsbuildinfo` files for better incremental build support\n- Improved error recovery in large-scale projects\n- Better handling of project dependencies with errors\n\n## Editor and Developer Experience\n\n### 7. **Editor Improvements**\n- **Region-prioritized diagnostics**: Faster error checking by prioritizing visible code areas\n- **Granular commit characters**: More intelligent autocomplete with character-specific commit behavior\n- **Auto-import filtering**: Regex patterns to exclude unwanted auto-import suggestions\n\n### 8. **Enhanced Error Detection**\n- Better detection of unreachable code patterns\n- Improved type narrowing in conditional statements\n- More accurate control flow analysis\n\n## Performance Optimizations\n\n- Faster compilation times with the `--noCheck` option\n- Improved memory usage with iterator helpers\n- Enhanced build performance for large codebases\n- Better caching efficiency in the language service\n\n## Availability and Future\n\nTypeScript 5.6 is available via:\n- `npm install -D typescript`\n- NuGet package manager\n- Visual Studio integration\n\nMicrosoft has announced TypeScript 5.7 is planned for release on November 21, 2024.\n\nThese improvements collectively enhance code safety, build efficiency, developer productivity, and align TypeScript more closely with modern ECMAScript features while maintaining backward compatibility."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":490,"cache_read_input_tokens":20303,"output_tokens":732,"service_tier":"standard"}},"requestId":"req_011CS12GZMid3AwJNveiQFWX","type":"assistant","uuid":"a11cf083-ca8c-4889-a96c-c855207170f8","timestamp":"2025-08-11T01:04:17.598Z"} -{"parentUuid":"5d5f20fb-8a18-4c39-8651-561b34ed3e4b","isSidechain":false,"userType":"external","cwd":"/Users/steve/Develop/slopus/happy-cli","sessionId":"a4e5b271-6948-4581-893b-4595c9ec41ea","version":"1.0.71","gitBranch":"main","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01EmKA8FJ7B2Ah9seGxK1Wct","type":"tool_result","content":[{"type":"text","text":"# TypeScript 5.6 New Features and Improvements Summary\n\nBased on my research, here are the key highlights and new capabilities introduced in TypeScript 5.6, released in September 2024:\n\n## Major New Features\n\n### 1. **Disallowed Nullish and Truthy Checks**\nTypeScript 5.6 introduces stricter checking for potentially erroneous conditional expressions. The compiler now flags syntactically suspicious code that will always evaluate to true or false, helping catch logical errors like:\n- Always-truthy regex checks\n- Misplaced comparison operators\n- Unintended nullish coalescing patterns\n\n### 2. **Iterator Helper Methods**\nFull support for ECMAScript iterator helper methods including:\n- `map()`, `filter()`, `take()`, `drop()`, `forEach()`, `reduce()`\n- Enables functional-style operations on iterators and generators\n- Improves memory efficiency by avoiding intermediate array creation\n- Better cache performance for large data processing\n\n### 3. **Arbitrary Module Identifiers**\nEnhanced module system flexibility allowing:\n- Non-standard module identifiers including emoji and special characters\n- Better interoperability with WebAssembly and other languages\n- More flexible import/export patterns\n\n### 4. **Strict Builtin Iterator Checks**\n- New `IteratorObject` type for modeling built-in iterators\n- `--strictBuiltinIteratorReturn` flag for enhanced type safety\n- More precise type checking for iterator return values\n\n## Compiler and Build Improvements\n\n### 5. **New Compiler Options**\n- **`--noUncheckedSideEffectImports`**: Catches unresolved side effect imports to prevent silent failures\n- **`--noCheck`**: Skips type checking for significantly faster builds during development\n- **Enhanced `--build`**: Continues building projects even with intermediate dependency errors\n\n### 6. **Build System Enhancements**\n- Always emits `.tsbuildinfo` files for better incremental build support\n- Improved error recovery in large-scale projects\n- Better handling of project dependencies with errors\n\n## Editor and Developer Experience\n\n### 7. **Editor Improvements**\n- **Region-prioritized diagnostics**: Faster error checking by prioritizing visible code areas\n- **Granular commit characters**: More intelligent autocomplete with character-specific commit behavior\n- **Auto-import filtering**: Regex patterns to exclude unwanted auto-import suggestions\n\n### 8. **Enhanced Error Detection**\n- Better detection of unreachable code patterns\n- Improved type narrowing in conditional statements\n- More accurate control flow analysis\n\n## Performance Optimizations\n\n- Faster compilation times with the `--noCheck` option\n- Improved memory usage with iterator helpers\n- Enhanced build performance for large codebases\n- Better caching efficiency in the language service\n\n## Availability and Future\n\nTypeScript 5.6 is available via:\n- `npm install -D typescript`\n- NuGet package manager\n- Visual Studio integration\n\nMicrosoft has announced TypeScript 5.7 is planned for release on November 21, 2024.\n\nThese improvements collectively enhance code safety, build efficiency, developer productivity, and align TypeScript more closely with modern ECMAScript features while maintaining backward compatibility."}]}]},"uuid":"6580ef02-7ebe-455c-9f0b-812b15fca3da","timestamp":"2025-08-11T01:04:17.657Z","toolUseResult":{"content":[{"type":"text","text":"# TypeScript 5.6 New Features and Improvements Summary\n\nBased on my research, here are the key highlights and new capabilities introduced in TypeScript 5.6, released in September 2024:\n\n## Major New Features\n\n### 1. **Disallowed Nullish and Truthy Checks**\nTypeScript 5.6 introduces stricter checking for potentially erroneous conditional expressions. The compiler now flags syntactically suspicious code that will always evaluate to true or false, helping catch logical errors like:\n- Always-truthy regex checks\n- Misplaced comparison operators\n- Unintended nullish coalescing patterns\n\n### 2. **Iterator Helper Methods**\nFull support for ECMAScript iterator helper methods including:\n- `map()`, `filter()`, `take()`, `drop()`, `forEach()`, `reduce()`\n- Enables functional-style operations on iterators and generators\n- Improves memory efficiency by avoiding intermediate array creation\n- Better cache performance for large data processing\n\n### 3. **Arbitrary Module Identifiers**\nEnhanced module system flexibility allowing:\n- Non-standard module identifiers including emoji and special characters\n- Better interoperability with WebAssembly and other languages\n- More flexible import/export patterns\n\n### 4. **Strict Builtin Iterator Checks**\n- New `IteratorObject` type for modeling built-in iterators\n- `--strictBuiltinIteratorReturn` flag for enhanced type safety\n- More precise type checking for iterator return values\n\n## Compiler and Build Improvements\n\n### 5. **New Compiler Options**\n- **`--noUncheckedSideEffectImports`**: Catches unresolved side effect imports to prevent silent failures\n- **`--noCheck`**: Skips type checking for significantly faster builds during development\n- **Enhanced `--build`**: Continues building projects even with intermediate dependency errors\n\n### 6. **Build System Enhancements**\n- Always emits `.tsbuildinfo` files for better incremental build support\n- Improved error recovery in large-scale projects\n- Better handling of project dependencies with errors\n\n## Editor and Developer Experience\n\n### 7. **Editor Improvements**\n- **Region-prioritized diagnostics**: Faster error checking by prioritizing visible code areas\n- **Granular commit characters**: More intelligent autocomplete with character-specific commit behavior\n- **Auto-import filtering**: Regex patterns to exclude unwanted auto-import suggestions\n\n### 8. **Enhanced Error Detection**\n- Better detection of unreachable code patterns\n- Improved type narrowing in conditional statements\n- More accurate control flow analysis\n\n## Performance Optimizations\n\n- Faster compilation times with the `--noCheck` option\n- Improved memory usage with iterator helpers\n- Enhanced build performance for large codebases\n- Better caching efficiency in the language service\n\n## Availability and Future\n\nTypeScript 5.6 is available via:\n- `npm install -D typescript`\n- NuGet package manager\n- Visual Studio integration\n\nMicrosoft has announced TypeScript 5.7 is planned for release on November 21, 2024.\n\nThese improvements collectively enhance code safety, build efficiency, developer productivity, and align TypeScript more closely with modern ECMAScript features while maintaining backward compatibility."}],"totalDurationMs":72558,"totalTokens":21530,"totalToolUseCount":2,"usage":{"input_tokens":5,"cache_creation_input_tokens":490,"cache_read_input_tokens":20303,"output_tokens":732,"service_tier":"standard"}}} -{"parentUuid":"6580ef02-7ebe-455c-9f0b-812b15fca3da","isSidechain":false,"userType":"external","cwd":"/Users/steve/Develop/slopus/happy-cli","sessionId":"a4e5b271-6948-4581-893b-4595c9ec41ea","version":"1.0.71","gitBranch":"main","message":{"id":"msg_01LPPXpoLXddEXTnUpLfSChG","type":"message","role":"assistant","model":"claude-opus-4-1-20250805","content":[{"type":"text","text":"The Task tool successfully searched for and compiled information about TypeScript 5.6 features. The agent found comprehensive details about new compiler options, iterator helpers, stricter type checking, and various performance improvements introduced in this version."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":907,"cache_read_input_tokens":22651,"output_tokens":49,"service_tier":"standard"}},"requestId":"req_011CS12HaK3kTPZN5F76tURp","type":"assistant","uuid":"5c5636e4-ab1b-43e5-a518-a17da318a76b","timestamp":"2025-08-11T01:04:20.550Z"} diff --git a/cli/src/claude/utils/__fixtures__/task_sdk.jsonl b/cli/src/claude/utils/__fixtures__/task_sdk.jsonl deleted file mode 100644 index 131a601a4d..0000000000 --- a/cli/src/claude/utils/__fixtures__/task_sdk.jsonl +++ /dev/null @@ -1,10 +0,0 @@ -{"type":"summary","summary":"AI Breakthroughs 2025: Models, Science, and Reasoning","leafUuid":"c624bd74-b03c-4783-82ac-59ee5c94e737"} -{"type":"summary","summary":"Task Tool Web Search: AI Developments Explored","leafUuid":"323ff8f1-92b8-4cee-b110-d92bda663a21"} -{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/Users/steve/Develop/slopus/happy-cli","sessionId":"1bf1ead3-1c75-46a4-8d6b-5ab1af247819","version":"1.0.71","gitBranch":"main","type":"user","message":{"role":"user","content":[{"type":"text","text":"Show me a sample of using Task tool, I am testing this feature, run some web search with it."}]},"uuid":"e06bcce8-52c6-47f3-9378-1770b77523f5","timestamp":"2025-08-11T00:56:53.561Z"} -{"parentUuid":"e06bcce8-52c6-47f3-9378-1770b77523f5","isSidechain":false,"userType":"external","cwd":"/Users/steve/Develop/slopus/happy-cli","sessionId":"2c74184e-6979-476d-a52f-0ac183bffe3a","version":"1.0.71","gitBranch":"main","type":"assistant","timestamp":"2025-08-11T01:01:43.257Z","message":{"id":"msg_01Vk7Nw6nMvw9zsnWpErvT6k","type":"message","role":"assistant","model":"claude-opus-4-1-20250805","content":[{"type":"text","text":"I'll demonstrate the Task tool by launching a general-purpose agent to search for information about recent AI developments."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":22177,"cache_read_input_tokens":0,"output_tokens":3,"service_tier":"standard"}},"requestId":"req_011CS11iqeMzBY1DxSh6DSxV","uuid":"8595b132-1f7c-4aab-a7ee-98cd8155385e"} -{"parentUuid":"8595b132-1f7c-4aab-a7ee-98cd8155385e","isSidechain":false,"userType":"external","cwd":"/Users/steve/Develop/slopus/happy-cli","sessionId":"2c74184e-6979-476d-a52f-0ac183bffe3a","version":"1.0.71","gitBranch":"main","type":"assistant","timestamp":"2025-08-11T01:01:43.257Z","message":{"id":"msg_01Vk7Nw6nMvw9zsnWpErvT6k","type":"message","role":"assistant","model":"claude-opus-4-1-20250805","content":[{"type":"tool_use","id":"toolu_01DAkBr3THZbTNLPPBHiX1sp","name":"Task","input":{"description":"Search recent AI news","prompt":"Search the web for the latest developments in AI and machine learning from 2025. Find information about recent breakthroughs, new model releases, or significant research papers. Provide a concise summary of 3-5 key findings.","subagent_type":"general-purpose"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":22177,"cache_read_input_tokens":0,"output_tokens":165,"service_tier":"standard"}},"requestId":"req_011CS11iqeMzBY1DxSh6DSxV","uuid":"54b4ea7c-f6ce-4310-96f0-08a0517854ec"} -{"parentUuid":"54b4ea7c-f6ce-4310-96f0-08a0517854ec","isSidechain":false,"userType":"external","cwd":"/Users/steve/Develop/slopus/happy-cli","sessionId":"2c74184e-6979-476d-a52f-0ac183bffe3a","version":"1.0.71","gitBranch":"main","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DAkBr3THZbTNLPPBHiX1sp","type":"tool_result","content":[{"type":"text","text":"Based on my search of the latest AI and machine learning developments from 2025, here are the key findings:\n\n## 5 Major AI Breakthroughs in 2025\n\n### 1. **Advanced Computer Vision: SAM 2**\nMeta AI released SAM 2 (Segment Anything Model 2), a significant advancement that extends object segmentation from still images to videos. This model can track and identify objects as they move through video sequences in real-time, running 6x faster than its predecessor while requiring minimal human input. This breakthrough has major implications for video analysis, autonomous systems, and content creation.\n\n### 2. **Revolutionary Reasoning Models**\nMultiple breakthrough reasoning models emerged in early 2025:\n- **DeepSeek-R1** and **Kimi k1.5** (January 2025) demonstrate significant advances in reinforcement learning for LLMs\n- **AM-Thinking-v1**, a 32B parameter model, achieved state-of-the-art reasoning performance, scoring 85.3 on AIME 2024 and outperforming much larger models\n- Anthropic released **Claude 3.7 Sonnet** in February 2025, featuring automatic reasoning mode switching for complex questions\n\n### 3. **AI-Driven Scientific Discovery**\n**AlphaEvolve** from Google DeepMind made mathematical history by discovering a new algorithm for multiplying 4×4 complex matrices using only 48 multiplications - the first improvement over Strassen's 1969 algorithm (49 multiplications). Additionally, Stanford researchers created an **AI Virtual Scientist** capable of independently designing, running, and analyzing biological experiments, potentially accelerating drug discovery and genomics research.\n\n### 4. **Computer Use and Agent Capabilities**\nOpenAI introduced **\"Operator\"** in January 2025, an AI model that can perform tasks directly on users' computers, following Anthropic's earlier breakthrough in computer use capabilities. This represents a major step toward autonomous AI agents that can interact with software interfaces like humans do.\n\n### 5. **Fundamental AI Understanding**\nResearch paper **\"Transformers Learn Low Sensitivity Functions\"** provided crucial insights into why transformer architectures have been so successful in AI. The discovery that transformers naturally learn functions with low sensitivity (outputs don't change dramatically with slight input changes) helps explain their robustness and effectiveness across diverse tasks.\n\n## Notable Research Trends\n\n- **Efficiency Focus**: Multiple papers addressed making AI models faster and more computationally efficient through techniques like speculative decoding\n- **Real-World Applications**: AI is making practical breakthroughs in materials science (cooling paints that reduce building energy consumption by 30%) and biological research\n- **Open Source Innovation**: Dense, smaller models like AM-Thinking-v1 are achieving performance comparable to much larger proprietary models\n\nThese developments show 2025 is shaping up to be a pivotal year for AI, with advances spanning from fundamental research to practical applications that could significantly impact various industries."}]}]},"uuid":"07fac7f8-e1dc-4b7b-99e4-f009292a8b49","timestamp":"2025-08-11T01:01:43.257Z","toolUseResult":{"content":[{"type":"text","text":"Based on my search of the latest AI and machine learning developments from 2025, here are the key findings:\n\n## 5 Major AI Breakthroughs in 2025\n\n### 1. **Advanced Computer Vision: SAM 2**\nMeta AI released SAM 2 (Segment Anything Model 2), a significant advancement that extends object segmentation from still images to videos. This model can track and identify objects as they move through video sequences in real-time, running 6x faster than its predecessor while requiring minimal human input. This breakthrough has major implications for video analysis, autonomous systems, and content creation.\n\n### 2. **Revolutionary Reasoning Models**\nMultiple breakthrough reasoning models emerged in early 2025:\n- **DeepSeek-R1** and **Kimi k1.5** (January 2025) demonstrate significant advances in reinforcement learning for LLMs\n- **AM-Thinking-v1**, a 32B parameter model, achieved state-of-the-art reasoning performance, scoring 85.3 on AIME 2024 and outperforming much larger models\n- Anthropic released **Claude 3.7 Sonnet** in February 2025, featuring automatic reasoning mode switching for complex questions\n\n### 3. **AI-Driven Scientific Discovery**\n**AlphaEvolve** from Google DeepMind made mathematical history by discovering a new algorithm for multiplying 4×4 complex matrices using only 48 multiplications - the first improvement over Strassen's 1969 algorithm (49 multiplications). Additionally, Stanford researchers created an **AI Virtual Scientist** capable of independently designing, running, and analyzing biological experiments, potentially accelerating drug discovery and genomics research.\n\n### 4. **Computer Use and Agent Capabilities**\nOpenAI introduced **\"Operator\"** in January 2025, an AI model that can perform tasks directly on users' computers, following Anthropic's earlier breakthrough in computer use capabilities. This represents a major step toward autonomous AI agents that can interact with software interfaces like humans do.\n\n### 5. **Fundamental AI Understanding**\nResearch paper **\"Transformers Learn Low Sensitivity Functions\"** provided crucial insights into why transformer architectures have been so successful in AI. The discovery that transformers naturally learn functions with low sensitivity (outputs don't change dramatically with slight input changes) helps explain their robustness and effectiveness across diverse tasks.\n\n## Notable Research Trends\n\n- **Efficiency Focus**: Multiple papers addressed making AI models faster and more computationally efficient through techniques like speculative decoding\n- **Real-World Applications**: AI is making practical breakthroughs in materials science (cooling paints that reduce building energy consumption by 30%) and biological research\n- **Open Source Innovation**: Dense, smaller models like AM-Thinking-v1 are achieving performance comparable to much larger proprietary models\n\nThese developments show 2025 is shaping up to be a pivotal year for AI, with advances spanning from fundamental research to practical applications that could significantly impact various industries."}],"totalDurationMs":133548,"totalTokens":23447,"totalToolUseCount":3,"usage":{"input_tokens":4,"cache_creation_input_tokens":1616,"cache_read_input_tokens":21156,"output_tokens":671,"service_tier":"standard"}}} -{"parentUuid":"07fac7f8-e1dc-4b7b-99e4-f009292a8b49","isSidechain":false,"userType":"external","cwd":"/Users/steve/Develop/slopus/happy-cli","sessionId":"2c74184e-6979-476d-a52f-0ac183bffe3a","version":"1.0.71","gitBranch":"main","type":"assistant","timestamp":"2025-08-11T01:01:43.257Z","message":{"id":"msg_01DdmYYRvTnj3A8jELpJrLVS","type":"message","role":"assistant","model":"claude-opus-4-1-20250805","content":[{"type":"text","text":"The Task tool successfully executed a web search about recent AI developments. The agent gathered information about key breakthroughs in 2025 including SAM 2 for video segmentation, new reasoning models like DeepSeek-R1 and Claude 3.7 Sonnet, AlphaEvolve's mathematical discovery, OpenAI's Operator for computer control, and fundamental research on transformer architectures."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":842,"cache_read_input_tokens":22177,"output_tokens":93,"service_tier":"standard"}},"requestId":"req_011CS11uBUVxMgsiGoCd4Qff","uuid":"323ff8f1-92b8-4cee-b110-d92bda663a21"} -{"parentUuid":"323ff8f1-92b8-4cee-b110-d92bda663a21","isSidechain":false,"userType":"external","cwd":"/Users/steve/Develop/slopus/happy-cli","sessionId":"2c74184e-6979-476d-a52f-0ac183bffe3a","version":"1.0.71","gitBranch":"main","type":"user","message":{"role":"user","content":"ok"},"uuid":"f9e223cc-1695-4b5a-8aa3-6735398cfae4","timestamp":"2025-08-11T01:01:49.400Z"} -{"parentUuid":"f9e223cc-1695-4b5a-8aa3-6735398cfae4","isSidechain":false,"userType":"external","cwd":"/Users/steve/Develop/slopus/happy-cli","sessionId":"2c74184e-6979-476d-a52f-0ac183bffe3a","version":"1.0.71","gitBranch":"main","message":{"id":"msg_01QRy7cd96wX9cXAQX8XeN62","type":"message","role":"assistant","model":"claude-opus-4-1-20250805","content":[{"type":"text","text":"I'll read that Claude session file to see what's in it."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":23597,"cache_read_input_tokens":0,"output_tokens":2,"service_tier":"standard"}},"requestId":"req_011CS126ek8sFiuHPR5hFFN5","type":"assistant","uuid":"ed139ded-8609-4154-ad93-8ca1ca43aaac","timestamp":"2025-08-11T01:01:53.445Z"} -{"parentUuid":"ed139ded-8609-4154-ad93-8ca1ca43aaac","isSidechain":false,"userType":"external","cwd":"/Users/steve/Develop/slopus/happy-cli","sessionId":"2c74184e-6979-476d-a52f-0ac183bffe3a","version":"1.0.71","gitBranch":"main","message":{"id":"msg_01QRy7cd96wX9cXAQX8XeN62","type":"message","role":"assistant","model":"claude-opus-4-1-20250805","content":[{"type":"tool_use","id":"toolu_01WcAeP5o1cykMtWBgyETq46","name":"Read","input":{"file_path":"/Users/steve/.claude/projects/-Users-steve-Develop-slopus-happy-cli/0a297c06-24df-4522-a03f-f6862c5a6c72.jsonl"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":23597,"cache_read_input_tokens":0,"output_tokens":119,"service_tier":"standard"}},"requestId":"req_011CS126ek8sFiuHPR5hFFN5","type":"assistant","uuid":"ba3e74b9-2b53-4926-8821-b9a0cc4e8c61","timestamp":"2025-08-11T01:01:54.389Z"} diff --git a/cli/src/claude/utils/claudeSettings.test.ts b/cli/src/claude/utils/claudeSettings.test.ts deleted file mode 100644 index 423edbb733..0000000000 --- a/cli/src/claude/utils/claudeSettings.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Tests for Claude settings reading functionality - * - * Tests reading Claude's settings.json file and respecting the includeCoAuthoredBy setting - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { existsSync, writeFileSync, unlinkSync, mkdirSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { readClaudeSettings, shouldIncludeCoAuthoredBy } from './claudeSettings'; - -describe('Claude Settings', () => { - let testClaudeDir: string; - let originalClaudeConfigDir: string | undefined; - - beforeEach(() => { - // Create a temporary directory for testing - testClaudeDir = join(tmpdir(), `test-claude-${Date.now()}`); - mkdirSync(testClaudeDir, { recursive: true }); - - // Set environment variable to point to test directory - originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; - process.env.CLAUDE_CONFIG_DIR = testClaudeDir; - }); - - afterEach(() => { - // Restore original environment variable - if (originalClaudeConfigDir !== undefined) { - process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; - } else { - delete process.env.CLAUDE_CONFIG_DIR; - } - - // Clean up test directory - if (existsSync(testClaudeDir)) { - rmSync(testClaudeDir, { recursive: true, force: true }); - } - }); - - describe('readClaudeSettings', () => { - it('returns null when settings file does not exist', () => { - const settings = readClaudeSettings(); - expect(settings).toBe(null); - }); - - it('reads settings when file exists', () => { - const settingsPath = join(testClaudeDir, 'settings.json'); - const testSettings = { includeCoAuthoredBy: false, otherSetting: 'value' }; - writeFileSync(settingsPath, JSON.stringify(testSettings)); - - const settings = readClaudeSettings(); - expect(settings).toEqual(testSettings); - }); - - it('returns null when settings file is invalid JSON', () => { - const settingsPath = join(testClaudeDir, 'settings.json'); - writeFileSync(settingsPath, 'invalid json'); - - const settings = readClaudeSettings(); - expect(settings).toBe(null); - }); - }); - - describe('shouldIncludeCoAuthoredBy', () => { - it('returns true when no settings file exists (default behavior)', () => { - const result = shouldIncludeCoAuthoredBy(); - expect(result).toBe(true); - }); - - it('returns true when includeCoAuthoredBy is not set (default behavior)', () => { - const settingsPath = join(testClaudeDir, 'settings.json'); - writeFileSync(settingsPath, JSON.stringify({ otherSetting: 'value' })); - - const result = shouldIncludeCoAuthoredBy(); - expect(result).toBe(true); - }); - - it('returns false when includeCoAuthoredBy is explicitly set to false', () => { - const settingsPath = join(testClaudeDir, 'settings.json'); - writeFileSync(settingsPath, JSON.stringify({ includeCoAuthoredBy: false })); - - const result = shouldIncludeCoAuthoredBy(); - expect(result).toBe(false); - }); - - it('returns true when includeCoAuthoredBy is explicitly set to true', () => { - const settingsPath = join(testClaudeDir, 'settings.json'); - writeFileSync(settingsPath, JSON.stringify({ includeCoAuthoredBy: true })); - - const result = shouldIncludeCoAuthoredBy(); - expect(result).toBe(true); - }); - }); -}); \ No newline at end of file diff --git a/cli/src/claude/utils/claudeSettings.ts b/cli/src/claude/utils/claudeSettings.ts deleted file mode 100644 index 356c17b59d..0000000000 --- a/cli/src/claude/utils/claudeSettings.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Utilities for reading Claude's settings.json configuration - * - * Handles reading Claude's settings.json file to respect user preferences - * like includeCoAuthoredBy setting for commit message generation. - */ - -import { existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { homedir } from 'node:os'; -import { logger } from '@/ui/logger'; - -export interface ClaudeSettings { - includeCoAuthoredBy?: boolean; - [key: string]: any; -} - -/** - * Get the path to Claude's settings.json file - */ -function getClaudeSettingsPath(): string { - const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'); - return join(claudeConfigDir, 'settings.json'); -} - -/** - * Read Claude's settings.json file from the default location - * - * @returns Claude settings object or null if file doesn't exist or can't be read - */ -export function readClaudeSettings(): ClaudeSettings | null { - try { - const settingsPath = getClaudeSettingsPath(); - - if (!existsSync(settingsPath)) { - logger.debug(`[ClaudeSettings] No Claude settings file found at ${settingsPath}`); - return null; - } - - const settingsContent = readFileSync(settingsPath, 'utf-8'); - const settings = JSON.parse(settingsContent) as ClaudeSettings; - - logger.debug(`[ClaudeSettings] Successfully read Claude settings from ${settingsPath}`); - logger.debug(`[ClaudeSettings] includeCoAuthoredBy: ${settings.includeCoAuthoredBy}`); - - return settings; - } catch (error) { - logger.debug(`[ClaudeSettings] Error reading Claude settings: ${error}`); - return null; - } -} - -/** - * Check if Co-Authored-By lines should be included in commit messages - * based on Claude's settings - * - * @returns true if Co-Authored-By should be included, false otherwise - */ -export function shouldIncludeCoAuthoredBy(): boolean { - const settings = readClaudeSettings(); - - // If no settings file or includeCoAuthoredBy is not explicitly set, - // default to true to maintain backward compatibility - if (!settings || settings.includeCoAuthoredBy === undefined) { - return true; - } - - return settings.includeCoAuthoredBy; -} \ No newline at end of file diff --git a/cli/src/claude/utils/permissionHandler.ts b/cli/src/claude/utils/permissionHandler.ts deleted file mode 100644 index 1f8d7b8c1b..0000000000 --- a/cli/src/claude/utils/permissionHandler.ts +++ /dev/null @@ -1,428 +0,0 @@ -/** - * Permission Handler for canCallTool integration - * - * Replaces the MCP permission server with direct SDK integration. - * 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 { getToolName } from "./getToolName"; -import { EnhancedMode, PermissionMode } from "../loop"; -import { getToolDescriptor } from "./getToolDescriptor"; -import { delay } from "@/utils/time"; - -interface PermissionResponse { - id: string; - approved: boolean; - reason?: string; - mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'; - allowTools?: string[]; - receivedAt?: number; -} - - -interface PendingRequest { - resolve: (value: PermissionResult) => void; - reject: (error: Error) => void; - toolName: string; - input: unknown; -} - -export class PermissionHandler { - private toolCalls: { id: string, name: string, input: any, used: boolean }[] = []; - private responses = new Map(); - private pendingRequests = new Map(); - private session: Session; - private allowedTools = new Set(); - private allowedBashLiterals = new Set(); - private allowedBashPrefixes = new Set(); - private permissionMode: PermissionMode = 'default'; - private onPermissionRequestCallback?: (toolCallId: string) => void; - - constructor(session: Session) { - this.session = session; - this.setupClientHandler(); - } - - /** - * Set callback to trigger when permission request is made - */ - setOnPermissionRequest(callback: (toolCallId: string) => void) { - this.onPermissionRequestCallback = callback; - } - - handleModeChange(mode: PermissionMode) { - this.permissionMode = mode; - } - - /** - * Handler response - */ - private handlePermissionResponse( - response: PermissionResponse, - pending: PendingRequest - ): void { - - // Update allowed tools - if (response.allowTools && response.allowTools.length > 0) { - response.allowTools.forEach(tool => { - if (tool.startsWith('Bash(') || tool === 'Bash') { - this.parseBashPermission(tool); - } else { - this.allowedTools.add(tool); - } - }); - } - - // Update permission mode - if (response.mode) { - this.permissionMode = response.mode; - } - - // Handle - if (pending.toolName === 'exit_plan_mode' || pending.toolName === 'ExitPlanMode') { - // Handle exit_plan_mode specially - logger.debug('Plan mode result received', response); - if (response.approved) { - logger.debug('Plan approved - injecting PLAN_FAKE_RESTART'); - // Inject the approval message at the beginning of the queue - if (response.mode && ['default', 'acceptEdits', 'bypassPermissions'].includes(response.mode)) { - this.session.queue.unshift(PLAN_FAKE_RESTART, { permissionMode: response.mode }); - } else { - this.session.queue.unshift(PLAN_FAKE_RESTART, { permissionMode: 'default' }); - } - pending.resolve({ behavior: 'deny', message: PLAN_FAKE_REJECT }); - } else { - pending.resolve({ behavior: 'deny', message: response.reason || 'Plan rejected' }); - } - } else { - // Handle default case for all other tools - const result: PermissionResult = response.approved - ? { behavior: 'allow', updatedInput: (pending.input as Record) || {} } - : { behavior: 'deny', message: response.reason || `The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.` }; - - pending.resolve(result); - } - } - - /** - * Creates the canCallTool callback for the SDK - */ - handleToolCall = async (toolName: string, input: unknown, mode: EnhancedMode, options: { signal: AbortSignal }): Promise => { - - // Check if tool is explicitly allowed - if (toolName === 'Bash') { - const inputObj = input as { command?: string }; - if (inputObj?.command) { - // Check literal matches - if (this.allowedBashLiterals.has(inputObj.command)) { - return { behavior: 'allow', updatedInput: input as Record }; - } - // Check prefix matches - for (const prefix of this.allowedBashPrefixes) { - if (inputObj.command.startsWith(prefix)) { - return { behavior: 'allow', updatedInput: input as Record }; - } - } - } - } else if (this.allowedTools.has(toolName)) { - return { behavior: 'allow', updatedInput: input as Record }; - } - - // Calculate descriptor - const descriptor = getToolDescriptor(toolName); - - // - // Handle special cases - // - - if (this.permissionMode === 'bypassPermissions') { - return { behavior: 'allow', updatedInput: input as Record }; - } - - if (this.permissionMode === 'acceptEdits' && descriptor.edit) { - return { behavior: 'allow', updatedInput: input as Record }; - } - - // - // Approval flow - // - - let toolCallId = this.resolveToolCallId(toolName, input); - if (!toolCallId) { // What if we got permission before tool call - await delay(1000); - toolCallId = this.resolveToolCallId(toolName, input); - if (!toolCallId) { - throw new Error(`Could not resolve tool call ID for ${toolName}`); - } - } - return this.handlePermissionRequest(toolCallId, toolName, input, options.signal); - } - - /** - * Handles individual permission requests - */ - private async handlePermissionRequest( - id: string, - toolName: string, - input: unknown, - signal: AbortSignal - ): Promise { - return new Promise((resolve, reject) => { - // Set up abort signal handling - const abortHandler = () => { - this.pendingRequests.delete(id); - reject(new Error('Permission request aborted')); - }; - signal.addEventListener('abort', abortHandler, { once: true }); - - // Store the pending request - this.pendingRequests.set(id, { - resolve: (result: PermissionResult) => { - signal.removeEventListener('abort', abortHandler); - resolve(result); - }, - reject: (error: Error) => { - signal.removeEventListener('abort', abortHandler); - reject(error); - }, - toolName, - input - }); - - // Trigger callback to send delayed messages immediately - if (this.onPermissionRequestCallback) { - this.onPermissionRequestCallback(id); - } - - // Send push notification - this.session.api.push().sendToAllDevices( - 'Permission Request', - `Claude wants to ${getToolName(toolName)}`, - { - sessionId: this.session.client.sessionId, - requestId: id, - tool: toolName, - type: 'permission_request' - } - ); - - // Update agent state - this.session.client.updateAgentState((currentState) => ({ - ...currentState, - requests: { - ...currentState.requests, - [id]: { - tool: toolName, - arguments: input, - createdAt: Date.now() - } - } - })); - - logger.debug(`Permission request sent for tool call ${id}: ${toolName}`); - }); - } - - - /** - * Parses Bash permission strings into literal and prefix sets - */ - private parseBashPermission(permission: string): void { - // Ignore plain "Bash" - if (permission === 'Bash') { - return; - } - - // Match Bash(command) or Bash(command:*) - const bashPattern = /^Bash\((.+?)\)$/; - const match = permission.match(bashPattern); - - if (!match) { - return; - } - - const command = match[1]; - - // Check if it's a prefix pattern (ends with :*) - if (command.endsWith(':*')) { - const prefix = command.slice(0, -2); // Remove :* - this.allowedBashPrefixes.add(prefix); - } else { - // Literal match - this.allowedBashLiterals.add(command); - } - } - - /** - * Resolves tool call ID based on tool name and input - */ - private resolveToolCallId(name: string, args: any): string | null { - // 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 && isDeepStrictEqual(call.input, args)) { - if (call.used) { - return null; - } - // Found unused match - mark as used and return - call.used = true; - return call.id; - } - } - - return null; - } - - /** - * Handles messages to track tool calls - */ - onMessage(message: SDKMessage): void { - if (message.type === 'assistant') { - const assistantMsg = message as SDKAssistantMessage; - if (assistantMsg.message && assistantMsg.message.content) { - for (const block of assistantMsg.message.content) { - if (block.type === 'tool_use') { - this.toolCalls.push({ - id: block.id!, - name: block.name!, - input: block.input, - used: false - }); - } - } - } - } - if (message.type === 'user') { - const userMsg = message as SDKUserMessage; - if (userMsg.message && userMsg.message.content && Array.isArray(userMsg.message.content)) { - for (const block of userMsg.message.content) { - if (block.type === 'tool_result' && block.tool_use_id) { - const toolCall = this.toolCalls.find(tc => tc.id === block.tool_use_id); - if (toolCall && !toolCall.used) { - toolCall.used = true; - } - } - } - } - } - } - - /** - * Checks if a tool call is rejected - */ - isAborted(toolCallId: string): boolean { - - // If tool not approved, it's aborted - if (this.responses.get(toolCallId)?.approved === false) { - return true; - } - - // Always abort exit_plan_mode - const toolCall = this.toolCalls.find(tc => tc.id === toolCallId); - if (toolCall && (toolCall.name === 'exit_plan_mode' || toolCall.name === 'ExitPlanMode')) { - return true; - } - - // Tool call is not aborted - return false; - } - - /** - * Resets all state for new sessions - */ - reset(): void { - this.toolCalls = []; - this.responses.clear(); - this.allowedTools.clear(); - this.allowedBashLiterals.clear(); - this.allowedBashPrefixes.clear(); - - // Cancel all pending requests - for (const [, pending] of this.pendingRequests.entries()) { - pending.reject(new Error('Session reset')); - } - this.pendingRequests.clear(); - - // Move all pending requests to completedRequests with canceled status - this.session.client.updateAgentState((currentState) => { - const pendingRequests = currentState.requests || {}; - const completedRequests = { ...currentState.completedRequests }; - - // Move each pending request to completed with canceled status - for (const [id, request] of Object.entries(pendingRequests)) { - completedRequests[id] = { - ...request, - completedAt: Date.now(), - status: 'canceled', - reason: 'Session switched to local mode' - }; - } - - return { - ...currentState, - requests: {}, // Clear all pending requests - completedRequests - }; - }); - } - - /** - * Sets up the client handler for permission responses - */ - private setupClientHandler(): void { - this.session.client.rpcHandlerManager.registerHandler('permission', async (message) => { - logger.debug(`Permission response: ${JSON.stringify(message)}`); - - const id = message.id; - const pending = this.pendingRequests.get(id); - - if (!pending) { - logger.debug('Permission request not found or already resolved'); - return; - } - - // Store the response with timestamp - this.responses.set(id, { ...message, receivedAt: Date.now() }); - this.pendingRequests.delete(id); - - // Handle the permission response based on tool type - this.handlePermissionResponse(message, pending); - - // Move processed request to completedRequests - this.session.client.updateAgentState((currentState) => { - const request = currentState.requests?.[id]; - if (!request) return currentState; - let r = { ...currentState.requests }; - delete r[id]; - return { - ...currentState, - requests: r, - completedRequests: { - ...currentState.completedRequests, - [id]: { - ...request, - completedAt: Date.now(), - status: message.approved ? 'approved' : 'denied', - reason: message.reason, - mode: message.mode, - allowTools: message.allowTools - } - } - }; - }); - }); - } - - /** - * Gets the responses map (for compatibility with existing code) - */ - getResponses(): Map { - return this.responses; - } -} \ No newline at end of file diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/claude/utils/startHappyServer.ts deleted file mode 100644 index 9a1bb21b0e..0000000000 --- a/cli/src/claude/utils/startHappyServer.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Happy MCP server - * Provides Happy CLI specific tools including chat session title management - */ - -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { createServer } from "node:http"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import { AddressInfo } from "node:net"; -import { z } from "zod"; -import { logger } from "@/ui/logger"; -import { ApiSessionClient } from "@/api/apiSession"; -import { randomUUID } from "node:crypto"; - -export async function startHappyServer(client: ApiSessionClient) { - // Handler that sends title updates via the client - const handler = async (title: string) => { - logger.debug('[happyMCP] Changing title to:', title); - try { - // Send title as a summary message, similar to title generator - client.sendClaudeSessionMessage({ - type: 'summary', - summary: title, - leafUuid: randomUUID() - }); - - return { success: true }; - } catch (error) { - return { success: false, error: String(error) }; - } - }; - - // - // Create the MCP server - // - - const mcp = new McpServer({ - name: "Happy MCP", - version: "1.0.0", - }); - - mcp.registerTool('change_title', { - description: 'Change the title of the current chat session', - title: 'Change Chat Title', - inputSchema: { - title: z.string().describe('The new title for the chat session'), - }, - }, async (args) => { - const response = await handler(args.title); - logger.debug('[happyMCP] Response:', response); - - if (response.success) { - return { - content: [ - { - type: 'text', - text: `Successfully changed chat title to: "${args.title}"`, - }, - ], - isError: false, - }; - } else { - return { - content: [ - { - type: 'text', - text: `Failed to change chat title: ${response.error || 'Unknown error'}`, - }, - ], - isError: true, - }; - } - }); - - const transport = new StreamableHTTPServerTransport({ - // NOTE: Returning session id here will result in claude - // sdk spawn to fail with `Invalid Request: Server already initialized` - sessionIdGenerator: undefined - }); - await mcp.connect(transport); - - // - // Create the HTTP server - // - - const server = createServer(async (req, res) => { - try { - await transport.handleRequest(req, res); - } catch (error) { - logger.debug("Error handling request:", error); - if (!res.headersSent) { - res.writeHead(500).end(); - } - } - }); - - const baseUrl = await new Promise((resolve) => { - server.listen(0, "127.0.0.1", () => { - const addr = server.address() as AddressInfo; - resolve(new URL(`http://127.0.0.1:${addr.port}`)); - }); - }); - - return { - url: baseUrl.toString(), - toolNames: ['change_title'], - stop: () => { - logger.debug('[happyMCP] Stopping server'); - mcp.close(); - server.close(); - } - } -} diff --git a/cli/src/claude/utils/systemPrompt.ts b/cli/src/claude/utils/systemPrompt.ts deleted file mode 100644 index 78a59b7857..0000000000 --- a/cli/src/claude/utils/systemPrompt.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { trimIdent } from "@/utils/trimIdent"; -import { shouldIncludeCoAuthoredBy } from "./claudeSettings"; - -/** - * Base system prompt shared across all configurations - */ -const BASE_SYSTEM_PROMPT = (() => trimIdent(` - ALWAYS when you start a new chat - you must call a tool "mcp__happy__change_title" to set a chat title. When you think chat title is not relevant anymore - call the tool again to change it. When chat name is too generic and you have a change to make it more specific - call the tool again to change it. This title is needed to easily find the chat in the future. Help human. -`))(); - -/** - * Co-authored-by credits to append when enabled - */ -const CO_AUTHORED_CREDITS = (() => trimIdent(` - When making commit messages, instead of just giving co-credit to Claude, also give credit to Happy like so: - -
- - Generated with [Claude Code](https://claude.ai/code) - via [Happy](https://happy.engineering) - - Co-Authored-By: Claude - Co-Authored-By: Happy -`))(); - -/** - * System prompt with conditional Co-Authored-By lines based on Claude's settings.json configuration. - * Settings are read once on startup for performance. - */ -export const systemPrompt = (() => { - const includeCoAuthored = shouldIncludeCoAuthoredBy(); - - if (includeCoAuthored) { - return BASE_SYSTEM_PROMPT + '\n\n' + CO_AUTHORED_CREDITS; - } else { - return BASE_SYSTEM_PROMPT; - } -})(); \ No newline at end of file diff --git a/cli/src/codex/codexMcpClient.ts b/cli/src/codex/codexMcpClient.ts deleted file mode 100644 index ed101394c0..0000000000 --- a/cli/src/codex/codexMcpClient.ts +++ /dev/null @@ -1,365 +0,0 @@ -/** - * Codex MCP Client - Simple wrapper for Codex tools - */ - -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import { logger } from '@/ui/logger'; -import type { CodexSessionConfig, CodexToolResponse } from './types'; -import { z } from 'zod'; -import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js'; -import { CodexPermissionHandler } from './utils/permissionHandler'; -import { execSync } from 'child_process'; - -const DEFAULT_TIMEOUT = 14 * 24 * 60 * 60 * 1000; // 14 days, which is the half of the maximum possible timeout (~28 days for int32 value in NodeJS) - -/** - * 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 | null { - try { - const version = execSync('codex --version', { encoding: 'utf8' }).trim(); - const match = version.match(/codex-cli\s+(\d+\.\d+\.\d+(?:-alpha\.\d+)?)/); - 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); - - // Version >= 0.43.0-alpha.5 has mcp-server - if (major > 0 || minor > 43) return 'mcp-server'; - if (minor === 43 && patch === 0) { - // Check for alpha version - if (versionStr.includes('-alpha.')) { - const alphaNum = parseInt(versionStr.split('-alpha.')[1]); - return alphaNum >= 5 ? 'mcp-server' : 'mcp'; - } - return 'mcp-server'; // 0.43.0 stable has mcp-server - } - return 'mcp'; // Older versions use mcp - } catch (error) { - logger.debug('[CodexMCP] Codex CLI not found or not executable:', error); - return null; - } -} - -export class CodexMcpClient { - private client: Client; - private transport: StdioClientTransport | null = null; - private connected: boolean = false; - private sessionId: string | null = null; - private conversationId: string | null = null; - private handler: ((event: any) => void) | null = null; - private permissionHandler: CodexPermissionHandler | null = null; - - constructor() { - this.client = new Client( - { name: 'happy-codex-client', version: '1.0.0' }, - { capabilities: { elicitation: {} } } - ); - - this.client.setNotificationHandler(z.object({ - method: z.literal('codex/event'), - params: z.object({ - msg: z.any() - }) - }).passthrough(), (data) => { - const msg = data.params.msg; - this.updateIdentifiersFromEvent(msg); - this.handler?.(msg); - }); - } - - setHandler(handler: ((event: any) => void) | null): void { - this.handler = handler; - } - - /** - * Set the permission handler for tool approval - */ - setPermissionHandler(handler: CodexPermissionHandler): void { - this.permissionHandler = handler; - } - - async connect(): Promise { - 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({ - command: 'codex', - args: [mcpCommand], - env: Object.keys(process.env).reduce((acc, key) => { - const value = process.env[key]; - if (typeof value === 'string') acc[key] = value; - return acc; - }, {} as Record) - }); - - // Register request handlers for Codex permission methods - this.registerPermissionHandlers(); - - await this.client.connect(this.transport); - this.connected = true; - - logger.debug('[CodexMCP] Connected to Codex'); - } - - private registerPermissionHandlers(): void { - // Register handler for exec command approval requests - this.client.setRequestHandler( - ElicitRequestSchema, - async (request) => { - console.log('[CodexMCP] Received elicitation request:', request.params); - - // Load params - const params = request.params as unknown as { - message: string, - codex_elicitation: string, - codex_mcp_tool_call_id: string, - codex_event_id: string, - codex_call_id: string, - codex_command: string[], - codex_cwd: string - } - const toolName = 'CodexBash'; - - // If no permission handler set, deny by default - if (!this.permissionHandler) { - logger.debug('[CodexMCP] No permission handler set, denying by default'); - return { - decision: 'denied' as const, - }; - } - - try { - // Request permission through the handler - const result = await this.permissionHandler.handleToolCall( - params.codex_call_id, - toolName, - { - command: params.codex_command, - cwd: params.codex_cwd - } - ); - - logger.debug('[CodexMCP] Permission result:', result); - return { - decision: result.decision - } - } catch (error) { - logger.debug('[CodexMCP] Error handling permission request:', error); - return { - decision: 'denied' as const, - reason: error instanceof Error ? error.message : 'Permission request failed' - }; - } - } - ); - - logger.debug('[CodexMCP] Permission handlers registered'); - } - - async startSession(config: CodexSessionConfig, options?: { signal?: AbortSignal }): Promise { - if (!this.connected) await this.connect(); - - logger.debug('[CodexMCP] Starting Codex session:', config); - - const response = await this.client.callTool({ - name: 'codex', - arguments: config as any - }, undefined, { - signal: options?.signal, - timeout: DEFAULT_TIMEOUT, - // maxTotalTimeout: 10000000000 - }); - - logger.debug('[CodexMCP] startSession response:', response); - - // Extract session / conversation identifiers from response if present - this.extractIdentifiers(response); - - return response as CodexToolResponse; - } - - async continueSession(prompt: string, options?: { signal?: AbortSignal }): Promise { - if (!this.connected) await this.connect(); - - if (!this.sessionId) { - throw new Error('No active session. Call startSession first.'); - } - - if (!this.conversationId) { - // Some Codex deployments reuse the session ID as the conversation identifier - this.conversationId = this.sessionId; - logger.debug('[CodexMCP] conversationId missing, defaulting to sessionId:', this.conversationId); - } - - const args = { sessionId: this.sessionId, conversationId: this.conversationId, prompt }; - logger.debug('[CodexMCP] Continuing Codex session:', args); - - const response = await this.client.callTool({ - name: 'codex-reply', - arguments: args - }, undefined, { - signal: options?.signal, - timeout: DEFAULT_TIMEOUT - }); - - logger.debug('[CodexMCP] continueSession response:', response); - this.extractIdentifiers(response); - - return response as CodexToolResponse; - } - - - private updateIdentifiersFromEvent(event: any): void { - if (!event || typeof event !== 'object') { - return; - } - - const candidates: any[] = [event]; - if (event.data && typeof event.data === 'object') { - candidates.push(event.data); - } - - for (const candidate of candidates) { - const sessionId = candidate.session_id ?? candidate.sessionId; - if (sessionId) { - this.sessionId = sessionId; - logger.debug('[CodexMCP] Session ID extracted from event:', this.sessionId); - } - - const conversationId = candidate.conversation_id ?? candidate.conversationId; - if (conversationId) { - this.conversationId = conversationId; - logger.debug('[CodexMCP] Conversation ID extracted from event:', this.conversationId); - } - } - } - private extractIdentifiers(response: any): void { - const meta = response?.meta || {}; - if (meta.sessionId) { - this.sessionId = meta.sessionId; - logger.debug('[CodexMCP] Session ID extracted:', this.sessionId); - } else if (response?.sessionId) { - this.sessionId = response.sessionId; - logger.debug('[CodexMCP] Session ID extracted:', this.sessionId); - } - - if (meta.conversationId) { - this.conversationId = meta.conversationId; - logger.debug('[CodexMCP] Conversation ID extracted:', this.conversationId); - } else if (response?.conversationId) { - this.conversationId = response.conversationId; - logger.debug('[CodexMCP] Conversation ID extracted:', this.conversationId); - } - - const content = response?.content; - if (Array.isArray(content)) { - for (const item of content) { - if (!this.sessionId && item?.sessionId) { - this.sessionId = item.sessionId; - logger.debug('[CodexMCP] Session ID extracted from content:', this.sessionId); - } - if (!this.conversationId && item && typeof item === 'object' && 'conversationId' in item && item.conversationId) { - this.conversationId = item.conversationId; - logger.debug('[CodexMCP] Conversation ID extracted from content:', this.conversationId); - } - } - } - } - - getSessionId(): string | null { - return this.sessionId; - } - - hasActiveSession(): boolean { - return this.sessionId !== null; - } - - clearSession(): void { - // Store the previous session ID before clearing for potential resume - const previousSessionId = this.sessionId; - this.sessionId = null; - this.conversationId = null; - logger.debug('[CodexMCP] Session cleared, previous sessionId:', previousSessionId); - } - - /** - * Store the current session ID without clearing it, useful for abort handling - */ - storeSessionForResume(): string | null { - logger.debug('[CodexMCP] Storing session for potential resume:', this.sessionId); - return this.sessionId; - } - - /** - * Force close the Codex MCP transport and clear all session identifiers. - * Use this for permanent shutdown (e.g. kill/exit). Prefer `disconnect()` for - * transient connection resets where you may want to keep the session id. - */ - async forceCloseSession(): Promise { - logger.debug('[CodexMCP] Force closing session'); - try { - await this.disconnect(); - } finally { - this.clearSession(); - } - logger.debug('[CodexMCP] Session force-closed'); - } - - async disconnect(): Promise { - if (!this.connected) return; - - // Capture pid in case we need to force-kill - const pid = this.transport?.pid ?? null; - logger.debug(`[CodexMCP] Disconnecting; child pid=${pid ?? 'none'}`); - - try { - // Ask client to close the transport - logger.debug('[CodexMCP] client.close begin'); - await this.client.close(); - logger.debug('[CodexMCP] client.close done'); - } catch (e) { - logger.debug('[CodexMCP] Error closing client, attempting transport close directly', e); - try { - logger.debug('[CodexMCP] transport.close begin'); - await this.transport?.close?.(); - logger.debug('[CodexMCP] transport.close done'); - } catch {} - } - - // As a last resort, if child still exists, send SIGKILL - if (pid) { - try { - process.kill(pid, 0); // check if alive - logger.debug('[CodexMCP] Child still alive, sending SIGKILL'); - try { process.kill(pid, 'SIGKILL'); } catch {} - } catch { /* not running */ } - } - - this.transport = null; - this.connected = false; - // Preserve session/conversation identifiers for potential reconnection / recovery flows. - logger.debug(`[CodexMCP] Disconnected; session ${this.sessionId ?? 'none'} preserved`); - } -} diff --git a/cli/src/codex/happyMcpStdioBridge.ts b/cli/src/codex/happyMcpStdioBridge.ts deleted file mode 100644 index d8e3a5d4f6..0000000000 --- a/cli/src/codex/happyMcpStdioBridge.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Happy MCP STDIO Bridge - * - * Minimal STDIO MCP server exposing a single tool `change_title`. - * On invocation it forwards the tool call to an existing Happy HTTP MCP server - * using the StreamableHTTPClientTransport. - * - * Configure the target HTTP MCP URL via env var `HAPPY_HTTP_MCP_URL` or - * via CLI flag `--url `. - * - * Note: This process must not print to stdout as it would break MCP STDIO. - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import { z } from 'zod'; - -function parseArgs(argv: string[]): { url: string | null } { - let url: string | null = null; - for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - if (a === '--url' && i + 1 < argv.length) { - url = argv[i + 1]; - i++; - } - } - return { url }; -} - -async function main() { - // Resolve target HTTP MCP URL - const { url: urlFromArgs } = parseArgs(process.argv.slice(2)); - const baseUrl = urlFromArgs || process.env.HAPPY_HTTP_MCP_URL || ''; - - if (!baseUrl) { - // Write to stderr; never stdout. - process.stderr.write( - '[happy-mcp] Missing target URL. Set HAPPY_HTTP_MCP_URL or pass --url \n' - ); - process.exit(2); - } - - let httpClient: Client | null = null; - - async function ensureHttpClient(): Promise { - if (httpClient) return httpClient; - const client = new Client( - { name: 'happy-stdio-bridge', version: '1.0.0' }, - { capabilities: {} } - ); - - const transport = new StreamableHTTPClientTransport(new URL(baseUrl)); - await client.connect(transport); - httpClient = client; - return client; - } - - // Create STDIO MCP server - const server = new McpServer({ - name: 'Happy MCP Bridge', - version: '1.0.0', - }); - - // Register the single tool and forward to HTTP MCP - server.registerTool( - 'change_title', - { - description: 'Change the title of the current chat session', - title: 'Change Chat Title', - inputSchema: { - title: z.string().describe('The new title for the chat session'), - }, - }, - async (args) => { - try { - const client = await ensureHttpClient(); - const response = await client.callTool({ name: 'change_title', arguments: args }); - // Pass-through response from HTTP server - return response as any; - } catch (error) { - return { - content: [ - { type: 'text', text: `Failed to change chat title: ${error instanceof Error ? error.message : String(error)}` }, - ], - isError: true, - }; - } - } - ); - - // Start STDIO transport - const stdio = new StdioServerTransport(); - await server.connect(stdio); -} - -// Start and surface fatal errors to stderr only -main().catch((err) => { - try { - process.stderr.write(`[happy-mcp] Fatal: ${err instanceof Error ? err.message : String(err)}\n`); - } finally { - process.exit(1); - } -}); - diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts deleted file mode 100644 index fdaf9b29dd..0000000000 --- a/cli/src/codex/runCodex.ts +++ /dev/null @@ -1,801 +0,0 @@ -import { render } from "ink"; -import React from "react"; -import { ApiClient } from '@/api/api'; -import { CodexMcpClient } from './codexMcpClient'; -import { CodexPermissionHandler } from './utils/permissionHandler'; -import { ReasoningProcessor } from './utils/reasoningProcessor'; -import { DiffProcessor } from './utils/diffProcessor'; -import { randomUUID } from 'node:crypto'; -import { logger } from '@/ui/logger'; -import { Credentials, readSettings } from '@/persistence'; -import { initialMachineMetadata } from '@/daemon/run'; -import { configuration } from '@/configuration'; -import packageJson from '../../package.json'; -import os from 'node:os'; -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"; -import { CodexDisplay } from "@/ui/ink/CodexDisplay"; -import { trimIdent } from "@/utils/trimIdent"; -import type { CodexSessionConfig } from './types'; -import { CHANGE_TITLE_INSTRUCTION } from '@/gemini/constants'; -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; - queueSize: () => number; - shouldExit: boolean; - sendReady: () => void; - notify?: () => void; -}; - -/** - * Notify connected clients when Codex finishes processing and the queue is idle. - * Returns true when a ready event was emitted. - */ -export function emitReadyIfIdle({ pending, queueSize, shouldExit, sendReady, notify }: ReadyEventOptions): boolean { - if (shouldExit) { - return false; - } - if (pending) { - return false; - } - if (queueSize() > 0) { - return false; - } - - sendReady(); - notify?.(); - return true; -} - -/** - * Main entry point for the codex command with ink UI - */ -export async function runCodex(opts: { - credentials: Credentials; - startedBy?: 'daemon' | 'terminal'; -}): Promise { - // Use shared PermissionMode type for cross-agent compatibility - type PermissionMode = import('@/api/types').PermissionMode; - interface EnhancedMode { - permissionMode: PermissionMode; - model?: string; - } - - // - // Define session - // - - 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 - logger.debug(`[codex] Starting with options: startedBy=${opts.startedBy || 'terminal'}`); - - // - // Machine - // - - const settings = await readSettings(); - let machineId = settings?.machineId; - if (!machineId) { - console.error(`[START] No machine ID found in settings, which is unexpected since authAndSetupMachineIfNeeded should have created it. Please report this issue on https://github.com/slopus/happy-cli/issues`); - process.exit(1); - } - logger.debug(`Using machineId: ${machineId}`); - await api.getOrCreateMachine({ - machineId, - metadata: initialMachineMetadata - }); - - // - // Create session - // - - const { state, metadata } = createSessionMetadata({ - flavor: 'codex', - machineId, - startedBy: opts.startedBy - }); - const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); - - // 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); - } - } - - const messageQueue = new MessageQueue2((mode) => hashObject({ - permissionMode: mode.permissionMode, - model: mode.model, - })); - - // Track current overrides to apply per message - // 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 (accept all modes, will be mapped in switch statement) - let messagePermissionMode = currentPermissionMode; - if (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)'}`); - } - - // Resolve model; explicit null resets to default (undefined) - let messageModel = currentModel; - if (message.meta?.hasOwnProperty('model')) { - messageModel = message.meta.model || undefined; - currentModel = messageModel; - logger.debug(`[Codex] Model updated from user message: ${messageModel || 'reset to default'}`); - } else { - logger.debug(`[Codex] User message received with no model override, using current: ${currentModel || 'default'}`); - } - - const enhancedMode: EnhancedMode = { - permissionMode: messagePermissionMode || 'default', - model: messageModel, - }; - messageQueue.push(message.content.text, enhancedMode); - }); - let thinking = false; - session.keepAlive(thinking, 'remote'); - // Periodic keep-alive; store handle so we can clear on exit - const keepAliveInterval = setInterval(() => { - session.keepAlive(thinking, 'remote'); - }, 2000); - - const sendReady = () => { - session.sendSessionEvent({ type: 'ready' }); - try { - api.push().sendToAllDevices( - "It's ready!", - 'Codex is waiting for your command', - { sessionId: session.sessionId } - ); - } catch (pushError) { - logger.debug('[Codex] Failed to send ready push', pushError); - } - }; - - // Debug helper: log active handles/requests if DEBUG is enabled - function logActiveHandles(tag: string) { - if (!process.env.DEBUG) return; - const anyProc: any = process as any; - const handles = typeof anyProc._getActiveHandles === 'function' ? anyProc._getActiveHandles() : []; - const requests = typeof anyProc._getActiveRequests === 'function' ? anyProc._getActiveRequests() : []; - logger.debug(`[codex][handles] ${tag}: handles=${handles.length} requests=${requests.length}`); - try { - const kinds = handles.map((h: any) => (h && h.constructor ? h.constructor.name : typeof h)); - logger.debug(`[codex][handles] kinds=${JSON.stringify(kinds)}`); - } catch { } - } - - // - // Abort handling - // IMPORTANT: There are two different operations: - // 1. Abort (handleAbort): Stops the current inference/task but keeps the session alive - // - Used by the 'abort' RPC from mobile app - // - Similar to Claude Code's abort behavior - // - Allows continuing with new prompts after aborting - // 2. Kill (handleKillSession): Terminates the entire process - // - Used by the 'killSession' RPC - // - Completely exits the CLI process - // - - let abortController = new AbortController(); - let shouldExit = false; - let storedSessionIdForResume: string | null = null; - - /** - * Handles aborting the current task/inference without exiting the process. - * This is the equivalent of Claude Code's abort - it stops what's currently - * happening but keeps the session alive for new prompts. - */ - async function handleAbort() { - logger.debug('[Codex] Abort requested - stopping current task'); - try { - // Store the current session ID before aborting for potential resume - if (client.hasActiveSession()) { - storedSessionIdForResume = client.storeSessionForResume(); - logger.debug('[Codex] Stored session for resume:', storedSessionIdForResume); - } - - abortController.abort(); - reasoningProcessor.abort(); - logger.debug('[Codex] Abort completed - session remains active'); - } catch (error) { - logger.debug('[Codex] Error during abort:', error); - } finally { - abortController = new AbortController(); - } - } - - /** - * Handles session termination and process exit. - * This is called when the session needs to be completely killed (not just aborted). - * Abort stops the current inference but keeps the session alive. - * Kill terminates the entire process. - */ - const handleKillSession = async () => { - logger.debug('[Codex] Kill session requested - terminating process'); - await handleAbort(); - logger.debug('[Codex] Abort completed, proceeding with termination'); - - try { - // Update lifecycle state to archived before closing - if (session) { - session.updateMetadata((currentMetadata) => ({ - ...currentMetadata, - lifecycleState: 'archived', - lifecycleStateSince: Date.now(), - archivedBy: 'cli', - archiveReason: 'User terminated' - })); - - // Send session death message - session.sendSessionDeath(); - await session.flush(); - await session.close(); - } - - // Force close Codex transport (best-effort) so we don't leave stray processes - try { - await client.forceCloseSession(); - } catch (e) { - logger.debug('[Codex] Error while force closing Codex session during termination', e); - } - - // Stop caffeinate - stopCaffeinate(); - - // Stop Happy MCP server - happyServer.stop(); - - logger.debug('[Codex] Session termination complete, exiting'); - process.exit(0); - } catch (error) { - logger.debug('[Codex] Error during session termination:', error); - process.exit(1); - } - }; - - // Register abort handler - session.rpcHandlerManager.registerHandler('abort', handleAbort); - - registerKillSessionHandler(session.rpcHandlerManager, handleKillSession); - - // - // Initialize Ink UI - // - - const messageBuffer = new MessageBuffer(); - const hasTTY = process.stdout.isTTY && process.stdin.isTTY; - let inkInstance: any = null; - - if (hasTTY) { - console.clear(); - inkInstance = render(React.createElement(CodexDisplay, { - messageBuffer, - logPath: process.env.DEBUG ? logger.getLogPath() : undefined, - onExit: async () => { - // Exit the agent - logger.debug('[codex]: Exiting agent via Ctrl-C'); - shouldExit = true; - await handleAbort(); - } - }), { - exitOnCtrlC: false, - patchConsole: false - }); - } - - if (hasTTY) { - process.stdin.resume(); - if (process.stdin.isTTY) { - process.stdin.setRawMode(true); - } - process.stdin.setEncoding("utf8"); - } - - // - // Start Context - // - - const client = new CodexMcpClient(); - - // Helper: find Codex session transcript for a given sessionId - function findCodexResumeFile(sessionId: string | null): string | null { - if (!sessionId) return null; - try { - const codexHomeDir = process.env.CODEX_HOME || join(os.homedir(), '.codex'); - const rootDir = join(codexHomeDir, 'sessions'); - - // Recursively collect all files under the sessions directory - function collectFilesRecursive(dir: string, acc: string[] = []): string[] { - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return acc; - } - for (const entry of entries) { - const full = join(dir, entry.name); - if (entry.isDirectory()) { - collectFilesRecursive(full, acc); - } else if (entry.isFile()) { - acc.push(full); - } - } - return acc; - } - - const candidates = collectFilesRecursive(rootDir) - .filter(full => full.endsWith(`-${sessionId}.jsonl`)) - .filter(full => { - try { return fs.statSync(full).isFile(); } catch { return false; } - }) - .sort((a, b) => { - const sa = fs.statSync(a).mtimeMs; - const sb = fs.statSync(b).mtimeMs; - return sb - sa; // newest first - }); - return candidates[0] || null; - } catch { - return null; - } - } - permissionHandler = new CodexPermissionHandler(session); - const reasoningProcessor = new ReasoningProcessor((message) => { - // Callback to send messages directly from the processor - session.sendCodexMessage(message); - }); - const diffProcessor = new DiffProcessor((message) => { - // Callback to send messages directly from the processor - session.sendCodexMessage(message); - }); - client.setPermissionHandler(permissionHandler); - client.setHandler((msg) => { - logger.debug(`[Codex] MCP message: ${JSON.stringify(msg)}`); - - // Add messages to the ink UI buffer based on message type - if (msg.type === 'agent_message') { - messageBuffer.addMessage(msg.message, 'assistant'); - } else if (msg.type === 'agent_reasoning_delta') { - // Skip reasoning deltas in the UI to reduce noise - } else if (msg.type === 'agent_reasoning') { - messageBuffer.addMessage(`[Thinking] ${msg.text.substring(0, 100)}...`, 'system'); - } else if (msg.type === 'exec_command_begin') { - messageBuffer.addMessage(`Executing: ${msg.command}`, 'tool'); - } else if (msg.type === 'exec_command_end') { - const output = msg.output || msg.error || 'Command completed'; - const truncatedOutput = output.substring(0, 200); - messageBuffer.addMessage( - `Result: ${truncatedOutput}${output.length > 200 ? '...' : ''}`, - 'result' - ); - } else if (msg.type === 'task_started') { - messageBuffer.addMessage('Starting task...', 'status'); - } else if (msg.type === 'task_complete') { - messageBuffer.addMessage('Task completed', 'status'); - sendReady(); - } else if (msg.type === 'turn_aborted') { - messageBuffer.addMessage('Turn aborted', 'status'); - sendReady(); - } - - if (msg.type === 'task_started') { - if (!thinking) { - logger.debug('thinking started'); - thinking = true; - session.keepAlive(thinking, 'remote'); - } - } - if (msg.type === 'task_complete' || msg.type === 'turn_aborted') { - if (thinking) { - logger.debug('thinking completed'); - thinking = false; - session.keepAlive(thinking, 'remote'); - } - // Reset diff processor on task end or abort - diffProcessor.reset(); - } - if (msg.type === 'agent_reasoning_section_break') { - // Reset reasoning processor for new section - reasoningProcessor.handleSectionBreak(); - } - if (msg.type === 'agent_reasoning_delta') { - // Process reasoning delta - tool calls are sent automatically via callback - reasoningProcessor.processDelta(msg.delta); - } - if (msg.type === 'agent_reasoning') { - // Complete the reasoning section - tool results or reasoning messages sent via callback - reasoningProcessor.complete(msg.text); - } - if (msg.type === 'agent_message') { - session.sendCodexMessage({ - type: 'message', - message: msg.message, - id: randomUUID() - }); - } - if (msg.type === 'exec_command_begin' || msg.type === 'exec_approval_request') { - let { call_id, type, ...inputs } = msg; - session.sendCodexMessage({ - type: 'tool-call', - name: 'CodexBash', - callId: call_id, - input: inputs, - id: randomUUID() - }); - } - if (msg.type === 'exec_command_end') { - let { call_id, type, ...output } = msg; - session.sendCodexMessage({ - type: 'tool-call-result', - callId: call_id, - output: output, - id: randomUUID() - }); - } - if (msg.type === 'token_count') { - session.sendCodexMessage({ - ...msg, - id: randomUUID() - }); - } - if (msg.type === 'patch_apply_begin') { - // Handle the start of a patch operation - let { call_id, auto_approved, changes } = msg; - - // Add UI feedback for patch operation - const changeCount = Object.keys(changes).length; - const filesMsg = changeCount === 1 ? '1 file' : `${changeCount} files`; - messageBuffer.addMessage(`Modifying ${filesMsg}...`, 'tool'); - - // Send tool call message - session.sendCodexMessage({ - type: 'tool-call', - name: 'CodexPatch', - callId: call_id, - input: { - auto_approved, - changes - }, - id: randomUUID() - }); - } - if (msg.type === 'patch_apply_end') { - // Handle the end of a patch operation - let { call_id, stdout, stderr, success } = msg; - - // Add UI feedback for completion - if (success) { - const message = stdout || 'Files modified successfully'; - messageBuffer.addMessage(message.substring(0, 200), 'result'); - } else { - const errorMsg = stderr || 'Failed to modify files'; - messageBuffer.addMessage(`Error: ${errorMsg.substring(0, 200)}`, 'result'); - } - - // Send tool call result message - session.sendCodexMessage({ - type: 'tool-call-result', - callId: call_id, - output: { - stdout, - stderr, - success - }, - id: randomUUID() - }); - } - if (msg.type === 'turn_diff') { - // Handle turn_diff messages and track unified_diff changes - if (msg.unified_diff) { - diffProcessor.processDiff(msg.unified_diff); - } - } - }); - - // Start Happy MCP server (HTTP) and prepare STDIO bridge config for Codex - const happyServer = await startHappyServer(session); - const bridgeCommand = join(projectPath(), 'bin', 'happy-mcp.mjs'); - const mcpServers = { - happy: { - command: bridgeCommand, - args: ['--url', happyServer.url] - } - } as const; - let first = true; - - try { - logger.debug('[codex]: client.connect begin'); - await client.connect(); - logger.debug('[codex]: client.connect done'); - let wasCreated = false; - let currentModeHash: string | null = null; - let pending: { message: string; mode: EnhancedMode; isolate: boolean; hash: string } | null = null; - // If we restart (e.g., mode change), use this to carry a resume file - let nextExperimentalResume: string | null = null; - - while (!shouldExit) { - logActiveHandles('loop-top'); - // Get next batch; respect mode boundaries like Claude - let message: { message: string; mode: EnhancedMode; isolate: boolean; hash: string } | null = pending; - pending = null; - if (!message) { - // Capture the current signal to distinguish idle-abort from queue close - const waitSignal = abortController.signal; - const batch = await messageQueue.waitForMessagesAndGetAsString(waitSignal); - if (!batch) { - // If wait was aborted (e.g., remote abort with no active inference), ignore and continue - if (waitSignal.aborted && !shouldExit) { - logger.debug('[codex]: Wait aborted while idle; ignoring and continuing'); - continue; - } - logger.debug(`[codex]: batch=${!!batch}, shouldExit=${shouldExit}`); - break; - } - message = batch; - } - - // Defensive check for TS narrowing - if (!message) { - break; - } - - // If a session exists and mode changed, restart on next iteration - if (wasCreated && currentModeHash && message.hash !== currentModeHash) { - logger.debug('[Codex] Mode changed – restarting Codex session'); - messageBuffer.addMessage('═'.repeat(40), 'status'); - messageBuffer.addMessage('Starting new Codex session (mode changed)...', 'status'); - // Capture previous sessionId and try to find its transcript to resume - try { - const prevSessionId = client.getSessionId(); - nextExperimentalResume = findCodexResumeFile(prevSessionId); - if (nextExperimentalResume) { - logger.debug(`[Codex] Found resume file for session ${prevSessionId}: ${nextExperimentalResume}`); - messageBuffer.addMessage('Resuming previous context…', 'status'); - } else { - logger.debug('[Codex] No resume file found for previous session'); - } - } catch (e) { - logger.debug('[Codex] Error while searching resume file', e); - } - client.clearSession(); - wasCreated = false; - currentModeHash = null; - pending = message; - // Reset processors/permissions like end-of-turn cleanup - permissionHandler.reset(); - reasoningProcessor.abort(); - diffProcessor.reset(); - thinking = false; - session.keepAlive(thinking, 'remote'); - continue; - } - - // Display user messages in the UI - messageBuffer.addMessage(message.message, 'user'); - currentModeHash = message.hash; - - try { - // Map permission mode to approval policy and sandbox for startSession - const approvalPolicy = (() => { - switch (message.mode.permissionMode) { - // 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) { - // 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 - } - })(); - - if (!wasCreated) { - const startConfig: CodexSessionConfig = { - prompt: first ? message.message + '\n\n' + CHANGE_TITLE_INSTRUCTION : message.message, - sandbox, - 'approval-policy': approvalPolicy, - config: { mcp_servers: mcpServers } - }; - if (message.mode.model) { - startConfig.model = message.mode.model; - } - - // Check for resume file from multiple sources - let resumeFile: string | null = null; - - // Priority 1: Explicit resume file from mode change - if (nextExperimentalResume) { - resumeFile = nextExperimentalResume; - nextExperimentalResume = null; // consume once - logger.debug('[Codex] Using resume file from mode change:', resumeFile); - } - // Priority 2: Resume from stored abort session - else if (storedSessionIdForResume) { - const abortResumeFile = findCodexResumeFile(storedSessionIdForResume); - if (abortResumeFile) { - resumeFile = abortResumeFile; - logger.debug('[Codex] Using resume file from aborted session:', resumeFile); - messageBuffer.addMessage('Resuming from aborted session...', 'status'); - } - storedSessionIdForResume = null; // consume once - } - - // Apply resume file if found - if (resumeFile) { - (startConfig.config as any).experimental_resume = resumeFile; - } - - await client.startSession( - startConfig, - { signal: abortController.signal } - ); - wasCreated = true; - first = false; - } else { - const response = await client.continueSession( - message.message, - { signal: abortController.signal } - ); - logger.debug('[Codex] continueSession response:', response); - } - } catch (error) { - logger.warn('Error in codex session:', error); - const isAbortError = error instanceof Error && error.name === 'AbortError'; - - if (isAbortError) { - messageBuffer.addMessage('Aborted by user', 'status'); - session.sendSessionEvent({ type: 'message', message: 'Aborted by user' }); - // Abort cancels the current task/inference but keeps the Codex session alive. - // Do not clear session state here; the next user message should continue on the - // existing session if possible. - } else { - messageBuffer.addMessage('Process exited unexpectedly', 'status'); - session.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' }); - // For unexpected exits, try to store session for potential recovery - if (client.hasActiveSession()) { - storedSessionIdForResume = client.storeSessionForResume(); - logger.debug('[Codex] Stored session after unexpected error:', storedSessionIdForResume); - } - } - } finally { - // Reset permission handler, reasoning processor, and diff processor - permissionHandler.reset(); - reasoningProcessor.abort(); // Use abort to properly finish any in-progress tool calls - diffProcessor.reset(); - thinking = false; - session.keepAlive(thinking, 'remote'); - emitReadyIfIdle({ - pending, - queueSize: () => messageQueue.size(), - shouldExit, - sendReady, - }); - logActiveHandles('after-turn'); - } - } - - } finally { - // 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(); - logger.debug('[codex]: flush begin'); - await session.flush(); - logger.debug('[codex]: flush done'); - logger.debug('[codex]: session.close begin'); - await session.close(); - logger.debug('[codex]: session.close done'); - } catch (e) { - logger.debug('[codex]: Error while closing session', e); - } - logger.debug('[codex]: client.forceCloseSession begin'); - await client.forceCloseSession(); - logger.debug('[codex]: client.forceCloseSession done'); - // Stop Happy MCP server - logger.debug('[codex]: happyServer.stop'); - happyServer.stop(); - - // Clean up ink UI - if (process.stdin.isTTY) { - logger.debug('[codex]: setRawMode(false)'); - try { process.stdin.setRawMode(false); } catch { } - } - // Stop reading from stdin so the process can exit - if (hasTTY) { - logger.debug('[codex]: stdin.pause()'); - try { process.stdin.pause(); } catch { } - } - // Clear periodic keep-alive to avoid keeping event loop alive - logger.debug('[codex]: clearInterval(keepAlive)'); - clearInterval(keepAliveInterval); - if (inkInstance) { - logger.debug('[codex]: inkInstance.unmount()'); - inkInstance.unmount(); - } - messageBuffer.clear(); - - logActiveHandles('cleanup-end'); - logger.debug('[codex]: Final cleanup completed'); - } -} diff --git a/cli/src/codex/types.ts b/cli/src/codex/types.ts deleted file mode 100644 index efaffb67a6..0000000000 --- a/cli/src/codex/types.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Type definitions for Codex MCP integration - */ - -export interface CodexSessionConfig { - prompt: string; - 'approval-policy'?: 'untrusted' | 'on-failure' | 'on-request' | 'never'; - 'base-instructions'?: string; - config?: Record; - cwd?: string; - 'include-plan-tool'?: boolean; - model?: string; - profile?: string; - sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access'; -} - -export interface CodexToolResponse { - content: Array<{ - type: 'text' | 'image' | 'resource'; - text?: string; - data?: any; - mimeType?: string; - }>; - isError?: boolean; -} diff --git a/cli/src/codex/utils/diffProcessor.ts b/cli/src/codex/utils/diffProcessor.ts deleted file mode 100644 index 8dd427ba71..0000000000 --- a/cli/src/codex/utils/diffProcessor.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Diff Processor - Handles turn_diff messages and tracks unified_diff changes - * - * This processor tracks changes to the unified_diff field in turn_diff messages - * and sends CodexDiff tool calls when the diff changes from its previous value. - */ - -import { randomUUID } from 'node:crypto'; -import { logger } from '@/ui/logger'; - -export interface DiffToolCall { - type: 'tool-call'; - name: 'CodexDiff'; - callId: string; - input: { - unified_diff: string; - }; - id: string; -} - -export interface DiffToolResult { - type: 'tool-call-result'; - callId: string; - output: { - status: 'completed'; - }; - id: string; -} - -export class DiffProcessor { - private previousDiff: string | null = null; - private onMessage: ((message: any) => void) | null = null; - - constructor(onMessage?: (message: any) => void) { - this.onMessage = onMessage || null; - } - - /** - * Process a turn_diff message and check if the unified_diff has changed - */ - processDiff(unifiedDiff: string): void { - // Check if the diff has changed from the previous value - if (this.previousDiff !== unifiedDiff) { - logger.debug('[DiffProcessor] Unified diff changed, sending CodexDiff tool call'); - - // Generate a unique call ID for this diff - const callId = randomUUID(); - - // Send tool call for the diff change - const toolCall: DiffToolCall = { - type: 'tool-call', - name: 'CodexDiff', - callId: callId, - input: { - unified_diff: unifiedDiff - }, - id: randomUUID() - }; - - this.onMessage?.(toolCall); - - // Immediately send the tool result to mark it as completed - const toolResult: DiffToolResult = { - type: 'tool-call-result', - callId: callId, - output: { - status: 'completed' - }, - id: randomUUID() - }; - - this.onMessage?.(toolResult); - } - - // Update the stored diff value - this.previousDiff = unifiedDiff; - logger.debug('[DiffProcessor] Updated stored diff'); - } - - /** - * Reset the processor state (called on task_complete or turn_aborted) - */ - reset(): void { - logger.debug('[DiffProcessor] Resetting diff state'); - this.previousDiff = null; - } - - /** - * Set the message callback for sending messages directly - */ - setMessageCallback(callback: (message: any) => void): void { - this.onMessage = callback; - } - - /** - * Get the current diff value - */ - getCurrentDiff(): string | null { - return this.previousDiff; - } -} \ No newline at end of file diff --git a/cli/src/codex/utils/permissionHandler.ts b/cli/src/codex/utils/permissionHandler.ts deleted file mode 100644 index 0720211d4f..0000000000 --- a/cli/src/codex/utils/permissionHandler.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Codex Permission Handler - * - * Handles tool permission requests and responses for Codex sessions. - * Extends BasePermissionHandler with Codex-specific configuration. - */ - -import { logger } from "@/ui/logger"; -import { ApiSessionClient } from "@/api/apiSession"; -import { - BasePermissionHandler, - PermissionResult, - PendingRequest -} from '@/utils/BasePermissionHandler'; - -// Re-export types for backwards compatibility -export type { PermissionResult, PendingRequest }; - -/** - * Codex-specific permission handler. - */ -export class CodexPermissionHandler extends BasePermissionHandler { - constructor(session: ApiSessionClient) { - super(session); - } - - protected getLogPrefix(): string { - return '[Codex]'; - } - - /** - * Handle a tool permission request - * @param toolCallId - The unique ID of the tool call - * @param toolName - The name of the tool being called - * @param input - The input parameters for the tool - * @returns Promise resolving to permission result - */ - async handleToolCall( - toolCallId: string, - toolName: string, - input: unknown - ): Promise { - return new Promise((resolve, reject) => { - // Store the pending request - this.pendingRequests.set(toolCallId, { - resolve, - reject, - toolName, - input - }); - - // Update agent state with pending request - this.addPendingRequestToState(toolCallId, toolName, input); - - logger.debug(`${this.getLogPrefix()} Permission request sent for tool: ${toolName} (${toolCallId})`); - }); - } -} \ No newline at end of file diff --git a/cli/src/daemon/CLAUDE.md b/cli/src/daemon/CLAUDE.md deleted file mode 100644 index d13a9ffd2c..0000000000 --- a/cli/src/daemon/CLAUDE.md +++ /dev/null @@ -1,455 +0,0 @@ -# Happy CLI Daemon: Control Flow and Lifecycle - -The daemon is a persistent background process that manages Happy sessions, enables remote control from the mobile app, and handles auto-updates when the CLI version changes. - -## 1. Daemon Lifecycle - -### Starting the Daemon - -Command: `happy daemon start` - -Control Flow: -1. `src/index.ts` receives `daemon start` command -2. Spawns detached process via `spawnHappyCLI(['daemon', 'start-sync'], { detached: true })` -3. New process calls `startDaemon()` from `src/daemon/run.ts` -4. `startDaemon()` performs startup: - - Sets up shutdown promise and handlers (SIGINT, SIGTERM, uncaughtException, unhandledRejection) - - Version check: `isDaemonRunningSameVersion()` reads daemon.state.json, compares `startedWithCliVersion` with `configuration.currentCliVersion` - - If version mismatch: calls `stopDaemon()` to kill old daemon before proceeding - - If same version running: exits with "Daemon already running" - - Lock acquisition: `acquireDaemonLock()` creates exclusive lock file to prevent multiple daemons - - Authentication: `authAndSetupMachineIfNeeded()` ensures credentials exist - - State persistence: writes PID, version, HTTP port to daemon.state.json - - HTTP server: starts on random port for local CLI control (list, stop, spawn) - - WebSocket: establishes persistent connection to backend via `ApiMachineClient` - - RPC registration: exposes `spawn-happy-session`, `stop-session`, `requestShutdown` handlers - - Heartbeat loop: every 60s (or HAPPY_DAEMON_HEARTBEAT_INTERVAL) checks for version updates and prunes dead sessions -5. Awaits shutdown promise which resolves when: - - OS signal received (SIGINT/SIGTERM) - - HTTP `/stop` endpoint called - - RPC `requestShutdown` invoked - - Uncaught exception occurs -6. On shutdown, `cleanupAndShutdown()` performs: - - Clears heartbeat interval - - Updates daemon state to "shutting-down" on backend - - Disconnects WebSocket - - Stops HTTP server - - Deletes daemon.state.json - - Releases lock file - - Exits process - -### Version Mismatch Auto-Update - -The daemon detects when `npm upgrade happy-coder` occurs: -1. Heartbeat reads package.json from disk -2. Compares `JSON.parse(package.json).version` with compiled `configuration.currentCliVersion` -3. If mismatch detected: - - Spawns new daemon via `spawnHappyCLI(['daemon', 'start'])` - - Hangs and waits to be killed -4. New daemon starts, sees old daemon.state.json version != its compiled version -5. New daemon calls `stopDaemon()` which tries HTTP `/stop`, falls back to SIGKILL -6. New daemon takes over - -### Stopping the Daemon - -Command: `happy daemon stop` - -Control Flow: -1. `stopDaemon()` in `controlClient.ts` reads daemon.state.json -2. Attempts graceful shutdown via HTTP POST to `/stop` -3. Daemon receives request, calls `cleanupAndShutdown()`: - - Updates backend status to "shutting-down" - - Closes WebSocket connection - - Stops HTTP server - - Deletes daemon.state.json - - Releases lock file -4. If HTTP fails, falls back to `process.kill(pid, 'SIGKILL')` - -## 2. Session Management - -### Daemon-Spawned Sessions (Remote) - -Initiated by mobile app via backend RPC: -1. Backend forwards RPC `spawn-happy-session` to daemon via WebSocket -2. `ApiMachineClient` invokes `spawnSession()` handler -3. `spawnSession()`: - - Creates directory if needed - - Spawns detached Happy process with `--happy-starting-mode remote --started-by daemon` - - Adds to `pidToTrackedSession` map - - Sets up 10-second awaiter for session webhook -4. New Happy process: - - Creates session with backend, receives `happySessionId` - - Calls `notifyDaemonSessionStarted()` to POST to daemon's `/session-started` -5. Daemon updates tracking with `happySessionId`, resolves awaiter -6. RPC returns session info to mobile app - -### Terminal-Spawned Sessions - -User runs `happy` directly: -1. CLI auto-starts daemon if configured -2. Happy process calls `notifyDaemonSessionStarted()` -3. Daemon receives webhook, creates `TrackedSession` with `startedBy: 'happy directly...'` -4. Session tracked for health monitoring - -### Session Termination - -Via RPC `stop-session` or health check: -1. `stopSession()` finds session by `happySessionId` -2. Sends SIGTERM to process -3. `on('exit')` handler removes from tracking map - -## 3. HTTP Control Server - -Local HTTP server (127.0.0.1 only) provides: -- `/session-started` - webhook for sessions to report themselves -- `/list` - returns tracked sessions -- `/stop-session` - terminates specific session -- `/spawn-session` - creates new session (used by integration tests) -- `/stop` - graceful daemon shutdown - -## 4. Process Discovery and Cleanup - -### Doctor Command - -`happy doctor` uses `ps aux | grep` to find all Happy processes: -- Production: matches `happy.mjs`, `happy-coder`, `dist/index.mjs` -- Development: matches `tsx.*src/index.ts` -- Categorizes by command args: daemon, daemon-spawned, user-session, doctor - -### Clean Runaway Processes - -`happy doctor clean`: -1. `findRunawayHappyProcesses()` filters for likely orphans -2. `killRunawayHappyProcesses()`: - - Sends SIGTERM - - Waits 1 second - - Sends SIGKILL if still alive - -## 5. State Persistence - -### daemon.state.json -```json -{ - "pid": 12345, - "httpPort": 50097, - "startTime": "8/24/2025, 6:46:22 PM", - "startedWithCliVersion": "0.9.0-6", - "lastHeartbeat": "8/24/2025, 6:47:22 PM", - "daemonLogPath": "/path/to/daemon.log" -} -``` - -### Lock File -- Created with O_EXCL flag for atomic acquisition -- Contains PID for debugging -- Prevents multiple daemon instances -- Cleaned up on graceful shutdown - -## 6. WebSocket Communication - -`ApiMachineClient` handles bidirectional communication: -- Daemon to Server: machine-alive, machine-update-metadata, machine-update-state -- Server to Daemon: rpc-request (spawn-happy-session, stop-session, requestShutdown) -- All data encrypted with TweetNaCl - -## 7. Integration Testing Challenges - -Version mismatch test simulates npm upgrade: -- Test modifies package.json, rebuilds with new version -- Daemon's compiled version != package.json on disk -- Critical timing: heartbeat interval must exceed rebuild time -- pkgroll doesn't update compiled imports, must use full yarn build - -# Improvements - -I do not like how - -- daemon.state.json file is getting hard removed when daemon exits or is stopped. We should keep it around and have 'state' field and 'stateReason' field that will explain why the daemon is in that state -- If the file is not found - we assume the daemon was never started or was cleaned out by the user or doctor -- If the file is found and corrupted - we should try to upgrade it to the latest version? or simply remove it if we have write access - -- posts helpers for daemon do not return typed results -- I don't like that daemonPost returns either response from daemon or { error: ... }. We should have consistent envelope type - -- we loose track of children processes when daemon exits / restarts - we should write them to the same state file? At least the pids should be there for doctor & cleanup - -- caffeinate process is not tracked in state at all & might become runaway -- caffeinate is also started by individual sesions - we should not do that for simpler cleanup - -- the port is not protected - lets encrypt something with a public portion of the secret key & send it as a signature along the rest of the unencrypted payload to the daemon - will make testing harder :/ - - -# Machine Sync Architecture - Separated Metadata & Daemon State - -## Data Structure (Similar to Session's metadata + agentState) - -```typescript -// Static machine information (rarely changes) -interface MachineMetadata { - host: string; // hostname - platform: string; // darwin, linux, win32 - happyCliVersion: string; - homeDir: string; - happyHomeDir: string; -} - -// Dynamic daemon state (frequently updated) -interface DaemonState { - status: 'running' | 'shutting-down' | 'offline'; - pid?: number; - httpPort?: number; - startedAt?: number; - shutdownRequestedAt?: number; - shutdownSource?: 'mobile-app' | 'cli' | 'os-signal' | 'unknown'; -} -``` - -## 1. CLI Startup Phase - -Checks if machine ID exists in settings: -- If not: creates ID locally only (so sessions can reference it) -- Does NOT create machine on server - that's daemon's job -- CLI doesn't manage machine details - all API & schema live in daemon subpackage - -## 2. Daemon Startup - Initial Registration - -### REST Request: `POST /v1/machines` -```json -{ - "id": "machine-uuid-123", - "metadata": "base64(encrypted({ - 'host': 'MacBook-Pro.local', - 'platform': 'darwin', - 'happyCliVersion': '1.0.0', - 'homeDir': '/Users/john', - 'happyHomeDir': '/Users/john/.happy' - }))", - "daemonState": "base64(encrypted({ - 'status': 'running', - 'pid': 12345, - 'httpPort': 8080, - 'startedAt': 1703001234567 - }))" -} -``` - -### Server Response: -```json -{ - "machine": { - "id": "machine-uuid-123", - "metadata": "base64(encrypted(...))", // echoed back - "metadataVersion": 1, - "daemonState": "base64(encrypted(...))", // echoed back - "daemonStateVersion": 1, - "active": true, - "lastActiveAt": 1703001234567, - "createdAt": 1703001234567, - "updatedAt": 1703001234567 - } -} -``` - -## 3. WebSocket Connection & Real-time Updates - -### Connection Handshake: -```javascript -io(serverUrl, { - auth: { - token: "auth-token", - clientType: "machine-scoped", - machineId: "machine-uuid-123" - } -}) -``` - -### Heartbeat (every 20s): -```json -// Client -> Server -socket.emit('machine-alive', { - "machineId": "machine-uuid-123", - "time": 1703001234567 -}) -``` - -## 4. Daemon State Updates (via WebSocket) - -### When daemon status changes: -```json -// Client -> Server -socket.emit('machine-update-state', { - "machineId": "machine-uuid-123", - "daemonState": "base64(encrypted({ - 'status': 'shutting-down', - 'pid': 12345, - 'httpPort': 8080, - 'startedAt': 1703001234567, - 'shutdownRequestedAt': 1703001244567, - 'shutdownSource': 'mobile-app' - }))", - "expectedVersion": 1 -}, callback) - -// Server -> Client (callback) -// Success: -{ - "result": "success", - "version": 2, - "daemonState": "base64(encrypted(...))" -} - -// Version mismatch: -{ - "result": "version-mismatch", - "version": 3, - "daemonState": "base64(encrypted(current_state))" -} -``` - -### Machine metadata update (rare): -```json -// Client -> Server -socket.emit('machine-update-metadata', { - "machineId": "machine-uuid-123", - "metadata": "base64(encrypted({ - 'host': 'MacBook-Pro.local', - 'platform': 'darwin', - 'happyCliVersion': '1.0.1', // version updated - 'homeDir': '/Users/john', - 'happyHomeDir': '/Users/john/.happy' - }))", - "expectedVersion": 1 -}, callback) -``` - -## 5. Mobile App RPC Calls - -### Stop Daemon Request: -```json -// Mobile -> Server -socket.emit('rpc-call', { - "method": "machine-uuid-123:stop-daemon", - "params": "base64(encrypted({ - 'reason': 'user-requested', - 'force': false - }))" -}, callback) - -// Server forwards to Daemon -// Daemon -> Server (response) -callback("base64(encrypted({ - 'message': 'Daemon shutdown initiated', - 'shutdownAt': 1703001244567 -}))") -``` - -### Flow when daemon receives stop request: -1. Daemon receives RPC `stop-daemon` -2. Updates daemon state immediately: -```json -socket.emit('machine-update-state', { - "machineId": "machine-uuid-123", - "daemonState": "base64(encrypted({ - 'status': 'shutting-down', - 'shutdownRequestedAt': 1703001244567, - 'shutdownSource': 'mobile-app' - }))", - "expectedVersion": 2 -}) -``` -3. Sends acknowledgment back via RPC callback -4. Performs cleanup -5. Final state update before exit: -```json -socket.emit('machine-update-state', { - "machineId": "machine-uuid-123", - "daemonState": "base64(encrypted({ - 'status': 'offline' - }))", - "expectedVersion": 3 -}) -``` - -## 6. Server Broadcasts to Clients - -### When daemon state changes: -```json -// Server -> Mobile/Web clients -socket.emit('update', { - "id": "update-id-xyz", - "seq": 456, - "body": { - "t": "update-machine", - "id": "machine-uuid-123", - "daemonState": { - "value": "base64(encrypted(...))", - "version": 2 - } - }, - "createdAt": 1703001244567 -}) -``` - -### When metadata changes: -```json -socket.emit('update', { - "id": "update-id-abc", - "seq": 457, - "body": { - "t": "update-machine", - "id": "machine-uuid-123", - "metadata": { - "value": "base64(encrypted(...))", - "version": 2 - } - }, - "createdAt": 1703001244567 -}) -``` - -## 7. GET Machine Status (REST) - -### Request: `GET /v1/machines/machine-uuid-123` -```http -Authorization: Bearer -``` - -### Response: -```json -{ - "machine": { - "id": "machine-uuid-123", - "metadata": "base64(encrypted(...))", - "metadataVersion": 2, - "daemonState": "base64(encrypted(...))", - "daemonStateVersion": 3, - "active": true, - "lastActiveAt": 1703001244567, - "createdAt": 1703001234567, - "updatedAt": 1703001244567 - } -} -``` - -## Key Design Decisions - -1. **Separation of Concerns**: - - `metadata`: Static machine info (host, platform, versions) - - `daemonState`: Dynamic runtime state (status, pid, ports) - -2. **Independent Versioning**: - - `metadataVersion`: For machine metadata updates - - `daemonStateVersion`: For daemon state updates - - Allows concurrent updates without conflicts - -3. **Encryption**: Both metadata and daemonState are encrypted separately - -4. **Update Events**: Server broadcasts use same pattern as sessions: - - `t: 'update-machine'` with optional metadata and/or daemonState fields - - Clients only receive updates for fields that changed - -5. **RPC Pattern**: Machine-scoped RPC methods prefixed with machineId (like sessions) - - - - diff --git a/cli/src/daemon/doctor.ts b/cli/src/daemon/doctor.ts deleted file mode 100644 index 177db5a442..0000000000 --- a/cli/src/daemon/doctor.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Daemon doctor utilities - * - * Process discovery and cleanup functions for the daemon - * Helps diagnose and fix issues with hung or orphaned processes - */ - -import psList from 'ps-list'; -import spawn from 'cross-spawn'; - -/** - * Find all Happy CLI processes (including current process) - */ -export async function findAllHappyProcesses(): Promise> { - try { - const processes = await psList(); - const allProcesses: Array<{ pid: number, command: string, type: string }> = []; - - for (const proc of processes) { - const cmd = proc.cmd || ''; - const name = proc.name || ''; - - // Check if it's a Happy process - const isHappy = name.includes('happy') || - name === 'node' && (cmd.includes('happy-cli') || cmd.includes('dist/index.mjs')) || - cmd.includes('happy.mjs') || - cmd.includes('happy-coder') || - (cmd.includes('tsx') && cmd.includes('src/index.ts') && cmd.includes('happy-cli')); - - if (!isHappy) continue; - - // Classify process type - let type = 'unknown'; - if (proc.pid === process.pid) { - type = 'current'; - } else if (cmd.includes('--version')) { - type = cmd.includes('tsx') ? 'dev-daemon-version-check' : 'daemon-version-check'; - } else if (cmd.includes('daemon start-sync') || cmd.includes('daemon start')) { - type = cmd.includes('tsx') ? 'dev-daemon' : 'daemon'; - } else if (cmd.includes('--started-by daemon')) { - type = cmd.includes('tsx') ? 'dev-daemon-spawned' : 'daemon-spawned-session'; - } else if (cmd.includes('doctor')) { - type = cmd.includes('tsx') ? 'dev-doctor' : 'doctor'; - } else if (cmd.includes('--yolo')) { - type = 'dev-session'; - } else { - type = cmd.includes('tsx') ? 'dev-related' : 'user-session'; - } - - allProcesses.push({ pid: proc.pid, command: cmd || name, type }); - } - - return allProcesses; - } catch (error) { - return []; - } -} - -/** - * Find all runaway Happy CLI processes that should be killed - */ -export async function findRunawayHappyProcesses(): Promise> { - const allProcesses = await findAllHappyProcesses(); - - // Filter to just runaway processes (excluding current process) - return allProcesses - .filter(p => - p.pid !== process.pid && ( - p.type === 'daemon' || - p.type === 'dev-daemon' || - p.type === 'daemon-spawned-session' || - p.type === 'dev-daemon-spawned' || - p.type === 'daemon-version-check' || - p.type === 'dev-daemon-version-check' - ) - ) - .map(p => ({ pid: p.pid, command: p.command })); -} - -/** - * Kill all runaway Happy CLI processes - */ -export async function killRunawayHappyProcesses(): Promise<{ killed: number, errors: Array<{ pid: number, error: string }> }> { - const runawayProcesses = await findRunawayHappyProcesses(); - const errors: Array<{ pid: number, error: string }> = []; - let killed = 0; - - for (const { pid, command } of runawayProcesses) { - try { - console.log(`Killing runaway process PID ${pid}: ${command}`); - - if (process.platform === 'win32') { - // Windows: use taskkill - const result = spawn.sync('taskkill', ['/F', '/PID', pid.toString()], { stdio: 'pipe' }); - if (result.error) throw result.error; - if (result.status !== 0) throw new Error(`taskkill exited with code ${result.status}`); - } else { - // Unix: try SIGTERM first - process.kill(pid, 'SIGTERM'); - - // Wait a moment - await new Promise(resolve => setTimeout(resolve, 1000)); - - // Check if still alive - const processes = await psList(); - const stillAlive = processes.find(p => p.pid === pid); - if (stillAlive) { - console.log(`Process PID ${pid} ignored SIGTERM, using SIGKILL`); - process.kill(pid, 'SIGKILL'); - } - } - - console.log(`Successfully killed runaway process PID ${pid}`); - killed++; - } catch (error) { - const errorMessage = (error as Error).message; - errors.push({ pid, error: errorMessage }); - console.log(`Failed to kill process PID ${pid}: ${errorMessage}`); - } - } - - return { killed, errors }; -} \ No newline at end of file diff --git a/cli/src/daemon/install.ts b/cli/src/daemon/install.ts deleted file mode 100644 index b20654a022..0000000000 --- a/cli/src/daemon/install.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { logger } from '@/ui/logger'; -import { install as installMac } from './mac/install'; - -export async function install(): Promise { - if (process.platform !== 'darwin') { - throw new Error('Daemon installation is currently only supported on macOS'); - } - - if (process.getuid && process.getuid() !== 0) { - throw new Error('Daemon installation requires sudo privileges. Please run with sudo.'); - } - - logger.info('Installing Happy CLI daemon for macOS...'); - await installMac(); -} \ No newline at end of file diff --git a/cli/src/daemon/mac/install.ts b/cli/src/daemon/mac/install.ts deleted file mode 100644 index edd3d3946f..0000000000 --- a/cli/src/daemon/mac/install.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Installation script for Happy daemon using macOS LaunchDaemons - * - * NOTE: This installation method is currently NOT USED in favor of auto-starting - * the daemon when the user runs the happy command. - * - * Why we're not using this approach: - * 1. Installing a LaunchDaemon requires sudo permissions, which users might not be comfortable with - * 2. We assume users will run happy frequently (every time they open their laptop) - * 3. The auto-start approach provides the same functionality without requiring elevated permissions - * - * This code is kept for potential future use if we decide to offer system-level installation as an option. - */ - -import { writeFileSync, chmodSync, existsSync } from 'fs'; -import { execSync } from 'child_process'; -import { logger } from '@/ui/logger'; -import { trimIdent } from '@/utils/trimIdent'; -import os from 'os'; - -const PLIST_LABEL = 'com.happy-cli.daemon'; -const PLIST_FILE = `/Library/LaunchDaemons/${PLIST_LABEL}.plist`; - -// NOTE: Local installation like --local does not make too much sense I feel like - -export async function install(): Promise { - try { - // Check if already installed - if (existsSync(PLIST_FILE)) { - logger.info('Daemon plist already exists. Uninstalling first...'); - execSync(`launchctl unload ${PLIST_FILE}`, { stdio: 'inherit' }); - } - - // Get the path to the happy CLI executable - const happyPath = process.argv[0]; // Node.js executable - const scriptPath = process.argv[1]; // Script path - - // Create plist content - const plistContent = trimIdent(` - - - - - Label - ${PLIST_LABEL} - - ProgramArguments - - ${happyPath} - ${scriptPath} - happy-daemon - - - EnvironmentVariables - - HAPPY_DAEMON_MODE - true - - - RunAtLoad - - - KeepAlive - - - StandardErrorPath - ${os.homedir()}/.happy/daemon.err - - StandardOutPath - ${os.homedir()}/.happy/daemon.log - - WorkingDirectory - /tmp - - - `); - - // Write plist file - writeFileSync(PLIST_FILE, plistContent); - chmodSync(PLIST_FILE, 0o644); - - logger.info(`Created daemon plist at ${PLIST_FILE}`); - - // Load the daemon - execSync(`launchctl load ${PLIST_FILE}`, { stdio: 'inherit' }); - - logger.info('Daemon installed and started successfully'); - logger.info('Check logs at ~/.happy/daemon.log'); - - } catch (error) { - logger.debug('Failed to install daemon:', error); - throw error; - } -} \ No newline at end of file diff --git a/cli/src/daemon/mac/uninstall.ts b/cli/src/daemon/mac/uninstall.ts deleted file mode 100644 index 541798163e..0000000000 --- a/cli/src/daemon/mac/uninstall.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Uninstallation script for Happy daemon LaunchDaemon - * - * NOTE: This uninstallation method is currently NOT USED since we moved away from - * system-level daemon installation. See install.ts for the full explanation. - * - * This code is kept for potential future use if we decide to offer system-level - * installation/uninstallation as an option. - */ - -import { existsSync, unlinkSync } from 'fs'; -import { execSync } from 'child_process'; -import { logger } from '@/ui/logger'; - -const PLIST_LABEL = 'com.happy-cli.daemon'; -const PLIST_FILE = `/Library/LaunchDaemons/${PLIST_LABEL}.plist`; - -export async function uninstall(): Promise { - try { - // Check if plist exists - if (!existsSync(PLIST_FILE)) { - logger.info('Daemon plist not found. Nothing to uninstall.'); - return; - } - - // Unload the daemon - try { - execSync(`launchctl unload ${PLIST_FILE}`, { stdio: 'inherit' }); - logger.info('Daemon stopped successfully'); - } catch (error) { - // Daemon might not be loaded, continue with removal - logger.info('Failed to unload daemon (it might not be running)'); - } - - // Remove the plist file - unlinkSync(PLIST_FILE); - logger.info(`Removed daemon plist from ${PLIST_FILE}`); - - logger.info('Daemon uninstalled successfully'); - - } catch (error) { - logger.debug('Failed to uninstall daemon:', error); - throw error; - } -} \ No newline at end of file diff --git a/cli/src/daemon/run.ts b/cli/src/daemon/run.ts deleted file mode 100644 index 75889d14e9..0000000000 --- a/cli/src/daemon/run.ts +++ /dev/null @@ -1,820 +0,0 @@ -import fs from 'fs/promises'; -import os from 'os'; -import * as tmp from 'tmp'; - -import { ApiClient } from '@/api/api'; -import { TrackedSession } from './types'; -import { MachineMetadata, DaemonState, Metadata } from '@/api/types'; -import { SpawnSessionOptions, SpawnSessionResult } from '@/modules/common/registerCommonHandlers'; -import { logger } from '@/ui/logger'; -import { authAndSetupMachineIfNeeded } from '@/ui/auth'; -import { configuration } from '@/configuration'; -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, 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 = { - host: os.hostname(), - platform: os.platform(), - happyCliVersion: packageJson.version, - homeDir: os.homedir(), - happyHomeDir: configuration.happyHomeDir, - 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: - // 1. Create promise that will resolve when shutdown is requested - // 2. Setup signal handlers to resolve this promise with the source of the shutdown - // 3. Once our setup is complete - if all goes well - we await this promise - // 4. When it resolves we can cleanup and exit - // - // In case the setup malfunctions - our signal handlers will not properly - // shut down. We will force exit the process with code 1. - let requestShutdown: (source: 'happy-app' | 'happy-cli' | 'os-signal' | 'exception', errorMessage?: string) => void; - let resolvesWhenShutdownRequested = new Promise<({ source: 'happy-app' | 'happy-cli' | 'os-signal' | 'exception', errorMessage?: string })>((resolve) => { - requestShutdown = (source, errorMessage) => { - logger.debug(`[DAEMON RUN] Requesting shutdown (source: ${source}, errorMessage: ${errorMessage})`); - - // Fallback - in case startup malfunctions - we will force exit the process with code 1 - setTimeout(async () => { - logger.debug('[DAEMON RUN] Startup malfunctioned, forcing exit with code 1'); - - // Give time for logs to be flushed - await new Promise(resolve => setTimeout(resolve, 100)) - - process.exit(1); - }, 1_000); - - // Start graceful shutdown - resolve({ source, errorMessage }); - }; - }); - - // Setup signal handlers - process.on('SIGINT', () => { - logger.debug('[DAEMON RUN] Received SIGINT'); - requestShutdown('os-signal'); - }); - - process.on('SIGTERM', () => { - logger.debug('[DAEMON RUN] Received SIGTERM'); - requestShutdown('os-signal'); - }); - - process.on('uncaughtException', (error) => { - logger.debug('[DAEMON RUN] FATAL: Uncaught exception', error); - logger.debug(`[DAEMON RUN] Stack trace: ${error.stack}`); - requestShutdown('exception', error.message); - }); - - process.on('unhandledRejection', (reason, promise) => { - logger.debug('[DAEMON RUN] FATAL: Unhandled promise rejection', reason); - logger.debug(`[DAEMON RUN] Rejected promise:`, promise); - const error = reason instanceof Error ? reason : new Error(`Unhandled promise rejection: ${reason}`); - logger.debug(`[DAEMON RUN] Stack trace: ${error.stack}`); - requestShutdown('exception', error.message); - }); - - process.on('exit', (code) => { - logger.debug(`[DAEMON RUN] Process exiting with code: ${code}`); - }); - - process.on('beforeExit', (code) => { - logger.debug(`[DAEMON RUN] Process about to exit with code: ${code}`); - }); - - logger.debug('[DAEMON RUN] Starting daemon process...'); - logger.debugLargeJson('[DAEMON RUN] Environment', getEnvironmentInfo()); - - // Check if already running - // Check if running daemon version matches current CLI version - const runningDaemonVersionMatches = await isDaemonRunningCurrentlyInstalledHappyVersion(); - if (!runningDaemonVersionMatches) { - logger.debug('[DAEMON RUN] Daemon version mismatch detected, restarting daemon with current CLI version'); - await stopDaemon(); - } else { - logger.debug('[DAEMON RUN] Daemon version matches, keeping existing daemon'); - console.log('Daemon already running with matching version'); - process.exit(0); - } - - // Acquire exclusive lock (proves daemon is running) - const daemonLockHandle = await acquireDaemonLock(5, 200); - if (!daemonLockHandle) { - logger.debug('[DAEMON RUN] Daemon lock file already held, another daemon is running'); - process.exit(0); - } - - // At this point we should be safe to startup the daemon: - // 1. Not have a stale daemon state - // 2. Should not have another daemon process running - - try { - // Start caffeinate - const caffeinateStarted = startCaffeinate(); - if (caffeinateStarted) { - logger.debug('[DAEMON RUN] Sleep prevention enabled'); - } - - // Ensure auth and machine registration BEFORE anything else - const { credentials, machineId } = await authAndSetupMachineIfNeeded(); - logger.debug('[DAEMON RUN] Auth and machine setup complete'); - - // Setup state - key by PID - const pidToTrackedSession = new Map(); - - // Session spawning awaiter system - const pidToAwaiter = new Map void>(); - - // Helper functions - const getCurrentChildren = () => Array.from(pidToTrackedSession.values()); - - // Handle webhook from happy session reporting itself - const onHappySessionWebhook = (sessionId: string, sessionMetadata: Metadata) => { - logger.debugLargeJson(`[DAEMON RUN] Session reported`, sessionMetadata); - - const pid = sessionMetadata.hostPid; - if (!pid) { - logger.debug(`[DAEMON RUN] Session webhook missing hostPid for sessionId: ${sessionId}`); - return; - } - - logger.debug(`[DAEMON RUN] Session webhook: ${sessionId}, PID: ${pid}, started by: ${sessionMetadata.startedBy || 'unknown'}`); - logger.debug(`[DAEMON RUN] Current tracked sessions before webhook: ${Array.from(pidToTrackedSession.keys()).join(', ')}`); - - // Check if we already have this PID (daemon-spawned) - const existingSession = pidToTrackedSession.get(pid); - - if (existingSession && existingSession.startedBy === 'daemon') { - // Update daemon-spawned session with reported data - existingSession.happySessionId = sessionId; - existingSession.happySessionMetadataFromLocalWebhook = sessionMetadata; - logger.debug(`[DAEMON RUN] Updated daemon-spawned session ${sessionId} with metadata`); - - // Resolve any awaiter for this PID - const awaiter = pidToAwaiter.get(pid); - if (awaiter) { - pidToAwaiter.delete(pid); - awaiter(existingSession); - logger.debug(`[DAEMON RUN] Resolved session awaiter for PID ${pid}`); - } - } else if (!existingSession) { - // New session started externally - const trackedSession: TrackedSession = { - startedBy: 'happy directly - likely by user from terminal', - happySessionId: sessionId, - happySessionMetadataFromLocalWebhook: sessionMetadata, - pid - }; - pidToTrackedSession.set(pid, trackedSession); - logger.debug(`[DAEMON RUN] Registered externally-started session ${sessionId}`); - } - }; - - // Spawn a new session (sessionId reserved for future --resume functionality) - const spawnSession = async (options: SpawnSessionOptions): Promise => { - logger.debugLargeJson('[DAEMON RUN] Spawning session', options); - - const { directory, sessionId, machineId, approvedNewDirectoryCreation = true } = options; - let directoryCreated = false; - - try { - await fs.access(directory); - logger.debug(`[DAEMON RUN] Directory exists: ${directory}`); - } catch (error) { - logger.debug(`[DAEMON RUN] Directory doesn't exist, creating: ${directory}`); - - // Check if directory creation is approved - if (!approvedNewDirectoryCreation) { - logger.debug(`[DAEMON RUN] Directory creation not approved for: ${directory}`); - return { - type: 'requestToApproveDirectoryCreation', - directory - }; - } - - try { - await fs.mkdir(directory, { recursive: true }); - logger.debug(`[DAEMON RUN] Successfully created directory: ${directory}`); - directoryCreated = true; - } catch (mkdirError: any) { - let errorMessage = `Unable to create directory at '${directory}'. `; - - // Provide more helpful error messages based on the error code - if (mkdirError.code === 'EACCES') { - errorMessage += `Permission denied. You don't have write access to create a folder at this location. Try using a different path or check your permissions.`; - } else if (mkdirError.code === 'ENOTDIR') { - errorMessage += `A file already exists at this path or in the parent path. Cannot create a directory here. Please choose a different location.`; - } else if (mkdirError.code === 'ENOSPC') { - errorMessage += `No space left on device. Your disk is full. Please free up some space and try again.`; - } else if (mkdirError.code === 'EROFS') { - errorMessage += `The file system is read-only. Cannot create directories here. Please choose a writable location.`; - } else { - errorMessage += `System error: ${mkdirError.message || mkdirError}. Please verify the path is valid and you have the necessary permissions.`; - } - - logger.debug(`[DAEMON RUN] Directory creation failed: ${errorMessage}`); - return { - type: 'error', - errorMessage - }; - } - } - - try { - - // 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') { - - // Create a temporary directory for Codex - const codexHomeDir = tmp.dirSync(); - - // Write the token to the temporary directory - fs.writeFile(join(codexHomeDir.name, 'auth.json'), options.token); - - // Set the environment variable for Codex - authEnv.CODEX_HOME = codexHomeDir.name; - } else { // Assuming claude - authEnv.CLAUDE_CODE_OAUTH_TOKEN = options.token; - } - } - - // 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('${'); - }); - - 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`; - }); - - 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 - }; - } - - // Check if tmux is available and should be used - const tmuxAvailable = await isTmuxAvailable(); - let useTmux = tmuxAvailable; - - // 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; - - // 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`); - } - } - - 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; - } - } - - // 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; - } - } - - // 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 - } - }); - - // 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 (!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); - return { - type: 'error', - errorMessage: `Failed to spawn session: ${errorMessage}` - }; - } - }; - - // Stop a session by sessionId or PID fallback - const stopSession = (sessionId: string): boolean => { - logger.debug(`[DAEMON RUN] Attempting to stop session ${sessionId}`); - - // Try to find by sessionId first - for (const [pid, session] of pidToTrackedSession.entries()) { - if (session.happySessionId === sessionId || - (sessionId.startsWith('PID-') && pid === parseInt(sessionId.replace('PID-', '')))) { - - if (session.startedBy === 'daemon' && session.childProcess) { - try { - session.childProcess.kill('SIGTERM'); - logger.debug(`[DAEMON RUN] Sent SIGTERM to daemon-spawned session ${sessionId}`); - } catch (error) { - logger.debug(`[DAEMON RUN] Failed to kill session ${sessionId}:`, error); - } - } else { - // For externally started sessions, try to kill by PID - try { - process.kill(pid, 'SIGTERM'); - logger.debug(`[DAEMON RUN] Sent SIGTERM to external session PID ${pid}`); - } catch (error) { - logger.debug(`[DAEMON RUN] Failed to kill external session PID ${pid}:`, error); - } - } - - pidToTrackedSession.delete(pid); - logger.debug(`[DAEMON RUN] Removed session ${sessionId} from tracking`); - return true; - } - } - - logger.debug(`[DAEMON RUN] Session ${sessionId} not found`); - return false; - }; - - // Handle child process exit - const onChildExited = (pid: number) => { - logger.debug(`[DAEMON RUN] Removing exited process PID ${pid} from tracking`); - pidToTrackedSession.delete(pid); - }; - - // Start control server - const { port: controlPort, stop: stopControlServer } = await startDaemonControlServer({ - getChildren: getCurrentChildren, - stopSession, - spawnSession, - requestShutdown: () => requestShutdown('happy-cli'), - onHappySessionWebhook - }); - - // Write initial daemon state (no lock needed for state file) - const fileState: DaemonLocallyPersistedState = { - pid: process.pid, - httpPort: controlPort, - startTime: new Date().toLocaleString(), - startedWithCliVersion: packageJson.version, - daemonLogPath: logger.logFilePath - }; - writeDaemonState(fileState); - logger.debug('[DAEMON RUN] Daemon state written'); - - // Prepare initial daemon state - const initialDaemonState: DaemonState = { - status: 'offline', - pid: process.pid, - httpPort: controlPort, - startedAt: Date.now() - }; - - // Create API client - const api = await ApiClient.create(credentials); - - // Get or create machine - const machine = await api.getOrCreateMachine({ - machineId, - metadata: initialMachineMetadata, - daemonState: initialDaemonState - }); - logger.debug(`[DAEMON RUN] Machine registered: ${machine.id}`); - - // Create realtime machine session - const apiMachine = api.machineSyncClient(machine); - - // Set RPC handlers - apiMachine.setRPCHandlers({ - spawnSession, - stopSession, - requestShutdown: () => requestShutdown('happy-app') - }); - - // Connect to server - apiMachine.connect(); - - // Every 60 seconds: - // 1. Prune stale sessions - // 2. Check if daemon needs update - // 3. If outdated, restart with latest version - // 4. Write heartbeat - const heartbeatIntervalMs = parseInt(process.env.HAPPY_DAEMON_HEARTBEAT_INTERVAL || '60000'); - let heartbeatRunning = false - const restartOnStaleVersionAndHeartbeat = setInterval(async () => { - if (heartbeatRunning) { - return; - } - heartbeatRunning = true; - - if (process.env.DEBUG) { - logger.debug(`[DAEMON RUN] Health check started at ${new Date().toLocaleString()}`); - } - - // Prune stale sessions - for (const [pid, _] of pidToTrackedSession.entries()) { - try { - // Check if process is still alive (signal 0 doesn't kill, just checks) - process.kill(pid, 0); - } catch (error) { - // Process is dead, remove from tracking - logger.debug(`[DAEMON RUN] Removing stale session with PID ${pid} (process no longer exists)`); - pidToTrackedSession.delete(pid); - } - } - - // Check if daemon needs update - // If version on disk is different from the one in package.json - we need to restart - // BIG if - does this get updated from underneath us on npm upgrade? - const projectVersion = JSON.parse(readFileSync(join(projectPath(), 'package.json'), 'utf-8')).version; - if (projectVersion !== configuration.currentCliVersion) { - logger.debug('[DAEMON RUN] Daemon is outdated, triggering self-restart with latest version, clearing heartbeat interval'); - - clearInterval(restartOnStaleVersionAndHeartbeat); - - // Spawn new daemon through the CLI - // We do not need to clean ourselves up - we will be killed by - // the CLI start command. - // 1. It will first check if daemon is running (yes in this case) - // 2. If the version is stale (it will read daemon.state.json file and check startedWithCliVersion) & compare it to its own version - // 3. Next it will start a new daemon with the latest version with daemon-sync :D - // Done! - try { - spawnHappyCLI(['daemon', 'start'], { - detached: true, - stdio: 'ignore' - }); - } catch (error) { - logger.debug('[DAEMON RUN] Failed to spawn new daemon, this is quite likely to happen during integration tests as we are cleaning out dist/ directory', error); - } - - // So we can just hang forever - logger.debug('[DAEMON RUN] Hanging for a bit - waiting for CLI to kill us because we are running outdated version of the code'); - await new Promise(resolve => setTimeout(resolve, 10_000)); - process.exit(0); - } - - // Before wrecklessly overriting the daemon state file, we should check if we are the ones who own it - // Race condition is possible, but thats okay for the time being :D - const daemonState = await readDaemonState(); - if (daemonState && daemonState.pid !== process.pid) { - logger.debug('[DAEMON RUN] Somehow a different daemon was started without killing us. We should kill ourselves.') - requestShutdown('exception', 'A different daemon was started without killing us. We should kill ourselves.') - } - - // Heartbeat - try { - const updatedState: DaemonLocallyPersistedState = { - pid: process.pid, - httpPort: controlPort, - startTime: fileState.startTime, - startedWithCliVersion: packageJson.version, - lastHeartbeat: new Date().toLocaleString(), - daemonLogPath: fileState.daemonLogPath - }; - writeDaemonState(updatedState); - if (process.env.DEBUG) { - logger.debug(`[DAEMON RUN] Health check completed at ${updatedState.lastHeartbeat}`); - } - } catch (error) { - logger.debug('[DAEMON RUN] Failed to write heartbeat', error); - } - - heartbeatRunning = false; - }, heartbeatIntervalMs); // Every 60 seconds in production - - // Setup signal handlers - const cleanupAndShutdown = async (source: 'happy-app' | 'happy-cli' | 'os-signal' | 'exception', errorMessage?: string) => { - logger.debug(`[DAEMON RUN] Starting proper cleanup (source: ${source}, errorMessage: ${errorMessage})...`); - - // Clear health check interval - if (restartOnStaleVersionAndHeartbeat) { - clearInterval(restartOnStaleVersionAndHeartbeat); - logger.debug('[DAEMON RUN] Health check interval cleared'); - } - - // Update daemon state before shutting down - await apiMachine.updateDaemonState((state: DaemonState | null) => ({ - ...state, - status: 'shutting-down', - shutdownRequestedAt: Date.now(), - shutdownSource: source - })); - - // Give time for metadata update to send - await new Promise(resolve => setTimeout(resolve, 100)); - - apiMachine.shutdown(); - await stopControlServer(); - await cleanupDaemonState(); - await stopCaffeinate(); - await releaseDaemonLock(daemonLockHandle); - - logger.debug('[DAEMON RUN] Cleanup completed, exiting process'); - process.exit(0); - }; - - logger.debug('[DAEMON RUN] Daemon started successfully, waiting for shutdown request'); - - // Wait for shutdown request - const shutdownRequest = await resolvesWhenShutdownRequested; - await cleanupAndShutdown(shutdownRequest.source, shutdownRequest.errorMessage); - } catch (error) { - logger.debug('[DAEMON RUN][FATAL] Failed somewhere unexpectedly - exiting with code 1', error); - process.exit(1); - } -} diff --git a/cli/src/daemon/types.ts b/cli/src/daemon/types.ts deleted file mode 100644 index ed8f08aa47..0000000000 --- a/cli/src/daemon/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Daemon-specific types (not related to API/server communication) - */ - -import { Metadata } from '@/api/types'; -import { ChildProcess } from 'child_process'; - -/** - * Session tracking for daemon - */ -export interface TrackedSession { - startedBy: 'daemon' | string; - happySessionId?: string; - happySessionMetadataFromLocalWebhook?: Metadata; - pid: number; - childProcess?: ChildProcess; - error?: string; - directoryCreated?: boolean; - message?: string; - /** tmux session identifier (format: session:window) */ - tmuxSessionId?: string; -} \ No newline at end of file diff --git a/cli/src/daemon/uninstall.ts b/cli/src/daemon/uninstall.ts deleted file mode 100644 index fea46da0bd..0000000000 --- a/cli/src/daemon/uninstall.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { logger } from '@/ui/logger'; -import { uninstall as uninstallMac } from './mac/uninstall'; - -export async function uninstall(): Promise { - if (process.platform !== 'darwin') { - throw new Error('Daemon uninstallation is currently only supported on macOS'); - } - - if (process.getuid && process.getuid() !== 0) { - throw new Error('Daemon uninstallation requires sudo privileges. Please run with sudo.'); - } - - logger.info('Uninstalling Happy CLI daemon for macOS...'); - await uninstallMac(); -} \ No newline at end of file diff --git a/cli/src/gemini/constants.ts b/cli/src/gemini/constants.ts deleted file mode 100644 index 4903b23410..0000000000 --- a/cli/src/gemini/constants.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Gemini Constants - * - * Centralized constants for Gemini integration including environment variable names - * and default values. - */ - -import { trimIdent } from '@/utils/trimIdent'; - -/** Environment variable name for Gemini API key */ -export const GEMINI_API_KEY_ENV = 'GEMINI_API_KEY'; - -/** Environment variable name for Google API key (alternative) */ -export const GOOGLE_API_KEY_ENV = 'GOOGLE_API_KEY'; - -/** Environment variable name for Gemini model selection */ -export const GEMINI_MODEL_ENV = 'GEMINI_MODEL'; - -/** Default Gemini model */ -export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro'; - -/** - * Instruction for changing chat title - * Used in system prompts to instruct agents to call change_title function - */ -export const CHANGE_TITLE_INSTRUCTION = trimIdent( - `Based on this message, call functions.happy__change_title to change chat session title that would represent the current task. If chat idea would change dramatically - call this function again to update the title.` -); - diff --git a/cli/src/gemini/types.ts b/cli/src/gemini/types.ts deleted file mode 100644 index 7149872564..0000000000 --- a/cli/src/gemini/types.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Gemini Types - * - * Centralized type definitions for Gemini integration. - */ - -import type { PermissionMode } from '@/api/types'; - -/** - * Mode configuration for Gemini messages - */ -export interface GeminiMode { - permissionMode: PermissionMode; - model?: string; - originalUserMessage?: string; // Original user message without system prompt -} - -/** - * Codex message payload for sending messages to mobile app - */ -export interface CodexMessagePayload { - type: 'message'; - message: string; - id: string; - options?: string[]; -} diff --git a/cli/src/gemini/utils/diffProcessor.ts b/cli/src/gemini/utils/diffProcessor.ts deleted file mode 100644 index 751d45b6d6..0000000000 --- a/cli/src/gemini/utils/diffProcessor.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Diff Processor for Gemini - Handles file edit events and tracks unified_diff changes - * - * This processor tracks changes from fs-edit events and tool_call results that contain - * file modification information, converting them to GeminiDiff tool calls similar to Codex. - * - * Note: Gemini ACP doesn't have direct turn_diff events like Codex, so we track - * file changes through fs-edit events and tool results that may contain diff information. - */ - -import { randomUUID } from 'node:crypto'; -import { logger } from '@/ui/logger'; - -export interface DiffToolCall { - type: 'tool-call'; - name: 'GeminiDiff'; - callId: string; - input: { - unified_diff?: string; - path?: string; - description?: string; - }; - id: string; -} - -export interface DiffToolResult { - type: 'tool-call-result'; - callId: string; - output: { - status: 'completed'; - }; - id: string; -} - -export class GeminiDiffProcessor { - private previousDiffs = new Map(); // Track diffs per file path - private onMessage: ((message: any) => void) | null = null; - - constructor(onMessage?: (message: any) => void) { - this.onMessage = onMessage || null; - } - - /** - * Process an fs-edit event and check if it contains diff information - */ - processFsEdit(path: string, description?: string, diff?: string): void { - logger.debug(`[GeminiDiffProcessor] Processing fs-edit for path: ${path}`); - - // If we have a diff, process it - if (diff) { - this.processDiff(path, diff, description); - } else { - // Even without diff, we can track that a file was edited - // Generate a simple diff representation - const simpleDiff = `File edited: ${path}${description ? ` - ${description}` : ''}`; - this.processDiff(path, simpleDiff, description); - } - } - - /** - * Process a tool result that may contain diff information - */ - processToolResult(toolName: string, result: any, callId: string): void { - // Check if result contains diff information - if (result && typeof result === 'object') { - // Look for common diff fields - const diff = result.diff || result.unified_diff || result.patch; - const path = result.path || result.file; - - if (diff && path) { - logger.debug(`[GeminiDiffProcessor] Found diff in tool result: ${toolName} (${callId})`); - this.processDiff(path, diff, result.description); - } else if (result.changes && typeof result.changes === 'object') { - // Handle multiple file changes (like patch operations) - for (const [filePath, change] of Object.entries(result.changes)) { - const changeDiff = (change as any).diff || (change as any).unified_diff || - JSON.stringify(change); - this.processDiff(filePath, changeDiff, (change as any).description); - } - } - } - } - - /** - * Process a unified diff and check if it has changed from the previous value - */ - private processDiff(path: string, unifiedDiff: string, description?: string): void { - const previousDiff = this.previousDiffs.get(path); - - // Check if the diff has changed from the previous value - if (previousDiff !== unifiedDiff) { - logger.debug(`[GeminiDiffProcessor] Unified diff changed for ${path}, sending GeminiDiff tool call`); - - // Generate a unique call ID for this diff - const callId = randomUUID(); - - // Send tool call for the diff change - const toolCall: DiffToolCall = { - type: 'tool-call', - name: 'GeminiDiff', - callId: callId, - input: { - unified_diff: unifiedDiff, - path: path, - description: description - }, - id: randomUUID() - }; - - this.onMessage?.(toolCall); - - // Immediately send the tool result to mark it as completed - const toolResult: DiffToolResult = { - type: 'tool-call-result', - callId: callId, - output: { - status: 'completed' - }, - id: randomUUID() - }; - - this.onMessage?.(toolResult); - } - - // Update the stored diff value - this.previousDiffs.set(path, unifiedDiff); - logger.debug(`[GeminiDiffProcessor] Updated stored diff for ${path}`); - } - - /** - * Reset the processor state (called on task_complete or turn_aborted) - */ - reset(): void { - logger.debug('[GeminiDiffProcessor] Resetting diff state'); - this.previousDiffs.clear(); - } - - /** - * Set the message callback for sending messages directly - */ - setMessageCallback(callback: (message: any) => void): void { - this.onMessage = callback; - } - - /** - * Get the current diff value for a specific path - */ - getCurrentDiff(path: string): string | null { - return this.previousDiffs.get(path) || null; - } - - /** - * Get all tracked diffs - */ - getAllDiffs(): Map { - return new Map(this.previousDiffs); - } -} diff --git a/cli/src/gemini/utils/optionsParser.ts b/cli/src/gemini/utils/optionsParser.ts deleted file mode 100644 index bf105dd1f8..0000000000 --- a/cli/src/gemini/utils/optionsParser.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Options Parser Utilities - * - * Utilities for parsing and formatting XML options blocks from agent responses. - * Used for extracting and formatting blocks. - */ - -/** - * Check if text has an incomplete options block (opening tag but no closing tag) - * - * @param text - The text to check - * @returns true if there's an opening tag without a closing tag - */ -export function hasIncompleteOptions(text: string): boolean { - const hasOpeningTag = //i.test(text); - const hasClosingTag = /<\/options>/i.test(text); - return hasOpeningTag && !hasClosingTag; -} - -/** - * Parse XML options from text - * Extracts blocks and returns - * the text without options and the parsed options array - * - * @param text - The text containing options XML - * @returns Object with text (without options) and options array - */ -export function parseOptionsFromText(text: string): { text: string; options: string[] } { - // Match ... block (multiline, non-greedy) - const optionsRegex = /\s*([\s\S]*?)\s*<\/options>/i; - const match = text.match(optionsRegex); - - if (!match) { - return { text: text.trim(), options: [] }; - } - - // Extract options block content - const optionsBlock = match[1]; - - // Parse individual