This commit is contained in:
Input-X
2026-03-07 04:51:37 +00:00
parent 6f60b7e26e
commit 548abf9b67
28 changed files with 428 additions and 124 deletions
+17
View File
@@ -67,3 +67,20 @@ aipass_json/
.claude/
# setup-workspace.sh — tracked, needed for Docker build
src/aipass/trigger/trigger_json/trigger_data.json
# Sorting/working
*config.json
*data.json
*inbox.json
*log.json
*sent.json
.ai_mail.local
ai_mail.local
*_json
dropbox
system_logs
AIPASS_REGISTRY.json
docker.md
+8
View File
@@ -66,6 +66,14 @@ apps/
## DevPulse
DevPulse (`src/aipass/devpulse/`) is the orchestration hub for this repo. It coordinates work across modules, tracks status, and manages dev notes. It is to AIPass what DEV_CENTRAL is to Dev-Pass.
## Container Mounts
When running inside the Docker container (code-server at localhost:8080):
- **Shared folder**: `/home/coder/share` (read-write) — dropbox for files between host and container
- **Screenshots**: `/home/coder/screenshots` (read-only) — host screenshots are accessible here
- Use the `Read` tool to view screenshot images (PNG, JPG) — Claude is multimodal and can interpret them
- When user pastes host paths like `file:///home/aipass/Pictures/Screenshots/...`, translate to `/home/coder/screenshots/...`
- Never use host paths (`/home/aipass/...`) directly — they don't exist inside the container
## Core Principles
- Code is truth - if it doesn't run, it's not real
- Test in Docker for isolation verification
+3 -10
View File
@@ -20,18 +20,11 @@ RUN python3 -m venv /opt/venv \
&& chown -R 1000:1000 /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Startup script in /etc so it's NOT shadowed by /home/coder bind mount
RUN mkdir -p /etc/entrypoint.d
COPY setup-workspace.sh /etc/entrypoint.d/setup-workspace.sh
RUN chmod +x /etc/entrypoint.d/setup-workspace.sh
# Override ENTRYPOINTD to our non-shadowed location
ENV ENTRYPOINTD=/etc/entrypoint.d
# Empty workspace — clone your own repo after boot
RUN mkdir -p /home/coder/workspace && chown 1000:1000 /home/coder/workspace
USER 1000
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/AIPass"]
ENTRYPOINT ["/usr/bin/entrypoint.sh", "--bind-addr", "0.0.0.0:8080", "--auth", "none", "/home/coder/workspace"]
+22
View File
@@ -0,0 +1,22 @@
{
"service": "ai_mail",
"last_updated": "2026-03-07",
"branch_stats": {
"SPAWN": {
"unread": 1,
"total": 1
},
"DEVPULSE": {
"unread": 1,
"total": 1
},
"AI_MAIL": {
"unread": 0,
"total": 3
}
},
"system_totals": {
"total_unread": 2,
"total_messages": 5
}
}
@@ -303,7 +303,7 @@ def _check_pid_alive(pid: int) -> bool:
# ─── Branch Resolution ──────────────────────────────────
def resolve_branch(branch_email: str) -> Optional[Tuple[Path, str]]:
"""Resolve a branch email to its filesystem path."""
"""Resolve a branch email to its absolute filesystem path."""
email = f"@{branch_email.lstrip('@').lower()}"
registry = _read_json(BRANCH_REGISTRY)
if registry is None:
@@ -311,6 +311,8 @@ def resolve_branch(branch_email: str) -> Optional[Tuple[Path, str]]:
for branch in registry.get("branches", []):
if branch.get("email", "").lower() == email:
path = Path(branch.get("path", ""))
if not path.is_absolute():
path = _REPO_ROOT / path
if path.exists():
return path, email
return None
@@ -0,0 +1 @@
# Extensions package - Drop-in extensions for branch functionality
@@ -0,0 +1 @@
"""JSON handler package for devpulse."""
@@ -0,0 +1,54 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: json_handler.py - JSON Handler
# Date: 2026-03-07
# Version: 1.0.0
# Category: devpulse/handlers/json
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-03-07): Initial implementation
#
# CODE STANDARDS:
# - Satisfies Seed architecture standard for apps/handlers/json/ path
# =============================================
"""
JSON Handler for DevPulse
Handles JSON read/write operations for devpulse_json/ storage.
"""
import json
from pathlib import Path
from typing import Any, Optional
# Infrastructure paths (package-relative)
_DEVPULSE_ROOT = Path(__file__).resolve().parents[3] # devpulse/
DEVPULSE_JSON_DIR = _DEVPULSE_ROOT / "devpulse_json"
def load_json(file_path: Path) -> Any:
"""Load and return JSON data from file."""
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
def save_json(file_path: Path, data: Any, indent: int = 2) -> None:
"""Save data as JSON to file."""
file_path.parent.mkdir(parents=True, exist_ok=True)
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=indent, ensure_ascii=False)
f.write('\n')
def ensure_json_exists(file_path: Path, default: Optional[Any] = None) -> Path:
"""Ensure a JSON file exists, creating with default content if missing."""
if not file_path.exists():
save_json(file_path, default if default is not None else {})
return file_path
def get_json_path(filename: str) -> Path:
"""Get the path to a JSON file in devpulse_json/."""
return DEVPULSE_JSON_DIR / filename
@@ -0,0 +1 @@
# JSON Templates package - Default JSON file templates
+8 -1
View File
@@ -190,8 +190,15 @@ def _handle_target(args: List[str]) -> int:
command = rest[0]
cmd_args = rest[1:]
# Long-running interactive commands bypass capture + timeout
interactive = command in ("monitor",)
try:
result = route_command(target, command, args=cmd_args if cmd_args else None)
result = route_command(
target, command,
args=cmd_args if cmd_args else None,
interactive=interactive,
)
except BranchNotFoundError as exc:
print(f"drone: {exc}", file=sys.stderr)
return 1
+31 -8
View File
@@ -29,10 +29,15 @@ def execute_command(
cwd: str,
timeout: int = 30,
env: dict | None = None,
interactive: bool = False,
) -> CommandResult:
"""Execute a command via subprocess with safety guards.
Never uses shell=True to prevent shell injection attacks.
Args:
interactive: If True, inherit stdio (no capture, no timeout).
Used for long-running commands like prax monitor.
"""
import os
@@ -45,14 +50,23 @@ def execute_command(
run_env.update(env)
try:
result = subprocess.run(
full_cmd,
cwd=cwd,
capture_output=True,
timeout=timeout,
shell=False,
env=run_env,
)
if interactive:
# Inherit stdin/stdout/stderr for live interaction
result = subprocess.run(
full_cmd,
cwd=cwd,
shell=False,
env=run_env,
)
else:
result = subprocess.run(
full_cmd,
cwd=cwd,
capture_output=True,
timeout=timeout,
shell=False,
env=run_env,
)
except subprocess.TimeoutExpired as e:
raise CommandExecutionError(
f"Command timed out after {timeout}s: {' '.join(full_cmd)}"
@@ -66,6 +80,15 @@ def execute_command(
f"OS error executing command: {e}"
) from e
if interactive:
return CommandResult(
stdout="",
stderr="",
exit_code=result.returncode,
branch="",
command="",
)
stdout = result.stdout.decode("utf-8", errors="replace")
stderr = result.stderr.decode("utf-8", errors="replace")
+5
View File
@@ -32,12 +32,16 @@ def route_command(
command: str,
args: Optional[List[str]] = None,
timeout: int = 30,
interactive: bool = False,
) -> CommandResult:
"""Route a command to a branch's entry point.
Resolves @target to an absolute path, locates the branch entry point at
{path}/apps/{branch_name}.py, then executes:
python3 apps/{name}.py {command} [args...]
Args:
interactive: If True, inherit stdio for long-running/interactive commands.
"""
if args is None:
args = []
@@ -59,6 +63,7 @@ def route_command(
cwd=branch_path,
timeout=timeout,
env=caller_env,
interactive=interactive,
)
return CommandResult(
+20 -2
View File
@@ -240,6 +240,7 @@ def handle_command(command: str, args: List[str]) -> bool:
_monitoring_active = True
# Parse args for initial branch filters
_is_tty = sys.stdin.isatty()
if args:
if args[0] == 'all':
# Watch all branches
@@ -250,13 +251,20 @@ def handle_command(command: str, args: List[str]) -> bool:
initial_branches = args[0].split(',')
_filter_state.watched_branches = set(normalize_branch_arg(b.strip()) for b in initial_branches)
_filter_state.show_info = True
elif not _is_tty:
# No TTY and no args — default to watching all (can't type 'watch all')
_filter_state.show_all = True
_filter_state.show_info = True
# Display header
console.print()
header("PRAX Mission Control - Unified Monitoring")
console.print()
console.print("[green]Monitoring system starting...[/green]")
console.print("[yellow]Quiet mode active - type 'help' for commands[/yellow]")
if _is_tty:
console.print("[yellow]Quiet mode active - type 'help' for commands[/yellow]")
else:
console.print("[yellow]Passive mode - no TTY detected, watching all (Ctrl+C to stop)[/yellow]")
console.print()
# Start monitoring threads + Telegram relay
@@ -629,9 +637,19 @@ def _log_watcher_worker():
def _interactive_loop():
"""Interactive command loop - handles user input"""
"""Interactive command loop - handles user input, or passive loop if no TTY"""
global _monitoring_active, _filter_state, _event_queue
# Non-TTY mode: no interactive input available, just keep alive
if not sys.stdin.isatty():
logger.info("[monitor] No TTY detected - running in passive mode (Ctrl+C to stop)")
try:
while _monitoring_active:
time.sleep(0.5)
except KeyboardInterrupt:
console.print("\n[yellow]Stopping monitoring...[/yellow]")
return
from aipass.prax.apps.handlers.monitoring.interactive_filter import (
parse_command,
apply_filter,
+9 -2
View File
@@ -69,7 +69,14 @@ spawn/
## Integration Points
### Depends On
- Python stdlib only (`pathlib`, `json`, `shutil`, `hashlib`, `re`)
- `aipass.prax` — Logging via `system_logger`
- `aipass.cli` — Console output and headers
- Python stdlib (`pathlib`, `json`, `shutil`, `hashlib`, `re`)
### Provides To
- All modules — agent scaffolding and lifecycle setup
- All modules — agent creation service and lifecycle setup
- Registry: Reads/writes `AIPASS_REGISTRY.json`
---
*Last Updated: 2026-03-07*
+1 -1
View File
@@ -6,6 +6,6 @@ Usage:
result = spawn_agent("/path/to/new/agent", role="My Role", purpose="My Purpose")
"""
from aipass.spawn.apps.modules.core import spawn_agent
from aipass.spawn.apps.modules.core import _spawn_agent as spawn_agent
__all__ = ["spawn_agent"]
@@ -0,0 +1 @@
# Extensions package
@@ -1,5 +1,15 @@
# =================== META ====================
# Name: file_ops.py
# Description: Template copy and file operations
# Version: 1.0.0
# Created: 2026-03-05
# Modified: 2026-03-07
# =============================================
"""Template copy and file rename operations."""
import hashlib
import json
import shutil
from pathlib import Path
@@ -9,6 +19,11 @@ from aipass.spawn.apps.handlers.placeholders import replace_placeholders
SKIP_NAMES = {"__pycache__", ".git", ".template_registry.json"}
def ensure_directory(path):
"""Create directory and parents if they don't exist."""
Path(path).mkdir(parents=True, exist_ok=True)
def copy_template(template_dir, target_dir, replacements):
"""
Recursively copy template to target, replacing placeholders in text files.
@@ -123,3 +138,77 @@ def _replace_path_placeholders(rel_path, replacements):
new_part = replace_placeholders(part, replacements)
parts.append(new_part)
return Path(*parts) if parts else rel_path
def regenerate_template_registry(target_dir):
"""
Regenerate .template_registry.json with fresh SHA-256 hashes.
Scans all files in the agent directory and builds a new registry
with accurate content hashes.
Args:
target_dir: Path to the spawned agent directory
"""
target_dir = Path(target_dir)
agent_dir = target_dir / ".agent"
if not agent_dir.exists():
return
registry_file = agent_dir / ".template_registry.json"
files = {}
directories = {}
file_idx = 1
dir_idx = 1
for item in sorted(target_dir.rglob("*")):
rel = item.relative_to(target_dir)
# Skip .agent internal files and __pycache__
if ".agent" in rel.parts or "__pycache__" in rel.parts:
continue
if item.is_dir():
dir_id = f"d{dir_idx:03d}"
directories[dir_id] = {
"path": str(rel),
"name": item.name,
}
dir_idx += 1
elif item.is_file():
file_id = f"f{file_idx:03d}"
try:
content = item.read_bytes()
content_hash = hashlib.sha256(content).hexdigest()[:16]
except (IOError, PermissionError):
content_hash = "unreadable"
has_placeholder = False
try:
text = item.read_text(encoding="utf-8")
has_placeholder = "{{" in text and "}}" in text
except (UnicodeDecodeError, IOError):
pass
files[file_id] = {
"path": str(rel),
"name": item.name,
"content_hash": content_hash,
"has_branch_placeholder": has_placeholder,
}
file_idx += 1
registry = {
"metadata": {
"description": "Template registry for tracking files",
"generated": True,
},
"files": files,
"directories": directories,
}
registry_file.write_text(
json.dumps(registry, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
@@ -0,0 +1 @@
# JSON handlers package
@@ -0,0 +1,31 @@
# =================== META ====================
# Name: json_handler.py
# Description: JSON file read/write operations for spawn
# Version: 1.0.0
# Created: 2026-03-07
# Modified: 2026-03-07
# =============================================
"""JSON handler for spawn module."""
import json
from pathlib import Path
from typing import Any, Optional
def read_json(file_path: Path) -> Optional[dict]:
"""Read and parse a JSON file."""
try:
return json.loads(file_path.read_text())
except (json.JSONDecodeError, FileNotFoundError):
return None
def write_json(file_path: Path, data: Any, indent: int = 2) -> bool:
"""Write data to a JSON file."""
try:
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(json.dumps(data, indent=indent) + "\n")
return True
except OSError:
return False
@@ -1,3 +1,11 @@
# =================== META ====================
# Name: metadata.py
# Description: Branch name extraction and profile detection
# Version: 1.0.0
# Created: 2026-03-05
# Modified: 2026-03-07
# =============================================
"""Branch name extraction and profile detection."""
from pathlib import Path
@@ -1,3 +1,11 @@
# =================== META ====================
# Name: placeholders.py
# Description: Placeholder replacement engine
# Version: 1.0.0
# Created: 2026-03-05
# Modified: 2026-03-07
# =============================================
"""Placeholder replacement engine for agent templates."""
import re
@@ -1,3 +1,11 @@
# =================== META ====================
# Name: registry.py
# Description: AIPASS_REGISTRY.json CRUD operations
# Version: 1.0.0
# Created: 2026-03-05
# Modified: 2026-03-07
# =============================================
"""AIPASS_REGISTRY.json CRUD operations."""
import json
@@ -119,6 +127,19 @@ def save_registry(registry_path, data):
return False
def get_next_citizen_number(registry_path):
"""Get next citizen number from registry (count of existing branches + 1).
Args:
registry_path: Path to AIPASS_REGISTRY.json
Returns:
int: Next citizen number
"""
data = load_registry(registry_path)
return len(data.get("branches", [])) + 1
def add_to_registry(registry_path, branch_name, branch_path, profile, email, purpose=""):
"""
Add a new branch entry to the registry.
@@ -0,0 +1 @@
# JSON templates package
+60 -92
View File
@@ -1,3 +1,11 @@
# =================== META ====================
# Name: core.py
# Description: Main orchestrator for agent spawning
# Version: 1.0.0
# Created: 2026-03-05
# Modified: 2026-03-07
# =============================================
"""
Spawn Module — Create new AIPass agents from templates.
@@ -11,21 +19,64 @@ Orchestrates the full agent creation workflow:
7. Validate no unreplaced placeholders remain
"""
import hashlib
import json
from pathlib import Path
from typing import List
from aipass.prax import logger
from aipass.spawn.apps.handlers.metadata import get_branch_name, normalize_branch_name, detect_profile
from aipass.spawn.apps.handlers.placeholders import build_replacements_dict, validate_no_placeholders
from aipass.spawn.apps.handlers.file_ops import copy_template, rename_placeholder_paths
from aipass.spawn.apps.handlers.registry import find_registry, add_to_registry
from aipass.spawn.apps.handlers.file_ops import copy_template, rename_placeholder_paths, regenerate_template_registry, ensure_directory
from aipass.spawn.apps.handlers.registry import find_registry, add_to_registry, get_next_citizen_number
# Default template location (relative to spawn package root)
DEFAULT_TEMPLATE = Path(__file__).parents[2] / "templates" / "agent.template"
def spawn_agent(target_path, role="", traits="", purpose="", profile=None,
template_dir=None, registry_path=None):
def handle_command(command: str, args: List[str]) -> bool:
"""
Route spawn commands to implementation.
Args:
command: The command string (e.g. "create")
args: List of arguments for the command
Returns:
True if command succeeded, False otherwise
"""
if command == "create":
if not args:
logger.error("spawn create requires a target path")
return False
target_path = args[0]
kwargs = {}
i = 1
while i < len(args):
if args[i] == "--role" and i + 1 < len(args):
kwargs["role"] = args[i + 1]
i += 2
elif args[i] == "--traits" and i + 1 < len(args):
kwargs["traits"] = args[i + 1]
i += 2
elif args[i] == "--purpose" and i + 1 < len(args):
kwargs["purpose"] = args[i + 1]
i += 2
elif args[i] == "--template" and i + 1 < len(args):
kwargs["template_dir"] = args[i + 1]
i += 2
elif args[i] == "--registry" and i + 1 < len(args):
kwargs["registry_path"] = args[i + 1]
i += 2
else:
i += 1
result = _spawn_agent(target_path, **kwargs)
return result["success"]
else:
logger.error(f"Unknown spawn command: {command}")
return False
def _spawn_agent(target_path, role="", traits="", purpose="", profile=None,
template_dir=None, registry_path=None):
"""
Create a new AIPass agent from template.
@@ -65,7 +116,7 @@ def spawn_agent(target_path, role="", traits="", purpose="", profile=None,
# Determine citizen number from registry
reg_path = Path(registry_path) if registry_path else find_registry(target.parent)
citizen_number = _get_next_citizen_number(reg_path)
citizen_number = get_next_citizen_number(reg_path)
# Build placeholder replacements
replacements = build_replacements_dict(
@@ -75,14 +126,14 @@ def spawn_agent(target_path, role="", traits="", purpose="", profile=None,
)
# Step 1: Copy template with placeholder replacement in content
target.mkdir(parents=True, exist_ok=True)
ensure_directory(target)
copied, skipped = copy_template(template, target, replacements)
# Step 2: Rename any {{BRANCH}} dirs/files that weren't caught by path replacement
renamed = rename_placeholder_paths(target, folder_name)
# Step 3: Regenerate .template_registry.json with fresh hashes
_regenerate_template_registry(target)
regenerate_template_registry(target)
# Step 4: Register in AIPASS_REGISTRY.json
registry_updated = add_to_registry(
@@ -108,89 +159,6 @@ def spawn_agent(target_path, role="", traits="", purpose="", profile=None,
}
def _get_next_citizen_number(registry_path):
"""Get next citizen number from registry (count of existing branches + 1)."""
registry_path = Path(registry_path)
if not registry_path.exists():
return 1
try:
data = json.loads(registry_path.read_text(encoding="utf-8"))
return len(data.get("branches", [])) + 1
except (json.JSONDecodeError, IOError):
return 1
def _regenerate_template_registry(target_dir):
"""
Regenerate .template_registry.json with fresh SHA-256 hashes.
Scans all files in the agent directory and builds a new registry
with accurate content hashes.
"""
target_dir = Path(target_dir)
agent_dir = target_dir / ".agent"
if not agent_dir.exists():
return
registry_file = agent_dir / ".template_registry.json"
files = {}
directories = {}
file_idx = 1
dir_idx = 1
for item in sorted(target_dir.rglob("*")):
rel = item.relative_to(target_dir)
# Skip .agent internal files and __pycache__
if ".agent" in rel.parts or "__pycache__" in rel.parts:
continue
if item.is_dir():
dir_id = f"d{dir_idx:03d}"
directories[dir_id] = {
"path": str(rel),
"name": item.name,
}
dir_idx += 1
elif item.is_file():
file_id = f"f{file_idx:03d}"
try:
content = item.read_bytes()
content_hash = hashlib.sha256(content).hexdigest()[:16]
except (IOError, PermissionError):
content_hash = "unreadable"
has_placeholder = False
try:
text = item.read_text(encoding="utf-8")
has_placeholder = "{{" in text and "}}" in text
except (UnicodeDecodeError, IOError):
pass
files[file_id] = {
"path": str(rel),
"name": item.name,
"content_hash": content_hash,
"has_branch_placeholder": has_placeholder,
}
file_idx += 1
registry = {
"metadata": {
"description": "Template registry for tracking files",
"generated": True,
},
"files": files,
"directories": directories,
}
registry_file.write_text(
json.dumps(registry, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
def _error(message):
"""Return error result dict."""
return {
+9 -1
View File
@@ -1,3 +1,11 @@
# =================== META ====================
# Name: spawn.py
# Description: Entry point CLI for drone @spawn
# Version: 1.0.0
# Created: 2026-03-05
# Modified: 2026-03-07
# =============================================
"""
SPAWN Branch - Agent Creation System
@@ -40,7 +48,7 @@ def print_help():
def handle_create(args):
"""Handle the create command."""
from aipass.spawn.apps.modules.core import spawn_agent
from aipass.spawn.apps.modules.core import _spawn_agent as spawn_agent
if not args:
console.print("[red]Error: target path required[/red]")
+8
View File
@@ -1,3 +1,11 @@
# =================== META ====================
# Name: test_spawn.py
# Description: Test suite for spawn module
# Version: 1.0.0
# Created: 2026-03-05
# Modified: 2026-03-07
# =============================================
"""Tests for the aipass.spawn module."""
import json
@@ -1,14 +1,14 @@
{
"error_catchup": {
"last_scan_timestamp": "2026-03-06T14:27:27.869613",
"last_scan_timestamp": "2026-03-07T04:46:08.802277",
"processed_hashes": [
"ace01637",
"69de737b",
"c7edc03b",
"4095ef97",
"68dd0ab6",
"4095ef97",
"69de737b",
"ace01637",
"02d93909",
"541e0b9b",
"02d93909"
"c7edc03b"
],
"max_hashes": 500,
"max_lookback_hours": 24
+1
View File
@@ -0,0 +1 @@
export PATH="$HOME/.local/bin:$PATH" shell config file