FPLAN-0410 repo cleanup + integration test bug fixes

VERA cleanup (FPLAN-0410):
- Remove all /home/aipass hardcoded paths (portability)
- Remove internal Telegram bot code from public repo (6k+ lines)
- Remove runtime logs, .claude/settings.local.json, stale files
- Clean READMEs across all branches
- Restructure spawn handlers into proper 3-layer architecture
- Add __init__.py files for proper package imports
- Update seedgo standards for public repo context
- Add log_structure standard to seedgo

Integration test bug fixes:
- Fix double @@ display in drone introspection output
- Fix spawn registry resolving to wrong file (walk up from package root)
- Fix email sender identity always showing as @ai_mail (pass AIPASS_CALLER_CWD)
- Fix branch_detection.py relative path resolution against CWD instead of registry dir
- Add .trinity/passport.json detection for spawned agents
- Archive duplicate json_ops.py, update imports to canonical json_handler
- Update .gitignore for runtime logs and .claude/ dirs

Tested end-to-end in Docker container (aipass-test:latest):
- Spawn → register → send email → receive → reply all verified working

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
AIOSAI
2026-03-06 15:29:16 -08:00
co-authored by Claude Opus 4.6
parent a59f9c006b
commit 3b45824dc6
186 changed files with 1350 additions and 9490 deletions
-55
View File
@@ -1,55 +0,0 @@
# Docker
Docker stores images at /var/lib/docker/ (managed by the Docker daemon). You don't interact with it directly — Docker
handles the layers.
Your cheat sheet:
┌────────────────────────────┬───────────────────────┐
│ Command │ What it does │
├────────────────────────────┼───────────────────────┤
│ docker start aipass-vscode │ Resume container │
├────────────────────────────┼───────────────────────┤
│ docker stop aipass-vscode │ Pause container │
├────────────────────────────┼───────────────────────┤
│ docker images │ List all images │
├────────────────────────────┼───────────────────────┤
│ docker ps -a │ List all containers │
├────────────────────────────┼───────────────────────┤
│ docker system df │ Disk usage │
├────────────────────────────┼───────────────────────┤
│ docker system prune │ Clean up unused stuff │
└────────────────────────────┴───────────────────────┘
The aipass-test image (~260MB) stays until you docker rmi aipass-test. The container stays until you docker rm it.
Both survive reboots.
## Git Workflow (inside the container)
The container is a separate user. It clones from your fork, not the main repo.
┌─────────────────────────────────┬──────────────────────────────────────────┐
│ Command │ What it does │
├─────────────────────────────────┼──────────────────────────────────────────┤
│ git pull upstream main │ Pull latest from AIOSAI/AIPass (source) │
├─────────────────────────────────┼──────────────────────────────────────────┤
│ git push origin main │ Push to your fork (Input-X/AIPass) │
├─────────────────────────────────┼──────────────────────────────────────────┤
│ git remote -v │ Check which remotes are set │
├─────────────────────────────────┼──────────────────────────────────────────┤
│ git log --oneline -5 │ See recent commits │
└─────────────────────────────────┴──────────────────────────────────────────┘
Remotes:
origin = Input-X/AIPass (your fork — push here)
upstream = AIOSAI/AIPass (main repo — pull from here)
Typical flow:
1. git pull upstream main # Get latest changes
2. Work on your code
3. git add . && git commit -m "what you did"
4. git push origin main # Push to your fork
Nothing you do in the container touches AIOSAI/AIPass directly.
+5 -3
View File
@@ -60,8 +60,10 @@ aipass_json/
.standards/
# AIPASS_REGISTRY.json — NOT ignored. Ships with core modules pre-registered.
# Runtime log files (created by prax logging)
**/logs
# Claude local settings (per-branch, not source code)
.claude/
setup-workspace.sh
# Local cheat sheets (not source code)
# Docker.md
@@ -1,168 +0,0 @@
# DPLAN-047: Path.home() Purge — AIPass Portability Fix
**Status:** READY
**Created:** 2026-03-06
**Priority:** CRITICAL — blocks all Docker/container testing
**Parent:** MPLAN-001 (AIPass Public Repo Build)
**Triggered by:** Docker container testing revealed `Permission denied: '/home/aipass'` on `drone @seedgo audit aipass`
---
## Problem
Every module in AIPass uses `Path.home()` or hardcoded `/home/aipass` strings to resolve paths at module level (import time). This means:
- **0 of 10 modules** are portable
- **107 module-level bindings** crash on any machine that isn't ours
- A pip user running `from aipass.prax import logger` gets `Permission denied` immediately
- Docker container (the acceptance test) fails on basic commands
### Root Cause
Code was transferred from Dev-Pass (where `/home/aipass` is the fixed root) to AIPass (pip package, root is wherever the user cloned it). The import rewire (FPLAN-0409) fixed `from` statements but didn't touch path resolution.
### The Fix Pattern
```python
# WRONG — Dev-Pass pattern (breaks everywhere else)
SYSTEM_LOGS_DIR = Path.home() / "system_logs"
BRANCH_REGISTRY = Path.home() / "BRANCH_REGISTRY.json"
API_ROOT = Path.home() / "aipass_core" / "api"
# RIGHT — Package-relative (works everywhere)
# Option A: Walk up from __file__ to find repo root
REPO_ROOT = Path(__file__).resolve().parents[N] # N = depth to repo root
# Option B: Walk-up finder (already used by drone/seedgo)
def _find_repo_root() -> Path:
current = Path(__file__).resolve().parent
for parent in [current] + list(current.parents):
if (parent / "AIPASS_REGISTRY.json").exists():
return parent
return Path.cwd()
```
---
## Scope — Module Debt Count
| Module | Path.home() | Hardcoded /home/aipass | Module-Level | Severity |
|--------|-------------|----------------------|--------------|----------|
| api | 40 | 20 | 33 | CRITICAL |
| ai_mail | 35 | 20 | 22 | CRITICAL |
| prax | 16 | 23 | 9 | CRITICAL |
| flow | 15 | 26 | 6 | HIGH |
| trigger | 8 | 14 | 8 | MEDIUM |
| seedgo | 10 | 66* | 0 | DONE (seed purged) |
| drone | 2 | 0 | 1 | LOW (fallback only) |
| cli | 2 | 11 | 2 | LOW |
| spawn | 2 | 0 | 2 | LOW |
| devpulse | 1 | 0 | 1 | LOW |
*seedgo 66 count is 59 documentation strings + 7 code. Seed already fixed all runtime breakers.
---
## Phase Plan
### Phase 0: Immediate Unblock (DEV_CENTRAL — done)
- [x] AIPASS_REGISTRY.json DEVPULSE absolute path fixed
- [x] README.md fake import paths fixed
- [x] pyproject.toml missing watchdog dependency added
- [x] Seedgo Path.home() purge dispatched to @seed (12 files, AST-verified clean)
- [x] Prax setup.py:125 hardcoded `Path("/home/aipass")` fixed
- [ ] Commit + push all pending changes
### Phase 1: Prax Logger Unblock (dispatch to @prax)
**Goal:** `from aipass.prax import logger` works in container
**Files:** 8 module-level bindings in prax
**Key targets:**
- config/load.py:51 — SYSTEM_LOGS_DIR = Path.home()
- config/load.py:55 — mkdir at import time
- setup.py:125 — branch_logs_dir (FIXED)
- introspection.py:103 — parts.index('aipass') username assumption
- registry/reader.py:34 — BRANCH_REGISTRY_PATH
- dashboard/agent_status_writer.py:45 — BRANCH_REGISTRY
- monitoring/telegram_command_bot.py:68 — AIPASS_HOME cascade
- monitoring/telegram_relay.py:60 — PRAX_MONITOR_CONFIG
- log_watchdog.py:44 — SYSTEM_LOGS_DIR duplicate
**Fix pattern:** Lazy initialization. Don't mkdir at import time. Use repo-relative paths.
### Phase 2: Core Module Purge (parallel dispatch)
**Goal:** drone, cli, spawn, devpulse portable
**These are small** — 1-2 fixes each. Can dispatch in parallel.
- drone config.py:41 — fallback Path.home() (already has walk-up, just remove fallback)
- cli json_handler.py:27 — CLI_ROOT
- spawn verify_branch.py — AIPASS_ROOT
- devpulse verify_branch.py — AIPASS_ROOT
### Phase 3: Heavy Modules (parallel dispatch, larger scope)
**Goal:** flow, trigger, ai_mail, api portable
**These are big** — 6-33 module-level bindings each.
Strategy: Many of these paths reference Dev-Pass infrastructure that doesn't exist in AIPass (MEMORY_BANK, AI_CENTRAL, BRANCH_REGISTRY.json, telegram bots, daemon). Two options per reference:
1. **Rewire to repo-relative** — if the feature exists in AIPass
2. **Guard with try/except or conditional** — if it's a Dev-Pass-only feature
3. **Remove entirely** — if the code shouldn't be in the public repo at all
**Decision needed from Patrick:** Which modules should ship functional vs stub in v1.0?
- api telegram handlers — probably Dev-Pass only (not for pip users)
- ai_mail daemon/dispatch — probably Dev-Pass only
- flow AI_CENTRAL aggregation — probably Dev-Pass only
- trigger event handlers — probably Dev-Pass only
### Phase 4: Docker Verification
**Goal:** Full test suite passes in container
- `pip install -e .` clean
- `drone --help` works
- `drone systems` works
- `drone @seedgo audit aipass` works
- `drone @seedgo verify` works
- `drone @seedgo list` works
- Python imports work: `from aipass.drone import ...`, `from aipass.prax import logger`
---
## Key Decision: What Ships in v1.0?
The massive path debt in api (40), ai_mail (35), flow (15) is because these modules contain Dev-Pass infrastructure code (telegram bots, daemon dispatch, AI_CENTRAL aggregation) that pip users will never use.
**Option A: Fix everything** — rewire all 131 Path.home() calls. Massive effort, most code is dead weight for pip users.
**Option B: Strip Dev-Pass code from public modules** — ship only the pip-relevant parts. Faster, cleaner, but requires deciding what's public vs private per module.
**Option C: Guard at module boundary** — keep all code but wrap Dev-Pass features in `try/import` guards so they don't crash when infrastructure is missing. Middle ground.
**Recommendation:** Option B for v1.0. Ship lean. The telegram bridge, daemon dispatch, and AI_CENTRAL aggregation are Dev-Pass features. Public users need: drone routing, seedgo auditing, prax logging (basic), flow planning (basic), cli formatting.
---
## Container Test Checklist (Acceptance Criteria)
```bash
# In Docker container (clean clone, pip install -e .)
source .venv/bin/activate
pip install -e .
# Core commands
drone --help # Must work
drone systems # Must show 10 branches
drone @seedgo verify # Must pass 5/5
drone @seedgo list # Must show packs
drone @seedgo audit aipass # Must run without Permission denied
# Python imports
python3 -c "from aipass.drone.apps.modules.registry import load_registry; print(load_registry())"
python3 -c "from aipass.prax import logger; logger.info('test')"
python3 -c "from aipass.cli import console, header; header('test')"
```
---
## Notes
- Shebangs (`#!/home/aipass/.venv/bin/python3`) are cosmetic — don't affect pip imports
- Display strings in seedgo standards checkers are documentation, not runtime
- The walk-up `_find_registry()` pattern is proven — drone and seedgo already use it
- Dev-Pass and AIPass can coexist on same machine (different venvs, different registries)
+1 -1
View File
@@ -34,4 +34,4 @@ ENV PATH="/opt/venv/bin:$PATH"
EXPOSE 8080
# Use the official entrypoint (already in the base image at /usr/bin/entrypoint.sh)
# It runs fixuid -> entrypoint.d scripts -> dumb-init code-server
ENTRYPOINT ["/usr/bin/entrypoint.sh", "--bind-addr", "0.0.0.0:8080", "--auth", "none", "/home/coder/workspace"]
ENTRYPOINT ["/usr/bin/entrypoint.sh", "--bind-addr", "0.0.0.0:8080", "--auth", "none", "/home/coder/workspace/AIPass"]
-35
View File
@@ -1,35 +0,0 @@
2026-03-06 19:26:01 - captured_ai_mail - INFO - [ai_mail] Discovering modules...
2026-03-06 19:26:01 - captured_ai_mail - ERROR - [-] aipass.ai_mail.apps.modules.email - import error: No module named 'aipass.dev_central'
2026-03-06 19:26:01 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.dispatch
2026-03-06 19:26:01 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.branch_ping
2026-03-06 19:26:01 - captured_ai_mail - INFO - [ai_mail] Discovered 2 modules
2026-03-06 19:26:27 - captured_ai_mail - INFO - [ai_mail] Discovering modules...
2026-03-06 19:26:27 - captured_ai_mail - ERROR - [-] aipass.ai_mail.apps.modules.email - import error: No module named 'aipass.dev_central'
2026-03-06 19:26:27 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.dispatch
2026-03-06 19:26:27 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.branch_ping
2026-03-06 19:26:27 - captured_ai_mail - INFO - [ai_mail] Discovered 2 modules
2026-03-06 20:32:14 - captured_ai_mail - INFO - [ai_mail] Discovering modules...
2026-03-06 20:32:14 - captured_ai_mail - ERROR - [-] aipass.ai_mail.apps.modules.email - import error: No module named 'aipass.dev_central'
2026-03-06 20:32:14 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.dispatch
2026-03-06 20:32:14 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.branch_ping
2026-03-06 20:32:14 - captured_ai_mail - INFO - [ai_mail] Discovered 2 modules
2026-03-06 20:32:37 - captured_ai_mail - INFO - [ai_mail] Discovering modules...
2026-03-06 20:32:37 - captured_ai_mail - ERROR - [-] aipass.ai_mail.apps.modules.email - import error: No module named 'aipass.dev_central'
2026-03-06 20:32:37 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.dispatch
2026-03-06 20:32:37 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.branch_ping
2026-03-06 20:32:37 - captured_ai_mail - INFO - [ai_mail] Discovered 2 modules
2026-03-06 20:32:42 - captured_ai_mail - INFO - [ai_mail] Discovering modules...
2026-03-06 20:32:42 - captured_ai_mail - ERROR - [-] aipass.ai_mail.apps.modules.email - import error: No module named 'aipass.dev_central'
2026-03-06 20:32:42 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.dispatch
2026-03-06 20:32:42 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.branch_ping
2026-03-06 20:32:42 - captured_ai_mail - INFO - [ai_mail] Discovered 2 modules
2026-03-06 20:33:19 - captured_ai_mail - INFO - [ai_mail] Discovering modules...
2026-03-06 20:33:19 - captured_ai_mail - ERROR - [-] aipass.ai_mail.apps.modules.email - import error: No module named 'aipass.dev_central'
2026-03-06 20:33:19 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.dispatch
2026-03-06 20:33:19 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.branch_ping
2026-03-06 20:33:19 - captured_ai_mail - INFO - [ai_mail] Discovered 2 modules
2026-03-06 20:33:19 - captured_ai_mail - INFO - [ai_mail] Discovering modules...
2026-03-06 20:33:19 - captured_ai_mail - ERROR - [-] aipass.ai_mail.apps.modules.email - import error: No module named 'aipass.dev_central'
2026-03-06 20:33:19 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.dispatch
2026-03-06 20:33:19 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.branch_ping
2026-03-06 20:33:19 - captured_ai_mail - INFO - [ai_mail] Discovered 2 modules
-1
View File
@@ -1 +0,0 @@
2026-03-06 20:33:19 - captured_dispatch - INFO - [dispatch] Showing dispatch status
-29
View File
@@ -1,29 +0,0 @@
2026-03-06 19:25:57 - captured_api - INFO - [api] Discovering modules...
2026-03-06 19:25:57 - captured_api - INFO - [+] telegram_service
2026-03-06 19:25:57 - captured_api - INFO - [+] telegram_bot
2026-03-06 19:25:57 - captured_api - INFO - [+] api_key
2026-03-06 19:25:57 - captured_api - ERROR - [-] openrouter_client - import error: name 'OpenAI' is not defined
2026-03-06 19:25:57 - captured_api - INFO - [+] usage_tracker
2026-03-06 19:25:57 - captured_api - INFO - [api] Discovered 4 modules
2026-03-06 19:25:57 - captured_api - WARNING - Unknown command: test
2026-03-06 19:26:31 - captured_api - INFO - [api] Discovering modules...
2026-03-06 19:26:31 - captured_api - INFO - [+] telegram_service
2026-03-06 19:26:31 - captured_api - INFO - [+] telegram_bot
2026-03-06 19:26:31 - captured_api - INFO - [+] api_key
2026-03-06 19:26:31 - captured_api - ERROR - [-] openrouter_client - import error: name 'OpenAI' is not defined
2026-03-06 19:26:31 - captured_api - INFO - [+] usage_tracker
2026-03-06 19:26:31 - captured_api - INFO - [api] Discovered 4 modules
2026-03-06 19:26:38 - captured_api - INFO - [api] Discovering modules...
2026-03-06 19:26:38 - captured_api - INFO - [+] telegram_service
2026-03-06 19:26:38 - captured_api - INFO - [+] telegram_bot
2026-03-06 19:26:38 - captured_api - INFO - [+] api_key
2026-03-06 19:26:38 - captured_api - ERROR - [-] openrouter_client - import error: name 'OpenAI' is not defined
2026-03-06 19:26:38 - captured_api - INFO - [+] usage_tracker
2026-03-06 19:26:38 - captured_api - INFO - [api] Discovered 4 modules
2026-03-06 19:26:41 - captured_api - INFO - [api] Discovering modules...
2026-03-06 19:26:41 - captured_api - INFO - [+] telegram_service
2026-03-06 19:26:41 - captured_api - INFO - [+] telegram_bot
2026-03-06 19:26:41 - captured_api - INFO - [+] api_key
2026-03-06 19:26:41 - captured_api - ERROR - [-] openrouter_client - import error: name 'OpenAI' is not defined
2026-03-06 19:26:41 - captured_api - INFO - [+] usage_tracker
2026-03-06 19:26:41 - captured_api - INFO - [api] Discovered 4 modules
-6
View File
@@ -1,6 +0,0 @@
2026-03-06 19:26:45 - captured_aggregate_central - INFO - [aggregate_central] Starting central aggregation
2026-03-06 19:26:45 - captured_aggregate_central - INFO - [aggregate_central] Processing branch: flow
2026-03-06 19:26:45 - captured_aggregate_central - INFO - [aggregate_central] SUCCESS: Aggregation complete: 0 active, 0 recently closed
2026-03-06 20:20:39 - captured_aggregate_central - INFO - [aggregate_central] Starting central aggregation
2026-03-06 20:20:39 - captured_aggregate_central - INFO - [aggregate_central] Processing branch: flow
2026-03-06 20:20:39 - captured_aggregate_central - INFO - [aggregate_central] SUCCESS: Aggregation complete: 1 active, 0 recently closed
-4
View File
@@ -1,4 +0,0 @@
2026-03-06 19:26:52 - captured_close_plan - INFO - [close_plan] Deleted empty template file: /tmp/FPLAN-0001_test_plan_from_devpulse_2026-03-06.md
2026-03-06 19:26:52 - captured_close_plan - INFO - [close_plan] Removed FPLAN-0001 from registry
2026-03-06 20:21:02 - captured_close_plan - INFO - [close_plan] Deleted empty template file: /home/coder/workspace/src/aipass/flow/FPLAN-0002_2026-03-06.md
2026-03-06 20:21:02 - captured_close_plan - INFO - [close_plan] Removed FPLAN-0002 from registry
-2
View File
@@ -1,2 +0,0 @@
2026-03-06 19:26:45 - captured_create_plan - INFO - [create_plan] Created FPLAN-0001 in /tmp
2026-03-06 20:20:39 - captured_create_plan - INFO - [create_plan] Created FPLAN-0002 in flow
-77
View File
@@ -1,77 +0,0 @@
2026-03-06 19:25:13 - captured_flow - INFO - [FLOW] Skipped post_close_runner - no handle_command()
2026-03-06 19:25:13 - captured_flow - INFO - [FLOW] Loaded module: aggregate_central
2026-03-06 19:25:13 - captured_flow - INFO - [FLOW] Loaded module: registry_monitor
2026-03-06 19:25:13 - captured_flow - INFO - [FLOW] Loaded module: restore_plan
2026-03-06 19:25:13 - captured_flow - INFO - [FLOW] Loaded module: list_plans
2026-03-06 19:25:13 - captured_flow - INFO - [FLOW] Loaded module: close_plan
2026-03-06 19:25:13 - captured_flow - INFO - [FLOW] Loaded module: create_plan
2026-03-06 19:25:53 - captured_flow - INFO - [FLOW] Skipped post_close_runner - no handle_command()
2026-03-06 19:25:53 - captured_flow - INFO - [FLOW] Loaded module: aggregate_central
2026-03-06 19:25:53 - captured_flow - INFO - [FLOW] Loaded module: registry_monitor
2026-03-06 19:25:53 - captured_flow - INFO - [FLOW] Loaded module: restore_plan
2026-03-06 19:25:53 - captured_flow - INFO - [FLOW] Loaded module: list_plans
2026-03-06 19:25:53 - captured_flow - INFO - [FLOW] Loaded module: close_plan
2026-03-06 19:25:53 - captured_flow - INFO - [FLOW] Loaded module: create_plan
2026-03-06 19:26:45 - captured_flow - INFO - [FLOW] Skipped post_close_runner - no handle_command()
2026-03-06 19:26:45 - captured_flow - INFO - [FLOW] Loaded module: aggregate_central
2026-03-06 19:26:45 - captured_flow - INFO - [FLOW] Loaded module: registry_monitor
2026-03-06 19:26:45 - captured_flow - INFO - [FLOW] Loaded module: restore_plan
2026-03-06 19:26:45 - captured_flow - INFO - [FLOW] Loaded module: list_plans
2026-03-06 19:26:45 - captured_flow - INFO - [FLOW] Loaded module: close_plan
2026-03-06 19:26:45 - captured_flow - INFO - [FLOW] Loaded module: create_plan
2026-03-06 19:26:49 - captured_flow - INFO - [FLOW] Skipped post_close_runner - no handle_command()
2026-03-06 19:26:49 - captured_flow - INFO - [FLOW] Loaded module: aggregate_central
2026-03-06 19:26:49 - captured_flow - INFO - [FLOW] Loaded module: registry_monitor
2026-03-06 19:26:49 - captured_flow - INFO - [FLOW] Loaded module: restore_plan
2026-03-06 19:26:49 - captured_flow - INFO - [FLOW] Loaded module: list_plans
2026-03-06 19:26:49 - captured_flow - INFO - [FLOW] Loaded module: close_plan
2026-03-06 19:26:49 - captured_flow - INFO - [FLOW] Loaded module: create_plan
2026-03-06 19:26:52 - captured_flow - INFO - [FLOW] Skipped post_close_runner - no handle_command()
2026-03-06 19:26:52 - captured_flow - INFO - [FLOW] Loaded module: aggregate_central
2026-03-06 19:26:52 - captured_flow - INFO - [FLOW] Loaded module: registry_monitor
2026-03-06 19:26:52 - captured_flow - INFO - [FLOW] Loaded module: restore_plan
2026-03-06 19:26:52 - captured_flow - INFO - [FLOW] Loaded module: list_plans
2026-03-06 19:26:52 - captured_flow - INFO - [FLOW] Loaded module: close_plan
2026-03-06 19:26:52 - captured_flow - INFO - [FLOW] Loaded module: create_plan
2026-03-06 20:20:13 - captured_flow - INFO - [FLOW] Skipped post_close_runner - no handle_command()
2026-03-06 20:20:13 - captured_flow - INFO - [FLOW] Loaded module: aggregate_central
2026-03-06 20:20:13 - captured_flow - INFO - [FLOW] Loaded module: registry_monitor
2026-03-06 20:20:13 - captured_flow - INFO - [FLOW] Loaded module: restore_plan
2026-03-06 20:20:13 - captured_flow - INFO - [FLOW] Loaded module: list_plans
2026-03-06 20:20:13 - captured_flow - INFO - [FLOW] Loaded module: close_plan
2026-03-06 20:20:13 - captured_flow - INFO - [FLOW] Loaded module: create_plan
2026-03-06 20:20:22 - captured_flow - INFO - [FLOW] Skipped post_close_runner - no handle_command()
2026-03-06 20:20:22 - captured_flow - INFO - [FLOW] Loaded module: aggregate_central
2026-03-06 20:20:22 - captured_flow - INFO - [FLOW] Loaded module: registry_monitor
2026-03-06 20:20:22 - captured_flow - INFO - [FLOW] Loaded module: restore_plan
2026-03-06 20:20:22 - captured_flow - INFO - [FLOW] Loaded module: list_plans
2026-03-06 20:20:22 - captured_flow - INFO - [FLOW] Loaded module: close_plan
2026-03-06 20:20:22 - captured_flow - INFO - [FLOW] Loaded module: create_plan
2026-03-06 20:20:39 - captured_flow - INFO - [FLOW] Skipped post_close_runner - no handle_command()
2026-03-06 20:20:39 - captured_flow - INFO - [FLOW] Loaded module: aggregate_central
2026-03-06 20:20:39 - captured_flow - INFO - [FLOW] Loaded module: registry_monitor
2026-03-06 20:20:39 - captured_flow - INFO - [FLOW] Loaded module: restore_plan
2026-03-06 20:20:39 - captured_flow - INFO - [FLOW] Loaded module: list_plans
2026-03-06 20:20:39 - captured_flow - INFO - [FLOW] Loaded module: close_plan
2026-03-06 20:20:39 - captured_flow - INFO - [FLOW] Loaded module: create_plan
2026-03-06 20:21:02 - captured_flow - INFO - [FLOW] Skipped post_close_runner - no handle_command()
2026-03-06 20:21:02 - captured_flow - INFO - [FLOW] Loaded module: aggregate_central
2026-03-06 20:21:02 - captured_flow - INFO - [FLOW] Loaded module: registry_monitor
2026-03-06 20:21:02 - captured_flow - INFO - [FLOW] Loaded module: restore_plan
2026-03-06 20:21:02 - captured_flow - INFO - [FLOW] Loaded module: list_plans
2026-03-06 20:21:02 - captured_flow - INFO - [FLOW] Loaded module: close_plan
2026-03-06 20:21:02 - captured_flow - INFO - [FLOW] Loaded module: create_plan
2026-03-06 20:37:06 - captured_flow - INFO - [FLOW] Skipped post_close_runner - no handle_command()
2026-03-06 20:37:06 - captured_flow - INFO - [FLOW] Loaded module: aggregate_central
2026-03-06 20:37:06 - captured_flow - INFO - [FLOW] Loaded module: registry_monitor
2026-03-06 20:37:06 - captured_flow - INFO - [FLOW] Loaded module: restore_plan
2026-03-06 20:37:06 - captured_flow - INFO - [FLOW] Loaded module: list_plans
2026-03-06 20:37:06 - captured_flow - INFO - [FLOW] Loaded module: close_plan
2026-03-06 20:37:06 - captured_flow - INFO - [FLOW] Loaded module: create_plan
2026-03-06 20:38:04 - captured_flow - INFO - [FLOW] Skipped post_close_runner - no handle_command()
2026-03-06 20:38:04 - captured_flow - INFO - [FLOW] Loaded module: aggregate_central
2026-03-06 20:38:04 - captured_flow - INFO - [FLOW] Loaded module: registry_monitor
2026-03-06 20:38:04 - captured_flow - INFO - [FLOW] Loaded module: restore_plan
2026-03-06 20:38:04 - captured_flow - INFO - [FLOW] Loaded module: list_plans
2026-03-06 20:38:04 - captured_flow - INFO - [FLOW] Loaded module: close_plan
2026-03-06 20:38:04 - captured_flow - INFO - [FLOW] Loaded module: create_plan
-2
View File
@@ -1,2 +0,0 @@
2026-03-06 19:25:53 - captured_list_plans - INFO - [list_plans] No plans in registry
2026-03-06 19:26:49 - captured_list_plans - INFO - [list_plans] Listed plans (filter: open)
-2
View File
@@ -1,2 +0,0 @@
2026-03-06 20:19:23 - captured_monitor_module - INFO - Starting unified monitoring (args: [])
2026-03-06 20:19:23 - captured_monitor_module - INFO - All monitoring threads started
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "aipass"
version = "1.0.0"
version = "2.0.0"
description = "Orchestration framework for autonomous AI agent ecosystems"
readme = "README.md"
license = "MIT"
-1
View File
@@ -1 +0,0 @@
2026-03-06 19:24:36 - captured_seedgo_verify - INFO - Seedgo verify: 5/5 (100%)
-78
View File
@@ -1,78 +0,0 @@
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Starting standards compliance check on src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:42 - captured_standards_checklist - ERROR - [standards_checklist] Error reading branch registry: name 'BRANCH_REGISTRY_PATH' is not defined
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running IMPORTS standard check on src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] IMPORTS check complete: 0/100
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running ARCHITECTURE standard check on src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] ARCHITECTURE check complete: 0/100
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running NAMING standard check on src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] NAMING check complete: 0/100
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running CLI standard check on src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] CLI check complete: 0/100
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running HANDLERS standard check on src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] HANDLERS check complete: 0/100
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running MODULES standard check on src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] MODULES check complete: 0/100
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running DOCUMENTATION standard check on src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] DOCUMENTATION check complete: 0/100
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running JSON_STRUCTURE standard check on src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] JSON_STRUCTURE check complete: 0/100
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running TESTING standard check on src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] TESTING check complete: 0/100
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running ERROR_HANDLING standard check on src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] ERROR_HANDLING check complete: 0/100
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running ENCAPSULATION standard check on src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] ENCAPSULATION check complete: 0/100
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running TRIGGER standard check on src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] TRIGGER check complete: 0/100
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running LOG_LEVEL standard check on src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] LOG_LEVEL check complete: 0/100
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running CLI_FLAGS standard check on src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] CLI_FLAGS check complete: 0/100
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running LOG_HANDLER standard check on src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] LOG_HANDLER check complete: 0/100
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running LOG_VISIBILITY standard check on src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] LOG_VISIBILITY check complete: 0/100
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running PERMISSION_FLAGS standard check on src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] PERMISSION_FLAGS check complete: 0/100
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running SHEBANG standard check on src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] SHEBANG check complete: 0/100
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Standards check complete: 0% average compliance
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Starting standards compliance check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:46 - captured_standards_checklist - ERROR - [standards_checklist] Error reading branch registry: name 'BRANCH_REGISTRY_PATH' is not defined
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running IMPORTS standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] IMPORTS check complete: 80/100
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running ARCHITECTURE standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] ARCHITECTURE check complete: 100/100
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running NAMING standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] NAMING check complete: 100/100
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running CLI standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] CLI check complete: 100/100
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running HANDLERS standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] HANDLERS check complete: 100/100
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running MODULES standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] MODULES check complete: 60/100
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running DOCUMENTATION standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] DOCUMENTATION check complete: 100/100
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running JSON_STRUCTURE standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] JSON_STRUCTURE check complete: 100/100
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running TESTING standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] TESTING check complete: 100/100
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running ERROR_HANDLING standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] ERROR_HANDLING check complete: 66/100
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running ENCAPSULATION standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] ENCAPSULATION check complete: 100/100
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running TRIGGER standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] TRIGGER check complete: 100/100
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running LOG_LEVEL standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] LOG_LEVEL check complete: 100/100
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running CLI_FLAGS standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] CLI_FLAGS check complete: 100/100
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running LOG_HANDLER standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] LOG_HANDLER check complete: 100/100
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running LOG_VISIBILITY standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] LOG_VISIBILITY check complete: 100/100
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running PERMISSION_FLAGS standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] PERMISSION_FLAGS check complete: 100/100
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running SHEBANG standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] SHEBANG check complete: 100/100
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Standards check complete: 94% average compliance
@@ -1,8 +0,0 @@
{
"permissions": {
"allow": [],
"deny": [],
"ask": []
},
"enabledMcpjsonServers": []
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"metadata": {
"version": "1.0.0",
"created": "2026-03-06T03:16:10.842297",
"created": "2026-03-06T14:27:27.887566",
"description": "Standards bypass configuration for this branch"
},
"bypass": [],
+53 -276
View File
@@ -1,308 +1,85 @@
# AI_MAIL - Branch Documentation
# ai_mail
## Overview
Inter-agent messaging for AIPass. File-based email system that lets agents send, receive, and process messages using `@branch` addresses. No SMTP, no external services — just JSON files and symbolic routing.
**Location**: `/home/aipass/aipass_core/ai_mail`
**Profile**: Communication Infrastructure
**Purpose**: Branch-to-branch email system providing file-based messaging for AI branches
**Created**: 2025-11-07
## What I Do
AI_MAIL provides internal email system for AI branches within AIPass. Core responsibilities include:
- Deliver email messages between AI branches
- Manage inbox, sent, and deleted mailboxes for each branch
- Track email lifecycle: new → opened → closed
- Support reply chains with auto-close behavior
- Auto-generate per-branch configs (user_config.json in [branch]_json/)
- Auto-detect calling branch via PWD/CWD for sender identity
- Update dashboard on delivery (DASHBOARD.local.json)
- Dispatch daemon: continuous polling engine spawns agents for `--dispatch` emails
- Single instance per branch locking (PID-based dispatch lock)
- Reply chain validation (sender must match dispatched_to)
- Maintain 100% CLI standards compliance
- Preserve branch email identities (no impersonation)
## What I Don't Do
- No SMTP/IMAP protocols
- No external email delivery
- No human email accounts
- No centralized identity (each branch has independent config)
- No manual setup required from branches
## How I Work
File-based architecture using JSON for message storage. PWD auto-detection finds calling branch, checks for local config in [branch]_json/, generates config if missing, sends from detected branch identity.
3-layer handler architecture:
- **Modules**: Orchestrate commands
- **Handlers**: Execute business logic
- **Prax**: Provides logging infrastructure
## Architecture
**Pattern**: Modular architecture
**Structure**: apps/ directory with modules/ and handlers/ subdirectories
**Orchestrator**: apps/ai_mail.py - auto-discovers and routes to modules
**Module Interface**: All modules implement handle_command(args) -> bool
### Directory Structure
```
apps/
├── ai_mail.py # Main orchestrator
├── __init__.py
├── modules/
│ ├── email.py # Core email functionality
│ ├── branch_ping.py # Branch health monitoring
│ ├── dispatch.py # Dispatch status tracking
│ └── extensions/ # Module extensions
├── handlers/
│ ├── central_writer.py # Central file updates
│ ├── dispatch/ # Dispatch operations
│ │ ├── daemon.py # Continuous dispatch daemon (polling + spawning)
│ │ ├── pending_work.py # Per-branch pending work tracking
│ │ └── status.py # Dispatch log and status
│ ├── email/ # Email operations
│ │ ├── create.py # Create email messages
│ │ ├── delivery.py # Deliver to branch inboxes (write-only, no spawning)
│ │ ├── format.py # Email formatting
│ │ ├── footer.py # Auto-footer for outgoing emails
│ │ ├── header.py # Email header formatting
│ │ ├── inbox_cleanup.py # Inbox cleanup operations
│ │ ├── inbox_lock.py # Inbox file locking
│ │ ├── inbox_ops.py # Inbox operations
│ │ ├── lock_utils.py # PID-based dispatch lock per branch
│ │ ├── purge.py # Auto-purge sent/deleted when >10 items
│ │ └── reply.py # Reply handling with chain validation
│ ├── json_utils/ # JSON operations
│ │ └── json_handler.py # JSON file operations
│ ├── monitoring/ # System monitoring
│ │ ├── data_ops.py # Data operations
│ │ ├── errors.py # Error handling
│ │ └── memory.py # Memory health checks
│ ├── persistence/ # Data persistence
│ │ └── json_ops.py # JSON persistence ops
│ ├── registry/ # Branch registry
│ │ ├── load.py # Load registry
│ │ ├── read.py # Read registry data
│ │ ├── update.py # Update registry
│ │ └── validate.py # Validate registry
│ ├── trigger/ # Event consumers
│ │ └── error_handler.py # Handle error_detected events
│ └── users/ # User/branch config
│ ├── branch_detection.py # Auto-detect calling branch
│ ├── config_generator.py # Generate branch configs
│ ├── load.py # Load configurations
│ └── user.py # User operations
├── extensions/ # App extensions (placeholder)
├── plugins/ # Plugins (placeholder)
└── json_templates/ # JSON templates
├── custom/ # Custom templates
├── default/ # Default templates
└── registry/ # Registry templates
```
### Modules
- `email` - Core email functionality (send, inbox, view, reply, close, sent, contacts)
- `branch_ping` - Branch memory health monitoring
- `dispatch` - Dispatch status tracking, log, and daemon management
### Handlers (by domain)
**Dispatch**: daemon, pending_work, status
**Email**: create, delivery, footer, format, header, inbox_cleanup, inbox_lock, inbox_ops, lock_utils, purge, reply
**JSON Utils**: json_handler
**Monitoring**: data_ops, errors, memory
**Persistence**: json_ops
**Registry**: load, read, update, validate
**Trigger**: error_handler
**Users**: branch_detection, config_generator, load, user
**Status:** Building. Core email workflow (send/inbox/reply/close) is functional. Dispatch system is working.
## Usage
Primary commands: `inbox`, `view`, `reply`, `close`, `send`, `sent`, `contacts`, `ping`, `dispatch`.
### Email Lifecycle v2
AI_MAIL uses a 3-state email model:
**States:**
- `new` - Email just delivered, never viewed
- `opened` - Email content has been viewed, awaiting resolution
- `closed` - Email resolved (replied or dismissed), archived to deleted
**Flow:**
```
Email sent → inbox (status: "new")
↓
view <id> → status: "opened"
↓
┌──────────┴──────────┐
↓ ↓
reply <id> "msg" close <id>
↓ ↓
auto-closes closes
└──────────┬──────────┘
↓
archived to deleted/
```
### Basic Commands
### CLI (via drone)
```bash
# Send email
drone @ai_mail send @recipient "Subject" "Message"
# Send a message
drone @ai_mail send @flow "Bug Report" "Found an issue in plan closing"
# Send with dispatch (auto-execute at recipient)
drone @ai_mail send @recipient "Task" "Details" --dispatch
# Broadcast to all branches
drone @ai_mail send @all "Announcement" "Message"
# Check inbox (shows new + opened emails)
# Check inbox
drone @ai_mail inbox
# View email content (marks as opened)
# View a message (marks as opened)
drone @ai_mail view <message_id>
# Reply to email (sends reply + auto-closes original)
drone @ai_mail reply <message_id> "Your reply message"
# Reply (auto-closes original)
drone @ai_mail reply <message_id> "Fixed in v2.1"
# Close email without replying (archives to deleted)
# Close without reply
drone @ai_mail close <message_id>
# View sent messages
drone @ai_mail sent
# List contacts
drone @ai_mail contacts
# Send with dispatch flag (recipient auto-executes the task)
drone @ai_mail send @flow "Task" "Details here" --dispatch
```
### Backward Compatibility
### Python
```bash
# 'read' command now behaves like 'view'
# (marks as opened, does NOT archive)
drone @ai_mail read <message_id>
```python
from aipass.ai_mail.apps.modules.email import handle_command
# Module interface — all commands go through handle_command
handle_command(["send", "@flow", "Subject", "Message body"])
handle_command(["inbox"])
```
### Dispatch System (v3.0)
## Email Lifecycle
The `--dispatch` flag marks emails for autonomous execution. The **dispatch daemon** polls inboxes and spawns agents — delivery.py is write-only.
Messages follow a 3-state model:
**Architecture:**
- `delivery.py` writes to inbox + sends desktop notification. No spawning.
- `daemon.py` polls all branch inboxes every 5 min for `--dispatch` emails
- Agents spawned via `claude -c -p` from the branch's CWD (auto-continues most recent session)
- Agents are ephemeral (wake, do work, exit). The daemon is the continuity.
- All safety limits configured in `safety_config.json`
**Running the daemon:**
```bash
python3 apps/handlers/dispatch/daemon.py # Standalone
ai_mail dispatch daemon # Via module
tmux new-session -d -s dispatch 'python3 apps/handlers/dispatch/daemon.py'
```
new → opened → closed
```
**Safety features:**
- **Kill switch**: `touch /home/aipass/.aipass/autonomous_pause` freezes all dispatches
- **Max turns**: 15 per wake (configurable)
- **Max dispatches/branch/day**: 10 (configurable, resets at midnight)
- **Single instance lock**: PID-based `.dispatch.lock` prevents concurrent agents per branch
- **DEV_CENTRAL protection**: @dev_central is never auto-dispatched
- **Notification throttling**: Max 3 desktop notifications per recipient in 30s window
- **Stale lock timeout**: 10 minutes (auto-cleanup of dead locks)
- **new** — Delivered to inbox, never viewed
- **opened** — Viewed by recipient, awaiting action
- **closed** — Replied or dismissed, archived automatically
### Direct Module Access
## Dispatch System
```bash
# Email operations
python3 apps/ai_mail.py email [command]
The `--dispatch` flag marks emails for autonomous execution. A polling daemon watches inboxes and spawns agents to process dispatch emails automatically.
# Branch health check
python3 apps/ai_mail.py ping
- Agents are ephemeral (wake, do work, exit)
- Safety limits: max turns per wake, max dispatches per branch per day
- PID-based locking prevents concurrent agents per branch
- Failed agents trigger bounce emails back to sender
# Dispatch status
python3 apps/ai_mail.py dispatch status
## Architecture
# Start dispatch daemon
python3 apps/ai_mail.py dispatch daemon
Follows the standard AIPass 3-layer pattern:
```
ai_mail/
├── apps/
│ ├── ai_mail.py # Entry point (auto-discovers modules)
│ ├── modules/
│ │ ├── email.py # Send, inbox, view, reply, close, contacts
│ │ ├── dispatch.py # Dispatch status, daemon, wake
│ │ └── branch_ping.py # Branch health monitoring
│ └── handlers/
│ ├── email/ # Delivery, formatting, inbox ops, purge
│ ├── dispatch/ # Daemon, wake, monitoring
│ ├── registry/ # Branch registry read/update
│ └── users/ # Branch detection, config generation
```
## Integration Points
## Dependencies
**Depends On**:
- PRAX - Logging infrastructure
- CLI - Display and formatting
- DRONE - Command routing
**Provides To**:
- All branches - Email communication
- AIPASS - System coordination
## Memory System
### Core Files
- `AI_MAIL.id.json` - Branch identity and architecture
- `AI_MAIL.local.json` - Session history (max 600 lines)
- `AI_MAIL.observations.json` - Collaboration patterns (max 600 lines)
- `AI_MAIL.ai_mail.json` - Email dashboard
### Mailbox Structure
- `ai_mail.local/inbox.json` - Incoming messages (new + opened status)
- `ai_mail.local/sent/` - Sent messages (individual files, auto-purged at >10)
- `ai_mail.local/deleted/` - Closed/archived messages (individual files, auto-purged at >10)
- `ai_mail.local/.dispatch.lock` - PID-based dispatch lock (when agent active)
- `ai_mail.local/daemon_state.json` - Daemon daily counts and session tracking
- `ai_mail.local/dispatch_daemon.log` - Daemon activity log
- `safety_config.json` - Kill switch, poll interval, max turns/dispatches, autonomous branch list
**Email Schema (v2):**
```json
{
"id": "abc123",
"from": "@sender",
"from_name": "SENDER Branch",
"to": "@recipient",
"subject": "Subject line",
"message": "Message body",
"status": "new", // new | opened | closed
"timestamp": "2025-11-30 12:00:00",
"thread_id": null, // For future threading
"reply_to": null // Original message ID if reply
}
```
### Health Monitoring
- 🟢 **Healthy**: Under 80% of limits
- 🟡 **Warning**: 80-100% of limits
- 🔴 **Critical**: Over limits (compression needed)
## Standards Compliance
Seed audit score: **89%** (passing)
Following AIPass code standards:
- 100% CLI integration
- Proper import patterns
- 3-layer architecture
- Handler independence
- Auto-discovery patterns
## Development Notes
- Code is truth - fail honestly
- Simple solutions over complex architecture
- Test incrementally, preserve what works
- Each instance isolated with own memory
- Never explain context again - memories persist
---
*Last Updated: 2026-02-28*
*Maintained by: AI_MAIL Branch*
- `prax` — Logging
- `cli` — Display formatting
- `drone` — Command routing and `@branch` resolution
+1
View File
@@ -0,0 +1 @@
"""AI Mail - Inter-branch messaging for AIPass."""
@@ -1,9 +0,0 @@
{
"module_name": "branch_ping",
"version": "1.0.0",
"timestamp": "2025-11-13",
"config": {
"auto_save": true,
"enabled": true
}
}
@@ -1,8 +0,0 @@
{
"module_name": "branch_ping",
"created": "2025-11-13",
"last_updated": "2025-11-13",
"operations_total": 0,
"operations_successful": 0,
"operations_failed": 0
}
@@ -1,11 +0,0 @@
[
{
"timestamp": "2026-03-06T20:33:19.802974",
"operation": "ping_executed",
"data": {
"branch": "AI_MAIL",
"local_count": 0,
"obs_count": 0
}
}
]
@@ -1,23 +0,0 @@
{
"last_updated": "2026-03-06T20:33:19.797905",
"active_branches": {
"/home/coder/workspace/src/aipass/ai_mail": {
"branch_name": "AI_MAIL",
"last_ping": "2026-03-06T20:33:19.797892",
"local_md": {
"line_count": 0,
"status": "\ud83d\udfe2 Healthy"
},
"observations_md": {
"line_count": 0,
"status": "\ud83d\udfe2 Healthy"
}
}
},
"statistics": {
"total_branches": 1,
"green_status": 0,
"yellow_status": 0,
"red_status": 0
}
}
+2 -7
View File
@@ -33,13 +33,8 @@ from typing import Dict, Any, Optional, List
# Handle broken pipe gracefully (e.g. output piped to head)
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
# Pre-import dashboard to ensure path is resolved before module discovery
# This prevents import failures in handlers that depend on devpulse dashboard
try:
from aipass.dev_central.devpulse.apps.modules.dashboard import update_section as _update_section # noqa: F401
except ImportError:
# Dashboard is optional - branch works without it
_update_section = None # type: ignore
# Dashboard integration (optional, requires dev_central package)
_update_section = None # type: ignore
# AIPass infrastructure imports
from aipass.prax.apps.modules.logger import system_logger as logger
+1 -1
View File
@@ -43,7 +43,7 @@ def _find_real_caller():
def _extract_branch_name(filepath: str) -> str:
"""Extract branch name from a file path."""
parts = filepath.split("/")
parts = Path(filepath).parts
for i, part in enumerate(parts):
if part in ("aipass", "MEMORY_BANK", "Nexus"):
if i + 1 < len(parts):
@@ -22,7 +22,7 @@ Aggregates branch inbox stats and writes to AI_MAIL.central.json.
This file serves as AI_MAIL's API output for AIPASS dashboard integration.
Architecture:
- Scans all ai_mail.local/inbox.json files across the system
- Scans all .ai_mail.local/inbox.json files across the system
- Calculates per-branch unread/total message counts
- Writes aggregated stats to AI_CENTRAL/AI_MAIL.central.json (under repo root)
"""
@@ -71,9 +71,9 @@ BRANCH_REGISTRY = _REPO_ROOT / "AIPASS_REGISTRY.json"
def find_all_inbox_files() -> List[Path]:
"""
Find all inbox.json files in ai_mail.local directories.
Find all inbox.json files in .ai_mail.local directories.
Scans the repo root directory for ai_mail.local/inbox.json files.
Scans the repo root directory for .ai_mail.local/inbox.json files.
Excludes backup directories to avoid counting archived data.
Returns:
@@ -84,8 +84,8 @@ def find_all_inbox_files() -> List[Path]:
"""
inbox_files = []
# Search pattern: any directory ending in ai_mail.local containing inbox.json
for ai_mail_dir in _REPO_ROOT.rglob("ai_mail.local"):
# Search pattern: any directory ending in .ai_mail.local containing inbox.json
for ai_mail_dir in _REPO_ROOT.rglob(".ai_mail.local"):
# Skip backup/archive directories (but NOT backup_system branch itself)
path_str = str(ai_mail_dir)
if ".backup" in path_str or ".archive" in path_str or "/backups/" in path_str:
@@ -102,10 +102,10 @@ def extract_branch_name(inbox_path: Path) -> str:
"""
Extract branch name from inbox.json path.
Given: .../seed/ai_mail.local/inbox.json
Given: .../seed/.ai_mail.local/inbox.json
Returns: SEED
Given: .../prax/ai_mail.local/inbox.json
Given: .../prax/.ai_mail.local/inbox.json
Returns: PRAX
Args:
@@ -114,7 +114,7 @@ def extract_branch_name(inbox_path: Path) -> str:
Returns:
Uppercase branch name
"""
# Parent of ai_mail.local is the branch directory
# Parent of .ai_mail.local is the branch directory
branch_dir = inbox_path.parent.parent
branch_name = branch_dir.name.upper()
@@ -280,7 +280,7 @@ def update_central() -> Dict[str, Any]:
Should be called whenever mail is sent/received to keep dashboard in sync.
Process:
1. Scans all branch ai_mail.local/inbox.json files
1. Scans all branch .ai_mail.local/inbox.json files
2. Aggregates unread and total message counts per branch
3. Calculates system-wide totals
4. Writes results to AI_CENTRAL/AI_MAIL.central.json (under repo root)
@@ -64,9 +64,9 @@ _AI_MAIL_DIR = Path(__file__).resolve().parents[3] # ai_mail/
# Paths
CONFIG_FILE = _AI_MAIL_DIR / "safety_config.json"
DAEMON_STATE_FILE = _AI_MAIL_DIR / "ai_mail.local" / "daemon_state.json"
DAEMON_LOG_FILE = _AI_MAIL_DIR / "ai_mail.local" / "dispatch_daemon.log"
DAEMON_PID_FILE = _AI_MAIL_DIR / "ai_mail.local" / "daemon.pid"
DAEMON_STATE_FILE = _AI_MAIL_DIR / ".ai_mail.local" / "daemon_state.json"
DAEMON_LOG_FILE = _AI_MAIL_DIR / ".ai_mail.local" / "dispatch_daemon.log"
DAEMON_PID_FILE = _AI_MAIL_DIR / ".ai_mail.local" / "daemon.pid"
BRANCH_REGISTRY = _REPO_ROOT / "AIPASS_REGISTRY.json"
# Telegram notifications (scheduler bot)
@@ -168,7 +168,7 @@ def _set_session_name(branch_path: Path, name: str) -> bool:
def _check_lock(branch_path: Path) -> Optional[Dict[str, Any]]:
"""Check if branch has an active dispatch lock. Returns lock data or None."""
lock_file = branch_path / "ai_mail.local" / ".dispatch.lock"
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
if not lock_file.exists():
return None
try:
@@ -212,7 +212,7 @@ def _check_lock(branch_path: Path) -> Optional[Dict[str, Any]]:
def _acquire_lock(branch_path: Path, pid: int) -> tuple[bool, str]:
"""Acquire dispatch lock for branch. Atomic creation via O_CREAT|O_EXCL."""
lock_file = branch_path / "ai_mail.local" / ".dispatch.lock"
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
lock_data = {
"pid": pid,
"timestamp": datetime.now().isoformat(),
@@ -336,7 +336,7 @@ def check_inbox_for_dispatch(branch_path: Path) -> Optional[Dict[str, Any]]:
Also retries opened dispatch emails orphaned for >30 min (agent crashed
before completing).
"""
inbox_file = branch_path / "ai_mail.local" / "inbox.json"
inbox_file = branch_path / ".ai_mail.local" / "inbox.json"
inbox_data = _read_json(inbox_file)
if inbox_data is None:
return None
@@ -372,7 +372,7 @@ def check_inbox_for_dispatch(branch_path: Path) -> Optional[Dict[str, Any]]:
def count_new_emails(branch_path: Path) -> int:
"""Count new (unread) emails in a branch's inbox."""
inbox_file = branch_path / "ai_mail.local" / "inbox.json"
inbox_file = branch_path / ".ai_mail.local" / "inbox.json"
inbox_data = _read_json(inbox_file)
if inbox_data is None:
return 0
@@ -409,7 +409,7 @@ def spawn_agent(
subject = message.get("subject", "")
max_turns = config.get("max_turns_per_wake", 15)
lock_file_path = str(branch_path / "ai_mail.local" / ".dispatch.lock")
lock_file_path = str(branch_path / ".ai_mail.local" / ".dispatch.lock")
# Prompt — no lock cleanup instruction (dispatch_monitor handles it)
prompt = (
@@ -426,7 +426,7 @@ def spawn_agent(
# Build monitor command (dispatch_monitor wraps claude, handles bounce + lock cleanup)
MONITOR_SCRIPT = Path(__file__).resolve().parent / "dispatch_monitor.py"
LOG_DIR = branch_path / "ai_mail.local"
LOG_DIR = branch_path / ".ai_mail.local"
LOG_DIR.mkdir(parents=True, exist_ok=True)
STDERR_LOG = str(LOG_DIR / "agent_stderr.log")
@@ -504,6 +504,8 @@ def is_protected_branch(branch_email: str) -> bool:
def _read_session_type(pid_str: str) -> str:
"""Read AIPASS_SESSION_TYPE from /proc/{pid}/environ. Returns 'interactive' if unset."""
if sys.platform != "linux":
return 'interactive'
try:
with open(f'/proc/{pid_str}/environ', 'rb') as f:
data = f.read()
@@ -540,6 +542,8 @@ def _is_branch_occupied(branch_path: Path) -> bool:
if not pid_str:
continue
try:
if sys.platform != "linux":
continue
cwd = os.readlink(f'/proc/{pid_str}/cwd')
if Path(cwd).resolve() == resolved:
session_type = _read_session_type(pid_str)
@@ -124,7 +124,7 @@ def main():
if key.startswith("CLAUDE") or key == "AIPASS_BOT_ID":
spawn_env.pop(key)
# Extract CWD from lock file path (branch_path/ai_mail.local/.dispatch.lock)
# Extract CWD from lock file path (branch_path/.ai_mail.local/.dispatch.lock)
lock_path = Path(lock_file)
branch_path = lock_path.parent.parent
cwd = str(branch_path)
@@ -32,8 +32,8 @@ PENDING_WORK_FILENAME = ".pending_work.json"
def _get_pending_path(branch_path: Path) -> Path:
"""Get the pending work file path for a branch."""
if branch_path == Path("/"):
return Path.cwd() / "ai_mail.local" / PENDING_WORK_FILENAME
return branch_path / "ai_mail.local" / PENDING_WORK_FILENAME
return Path.cwd() / ".ai_mail.local" / PENDING_WORK_FILENAME
return branch_path / ".ai_mail.local" / PENDING_WORK_FILENAME
def load_pending_work(branch_path: Path) -> Dict[str, Any]:
@@ -29,7 +29,7 @@ from typing import Dict, Any, List, Optional
# Dispatch log location (package-relative)
_AI_MAIL_DIR = Path(__file__).resolve().parents[3] # ai_mail/
DISPATCH_LOG_FILE = _AI_MAIL_DIR / "ai_mail.local" / "dispatch_log.json"
DISPATCH_LOG_FILE = _AI_MAIL_DIR / ".ai_mail.local" / "dispatch_log.json"
def load_dispatch_log() -> List[Dict[str, Any]]:
@@ -122,7 +122,7 @@ def _read_json(filepath: Path) -> Optional[dict]:
def _check_lock(branch_path: Path) -> Optional[dict]:
"""Check if branch has an active dispatch lock. Returns lock data or None."""
lock_file = branch_path / "ai_mail.local" / ".dispatch.lock"
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
if not lock_file.exists():
return None
try:
@@ -158,7 +158,7 @@ def _check_lock(branch_path: Path) -> Optional[dict]:
def _acquire_lock(branch_path: Path, pid: int) -> Tuple[bool, str]:
"""Acquire dispatch lock for branch. Atomic creation."""
lock_file = branch_path / "ai_mail.local" / ".dispatch.lock"
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
lock_data = {
"pid": pid,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
@@ -218,6 +218,8 @@ def _set_session_name(branch_path: Path, name: str) -> bool:
def _read_session_type(pid_str: str) -> str:
"""Read AIPASS_SESSION_TYPE from /proc/{pid}/environ. Returns 'interactive' if unset."""
if sys.platform != "linux":
return 'interactive'
try:
with open(f'/proc/{pid_str}/environ', 'rb') as f:
data = f.read()
@@ -248,6 +250,8 @@ def _is_branch_occupied(branch_path: Path) -> bool:
if not pid_str:
continue
try:
if sys.platform != "linux":
continue
cwd = os.readlink(f'/proc/{pid_str}/cwd')
if str(Path(cwd).resolve()) == resolved:
session_type = _read_session_type(pid_str)
@@ -283,11 +287,12 @@ def _check_pid_alive(pid: int) -> bool:
"""Check if a process is alive (not zombie)."""
try:
os.kill(pid, 0)
# Also verify not zombie
with open(f'/proc/{pid}/status', 'r') as f:
for line in f:
if line.startswith('State:'):
return 'Z' not in line
# Also verify not zombie via /proc (Linux only)
if sys.platform == "linux":
with open(f'/proc/{pid}/status', 'r') as f:
for line in f:
if line.startswith('State:'):
return 'Z' not in line
return True
except (ProcessLookupError, FileNotFoundError):
return False
@@ -376,7 +381,7 @@ def wake_branch(branch_email: str, custom_message: Optional[str] = None,
config = _load_config()
max_turns = config.get("max_turns_per_wake", 50)
lock_file_path = str(branch_path / "ai_mail.local" / ".dispatch.lock")
lock_file_path = str(branch_path / ".ai_mail.local" / ".dispatch.lock")
if custom_message:
prompt = f"Hi. {custom_message} "
else:
@@ -406,7 +411,7 @@ def wake_branch(branch_email: str, custom_message: Optional[str] = None,
_set_session_name(branch_path, session_label)
# Step 7: Spawn via dispatch_monitor
log_dir = branch_path / "ai_mail.local"
log_dir = branch_path / ".ai_mail.local"
log_dir.mkdir(parents=True, exist_ok=True)
stderr_log = str(log_dir / "agent_stderr.log")
@@ -459,7 +464,7 @@ def wake_branch(branch_email: str, custom_message: Optional[str] = None,
else:
status.fail("alive", f"Agent died immediately (PID {monitor_pid})")
# Clean up lock
lock_file = branch_path / "ai_mail.local" / ".dispatch.lock"
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
lock_file.unlink(missing_ok=True)
return status, False
@@ -154,7 +154,7 @@ def push_dashboard_update(branch_path: Path) -> bool:
Read branch inbox and push ai_mail section to dashboard.
This is the primary public function. It:
1. Reads the branch's ai_mail.local/inbox.json
1. Reads the branch's .ai_mail.local/inbox.json
2. Calculates section data (new, opened, total, oldest_unread_age, etc.)
3. Calls write_section() to update DASHBOARD.local.json
@@ -171,9 +171,9 @@ def push_dashboard_update(branch_path: Path) -> bool:
# Determine inbox path
if branch_path == Path("/"):
inbox_file = Path.cwd() / "ai_mail.local" / "inbox.json"
inbox_file = Path.cwd() / ".ai_mail.local" / "inbox.json"
else:
inbox_file = branch_path / "ai_mail.local" / "inbox.json"
inbox_file = branch_path / ".ai_mail.local" / "inbox.json"
# Read inbox (BYPASS: direct json.load - this is a data file, not a template)
if not inbox_file.exists():
@@ -229,7 +229,7 @@ def deliver_email_to_branch(
on_delivered: Optional[Callable] = None
) -> Tuple[bool, str]:
"""
Deliver email to target branch's ai_mail.local/inbox.json file.
Deliver email to target branch's .ai_mail.local/inbox.json file.
Appends message to inbox JSON messages array.
@@ -283,11 +283,11 @@ def deliver_email_to_branch(
branch_path = Path(branches[to_branch])
# Find the branch's ai_mail.local/inbox.json file
# Find the branch's .ai_mail.local/inbox.json file
if branch_path == Path("/") or branch_path == _REPO_ROOT:
inbox_file = _REPO_ROOT / "ai_mail.local" / "inbox.json"
inbox_file = _REPO_ROOT / ".ai_mail.local" / "inbox.json"
else:
inbox_file = branch_path / "ai_mail.local" / "inbox.json"
inbox_file = branch_path / ".ai_mail.local" / "inbox.json"
if not inbox_file.exists():
# Auto-provision inbox for new branches (self-healing)
@@ -80,7 +80,7 @@ def _save_to_deleted_folder(mailbox_path: Path, message: Dict) -> Path:
Save a message to the deleted/ folder as individual JSON file.
Args:
mailbox_path: Path to ai_mail.local directory
mailbox_path: Path to .ai_mail.local directory
message: Email message dict to archive
Returns:
@@ -111,7 +111,7 @@ def _migrate_deleted_json_if_exists(mailbox_path: Path) -> int:
Migrate existing deleted.json to deleted/ directory on first access.
Args:
mailbox_path: Path to ai_mail.local directory
mailbox_path: Path to .ai_mail.local directory
Returns:
Number of messages migrated
@@ -165,7 +165,7 @@ def mark_read_and_archive(branch_path: Path, message_id: str) -> Tuple[bool, str
Returns:
Tuple of (success: bool, message: str)
"""
mailbox_path = branch_path / "ai_mail.local"
mailbox_path = branch_path / ".ai_mail.local"
inbox_file = mailbox_path / "inbox.json"
if not inbox_file.exists():
@@ -240,7 +240,7 @@ def mark_all_read_and_archive(branch_path: Path) -> Tuple[bool, str, int]:
Returns:
Tuple of (success: bool, message: str, count: int)
"""
mailbox_path = branch_path / "ai_mail.local"
mailbox_path = branch_path / ".ai_mail.local"
inbox_file = mailbox_path / "inbox.json"
if not inbox_file.exists():
@@ -308,7 +308,7 @@ def _trigger_deleted_purge(branch_path: Path) -> None:
"""
try:
from aipass.ai_mail.apps.handlers.email.purge import purge_deleted_folder
mailbox_path = branch_path / "ai_mail.local"
mailbox_path = branch_path / ".ai_mail.local"
purge_deleted_folder(mailbox_path)
except Exception:
pass # Silent fail - purge is best-effort
@@ -331,7 +331,7 @@ def mark_as_opened(branch_path: Path, message_id: str) -> Tuple[bool, str, Optio
Returns:
Tuple of (success: bool, message: str, email_data: dict or None)
"""
inbox_file = branch_path / "ai_mail.local" / "inbox.json"
inbox_file = branch_path / ".ai_mail.local" / "inbox.json"
if not inbox_file.exists():
return False, f"Inbox not found: {inbox_file}", None
@@ -390,7 +390,7 @@ def mark_as_closed_and_archive(branch_path: Path, message_id: str, skip_post_ops
Returns:
Tuple of (success: bool, message: str)
"""
mailbox_path = branch_path / "ai_mail.local"
mailbox_path = branch_path / ".ai_mail.local"
inbox_file = mailbox_path / "inbox.json"
if not inbox_file.exists():
@@ -31,7 +31,7 @@ from datetime import datetime
# Standard logging
# Lock file name - placed in branch's ai_mail.local/ directory
# Lock file name - placed in branch's .ai_mail.local/ directory
LOCK_FILENAME = ".dispatch.lock"
# Stale lock timeout in seconds (10 minutes)
@@ -41,8 +41,8 @@ STALE_LOCK_TIMEOUT = 600
def _get_lock_path(branch_path: Path) -> Path:
"""Get the lock file path for a branch."""
if branch_path == Path("/"):
return Path.cwd() / "ai_mail.local" / LOCK_FILENAME
return branch_path / "ai_mail.local" / LOCK_FILENAME
return Path.cwd() / ".ai_mail.local" / LOCK_FILENAME
return branch_path / ".ai_mail.local" / LOCK_FILENAME
def _is_pid_running(pid: int) -> bool:
@@ -63,7 +63,7 @@ def purge_sent_folder(mailbox_path: Path) -> Dict[str, Any]:
Keeps 10 most recent emails, vectorizes and archives older ones.
Args:
mailbox_path: Path to ai_mail.local directory
mailbox_path: Path to .ai_mail.local directory
Returns:
Dict with success, purged_count, archived_paths
@@ -97,7 +97,7 @@ def purge_deleted_folder(mailbox_path: Path) -> Dict[str, Any]:
Keeps 10 most recent emails, vectorizes and archives older ones.
Args:
mailbox_path: Path to ai_mail.local directory
mailbox_path: Path to .ai_mail.local directory
Returns:
Dict with success, purged_count, archived_count
@@ -129,7 +129,7 @@ def _purge_email_files(mailbox_path: Path, files: List[Path], folder_type: str)
Purge list of email files (vectorize, archive, delete).
Args:
mailbox_path: Path to ai_mail.local directory
mailbox_path: Path to .ai_mail.local directory
files: List of file paths to purge
folder_type: "sent" or "deleted" for logging
@@ -246,7 +246,7 @@ def _archive_email_files(mailbox_path: Path, files: List[Path], folder_type: str
Archive email files to .archive/ directory.
Args:
mailbox_path: Path to ai_mail.local directory
mailbox_path: Path to .ai_mail.local directory
files: List of file paths to archive
folder_type: "sent" or "deleted" for subdirectory
@@ -280,7 +280,7 @@ def run_purge(mailbox_path: Path) -> Dict[str, Any]:
Convenience function to run both purges.
Args:
mailbox_path: Path to ai_mail.local directory
mailbox_path: Path to .ai_mail.local directory
Returns:
Dict with combined results
@@ -151,7 +151,7 @@ def send_reply(
return False, f"Failed to deliver reply: {error_msg}", None
# Save to sender's sent folder
sent_folder = from_branch_path / "ai_mail.local" / "sent"
sent_folder = from_branch_path / ".ai_mail.local" / "sent"
sent_folder.mkdir(parents=True, exist_ok=True)
reply_id = str(uuid.uuid4())[:8]
@@ -25,7 +25,7 @@ from pathlib import Path
# Infrastructure paths (package-relative)
_AI_MAIL_ROOT = Path(__file__).resolve().parents[3] # ai_mail/
AI_MAIL_JSON_DIR = _AI_MAIL_ROOT / "ai_mail_json"
AI_MAIL_JSON_DIR = _AI_MAIL_ROOT / ".ai_mail.local"
from aipass.ai_mail.apps.handlers.json_utils.json_handler import ( # noqa: F401
load_json,
@@ -32,7 +32,7 @@ import inspect
_AI_MAIL_ROOT = Path(__file__).resolve().parents[3] # ai_mail/
# Constants - Updated for AI_MAIL
AI_MAIL_JSON_DIR = _AI_MAIL_ROOT / "ai_mail_json"
AI_MAIL_JSON_DIR = _AI_MAIL_ROOT / ".ai_mail.local"
JSON_TEMPLATES_DIR = _AI_MAIL_ROOT / "apps" / "json_templates"
@@ -1,260 +0,0 @@
# =============================================
# META DATA HEADER
# Name: json_ops.py - Persistence Handler (JSON Operations)
# Date: 2025-11-15
# Version: 1.0.0
# Category: ai_mail/handlers/persistence
#
# CHANGELOG:
# - v1.0.0 (2025-11-15): Initial version with auto-creating & self-healing JSON system
#
# CODE STANDARDS:
# - Auto-creates JSON files from templates
# - Self-healing for corrupted files
# - Auto-detects calling module for logging
# - Implements log rotation based on config limits
# =============================================
"""
Persistence Handler - JSON Operations
Handles persistent data operations for ai_mail modules.
Manages default JSON files (config, data, log).
Auto-creating & self-healing JSON system.
"""
import json
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Any, Optional
import inspect
# Infrastructure paths (package-relative)
_AI_MAIL_ROOT = Path(__file__).resolve().parents[3] # ai_mail/
# Constants
AI_MAIL_JSON_DIR = _AI_MAIL_ROOT / "ai_mail_json"
JSON_TEMPLATES_DIR = _AI_MAIL_ROOT / "apps" / "json_templates"
def _get_caller_module_name() -> str:
"""
Auto-detect calling module name from call stack
Returns:
Module name (e.g., "imports_standard" from imports_standard.py)
"""
try:
stack = inspect.stack()
# Skip frames: [0]=this function, [1]=log_operation, [2]=actual caller
if len(stack) > 2:
caller_frame = stack[2]
caller_path = Path(caller_frame.filename)
module_name = caller_path.stem
# Validate module name
if module_name and not module_name.startswith('_'):
return module_name
# Fallback
return "unknown"
except Exception:
return "unknown"
def load_template(json_type: str, module_name: str) -> Any:
"""Load JSON template from template file"""
template_path = JSON_TEMPLATES_DIR / "default" / f"{json_type}.json"
if not template_path.exists():
return None
try:
with open(template_path, 'r', encoding='utf-8') as f:
template = json.load(f)
# Replace placeholders
template_str = json.dumps(template)
template_str = template_str.replace("{{MODULE_NAME}}", module_name)
template_str = template_str.replace("{{TIMESTAMP}}", datetime.now().date().isoformat())
return json.loads(template_str)
except Exception:
return None
def validate_json_structure(data: Any, json_type: str) -> bool:
"""Validate JSON structure matches expected type"""
if json_type == "config":
if not isinstance(data, dict):
return False
required = ["module_name", "version", "config"]
return all(key in data for key in required)
elif json_type == "data":
if not isinstance(data, dict):
return False
required = ["created", "last_updated"]
return all(key in data for key in required)
elif json_type == "log":
return isinstance(data, list)
return False
def get_json_path(module_name: str, json_type: str) -> Path:
"""Get path for module JSON file"""
filename = f"{module_name}_{json_type}.json"
return AI_MAIL_JSON_DIR / filename
def ensure_json_exists(module_name: str, json_type: str) -> bool:
"""Ensure JSON file exists, create from template if missing"""
AI_MAIL_JSON_DIR.mkdir(parents=True, exist_ok=True)
json_path = get_json_path(module_name, json_type)
if json_path.exists():
try:
with open(json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
if validate_json_structure(data, json_type):
return True
except Exception:
pass
template = load_template(json_type, module_name)
if template is None:
return False
try:
with open(json_path, 'w', encoding='utf-8') as f:
json.dump(template, f, indent=2, ensure_ascii=False)
return True
except Exception:
return False
def load_json(module_name: str, json_type: str) -> Optional[Any]:
"""Load JSON file, auto-create if missing"""
if not ensure_json_exists(module_name, json_type):
return None
json_path = get_json_path(module_name, json_type)
try:
with open(json_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception:
return None
def save_json(module_name: str, json_type: str, data: Any) -> bool:
"""Save JSON file"""
json_path = get_json_path(module_name, json_type)
if not validate_json_structure(data, json_type):
return False
if json_type == "data" and isinstance(data, dict):
data["last_updated"] = datetime.now().date().isoformat()
try:
with open(json_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
return True
except Exception:
return False
def ensure_module_jsons(module_name: str) -> bool:
"""Ensure all 3 JSON files exist for a module"""
ensure_json_exists(module_name, "config")
ensure_json_exists(module_name, "data")
ensure_json_exists(module_name, "log")
return True
def log_operation(operation: str, data: Dict[str, Any] | None = None, module_name: str | None = None) -> bool:
"""
Add entry to module log with automatic rotation
Auto-detects calling module if module_name not provided.
Implements config-controlled log limits to prevent unbounded growth.
When max_log_entries is reached, removes oldest entries (FIFO).
Args:
operation: Operation name to log
data: Optional data dict
module_name: Optional module name (auto-detected if not provided)
Returns:
True if successful, False otherwise
"""
# Auto-detect module name if not provided
if module_name is None:
module_name = _get_caller_module_name()
ensure_module_jsons(module_name)
# Load config to get max_log_entries
config = load_json(module_name, "config")
max_entries = 100 # Default
if config and "config" in config:
max_entries = config["config"].get("max_log_entries", 100)
# Load existing log
log = load_json(module_name, "log")
if log is None:
log = []
# Create new entry
entry: Dict[str, Any] = {
"timestamp": datetime.now().isoformat(),
"operation": operation
}
if data:
entry["data"] = data
# Add new entry
log.append(entry)
# Rotate if exceeds max (keep most recent entries)
if len(log) > max_entries:
log = log[-max_entries:]
return save_json(module_name, "log", log)
def increment_counter(module_name: str, counter_name: str, amount: int = 1) -> bool:
"""Increment a counter in data JSON"""
ensure_module_jsons(module_name)
data = load_json(module_name, "data")
if data is None:
return False
if counter_name not in data:
data[counter_name] = 0
data[counter_name] += amount
return save_json(module_name, "data", data)
def update_data_metrics(module_name: str, **metrics: Any) -> bool:
"""Update data metrics"""
ensure_module_jsons(module_name)
data = load_json(module_name, "data")
if data is None:
return False
for key, value in metrics.items():
data[key] = value
return save_json(module_name, "data", data)
@@ -39,7 +39,7 @@ from aipass.cli.apps.modules import console
# Constants
MODULE_NAME = "registry.update"
_AI_MAIL_ROOT = Path(__file__).resolve().parents[3] # ai_mail/
AI_MAIL_JSON = _AI_MAIL_ROOT / "ai_mail_json"
AI_MAIL_JSON = _AI_MAIL_ROOT / ".ai_mail.local"
REGISTRY_PATH = AI_MAIL_JSON / "local_memory_monitor_registry.json"
THRESHOLDS = {
"green": (0, 400),
@@ -67,8 +67,9 @@ def detect_branch_from_pwd() -> Dict | None:
None if no branch detected
"""
try:
# Get current working directory
cwd = Path.cwd()
# Use caller's CWD if passed by drone, otherwise fall back to process CWD
caller_cwd = os.environ.get("AIPASS_CALLER_CWD")
cwd = Path(caller_cwd) if caller_cwd else Path.cwd()
# Find branch root
branch_root = find_branch_root(cwd)
@@ -103,9 +104,12 @@ def find_branch_root(start_path: Path) -> Path | None:
# Walk up directory tree (max 10 levels to prevent infinite loop)
for _ in range(10):
# Check if this directory has a [BRANCH].id.json file
for file in current.glob("*.id.json"):
# Found a .id.json file - this is likely a branch root
# Check for dev-pass pattern: [BRANCH].id.json
for _ in current.glob("*.id.json"):
return current
# Check for public/trinity pattern: .trinity/passport.json
if (current / ".trinity" / "passport.json").exists():
return current
# Move up one level
@@ -134,13 +138,18 @@ def get_branch_info_from_registry(branch_path: Path) -> Dict | None:
with open(BRANCH_REGISTRY_PATH, 'r', encoding='utf-8') as f:
registry = json.load(f)
# Normalize branch_path for comparison
branch_path_str = str(branch_path.resolve())
registry_dir = BRANCH_REGISTRY_PATH.parent
branch_path_resolved = branch_path.resolve()
# Search registry for matching path
for branch in registry.get("branches", []):
if Path(branch["path"]).resolve() == Path(branch_path_str):
# Found match - return branch info
reg_path = Path(branch["path"])
# Resolve relative paths against registry location, not CWD
if not reg_path.is_absolute():
reg_path = (registry_dir / reg_path).resolve()
else:
reg_path = reg_path.resolve()
if reg_path == branch_path_resolved:
return branch
return None
@@ -54,8 +54,8 @@ def generate_local_config(branch_info: Dict) -> Dict:
# Generate display name from branch info
display_name = generate_display_name(branch_info)
# Generate mailbox path (ai_mail.local/ in branch directory)
mailbox_path = str(branch_path / "ai_mail.local")
# Generate mailbox path (.ai_mail.local/ in branch directory)
mailbox_path = str(branch_path / ".ai_mail.local")
config = {
"version": "1.0.0",
@@ -155,7 +155,7 @@ def create_local_config_file(branch_info: Dict, force: bool = False) -> Path | N
def create_mailbox_directory(branch_path: Path) -> Path | None:
"""
Create ai_mail.local/ mailbox directory for a branch.
Create .ai_mail.local/ mailbox directory for a branch.
Creates subdirectories: inbox/, sent/, deleted/
@@ -166,7 +166,7 @@ def create_mailbox_directory(branch_path: Path) -> Path | None:
Path to mailbox directory, or None if failed
"""
try:
mailbox_dir = branch_path / "ai_mail.local"
mailbox_dir = branch_path / ".ai_mail.local"
mailbox_dir.mkdir(parents=True, exist_ok=True)
# Create subdirectories
@@ -207,9 +207,9 @@ def setup_branch_for_aimail(branch_info: Dict, force: bool = False) -> bool:
Creates:
- ai_mail_config/user_config.json
- ai_mail.local/ mailbox directory
- ai_mail.local/inbox.json
- ai_mail.local/sent.json
- .ai_mail.local/ mailbox directory
- .ai_mail.local/inbox.json
- .ai_mail.local/sent.json
Args:
branch_info: Branch info dict from registry
@@ -263,7 +263,7 @@ if __name__ == "__main__":
console.print(" 1. Generate config from branch registry info")
console.print(" 2. Create ai_mail_config/ directory")
console.print(" 3. Write user_config.json with branch-specific settings")
console.print(" 4. Create ai_mail.local/ mailbox directory")
console.print(" 4. Create .ai_mail.local/ mailbox directory")
console.print(" 5. Initialize inbox.json and sent.json")
console.print()
console.print("="*70 + "\n")
@@ -35,7 +35,7 @@ from typing import Dict
# CONSTANTS
# =============================================
_AI_MAIL_ROOT = Path(__file__).resolve().parents[3] # ai_mail/
AI_MAIL_JSON = _AI_MAIL_ROOT / "ai_mail_json"
AI_MAIL_JSON = _AI_MAIL_ROOT / ".ai_mail.local"
USER_CONFIG_FILE = AI_MAIL_JSON / "user_config.json"
# Import branch detection (after constants defined)
@@ -46,7 +46,7 @@ def get_current_user() -> Dict:
{
"email_address": "@branch",
"display_name": "BRANCH_NAME",
"mailbox_path": "/path/to/branch/ai_mail.local",
"mailbox_path": "/path/to/branch/.ai_mail.local",
"timestamp_format": "%Y-%m-%d %H:%M:%S"
}
@@ -81,7 +81,7 @@ def get_current_user() -> Dict:
# Construct mailbox path (branch_path guaranteed non-None by check above)
assert branch_path is not None
mailbox_path = branch_path / "ai_mail.local"
mailbox_path = branch_path / ".ai_mail.local"
# Return user info in expected format
return {
@@ -121,7 +121,7 @@ def get_user_by_email(email: str) -> Dict | None:
return {
"email_address": branch.get("email"),
"display_name": branch.get("name"),
"mailbox_path": str(branch_path / "ai_mail.local"),
"mailbox_path": str(branch_path / ".ai_mail.local"),
"timestamp_format": "%Y-%m-%d %H:%M:%S"
}
return None
@@ -154,7 +154,7 @@ def get_all_users() -> Dict[str, Dict]:
users[email] = {
"email_address": email,
"display_name": branch.get("name"),
"mailbox_path": str(branch_path / "ai_mail.local"),
"mailbox_path": str(branch_path / ".ai_mail.local"),
"timestamp_format": "%Y-%m-%d %H:%M:%S"
}
return users
@@ -41,7 +41,7 @@ from aipass.ai_mail.apps.handlers.registry.update import (
get_branch_context,
update_json_memory_health
)
from aipass.ai_mail.apps.handlers.persistence.json_ops import log_operation
from aipass.ai_mail.apps.handlers.json_utils.json_handler import log_operation
MODULE_NAME = "branch_ping"
THRESHOLDS = {"green": (0, 400), "yellow": (401, 550), "red": (551, float('inf'))}
+7 -7
View File
@@ -68,7 +68,7 @@ from aipass.ai_mail.apps.handlers.email.reply import get_email_by_id, send_reply
from aipass.ai_mail.apps.handlers.email.header import prepend_dispatch_header
from aipass.ai_mail.apps.handlers.users.user import get_current_user
from aipass.ai_mail.apps.handlers.registry.read import get_all_branches, get_branch_by_email
from aipass.ai_mail.apps.handlers.persistence.json_ops import log_operation
from aipass.ai_mail.apps.handlers.json_utils.json_handler import log_operation
def _on_email_delivered(branch_path, new_count, opened_count, total):
@@ -457,7 +457,7 @@ def send_email_direct(to_branch: str, subject: str, message: str, auto_execute:
user_info = {
"email_address": email_addr,
"display_name": branch_info["name"],
"mailbox_path": str(branch_path / "ai_mail.local"),
"mailbox_path": str(branch_path / ".ai_mail.local"),
"timestamp_format": "%Y-%m-%d %H:%M:%S"
}
else:
@@ -466,7 +466,7 @@ def send_email_direct(to_branch: str, subject: str, message: str, auto_execute:
user_info = {
"email_address": email_addr,
"display_name": branch_name,
"mailbox_path": str(_AI_MAIL_DIR.parent / from_branch.lstrip('@').lower() / "ai_mail.local"),
"mailbox_path": str(_AI_MAIL_DIR.parent / from_branch.lstrip('@').lower() / ".ai_mail.local"),
"timestamp_format": "%Y-%m-%d %H:%M:%S"
}
else:
@@ -605,7 +605,7 @@ def handle_inbox(args: List[str]) -> bool:
console.print(f"❌ Unknown branch: {target_branch}")
return False
branch_path = Path(branch_info["path"])
mailbox_path = branch_path / "ai_mail.local"
mailbox_path = branch_path / ".ai_mail.local"
display_name = branch_info.get("name", target_branch)
else:
# Use current branch (detected from PWD)
@@ -670,7 +670,7 @@ def handle_read(args: List[str]) -> bool:
try:
user_info = get_current_user()
# mailbox_path is ai_mail.local/, branch_path is parent
# mailbox_path is .ai_mail.local/, branch_path is parent
branch_path = Path(user_info["mailbox_path"]).parent
# Archive all messages
@@ -801,7 +801,7 @@ def handle_close(args: List[str]) -> bool:
pass # Dashboard/central update is best-effort
try:
from aipass.ai_mail.apps.handlers.email.purge import purge_deleted_folder
purge_deleted_folder(branch_path / "ai_mail.local")
purge_deleted_folder(branch_path / ".ai_mail.local")
except Exception:
pass
@@ -830,7 +830,7 @@ def handle_reply(args: List[str]) -> bool:
try:
user_info = get_current_user()
branch_path = Path(user_info["mailbox_path"]).parent
inbox_file = branch_path / "ai_mail.local" / "inbox.json"
inbox_file = branch_path / ".ai_mail.local" / "inbox.json"
message_id = args[0]
reply_message = args[1]
@@ -1,8 +0,0 @@
{
"permissions": {
"allow": [],
"deny": [],
"ask": []
},
"enabledMcpjsonServers": []
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"metadata": {
"version": "1.0.0",
"created": "2026-03-06T03:16:10.876557",
"created": "2026-03-06T14:27:31.471013",
"description": "Standards bypass configuration for this branch"
},
"bypass": [],
+50 -23
View File
@@ -1,51 +1,78 @@
# API
**Purpose:** Model routing, provider abstraction, LLM API access layer
**Purpose:** LLM API access layer with provider abstraction, key management, model routing, and usage tracking.
**Module:** `aipass.api`
**Created:** 2026-03-05
---
## Overview
### What I Do
- Manage API keys for LLM providers (currently OpenRouter)
- Route requests to LLM models with provider-level abstraction
- Track API usage metrics and provide statistics
- Validate credentials and test provider connectivity
- Discover available models from configured providers
### How I Work
- **Entry Point:** `apps/api.py`
- **Pattern:** Auto-discovers and routes to modules
- **Entry Point:** `apps/api.py` -- auto-discovers and routes to modules
- **Pattern:** Standard AIPass 3-tier architecture (entry point / modules / handlers)
---
## CLI
```bash
drone @api get-key # Retrieve API key for provider
drone @api validate # Validate API credentials and connection
drone @api test # Test OpenRouter connection status
drone @api models # List available models from provider
drone @api track # Track API usage metrics
drone @api stats # Display API usage statistics
drone @api --help # Full help output
```
---
## Architecture
```
API/
api/
├── __init__.py # Public API exports
├── apps/
│ ├── api.py # Entry point
│ ├── modules/ # Business logic
│ ├── handlers/ # Implementation
│ └── plugins/ # Extensions
├── docs/
├── tests/
├── passport.json # Identity
├── local.json # Session history
├── observations.json # Collaboration patterns
│ ├── api.py # Entry point (module discovery, command routing)
│ ├── modules/
│ │ ├── api_key.py # Key retrieval and validation logic
│ │ ├── openrouter_client.py # OpenRouter API client
│ │ └── usage_tracker.py # Usage metrics tracking
│ └── handlers/
│ ├── auth/
│ │ ├── env.py # Environment variable credential loading
│ │ └── keys.py # API key storage and retrieval
│ ├── config/
│ │ └── provider.py # Provider configuration management
│ ├── json/
│ │ └── json_handler.py # JSON operation logging
│ ├── openrouter/
│ │ ├── caller.py # HTTP request execution
│ │ ├── client.py # OpenRouter client implementation
│ │ ├── models.py # Model discovery and listing
│ │ └── provision.py # Provider provisioning
│ └── usage/
│ ├── aggregation.py # Usage data aggregation
│ ├── cleanup.py # Usage data cleanup
│ └── tracking.py # Usage event tracking
└── README.md
```
---
## Commands
*Configure after initialization*
---
## Integration Points
### Depends On
- `aipass.prax` -- structured logging via `system_logger`
- `aipass.cli` -- Rich console output formatting
### Provides To
- All modules -- LLM API access for any branch that needs model inference
- System-wide API key management and credential validation
+1
View File
@@ -0,0 +1 @@
"""API - External API integrations for AIPass."""
+1 -5
View File
@@ -24,7 +24,7 @@ from pathlib import Path
# Standard library imports
import importlib
from typing import Dict, Any, Optional, List
from typing import Any, List
# AIPass infrastructure imports
from aipass.prax.apps.modules.logger import system_logger as logger
@@ -164,10 +164,6 @@ def print_help():
table.add_row("models", "List available models from provider")
table.add_row("track", "Track API usage metrics")
table.add_row("stats", "Display API usage statistics")
table.add_row("telegram start", "Start Telegram bridge service")
table.add_row("telegram stop", "Stop Telegram bridge service")
table.add_row("telegram status", "Check Telegram bridge status")
table.add_row("telegram logs", "View Telegram bridge logs")
console.print(table)
console.print()
+1 -1
View File
@@ -26,7 +26,7 @@ def _find_real_caller():
def _extract_branch_name(filepath: str) -> str:
"""Extract branch name from a file path."""
parts = filepath.split("/")
parts = Path(filepath).parts
for i, part in enumerate(parts):
if part in ("MEMORY_BANK", "seed", ".vscode"):
if i + 1 < len(parts):
@@ -1,31 +0,0 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: __init__.py - Telegram Handlers Package
# Date: 2026-02-03
# Version: 1.0.0
# Category: api/handlers/telegram
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-03): Initial package for Telegram bridge
# =============================================
"""
Telegram Handlers Package
Provides Telegram bot integration for AIPass:
- config.py: Token and configuration loading
- bridge.py: Core bot service with message handling
"""
from aipass.api.apps.handlers.telegram.config import (
load_telegram_config,
get_bot_token,
get_bot_username
)
__all__ = [
"load_telegram_config",
"get_bot_token",
"get_bot_username"
]
File diff suppressed because it is too large Load Diff
@@ -1,555 +0,0 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: bot_factory.py - Bot creation and deletion factory
# Date: 2026-02-24
# Version: 1.2.0
# Category: api/handlers/telegram
#
# CHANGELOG (Max 5 entries):
# - v1.2.0 (2026-02-25): Fix: create_bot always uses registry path when branch_name provided
# - v1.1.0 (2026-02-24): Auto-start bot process after create_bot via Popen fire-and-forget
# - v1.0.0 (2026-02-24): Initial - bot lifecycle management (create, delete, validate, systemd)
#
# CODE STANDARDS:
# - Pure functions with proper error handling (graceful - never raise)
# - Uses Prax system_logger (FPLAN-0382 migration)
# - Stdlib only (urllib for HTTP, subprocess for systemd)
# =============================================
"""
Bot Creation and Deletion Factory
Manages the full lifecycle of Telegram bots in the multi-bot architecture:
- Validate bot tokens via Telegram getMe API
- Validate branch existence against BRANCH_REGISTRY.json
- Write per-bot config files to ~/.aipass/telegram_bots/{bot_id}.json
- Register/deregister bots in the central bot registry
- Set BotFather commands via setMyCommands API
- Enable/disable/stop systemd user services
All HTTP calls use urllib (stdlib). No external dependencies.
"""
# Infrastructure
import sys
from pathlib import Path
# Standard library
import json
import os
import shutil
import subprocess
from datetime import datetime, timezone
from typing import Optional
from urllib.request import urlopen, Request
from urllib.error import URLError, HTTPError
# Logging (Prax system_logger — FPLAN-0382)
from aipass.prax.apps.modules.logger import system_logger as logger
# Internal imports
from aipass.api.apps.handlers.telegram.bot_registry import (
ensure_registry, register_bot, deregister_bot, get_bot, get_bot_by_branch
)
# =============================================
# CONSTANTS
# =============================================
TELEGRAM_API = "https://api.telegram.org/bot{token}"
def _aipass_data_dir() -> Path:
"""User data directory for AIPass runtime files."""
env = os.environ.get("AIPASS_DATA_DIR")
if env:
return Path(env)
return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "aipass"
def _find_repo_root() -> Path:
"""Walk up from this file to find AIPASS_REGISTRY.json (repo root)."""
current = Path(__file__).resolve().parent
for parent in [current] + list(current.parents):
if (parent / "AIPASS_REGISTRY.json").exists():
return parent
return Path.cwd()
_DATA_DIR = _aipass_data_dir()
BOT_CONFIG_DIR = _DATA_DIR / "telegram_bots"
BRANCH_REGISTRY = _find_repo_root() / "AIPASS_REGISTRY.json"
SYSTEMD_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "systemd" / "user"
# Default commands set on every new bot via BotFather
DEFAULT_BOT_COMMANDS = [
{"command": "start", "description": "Start the bot"},
{"command": "help", "description": "Show available commands"},
{"command": "status", "description": "Show session status"},
{"command": "new", "description": "Start a fresh session"},
]
# =============================================
# TELEGRAM API HELPERS
# =============================================
def validate_token(bot_token: str) -> Optional[dict]:
"""
Validate a bot token via the Telegram getMe API call.
Args:
bot_token: Telegram bot token string (e.g., "123456:ABC-DEF...").
Returns:
Bot info dict with keys like "id", "username", "first_name" on success.
None if the token is invalid or the API is unreachable.
"""
url = f"{TELEGRAM_API.format(token=bot_token)}/getMe"
try:
req = Request(url, method="GET")
with urlopen(req, timeout=15) as resp:
result = json.loads(resp.read().decode("utf-8"))
if result.get("ok") and result.get("result"):
bot_info = result["result"]
logger.info("Token validated: @%s (id=%s)", bot_info.get("username"), bot_info.get("id"))
return bot_info
logger.warning("Token validation failed: API returned ok=false")
return None
except HTTPError as e:
logger.warning("Token validation HTTP error %d: %s", e.code, e.reason)
return None
except URLError as e:
logger.warning("Token validation network error: %s", e)
return None
except Exception as e:
logger.warning("Token validation unexpected error: %s", e)
return None
def validate_branch(branch_name: str) -> Optional[dict]:
"""
Check that a branch exists in BRANCH_REGISTRY.json.
Args:
branch_name: Branch name to look up (case-insensitive, matches email field).
Returns:
Branch info dict from the registry on success, None if not found.
"""
try:
if not BRANCH_REGISTRY.exists():
logger.warning("Branch registry not found: %s", BRANCH_REGISTRY)
return None
with open(BRANCH_REGISTRY, "r", encoding="utf-8") as f:
registry = json.load(f)
branches = registry.get("branches", [])
target = branch_name.lower()
for branch_entry in branches:
clean_email = branch_entry.get("email", "").replace("@", "").lower()
if clean_email == target:
logger.info("Branch validated: %s -> %s", branch_name, branch_entry.get("path"))
return branch_entry
logger.warning("Branch '%s' not found in registry", branch_name)
return None
except (json.JSONDecodeError, OSError) as e:
logger.warning("Failed to read branch registry: %s", e)
return None
def set_bot_commands(bot_token: str, commands: list[dict]) -> bool:
"""
Set BotFather commands via the Telegram setMyCommands API.
Args:
bot_token: Telegram bot token.
commands: List of command dicts, each with "command" and "description" keys.
Returns:
True if commands were set successfully, False otherwise.
"""
url = f"{TELEGRAM_API.format(token=bot_token)}/setMyCommands"
payload = {"commands": commands}
data = json.dumps(payload).encode("utf-8")
req = Request(url, data=data, headers={"Content-Type": "application/json"})
try:
with urlopen(req, timeout=15) as resp:
result = json.loads(resp.read().decode("utf-8"))
if result.get("ok"):
logger.info("Bot commands set successfully (%d commands)", len(commands))
return True
logger.warning("setMyCommands failed: %s", result.get("description"))
return False
except (HTTPError, URLError) as e:
logger.warning("Failed to set bot commands: %s", e)
return False
except Exception as e:
logger.warning("Unexpected error setting bot commands: %s", e)
return False
# =============================================
# SYSTEMD SERVICE MANAGEMENT
# =============================================
def enable_service(bot_id: str) -> bool:
"""
Enable the systemd user service for a bot (does not start it).
Runs: systemctl --user enable telegram-bot@{bot_id}
Args:
bot_id: Bot identifier used in the service template.
Returns:
True if the service was enabled successfully, False otherwise.
"""
SERVICE_NAME = f"telegram-bot@{bot_id}"
try:
result = subprocess.run(
["systemctl", "--user", "enable", SERVICE_NAME],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0:
logger.info("Enabled systemd service: %s", SERVICE_NAME)
return True
logger.warning("Failed to enable service %s: %s", SERVICE_NAME, result.stderr.strip())
return False
except subprocess.TimeoutExpired:
logger.warning("Timeout enabling service: %s", SERVICE_NAME)
return False
except OSError as e:
logger.warning("Error enabling service %s: %s", SERVICE_NAME, e)
return False
def disable_service(bot_id: str) -> bool:
"""
Disable the systemd user service for a bot.
Runs: systemctl --user disable telegram-bot@{bot_id}
Args:
bot_id: Bot identifier used in the service template.
Returns:
True if the service was disabled successfully, False otherwise.
"""
SERVICE_NAME = f"telegram-bot@{bot_id}"
try:
result = subprocess.run(
["systemctl", "--user", "disable", SERVICE_NAME],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0:
logger.info("Disabled systemd service: %s", SERVICE_NAME)
return True
logger.warning("Failed to disable service %s: %s", SERVICE_NAME, result.stderr.strip())
return False
except subprocess.TimeoutExpired:
logger.warning("Timeout disabling service: %s", SERVICE_NAME)
return False
except OSError as e:
logger.warning("Error disabling service %s: %s", SERVICE_NAME, e)
return False
def start_bot_process(bot_id: str) -> bool:
"""
Launch the bot process via subprocess.Popen (fire-and-forget).
Starts base_bot.py --bot-id {bot_id} as a detached process.
This is called after create_bot() to auto-start the new bot.
Args:
bot_id: Bot identifier to start.
Returns:
True if the process was launched successfully, False otherwise.
"""
BASE_BOT_PATH = Path(__file__).resolve().parent / "base_bot.py"
PYTHON = shutil.which("python3") or sys.executable
try:
proc = subprocess.Popen(
[PYTHON, str(BASE_BOT_PATH), "--bot-id", bot_id],
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
logger.info("Started bot process: bot_id=%s, pid=%d", bot_id, proc.pid)
return True
except OSError as e:
logger.warning("Failed to start bot process for '%s': %s", bot_id, e)
return False
def stop_service(bot_id: str) -> bool:
"""
Stop the systemd user service for a bot.
Runs: systemctl --user stop telegram-bot@{bot_id}
Args:
bot_id: Bot identifier used in the service template.
Returns:
True if the service was stopped successfully, False otherwise.
"""
SERVICE_NAME = f"telegram-bot@{bot_id}"
try:
result = subprocess.run(
["systemctl", "--user", "stop", SERVICE_NAME],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0:
logger.info("Stopped systemd service: %s", SERVICE_NAME)
return True
logger.warning("Failed to stop service %s: %s", SERVICE_NAME, result.stderr.strip())
return False
except subprocess.TimeoutExpired:
logger.warning("Timeout stopping service: %s", SERVICE_NAME)
return False
except OSError as e:
logger.warning("Error stopping service %s: %s", SERVICE_NAME, e)
return False
# =============================================
# BOT LIFECYCLE
# =============================================
def create_bot(
bot_id: str,
bot_token: str,
branch_name: Optional[str] = None,
work_dir: Optional[str] = None,
bot_name: Optional[str] = None,
allowed_user_ids: Optional[list[int]] = None,
) -> Optional[dict]:
"""
Create a new bot: validate, write config, register, setup systemd.
Steps:
1. Validate token via getMe
2. If branch_name provided: validate branch exists in BRANCH_REGISTRY.json
3. Check bot_id not already registered
4. Write config file: ~/.aipass/telegram_bots/{bot_id}.json
5. Register in bot registry
6. Set BotFather commands via setMyCommands API
7. Enable systemd service (don't start - leave that to caller)
Args:
bot_id: Unique identifier for this bot (e.g., "dev_central", "base").
bot_token: Telegram bot token from BotFather.
branch_name: AIPass branch name to associate, or None for base bot.
work_dir: Working directory for Claude sessions. Auto-resolved from branch if None.
bot_name: Human-readable bot name. Auto-generated if None.
allowed_user_ids: List of Telegram user IDs allowed to use this bot.
Returns:
Bot info dict on success, None on any failure.
"""
# Step 1: Validate token
bot_info = validate_token(bot_token)
if not bot_info:
logger.warning("create_bot failed: invalid token for bot_id '%s'", bot_id)
return None
BOT_USERNAME = bot_info.get("username", "unknown")
# Step 2: Validate branch if provided
RESOLVED_WORK_DIR = str(Path.cwd()) if work_dir is None else str(work_dir)
if branch_name:
branch_info = validate_branch(branch_name)
if not branch_info:
logger.warning("create_bot failed: branch '%s' not found", branch_name)
return None
# Always use registry path as source of truth when branch_name is provided
REGISTRY_PATH = branch_info.get("path", "")
if REGISTRY_PATH:
if work_dir and str(work_dir) != REGISTRY_PATH:
logger.warning(
"create_bot: explicit work_dir '%s' differs from registry path '%s' — using registry",
work_dir, REGISTRY_PATH,
)
RESOLVED_WORK_DIR = REGISTRY_PATH
# Step 3: Check not already registered
existing = get_bot(bot_id)
if existing:
logger.warning("create_bot failed: bot_id '%s' already registered", bot_id)
return None
# Also check no other bot owns this branch
if branch_name:
branch_bot = get_bot_by_branch(branch_name)
if branch_bot:
logger.warning(
"create_bot failed: branch '%s' already has bot '%s'",
branch_name, branch_bot.get("bot_id"),
)
return None
# Step 4: Write config file
ensure_registry()
BOT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
RESOLVED_BOT_NAME = bot_name or f"AIPass {bot_id.replace('_', ' ').title()} Bot"
CONFIG_PATH = BOT_CONFIG_DIR / f"{bot_id}.json"
config_data = {
"bot_id": bot_id,
"bot_token": bot_token,
"bot_name": RESOLVED_BOT_NAME,
"branch_name": branch_name,
"work_dir": RESOLVED_WORK_DIR,
"allowed_user_ids": allowed_user_ids or [],
"created_at": datetime.now(timezone.utc).isoformat(),
}
try:
CONFIG_PATH.write_text(
json.dumps(config_data, indent=2),
encoding="utf-8",
)
logger.info("Wrote bot config: %s", CONFIG_PATH)
except OSError as e:
logger.warning("Failed to write bot config: %s", e)
return None
# Step 5: Register in bot registry
registered = register_bot(
bot_id=bot_id,
username=BOT_USERNAME,
branch_name=branch_name,
work_dir=RESOLVED_WORK_DIR,
config_path=str(CONFIG_PATH),
)
if not registered:
# Clean up config file on registration failure
CONFIG_PATH.unlink(missing_ok=True)
logger.warning("create_bot failed: registry registration failed for '%s'", bot_id)
return None
# Step 6: Set BotFather commands
set_bot_commands(bot_token, DEFAULT_BOT_COMMANDS)
# Step 7: Enable systemd service
enable_service(bot_id)
# Step 8: Auto-start the bot process
started = start_bot_process(bot_id)
logger.info(
"Bot created: %s (@%s, branch=%s, work_dir=%s, started=%s)",
bot_id, BOT_USERNAME, branch_name, RESOLVED_WORK_DIR, started,
)
return {
"bot_id": bot_id,
"username": BOT_USERNAME,
"bot_name": RESOLVED_BOT_NAME,
"branch_name": branch_name,
"work_dir": RESOLVED_WORK_DIR,
"config_path": str(CONFIG_PATH),
"service_name": f"telegram-bot@{bot_id}",
"auto_started": started,
}
def delete_bot(bot_id: str, kill_tmux: bool = True) -> bool:
"""
Delete a bot: stop service, kill tmux, remove config, deregister.
Steps:
1. Stop systemd service
2. Kill tmux session if exists and kill_tmux is True
3. Remove config file
4. Clean up pending file (both v1 and v2 naming)
5. Deregister from registry
Args:
bot_id: Bot identifier to delete.
kill_tmux: Whether to kill the associated tmux session (default True).
Returns:
True if the bot was fully cleaned up, False on any failure.
"""
bot = get_bot(bot_id)
if not bot:
logger.warning("delete_bot failed: bot '%s' not found in registry", bot_id)
return False
# Step 1: Stop systemd service
stop_service(bot_id)
disable_service(bot_id)
# Step 2: Kill tmux session if requested
if kill_tmux:
TMUX_SESSION_NAME = f"telegram-{bot_id}"
try:
subprocess.run(
["tmux", "kill-session", "-t", TMUX_SESSION_NAME],
capture_output=True,
text=True,
timeout=5,
)
logger.info("Killed tmux session: %s", TMUX_SESSION_NAME)
except (subprocess.TimeoutExpired, OSError) as e:
logger.info("tmux kill-session for '%s' skipped (may not exist): %s", TMUX_SESSION_NAME, e)
# Step 3: Remove config file
CONFIG_PATH = bot.get("config_path", "")
if CONFIG_PATH:
try:
Path(CONFIG_PATH).unlink(missing_ok=True)
logger.info("Removed config file: %s", CONFIG_PATH)
except OSError as e:
logger.warning("Failed to remove config file: %s", e)
# Step 4: Clean up pending files (both v1 and v2 naming)
PENDING_DIR = _DATA_DIR / "telegram_pending"
BRANCH_NAME = bot.get("branch_name", bot_id)
# v2 naming: bot-{bot_id}.json
PENDING_V2 = PENDING_DIR / f"bot-{bot_id}.json"
PENDING_V2.unlink(missing_ok=True)
# v1 naming: telegram-{branch_name}.json
if BRANCH_NAME:
PENDING_V1 = PENDING_DIR / f"telegram-{BRANCH_NAME}.json"
PENDING_V1.unlink(missing_ok=True)
# Step 5: Deregister from registry
if not deregister_bot(bot_id):
logger.warning("delete_bot: deregistration failed for '%s'", bot_id)
return False
logger.info("Bot deleted: %s", bot_id)
return True
@@ -1,240 +0,0 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: bot_operations.py - Bot operation handlers for multi-bot module
# Date: 2026-02-24
# Version: 1.0.0
# Category: api/handlers/telegram
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-24): Initial - start, stop, status, list operations for multi-bot system
#
# CODE STANDARDS:
# - Pure functions with proper error handling (graceful - never raise)
# - No Prax imports (handler tier 3)
# - Stdlib only (subprocess for systemd)
# - Returns values for caller to log/display - no handler-level logging
# =============================================
"""
Bot Operation Handlers for Multi-Bot Architecture
Implementation logic for bot lifecycle operations:
- start_bot: load config and run polling loop
- stop_bot: stop systemd service
- get_status: query registry for bot status
- get_all_bots: list all registered bots
- format_bot_details: format a bot entry for display
Called by the telegram_bot module (thin orchestration layer).
All functions return values - the module layer handles logging and display.
"""
# Infrastructure
import sys
from pathlib import Path
# Standard library
import subprocess
# Internal handler imports
from aipass.api.apps.handlers.telegram.base_bot import BaseBot
from aipass.api.apps.handlers.telegram.branch_plugin import BranchPlugin
from aipass.api.apps.handlers.telegram.bot_registry import list_bots, get_bot
from aipass.api.apps.handlers.telegram.config import load_bot_config
# =============================================
# BOT OPERATIONS
# =============================================
def start_bot(bot_id: str) -> int | None:
"""
Load config and start a bot's polling loop.
Loads config from ~/.aipass/telegram_bots/{bot_id}.json.
If config has "branch_name", creates a BranchPlugin, else a BaseBot.
Calls bot.run() which blocks until terminated.
Args:
bot_id: Bot identifier to start
Returns:
Bot exit code, or None if config loading failed.
"""
config = load_bot_config(bot_id)
if not config:
return None
bot_token = config.get("bot_token")
if not bot_token:
return None
work_dir = Path(config.get("work_dir", str(Path.cwd())))
bot_name = config.get("bot_name", f"AIPass {bot_id} Bot")
allowed_user_ids = config.get("allowed_user_ids", [])
branch_name = config.get("branch_name")
if branch_name:
bot = BranchPlugin(
branch_name=branch_name,
bot_id=bot_id,
bot_token=bot_token,
work_dir=work_dir,
bot_name=bot_name,
allowed_user_ids=allowed_user_ids,
)
else:
bot = BaseBot(
bot_id=bot_id,
bot_token=bot_token,
work_dir=work_dir,
bot_name=bot_name,
allowed_user_ids=allowed_user_ids,
)
return bot.run()
def stop_bot(bot_id: str) -> tuple[bool, str]:
"""
Stop a bot's systemd service.
Args:
bot_id: Bot identifier to stop
Returns:
Tuple of (success, message).
"""
service_name = f"telegram-bot@{bot_id}"
try:
result = subprocess.run(
["systemctl", "--user", "stop", service_name],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0:
return True, f"Stopped {service_name}"
return False, f"Failed to stop {service_name}: {result.stderr.strip()}"
except subprocess.TimeoutExpired:
return False, f"Timeout stopping {service_name}"
except OSError as e:
return False, f"Error stopping {service_name}: {e}"
def get_status(bot_id: str | None = None) -> list[dict]:
"""
Get bot status entries. If no bot_id, returns all bots.
Args:
bot_id: Specific bot to check, or None for all bots
Returns:
List of bot entry dicts. Empty list if not found.
"""
if bot_id:
bot = get_bot(bot_id)
return [bot] if bot else []
return list_bots()
def get_all_bots() -> list[dict]:
"""
Get all registered bots.
Returns:
List of bot entry dicts.
"""
return list_bots()
def format_bot_details(bot: dict) -> list[str]:
"""
Format a single bot entry into display lines.
Args:
bot: Bot entry dict from registry.
Returns:
List of formatted strings for display.
"""
bot_id = bot.get("bot_id", "?")
username = bot.get("username", "?")
branch = bot.get("branch_name") or "none (base bot)"
work_dir = bot.get("work_dir", "?")
status = bot.get("status", "?")
service = bot.get("service_name", f"telegram-bot@{bot_id}")
return [
f"Bot ID: {bot_id}",
f"Username: @{username}",
f"Branch: {branch}",
f"Work Dir: {work_dir}",
f"Status: {status}",
f"Service: {service}",
]
def format_bot_table(bots: list[dict]) -> list[str]:
"""
Format a list of bots into table rows.
Args:
bots: List of bot entry dicts.
Returns:
List of formatted strings (header + separator + rows + total).
"""
lines = []
lines.append(f" {'Bot ID':<18} {'Branch':<16} {'Username':<24} {'Status':<10}")
lines.append(f" {'---' * 6:<18} {'---' * 5:<16} {'---' * 8:<24} {'---' * 3:<10}")
for bot in bots:
bot_id = bot.get("bot_id", "?")
branch = bot.get("branch_name") or "-"
username = f"@{bot.get('username', '?')}"
status = bot.get("status", "?")
lines.append(f" {bot_id:<18} {branch:<16} {username:<24} {status}")
lines.append(f" Total: {len(bots)} bot(s)")
return lines
def parse_create_args(args: list) -> dict | None:
"""
Parse create command arguments.
Args:
args: Arguments after 'create' (bot_id, token, [--branch name], [--work-dir path])
Returns:
Dict with parsed values, or None if args are insufficient.
"""
if len(args) < 2:
return None
result = {
"bot_id": args[0],
"bot_token": args[1],
"branch_name": None,
"work_dir": None,
}
i = 2
while i < len(args):
if args[i] == "--branch" and i + 1 < len(args):
result["branch_name"] = args[i + 1]
i += 2
elif args[i] == "--work-dir" and i + 1 < len(args):
result["work_dir"] = args[i + 1]
i += 2
else:
i += 1
return result
@@ -1,371 +0,0 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: bot_registry.py - Bot registry management for multi-bot architecture
# Date: 2026-02-24
# Version: 1.0.0
# Category: api/handlers/telegram
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-24): Initial - registry CRUD with fcntl locking for multi-bot management
#
# CODE STANDARDS:
# - Pure functions with proper error handling (graceful - never raise)
# - Uses Prax system_logger (FPLAN-0382 migration)
# - Thread-safe via fcntl.flock (shared read, exclusive write)
# =============================================
"""
Bot Registry Management for Multi-Bot Architecture
Manages a central registry of all Telegram bots in the AIPass ecosystem.
Each bot entry tracks its ID, branch association, working directory,
config path, systemd service name, and status.
Registry location: ~/.aipass/telegram_bots/_registry.json
Thread-safe via fcntl.flock file locking (same pattern as session_store.py).
All public functions return None/False on errors - never raise exceptions.
"""
# Infrastructure
import sys
from pathlib import Path
# Standard library
import fcntl
import json
import os
from datetime import datetime, timezone
from typing import Optional
# Logging (Prax system_logger — FPLAN-0382)
from aipass.prax.apps.modules.logger import system_logger as logger
# =============================================
# CONSTANTS
# =============================================
def _aipass_data_dir() -> Path:
"""User data directory for AIPass runtime files."""
env = os.environ.get("AIPASS_DATA_DIR")
if env:
return Path(env)
return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "aipass"
REGISTRY_DIR = _aipass_data_dir() / "telegram_bots"
REGISTRY_FILE = REGISTRY_DIR / "_registry.json"
# =============================================
# EMPTY REGISTRY TEMPLATE
# =============================================
def _empty_registry() -> dict:
"""Return a fresh empty registry structure."""
return {
"bots": {},
"metadata": {
"version": "1.0.0",
"last_updated": datetime.now(timezone.utc).isoformat(),
},
}
def _now_iso() -> str:
"""Return current UTC timestamp in ISO format."""
return datetime.now(timezone.utc).isoformat()
# =============================================
# REGISTRY LIFECYCLE
# =============================================
def ensure_registry() -> None:
"""
Create registry directory and file if they don't exist.
Safe to call multiple times - only creates what is missing.
"""
try:
REGISTRY_DIR.mkdir(parents=True, exist_ok=True)
if not REGISTRY_FILE.exists():
data = _empty_registry()
with open(REGISTRY_FILE, "w", encoding="utf-8") as f:
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
try:
json.dump(data, f, indent=2)
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
logger.info("Created new bot registry at %s", REGISTRY_FILE)
except OSError as e:
logger.warning("Failed to ensure registry: %s", e)
# =============================================
# READ / WRITE WITH LOCKING
# =============================================
def load_registry() -> dict:
"""
Load registry with fcntl shared lock.
Returns:
Registry dict. Returns empty structure if file missing or corrupt.
"""
if not REGISTRY_FILE.exists():
return _empty_registry()
try:
with open(REGISTRY_FILE, "r", encoding="utf-8") as f:
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
try:
data = json.load(f)
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
if not isinstance(data, dict) or "bots" not in data:
logger.warning("Registry file has unexpected structure, returning empty")
return _empty_registry()
return data
except (json.JSONDecodeError, OSError) as e:
logger.warning("Failed to load registry: %s", e)
return _empty_registry()
def save_registry(data: dict) -> bool:
"""
Save registry with fcntl exclusive lock.
Args:
data: Full registry dict to write.
Returns:
True if saved successfully, False on error.
"""
try:
REGISTRY_DIR.mkdir(parents=True, exist_ok=True)
# Update metadata timestamp
if "metadata" not in data:
data["metadata"] = {}
data["metadata"]["last_updated"] = _now_iso()
with open(REGISTRY_FILE, "w", encoding="utf-8") as f:
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
try:
json.dump(data, f, indent=2)
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
return True
except OSError as e:
logger.warning("Failed to save registry: %s", e)
return False
# =============================================
# CRUD OPERATIONS
# =============================================
def get_bot(bot_id: str) -> Optional[dict]:
"""
Get a single bot entry by bot_id.
Args:
bot_id: Unique bot identifier.
Returns:
Bot entry dict or None if not found.
"""
registry = load_registry()
return registry.get("bots", {}).get(bot_id)
def list_bots(status: Optional[str] = None) -> list[dict]:
"""
List all bots, optionally filtered by status.
Args:
status: Filter by status (e.g., "active", "inactive"). None returns all.
Returns:
List of bot entry dicts.
"""
registry = load_registry()
bots = list(registry.get("bots", {}).values())
if status is not None:
bots = [b for b in bots if b.get("status") == status]
return bots
def register_bot(
bot_id: str,
username: str,
branch_name: Optional[str],
work_dir: str,
config_path: str,
bot_token_ref: Optional[str] = None,
) -> bool:
"""
Register a new bot in the registry.
Args:
bot_id: Unique bot identifier.
username: Telegram bot username (e.g., "aipass_dev_central_bot").
branch_name: AIPass branch name, or None for the base bot.
work_dir: Working directory for Claude sessions.
config_path: Path to the bot's config JSON file.
bot_token_ref: Optional env var name or reference for the token.
Returns:
True on success, False if bot_id already exists or on error.
"""
registry = load_registry()
bots = registry.get("bots", {})
if bot_id in bots:
logger.warning("Bot '%s' already registered", bot_id)
return False
now = _now_iso()
entry = {
"bot_id": bot_id,
"username": username,
"branch_name": branch_name,
"work_dir": str(work_dir),
"config_path": str(config_path),
"service_name": f"telegram-bot@{bot_id}",
"status": "active",
"created_at": now,
"updated_at": now,
}
if bot_token_ref:
entry["bot_token_env"] = bot_token_ref
bots[bot_id] = entry
registry["bots"] = bots
if not save_registry(registry):
return False
logger.info("Registered bot '%s' (branch=%s, work_dir=%s)", bot_id, branch_name, work_dir)
return True
def update_bot(bot_id: str, **kwargs) -> bool:
"""
Update specific fields of a bot entry.
Auto-updates the updated_at timestamp.
Args:
bot_id: Bot identifier to update.
**kwargs: Fields to update (e.g., status="inactive", username="new_name").
Returns:
True on success, False if bot not found or on error.
"""
registry = load_registry()
bots = registry.get("bots", {})
if bot_id not in bots:
logger.warning("Cannot update bot '%s': not found", bot_id)
return False
for key, value in kwargs.items():
bots[bot_id][key] = value
bots[bot_id]["updated_at"] = _now_iso()
registry["bots"] = bots
if not save_registry(registry):
return False
logger.info("Updated bot '%s': %s", bot_id, list(kwargs.keys()))
return True
def deregister_bot(bot_id: str) -> bool:
"""
Remove a bot from the registry.
Args:
bot_id: Bot identifier to remove.
Returns:
True on success, False if bot not found or on error.
"""
registry = load_registry()
bots = registry.get("bots", {})
if bot_id not in bots:
logger.warning("Cannot deregister bot '%s': not found", bot_id)
return False
del bots[bot_id]
registry["bots"] = bots
if not save_registry(registry):
return False
logger.info("Deregistered bot '%s'", bot_id)
return True
# =============================================
# LOOKUP HELPERS
# =============================================
def get_bot_by_branch(branch_name: str) -> Optional[dict]:
"""
Find a bot by its branch_name.
Args:
branch_name: AIPass branch name (e.g., "dev_central").
Returns:
Bot entry dict or None if not found.
"""
registry = load_registry()
for bot in registry.get("bots", {}).values():
if bot.get("branch_name") == branch_name:
return bot
return None
def get_bot_by_work_dir(work_dir) -> Optional[dict]:
"""
Find a bot whose work_dir matches the given path.
Used by the response router to match CWD to a bot.
Args:
work_dir: Path (str or Path) to match against bot work_dir fields.
Returns:
Bot entry dict or None if not found.
"""
target = str(Path(work_dir).resolve())
registry = load_registry()
for bot in registry.get("bots", {}).values():
bot_dir = bot.get("work_dir", "")
if bot_dir:
try:
if str(Path(bot_dir).resolve()) == target:
return bot
except (ValueError, OSError):
continue
return None
@@ -1,539 +0,0 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: botfather_client.py - Telethon-based BotFather automation client
# Date: 2026-02-24
# Version: 1.0.0
# Category: api/handlers/telegram
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-24): Initial - automated bot creation via BotFather using Telethon
#
# CODE STANDARDS:
# - Telethon for Telegram user-account interaction with @BotFather
# - Graceful error handling (never raise - return None on failure)
# - Sync wrapper for async Telethon calls (callable from stdlib code)
# - Uses Prax system_logger (FPLAN-0382 migration)
# =============================================
"""
BotFather Automation Client
Automates Telegram bot creation by driving a conversation with @BotFather
using Telethon (user-account MTProto client). This replaces the manual
"go to BotFather, create a bot, paste the token" workflow.
Flow:
1. Connect to Telegram as Patrick's user account (pre-authenticated session)
2. Send /newbot to @BotFather
3. Provide display name and username
4. Parse the bot token from BotFather's success response
5. Return token + metadata for bot_factory.py to complete registration
Requirements:
- Telethon 1.42.0+ installed in .venv
- One-time manual phone auth to create .telethon.session file
- API credentials in ~/.aipass/telegram_bots/.telethon_config.json
"""
# Infrastructure
import sys
from pathlib import Path
# Standard library
import asyncio
import json
import os
import re
import time
from typing import Any, Optional
# Logging (Prax system_logger — FPLAN-0382)
from aipass.prax.apps.modules.logger import system_logger as logger
# Third party (Telethon) — runtime-imported in methods to avoid Pyright issues
TELETHON_AVAILABLE = False
try:
import telethon as _telethon_check # noqa: F401
TELETHON_AVAILABLE = True
del _telethon_check
except ImportError:
pass
# =============================================
# CONSTANTS
# =============================================
def _aipass_data_dir() -> Path:
"""User data directory for AIPass runtime files."""
env = os.environ.get("AIPASS_DATA_DIR")
if env:
return Path(env)
return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "aipass"
BOT_CONFIG_DIR = _aipass_data_dir() / "telegram_bots"
TELETHON_CONFIG_PATH = BOT_CONFIG_DIR / ".telethon_config.json"
SESSION_PATH = BOT_CONFIG_DIR / ".telethon" # Telethon appends .session automatically
BOTFATHER_USERNAME = "BotFather"
BOT_TOKEN_PATTERN = re.compile(r"\d+:[A-Za-z0-9_-]+")
# Timeouts
MESSAGE_TIMEOUT = 30 # seconds to wait for BotFather response
MAX_USERNAME_ATTEMPTS = 3
# =============================================
# CONFIG LOADER
# =============================================
def _load_telethon_config() -> Optional[dict]:
"""
Load Telethon API credentials from .telethon_config.json.
Expected format:
{"api_id": 12345, "api_hash": "abc123..."}
Returns:
Dict with "api_id" (int) and "api_hash" (str), or None on failure.
"""
try:
if not TELETHON_CONFIG_PATH.exists():
logger.warning("Telethon config not found: %s", TELETHON_CONFIG_PATH)
return None
with open(TELETHON_CONFIG_PATH, "r", encoding="utf-8") as f:
config = json.load(f)
api_id = config.get("api_id")
api_hash = config.get("api_hash")
if not api_id or not api_hash:
logger.warning("Telethon config missing api_id or api_hash")
return None
# Ensure api_id is an integer
config["api_id"] = int(api_id)
config["api_hash"] = str(api_hash)
logger.info("Telethon config loaded successfully")
return config
except (json.JSONDecodeError, ValueError, OSError) as e:
logger.warning("Failed to load Telethon config: %s", e)
return None
# =============================================
# SETUP CHECK
# =============================================
def check_telethon_setup() -> tuple[bool, str]:
"""
Check whether Telethon is ready for BotFather automation.
Verifies:
1. Telethon library is importable
2. .telethon_config.json exists with valid credentials
3. .telethon.session exists (phone auth already completed)
Returns:
(True, "ready") if everything is in place.
(False, "reason") with a human-readable explanation of what is missing.
"""
if not TELETHON_AVAILABLE:
return (False, "Telethon library not installed. Run: pip install telethon")
if not TELETHON_CONFIG_PATH.exists():
return (False, f"Telethon config not found at {TELETHON_CONFIG_PATH}")
config = _load_telethon_config()
if config is None:
return (False, "Telethon config is invalid (missing api_id or api_hash)")
# Telethon creates session files with .session extension
session_file = Path(str(SESSION_PATH) + ".session")
if not session_file.exists():
return (False, f"Telethon session not found at {session_file}. Run one-time phone auth first.")
return (True, "ready")
# =============================================
# HELPER FUNCTIONS
# =============================================
def _format_display_name(branch_name: str) -> str:
"""
Convert a branch_name to a BotFather display name.
Examples:
"dev_central" -> "AIPass Dev Central"
"flow" -> "AIPass Flow"
"vera" -> "AIPass Vera"
Args:
branch_name: AIPass branch name (snake_case).
Returns:
Display name string.
"""
title = branch_name.replace("_", " ").title()
return f"AIPass {title}"
def _format_username(branch_name: str, suffix: int = 0) -> str:
"""
Generate a BotFather username from a branch name.
Examples:
("dev_central", 0) -> "aipass_dev_central_bot"
("dev_central", 1) -> "aipass_dev_central_1_bot"
("dev_central", 2) -> "aipass_dev_central_2_bot"
Args:
branch_name: AIPass branch name (snake_case).
suffix: Numeric suffix for retries (0 = no suffix).
Returns:
Username string ending in _bot.
"""
if suffix == 0:
return f"aipass_{branch_name}_bot"
return f"aipass_{branch_name}_{suffix}_bot"
# =============================================
# BOTFATHER CLIENT
# =============================================
class BotFatherClient:
"""
Telethon-based client that automates bot creation via @BotFather.
Uses Patrick's authenticated user session to send commands to BotFather
and parse the resulting bot token.
Usage:
client = BotFatherClient(api_id=12345, api_hash="abc...")
result = await client.create_bot("dev_central")
# result = {"token": "123:ABC", "username": "aipass_dev_central_bot", "display_name": "AIPass Dev Central"}
"""
def __init__(self, api_id: int, api_hash: str) -> None:
self._api_id = api_id
self._api_hash = api_hash
self._client: Any = None
async def connect(self) -> bool:
"""
Connect to Telegram using the existing session file.
The session file must already exist from a prior manual phone auth.
This method will NOT prompt for phone/code input.
Returns:
True if connected and authorized, False otherwise.
"""
try:
from telethon import TelegramClient as _TelegramClient
self._client = _TelegramClient(
str(SESSION_PATH),
self._api_id,
self._api_hash,
)
await self._client.connect()
if not await self._client.is_user_authorized():
logger.warning("Telethon session exists but is not authorized. Re-run phone auth.")
await self._client.disconnect()
self._client = None
return False
me = await self._client.get_me()
if me:
logger.info(
"Connected to Telegram as: %s (id=%s)",
getattr(me, "first_name", "?"),
getattr(me, "id", "?"),
)
else:
logger.info("Connected to Telegram (could not resolve self)")
return True
except Exception as e:
logger.warning("Failed to connect to Telegram: %s", e)
self._client = None
return False
async def disconnect(self) -> None:
"""Disconnect from Telegram gracefully."""
if self._client:
try:
await self._client.disconnect()
logger.info("Disconnected from Telegram")
except Exception as e:
logger.warning("Error during disconnect: %s", e)
finally:
self._client = None
async def _send_and_wait(self, entity: Any, message: str) -> Optional[str]:
"""
Send a message to BotFather and wait for a response.
Handles FloodWaitError by sleeping for the required duration and retrying once.
Args:
entity: The BotFather entity to send to.
message: The text message to send.
Returns:
BotFather's response text, or None on timeout/error.
"""
if not self._client:
logger.warning("_send_and_wait called without active client")
return None
from telethon.errors import FloodWaitError as _FloodWaitError
from telethon.errors import RPCError as _RPCError
try:
await self._client.send_message(entity, message)
logger.info("Sent to BotFather: %s", message)
except _FloodWaitError as e:
wait_seconds = e.seconds
logger.warning("FloodWaitError: waiting %d seconds before retry", wait_seconds)
await asyncio.sleep(wait_seconds)
try:
await self._client.send_message(entity, message)
logger.info("Sent to BotFather (after flood wait): %s", message)
except Exception as retry_err:
logger.warning("Failed to send after flood wait: %s", retry_err)
return None
except _RPCError as e:
logger.warning("RPC error sending to BotFather: %s", e)
return None
except Exception as e:
logger.warning("Unexpected error sending to BotFather: %s", e)
return None
# Wait for BotFather's response
deadline = time.monotonic() + MESSAGE_TIMEOUT
# Brief pause to let BotFather process
await asyncio.sleep(1.5)
try:
while time.monotonic() < deadline:
# Get the most recent messages from BotFather
messages = await self._client.get_messages(entity, limit=1)
if not messages:
await asyncio.sleep(1.0)
continue
# get_messages returns a list-like object
msg_list = list(messages)
if msg_list:
latest = msg_list[0]
# Check that this message is FROM BotFather (not our own)
if getattr(latest, "out", True) is False and getattr(latest, "text", None):
response_text: str = latest.text
logger.info("BotFather response received (%d chars)", len(response_text))
return response_text
# Poll interval
await asyncio.sleep(1.0)
logger.warning("Timeout waiting for BotFather response (after %ds)", MESSAGE_TIMEOUT)
return None
except Exception as e:
logger.warning("Error reading BotFather response: %s", e)
return None
async def create_bot(self, branch_name: str) -> Optional[dict]:
"""
Create a new Telegram bot via @BotFather conversation.
Conversation flow:
1. /newbot
2. Display name (e.g., "AIPass Dev Central")
3. Username (e.g., "aipass_dev_central_bot")
4. Parse token from success response
If the username is taken, retries with numeric suffixes up to MAX_USERNAME_ATTEMPTS.
Args:
branch_name: AIPass branch name (e.g., "dev_central", "flow").
Returns:
Dict with "token", "username", "display_name" on success.
None on any failure.
"""
if not self._client:
logger.warning("create_bot called without active connection")
return None
display_name = _format_display_name(branch_name)
# Resolve BotFather entity
try:
botfather = await self._client.get_entity(BOTFATHER_USERNAME)
logger.info("Resolved BotFather entity: %s", getattr(botfather, "id", "?"))
except Exception as e:
logger.warning("Failed to resolve @BotFather entity: %s", e)
return None
# Step 1: Send /newbot
response = await self._send_and_wait(botfather, "/newbot")
if not response:
logger.warning("BotFather did not respond to /newbot")
return None
# BotFather should ask for a name
if "name" not in response.lower():
logger.warning("Unexpected BotFather response to /newbot: %s", response[:200])
return None
logger.info("BotFather asked for bot name")
# Step 2: Send display name
response = await self._send_and_wait(botfather, display_name)
if not response:
logger.warning("BotFather did not respond to display name")
return None
# BotFather should ask for a username
if "username" not in response.lower():
logger.warning("Unexpected BotFather response to display name: %s", response[:200])
return None
logger.info("BotFather asked for username")
# Step 3: Try usernames with incrementing suffix
for attempt in range(MAX_USERNAME_ATTEMPTS):
username = _format_username(branch_name, suffix=attempt)
logger.info("Trying username: %s (attempt %d/%d)", username, attempt + 1, MAX_USERNAME_ATTEMPTS)
response = await self._send_and_wait(botfather, username)
if not response:
logger.warning("BotFather did not respond to username '%s'", username)
return None
# Check if the username was accepted (token in response)
token_match = BOT_TOKEN_PATTERN.search(response)
if token_match:
token = token_match.group()
logger.info(
"Bot created successfully: @%s (token: %s...%s)",
username, token[:8], token[-4:],
)
return {
"token": token,
"username": username,
"display_name": display_name,
}
# Username taken - BotFather says "Sorry" or mentions "already"
if "sorry" in response.lower() or "already" in response.lower() or "taken" in response.lower():
logger.info("Username '%s' is taken, trying next", username)
# If not the last attempt, BotFather is still waiting for a username
# so we can send another one directly without restarting /newbot
continue
# Unexpected response
logger.warning(
"Unexpected BotFather response for username '%s': %s",
username, response[:200],
)
return None
logger.warning(
"All %d username attempts exhausted for branch '%s'",
MAX_USERNAME_ATTEMPTS, branch_name,
)
return None
# =============================================
# SYNCHRONOUS WRAPPER
# =============================================
def create_bot_via_botfather(branch_name: str) -> Optional[dict]:
"""
Synchronous wrapper to create a Telegram bot via BotFather automation.
This is the main entry point for stdlib-based callers (e.g., base_bot.py).
Loads config, connects via Telethon, drives the BotFather conversation,
and returns the result.
Args:
branch_name: AIPass branch name (e.g., "dev_central").
Returns:
Dict with "token", "username", "display_name" on success.
None on any failure (config missing, connection failed, BotFather error, etc.).
"""
# Pre-flight checks
ready, reason = check_telethon_setup()
if not ready:
logger.warning("Telethon setup check failed: %s", reason)
return None
# Load config
config = _load_telethon_config()
if config is None:
logger.warning("Cannot create bot: Telethon config not loaded")
return None
api_id = config["api_id"]
api_hash = config["api_hash"]
# Run the async flow
client = BotFatherClient(api_id, api_hash)
result = None
async def _run() -> Optional[dict]:
connected = await client.connect()
if not connected:
return None
try:
return await client.create_bot(branch_name)
finally:
await client.disconnect()
try:
# Handle the case where an event loop is already running
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop and loop.is_running():
# We're inside an existing event loop (unlikely for our stdlib callers,
# but handle gracefully). Create a new loop in a thread.
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(asyncio.run, _run())
result = future.result(timeout=120)
else:
result = asyncio.run(_run())
except Exception as e:
logger.warning("create_bot_via_botfather failed: %s", e)
return None
if result:
logger.info(
"Bot created via BotFather: @%s for branch '%s'",
result.get("username"), branch_name,
)
else:
logger.warning("Bot creation via BotFather failed for branch '%s'", branch_name)
return result
@@ -1,170 +0,0 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: branch_plugin.py - BranchPlugin extends BaseBot for per-branch bots
# Date: 2026-02-24
# Version: 1.0.0
# Category: api/handlers/telegram
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-24): Initial - BranchPlugin with message prefixing and startup injection
#
# CODE STANDARDS:
# - Inherits Prax get_direct_logger() from BaseBot (FPLAN-0382 migration)
# - Extends BaseBot via hook overrides
# - No duplicated logic - all core behavior lives in BaseBot
# =============================================
"""
BranchPlugin - Per-branch Telegram bot extending BaseBot.
Each AIPass branch gets its own dedicated Telegram bot. BranchPlugin overrides
BaseBot's hooks to:
- Prefix incoming messages with "Patrick via Telegram: "
- Prefix outgoing responses with "@branch_name"
- Inject "hi" on session creation to trigger the branch startup protocol
Usage:
bot = BranchPlugin(
branch_name="dev_central",
bot_id="dev_central",
bot_token="123:ABC",
work_dir=Path.cwd(),
bot_name="AIPass Dev Central Bot",
allowed_user_ids=[7235222625],
)
sys.exit(bot.run())
"""
# Infrastructure
import os
import sys
from pathlib import Path
# Standard library
import argparse
import json
import time
# Sibling import
from aipass.api.apps.handlers.telegram.base_bot import BaseBot
# =============================================
# BranchPlugin CLASS
# =============================================
class BranchPlugin(BaseBot):
"""
Per-branch Telegram bot that extends BaseBot with branch-specific behavior.
Overrides BaseBot hooks to prefix messages, tag responses, and trigger
the AIPass startup protocol when a new tmux session is created.
"""
def __init__(self, branch_name: str, **kwargs) -> None:
"""
Initialize BranchPlugin.
Args:
branch_name: AIPass branch name (e.g., "dev_central", "seed")
**kwargs: All BaseBot constructor arguments (bot_id, bot_token, etc.)
"""
self.branch_name = branch_name
super().__init__(**kwargs)
# =============================================
# HOOK OVERRIDES
# =============================================
def on_message(self, text: str) -> str:
"""
Prefix incoming messages with sender attribution.
Args:
text: Raw message text from Telegram
Returns:
Prefixed text for Claude: "Patrick via Telegram: {text}"
"""
return f"Patrick via Telegram: {text}"
def on_response(self, text: str) -> str:
"""
Prefix outgoing responses with branch tag.
Args:
text: Raw response text from Claude
Returns:
Tagged text: "@{branch_name}\n{text}"
"""
return f"@{self.branch_name}\n{text}"
def on_session_create(self, session_name: str, work_dir: Path) -> None:
"""
Inject "hi" after tmux session creation to trigger startup protocol.
Waits 2 seconds for Claude to fully initialize, then injects "hi"
which triggers the AIPass startup sequence (reading memories, etc.).
Args:
session_name: The tmux session name that was created
work_dir: The working directory of the session
"""
self.logger.info(
"Branch session created for @%s, injecting startup greeting",
self.branch_name,
)
time.sleep(2)
self.inject_message("hi")
# =============================================
# CLI ENTRY POINT
# =============================================
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="AIPass Telegram Branch Bot")
parser.add_argument("--bot-id", required=True, help="Bot identifier")
parser.add_argument("--config", help="Path to bot config JSON")
args = parser.parse_args()
# Load config from data dir telegram_bots/{bot_id}.json or --config path
_env_data = os.environ.get("AIPASS_DATA_DIR")
_data_dir = Path(_env_data) if _env_data else (
Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "aipass"
)
config_path = (
Path(args.config)
if args.config
else _data_dir / "telegram_bots" / f"{args.bot_id}.json"
)
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
# If config has "branch_name", create BranchPlugin; otherwise BaseBot
shared_session = config.get("shared_session")
if config.get("branch_name"):
bot = BranchPlugin(
branch_name=config["branch_name"],
bot_id=args.bot_id,
bot_token=config["bot_token"],
work_dir=Path(config["work_dir"]),
bot_name=config.get("bot_name", f"AIPass {config['branch_name']} Bot"),
allowed_user_ids=config.get("allowed_user_ids", []),
shared_session=shared_session,
)
else:
bot = BaseBot(
bot_id=args.bot_id,
bot_token=config["bot_token"],
work_dir=Path(config.get("work_dir", str(Path.cwd()))),
bot_name=config.get("bot_name", "AIPass Bot"),
allowed_user_ids=config.get("allowed_user_ids", []),
shared_session=shared_session,
)
sys.exit(bot.run())
@@ -1,267 +0,0 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: config.py - Telegram Configuration Handler
# Date: 2026-02-03
# Version: 1.2.0
# Category: api/handlers/telegram
#
# CHANGELOG (Max 5 entries):
# - v1.2.0 (2026-02-24): Add multi-bot config: load_bot_config, list_bot_configs, validate_bot_config
# - v1.1.0 (2026-02-03): Add allowed_user_ids loading for user allowlist
# - v1.0.0 (2026-02-03): Initial config loader for Telegram bridge
#
# CODE STANDARDS:
# - Pure functions with proper error raising
# - No Prax imports (handler tier 3)
# =============================================
"""
Telegram Configuration Handler
Manages Telegram bot configuration:
- Load bot token from config file (legacy single-bot: ~/.aipass/telegram_config.json)
- Load bot username
- Load allowed user IDs for access control
- Load per-bot configs (multi-bot: ~/.aipass/telegram_bots/{bot_id}.json)
- List and validate bot configs
"""
# Infrastructure
import os
import sys
from pathlib import Path
# Standard library
import json
from typing import Optional, List
# =============================================
# CONSTANTS
# =============================================
def _aipass_data_dir() -> Path:
"""User data directory for AIPass runtime files."""
env = os.environ.get("AIPASS_DATA_DIR")
if env:
return Path(env)
return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "aipass"
_DATA_DIR = _aipass_data_dir()
CONFIG_PATH = _DATA_DIR / "telegram_config.json"
BOT_CONFIG_DIR = _DATA_DIR / "telegram_bots"
REQUIRED_BOT_FIELDS = ("bot_id", "bot_token")
# =============================================
# CONFIGURATION LOADING
# =============================================
def load_telegram_config() -> Optional[dict]:
"""
Load Telegram configuration from config file.
Config file location: ~/.aipass/telegram_config.json
Expected structure:
{
"telegram_bot_token": "...",
"telegram_bot_username": "aipass_bridge_bot",
"allowed_user_ids": []
}
Returns:
Configuration dict or None if load fails
"""
try:
if not CONFIG_PATH.exists():
return None
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
config = json.load(f)
return config
except json.JSONDecodeError:
return None
except Exception:
return None
def get_bot_token() -> Optional[str]:
"""
Get Telegram bot token from config.
Returns:
Bot token string or None if not found
"""
config = load_telegram_config()
if not config:
return None
token = config.get("telegram_bot_token")
if not token:
return None
return token
def get_bot_username() -> Optional[str]:
"""
Get Telegram bot username from config.
Returns:
Bot username string or None if not found
"""
config = load_telegram_config()
if not config:
return None
username = config.get("telegram_bot_username")
if not username:
return None
return username
def get_allowed_user_ids() -> List[int]:
"""
Get list of allowed Telegram user IDs from config.
Returns:
List of allowed user IDs. Empty list means allow all (for testing).
"""
config = load_telegram_config()
if not config:
return []
allowed = config.get("allowed_user_ids", [])
if not isinstance(allowed, list):
return []
return [int(uid) for uid in allowed if isinstance(uid, (int, str))]
def validate_config() -> bool:
"""
Validate that Telegram configuration is complete.
Returns:
True if config is valid, False otherwise
"""
config = load_telegram_config()
if not config:
return False
if not config.get("telegram_bot_token"):
return False
return True
# =============================================
# MULTI-BOT CONFIGURATION (per-bot configs)
# =============================================
def load_bot_config(bot_id: str) -> dict | None:
"""
Load per-bot config from ~/.aipass/telegram_bots/{bot_id}.json.
Config format:
{
"bot_id": "dev_central",
"bot_token": "123:ABC...",
"bot_name": "AIPass Dev Central Bot",
"branch_name": "dev_central", // null for base bot
"work_dir": "/path/to/branch/work_dir",
"allowed_user_ids": [7235222625]
}
Args:
bot_id: Bot identifier matching the config filename.
Returns:
Config dict or None if not found/invalid.
"""
config_path = BOT_CONFIG_DIR / f"{bot_id}.json"
try:
if not config_path.exists():
return None
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
if not isinstance(config, dict):
return None
return config
except json.JSONDecodeError:
return None
except OSError:
return None
def list_bot_configs() -> list[str]:
"""
List all bot config files (returns list of bot_ids).
Scans ~/.aipass/telegram_bots/ for .json files, excluding
internal files that start with underscore (e.g., _registry.json).
Returns:
List of bot_id strings derived from config filenames.
"""
if not BOT_CONFIG_DIR.exists():
return []
bot_ids = []
try:
for path in sorted(BOT_CONFIG_DIR.glob("*.json")):
# Skip internal files (e.g., _registry.json)
if path.stem.startswith("_"):
continue
bot_ids.append(path.stem)
except OSError:
return []
return bot_ids
def validate_bot_config(config: object) -> tuple[bool, str]:
"""
Validate a bot config dict.
Checks for required fields and basic type correctness.
Args:
config: Bot config dict to validate.
Returns:
Tuple of (valid, error_message). error_message is empty on success.
"""
if not isinstance(config, dict):
return False, "Config must be a dict"
# Check required fields
for field in REQUIRED_BOT_FIELDS:
if not config.get(field):
return False, f"Missing required field: {field}"
# Type checks
bot_token = config.get("bot_token", "")
if not isinstance(bot_token, str) or ":" not in bot_token:
return False, "bot_token must be a string in format 'id:hash'"
if "work_dir" in config and config["work_dir"] is not None:
work_dir = Path(config["work_dir"])
if not work_dir.is_absolute():
return False, "work_dir must be an absolute path"
if "allowed_user_ids" in config:
allowed = config["allowed_user_ids"]
if not isinstance(allowed, list):
return False, "allowed_user_ids must be a list"
return True, ""
@@ -1,220 +0,0 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: file_handler.py - Telegram File Upload Handler
# Date: 2026-02-10
# Version: 1.0.0
# Category: api/handlers/telegram
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-10): Initial file handler - photo, document, file upload support (Phase 5 FPLAN-0312)
#
# CODE STANDARDS:
# - Pure functions with proper error raising
# - No Prax imports (handler tier 3)
# =============================================
"""
Telegram File Upload Handler
Handles file uploads from Telegram:
- Downloads files to temp directory
- Detects file type (text, image, pdf, binary)
- Builds Claude prompts with file content inline or path reference
- Cleans up temp files after processing
"""
# Infrastructure
import sys
from pathlib import Path
import uuid
# Constants
TEMP_DIR = Path('/tmp/telegram_uploads')
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
TEXT_CONTENT_LIMIT = 50000
# Text file extensions that can be read as UTF-8
SUPPORTED_TEXT_EXTENSIONS = {
'.py', '.js', '.ts', '.java', '.go', '.rs', '.rb', '.php', '.c', '.cpp',
'.h', '.hpp', '.cs', '.swift', '.kt',
'.sh', '.bash', '.zsh', '.sql', '.html', '.css', '.scss',
'.json', '.yaml', '.yml', '.xml', '.toml', '.ini', '.cfg',
'.md', '.txt', '.rst', '.log', '.csv', '.env', '.gitignore', '.dockerfile',
}
# Image file extensions
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'}
# Map file extensions to language names for code blocks
LANGUAGE_MAP = {
'.py': 'python', '.js': 'javascript', '.ts': 'typescript', '.java': 'java',
'.go': 'go', '.rs': 'rust', '.rb': 'ruby', '.php': 'php', '.c': 'c',
'.cpp': 'cpp', '.h': 'c', '.cs': 'csharp', '.swift': 'swift', '.kt': 'kotlin',
'.sh': 'bash', '.bash': 'bash', '.sql': 'sql', '.html': 'html', '.css': 'css',
'.json': 'json', '.yaml': 'yaml', '.yml': 'yaml', '.xml': 'xml',
'.toml': 'toml', '.md': 'markdown',
}
def _sanitize_filename(raw_filename: str) -> str:
"""
Sanitize a filename by removing path separators and dangerous characters.
Args:
raw_filename: The original filename to sanitize
Returns:
A safe filename string
"""
safe_name = Path(raw_filename).name
safe_name = "".join(c if c.isalnum() or c in '.-_' else '_' for c in safe_name)
return safe_name or str(uuid.uuid4())
async def download_telegram_file(file_obj, filename: str | None = None) -> Path:
"""
Download a Telegram file to the temp directory.
Args:
file_obj: Telegram File object (from get_file())
filename: Optional original filename
Returns:
Path to the downloaded file
Raises:
ValueError: If file exceeds MAX_FILE_SIZE
"""
if file_obj.file_size and file_obj.file_size > MAX_FILE_SIZE:
raise ValueError(
f"File too large: {file_obj.file_size} bytes "
f"(max {MAX_FILE_SIZE // (1024 * 1024)}MB)"
)
TEMP_DIR.mkdir(parents=True, exist_ok=True)
if filename:
safe_name = _sanitize_filename(filename)
else:
ext = ''
if file_obj.file_path:
ext = Path(file_obj.file_path).suffix
safe_name = f"{uuid.uuid4()}{ext}"
dest = TEMP_DIR / safe_name
await file_obj.download_to_drive(dest)
print("[INFO]", "Downloaded file to %s (%s bytes)", dest, file_obj.file_size)
return dest
def detect_file_type(file_path: Path) -> str:
"""
Detect the type of a file based on extension and content.
Args:
file_path: Path to the file
Returns:
One of: 'text', 'image', 'pdf', 'binary'
"""
suffix = file_path.suffix.lower()
if suffix in SUPPORTED_TEXT_EXTENSIONS:
return 'text'
if suffix in IMAGE_EXTENSIONS:
return 'image'
if suffix == '.pdf':
return 'pdf'
# Unknown extension - try reading as UTF-8
try:
with open(file_path, 'rb') as f:
sample = f.read(1024)
sample.decode('utf-8')
return 'text'
except (UnicodeDecodeError, OSError):
return 'binary'
def build_file_prompt(
file_path: Path,
file_type: str,
caption: str | None = None,
sender_name: str = 'Patrick'
) -> str:
"""
Build a Claude prompt that includes file content.
Args:
file_path: Path to the downloaded file
file_type: One of 'text', 'image', 'pdf', 'binary'
caption: Optional caption from the Telegram message
sender_name: Name of the sender
Returns:
Formatted prompt string for Claude
"""
FILE_NAME = file_path.name
if file_type == 'text':
try:
FILE_CONTENT = file_path.read_text(encoding='utf-8', errors='ignore')
except OSError:
FILE_CONTENT = '[Error reading file]'
if len(FILE_CONTENT) > TEXT_CONTENT_LIMIT:
FILE_CONTENT = FILE_CONTENT[:TEXT_CONTENT_LIMIT] + '\n[...truncated]'
FILE_SUFFIX = file_path.suffix.lower()
FILE_LANGUAGE = LANGUAGE_MAP.get(FILE_SUFFIX, '')
PROMPT_CAPTION = caption or 'Review this file'
return (
f"{sender_name} via Telegram: {PROMPT_CAPTION}\n\n"
f"File: {FILE_NAME}\n\n"
f"```{FILE_LANGUAGE}\n{FILE_CONTENT}\n```"
)
elif file_type == 'image':
PROMPT_CAPTION = caption or 'What do you see in this image?'
return (
f"{sender_name} via Telegram: {PROMPT_CAPTION}\n\n"
f"[Image attached at: {file_path}]\n"
f"Please use the Read tool to view the image file at the path above."
)
elif file_type == 'pdf':
PROMPT_CAPTION = caption or 'Review this document'
return (
f"{sender_name} via Telegram: {PROMPT_CAPTION}\n\n"
f"[PDF document at: {file_path}]\n"
f"Please use the Read tool to view the PDF file at the path above."
)
else: # binary
try:
FILE_SIZE = file_path.stat().st_size
except OSError:
FILE_SIZE = 0
PROMPT_CAPTION = caption or 'I sent a file'
return (
f"{sender_name} via Telegram: {PROMPT_CAPTION}\n\n"
f"[File at: {file_path}] (binary, {FILE_SIZE} bytes)\n"
f"Note: This is a binary file that may not be directly readable."
)
def cleanup_file(file_path: Path) -> None:
"""
Remove a temporary file.
Args:
file_path: Path to the file to clean up
"""
file_path.unlink(missing_ok=True)
print("[INFO]", "Cleaned up temp file: %s", file_path)
@@ -1,263 +0,0 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: log_streamer.py - Stream system logs to Telegram
# Date: 2026-02-26
# Version: 1.0.0
# Category: api/handlers/telegram
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-26): Initial - daemon thread log tailing with batched Telegram delivery
#
# CODE STANDARDS:
# - Uses Prax get_direct_logger() — no event pipeline (FPLAN-0382 migration)
# - Silent failure on Telegram errors (log warning, never crash)
# - Handlers implement logic, modules orchestrate
# =============================================
"""
LogStreamer - Stream system log lines to a Telegram chat.
v1.0.0
Runs as a background daemon thread, tailing log files for a specific branch
and batching new lines to send via the Telegram Bot API. Tracks file positions
to only deliver new content, handles file rotation, and discovers new log files
each cycle.
Usage:
streamer = LogStreamer(bot_token="...", chat_id=123456, branch_name="api")
streamer.start()
# ... later ...
streamer.stop()
"""
# Infrastructure
import sys
from pathlib import Path
# Standard library
import json
import threading
from typing import Dict, List
from urllib.error import URLError
from urllib.request import Request, urlopen
# Logging (Prax direct logger — FPLAN-0382, no event pipeline)
from aipass.prax.apps.modules.logger import get_direct_logger
# =============================================
# CONSTANTS
# =============================================
def _find_repo_root() -> Path:
"""Walk up from this file to find AIPASS_REGISTRY.json (repo root)."""
current = Path(__file__).resolve().parent
for parent in [current] + list(current.parents):
if (parent / "AIPASS_REGISTRY.json").exists():
return parent
return Path.cwd()
SYSTEM_LOGS_DIR = _find_repo_root() / "logs"
BATCH_INTERVAL = 5.0
TELEGRAM_MAX_LENGTH = 4000
# =============================================
# LOG STREAMER
# =============================================
class LogStreamer:
"""Stream system log lines for a branch to Telegram via batched sends."""
def __init__(self, bot_token: str, chat_id: int, branch_name: str) -> None:
self.bot_token = bot_token
self.chat_id = chat_id
self.branch_name = branch_name
self._running = False
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self.log_positions: Dict[str, int] = {}
# Direct logger (no event pipeline — avoids recursion with log tailing)
self.logger = get_direct_logger()
# Initialize positions to end of all existing log files
self._init_positions()
# -----------------------------------------
# POSITION TRACKING
# -----------------------------------------
def _get_log_files(self) -> List[Path]:
"""Find all log files matching this branch's pattern."""
if not SYSTEM_LOGS_DIR.exists():
return []
return sorted(SYSTEM_LOGS_DIR.glob(f"{self.branch_name}_*.log"))
def _init_positions(self) -> None:
"""Set initial positions to end of file so we only tail new lines."""
for log_file in self._get_log_files():
file_path = str(log_file)
try:
self.log_positions[file_path] = log_file.stat().st_size
except OSError:
self.log_positions[file_path] = 0
self.logger.info(
"Initialized positions for %d log files (branch: %s)",
len(self.log_positions), self.branch_name
)
def _read_new_lines(self) -> List[str]:
"""Read new lines from all tracked log files."""
all_new_lines: List[str] = []
for log_file in self._get_log_files():
file_path = str(log_file)
try:
current_size = log_file.stat().st_size
except OSError:
continue
last_pos = self.log_positions.get(file_path, 0)
try:
# New file discovered mid-run: start from beginning
if file_path not in self.log_positions:
last_pos = 0
self.logger.info("New log file discovered: %s", file_path)
# File rotation: size shrank, reset to beginning
if current_size < last_pos:
self.logger.info("File rotation detected: %s", file_path)
last_pos = 0
# Read new content
if current_size > last_pos:
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
f.seek(last_pos)
new_content = f.read()
self.log_positions[file_path] = f.tell()
lines = new_content.splitlines()
if lines:
all_new_lines.extend(lines)
else:
# Update position even when nothing new (handles new file registration)
self.log_positions[file_path] = current_size
except OSError as e:
self.logger.warning("Failed to process %s: %s", file_path, e)
continue
return all_new_lines
# -----------------------------------------
# TELEGRAM DELIVERY
# -----------------------------------------
def _send_message(self, message: str) -> bool:
"""Send a message to Telegram. Returns True on success."""
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
payload = json.dumps({
"chat_id": self.chat_id,
"text": message,
"disable_notification": True
}).encode("utf-8")
req = Request(url, data=payload, headers={"Content-Type": "application/json"})
try:
with urlopen(req, timeout=10) as resp:
result = json.loads(resp.read())
return result.get("ok", False)
except (URLError, Exception) as e:
self.logger.warning("Telegram send failed: %s", e)
return False
def _send_batched(self, lines: List[str]) -> None:
"""Split lines into messages respecting TELEGRAM_MAX_LENGTH, send each."""
if not lines:
return
batch: List[str] = []
batch_len = 0
for line in lines:
# +1 for the newline separator between lines
line_len = len(line) + (1 if batch else 0)
if batch_len + line_len > TELEGRAM_MAX_LENGTH and batch:
# Send current batch
message = "\n".join(batch)
self._send_message(message)
batch = []
batch_len = 0
batch.append(line)
batch_len += line_len
# Send remaining
if batch:
message = "\n".join(batch)
self._send_message(message)
# -----------------------------------------
# DAEMON THREAD
# -----------------------------------------
def _run(self) -> None:
"""Main loop: read new lines, batch, send, sleep."""
self.logger.info("Log streamer started for branch: %s", self.branch_name)
self.logger.info("Watching: %s/%s_*.log (chat_id=%s)", SYSTEM_LOGS_DIR, self.branch_name, self.chat_id)
while self._running:
try:
new_lines = self._read_new_lines()
if new_lines:
self.logger.info("Found %d new log lines, sending to Telegram", len(new_lines))
self._send_batched(new_lines)
except Exception as e:
self.logger.warning("Streamer cycle error: %s", e)
# Interruptible sleep
self._stop_event.wait(BATCH_INTERVAL)
self.logger.info("Log streamer stopped for branch: %s", self.branch_name)
# -----------------------------------------
# PUBLIC API
# -----------------------------------------
def start(self) -> None:
"""Start the log streamer daemon thread."""
if self._running:
self.logger.warning("Log streamer already running")
return
self._running = True
self._stop_event.clear()
self._thread = threading.Thread(
target=self._run,
name=f"log-streamer-{self.branch_name}",
daemon=True
)
self._thread.start()
self.logger.info("Daemon thread started: %s", self._thread.name)
def stop(self) -> None:
"""Stop the log streamer and wait for thread to finish."""
if not self._running:
return
self._running = False
self._stop_event.set()
if self._thread is not None:
self._thread.join(timeout=BATCH_INTERVAL + 2)
if self._thread.is_alive():
self.logger.warning("Daemon thread did not exit cleanly")
self._thread = None
self.logger.info("Log streamer stopped")
@@ -1,122 +0,0 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: notifier.py - Telegram Push Notifications
# Date: 2026-02-17
# Version: 1.1.0
# Category: api/handlers/telegram
#
# CHANGELOG (Max 5 entries):
# - v1.1.0 (2026-02-18): DPLAN-005 - Add silent mode, CLI interface for cross-branch use, markdown support
# - v1.0.0 (2026-02-17): Initial implementation - reusable Telegram notification sender
#
# CODE STANDARDS:
# - Handlers implement logic, modules orchestrate
# - No cross-branch imports, no Prax logger
# =============================================
"""
Telegram notification sender for the scheduler bot.
Can be used two ways:
1. Import (within API branch): send_telegram_notification("message")
2. CLI (cross-branch, no import guard): python3 notifier.py "message"
Flags: --silent (silent push), --markdown (Markdown parse mode)
Reads bot token and chat_id from ~/.aipass/scheduler_config.json.
"""
# Infrastructure
import os
import sys
from pathlib import Path
# Standard library
import json
from urllib.request import Request, urlopen
from urllib.error import URLError
# =============================================
# CONSTANTS
# =============================================
def _aipass_data_dir() -> Path:
"""User data directory for AIPass runtime files."""
env = os.environ.get("AIPASS_DATA_DIR")
if env:
return Path(env)
return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "aipass"
CONFIG_PATH = _aipass_data_dir() / "scheduler_config.json"
# =============================================
# PUBLIC API
# =============================================
def send_telegram_notification(
message: str,
silent: bool = False,
parse_mode: str | None = None,
) -> bool:
"""
Send a message to Telegram via the scheduler bot.
Args:
message: Text to send (plain text or Markdown)
silent: If True, send as silent notification (no sound on phone)
parse_mode: Telegram parse mode ("Markdown" or "HTML"). None for plain text.
Returns:
True if sent successfully, False otherwise
"""
try:
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
config = json.load(f)
bot_token = config["telegram_bot_token"]
chat_id = config["telegram_chat_id"]
except (FileNotFoundError, KeyError, json.JSONDecodeError):
return False
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
payload_dict: dict[str, object] = {"chat_id": chat_id, "text": message}
if silent:
payload_dict["disable_notification"] = True
if parse_mode:
payload_dict["parse_mode"] = parse_mode
data = json.dumps(payload_dict).encode("utf-8")
req = Request(url, data=data, headers={"Content-Type": "application/json"})
try:
with urlopen(req, timeout=15) as resp:
result = json.loads(resp.read())
return result.get("ok", False)
except (URLError, Exception):
return False
# =============================================
# CLI INTERFACE (cross-branch use)
# =============================================
if __name__ == "__main__":
args = sys.argv[1:]
if not args or args[0] in ("-h", "--help"):
print("Usage: python3 notifier.py [--silent] [--markdown] \"message\"")
print(" --silent Send as silent notification (no sound)")
print(" --markdown Use Telegram Markdown parse mode")
sys.exit(0)
silent_flag = "--silent" in args
markdown_flag = "--markdown" in args
msg_args = [a for a in args if not a.startswith("--")]
if not msg_args:
print("Error: no message provided", file=sys.stderr)
sys.exit(1)
msg = " ".join(msg_args)
mode = "Markdown" if markdown_flag else None
ok = send_telegram_notification(msg, silent=silent_flag, parse_mode=mode)
sys.exit(0 if ok else 1)
@@ -1,156 +0,0 @@
#!/usr/bin/env python3
# ===================AIPASS====================
# META DATA HEADER
# Name: output_parser.py - Claude Stream JSON Output Parser
# Date: 2026-02-10
# Version: 1.0.0
# Category: api/handlers/telegram
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-10): Initial - parse stream-json from Claude CLI
#
# CODE STANDARDS:
# - Pure functions with proper error raising
# - No Prax imports (handler tier 3)
# =============================================
"""
Claude Stream JSON Output Parser
Parses newline-delimited JSON output from Claude CLI when invoked with
--output-format stream-json --verbose.
Output format (3 line types):
1. system/init - session_id, model, tools
2. assistant - message content blocks (text, tool_use)
3. result - success/error, session_id, cost, duration
"""
import sys
import json
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional
# Infrastructure — disabled file, no sys.path manipulation needed
logger = logging.getLogger("telegram_output_parser")
@dataclass
class ParsedResult:
"""Structured result from parsing Claude stream-json output."""
success: bool
text: str
session_id: Optional[str] = None
cost_usd: Optional[float] = None
duration_ms: Optional[int] = None
error_message: Optional[str] = None
class OutputParser:
"""Parse Claude CLI stream-json output into structured results."""
@staticmethod
def parse_stream(raw_output: str) -> ParsedResult:
"""
Parse raw stream-json output from Claude CLI.
Splits output by newlines, parses each line as JSON, and extracts:
- Text from assistant message content blocks (type='text' only)
- session_id from result line
- cost from result line total_cost_usd
- Error detection from result line is_error / subtype='error'
Falls back to returning raw text if JSON parsing fails entirely.
Args:
raw_output: Raw stdout from Claude CLI with --output-format stream-json
Returns:
ParsedResult with extracted fields
"""
if not raw_output or not raw_output.strip():
return ParsedResult(success=False, text="", error_message="Empty output")
text_parts: List[str] = []
session_id: Optional[str] = None
cost_usd: Optional[float] = None
duration_ms: Optional[int] = None
is_error = False
error_message: Optional[str] = None
parsed_any = False
for line in raw_output.strip().splitlines():
line = line.strip()
if not line:
continue
try:
data = json.loads(line)
except json.JSONDecodeError:
logger.info("Skipping non-JSON line: %s", line[:100])
continue
parsed_any = True
line_type = data.get("type")
if line_type == "assistant":
# Extract text from content blocks
message = data.get("message", {})
content_blocks = message.get("content", [])
for block in content_blocks:
if block.get("type") == "text":
text_parts.append(block.get("text", ""))
elif line_type == "result":
# Extract session_id, cost, duration, error status
session_id = data.get("session_id", session_id)
cost_usd = data.get("total_cost_usd", cost_usd)
duration_ms = data.get("duration_ms", duration_ms)
if data.get("is_error", False) or data.get("subtype") == "error":
is_error = True
error_message = data.get("result", "Unknown error")
elif line_type == "system":
# Init line - capture session_id as fallback
if not session_id:
session_id = data.get("session_id")
# If we couldn't parse any JSON at all, fall back to raw text
if not parsed_any:
logger.warning("No valid JSON lines found, returning raw output")
return ParsedResult(success=True, text=raw_output.strip())
combined_text = "\n".join(text_parts) if text_parts else ""
if is_error:
return ParsedResult(
success=False,
text=combined_text,
session_id=session_id,
cost_usd=cost_usd,
duration_ms=duration_ms,
error_message=error_message,
)
if not combined_text:
return ParsedResult(
success=False,
text="",
session_id=session_id,
cost_usd=cost_usd,
duration_ms=duration_ms,
error_message="No text content in response",
)
return ParsedResult(
success=True,
text=combined_text,
session_id=session_id,
cost_usd=cost_usd,
duration_ms=duration_ms,
)
@@ -1,345 +0,0 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: response_router.py - CWD-safe response routing for multi-bot architecture
# Date: 2026-02-24
# Version: 1.0.0
# Category: api/handlers/telegram
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-24): Initial - CWD-safe pending file matching with directory tree resolution
#
# CODE STANDARDS:
# - Pure functions with proper error handling (graceful - never raise)
# - Uses Prax system_logger (FPLAN-0382 migration)
# - Stdlib only (no external deps)
# =============================================
"""
CWD-Safe Response Routing for Multi-Bot Architecture
Fixes the CWD mismatch bug in the Stop hook. When Claude fires the Stop hook,
the working directory may be a subdirectory of the branch root (e.g.,
<branch_root>/git_repo/ instead of <branch_root>/). The old logic used
Path.cwd().name which fails in subdirectories.
New logic uses cwd.relative_to(work_dir) which succeeds if CWD is ANYWHERE
in the bot's directory tree.
Pending file naming:
- v2 (new): bot-{bot_id}.json
- v1 (legacy): telegram-{branch_name}.json
Both formats are supported during the transition period.
"""
# Infrastructure
import sys
from pathlib import Path
# Standard library
import json
import os
import subprocess
import time
from typing import Optional
# Logging (Prax system_logger — FPLAN-0382)
from aipass.prax.apps.modules.logger import system_logger as logger
# =============================================
# CONSTANTS
# =============================================
def _aipass_data_dir() -> Path:
"""User data directory for AIPass runtime files."""
env = os.environ.get("AIPASS_DATA_DIR")
if env:
return Path(env)
return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "aipass"
_DATA_DIR = _aipass_data_dir()
PENDING_DIR = _DATA_DIR / "telegram_pending"
PENDING_TTL = 3600 # 1 hour
# =============================================
# DIRECTORY TREE MATCHING
# =============================================
def is_cwd_in_tree(cwd: Path, work_dir) -> bool:
"""
Check if cwd is within work_dir's directory tree using relative_to().
This is the core fix for the CWD mismatch bug. Instead of comparing
directory names (which fails in subdirectories), we check if cwd is
a child of work_dir at any depth.
Args:
cwd: Current working directory to check.
work_dir: Bot's configured working directory (str or Path).
Returns:
True if cwd is within work_dir's tree, False otherwise.
"""
try:
cwd.relative_to(Path(work_dir))
return True
except ValueError:
return False
# =============================================
# TMUX SESSION CHECKING
# =============================================
def is_tmux_alive(session_name: str) -> bool:
"""
Check if a tmux session exists.
Args:
session_name: Name of the tmux session to check.
Returns:
True if the session exists, False otherwise.
"""
try:
result = subprocess.run(
["tmux", "has-session", "-t", session_name],
capture_output=True,
text=True,
timeout=5,
)
return result.returncode == 0
except (subprocess.TimeoutExpired, OSError):
return False
# =============================================
# PENDING FILE EXPIRY
# =============================================
def is_pending_expired(pending_data: dict) -> bool:
"""
Check if a pending file is expired.
A pending file is considered expired only when BOTH conditions are met:
1. The timestamp is older than PENDING_TTL seconds
2. The associated tmux session is no longer alive
This prevents premature cleanup of pending files for long-running sessions.
Args:
pending_data: Parsed contents of a pending file.
Returns:
True if the pending file should be cleaned up, False otherwise.
"""
# Condition 1: Check TTL
timestamp = pending_data.get("timestamp", 0)
if isinstance(timestamp, str):
try:
timestamp = float(timestamp)
except ValueError:
timestamp = 0
if time.time() - timestamp <= PENDING_TTL:
return False # Still within TTL, not expired
# Condition 2: Check tmux session
# Derive session name from bot_id or branch_name
bot_id = pending_data.get("bot_id", "")
branch_name = pending_data.get("branch_name", "")
# Try the bot_id-based tmux session name first (v2)
if bot_id:
if is_tmux_alive(f"telegram-{bot_id}"):
return False # Session alive, not expired
# Try the branch-based tmux session name (v1)
if branch_name and branch_name != bot_id:
if is_tmux_alive(f"telegram-{branch_name}"):
return False # Session alive, not expired
# Past TTL AND no tmux session alive
return True
# =============================================
# PENDING FILE LOADING
# =============================================
def _load_pending_file(pending_path: Path) -> Optional[dict]:
"""
Load and parse a pending file from disk.
Args:
pending_path: Path to the pending JSON file.
Returns:
Parsed dict with "pending_path" key added, or None on error.
"""
try:
data = json.loads(pending_path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
return None
data["pending_path"] = str(pending_path)
return data
except (json.JSONDecodeError, OSError):
return None
# =============================================
# MAIN ROUTING LOGIC
# =============================================
def find_pending_bot(
cwd: Optional[Path] = None,
session_id: Optional[str] = None,
env_bot_id: Optional[str] = None,
) -> Optional[dict]:
"""
Find which bot's pending file matches the current context.
Uses a priority-based matching strategy:
Priority 1: AIPASS_BOT_ID env var (set in tmux session by BaseBot)
Direct match: look for bot-{env_bot_id}.json
Priority 2: cwd.relative_to(work_dir) - CWD anywhere in bot's directory tree
Load each pending file, check if cwd is within its work_dir
Priority 3: session_id match - fallback for legacy compatibility
Check session_id field in each pending file
Args:
cwd: Current working directory. Defaults to Path.cwd().
session_id: Claude Code session ID for fallback matching.
env_bot_id: Bot ID from environment. Defaults to AIPASS_BOT_ID env var.
Returns:
Pending file data dict with "pending_path" key, or None if no match.
"""
if not PENDING_DIR.exists():
return None
if cwd is None:
try:
cwd = Path.cwd()
except OSError:
cwd = Path.cwd()
if env_bot_id is None:
env_bot_id = os.environ.get("AIPASS_BOT_ID")
# Priority 1: Direct match via AIPASS_BOT_ID env var
if env_bot_id:
# v2 naming: bot-{bot_id}.json
PENDING_V2 = PENDING_DIR / f"bot-{env_bot_id}.json"
if PENDING_V2.exists():
data = _load_pending_file(PENDING_V2)
if data and not is_pending_expired(data):
logger.info("Matched pending by AIPASS_BOT_ID: %s", env_bot_id)
return data
# Also check v1 naming for this bot_id
PENDING_V1 = PENDING_DIR / f"telegram-{env_bot_id}.json"
if PENDING_V1.exists():
data = _load_pending_file(PENDING_V1)
if data and not is_pending_expired(data):
logger.info("Matched pending by AIPASS_BOT_ID (v1 naming): %s", env_bot_id)
return data
# Priority 2: CWD directory tree matching
# Check all pending files and see if CWD is within any bot's work_dir
ALL_PENDING = list(PENDING_DIR.glob("bot-*.json")) + list(PENDING_DIR.glob("telegram-*.json"))
for pending_path in ALL_PENDING:
data = _load_pending_file(pending_path)
if not data:
continue
if is_pending_expired(data):
continue
work_dir = data.get("work_dir", "")
if work_dir and is_cwd_in_tree(cwd, work_dir):
logger.info("Matched pending by CWD tree: cwd=%s within work_dir=%s", cwd, work_dir)
return data
# Legacy v1 files may not have work_dir - try branch_name directory matching
branch_name = data.get("branch_name", "")
if branch_name and not work_dir:
# CWD's directory name or any parent matches branch_name
path_cursor = cwd
while path_cursor != path_cursor.parent:
if path_cursor.name == branch_name:
logger.info("Matched pending by branch name in CWD path: %s", branch_name)
return data
path_cursor = path_cursor.parent
# Priority 3: Session ID fallback
if session_id:
for pending_path in ALL_PENDING:
data = _load_pending_file(pending_path)
if not data:
continue
if is_pending_expired(data):
continue
if data.get("session_id") == session_id:
logger.info("Matched pending by session_id: %s", session_id[:8])
return data
return None
# =============================================
# CLEANUP
# =============================================
def clean_expired_pending() -> int:
"""
Remove all expired pending files from the pending directory.
A file is expired when it is past TTL AND its tmux session is dead.
Returns:
Number of expired files removed.
"""
if not PENDING_DIR.exists():
return 0
REMOVED_COUNT = 0
ALL_PENDING = list(PENDING_DIR.glob("bot-*.json")) + list(PENDING_DIR.glob("telegram-*.json"))
for pending_path in ALL_PENDING:
data = _load_pending_file(pending_path)
if not data:
# Corrupt or unreadable file - remove it
try:
pending_path.unlink(missing_ok=True)
REMOVED_COUNT += 1
logger.info("Removed corrupt pending file: %s", pending_path.name)
except OSError as e:
logger.warning("Failed to remove corrupt pending file %s: %s", pending_path.name, e)
continue
if is_pending_expired(data):
try:
pending_path.unlink(missing_ok=True)
REMOVED_COUNT += 1
logger.info("Removed expired pending file: %s", pending_path.name)
except OSError as e:
logger.warning("Failed to remove expired pending file %s: %s", pending_path.name, e)
if REMOVED_COUNT > 0:
logger.info("Cleaned %d expired pending file(s)", REMOVED_COUNT)
return REMOVED_COUNT
@@ -1,573 +0,0 @@
#!/usr/bin/env python3
# ===================AIPASS====================
# META DATA HEADER
# Name: spawner.py - Claude Session Spawner
# Date: 2026-02-03
# Version: 4.3.0
# Category: api/handlers/telegram
#
# CHANGELOG (Max 5 entries):
# - v4.3.0 (2026-02-12): Branch targeting - @branch prefix routes Claude to branch CWD
# - v4.2.0 (2026-02-10): Add raw_prompt flag to run_claude_capture (Phase 5 FPLAN-0312)
# - v4.1.0 (2026-02-10): Session persistence - use stored session_id for resume, deterministic as fallback
# - v4.0.0 (2026-02-10): Stream JSON output parsing - structured results with session_id, cost
# - v3.2.0 (2026-02-10): Security fix - subprocess_exec for capture mode (no shell injection)
# - v3.1.0 (2026-02-09): Fix session ID collision - random UUID for new sessions, deterministic for resume only
#
# CODE STANDARDS:
# - Pure functions with proper error raising
# - No Prax imports (handler tier 3)
# =============================================
"""
Claude Session Spawner
Two modes of operation:
1. Visual mode: Spawn visible sessions (gnome-terminal/tmux) - fire and forget
2. Capture mode: Run Claude directly and capture stdout for response
Supports branch targeting: messages starting with @branch_name spawn Claude
in that branch's directory.
"""
import sys
import os
import re
import json
import subprocess
import shutil
import asyncio
import uuid
from pathlib import Path
from typing import Tuple, Optional, List
# Infrastructure — disabled file, kept for reference only
import logging
logger = logging.getLogger("telegram_spawner")
# NOTE: These imports reference old non-aipass namespace and won't work.
# from api.apps.handlers.telegram.output_parser import OutputParser, ParsedResult
# from api.apps.handlers.telegram.session_store import get_session, save_session
# Constants — package-relative
DEFAULT_SESSION_PATH = Path.cwd()
BRANCH_REGISTRY_PATH = Path.cwd() / "AIPASS_REGISTRY.json"
CLAUDE_BIN = shutil.which("claude") or "claude"
TMUX_SESSION_NAME = "telegram-claude"
TELEGRAM_CHAR_LIMIT = 4096
DEFAULT_TIMEOUT = 120 # seconds
# Backwards compatibility
SESSION_PATH = DEFAULT_SESSION_PATH
def resolve_branch_target(message: str) -> Tuple[str, Path]:
"""
Extract @branch target from message and resolve to a directory path.
If message starts with @branch_name, look up the branch in BRANCH_REGISTRY.json
and return the cleaned message (without the @branch prefix) and the branch path.
If no @branch prefix or branch not found, returns original message and DEFAULT_SESSION_PATH.
Returns:
Tuple of (cleaned_message, target_path)
"""
match = re.match(r'^@(\w+)\s*(.*)', message, re.DOTALL)
if not match:
return message, DEFAULT_SESSION_PATH
branch_name = match.group(1).lower()
rest_of_message = match.group(2).strip()
try:
with open(BRANCH_REGISTRY_PATH, 'r', encoding='utf-8') as f:
registry = json.load(f)
branches = registry.get("branches", [])
for branch_entry in branches:
# Match against email (@branch_name) or name field
clean_email = branch_entry.get("email", "").replace("@", "").lower()
if clean_email == branch_name:
branch_path = Path(branch_entry.get("path", ""))
if branch_path.is_dir():
logger.info("Branch target resolved: @%s -> %s", branch_name, branch_path)
return rest_of_message or "hi", branch_path
else:
logger.warning("Branch path not found on disk: %s", branch_path)
return message, DEFAULT_SESSION_PATH
except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
logger.warning("Failed to resolve branch @%s: %s", branch_name, e)
return message, DEFAULT_SESSION_PATH
def has_display() -> bool:
"""Check if DISPLAY environment variable is set (GUI available)."""
return bool(os.environ.get("DISPLAY"))
def has_gnome_terminal() -> bool:
"""Check if gnome-terminal is available."""
return shutil.which("gnome-terminal") is not None
def has_tmux() -> bool:
"""Check if tmux is available."""
return shutil.which("tmux") is not None
def build_prompt(message: str) -> str:
"""
Build the Claude prompt from Telegram message (shell-escaped).
Used by visual mode (gnome-terminal, tmux) which still requires shell escaping.
Args:
message: The message text
Returns:
Formatted prompt string with shell-safe escaping
"""
# Escape single quotes for shell safety
safe_message = message.replace("'", "'\"'\"'")
return f"Patrick via Telegram: {safe_message}"
def build_prompt_clean(message: str) -> str:
"""
Build the Claude prompt from Telegram message (no shell escaping).
Used by capture mode where subprocess_exec passes args directly,
bypassing the shell entirely.
Args:
message: The message text
Returns:
Formatted prompt string (raw, no escaping needed)
"""
return f"Patrick via Telegram: {message}"
def spawn_gnome_terminal(prompt: str) -> Tuple[bool, str, Optional[int]]:
"""
Spawn Claude in a visible gnome-terminal window.
Args:
prompt: The prompt to pass to Claude
Returns:
Tuple of (success, message, pid or None)
"""
try:
# Escape prompt for shell
safe_prompt = prompt.replace("'", "'\"'\"'")
# Build command: gnome-terminal spawns, runs claude, stays open
cmd = [
"gnome-terminal",
"--",
"bash", "-c",
f"cd {SESSION_PATH} && claude -p '{safe_prompt}' --session-id {uuid.uuid4()} --permission-mode bypassPermissions; exec bash"
]
logger.info("Spawning gnome-terminal at %s", SESSION_PATH)
process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True
)
return True, f"gnome-terminal spawned (PID: {process.pid})", process.pid
except Exception as e:
logger.error("gnome-terminal spawn failed: %s", e)
return False, str(e), None
def spawn_tmux(prompt: str) -> Tuple[bool, str, Optional[int]]:
"""
Spawn Claude in a tmux session (headless fallback).
Creates or attaches to session named 'telegram-claude'.
Args:
prompt: The prompt to pass to Claude
Returns:
Tuple of (success, message, pid or None)
"""
try:
# Escape prompt for shell
safe_prompt = prompt.replace("'", "'\"'\"'")
# Check if session already exists
check_cmd = ["tmux", "has-session", "-t", TMUX_SESSION_NAME]
session_exists = subprocess.run(
check_cmd,
capture_output=True
).returncode == 0
if session_exists:
# Kill existing session to start fresh
subprocess.run(
["tmux", "kill-session", "-t", TMUX_SESSION_NAME],
capture_output=True
)
logger.info("Killed existing tmux session: %s", TMUX_SESSION_NAME)
# Create new session with Claude
cmd = [
"tmux", "new-session",
"-d", # Detached
"-s", TMUX_SESSION_NAME,
"-c", str(SESSION_PATH), # Working directory
f"claude -p '{safe_prompt}' --session-id {uuid.uuid4()} --permission-mode bypassPermissions"
]
logger.info("Spawning tmux session '%s' at %s", TMUX_SESSION_NAME, SESSION_PATH)
process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
process.wait()
if process.returncode == 0:
msg = f"tmux session '{TMUX_SESSION_NAME}' created. Attach with: tmux attach -t {TMUX_SESSION_NAME}"
return True, msg, None # tmux doesn't give us the claude PID directly
else:
return False, f"tmux exited with code {process.returncode}", None
except Exception as e:
logger.error("tmux spawn failed: %s", e)
return False, str(e), None
def spawn_claude_session(sender_name: str, message: str) -> Tuple[bool, str]:
"""
Spawn a visible Claude session for a Telegram message.
Strategy:
1. If DISPLAY exists and gnome-terminal available → use gnome-terminal
2. Otherwise if tmux available → use tmux
3. If neither available → return error
Args:
sender_name: Telegram sender name/username
message: The message text from Telegram
Returns:
Tuple of (success, status_message)
"""
_ = sender_name # Reserved for future use (e.g., multi-user filtering)
prompt = build_prompt(message)
# Strategy 1: gnome-terminal (GUI)
if has_display() and has_gnome_terminal():
logger.info("Using gnome-terminal (DISPLAY available)")
success, msg, _ = spawn_gnome_terminal(prompt)
return success, msg
# Strategy 2: tmux (headless)
if has_tmux():
logger.info("Using tmux (headless fallback)")
success, msg, _ = spawn_tmux(prompt)
return success, msg
# No spawner available
error_msg = "No spawner available. Install gnome-terminal (GUI) or tmux (headless)."
logger.error(error_msg)
return False, error_msg
# =============================================
# RESPONSE CHUNKING
# =============================================
def chunk_response(text: str, limit: int = TELEGRAM_CHAR_LIMIT) -> List[str]:
"""
Split text into chunks for Telegram's message limit.
Attempts to split at sentence boundaries when possible.
Args:
text: The full response text
limit: Maximum characters per chunk (default 4096)
Returns:
List of text chunks, each within the limit
"""
if len(text) <= limit:
return [text]
chunks: List[str] = []
remaining = text
while remaining:
if len(remaining) <= limit:
chunks.append(remaining)
break
# Try to find a sentence boundary within limit
chunk = remaining[:limit]
# Look for sentence endings (. ! ?) followed by space or newline
best_break = -1
for i in range(len(chunk) - 1, max(0, len(chunk) - 500), -1):
if chunk[i] in '.!?' and (i + 1 >= len(chunk) or chunk[i + 1] in ' \n'):
best_break = i + 1
break
# If no sentence boundary, try paragraph break
if best_break == -1:
newline_pos = chunk.rfind('\n\n')
if newline_pos > limit // 2:
best_break = newline_pos + 2
# If still no good break, try single newline
if best_break == -1:
newline_pos = chunk.rfind('\n')
if newline_pos > limit // 2:
best_break = newline_pos + 1
# Last resort: break at space
if best_break == -1:
space_pos = chunk.rfind(' ')
if space_pos > limit // 2:
best_break = space_pos + 1
# Ultimate fallback: hard break at limit
if best_break == -1:
best_break = limit
chunks.append(remaining[:best_break].rstrip())
remaining = remaining[best_break:].lstrip()
return chunks
# =============================================
# CAPTURE MODE - Run Claude and capture output
# =============================================
def _chat_id_to_uuid(chat_id: int) -> str:
"""Generate a deterministic UUID from a Telegram chat ID."""
namespace = uuid.UUID("a1a55000-0000-4000-8000-000000000000")
return str(uuid.uuid5(namespace, str(chat_id)))
async def _run_claude_cmd(
args: List[str],
timeout: int = DEFAULT_TIMEOUT,
target_cwd: Optional[Path] = None
) -> Tuple[bool, str, Optional[str]]:
"""
Execute a Claude CLI command and capture output.
Uses subprocess_exec (no shell) to prevent shell injection attacks.
Parses stream-json output via OutputParser.
Args:
args: Command arguments as a list (e.g. [CLAUDE_BIN, '-p', prompt, ...])
timeout: Maximum seconds to wait for response
target_cwd: Working directory for the Claude process (defaults to DEFAULT_SESSION_PATH)
Returns:
Tuple of (success, response_text or error_message, session_id or None)
"""
session_cwd = target_cwd or DEFAULT_SESSION_PATH
try:
process = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=session_cwd
)
try:
raw_stdout, raw_stderr = await asyncio.wait_for(
process.communicate(),
timeout=timeout
)
except asyncio.TimeoutError:
process.kill()
await process.wait()
return False, f"Claude timed out after {timeout} seconds", None
if process.returncode != 0:
error_text = raw_stderr.decode('utf-8', errors='replace').strip()
return False, f"Claude error: {error_text or 'Unknown error'}", None
raw_output = raw_stdout.decode('utf-8', errors='replace').strip()
if not raw_output:
return False, "Claude returned empty response", None
# Parse stream-json output
result = OutputParser.parse_stream(raw_output)
if result.success:
logger.info(
"Parsed response: %d chars, session=%s, cost=$%.4f",
len(result.text),
result.session_id or "unknown",
result.cost_usd or 0.0,
)
return True, result.text, result.session_id
else:
error = result.error_message or "Parse failed"
logger.warning("Parse error: %s", error)
return False, error, result.session_id
except Exception as e:
return False, f"Capture failed: {str(e)}", None
async def run_claude_capture(
message: str,
chat_id: int = 0,
timeout: int = DEFAULT_TIMEOUT,
raw_prompt: bool = False,
target_cwd: Optional[Path] = None
) -> Tuple[bool, str, Optional[str]]:
"""
Run Claude CLI and capture its response with session persistence.
Strategy (when chat_id provided):
1. Look up stored session_id → try --resume with it
2. If no stored or resume fails → create new with random UUID
3. If random create fails → fall back to deterministic UUID
Args:
message: The message/prompt to send to Claude
chat_id: Telegram chat ID for session persistence
timeout: Maximum seconds to wait for response
raw_prompt: If True, use message as-is (skip build_prompt_clean)
target_cwd: Working directory for the Claude process (branch targeting)
Returns:
Tuple of (success, response_text or error_message, session_id or None)
"""
if raw_prompt:
prompt = message
else:
prompt = build_prompt_clean(message)
if chat_id:
# Strategy 1: Try stored session_id from session_store
stored = get_session(chat_id)
if stored and stored.get("session_id"):
stored_id = stored["session_id"]
resume_args = [
CLAUDE_BIN, '-p', prompt,
'--resume', stored_id,
'--output-format', 'stream-json', '--verbose',
'--permission-mode', 'bypassPermissions'
]
logger.info("Attempting resume with stored session %s for chat %s", stored_id[:8], chat_id)
success, response, session_id = await _run_claude_cmd(resume_args, timeout, target_cwd=target_cwd)
if success:
logger.info("Stored session resumed - response captured (%d chars)", len(response))
return True, response, session_id
logger.info("Stored session resume failed for chat %s, creating new session", chat_id)
# Strategy 2: Create new session with random UUID
random_id = str(uuid.uuid4())
logger.info("Creating new session for chat %s with random ID %s", chat_id, random_id[:8])
create_args = [
CLAUDE_BIN, '-p', prompt,
'--session-id', random_id,
'--output-format', 'stream-json', '--verbose',
'--permission-mode', 'bypassPermissions'
]
success, response, session_id = await _run_claude_cmd(create_args, timeout, target_cwd=target_cwd)
if success:
return True, response, session_id
# Strategy 3: Deterministic UUID as last resort
deterministic_id = _chat_id_to_uuid(chat_id)
logger.info("Random create failed, falling back to deterministic ID %s", deterministic_id[:8])
fallback_args = [
CLAUDE_BIN, '-p', prompt,
'--resume', deterministic_id,
'--output-format', 'stream-json', '--verbose',
'--permission-mode', 'bypassPermissions'
]
return await _run_claude_cmd(fallback_args, timeout, target_cwd=target_cwd)
else:
# No chat_id - one-shot with no session ID
one_shot_args = [
CLAUDE_BIN, '-p', prompt,
'--output-format', 'stream-json', '--verbose',
'--permission-mode', 'bypassPermissions'
]
logger.info("Running Claude in one-shot mode (timeout: %ds)", timeout)
return await _run_claude_cmd(one_shot_args, timeout, target_cwd=target_cwd)
async def spawn_and_capture(
sender_name: str,
message: str,
chat_id: int = 0,
timeout: int = DEFAULT_TIMEOUT,
target_cwd: Optional[Path] = None
) -> Tuple[bool, List[str], Optional[str]]:
"""
Run Claude and return chunked response for Telegram.
This is the main entry point for capture mode.
Args:
sender_name: Telegram sender name/username (for logging)
message: The message text from Telegram
chat_id: Telegram chat ID (for logging)
timeout: Maximum seconds to wait for Claude
target_cwd: Working directory for the Claude process (branch targeting)
Returns:
Tuple of (success, list_of_response_chunks, session_id or None)
"""
logger.info("spawn_and_capture called by %s (chat_id: %s)", sender_name, chat_id)
success, response, session_id = await run_claude_capture(message, chat_id, timeout, target_cwd=target_cwd)
if not success:
return False, [response], session_id
chunks = chunk_response(response)
logger.info("Response split into %d chunk(s)", len(chunks))
return True, chunks, session_id
if __name__ == "__main__":
# Test the spawner
print("=" * 60)
print("TELEGRAM CLAUDE SPAWNER")
print("=" * 60)
print()
print("Environment check:")
print(f" DISPLAY: {os.environ.get('DISPLAY', 'Not set')}")
print(f" gnome-terminal: {'Available' if has_gnome_terminal() else 'Not found'}")
print(f" tmux: {'Available' if has_tmux() else 'Not found'}")
print()
print(f"Session path: {SESSION_PATH}")
print(f"tmux session name: {TMUX_SESSION_NAME}")
print()
print("Would spawn with:")
if has_display() and has_gnome_terminal():
print(" → gnome-terminal (GUI mode)")
elif has_tmux():
print(" → tmux (headless mode)")
else:
print(" → ERROR: No spawner available")
print()
@@ -1,370 +0,0 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: telegram_standards.py - Shared Telegram Bot Standards
# Date: 2026-02-15
# Version: 1.0.0
# Category: api/handlers/telegram
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-15): Initial - shared commands, templates, and utilities for all bots
#
# CODE STANDARDS:
# - stdlib ONLY (importable by all bots)
# - Provides text generation, not message delivery
# - Single source of truth for standard bot commands
# =============================================
"""
Shared Telegram Bot Standards for AIPass
Central definitions for standard commands, response templates, and text
builders used by ALL AIPass Telegram bots (bridge, assistant, test, scheduler).
Two bot types exist in AIPass:
- Async bots (python-telegram-bot): Use the text builders directly and
deliver via their own async handlers.
- Sync bots (stdlib urllib): Use parse_command() and handle_standard_command()
to process commands, then deliver via their own send functions.
This module has ZERO external dependencies (stdlib only). Every bot can import it.
Usage (async bot):
from aipass.api.apps.handlers.telegram.telegram_standards import (
build_welcome_text, build_status_text, STANDARD_COMMANDS,
)
Usage (sync bot):
from aipass.api.apps.handlers.telegram.telegram_standards import (
parse_command, handle_standard_command, STANDARD_COMMANDS,
)
"""
import subprocess
from typing import Optional
# =============================================
# STANDARD COMMAND REGISTRY
# =============================================
STANDARD_COMMANDS: dict[str, dict[str, str]] = {
"start": {
"description": "Welcome message and command list",
"menu_text": "Start / welcome message",
},
"help": {
"description": "Show available commands",
"menu_text": "Show help",
},
"new": {
"description": "Kill current session and start fresh (clean Claude context)",
"menu_text": "Fresh session",
},
"status": {
"description": "Show session info (branch, uptime, session state)",
"menu_text": "Session status",
},
}
# =============================================
# RESPONSE TEMPLATES
# =============================================
PROCESSING_MSG = "Processing..."
ERROR_TEMPLATE = "Something went wrong: {error}"
HELP_FOOTER = "\nSend any message to chat with Claude."
# Internal templates (used by builder functions)
_WELCOME_HEADER = "Hello! I'm {bot_name}."
_WELCOME_BRANCH = "Branch: @{branch_name}"
_STATUS_HEADER = "Session Status"
# =============================================
# TEXT BUILDERS
# =============================================
def _format_command_list(
standard_commands: dict[str, dict[str, str]],
custom_commands: Optional[dict[str, dict[str, str]]] = None,
) -> str:
"""
Format a combined command list as readable text.
Each command appears as: /command - description
Args:
standard_commands: The STANDARD_COMMANDS dict (or a subset).
custom_commands: Optional additional commands in the same format.
Returns:
Multi-line string of formatted commands.
"""
lines: list[str] = []
for cmd, info in standard_commands.items():
lines.append(f"/{cmd} - {info['description']}")
if custom_commands:
for cmd, info in custom_commands.items():
lines.append(f"/{cmd} - {info['description']}")
return "\n".join(lines)
def build_help_text(
standard_commands: Optional[dict[str, dict[str, str]]] = None,
custom_commands: Optional[dict[str, dict[str, str]]] = None,
) -> str:
"""
Build a /help message combining standard and custom commands.
Args:
standard_commands: Command registry dict. Defaults to STANDARD_COMMANDS.
custom_commands: Optional bot-specific commands in the same format.
Returns:
Formatted help text string.
"""
if standard_commands is None:
standard_commands = STANDARD_COMMANDS
parts: list[str] = [
"Commands:",
_format_command_list(standard_commands, custom_commands),
HELP_FOOTER,
]
return "\n".join(parts)
def build_welcome_text(
bot_name: str,
branch_name: str,
standard_commands: Optional[dict[str, dict[str, str]]] = None,
custom_commands: Optional[dict[str, dict[str, str]]] = None,
) -> str:
"""
Build the /start welcome message.
Args:
bot_name: Display name of the bot (e.g., "AIPass Bridge Bot").
branch_name: The branch this bot operates on (e.g., "dev_central").
standard_commands: Command registry dict. Defaults to STANDARD_COMMANDS.
custom_commands: Optional bot-specific commands in the same format.
Returns:
Formatted welcome text string.
"""
if standard_commands is None:
standard_commands = STANDARD_COMMANDS
parts: list[str] = [
_WELCOME_HEADER.format(bot_name=bot_name),
_WELCOME_BRANCH.format(branch_name=branch_name),
"",
"Commands:",
_format_command_list(standard_commands, custom_commands),
HELP_FOOTER,
]
return "\n".join(parts)
def build_status_text(
session_name: str,
branch_name: str,
uptime: Optional[str] = None,
message_count: Optional[int] = None,
chat_id: Optional[str | int] = None,
) -> str:
"""
Build the /status response.
Checks tmux session state via subprocess. Reports branch, session,
activity status, and optional metrics.
Args:
session_name: tmux session name (e.g., "telegram-assistant").
branch_name: Branch name (e.g., "assistant").
uptime: Optional human-readable uptime string.
message_count: Optional count of messages processed.
chat_id: Optional Telegram chat ID to display.
Returns:
Formatted status text string.
"""
active = _tmux_session_exists(session_name)
lines: list[str] = [_STATUS_HEADER]
if chat_id is not None:
lines.append(f"Chat ID: {chat_id}")
lines.append(f"Branch: @{branch_name}")
lines.append(f"Session: {session_name}")
lines.append(f"State: {'Active' if active else 'Inactive'}")
if uptime:
lines.append(f"Uptime: {uptime}")
if message_count is not None:
lines.append(f"Messages: {message_count}")
return "\n".join(lines)
def build_botfather_commands(
standard_commands: Optional[dict[str, dict[str, str]]] = None,
custom_commands: Optional[dict[str, dict[str, str]]] = None,
) -> list[dict[str, str]]:
"""
Build command list for BotFather setMyCommands API.
Returns the format expected by Telegram's setMyCommands endpoint:
[{"command": "start", "description": "Start / welcome message"}, ...]
Args:
standard_commands: Command registry dict. Defaults to STANDARD_COMMANDS.
custom_commands: Optional bot-specific commands in the same format.
Returns:
List of dicts with "command" and "description" keys.
"""
if standard_commands is None:
standard_commands = STANDARD_COMMANDS
result: list[dict[str, str]] = []
for cmd, info in standard_commands.items():
result.append({"command": cmd, "description": info["menu_text"]})
if custom_commands:
for cmd, info in custom_commands.items():
result.append({"command": cmd, "description": info["menu_text"]})
return result
# =============================================
# SYNC BOT UTILITIES (stdlib bots)
# =============================================
def parse_command(text: str) -> Optional[tuple[str, str]]:
"""
Extract command name and arguments from message text.
Handles both '/command' and '/command@bot_username' formats.
Returns None if the text is not a command.
Args:
text: Raw message text from Telegram.
Returns:
Tuple of (command_name, args_string) or None if not a command.
command_name is lowercase without the leading slash.
args_string is everything after the command, stripped.
Examples:
parse_command("/status") -> ("status", "")
parse_command("/new please") -> ("new", "please")
parse_command("/help@mybot") -> ("help", "")
parse_command("hello world") -> None
"""
if not text or not text.startswith("/"):
return None
# Split on whitespace: first part is /command[@botname], rest is args
parts = text.split(None, 1)
raw_command = parts[0][1:] # Remove leading /
args = parts[1] if len(parts) > 1 else ""
# Strip @bot_username suffix if present
if "@" in raw_command:
raw_command = raw_command.split("@", 1)[0]
command = raw_command.lower().strip()
if not command:
return None
return (command, args.strip())
def handle_standard_command(
command: str,
session_name: str,
branch_name: str,
bot_name: str,
custom_commands: Optional[dict[str, dict[str, str]]] = None,
chat_id: Optional[str | int] = None,
message_count: Optional[int] = None,
uptime: Optional[str] = None,
) -> Optional[str | tuple[str, str]]:
"""
Handle a standard command and return the response text.
For most commands, returns a string with the response text.
For /new, returns a tuple ("new", instructions_text) to signal
the caller that they need to kill and restart their tmux session.
The caller is responsible for tmux operations and for sending
the response text.
Returns None if the command is not a standard command.
Args:
command: The command name (lowercase, no slash).
session_name: tmux session name (e.g., "telegram-assistant").
branch_name: Branch name (e.g., "assistant").
bot_name: Display name of the bot.
custom_commands: Optional bot-specific commands for help text.
chat_id: Optional Telegram chat ID (for /status display).
message_count: Optional message count (for /status display).
uptime: Optional uptime string (for /status display).
Returns:
- str: Response text for /start, /help, /status
- tuple[str, str]: ("new", response_text) for /new command
- None: Command is not a standard command
"""
if command == "start":
return build_welcome_text(
bot_name=bot_name,
branch_name=branch_name,
custom_commands=custom_commands,
)
if command == "help":
return build_help_text(custom_commands=custom_commands)
if command == "new":
response_text = f"Session cleared for @{branch_name}. Next message starts fresh."
return ("new", response_text)
if command == "status":
return build_status_text(
session_name=session_name,
branch_name=branch_name,
uptime=uptime,
message_count=message_count,
chat_id=chat_id,
)
return None
# =============================================
# INTERNAL HELPERS
# =============================================
def _tmux_session_exists(session_name: str) -> bool:
"""
Check if a tmux session exists by name.
Args:
session_name: The tmux session name to check.
Returns:
True if the session is running, False otherwise.
"""
try:
result = subprocess.run(
["tmux", "has-session", "-t", session_name],
capture_output=True,
)
return result.returncode == 0
except FileNotFoundError:
# tmux not installed
return False
@@ -1,314 +0,0 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: tmux_manager.py - tmux Session Manager for Telegram Bridge
# Date: 2026-02-12
# Version: 1.2.0
# Category: api/handlers/telegram
#
# CHANGELOG (Max 5 entries):
# - v1.2.0 (2026-03-01): Add AIPASS_SESSION_TYPE env var + auto /rename for Claude session identification
# - v1.1.0 (2026-02-24): Add bot_id awareness — AIPASS_BOT_ID env var in tmux sessions
# - v1.0.0 (2026-02-12): Initial - tmux session create/kill/send/list for persistent Claude sessions
#
# CODE STANDARDS:
# - Pure functions with proper error raising
# - No Prax imports (handler tier 3)
# =============================================
"""
tmux Session Manager for Telegram Bridge
Manages persistent Claude Code sessions in tmux:
- Create named sessions (telegram-{branch_name}) running Claude Code
- Inject messages via tmux send-keys -l (literal mode)
- Kill/list sessions
- Capture pane content for status display
Each tmux session runs `claude --permission-mode bypassPermissions` continuously.
Messages are injected via send-keys, responses captured via Stop hook.
"""
# Infrastructure
import sys
from pathlib import Path
import asyncio
import shutil
import subprocess
import time
from typing import List, Optional
# Constants
SESSION_PREFIX = "telegram-"
DEFAULT_BRANCH = "dev_central"
CLAUDE_BIN = shutil.which("claude") or "claude"
SEND_KEYS_DELAY = 0.5 # seconds between text injection and Enter
RENAME_DELAY = 3 # seconds to wait for Claude to initialize before /rename
def _session_name(branch_name: str) -> str:
"""Build tmux session name from branch name."""
return f"{SESSION_PREFIX}{branch_name}"
def _send_rename(session_name: str, branch_name: str) -> None:
"""Send /rename to a tmux session after Claude initializes."""
time.sleep(RENAME_DELAY)
rename_cmd = f"/rename {branch_name.upper()}-telegram"
subprocess.run(
["tmux", "send-keys", "-t", session_name, rename_cmd, "Enter"],
capture_output=True,
)
def has_tmux() -> bool:
"""Check if tmux is available on the system."""
return shutil.which("tmux") is not None
def session_exists(branch_name: str) -> bool:
"""
Check if a tmux session exists for the given branch.
Args:
branch_name: Branch name (e.g. 'dev_central')
Returns:
True if session is alive
"""
name = _session_name(branch_name)
result = subprocess.run(
["tmux", "has-session", "-t", name],
capture_output=True,
)
return result.returncode == 0
def create_session(branch_name: str, branch_path: Path, *, bot_id: Optional[str] = None) -> bool:
"""
Create a tmux session and launch Claude Code inside it.
Session is named telegram-{branch_name} and starts in branch_path.
Claude is launched with AIPASS_SESSION_TYPE=telegram and
--permission-mode bypassPermissions. After initialization,
sends /rename BRANCH-telegram for the /resume picker.
Args:
branch_name: Branch name for session naming
branch_path: Working directory for Claude Code
bot_id: Optional bot ID — sets AIPASS_BOT_ID env var in tmux session
Returns:
True if session was created successfully
"""
name = _session_name(branch_name)
if session_exists(branch_name):
print("[INFO]", "Session %s already exists", name)
return True
if not branch_path.is_dir():
print("[ERROR]", "Branch path does not exist: %s", branch_path)
return False
try:
# Create detached tmux session
result = subprocess.run(
[
"tmux", "new-session",
"-d", # Detached
"-s", name, # Session name
"-c", str(branch_path), # Working directory
],
capture_output=True,
text=True,
)
if result.returncode != 0:
print("[ERROR]", "Failed to create tmux session %s: %s", name, result.stderr)
return False
# Set bot_id environment variable if provided
if bot_id:
subprocess.run(
["tmux", "set-environment", "-t", name, "AIPASS_BOT_ID", bot_id],
capture_output=True,
text=True,
)
# Launch Claude Code inside the session
# Explicit cd guarantees CWD even if shell profile drifts it
# AIPASS_SESSION_TYPE=telegram lets drone status label this session
claude_cmd = (
f"cd '{branch_path}' && "
f"AIPASS_SESSION_TYPE=telegram {CLAUDE_BIN} --permission-mode bypassPermissions"
)
subprocess.run(
["tmux", "send-keys", "-t", name, claude_cmd, "Enter"],
capture_output=True,
)
# Rename the Claude conversation for the /resume picker
# Claude needs a few seconds to initialize before /rename works
_send_rename(name, branch_name)
print("[INFO]", "Created tmux session %s at %s", name, branch_path)
return True
except Exception as e:
print("[ERROR]", "Error creating tmux session %s: %s", name, e)
return False
async def send_message(branch_name: str, message: str) -> bool:
"""
Inject a message into a tmux session via send-keys.
Uses -l flag for literal mode (no shell interpretation).
Sends text first, waits briefly, then sends Enter.
Args:
branch_name: Branch name identifying the session
message: The message text to inject
Returns:
True if message was sent successfully
"""
name = _session_name(branch_name)
if not session_exists(branch_name):
print("[ERROR]", "Session %s does not exist", name)
return False
try:
# Send text literally (no shell interpretation)
result = subprocess.run(
["tmux", "send-keys", "-t", name, "-l", message],
capture_output=True,
text=True,
)
if result.returncode != 0:
print("[ERROR]", "Failed to send text to %s: %s", name, result.stderr)
return False
# Wait before sending Enter (prevents rapid keystroke issues)
await asyncio.sleep(SEND_KEYS_DELAY)
# Send Enter to submit the message
result = subprocess.run(
["tmux", "send-keys", "-t", name, "Enter"],
capture_output=True,
text=True,
)
if result.returncode != 0:
print("[ERROR]", "Failed to send Enter to %s: %s", name, result.stderr)
return False
print("[INFO]", "Injected message into %s (%d chars)", name, len(message))
return True
except Exception as e:
print("[ERROR]", "Error sending to tmux session %s: %s", name, e)
return False
def kill_session(branch_name: str) -> bool:
"""
Kill a tmux session for the given branch.
Args:
branch_name: Branch name identifying the session
Returns:
True if session was killed (or didn't exist)
"""
name = _session_name(branch_name)
if not session_exists(branch_name):
print("[INFO]", "Session %s does not exist, nothing to kill", name)
return True
try:
result = subprocess.run(
["tmux", "kill-session", "-t", name],
capture_output=True,
text=True,
)
if result.returncode == 0:
print("[INFO]", "Killed tmux session %s", name)
return True
else:
print("[ERROR]", "Failed to kill session %s: %s", name, result.stderr)
return False
except Exception as e:
print("[ERROR]", "Error killing tmux session %s: %s", name, e)
return False
def list_sessions() -> List[str]:
"""
List all active telegram-* tmux sessions.
Returns:
List of branch names with active sessions
"""
try:
result = subprocess.run(
["tmux", "list-sessions", "-F", "#{session_name}"],
capture_output=True,
text=True,
)
if result.returncode != 0:
return []
sessions = []
for line in result.stdout.strip().split("\n"):
line = line.strip()
if line.startswith(SESSION_PREFIX):
branch = line[len(SESSION_PREFIX):]
if branch:
sessions.append(branch)
return sessions
except Exception:
return []
def get_session_pane(branch_name: str) -> Optional[str]:
"""
Capture current visible pane content from a tmux session.
Args:
branch_name: Branch name identifying the session
Returns:
Pane content as string, or None if session doesn't exist
"""
name = _session_name(branch_name)
if not session_exists(branch_name):
return None
try:
result = subprocess.run(
["tmux", "capture-pane", "-t", name, "-p"],
capture_output=True,
text=True,
)
if result.returncode == 0:
return result.stdout
return None
except Exception:
return None
@@ -1,29 +0,0 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: __init__.py - Telegram Service Handler Package
# Date: 2026-02-03
# Version: 1.0.0
# Category: api/handlers
# CODE STANDARDS: Seed v1.0.0
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-03): Initial package
# =============================================
"""Telegram Service Handler - systemd control operations"""
from aipass.api.apps.handlers.telegram_service.service import (
start_service,
stop_service,
get_status,
get_logs,
SERVICE_NAME,
)
__all__ = [
"start_service",
"stop_service",
"get_status",
"get_logs",
"SERVICE_NAME",
]
@@ -1,121 +0,0 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: service.py - Telegram Service Handler
# Date: 2026-02-03
# Version: 1.0.0
# Category: api/handlers
# CODE STANDARDS: Seed v1.0.0
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-03): Initial handler - systemd service operations
# =============================================
"""
Telegram Service Handler
Low-level systemd operations for telegram-bridge service.
"""
import os
import subprocess
from pathlib import Path
from typing import Tuple
def _find_repo_root() -> Path:
"""Walk up from this file to find AIPASS_REGISTRY.json (repo root)."""
current = Path(__file__).resolve().parent
for parent in [current] + list(current.parents):
if (parent / "AIPASS_REGISTRY.json").exists():
return parent
return Path.cwd()
SERVICE_NAME = "telegram-bridge"
LOG_FILE = _find_repo_root() / "logs" / "telegram_bridge.log"
def start_service() -> Tuple[bool, str]:
"""
Start the telegram-bridge service
Returns:
Tuple of (success, message)
"""
result = subprocess.run(
["systemctl", "--user", "start", SERVICE_NAME],
capture_output=True,
text=True
)
if result.returncode == 0:
return True, "Service started"
return False, result.stderr.strip() if result.stderr else "Unknown error"
def stop_service() -> Tuple[bool, str]:
"""
Stop the telegram-bridge service
Returns:
Tuple of (success, message)
"""
result = subprocess.run(
["systemctl", "--user", "stop", SERVICE_NAME],
capture_output=True,
text=True
)
if result.returncode == 0:
return True, "Service stopped"
return False, result.stderr.strip() if result.stderr else "Unknown error"
def get_status() -> Tuple[str, str]:
"""
Get telegram-bridge service status
Returns:
Tuple of (status_code, details)
status_code: 'running', 'stopped', 'not_found', 'unknown'
"""
result = subprocess.run(
["systemctl", "--user", "status", SERVICE_NAME],
capture_output=True,
text=True
)
output = result.stdout if result.stdout else result.stderr
if "Active: active (running)" in output:
return "running", output
elif "Active: inactive (dead)" in output:
return "stopped", output
elif "could not be found" in output.lower():
return "not_found", output
return "unknown", output
def get_logs(lines: int = 30) -> Tuple[bool, str]:
"""
Get recent service logs
Args:
lines: Number of recent lines to return
Returns:
Tuple of (success, log_content or error_message)
"""
if not LOG_FILE.exists():
return False, f"No log file found at {LOG_FILE}"
try:
with open(LOG_FILE, encoding="utf-8") as f:
all_lines = f.readlines()
recent = all_lines[-lines:] if len(all_lines) > lines else all_lines
if not recent:
return False, "Log file is empty"
return True, "".join(recent)
except OSError as e:
return False, f"Failed to read logs: {e}"
-415
View File
@@ -1,415 +0,0 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: telegram_bot.py - Telegram multi-bot module (replaces telegram_bridge.py + telegram_chat.py)
# Date: 2026-02-24
# Version: 1.0.0
# Category: api/modules
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-24): Initial - unified multi-bot public API module
#
# CODE STANDARDS:
# - Module layer: thin orchestration over handler functions
# - This is the PUBLIC API that other branches import from
# - Replaces telegram_bridge.py and telegram_chat.py
# =============================================
"""
Telegram Multi-Bot Module - Public API
Unified entry point for the AIPass multi-bot Telegram architecture.
Re-exports all handler internals so other branches can import them
without triggering the cross-branch handler guard.
Replaces:
- telegram_bridge.py (bridge operations)
- telegram_chat.py (direct chat + standards re-exports)
Usage from other branches:
from aipass.api.apps.modules.telegram_bot import BaseBot, BranchPlugin
from aipass.api.apps.modules.telegram_bot import create_bot, delete_bot
from aipass.api.apps.modules.telegram_bot import list_bots, get_bot
from aipass.api.apps.modules.telegram_bot import load_bot_config
from aipass.api.apps.modules.telegram_bot import (
STANDARD_COMMANDS, build_help_text, build_welcome_text,
build_status_text, parse_command, handle_standard_command,
)
"""
# Infrastructure
import sys
from pathlib import Path
# Prax logger and CLI utilities
from aipass.prax.apps.modules.logger import system_logger as logger
from aipass.cli.apps.modules import console, header, success, error, warning
# Handler imports (operations)
from aipass.api.apps.handlers.telegram import bot_operations
# =============================================
# RE-EXPORTS FROM HANDLERS (Public API)
# =============================================
from aipass.api.apps.handlers.telegram.base_bot import BaseBot
from aipass.api.apps.handlers.telegram.branch_plugin import BranchPlugin
from aipass.api.apps.handlers.telegram.bot_registry import (
list_bots,
get_bot,
register_bot,
get_bot_by_branch,
)
from aipass.api.apps.handlers.telegram.bot_factory import create_bot, delete_bot
from aipass.api.apps.handlers.telegram.config import load_bot_config
from aipass.api.apps.handlers.telegram.telegram_standards import (
STANDARD_COMMANDS,
PROCESSING_MSG,
build_help_text,
build_welcome_text,
build_status_text,
build_botfather_commands,
parse_command,
handle_standard_command,
)
# =============================================
# MODULE INTROSPECTION
# =============================================
def print_introspection() -> None:
"""Show module introspection - connected handlers and capabilities"""
console.print()
header("Telegram Multi-Bot Module Introspection")
console.print()
console.print("[cyan]Purpose:[/cyan] Multi-bot Telegram architecture for AIPass")
console.print()
console.print("[cyan]Connected Handlers:[/cyan]")
console.print(" - api.apps.handlers.telegram.base_bot")
console.print(" - api.apps.handlers.telegram.branch_plugin")
console.print(" - api.apps.handlers.telegram.bot_registry")
console.print(" - api.apps.handlers.telegram.bot_factory")
console.print(" - api.apps.handlers.telegram.bot_operations")
console.print(" - api.apps.handlers.telegram.config")
console.print(" - api.apps.handlers.telegram.telegram_standards")
console.print()
console.print("[cyan]Available Commands:[/cyan]")
console.print(" - start <bot_id> - Start a specific bot (polling loop)")
console.print(" - stop <bot_id> - Stop a bot's systemd service")
console.print(" - status [bot_id] - Show bot status (single or all)")
console.print(" - list - List all registered bots")
console.print(" - create <bot_id> <token> [options] - Create new bot")
console.print(" - delete <bot_id> - Delete a bot")
console.print()
def print_help() -> None:
"""Print module help with argparse"""
import argparse
parser = argparse.ArgumentParser(
prog="python3 telegram_bot.py",
description="Telegram Multi-Bot Module - AIPass multi-bot management",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
COMMANDS:
start <bot_id> - Start a bot (long-polling, blocks)
stop <bot_id> - Stop a bot's systemd service
status [bot_id] - Show bot status (single or all)
list - List all registered bots
create <bot_id> <token> [opts] - Create a new bot
delete <bot_id> - Delete a bot
CREATE OPTIONS:
--branch <name> - Associate with an AIPass branch
--work-dir <path> - Working directory for Claude sessions
EXAMPLES:
# Start a bot (blocks until Ctrl+C)
python3 telegram_bot.py start dev_central
# Check all bot statuses
python3 telegram_bot.py status
# Create a new branch bot
python3 telegram_bot.py create dev_central 123:ABC --branch dev_central
# List all bots
python3 telegram_bot.py list
"""
)
subparsers = parser.add_subparsers(dest="command", help="Available commands")
start_p = subparsers.add_parser("start", help="Start a bot")
start_p.add_argument("bot_id", help="Bot identifier")
stop_p = subparsers.add_parser("stop", help="Stop a bot's systemd service")
stop_p.add_argument("bot_id", help="Bot identifier")
status_p = subparsers.add_parser("status", help="Show bot status")
status_p.add_argument("bot_id", nargs="?", help="Bot identifier (optional)")
subparsers.add_parser("list", help="List all registered bots")
create_p = subparsers.add_parser("create", help="Create a new bot")
create_p.add_argument("bot_id", help="Bot identifier")
create_p.add_argument("token", help="Telegram bot token")
create_p.add_argument("--branch", help="AIPass branch name")
create_p.add_argument("--work-dir", help="Working directory")
delete_p = subparsers.add_parser("delete", help="Delete a bot")
delete_p.add_argument("bot_id", help="Bot identifier")
console.print(parser.format_help())
# =============================================
# COMMAND HANDLING
# =============================================
def handle_command(command: str, args: list) -> bool:
"""
Handle module commands routed via drone.
Args:
command: Command name (e.g., "telegram_bot")
args: Command arguments
Returns:
True if command was handled, False to pass through
"""
if not args:
return False
subcommand = args[0]
if subcommand == "start" and len(args) >= 2:
_cmd_start(args[1])
return True
elif subcommand == "stop" and len(args) >= 2:
_cmd_stop(args[1])
return True
elif subcommand == "status":
bot_id = args[1] if len(args) >= 2 else None
_cmd_status(bot_id)
return True
elif subcommand == "list":
_cmd_list()
return True
elif subcommand == "create" and len(args) >= 3:
_cmd_create(args[1:])
return True
elif subcommand == "delete" and len(args) >= 2:
_cmd_delete(args[1])
return True
elif subcommand == "help":
print_help()
return True
return False
# =============================================
# THIN ORCHESTRATION (delegates to bot_operations handler)
# =============================================
def _cmd_start(bot_id: str) -> None:
"""Orchestrate bot start: header, delegate, exit."""
header(f"Starting Bot: {bot_id}")
console.print()
config = load_bot_config(bot_id)
if not config:
error(f"No config found for bot '{bot_id}'")
console.print("[dim]Expected: ~/.aipass/telegram_bots/{bot_id}.json[/dim]")
return
if not config.get("bot_token"):
error(f"No bot_token in config for '{bot_id}'")
return
bot_name = config.get("bot_name", f"AIPass {bot_id} Bot")
logger.info("Starting bot '%s' (%s)", bot_id, bot_name)
success(f"Launching {bot_name}...")
console.print()
exit_code = bot_operations.start_bot(bot_id)
if exit_code is None:
error(f"Bot '{bot_id}' failed to start")
else:
sys.exit(exit_code)
def _cmd_stop(bot_id: str) -> None:
"""Orchestrate bot stop: header, delegate, display result."""
header(f"Stopping Bot: {bot_id}")
console.print()
ok, msg = bot_operations.stop_bot(bot_id)
if ok:
logger.info("Stopped bot '%s'", bot_id)
success(msg)
else:
logger.warning("Failed to stop bot '%s': %s", bot_id, msg)
error(msg)
console.print()
def _cmd_status(bot_id: str | None = None) -> None:
"""Orchestrate status display: header, delegate, format."""
if bot_id:
header(f"Bot Status: {bot_id}")
else:
header("All Bots Status")
console.print()
bots = bot_operations.get_status(bot_id)
if not bots:
if bot_id:
error(f"Bot '{bot_id}' not found in registry")
else:
warning("No bots registered")
console.print("[dim]Use 'create' to add a new bot[/dim]")
console.print()
return
for bot in bots:
for line in bot_operations.format_bot_details(bot):
console.print(f" [cyan]{line}[/cyan]")
console.print()
def _cmd_list() -> None:
"""Orchestrate bot listing: header, delegate, format."""
header("Registered Bots")
console.print()
bots = bot_operations.get_all_bots()
if not bots:
warning("No bots registered")
console.print("[dim]Use 'create' to add a new bot[/dim]")
console.print()
return
for line in bot_operations.format_bot_table(bots):
console.print(line)
console.print()
def _cmd_create(args: list) -> None:
"""Orchestrate bot creation: parse args, delegate, display result."""
parsed = bot_operations.parse_create_args(args)
if not parsed:
error("Usage: create <bot_id> <token> [--branch <name>] [--work-dir <path>]")
return
bot_id = parsed["bot_id"]
header(f"Creating Bot: {bot_id}")
console.print()
result = create_bot(
bot_id=bot_id,
bot_token=parsed["bot_token"],
branch_name=parsed["branch_name"],
work_dir=parsed["work_dir"],
)
if result:
logger.info("Bot created via module: %s", bot_id)
success(f"Bot '{bot_id}' created successfully")
console.print()
details = bot_operations.format_bot_details({
"bot_id": result["bot_id"],
"username": result["username"],
"branch_name": result.get("branch_name"),
"work_dir": result["work_dir"],
"status": "active",
"service_name": result["service_name"],
})
for line in details:
console.print(f" [cyan]{line}[/cyan]")
else:
logger.warning("Bot creation failed for '%s'", bot_id)
error(f"Failed to create bot '{bot_id}'. Check logs for details.")
console.print()
def _cmd_delete(bot_id: str) -> None:
"""Orchestrate bot deletion: verify, delegate, display result."""
header(f"Deleting Bot: {bot_id}")
console.print()
bot = get_bot(bot_id)
if not bot:
error(f"Bot '{bot_id}' not found in registry")
console.print()
return
result = delete_bot(bot_id)
if result:
logger.info("Bot deleted via module: %s", bot_id)
success(f"Bot '{bot_id}' deleted successfully")
else:
logger.warning("Bot deletion failed for '%s'", bot_id)
error(f"Failed to delete bot '{bot_id}'. Check logs for details.")
console.print()
# =============================================
# STANDALONE EXECUTION
# =============================================
if __name__ == "__main__":
"""Standalone execution mode"""
args = sys.argv[1:]
# Show introspection when run without arguments
if len(args) == 0:
print_introspection()
sys.exit(0)
# Show help for explicit help flags
if args[0] in ['--help', '-h', 'help']:
print_help()
sys.exit(0)
# Execute command directly (standalone mode, not drone-routed)
command = args[0]
if command == "start" and len(args) >= 2:
_cmd_start(args[1])
elif command == "stop" and len(args) >= 2:
_cmd_stop(args[1])
elif command == "status":
bot_id = args[1] if len(args) >= 2 else None
_cmd_status(bot_id)
elif command == "list":
_cmd_list()
elif command == "create" and len(args) >= 3:
_cmd_create(args[1:])
elif command == "delete" and len(args) >= 2:
_cmd_delete(args[1])
else:
console.print()
console.print(f"[red]Unknown command: {command}[/red]")
console.print()
console.print("Run [dim]python3 telegram_bot.py --help[/dim] for available commands")
console.print()
sys.exit(1)
@@ -1,230 +0,0 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: telegram_service.py - Telegram Service Control Module
# Date: 2026-02-03
# Version: 1.0.0
# Category: api/modules
# CODE STANDARDS: Seed v1.0.0
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-03): Initial module - systemd service control for telegram bridge
# =============================================
"""
Telegram Service Control Module
Manages the telegram-bridge systemd user service:
- drone @api telegram start → Start service
- drone @api telegram stop → Stop service
- drone @api telegram status → Check service status
"""
import sys
from pathlib import Path
from typing import List
from aipass.prax.apps.modules.logger import system_logger as logger
from aipass.cli.apps.modules import console, header, success, error, warning
from aipass.api.apps.handlers.telegram_service import service
def print_introspection() -> None:
"""Show module introspection"""
console.print()
header("Telegram Service Control Introspection")
console.print()
console.print("[cyan]Purpose:[/cyan] Manage telegram-bridge systemd service")
console.print()
console.print("[cyan]Connected Handlers:[/cyan]")
console.print(" • api.apps.handlers.telegram_service.service")
console.print()
console.print("[cyan]Available Commands:[/cyan]")
console.print(" • telegram start - Start the service")
console.print(" • telegram stop - Stop the service")
console.print(" • telegram status - Check service status")
console.print(" • telegram logs - View recent logs")
console.print()
console.print("[cyan]Service:[/cyan]")
console.print(f" • Name: {service.SERVICE_NAME}.service")
console.print(" • Type: systemd user service")
console.print()
def print_help() -> None:
"""Print module help"""
import argparse
parser = argparse.ArgumentParser(
prog="drone @api telegram",
description="Telegram Service Control - Manage the telegram-bridge service",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
SUBCOMMANDS:
start - Start the telegram-bridge service
stop - Stop the telegram-bridge service
status - Check service status
logs - View recent service logs
USAGE:
drone @api telegram start
drone @api telegram stop
drone @api telegram status
drone @api telegram logs
EXAMPLES:
# Start the bot
drone @api telegram start
# Check if running
drone @api telegram status
# Stop the bot
drone @api telegram stop
# View logs
drone @api telegram logs
"""
)
console.print(parser.format_help())
def handle_command(command: str, args: List[str]) -> bool:
"""
Handle telegram service commands
Args:
command: Command name (should be 'telegram')
args: Subcommand and arguments
Returns:
True if command was handled, False otherwise
"""
if command != "telegram":
return False
try:
if not args:
print_help()
return True
subcommand = args[0]
if subcommand == "start":
_handle_start()
elif subcommand == "stop":
_handle_stop()
elif subcommand == "status":
_handle_status()
elif subcommand == "logs":
_handle_logs()
elif subcommand in ["--help", "-h", "help"]:
print_help()
else:
error(f"Unknown subcommand: {subcommand}")
console.print()
console.print("[dim]Use 'drone @api telegram --help' for usage[/dim]")
return True
except Exception as e:
logger.error("Error in telegram_service.handle_command: %s", str(e))
raise
def _handle_start() -> None:
"""Orchestrate service start"""
header("Telegram Bridge Service")
console.print()
ok, msg = service.start_service()
if ok:
success(msg)
console.print()
console.print("[dim]Check status: drone @api telegram status[/dim]")
console.print("[dim]View logs: drone @api telegram logs[/dim]")
else:
error(f"Failed to start service: {msg}")
def _handle_stop() -> None:
"""Orchestrate service stop"""
header("Telegram Bridge Service")
console.print()
ok, msg = service.stop_service()
if ok:
success(msg)
else:
error(f"Failed to stop service: {msg}")
def _handle_status() -> None:
"""Orchestrate status check"""
header("Telegram Bridge Service Status")
console.print()
status_code, output = service.get_status()
if status_code == "running":
success("Service is running")
elif status_code == "stopped":
warning("Service is stopped")
elif status_code == "not_found":
error("Service not found - run 'systemctl --user daemon-reload'")
else:
warning("Unknown status")
console.print()
for line in output.split("\n"):
line = line.strip()
if any(k in line for k in ["Active:", "Main PID:", "Memory:", "CPU:"]):
console.print(f" {line}")
console.print()
console.print("[dim]Logs: drone @api telegram logs[/dim]")
def _handle_logs() -> None:
"""Orchestrate log retrieval"""
header("Telegram Bridge Service Logs")
console.print()
ok, content = service.get_logs(30)
if not ok:
warning(content)
return
console.print(f"[dim]Showing last 30 lines[/dim]")
console.print()
for line in content.split("\n"):
console.print(line.rstrip())
if __name__ == "__main__":
"""Standalone execution mode"""
args = sys.argv[1:]
if len(args) == 0:
print_introspection()
sys.exit(0)
if args[0] in ['--help', '-h', 'help']:
print_help()
sys.exit(0)
if handle_command("telegram", args):
sys.exit(0)
else:
console.print()
console.print(f"[red]Unknown command: {args[0]}[/red]")
console.print()
sys.exit(1)
@@ -1,8 +0,0 @@
{
"permissions": {
"allow": [],
"deny": [],
"ask": []
},
"enabledMcpjsonServers": []
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"metadata": {
"version": "1.0.0",
"created": "2026-03-06T03:16:10.906535",
"created": "2026-03-06T14:27:34.810230",
"description": "Standards bypass configuration for this branch"
},
"bypass": [],
+35 -36
View File
@@ -1,51 +1,50 @@
# CLI
**Purpose:** Terminal display formatting - headers, success, error, warning helpers
**Module:** `aipass.cli`
**Created:** 2026-03-05
Display and output formatting service for AIPass modules. Provides consistent terminal output — headers, success/error/warning messages, section breaks, and operation templates — so every module looks the same without duplicating Rich formatting code.
---
## Usage
## Overview
```python
from aipass.cli import header, success, error, warning, section
### What I Do
header("Creating Branch", {"Name": "feature", "Type": "module"})
success("Files created", items=12, time="2.3s")
error("Path not found", suggestion="Check spelling")
warning("Config missing, using defaults")
section("Results")
```
### Operation Templates
### How I Work
- **Entry Point:** `apps/cli.py`
- **Pattern:** Auto-discovers and routes to modules
```python
from aipass.cli import operation_start, operation_complete
---
operation_start("Processing", count=10)
# ... do work ...
operation_complete(created=5, skipped=3, failed=0)
```
### Direct Console Access
```python
from aipass.cli import console
console.print("[bold cyan]Custom Rich output[/bold cyan]")
```
## Architecture
```
CLI/
cli/
├── apps/
│ ├── cli.py # Entry point
│ ├── modules/ # Business logic
│ ├── handlers/ # Implementation
│ └── plugins/ # Extensions
├── docs/
├── tests/
├── passport.json # Identity
├── local.json # Session history
├── observations.json # Collaboration patterns
└── README.md
│ ├── cli.py # Entry point
│ ├── modules/
│ │ ├── display.py # header, success, error, warning, section
│ │ └── templates.py # operation_start, operation_complete
│ └── handlers/
│ └── json/ # JSON file management
└── tests/
```
---
## Commands
*Configure after initialization*
---
## Integration Points
### Depends On
### Provides To
- `apps/modules/` — Public API. Import from here.
- `apps/handlers/` — Internal implementation. Don't import directly.
+1 -1
View File
@@ -64,7 +64,7 @@ def _find_real_caller():
def _extract_branch_name(filepath: str) -> str:
"""Extract branch name from a file path."""
parts = filepath.split("/")
parts = Path(filepath).parts
for i, part in enumerate(parts):
if part == "aipass":
if i + 1 < len(parts):
@@ -15,15 +15,27 @@
# - No Prax imports (handler tier 3)
# =============================================
"""JSON Auto-Creating Handler - manages CLI JSON files with templates and auto-rotation."""
import json
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Any, Optional
import sys
from typing import Dict, Any, Optional
import inspect
from aipass.prax.apps.modules.logger import system_logger as logger
# Constants — package-relative paths (portable across any machine)
CLI_ROOT = Path(__file__).resolve().parents[3] # json_handler.py -> json -> handlers -> apps -> cli
def _find_repo_root() -> Path:
"""Walk up from this file to find the repo root (contains pyproject.toml or AIPASS_REGISTRY.json)."""
current = Path(__file__).resolve().parent
for parent in [current] + list(current.parents):
if (parent / "pyproject.toml").exists() or (parent / "AIPASS_REGISTRY.json").exists():
return parent
return Path.cwd()
# Constants — resolved via repo root walk-up (portable across any machine)
_REPO_ROOT = _find_repo_root()
CLI_ROOT = _REPO_ROOT / "src" / "aipass" / "cli"
CLI_JSON_DIR = CLI_ROOT / "cli_json"
JSON_TEMPLATES_DIR = CLI_ROOT / "apps" / "json_templates"
@@ -108,9 +120,8 @@ def ensure_json_exists(module_name: str, json_type: str) -> bool:
if validate_json_structure(data, json_type):
return True
# If corrupted, fall through to regenerate
except Exception:
# If unreadable, fall through to regenerate
pass
except Exception as exc:
logger.warning("JSON unreadable at %s, regenerating: %s", json_path, exc)
template = load_template(json_type, module_name)
@@ -1,8 +0,0 @@
{
"permissions": {
"allow": [],
"deny": [],
"ask": []
},
"enabledMcpjsonServers": []
}
+33 -33
View File
@@ -1,22 +1,17 @@
# DEVPULSE
# DevPulse
**Purpose:** Orchestration hub for AIPass public repo. Manages dev notes, DPLANs, system status dashboards. The DEV_CENTRAL of AIPass.
**Purpose:** Dev notes and status tracking for AIPass projects
**Module:** `aipass.devpulse`
**Created:** 2026-03-06
**Last Updated:** 2026-03-06
**Status:** Building
---
## Overview
### What I Do
- Coordinate work across all 10 AIPass modules
- Track system health via seedgo audits and recon reports
- Manage DPLANs and development notes
- Test and wire modules in Docker container environment
DevPulse tracks development notes, plans, and project status across an AIPass ecosystem. It provides a shared notation layer where both humans and agents can log issues, todos, and progress — giving visibility into what's happening without requiring meetings or status emails.
### How I Work
- **Entry Point:** `apps/branch.py`
### How It Works
- **Entry Point:** `apps/devpulse.py`
- **Pattern:** Auto-discovers modules in `apps/modules/` with `handle_command()` and routes commands
---
@@ -24,27 +19,15 @@
## Architecture
```
DEVPULSE/
devpulse/
├── apps/
│ ├── branch.py # Entry point (auto-discovery + routing)
│ ├── modules/ # Business logic (empty, building out)
│ ├── handlers/ # Implementation
│ └── plugins/ # Extensions
├── .trinity/
│ ├── passport.json # Identity
│ ├── local.json # Session history
│ └── observations.json # Collaboration patterns
├── .agent/ # System metadata
├── .aipass/ # Branch prompt context
├── artifacts/ # Birth certificate
│ ├── devpulse.py # Entry point (auto-discovery + routing)
│ ├── modules/ # Business logic
│ ├── handlers/ # Implementation
│ └── plugins/ # Extensions
├── devpulse_json/ # JSON storage
├── docs/
│ └── sub_agent_drops/ # Recon reports from sub-agents
├── tools/
│ └── verify_branch.py # Template verification
├── devpulse_json/ # Branch JSON storage
├── tests/
├── DASHBOARD.local.json # System status
├── flow.local.md # Issues and todos
└── README.md
```
@@ -52,16 +35,33 @@ DEVPULSE/
## Commands
*No modules built yet — commands will be added as modules are created.*
```bash
drone @devpulse --help # Show available commands
```
*Modules are being built out — commands will appear as they ship.*
---
## Python Usage
```python
from aipass.devpulse.apps.devpulse import discover_modules, route_command
# Discover available sub-modules
modules = discover_modules()
# Route a command
route_command("status", [], modules)
```
---
## Integration Points
### Depends On
- `aipass.prax` — Logger
- `aipass.prax` — Logging
- `aipass.cli` — Display formatting
- `aipass.seedgo` — Standards auditing
### Provides To
- All modules — orchestration coordination, system health tracking
- All modules — dev notes, plan tracking, project status
+1
View File
@@ -0,0 +1 @@
"""DevPulse - System monitoring and dashboards for AIPass."""
@@ -1,8 +0,0 @@
{
"permissions": {
"allow": [],
"deny": [],
"ask": []
},
"enabledMcpjsonServers": []
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"metadata": {
"version": "1.0.0",
"created": "2026-03-06T03:16:10.929991",
"created": "2026-03-06T14:27:37.777414",
"description": "Standards bypass configuration for this branch"
},
"bypass": [],
+67 -38
View File
@@ -1,51 +1,80 @@
# DRONE
# Drone
**Purpose:** Command routing, @ resolution, module discovery and dispatch
**Module:** `aipass.drone`
**Created:** 2026-03-05
Command router and symbolic addressing for AIPass. Resolves `@branch` names to paths at runtime via `AIPASS_REGISTRY.json`, routes commands to module entry points, and discovers available commands across the system.
---
## CLI
## Overview
```bash
drone systems # List all registered modules and branches
drone @seedgo verify # Route "verify" to the seedgo module
drone @seedgo audit aipass # Route "audit aipass" to seedgo
drone @module --help # Show help for any module
drone --version # Show version
```
### What I Do
## Python API
```python
from aipass.drone import resolve_branch, list_branches, route_command
### How I Work
- **Entry Point:** `apps/drone.py`
- **Pattern:** Auto-discovers and routes to modules
# Resolve @name to absolute path
path = resolve_branch("@seedgo")
---
# List all registered branches
branches = list_branches() # All branches
active = list_branches(status="active") # Filter by status
# Route a command to a branch
result = route_command("@seedgo", "verify")
print(result.stdout) # Command output
print(result.exit_code) # 0 on success
```
### Registry Management
```python
from aipass.drone import set_registry_path, get_registry_path
# Use a custom registry location
set_registry_path("/path/to/AIPASS_REGISTRY.json")
# Or set via environment variable
# export AIPASS_REGISTRY_PATH=/path/to/registry.json
```
### Error Handling
```python
from aipass.drone import resolve_branch, BranchNotFoundError, CommandExecutionError
try:
path = resolve_branch("@nonexistent")
except BranchNotFoundError:
print("Branch not found in registry")
try:
result = route_command("@seedgo", "audit", args=["aipass"], timeout=120)
except CommandExecutionError as e:
print(f"Command failed: {e}")
```
## Architecture
```
DRONE/
drone/
├── cli.py # pip entry point (drone command)
├── drone_adapter.py # Self-routing adapter for drone @drone
├── __init__.py # Public API exports
├── apps/
│ ├── drone.py # Entry point
│ ├── modules/ # Business logic
│ ├── handlers/ # Implementation
│ └── plugins/ # Extensions
├── docs/
├── tests/
├── passport.json # Identity
├── local.json # Session history
├── observations.json # Collaboration patterns
└── README.md
│ ├── drone.py # Core entry point
│ ├── modules/ # Business logic
│ │ ├── config.py # Registry path resolution
│ │ ├── resolver.py # Branch resolution (@name -> path)
│ │ ├── router.py # Command routing via subprocess
│ │ ├── discovery.py # Module and command discovery
│ │ └── module_registry.py # Internal module routing
│ └── handlers/ # Implementation
│ ├── executor.py # Safe subprocess execution
│ └── exceptions.py # Exception hierarchy
└── tests/
```
---
## Commands
*Configure after initialization*
---
## Integration Points
### Depends On
### Provides To
+1 -1
View File
@@ -84,7 +84,7 @@ def show_introspection() -> None:
if branches:
print(f"Registered Branches ({len(branches)}):")
for name in sorted(branches):
print(f" @{name}")
print(f" {name}")
if not modules and not branches:
print("No branches or modules registered.")
@@ -28,13 +28,22 @@ def execute_command(
args: List[str],
cwd: str,
timeout: int = 30,
env: dict | None = None,
) -> CommandResult:
"""Execute a command via subprocess with safety guards.
Never uses shell=True to prevent shell injection attacks.
"""
import os
full_cmd = [executable] + list(args)
# Merge custom env vars with current environment
run_env = None
if env:
run_env = os.environ.copy()
run_env.update(env)
try:
result = subprocess.run(
full_cmd,
@@ -42,6 +51,7 @@ def execute_command(
capture_output=True,
timeout=timeout,
shell=False,
env=run_env,
)
except subprocess.TimeoutExpired as e:
raise CommandExecutionError(
+3 -2
View File
@@ -7,6 +7,7 @@ and scanning module directories as a fallback.
import logging
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional
@@ -82,7 +83,7 @@ def discover_modules(target: str) -> List[str]:
if entry_point is not None:
try:
result = subprocess.run(
["python3", str(entry_point.relative_to(branch_path)), "--help"],
[sys.executable, str(entry_point.relative_to(branch_path)), "--help"],
cwd=branch_path,
capture_output=True,
timeout=10,
@@ -121,7 +122,7 @@ def get_help(target: str, command: Optional[str] = None) -> HelpResult:
try:
result = subprocess.run(
["python3"] + cmd_args,
[sys.executable] + cmd_args,
cwd=branch_path,
capture_output=True,
timeout=10,
+6 -1
View File
@@ -6,6 +6,7 @@ locating the branch's apps/{name}.py entry point, and executing via subprocess.
"""
import logging
import sys
from pathlib import Path
from typing import Dict, List, Optional
@@ -49,11 +50,15 @@ def route_command(
relative_entry = str(entry_point.relative_to(branch_path))
cmd_args = [relative_entry, command] + list(args)
# Pass caller's CWD so target branches can detect who invoked them
caller_env = {"AIPASS_CALLER_CWD": str(Path.cwd())}
result = execute_command(
executable="python3",
executable=sys.executable,
args=cmd_args,
cwd=branch_path,
timeout=timeout,
env=caller_env,
)
return CommandResult(
@@ -1,8 +0,0 @@
{
"permissions": {
"allow": [],
"deny": [],
"ask": []
},
"enabledMcpjsonServers": []
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"metadata": {
"version": "1.0.0",
"created": "2026-03-06T03:16:10.952307",
"created": "2026-03-06T14:27:39.378089",
"description": "Standards bypass configuration for this branch"
},
"bypass": [],
+49 -33
View File
@@ -1,51 +1,67 @@
# FLOW
# Flow
**Purpose:** Plan lifecycle management - DPLAN/FPLAN/MPLAN creation, tracking, closure
**Module:** `aipass.flow`
**Created:** 2026-03-05
Plan lifecycle management for AIPass. Creates, tracks, closes, and archives numbered work plans (FPLANs) with registry-backed state, async post-processing, and cross-branch aggregation.
---
## Usage
## Overview
### CLI (via drone)
### What I Do
```bash
drone @flow create . "Build authentication module" # Create plan in current dir
drone @flow create /path/to "Migration task" master # Master plan (multi-phase)
drone @flow close 42 # Close plan
drone @flow close --all # Close all open plans
drone @flow list # List plans
drone @flow aggregate # Aggregate plans across branches
drone @flow registry # Registry health monitor
```
### Python
### How I Work
- **Entry Point:** `apps/flow.py`
- **Pattern:** Auto-discovers and routes to modules
```python
from aipass.flow.apps.modules.create_plan import create_plan
from aipass.flow.apps.modules.close_plan import close_plan
from aipass.flow.apps.modules.list_plans import list_plans
from aipass.flow.apps.handlers.registry.load_registry import load_registry
---
# Load the plan registry
registry = load_registry()
```
## Architecture
```
FLOW/
flow/
├── apps/
│ ├── flow.py # Entry point
│ ├── modules/ # Business logic
│ ├── handlers/ # Implementation
│ └── plugins/ # Extensions
├── docs/
├── tests/
├── passport.json # Identity
├── local.json # Session history
├── observations.json # Collaboration patterns
└── README.md
│ ├── flow.py # Entry point (auto-discovers modules)
│ ├── modules/ # Business logic
│ │ ├── create_plan.py # Plan creation with template support
│ │ ├── close_plan.py # Closure with async archival
│ │ ├── list_plans.py # Plan listing and filtering
│ │ ├── restore_plan.py # Plan recovery from backups
│ │ ├── registry_monitor.py # Orphan detection, auto-healing
│ │ ├── aggregate_central.py # Cross-branch plan aggregation
│ │ └── post_close_runner.py # Background post-processing
│ └── handlers/ # Implementation details
│ ├── plan/ # Lifecycle, file ops, validation
│ ├── registry/ # Load, save, auto-heal
│ ├── template/ # Plan templates (default, master, proposal)
│ ├── dashboard/ # Status aggregation
│ ├── mbank/ # Memory bank archival
│ └── summary/ # AI-generated plan summaries
├── templates/ # Plan template files
├── flow_json/ # Configuration and registry data
└── tests/
```
---
## Plan Naming
## Commands
Plans follow the convention `FPLAN-XXXX_topic_slug_YYYY-MM-DD.md` where XXXX is an auto-incrementing number.
*Configure after initialization*
## Dependencies
---
## Integration Points
### Depends On
### Provides To
- `aipass.cli` — terminal formatting
- `aipass.prax` — structured logging
- `aipass.trigger` — error reporting (optional)
Last Updated: 2026-03-06
+1
View File
@@ -0,0 +1 @@
"""Flow - Task planning and workflow management for AIPass."""
+1 -1
View File
@@ -43,7 +43,7 @@ def _find_real_caller():
def _extract_branch_name(filepath: str) -> str:
"""Extract branch name from a file path."""
parts = filepath.split("/")
parts = Path(filepath).parts
for i, part in enumerate(parts):
if part in ("aipass", "MEMORY_BANK", "Nexus"):
if i + 1 < len(parts):
@@ -1,8 +0,0 @@
{
"permissions": {
"allow": [],
"deny": [],
"ask": []
},
"enabledMcpjsonServers": []
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"metadata": {
"version": "1.0.0",
"created": "2026-03-06T03:16:10.975118",
"created": "2026-03-06T14:27:42.646499",
"description": "Standards bypass configuration for this branch"
},
"bypass": [],
+65 -36
View File
@@ -1,51 +1,80 @@
# PRAX
# Prax
**Purpose:** System logging, event tracking, and real-time monitoring infrastructure
**Module:** `aipass.prax`
**Created:** 2026-03-05
System-wide logging and real-time monitoring for AIPass. Prax auto-routes log output from any module to per-module log files and provides a live monitoring console (Mission Control) that shows file changes, log events, and command execution across all branches.
---
## Usage
## Overview
### Logging
### What I Do
```python
from aipass.prax import logger
logger.info("Processing started")
logger.warning("Disk usage high")
logger.error("Connection failed")
```
### How I Work
- **Entry Point:** `apps/prax.py`
- **Pattern:** Auto-discovers and routes to modules
Logs auto-route to `system_logs/<branch>_<module>.log` based on the calling module. No configuration needed — prax detects the caller via stack introspection.
---
For handlers or plugins that need to bypass the event pipeline:
```python
from aipass.prax.apps.modules.logger import get_direct_logger
log = get_direct_logger("my_handler")
log.info("Direct log entry")
```
### Mission Control
Real-time monitoring console for watching system activity across all branches.
```bash
drone @prax monitor
```
Interactive commands inside the monitor:
```
watch all # Watch all branches
watch prax # Watch specific branch
watch errors # Only show errors
status # Show current filters
quit # Exit
```
### CLI Commands
| Command | Description |
|---------|-------------|
| `drone @prax monitor` | Launch Mission Control |
| `drone @prax init` | Initialize logging system |
| `drone @prax status` | Show system status |
| `drone @prax run` | Start continuous logging mode |
| `drone @prax shutdown` | Shutdown logging system |
| `drone @prax discover` | Discover Python modules in ecosystem |
| `drone @prax log-audit` | Audit log file sizes and health |
## Architecture
```
PRAX/
prax/
├── apps/
│ ├── prax.py # Entry point
│ ├── modules/ # Business logic
│ ├── handlers/ # Implementation
│ └── plugins/ # Extensions
├── docs/
├── tests/
├── passport.json # Identity
├── local.json # Session history
├── observations.json # Collaboration patterns
└── README.md
│ ├── prax.py # Entry point
│ ├── modules/
│ │ ├── logger.py # SystemLogger (public API)
│ │ └── monitor_module.py # Mission Control
│ └── handlers/
│ ├── logging/ # Log setup, rotation, introspection
│ ├── monitoring/ # Event queue, branch detection, stream output
│ ├── discovery/ # Module scanning and filtering
│ ├── config/ # Configuration loading
│ └── registry/ # Module registry management
└── tests/
```
---
## Commands
*Configure after initialization*
---
## Integration Points
### Depends On
### Provides To
## How It Works
1. **Auto-routing** — When any module calls `logger.info()`, prax inspects the call stack to identify the caller and routes the log entry to the appropriate file.
2. **Dual output** — Each log entry goes to both a system-wide log (`system_logs/`) and a branch-local log (`{branch}/logs/`), both with rotation.
3. **Mission Control** — A multi-threaded monitoring console that watches file changes (via inotify), log events, and agent activity across all branches simultaneously.
+1 -1
View File
@@ -43,7 +43,7 @@ def _find_real_caller():
def _extract_branch_name(filepath: str) -> str:
"""Extract branch name from a file path."""
parts = filepath.split("/")
parts = Path(filepath).parts
for i, part in enumerate(parts):
if part == "aipass":
if i + 1 < len(parts):
+22 -1
View File
@@ -62,7 +62,11 @@ def _find_repo_root() -> Path:
_system_logs_dir_cache: Path | None = None
def get_system_logs_dir() -> Path:
"""Lazily resolve and create system_logs directory (package-relative)."""
"""Lazily resolve and create system_logs directory (package-relative).
DEPRECATED: Use get_module_logs_dir(module_name) for per-module logging.
Kept for monitoring code that scans a central directory.
"""
global _system_logs_dir_cache
if _system_logs_dir_cache is None:
repo_root = _find_repo_root()
@@ -70,6 +74,23 @@ def get_system_logs_dir() -> Path:
_system_logs_dir_cache.mkdir(parents=True, exist_ok=True)
return _system_logs_dir_cache
def get_module_logs_dir(module_name: str) -> Path:
"""Get the logs directory for a specific module.
Returns ECOSYSTEM_ROOT / module_name / "logs", creating it if needed.
Each module logs to its own directory instead of a centralized system_logs/.
Args:
module_name: Module name (e.g., "flow", "prax", "trigger")
Returns:
Path to the module's logs directory
"""
logs_dir = ECOSYSTEM_ROOT / module_name / "logs"
logs_dir.mkdir(parents=True, exist_ok=True)
return logs_dir
# Config file
PRAX_LOGGER_CONFIG_FILE = PRAX_JSON_DIR / "prax_logger_config.json"
@@ -97,6 +97,8 @@ def _is_pid_alive(pid: int) -> bool:
True if process exists and looks like a claude agent
"""
try:
if sys.platform != "linux":
return False
cmdline_path = Path(f"/proc/{pid}/cmdline")
if not cmdline_path.exists():
return False

Some files were not shown because too many files have changed in this diff Show More