Rebrand NanoClaw to EJClaw

This commit is contained in:
Eyejoker
2026-03-19 03:31:41 +09:00
parent 29b78fc286
commit f2ad1331a9
44 changed files with 564 additions and 535 deletions

View File

@@ -1,10 +1,10 @@
# NanoClaw
# EJClaw
Dual-agent AI assistant (Claude Code + Codex) over Discord. Based on [qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw).
## Quick Context
Two systemd services (`nanoclaw`, `nanoclaw-codex`) share the same codebase but run with separate stores, data, and groups (will be unified — DB supports shared access via WAL mode + service partitioning). Agents run as direct host processes (no containers). Claude Code uses the Agent SDK; Codex uses the Codex SDK (`codex exec`). Auth via `CLAUDE_CODE_OAUTH_TOKEN` in `.env` (1-year token from `claude setup-token`).
Two systemd services (`ejclaw`, `ejclaw-codex`) share the same codebase but run with separate stores, data, and groups (will be unified — DB supports shared access via WAL mode + service partitioning). Agents run as direct host processes (no containers). Claude Code uses the Agent SDK; Codex uses the Codex SDK (`codex exec`). Auth via `CLAUDE_CODE_OAUTH_TOKEN` in `.env` (1-year token from `claude setup-token`).
## Key Files
@@ -29,7 +29,7 @@ Two systemd services (`nanoclaw`, `nanoclaw-codex`) share the same codebase but
| `/setup` | First-time installation, authentication, service configuration |
| `/customize` | Adding channels, integrations, changing behavior |
| `/debug` | Agent issues, logs, troubleshooting |
| `/update-nanoclaw` | Bring upstream NanoClaw updates into a customized install |
| `/update-nanoclaw` | Bring upstream EJClaw updates into a customized install |
| `/qodo-pr-resolver` | Fetch and fix Qodo PR review issues interactively or in batch |
| `/get-qodo-rules` | Load org- and repo-level coding rules from Qodo before code tasks |
@@ -45,17 +45,17 @@ npm run dev # Dev mode with hot reload
Service management (Linux):
```bash
systemctl --user restart nanoclaw nanoclaw-codex # Restart both
systemctl --user status nanoclaw # Check status
journalctl --user -u nanoclaw -f # Follow logs
systemctl --user restart ejclaw ejclaw-codex # Restart both
systemctl --user status ejclaw # Check status
journalctl --user -u ejclaw -f # Follow logs
```
Deploy to server: `scp dist/*.js clone-ej@100.64.185.108:~/nanoclaw/dist/`
Deploy to server: `scp dist/*.js clone-ej@100.64.185.108:~/ejclaw/dist/`
## Dual-Service Architecture
- `nanoclaw.service` — Claude Code bot (`@claude`), `SERVICE_ID=claude`, `SERVICE_AGENT_TYPE=claude-code`
- `nanoclaw-codex.service` — Codex bot (`@codex`), `SERVICE_ID=codex`, `SERVICE_AGENT_TYPE=codex`
- `ejclaw.service` — Claude Code bot (`@claude`), `SERVICE_ID=claude`, `SERVICE_AGENT_TYPE=claude-code`
- `ejclaw-codex.service` — Codex bot (`@codex`), `SERVICE_ID=codex`, `SERVICE_AGENT_TYPE=codex`
- Both share the same codebase (`dist/index.js`), differentiated by env vars
- Unified dirs (`store/`, `groups/`, `data/` shared by both services):
- `router_state`: keys prefixed with `{SERVICE_ID}:` (e.g., `claude:last_timestamp`)
@@ -70,13 +70,14 @@ Unified DB + directories (both services share `store/`, `groups/`, `data/`):
| 항목 | 경로 |
|------|------|
| **DB** | `store/messages.db` (공유, WAL 모드) |
| 서비스 로그 (Claude) | `journalctl --user -u nanoclaw -f` 또는 `logs/nanoclaw.log` |
| 서비스 로그 (Codex) | `journalctl --user -u nanoclaw-codex -f` 또는 `logs/nanoclaw-codex.log` |
| 서비스 로그 (Claude) | `journalctl --user -u ejclaw -f` 또는 `logs/ejclaw.log` |
| 서비스 로그 (Codex) | `journalctl --user -u ejclaw-codex -f` 또는 `logs/ejclaw-codex.log` |
| 그룹별 로그 | `groups/{name}/logs/` (공유 채널은 양쪽 봇 로그가 같은 폴더) |
| Claude 세션 | `data/sessions/{name}/.claude/` |
| Codex 세션 | `data/sessions/{name}/.codex/` |
| Claude 글로벌 설정 | `groups/global/CLAUDE.md` |
| Codex 글로벌 설정 | `~/.codex/AGENTS.md` |
| Claude 플랫폼 규칙 | `prompts/claude-platform.md` |
| Codex 플랫폼 규칙 | `prompts/codex-platform.md` |
| Claude 글로벌 메모리 | `groups/global/CLAUDE.md` |
## Codex SDK

View File

@@ -8,7 +8,7 @@
## Skills
A [skill](https://code.claude.com/docs/en/skills) is a markdown file in `.claude/skills/` that teaches Claude Code how to transform a NanoClaw installation.
A [skill](https://code.claude.com/docs/en/skills) is a markdown file in `.claude/skills/` that teaches Claude Code how to transform an EJClaw installation.
A PR that contributes a skill should not modify any source files.

View File

@@ -1,13 +1,11 @@
<p align="center">
<img src="assets/nanoclaw-logo.png" alt="NanoClaw" width="400">
</p>
<h1 align="center">EJClaw</h1>
<p align="center">
Dual-agent AI assistant running Claude Code + Codex as parallel services over Discord.
</p>
<p align="center">
Based on <a href="https://github.com/qwibitai/nanoclaw">qwibitai/nanoclaw</a>
Forked from <a href="https://github.com/qwibitai/nanoclaw">qwibitai/nanoclaw</a>
</p>
## Overview
@@ -40,7 +38,7 @@ Discord ──► SQLite ──► GroupQueue ──┬──► Claude Agent SD
### Directory Layout
```
nanoclaw/
ejclaw/
├── src/
│ ├── index.ts # Orchestrator: state, message loop, agent invocation
│ ├── agent-runner.ts # Spawns agent processes, manages env/sessions/skills
@@ -119,8 +117,8 @@ Skills are managed from a single source of truth (`~/.claude/skills/` on the ser
### 1. Clone and Install
```bash
git clone https://github.com/phj1081/nanoclaw.git
cd nanoclaw
git clone https://github.com/phj1081/nanoclaw.git ejclaw
cd ejclaw
npm install
npm run build:runners # installs + builds both agent-runner and codex-runner
npm run build # builds main project
@@ -163,17 +161,17 @@ DISCORD_BOT_TOKEN= # Codex Discord bot token (different from above)
### 4. Systemd Services (Linux)
Create `~/.config/systemd/user/nanoclaw.service`:
Create `~/.config/systemd/user/ejclaw.service`:
```ini
[Unit]
Description=NanoClaw Claude Code
Description=EJClaw Claude Code
After=network.target
[Service]
Type=simple
ExecStart=/path/to/node /path/to/nanoclaw/dist/index.js
WorkingDirectory=/path/to/nanoclaw
ExecStart=/path/to/node /path/to/ejclaw/dist/index.js
WorkingDirectory=/path/to/ejclaw
Restart=always
RestartSec=5
Environment=HOME=/home/youruser
@@ -183,26 +181,26 @@ Environment=PATH=/path/to/node/bin:/usr/local/bin:/usr/bin:/bin
WantedBy=default.target
```
Create `~/.config/systemd/user/nanoclaw-codex.service`:
Create `~/.config/systemd/user/ejclaw-codex.service`:
```ini
[Unit]
Description=NanoClaw Codex
Description=EJClaw Codex
After=network.target
[Service]
EnvironmentFile=/path/to/nanoclaw/.env.codex
EnvironmentFile=/path/to/ejclaw/.env.codex
Type=simple
ExecStart=/path/to/node /path/to/nanoclaw/dist/index.js
WorkingDirectory=/path/to/nanoclaw
ExecStart=/path/to/node /path/to/ejclaw/dist/index.js
WorkingDirectory=/path/to/ejclaw
Restart=always
RestartSec=5
Environment=HOME=/home/youruser
Environment=PATH=/path/to/node/bin:/usr/local/bin:/usr/bin:/bin
Environment=ASSISTANT_NAME=codex
Environment=NANOCLAW_STORE_DIR=/path/to/nanoclaw/store-codex
Environment=NANOCLAW_DATA_DIR=/path/to/nanoclaw/data-codex
Environment=NANOCLAW_GROUPS_DIR=/path/to/nanoclaw/groups-codex
Environment=EJCLAW_STORE_DIR=/path/to/ejclaw/store-codex
Environment=EJCLAW_DATA_DIR=/path/to/ejclaw/data-codex
Environment=EJCLAW_GROUPS_DIR=/path/to/ejclaw/groups-codex
[Install]
WantedBy=default.target
@@ -212,12 +210,12 @@ Then enable and start:
```bash
systemctl --user daemon-reload
systemctl --user enable nanoclaw nanoclaw-codex
systemctl --user start nanoclaw nanoclaw-codex
systemctl --user enable ejclaw ejclaw-codex
systemctl --user start ejclaw ejclaw-codex
# Logs
journalctl --user -u nanoclaw -f
journalctl --user -u nanoclaw-codex -f
journalctl --user -u ejclaw -f
journalctl --user -u ejclaw-codex -f
```
### 5. Register Discord Channels
@@ -247,9 +245,9 @@ Fields:
### macOS (launchd)
```bash
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl load ~/Library/LaunchAgents/com.nanoclaw-codex.plist
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
launchctl load ~/Library/LaunchAgents/com.ejclaw.plist
launchctl load ~/Library/LaunchAgents/com.ejclaw-codex.plist
launchctl kickstart -k gui/$(id -u)/com.ejclaw
```
## Development

View File

@@ -1,4 +1,4 @@
# NanoClaw Debug Checklist
# EJClaw Debug Checklist
## Known Issues (2026-02-08)
@@ -16,7 +16,7 @@ Both timers fire at the same time, so containers always exit via hard SIGKILL (c
```bash
# 1. Is the service running?
launchctl list | grep nanoclaw
# Expected: PID 0 com.nanoclaw (PID = running, "-" = not running, non-zero exit = crashed)
# Expected: PID 0 com.ejclaw (PID = running, "-" = not running, non-zero exit = crashed)
# 2. Any running containers?
container ls --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw
@@ -25,13 +25,13 @@ container ls --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw
container ls -a --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw
# 4. Recent errors in service log?
grep -E 'ERROR|WARN' logs/nanoclaw.log | tail -20
grep -E 'ERROR|WARN' logs/ejclaw.log | tail -20
# 5. Is WhatsApp connected? (look for last connection event)
grep -E 'Connected to WhatsApp|Connection closed|connection.*close' logs/nanoclaw.log | tail -5
grep -E 'Connected to WhatsApp|Connection closed|connection.*close' logs/ejclaw.log | tail -5
# 6. Are groups loaded?
grep 'groupCount' logs/nanoclaw.log | tail -3
grep 'groupCount' logs/ejclaw.log | tail -3
```
## Session Transcript Branching
@@ -62,7 +62,7 @@ for i, line in enumerate(lines):
```bash
# Check for recent timeouts
grep -E 'Container timeout|timed out' logs/nanoclaw.log | tail -10
grep -E 'Container timeout|timed out' logs/ejclaw.log | tail -10
# Check container log files for the timed-out container
ls -lt groups/*/logs/container-*.log | head -10
@@ -71,23 +71,23 @@ ls -lt groups/*/logs/container-*.log | head -10
cat groups/<group>/logs/container-<timestamp>.log
# Check if retries were scheduled and what happened
grep -E 'Scheduling retry|retry|Max retries' logs/nanoclaw.log | tail -10
grep -E 'Scheduling retry|retry|Max retries' logs/ejclaw.log | tail -10
```
## Agent Not Responding
```bash
# Check if messages are being received from WhatsApp
grep 'New messages' logs/nanoclaw.log | tail -10
grep 'New messages' logs/ejclaw.log | tail -10
# Check if messages are being processed (container spawned)
grep -E 'Processing messages|Spawning container' logs/nanoclaw.log | tail -10
grep -E 'Processing messages|Spawning container' logs/ejclaw.log | tail -10
# Check if messages are being piped to active container
grep -E 'Piped messages|sendMessage' logs/nanoclaw.log | tail -10
grep -E 'Piped messages|sendMessage' logs/ejclaw.log | tail -10
# Check the queue state — any active containers?
grep -E 'Starting container|Container active|concurrency limit' logs/nanoclaw.log | tail -10
grep -E 'Starting container|Container active|concurrency limit' logs/ejclaw.log | tail -10
# Check lastAgentTimestamp vs latest message timestamp
sqlite3 store/messages.db "SELECT chat_jid, MAX(timestamp) as latest FROM messages GROUP BY chat_jid ORDER BY latest DESC LIMIT 5;"
@@ -97,10 +97,10 @@ sqlite3 store/messages.db "SELECT chat_jid, MAX(timestamp) as latest FROM messag
```bash
# Check mount validation logs (shows on container spawn)
grep -E 'Mount validated|Mount.*REJECTED|mount' logs/nanoclaw.log | tail -10
grep -E 'Mount validated|Mount.*REJECTED|mount' logs/ejclaw.log | tail -10
# Verify the mount allowlist is readable
cat ~/.config/nanoclaw/mount-allowlist.json
cat ~/.config/ejclaw/mount-allowlist.json
# Check group's container_config in DB
sqlite3 store/messages.db "SELECT name, container_config FROM registered_groups;"
@@ -114,7 +114,7 @@ container run -i --rm --entrypoint ls nanoclaw-agent:latest /workspace/extra/
```bash
# Check if QR code was requested (means auth expired)
grep 'QR\|authentication required\|qr' logs/nanoclaw.log | tail -5
grep 'QR\|authentication required\|qr' logs/ejclaw.log | tail -5
# Check auth files exist
ls -la store/auth/
@@ -127,17 +127,17 @@ npm run auth
```bash
# Restart the service
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
launchctl kickstart -k gui/$(id -u)/com.ejclaw
# View live logs
tail -f logs/nanoclaw.log
tail -f logs/ejclaw.log
# Stop the service (careful — running containers are detached, not killed)
launchctl bootout gui/$(id -u)/com.nanoclaw
launchctl bootout gui/$(id -u)/com.ejclaw
# Start the service
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.ejclaw.plist
# Rebuild after code changes
npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw
npm run build && launchctl kickstart -k gui/$(id -u)/com.ejclaw
```

View File

@@ -1,4 +1,4 @@
# NanoClaw Requirements
# EJClaw Requirements
Original requirements and design decisions from the project creator.
@@ -8,7 +8,7 @@ Original requirements and design decisions from the project creator.
This is a lightweight, secure alternative to OpenClaw (formerly ClawBot). That project became a monstrosity - 4-5 different processes running different gateways, endless configuration files, endless integrations. It's a security nightmare where agents don't run in isolated processes; there's all kinds of leaky workarounds trying to prevent them from accessing parts of the system they shouldn't. It's impossible for anyone to realistically understand the whole codebase. When you run it you're kind of just yoloing it.
NanoClaw gives you the core functionality without that mess.
EJClaw gives you the core functionality without that mess.
---
@@ -193,4 +193,4 @@ These are the creator's settings, stored here for reference:
## Project Name
**NanoClaw** - A reference to Clawdbot (now OpenClaw).
**EJClaw** - A reference to Clawdbot (now OpenClaw).

View File

@@ -1,4 +1,4 @@
# NanoClaw Security Model
# EJClaw Security Model
## Trust Model
@@ -23,7 +23,7 @@ This is the primary security boundary. Rather than relying on application-level
### 2. Mount Security
**External Allowlist** - Mount permissions stored at `~/.config/nanoclaw/mount-allowlist.json`, which is:
**External Allowlist** - Mount permissions stored at `~/.config/ejclaw/mount-allowlist.json`, which is:
- Outside project root
- Never mounted into containers
- Cannot be modified by agents

View File

@@ -1,4 +1,4 @@
# NanoClaw Specification
# EJClaw Specification
A personal Claude assistant with multi-channel support, persistent memory per conversation, scheduled tasks, and container-isolated agent execution.
@@ -314,12 +314,12 @@ nanoclaw/
│ └── ipc/ # Container IPC (messages/, tasks/)
├── logs/ # Runtime logs (gitignored)
│ ├── nanoclaw.log # Host stdout
│ └── nanoclaw.error.log # Host stderr
│ ├── ejclaw.log # Host stdout
│ └── ejclaw.error.log # Host stderr
│ # Note: Per-container logs are in groups/{folder}/logs/container-*.log
└── launchd/
└── com.nanoclaw.plist # macOS service configuration
└── com.ejclaw.plist # macOS service configuration
```
---
@@ -422,7 +422,7 @@ Files with `{{PLACEHOLDER}}` values need to be configured:
## Memory System
NanoClaw uses a hierarchical memory system based on CLAUDE.md files.
EJClaw uses a hierarchical memory system based on CLAUDE.md files.
### Memory Hierarchy
@@ -555,7 +555,7 @@ This allows the agent to understand the conversation context even if it wasn't m
## Scheduled Tasks
NanoClaw has a built-in scheduler that runs tasks as full agents in their group's context.
EJClaw has a built-in scheduler that runs tasks as full agents in their group's context.
### How Scheduling Works
@@ -616,7 +616,7 @@ From main channel:
## MCP Servers
### NanoClaw MCP (built-in)
### EJClaw MCP (built-in)
The `nanoclaw` MCP server is created dynamically per agent call with the current group's context.
@@ -636,12 +636,12 @@ The `nanoclaw` MCP server is created dynamically per agent call with the current
## Deployment
NanoClaw runs as a single macOS launchd service.
EJClaw runs as a single macOS launchd service.
### Startup Sequence
When NanoClaw starts, it:
1. **Ensures container runtime is running** - Automatically starts it if needed; kills orphaned NanoClaw containers from previous runs
When EJClaw starts, it:
1. **Ensures container runtime is running** - Automatically starts it if needed; kills orphaned EJClaw containers from previous runs
2. Initializes the SQLite database (migrates from JSON files if they exist)
3. Loads state from SQLite (registered groups, sessions, router state)
4. **Connects channels** — loops through registered channels, instantiates those with credentials, calls `connect()` on each
@@ -652,16 +652,16 @@ When NanoClaw starts, it:
- Recovers any unprocessed messages from before shutdown
- Starts the message polling loop
### Service: com.nanoclaw
### Service: com.ejclaw
**launchd/com.nanoclaw.plist:**
**launchd/com.ejclaw.plist:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "...">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.nanoclaw</string>
<string>com.ejclaw</string>
<key>ProgramArguments</key>
<array>
<string>{{NODE_PATH}}</string>
@@ -683,9 +683,9 @@ When NanoClaw starts, it:
<string>Andy</string>
</dict>
<key>StandardOutPath</key>
<string>{{PROJECT_ROOT}}/logs/nanoclaw.log</string>
<string>{{PROJECT_ROOT}}/logs/ejclaw.log</string>
<key>StandardErrorPath</key>
<string>{{PROJECT_ROOT}}/logs/nanoclaw.error.log</string>
<string>{{PROJECT_ROOT}}/logs/ejclaw.error.log</string>
</dict>
</plist>
```
@@ -694,19 +694,19 @@ When NanoClaw starts, it:
```bash
# Install service
cp launchd/com.nanoclaw.plist ~/Library/LaunchAgents/
cp launchd/com.ejclaw.plist ~/Library/LaunchAgents/
# Start service
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl load ~/Library/LaunchAgents/com.ejclaw.plist
# Stop service
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl unload ~/Library/LaunchAgents/com.ejclaw.plist
# Check status
launchctl list | grep nanoclaw
# View logs
tail -f logs/nanoclaw.log
tail -f logs/ejclaw.log
```
---
@@ -763,7 +763,7 @@ chmod 700 groups/
| Issue | Cause | Solution |
|-------|-------|----------|
| No response to messages | Service not running | Check `launchctl list | grep nanoclaw` |
| "Claude Code process exited with code 1" | Container runtime failed to start | Check logs; NanoClaw auto-starts container runtime but may fail |
| "Claude Code process exited with code 1" | Container runtime failed to start | Check logs; EJClaw auto-starts container runtime but may fail |
| "Claude Code process exited with code 1" | Session mount path wrong | Ensure mount is to `/home/node/.claude/` not `/root/.claude/` |
| Session not continuing | Session ID not saved | Check SQLite: `sqlite3 store/messages.db "SELECT * FROM sessions"` |
| Session not continuing | Mount path mismatch | Container user is `node` with HOME=/home/node; sessions must be at `/home/node/.claude/` |
@@ -772,8 +772,8 @@ chmod 700 groups/
### Log Location
- `logs/nanoclaw.log` - stdout
- `logs/nanoclaw.error.log` - stderr
- `logs/ejclaw.log` - stdout
- `logs/ejclaw.error.log` - stderr
### Debug Mode

View File

@@ -1,4 +1,4 @@
# NanoClaw Skills Architecture
# EJClaw Skills Architecture
## Core Principle
@@ -123,7 +123,7 @@ skills/
- Git's three-way merge uses context matching, so it works even if the user has moved code around — unlike line-number-based diffs that break immediately
- Auditable: `diff .nanoclaw/base/src/server.ts skills/add-whatsapp/modify/src/server.ts` shows exactly what the skill changes
- Deterministic: same three inputs always produce the same merge result
- Size is negligible since NanoClaw's core files are small
- Size is negligible since EJClaw's core files are small
### Intent Files
@@ -382,11 +382,11 @@ If tests fail and Level 2 can't resolve, restore from `.nanoclaw/backup/` and re
### The Problem
`git rerere` is local by default. But NanoClaw has thousands of users applying the same skill combinations. Every user hitting the same conflict and waiting for Claude Code to resolve it is wasteful.
`git rerere` is local by default. But EJClaw has thousands of users applying the same skill combinations. Every user hitting the same conflict and waiting for Claude Code to resolve it is wasteful.
### The Solution
NanoClaw maintains a verified resolution cache in `.nanoclaw/resolutions/` that ships with the project. This is the shared artifact — **not** `.git/rr-cache/`, which stays local.
EJClaw maintains a verified resolution cache in `.nanoclaw/resolutions/` that ships with the project. This is the shared artifact — **not** `.git/rr-cache/`, which stays local.
```
.nanoclaw/
@@ -588,7 +588,7 @@ There is no unrecoverable state.
## 10. Core Updates
Core updates must be as programmatic as possible. The NanoClaw team is responsible for ensuring updates apply cleanly to common skill combinations.
Core updates must be as programmatic as possible. The EJClaw team is responsible for ensuring updates apply cleanly to common skill combinations.
### Patches and Migrations
@@ -977,7 +977,7 @@ Each passing combination generates a verified resolution entry for the shared ca
### `.gitattributes`
Ship with NanoClaw to reduce noisy merge conflicts:
Ship with EJClaw to reduce noisy merge conflicts:
```
* text=auto

View File

@@ -1,8 +1,8 @@
# NanoClaw Skills Architecture
# EJClaw Skills Architecture
## What Skills Are For
NanoClaw's core is intentionally minimal. Skills are how users extend it: adding channels, integrations, cross-platform support, or replacing internals entirely. Examples: add Telegram alongside WhatsApp, switch from Apple Container to Docker, add Gmail integration, add voice message transcription. Each skill modifies the actual codebase, adding channel handlers, updating the message router, changing container configuration, and adding dependencies, rather than working through a plugin API or runtime hooks.
EJClaw's core is intentionally minimal. Skills are how users extend it: adding channels, integrations, cross-platform support, or replacing internals entirely. Examples: add Telegram alongside WhatsApp, switch from Apple Container to Docker, add Gmail integration, add voice message transcription. Each skill modifies the actual codebase, adding channel handlers, updating the message router, changing container configuration, and adding dependencies, rather than working through a plugin API or runtime hooks.
## Why This Architecture

View File

@@ -1,58 +1,7 @@
# Andy
# Global Memory
You are Andy, a personal assistant. You help with tasks, answer questions, and can schedule reminders.
This file is for mutable memory shared across Claude groups.
## What You Can Do
Use it for durable facts, preferences, and shared context that may change over time.
- Answer questions and have conversations
- Search the web and fetch content from URLs
- **Browse the web** with `agent-browser` — open pages, click, fill forms, take screenshots, extract data (run `agent-browser open <url>` to start, then `agent-browser snapshot -i` to see interactive elements)
- Read and write files in your workspace
- Run bash commands in your sandbox
- Schedule tasks to run later or on a recurring basis
- Send messages back to the chat
## Communication
Your output is sent to the user or group.
You also have `mcp__nanoclaw__send_message` which sends a message immediately while you're still working. This is useful when you want to acknowledge a request before starting longer work.
### Internal thoughts
If part of your output is internal reasoning rather than something for the user, wrap it in `<internal>` tags:
```
<internal>Compiled all three reports, ready to summarize.</internal>
Here are the key findings from the research...
```
Text inside `<internal>` tags is logged but not sent to the user. If you've already sent the key information via `send_message`, you can wrap the recap in `<internal>` to avoid sending it again.
### Sub-agents and teammates
When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent.
## Your Workspace
Files you create are saved in `/workspace/group/`. Use this for notes, research, or anything that should persist.
## Memory
The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions.
When you learn something important:
- Create files for structured data (e.g., `customers.md`, `preferences.md`)
- Split files larger than 500 lines into folders
- Keep an index in your memory for the files you create
## Message Formatting
NEVER use markdown. Only use WhatsApp/Telegram formatting:
- *single asterisks* for bold (NEVER **double asterisks**)
- _underscores_ for italic
- • bullet points
- ```triple backticks``` for code
No ## headings. No [links](url). No **double stars**.
Do not store platform-wide operating rules here. Those now live in `prompts/claude-platform.md`.

View File

@@ -1,246 +1,5 @@
# Andy
# Main Group Memory
You are Andy, a personal assistant. You help with tasks, answer questions, and can schedule reminders.
This file is for mutable memory and admin context specific to the main group.
## What You Can Do
- Answer questions and have conversations
- Search the web and fetch content from URLs
- **Browse the web** with `agent-browser` — open pages, click, fill forms, take screenshots, extract data (run `agent-browser open <url>` to start, then `agent-browser snapshot -i` to see interactive elements)
- Read and write files in your workspace
- Run bash commands in your sandbox
- Schedule tasks to run later or on a recurring basis
- Send messages back to the chat
## Communication
Your output is sent to the user or group.
You also have `mcp__nanoclaw__send_message` which sends a message immediately while you're still working. This is useful when you want to acknowledge a request before starting longer work.
### Internal thoughts
If part of your output is internal reasoning rather than something for the user, wrap it in `<internal>` tags:
```
<internal>Compiled all three reports, ready to summarize.</internal>
Here are the key findings from the research...
```
Text inside `<internal>` tags is logged but not sent to the user. If you've already sent the key information via `send_message`, you can wrap the recap in `<internal>` to avoid sending it again.
### Sub-agents and teammates
When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent.
## Memory
The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions.
When you learn something important:
- Create files for structured data (e.g., `customers.md`, `preferences.md`)
- Split files larger than 500 lines into folders
- Keep an index in your memory for the files you create
## WhatsApp Formatting (and other messaging apps)
Do NOT use markdown headings (##) in WhatsApp messages. Only use:
- *Bold* (single asterisks) (NEVER **double asterisks**)
- _Italic_ (underscores)
- • Bullets (bullet points)
- ```Code blocks``` (triple backticks)
Keep messages clean and readable for WhatsApp.
---
## Admin Context
This is the **main channel**, which has elevated privileges.
## Container Mounts
Main has read-only access to the project and read-write access to its group folder:
| Container Path | Host Path | Access |
|----------------|-----------|--------|
| `/workspace/project` | Project root | read-only |
| `/workspace/group` | `groups/main/` | read-write |
Key paths inside the container:
- `/workspace/project/store/messages.db` - SQLite database
- `/workspace/project/store/messages.db` (registered_groups table) - Group config
- `/workspace/project/groups/` - All group folders
---
## Managing Groups
### Finding Available Groups
Available groups are provided in `/workspace/ipc/available_groups.json`:
```json
{
"groups": [
{
"jid": "120363336345536173@g.us",
"name": "Family Chat",
"lastActivity": "2026-01-31T12:00:00.000Z",
"isRegistered": false
}
],
"lastSync": "2026-01-31T12:00:00.000Z"
}
```
Groups are ordered by most recent activity. The list is synced from WhatsApp daily.
If a group the user mentions isn't in the list, request a fresh sync:
```bash
echo '{"type": "refresh_groups"}' > /workspace/ipc/tasks/refresh_$(date +%s).json
```
Then wait a moment and re-read `available_groups.json`.
**Fallback**: Query the SQLite database directly:
```bash
sqlite3 /workspace/project/store/messages.db "
SELECT jid, name, last_message_time
FROM chats
WHERE jid LIKE '%@g.us' AND jid != '__group_sync__'
ORDER BY last_message_time DESC
LIMIT 10;
"
```
### Registered Groups Config
Groups are registered in the SQLite `registered_groups` table:
```json
{
"1234567890-1234567890@g.us": {
"name": "Family Chat",
"folder": "whatsapp_family-chat",
"trigger": "@Andy",
"added_at": "2024-01-31T12:00:00.000Z"
}
}
```
Fields:
- **Key**: The chat JID (unique identifier — WhatsApp, Telegram, Slack, Discord, etc.)
- **name**: Display name for the group
- **folder**: Channel-prefixed folder name under `groups/` for this group's files and memory
- **trigger**: The trigger word (usually same as global, but could differ)
- **requiresTrigger**: Whether `@trigger` prefix is needed (default: `true`). Set to `false` for solo/personal chats where all messages should be processed
- **isMain**: Whether this is the main control group (elevated privileges, no trigger required)
- **added_at**: ISO timestamp when registered
### Trigger Behavior
- **Main group** (`isMain: true`): No trigger needed — all messages are processed automatically
- **Groups with `requiresTrigger: false`**: No trigger needed — all messages processed (use for 1-on-1 or solo chats)
- **Other groups** (default): Messages must start with `@AssistantName` to be processed
### Adding a Group
1. Query the database to find the group's JID
2. Use the `register_group` MCP tool with the JID, name, folder, and trigger
3. Optionally include `containerConfig` for additional mounts
4. The group folder is created automatically: `/workspace/project/groups/{folder-name}/`
5. Optionally create an initial `CLAUDE.md` for the group
Folder naming convention — channel prefix with underscore separator:
- WhatsApp "Family Chat" → `whatsapp_family-chat`
- Telegram "Dev Team" → `telegram_dev-team`
- Discord "General" → `discord_general`
- Slack "Engineering" → `slack_engineering`
- Use lowercase, hyphens for the group name part
#### Adding Additional Directories for a Group
Groups can have extra directories mounted. Add `containerConfig` to their entry:
```json
{
"1234567890@g.us": {
"name": "Dev Team",
"folder": "dev-team",
"trigger": "@Andy",
"added_at": "2026-01-31T12:00:00Z",
"containerConfig": {
"additionalMounts": [
{
"hostPath": "~/projects/webapp",
"containerPath": "webapp",
"readonly": false
}
]
}
}
}
```
The directory will appear at `/workspace/extra/webapp` in that group's container.
#### Sender Allowlist
After registering a group, explain the sender allowlist feature to the user:
> This group can be configured with a sender allowlist to control who can interact with me. There are two modes:
>
> - **Trigger mode** (default): Everyone's messages are stored for context, but only allowed senders can trigger me with @{AssistantName}.
> - **Drop mode**: Messages from non-allowed senders are not stored at all.
>
> For closed groups with trusted members, I recommend setting up an allow-only list so only specific people can trigger me. Want me to configure that?
If the user wants to set up an allowlist, edit `~/.config/nanoclaw/sender-allowlist.json` on the host:
```json
{
"default": { "allow": "*", "mode": "trigger" },
"chats": {
"<chat-jid>": {
"allow": ["sender-id-1", "sender-id-2"],
"mode": "trigger"
}
},
"logDenied": true
}
```
Notes:
- Your own messages (`is_from_me`) explicitly bypass the allowlist in trigger checks. Bot messages are filtered out by the database query before trigger evaluation, so they never reach the allowlist.
- If the config file doesn't exist or is invalid, all senders are allowed (fail-open)
- The config file is on the host at `~/.config/nanoclaw/sender-allowlist.json`, not inside the container
### Removing a Group
1. Read `/workspace/project/data/registered_groups.json`
2. Remove the entry for that group
3. Write the updated JSON back
4. The group folder and its files remain (don't delete them)
### Listing Groups
Read `/workspace/project/data/registered_groups.json` and format it nicely.
---
## Global Memory
You can read and write to `/workspace/project/groups/global/CLAUDE.md` for facts that should apply to all groups. Only update global memory when explicitly asked to "remember this globally" or similar.
---
## Scheduling for Other Groups
When scheduling tasks for other groups, use the `target_group_jid` parameter with the group's JID from `registered_groups.json`:
- `schedule_task(prompt: "...", schedule_type: "cron", schedule_value: "0 9 * * 1", target_group_jid: "120363336345536173@g.us")`
The task will run in that group's context with access to their files and memory.
Keep platform-wide operating rules in `prompts/claude-platform.md`.

View File

@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.nanoclaw-codex</string>
<string>com.ejclaw-codex</string>
<key>ProgramArguments</key>
<array>
<string>{{NODE_PATH}}</string>
@@ -31,8 +31,8 @@
<string>{{PROJECT_ROOT}}/data-codex</string>
</dict>
<key>StandardOutPath</key>
<string>{{PROJECT_ROOT}}/logs/nanoclaw-codex.log</string>
<string>{{PROJECT_ROOT}}/logs/ejclaw-codex.log</string>
<key>StandardErrorPath</key>
<string>{{PROJECT_ROOT}}/logs/nanoclaw-codex.error.log</string>
<string>{{PROJECT_ROOT}}/logs/ejclaw-codex.error.log</string>
</dict>
</plist>

View File

@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.nanoclaw</string>
<string>com.ejclaw</string>
<key>ProgramArguments</key>
<array>
<string>{{NODE_PATH}}</string>
@@ -25,8 +25,8 @@
<string>Andy</string>
</dict>
<key>StandardOutPath</key>
<string>{{PROJECT_ROOT}}/logs/nanoclaw.log</string>
<string>{{PROJECT_ROOT}}/logs/ejclaw.log</string>
<key>StandardErrorPath</key>
<string>{{PROJECT_ROOT}}/logs/nanoclaw.error.log</string>
<string>{{PROJECT_ROOT}}/logs/ejclaw.error.log</string>
</dict>
</plist>

4
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{
"name": "nanoclaw",
"name": "ejclaw",
"version": "1.2.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanoclaw",
"name": "ejclaw",
"version": "1.2.12",
"dependencies": {
"better-sqlite3": "^11.8.1",

View File

@@ -1,5 +1,5 @@
{
"name": "nanoclaw",
"name": "ejclaw",
"version": "1.2.12",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",

View File

@@ -6,7 +6,7 @@ You are Andy, a personal assistant.
Your output is sent directly to the user or Discord group.
You also have `mcp__nanoclaw__send_message`, which sends a message immediately while you are still working. Use it when you want to acknowledge a request before starting longer work.
You also have a `send_message` tool, which sends a message immediately while you are still working. Use it when you want to acknowledge a request before starting longer work.
### Internal thoughts

View File

@@ -15,7 +15,7 @@ Your output is sent directly to the Discord group.
- Keep answers concise unless more detail is genuinely needed
- Give conclusions and concrete next steps, not hidden reasoning
- Use code blocks for commands or code when helpful
- Do not claim you will keep watching, monitor later, report back later, or continue tracking unless you actually scheduled a NanoClaw task with `schedule_task`
- Do not claim you will keep watching, monitor later, report back later, or continue tracking unless you actually scheduled an EJClaw task with `schedule_task`
- If no task was scheduled, do not imply that background tracking is active. If future follow-up is needed, tell the user to ping you again or explicitly ask for scheduling
- When you do schedule background follow-up, mention that it was scheduled. Include the task ID only when it is useful for later reference

View File

@@ -1,11 +1,11 @@
{
"name": "nanoclaw-agent-runner",
"name": "ejclaw-agent-runner",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanoclaw-agent-runner",
"name": "ejclaw-agent-runner",
"version": "1.0.0",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.76",

View File

@@ -1,8 +1,8 @@
{
"name": "nanoclaw-agent-runner",
"name": "ejclaw-agent-runner",
"version": "1.0.0",
"type": "module",
"description": "Container-side agent runner for NanoClaw",
"description": "Container-side agent runner for EJClaw",
"main": "dist/index.js",
"scripts": {
"build": "tsc",

View File

@@ -1,5 +1,5 @@
/**
* NanoClaw Agent Runner
* EJClaw Agent Runner
* Runs inside a container, receives config via stdin, outputs result to stdout
*
* Input protocol:
@@ -60,11 +60,19 @@ interface SDKUserMessage {
}
// Paths configurable via env vars (defaults to container paths for backwards compat)
const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group';
const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc';
const EXTRA_BASE = process.env.NANOCLAW_EXTRA_DIR || '/workspace/extra';
const GROUP_DIR =
process.env.EJCLAW_GROUP_DIR ||
process.env.NANOCLAW_GROUP_DIR ||
'/workspace/group';
const IPC_DIR =
process.env.EJCLAW_IPC_DIR || process.env.NANOCLAW_IPC_DIR || '/workspace/ipc';
const EXTRA_BASE =
process.env.EJCLAW_EXTRA_DIR ||
process.env.NANOCLAW_EXTRA_DIR ||
'/workspace/extra';
// Optional: override cwd (agent works in this directory instead of GROUP_DIR)
const WORK_DIR = process.env.NANOCLAW_WORK_DIR || '';
const WORK_DIR =
process.env.EJCLAW_WORK_DIR || process.env.NANOCLAW_WORK_DIR || '';
const IPC_INPUT_DIR = path.join(IPC_DIR, 'input');
const MAX_TURNS = 100;

View File

@@ -1,5 +1,5 @@
/**
* Stdio MCP Server for NanoClaw
* Stdio MCP Server for EJClaw
* Standalone process that agent teams subagents can inherit.
* Reads context from environment variables, writes IPC files for the host.
*/
@@ -11,14 +11,17 @@ import fs from 'fs';
import path from 'path';
import { CronExpressionParser } from 'cron-parser';
const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc';
const IPC_DIR =
process.env.EJCLAW_IPC_DIR || process.env.NANOCLAW_IPC_DIR || '/workspace/ipc';
const MESSAGES_DIR = path.join(IPC_DIR, 'messages');
const TASKS_DIR = path.join(IPC_DIR, 'tasks');
// Context from environment variables (set by the agent runner)
const chatJid = process.env.NANOCLAW_CHAT_JID!;
const groupFolder = process.env.NANOCLAW_GROUP_FOLDER!;
const isMain = process.env.NANOCLAW_IS_MAIN === '1';
const chatJid = process.env.EJCLAW_CHAT_JID || process.env.NANOCLAW_CHAT_JID!;
const groupFolder =
process.env.EJCLAW_GROUP_FOLDER || process.env.NANOCLAW_GROUP_FOLDER!;
const isMain =
(process.env.EJCLAW_IS_MAIN || process.env.NANOCLAW_IS_MAIN) === '1';
function writeIpcFile(dir: string, data: object): string {
fs.mkdirSync(dir, { recursive: true });

View File

@@ -1,11 +1,11 @@
{
"name": "nanoclaw-codex-runner",
"name": "ejclaw-codex-runner",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanoclaw-codex-runner",
"name": "ejclaw-codex-runner",
"version": "1.0.0",
"dependencies": {
"@openai/codex-sdk": "^0.114.0"

View File

@@ -1,8 +1,8 @@
{
"name": "nanoclaw-codex-runner",
"name": "ejclaw-codex-runner",
"version": "1.0.0",
"type": "module",
"description": "Container-side Codex CLI runner for NanoClaw",
"description": "Container-side Codex CLI runner for EJClaw",
"main": "dist/index.js",
"scripts": {
"build": "tsc",

View File

@@ -144,8 +144,8 @@ export class CodexAppServerClient {
await this.request('initialize', {
clientInfo: {
name: 'nanoclaw_codex_runner',
title: 'NanoClaw Codex Runner',
name: 'ejclaw_codex_runner',
title: 'EJClaw Codex Runner',
version: '1.0.0',
},
capabilities: {
@@ -182,7 +182,7 @@ export class CodexAppServerClient {
model: options.model,
approvalPolicy: 'never',
sandbox: 'danger-full-access',
serviceName: 'nanoclaw',
serviceName: 'ejclaw',
};
const result = sessionId
@@ -362,7 +362,7 @@ export class CodexAppServerClient {
this.respondError(
message.id,
-32601,
`NanoClaw does not handle server request ${message.method}`,
`EJClaw does not handle server request ${message.method}`,
);
}

View File

@@ -1,12 +1,12 @@
/**
* NanoClaw Codex Runner
* EJClaw Codex Runner
*
* Default runtime is Codex app-server, with SDK fallback available via
* CODEX_RUNTIME=sdk or automatic fallback when app-server startup fails.
*
* Input protocol:
* Stdin: Full ContainerInput JSON (read until EOF)
* IPC: Follow-up messages as JSON files in $NANOCLAW_IPC_DIR/input/
* IPC: Follow-up messages as JSON files in $EJCLAW_IPC_DIR/input/
* Sentinel: _close — signals session end
*
* Stdout protocol:
@@ -45,9 +45,14 @@ interface ContainerOutput {
// ── Constants ──────────────────────────────────────────────────────
const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group';
const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc';
const WORK_DIR = process.env.NANOCLAW_WORK_DIR || '';
const GROUP_DIR =
process.env.EJCLAW_GROUP_DIR ||
process.env.NANOCLAW_GROUP_DIR ||
'/workspace/group';
const IPC_DIR =
process.env.EJCLAW_IPC_DIR || process.env.NANOCLAW_IPC_DIR || '/workspace/ipc';
const WORK_DIR =
process.env.EJCLAW_WORK_DIR || process.env.NANOCLAW_WORK_DIR || '';
const IPC_INPUT_DIR = path.join(IPC_DIR, 'input');
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
const IPC_POLL_MS = 500;

View File

@@ -26,7 +26,7 @@ function parseArgs(args: string[]): { empty: boolean; json: string } {
export async function run(args: string[]): Promise<void> {
const { empty, json } = parseArgs(args);
const homeDir = os.homedir();
const configDir = path.join(homeDir, '.config', 'nanoclaw');
const configDir = path.join(homeDir, '.config', 'ejclaw');
const configFile = path.join(configDir, 'mount-allowlist.json');
if (isRoot()) {

View File

@@ -1,5 +1,5 @@
/**
* Cross-platform detection utilities for NanoClaw setup.
* Cross-platform detection utilities for EJClaw setup.
*/
import { execSync } from 'child_process';
import fs from 'fs';

View File

@@ -19,7 +19,7 @@ function generatePlist(
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.nanoclaw</string>
<string>com.ejclaw</string>
<key>ProgramArguments</key>
<array>
<string>${nodePath}</string>
@@ -39,9 +39,9 @@ function generatePlist(
<string>${homeDir}</string>
</dict>
<key>StandardOutPath</key>
<string>${projectRoot}/logs/nanoclaw.log</string>
<string>${projectRoot}/logs/ejclaw.log</string>
<key>StandardErrorPath</key>
<string>${projectRoot}/logs/nanoclaw.error.log</string>
<string>${projectRoot}/logs/ejclaw.error.log</string>
</dict>
</plist>`;
}
@@ -53,7 +53,7 @@ function generateSystemdUnit(
isSystem: boolean,
): string {
return `[Unit]
Description=NanoClaw Personal Assistant
Description=EJClaw Personal Assistant
After=network.target
[Service]
@@ -64,8 +64,8 @@ Restart=always
RestartSec=5
Environment=HOME=${homeDir}
Environment=PATH=/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin
StandardOutput=append:${projectRoot}/logs/nanoclaw.log
StandardError=append:${projectRoot}/logs/nanoclaw.error.log
StandardOutput=append:${projectRoot}/logs/ejclaw.log
StandardError=append:${projectRoot}/logs/ejclaw.error.log
[Install]
WantedBy=${isSystem ? 'multi-user.target' : 'default.target'}`;
@@ -75,16 +75,16 @@ describe('plist generation', () => {
it('contains the correct label', () => {
const plist = generatePlist(
'/usr/local/bin/node',
'/home/user/nanoclaw',
'/home/user/ejclaw',
'/home/user',
);
expect(plist).toContain('<string>com.nanoclaw</string>');
expect(plist).toContain('<string>com.ejclaw</string>');
});
it('uses the correct node path', () => {
const plist = generatePlist(
'/opt/node/bin/node',
'/home/user/nanoclaw',
'/home/user/ejclaw',
'/home/user',
);
expect(plist).toContain('<string>/opt/node/bin/node</string>');
@@ -93,20 +93,20 @@ describe('plist generation', () => {
it('points to dist/index.js', () => {
const plist = generatePlist(
'/usr/local/bin/node',
'/home/user/nanoclaw',
'/home/user/ejclaw',
'/home/user',
);
expect(plist).toContain('/home/user/nanoclaw/dist/index.js');
expect(plist).toContain('/home/user/ejclaw/dist/index.js');
});
it('sets log paths', () => {
const plist = generatePlist(
'/usr/local/bin/node',
'/home/user/nanoclaw',
'/home/user/ejclaw',
'/home/user',
);
expect(plist).toContain('nanoclaw.log');
expect(plist).toContain('nanoclaw.error.log');
expect(plist).toContain('ejclaw.log');
expect(plist).toContain('ejclaw.error.log');
});
});
@@ -114,7 +114,7 @@ describe('systemd unit generation', () => {
it('user unit uses default.target', () => {
const unit = generateSystemdUnit(
'/usr/bin/node',
'/home/user/nanoclaw',
'/home/user/ejclaw',
'/home/user',
false,
);
@@ -124,7 +124,7 @@ describe('systemd unit generation', () => {
it('system unit uses multi-user.target', () => {
const unit = generateSystemdUnit(
'/usr/bin/node',
'/home/user/nanoclaw',
'/home/user/ejclaw',
'/home/user',
true,
);
@@ -134,7 +134,7 @@ describe('systemd unit generation', () => {
it('contains restart policy', () => {
const unit = generateSystemdUnit(
'/usr/bin/node',
'/home/user/nanoclaw',
'/home/user/ejclaw',
'/home/user',
false,
);
@@ -145,32 +145,32 @@ describe('systemd unit generation', () => {
it('sets correct ExecStart', () => {
const unit = generateSystemdUnit(
'/usr/bin/node',
'/srv/nanoclaw',
'/srv/ejclaw',
'/home/user',
false,
);
expect(unit).toContain(
'ExecStart=/usr/bin/node /srv/nanoclaw/dist/index.js',
'ExecStart=/usr/bin/node /srv/ejclaw/dist/index.js',
);
});
});
describe('WSL nohup fallback', () => {
it('generates a valid wrapper script', () => {
const projectRoot = '/home/user/nanoclaw';
const projectRoot = '/home/user/ejclaw';
const nodePath = '/usr/bin/node';
const pidFile = path.join(projectRoot, 'nanoclaw.pid');
const pidFile = path.join(projectRoot, 'ejclaw.pid');
// Simulate what service.ts generates
const wrapper = `#!/bin/bash
set -euo pipefail
cd ${JSON.stringify(projectRoot)}
nohup ${JSON.stringify(nodePath)} ${JSON.stringify(projectRoot)}/dist/index.js >> ${JSON.stringify(projectRoot)}/logs/nanoclaw.log 2>> ${JSON.stringify(projectRoot)}/logs/nanoclaw.error.log &
nohup ${JSON.stringify(nodePath)} ${JSON.stringify(projectRoot)}/dist/index.js >> ${JSON.stringify(projectRoot)}/logs/ejclaw.log 2>> ${JSON.stringify(projectRoot)}/logs/ejclaw.error.log &
echo $! > ${JSON.stringify(pidFile)}`;
expect(wrapper).toContain('#!/bin/bash');
expect(wrapper).toContain('nohup');
expect(wrapper).toContain(nodePath);
expect(wrapper).toContain('nanoclaw.pid');
expect(wrapper).toContain('ejclaw.pid');
});
});

View File

@@ -20,6 +20,9 @@ import {
} from './platform.js';
import { emitStatus } from './status.js';
const EJCLAW_SERVICE_NAME = 'ejclaw';
const EJCLAW_LAUNCHD_LABEL = 'com.ejclaw';
export async function run(_args: string[]): Promise<void> {
const projectRoot = process.cwd();
const platform = getPlatform();
@@ -77,7 +80,7 @@ function setupLaunchd(
homeDir,
'Library',
'LaunchAgents',
'com.nanoclaw.plist',
`${EJCLAW_LAUNCHD_LABEL}.plist`,
);
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
@@ -86,7 +89,7 @@ function setupLaunchd(
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.nanoclaw</string>
<string>${EJCLAW_LAUNCHD_LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>${nodePath}</string>
@@ -106,9 +109,9 @@ function setupLaunchd(
<string>${homeDir}</string>
</dict>
<key>StandardOutPath</key>
<string>${projectRoot}/logs/nanoclaw.log</string>
<string>${projectRoot}/logs/ejclaw.log</string>
<key>StandardErrorPath</key>
<string>${projectRoot}/logs/nanoclaw.error.log</string>
<string>${projectRoot}/logs/ejclaw.error.log</string>
</dict>
</plist>`;
@@ -128,7 +131,7 @@ function setupLaunchd(
let serviceLoaded = false;
try {
const output = execSync('launchctl list', { encoding: 'utf-8' });
serviceLoaded = output.includes('com.nanoclaw');
serviceLoaded = output.includes(EJCLAW_LAUNCHD_LABEL);
} catch {
// launchctl list failed
}
@@ -160,7 +163,7 @@ function setupLinux(
}
/**
* Kill any orphaned nanoclaw node processes left from previous runs or debugging.
* Kill any orphaned ejclaw node processes left from previous runs or debugging.
* Prevents connection conflicts when two instances connect to the same channel simultaneously.
*/
function killOrphanedProcesses(projectRoot: string): void {
@@ -168,7 +171,7 @@ function killOrphanedProcesses(projectRoot: string): void {
execSync(`pkill -f '${projectRoot}/dist/index\\.js' || true`, {
stdio: 'ignore',
});
logger.info('Stopped any orphaned nanoclaw processes');
logger.info('Stopped any orphaned ejclaw processes');
} catch {
// pkill not available or no orphans
}
@@ -186,7 +189,7 @@ function setupSystemd(
let systemctlPrefix: string;
if (runningAsRoot) {
unitPath = '/etc/systemd/system/nanoclaw.service';
unitPath = `/etc/systemd/system/${EJCLAW_SERVICE_NAME}.service`;
systemctlPrefix = 'systemctl';
logger.info('Running as root — installing system-level systemd unit');
} else {
@@ -202,12 +205,12 @@ function setupSystemd(
}
const unitDir = path.join(homeDir, '.config', 'systemd', 'user');
fs.mkdirSync(unitDir, { recursive: true });
unitPath = path.join(unitDir, 'nanoclaw.service');
unitPath = path.join(unitDir, `${EJCLAW_SERVICE_NAME}.service`);
systemctlPrefix = 'systemctl --user';
}
const unit = `[Unit]
Description=NanoClaw Personal Assistant
Description=EJClaw Personal Assistant
After=network.target
[Service]
@@ -218,8 +221,8 @@ 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/nanoclaw.log
StandardError=append:${projectRoot}/logs/nanoclaw.error.log
StandardOutput=append:${projectRoot}/logs/ejclaw.log
StandardError=append:${projectRoot}/logs/ejclaw.error.log
[Install]
WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
@@ -227,7 +230,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
fs.writeFileSync(unitPath, unit);
logger.info({ unitPath }, 'Wrote systemd unit');
// Kill orphaned nanoclaw processes to avoid channel connection conflicts
// Kill orphaned ejclaw processes to avoid channel connection conflicts
killOrphanedProcesses(projectRoot);
// Enable and start
@@ -238,13 +241,17 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
}
try {
execSync(`${systemctlPrefix} enable nanoclaw`, { stdio: 'ignore' });
execSync(`${systemctlPrefix} enable ${EJCLAW_SERVICE_NAME}`, {
stdio: 'ignore',
});
} catch (err) {
logger.error({ err }, 'systemctl enable failed');
}
try {
execSync(`${systemctlPrefix} start nanoclaw`, { stdio: 'ignore' });
execSync(`${systemctlPrefix} start ${EJCLAW_SERVICE_NAME}`, {
stdio: 'ignore',
});
} catch (err) {
logger.error({ err }, 'systemctl start failed');
}
@@ -252,7 +259,9 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
// Verify
let serviceLoaded = false;
try {
execSync(`${systemctlPrefix} is-active nanoclaw`, { stdio: 'ignore' });
execSync(`${systemctlPrefix} is-active ${EJCLAW_SERVICE_NAME}`, {
stdio: 'ignore',
});
serviceLoaded = true;
} catch {
// Not active
@@ -276,12 +285,12 @@ function setupNohupFallback(
): void {
logger.warn('No systemd detected — generating nohup wrapper script');
const wrapperPath = path.join(projectRoot, 'start-nanoclaw.sh');
const pidFile = path.join(projectRoot, 'nanoclaw.pid');
const wrapperPath = path.join(projectRoot, 'start-ejclaw.sh');
const pidFile = path.join(projectRoot, 'ejclaw.pid');
const lines = [
'#!/bin/bash',
'# start-nanoclaw.sh — Start NanoClaw without systemd',
'# start-ejclaw.sh — Start EJClaw without systemd',
`# To stop: kill \\$(cat ${pidFile})`,
'',
'set -euo pipefail',
@@ -292,20 +301,20 @@ function setupNohupFallback(
`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 NanoClaw (PID $OLD_PID)..."',
' echo "Stopping existing EJClaw (PID $OLD_PID)..."',
' kill "$OLD_PID" 2>/dev/null || true',
' sleep 2',
' fi',
'fi',
'',
'echo "Starting NanoClaw..."',
'echo "Starting EJClaw..."',
`nohup ${JSON.stringify(nodePath)} ${JSON.stringify(projectRoot + '/dist/index.js')} \\`,
` >> ${JSON.stringify(projectRoot + '/logs/nanoclaw.log')} \\`,
` 2>> ${JSON.stringify(projectRoot + '/logs/nanoclaw.error.log')} &`,
` >> ${JSON.stringify(projectRoot + '/logs/ejclaw.log')} \\`,
` 2>> ${JSON.stringify(projectRoot + '/logs/ejclaw.error.log')} &`,
'',
`echo $! > ${JSON.stringify(pidFile)}`,
'echo "NanoClaw started (PID $!)"',
`echo "Logs: tail -f ${projectRoot}/logs/nanoclaw.log"`,
'echo "EJClaw started (PID $!)"',
`echo "Logs: tail -f ${projectRoot}/logs/ejclaw.log"`,
];
const wrapper = lines.join('\n') + '\n';

View File

@@ -26,6 +26,9 @@ export async function run(_args: string[]): Promise<void> {
const projectRoot = process.cwd();
const platform = getPlatform();
const homeDir = os.homedir();
const launchdLabels = ['com.ejclaw', 'com.nanoclaw'];
const systemdUnits = ['ejclaw', 'nanoclaw'];
const pidFiles = ['ejclaw.pid', 'nanoclaw.pid'];
logger.info('Starting verification');
@@ -36,9 +39,10 @@ export async function run(_args: string[]): Promise<void> {
if (mgr === 'launchd') {
try {
const output = execSync('launchctl list', { encoding: 'utf-8' });
if (output.includes('com.nanoclaw')) {
const matchedLabel = launchdLabels.find((label) => output.includes(label));
if (matchedLabel) {
// Check if it has a PID (actually running)
const line = output.split('\n').find((l) => l.includes('com.nanoclaw'));
const line = output.split('\n').find((l) => l.includes(matchedLabel));
if (line) {
const pidField = line.trim().split(/\s+/)[0];
service = pidField !== '-' && pidField ? 'running' : 'stopped';
@@ -49,15 +53,23 @@ export async function run(_args: string[]): Promise<void> {
}
} else if (mgr === 'systemd') {
const prefix = isRoot() ? 'systemctl' : 'systemctl --user';
try {
execSync(`${prefix} is-active nanoclaw`, { stdio: 'ignore' });
const activeUnit = systemdUnits.find((unit) => {
try {
execSync(`${prefix} is-active ${unit}`, { stdio: 'ignore' });
return true;
} catch {
return false;
}
});
if (activeUnit) {
service = 'running';
} catch {
} else {
try {
const output = execSync(`${prefix} list-unit-files`, {
encoding: 'utf-8',
});
if (output.includes('nanoclaw')) {
if (systemdUnits.some((unit) => output.includes(unit))) {
service = 'stopped';
}
} catch {
@@ -66,8 +78,10 @@ export async function run(_args: string[]): Promise<void> {
}
} else {
// Check for nohup PID file
const pidFile = path.join(projectRoot, 'nanoclaw.pid');
if (fs.existsSync(pidFile)) {
const pidFile = pidFiles
.map((name) => path.join(projectRoot, name))
.find((candidate) => fs.existsSync(candidate));
if (pidFile) {
try {
const raw = fs.readFileSync(pidFile, 'utf-8').trim();
const pid = Number(raw);
@@ -144,6 +158,7 @@ export async function run(_args: string[]): Promise<void> {
// 5. Check mount allowlist
let mountAllowlist = 'missing';
if (
fs.existsSync(path.join(homeDir, '.config', 'ejclaw', 'mount-allowlist.json')) ||
fs.existsSync(
path.join(homeDir, '.config', 'nanoclaw', 'mount-allowlist.json'),
)

View File

@@ -11,8 +11,8 @@ const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
vi.mock('./config.js', () => ({
AGENT_MAX_OUTPUT_SIZE: 10485760,
AGENT_TIMEOUT: 1800000, // 30min
DATA_DIR: '/tmp/nanoclaw-test-data',
GROUPS_DIR: '/tmp/nanoclaw-test-groups',
DATA_DIR: '/tmp/ejclaw-test-data',
GROUPS_DIR: '/tmp/ejclaw-test-groups',
IDLE_TIMEOUT: 1800000, // 30min
SERVICE_ID: 'claude',
SERVICE_AGENT_TYPE: 'claude-code',
@@ -223,16 +223,18 @@ describe('agent-runner timeout behavior', () => {
path.endsWith('/global/CLAUDE.md')
);
});
vi.mocked(fs.readFileSync).mockImplementation((p: fs.PathOrFileDescriptor) => {
const path = String(p);
if (path.endsWith('/prompts/claude-platform.md')) {
return 'Platform Claude Rules';
}
if (path.endsWith('/global/CLAUDE.md')) {
return 'Global Claude Memory';
}
return '';
});
vi.mocked(fs.readFileSync).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
const path = String(p);
if (path.endsWith('/prompts/claude-platform.md')) {
return 'Platform Claude Rules';
}
if (path.endsWith('/global/CLAUDE.md')) {
return 'Global Claude Memory';
}
return '';
},
);
const resultPromise = runAgentProcess(
testGroup,
@@ -246,7 +248,7 @@ describe('agent-runner timeout behavior', () => {
await resultPromise;
expect(fs.writeFileSync).toHaveBeenCalledWith(
'/tmp/nanoclaw-test-data/sessions/test-group/.claude/CLAUDE.md',
'/tmp/ejclaw-test-data/sessions/test-group/.claude/CLAUDE.md',
'Platform Claude Rules\n\n---\n\nGlobal Claude Memory\n',
);
});

View File

@@ -1,5 +1,5 @@
/**
* Agent Process Runner for NanoClaw
* Agent Process Runner for EJClaw
* Spawns agent execution as direct host processes and handles IPC
*/
import { ChildProcess, spawn } from 'child_process';
@@ -200,15 +200,27 @@ function prepareGroupEnvironment(
TZ: TIMEZONE,
HOME: os.homedir(),
// Path configuration for the runner
EJCLAW_GROUP_DIR: groupDir,
NANOCLAW_GROUP_DIR: groupDir,
EJCLAW_IPC_DIR: groupIpcDir,
NANOCLAW_IPC_DIR: groupIpcDir,
EJCLAW_GLOBAL_DIR: globalDir,
NANOCLAW_GLOBAL_DIR: globalDir,
EJCLAW_EXTRA_DIR: extraDirs.length > 0 ? extraDirs[0] : '',
NANOCLAW_EXTRA_DIR: extraDirs.length > 0 ? extraDirs[0] : '',
// Working directory override (agent uses this as cwd instead of group dir)
...(group.workDir ? { NANOCLAW_WORK_DIR: group.workDir } : {}),
...(group.workDir
? {
EJCLAW_WORK_DIR: group.workDir,
NANOCLAW_WORK_DIR: group.workDir,
}
: {}),
// MCP server context
EJCLAW_CHAT_JID: group.folder,
NANOCLAW_CHAT_JID: group.folder,
EJCLAW_GROUP_FOLDER: group.folder,
NANOCLAW_GROUP_FOLDER: group.folder,
EJCLAW_IS_MAIN: isMain ? '1' : '0',
NANOCLAW_IS_MAIN: isMain ? '1' : '0',
// Claude sessions directory — set CLAUDE_CONFIG_DIR so SDK uses per-group sessions
CLAUDE_CONFIG_DIR: groupSessionsDir,
@@ -373,12 +385,13 @@ export async function runAgentProcess(
input.chatJid,
);
if (input.runId) {
env.EJCLAW_RUN_ID = input.runId;
env.NANOCLAW_RUN_ID = input.runId;
}
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
const processSuffix = input.runId || `${Date.now()}`;
const processName = `nanoclaw-${safeName}-${processSuffix}`;
const processName = `ejclaw-${safeName}-${processSuffix}`;
// Check if runner is built
const distEntry = path.join(runnerDir, 'dist', 'index.js');

View File

@@ -12,8 +12,8 @@ vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) }));
vi.mock('../config.js', () => ({
ASSISTANT_NAME: 'Andy',
TRIGGER_PATTERN: /^@Andy\b/i,
DATA_DIR: '/tmp/nanoclaw-test-data',
CACHE_DIR: '/tmp/nanoclaw-test-cache',
DATA_DIR: '/tmp/ejclaw-test-data',
CACHE_DIR: '/tmp/ejclaw-test-cache',
}));
// Mock logger

53
src/claude-usage.test.ts Normal file
View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { parseClaudeUsagePanel } from './claude-usage.js';
describe('parseClaudeUsagePanel', () => {
it('parses session and weekly usage from Claude CLI panel output', () => {
const sample = `
Settings: Status Config Usage
Loading usage data...
Current session
████ 4% used
Resets in 8m (Asia/Seoul)
Current week (all models)
███████████████████████████████████████ 78% used
Resets Mar 17 at 10pm (Asia/Seoul)
Current week (Sonnet only)
███ 6% used
Resets Mar 17 at 11pm (Asia/Seoul)
Extra usage
Extra usage not enabled • /extra-usage to enable
`;
expect(parseClaudeUsagePanel(sample)).toEqual({
five_hour: {
utilization: 4,
resets_at: 'Resets in 8m (Asia/Seoul)',
},
seven_day: {
utilization: 78,
resets_at: 'Resets Mar 17 at 10pm (Asia/Seoul)',
},
});
});
it('converts percent left into used percent', () => {
const sample = `
Current session
60% left
Resets in 1h
`;
expect(parseClaudeUsagePanel(sample)).toEqual({
five_hour: {
utilization: 40,
resets_at: 'Resets in 1h',
},
});
});
});

183
src/claude-usage.ts Normal file
View File

@@ -0,0 +1,183 @@
import { spawn } from 'child_process';
import { logger } from './logger.js';
export interface ClaudeUsageData {
five_hour?: { utilization: number; resets_at: string };
seven_day?: { utilization: number; resets_at: string };
}
const CLAUDE_EXPECT_TIMEOUT_MS = 25000;
const ANSI_RE = /\u001b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
const EXPECT_PROGRAM = `
set timeout 20
log_user 1
match_max 200000
set binary $env(CLAUDE_BINARY)
spawn -noecho -- $binary --setting-sources user --allowed-tools ""
expect {
-re "Do you trust the files in this folder\\\\?" { send "y\\r"; exp_continue }
-re "Quick safety check:" { send "\\r"; exp_continue }
-re "Yes, I trust this folder" { send "\\r"; exp_continue }
-re "Ready to code here\\\\?" { send "\\r"; exp_continue }
-re "Press Enter to continue" { send "\\r"; exp_continue }
timeout {}
}
send "/usage\\r"
set deadline [expr {[clock seconds] + 20}]
while {[clock seconds] < $deadline} {
expect {
-re "Do you trust the files in this folder\\\\?" { send "y\\r"; exp_continue }
-re "Quick safety check:" { send "\\r"; exp_continue }
-re "Yes, I trust this folder" { send "\\r"; exp_continue }
-re "Ready to code here\\\\?" { send "\\r"; exp_continue }
-re "Press Enter to continue" { send "\\r"; exp_continue }
-re "Current session" { after 2000; exit 0 }
-re "Failed to load usage data" { after 500; exit 2 }
eof { exit 3 }
timeout { send "\\r" }
}
}
exit 4
`;
function normalizeLines(rawText: string): string[] {
return rawText
.replace(ANSI_RE, '')
.replace(/\r/g, '\n')
.split('\n')
.map((line) => line.replace(/\s+/g, ' ').trim())
.filter(Boolean);
}
function parsePercent(windowText: string): number | null {
const match = windowText.match(/(\d{1,3})%\s*(used|left)\b/i);
if (!match) return null;
const value = parseInt(match[1], 10);
if (Number.isNaN(value)) return null;
return match[2].toLowerCase() === 'left' ? 100 - value : value;
}
function parseWindow(
lines: string[],
labels: string[],
): { utilization: number; resets_at: string } | null {
const normalizedLabels = labels.map((label) => label.toLowerCase());
for (let i = 0; i < lines.length; i++) {
const line = lines[i].toLowerCase();
if (!normalizedLabels.some((label) => line.includes(label))) continue;
const windowLines = lines.slice(i, i + 6);
const windowText = windowLines.join('\n');
const utilization = parsePercent(windowText);
if (utilization === null) continue;
const resetLine = windowLines.find((candidate) =>
candidate.toLowerCase().startsWith('resets'),
);
return {
utilization,
resets_at: resetLine || '',
};
}
return null;
}
export function parseClaudeUsagePanel(rawText: string): ClaudeUsageData | null {
const lines = normalizeLines(rawText);
if (lines.length === 0) return null;
if (
lines.some((line) =>
line.toLowerCase().includes('failed to load usage data'),
)
) {
return null;
}
const fiveHour = parseWindow(lines, ['Current session']);
if (!fiveHour) return null;
const sevenDay =
parseWindow(lines, ['Current week (all models)']) ||
parseWindow(lines, [
'Current week (Sonnet only)',
'Current week (Sonnet)',
]) ||
parseWindow(lines, ['Current week (Opus)']);
return {
five_hour: fiveHour,
...(sevenDay ? { seven_day: sevenDay } : {}),
};
}
export async function fetchClaudeUsageViaCli(
binary = 'claude',
): Promise<ClaudeUsageData | null> {
return new Promise((resolve) => {
let output = '';
let finished = false;
const finish = (value: ClaudeUsageData | null) => {
if (finished) return;
finished = true;
clearTimeout(timer);
resolve(value);
};
let proc: ReturnType<typeof spawn> | null = null;
try {
proc = spawn('expect', ['-c', EXPECT_PROGRAM], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...(process.env as Record<string, string>),
CLAUDE_BINARY: binary,
},
});
} catch (err) {
logger.debug({ err }, 'Claude CLI PTY probe unavailable');
finish(null);
return;
}
const timer = setTimeout(() => {
try {
proc?.kill('SIGTERM');
} catch {
/* ignore */
}
finish(null);
}, CLAUDE_EXPECT_TIMEOUT_MS);
if (!proc.stdout || !proc.stderr) {
finish(null);
return;
}
proc.stdout.setEncoding('utf8');
proc.stderr.setEncoding('utf8');
proc.stdout.on('data', (chunk: string) => {
output += chunk;
});
proc.stderr.on('data', (chunk: string) => {
output += chunk;
});
proc.on('error', (err) => {
logger.debug({ err }, 'Claude CLI PTY probe failed to start');
finish(null);
});
proc.on('close', () => {
const parsed = parseClaudeUsagePanel(output);
if (!parsed && output.trim()) {
logger.debug(
{ tail: output.slice(-400) },
'Claude CLI PTY probe produced unparsable output',
);
}
finish(parsed);
});
});
}

View File

@@ -1,3 +1,4 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
@@ -36,21 +37,33 @@ export const SCHEDULER_POLL_INTERVAL = 60000;
const PROJECT_ROOT = process.cwd();
const HOME_DIR = process.env.HOME || os.homedir();
const CONFIG_ROOT = path.join(HOME_DIR, '.config');
const EJCLAW_CONFIG_DIR = path.join(CONFIG_ROOT, 'ejclaw');
const LEGACY_NANOCLAW_CONFIG_DIR = path.join(CONFIG_ROOT, 'nanoclaw');
const ACTIVE_CONFIG_DIR = fs.existsSync(EJCLAW_CONFIG_DIR)
? EJCLAW_CONFIG_DIR
: fs.existsSync(LEGACY_NANOCLAW_CONFIG_DIR)
? LEGACY_NANOCLAW_CONFIG_DIR
: EJCLAW_CONFIG_DIR;
export const SENDER_ALLOWLIST_PATH = path.join(
HOME_DIR,
'.config',
'nanoclaw',
ACTIVE_CONFIG_DIR,
'sender-allowlist.json',
);
export const STORE_DIR = path.resolve(
process.env.NANOCLAW_STORE_DIR || path.join(PROJECT_ROOT, 'store'),
process.env.EJCLAW_STORE_DIR ||
process.env.NANOCLAW_STORE_DIR ||
path.join(PROJECT_ROOT, 'store'),
);
export const GROUPS_DIR = path.resolve(
process.env.NANOCLAW_GROUPS_DIR || path.join(PROJECT_ROOT, 'groups'),
process.env.EJCLAW_GROUPS_DIR ||
process.env.NANOCLAW_GROUPS_DIR ||
path.join(PROJECT_ROOT, 'groups'),
);
export const DATA_DIR = path.resolve(
process.env.NANOCLAW_DATA_DIR || path.join(PROJECT_ROOT, 'data'),
process.env.EJCLAW_DATA_DIR ||
process.env.NANOCLAW_DATA_DIR ||
path.join(PROJECT_ROOT, 'data'),
);
// Shared cache directory (same across both services for dedup)
export const CACHE_DIR = path.join(PROJECT_ROOT, 'cache');

View File

@@ -3,6 +3,10 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import {
fetchClaudeUsageViaCli,
type ClaudeUsageData,
} from './claude-usage.js';
import { USAGE_DASHBOARD_ENABLED } from './config.js';
import { readEnvFile } from './env.js';
import { GroupQueue, GroupStatus } from './group-queue.js';
@@ -20,11 +24,6 @@ export interface DashboardOptions {
usageUpdateInterval: number;
}
interface ClaudeUsageData {
five_hour?: { utilization: number; resets_at: string };
seven_day?: { utilization: number; resets_at: string };
}
interface CodexRateLimit {
limitId?: string;
limitName: string | null;
@@ -71,6 +70,7 @@ function formatResetKST(value: string | number): string {
try {
const date =
typeof value === 'number' ? new Date(value * 1000) : new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString('ko-KR', {
timeZone: 'Asia/Seoul',
month: 'short',
@@ -188,6 +188,9 @@ export function buildStatusContent(opts: DashboardOptions): string {
}
async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
const cliUsage = await fetchClaudeUsageViaCli();
if (cliUsage) return cliUsage;
try {
const envToken = readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN']);
let token =

View File

@@ -139,7 +139,9 @@ function createSchema(database: Database.Database): void {
const hasAgentType = registeredGroupCols.some(
(col) => col.name === 'agent_type',
);
const hasWorkDir = registeredGroupCols.some((col) => col.name === 'work_dir');
const hasWorkDir = registeredGroupCols.some(
(col) => col.name === 'work_dir',
);
database.exec(`
CREATE TABLE registered_groups_new (

View File

@@ -4,7 +4,7 @@ import { GroupQueue } from './group-queue.js';
// Mock config to control concurrency limit
vi.mock('./config.js', () => ({
DATA_DIR: '/tmp/nanoclaw-test-data',
DATA_DIR: '/tmp/ejclaw-test-data',
MAX_CONCURRENT_AGENTS: 2,
}));

View File

@@ -280,7 +280,7 @@ const isDirectRun =
if (isDirectRun) {
main().catch((err) => {
logger.error({ err }, 'Failed to start NanoClaw');
logger.error({ err }, 'Failed to start EJClaw');
process.exit(1);
});
}

View File

@@ -346,7 +346,19 @@ describe('createMessageRuntime', () => {
'progress-1',
'오래 걸리는 작업입니다.\n\n1분 10초',
);
await vi.advanceTimersByTimeAsync(3_600_000);
await vi.advanceTimersByTimeAsync(50_000);
expect(channel.editMessage).toHaveBeenLastCalledWith(
chatJid,
'progress-1',
'오래 걸리는 작업입니다.\n\n2분 0초',
);
await vi.advanceTimersByTimeAsync(3_480_000);
expect(channel.editMessage).toHaveBeenLastCalledWith(
chatJid,
'progress-1',
'오래 걸리는 작업입니다.\n\n1시간 0분 0초',
);
await vi.advanceTimersByTimeAsync(70_000);
await onOutput?.({
status: 'success',
result: null,

View File

@@ -352,15 +352,14 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
const hours = Math.floor(elapsedSeconds / 3600);
const minutes = Math.floor((elapsedSeconds % 3600) / 60);
const seconds = elapsedSeconds % 60;
const elapsedParts: string[] = [];
const elapsedLabel =
hours > 0
? `${hours}시간 ${minutes}${seconds}`
: minutes > 0
? `${minutes}${seconds}`
: `${seconds}`;
if (hours > 0) elapsedParts.push(`${hours}시간`);
if (minutes > 0) elapsedParts.push(`${minutes}`);
if (seconds > 0 || elapsedParts.length === 0) {
elapsedParts.push(`${seconds}`);
}
return `${text}\n\n${elapsedParts.join(' ')}`;
return `${text}\n\n${elapsedLabel}`;
};
const syncTrackedProgressMessage = async () => {
@@ -552,7 +551,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
}
messageLoopRunning = true;
logger.info(`NanoClaw running (trigger: @${deps.assistantName})`);
logger.info(`EJClaw running (trigger: @${deps.assistantName})`);
while (true) {
try {

View File

@@ -15,7 +15,7 @@ describe('platform-prompts', () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-prompts-'));
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-prompts-'));
vi.spyOn(process, 'cwd').mockReturnValue(tempDir);
});

View File

@@ -84,9 +84,11 @@ describe('isSessionCommandControlMessage', () => {
});
it('does not match regular bot conversation', () => {
expect(isSessionCommandControlMessage('좋네요. 필요해지면 바로 말씀드리겠습니다.')).toBe(
false,
);
expect(
isSessionCommandControlMessage(
'좋네요. 필요해지면 바로 말씀드리겠습니다.',
),
).toBe(false);
});
});