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("