From 1fb4a82b39e10222f76c5e99be75c2bf3c3fe1cd Mon Sep 17 00:00:00 2001 From: teernisse Date: Fri, 27 Feb 2026 11:05:39 -0500 Subject: [PATCH] refactor(server): extract context.py into focused modules Split the monolithic context.py (117 lines) into five purpose-specific modules following single-responsibility principle: - config.py: Server-level constants (DATA_DIR, SESSIONS_DIR, PORT, STALE_EVENT_AGE, _state_lock) - agents.py: Agent-specific paths and caches (CLAUDE_PROJECTS_DIR, CODEX_SESSIONS_DIR, discovery caches) - auth.py: Authentication token generation/validation for spawn endpoint - spawn_config.py: Spawn feature configuration (PENDING_SPAWNS_DIR, rate limiting, projects watcher thread) - zellij.py: Zellij binary resolution and session management constants This refactoring improves: - Code navigation: Find relevant constants by domain, not alphabetically - Testing: Each module can be tested in isolation - Import clarity: Mixins import only what they need - Future maintenance: Changes to one domain don't risk breaking others All mixins updated to import from new module locations. Tests updated to use new import paths. Includes PROPOSED_CODE_FILE_REORGANIZATION_PLAN.md documenting the rationale and mapping from old to new locations. Co-Authored-By: Claude Opus 4.5 --- PROPOSED_CODE_FILE_REORGANIZATION_PLAN.md | 316 ++++++++++++++++++++++ amc_server/agents.py | 28 ++ amc_server/auth.py | 18 ++ amc_server/config.py | 20 ++ amc_server/context.py | 117 -------- amc_server/mixins/control.py | 4 +- amc_server/mixins/conversation.py | 2 +- amc_server/mixins/discovery.py | 94 ++++++- amc_server/mixins/http.py | 8 +- amc_server/mixins/parsing.py | 2 +- amc_server/mixins/skills.py | 94 ++++--- amc_server/mixins/spawn.py | 55 +++- amc_server/server.py | 4 +- amc_server/spawn_config.py | 40 +++ amc_server/zellij.py | 34 +++ tests/test_context.py | 10 +- tests/test_control.py | 2 +- tests/test_discovery.py | 8 +- tests/test_parsing.py | 10 +- tests/test_spawn.py | 8 +- 20 files changed, 683 insertions(+), 191 deletions(-) create mode 100644 PROPOSED_CODE_FILE_REORGANIZATION_PLAN.md create mode 100644 amc_server/agents.py create mode 100644 amc_server/auth.py create mode 100644 amc_server/config.py delete mode 100644 amc_server/context.py create mode 100644 amc_server/spawn_config.py create mode 100644 amc_server/zellij.py diff --git a/PROPOSED_CODE_FILE_REORGANIZATION_PLAN.md b/PROPOSED_CODE_FILE_REORGANIZATION_PLAN.md new file mode 100644 index 0000000..4ce9599 --- /dev/null +++ b/PROPOSED_CODE_FILE_REORGANIZATION_PLAN.md @@ -0,0 +1,316 @@ +# Proposed Code File Reorganization Plan + +## Executive Summary + +After reading every source file in the project, analyzing all import graphs, and understanding how each module fits into the architecture, my assessment is: **the project is already reasonably well-organized**. The mixin-based decomposition of the handler, the dashboard's `components/utils/lib` split, and the test structure that mirrors source all reflect sound engineering. + +That said, there is one clear structural problem and a few smaller wins. This plan proposes **surgical, high-value changes** rather than a gratuitous restructure. The guiding principle: every change must make it easier for a developer (or agent) to find things and understand the architecture. + +--- + +## Current Structure (Annotated) + +``` +amc/ + amc_server/ # Python backend (2,571 LOC) + __init__.py # Package init, exports main + server.py # Server startup/shutdown (38 LOC) + handler.py # Handler class composed from mixins (31 LOC) + context.py # ** PROBLEM ** All config, constants, caches, locks, auth (121 LOC) + logging_utils.py # Logging + signal handlers (31 LOC) + mixins/ # Handler mixins (one per concern) + __init__.py # Package comment (1 LOC) + http.py # HTTP routing, static file serving (173 LOC) + state.py # State aggregation, SSE, session collection, cleanup (440 LOC) + conversation.py # Conversation parsing for Claude/Codex (278 LOC) + control.py # Session dismiss/respond, Zellij pane injection (295 LOC) + discovery.py # Codex session discovery, pane matching (347 LOC) + parsing.py # JSONL parsing, context usage extraction (274 LOC) + skills.py # Skill enumeration for autocomplete (184 LOC) + spawn.py # Agent spawning in Zellij tabs (358 LOC) + + dashboard/ # Preact frontend (2,564 LOC) + index.html # Entry HTML with Tailwind config + main.js # App mount point (7 LOC) + styles.css # Custom styles + lib/ # Third-party/shared + preact.js # Preact re-exports + markdown.js # Markdown rendering + syntax highlighting (159 LOC) + utils/ # Pure utility functions + api.js # API constants + fetch helpers (39 LOC) + formatting.js # Time/token formatting (66 LOC) + status.js # Status metadata + session grouping (79 LOC) + autocomplete.js # Autocomplete trigger detection (48 LOC) + components/ # UI components + App.js # Root component (616 LOC) + Sidebar.js # Project nav sidebar (102 LOC) + SessionCard.js # Session card (176 LOC) + Modal.js # Full-screen modal wrapper (79 LOC) + ChatMessages.js # Message list (39 LOC) + MessageBubble.js # Individual message (54 LOC) + QuestionBlock.js # AskUserQuestion UI (228 LOC) + SimpleInput.js # Freeform text input (228 LOC) + OptionButton.js # Option button (24 LOC) + AgentActivityIndicator.js # Turn timer (115 LOC) + SpawnModal.js # Spawn dropdown (241 LOC) + Toast.js # Toast notifications (125 LOC) + EmptyState.js # Empty state (18 LOC) + Header.js # ** DEAD CODE ** (58 LOC, zero imports) + SessionGroup.js # ** DEAD CODE ** (56 LOC, zero imports) + + bin/ # Shell/Python scripts + amc # Launcher (start/stop/status) + amc-hook # Hook script (standalone, writes session state) + amc-server # Server launch script + amc-server-restart # Server restart helper + + tests/ # Test suite (mirrors mixin structure) + test_context.py # Context tests + test_control.py # Control mixin tests + test_conversation.py # Conversation parsing tests + test_conversation_mtime.py # Conversation mtime tests + test_discovery.py # Discovery mixin tests + test_hook.py # Hook script tests + test_http.py # HTTP mixin tests + test_parsing.py # Parsing mixin tests + test_skills.py # Skills mixin tests + test_spawn.py # Spawn mixin tests + test_state.py # State mixin tests + test_zellij_metadata.py # Zellij metadata tests + e2e/ # End-to-end tests + __init__.py + test_skills_endpoint.py + test_autocomplete_workflow.js + e2e_spawn.sh # Spawn E2E script +``` + +--- + +## Proposed Changes + +### Change 1: Split `context.py` into Focused Modules (HIGH VALUE) + +**Problem:** `context.py` is the classic "junk drawer" module. It contains: +- Path constants for the server, Zellij, Claude, and Codex +- Server configuration (port, timeouts) +- 5 independent caches with their own size limits +- 2 threading locks for unrelated concerns +- Auth token generation/validation +- Zellij binary resolution +- Spawn-related config +- Background thread management for projects cache + +Every mixin imports from it, but each only needs a subset. When a developer asks "where is the spawn rate limit configured?", they have to scan through an unrelated grab-bag of constants. When they ask "where's the Codex transcript cache?", same problem. + +**Proposed split:** + +``` +amc_server/ + config.py # Server-level constants: PORT, DATA_DIR, SESSIONS_DIR, EVENTS_DIR, + # DASHBOARD_DIR, PROJECT_DIR, STALE_EVENT_AGE, STALE_STARTING_AGE + # These are the "universal" constants every module might need. + + zellij.py # Zellij integration: ZELLIJ_BIN resolution, ZELLIJ_PLUGIN path, + # ZELLIJ_SESSION, _zellij_cache (sessions cache + expiry) + # Rationale: All Zellij-specific constants and helpers in one place. + # Any developer working on Zellij integration knows exactly where to look. + + agents.py # Agent-specific paths and caches: + # CLAUDE_PROJECTS_DIR, CODEX_SESSIONS_DIR, CODEX_ACTIVE_WINDOW, + # _codex_pane_cache, _codex_transcript_cache, _CODEX_CACHE_MAX, + # _context_usage_cache, _CONTEXT_CACHE_MAX, + # _dismissed_codex_ids, _DISMISSED_MAX + # Rationale: Agent data source configuration and caches that are only + # relevant to discovery/parsing mixins, not the whole server. + + auth.py # Auth token: generate_auth_token(), validate_auth_token(), _auth_token + # Rationale: Security-sensitive code in its own module. Small, but + # architecturally clean separation from general config. + + spawn_config.py # Spawn feature config: + # PROJECTS_DIR, PENDING_SPAWNS_DIR, PENDING_SPAWN_TTL, + # _spawn_lock, _spawn_timestamps, SPAWN_COOLDOWN_SEC + # + start_projects_watcher() (background refresh thread) + # Rationale: Spawn feature has its own set of constants, lock, and + # background thread. Currently scattered between context.py and spawn.py. + # Consolidating makes the spawn feature self-contained. +``` + +Kept from current structure (unchanged): +- `_state_lock` moves to `config.py` (it's a server-level concern) + +**Import changes required:** + +| File | Current import from `context` | New import from | +|------|------|------| +| `server.py` | `DATA_DIR, PORT, generate_auth_token, start_projects_watcher` | `config.DATA_DIR, config.PORT`, `auth.generate_auth_token`, `spawn_config.start_projects_watcher` | +| `handler.py` | (none, uses mixins) | (unchanged) | +| `mixins/http.py` | `DASHBOARD_DIR`, `ctx._auth_token` | `config.DASHBOARD_DIR`, `auth._auth_token` | +| `mixins/state.py` | `EVENTS_DIR, SESSIONS_DIR, STALE_*, ZELLIJ_BIN, _state_lock, _zellij_cache` | `config.*`, `zellij.ZELLIJ_BIN, zellij._zellij_cache` | +| `mixins/conversation.py` | `EVENTS_DIR` | `config.EVENTS_DIR` | +| `mixins/control.py` | `SESSIONS_DIR, ZELLIJ_BIN, ZELLIJ_PLUGIN, _DISMISSED_MAX, _dismissed_codex_ids` | `config.SESSIONS_DIR`, `zellij.*`, `agents._DISMISSED_MAX, agents._dismissed_codex_ids` | +| `mixins/discovery.py` | `CODEX_*, PENDING_SPAWNS_DIR, SESSIONS_DIR, _codex_*` | `agents.*`, `spawn_config.PENDING_SPAWNS_DIR`, `config.SESSIONS_DIR` | +| `mixins/parsing.py` | `CLAUDE_PROJECTS_DIR, CODEX_SESSIONS_DIR, _*_cache, _*_MAX` | `agents.*` | +| `mixins/spawn.py` | `PENDING_SPAWNS_DIR, PROJECTS_DIR, SESSIONS_DIR, ZELLIJ_*, _spawn_*, validate_auth_token` | `spawn_config.*`, `config.SESSIONS_DIR`, `zellij.*`, `auth.validate_auth_token` | + +**Why this is the right split:** + +1. **By domain, not by size.** Each new module groups constants + caches + helpers that serve one architectural concern. A developer working on Zellij integration opens `zellij.py`. Working on Codex discovery? `agents.py`. Spawn feature? `spawn_config.py`. + +2. **No circular imports.** The dependency graph is DAG: `config.py` is a leaf (imported by everything, imports nothing from `amc_server`). `zellij.py`, `agents.py`, `auth.py`, `spawn_config.py` import only from `config.py` (if at all). Mixins import from these. + +3. **No behavioral change.** Module-level caches and singletons work the same way whether they're in one file or five. + +--- + +### Change 2: Delete Dead Dashboard Components (LOW EFFORT, HIGH CLARITY) + +**Problem:** `Header.js` (58 LOC) and `SessionGroup.js` (56 LOC) are completely unused. Zero imports anywhere in the codebase. They were replaced by the current Sidebar + grid layout but never cleaned up. + +**Action:** Delete both files. + +**Import changes required:** None (nothing imports them). + +**Rationale:** Dead code is noise. Anyone exploring the `components/` directory would reasonably assume these are active and try to understand how they fit. Removing them prevents that confusion. + +--- + +### Change 3: No Changes to Dashboard Structure + +The dashboard is already well-organized: + +- `components/` - All React-like components +- `utils/` - Pure utility functions (formatting, API, status, autocomplete) +- `lib/` - Third-party wrappers (Preact, markdown rendering) + +This is a standard and intuitive layout. The `components/` directory has 13 files (15 before dead code removal), which is manageable. Creating sub-directories (e.g., `components/session/`, `components/layout/`) would add nesting without meaningful benefit at this scale. + +--- + +### Change 4: No Changes to `mixins/` Structure + +The mixin decomposition is the project's architectural backbone. Each mixin handles one concern: + +| Mixin | Responsibility | +|-------|---------------| +| `http.py` | HTTP routing, static file serving, CORS | +| `state.py` | State aggregation, SSE streaming, session collection | +| `conversation.py` | Conversation history parsing (Claude + Codex JSONL) | +| `control.py` | Session dismiss/respond, Zellij pane injection | +| `discovery.py` | Codex session auto-discovery, pane matching | +| `parsing.py` | JSONL tail reading, context usage extraction, caching | +| `skills.py` | Skill enumeration for Claude/Codex autocomplete | +| `spawn.py` | Agent spawning in Zellij tabs | + +All are 170-440 lines, which is reasonable. The largest (`state.py` at 440 lines) could theoretically be split, but its methods are tightly coupled around session collection. Splitting would create artificial seams. + +--- + +### Change 5: No Changes to `tests/` Structure + +Tests already mirror the source structure (`test_state.py` tests `mixins/state.py`, etc.). This is the correct pattern. + +**One consideration:** After splitting `context.py`, `test_context.py` may need updates to import from the new module locations. The test file is small (755 bytes) and covers basic context constants, so the update would be trivial. + +--- + +### Change 6: No Changes to `bin/` Scripts + +The `amc-hook` script intentionally duplicates `DATA_DIR`, `SESSIONS_DIR`, `EVENTS_DIR` from `context.py`. This is correct: the hook runs as a standalone process launched by Claude Code, not as part of the server. It must be self-contained with zero dependencies on the server package. Sharing code would create a fragile coupling. + +--- + +## What I Explicitly Decided NOT to Do + +1. **Not creating a `src/` directory.** The project root is clean. Adding `src/` would be an extra nesting level with no benefit. + +2. **Not splitting any mixins.** `state.py` (440 LOC) and `spawn.py` (358 LOC) are the largest, but their methods are cohesive. Splitting would scatter related logic across files. + +3. **Not merging small files.** `EmptyState.js` (18 LOC), `OptionButton.js` (24 LOC), and `ChatMessages.js` (39 LOC) are tiny but each has a clear purpose and is imported independently. Merging them would violate component-per-file convention. + +4. **Not reorganizing dashboard components into sub-folders.** With 13 components, flat is fine. Sub-folders like `components/session/` and `components/layout/` become necessary at ~25+ components. + +5. **Not consolidating `api.js` + `formatting.js` + `status.js` + `autocomplete.js`.** Each is focused and independently imported. A combined `utils.js` would be a grab-bag (the exact problem we're fixing in `context.py`). + +6. **Not moving `markdown.js` out of `lib/`.** It uses third-party dependencies and provides rendering utilities. `lib/` is the correct location. + +--- + +## Proposed Final Structure + +``` +amc/ + amc_server/ + __init__.py # (unchanged) + server.py # (updated imports) + handler.py # (unchanged) + config.py # NEW: Server constants, DATA_DIR, SESSIONS_DIR, EVENTS_DIR, PORT, etc. + zellij.py # NEW: Zellij binary resolution, ZELLIJ_PLUGIN, ZELLIJ_SESSION, cache + agents.py # NEW: Agent paths (Claude/Codex), transcript caches, dismissed cache + auth.py # NEW: Auth token generation/validation + spawn_config.py # NEW: Spawn constants, locks, rate limiting, projects watcher + logging_utils.py # (unchanged) + mixins/ # (unchanged structure, updated imports) + __init__.py + http.py + state.py + conversation.py + control.py + discovery.py + parsing.py + skills.py + spawn.py + + dashboard/ + index.html # (unchanged) + main.js # (unchanged) + styles.css # (unchanged) + lib/ + preact.js # (unchanged) + markdown.js # (unchanged) + utils/ + api.js # (unchanged) + formatting.js # (unchanged) + status.js # (unchanged) + autocomplete.js # (unchanged) + components/ + App.js # (unchanged) + Sidebar.js # (unchanged) + SessionCard.js # (unchanged) + Modal.js # (unchanged) + ChatMessages.js # (unchanged) + MessageBubble.js # (unchanged) + QuestionBlock.js # (unchanged) + SimpleInput.js # (unchanged) + OptionButton.js # (unchanged) + AgentActivityIndicator.js # (unchanged) + SpawnModal.js # (unchanged) + Toast.js # (unchanged) + EmptyState.js # (unchanged) + [DELETED] Header.js + [DELETED] SessionGroup.js + + bin/ # (unchanged) + tests/ # (minor import updates in test_context.py) +``` + +--- + +## Implementation Order + +1. **Delete dead dashboard components** (`Header.js`, `SessionGroup.js`) - zero risk, instant clarity +2. **Create new Python modules** (`config.py`, `zellij.py`, `agents.py`, `auth.py`, `spawn_config.py`) with the correct constants/functions +3. **Update all mixin imports** to use new module locations +4. **Update `server.py`** imports +5. **Delete `context.py`** +6. **Run full test suite** to verify nothing broke +7. **Update `test_context.py`** if needed + +--- + +## Risk Assessment + +- **Risk of breaking imports:** MEDIUM. There are many import statements to update across 8 mixin files + `server.py`. Mitigated by running the full test suite after changes. +- **Risk of circular imports:** LOW. The new modules form a clean DAG (config <- zellij/agents/auth/spawn_config <- mixins). +- **Risk to `bin/amc-hook`:** NONE. The hook is standalone and doesn't import from `amc_server`. +- **Risk to dashboard:** NONE for dead code deletion. Zero imports to either file. diff --git a/amc_server/agents.py b/amc_server/agents.py new file mode 100644 index 0000000..baa5a98 --- /dev/null +++ b/amc_server/agents.py @@ -0,0 +1,28 @@ +"""Agent-specific paths, caches, and constants for Claude/Codex discovery.""" + +from pathlib import Path + +# Claude Code conversation directory +CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects" + +# Codex conversation directory +CODEX_SESSIONS_DIR = Path.home() / ".codex" / "sessions" + +# Only discover recently-active Codex sessions (10 minutes) +CODEX_ACTIVE_WINDOW = 600 + +# Cache for Codex pane info (avoid running pgrep/ps/lsof on every request) +_codex_pane_cache = {"pid_info": {}, "cwd_map": {}, "expires": 0} + +# Cache for parsed context usage by transcript file path + mtime/size +_context_usage_cache = {} +_CONTEXT_CACHE_MAX = 100 + +# Cache mapping Codex session IDs to transcript paths (or None when missing) +_codex_transcript_cache = {} +_CODEX_CACHE_MAX = 200 + +# Codex sessions dismissed during this server lifetime (prevents re-discovery) +# Uses dict (not set) for O(1) lookup + FIFO eviction via insertion order (Python 3.7+) +_dismissed_codex_ids = {} +_DISMISSED_MAX = 500 diff --git a/amc_server/auth.py b/amc_server/auth.py new file mode 100644 index 0000000..c1f557f --- /dev/null +++ b/amc_server/auth.py @@ -0,0 +1,18 @@ +"""Auth token generation and validation for spawn endpoint security.""" + +import secrets + +# Auth token for spawn endpoint +_auth_token: str = '' + + +def generate_auth_token(): + """Generate a one-time auth token for this server instance.""" + global _auth_token + _auth_token = secrets.token_urlsafe(32) + return _auth_token + + +def validate_auth_token(request_token: str) -> bool: + """Validate the Authorization header token.""" + return request_token == f'Bearer {_auth_token}' diff --git a/amc_server/config.py b/amc_server/config.py new file mode 100644 index 0000000..c5a7bbe --- /dev/null +++ b/amc_server/config.py @@ -0,0 +1,20 @@ +"""Server-level constants: paths, port, timeouts, state lock.""" + +import threading +from pathlib import Path + +# Runtime data lives in XDG data dir +DATA_DIR = Path.home() / ".local" / "share" / "amc" +SESSIONS_DIR = DATA_DIR / "sessions" +EVENTS_DIR = DATA_DIR / "events" + +# Source files live in project directory (relative to this module) +PROJECT_DIR = Path(__file__).resolve().parent.parent +DASHBOARD_DIR = PROJECT_DIR / "dashboard" + +PORT = 7400 +STALE_EVENT_AGE = 86400 # 24 hours in seconds +STALE_STARTING_AGE = 3600 # 1 hour - sessions stuck in "starting" are orphans + +# Serialize state collection because it mutates session files/caches. +_state_lock = threading.Lock() diff --git a/amc_server/context.py b/amc_server/context.py deleted file mode 100644 index a75c083..0000000 --- a/amc_server/context.py +++ /dev/null @@ -1,117 +0,0 @@ -import secrets -import shutil -from pathlib import Path -import threading - -# Claude Code conversation directory -CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects" - -# Codex conversation directory -CODEX_SESSIONS_DIR = Path.home() / ".codex" / "sessions" - -# Plugin path for zellij-send-keys -ZELLIJ_PLUGIN = Path.home() / ".config" / "zellij" / "plugins" / "zellij-send-keys.wasm" - - -def _resolve_zellij_bin(): - """Resolve zellij binary even when PATH is minimal (eg launchctl).""" - from_path = shutil.which("zellij") - if from_path: - return from_path - - common_paths = ( - "/opt/homebrew/bin/zellij", # Apple Silicon Homebrew - "/usr/local/bin/zellij", # Intel Homebrew - "/usr/bin/zellij", - ) - for candidate in common_paths: - p = Path(candidate) - if p.exists() and p.is_file(): - return str(p) - return "zellij" # Fallback for explicit error reporting by subprocess - - -ZELLIJ_BIN = _resolve_zellij_bin() - -# Runtime data lives in XDG data dir -DATA_DIR = Path.home() / ".local" / "share" / "amc" -SESSIONS_DIR = DATA_DIR / "sessions" -EVENTS_DIR = DATA_DIR / "events" - -# Source files live in project directory (relative to this module) -PROJECT_DIR = Path(__file__).resolve().parent.parent -DASHBOARD_DIR = PROJECT_DIR / "dashboard" - -PORT = 7400 -STALE_EVENT_AGE = 86400 # 24 hours in seconds -STALE_STARTING_AGE = 3600 # 1 hour - sessions stuck in "starting" are orphans -CODEX_ACTIVE_WINDOW = 600 # 10 minutes - only discover recently-active Codex sessions - -# Cache for Zellij session list (avoid calling zellij on every request) -_zellij_cache = {"sessions": None, "expires": 0} - -# Cache for Codex pane info (avoid running pgrep/ps/lsof on every request) -_codex_pane_cache = {"pid_info": {}, "cwd_map": {}, "expires": 0} - -# Cache for parsed context usage by transcript file path + mtime/size -# Limited to prevent unbounded memory growth -_context_usage_cache = {} -_CONTEXT_CACHE_MAX = 100 - -# Cache mapping Codex session IDs to transcript paths (or None when missing) -_codex_transcript_cache = {} -_CODEX_CACHE_MAX = 200 - -# Codex sessions dismissed during this server lifetime (prevents re-discovery) -# Uses dict (not set) for O(1) lookup + FIFO eviction via insertion order (Python 3.7+) -_dismissed_codex_ids = {} -_DISMISSED_MAX = 500 - -# Serialize state collection because it mutates session files/caches. -_state_lock = threading.Lock() - -# Projects directory for spawning agents -PROJECTS_DIR = Path.home() / 'projects' - -# Default Zellij session for spawning -ZELLIJ_SESSION = 'infra' - -# Lock for serializing spawn operations (prevents Zellij race conditions) -_spawn_lock = threading.Lock() - -# Rate limiting: track last spawn time per project (prevents spam) -_spawn_timestamps: dict[str, float] = {} -SPAWN_COOLDOWN_SEC = 10.0 - -# Auth token for spawn endpoint -_auth_token: str = '' - - -def generate_auth_token(): - """Generate a one-time auth token for this server instance.""" - global _auth_token - _auth_token = secrets.token_urlsafe(32) - return _auth_token - - -def validate_auth_token(request_token: str) -> bool: - """Validate the Authorization header token.""" - return request_token == f'Bearer {_auth_token}' - - -def start_projects_watcher(): - """Start background thread to refresh projects cache every 5 minutes.""" - import logging - from amc_server.mixins.spawn import load_projects_cache - - def _watch_loop(): - import time - while True: - try: - time.sleep(300) - load_projects_cache() - except Exception: - logging.exception('Projects cache refresh failed') - - thread = threading.Thread(target=_watch_loop, daemon=True) - thread.start() diff --git a/amc_server/mixins/control.py b/amc_server/mixins/control.py index 65dc28a..8595070 100644 --- a/amc_server/mixins/control.py +++ b/amc_server/mixins/control.py @@ -3,7 +3,9 @@ import os import subprocess import time -from amc_server.context import SESSIONS_DIR, ZELLIJ_BIN, ZELLIJ_PLUGIN, _DISMISSED_MAX, _dismissed_codex_ids +from amc_server.agents import _DISMISSED_MAX, _dismissed_codex_ids +from amc_server.config import SESSIONS_DIR +from amc_server.zellij import ZELLIJ_BIN, ZELLIJ_PLUGIN from amc_server.logging_utils import LOGGER diff --git a/amc_server/mixins/conversation.py b/amc_server/mixins/conversation.py index b7c1637..b6e01e9 100644 --- a/amc_server/mixins/conversation.py +++ b/amc_server/mixins/conversation.py @@ -1,7 +1,7 @@ import json import os -from amc_server.context import EVENTS_DIR +from amc_server.config import EVENTS_DIR class ConversationMixin: diff --git a/amc_server/mixins/discovery.py b/amc_server/mixins/discovery.py index 31ee280..3aa9bb5 100644 --- a/amc_server/mixins/discovery.py +++ b/amc_server/mixins/discovery.py @@ -5,18 +5,99 @@ import subprocess import time from datetime import datetime, timezone -from amc_server.context import ( +from amc_server.agents import ( CODEX_ACTIVE_WINDOW, CODEX_SESSIONS_DIR, - SESSIONS_DIR, _CODEX_CACHE_MAX, _codex_pane_cache, _codex_transcript_cache, _dismissed_codex_ids, ) +from amc_server.config import SESSIONS_DIR +from amc_server.spawn_config import PENDING_SPAWNS_DIR from amc_server.logging_utils import LOGGER +def _parse_session_timestamp(session_ts): + """Parse Codex session timestamp to Unix time. Returns None on failure.""" + if not session_ts: + return None + try: + # Codex uses ISO format, possibly with Z suffix or +00:00 + ts_str = session_ts.replace('Z', '+00:00') + dt = datetime.fromisoformat(ts_str) + return dt.timestamp() + except (ValueError, TypeError, AttributeError): + return None + + +def _match_pending_spawn(session_cwd, session_start_ts): + """Match a Codex session to a pending spawn by CWD and timestamp. + + Args: + session_cwd: The CWD of the Codex session + session_start_ts: The session's START timestamp (ISO string from Codex metadata) + IMPORTANT: Must be session start time, not file mtime, to avoid false + matches with pre-existing sessions that were recently active. + + Returns: + spawn_id if matched (and deletes the pending file), None otherwise + """ + if not PENDING_SPAWNS_DIR.exists(): + return None + + normalized_cwd = os.path.normpath(session_cwd) if session_cwd else "" + if not normalized_cwd: + return None + + # Parse session start time - if we can't parse it, we can't safely match + session_start_unix = _parse_session_timestamp(session_start_ts) + if session_start_unix is None: + return None + + try: + for pending_file in PENDING_SPAWNS_DIR.glob('*.json'): + try: + data = json.loads(pending_file.read_text()) + if not isinstance(data, dict): + continue + + # Check agent type (only match codex to codex) + if data.get('agent_type') != 'codex': + continue + + # Check CWD match + pending_path = os.path.normpath(data.get('project_path', '')) + if normalized_cwd != pending_path: + continue + + # Check timing: session must have STARTED after spawn was initiated + # Using session start time (not mtime) prevents false matches with + # pre-existing sessions that happen to be recently active + spawn_ts = data.get('timestamp', 0) + if session_start_unix < spawn_ts: + continue + + # Match found - claim the spawn_id and delete the pending file + spawn_id = data.get('spawn_id') + try: + pending_file.unlink() + except OSError: + pass + LOGGER.info( + 'Matched Codex session (cwd=%s) to pending spawn_id=%s', + session_cwd, spawn_id, + ) + return spawn_id + + except (json.JSONDecodeError, OSError): + continue + except OSError: + pass + + return None + + class SessionDiscoveryMixin: def _discover_active_codex_sessions(self): """Find active Codex sessions and create/update session files with Zellij pane info.""" @@ -131,6 +212,13 @@ class SessionDiscoveryMixin: session_ts = payload.get("timestamp", "") last_event_at = datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat() + # Check for spawn_id: preserve existing, or match to pending spawn + # Use session_ts (start time) not mtime to avoid false matches + # with pre-existing sessions that were recently active + spawn_id = existing.get("spawn_id") + if not spawn_id: + spawn_id = _match_pending_spawn(cwd, session_ts) + session_data = { "session_id": session_id, "agent": "codex", @@ -145,6 +233,8 @@ class SessionDiscoveryMixin: "zellij_pane": zellij_pane or existing.get("zellij_pane", ""), "transcript_path": str(jsonl_file), } + if spawn_id: + session_data["spawn_id"] = spawn_id if context_usage: session_data["context_usage"] = context_usage elif existing.get("context_usage"): diff --git a/amc_server/mixins/http.py b/amc_server/mixins/http.py index 0a528ed..7ca092a 100644 --- a/amc_server/mixins/http.py +++ b/amc_server/mixins/http.py @@ -1,8 +1,8 @@ import json import urllib.parse -import amc_server.context as ctx -from amc_server.context import DASHBOARD_DIR +import amc_server.auth as auth +from amc_server.config import DASHBOARD_DIR from amc_server.logging_utils import LOGGER @@ -148,10 +148,10 @@ class HttpMixin: content_type = content_types.get(ext, "application/octet-stream") # Inject auth token into index.html for spawn endpoint security - if file_path == "index.html" and ctx._auth_token: + if file_path == "index.html" and auth._auth_token: content = content.replace( b"", - f''.encode(), + f''.encode(), ) # No caching during development diff --git a/amc_server/mixins/parsing.py b/amc_server/mixins/parsing.py index 591dba8..0fdf9aa 100644 --- a/amc_server/mixins/parsing.py +++ b/amc_server/mixins/parsing.py @@ -2,7 +2,7 @@ import json import os from pathlib import Path -from amc_server.context import ( +from amc_server.agents import ( CLAUDE_PROJECTS_DIR, CODEX_SESSIONS_DIR, _CODEX_CACHE_MAX, diff --git a/amc_server/mixins/skills.py b/amc_server/mixins/skills.py index dc58bd4..c957f07 100644 --- a/amc_server/mixins/skills.py +++ b/amc_server/mixins/skills.py @@ -37,7 +37,7 @@ class SkillsMixin: Checks SKILL.md (canonical) first, then falls back to skill.md, prompt.md, README.md for description extraction. Parses YAML - frontmatter if present to extract the description field. + frontmatter if present to extract name and description fields. Returns: List of {name: str, description: str} dicts. @@ -53,35 +53,41 @@ class SkillsMixin: if not skill_dir.is_dir() or skill_dir.name.startswith("."): continue - description = "" - # Check files in priority order + meta = {"name": "", "description": ""} + # Check files in priority order, accumulating metadata + # (earlier files take precedence for each field) for md_name in ["SKILL.md", "skill.md", "prompt.md", "README.md"]: md_file = skill_dir / md_name if md_file.exists(): try: content = md_file.read_text() - description = self._extract_description(content) - if description: + parsed = self._parse_frontmatter(content) + if not meta["name"] and parsed["name"]: + meta["name"] = parsed["name"] + if not meta["description"] and parsed["description"]: + meta["description"] = parsed["description"] + if meta["description"]: break except OSError: pass skills.append({ - "name": skill_dir.name, - "description": description or f"Skill: {skill_dir.name}", + "name": meta["name"] or skill_dir.name, + "description": meta["description"] or f"Skill: {skill_dir.name}", }) return skills - def _extract_description(self, content: str) -> str: - """Extract description from markdown content. + def _parse_frontmatter(self, content: str) -> dict: + """Extract name and description from markdown YAML frontmatter. - Handles YAML frontmatter (looks for 'description:' field) and - falls back to first meaningful line after frontmatter. + Returns: + Dict with 'name' and 'description' keys (both str, may be empty). """ + result = {"name": "", "description": ""} lines = content.splitlines() if not lines: - return "" + return result # Check for YAML frontmatter frontmatter_end = 0 @@ -91,33 +97,37 @@ class SkillsMixin: if stripped == "---": frontmatter_end = i + 1 break - # Look for description field in frontmatter - if stripped.startswith("description:"): - # Extract value after colon - desc = stripped[len("description:"):].strip() - # Remove quotes if present - if desc.startswith('"') and desc.endswith('"'): - desc = desc[1:-1] - elif desc.startswith("'") and desc.endswith("'"): - desc = desc[1:-1] - # Handle YAML multi-line indicators (>- or |-) - if desc in (">-", "|-", ">", "|", ""): - # Multi-line: read the next indented line - if i + 1 < len(lines): - next_line = lines[i + 1].strip() - if next_line and not next_line.startswith("---"): - return next_line[:100] - elif desc: - return desc[:100] + # Check each known frontmatter field + for field in ("name", "description"): + if stripped.startswith(f"{field}:"): + val = stripped[len(field) + 1:].strip() + # Remove quotes if present + if val.startswith('"') and val.endswith('"'): + val = val[1:-1] + elif val.startswith("'") and val.endswith("'"): + val = val[1:-1] + # Handle YAML multi-line indicators (>- or |-) + if val in (">-", "|-", ">", "|", ""): + if i + 1 < len(lines): + next_line = lines[i + 1].strip() + if next_line and not next_line.startswith("---"): + val = next_line + else: + val = "" + else: + val = "" + if val: + result[field] = val[:100] - # Fall back to first meaningful line after frontmatter - for line in lines[frontmatter_end:]: - stripped = line.strip() - # Skip empty lines, headers, comments, and frontmatter delimiters - if stripped and not stripped.startswith("#") and not stripped.startswith("