chore: align setup with EJClaw dual-service architecture

This commit is contained in:
Eyejoker
2026-03-26 19:10:07 +09:00
parent 3efacbdda1
commit 2fd4cfc9d0
10 changed files with 567 additions and 198 deletions

View File

@@ -1,13 +1,5 @@
{
"env": {
"CLAUDE_AUTOCOMPACT_PCT_OVERRIDE": "50"
},
"extraKnownMarketplaces": {
"nanoclaw-skills": {
"source": {
"source": "github",
"repo": "qwibitai/nanoclaw-skills"
}
}
}
}

View File

@@ -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
```
메인 채널이면 일반 메시지, 보조 채널이면 멘션 또는 트리거 메시지로 테스트합니다.

View File

@@ -43,7 +43,7 @@ PARALLEL_API_KEY=...
- 빠른 사실 조회나 최신 정보 확인은 `parallel-search`
- 긴 조사나 깊은 분석은 `parallel-task`
- `parallel-task`는 항상 먼저 사용자 허가를 받을 것
- 오래 걸리는 작업은 답변을 붙잡고 있지 말고 `mcp__nanoclaw__schedule_task`로 후속 체크를 맡길 것
- 오래 걸리는 작업은 답변을 붙잡고 있지 말고 `mcp__ejclaw__schedule_task`로 후속 체크를 맡길 것
## 5. 빌드와 검증

View File

@@ -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입니다.

View File

@@ -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 <name>`으로 진행합니다. 현재 기준 채널은 디스코드만 지원합니다.
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:<channel_id>` 형식입니다.
메인 채널 예시:
듀얼 서비스에서는 **같은 채널을 두 번 등록**할 수 있습니다 — 각 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`

7
.env.codex.example Normal file
View File

@@ -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)

View File

@@ -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

View File

@@ -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.

View File

@@ -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<string, string>;
}
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<void> {
const projectRoot = process.cwd();
@@ -54,10 +106,14 @@ export async function run(_args: string[]): Promise<void> {
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<void> {
}
}
/* ------------------------------------------------------------------ */
/* 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 = [
` <key>PATH</key>`,
` <string>${path.dirname(nodePath)}:/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin:${homeDir}/.npm-global/bin</string>`,
` <key>HOME</key>`,
` <string>${homeDir}</string>`,
];
if (def.extraEnv) {
for (const [k, v] of Object.entries(def.extraEnv)) {
envEntries.push(` <key>${k}</key>`);
envEntries.push(` <string>${v}</string>`);
}
}
const plist = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${EJCLAW_LAUNCHD_LABEL}</string>
<string>${def.launchdLabel}</string>
<key>ProgramArguments</key>
<array>
<string>${nodePath}</string>
@@ -103,40 +178,41 @@ function setupLaunchd(
<true/>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>${path.dirname(nodePath)}:/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin:${homeDir}/.npm-global/bin</string>
<key>HOME</key>
<string>${homeDir}</string>
${envEntries.join('\n')}
</dict>
<key>StandardOutPath</key>
<string>${projectRoot}/logs/ejclaw.log</string>
<string>${projectRoot}/logs/${def.logName}.log</string>
<key>StandardErrorPath</key>
<string>${projectRoot}/logs/ejclaw.error.log</string>
<string>${projectRoot}/logs/${def.logName}.error.log</string>
</dict>
</plist>`;
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,

View File

@@ -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<typeof getServiceManager>,
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<void> {
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<void> {
// 4. Check registered groups (using better-sqlite3, not sqlite3 CLI)
let registeredGroups = 0;
let groupsByAgent: Record<string, number> = {};
const dbPath = path.join(STORE_DIR, 'messages.db');
if (fs.existsSync(dbPath)) {
try {
@@ -107,6 +173,21 @@ export async function run(_args: string[]): Promise<void> {
.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<void> {
}
// 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<string, string> = {};
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',
});