diff --git a/.claude/settings.json b/.claude/settings.json index d155cbc..72a599f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,13 +1,5 @@ { "env": { "CLAUDE_AUTOCOMPACT_PCT_OVERRIDE": "50" - }, - "extraKnownMarketplaces": { - "nanoclaw-skills": { - "source": { - "source": "github", - "repo": "qwibitai/nanoclaw-skills" - } - } } } diff --git a/.claude/skills/add-discord/SKILL.md b/.claude/skills/add-discord/SKILL.md index c56e889..bc627d2 100644 --- a/.claude/skills/add-discord/SKILL.md +++ b/.claude/skills/add-discord/SKILL.md @@ -1,6 +1,6 @@ --- name: add-discord -description: Finish Discord bot setup and registration for NanoClaw. +description: Finish Discord bot setup and registration for EJClaw. --- # Add Discord Channel @@ -78,7 +78,7 @@ npm run setup -- --step verify 필요하면 로그 확인: ```bash -tail -f logs/nanoclaw.log +tail -f logs/ejclaw.log ``` 메인 채널이면 일반 메시지, 보조 채널이면 멘션 또는 트리거 메시지로 테스트합니다. diff --git a/.claude/skills/add-parallel/SKILL.md b/.claude/skills/add-parallel/SKILL.md index 084f2e9..b4f3bcf 100644 --- a/.claude/skills/add-parallel/SKILL.md +++ b/.claude/skills/add-parallel/SKILL.md @@ -43,7 +43,7 @@ PARALLEL_API_KEY=... - 빠른 사실 조회나 최신 정보 확인은 `parallel-search` - 긴 조사나 깊은 분석은 `parallel-task` - `parallel-task`는 항상 먼저 사용자 허가를 받을 것 -- 오래 걸리는 작업은 답변을 붙잡고 있지 말고 `mcp__nanoclaw__schedule_task`로 후속 체크를 맡길 것 +- 오래 걸리는 작업은 답변을 붙잡고 있지 말고 `mcp__ejclaw__schedule_task`로 후속 체크를 맡길 것 ## 5. 빌드와 검증 diff --git a/.claude/skills/debug/SKILL.md b/.claude/skills/debug/SKILL.md index 7709a4b..6cc5024 100644 --- a/.claude/skills/debug/SKILL.md +++ b/.claude/skills/debug/SKILL.md @@ -1,11 +1,11 @@ --- name: debug -description: Debug NanoClaw runtime issues on the current Discord-only, host-process architecture. +description: Debug EJClaw runtime issues on the current Discord-only, host-process architecture. --- -# NanoClaw Debugging +# EJClaw Debugging -현재 기준 NanoClaw는 디스코드 전용이고, 에이전트는 컨테이너가 아니라 호스트 프로세스로 실행됩니다. 디버깅은 `채널`, `서비스`, `러너`, `DB 등록`, `자격 증명` 순서로 좁혀갑니다. +현재 기준 EJClaw는 디스코드 전용이고, 에이전트는 컨테이너가 아니라 호스트 프로세스로 실행됩니다. 디버깅은 `채널`, `서비스`, `러너`, `DB 등록`, `자격 증명` 순서로 좁혀갑니다. ## 빠른 점검 순서 @@ -20,8 +20,8 @@ description: Debug NanoClaw runtime issues on the current Discord-only, host-pro ``` 3. 런타임 로그 확인 ```bash - tail -f logs/nanoclaw.log - tail -f logs/nanoclaw.error.log + tail -f logs/ejclaw.log + tail -f logs/ejclaw.error.log ls -t groups/*/logs/agent-*.log | head ``` 4. 러너 빌드 확인 @@ -68,7 +68,7 @@ ls -t groups/*/logs/agent-*.log | head -3 ```bash grep -nE '^(GROQ_API_KEY|OPENAI_API_KEY)=' .env -tail -f logs/nanoclaw.log | grep -iE 'transcri|audio|whisper|groq' +tail -f logs/ejclaw.log | grep -iE 'transcri|audio|whisper|groq' ``` - 기본 우선순위는 Groq Whisper, 없으면 OpenAI Whisper fallback입니다. diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index e5d9d17..bc74dc4 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -1,12 +1,16 @@ --- name: setup -description: Run initial NanoClaw setup for the current Discord-only, host-process architecture. +description: Run initial EJClaw setup for the dual-service Discord architecture. --- -# NanoClaw Setup +# EJClaw Setup 설치는 `bash setup.sh`로 부트스트랩하고, 나머지는 `npm run setup -- --step `으로 진행합니다. 현재 기준 채널은 디스코드만 지원합니다. +EJClaw는 두 개의 서비스로 구성됩니다: +- **ejclaw** — Claude Code 봇 (`@claude`) +- **ejclaw-codex** — Codex 봇 (`@codex`) — `.env.codex` 파일이 있으면 자동 설치 + ## 1. 부트스트랩 ```bash @@ -30,19 +34,56 @@ npm run setup -- --step environment ## 3. 필수 환경 변수 +### Claude 서비스 (.env) + `.env`에 최소한 아래 값이 있어야 합니다. ```bash -DISCORD_BOT_TOKEN=... -CLAUDE_CODE_OAUTH_TOKEN=... # 또는 ANTHROPIC_API_KEY=... +DISCORD_BOT_TOKEN=... # Claude 봇 토큰 +CLAUDE_CODE_OAUTH_TOKEN=... # 또는 ANTHROPIC_API_KEY=... +ASSISTANT_NAME=claude # 트리거 이름 (@claude) ``` -선택: +권장: ```bash -OPENAI_API_KEY=... # Codex 사용 시 -CODEX_OPENAI_API_KEY=... # Codex 전용 키를 따로 쓸 때 -GROQ_API_KEY=... # Discord 음성 전사 +CLAUDE_CODE_OAUTH_TOKENS=token1,token2 # 다중 계정 자동 로테이션 +GROQ_API_KEY=... # Discord 음성 전사 (Groq Whisper) +``` + +### Codex 서비스 (.env.codex) + +Codex 봇을 함께 운영하려면 `.env.codex`를 만듭니다. 이 파일이 있으면 `--step service`가 자동으로 `ejclaw-codex` 서비스도 설치합니다. + +```bash +DISCORD_BOT_TOKEN=... # Codex 봇 토큰 (Claude와 별도) +``` + +Codex 서비스의 추가 설정은 systemd 유닛에서 `Environment=` 라인으로 지정하거나 `.env.codex`에 추가합니다: + +```bash +# systemd 유닛 또는 .env.codex에 추가 가능 +CODEX_MODEL=gpt-5.4 +CODEX_EFFORT=xhigh +OPENAI_API_KEY=... +``` + +### 선택 환경 변수 + +```bash +# Provider fallback (Claude 429 시 대체) +FALLBACK_PROVIDER_NAME=kimi +FALLBACK_BASE_URL=https://api.kimi.com/coding +FALLBACK_AUTH_TOKEN=... +FALLBACK_MODEL=kimi-k2.5 + +# 사용량 대시보드 +STATUS_CHANNEL_ID=... # 상태 업데이트 디스코드 채널 +USAGE_DASHBOARD=true + +# 고급 설정 +MAX_CONCURRENT_AGENTS=5 +SESSION_COMMAND_ALLOWED_SENDERS=... # 세션 명령 허용 유저 ID (쉼표 구분) ``` ## 4. 러너 빌드 @@ -53,8 +94,8 @@ npm run setup -- --step runners 이 단계는 아래 두 러너를 빌드합니다. -- `runners/agent-runner` -- `runners/codex-runner` +- `runners/agent-runner` (Claude Code) +- `runners/codex-runner` (Codex) 실패하면 보통 `npm run build:runners` 출력과 각 러너의 `package.json` 의존성을 같이 보면 됩니다. @@ -62,14 +103,28 @@ npm run setup -- --step runners 먼저 디스코드에서 개발자 모드를 켜고 채널 ID를 복사합니다. 등록 JID는 `dc:` 형식입니다. -메인 채널 예시: +듀얼 서비스에서는 **같은 채널을 두 번 등록**할 수 있습니다 — 각 agent type에 한 번씩. 등록은 `(jid, agent_type)` 복합 키로 저장됩니다. + +Claude 봇 채널 등록: ```bash npm run setup -- --step register -- \ --jid dc:123456789012345678 \ --name "My Server #general" \ --folder discord_main \ - --trigger @Andy \ + --trigger @claude \ + --is-main \ + --no-trigger-required +``` + +Codex 봇도 같은 채널을 쓴다면 별도로 등록합니다: + +```bash +ASSISTANT_NAME=codex npm run setup -- --step register -- \ + --jid dc:123456789012345678 \ + --name "My Server #general" \ + --folder discord_main \ + --trigger @codex \ --is-main \ --no-trigger-required ``` @@ -81,7 +136,7 @@ npm run setup -- --step register -- \ --jid dc:123456789012345678 \ --name "My Server #ops" \ --folder discord_ops \ - --trigger @Andy + --trigger @claude ``` ## 6. 서비스 시작 @@ -90,8 +145,26 @@ npm run setup -- --step register -- \ npm run setup -- --step service ``` -- macOS는 `launchd` -- Linux는 `systemd` 또는 fallback wrapper +이 명령은: +- **ejclaw** 서비스를 항상 설치합니다 +- `.env.codex`가 있으면 **ejclaw-codex** 서비스도 함께 설치합니다 + +플랫폼별: +- Linux (systemd): `~/.config/systemd/user/ejclaw.service` + `ejclaw-codex.service` +- macOS: `~/Library/LaunchAgents/com.ejclaw.plist` + `com.ejclaw-codex.plist` +- WSL (no systemd): `start-ejclaw.sh` + `start-ejclaw-codex.sh` + +수동으로 서비스 관리: + +```bash +# Linux (systemd) +systemctl --user status ejclaw ejclaw-codex +systemctl --user restart ejclaw ejclaw-codex + +# 로그 +journalctl --user -u ejclaw -f +journalctl --user -u ejclaw-codex -f +``` ## 7. 최종 검증 @@ -101,7 +174,8 @@ npm run setup -- --step verify 성공 기준: -- 서비스가 running +- **ejclaw** 서비스가 running +- **ejclaw-codex** 서비스가 running (`.env.codex`가 있을 때) - Claude 인증이 configured - `CHANNEL_AUTH`에 `discord` - 등록 그룹 수가 1 이상 @@ -109,6 +183,7 @@ npm run setup -- --step verify ## 빠른 문제 해결 - 빌드 문제: `npm run typecheck`, `npm test`, `npm run build:runners` -- 서비스 문제: `logs/nanoclaw.error.log` +- Claude 서비스 문제: `logs/ejclaw.error.log` 또는 `journalctl --user -u ejclaw -f` +- Codex 서비스 문제: `logs/ejclaw-codex.error.log` 또는 `journalctl --user -u ejclaw-codex -f` - 디스코드 연결 문제: `.env`의 `DISCORD_BOT_TOKEN`과 등록된 `dc:*` JID 확인 -- 응답 문제: `tail -f logs/nanoclaw.log` +- 응답 문제: `tail -f logs/ejclaw.log` diff --git a/.env.codex.example b/.env.codex.example new file mode 100644 index 0000000..198e5be --- /dev/null +++ b/.env.codex.example @@ -0,0 +1,7 @@ +# ===== EJClaw Codex Service Environment ===== +# Copy this file to .env.codex for the ejclaw-codex service. +# This file is loaded via EnvironmentFile in the systemd unit. +# The main .env is NOT loaded by the codex service — put shared vars there and +# set them as Environment= lines in the systemd unit, or duplicate them here. + +DISCORD_BOT_TOKEN= # Discord bot token (Codex bot — separate from Claude bot) diff --git a/.env.example b/.env.example index 2f05389..70836c6 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,44 @@ +# ===== EJClaw Environment Configuration ===== +# Copy this file to .env and fill in the values. +# For dual-service (Codex), also create .env.codex with DISCORD_BOT_TOKEN for the Codex bot. +# --- Required --- +DISCORD_BOT_TOKEN= # Discord bot token (Claude bot) +CLAUDE_CODE_OAUTH_TOKEN= # Claude Code OAuth token (primary account) -# Added by skill -DISCORD_BOT_TOKEN= +# --- Multi-account rotation (recommended) --- +CLAUDE_CODE_OAUTH_TOKENS= # Comma-separated tokens for auto-rotation on 429 + # e.g. token1,token2 -# Codex support (optional — only needed for Codex agent type) -OPENAI_API_KEY= -# CODEX_OPENAI_API_KEY= # Use this to override OPENAI_API_KEY for Codex only +# --- Codex support (optional — needed for ejclaw-codex service) --- +OPENAI_API_KEY= # OpenAI API key for Codex +# CODEX_OPENAI_API_KEY= # Override OPENAI_API_KEY for Codex only +# CODEX_MODEL=gpt-5.4 # Codex model +# CODEX_EFFORT=xhigh # Codex reasoning effort + +# --- Bot identity --- +ASSISTANT_NAME=claude # Trigger name (@claude) +# SERVICE_ID=claude # Service identifier (defaults to ASSISTANT_NAME slug) +# SERVICE_AGENT_TYPE=claude-code # Agent type: claude-code or codex + +# --- Voice transcription --- +GROQ_API_KEY= # Groq Whisper (primary, free: console.groq.com) +# OPENAI_API_KEY also used as Whisper fallback + +# --- Provider fallback (optional — Claude 429 → alternative) --- +# FALLBACK_PROVIDER_NAME=kimi +# FALLBACK_BASE_URL=https://api.kimi.com/coding +# FALLBACK_AUTH_TOKEN= +# FALLBACK_MODEL=kimi-k2.5 +# FALLBACK_COOLDOWN_MS=600000 + +# --- Usage dashboard (optional) --- +# STATUS_CHANNEL_ID= # Discord channel for live status updates +# USAGE_DASHBOARD=true # Enable usage dashboard +# STATUS_SHOW_ROOMS=true # Show room status in dashboard +# STATUS_SHOW_ROOM_DETAILS=false # Show detailed room info + +# --- Advanced --- +# MAX_CONCURRENT_AGENTS=5 # Max parallel agent processes +# AGENT_TIMEOUT=1800000 # Agent timeout in ms (default: 30min) +# SESSION_COMMAND_ALLOWED_SENDERS= # Comma-separated Discord user IDs for session commands diff --git a/README.md b/README.md index b1c8750..0796a19 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![Claude Agent SDK](https://img.shields.io/badge/Claude_Agent_SDK-0.2.81-blueviolet) ![Codex SDK](https://img.shields.io/badge/Codex_SDK-0.115.0-green) -![Node](https://img.shields.io/badge/Node-24+-339933?logo=nodedotjs&logoColor=white) +![Node](https://img.shields.io/badge/Node-20+-339933?logo=nodedotjs&logoColor=white) ![Discord](https://img.shields.io/badge/Discord-Bot-5865F2?logo=discord&logoColor=white) Dual-agent AI assistant (Claude Code + Codex) over Discord. @@ -55,7 +55,7 @@ Each agent has access to: ### Prerequisites - Linux (Ubuntu 22.04+) or macOS -- Node.js 24+ (fnm recommended) +- Node.js 20+ (24 recommended, fnm for version management) - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) - [Codex CLI](https://github.com/openai/codex) (`npm install -g @openai/codex`) - Bun 1.0+ (for browser automation) @@ -89,6 +89,17 @@ FALLBACK_AUTH_TOKEN= # Fallback provider API key FALLBACK_MODEL= # e.g. kimi-k2.5 ``` +### Codex Service (optional) + +To run the Codex agent alongside Claude, create `.env.codex`: + +```bash +# .env.codex +DISCORD_BOT_TOKEN= # Separate Discord bot token for Codex +``` + +The setup step (`npm run setup -- --step service`) auto-detects `.env.codex` and installs `ejclaw-codex` alongside `ejclaw`. Additional Codex settings (`CODEX_MODEL`, `CODEX_EFFORT`, etc.) can be added to `.env.codex` or as `Environment=` lines in the systemd unit. + ### Authentication Multi-account OAuth token rotation is supported via `CLAUDE_CODE_OAUTH_TOKENS` (comma-separated). When one account hits a rate limit, the system automatically rotates to the next. diff --git a/setup/service.ts b/setup/service.ts index 48cd862..dc6b76d 100644 --- a/setup/service.ts +++ b/setup/service.ts @@ -2,7 +2,9 @@ * Step: service — Generate and load service manager config. * Replaces 08-setup-service.sh * - * Fixes: Root→system systemd, WSL nohup fallback, no `|| true` swallowing errors. + * Supports dual-service architecture: + * - ejclaw (Claude Code) — always installed + * - ejclaw-codex (Codex) — installed when .env.codex exists */ import { execSync } from 'child_process'; import fs from 'fs'; @@ -20,8 +22,58 @@ import { } from './platform.js'; import { emitStatus } from './status.js'; -const EJCLAW_SERVICE_NAME = 'ejclaw'; -const EJCLAW_LAUNCHD_LABEL = 'com.ejclaw'; +/* ------------------------------------------------------------------ */ +/* Service definition */ +/* ------------------------------------------------------------------ */ + +interface ServiceDef { + /** systemd unit name / nohup script name */ + name: string; + /** launchd label */ + launchdLabel: string; + /** Human-readable description for systemd/launchd */ + description: string; + /** Log file prefix (e.g. "ejclaw" → logs/ejclaw.log) */ + logName: string; + /** Absolute path to EnvironmentFile (systemd) — loaded before Environment= */ + environmentFile?: string; + /** Extra Environment= lines for systemd / env dict entries for launchd */ + extraEnv?: Record; +} + +function getServiceDefs(projectRoot: string): ServiceDef[] { + const defs: ServiceDef[] = [ + { + name: 'ejclaw', + launchdLabel: 'com.ejclaw', + description: 'EJClaw Personal Assistant (Claude Code)', + logName: 'ejclaw', + }, + ]; + + const codexEnvPath = path.join(projectRoot, '.env.codex'); + if (fs.existsSync(codexEnvPath)) { + defs.push({ + name: 'ejclaw-codex', + launchdLabel: 'com.ejclaw-codex', + description: 'EJClaw Codex Assistant', + logName: 'ejclaw-codex', + environmentFile: codexEnvPath, + extraEnv: { + ASSISTANT_NAME: 'codex', + }, + }); + logger.info( + 'Detected .env.codex — will also install ejclaw-codex service', + ); + } + + return defs; +} + +/* ------------------------------------------------------------------ */ +/* Entry point */ +/* ------------------------------------------------------------------ */ export async function run(_args: string[]): Promise { const projectRoot = process.cwd(); @@ -54,10 +106,14 @@ export async function run(_args: string[]): Promise { fs.mkdirSync(path.join(projectRoot, 'logs'), { recursive: true }); + const serviceDefs = getServiceDefs(projectRoot); + if (platform === 'macos') { - setupLaunchd(projectRoot, nodePath, homeDir); + for (const def of serviceDefs) { + setupLaunchd(def, projectRoot, nodePath, homeDir); + } } else if (platform === 'linux') { - setupLinux(projectRoot, nodePath, homeDir); + setupLinux(serviceDefs, projectRoot, nodePath, homeDir); } else { emitStatus('SETUP_SERVICE', { SERVICE_TYPE: 'unknown', @@ -71,7 +127,12 @@ export async function run(_args: string[]): Promise { } } +/* ------------------------------------------------------------------ */ +/* macOS (launchd) */ +/* ------------------------------------------------------------------ */ + function setupLaunchd( + def: ServiceDef, projectRoot: string, nodePath: string, homeDir: string, @@ -80,16 +141,30 @@ function setupLaunchd( homeDir, 'Library', 'LaunchAgents', - `${EJCLAW_LAUNCHD_LABEL}.plist`, + `${def.launchdLabel}.plist`, ); fs.mkdirSync(path.dirname(plistPath), { recursive: true }); + // Build extra env dict entries + const envEntries = [ + ` PATH`, + ` ${path.dirname(nodePath)}:/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin:${homeDir}/.npm-global/bin`, + ` HOME`, + ` ${homeDir}`, + ]; + if (def.extraEnv) { + for (const [k, v] of Object.entries(def.extraEnv)) { + envEntries.push(` ${k}`); + envEntries.push(` ${v}`); + } + } + const plist = ` Label - ${EJCLAW_LAUNCHD_LABEL} + ${def.launchdLabel} ProgramArguments ${nodePath} @@ -103,40 +178,41 @@ function setupLaunchd( EnvironmentVariables - PATH - ${path.dirname(nodePath)}:/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin:${homeDir}/.npm-global/bin - HOME - ${homeDir} +${envEntries.join('\n')} StandardOutPath - ${projectRoot}/logs/ejclaw.log + ${projectRoot}/logs/${def.logName}.log StandardErrorPath - ${projectRoot}/logs/ejclaw.error.log + ${projectRoot}/logs/${def.logName}.error.log `; fs.writeFileSync(plistPath, plist); - logger.info({ plistPath }, 'Wrote launchd plist'); + logger.info({ plistPath, service: def.name }, 'Wrote launchd plist'); try { execSync(`launchctl load ${JSON.stringify(plistPath)}`, { stdio: 'ignore', }); - logger.info('launchctl load succeeded'); + logger.info({ service: def.name }, 'launchctl load succeeded'); } catch { - logger.warn('launchctl load failed (may already be loaded)'); + logger.warn( + { service: def.name }, + 'launchctl load failed (may already be loaded)', + ); } // Verify let serviceLoaded = false; try { const output = execSync('launchctl list', { encoding: 'utf-8' }); - serviceLoaded = output.includes(EJCLAW_LAUNCHD_LABEL); + serviceLoaded = output.includes(def.launchdLabel); } catch { // launchctl list failed } emitStatus('SETUP_SERVICE', { + SERVICE_NAME: def.name, SERVICE_TYPE: 'launchd', NODE_PATH: nodePath, PROJECT_PATH: projectRoot, @@ -147,7 +223,12 @@ function setupLaunchd( }); } +/* ------------------------------------------------------------------ */ +/* Linux */ +/* ------------------------------------------------------------------ */ + function setupLinux( + serviceDefs: ServiceDef[], projectRoot: string, nodePath: string, homeDir: string, @@ -155,10 +236,12 @@ function setupLinux( const serviceManager = getServiceManager(); if (serviceManager === 'systemd') { - setupSystemd(projectRoot, nodePath, homeDir); + setupSystemdAll(serviceDefs, projectRoot, nodePath, homeDir); } else { // WSL without systemd or other Linux without systemd - setupNohupFallback(projectRoot, nodePath, homeDir); + for (const def of serviceDefs) { + setupNohupFallback(def, projectRoot, nodePath, homeDir); + } } } @@ -177,151 +260,221 @@ function killOrphanedProcesses(projectRoot: string): void { } } -function setupSystemd( +/* ------------------------------------------------------------------ */ +/* systemd */ +/* ------------------------------------------------------------------ */ + +function setupSystemdAll( + serviceDefs: ServiceDef[], projectRoot: string, nodePath: string, homeDir: string, ): void { const runningAsRoot = isRoot(); + const systemctlPrefix = runningAsRoot ? 'systemctl' : 'systemctl --user'; - // Root uses system-level service, non-root uses user-level - let unitPath: string; - let systemctlPrefix: string; - - if (runningAsRoot) { - unitPath = `/etc/systemd/system/${EJCLAW_SERVICE_NAME}.service`; - systemctlPrefix = 'systemctl'; - logger.info('Running as root — installing system-level systemd unit'); - } else { - // Check if user-level systemd session is available + // Pre-flight: verify user-level systemd session is available + if (!runningAsRoot) { try { execSync('systemctl --user daemon-reload', { stdio: 'pipe' }); } catch { logger.warn( 'systemd user session not available — falling back to nohup wrapper', ); - setupNohupFallback(projectRoot, nodePath, homeDir); + for (const def of serviceDefs) { + setupNohupFallback(def, projectRoot, nodePath, homeDir); + } return; } - const unitDir = path.join(homeDir, '.config', 'systemd', 'user'); - fs.mkdirSync(unitDir, { recursive: true }); - unitPath = path.join(unitDir, `${EJCLAW_SERVICE_NAME}.service`); - systemctlPrefix = 'systemctl --user'; } - const unit = `[Unit] -Description=EJClaw Personal Assistant -After=network.target - -[Service] -Type=simple -ExecStart=${nodePath} ${projectRoot}/dist/index.js -WorkingDirectory=${projectRoot} -Restart=always -RestartSec=5 -Environment=HOME=${homeDir} -Environment=PATH=${path.dirname(nodePath)}:/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin:${homeDir}/.npm-global/bin -StandardOutput=append:${projectRoot}/logs/ejclaw.log -StandardError=append:${projectRoot}/logs/ejclaw.error.log - -[Install] -WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; - - fs.writeFileSync(unitPath, unit); - logger.info({ unitPath }, 'Wrote systemd unit'); - - // Kill orphaned ejclaw processes to avoid channel connection conflicts + // Kill orphaned processes once before installing any services killOrphanedProcesses(projectRoot); - // Enable and start + // Install each service + for (const def of serviceDefs) { + setupSystemdUnit(def, projectRoot, nodePath, homeDir, runningAsRoot); + } + + // Reload daemon once after all units are written try { execSync(`${systemctlPrefix} daemon-reload`, { stdio: 'ignore' }); } catch (err) { logger.error({ err }, 'systemctl daemon-reload failed'); } - try { - execSync(`${systemctlPrefix} enable ${EJCLAW_SERVICE_NAME}`, { - stdio: 'ignore', - }); - } catch (err) { - logger.error({ err }, 'systemctl enable failed'); - } + // Enable and start each service + for (const def of serviceDefs) { + try { + execSync(`${systemctlPrefix} enable ${def.name}`, { stdio: 'ignore' }); + } catch (err) { + logger.error({ err, service: def.name }, 'systemctl enable failed'); + } - try { - execSync(`${systemctlPrefix} start ${EJCLAW_SERVICE_NAME}`, { - stdio: 'ignore', - }); - } catch (err) { - logger.error({ err }, 'systemctl start failed'); - } + try { + execSync(`${systemctlPrefix} start ${def.name}`, { stdio: 'ignore' }); + } catch (err) { + logger.error({ err, service: def.name }, 'systemctl start failed'); + } - // Verify - let serviceLoaded = false; - try { - execSync(`${systemctlPrefix} is-active ${EJCLAW_SERVICE_NAME}`, { - stdio: 'ignore', - }); - serviceLoaded = true; - } catch { - // Not active - } + // Verify + let serviceLoaded = false; + try { + execSync(`${systemctlPrefix} is-active ${def.name}`, { + stdio: 'ignore', + }); + serviceLoaded = true; + } catch { + // Not active + } - emitStatus('SETUP_SERVICE', { - SERVICE_TYPE: runningAsRoot ? 'systemd-system' : 'systemd-user', - NODE_PATH: nodePath, - PROJECT_PATH: projectRoot, - UNIT_PATH: unitPath, - SERVICE_LOADED: serviceLoaded, - STATUS: 'success', - LOG: 'logs/setup.log', - }); + emitStatus('SETUP_SERVICE', { + SERVICE_NAME: def.name, + SERVICE_TYPE: runningAsRoot ? 'systemd-system' : 'systemd-user', + NODE_PATH: nodePath, + PROJECT_PATH: projectRoot, + UNIT_PATH: getUnitPath(def.name, homeDir, runningAsRoot), + SERVICE_LOADED: serviceLoaded, + STATUS: 'success', + LOG: 'logs/setup.log', + }); + } } +function getUnitPath( + serviceName: string, + homeDir: string, + runningAsRoot: boolean, +): string { + if (runningAsRoot) { + return `/etc/systemd/system/${serviceName}.service`; + } + return path.join( + homeDir, + '.config', + 'systemd', + 'user', + `${serviceName}.service`, + ); +} + +function setupSystemdUnit( + def: ServiceDef, + projectRoot: string, + nodePath: string, + homeDir: string, + runningAsRoot: boolean, +): void { + const unitPath = getUnitPath(def.name, homeDir, runningAsRoot); + fs.mkdirSync(path.dirname(unitPath), { recursive: true }); + + // Build Environment= lines + const envLines = [ + `Environment=HOME=${homeDir}`, + `Environment=PATH=${path.dirname(nodePath)}:/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin:${homeDir}/.npm-global/bin`, + ]; + if (def.extraEnv) { + for (const [k, v] of Object.entries(def.extraEnv)) { + envLines.push(`Environment=${k}=${v}`); + } + } + + // EnvironmentFile line (optional — for codex service loading .env.codex) + const envFileLine = def.environmentFile + ? `EnvironmentFile=${def.environmentFile}\n` + : ''; + + const unit = `[Unit] +Description=${def.description} +After=network.target + +[Service] +${envFileLine}Type=simple +ExecStart=${nodePath} ${projectRoot}/dist/index.js +WorkingDirectory=${projectRoot} +Restart=always +RestartSec=5 +${envLines.join('\n')} +StandardOutput=append:${projectRoot}/logs/${def.logName}.log +StandardError=append:${projectRoot}/logs/${def.logName}.error.log + +[Install] +WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; + + fs.writeFileSync(unitPath, unit); + logger.info({ unitPath, service: def.name }, 'Wrote systemd unit'); +} + +/* ------------------------------------------------------------------ */ +/* nohup fallback (WSL / no systemd) */ +/* ------------------------------------------------------------------ */ + function setupNohupFallback( + def: ServiceDef, projectRoot: string, nodePath: string, homeDir: string, ): void { - logger.warn('No systemd detected — generating nohup wrapper script'); + logger.warn( + { service: def.name }, + 'No systemd detected — generating nohup wrapper script', + ); - const wrapperPath = path.join(projectRoot, 'start-ejclaw.sh'); - const pidFile = path.join(projectRoot, 'ejclaw.pid'); + const wrapperPath = path.join(projectRoot, `start-${def.name}.sh`); + const pidFile = path.join(projectRoot, `${def.name}.pid`); + + // Build export lines for extra env + const exportLines: string[] = []; + if (def.environmentFile) { + exportLines.push(`# Load environment file`); + exportLines.push(`set -a`); + exportLines.push(`source ${JSON.stringify(def.environmentFile)}`); + exportLines.push(`set +a`); + exportLines.push(''); + } + if (def.extraEnv) { + for (const [k, v] of Object.entries(def.extraEnv)) { + exportLines.push(`export ${k}=${JSON.stringify(v)}`); + } + exportLines.push(''); + } const lines = [ '#!/bin/bash', - '# start-ejclaw.sh — Start EJClaw without systemd', + `# start-${def.name}.sh — Start ${def.description} without systemd`, `# To stop: kill \\$(cat ${pidFile})`, '', 'set -euo pipefail', '', `cd ${JSON.stringify(projectRoot)}`, '', + ...exportLines, '# Stop existing instance if running', `if [ -f ${JSON.stringify(pidFile)} ]; then`, ` OLD_PID=$(cat ${JSON.stringify(pidFile)} 2>/dev/null || echo "")`, ' if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then', - ' echo "Stopping existing EJClaw (PID $OLD_PID)..."', + ` echo "Stopping existing ${def.name} (PID $OLD_PID)..."`, ' kill "$OLD_PID" 2>/dev/null || true', ' sleep 2', ' fi', 'fi', '', - 'echo "Starting EJClaw..."', + `echo "Starting ${def.description}..."`, `nohup ${JSON.stringify(nodePath)} ${JSON.stringify(projectRoot + '/dist/index.js')} \\`, - ` >> ${JSON.stringify(projectRoot + '/logs/ejclaw.log')} \\`, - ` 2>> ${JSON.stringify(projectRoot + '/logs/ejclaw.error.log')} &`, + ` >> ${JSON.stringify(projectRoot + '/logs/' + def.logName + '.log')} \\`, + ` 2>> ${JSON.stringify(projectRoot + '/logs/' + def.logName + '.error.log')} &`, '', `echo $! > ${JSON.stringify(pidFile)}`, - 'echo "EJClaw started (PID $!)"', - `echo "Logs: tail -f ${projectRoot}/logs/ejclaw.log"`, + `echo "${def.name} started (PID $!)"`, + `echo "Logs: tail -f ${projectRoot}/logs/${def.logName}.log"`, ]; const wrapper = lines.join('\n') + '\n'; fs.writeFileSync(wrapperPath, wrapper, { mode: 0o755 }); - logger.info({ wrapperPath }, 'Wrote nohup wrapper script'); + logger.info({ wrapperPath, service: def.name }, 'Wrote nohup wrapper script'); emitStatus('SETUP_SERVICE', { + SERVICE_NAME: def.name, SERVICE_TYPE: 'nohup', NODE_PATH: nodePath, PROJECT_PATH: projectRoot, diff --git a/setup/verify.ts b/setup/verify.ts index 2b6b393..049ee41 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -2,6 +2,10 @@ * Step: verify — End-to-end health check of the full installation. * Replaces 09-verify.sh * + * Supports dual-service architecture: + * - ejclaw (Claude Code) — always checked + * - ejclaw-codex (Codex) — checked when .env.codex exists + * * Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks. */ import { execSync } from 'child_process'; @@ -16,64 +20,125 @@ import { logger } from '../src/logger.js'; import { getPlatform, getServiceManager, isRoot } from './platform.js'; import { emitStatus } from './status.js'; +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +type ServiceStatus = 'running' | 'stopped' | 'not_found' | 'not_configured'; + +interface ServiceCheck { + name: string; + status: ServiceStatus; +} + +/* ------------------------------------------------------------------ */ +/* Service status checks */ +/* ------------------------------------------------------------------ */ + +function checkLaunchdService(label: string): ServiceStatus { + try { + const output = execSync('launchctl list', { encoding: 'utf-8' }); + if (output.includes(label)) { + const line = output.split('\n').find((l) => l.includes(label)); + if (line) { + const pidField = line.trim().split(/\s+/)[0]; + return pidField !== '-' && pidField ? 'running' : 'stopped'; + } + } + } catch { + // launchctl not available + } + return 'not_found'; +} + +function checkSystemdService(name: string): ServiceStatus { + const prefix = isRoot() ? 'systemctl' : 'systemctl --user'; + try { + execSync(`${prefix} is-active ${name}`, { stdio: 'ignore' }); + return 'running'; + } catch { + try { + const output = execSync(`${prefix} list-unit-files`, { + encoding: 'utf-8', + }); + if (output.includes(name)) { + return 'stopped'; + } + } catch { + // systemctl not available + } + } + return 'not_found'; +} + +function checkNohupService( + projectRoot: string, + serviceName: string, +): ServiceStatus { + const pidFile = path.join(projectRoot, `${serviceName}.pid`); + if (fs.existsSync(pidFile)) { + try { + const raw = fs.readFileSync(pidFile, 'utf-8').trim(); + const pid = Number(raw); + if (raw && Number.isInteger(pid) && pid > 0) { + process.kill(pid, 0); + return 'running'; + } + } catch { + return 'stopped'; + } + } + return 'not_found'; +} + +function checkService( + projectRoot: string, + mgr: ReturnType, + serviceName: string, + launchdLabel: string, +): ServiceStatus { + if (mgr === 'launchd') return checkLaunchdService(launchdLabel); + if (mgr === 'systemd') return checkSystemdService(serviceName); + return checkNohupService(projectRoot, serviceName); +} + +/* ------------------------------------------------------------------ */ +/* Main */ +/* ------------------------------------------------------------------ */ + export async function run(_args: string[]): Promise { const projectRoot = process.cwd(); - const platform = getPlatform(); + const mgr = getServiceManager(); logger.info('Starting verification'); - // 1. Check service status - let service = 'not_found'; - const mgr = getServiceManager(); + // 1. Check service statuses + const services: ServiceCheck[] = []; - if (mgr === 'launchd') { - try { - const output = execSync('launchctl list', { encoding: 'utf-8' }); - if (output.includes('com.ejclaw')) { - // Check if it has a PID (actually running) - const line = output.split('\n').find((l) => l.includes('com.ejclaw')); - if (line) { - const pidField = line.trim().split(/\s+/)[0]; - service = pidField !== '-' && pidField ? 'running' : 'stopped'; - } - } - } catch { - // launchctl not available - } - } else if (mgr === 'systemd') { - const prefix = isRoot() ? 'systemctl' : 'systemctl --user'; - try { - execSync(`${prefix} is-active ejclaw`, { stdio: 'ignore' }); - service = 'running'; - } catch { - try { - const output = execSync(`${prefix} list-unit-files`, { - encoding: 'utf-8', - }); - if (output.includes('ejclaw')) { - service = 'stopped'; - } - } catch { - // systemctl not available - } - } - } else { - // Check for nohup PID file - const pidFile = path.join(projectRoot, 'ejclaw.pid'); - if (fs.existsSync(pidFile)) { - try { - const raw = fs.readFileSync(pidFile, 'utf-8').trim(); - const pid = Number(raw); - if (raw && Number.isInteger(pid) && pid > 0) { - process.kill(pid, 0); - service = 'running'; - } - } catch { - service = 'stopped'; - } - } + // Primary service (always checked) + services.push({ + name: 'ejclaw', + status: checkService(projectRoot, mgr, 'ejclaw', 'com.ejclaw'), + }); + + // Codex service (checked when .env.codex exists) + const codexEnvPath = path.join(projectRoot, '.env.codex'); + const codexConfigured = fs.existsSync(codexEnvPath); + if (codexConfigured) { + services.push({ + name: 'ejclaw-codex', + status: checkService( + projectRoot, + mgr, + 'ejclaw-codex', + 'com.ejclaw-codex', + ), + }); + } + + for (const svc of services) { + logger.info({ service: svc.name, status: svc.status }, 'Service status'); } - logger.info({ service }, 'Service status'); // 2. Check credentials let credentials = 'missing'; @@ -99,6 +164,7 @@ export async function run(_args: string[]): Promise { // 4. Check registered groups (using better-sqlite3, not sqlite3 CLI) let registeredGroups = 0; + let groupsByAgent: Record = {}; const dbPath = path.join(STORE_DIR, 'messages.db'); if (fs.existsSync(dbPath)) { try { @@ -107,6 +173,21 @@ export async function run(_args: string[]): Promise { .prepare('SELECT COUNT(*) as count FROM registered_groups') .get() as { count: number }; registeredGroups = row.count; + + // Count by agent type + try { + const rows = db + .prepare( + 'SELECT agent_type, COUNT(*) as count FROM registered_groups GROUP BY agent_type', + ) + .all() as { agent_type: string; count: number }[]; + for (const r of rows) { + groupsByAgent[r.agent_type || 'unknown'] = r.count; + } + } catch { + // agent_type column might not exist in older schema + } + db.close(); } catch { // Table might not exist @@ -114,22 +195,36 @@ export async function run(_args: string[]): Promise { } // Determine overall status + const primaryRunning = services[0].status === 'running'; + const codexOk = !codexConfigured || services[1]?.status === 'running'; + const status = - service === 'running' && + primaryRunning && + codexOk && credentials !== 'missing' && anyChannelConfigured && registeredGroups > 0 ? 'success' : 'failed'; - logger.info({ status, channelAuth }, 'Verification complete'); + // Build service status summary + const servicesSummary: Record = {}; + for (const svc of services) { + servicesSummary[svc.name] = svc.status; + } + + logger.info({ status, channelAuth, servicesSummary }, 'Verification complete'); emitStatus('VERIFY', { - SERVICE: service, + SERVICES: JSON.stringify(servicesSummary), + // Legacy field (keep for backward compatibility) + SERVICE: services[0].status, CREDENTIALS: credentials, CONFIGURED_CHANNELS: configuredChannels.join(','), CHANNEL_AUTH: JSON.stringify(channelAuth), REGISTERED_GROUPS: registeredGroups, + GROUPS_BY_AGENT: JSON.stringify(groupsByAgent), + CODEX_CONFIGURED: codexConfigured, STATUS: status, LOG: 'logs/setup.log', });