Rebrand NanoClaw to EJClaw
This commit is contained in:
27
CLAUDE.md
27
CLAUDE.md
@@ -1,10 +1,10 @@
|
|||||||
# NanoClaw
|
# EJClaw
|
||||||
|
|
||||||
Dual-agent AI assistant (Claude Code + Codex) over Discord. Based on [qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw).
|
Dual-agent AI assistant (Claude Code + Codex) over Discord. Based on [qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw).
|
||||||
|
|
||||||
## Quick Context
|
## 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
|
## Key Files
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ Two systemd services (`nanoclaw`, `nanoclaw-codex`) share the same codebase but
|
|||||||
| `/setup` | First-time installation, authentication, service configuration |
|
| `/setup` | First-time installation, authentication, service configuration |
|
||||||
| `/customize` | Adding channels, integrations, changing behavior |
|
| `/customize` | Adding channels, integrations, changing behavior |
|
||||||
| `/debug` | Agent issues, logs, troubleshooting |
|
| `/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 |
|
| `/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 |
|
| `/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):
|
Service management (Linux):
|
||||||
```bash
|
```bash
|
||||||
systemctl --user restart nanoclaw nanoclaw-codex # Restart both
|
systemctl --user restart ejclaw ejclaw-codex # Restart both
|
||||||
systemctl --user status nanoclaw # Check status
|
systemctl --user status ejclaw # Check status
|
||||||
journalctl --user -u nanoclaw -f # Follow logs
|
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
|
## Dual-Service Architecture
|
||||||
|
|
||||||
- `nanoclaw.service` — Claude Code bot (`@claude`), `SERVICE_ID=claude`, `SERVICE_AGENT_TYPE=claude-code`
|
- `ejclaw.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-codex.service` — Codex bot (`@codex`), `SERVICE_ID=codex`, `SERVICE_AGENT_TYPE=codex`
|
||||||
- Both share the same codebase (`dist/index.js`), differentiated by env vars
|
- Both share the same codebase (`dist/index.js`), differentiated by env vars
|
||||||
- Unified dirs (`store/`, `groups/`, `data/` shared by both services):
|
- Unified dirs (`store/`, `groups/`, `data/` shared by both services):
|
||||||
- `router_state`: keys prefixed with `{SERVICE_ID}:` (e.g., `claude:last_timestamp`)
|
- `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 모드) |
|
| **DB** | `store/messages.db` (공유, WAL 모드) |
|
||||||
| 서비스 로그 (Claude) | `journalctl --user -u nanoclaw -f` 또는 `logs/nanoclaw.log` |
|
| 서비스 로그 (Claude) | `journalctl --user -u ejclaw -f` 또는 `logs/ejclaw.log` |
|
||||||
| 서비스 로그 (Codex) | `journalctl --user -u nanoclaw-codex -f` 또는 `logs/nanoclaw-codex.log` |
|
| 서비스 로그 (Codex) | `journalctl --user -u ejclaw-codex -f` 또는 `logs/ejclaw-codex.log` |
|
||||||
| 그룹별 로그 | `groups/{name}/logs/` (공유 채널은 양쪽 봇 로그가 같은 폴더) |
|
| 그룹별 로그 | `groups/{name}/logs/` (공유 채널은 양쪽 봇 로그가 같은 폴더) |
|
||||||
| Claude 세션 | `data/sessions/{name}/.claude/` |
|
| Claude 세션 | `data/sessions/{name}/.claude/` |
|
||||||
| Codex 세션 | `data/sessions/{name}/.codex/` |
|
| Codex 세션 | `data/sessions/{name}/.codex/` |
|
||||||
| Claude 글로벌 설정 | `groups/global/CLAUDE.md` |
|
| Claude 플랫폼 규칙 | `prompts/claude-platform.md` |
|
||||||
| Codex 글로벌 설정 | `~/.codex/AGENTS.md` |
|
| Codex 플랫폼 규칙 | `prompts/codex-platform.md` |
|
||||||
|
| Claude 글로벌 메모리 | `groups/global/CLAUDE.md` |
|
||||||
|
|
||||||
## Codex SDK
|
## Codex SDK
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
## Skills
|
## 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.
|
A PR that contributes a skill should not modify any source files.
|
||||||
|
|
||||||
|
|||||||
50
README.md
50
README.md
@@ -1,13 +1,11 @@
|
|||||||
<p align="center">
|
<h1 align="center">EJClaw</h1>
|
||||||
<img src="assets/nanoclaw-logo.png" alt="NanoClaw" width="400">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
Dual-agent AI assistant running Claude Code + Codex as parallel services over Discord.
|
Dual-agent AI assistant running Claude Code + Codex as parallel services over Discord.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<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>
|
</p>
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
@@ -40,7 +38,7 @@ Discord ──► SQLite ──► GroupQueue ──┬──► Claude Agent SD
|
|||||||
### Directory Layout
|
### Directory Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
nanoclaw/
|
ejclaw/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── index.ts # Orchestrator: state, message loop, agent invocation
|
│ ├── index.ts # Orchestrator: state, message loop, agent invocation
|
||||||
│ ├── agent-runner.ts # Spawns agent processes, manages env/sessions/skills
|
│ ├── 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
|
### 1. Clone and Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/phj1081/nanoclaw.git
|
git clone https://github.com/phj1081/nanoclaw.git ejclaw
|
||||||
cd nanoclaw
|
cd ejclaw
|
||||||
npm install
|
npm install
|
||||||
npm run build:runners # installs + builds both agent-runner and codex-runner
|
npm run build:runners # installs + builds both agent-runner and codex-runner
|
||||||
npm run build # builds main project
|
npm run build # builds main project
|
||||||
@@ -163,17 +161,17 @@ DISCORD_BOT_TOKEN= # Codex Discord bot token (different from above)
|
|||||||
|
|
||||||
### 4. Systemd Services (Linux)
|
### 4. Systemd Services (Linux)
|
||||||
|
|
||||||
Create `~/.config/systemd/user/nanoclaw.service`:
|
Create `~/.config/systemd/user/ejclaw.service`:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=NanoClaw Claude Code
|
Description=EJClaw Claude Code
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
ExecStart=/path/to/node /path/to/nanoclaw/dist/index.js
|
ExecStart=/path/to/node /path/to/ejclaw/dist/index.js
|
||||||
WorkingDirectory=/path/to/nanoclaw
|
WorkingDirectory=/path/to/ejclaw
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
Environment=HOME=/home/youruser
|
Environment=HOME=/home/youruser
|
||||||
@@ -183,26 +181,26 @@ Environment=PATH=/path/to/node/bin:/usr/local/bin:/usr/bin:/bin
|
|||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
```
|
```
|
||||||
|
|
||||||
Create `~/.config/systemd/user/nanoclaw-codex.service`:
|
Create `~/.config/systemd/user/ejclaw-codex.service`:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=NanoClaw Codex
|
Description=EJClaw Codex
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
EnvironmentFile=/path/to/nanoclaw/.env.codex
|
EnvironmentFile=/path/to/ejclaw/.env.codex
|
||||||
Type=simple
|
Type=simple
|
||||||
ExecStart=/path/to/node /path/to/nanoclaw/dist/index.js
|
ExecStart=/path/to/node /path/to/ejclaw/dist/index.js
|
||||||
WorkingDirectory=/path/to/nanoclaw
|
WorkingDirectory=/path/to/ejclaw
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
Environment=HOME=/home/youruser
|
Environment=HOME=/home/youruser
|
||||||
Environment=PATH=/path/to/node/bin:/usr/local/bin:/usr/bin:/bin
|
Environment=PATH=/path/to/node/bin:/usr/local/bin:/usr/bin:/bin
|
||||||
Environment=ASSISTANT_NAME=codex
|
Environment=ASSISTANT_NAME=codex
|
||||||
Environment=NANOCLAW_STORE_DIR=/path/to/nanoclaw/store-codex
|
Environment=EJCLAW_STORE_DIR=/path/to/ejclaw/store-codex
|
||||||
Environment=NANOCLAW_DATA_DIR=/path/to/nanoclaw/data-codex
|
Environment=EJCLAW_DATA_DIR=/path/to/ejclaw/data-codex
|
||||||
Environment=NANOCLAW_GROUPS_DIR=/path/to/nanoclaw/groups-codex
|
Environment=EJCLAW_GROUPS_DIR=/path/to/ejclaw/groups-codex
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
@@ -212,12 +210,12 @@ Then enable and start:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
systemctl --user enable nanoclaw nanoclaw-codex
|
systemctl --user enable ejclaw ejclaw-codex
|
||||||
systemctl --user start nanoclaw nanoclaw-codex
|
systemctl --user start ejclaw ejclaw-codex
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
journalctl --user -u nanoclaw -f
|
journalctl --user -u ejclaw -f
|
||||||
journalctl --user -u nanoclaw-codex -f
|
journalctl --user -u ejclaw-codex -f
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Register Discord Channels
|
### 5. Register Discord Channels
|
||||||
@@ -247,9 +245,9 @@ Fields:
|
|||||||
### macOS (launchd)
|
### macOS (launchd)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
|
launchctl load ~/Library/LaunchAgents/com.ejclaw.plist
|
||||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw-codex.plist
|
launchctl load ~/Library/LaunchAgents/com.ejclaw-codex.plist
|
||||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
launchctl kickstart -k gui/$(id -u)/com.ejclaw
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# NanoClaw Debug Checklist
|
# EJClaw Debug Checklist
|
||||||
|
|
||||||
## Known Issues (2026-02-08)
|
## 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
|
```bash
|
||||||
# 1. Is the service running?
|
# 1. Is the service running?
|
||||||
launchctl list | grep nanoclaw
|
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?
|
# 2. Any running containers?
|
||||||
container ls --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw
|
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
|
container ls -a --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw
|
||||||
|
|
||||||
# 4. Recent errors in service log?
|
# 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)
|
# 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?
|
# 6. Are groups loaded?
|
||||||
grep 'groupCount' logs/nanoclaw.log | tail -3
|
grep 'groupCount' logs/ejclaw.log | tail -3
|
||||||
```
|
```
|
||||||
|
|
||||||
## Session Transcript Branching
|
## Session Transcript Branching
|
||||||
@@ -62,7 +62,7 @@ for i, line in enumerate(lines):
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check for recent timeouts
|
# 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
|
# Check container log files for the timed-out container
|
||||||
ls -lt groups/*/logs/container-*.log | head -10
|
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
|
cat groups/<group>/logs/container-<timestamp>.log
|
||||||
|
|
||||||
# Check if retries were scheduled and what happened
|
# 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
|
## Agent Not Responding
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check if messages are being received from WhatsApp
|
# 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)
|
# 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
|
# 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?
|
# 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
|
# 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;"
|
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
|
```bash
|
||||||
# Check mount validation logs (shows on container spawn)
|
# 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
|
# 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
|
# Check group's container_config in DB
|
||||||
sqlite3 store/messages.db "SELECT name, container_config FROM registered_groups;"
|
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
|
```bash
|
||||||
# Check if QR code was requested (means auth expired)
|
# 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
|
# Check auth files exist
|
||||||
ls -la store/auth/
|
ls -la store/auth/
|
||||||
@@ -127,17 +127,17 @@ npm run auth
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Restart the service
|
# Restart the service
|
||||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
launchctl kickstart -k gui/$(id -u)/com.ejclaw
|
||||||
|
|
||||||
# View live logs
|
# View live logs
|
||||||
tail -f logs/nanoclaw.log
|
tail -f logs/ejclaw.log
|
||||||
|
|
||||||
# Stop the service (careful — running containers are detached, not killed)
|
# 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
|
# 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
|
# 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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# NanoClaw Requirements
|
# EJClaw Requirements
|
||||||
|
|
||||||
Original requirements and design decisions from the project creator.
|
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.
|
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
|
## Project Name
|
||||||
|
|
||||||
**NanoClaw** - A reference to Clawdbot (now OpenClaw).
|
**EJClaw** - A reference to Clawdbot (now OpenClaw).
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# NanoClaw Security Model
|
# EJClaw Security Model
|
||||||
|
|
||||||
## Trust Model
|
## Trust Model
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ This is the primary security boundary. Rather than relying on application-level
|
|||||||
|
|
||||||
### 2. Mount Security
|
### 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
|
- Outside project root
|
||||||
- Never mounted into containers
|
- Never mounted into containers
|
||||||
- Cannot be modified by agents
|
- Cannot be modified by agents
|
||||||
|
|||||||
44
docs/SPEC.md
44
docs/SPEC.md
@@ -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.
|
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/)
|
│ └── ipc/ # Container IPC (messages/, tasks/)
|
||||||
│
|
│
|
||||||
├── logs/ # Runtime logs (gitignored)
|
├── logs/ # Runtime logs (gitignored)
|
||||||
│ ├── nanoclaw.log # Host stdout
|
│ ├── ejclaw.log # Host stdout
|
||||||
│ └── nanoclaw.error.log # Host stderr
|
│ └── ejclaw.error.log # Host stderr
|
||||||
│ # Note: Per-container logs are in groups/{folder}/logs/container-*.log
|
│ # Note: Per-container logs are in groups/{folder}/logs/container-*.log
|
||||||
│
|
│
|
||||||
└── launchd/
|
└── 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
|
## 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
|
### Memory Hierarchy
|
||||||
|
|
||||||
@@ -555,7 +555,7 @@ This allows the agent to understand the conversation context even if it wasn't m
|
|||||||
|
|
||||||
## Scheduled Tasks
|
## 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
|
### How Scheduling Works
|
||||||
|
|
||||||
@@ -616,7 +616,7 @@ From main channel:
|
|||||||
|
|
||||||
## MCP Servers
|
## 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.
|
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
|
## Deployment
|
||||||
|
|
||||||
NanoClaw runs as a single macOS launchd service.
|
EJClaw runs as a single macOS launchd service.
|
||||||
|
|
||||||
### Startup Sequence
|
### Startup Sequence
|
||||||
|
|
||||||
When NanoClaw starts, it:
|
When EJClaw starts, it:
|
||||||
1. **Ensures container runtime is running** - Automatically starts it if needed; kills orphaned NanoClaw containers from previous runs
|
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)
|
2. Initializes the SQLite database (migrates from JSON files if they exist)
|
||||||
3. Loads state from SQLite (registered groups, sessions, router state)
|
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
|
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
|
- Recovers any unprocessed messages from before shutdown
|
||||||
- Starts the message polling loop
|
- Starts the message polling loop
|
||||||
|
|
||||||
### Service: com.nanoclaw
|
### Service: com.ejclaw
|
||||||
|
|
||||||
**launchd/com.nanoclaw.plist:**
|
**launchd/com.ejclaw.plist:**
|
||||||
```xml
|
```xml
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "...">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "...">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>Label</key>
|
<key>Label</key>
|
||||||
<string>com.nanoclaw</string>
|
<string>com.ejclaw</string>
|
||||||
<key>ProgramArguments</key>
|
<key>ProgramArguments</key>
|
||||||
<array>
|
<array>
|
||||||
<string>{{NODE_PATH}}</string>
|
<string>{{NODE_PATH}}</string>
|
||||||
@@ -683,9 +683,9 @@ When NanoClaw starts, it:
|
|||||||
<string>Andy</string>
|
<string>Andy</string>
|
||||||
</dict>
|
</dict>
|
||||||
<key>StandardOutPath</key>
|
<key>StandardOutPath</key>
|
||||||
<string>{{PROJECT_ROOT}}/logs/nanoclaw.log</string>
|
<string>{{PROJECT_ROOT}}/logs/ejclaw.log</string>
|
||||||
<key>StandardErrorPath</key>
|
<key>StandardErrorPath</key>
|
||||||
<string>{{PROJECT_ROOT}}/logs/nanoclaw.error.log</string>
|
<string>{{PROJECT_ROOT}}/logs/ejclaw.error.log</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
```
|
```
|
||||||
@@ -694,19 +694,19 @@ When NanoClaw starts, it:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install service
|
# Install service
|
||||||
cp launchd/com.nanoclaw.plist ~/Library/LaunchAgents/
|
cp launchd/com.ejclaw.plist ~/Library/LaunchAgents/
|
||||||
|
|
||||||
# Start service
|
# Start service
|
||||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
|
launchctl load ~/Library/LaunchAgents/com.ejclaw.plist
|
||||||
|
|
||||||
# Stop service
|
# Stop service
|
||||||
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
|
launchctl unload ~/Library/LaunchAgents/com.ejclaw.plist
|
||||||
|
|
||||||
# Check status
|
# Check status
|
||||||
launchctl list | grep nanoclaw
|
launchctl list | grep nanoclaw
|
||||||
|
|
||||||
# View logs
|
# View logs
|
||||||
tail -f logs/nanoclaw.log
|
tail -f logs/ejclaw.log
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -763,7 +763,7 @@ chmod 700 groups/
|
|||||||
| Issue | Cause | Solution |
|
| Issue | Cause | Solution |
|
||||||
|-------|-------|----------|
|
|-------|-------|----------|
|
||||||
| No response to messages | Service not running | Check `launchctl list | grep nanoclaw` |
|
| 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/` |
|
| "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 | 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/` |
|
| 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
|
### Log Location
|
||||||
|
|
||||||
- `logs/nanoclaw.log` - stdout
|
- `logs/ejclaw.log` - stdout
|
||||||
- `logs/nanoclaw.error.log` - stderr
|
- `logs/ejclaw.error.log` - stderr
|
||||||
|
|
||||||
### Debug Mode
|
### Debug Mode
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# NanoClaw Skills Architecture
|
# EJClaw Skills Architecture
|
||||||
|
|
||||||
## Core Principle
|
## 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
|
- 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
|
- 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
|
- 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
|
### Intent Files
|
||||||
|
|
||||||
@@ -382,11 +382,11 @@ If tests fail and Level 2 can't resolve, restore from `.nanoclaw/backup/` and re
|
|||||||
|
|
||||||
### The Problem
|
### 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
|
### 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/
|
.nanoclaw/
|
||||||
@@ -588,7 +588,7 @@ There is no unrecoverable state.
|
|||||||
|
|
||||||
## 10. Core Updates
|
## 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
|
### Patches and Migrations
|
||||||
|
|
||||||
@@ -977,7 +977,7 @@ Each passing combination generates a verified resolution entry for the shared ca
|
|||||||
|
|
||||||
### `.gitattributes`
|
### `.gitattributes`
|
||||||
|
|
||||||
Ship with NanoClaw to reduce noisy merge conflicts:
|
Ship with EJClaw to reduce noisy merge conflicts:
|
||||||
|
|
||||||
```
|
```
|
||||||
* text=auto
|
* text=auto
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# NanoClaw Skills Architecture
|
# EJClaw Skills Architecture
|
||||||
|
|
||||||
## What Skills Are For
|
## 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
|
## Why This Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
Do not store platform-wide operating rules here. Those now live in `prompts/claude-platform.md`.
|
||||||
- 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**.
|
|
||||||
|
|||||||
@@ -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
|
Keep platform-wide operating rules in `prompts/claude-platform.md`.
|
||||||
|
|
||||||
- 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.
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>Label</key>
|
<key>Label</key>
|
||||||
<string>com.nanoclaw-codex</string>
|
<string>com.ejclaw-codex</string>
|
||||||
<key>ProgramArguments</key>
|
<key>ProgramArguments</key>
|
||||||
<array>
|
<array>
|
||||||
<string>{{NODE_PATH}}</string>
|
<string>{{NODE_PATH}}</string>
|
||||||
@@ -31,8 +31,8 @@
|
|||||||
<string>{{PROJECT_ROOT}}/data-codex</string>
|
<string>{{PROJECT_ROOT}}/data-codex</string>
|
||||||
</dict>
|
</dict>
|
||||||
<key>StandardOutPath</key>
|
<key>StandardOutPath</key>
|
||||||
<string>{{PROJECT_ROOT}}/logs/nanoclaw-codex.log</string>
|
<string>{{PROJECT_ROOT}}/logs/ejclaw-codex.log</string>
|
||||||
<key>StandardErrorPath</key>
|
<key>StandardErrorPath</key>
|
||||||
<string>{{PROJECT_ROOT}}/logs/nanoclaw-codex.error.log</string>
|
<string>{{PROJECT_ROOT}}/logs/ejclaw-codex.error.log</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>Label</key>
|
<key>Label</key>
|
||||||
<string>com.nanoclaw</string>
|
<string>com.ejclaw</string>
|
||||||
<key>ProgramArguments</key>
|
<key>ProgramArguments</key>
|
||||||
<array>
|
<array>
|
||||||
<string>{{NODE_PATH}}</string>
|
<string>{{NODE_PATH}}</string>
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
<string>Andy</string>
|
<string>Andy</string>
|
||||||
</dict>
|
</dict>
|
||||||
<key>StandardOutPath</key>
|
<key>StandardOutPath</key>
|
||||||
<string>{{PROJECT_ROOT}}/logs/nanoclaw.log</string>
|
<string>{{PROJECT_ROOT}}/logs/ejclaw.log</string>
|
||||||
<key>StandardErrorPath</key>
|
<key>StandardErrorPath</key>
|
||||||
<string>{{PROJECT_ROOT}}/logs/nanoclaw.error.log</string>
|
<string>{{PROJECT_ROOT}}/logs/ejclaw.error.log</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw",
|
"name": "ejclaw",
|
||||||
"version": "1.2.12",
|
"version": "1.2.12",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nanoclaw",
|
"name": "ejclaw",
|
||||||
"version": "1.2.12",
|
"version": "1.2.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw",
|
"name": "ejclaw",
|
||||||
"version": "1.2.12",
|
"version": "1.2.12",
|
||||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ You are Andy, a personal assistant.
|
|||||||
|
|
||||||
Your output is sent directly to the user or Discord group.
|
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
|
### Internal thoughts
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Your output is sent directly to the Discord group.
|
|||||||
- Keep answers concise unless more detail is genuinely needed
|
- Keep answers concise unless more detail is genuinely needed
|
||||||
- Give conclusions and concrete next steps, not hidden reasoning
|
- Give conclusions and concrete next steps, not hidden reasoning
|
||||||
- Use code blocks for commands or code when helpful
|
- 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
|
- 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
|
- When you do schedule background follow-up, mention that it was scheduled. Include the task ID only when it is useful for later reference
|
||||||
|
|
||||||
|
|||||||
4
runners/agent-runner/package-lock.json
generated
4
runners/agent-runner/package-lock.json
generated
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw-agent-runner",
|
"name": "ejclaw-agent-runner",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nanoclaw-agent-runner",
|
"name": "ejclaw-agent-runner",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.76",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.76",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw-agent-runner",
|
"name": "ejclaw-agent-runner",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Container-side agent runner for NanoClaw",
|
"description": "Container-side agent runner for EJClaw",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* NanoClaw Agent Runner
|
* EJClaw Agent Runner
|
||||||
* Runs inside a container, receives config via stdin, outputs result to stdout
|
* Runs inside a container, receives config via stdin, outputs result to stdout
|
||||||
*
|
*
|
||||||
* Input protocol:
|
* Input protocol:
|
||||||
@@ -60,11 +60,19 @@ interface SDKUserMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Paths configurable via env vars (defaults to container paths for backwards compat)
|
// Paths configurable via env vars (defaults to container paths for backwards compat)
|
||||||
const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group';
|
const GROUP_DIR =
|
||||||
const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc';
|
process.env.EJCLAW_GROUP_DIR ||
|
||||||
const EXTRA_BASE = process.env.NANOCLAW_EXTRA_DIR || '/workspace/extra';
|
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)
|
// 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 IPC_INPUT_DIR = path.join(IPC_DIR, 'input');
|
||||||
const MAX_TURNS = 100;
|
const MAX_TURNS = 100;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Stdio MCP Server for NanoClaw
|
* Stdio MCP Server for EJClaw
|
||||||
* Standalone process that agent teams subagents can inherit.
|
* Standalone process that agent teams subagents can inherit.
|
||||||
* Reads context from environment variables, writes IPC files for the host.
|
* Reads context from environment variables, writes IPC files for the host.
|
||||||
*/
|
*/
|
||||||
@@ -11,14 +11,17 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { CronExpressionParser } from 'cron-parser';
|
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 MESSAGES_DIR = path.join(IPC_DIR, 'messages');
|
||||||
const TASKS_DIR = path.join(IPC_DIR, 'tasks');
|
const TASKS_DIR = path.join(IPC_DIR, 'tasks');
|
||||||
|
|
||||||
// Context from environment variables (set by the agent runner)
|
// Context from environment variables (set by the agent runner)
|
||||||
const chatJid = process.env.NANOCLAW_CHAT_JID!;
|
const chatJid = process.env.EJCLAW_CHAT_JID || process.env.NANOCLAW_CHAT_JID!;
|
||||||
const groupFolder = process.env.NANOCLAW_GROUP_FOLDER!;
|
const groupFolder =
|
||||||
const isMain = process.env.NANOCLAW_IS_MAIN === '1';
|
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 {
|
function writeIpcFile(dir: string, data: object): string {
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
|||||||
4
runners/codex-runner/package-lock.json
generated
4
runners/codex-runner/package-lock.json
generated
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw-codex-runner",
|
"name": "ejclaw-codex-runner",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nanoclaw-codex-runner",
|
"name": "ejclaw-codex-runner",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openai/codex-sdk": "^0.114.0"
|
"@openai/codex-sdk": "^0.114.0"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw-codex-runner",
|
"name": "ejclaw-codex-runner",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Container-side Codex CLI runner for NanoClaw",
|
"description": "Container-side Codex CLI runner for EJClaw",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
|||||||
@@ -144,8 +144,8 @@ export class CodexAppServerClient {
|
|||||||
|
|
||||||
await this.request('initialize', {
|
await this.request('initialize', {
|
||||||
clientInfo: {
|
clientInfo: {
|
||||||
name: 'nanoclaw_codex_runner',
|
name: 'ejclaw_codex_runner',
|
||||||
title: 'NanoClaw Codex Runner',
|
title: 'EJClaw Codex Runner',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
@@ -182,7 +182,7 @@ export class CodexAppServerClient {
|
|||||||
model: options.model,
|
model: options.model,
|
||||||
approvalPolicy: 'never',
|
approvalPolicy: 'never',
|
||||||
sandbox: 'danger-full-access',
|
sandbox: 'danger-full-access',
|
||||||
serviceName: 'nanoclaw',
|
serviceName: 'ejclaw',
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = sessionId
|
const result = sessionId
|
||||||
@@ -362,7 +362,7 @@ export class CodexAppServerClient {
|
|||||||
this.respondError(
|
this.respondError(
|
||||||
message.id,
|
message.id,
|
||||||
-32601,
|
-32601,
|
||||||
`NanoClaw does not handle server request ${message.method}`,
|
`EJClaw does not handle server request ${message.method}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* NanoClaw Codex Runner
|
* EJClaw Codex Runner
|
||||||
*
|
*
|
||||||
* Default runtime is Codex app-server, with SDK fallback available via
|
* Default runtime is Codex app-server, with SDK fallback available via
|
||||||
* CODEX_RUNTIME=sdk or automatic fallback when app-server startup fails.
|
* CODEX_RUNTIME=sdk or automatic fallback when app-server startup fails.
|
||||||
*
|
*
|
||||||
* Input protocol:
|
* Input protocol:
|
||||||
* Stdin: Full ContainerInput JSON (read until EOF)
|
* 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
|
* Sentinel: _close — signals session end
|
||||||
*
|
*
|
||||||
* Stdout protocol:
|
* Stdout protocol:
|
||||||
@@ -45,9 +45,14 @@ interface ContainerOutput {
|
|||||||
|
|
||||||
// ── Constants ──────────────────────────────────────────────────────
|
// ── Constants ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group';
|
const GROUP_DIR =
|
||||||
const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc';
|
process.env.EJCLAW_GROUP_DIR ||
|
||||||
const WORK_DIR = process.env.NANOCLAW_WORK_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_DIR = path.join(IPC_DIR, 'input');
|
||||||
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
|
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
|
||||||
const IPC_POLL_MS = 500;
|
const IPC_POLL_MS = 500;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ function parseArgs(args: string[]): { empty: boolean; json: string } {
|
|||||||
export async function run(args: string[]): Promise<void> {
|
export async function run(args: string[]): Promise<void> {
|
||||||
const { empty, json } = parseArgs(args);
|
const { empty, json } = parseArgs(args);
|
||||||
const homeDir = os.homedir();
|
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');
|
const configFile = path.join(configDir, 'mount-allowlist.json');
|
||||||
|
|
||||||
if (isRoot()) {
|
if (isRoot()) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Cross-platform detection utilities for NanoClaw setup.
|
* Cross-platform detection utilities for EJClaw setup.
|
||||||
*/
|
*/
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ function generatePlist(
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>Label</key>
|
<key>Label</key>
|
||||||
<string>com.nanoclaw</string>
|
<string>com.ejclaw</string>
|
||||||
<key>ProgramArguments</key>
|
<key>ProgramArguments</key>
|
||||||
<array>
|
<array>
|
||||||
<string>${nodePath}</string>
|
<string>${nodePath}</string>
|
||||||
@@ -39,9 +39,9 @@ function generatePlist(
|
|||||||
<string>${homeDir}</string>
|
<string>${homeDir}</string>
|
||||||
</dict>
|
</dict>
|
||||||
<key>StandardOutPath</key>
|
<key>StandardOutPath</key>
|
||||||
<string>${projectRoot}/logs/nanoclaw.log</string>
|
<string>${projectRoot}/logs/ejclaw.log</string>
|
||||||
<key>StandardErrorPath</key>
|
<key>StandardErrorPath</key>
|
||||||
<string>${projectRoot}/logs/nanoclaw.error.log</string>
|
<string>${projectRoot}/logs/ejclaw.error.log</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>`;
|
</plist>`;
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ function generateSystemdUnit(
|
|||||||
isSystem: boolean,
|
isSystem: boolean,
|
||||||
): string {
|
): string {
|
||||||
return `[Unit]
|
return `[Unit]
|
||||||
Description=NanoClaw Personal Assistant
|
Description=EJClaw Personal Assistant
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
@@ -64,8 +64,8 @@ Restart=always
|
|||||||
RestartSec=5
|
RestartSec=5
|
||||||
Environment=HOME=${homeDir}
|
Environment=HOME=${homeDir}
|
||||||
Environment=PATH=/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin
|
Environment=PATH=/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin
|
||||||
StandardOutput=append:${projectRoot}/logs/nanoclaw.log
|
StandardOutput=append:${projectRoot}/logs/ejclaw.log
|
||||||
StandardError=append:${projectRoot}/logs/nanoclaw.error.log
|
StandardError=append:${projectRoot}/logs/ejclaw.error.log
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=${isSystem ? 'multi-user.target' : 'default.target'}`;
|
WantedBy=${isSystem ? 'multi-user.target' : 'default.target'}`;
|
||||||
@@ -75,16 +75,16 @@ describe('plist generation', () => {
|
|||||||
it('contains the correct label', () => {
|
it('contains the correct label', () => {
|
||||||
const plist = generatePlist(
|
const plist = generatePlist(
|
||||||
'/usr/local/bin/node',
|
'/usr/local/bin/node',
|
||||||
'/home/user/nanoclaw',
|
'/home/user/ejclaw',
|
||||||
'/home/user',
|
'/home/user',
|
||||||
);
|
);
|
||||||
expect(plist).toContain('<string>com.nanoclaw</string>');
|
expect(plist).toContain('<string>com.ejclaw</string>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the correct node path', () => {
|
it('uses the correct node path', () => {
|
||||||
const plist = generatePlist(
|
const plist = generatePlist(
|
||||||
'/opt/node/bin/node',
|
'/opt/node/bin/node',
|
||||||
'/home/user/nanoclaw',
|
'/home/user/ejclaw',
|
||||||
'/home/user',
|
'/home/user',
|
||||||
);
|
);
|
||||||
expect(plist).toContain('<string>/opt/node/bin/node</string>');
|
expect(plist).toContain('<string>/opt/node/bin/node</string>');
|
||||||
@@ -93,20 +93,20 @@ describe('plist generation', () => {
|
|||||||
it('points to dist/index.js', () => {
|
it('points to dist/index.js', () => {
|
||||||
const plist = generatePlist(
|
const plist = generatePlist(
|
||||||
'/usr/local/bin/node',
|
'/usr/local/bin/node',
|
||||||
'/home/user/nanoclaw',
|
'/home/user/ejclaw',
|
||||||
'/home/user',
|
'/home/user',
|
||||||
);
|
);
|
||||||
expect(plist).toContain('/home/user/nanoclaw/dist/index.js');
|
expect(plist).toContain('/home/user/ejclaw/dist/index.js');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets log paths', () => {
|
it('sets log paths', () => {
|
||||||
const plist = generatePlist(
|
const plist = generatePlist(
|
||||||
'/usr/local/bin/node',
|
'/usr/local/bin/node',
|
||||||
'/home/user/nanoclaw',
|
'/home/user/ejclaw',
|
||||||
'/home/user',
|
'/home/user',
|
||||||
);
|
);
|
||||||
expect(plist).toContain('nanoclaw.log');
|
expect(plist).toContain('ejclaw.log');
|
||||||
expect(plist).toContain('nanoclaw.error.log');
|
expect(plist).toContain('ejclaw.error.log');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ describe('systemd unit generation', () => {
|
|||||||
it('user unit uses default.target', () => {
|
it('user unit uses default.target', () => {
|
||||||
const unit = generateSystemdUnit(
|
const unit = generateSystemdUnit(
|
||||||
'/usr/bin/node',
|
'/usr/bin/node',
|
||||||
'/home/user/nanoclaw',
|
'/home/user/ejclaw',
|
||||||
'/home/user',
|
'/home/user',
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
@@ -124,7 +124,7 @@ describe('systemd unit generation', () => {
|
|||||||
it('system unit uses multi-user.target', () => {
|
it('system unit uses multi-user.target', () => {
|
||||||
const unit = generateSystemdUnit(
|
const unit = generateSystemdUnit(
|
||||||
'/usr/bin/node',
|
'/usr/bin/node',
|
||||||
'/home/user/nanoclaw',
|
'/home/user/ejclaw',
|
||||||
'/home/user',
|
'/home/user',
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@@ -134,7 +134,7 @@ describe('systemd unit generation', () => {
|
|||||||
it('contains restart policy', () => {
|
it('contains restart policy', () => {
|
||||||
const unit = generateSystemdUnit(
|
const unit = generateSystemdUnit(
|
||||||
'/usr/bin/node',
|
'/usr/bin/node',
|
||||||
'/home/user/nanoclaw',
|
'/home/user/ejclaw',
|
||||||
'/home/user',
|
'/home/user',
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
@@ -145,32 +145,32 @@ describe('systemd unit generation', () => {
|
|||||||
it('sets correct ExecStart', () => {
|
it('sets correct ExecStart', () => {
|
||||||
const unit = generateSystemdUnit(
|
const unit = generateSystemdUnit(
|
||||||
'/usr/bin/node',
|
'/usr/bin/node',
|
||||||
'/srv/nanoclaw',
|
'/srv/ejclaw',
|
||||||
'/home/user',
|
'/home/user',
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
expect(unit).toContain(
|
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', () => {
|
describe('WSL nohup fallback', () => {
|
||||||
it('generates a valid wrapper script', () => {
|
it('generates a valid wrapper script', () => {
|
||||||
const projectRoot = '/home/user/nanoclaw';
|
const projectRoot = '/home/user/ejclaw';
|
||||||
const nodePath = '/usr/bin/node';
|
const nodePath = '/usr/bin/node';
|
||||||
const pidFile = path.join(projectRoot, 'nanoclaw.pid');
|
const pidFile = path.join(projectRoot, 'ejclaw.pid');
|
||||||
|
|
||||||
// Simulate what service.ts generates
|
// Simulate what service.ts generates
|
||||||
const wrapper = `#!/bin/bash
|
const wrapper = `#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd ${JSON.stringify(projectRoot)}
|
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)}`;
|
echo $! > ${JSON.stringify(pidFile)}`;
|
||||||
|
|
||||||
expect(wrapper).toContain('#!/bin/bash');
|
expect(wrapper).toContain('#!/bin/bash');
|
||||||
expect(wrapper).toContain('nohup');
|
expect(wrapper).toContain('nohup');
|
||||||
expect(wrapper).toContain(nodePath);
|
expect(wrapper).toContain(nodePath);
|
||||||
expect(wrapper).toContain('nanoclaw.pid');
|
expect(wrapper).toContain('ejclaw.pid');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import {
|
|||||||
} from './platform.js';
|
} from './platform.js';
|
||||||
import { emitStatus } from './status.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> {
|
export async function run(_args: string[]): Promise<void> {
|
||||||
const projectRoot = process.cwd();
|
const projectRoot = process.cwd();
|
||||||
const platform = getPlatform();
|
const platform = getPlatform();
|
||||||
@@ -77,7 +80,7 @@ function setupLaunchd(
|
|||||||
homeDir,
|
homeDir,
|
||||||
'Library',
|
'Library',
|
||||||
'LaunchAgents',
|
'LaunchAgents',
|
||||||
'com.nanoclaw.plist',
|
`${EJCLAW_LAUNCHD_LABEL}.plist`,
|
||||||
);
|
);
|
||||||
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
|
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
|
||||||
|
|
||||||
@@ -86,7 +89,7 @@ function setupLaunchd(
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>Label</key>
|
<key>Label</key>
|
||||||
<string>com.nanoclaw</string>
|
<string>${EJCLAW_LAUNCHD_LABEL}</string>
|
||||||
<key>ProgramArguments</key>
|
<key>ProgramArguments</key>
|
||||||
<array>
|
<array>
|
||||||
<string>${nodePath}</string>
|
<string>${nodePath}</string>
|
||||||
@@ -106,9 +109,9 @@ function setupLaunchd(
|
|||||||
<string>${homeDir}</string>
|
<string>${homeDir}</string>
|
||||||
</dict>
|
</dict>
|
||||||
<key>StandardOutPath</key>
|
<key>StandardOutPath</key>
|
||||||
<string>${projectRoot}/logs/nanoclaw.log</string>
|
<string>${projectRoot}/logs/ejclaw.log</string>
|
||||||
<key>StandardErrorPath</key>
|
<key>StandardErrorPath</key>
|
||||||
<string>${projectRoot}/logs/nanoclaw.error.log</string>
|
<string>${projectRoot}/logs/ejclaw.error.log</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>`;
|
</plist>`;
|
||||||
|
|
||||||
@@ -128,7 +131,7 @@ function setupLaunchd(
|
|||||||
let serviceLoaded = false;
|
let serviceLoaded = false;
|
||||||
try {
|
try {
|
||||||
const output = execSync('launchctl list', { encoding: 'utf-8' });
|
const output = execSync('launchctl list', { encoding: 'utf-8' });
|
||||||
serviceLoaded = output.includes('com.nanoclaw');
|
serviceLoaded = output.includes(EJCLAW_LAUNCHD_LABEL);
|
||||||
} catch {
|
} catch {
|
||||||
// launchctl list failed
|
// 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.
|
* Prevents connection conflicts when two instances connect to the same channel simultaneously.
|
||||||
*/
|
*/
|
||||||
function killOrphanedProcesses(projectRoot: string): void {
|
function killOrphanedProcesses(projectRoot: string): void {
|
||||||
@@ -168,7 +171,7 @@ function killOrphanedProcesses(projectRoot: string): void {
|
|||||||
execSync(`pkill -f '${projectRoot}/dist/index\\.js' || true`, {
|
execSync(`pkill -f '${projectRoot}/dist/index\\.js' || true`, {
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
});
|
});
|
||||||
logger.info('Stopped any orphaned nanoclaw processes');
|
logger.info('Stopped any orphaned ejclaw processes');
|
||||||
} catch {
|
} catch {
|
||||||
// pkill not available or no orphans
|
// pkill not available or no orphans
|
||||||
}
|
}
|
||||||
@@ -186,7 +189,7 @@ function setupSystemd(
|
|||||||
let systemctlPrefix: string;
|
let systemctlPrefix: string;
|
||||||
|
|
||||||
if (runningAsRoot) {
|
if (runningAsRoot) {
|
||||||
unitPath = '/etc/systemd/system/nanoclaw.service';
|
unitPath = `/etc/systemd/system/${EJCLAW_SERVICE_NAME}.service`;
|
||||||
systemctlPrefix = 'systemctl';
|
systemctlPrefix = 'systemctl';
|
||||||
logger.info('Running as root — installing system-level systemd unit');
|
logger.info('Running as root — installing system-level systemd unit');
|
||||||
} else {
|
} else {
|
||||||
@@ -202,12 +205,12 @@ function setupSystemd(
|
|||||||
}
|
}
|
||||||
const unitDir = path.join(homeDir, '.config', 'systemd', 'user');
|
const unitDir = path.join(homeDir, '.config', 'systemd', 'user');
|
||||||
fs.mkdirSync(unitDir, { recursive: true });
|
fs.mkdirSync(unitDir, { recursive: true });
|
||||||
unitPath = path.join(unitDir, 'nanoclaw.service');
|
unitPath = path.join(unitDir, `${EJCLAW_SERVICE_NAME}.service`);
|
||||||
systemctlPrefix = 'systemctl --user';
|
systemctlPrefix = 'systemctl --user';
|
||||||
}
|
}
|
||||||
|
|
||||||
const unit = `[Unit]
|
const unit = `[Unit]
|
||||||
Description=NanoClaw Personal Assistant
|
Description=EJClaw Personal Assistant
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
@@ -218,8 +221,8 @@ Restart=always
|
|||||||
RestartSec=5
|
RestartSec=5
|
||||||
Environment=HOME=${homeDir}
|
Environment=HOME=${homeDir}
|
||||||
Environment=PATH=${path.dirname(nodePath)}:/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin:${homeDir}/.npm-global/bin
|
Environment=PATH=${path.dirname(nodePath)}:/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin:${homeDir}/.npm-global/bin
|
||||||
StandardOutput=append:${projectRoot}/logs/nanoclaw.log
|
StandardOutput=append:${projectRoot}/logs/ejclaw.log
|
||||||
StandardError=append:${projectRoot}/logs/nanoclaw.error.log
|
StandardError=append:${projectRoot}/logs/ejclaw.error.log
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
|
WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
|
||||||
@@ -227,7 +230,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
|
|||||||
fs.writeFileSync(unitPath, unit);
|
fs.writeFileSync(unitPath, unit);
|
||||||
logger.info({ unitPath }, 'Wrote systemd 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);
|
killOrphanedProcesses(projectRoot);
|
||||||
|
|
||||||
// Enable and start
|
// Enable and start
|
||||||
@@ -238,13 +241,17 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(`${systemctlPrefix} enable nanoclaw`, { stdio: 'ignore' });
|
execSync(`${systemctlPrefix} enable ${EJCLAW_SERVICE_NAME}`, {
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err }, 'systemctl enable failed');
|
logger.error({ err }, 'systemctl enable failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(`${systemctlPrefix} start nanoclaw`, { stdio: 'ignore' });
|
execSync(`${systemctlPrefix} start ${EJCLAW_SERVICE_NAME}`, {
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err }, 'systemctl start failed');
|
logger.error({ err }, 'systemctl start failed');
|
||||||
}
|
}
|
||||||
@@ -252,7 +259,9 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
|
|||||||
// Verify
|
// Verify
|
||||||
let serviceLoaded = false;
|
let serviceLoaded = false;
|
||||||
try {
|
try {
|
||||||
execSync(`${systemctlPrefix} is-active nanoclaw`, { stdio: 'ignore' });
|
execSync(`${systemctlPrefix} is-active ${EJCLAW_SERVICE_NAME}`, {
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
serviceLoaded = true;
|
serviceLoaded = true;
|
||||||
} catch {
|
} catch {
|
||||||
// Not active
|
// Not active
|
||||||
@@ -276,12 +285,12 @@ function setupNohupFallback(
|
|||||||
): void {
|
): void {
|
||||||
logger.warn('No systemd detected — generating nohup wrapper script');
|
logger.warn('No systemd detected — generating nohup wrapper script');
|
||||||
|
|
||||||
const wrapperPath = path.join(projectRoot, 'start-nanoclaw.sh');
|
const wrapperPath = path.join(projectRoot, 'start-ejclaw.sh');
|
||||||
const pidFile = path.join(projectRoot, 'nanoclaw.pid');
|
const pidFile = path.join(projectRoot, 'ejclaw.pid');
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
'#!/bin/bash',
|
'#!/bin/bash',
|
||||||
'# start-nanoclaw.sh — Start NanoClaw without systemd',
|
'# start-ejclaw.sh — Start EJClaw without systemd',
|
||||||
`# To stop: kill \\$(cat ${pidFile})`,
|
`# To stop: kill \\$(cat ${pidFile})`,
|
||||||
'',
|
'',
|
||||||
'set -euo pipefail',
|
'set -euo pipefail',
|
||||||
@@ -292,20 +301,20 @@ function setupNohupFallback(
|
|||||||
`if [ -f ${JSON.stringify(pidFile)} ]; then`,
|
`if [ -f ${JSON.stringify(pidFile)} ]; then`,
|
||||||
` OLD_PID=$(cat ${JSON.stringify(pidFile)} 2>/dev/null || echo "")`,
|
` OLD_PID=$(cat ${JSON.stringify(pidFile)} 2>/dev/null || echo "")`,
|
||||||
' if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then',
|
' 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',
|
' kill "$OLD_PID" 2>/dev/null || true',
|
||||||
' sleep 2',
|
' sleep 2',
|
||||||
' fi',
|
' fi',
|
||||||
'fi',
|
'fi',
|
||||||
'',
|
'',
|
||||||
'echo "Starting NanoClaw..."',
|
'echo "Starting EJClaw..."',
|
||||||
`nohup ${JSON.stringify(nodePath)} ${JSON.stringify(projectRoot + '/dist/index.js')} \\`,
|
`nohup ${JSON.stringify(nodePath)} ${JSON.stringify(projectRoot + '/dist/index.js')} \\`,
|
||||||
` >> ${JSON.stringify(projectRoot + '/logs/nanoclaw.log')} \\`,
|
` >> ${JSON.stringify(projectRoot + '/logs/ejclaw.log')} \\`,
|
||||||
` 2>> ${JSON.stringify(projectRoot + '/logs/nanoclaw.error.log')} &`,
|
` 2>> ${JSON.stringify(projectRoot + '/logs/ejclaw.error.log')} &`,
|
||||||
'',
|
'',
|
||||||
`echo $! > ${JSON.stringify(pidFile)}`,
|
`echo $! > ${JSON.stringify(pidFile)}`,
|
||||||
'echo "NanoClaw started (PID $!)"',
|
'echo "EJClaw started (PID $!)"',
|
||||||
`echo "Logs: tail -f ${projectRoot}/logs/nanoclaw.log"`,
|
`echo "Logs: tail -f ${projectRoot}/logs/ejclaw.log"`,
|
||||||
];
|
];
|
||||||
const wrapper = lines.join('\n') + '\n';
|
const wrapper = lines.join('\n') + '\n';
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
const projectRoot = process.cwd();
|
const projectRoot = process.cwd();
|
||||||
const platform = getPlatform();
|
const platform = getPlatform();
|
||||||
const homeDir = os.homedir();
|
const homeDir = os.homedir();
|
||||||
|
const launchdLabels = ['com.ejclaw', 'com.nanoclaw'];
|
||||||
|
const systemdUnits = ['ejclaw', 'nanoclaw'];
|
||||||
|
const pidFiles = ['ejclaw.pid', 'nanoclaw.pid'];
|
||||||
|
|
||||||
logger.info('Starting verification');
|
logger.info('Starting verification');
|
||||||
|
|
||||||
@@ -36,9 +39,10 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
if (mgr === 'launchd') {
|
if (mgr === 'launchd') {
|
||||||
try {
|
try {
|
||||||
const output = execSync('launchctl list', { encoding: 'utf-8' });
|
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)
|
// 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) {
|
if (line) {
|
||||||
const pidField = line.trim().split(/\s+/)[0];
|
const pidField = line.trim().split(/\s+/)[0];
|
||||||
service = pidField !== '-' && pidField ? 'running' : 'stopped';
|
service = pidField !== '-' && pidField ? 'running' : 'stopped';
|
||||||
@@ -49,15 +53,23 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
} else if (mgr === 'systemd') {
|
} else if (mgr === 'systemd') {
|
||||||
const prefix = isRoot() ? 'systemctl' : 'systemctl --user';
|
const prefix = isRoot() ? 'systemctl' : 'systemctl --user';
|
||||||
try {
|
const activeUnit = systemdUnits.find((unit) => {
|
||||||
execSync(`${prefix} is-active nanoclaw`, { stdio: 'ignore' });
|
try {
|
||||||
|
execSync(`${prefix} is-active ${unit}`, { stdio: 'ignore' });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeUnit) {
|
||||||
service = 'running';
|
service = 'running';
|
||||||
} catch {
|
} else {
|
||||||
try {
|
try {
|
||||||
const output = execSync(`${prefix} list-unit-files`, {
|
const output = execSync(`${prefix} list-unit-files`, {
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
});
|
});
|
||||||
if (output.includes('nanoclaw')) {
|
if (systemdUnits.some((unit) => output.includes(unit))) {
|
||||||
service = 'stopped';
|
service = 'stopped';
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -66,8 +78,10 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Check for nohup PID file
|
// Check for nohup PID file
|
||||||
const pidFile = path.join(projectRoot, 'nanoclaw.pid');
|
const pidFile = pidFiles
|
||||||
if (fs.existsSync(pidFile)) {
|
.map((name) => path.join(projectRoot, name))
|
||||||
|
.find((candidate) => fs.existsSync(candidate));
|
||||||
|
if (pidFile) {
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(pidFile, 'utf-8').trim();
|
const raw = fs.readFileSync(pidFile, 'utf-8').trim();
|
||||||
const pid = Number(raw);
|
const pid = Number(raw);
|
||||||
@@ -144,6 +158,7 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
// 5. Check mount allowlist
|
// 5. Check mount allowlist
|
||||||
let mountAllowlist = 'missing';
|
let mountAllowlist = 'missing';
|
||||||
if (
|
if (
|
||||||
|
fs.existsSync(path.join(homeDir, '.config', 'ejclaw', 'mount-allowlist.json')) ||
|
||||||
fs.existsSync(
|
fs.existsSync(
|
||||||
path.join(homeDir, '.config', 'nanoclaw', 'mount-allowlist.json'),
|
path.join(homeDir, '.config', 'nanoclaw', 'mount-allowlist.json'),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
|
|||||||
vi.mock('./config.js', () => ({
|
vi.mock('./config.js', () => ({
|
||||||
AGENT_MAX_OUTPUT_SIZE: 10485760,
|
AGENT_MAX_OUTPUT_SIZE: 10485760,
|
||||||
AGENT_TIMEOUT: 1800000, // 30min
|
AGENT_TIMEOUT: 1800000, // 30min
|
||||||
DATA_DIR: '/tmp/nanoclaw-test-data',
|
DATA_DIR: '/tmp/ejclaw-test-data',
|
||||||
GROUPS_DIR: '/tmp/nanoclaw-test-groups',
|
GROUPS_DIR: '/tmp/ejclaw-test-groups',
|
||||||
IDLE_TIMEOUT: 1800000, // 30min
|
IDLE_TIMEOUT: 1800000, // 30min
|
||||||
SERVICE_ID: 'claude',
|
SERVICE_ID: 'claude',
|
||||||
SERVICE_AGENT_TYPE: 'claude-code',
|
SERVICE_AGENT_TYPE: 'claude-code',
|
||||||
@@ -223,16 +223,18 @@ describe('agent-runner timeout behavior', () => {
|
|||||||
path.endsWith('/global/CLAUDE.md')
|
path.endsWith('/global/CLAUDE.md')
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
vi.mocked(fs.readFileSync).mockImplementation((p: fs.PathOrFileDescriptor) => {
|
vi.mocked(fs.readFileSync).mockImplementation(
|
||||||
const path = String(p);
|
(p: fs.PathOrFileDescriptor) => {
|
||||||
if (path.endsWith('/prompts/claude-platform.md')) {
|
const path = String(p);
|
||||||
return 'Platform Claude Rules';
|
if (path.endsWith('/prompts/claude-platform.md')) {
|
||||||
}
|
return 'Platform Claude Rules';
|
||||||
if (path.endsWith('/global/CLAUDE.md')) {
|
}
|
||||||
return 'Global Claude Memory';
|
if (path.endsWith('/global/CLAUDE.md')) {
|
||||||
}
|
return 'Global Claude Memory';
|
||||||
return '';
|
}
|
||||||
});
|
return '';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const resultPromise = runAgentProcess(
|
const resultPromise = runAgentProcess(
|
||||||
testGroup,
|
testGroup,
|
||||||
@@ -246,7 +248,7 @@ describe('agent-runner timeout behavior', () => {
|
|||||||
await resultPromise;
|
await resultPromise;
|
||||||
|
|
||||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
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',
|
'Platform Claude Rules\n\n---\n\nGlobal Claude Memory\n',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Agent Process Runner for NanoClaw
|
* Agent Process Runner for EJClaw
|
||||||
* Spawns agent execution as direct host processes and handles IPC
|
* Spawns agent execution as direct host processes and handles IPC
|
||||||
*/
|
*/
|
||||||
import { ChildProcess, spawn } from 'child_process';
|
import { ChildProcess, spawn } from 'child_process';
|
||||||
@@ -200,15 +200,27 @@ function prepareGroupEnvironment(
|
|||||||
TZ: TIMEZONE,
|
TZ: TIMEZONE,
|
||||||
HOME: os.homedir(),
|
HOME: os.homedir(),
|
||||||
// Path configuration for the runner
|
// Path configuration for the runner
|
||||||
|
EJCLAW_GROUP_DIR: groupDir,
|
||||||
NANOCLAW_GROUP_DIR: groupDir,
|
NANOCLAW_GROUP_DIR: groupDir,
|
||||||
|
EJCLAW_IPC_DIR: groupIpcDir,
|
||||||
NANOCLAW_IPC_DIR: groupIpcDir,
|
NANOCLAW_IPC_DIR: groupIpcDir,
|
||||||
|
EJCLAW_GLOBAL_DIR: globalDir,
|
||||||
NANOCLAW_GLOBAL_DIR: globalDir,
|
NANOCLAW_GLOBAL_DIR: globalDir,
|
||||||
|
EJCLAW_EXTRA_DIR: extraDirs.length > 0 ? extraDirs[0] : '',
|
||||||
NANOCLAW_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)
|
// 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
|
// MCP server context
|
||||||
|
EJCLAW_CHAT_JID: group.folder,
|
||||||
NANOCLAW_CHAT_JID: group.folder,
|
NANOCLAW_CHAT_JID: group.folder,
|
||||||
|
EJCLAW_GROUP_FOLDER: group.folder,
|
||||||
NANOCLAW_GROUP_FOLDER: group.folder,
|
NANOCLAW_GROUP_FOLDER: group.folder,
|
||||||
|
EJCLAW_IS_MAIN: isMain ? '1' : '0',
|
||||||
NANOCLAW_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 sessions directory — set CLAUDE_CONFIG_DIR so SDK uses per-group sessions
|
||||||
CLAUDE_CONFIG_DIR: groupSessionsDir,
|
CLAUDE_CONFIG_DIR: groupSessionsDir,
|
||||||
@@ -373,12 +385,13 @@ export async function runAgentProcess(
|
|||||||
input.chatJid,
|
input.chatJid,
|
||||||
);
|
);
|
||||||
if (input.runId) {
|
if (input.runId) {
|
||||||
|
env.EJCLAW_RUN_ID = input.runId;
|
||||||
env.NANOCLAW_RUN_ID = input.runId;
|
env.NANOCLAW_RUN_ID = input.runId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
|
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||||
const processSuffix = input.runId || `${Date.now()}`;
|
const processSuffix = input.runId || `${Date.now()}`;
|
||||||
const processName = `nanoclaw-${safeName}-${processSuffix}`;
|
const processName = `ejclaw-${safeName}-${processSuffix}`;
|
||||||
|
|
||||||
// Check if runner is built
|
// Check if runner is built
|
||||||
const distEntry = path.join(runnerDir, 'dist', 'index.js');
|
const distEntry = path.join(runnerDir, 'dist', 'index.js');
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) }));
|
|||||||
vi.mock('../config.js', () => ({
|
vi.mock('../config.js', () => ({
|
||||||
ASSISTANT_NAME: 'Andy',
|
ASSISTANT_NAME: 'Andy',
|
||||||
TRIGGER_PATTERN: /^@Andy\b/i,
|
TRIGGER_PATTERN: /^@Andy\b/i,
|
||||||
DATA_DIR: '/tmp/nanoclaw-test-data',
|
DATA_DIR: '/tmp/ejclaw-test-data',
|
||||||
CACHE_DIR: '/tmp/nanoclaw-test-cache',
|
CACHE_DIR: '/tmp/ejclaw-test-cache',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock logger
|
// Mock logger
|
||||||
|
|||||||
53
src/claude-usage.test.ts
Normal file
53
src/claude-usage.test.ts
Normal 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
183
src/claude-usage.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import fs from 'fs';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
@@ -36,21 +37,33 @@ export const SCHEDULER_POLL_INTERVAL = 60000;
|
|||||||
|
|
||||||
const PROJECT_ROOT = process.cwd();
|
const PROJECT_ROOT = process.cwd();
|
||||||
const HOME_DIR = process.env.HOME || os.homedir();
|
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(
|
export const SENDER_ALLOWLIST_PATH = path.join(
|
||||||
HOME_DIR,
|
ACTIVE_CONFIG_DIR,
|
||||||
'.config',
|
|
||||||
'nanoclaw',
|
|
||||||
'sender-allowlist.json',
|
'sender-allowlist.json',
|
||||||
);
|
);
|
||||||
export const STORE_DIR = path.resolve(
|
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(
|
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(
|
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)
|
// Shared cache directory (same across both services for dedup)
|
||||||
export const CACHE_DIR = path.join(PROJECT_ROOT, 'cache');
|
export const CACHE_DIR = path.join(PROJECT_ROOT, 'cache');
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import fs from 'fs';
|
|||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchClaudeUsageViaCli,
|
||||||
|
type ClaudeUsageData,
|
||||||
|
} from './claude-usage.js';
|
||||||
import { USAGE_DASHBOARD_ENABLED } from './config.js';
|
import { USAGE_DASHBOARD_ENABLED } from './config.js';
|
||||||
import { readEnvFile } from './env.js';
|
import { readEnvFile } from './env.js';
|
||||||
import { GroupQueue, GroupStatus } from './group-queue.js';
|
import { GroupQueue, GroupStatus } from './group-queue.js';
|
||||||
@@ -20,11 +24,6 @@ export interface DashboardOptions {
|
|||||||
usageUpdateInterval: number;
|
usageUpdateInterval: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClaudeUsageData {
|
|
||||||
five_hour?: { utilization: number; resets_at: string };
|
|
||||||
seven_day?: { utilization: number; resets_at: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CodexRateLimit {
|
interface CodexRateLimit {
|
||||||
limitId?: string;
|
limitId?: string;
|
||||||
limitName: string | null;
|
limitName: string | null;
|
||||||
@@ -71,6 +70,7 @@ function formatResetKST(value: string | number): string {
|
|||||||
try {
|
try {
|
||||||
const date =
|
const date =
|
||||||
typeof value === 'number' ? new Date(value * 1000) : new Date(value);
|
typeof value === 'number' ? new Date(value * 1000) : new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return String(value);
|
||||||
return date.toLocaleString('ko-KR', {
|
return date.toLocaleString('ko-KR', {
|
||||||
timeZone: 'Asia/Seoul',
|
timeZone: 'Asia/Seoul',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
@@ -188,6 +188,9 @@ export function buildStatusContent(opts: DashboardOptions): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
|
async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
|
||||||
|
const cliUsage = await fetchClaudeUsageViaCli();
|
||||||
|
if (cliUsage) return cliUsage;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const envToken = readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN']);
|
const envToken = readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN']);
|
||||||
let token =
|
let token =
|
||||||
|
|||||||
@@ -139,7 +139,9 @@ function createSchema(database: Database.Database): void {
|
|||||||
const hasAgentType = registeredGroupCols.some(
|
const hasAgentType = registeredGroupCols.some(
|
||||||
(col) => col.name === 'agent_type',
|
(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(`
|
database.exec(`
|
||||||
CREATE TABLE registered_groups_new (
|
CREATE TABLE registered_groups_new (
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { GroupQueue } from './group-queue.js';
|
|||||||
|
|
||||||
// Mock config to control concurrency limit
|
// Mock config to control concurrency limit
|
||||||
vi.mock('./config.js', () => ({
|
vi.mock('./config.js', () => ({
|
||||||
DATA_DIR: '/tmp/nanoclaw-test-data',
|
DATA_DIR: '/tmp/ejclaw-test-data',
|
||||||
MAX_CONCURRENT_AGENTS: 2,
|
MAX_CONCURRENT_AGENTS: 2,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ const isDirectRun =
|
|||||||
|
|
||||||
if (isDirectRun) {
|
if (isDirectRun) {
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
logger.error({ err }, 'Failed to start NanoClaw');
|
logger.error({ err }, 'Failed to start EJClaw');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -346,7 +346,19 @@ describe('createMessageRuntime', () => {
|
|||||||
'progress-1',
|
'progress-1',
|
||||||
'오래 걸리는 작업입니다.\n\n1분 10초',
|
'오래 걸리는 작업입니다.\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?.({
|
await onOutput?.({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
result: null,
|
result: null,
|
||||||
|
|||||||
@@ -352,15 +352,14 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
const hours = Math.floor(elapsedSeconds / 3600);
|
const hours = Math.floor(elapsedSeconds / 3600);
|
||||||
const minutes = Math.floor((elapsedSeconds % 3600) / 60);
|
const minutes = Math.floor((elapsedSeconds % 3600) / 60);
|
||||||
const seconds = elapsedSeconds % 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}시간`);
|
return `${text}\n\n${elapsedLabel}`;
|
||||||
if (minutes > 0) elapsedParts.push(`${minutes}분`);
|
|
||||||
if (seconds > 0 || elapsedParts.length === 0) {
|
|
||||||
elapsedParts.push(`${seconds}초`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${text}\n\n${elapsedParts.join(' ')}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const syncTrackedProgressMessage = async () => {
|
const syncTrackedProgressMessage = async () => {
|
||||||
@@ -552,7 +551,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
}
|
}
|
||||||
messageLoopRunning = true;
|
messageLoopRunning = true;
|
||||||
|
|
||||||
logger.info(`NanoClaw running (trigger: @${deps.assistantName})`);
|
logger.info(`EJClaw running (trigger: @${deps.assistantName})`);
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ describe('platform-prompts', () => {
|
|||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
|
|
||||||
beforeEach(() => {
|
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);
|
vi.spyOn(process, 'cwd').mockReturnValue(tempDir);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -84,9 +84,11 @@ describe('isSessionCommandControlMessage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not match regular bot conversation', () => {
|
it('does not match regular bot conversation', () => {
|
||||||
expect(isSessionCommandControlMessage('좋네요. 필요해지면 바로 말씀드리겠습니다.')).toBe(
|
expect(
|
||||||
false,
|
isSessionCommandControlMessage(
|
||||||
);
|
'좋네요. 필요해지면 바로 말씀드리겠습니다.',
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user