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 <noreply@anthropic.com>
This commit is contained in:
316
PROPOSED_CODE_FILE_REORGANIZATION_PLAN.md
Normal file
316
PROPOSED_CODE_FILE_REORGANIZATION_PLAN.md
Normal file
@@ -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.
|
||||
28
amc_server/agents.py
Normal file
28
amc_server/agents.py
Normal file
@@ -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
|
||||
18
amc_server/auth.py
Normal file
18
amc_server/auth.py
Normal file
@@ -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}'
|
||||
20
amc_server/config.py
Normal file
20
amc_server/config.py
Normal file
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
from amc_server.context import EVENTS_DIR
|
||||
from amc_server.config import EVENTS_DIR
|
||||
|
||||
|
||||
class ConversationMixin:
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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"<!-- AMC_AUTH_TOKEN -->",
|
||||
f'<script>window.AMC_AUTH_TOKEN = "{ctx._auth_token}";</script>'.encode(),
|
||||
f'<script>window.AMC_AUTH_TOKEN = "{auth._auth_token}";</script>'.encode(),
|
||||
)
|
||||
|
||||
# No caching during development
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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("<!--") and stripped != "---":
|
||||
return stripped[:100]
|
||||
# Fall back to first meaningful line for description
|
||||
if not result["description"]:
|
||||
for line in lines[frontmatter_end:]:
|
||||
stripped = line.strip()
|
||||
if stripped and not stripped.startswith("#") and not stripped.startswith("<!--") and stripped != "---":
|
||||
result["description"] = stripped[:100]
|
||||
break
|
||||
|
||||
return ""
|
||||
return result
|
||||
|
||||
def _enumerate_codex_skills(self) -> list[dict]:
|
||||
"""Enumerate Codex skills from cache and user directory.
|
||||
@@ -161,19 +171,19 @@ class SkillsMixin:
|
||||
if not skill_dir.is_dir() or skill_dir.name.startswith("."):
|
||||
continue
|
||||
|
||||
description = ""
|
||||
# Check SKILL.md for description
|
||||
meta = {"name": "", "description": ""}
|
||||
# Check SKILL.md for metadata
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
if skill_md.exists():
|
||||
try:
|
||||
content = skill_md.read_text()
|
||||
description = self._extract_description(content)
|
||||
meta = self._parse_frontmatter(content)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
skills.append({
|
||||
"name": skill_dir.name,
|
||||
"description": description or f"User skill: {skill_dir.name}",
|
||||
"name": meta["name"] or skill_dir.name,
|
||||
"description": meta["description"] or f"User skill: {skill_dir.name}",
|
||||
})
|
||||
|
||||
return skills
|
||||
|
||||
@@ -5,13 +5,52 @@ import subprocess
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from amc_server.context import (
|
||||
PROJECTS_DIR, SESSIONS_DIR, ZELLIJ_BIN, ZELLIJ_SESSION,
|
||||
from amc_server.auth import validate_auth_token
|
||||
from amc_server.config import SESSIONS_DIR
|
||||
from amc_server.spawn_config import (
|
||||
PENDING_SPAWNS_DIR, PENDING_SPAWN_TTL,
|
||||
PROJECTS_DIR,
|
||||
_spawn_lock, _spawn_timestamps, SPAWN_COOLDOWN_SEC,
|
||||
validate_auth_token,
|
||||
)
|
||||
from amc_server.zellij import ZELLIJ_BIN, ZELLIJ_SESSION
|
||||
from amc_server.logging_utils import LOGGER
|
||||
|
||||
|
||||
def _write_pending_spawn(spawn_id, project_path, agent_type):
|
||||
"""Write a pending spawn record for later correlation by discovery.
|
||||
|
||||
This enables Codex session correlation since env vars don't propagate
|
||||
through Zellij's pane spawn mechanism.
|
||||
"""
|
||||
PENDING_SPAWNS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
pending_file = PENDING_SPAWNS_DIR / f'{spawn_id}.json'
|
||||
data = {
|
||||
'spawn_id': spawn_id,
|
||||
'project_path': str(project_path),
|
||||
'agent_type': agent_type,
|
||||
'timestamp': time.time(),
|
||||
}
|
||||
try:
|
||||
pending_file.write_text(json.dumps(data))
|
||||
except OSError:
|
||||
LOGGER.warning('Failed to write pending spawn file for %s', spawn_id)
|
||||
|
||||
|
||||
def _cleanup_stale_pending_spawns():
|
||||
"""Remove pending spawn files older than PENDING_SPAWN_TTL."""
|
||||
if not PENDING_SPAWNS_DIR.exists():
|
||||
return
|
||||
now = time.time()
|
||||
try:
|
||||
for f in PENDING_SPAWNS_DIR.glob('*.json'):
|
||||
try:
|
||||
if now - f.stat().st_mtime > PENDING_SPAWN_TTL:
|
||||
f.unlink()
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Agent commands (AC-8, AC-9: full autonomous permissions)
|
||||
AGENT_COMMANDS = {
|
||||
'claude': ['claude', '--dangerously-skip-permissions'],
|
||||
@@ -215,6 +254,16 @@ class SpawnMixin:
|
||||
|
||||
def _spawn_agent_in_project_tab(self, project, project_path, agent_type, spawn_id):
|
||||
"""Spawn an agent in a project-named Zellij tab."""
|
||||
# Clean up stale pending spawns opportunistically
|
||||
_cleanup_stale_pending_spawns()
|
||||
|
||||
# For Codex, write pending spawn record before launching.
|
||||
# Zellij doesn't propagate env vars to pane commands, so discovery
|
||||
# will match the session to this record by CWD + timestamp.
|
||||
# (Claude doesn't need this - amc-hook writes spawn_id directly)
|
||||
if agent_type == 'codex':
|
||||
_write_pending_spawn(spawn_id, project_path, agent_type)
|
||||
|
||||
# Check session exists
|
||||
if not self._check_zellij_session_exists():
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import os
|
||||
from http.server import ThreadingHTTPServer
|
||||
|
||||
from amc_server.context import DATA_DIR, PORT, generate_auth_token, start_projects_watcher
|
||||
from amc_server.auth import generate_auth_token
|
||||
from amc_server.config import DATA_DIR, PORT
|
||||
from amc_server.spawn_config import start_projects_watcher
|
||||
from amc_server.handler import AMCHandler
|
||||
from amc_server.logging_utils import LOGGER, configure_logging, install_signal_handlers
|
||||
from amc_server.mixins.spawn import load_projects_cache
|
||||
|
||||
40
amc_server/spawn_config.py
Normal file
40
amc_server/spawn_config.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Spawn feature config: paths, locks, rate limiting, projects watcher."""
|
||||
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from amc_server.config import DATA_DIR
|
||||
|
||||
# Pending spawn registry
|
||||
PENDING_SPAWNS_DIR = DATA_DIR / "pending_spawns"
|
||||
|
||||
# Pending spawn TTL: how long to keep unmatched spawn records (seconds)
|
||||
PENDING_SPAWN_TTL = 60
|
||||
|
||||
# Projects directory for spawning agents
|
||||
PROJECTS_DIR = Path.home() / 'projects'
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
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()
|
||||
34
amc_server/zellij.py
Normal file
34
amc_server/zellij.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Zellij integration: binary resolution, plugin path, session name, cache."""
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
# 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()
|
||||
|
||||
# Default Zellij session for spawning
|
||||
ZELLIJ_SESSION = 'infra'
|
||||
|
||||
# Cache for Zellij session list (avoid calling zellij on every request)
|
||||
_zellij_cache = {"sessions": None, "expires": 0}
|
||||
@@ -1,18 +1,18 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from amc_server.context import _resolve_zellij_bin
|
||||
from amc_server.zellij import _resolve_zellij_bin
|
||||
|
||||
|
||||
class ContextTests(unittest.TestCase):
|
||||
def test_resolve_zellij_bin_prefers_which(self):
|
||||
with patch("amc_server.context.shutil.which", return_value="/custom/bin/zellij"):
|
||||
with patch("amc_server.zellij.shutil.which", return_value="/custom/bin/zellij"):
|
||||
self.assertEqual(_resolve_zellij_bin(), "/custom/bin/zellij")
|
||||
|
||||
def test_resolve_zellij_bin_falls_back_to_default_name(self):
|
||||
with patch("amc_server.context.shutil.which", return_value=None), patch(
|
||||
"amc_server.context.Path.exists", return_value=False
|
||||
), patch("amc_server.context.Path.is_file", return_value=False):
|
||||
with patch("amc_server.zellij.shutil.which", return_value=None), patch(
|
||||
"amc_server.zellij.Path.exists", return_value=False
|
||||
), patch("amc_server.zellij.Path.is_file", return_value=False):
|
||||
self.assertEqual(_resolve_zellij_bin(), "zellij")
|
||||
|
||||
|
||||
|
||||
@@ -336,7 +336,7 @@ class TestDismissSession(unittest.TestCase):
|
||||
# (if it existed, it would still exist)
|
||||
|
||||
def test_tracks_dismissed_codex_session(self):
|
||||
from amc_server.context import _dismissed_codex_ids
|
||||
from amc_server.agents import _dismissed_codex_ids
|
||||
_dismissed_codex_ids.clear()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
|
||||
@@ -27,7 +27,7 @@ class TestGetCodexPaneInfo(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.handler = DummyDiscoveryHandler()
|
||||
# Clear cache before each test
|
||||
from amc_server.context import _codex_pane_cache
|
||||
from amc_server.agents import _codex_pane_cache
|
||||
_codex_pane_cache["expires"] = 0
|
||||
_codex_pane_cache["pid_info"] = {}
|
||||
_codex_pane_cache["cwd_map"] = {}
|
||||
@@ -80,7 +80,7 @@ class TestGetCodexPaneInfo(unittest.TestCase):
|
||||
self.assertEqual(pid_info["12345"]["zellij_session"], "myproject")
|
||||
|
||||
def test_cache_used_when_fresh(self):
|
||||
from amc_server.context import _codex_pane_cache
|
||||
from amc_server.agents import _codex_pane_cache
|
||||
_codex_pane_cache["pid_info"] = {"cached": {"pane_id": "1", "zellij_session": "s"}}
|
||||
_codex_pane_cache["cwd_map"] = {"/cached/path": {"session": "s", "pane_id": "1"}}
|
||||
_codex_pane_cache["expires"] = time.time() + 100
|
||||
@@ -203,7 +203,7 @@ class TestDiscoverActiveCodexSessions(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.handler = DummyDiscoveryHandler()
|
||||
# Clear caches
|
||||
from amc_server.context import _codex_transcript_cache, _dismissed_codex_ids
|
||||
from amc_server.agents import _codex_transcript_cache, _dismissed_codex_ids
|
||||
_codex_transcript_cache.clear()
|
||||
_dismissed_codex_ids.clear()
|
||||
|
||||
@@ -236,7 +236,7 @@ class TestDiscoverActiveCodexSessions(unittest.TestCase):
|
||||
|
||||
def test_skips_dismissed_sessions(self):
|
||||
"""Sessions in _dismissed_codex_ids should be skipped."""
|
||||
from amc_server.context import _dismissed_codex_ids
|
||||
from amc_server.agents import _dismissed_codex_ids
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
codex_dir = Path(tmpdir)
|
||||
|
||||
@@ -228,7 +228,7 @@ class TestFindCodexTranscriptFile(unittest.TestCase):
|
||||
def test_codex_sessions_dir_missing_returns_none(self):
|
||||
with patch("amc_server.mixins.parsing.CODEX_SESSIONS_DIR", Path("/nonexistent")):
|
||||
# Clear cache to force discovery
|
||||
from amc_server.context import _codex_transcript_cache
|
||||
from amc_server.agents import _codex_transcript_cache
|
||||
_codex_transcript_cache.clear()
|
||||
result = self.handler._find_codex_transcript_file("abc123")
|
||||
self.assertIsNone(result)
|
||||
@@ -238,7 +238,7 @@ class TestFindCodexTranscriptFile(unittest.TestCase):
|
||||
transcript_file = Path(tmpdir) / "abc123.jsonl"
|
||||
transcript_file.write_text('{"type": "session_meta"}\n')
|
||||
|
||||
from amc_server.context import _codex_transcript_cache
|
||||
from amc_server.agents import _codex_transcript_cache
|
||||
_codex_transcript_cache["abc123"] = str(transcript_file)
|
||||
|
||||
result = self.handler._find_codex_transcript_file("abc123")
|
||||
@@ -248,7 +248,7 @@ class TestFindCodexTranscriptFile(unittest.TestCase):
|
||||
_codex_transcript_cache.clear()
|
||||
|
||||
def test_cache_hit_with_deleted_file_returns_none(self):
|
||||
from amc_server.context import _codex_transcript_cache
|
||||
from amc_server.agents import _codex_transcript_cache
|
||||
_codex_transcript_cache["deleted-session"] = "/nonexistent/file.jsonl"
|
||||
|
||||
result = self.handler._find_codex_transcript_file("deleted-session")
|
||||
@@ -257,7 +257,7 @@ class TestFindCodexTranscriptFile(unittest.TestCase):
|
||||
_codex_transcript_cache.clear()
|
||||
|
||||
def test_cache_hit_with_none_returns_none(self):
|
||||
from amc_server.context import _codex_transcript_cache
|
||||
from amc_server.agents import _codex_transcript_cache
|
||||
_codex_transcript_cache["cached-none"] = None
|
||||
|
||||
result = self.handler._find_codex_transcript_file("cached-none")
|
||||
@@ -557,7 +557,7 @@ class TestGetCachedContextUsage(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.handler = DummyParsingHandler()
|
||||
# Clear cache before each test
|
||||
from amc_server.context import _context_usage_cache
|
||||
from amc_server.agents import _context_usage_cache
|
||||
_context_usage_cache.clear()
|
||||
|
||||
def test_nonexistent_file_returns_none(self):
|
||||
|
||||
@@ -290,7 +290,7 @@ class TestRateLimiting(unittest.TestCase):
|
||||
|
||||
def test_first_spawn_allowed(self):
|
||||
"""First spawn for a project should not be rate-limited."""
|
||||
from amc_server.context import _spawn_timestamps
|
||||
from amc_server.spawn_config import _spawn_timestamps
|
||||
_spawn_timestamps.clear()
|
||||
|
||||
handler = self._make_handler('fresh-project')
|
||||
@@ -317,7 +317,7 @@ class TestRateLimiting(unittest.TestCase):
|
||||
|
||||
def test_rapid_spawn_same_project_rejected(self):
|
||||
"""Spawning the same project within cooldown returns 429."""
|
||||
from amc_server.context import _spawn_timestamps
|
||||
from amc_server.spawn_config import _spawn_timestamps
|
||||
_spawn_timestamps.clear()
|
||||
# Pretend we just spawned this project
|
||||
_spawn_timestamps['rapid-project'] = time.monotonic()
|
||||
@@ -339,7 +339,7 @@ class TestRateLimiting(unittest.TestCase):
|
||||
|
||||
def test_spawn_different_project_allowed(self):
|
||||
"""Spawning a different project while one is on cooldown succeeds."""
|
||||
from amc_server.context import _spawn_timestamps
|
||||
from amc_server.spawn_config import _spawn_timestamps
|
||||
_spawn_timestamps.clear()
|
||||
_spawn_timestamps['project-a'] = time.monotonic()
|
||||
|
||||
@@ -360,7 +360,7 @@ class TestRateLimiting(unittest.TestCase):
|
||||
|
||||
def test_spawn_after_cooldown_allowed(self):
|
||||
"""Spawning the same project after cooldown expires succeeds."""
|
||||
from amc_server.context import _spawn_timestamps, SPAWN_COOLDOWN_SEC
|
||||
from amc_server.spawn_config import _spawn_timestamps, SPAWN_COOLDOWN_SEC
|
||||
_spawn_timestamps.clear()
|
||||
# Set timestamp far enough in the past
|
||||
_spawn_timestamps['cooled-project'] = time.monotonic() - SPAWN_COOLDOWN_SEC - 1
|
||||
|
||||
Reference in New Issue
Block a user