feat: add Pi Coding Agent backend runtime, update dashboard UI

This commit is contained in:
Tanmay Karande
2026-03-03 00:24:19 -05:00
parent 453389f55c
commit 4c3a58b680
21 changed files with 4955 additions and 192 deletions

View File

@@ -6,7 +6,7 @@ OUTPUT_CHANNEL_ID=your-discord-channel-id-for-heartbeat-cron-output
# Optional # Optional
# CLAUDE_CLI_PATH=claude # Deprecated: use BACKEND_CLI_PATH instead # CLAUDE_CLI_PATH=claude # Deprecated: use BACKEND_CLI_PATH instead
# AGENT_BACKEND=claude # claude | codex | gemini | opencode # AGENT_BACKEND=claude # claude | codex | gemini | opencode | pi
# BACKEND_CLI_PATH=claude # Path to CLI binary (defaults to backend name) # BACKEND_CLI_PATH=claude # Path to CLI binary (defaults to backend name)
# BACKEND_MODEL= # Optional model override for the active backend # BACKEND_MODEL= # Optional model override for the active backend
# BACKEND_MAX_TURNS=25 # Max agentic turns per query # BACKEND_MAX_TURNS=25 # Max agentic turns per query

212
README.md
View File

@@ -1,27 +1,35 @@
# Aetheel — Discord-Claude Gateway # Aetheel — AI Agent Runtime with Mission Control
An event-driven AI agent runtime that connects Discord to Claude Code CLI. Inspired by [OpenClaw](https://github.com/nichochar/open-claw)'s architecture — a gateway in front of an agent runtime with markdown-based personality, memory, scheduled behaviors, and proactive messaging. An event-driven AI agent runtime that connects Discord to your choice of coding agent CLI — **Claude Code**, **Codex**, **Gemini CLI**, **OpenCode**, or **Pi** — with an embedded **Mission Control dashboard** for real-time monitoring, second brain knowledge management, task tracking, and content curation. Inspired by [OpenClaw](https://github.com/nichochar/open-claw)'s architecture — a gateway in front of an agent runtime with markdown-based personality, memory, scheduled behaviors, and proactive messaging.
## How It Works ## How It Works
``` ```
Discord Users ──► Discord Bot ──► Event Queue ──► Agent Runtime ──► Claude Code CLI Discord Users ──► Discord Bot ──► Event Queue ──► Agent Runtime ──► Backend Adapter
▲ │
Heartbeats ───────── Heartbeats ─────────┤ ┌────────────────┘
Cron Jobs ──────────┌─────────────────────┘ Cron Jobs ──────────┤
Hooks ────────────── Hooks ──────────────┘ Claude | Codex | Gemini
IPC Watcher ───────────── Markdown Config Files IPC Watcher ────────── OpenCode | Pi (CLI)
(CLAUDE.md, memory.md, etc.)
Markdown Config Files
(CLAUDE.md, memory.md, etc.)
Mission Control Dashboard ◄───────────┘
(http://localhost:3100) JSON Stores
(brain, tasks, content)
``` ```
All inputs — Discord messages, heartbeat timers, cron jobs, lifecycle hooks — enter a unified event queue. The agent runtime reads your markdown config files fresh on each event, assembles a dynamic system prompt, and calls the Claude Code CLI. The agent can write back to `memory.md` to persist facts across sessions, and send proactive messages via the IPC system. All inputs — Discord messages, heartbeat timers, cron jobs, lifecycle hooks — enter a unified event queue. The agent runtime reads your markdown config files fresh on each event, assembles a dynamic system prompt, and calls your configured backend CLI (Claude Code, Codex, Gemini, OpenCode, or Pi). The agent can write back to `memory.md` to persist facts across sessions, and send proactive messages via the IPC system.
Uses your existing Claude Code subscription — no API key needed. The **Mission Control dashboard** runs on an embedded HTTP server, providing real-time visibility into agent activity, a knowledge store (Second Brain), productivity task tracking, content curation, and full configuration management — all backed by local JSON files with zero external dependencies.
Uses your existing subscription or API key for whichever backend you choose — no extra configuration needed.
## Prerequisites ## Prerequisites
- Node.js 18+ - Node.js 18+
- Claude Code CLI installed and signed in (`npm install -g @anthropic-ai/claude-code && claude`) - At least one supported coding agent CLI installed and signed in (see [Multi-Backend Support](#multi-backend-support))
- A Discord bot token ([create one here](https://discord.com/developers/applications)) with Message Content Intent enabled - A Discord bot token ([create one here](https://discord.com/developers/applications)) with Message Content Intent enabled
## Quick Start ## Quick Start
@@ -36,6 +44,8 @@ mkdir -p config
npm run dev npm run dev
``` ```
The dashboard will be available at **http://localhost:3100** once the gateway starts.
Or run the interactive setup: Or run the interactive setup:
```bash ```bash
bash scripts/setup.sh bash scripts/setup.sh
@@ -51,9 +61,14 @@ Create a `.env` file in the project root (auto-loaded via dotenv):
|----------|----------|---------|-------------| |----------|----------|---------|-------------|
| `DISCORD_BOT_TOKEN` | Yes | — | Discord bot token | | `DISCORD_BOT_TOKEN` | Yes | — | Discord bot token |
| `OUTPUT_CHANNEL_ID` | No | — | Discord channel for heartbeat/cron/hook output | | `OUTPUT_CHANNEL_ID` | No | — | Discord channel for heartbeat/cron/hook output |
| `CLAUDE_CLI_PATH` | No | `claude` | Path to the Claude Code CLI binary | | `AGENT_BACKEND` | No | `claude` | Backend runtime: `claude`, `codex`, `gemini`, `opencode`, or `pi` |
| `BACKEND_CLI_PATH` | No | *(backend name)* | Path to the backend CLI binary |
| `BACKEND_MODEL` | No | — | Model override for the active backend |
| `BACKEND_MAX_TURNS` | No | `25` | Max agentic turns per query |
| `CLAUDE_CLI_PATH` | No | `claude` | *(Deprecated)* Use `BACKEND_CLI_PATH` instead |
| `CONFIG_DIR` | No | `./config` | Path to markdown config directory | | `CONFIG_DIR` | No | `./config` | Path to markdown config directory |
| `ALLOWED_TOOLS` | No | `Read,Write,Edit,Glob,Grep,WebSearch,WebFetch` | Comma-separated tools the agent can use | | `DASHBOARD_PORT` | No | `3100` | Port for the Mission Control dashboard |
| `ALLOWED_TOOLS` | No | `Read,Write,Edit,Glob,Grep,WebSearch,WebFetch` | Comma-separated tools (Claude only) |
| `PERMISSION_MODE` | No | `bypassPermissions` | Claude Code permission mode | | `PERMISSION_MODE` | No | `bypassPermissions` | Claude Code permission mode |
| `QUERY_TIMEOUT_MS` | No | `120000` | Max time per query (ms) | | `QUERY_TIMEOUT_MS` | No | `120000` | Max time per query (ms) |
| `MAX_CONCURRENT_QUERIES` | No | `5` | Max simultaneous queries | | `MAX_CONCURRENT_QUERIES` | No | `5` | Max simultaneous queries |
@@ -86,20 +101,53 @@ config/skills/
└── SKILL.md → "When reviewing code, focus on..." └── SKILL.md → "When reviewing code, focus on..."
``` ```
## Multi-Backend Support
Aetheel supports five coding agent CLI backends. Switch between them via the `AGENT_BACKEND` environment variable — no code changes required.
| Backend | `AGENT_BACKEND` | CLI | Install | Session Resume | Tool Filtering |
|---------|-----------------|-----|---------|----------------|----------------|
| **Claude Code** | `claude` *(default)* | `claude` | `npm i -g @anthropic-ai/claude-code` | `--resume <id>` | ✅ `--allowedTools` |
| **Codex** | `codex` | `codex` | `npm i -g @openai/codex` | `exec resume <id>` | ❌ |
| **Gemini CLI** | `gemini` | `gemini` | `npm i -g @anthropic-ai/gemini-cli` | `--resume <id>` | ❌ |
| **OpenCode** | `opencode` | `opencode` | See [OpenCode docs](https://github.com/opencode-ai/opencode) | `--session <id> --continue` | ❌ |
| **Pi** | `pi` | `pi` | `npm i -g @mariozechner/pi-coding-agent` | `--session <id> --continue` | ❌ |
### Switching Backends
Edit your `.env` file:
```bash
# Use Pi as the backend
AGENT_BACKEND=pi
BACKEND_CLI_PATH=pi
# BACKEND_MODEL=anthropic/sonnet # Optional model override
```
```bash
# Use Codex as the backend
AGENT_BACKEND=codex
BACKEND_CLI_PATH=codex
```
Each backend adapter normalizes the CLI's output into the same internal format (`BackendEventResult`), so all features — sessions, streaming, retries, error handling — work identically regardless of which CLI you use.
> **Note:** `ALLOWED_TOOLS` only works with the Claude backend (it's the only CLI that supports `--allowedTools` flags). Other backends will log a warning if `ALLOWED_TOOLS` is configured.
## Features ## Features
### Discord Integration ### 🎮 Discord Integration
- Mention the bot (`@Aetheel hi`) or use `/claude` slash command - Mention the bot (`@Aetheel hi`) or use `/claude` slash command
- `/claude-reset` to start a fresh conversation - `/claude-reset` to start a fresh conversation
- Responses auto-split at 2000 chars with code block preservation - Responses auto-split at 2000 chars with code block preservation
- Typing indicators while processing - Typing indicators while processing
### Session Management ### 💬 Session Management
- Per-channel conversation sessions with Claude Code CLI `--resume` - Per-channel conversation sessions (each backend uses its own resume mechanism)
- Sessions persist to `config/sessions.json` (survive restarts) - Sessions persist to `config/sessions.json` (survive restarts)
- Auto-cleanup of idle sessions after 30 minutes (configurable) - Auto-cleanup of idle sessions after 30 minutes (configurable)
### Heartbeats (Timer Events) ### 💚 Heartbeats (Timer Events)
Define in `config/heartbeat.md`: Define in `config/heartbeat.md`:
```markdown ```markdown
## check-email ## check-email
@@ -107,7 +155,7 @@ Interval: 1800
Instruction: Check my inbox for anything urgent. Instruction: Check my inbox for anything urgent.
``` ```
### Cron Jobs (Scheduled Events) ### 🕐 Cron Jobs (Scheduled Events)
Define in `config/agents.md`: Define in `config/agents.md`:
```markdown ```markdown
## Cron Jobs ## Cron Jobs
@@ -117,7 +165,7 @@ Cron: 0 8 * * *
Instruction: Good morning! Search for the latest AI news and post a summary. Instruction: Good morning! Search for the latest AI news and post a summary.
``` ```
### Lifecycle Hooks ### 🪝 Lifecycle Hooks
Define in `config/agents.md`: Define in `config/agents.md`:
```markdown ```markdown
## Hooks ## Hooks
@@ -129,25 +177,98 @@ Instruction: Say hello, you just came online.
Instruction: Save important context to memory.md. Instruction: Save important context to memory.md.
``` ```
### Proactive Messaging (IPC) ### 📨 Proactive Messaging (IPC)
The agent can send messages to any Discord channel by writing JSON files to `config/ipc/outbound/`: The agent can send messages to any Discord channel by writing JSON files to `config/ipc/outbound/`:
```json ```json
{"channelId": "123456789", "text": "Hey, found something interesting!"} {"channelId": "123456789", "text": "Hey, found something interesting!"}
``` ```
The gateway polls every 2 seconds and delivers the message. The gateway polls every 2 seconds and delivers the message.
### Message History ### 📊 Message History
All inbound/outbound messages stored per channel in `config/messages/{channelId}.json`. Max 100 messages per channel, auto-trimmed. All inbound/outbound messages stored per channel in `config/messages/{channelId}.json`. Max 100 messages per channel, auto-trimmed.
### Conversation Archiving ### 📝 Conversation Archiving
Every exchange saved as readable markdown in `config/conversations/{channelId}/{YYYY-MM-DD}.md`. Every exchange saved as readable markdown in `config/conversations/{channelId}/{YYYY-MM-DD}.md`.
### Retry with Backoff ### 🔁 Retry with Backoff
Claude CLI calls retry 3 times with exponential backoff (5s, 10s, 20s) on transient errors. Session corruption errors fail immediately. Backend CLI calls retry 3 times with exponential backoff (5s, 10s, 20s) on transient errors. Session corruption errors fail immediately.
### Structured Logging ### 📋 Structured Logging
Pino-based structured JSON logging. Set `LOG_LEVEL=debug` for verbose output. Pretty-printed in dev, JSON in production (`NODE_ENV=production`). Pino-based structured JSON logging. Set `LOG_LEVEL=debug` for verbose output. Pretty-printed in dev, JSON in production (`NODE_ENV=production`).
---
## Mission Control Dashboard
An embedded web dashboard for real-time agent monitoring and data management. Accessible at `http://localhost:3100` when the gateway is running.
### Pages
| Page | Description |
|------|-------------|
| **Command Center** | Real-time stats (messages, heartbeats, cron runs, uptime), live activity feed, agent configuration |
| **Activity** | Full event history with real-time SSE updates |
| **Sessions** | Active Discord channel sessions and message history viewer |
| **🧠 Second Brain** | Knowledge store with facts, notes, URLs, and file references — searchable with categories and tags. Also includes memory and persona editors, and a skills viewer |
| **📋 Productivity** | Kanban-style task board (To Do → In Progress → Done) with priorities, projects, and due dates |
| **📰 Content Intel** | Content curation and tracking — save articles, videos, papers, repos with status workflow (queued → read → archived) |
| **⏱️ Scheduler** | View all active cron jobs, heartbeat checks, and lifecycle hooks |
| **🔌 Connections** | Status of all integrations (Discord, Claude, heartbeat, cron, IPC, skills) |
| **⚙️ Settings** | Agent configuration overview and quick todos |
### Local Storage
All dashboard data is persisted as JSON files in the `config/` directory — zero external dependencies:
| Store | File | Description |
|-------|------|-------------|
| Brain facts | `config/brain.json` | Notes, URLs, file references with categories and tags |
| Tasks | `config/tasks.json` | Productivity tasks with status, priority, project, due dates |
| Content items | `config/content-items.json` | Curated articles, videos, papers with read-status tracking |
| Activity log | `config/activity-log.json` | Last 2000 events — survives restarts |
| Bot config | `config/bot-config.json` | Dashboard-editable settings |
Data is flushed on shutdown and debounced during writes for performance.
### Dashboard Development
You can run the dashboard standalone with mock data (no Discord bot or Claude CLI needed):
```bash
npm run dashboard
```
This starts a dev server at http://localhost:3100 with simulated activity events and sample data.
### Dashboard API
All dashboard APIs are available at the same port:
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/stats` | GET | Agent statistics (uptime, messages, heartbeats, cron runs) |
| `/api/activity` | GET | Recent activity log entries |
| `/api/config` | GET | Agent configuration |
| `/api/sessions` | GET | Active sessions |
| `/api/messages` | GET | Message history (per channel) |
| `/api/cron` | GET | Cron job definitions |
| `/api/heartbeats` | GET | Heartbeat definitions |
| `/api/hooks` | GET | Lifecycle hook definitions |
| `/api/connections` | GET | Integration statuses |
| `/api/skills` | GET | Loaded skills |
| `/api/memory` | GET/POST | Read/write memory.md |
| `/api/persona` | GET/POST | Read/write CLAUDE.md |
| `/api/brain` | GET/POST/DELETE | CRUD for brain facts |
| `/api/brain?q=...` | GET | Search brain facts |
| `/api/tasks` | GET/POST/DELETE | CRUD for productivity tasks |
| `/api/tasks/update` | POST | Update task fields (e.g. status) |
| `/api/content` | GET/POST/DELETE | CRUD for content items |
| `/api/content/update` | POST | Update content fields (e.g. status) |
| `/api/bot-config` | GET/POST | Read/write bot configuration |
| `/events` | SSE | Real-time event stream |
---
## Deployment ## Deployment
### systemd (recommended for Linux servers) ### systemd (recommended for Linux servers)
@@ -176,7 +297,8 @@ pm2 save
### Dev mode ### Dev mode
```bash ```bash
npm run dev npm run dev # Full gateway + dashboard
npm run dashboard # Dashboard only (mock data)
``` ```
## Project Structure ## Project Structure
@@ -189,7 +311,16 @@ src/
├── logger.ts # Pino structured logger ├── logger.ts # Pino structured logger
├── discord-bot.ts # Discord.js wrapper ├── discord-bot.ts # Discord.js wrapper
├── event-queue.ts # Unified FIFO event queue ├── event-queue.ts # Unified FIFO event queue
├── agent-runtime.ts # Core engine: reads configs, spawns CLI, streams output ├── agent-runtime.ts # Core engine: reads configs, delegates to backend
├── backends/ # Pluggable CLI backend adapters
│ ├── types.ts # BackendAdapter interface & BackendName type
│ ├── registry.ts # resolveBackendName() & createBackend() factory
│ ├── index.ts # Barrel exports
│ ├── claude-backend.ts # Claude Code CLI adapter
│ ├── codex-backend.ts # OpenAI Codex CLI adapter
│ ├── gemini-backend.ts # Google Gemini CLI adapter
│ ├── opencode-backend.ts # OpenCode CLI adapter
│ └── pi-backend.ts # Pi Coding Agent CLI adapter
├── markdown-config-loader.ts # Reads CLAUDE.md, agents.md, memory.md ├── markdown-config-loader.ts # Reads CLAUDE.md, agents.md, memory.md
├── system-prompt-assembler.ts # Assembles system prompt with sections ├── system-prompt-assembler.ts # Assembles system prompt with sections
├── skills-loader.ts # Loads skills from config/skills/*/SKILL.md ├── skills-loader.ts # Loads skills from config/skills/*/SKILL.md
@@ -204,7 +335,16 @@ src/
├── hook-manager.ts # Lifecycle hooks ├── hook-manager.ts # Lifecycle hooks
├── bootstrap-manager.ts # First-run file validation/creation ├── bootstrap-manager.ts # First-run file validation/creation
├── channel-queue.ts # Per-channel sequential processing ├── channel-queue.ts # Per-channel sequential processing
── shutdown-handler.ts # Graceful SIGTERM/SIGINT handling ── shutdown-handler.ts # Graceful SIGTERM/SIGINT handling
├── dashboard-server.ts # Embedded HTTP server for Mission Control
├── dashboard-dev.ts # Standalone dashboard dev server (mock data)
├── activity-log.ts # In-memory + persisted activity event log
└── local-store.ts # JSON file persistence layer (brain, tasks, content)
dashboard/ # Mission Control frontend
├── index.html # SPA shell with sidebar navigation
├── styles.css # Dark-theme design system (~1700 lines)
└── app.js # Client-side SPA (routing, API, rendering)
config/ # Agent workspace (gitignored) config/ # Agent workspace (gitignored)
├── CLAUDE.md # Persona ├── CLAUDE.md # Persona
@@ -212,18 +352,28 @@ config/ # Agent workspace (gitignored)
├── memory.md # Long-term memory (agent-writable) ├── memory.md # Long-term memory (agent-writable)
├── heartbeat.md # Heartbeat checks ├── heartbeat.md # Heartbeat checks
├── sessions.json # Session persistence (auto) ├── sessions.json # Session persistence (auto)
├── brain.json # Second Brain facts (auto)
├── tasks.json # Productivity tasks (auto)
├── content-items.json # Content Intel items (auto)
├── activity-log.json # Persisted activity log (auto)
├── bot-config.json # Dashboard bot config (auto)
├── messages/ # Message history (auto) ├── messages/ # Message history (auto)
├── conversations/ # Conversation archives (auto) ├── conversations/ # Conversation archives (auto)
├── ipc/outbound/ # Proactive message queue (auto) ├── ipc/outbound/ # Proactive message queue (auto)
├── skills/ # Skill definitions ├── skills/ # Skill definitions
└── news/ # Example: agent-created content └── news/ # Example: agent-created content
docs/
├── PROCESS-FLOW.md # Step-by-step event processing walkthrough
└── FUTURE-FEATURES.md # Feature roadmap and ideas
``` ```
## Development ## Development
```bash ```bash
npm test # Run tests (85 passing) npm test # Run tests (vitest)
npm run dev # Dev mode with tsx npm run dev # Full gateway + embedded dashboard
npm run dashboard # Dashboard only with mock data
npm run build # Compile TypeScript npm run build # Compile TypeScript
npm start # Run compiled JS npm start # Run compiled JS
``` ```

1139
dashboard/app.js Normal file

File diff suppressed because it is too large Load Diff

124
dashboard/index.html Normal file
View File

@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Aetheel · Mission Control</title>
<meta name="description" content="Mission Control dashboard for the Aetheel AI agent runtime" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<!-- ═══ SIDEBAR ═══ -->
<aside id="sidebar" class="sidebar">
<div class="sidebar-header">
<div class="sidebar-logo">
<div class="logo-glyph">Æ</div>
<div class="logo-text">
<span class="logo-name">Aetheel</span>
<span class="logo-version">v1.0.0</span>
</div>
</div>
</div>
<div class="sidebar-status" id="agent-status">
<span class="status-dot"></span>
<span class="status-text">Connecting…</span>
</div>
<nav class="sidebar-nav">
<a href="#/" class="nav-item active" data-page="command-center">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="9" rx="1" />
<rect x="14" y="3" width="7" height="5" rx="1" />
<rect x="14" y="12" width="7" height="9" rx="1" />
<rect x="3" y="16" width="7" height="5" rx="1" />
</svg>
<span>Command Center</span>
</a>
<a href="#/activity" class="nav-item" data-page="activity">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
<span>Activity</span>
</a>
<a href="#/sessions" class="nav-item" data-page="sessions">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
<span>Sessions</span>
</a>
<a href="#/brain" class="nav-item" data-page="brain">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path
d="M12 2a9 9 0 0 0-9 9c0 3.9 2.5 7.1 6 8.4V21a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-1.6c3.5-1.3 6-4.5 6-8.4a9 9 0 0 0-9-9z" />
<line x1="9" y1="17" x2="15" y2="17" />
</svg>
<span>Brain</span>
</a>
<a href="#/productivity" class="nav-item" data-page="productivity">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" />
</svg>
<span>Productivity</span>
</a>
<a href="#/content" class="nav-item" data-page="content">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</svg>
<span>Content Intel</span>
</a>
<a href="#/tasks" class="nav-item" data-page="tasks">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span>Scheduler</span>
</a>
<a href="#/connections" class="nav-item" data-page="connections">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
<span>Connections</span>
</a>
<a href="#/settings" class="nav-item" data-page="settings">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
<span>Settings</span>
</a>
</nav>
<div class="sidebar-footer">
<div class="xp-bar">
<div class="xp-label">
<span class="xp-level">Agent Runtime</span>
<span class="xp-sub" id="xp-uptime">0s</span>
</div>
<div class="xp-track">
<div class="xp-fill" id="xp-fill"></div>
</div>
</div>
</div>
</aside>
<!-- ═══ MAIN ═══ -->
<main id="main-content" class="main-content">
<div id="page-container"></div>
</main>
<script src="/app.js"></script>
</body>
</html>

1784
dashboard/styles.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,47 +2,60 @@
Features inspired by nanoclaw that can be implemented in the CLI-based gateway. Excludes Docker containers, WhatsApp channel, and Agent SDK (already ruled out). Features inspired by nanoclaw that can be implemented in the CLI-based gateway. Excludes Docker containers, WhatsApp channel, and Agent SDK (already ruled out).
## Quick Wins (hours) ---
### 1. Message History Storage ## ✅ Completed
Store inbound/outbound messages in a JSON file per channel. Nanoclaw uses SQLite, but a simple `config/messages/{channelId}.json` would work. This lets the agent see conversation history even after session expiry. ### Message History Storage ✅
Store inbound/outbound messages in a JSON file per channel. Implemented at `config/messages/{channelId}.json` with append-only storage, max 100 messages per channel, auto-trimmed.
How nanoclaw does it: SQLite table with id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message columns. Indexed by timestamp. ### Structured Logging ✅
Pino-based structured JSON logging with configurable log levels via `LOG_LEVEL` env var. Pretty-printed in dev, JSON in production.
Our approach: JSON file per channel at `config/messages/{channelId}.json`. Append-only. Include sender name, content, timestamp, and direction (inbound/outbound). Load recent messages into the system prompt or pass as context. ### Conversation Archiving ✅
Every exchange saved as readable markdown in `config/conversations/{channelId}/{YYYY-MM-DD}.md`.
### 2. Structured Logging ### Retry with Backoff ✅
Backend CLI calls retry 3 times with exponential backoff (5s, 10s, 20s) on transient errors. Session corruption errors fail immediately.
Replace `console.log` with a proper logger (pino). Nanoclaw logs everything with structured context (group, duration, event type). Makes debugging way easier on a headless server. ### Idle Timeout for Sessions ✅
Auto-clear sessions after configurable period of inactivity per channel (default 30 min). Tracked via `lastActivityTimestamp` in the session manager.
How nanoclaw does it: Pino logger with pretty-printing, configurable log levels via LOG_LEVEL env var, uncaught exception routing, container logs saved to disk. ### IPC-Based Proactive Messaging ✅
Agent writes JSON files to `config/ipc/outbound/` using the Write tool. Gateway polls every 2 seconds, delivers messages, and deletes processed files.
Our approach: Install pino, replace all console.log/error/warn calls, add log levels, save logs to `config/logs/`. Structured JSON logs make it easy to grep and filter. ### Skills Engine ✅
`config/skills/` directory where each subdirectory contains a `SKILL.md` file. Skills are loaded into the system prompt as additional sections.
### 3. Conversation Archiving ### Config Simplification ✅
Merged `soul.md`, `identity.md`, `user.md`, and `tools.md` into a single `CLAUDE.md` file.
Before sessions get too long, archive transcripts to `config/conversations/`. Nanoclaw does this via a PreCompact hook that saves the full transcript as markdown before Claude compacts its context. ### Mission Control Dashboard ✅
Embedded web-based dashboard served on port 3100 with:
- **Command Center** — Real-time stats, live activity feed, agent config
- **Activity** — Full event history with SSE real-time updates
- **Sessions** — Active Discord channel sessions and message viewer
- **Second Brain** — Knowledge store with facts (notes/URLs/files), categories, tags, search. Plus memory and persona editors, and skills viewer
- **Productivity** — Kanban-style task board (To Do → In Progress → Done) with priorities, projects, and due dates
- **Content Intel** — Content curation (articles, videos, papers, repos) with status tracking (queued → read → archived)
- **Scheduler** — View cron jobs, heartbeat checks, and lifecycle hooks
- **Connections** — Integration statuses
- **Settings** — Config overview and quick todos
Our approach: After each CLI response, optionally append the exchange (prompt + response) to `config/conversations/{channelId}/{date}.md`. This creates a human-readable conversation log separate from Claude's internal session storage. All backed by local JSON file persistence in `config/` via `local-store.ts` — zero external dependencies.
### 4. Retry with Backoff ### Multi-Backend Support ✅
Abstracted backend interface (`BackendAdapter`) with pluggable CLI adapters. Supports five backends: **Claude Code**, **Codex**, **Gemini CLI**, **OpenCode**, and **Pi**. Switchable via `AGENT_BACKEND` env var. Each adapter normalizes CLI-specific JSON output into a common `BackendEventResult` format. Full property and unit test coverage for all backends.
When the Claude CLI fails, retry with exponential backoff (5s, 10s, 20s, 40s, 80s) up to 5 times. Nanoclaw does this for container failures. ---
How nanoclaw does it: GroupQueue tracks retryCount per group, uses `BASE_RETRY_MS * 2^retryCount` delay, max 5 retries, resets on success. ## Remaining Features
Our approach: Wrap the `executeClaude` call in a retry loop. On transient errors (timeout, CLI crash), wait and retry. On permanent errors (auth failure, invalid session), fail immediately. ### Agent-Managed Tasks via File-Based IPC (Medium Effort)
## Medium Effort (day or two) The agent can create, pause, resume, and cancel scheduled tasks dynamically during a conversation.
### 5. Agent-Managed Tasks via File-Based IPC Our approach: The agent writes task definitions to `config/tasks.json` using the Write tool. The gateway polls this file periodically and schedules/unschedules tasks accordingly. Task format:
The big one from nanoclaw. The agent can create, pause, resume, and cancel scheduled tasks dynamically during a conversation.
How nanoclaw does it: Custom MCP server with tools like `schedule_task`, `list_tasks`, `pause_task`, `resume_task`, `cancel_task`. Tasks stored in SQLite with schedule_type (cron/interval/once), context_mode (group/isolated), and full lifecycle tracking.
Our approach (simpler): The agent writes task definitions to `config/tasks.json` using the Write tool. The gateway polls this file periodically and schedules/unschedules tasks accordingly. Task format:
```json ```json
[ [
@@ -57,115 +70,42 @@ Our approach (simpler): The agent writes task definitions to `config/tasks.json`
] ]
``` ```
The agent can add/remove/modify entries. The gateway watches the file and updates schedulers. ### Multi-Turn Message Batching (Medium Effort)
### 6. Multi-Turn Message Batching
Instead of sending one message at a time to Claude, batch multiple messages that arrive while the agent is processing. Instead of sending one message at a time to Claude, batch multiple messages that arrive while the agent is processing.
How nanoclaw does it: Collects all messages since the last agent run timestamp, formats them as XML (`<messages><message sender="..." time="...">content</message></messages>`), sends the batch as a single prompt. Our approach: When a message arrives while the agent is already processing for that channel, queue it. When the current processing finishes, batch all queued messages into a single prompt with sender/timestamp metadata.
Our approach: When a message arrives while the agent is already processing for that channel, queue it. When the current processing finishes, batch all queued messages into a single prompt with sender/timestamp metadata. This prevents the agent from missing rapid-fire messages. ### Per-Channel Isolation (Medium Effort)
### 7. Idle Timeout for Sessions
Auto-clear sessions after a configurable period of inactivity per channel.
How nanoclaw does it: 30-minute idle timeout per container. After last output, a timer starts. If no new messages arrive, the container is closed and the session ends.
Our approach: Track `lastActivityTimestamp` per channel in the session manager. Run a periodic check (every 5 minutes) that removes sessions older than the timeout (default 30 min). This prevents stale sessions from accumulating and keeps memory.md relevant.
### 8. Per-Channel Isolation
Different channels could have different persona configs. A "work" channel gets a professional persona, a "fun" channel gets a casual one. Different channels could have different persona configs. A "work" channel gets a professional persona, a "fun" channel gets a casual one.
How nanoclaw does it: Per-group folders with separate CLAUDE.md, sessions, memory, and container mounts. Each group is fully isolated. Our approach: Support a `config/channels/{channelId}/` override directory. If it exists, load persona files from there instead of the root config. Falls back to root config for missing files.
Our approach: Support a `config/channels/{channelId}/` override directory. If it exists, load persona files from there instead of the root config. Falls back to root config for missing files. This lets you customize identity.md or soul.md per channel. ### SQLite Storage (Bigger Effort)
## Bigger Features (multi-day)
### 9. IPC-Based Proactive Messaging
Let the agent send messages to Discord channels proactively — not just as a response to a user message.
How nanoclaw does it: Filesystem-based IPC. The agent writes a JSON file to `/workspace/ipc/messages/` with `{type: "message", chatJid: "...", text: "..."}`. The host polls the directory every second and sends the message.
Our approach: Create a `config/ipc/outbound/` directory. The agent writes JSON files there using the Write tool. The gateway polls every 2 seconds, reads new files, sends messages to the specified Discord channel, and deletes the processed files. This enables:
- Agent sending follow-up messages after thinking
- Agent notifying you about something it found during a heartbeat
- Agent sending messages to channels it's not currently chatting in
File format:
```json
{
"type": "message",
"channelId": "1475008084022788312",
"text": "Hey, I found something interesting in your email!"
}
```
### 10. Skills Engine (Simplified)
A system for adding capabilities via markdown skill files that get loaded into the system prompt.
How nanoclaw does it: Full manifest-based system with YAML manifests, dependency resolution, conflict detection, file operations (rename, delete, move), backup/restore, rebase capability, and resolution caching.
Our approach (much simpler): A `config/skills/` directory where each subdirectory contains a `SKILL.md` file. Skills are loaded into the system prompt as additional sections. The agent can reference skills by name. No dependency management — just markdown files that add context.
```
config/skills/
├── web-research/
│ └── SKILL.md → "When asked to research, use WebSearch and WebFetch..."
├── code-review/
│ └── SKILL.md → "When reviewing code, focus on security, performance..."
└── email-helper/
└── SKILL.md → "When checking email, use the Gmail API via..."
```
### 11. SQLite Storage
Replace JSON files with SQLite for messages, sessions, tasks, and state. Better for concurrent access, querying, and doesn't corrupt on partial writes. Replace JSON files with SQLite for messages, sessions, tasks, and state. Better for concurrent access, querying, and doesn't corrupt on partial writes.
How nanoclaw does it: Single SQLite database at `store/messages.db` with tables for chats, messages, scheduled_tasks, task_run_logs, router_state, sessions, registered_groups. Uses better-sqlite3 (synchronous API).
Our approach: Install better-sqlite3, create a single `config/aetheel.db` with tables for sessions, messages, tasks, and state. Migrate existing JSON files on first run. Our approach: Install better-sqlite3, create a single `config/aetheel.db` with tables for sessions, messages, tasks, and state. Migrate existing JSON files on first run.
### 12. Secrets Management ### Secrets Management (Bigger Effort)
Prevent API keys and sensitive data from leaking through agent tool use. Prevent API keys and sensitive data from leaking through agent tool use.
How nanoclaw does it: Secrets passed via stdin (never written to disk), stripped from Bash subprocess environments via a PreToolUse hook, never mounted as files in containers. Our approach: Add a sanitization step: before passing the prompt to Claude, strip any env var values that look like secrets from the system prompt. Also consider using `--disallowedTools Bash` if the agent doesn't need shell access.
### MCP Server Integration (Bigger Effort)
Expose Aetheel's capabilities as MCP tools so the agent can interact with the dashboard data, manage tasks, and store knowledge programmatically during conversations.
Our approach: Since we use `--dangerously-skip-permissions`, the agent can run Bash commands that might echo environment variables. Add a sanitization step: before passing the prompt to Claude, strip any env var values that look like secrets from the system prompt. Also consider using `--disallowedTools Bash` if the agent doesn't need shell access.
## Priority Recommendation ## Priority Recommendation
1. Structured logging (immediate debugging value) 1. Agent-managed dynamic tasks (agent autonomy)
2. Message history storage (conversation context) 2. Multi-turn message batching (UX improvement)
3. Retry with backoff (reliability) 3. Per-channel isolation (flexibility)
4. IPC-based proactive messaging (agent autonomy) 4. MCP Server integration (agent ↔ dashboard)
5. Agent-managed tasks (dynamic scheduling) 5. SQLite storage (reliability at scale)
6. Conversation archiving (audit trail) 6. Everything else as needed
7. Everything else as needed
---
## Completed: Config Simplification
Merged `soul.md`, `identity.md`, `user.md`, and `tools.md` into a single `CLAUDE.md` file. The config directory is now:
```
config/
├── CLAUDE.md ← Persona: identity, personality, user context, tools (all in one)
├── agents.md ← Cron jobs + Hooks (parsed by gateway at startup)
├── heartbeat.md ← Heartbeat checks (parsed by gateway at startup)
├── memory.md ← Long-term memory (agent-writable, auto-created)
└── sessions.json ← Channel → session ID map (auto-generated)
```
Why this split:
- `CLAUDE.md` is pure prompt context — the agent reads it but the gateway doesn't parse it
- `agents.md` and `heartbeat.md` are parsed programmatically by the gateway to set up cron timers and heartbeat intervals
- `memory.md` is the only file the agent writes to — keeping it separate prevents the agent from accidentally overwriting persona config
- Fewer files to manage, one place to edit your persona

View File

@@ -5,7 +5,7 @@ How a Discord message becomes an AI response, step by step.
## The Big Picture ## The Big Picture
``` ```
Discord User Aetheel Gateway Claude Code CLI Discord User Aetheel Gateway Backend CLI
│ │ │ │ │ │
│ @Aetheel what's 2+2? │ │ │ @Aetheel what's 2+2? │ │
├──────────────────────────────► │ │ ├──────────────────────────────► │ │
@@ -195,21 +195,23 @@ Sections with null or empty content are omitted entirely.
The assembled system prompt is written to a temporary file because it can be thousands of characters — too large for a CLI argument. The assembled system prompt is written to a temporary file because it can be thousands of characters — too large for a CLI argument.
**File:** `src/agent-runtime.ts``executeClaude()` **File:** `src/agent-runtime.ts``processMessage()`
``` ```
/tmp/aetheel-prompt-1d6c77f1-4a4e-49f8-ae9b-cff6fb47b971.txt /tmp/aetheel-prompt-1d6c77f1-4a4e-49f8-ae9b-cff6fb47b971.txt
``` ```
This file is deleted after the CLI process completes. This file is deleted after the CLI process completes (used by the Claude backend; other backends may prepend the system prompt directly to the user prompt).
### Step 8: Spawn Claude CLI ### Step 8: Spawn Backend CLI
The gateway spawns the Claude Code CLI as a child process. The gateway delegates to the configured **Backend Adapter**, which spawns the corresponding CLI as a child process.
**File:** `src/agent-runtime.ts``runClaude()` **File:** `src/backends/{claude,codex,gemini,opencode,pi}-backend.ts``spawnCli()`
The actual command: The backend is selected via the `AGENT_BACKEND` environment variable (default: `claude`). Each backend adapter translates the common interface into the CLI-specific flags.
#### Example: Claude Code CLI (default)
```bash ```bash
claude \ claude \
@@ -227,15 +229,55 @@ claude \
--max-turns 25 --max-turns 25
``` ```
Key flags: #### Example: Pi CLI
```bash
pi \
-p "what's the weather like?" \
--mode json \
--append-system-prompt "<system prompt text>" \
--no-session \
--no-extensions --no-skills --no-themes
```
#### Example: Codex CLI
```bash
codex exec \
"<system prompt + user prompt>" \
--json \
--dangerously-bypass-approvals-and-sandbox \
--cd /path/to/config
```
#### Example: OpenCode CLI
```bash
opencode run \
"<system prompt + user prompt>" \
--format json
```
#### Example: Gemini CLI
```bash
gemini \
"<system prompt + user prompt>" \
--output-format json \
--approval-mode yolo
```
Key flags (Claude):
- `-p` — Print mode (non-interactive, exits after response) - `-p` — Print mode (non-interactive, exits after response)
- `--output-format json` — Returns JSON array of message objects - `--output-format json` — Returns JSON array of message objects
- `--dangerously-skip-permissions` — No interactive permission prompts - `--dangerously-skip-permissions` — No interactive permission prompts
- `--append-system-prompt-file` — Appends our persona/memory to Claude's default prompt - `--append-system-prompt-file` — Appends our persona/memory to Claude's default prompt
- `--allowedTools` — Which tools Claude can use (one flag per tool) - `--allowedTools` — Which tools Claude can use (one flag per tool; Claude-only feature)
- `--max-turns` — Prevents runaway agent loops - `--max-turns` — Prevents runaway agent loops
- `--resume SESSION_ID` — Added when resuming an existing conversation - `--resume SESSION_ID` — Added when resuming an existing conversation
Other backends use equivalent flags for their CLIs (e.g., Pi uses `--mode json` and `--append-system-prompt`, Codex uses `--json` and `--dangerously-bypass-approvals-and-sandbox`).
The process runs with `cwd` set to the `config/` directory, so Claude can read/write files there (like `memory.md`). The process runs with `cwd` set to the `config/` directory, so Claude can read/write files there (like `memory.md`).
`stdin` is set to `"ignore"` to prevent the CLI from waiting for interactive input. `stdin` is set to `"ignore"` to prevent the CLI from waiting for interactive input.
@@ -259,9 +301,9 @@ When a session ID exists, `--resume 37336c32-73cb-4cf5-9771-1c8f694398ff` is add
The CLI returns a JSON array on stdout. The gateway parses it as chunks arrive. The CLI returns a JSON array on stdout. The gateway parses it as chunks arrive.
**File:** `src/agent-runtime.ts``runClaude()` stdout handler **File:** `src/backends/{backend}-backend.ts``spawnCli()` stdout handler
Example CLI output: Example CLI output (Claude):
```json ```json
[ [
@@ -380,11 +422,11 @@ Instruction: Save important context to memory.md before shutting down.
--- ---
## What Gets Sent to Claude ## What Gets Sent to the Backend
For every event, Claude receives: For every event, the backend CLI receives:
1. **Default Claude Code system prompt** (built-in, from the CLI) 1. **Default system prompt** (built-in from the CLI — varies by backend)
2. **Appended system prompt** (from our assembled markdown files): 2. **Appended system prompt** (from our assembled markdown files):
- Identity (who the agent is) - Identity (who the agent is)
- Personality (how it behaves) - Personality (how it behaves)
@@ -394,10 +436,12 @@ For every event, Claude receives:
- Tool configuration (API notes) - Tool configuration (API notes)
- Preamble about writing to memory.md - Preamble about writing to memory.md
3. **The prompt text** (user message, heartbeat instruction, or cron instruction) 3. **The prompt text** (user message, heartbeat instruction, or cron instruction)
4. **Session history** (if resuming via `--resume`) 4. **Session history** (if resuming via backend-specific session flags)
5. **Allowed tools** (Read, Write, Edit, Glob, Grep, WebSearch, WebFetch) 5. **Allowed tools** (Claude only: Read, Write, Edit, Glob, Grep, WebSearch, WebFetch)
Claude runs in the `config/` directory, so it can read and write files there — including updating `memory.md` with new facts. > **Note:** How the system prompt is delivered varies by backend. Claude Code uses `--append-system-prompt-file`, Pi uses `--append-system-prompt`, and other backends prepend it to the user prompt.
The backend CLI runs in the `config/` directory, so the agent can read and write files there — including updating `memory.md` with new facts.
--- ---
@@ -408,12 +452,26 @@ src/
├── index.ts ← Entry point: creates GatewayCore, registers shutdown handler ├── index.ts ← Entry point: creates GatewayCore, registers shutdown handler
├── gateway-core.ts ← Orchestrator: wires everything, manages lifecycle ├── gateway-core.ts ← Orchestrator: wires everything, manages lifecycle
├── config.ts ← Reads env vars (DISCORD_BOT_TOKEN, etc.) ├── config.ts ← Reads env vars (DISCORD_BOT_TOKEN, etc.)
├── logger.ts ← Pino structured logger with pretty-printing
├── discord-bot.ts ← Discord.js wrapper: messages, slash commands, typing ├── discord-bot.ts ← Discord.js wrapper: messages, slash commands, typing
├── event-queue.ts ← FIFO queue: all events (message, heartbeat, cron, hook) ├── event-queue.ts ← FIFO queue: all events (message, heartbeat, cron, hook)
├── agent-runtime.ts ← Core engine: reads configs, spawns CLI, parses output ├── agent-runtime.ts ← Core engine: reads configs, delegates to backend adapter
├── backends/ ← Pluggable CLI backend adapters
│ ├── types.ts ← BackendAdapter interface & BackendName union type
│ ├── registry.ts ← resolveBackendName() & createBackend() factory
│ ├── index.ts ← Barrel exports
│ ├── claude-backend.ts ← Claude Code CLI adapter
│ ├── codex-backend.ts ← OpenAI Codex CLI adapter
│ ├── gemini-backend.ts ← Google Gemini CLI adapter
│ ├── opencode-backend.ts ← OpenCode CLI adapter
│ └── pi-backend.ts ← Pi Coding Agent CLI adapter
├── markdown-config-loader.ts ← Reads config/*.md files fresh each event ├── markdown-config-loader.ts ← Reads config/*.md files fresh each event
├── system-prompt-assembler.ts ← Concatenates markdown into system prompt with headers ├── system-prompt-assembler.ts ← Concatenates markdown into system prompt with headers
├── skills-loader.ts ← Loads skills from config/skills/*/SKILL.md
├── session-manager.ts ← Channel → session ID mapping (persisted to JSON) ├── session-manager.ts ← Channel → session ID mapping (persisted to JSON)
├── message-history.ts ← Per-channel message storage
├── conversation-archiver.ts ← Markdown conversation logs
├── ipc-watcher.ts ← Polls ipc/outbound/ for proactive messages
├── response-formatter.ts ← Splits long text for Discord's 2000 char limit ├── response-formatter.ts ← Splits long text for Discord's 2000 char limit
├── error-formatter.ts ← Sanitizes errors (strips keys, paths, stacks) ├── error-formatter.ts ← Sanitizes errors (strips keys, paths, stacks)
├── heartbeat-scheduler.ts ← setInterval timers from heartbeat.md ├── heartbeat-scheduler.ts ← setInterval timers from heartbeat.md
@@ -421,16 +479,64 @@ src/
├── hook-manager.ts ← Lifecycle hooks from agents.md ├── hook-manager.ts ← Lifecycle hooks from agents.md
├── bootstrap-manager.ts ← First-run: validates/creates config files ├── bootstrap-manager.ts ← First-run: validates/creates config files
├── channel-queue.ts ← Per-channel sequential processing ├── channel-queue.ts ← Per-channel sequential processing
── shutdown-handler.ts ← SIGTERM/SIGINT → graceful shutdown ── shutdown-handler.ts ← SIGTERM/SIGINT → graceful shutdown
├── dashboard-server.ts ← Embedded HTTP server for Mission Control dashboard
├── dashboard-dev.ts ← Standalone dashboard dev server (mock data)
├── activity-log.ts ← In-memory + file-persisted activity event log
└── local-store.ts ← JSON file persistence layer (brain, tasks, content)
dashboard/ ← Mission Control frontend (served by dashboard-server)
├── index.html ← SPA shell with sidebar navigation
├── styles.css ← Dark-theme design system
└── app.js ← Client-side routing, API calls, rendering
config/ config/
├── identity.md ← Agent name, role, specialization ├── CLAUDE.md ← Persona: identity, personality, user context, tools
├── soul.md ← Personality, tone, values ├── agents.md ← Rules, cron jobs, hooks (parsed at startup)
├── agents.md ← Rules, cron jobs, hooks ├── heartbeat.md ← Heartbeat checks (parsed at startup)
├── user.md ← Human's info and preferences ├── memory.md ← Long-term memory (agent-writable, auto-created)
├── memory.md Long-term memory (agent-writable) ├── sessions.jsonChannel → session ID map (auto-generated)
├── tools.md Tool configs and notes ├── brain.jsonSecond Brain facts (auto, managed by dashboard)
├── heartbeat.md ← Proactive check definitions ├── tasks.json ← Productivity tasks (auto, managed by dashboard)
├── boot.md ← Bootstrap parameters (optional) ├── content-items.json ← Content Intel items (auto, managed by dashboard)
── sessions.json ← Channel → session ID map (auto-generated) ── activity-log.json ← Persisted activity log (auto)
├── bot-config.json ← Dashboard bot config (auto)
├── messages/ ← Message history (auto)
├── conversations/ ← Conversation archives (auto)
├── ipc/outbound/ ← Proactive message queue (auto)
└── skills/ ← Skill definitions
``` ```
---
## Dashboard Flow
The Mission Control dashboard runs as an embedded HTTP server inside the gateway process.
### Real-Time Activity (SSE)
```
Agent Runtime processes event
→ ActivityLog.record() called
→ Entry added to in-memory buffer (capped at 200)
→ Entry persisted to config/activity-log.json (capped at 2000)
→ SSE broadcast to all connected dashboard clients
→ Dashboard UI updates live activity feed
```
### Data Persistence Flow
```
Dashboard UI (browser)
→ POST /api/brain { content, type, category, tags }
→ DashboardServer routes to LocalStores
→ JsonStore.append() adds entry with generated ID
→ Debounced write to config/brain.json (300ms)
→ Response with updated data
```
All persistent stores use the same pattern:
- **Read:** Load from JSON file on startup, serve from memory
- **Write:** Append/update in memory, debounced flush to disk
- **Shutdown:** `flushAll()` called to ensure all pending writes complete

View File

@@ -8,6 +8,7 @@
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/index.js",
"dev": "tsx src/index.ts", "dev": "tsx src/index.ts",
"dashboard": "tsx src/dashboard-dev.ts",
"test": "vitest --run", "test": "vitest --run",
"test:watch": "vitest" "test:watch": "vitest"
}, },

140
src/activity-log.ts Normal file
View File

@@ -0,0 +1,140 @@
import type { Event, EventPayload, MessagePayload, HeartbeatPayload, CronPayload, HookPayload } from "./event-queue.js";
import type { LocalStores, ActivityEntry as StoredEntry } from "./local-store.js";
export interface ActivityEntry {
id: number;
type: string;
source: string;
detail: string;
timestamp: string;
channelId?: string;
}
type Subscriber = (entry: ActivityEntry) => void;
const MAX_ENTRIES = 500;
const MAX_PERSISTED = 2000;
export class ActivityLog {
private entries: ActivityEntry[] = [];
private subscribers: Set<Subscriber> = new Set();
private startTime = Date.now();
private stores: LocalStores | null = null;
private counters = {
messages: 0,
heartbeats: 0,
cron: 0,
hooks: 0,
errors: 0,
toolCalls: 0,
};
/** Wire up file-based persistence. Call once after LocalStores is created. */
async configureStore(stores: LocalStores): Promise<void> {
this.stores = stores;
// Hydrate in-memory log from disk
const persisted = await stores.activityLog.getAll();
if (persisted.length > 0) {
this.entries = persisted.slice(-MAX_ENTRIES).map((e: StoredEntry) => ({
...e,
channelId: undefined,
}));
}
}
getUptime(): number {
return Date.now() - this.startTime;
}
getCounters() {
return { ...this.counters };
}
record(event: Event, responsePreview?: string, isError?: boolean): void {
const detail = this.extractDetail(event.type, event.payload, responsePreview);
const entry: ActivityEntry = {
id: event.id,
type: event.type,
source: event.source,
detail,
timestamp: event.timestamp.toISOString(),
channelId: this.extractChannelId(event.payload),
};
// Update counters
switch (event.type) {
case "message": this.counters.messages++; break;
case "heartbeat": this.counters.heartbeats++; break;
case "cron": this.counters.cron++; break;
case "hook": this.counters.hooks++; break;
}
if (isError) this.counters.errors++;
this.entries.push(entry);
if (this.entries.length > MAX_ENTRIES) {
this.entries = this.entries.slice(-MAX_ENTRIES);
}
// Persist to disk
if (this.stores) {
const stored: StoredEntry = {
id: entry.id,
type: entry.type,
source: entry.source,
detail: entry.detail,
timestamp: entry.timestamp,
error: isError,
};
this.stores.activityLog.append(stored, MAX_PERSISTED).catch(() => { });
}
// Notify SSE subscribers
for (const sub of this.subscribers) {
try { sub(entry); } catch { /* ignore */ }
}
}
recordToolCall(): void {
this.counters.toolCalls++;
}
getRecent(count: number): ActivityEntry[] {
return this.entries.slice(-count).reverse();
}
subscribe(fn: Subscriber): () => void {
this.subscribers.add(fn);
return () => { this.subscribers.delete(fn); };
}
private extractDetail(type: string, payload: EventPayload, responsePreview?: string): string {
switch (type) {
case "message": {
const p = payload as MessagePayload;
const text = p.prompt.text;
return text.length > 120 ? text.slice(0, 120) + "…" : text;
}
case "heartbeat": {
const p = payload as HeartbeatPayload;
return `[${p.checkName}] ${p.instruction}`;
}
case "cron": {
const p = payload as CronPayload;
return `[${p.jobName}] ${p.instruction}`;
}
case "hook": {
const p = payload as HookPayload;
return `${p.hookType}${p.instruction ? ": " + p.instruction : ""}`;
}
default:
return responsePreview ?? "";
}
}
private extractChannelId(payload: EventPayload): string | undefined {
if ("prompt" in payload && typeof (payload as MessagePayload).prompt?.channelId === "string") {
return (payload as MessagePayload).prompt.channelId;
}
return undefined;
}
}

View File

@@ -3,4 +3,5 @@ export { ClaudeCodeBackend } from "./claude-backend.js";
export { CodexBackend } from "./codex-backend.js"; export { CodexBackend } from "./codex-backend.js";
export { GeminiBackend } from "./gemini-backend.js"; export { GeminiBackend } from "./gemini-backend.js";
export { OpenCodeBackend } from "./opencode-backend.js"; export { OpenCodeBackend } from "./opencode-backend.js";
export { PiBackend } from "./pi-backend.js";
export { resolveBackendName, createBackend } from "./registry.js"; export { resolveBackendName, createBackend } from "./registry.js";

323
src/backends/pi-backend.ts Normal file
View File

@@ -0,0 +1,323 @@
import { spawn } from "node:child_process";
import { access, constants } from "node:fs/promises";
import { logger } from "../logger.js";
import type {
BackendAdapter,
BackendAdapterConfig,
BackendEventResult,
StreamCallback,
} from "./types.js";
/**
* Backend adapter for the Pi Coding Agent CLI.
*
* Pi is a minimal terminal coding harness that supports non-interactive
* print mode (`-p`) with JSON output (`--mode json`). Sessions are managed
* via `--session <id>` and `--continue`.
*
* See: https://github.com/badlogic/pi-mono / npm @mariozechner/pi-coding-agent
*/
export class PiBackend implements BackendAdapter {
constructor(private readonly config: BackendAdapterConfig) { }
name(): string {
return "pi";
}
/**
* Build the CLI argument list for a Pi invocation.
* Exposed as public so property tests can verify flag correctness
* without spawning a process.
*/
public buildArgs(
prompt: string,
systemPrompt?: string,
sessionId?: string,
): string[] {
const args: string[] = [];
// Non-interactive print mode — Pi sends the response and exits
args.push("-p");
// JSON output for structured parsing
args.push("--mode", "json");
// Append system prompt via the dedicated flag
if (systemPrompt) {
args.push("--append-system-prompt", systemPrompt);
}
// Session resumption
if (sessionId) {
args.push("--session", sessionId, "--continue");
}
// Model override
if (this.config.model) {
args.push("--model", this.config.model);
}
// No permission popups / no session saving for headless usage
args.push("--no-session");
// Disable extension/skill discovery for deterministic headless runs
args.push("--no-extensions");
args.push("--no-skills");
args.push("--no-themes");
// User prompt comes last as a positional argument
args.push(prompt);
return args;
}
async execute(
prompt: string,
systemPrompt: string,
sessionId?: string,
onStream?: StreamCallback,
): Promise<BackendEventResult> {
return this.spawnCli(prompt, systemPrompt, sessionId, onStream);
}
async validate(): Promise<boolean> {
try {
await access(this.config.cliPath, constants.X_OK);
return true;
} catch {
logger.error(
{ backend: this.name(), cliPath: this.config.cliPath },
"CLI binary is not executable",
);
return false;
}
}
/**
* Parse Pi CLI JSON output.
*
* In `--mode json`, Pi emits newline-delimited JSON events. Each event has
* a `type` field. Key event types:
* - `{ type: "assistant", content: "..." }` — model response text
* - `{ type: "result", result: "..." }` — final result text
* - `{ type: "system", session_id: "..." }` — session metadata
* - `{ type: "tool_call", ... }` — tool invocations
*
* We extract the last assistant/result text and any session ID.
*/
public parseOutput(stdout: string): BackendEventResult {
let sessionId: string | undefined;
let responseText: string | undefined;
const lines = stdout.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const obj = JSON.parse(trimmed);
// Extract session ID from any event that carries one
if (obj.session_id) {
sessionId = obj.session_id;
}
// Extract response text — last one wins
if (obj.type === "result" && obj.result) {
responseText = obj.result;
} else if (obj.type === "assistant" && obj.content) {
responseText = typeof obj.content === "string"
? obj.content
: obj.content.text ?? JSON.stringify(obj.content);
} else if (obj.type === "message" && obj.role === "assistant") {
if (typeof obj.content === "string") {
responseText = obj.content;
} else if (Array.isArray(obj.content)) {
const textParts = obj.content
.filter((c: { type: string }) => c.type === "text")
.map((c: { text: string }) => c.text);
if (textParts.length > 0) {
responseText = textParts.join("");
}
}
} else if (obj.message && obj.message.role === "assistant") {
if (typeof obj.message.content === "string") {
responseText = obj.message.content;
} else if (Array.isArray(obj.message.content)) {
const textParts = obj.message.content
.filter((c: { type: string }) => c.type === "text")
.map((c: { text: string }) => c.text);
if (textParts.length > 0) {
responseText = textParts.join("");
}
}
} else if (obj.response) {
responseText = typeof obj.response === "string"
? obj.response
: obj.response.text ?? JSON.stringify(obj.response);
} else if (obj.text && !obj.type) {
responseText = obj.text;
}
} catch {
// Skip non-JSON lines
}
}
return { responseText, sessionId, isError: false };
}
private spawnCli(
prompt: string,
systemPrompt: string,
sessionId?: string,
onStream?: StreamCallback,
): Promise<BackendEventResult> {
return new Promise((resolve) => {
const args = this.buildArgs(prompt, systemPrompt, sessionId);
logger.debug(
{ cliPath: this.config.cliPath, cwd: this.config.workingDir, argCount: args.length },
"Spawning Pi CLI",
);
const child = spawn(this.config.cliPath, args, {
stdio: ["ignore", "pipe", "pipe"],
cwd: this.config.workingDir,
});
let stdout = "";
let stderr = "";
let streamBuffer = "";
let lastResponseText: string | undefined;
let parsedSessionId: string | undefined;
child.stdout.on("data", (data: Buffer) => {
const chunk = data.toString();
stdout += chunk;
streamBuffer += chunk;
// Parse newline-delimited JSON events as they arrive
let newlineIndex;
while ((newlineIndex = streamBuffer.indexOf("\n")) !== -1) {
const line = streamBuffer.slice(0, newlineIndex).trim();
streamBuffer = streamBuffer.slice(newlineIndex + 1);
if (!line) continue;
try {
const obj = JSON.parse(line);
if (obj.session_id) {
parsedSessionId = obj.session_id;
}
let text: string | undefined;
if (obj.type === "result" && obj.result) {
text = obj.result;
} else if (obj.type === "assistant" && obj.content) {
text = typeof obj.content === "string"
? obj.content
: obj.content.text ?? JSON.stringify(obj.content);
} else if (obj.type === "message" && obj.role === "assistant") {
if (typeof obj.content === "string") {
text = obj.content;
} else if (Array.isArray(obj.content)) {
const textParts = obj.content
.filter((c: { type: string }) => c.type === "text")
.map((c: { text: string }) => c.text);
if (textParts.length > 0) {
text = textParts.join("");
}
}
} else if (obj.message && obj.message.role === "assistant") {
if (typeof obj.message.content === "string") {
text = obj.message.content;
} else if (Array.isArray(obj.message.content)) {
const textParts = obj.message.content
.filter((c: { type: string }) => c.type === "text")
.map((c: { text: string }) => c.text);
if (textParts.length > 0) {
text = textParts.join("");
}
}
} else if (obj.response) {
text = typeof obj.response === "string"
? obj.response
: obj.response.text ?? JSON.stringify(obj.response);
} else if (obj.text && !obj.type) {
text = obj.text;
}
if (text) {
lastResponseText = text;
}
} catch {
// Not valid JSON yet
}
}
});
child.stderr.on("data", (data: Buffer) => {
stderr += data.toString();
});
const timer = setTimeout(() => {
logger.debug("Timeout reached, killing Pi CLI process");
child.kill("SIGTERM");
resolve({ isError: true, responseText: "Query timed out" });
}, this.config.queryTimeoutMs);
child.on("close", (code) => {
clearTimeout(timer);
logger.debug(
{ code, stdoutLength: stdout.length },
"Pi CLI exited",
);
if (code !== 0 && code !== null) {
resolve({
isError: true,
responseText: `Pi CLI error (exit ${code}): ${stderr.slice(0, 500) || "unknown error"}`,
});
return;
}
if (streamBuffer.trim()) {
try {
const parsed = this.parseOutput(streamBuffer);
if (parsed.responseText && !lastResponseText) lastResponseText = parsed.responseText;
if (parsed.sessionId && !parsedSessionId) parsedSessionId = parsed.sessionId;
} catch { }
}
// Final parse of full output as fallback
if (!lastResponseText) {
const parsed = this.parseOutput(stdout);
lastResponseText = parsed.responseText;
if (!parsedSessionId) {
parsedSessionId = parsed.sessionId;
}
}
logger.debug(
{ resultLength: lastResponseText?.length ?? 0, session: parsedSessionId ?? "none" },
"Parsed Pi response",
);
resolve({
responseText: lastResponseText,
sessionId: parsedSessionId,
isError: false,
});
});
child.on("error", (err) => {
clearTimeout(timer);
logger.error({ err }, "Failed to spawn Pi CLI");
resolve({
isError: true,
responseText: `Failed to spawn Pi CLI: ${err.message}`,
});
});
});
}
}

View File

@@ -3,8 +3,9 @@ import { ClaudeCodeBackend } from "./claude-backend.js";
import { CodexBackend } from "./codex-backend.js"; import { CodexBackend } from "./codex-backend.js";
import { GeminiBackend } from "./gemini-backend.js"; import { GeminiBackend } from "./gemini-backend.js";
import { OpenCodeBackend } from "./opencode-backend.js"; import { OpenCodeBackend } from "./opencode-backend.js";
import { PiBackend } from "./pi-backend.js";
const VALID_BACKEND_NAMES: readonly BackendName[] = ["claude", "codex", "gemini", "opencode"]; const VALID_BACKEND_NAMES: readonly BackendName[] = ["claude", "codex", "gemini", "opencode", "pi"];
/** /**
* Resolve a raw string (typically from the AGENT_BACKEND env var) to a valid BackendName. * Resolve a raw string (typically from the AGENT_BACKEND env var) to a valid BackendName.
@@ -37,5 +38,7 @@ export function createBackend(name: BackendName, config: BackendAdapterConfig):
return new GeminiBackend(config); return new GeminiBackend(config);
case "opencode": case "opencode":
return new OpenCodeBackend(config); return new OpenCodeBackend(config);
case "pi":
return new PiBackend(config);
} }
} }

View File

@@ -1,4 +1,4 @@
export type BackendName = "claude" | "codex" | "gemini" | "opencode"; export type BackendName = "claude" | "codex" | "gemini" | "opencode" | "pi";
export interface BackendAdapterConfig { export interface BackendAdapterConfig {
cliPath: string; cliPath: string;

104
src/dashboard-dev.ts Normal file
View File

@@ -0,0 +1,104 @@
/**
* Standalone dashboard dev server — runs Mission Control without Discord or the agent runtime.
* Usage: npx tsx src/dashboard-dev.ts
*/
import { DashboardServer } from "./dashboard-server.js";
import { ActivityLog } from "./activity-log.js";
import { SessionManager } from "./session-manager.js";
import { MarkdownConfigLoader } from "./markdown-config-loader.js";
import type { GatewayConfig } from "./config.js";
import { LocalStores } from "./local-store.js";
const mockConfig: GatewayConfig = {
discordBotToken: "dev-mode",
claudeCliPath: "claude",
allowedTools: ["Read", "Write", "Edit", "Glob", "Grep", "WebSearch", "WebFetch"],
permissionMode: "bypassPermissions",
queryTimeoutMs: 120000,
maxConcurrentQueries: 5,
configDir: "./config",
maxQueueDepth: 100,
idleSessionTimeoutMs: 1800000,
agentBackend: "claude",
backendCliPath: "claude",
backendModel: "sonnet-4",
backendMaxTurns: 25,
};
const activityLog = new ActivityLog();
const sessionManager = new SessionManager();
const markdownConfigLoader = new MarkdownConfigLoader();
const localStores = new LocalStores(mockConfig.configDir);
const server = new DashboardServer({
config: mockConfig,
activityLog,
sessionManager,
markdownConfigLoader,
heartbeatChecks: [
{ name: "check-email", instruction: "Check inbox for urgent emails", intervalSeconds: 1800 },
{ name: "check-news", instruction: "Scan for AI news updates", intervalSeconds: 3600 },
],
cronJobs: [
{ name: "morning-briefing", expression: "0 8 * * *", instruction: "Good morning! Summarize today's schedule." },
{ name: "weekly-report", expression: "0 18 * * 5", instruction: "Generate weekly progress report." },
],
hookConfig: {
startup: "Say hello, you just came online.",
shutdown: "Save important context to memory.md.",
},
discordStatus: () => ({ online: true, tag: "Aetheel#1234", guilds: 3 }),
localStores,
}, 3100);
// Inject some demo activity
const demoEvents = [
{ type: "hook" as const, payload: { hookType: "startup" as const, instruction: "Say hello, you just came online." }, source: "hook-manager" },
{ type: "message" as const, payload: { prompt: { text: "Hey Aetheel, what's the weather today?", channelId: "12345", userId: "user1", guildId: "guild1" } }, source: "discord" },
{ type: "heartbeat" as const, payload: { instruction: "Check inbox for urgent emails", checkName: "check-email" }, source: "heartbeat-scheduler" },
{ type: "message" as const, payload: { prompt: { text: "Can you summarize the latest AI research papers?", channelId: "67890", userId: "user2", guildId: "guild1" } }, source: "discord" },
{ type: "cron" as const, payload: { instruction: "Good morning! Summarize today's schedule.", jobName: "morning-briefing" }, source: "cron-scheduler" },
{ type: "message" as const, payload: { prompt: { text: "Write a Python script that scrapes HN front page", channelId: "12345", userId: "user1", guildId: "guild1" } }, source: "discord" },
];
let eventId = 1;
for (const ev of demoEvents) {
activityLog.record({
id: eventId++,
type: ev.type,
payload: ev.payload,
timestamp: new Date(Date.now() - (demoEvents.length - eventId) * 300000),
source: ev.source,
});
}
// Add demo sessions
sessionManager.setSessionId("12345", "sess_abc123def456");
sessionManager.setSessionId("67890", "sess_xyz789ghi012");
server.start().then(() => {
console.log("\n ✨ Mission Control running at http://localhost:3100\n");
console.log(" This is dev mode — showing mock data.\n");
console.log(" Press Ctrl+C to stop.\n");
// Simulate occasional events
let simId = eventId;
setInterval(() => {
const types = ["message", "heartbeat", "cron"] as const;
const type = types[Math.floor(Math.random() * types.length)];
const sources: Record<string, string> = { message: "discord", heartbeat: "heartbeat-scheduler", cron: "cron-scheduler" };
const payloads: Record<string, any> = {
message: { prompt: { text: "Simulated message #" + simId, channelId: "12345", userId: "user1", guildId: "guild1" } },
heartbeat: { instruction: "Periodic health check", checkName: "auto-check" },
cron: { instruction: "Scheduled task execution", jobName: "auto-task" },
};
activityLog.record({
id: simId++,
type,
payload: payloads[type],
timestamp: new Date(),
source: sources[type],
});
}, 15000);
});

507
src/dashboard-server.ts Normal file
View File

@@ -0,0 +1,507 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { readFile, readdir, writeFile } from "node:fs/promises";
import { join, extname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { logger } from "./logger.js";
import type { ActivityLog } from "./activity-log.js";
import type { GatewayConfig } from "./config.js";
import type { SessionManager } from "./session-manager.js";
import type { MarkdownConfigLoader } from "./markdown-config-loader.js";
import type { HeartbeatCheck } from "./heartbeat-scheduler.js";
import type { CronJob } from "./cron-scheduler.js";
import type { HookConfig } from "./hook-manager.js";
import { loadSkills } from "./skills-loader.js";
import { type LocalStores, type BrainFact, type ProductivityTask, type ContentItem, type BotConfigEntry, generateId } from "./local-store.js";
const MIME_TYPES: Record<string, string> = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".ico": "image/x-icon",
};
export interface DashboardDeps {
config: GatewayConfig;
activityLog: ActivityLog;
sessionManager: SessionManager;
markdownConfigLoader: MarkdownConfigLoader;
heartbeatChecks: HeartbeatCheck[];
cronJobs: CronJob[];
hookConfig: HookConfig;
discordStatus: () => { online: boolean; tag?: string; guilds?: number };
localStores: LocalStores;
}
export class DashboardServer {
private server: ReturnType<typeof createServer> | null = null;
private deps: DashboardDeps;
private port: number;
private staticDir: string;
constructor(deps: DashboardDeps, port = 3100) {
this.deps = deps;
this.port = port;
// Static files are in dashboard/ relative to project root
// In both dev (tsx) and prod (dist/), we resolve from the src/ dir up to project root
const thisDir = typeof __dirname !== "undefined"
? __dirname
: fileURLToPath(new URL(".", import.meta.url));
this.staticDir = resolve(thisDir, "..", "dashboard");
}
async start(): Promise<void> {
this.server = createServer((req, res) => {
this.handleRequest(req, res).catch((err) => {
logger.error({ err }, "Dashboard request error");
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Internal server error" }));
});
});
return new Promise((resolve) => {
this.server!.listen(this.port, () => {
logger.info({ port: this.port }, "Dashboard server started at http://localhost:" + this.port);
resolve();
});
});
}
stop(): void {
this.server?.close();
}
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
const url = new URL(req.url || "/", `http://localhost:${this.port}`);
const path = url.pathname;
// CORS headers
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
// API routes
if (path.startsWith("/api/")) {
return this.handleApi(req, res, path, url);
}
// SSE endpoint
if (path === "/events") {
return this.handleSSE(req, res);
}
// Static files
return this.serveStatic(res, path);
}
// ── API Routes ──────────────────────────────────────────
private async handleApi(req: IncomingMessage, res: ServerResponse, path: string, url: URL): Promise<void> {
const json = (data: unknown) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(data));
};
switch (path) {
case "/api/stats":
return json(this.getStats());
case "/api/activity": {
const count = parseInt(url.searchParams.get("count") || "50", 10);
return json(this.deps.activityLog.getRecent(count));
}
case "/api/sessions":
return json(this.getSessions());
case "/api/config":
return json(this.getConfig());
case "/api/connections":
return json(this.getConnections());
case "/api/memory": {
if (req.method === "POST") {
const body = await this.readBody(req);
const { content } = JSON.parse(body);
await writeFile(join(this.deps.config.configDir, "memory.md"), content, "utf-8");
return json({ ok: true });
}
const content = await this.deps.markdownConfigLoader.loadFile(this.deps.config.configDir, "memory.md");
return json({ content: content ?? "" });
}
case "/api/persona": {
if (req.method === "POST") {
const body = await this.readBody(req);
const { content } = JSON.parse(body);
await writeFile(join(this.deps.config.configDir, "CLAUDE.md"), content, "utf-8");
return json({ ok: true });
}
const content = await this.deps.markdownConfigLoader.loadFile(this.deps.config.configDir, "CLAUDE.md");
return json({ content: content ?? "" });
}
case "/api/skills": {
const skills = await loadSkills(this.deps.config.configDir);
return json(skills);
}
case "/api/heartbeats":
return json(this.deps.heartbeatChecks);
case "/api/cron":
return json(this.deps.cronJobs);
case "/api/hooks":
return json(this.deps.hookConfig);
// ── Brain (Second Brain) ──
case "/api/brain": {
if (req.method === "POST") {
const body = JSON.parse(await this.readBody(req));
const fact: BrainFact = {
id: generateId(),
content: body.content ?? "",
type: body.type ?? "note",
category: body.category ?? "general",
tags: body.tags ?? [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await this.deps.localStores.brain.add(fact);
return json(fact);
}
if (req.method === "DELETE") {
const id = url.searchParams.get("id");
if (id) await this.deps.localStores.brain.remove(id);
return json({ ok: true });
}
const q = url.searchParams.get("q");
if (q) return json(await this.deps.localStores.brain.search(q));
return json(await this.deps.localStores.brain.getAll());
}
case "/api/brain/update": {
if (req.method === "POST") {
const body = JSON.parse(await this.readBody(req));
const updated = await this.deps.localStores.brain.update(body.id, {
...body,
updatedAt: new Date().toISOString(),
});
return json(updated ?? { error: "not found" });
}
res.writeHead(405); res.end(); return;
}
// ── Productivity (Tasks) ──
case "/api/tasks": {
if (req.method === "POST") {
const body = JSON.parse(await this.readBody(req));
const task: ProductivityTask = {
id: generateId(),
title: body.title ?? "",
description: body.description ?? "",
status: body.status ?? "todo",
priority: body.priority ?? "medium",
project: body.project ?? "default",
dueDate: body.dueDate ?? null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
completedAt: null,
};
await this.deps.localStores.tasks.add(task);
return json(task);
}
if (req.method === "DELETE") {
const id = url.searchParams.get("id");
if (id) await this.deps.localStores.tasks.remove(id);
return json({ ok: true });
}
return json(await this.deps.localStores.tasks.getAll());
}
case "/api/tasks/update": {
if (req.method === "POST") {
const body = JSON.parse(await this.readBody(req));
const patch: Partial<ProductivityTask> = {
...body,
updatedAt: new Date().toISOString(),
};
if (body.status === "done" && !body.completedAt) {
patch.completedAt = new Date().toISOString();
}
const updated = await this.deps.localStores.tasks.update(body.id, patch);
return json(updated ?? { error: "not found" });
}
res.writeHead(405); res.end(); return;
}
// ── Content Intel ──
case "/api/content": {
if (req.method === "POST") {
const body = JSON.parse(await this.readBody(req));
const item: ContentItem = {
id: generateId(),
title: body.title ?? "",
url: body.url ?? "",
source: body.source ?? "manual",
type: body.type ?? "article",
summary: body.summary ?? "",
tags: body.tags ?? [],
status: body.status ?? "queued",
savedAt: new Date().toISOString(),
};
await this.deps.localStores.content.add(item);
return json(item);
}
if (req.method === "DELETE") {
const id = url.searchParams.get("id");
if (id) await this.deps.localStores.content.remove(id);
return json({ ok: true });
}
const q = url.searchParams.get("q");
if (q) return json(await this.deps.localStores.content.search(q));
return json(await this.deps.localStores.content.getAll());
}
case "/api/content/update": {
if (req.method === "POST") {
const body = JSON.parse(await this.readBody(req));
const updated = await this.deps.localStores.content.update(body.id, body);
return json(updated ?? { error: "not found" });
}
res.writeHead(405); res.end(); return;
}
// ── Bot Config ──
case "/api/bot-config": {
if (req.method === "POST") {
const body = JSON.parse(await this.readBody(req));
const existing = (await this.deps.localStores.botConfig.getAll()).find(
(e: BotConfigEntry) => e.key === body.key,
);
if (existing) {
await this.deps.localStores.botConfig.update(existing.key, {
value: body.value,
updatedAt: new Date().toISOString(),
});
} else {
const entry: BotConfigEntry = {
key: body.key,
value: body.value,
category: body.category ?? "general",
updatedAt: new Date().toISOString(),
};
await this.deps.localStores.botConfig.add(entry);
}
return json({ ok: true });
}
return json(await this.deps.localStores.botConfig.getAll());
}
case "/api/messages": {
const channelId = url.searchParams.get("channelId");
if (!channelId) {
// List all channel files
const dir = join(this.deps.config.configDir, "messages");
try {
const files = await readdir(dir);
const channels = files.filter(f => f.endsWith(".json")).map(f => f.replace(".json", ""));
return json(channels);
} catch {
return json([]);
}
}
try {
const data = await readFile(join(this.deps.config.configDir, "messages", `${channelId}.json`), "utf-8");
return json(JSON.parse(data));
} catch {
return json([]);
}
}
default:
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Not found" }));
}
}
// ── SSE ──────────────────────────────────────────────
private handleSSE(_req: IncomingMessage, res: ServerResponse): void {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
});
// Send initial heartbeat
res.write("data: {\"type\":\"connected\"}\n\n");
const unsubscribe = this.deps.activityLog.subscribe((entry) => {
res.write(`data: ${JSON.stringify(entry)}\n\n`);
});
// Keep-alive ping every 30s
const keepAlive = setInterval(() => {
res.write(": ping\n\n");
}, 30_000);
_req.on("close", () => {
unsubscribe();
clearInterval(keepAlive);
});
}
// ── Static file serving ──────────────────────────────────
private async serveStatic(res: ServerResponse, urlPath: string): Promise<void> {
// SPA: serve index.html for all non-file paths
let filePath = urlPath === "/" ? "/index.html" : urlPath;
// If no extension, serve index.html (SPA routing)
if (!extname(filePath)) {
filePath = "/index.html";
}
const absolutePath = join(this.staticDir, filePath);
// Security: prevent path traversal
if (!absolutePath.startsWith(this.staticDir)) {
res.writeHead(403);
res.end("Forbidden");
return;
}
try {
const data = await readFile(absolutePath);
const ext = extname(filePath);
const mime = MIME_TYPES[ext] || "application/octet-stream";
res.writeHead(200, { "Content-Type": mime });
res.end(data);
} catch {
// Fallback to index.html for SPA
try {
const indexData = await readFile(join(this.staticDir, "index.html"));
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(indexData);
} catch {
res.writeHead(404);
res.end("Not found");
}
}
}
// ── Helpers ──────────────────────────────────────────────
private getStats() {
const counters = this.deps.activityLog.getCounters();
const uptimeMs = this.deps.activityLog.getUptime();
return {
messagesHandled: counters.messages,
heartbeats: counters.heartbeats,
cronRuns: counters.cron,
hooksFired: counters.hooks,
errors: counters.errors,
toolCalls: counters.toolCalls,
uptimeMs,
uptimeFormatted: this.formatUptime(uptimeMs),
};
}
private getSessions(): { channelId: string; sessionId: string }[] {
return this.deps.sessionManager.getAll();
}
private getConfig() {
const c = this.deps.config;
return {
agentBackend: c.agentBackend,
backendCliPath: c.backendCliPath,
backendModel: c.backendModel ?? "default",
backendMaxTurns: c.backendMaxTurns,
configDir: c.configDir,
allowedTools: c.allowedTools,
permissionMode: c.permissionMode,
queryTimeoutMs: c.queryTimeoutMs,
maxConcurrentQueries: c.maxConcurrentQueries,
maxQueueDepth: c.maxQueueDepth,
idleSessionTimeoutMs: c.idleSessionTimeoutMs,
outputChannelId: c.outputChannelId ?? "not set",
};
}
private getConnections() {
const discord = this.deps.discordStatus();
return [
{
name: "Discord",
logo: "discord",
status: discord.online ? "active" : "inactive",
detail: discord.online ? `${discord.tag} · ${discord.guilds} guild(s)` : "Disconnected",
},
{
name: this.capitalize(this.deps.config.agentBackend) + " CLI",
logo: this.deps.config.agentBackend,
status: "active",
detail: `${this.deps.config.backendCliPath}${this.deps.config.backendModel ? " · " + this.deps.config.backendModel : ""}`,
},
{
name: "Heartbeat Scheduler",
logo: "heartbeat",
status: this.deps.heartbeatChecks.length > 0 ? "active" : "inactive",
detail: `${this.deps.heartbeatChecks.length} check(s)`,
},
{
name: "Cron Scheduler",
logo: "cron",
status: this.deps.cronJobs.length > 0 ? "active" : "inactive",
detail: `${this.deps.cronJobs.length} job(s)`,
},
{
name: "IPC Watcher",
logo: "ipc",
status: "active",
detail: "Polling outbound/",
},
{
name: "Skills System",
logo: "skills",
status: "active",
detail: "config/skills/",
},
];
}
private formatUptime(ms: number): string {
const seconds = Math.floor(ms / 1000);
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
const parts: string[] = [];
if (d > 0) parts.push(`${d}d`);
if (h > 0) parts.push(`${h}h`);
if (m > 0) parts.push(`${m}m`);
parts.push(`${s}s`);
return parts.join(" ");
}
private capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
private readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk) => { body += chunk; });
req.on("end", () => resolve(body));
req.on("error", reject);
});
}
}

View File

@@ -16,6 +16,9 @@ import { appendMessage } from "./message-history.js";
import { IpcWatcher } from "./ipc-watcher.js"; import { IpcWatcher } from "./ipc-watcher.js";
import { ConversationArchiver } from "./conversation-archiver.js"; import { ConversationArchiver } from "./conversation-archiver.js";
import { logger } from "./logger.js"; import { logger } from "./logger.js";
import { ActivityLog } from "./activity-log.js";
import { DashboardServer } from "./dashboard-server.js";
import { LocalStores } from "./local-store.js";
import { resolveBackendName, createBackend } from "./backends/index.js"; import { resolveBackendName, createBackend } from "./backends/index.js";
export class GatewayCore { export class GatewayCore {
@@ -30,7 +33,13 @@ export class GatewayCore {
private markdownConfigLoader!: MarkdownConfigLoader; private markdownConfigLoader!: MarkdownConfigLoader;
private ipcWatcher!: IpcWatcher; private ipcWatcher!: IpcWatcher;
private conversationArchiver = new ConversationArchiver(); private conversationArchiver = new ConversationArchiver();
private activityLog = new ActivityLog();
private dashboardServer: DashboardServer | null = null;
private localStores!: LocalStores;
private idleCleanupTimer: ReturnType<typeof setInterval> | null = null; private idleCleanupTimer: ReturnType<typeof setInterval> | null = null;
private heartbeatChecksCache: import("./heartbeat-scheduler.js").HeartbeatCheck[] = [];
private cronJobsCache: import("./cron-scheduler.js").CronJob[] = [];
private hookConfigCache: import("./hook-manager.js").HookConfig = {};
private activeQueryCount = 0; private activeQueryCount = 0;
private isShuttingDown = false; private isShuttingDown = false;
@@ -93,6 +102,7 @@ export class GatewayCore {
); );
if (heartbeatContent) { if (heartbeatContent) {
const checks = this.heartbeatScheduler.parseConfig(heartbeatContent); const checks = this.heartbeatScheduler.parseConfig(heartbeatContent);
this.heartbeatChecksCache = checks;
this.heartbeatScheduler.start(checks, (event) => this.heartbeatScheduler.start(checks, (event) =>
this.eventQueue.enqueue(event), this.eventQueue.enqueue(event),
); );
@@ -109,18 +119,21 @@ export class GatewayCore {
); );
if (agentsContent) { if (agentsContent) {
const cronJobs = this.cronScheduler.parseConfig(agentsContent); const cronJobs = this.cronScheduler.parseConfig(agentsContent);
this.cronJobsCache = cronJobs;
this.cronScheduler.start(cronJobs, (event) => this.cronScheduler.start(cronJobs, (event) =>
this.eventQueue.enqueue(event), this.eventQueue.enqueue(event),
); );
logger.info({ count: cronJobs.length }, "CronScheduler started"); logger.info({ count: cronJobs.length }, "CronScheduler started");
this.hookManager.parseConfig(agentsContent); this.hookConfigCache = this.hookManager.parseConfig(agentsContent);
logger.info("HookConfig loaded from agents.md"); logger.info("HookConfig loaded from agents.md");
} }
// 8. Register EventQueue processing handler // 8. Register EventQueue processing handler
this.eventQueue.onEvent(async (event: Event) => { this.eventQueue.onEvent(async (event: Event) => {
logger.debug({ type: event.type, id: event.id }, "Processing event"); logger.debug({ type: event.type, id: event.id }, "Processing event");
// Record to activity log for the dashboard
this.activityLog.record(event);
try { try {
// Streaming callback — sends results to Discord as they arrive // Streaming callback — sends results to Discord as they arrive
const onStreamResult = async (text: string, channelId: string) => { const onStreamResult = async (text: string, channelId: string) => {
@@ -145,11 +158,12 @@ export class GatewayCore {
content: result.responseText, content: result.responseText,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
direction: "outbound", direction: "outbound",
}).catch(() => {}); }).catch(() => { });
} }
if (result.error && result.targetChannelId) { if (result.error && result.targetChannelId) {
await this.discordBot.sendMessage(result.targetChannelId, result.error); await this.discordBot.sendMessage(result.targetChannelId, result.error);
this.activityLog.record(event, result.error, true);
} }
// Archive conversation for message events // Archive conversation for message events
@@ -157,16 +171,17 @@ export class GatewayCore {
const payload = event.payload as MessagePayload; const payload = event.payload as MessagePayload;
await this.conversationArchiver await this.conversationArchiver
.archive(this.config.configDir, payload.prompt.channelId, payload.prompt.text, result.responseText) .archive(this.config.configDir, payload.prompt.channelId, payload.prompt.text, result.responseText)
.catch(() => {}); .catch(() => { });
} }
} catch (error) { } catch (error) {
logger.error({ err: error }, "Error processing event"); logger.error({ err: error }, "Error processing event");
this.activityLog.record(event, String(error), true);
if (event.type === "message") { if (event.type === "message") {
const payload = event.payload as MessagePayload; const payload = event.payload as MessagePayload;
const errorMsg = formatErrorForUser(error); const errorMsg = formatErrorForUser(error);
await this.discordBot await this.discordBot
.sendMessage(payload.prompt.channelId, errorMsg) .sendMessage(payload.prompt.channelId, errorMsg)
.catch(() => {}); .catch(() => { });
} }
} finally { } finally {
if (event.type === "message") { if (event.type === "message") {
@@ -182,14 +197,14 @@ export class GatewayCore {
if (this.isShuttingDown) { if (this.isShuttingDown) {
this.discordBot this.discordBot
.sendMessage(prompt.channelId, "Gateway is shutting down. Please try again later.") .sendMessage(prompt.channelId, "Gateway is shutting down. Please try again later.")
.catch(() => {}); .catch(() => { });
return; return;
} }
if (this.activeQueryCount >= this.config.maxConcurrentQueries) { if (this.activeQueryCount >= this.config.maxConcurrentQueries) {
this.discordBot this.discordBot
.sendMessage(prompt.channelId, "System is busy. Please try again later.") .sendMessage(prompt.channelId, "System is busy. Please try again later.")
.catch(() => {}); .catch(() => { });
return; return;
} }
@@ -201,13 +216,13 @@ export class GatewayCore {
content: prompt.text, content: prompt.text,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
direction: "inbound", direction: "inbound",
}).catch(() => {}); }).catch(() => { });
// Touch session activity // Touch session activity
this.sessionManager.touchActivity(prompt.channelId); this.sessionManager.touchActivity(prompt.channelId);
// Send typing indicator // Send typing indicator
this.discordBot.sendTyping(prompt.channelId).catch(() => {}); this.discordBot.sendTyping(prompt.channelId).catch(() => { });
const enqueued = this.eventQueue.enqueue({ const enqueued = this.eventQueue.enqueue({
type: "message", type: "message",
@@ -219,7 +234,7 @@ export class GatewayCore {
this.activeQueryCount--; this.activeQueryCount--;
this.discordBot this.discordBot
.sendMessage(prompt.channelId, "System is busy. Please try again later.") .sendMessage(prompt.channelId, "System is busy. Please try again later.")
.catch(() => {}); .catch(() => { });
} }
}); });
@@ -243,6 +258,32 @@ export class GatewayCore {
this.sessionManager.cleanupIdleSessions(this.config.idleSessionTimeoutMs); this.sessionManager.cleanupIdleSessions(this.config.idleSessionTimeoutMs);
}, 5 * 60 * 1000); }, 5 * 60 * 1000);
// 14. Start Mission Control dashboard
this.localStores = new LocalStores(this.config.configDir);
await this.activityLog.configureStore(this.localStores);
const dashboardPort = process.env.DASHBOARD_PORT ? parseInt(process.env.DASHBOARD_PORT, 10) : 3100;
this.dashboardServer = new DashboardServer({
config: this.config,
activityLog: this.activityLog,
sessionManager: this.sessionManager,
markdownConfigLoader: this.markdownConfigLoader,
heartbeatChecks: this.heartbeatChecksCache,
cronJobs: this.cronJobsCache,
hookConfig: this.hookConfigCache,
discordStatus: () => {
const user = this.discordBot?.['client']?.user;
const guilds = this.discordBot?.['client']?.guilds?.cache?.size;
return {
online: !!user,
tag: user?.tag,
guilds: guilds ?? 0,
};
},
localStores: this.localStores,
}, dashboardPort);
await this.dashboardServer.start();
logger.info("Gateway started successfully"); logger.info("Gateway started successfully");
} }
@@ -256,6 +297,8 @@ export class GatewayCore {
this.heartbeatScheduler?.stop(); this.heartbeatScheduler?.stop();
this.cronScheduler?.stop(); this.cronScheduler?.stop();
this.ipcWatcher?.stop(); this.ipcWatcher?.stop();
this.dashboardServer?.stop();
await this.localStores?.flushAll();
if (this.idleCleanupTimer) { if (this.idleCleanupTimer) {
clearInterval(this.idleCleanupTimer); clearInterval(this.idleCleanupTimer);
this.idleCleanupTimer = null; this.idleCleanupTimer = null;

191
src/local-store.ts Normal file
View File

@@ -0,0 +1,191 @@
/**
* Local JSON-file persistence for Mission Control.
* All data lives in config/ — zero external dependencies.
*/
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { join, dirname } from "node:path";
import { logger } from "./logger.js";
// ── Types ──────────────────────────────────────────────
export interface BrainFact {
id: string;
content: string;
type: "note" | "url" | "file";
category: string;
tags: string[];
createdAt: string;
updatedAt: string;
}
export interface ProductivityTask {
id: string;
title: string;
description: string;
status: "todo" | "in-progress" | "done" | "archived";
priority: "low" | "medium" | "high" | "urgent";
project: string;
dueDate: string | null;
createdAt: string;
updatedAt: string;
completedAt: string | null;
}
export interface ContentItem {
id: string;
title: string;
url: string;
source: string;
type: "article" | "video" | "tweet" | "paper" | "repo" | "other";
summary: string;
tags: string[];
status: "queued" | "read" | "archived";
savedAt: string;
}
export interface ActivityEntry {
id: number;
type: string;
source: string;
detail: string;
timestamp: string;
error?: boolean;
}
export interface BotConfigEntry {
key: string;
value: string;
category: string;
updatedAt: string;
}
// ── Generic JSON Store ──────────────────────────────────
class JsonStore<T> {
private filePath: string;
private cache: T[] | null = null;
private writeTimer: ReturnType<typeof setTimeout> | null = null;
constructor(configDir: string, filename: string) {
this.filePath = join(configDir, filename);
}
async load(): Promise<T[]> {
if (this.cache !== null) return this.cache;
try {
const data = await readFile(this.filePath, "utf-8");
this.cache = JSON.parse(data) as T[];
} catch {
this.cache = [];
}
return this.cache;
}
async save(): Promise<void> {
if (this.cache === null) return;
try {
await mkdir(dirname(this.filePath), { recursive: true });
await writeFile(this.filePath, JSON.stringify(this.cache, null, 2), "utf-8");
} catch (err) {
logger.error({ err, path: this.filePath }, "Failed to save store");
}
}
/** Debounced write — batches rapid updates */
private scheduleSave(): void {
if (this.writeTimer) clearTimeout(this.writeTimer);
this.writeTimer = setTimeout(() => this.save(), 500);
}
async getAll(): Promise<T[]> {
return this.load();
}
async add(item: T): Promise<T> {
const items = await this.load();
items.push(item);
this.cache = items;
this.scheduleSave();
return item;
}
async update(id: string, patch: Partial<T>): Promise<T | null> {
const items = await this.load();
const index = items.findIndex((i: any) => i.id === id);
if (index === -1) return null;
items[index] = { ...items[index], ...patch };
this.cache = items;
this.scheduleSave();
return items[index];
}
async remove(id: string): Promise<boolean> {
const items = await this.load();
const index = items.findIndex((i: any) => i.id === id);
if (index === -1) return false;
items.splice(index, 1);
this.cache = items;
this.scheduleSave();
return true;
}
async search(query: string): Promise<T[]> {
const items = await this.load();
const q = query.toLowerCase();
return items.filter((item: any) => {
return Object.values(item).some(
(v) => typeof v === "string" && v.toLowerCase().includes(q),
);
});
}
/** Append to the end, keep max N items */
async append(item: T, maxItems: number): Promise<void> {
const items = await this.load();
items.push(item);
if (items.length > maxItems) {
this.cache = items.slice(-maxItems);
}
this.scheduleSave();
}
/** Force flush to disk (for shutdown) */
async flush(): Promise<void> {
if (this.writeTimer) clearTimeout(this.writeTimer);
await this.save();
}
}
// ── Stores ──────────────────────────────────────────────
export class LocalStores {
readonly brain: JsonStore<BrainFact>;
readonly tasks: JsonStore<ProductivityTask>;
readonly content: JsonStore<ContentItem>;
readonly activityLog: JsonStore<ActivityEntry>;
readonly botConfig: JsonStore<BotConfigEntry>;
constructor(configDir: string) {
this.brain = new JsonStore<BrainFact>(configDir, "brain.json");
this.tasks = new JsonStore<ProductivityTask>(configDir, "tasks.json");
this.content = new JsonStore<ContentItem>(configDir, "content-items.json");
this.activityLog = new JsonStore<ActivityEntry>(configDir, "activity-log.json");
this.botConfig = new JsonStore<BotConfigEntry>(configDir, "bot-config.json");
}
async flushAll(): Promise<void> {
await Promise.all([
this.brain.flush(),
this.tasks.flush(),
this.content.flush(),
this.activityLog.flush(),
this.botConfig.flush(),
]);
}
}
// ── ID generator ────────────────────────────────────────
export function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}

View File

@@ -36,6 +36,13 @@ export class SessionManager {
this.saveToDisk(); this.saveToDisk();
} }
getAll(): { channelId: string; sessionId: string }[] {
return Array.from(this.bindings.entries()).map(([channelId, sessionId]) => ({
channelId,
sessionId,
}));
}
touchActivity(channelId: string): void { touchActivity(channelId: string): void {
if (this.bindings.has(channelId)) { if (this.bindings.has(channelId)) {
this.lastActivity.set(channelId, Date.now()); this.lastActivity.set(channelId, Date.now());

View File

@@ -4,6 +4,7 @@ import { ClaudeCodeBackend } from "../../src/backends/claude-backend.js";
import { CodexBackend } from "../../src/backends/codex-backend.js"; import { CodexBackend } from "../../src/backends/codex-backend.js";
import { GeminiBackend } from "../../src/backends/gemini-backend.js"; import { GeminiBackend } from "../../src/backends/gemini-backend.js";
import { OpenCodeBackend } from "../../src/backends/opencode-backend.js"; import { OpenCodeBackend } from "../../src/backends/opencode-backend.js";
import { PiBackend } from "../../src/backends/pi-backend.js";
import type { BackendAdapterConfig } from "../../src/backends/types.js"; import type { BackendAdapterConfig } from "../../src/backends/types.js";
// ── Shared arbitraries ────────────────────────────────────────────── // ── Shared arbitraries ──────────────────────────────────────────────
@@ -151,6 +152,35 @@ describe("Property 5: Session resume args across backends", () => {
); );
}); });
}); });
describe("Pi: --session <id> --continue when session provided, absent otherwise", () => {
it("includes --session <id> --continue when session ID is provided", () => {
fc.assert(
fc.property(nonEmptyString, nonEmptyString, sessionId, (prompt, sysPr, sid) => {
const backend = new PiBackend(makeConfig());
const args = backend.buildArgs(prompt, sysPr, sid);
const sessionIdx = args.indexOf("--session");
return (
sessionIdx !== -1 &&
args[sessionIdx + 1] === sid &&
args.includes("--continue")
);
}),
{ numRuns: 100 },
);
});
it("does not include --session or --continue when no session ID is provided", () => {
fc.assert(
fc.property(nonEmptyString, nonEmptyString, (prompt, sysPr) => {
const backend = new PiBackend(makeConfig());
const args = backend.buildArgs(prompt, sysPr);
return !args.includes("--session") && !args.includes("--continue");
}),
{ numRuns: 100 },
);
});
});
}); });
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
@@ -229,6 +259,24 @@ describe("Property 6: Output parsing extracts correct fields", () => {
{ numRuns: 100 }, { numRuns: 100 },
); );
}); });
it("Pi: parses NDJSON with result type and session_id", () => {
fc.assert(
fc.property(responseText, sessionId, (text, sid) => {
const backend = new PiBackend(makeConfig());
const lines = [
JSON.stringify({ type: "result", result: text, session_id: sid }),
].join("\n");
const result = backend.parseOutput(lines);
return (
result.isError === false &&
result.responseText === text &&
result.sessionId === sid
);
}),
{ numRuns: 100 },
);
});
}); });
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
@@ -252,6 +300,7 @@ const backendErrorPrefixes: Record<string, string> = {
codex: "Codex CLI error", codex: "Codex CLI error",
gemini: "Gemini CLI error", gemini: "Gemini CLI error",
opencode: "OpenCode CLI error", opencode: "OpenCode CLI error",
pi: "Pi CLI error",
}; };
/** /**
@@ -272,7 +321,7 @@ function simulateErrorResult(
} }
describe("Property 8: Non-zero exit code produces error result", () => { describe("Property 8: Non-zero exit code produces error result", () => {
const backendNames = ["claude", "codex", "gemini", "opencode"] as const; const backendNames = ["claude", "codex", "gemini", "opencode", "pi"] as const;
it("for any backend, non-zero exit code and stderr, result has isError=true and responseText contains stderr", () => { it("for any backend, non-zero exit code and stderr, result has isError=true and responseText contains stderr", () => {
fc.assert( fc.assert(

View File

@@ -0,0 +1,143 @@
import { describe, it } from "vitest";
import fc from "fast-check";
import { PiBackend } from "../../src/backends/pi-backend.js";
import type { BackendAdapterConfig } from "../../src/backends/types.js";
// Feature: multi-cli-backend, Property: Pi backend required flags
// Validates Pi-specific CLI argument construction
/**
* Arbitrary for non-empty strings that won't break CLI arg parsing.
*/
const nonEmptyString = fc.string({ minLength: 1, maxLength: 200 });
/**
* Arbitrary for model strings (provider/model format).
*/
const modelString = fc.stringMatching(/^[a-z]{1,20}\/[a-z0-9-]{1,40}$/);
function createBackend(model?: string): PiBackend {
const config: BackendAdapterConfig = {
cliPath: "pi",
workingDir: "/tmp",
queryTimeoutMs: 60000,
allowedTools: [],
maxTurns: 25,
model,
};
return new PiBackend(config);
}
describe("Pi backend required flags", () => {
it("generated args always contain -p for print mode", () => {
fc.assert(
fc.property(
nonEmptyString,
(prompt) => {
const backend = createBackend();
const args = backend.buildArgs(prompt);
return args.includes("-p");
},
),
{ numRuns: 100 },
);
});
it("generated args always contain --mode json", () => {
fc.assert(
fc.property(
nonEmptyString,
(prompt) => {
const backend = createBackend();
const args = backend.buildArgs(prompt);
const modeIndex = args.indexOf("--mode");
return modeIndex !== -1 && args[modeIndex + 1] === "json";
},
),
{ numRuns: 100 },
);
});
it("generated args contain --model when a model is configured", () => {
fc.assert(
fc.property(
nonEmptyString,
modelString,
(prompt, model) => {
const backend = createBackend(model);
const args = backend.buildArgs(prompt);
const modelIndex = args.indexOf("--model");
return modelIndex !== -1 && args[modelIndex + 1] === model;
},
),
{ numRuns: 100 },
);
});
it("generated args do not contain --model when no model is configured", () => {
fc.assert(
fc.property(
nonEmptyString,
(prompt) => {
const backend = createBackend(undefined);
const args = backend.buildArgs(prompt);
return !args.includes("--model");
},
),
{ numRuns: 100 },
);
});
it("generated args contain --no-session for headless usage", () => {
fc.assert(
fc.property(
nonEmptyString,
(prompt) => {
const backend = createBackend();
const args = backend.buildArgs(prompt);
return args.includes("--no-session");
},
),
{ numRuns: 100 },
);
});
it("generated args contain --no-extensions, --no-skills, --no-themes for deterministic runs", () => {
fc.assert(
fc.property(
nonEmptyString,
(prompt) => {
const backend = createBackend();
const args = backend.buildArgs(prompt);
return (
args.includes("--no-extensions") &&
args.includes("--no-skills") &&
args.includes("--no-themes")
);
},
),
{ numRuns: 100 },
);
});
it("prompt is always the last argument", () => {
fc.assert(
fc.property(
nonEmptyString,
(prompt) => {
const backend = createBackend();
const args = backend.buildArgs(prompt);
return args[args.length - 1] === prompt;
},
),
{ numRuns: 100 },
);
});
});

View File

@@ -3,6 +3,7 @@ import { ClaudeCodeBackend } from "../../src/backends/claude-backend.js";
import { CodexBackend } from "../../src/backends/codex-backend.js"; import { CodexBackend } from "../../src/backends/codex-backend.js";
import { GeminiBackend } from "../../src/backends/gemini-backend.js"; import { GeminiBackend } from "../../src/backends/gemini-backend.js";
import { OpenCodeBackend } from "../../src/backends/opencode-backend.js"; import { OpenCodeBackend } from "../../src/backends/opencode-backend.js";
import { PiBackend } from "../../src/backends/pi-backend.js";
import { createBackend } from "../../src/backends/registry.js"; import { createBackend } from "../../src/backends/registry.js";
import { AgentRuntime, mapBackendEventResult } from "../../src/agent-runtime.js"; import { AgentRuntime, mapBackendEventResult } from "../../src/agent-runtime.js";
import { SessionManager } from "../../src/session-manager.js"; import { SessionManager } from "../../src/session-manager.js";
@@ -21,7 +22,7 @@ const defaultConfig: BackendAdapterConfig = {
// ─── 11.1 validate() method tests ─────────────────────────────────────────── // ─── 11.1 validate() method tests ───────────────────────────────────────────
describe("11.1 Backend validate() method", () => { describe("11.1 Backend validate() method", () => {
const backends = ["claude", "codex", "gemini", "opencode"] as const; const backends = ["claude", "codex", "gemini", "opencode", "pi"] as const;
for (const name of backends) { for (const name of backends) {
describe(`${name} backend`, () => { describe(`${name} backend`, () => {
@@ -57,7 +58,7 @@ describe("11.2 Timeout behavior", () => {
// Create a helper script path that sleeps for 30 seconds // Create a helper script path that sleeps for 30 seconds
const nodeExe = process.execPath; const nodeExe = process.execPath;
const backends = ["claude", "codex", "gemini", "opencode"] as const; const backends = ["claude", "codex", "gemini", "opencode", "pi"] as const;
for (const name of backends) { for (const name of backends) {
it(`${name} backend should return timeout error when process exceeds queryTimeoutMs`, async () => { it(`${name} backend should return timeout error when process exceeds queryTimeoutMs`, async () => {
@@ -445,12 +446,19 @@ describe("11.5 Unsupported option warning for ALLOWED_TOOLS", () => {
expect(args.join(" ")).not.toContain("--allowedTools"); expect(args.join(" ")).not.toContain("--allowedTools");
}); });
it("Pi backend should NOT include any allowedTools flags", () => {
const backend = new PiBackend(toolFilteringConfig);
const args = backend.buildArgs("prompt", "system prompt");
expect(args.join(" ")).not.toContain("allowedTools");
expect(args.join(" ")).not.toContain("--allowedTools");
});
it("should log a warning when ALLOWED_TOOLS is set for a non-Claude backend", () => { it("should log a warning when ALLOWED_TOOLS is set for a non-Claude backend", () => {
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => undefined as any); const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => undefined as any);
// Simulate the check that should happen at startup: // Simulate the check that should happen at startup:
// When the backend doesn't support tool filtering but allowedTools is configured // When the backend doesn't support tool filtering but allowedTools is configured
const backendsWithoutToolFiltering = ["codex", "gemini", "opencode"] as const; const backendsWithoutToolFiltering = ["codex", "gemini", "opencode", "pi"] as const;
const allowedTools = ["Read", "Write", "Bash"]; const allowedTools = ["Read", "Write", "Bash"];
for (const name of backendsWithoutToolFiltering) { for (const name of backendsWithoutToolFiltering) {
@@ -464,7 +472,7 @@ describe("11.5 Unsupported option warning for ALLOWED_TOOLS", () => {
} }
} }
expect(warnSpy).toHaveBeenCalledTimes(3); expect(warnSpy).toHaveBeenCalledTimes(4);
for (const name of backendsWithoutToolFiltering) { for (const name of backendsWithoutToolFiltering) {
expect(warnSpy).toHaveBeenCalledWith( expect(warnSpy).toHaveBeenCalledWith(
{ backend: name, allowedTools }, { backend: name, allowedTools },