Compare commits

...

46 Commits

Author SHA1 Message Date
teernisse
c5b1fb3a80 chore(plans): add input-history and model-selection plans
plans/input-history.md:
- Implementation plan for shell-style up/down arrow message history
  in SimpleInput, deriving history from session log conversation data
- Covers prop threading, history derivation, navigation state,
  keybinding details, modal parity, and test cases

plans/model-selection.md:
- Three-phase plan for model visibility and control: display current
  model, model picker at spawn, mid-session model switching via Zellij

plans/PLAN-tool-result-display.md:
- Updates to tool result display plan (pre-existing changes)

plans/subagent-visibility.md:
- Updates to subagent visibility plan (pre-existing changes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:51:36 -05:00
teernisse
abbede923d feat(server): filter system-injected messages from Claude conversations
Add _is_system_injected() filter to conversation.py that drops user
messages starting with known system-injected prefixes. These messages
(hook outputs, system reminders, teammate notifications) appear in
JSONL session logs as type: "user" with string content but are not
human-typed input.

Filtered prefixes:
- <system-reminder>    — Claude Code system context injection
- <local-command-caveat> — local command hook output
- <available-deferred-tools> — deferred tool discovery messages
- <teammate-message    — team agent message delivery (no closing >
                         because tag has attributes)

This brings Claude parsing to parity with the Codex parser, which
already filters system-injected content via SKIP_PREFIXES (line 222).
The filter uses str.startswith(tuple) with lstrip() to handle leading
whitespace. Applied at line 75 in the existing content-type guard chain.

Affects both chat display and input history navigation — system noise
is removed at the source so all consumers benefit.

tests/test_conversation.py:
- TestIsSystemInjected: 10 unit tests for the filter function covering
  each prefix, leading whitespace, normal messages, mid-string tags,
  empty strings, slash commands, and multiline content
- TestClaudeSystemInjectedFiltering: 5 integration tests through the
  full parser verifying exclusion, sequential ID preservation after
  filtering, and all-system-message edge case

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:51:28 -05:00
teernisse
ef451cf20f feat(dashboard): add input history navigation with up/down arrows
Add shell-style up/down arrow navigation through past messages in the
SimpleInput component. History is derived from the conversation data
already parsed from session logs — no new state management needed.

dashboard/components/SessionCard.js:
- Pass `conversation` prop through to SimpleInput (line 170)
- Prop chain verified: App -> SessionCard -> SimpleInput, including
  the Modal/enlarged path (Modal.js:69 already passes conversation)

dashboard/components/SimpleInput.js:
- Accept `conversation` prop and derive `userHistory` via useMemo,
  filtering for role === 'user' messages and mapping to content
- Add historyIndexRef (-1 = not browsing) and draftRef (preserves
  in-progress text when entering history mode)
- ArrowUp: intercepts only when cursor at position 0 and autocomplete
  closed, walks backward through history (newest to oldest)
- ArrowDown: only when already browsing history, walks forward;
  past newest entry restores saved draft and exits history mode
- Bounds clamp on ArrowUp prevents undefined array access if
  userHistory shrinks between navigations (SSE update edge case)
- Reset historyIndexRef on submit (line 110) and manual input (line 141)
- Textarea height recalculated after setting history text via
  setTimeout to run after Preact commits the state update

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:51:14 -05:00
teernisse
fb9d4e5b9f chore(plans): update implementation plans
plans/PLAN-tool-result-display.md:
- Add comprehensive plan for displaying tool results inline in
  conversation view, including truncation strategies and expand/collapse
  UI patterns

plans/subagent-visibility.md:
- Mark completed phases and update remaining work items
- Reflects current state of subagent tracking implementation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:49:02 -05:00
teernisse
781e74cda2 docs(jsonl): add comprehensive Claude JSONL session log reference
Create authoritative documentation suite for Claude Code JSONL session
log processing, synthesized from codebase analysis, official Anthropic
documentation, and community tooling research.

Documentation structure (docs/claude-jsonl-reference/):

01-format-specification.md (214 lines):
- Complete message envelope structure with all fields
- Content block types (text, thinking, tool_use, tool_result)
- Usage object for token reporting
- Model identifiers and version history
- Conversation DAG structure via parentUuid

02-message-types.md (346 lines):
- Every message type with concrete JSON examples
- User messages (string content vs array for tool results)
- Assistant messages with all content block variants
- Progress events (hooks, bash, MCP)
- System, summary, and file-history-snapshot types
- Codex format differences (response_item, function_call)

03-tool-lifecycle.md (341 lines):
- Complete tool invocation to result flow
- Hook input/output formats (PreToolUse, PostToolUse)
- Parallel tool call handling
- Tool-to-result pairing algorithm
- Missing result edge cases
- Codex tool format differences

04-subagent-teams.md (363 lines):
- Task tool invocation and input fields
- Subagent transcript locations and format
- Team coordination (TeamCreate, SendMessage)
- Hook events (SubagentStart, SubagentStop)
- AMC spawn tracking with pending spawn registry
- Worktree isolation for subagents

05-edge-cases.md (475 lines):
- Parsing edge cases (invalid JSON, type ambiguity)
- Type coercion gotchas (bool vs int in Python)
- Session state edge cases (orphans, dead detection)
- Tool call edge cases (missing results, parallel ordering)
- Codex-specific quirks (content injection, buffering)
- File system safety (path traversal, permissions)
- Cache invalidation strategies

06-quick-reference.md (238 lines):
- File locations cheat sheet
- jq recipes for common queries
- Python parsing snippets
- Common gotchas table
- Useful constants
- Debugging commands

Also adds CLAUDE.md at project root linking to documentation and
providing project overview for agents working on AMC.

Sources include Claude Code hooks.md, headless.md, Anthropic Messages
API reference, and community tools (claude-code-log, claude-JSONL-browser).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:48:55 -05:00
teernisse
ac629bd149 refactor(dashboard): remove unused Header and SessionGroup components
Remove components that are no longer part of the dashboard architecture:

Header.js:
- Original header component replaced by integrated spawn controls
- SpawnModal now handles project selection and agent spawning
- Sidebar provides navigation context previously in header

SessionGroup.js:
- Session grouping now handled directly in App.js
- Simplified rendering without intermediate wrapper component
- groupSessionsByProject utility provides the grouping logic

These components were bypassed during the spawn feature implementation
and maintaining them increases cognitive overhead without benefit.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:48:34 -05:00
teernisse
fa1fe8613a feat(dashboard): wire skills autocomplete configuration to session cards
Connect the skills enumeration API to session card input fields for
slash command autocomplete:

App.js:
- Add skillsConfig state for Claude and Codex skill configs
- Fetch skills for both agent types on mount using Promise.all
- Pass agent-appropriate autocompleteConfig to each SessionCard

SessionCard.js:
- Accept autocompleteConfig prop and forward to SimpleInput
- Move context usage display from header to footer status bar for
  better information hierarchy (activity indicator + context together)

SimpleInput.js:
- Fix autocomplete dropdown padding (py-2 -> py-1.5)
- Fix font inheritance (add font-mono to skill name)
- Fix description tooltip whitespace handling (add font-sans,
  whitespace-normal)

SpawnModal.js:
- Add SPAWN_TIMEOUT_MS (2x default) to handle pending spawn registry
  wait time plus session file confirmation polling

AgentActivityIndicator.js:
- Minor styling refinement for status display

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:48:26 -05:00
teernisse
d3c6af9b00 test(skills): add frontmatter name override tests
Add comprehensive test coverage for skill name resolution priority:

- test_frontmatter_name_overrides_dir_name: Verifies that the 'name'
  field in YAML frontmatter takes precedence over directory name,
  enabling namespaced skills like "doodle:bug-hunter"

- test_name_preserved_across_fallback_files: Confirms that when SKILL.md
  provides a name but README.md provides the description, the name from
  SKILL.md is preserved (important for multi-file skill definitions)

- test_no_frontmatter_name_uses_dir_name: Validates fallback behavior
  when no name is specified in frontmatter

This ensures the skill autocomplete system correctly handles both
simple directory-named skills and explicitly namespaced skills from
skill packs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:47:49 -05:00
teernisse
0acc56417e test(spawn): add pending spawn registry and timestamp parsing tests
Extend spawn test coverage with verification of Codex session correlation
mechanisms:

Pending spawn registry tests:
- _write_pending_spawn creates JSON file with correct spawn_id,
  project_path, agent_type, and timestamp
- _cleanup_stale_pending_spawns removes files older than PENDING_SPAWN_TTL
  while preserving fresh files

Session timestamp parsing tests:
- Handles ISO 8601 with Z suffix (Zulu time)
- Handles ISO 8601 with explicit +00:00 offset
- Returns None for invalid format strings
- Returns None for empty string input

These tests ensure spawn_id correlation works correctly for Codex
sessions, which don't have direct environment variable injection like
Claude sessions do.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:47:40 -05:00
teernisse
19a31a4620 feat(state): add same-pane session deduplication
Handle edge case where Claude's --resume creates an orphan session file
before resuming the original session, leaving two session files pointing
to the same Zellij pane.

The deduplication algorithm (_dedupe_same_pane_sessions) resolves
conflicts by preferring:
1. Sessions with context_usage (indicates actual conversation occurred)
2. Higher conversation_mtime_ns (more recent file activity)

When an orphan is identified, its session file is deleted from disk to
prevent re-discovery on subsequent state collection cycles.

Test coverage includes:
- Keeping session with context_usage over one without
- Keeping higher mtime when both have context_usage
- Keeping higher mtime when neither has context_usage
- Preserving sessions on different panes (no false positives)
- Single session per pane unchanged
- Sessions without pane info unchanged
- Handling non-numeric mtime values defensively

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:47:28 -05:00
teernisse
1fb4a82b39 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>
2026-02-28 00:47:15 -05:00
teernisse
69175f08f9 fix(spawn): strip ANSI codes from zellij list-sessions output
Zellij outputs colored text with ANSI escape codes, which caused
session name parsing to fail. Now strips escape codes before parsing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 17:27:56 -05:00
teernisse
f26e9acb34 chore(beads): close final spawn feature beads
All spawn feature beads completed:
- bd-zgt: Spawn Modal UI verification (dropdown implementation)
- bd-3g8: Polish and edge cases (validation, feedback, warnings)

Feature complete: agent spawning with autocomplete.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 17:18:37 -05:00
teernisse
baa712ba15 refactor(dashboard): change SpawnModal from overlay modal to dropdown
Position the spawn modal directly under the 'New Agent' button without a
blur overlay. Uses click-outside dismissal and absolute positioning.
Reduces visual disruption for quick agent spawning.
2026-02-26 17:15:22 -05:00
teernisse
7a9d290cb9 test(spawn): verify Zellij metadata for spawned agents 2026-02-26 17:10:41 -05:00
teernisse
1e21dd08b6 feat(dashboard): add visual feedback for spawned agents 2026-02-26 17:09:52 -05:00
teernisse
e3e42e53f2 fix(spawn): handle special characters in project names
- Reject null bytes and control characters (U+0000-U+001F, U+007F) in
  _validate_spawn_params with explicit INVALID_PROJECT error
- Reject whitespace-only project names as MISSING_PROJECT
- Reject non-string project names (int, list, etc.)
- Add _sanitize_pane_name() to clean Zellij pane names: replaces quotes,
  backticks, and control chars with underscores; collapses whitespace;
  truncates to 64 chars
- Add 44 new tests: safe chars (hyphens, spaces, dots, @, +, #),
  dangerous chars (null byte, newline, tab, ESC, DEL), shell
  metacharacters ($, ;, backtick, |), pane name sanitization, and
  spawn command construction with special char names

Closes bd-14p
2026-02-26 17:09:51 -05:00
teernisse
99a55472a5 test(e2e): add spawn workflow end-to-end tests 2026-02-26 17:09:49 -05:00
teernisse
5494d76a98 feat(dashboard): add Zellij unavailable warning banner 2026-02-26 17:09:30 -05:00
teernisse
c0ee053d50 chore(beads): close bd-3c7 empty projects edge case 2026-02-26 17:08:37 -05:00
teernisse
8070c4132a fix(spawn): handle empty or missing projects directory
- Add empty-state message in SpawnModal when no projects found
- Spawn button stays disabled when projects list is empty
- Server already handled OSError/missing dir gracefully (returns [])
- Add tests: missing directory, only-hidden-dirs, empty API responses

Closes: bd-3c7
2026-02-26 17:08:33 -05:00
teernisse
2d65d8f95b feat(server): integrate SpawnMixin into AMCHandler 2026-02-26 17:07:35 -05:00
teernisse
37748cb99c test(e2e): add autocomplete workflow tests 2026-02-26 17:04:11 -05:00
teernisse
9695e9b08a feat(server): add spawn feature startup initialization
- Import and call load_projects_cache() to populate cache before requests
- Import and call generate_auth_token() to create one-time auth token
- Import and call start_projects_watcher() for background cache refresh
- Inject auth token into dashboard HTML via placeholder replacement
- Add AMC_AUTH_TOKEN placeholder in index.html head
2026-02-26 17:03:20 -05:00
teernisse
58f0befe72 feat(dashboard): integrate SpawnModal with header button 2026-02-26 17:03:14 -05:00
teernisse
62d23793c4 feat(server): add spawn API HTTP routes
Add routing for spawn-related endpoints to HttpMixin:
- GET /api/projects -> _handle_projects
- GET /api/health -> _handle_health
- POST /api/spawn -> _handle_spawn
- POST /api/projects/refresh -> _handle_projects_refresh

Update CORS preflight (AC-39) to include GET in allowed methods
and Authorization in allowed headers.

Closes bd-2al
2026-02-26 17:03:00 -05:00
teernisse
7b1e47adc0 feat(dashboard): implement SpawnModal component 2026-02-26 17:00:51 -05:00
teernisse
a7a9ebbf2b feat(server): implement SpawnMixin for agent spawning
Implement amc_server/mixins/spawn.py with:
- _handle_spawn: POST /api/spawn handler with auth, validation, rate limiting
- _validate_spawn_params: path traversal/symlink escape protection
- _check_zellij_session_exists: session availability check
- _wait_for_session_file: poll for spawn correlation via spawn_id
- _spawn_agent_in_project_tab: Zellij tab creation + pane spawn
- _handle_projects: GET cached project list
- _handle_projects_refresh: POST to refresh cache
- _handle_health: Zellij availability check
- load_projects_cache: module-level project directory scanner

Closes bd-5m4
2026-02-26 17:00:49 -05:00
teernisse
48c3ddce90 test(dashboard): add autocomplete trigger/filter tests 2026-02-26 17:00:19 -05:00
teernisse
7059dea3f8 test(skills): add comprehensive SkillsMixin tests 2026-02-26 16:59:34 -05:00
teernisse
e4a0631fd7 feat(hook): add spawn_id correlation to amc-hook
Read AMC_SPAWN_ID env var and include spawn_id in session JSON when
present. This enables deterministic spawn correlation: the server
generates a UUID, passes it via env to the spawned agent, and then
polls for a session file containing that specific spawn_id.

Closes: bd-1zy
2026-02-26 16:58:20 -05:00
teernisse
1bece476a4 feat(server): add spawn constants to context.py 2026-02-26 16:58:02 -05:00
teernisse
e99ae2ed89 feat(dashboard): add spawn API constants 2026-02-26 16:57:56 -05:00
teernisse
49a57b5364 feat(dashboard): scroll selected autocomplete item into view
Closes bd-4lc. When navigating with arrow keys, the selected item now
scrolls into view using scrollIntoView({ block: 'nearest' }) to prevent
jarring scrolls when item is already visible.
2026-02-26 16:55:34 -05:00
teernisse
db3d2a2e31 feat(dashboard): add click-outside dismissal for autocomplete dropdown
Closes bd-3ny. Added mousedown listener that dismisses the dropdown when
clicking outside both the dropdown and textarea. Uses early return to avoid
registering listeners when dropdown is already closed.
2026-02-26 16:54:40 -05:00
teernisse
ba16daac2a feat(dashboard): complete autocomplete UI with dropdown and keyboard navigation
- Add triggerInfo state and filteredSkills useMemo
- Add showAutocomplete, selectedIndex state management
- Implement insertSkill callback for skill insertion
- Add full keyboard navigation (ArrowUp/Down, Enter/Tab, Escape)
- Wrap textarea in relative container for dropdown positioning
- Add autocomplete dropdown UI with empty states and mouse interaction

Closes: bd-29o, bd-1y3, bd-3vd, bd-2uj, bd-3us, bd-253

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 16:52:36 -05:00
teernisse
c7db46191c feat(dashboard): add skill autocomplete server-side enumeration and client wiring
- Add SkillsMixin with _enumerate_claude_skills and _enumerate_codex_skills
- Claude: reads ~/.claude/skills/, parses YAML frontmatter for descriptions
- Codex: reads curated cache + ~/.codex/skills/ user directory
- Add /api/skills?agent= endpoint to HttpMixin
- Add fetchSkills() API helper in dashboard
- Wire autocomplete config through Modal -> SessionCard -> SimpleInput
- Add getTriggerInfo() for detecting trigger at valid positions

Closes: bd-3q1, bd-sv1, bd-3eu, bd-g9t, bd-30p, bd-1ba, bd-2n7, bd-3s3

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 16:48:23 -05:00
teernisse
2926645b10 docs: add implementation plans for upcoming features
Planning documents for future AMC features:

PLAN-slash-autocomplete.md:
- Slash-command autocomplete for SimpleInput
- Skills API endpoint, SlashMenu dropdown, keyboard navigation
- 8 implementation steps with file locations and dependencies

plans/agent-spawning.md:
- Agent spawning acceptance criteria documentation
- Spawn command integration, status tracking, error handling
- Written as testable acceptance criteria (AC-1 through AC-10)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 15:25:09 -05:00
teernisse
6e566cfe82 chore: add .gitignore for bv cache directory
Ignore .bv/ directory which contains local cache and configuration
for the bv (beads viewer) graph-aware triage tool.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 15:25:02 -05:00
teernisse
117784f8ef chore: initialize beads project tracking
Add .beads/ directory for dependency-aware issue tracking with the
beads (br) CLI. This provides lightweight task management for the
AMC project.

Files:
- config.yaml: Project configuration (prefix, priorities, types)
- issues.jsonl: Issue database in append-only JSONL format
- metadata.json: Project metadata and statistics
- .gitignore: Ignore database files, locks, and temporaries

Current issues track slash-command autocomplete feature planning
(fetchSkills API, SlashMenu component, hook integration, etc.)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 15:24:57 -05:00
teernisse
de994bb837 feat(dashboard): add markdown preview for AskUserQuestion options
Implement side-by-side preview layout when AskUserQuestion options
include markdown content, matching the Claude Code tool spec.

Hook changes (bin/amc-hook):
- Extract optional 'markdown' field from question options
- Only include when present (maintains backward compatibility)

QuestionBlock layout:
- Detect hasMarkdownPreviews via options.some(opt => opt.markdown)
- Standard layout: vertical option list (unchanged)
- Preview layout: 40% options left, 60% preview pane right
- Fixed 400px preview height prevents layout thrashing on hover
- Track previewIndex state, update on mouseEnter/focus

Content rendering (smart detection):
- Code fences (starts with ```): renderContent() for syntax highlighting
- Everything else: raw <pre> to preserve ASCII diagrams exactly
- "No preview for this option" when hovered option lacks markdown

OptionButton enhancements:
- Accept selected, onMouseEnter, onFocus props
- Visual selected state: brighter border/bg with subtle shadow
- Compact padding (py-2 vs py-3.5) for preview layout density
- Graceful undefined handling for standard layout usage

Bug fixes during development:
- Layout thrashing: Fixed height (not max-height) on preview pane
- ASCII corruption: Detect code fences vs plain text for render path
- Horizontal overflow: Use overflow-auto (not just overflow-y-auto)
- Data pipeline: Hook was stripping markdown field (root cause)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 15:24:35 -05:00
teernisse
b9c1bd6ff1 feat(dashboard): improve real-time updates and scroll behavior
Major UX improvements to conversation display and state management.

Scroll behavior (SessionCard.js):
- Replace scroll-position tracking with wheel-event intent detection
- Accumulate scroll-up distance before disabling sticky mode (50px threshold)
- Re-enable sticky on scroll-down when near bottom (100px threshold)
- Always scroll to bottom on first load or user's own message submission
- Use requestAnimationFrame for smooth scroll positioning

Optimistic updates (App.js):
- Immediately show user messages before API confirmation
- Remove optimistic message on send failure
- Eliminates perceived latency when sending responses

Error tracking integration (App.js):
- Wire up trackError/clearErrorCount for API operations
- Track: state fetch, conversation fetch, respond, dismiss, SSE init/parse
- Clear error counts on successful operations
- Clear SSE event cache on reconnect to force refresh

Conversation management (App.js):
- Use mtime_ns (preferred) or last_event_at for change detection
- Clean up conversation cache when sessions are dismissed
- Add modalSessionRef for stable reference across renders

Message stability (ChatMessages.js):
- Prefer server-assigned message IDs for React keys
- Fallback to role+timestamp+index for legacy messages

Input UX (SimpleInput.js):
- Auto-refocus textarea after successful submission
- Use setTimeout to ensure React has re-rendered first

Sorting simplification (status.js):
- Remove status-based group/session reordering
- Return groups in API order (server handles sorting)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 15:24:21 -05:00
teernisse
3dc10aa060 feat(dashboard): add toast notification system with error tracking
Add lightweight toast notification infrastructure that surfaces
repeated errors to users while avoiding alert fatigue.

Components:
- ToastContainer: Renders toast notifications with auto-dismiss
- showToast(): Singleton function to display messages from anywhere
- trackError(): Counts errors by key, surfaces toast after threshold

Error tracking strategy:
- Errors logged immediately (console.error)
- Toast shown only after 3+ occurrences within 30-second window
- Prevents spam from transient network issues
- Reset counter on success (clearErrorCount)

Toast styling:
- Fixed position bottom-right with z-index 100
- Type-aware colors (error=red, success=green, info=yellow)
- Manual dismiss button with auto-dismiss after 5 seconds
- Backdrop blur and slide-up animation

This prepares the dashboard to gracefully handle API failures
without overwhelming users with error popups for transient issues.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 15:24:06 -05:00
teernisse
0d15787c7a test(server): add unit tests for conversation mtime tracking
Comprehensive test coverage for _get_conversation_mtime() and its
integration with _collect_sessions().

Test cases:
- Claude sessions: file exists, file missing, OSError on stat,
  missing project_dir, missing session_id
- Codex sessions: transcript_path exists, transcript_path missing,
  discovery fallback, discovery returns None, OSError on stat
- Edge cases: unknown agent type, missing agent key
- Integration: mtime included when file exists, omitted when missing
- Change detection: mtime changes trigger payload hash changes

Uses mock patching to isolate file system interactions and test
error handling paths without requiring actual conversation files.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 15:23:55 -05:00
teernisse
dcbaf12f07 feat(server): add conversation mtime tracking for real-time updates
Add conversation_mtime_ns field to session state that tracks the actual
modification time of conversation files. This enables more responsive
dashboard updates by detecting changes that occur between hook events
(e.g., during streaming tool execution).

Changes:
- state.py: Add _get_conversation_mtime() to stat conversation files
  and include mtime_ns in session payloads when available
- conversation.py: Add stable message IDs (claude-{session}-{n} format)
  for React key stability and message deduplication
- control.py: Fix FIFO eviction for dismissed_codex_ids - set.pop()
  removes arbitrary element, now uses dict with insertion-order iteration
- context.py: Update dismissed_codex_ids type from set to dict

The mtime approach complements existing last_event_at tracking:
- last_event_at: Changes on hook events (session boundaries)
- conversation_mtime_ns: Changes on every file write (real-time)

Dashboard can now detect mid-session conversation updates without
waiting for the next hook event.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 15:23:42 -05:00
teernisse
fa1ad4b22b unify card/modal 2026-02-26 11:39:39 -05:00
103 changed files with 15990 additions and 550 deletions

11
.beads/.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
# Database
*.db
*.db-shm
*.db-wal
# Lock files
*.lock
# Temporary
last-touched
*.tmp

4
.beads/config.yaml Normal file
View File

@@ -0,0 +1,4 @@
# Beads Project Configuration
# issue_prefix: bd
# default_priority: 2
# default_type: task

40
.beads/issues.jsonl Normal file

File diff suppressed because one or more lines are too long

4
.beads/metadata.json Normal file
View File

@@ -0,0 +1,4 @@
{
"database": "beads.db",
"jsonl_export": "issues.jsonl"
}

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# bv (beads viewer) local config and caches
.bv/

View File

@@ -0,0 +1 @@
[ 94144ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://127.0.0.1:7400/api/spawn:0

View File

@@ -0,0 +1 @@
[ 398ms] [WARNING] cdn.tailwindcss.com should not be used in production. To use Tailwind CSS in production, install it as a PostCSS plugin or use the Tailwind CLI: https://tailwindcss.com/docs/installation @ https://cdn.tailwindcss.com/:63

68
CLAUDE.md Normal file
View File

@@ -0,0 +1,68 @@
# AMC (Agent Management Console)
A dashboard and management system for monitoring and controlling Claude Code and Codex agent sessions.
## Key Documentation
### Claude JSONL Session Log Reference
**Location:** `docs/claude-jsonl-reference/`
Comprehensive documentation for parsing and processing Claude Code JSONL session logs. **Always consult this before implementing JSONL parsing logic.**
| Document | Purpose |
|----------|---------|
| [01-format-specification](docs/claude-jsonl-reference/01-format-specification.md) | Complete JSONL format spec with all fields |
| [02-message-types](docs/claude-jsonl-reference/02-message-types.md) | Every message type with concrete examples |
| [03-tool-lifecycle](docs/claude-jsonl-reference/03-tool-lifecycle.md) | Tool call flow from invocation to result |
| [04-subagent-teams](docs/claude-jsonl-reference/04-subagent-teams.md) | Subagent and team message formats |
| [05-edge-cases](docs/claude-jsonl-reference/05-edge-cases.md) | Error handling, malformed input, recovery |
| [06-quick-reference](docs/claude-jsonl-reference/06-quick-reference.md) | Cheat sheet for common operations |
## Architecture
### Server (Python)
The server uses a mixin-based architecture in `amc_server/`:
| Module | Purpose |
|--------|---------|
| `server.py` | Main AMC server class combining all mixins |
| `mixins/parsing.py` | JSONL reading and token extraction |
| `mixins/conversation.py` | Claude/Codex conversation parsing |
| `mixins/state.py` | Session state management |
| `mixins/discovery.py` | Codex session auto-discovery |
| `mixins/spawn.py` | Agent spawning via Zellij |
| `mixins/control.py` | Session control (focus, dismiss) |
| `mixins/skills.py` | Skill enumeration |
| `mixins/http.py` | HTTP routing |
### Dashboard (React)
Single-page app in `dashboard/` served via HTTP.
## File Locations
| Content | Location |
|---------|----------|
| Claude sessions | `~/.claude/projects/<encoded-path>/<session-id>.jsonl` |
| Codex sessions | `~/.codex/sessions/**/<session-id>.jsonl` |
| AMC session state | `~/.local/share/amc/sessions/<session-id>.json` |
| AMC event logs | `~/.local/share/amc/events/<session-id>.jsonl` |
| Pending spawns | `~/.local/share/amc/pending_spawns/<spawn-id>.json` |
## Critical Parsing Notes
1. **Content type ambiguity** — User message `content` can be string (user input) OR array (tool results)
2. **Missing fields** — Always use `.get()` with defaults for optional fields
3. **Boolean vs int** — Python's `isinstance(True, int)` is True; check bool first
4. **Partial reads** — When seeking to file end, first line may be truncated
5. **Codex differences** — Uses `response_item` type, `function_call` for tools
## Testing
```bash
pytest tests/
```
All parsing edge cases are covered in `tests/test_parsing.py` and `tests/test_conversation.py`.

509
PLAN-slash-autocomplete.md Normal file
View File

@@ -0,0 +1,509 @@
# Plan: Skill Autocomplete for Agent Sessions
## Summary
Add autocomplete functionality to the SimpleInput component that displays available skills when the user types the agent-specific trigger character (`/` for Claude, `$` for Codex). Autocomplete triggers at the start of input or after any whitespace, enabling quick skill discovery and selection mid-message.
## User Workflow
1. User opens a session modal or card with the input field
2. User types the trigger character (`/` for Claude, `$` for Codex):
- At position 0, OR
- After a space/newline (mid-message)
3. Autocomplete dropdown appears showing available skills (alphabetically sorted)
4. User can:
- Continue typing to filter the list
- Use arrow keys to navigate
- Press Enter/Tab to select and insert the skill name
- Press Escape or click outside to dismiss
5. Selected skill replaces the trigger with `{trigger}skill-name ` (e.g., `/commit ` or `$yeet `)
## Acceptance Criteria
### Core Functionality
- **AC-1**: Autocomplete triggers when trigger character is typed at position 0 or after whitespace
- **AC-2**: Claude sessions use `/` trigger; Codex sessions use `$` trigger
- **AC-3**: Wrong trigger character for agent type is ignored (no autocomplete)
- **AC-4**: Dropdown displays skill names with trigger prefix and descriptions
- **AC-5**: Skills are sorted alphabetically by name
- **AC-6**: Typing additional characters filters the skill list (case-insensitive match on name)
- **AC-7**: Arrow up/down navigates the highlighted option
- **AC-8**: Enter or Tab inserts the selected skill name (with trigger) followed by a space
- **AC-9**: Escape, clicking outside, or backspacing over the trigger character dismisses the dropdown without insertion
- **AC-10**: Cursor movement (arrow left/right) is ignored while autocomplete is open; dropdown position is locked to trigger location
- **AC-11**: If no skills match the filter, dropdown shows "No matching skills"
### Data Flow
- **AC-12**: On session open, an agent-specific config is loaded containing: (a) trigger character (`/` for Claude, `$` for Codex), (b) enumerated skills list
- **AC-13**: Claude skills are enumerated from `~/.claude/skills/`
- **AC-14**: Codex skills are loaded from `~/.codex/vendor_imports/skills-curated-cache.json` plus `~/.codex/skills/`
- **AC-15**: If session has no skills, dropdown shows "No skills available" when trigger is typed
### UX Polish
- **AC-16**: Dropdown positions above the input (bottom-anchored), aligned left
- **AC-17**: Dropdown has max height with vertical scroll for long lists
- **AC-18**: Currently highlighted item is visually distinct
- **AC-19**: Dropdown respects the existing color scheme
- **AC-20**: After skill insertion, cursor is positioned after the trailing space, ready to continue typing
### Known Limitations (Out of Scope)
- **Duplicate skill names**: If curated and user skills share a name, both appear (no deduplication)
- **Long skill names**: No truncation; names may overflow if extremely long
- **Accessibility**: ARIA roles, active-descendant, screen reader support deferred to future iteration
- **IME/composition**: Japanese/Korean input edge cases not handled in v1
- **Server-side caching**: Skills re-enumerated on each request; mtime-based cache could improve performance at scale
## Architecture
### Autocomplete Config Per Agent
Each session gets an autocomplete config loaded at modal open:
```typescript
type AutocompleteConfig = {
trigger: '/' | '$';
skills: Array<{ name: string; description: string }>;
}
```
| Agent | Trigger | Skill Sources |
|-------|---------|---------------|
| Claude | `/` | Enumerate `~/.claude/skills/*/` |
| Codex | `$` | `~/.codex/vendor_imports/skills-curated-cache.json` + `~/.codex/skills/*/` |
### Server-Side: New Endpoint for Skills
**Endpoint**: `GET /api/skills?agent={claude|codex}`
**Response**:
```json
{
"trigger": "/",
"skills": [
{ "name": "commit", "description": "Create a git commit with a message" },
{ "name": "review-pr", "description": "Review a pull request" }
]
}
```
**Rationale**: Skills are agent-global, not session-specific. The client already knows `session.agent` from state, so no session_id is needed. Server enumerates skill directories directly.
### Component Structure
```
SimpleInput.js
├── Props: sessionId, status, onRespond, agent, autocompleteConfig
├── State: text, focused, sending, error
├── State: showAutocomplete, selectedIndex
├── Derived: triggerMatch (detects trigger at valid position)
├── Derived: filterText, filteredSkills (alphabetically sorted)
├── onInput: detect trigger character at pos 0 or after whitespace
├── onKeyDown: arrow/enter/escape handling for autocomplete
└── Render: textarea + autocomplete dropdown
```
### Data Flow
```
┌─────────────────┐ ┌──────────────────────┐ ┌─────────────────┐
│ Modal opens │────▶│ GET /api/skills?agent│────▶│ SimpleInput │
│ (session) │ │ (server) │ │ (dropdown) │
└─────────────────┘ └──────────────────────┘ └─────────────────┘
```
Skills are agent-global, so the same response can be cached client-side per agent type.
## Implementation Specifications
### IMP-1: Server-side skill enumeration (fulfills AC-12, AC-13, AC-14, AC-15)
**Location**: `amc_server/mixins/skills.py` (new file)
```python
class SkillsMixin:
def _serve_skills(self, agent):
"""Return autocomplete config for a session."""
if agent == "codex":
trigger = "$"
skills = self._enumerate_codex_skills()
else: # claude
trigger = "/"
skills = self._enumerate_claude_skills()
# Sort alphabetically
skills.sort(key=lambda s: s["name"].lower())
self._send_json(200, {"trigger": trigger, "skills": skills})
def _enumerate_codex_skills(self):
"""Load Codex skills from cache + user directory."""
skills = []
# Curated skills from cache
cache_file = Path.home() / ".codex/vendor_imports/skills-curated-cache.json"
if cache_file.exists():
try:
data = json.loads(cache_file.read_text())
for skill in data.get("skills", []):
skills.append({
"name": skill.get("id", skill.get("name", "")),
"description": skill.get("shortDescription", skill.get("description", ""))[:100]
})
except (json.JSONDecodeError, OSError):
pass
# User-installed skills
user_skills_dir = Path.home() / ".codex/skills"
if user_skills_dir.exists():
for skill_dir in user_skills_dir.iterdir():
if skill_dir.is_dir() and not skill_dir.name.startswith("."):
skill_md = skill_dir / "SKILL.md"
description = ""
if skill_md.exists():
# Parse first non-empty line as description
try:
for line in skill_md.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#"):
description = line[:100]
break
except OSError:
pass
skills.append({
"name": skill_dir.name,
"description": description or f"User skill: {skill_dir.name}"
})
return skills
def _enumerate_claude_skills(self):
"""Load Claude skills from user directory.
Note: Checks SKILL.md first (canonical casing used by Claude Code),
then falls back to lowercase variants for compatibility.
"""
skills = []
skills_dir = Path.home() / ".claude/skills"
if skills_dir.exists():
for skill_dir in skills_dir.iterdir():
if skill_dir.is_dir() and not skill_dir.name.startswith("."):
# Look for SKILL.md (canonical), then fallbacks
description = ""
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()
# Find first meaningful line
for line in content.splitlines():
line = line.strip()
if line and not line.startswith("#") and not line.startswith("<!--"):
description = line[:100]
break
if description:
break
except OSError:
pass
skills.append({
"name": skill_dir.name,
"description": description or f"Skill: {skill_dir.name}"
})
return skills
```
### IMP-2: Add skills endpoint to HttpMixin (fulfills AC-12)
**Location**: `amc_server/mixins/http.py`
```python
# In HttpMixin.do_GET, add route handling:
elif path == "/api/skills":
agent = query_params.get("agent", ["claude"])[0]
self._serve_skills(agent)
```
**Note**: Route goes in `HttpMixin.do_GET` (where all GET routing lives), not `handler.py`. The handler just composes mixins.
### IMP-3: Client-side API call (fulfills AC-12)
**Location**: `dashboard/utils/api.js`
```javascript
export const API_SKILLS = '/api/skills';
export async function fetchSkills(agent) {
const url = `${API_SKILLS}?agent=${encodeURIComponent(agent)}`;
const response = await fetch(url);
if (!response.ok) return null;
return response.json();
}
```
### IMP-4: Autocomplete config loading in Modal (fulfills AC-12)
**Location**: `dashboard/components/Modal.js`
```javascript
const [autocompleteConfig, setAutocompleteConfig] = useState(null);
// Load skills when agent type changes
useEffect(() => {
if (!session) {
setAutocompleteConfig(null);
return;
}
const agent = session.agent || 'claude';
fetchSkills(agent)
.then(config => setAutocompleteConfig(config))
.catch(() => setAutocompleteConfig(null));
}, [session?.agent]);
// Pass to SimpleInput
<${SimpleInput}
...
autocompleteConfig=${autocompleteConfig}
/>
```
### IMP-5: Trigger detection logic (fulfills AC-1, AC-2, AC-3)
**Location**: `dashboard/components/SimpleInput.js`
```javascript
// Detect if we should show autocomplete
const getTriggerInfo = useCallback((value, cursorPos) => {
if (!autocompleteConfig) return null;
const { trigger } = autocompleteConfig;
// Find the start of the current "word" (after last whitespace before cursor)
let wordStart = cursorPos;
while (wordStart > 0 && !/\s/.test(value[wordStart - 1])) {
wordStart--;
}
// Check if word starts with trigger
if (value[wordStart] === trigger) {
return {
trigger,
filterText: value.slice(wordStart + 1, cursorPos).toLowerCase(),
replaceStart: wordStart,
replaceEnd: cursorPos
};
}
return null;
}, [autocompleteConfig]);
```
### IMP-6: Filtered and sorted skills (fulfills AC-5, AC-6)
**Location**: `dashboard/components/SimpleInput.js`
```javascript
const filteredSkills = useMemo(() => {
if (!autocompleteConfig || !triggerInfo) return [];
const { skills } = autocompleteConfig;
const { filterText } = triggerInfo;
let filtered = skills;
if (filterText) {
filtered = skills.filter(s =>
s.name.toLowerCase().includes(filterText)
);
}
// Already sorted by server, but ensure alphabetical
return filtered.sort((a, b) => a.name.localeCompare(b.name));
}, [autocompleteConfig, triggerInfo]);
```
### IMP-7: Keyboard navigation (fulfills AC-7, AC-8, AC-9)
**Location**: `dashboard/components/SimpleInput.js`
**Note**: Enter with empty filter dismisses dropdown (doesn't submit message). This prevents accidental submissions when user types a partial match that has no results.
```javascript
onKeyDown=${(e) => {
if (showAutocomplete) {
// Always handle Escape when dropdown is open
if (e.key === 'Escape') {
e.preventDefault();
setShowAutocomplete(false);
return;
}
// Handle Enter/Tab: select if matches exist, otherwise dismiss (don't submit)
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
if (filteredSkills.length > 0 && filteredSkills[selectedIndex]) {
insertSkill(filteredSkills[selectedIndex]);
} else {
setShowAutocomplete(false);
}
return;
}
// Arrow navigation only when there are matches
if (filteredSkills.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(i => Math.min(i + 1, filteredSkills.length - 1));
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(i => Math.max(i - 1, 0));
return;
}
}
}
// Existing Enter-to-submit logic (only when dropdown is closed)
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
```
### IMP-8: Skill insertion (fulfills AC-8)
**Location**: `dashboard/components/SimpleInput.js`
```javascript
const insertSkill = useCallback((skill) => {
if (!triggerInfo || !autocompleteConfig) return;
const { trigger } = autocompleteConfig;
const { replaceStart, replaceEnd } = triggerInfo;
const before = text.slice(0, replaceStart);
const after = text.slice(replaceEnd);
const inserted = `${trigger}${skill.name} `;
setText(before + inserted + after);
setShowAutocomplete(false);
// Move cursor after inserted text
const newCursorPos = replaceStart + inserted.length;
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart = newCursorPos;
textareaRef.current.selectionEnd = newCursorPos;
textareaRef.current.focus();
}
}, 0);
}, [text, triggerInfo, autocompleteConfig]);
```
### IMP-9: Autocomplete dropdown UI (fulfills AC-4, AC-10, AC-15, AC-16, AC-17, AC-18)
**Location**: `dashboard/components/SimpleInput.js`
**Note**: Uses index as `key` instead of `skill.name` to handle potential duplicate skill names (curated + user skills with same name).
```javascript
${showAutocomplete && html`
<div
ref=${autocompleteRef}
class="absolute left-0 bottom-full mb-1 w-full max-h-48 overflow-y-auto rounded-lg border border-selection/75 bg-surface shadow-lg z-50"
>
${filteredSkills.length === 0 ? html`
<div class="px-3 py-2 text-sm text-dim">No matching skills</div>
` : filteredSkills.map((skill, i) => html`
<div
key=${i}
class="px-3 py-2 cursor-pointer text-sm transition-colors ${
i === selectedIndex
? 'bg-selection/50 text-bright'
: 'text-fg hover:bg-selection/25'
}"
onClick=${() => insertSkill(skill)}
onMouseEnter=${() => setSelectedIndex(i)}
>
<div class="font-medium font-mono text-bright">
${autocompleteConfig.trigger}${skill.name}
</div>
<div class="text-micro text-dim truncate">${skill.description}</div>
</div>
`)}
</div>
`}
```
### IMP-10: Click-outside dismissal (fulfills AC-9)
**Location**: `dashboard/components/SimpleInput.js`
```javascript
useEffect(() => {
if (!showAutocomplete) return;
const handleClickOutside = (e) => {
if (autocompleteRef.current && !autocompleteRef.current.contains(e.target) &&
textareaRef.current && !textareaRef.current.contains(e.target)) {
setShowAutocomplete(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showAutocomplete]);
```
## Testing Considerations
### Manual Testing Checklist
1. Claude session: Type `/` - dropdown appears with Claude skills
2. Codex session: Type `$` - dropdown appears with Codex skills
3. Claude session: Type `$` - nothing happens (wrong trigger)
4. Type `/com` - list filters to skills containing "com"
5. Mid-message: Type "please run /commit" - autocomplete triggers on `/`
6. Arrow keys navigate, Enter selects
7. Escape dismisses without selection
8. Click outside dismisses
9. Selected skill shows as `{trigger}skill-name ` in input
10. Verify alphabetical ordering
11. Verify vertical scroll with many skills
### Edge Cases
- Session without skills (dropdown doesn't appear)
- Single skill (still shows dropdown)
- Very long skill descriptions (truncated with ellipsis)
- Multiple triggers in one message (each can trigger independently)
- Backspace over trigger (dismisses autocomplete)
## Rollout Slices
### Slice 1: Server-side skill enumeration
- Add `SkillsMixin` with `_enumerate_codex_skills()` and `_enumerate_claude_skills()`
- Add `/api/skills?agent=` endpoint in `HttpMixin.do_GET`
- Test endpoint returns correct data for each agent type
### Slice 2: Client-side skill loading
- Add `fetchSkills()` API helper
- Load skills in Modal.js on session open
- Pass `autocompleteConfig` to SimpleInput
### Slice 3: Basic autocomplete trigger
- Add trigger detection logic (position 0 + after whitespace)
- Show/hide dropdown based on trigger
- Basic filtered list display
### Slice 4: Keyboard navigation + selection
- Arrow key navigation
- Enter/Tab selection
- Escape dismissal
- Click-outside dismissal
### Slice 5: Polish
- Mouse hover to select
- Scroll into view for long lists
- Cursor positioning after insertion

View 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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

28
amc_server/agents.py Normal file
View 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
View 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
View 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()

View File

@@ -1,69 +0,0 @@
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)
_dismissed_codex_ids = set()
_DISMISSED_MAX = 500
# Serialize state collection because it mutates session files/caches.
_state_lock = threading.Lock()

View File

@@ -5,6 +5,8 @@ from amc_server.mixins.control import SessionControlMixin
from amc_server.mixins.discovery import SessionDiscoveryMixin
from amc_server.mixins.http import HttpMixin
from amc_server.mixins.parsing import SessionParsingMixin
from amc_server.mixins.skills import SkillsMixin
from amc_server.mixins.spawn import SpawnMixin
from amc_server.mixins.state import StateMixin
@@ -15,6 +17,8 @@ class AMCHandler(
SessionControlMixin,
SessionDiscoveryMixin,
SessionParsingMixin,
SkillsMixin,
SpawnMixin,
BaseHTTPRequestHandler,
):
"""HTTP handler composed from focused mixins."""

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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
@@ -16,13 +18,47 @@ class SessionControlMixin:
safe_id = os.path.basename(session_id)
session_file = SESSIONS_DIR / f"{safe_id}.json"
# Track dismissed Codex sessions to prevent re-discovery
# Evict oldest entries if set is full (prevents unbounded growth)
# Evict oldest entries via FIFO (dict maintains insertion order in Python 3.7+)
while len(_dismissed_codex_ids) >= _DISMISSED_MAX:
_dismissed_codex_ids.pop()
_dismissed_codex_ids.add(safe_id)
oldest_key = next(iter(_dismissed_codex_ids))
del _dismissed_codex_ids[oldest_key]
_dismissed_codex_ids[safe_id] = True
session_file.unlink(missing_ok=True)
self._send_json(200, {"ok": True})
def _dismiss_dead_sessions(self):
"""Delete all dead session files (clear all from dashboard).
Note: is_dead is computed dynamically, not stored on disk, so we must
recompute it here using the same logic as _collect_sessions.
"""
# Get liveness data (same as _collect_sessions)
active_zellij_sessions = self._get_active_zellij_sessions()
active_transcript_files = self._get_active_transcript_files()
dismissed_count = 0
for f in SESSIONS_DIR.glob("*.json"):
try:
data = json.loads(f.read_text())
if not isinstance(data, dict):
continue
# Recompute is_dead (it's not persisted to disk)
is_dead = self._is_session_dead(
data, active_zellij_sessions, active_transcript_files
)
if is_dead:
safe_id = f.stem
# Track dismissed Codex sessions
while len(_dismissed_codex_ids) >= _DISMISSED_MAX:
oldest_key = next(iter(_dismissed_codex_ids))
del _dismissed_codex_ids[oldest_key]
_dismissed_codex_ids[safe_id] = True
f.unlink(missing_ok=True)
dismissed_count += 1
except (json.JSONDecodeError, OSError):
continue
self._send_json(200, {"ok": True, "dismissed": dismissed_count})
def _respond_to_session(self, session_id):
"""Inject a response into the session's Zellij pane."""
safe_id = os.path.basename(session_id)

View File

@@ -1,7 +1,22 @@
import json
import os
from amc_server.context import EVENTS_DIR
from amc_server.config import EVENTS_DIR
# Prefixes for system-injected content that appears as user messages
# but was not typed by the human (hook outputs, system reminders, etc.)
_SYSTEM_INJECTED_PREFIXES = (
"<system-reminder>",
"<local-command-caveat>",
"<available-deferred-tools>",
"<teammate-message",
)
def _is_system_injected(content):
"""Return True if user message content is system-injected, not human-typed."""
stripped = content.lstrip()
return stripped.startswith(_SYSTEM_INJECTED_PREFIXES)
class ConversationMixin:
@@ -39,6 +54,7 @@ class ConversationMixin:
def _parse_claude_conversation(self, session_id, project_dir):
"""Parse Claude Code JSONL conversation format."""
messages = []
msg_id = 0
conv_file = self._get_claude_conversation_file(session_id, project_dir)
@@ -56,12 +72,14 @@ class ConversationMixin:
if msg_type == "user":
content = entry.get("message", {}).get("content", "")
# Only include actual human messages (strings), not tool results (arrays)
if content and isinstance(content, str):
if content and isinstance(content, str) and not _is_system_injected(content):
messages.append({
"id": f"claude-{session_id[:8]}-{msg_id}",
"role": "user",
"content": content,
"timestamp": entry.get("timestamp", ""),
})
msg_id += 1
elif msg_type == "assistant":
# Assistant messages have structured content
@@ -90,6 +108,7 @@ class ConversationMixin:
text_parts.append(part)
if text_parts or tool_calls or thinking_parts:
msg = {
"id": f"claude-{session_id[:8]}-{msg_id}",
"role": "assistant",
"content": "\n".join(text_parts) if text_parts else "",
"timestamp": entry.get("timestamp", ""),
@@ -99,6 +118,7 @@ class ConversationMixin:
if thinking_parts:
msg["thinking"] = "\n\n".join(thinking_parts)
messages.append(msg)
msg_id += 1
except json.JSONDecodeError:
continue
@@ -117,6 +137,7 @@ class ConversationMixin:
"""
messages = []
pending_tool_calls = [] # Accumulate tool calls to attach to next assistant message
msg_id = 0
conv_file = self._find_codex_transcript_file(session_id)
@@ -161,19 +182,23 @@ class ConversationMixin:
# Flush any pending tool calls first
if pending_tool_calls:
messages.append({
"id": f"codex-{session_id[:8]}-{msg_id}",
"role": "assistant",
"content": "",
"tool_calls": pending_tool_calls,
"timestamp": timestamp,
})
msg_id += 1
pending_tool_calls = []
# Add thinking as assistant message
messages.append({
"id": f"codex-{session_id[:8]}-{msg_id}",
"role": "assistant",
"content": "",
"thinking": "\n".join(thinking_text),
"timestamp": timestamp,
})
msg_id += 1
continue
# Handle message (user/assistant text)
@@ -208,19 +233,24 @@ class ConversationMixin:
# Flush any pending tool calls before user message
if pending_tool_calls:
messages.append({
"id": f"codex-{session_id[:8]}-{msg_id}",
"role": "assistant",
"content": "",
"tool_calls": pending_tool_calls,
"timestamp": timestamp,
})
msg_id += 1
pending_tool_calls = []
messages.append({
"id": f"codex-{session_id[:8]}-{msg_id}",
"role": "user",
"content": "\n".join(text_parts),
"timestamp": timestamp,
})
msg_id += 1
elif role == "assistant":
msg = {
"id": f"codex-{session_id[:8]}-{msg_id}",
"role": "assistant",
"content": "\n".join(text_parts) if text_parts else "",
"timestamp": timestamp,
@@ -231,6 +261,7 @@ class ConversationMixin:
pending_tool_calls = []
if text_parts or msg.get("tool_calls"):
messages.append(msg)
msg_id += 1
except json.JSONDecodeError:
continue
@@ -238,6 +269,7 @@ class ConversationMixin:
# Flush any remaining pending tool calls
if pending_tool_calls:
messages.append({
"id": f"codex-{session_id[:8]}-{msg_id}",
"role": "assistant",
"content": "",
"tool_calls": pending_tool_calls,

View File

@@ -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"):

View File

@@ -1,7 +1,8 @@
import json
import urllib.parse
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
@@ -62,6 +63,19 @@ class HttpMixin:
project_dir = ""
agent = "claude"
self._serve_conversation(urllib.parse.unquote(session_id), urllib.parse.unquote(project_dir), agent)
elif self.path == "/api/skills" or self.path.startswith("/api/skills?"):
# Parse agent from query params, default to claude
if "?" in self.path:
query = self.path.split("?", 1)[1]
params = urllib.parse.parse_qs(query)
agent = params.get("agent", ["claude"])[0]
else:
agent = "claude"
self._serve_skills(agent)
elif self.path == "/api/projects":
self._handle_projects()
elif self.path == "/api/health":
self._handle_health()
else:
self._json_error(404, "Not Found")
except Exception:
@@ -73,12 +87,18 @@ class HttpMixin:
def do_POST(self):
try:
if self.path.startswith("/api/dismiss/"):
if self.path == "/api/dismiss-dead":
self._dismiss_dead_sessions()
elif self.path.startswith("/api/dismiss/"):
session_id = urllib.parse.unquote(self.path[len("/api/dismiss/"):])
self._dismiss_session(session_id)
elif self.path.startswith("/api/respond/"):
session_id = urllib.parse.unquote(self.path[len("/api/respond/"):])
self._respond_to_session(session_id)
elif self.path == "/api/spawn":
self._handle_spawn()
elif self.path == "/api/projects/refresh":
self._handle_projects_refresh()
else:
self._json_error(404, "Not Found")
except Exception:
@@ -89,11 +109,12 @@ class HttpMixin:
pass
def do_OPTIONS(self):
# CORS preflight for respond endpoint
# CORS preflight for API endpoints (AC-39: wildcard CORS;
# localhost-only binding AC-24 is the real security boundary)
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
self.end_headers()
def _serve_dashboard_file(self, file_path):
@@ -113,7 +134,12 @@ class HttpMixin:
full_path = DASHBOARD_DIR / file_path
# Security: ensure path doesn't escape dashboard directory
full_path = full_path.resolve()
if not str(full_path).startswith(str(DASHBOARD_DIR.resolve())):
resolved_dashboard = DASHBOARD_DIR.resolve()
try:
# Use relative_to for robust path containment check
# (avoids startswith prefix-match bugs like "/dashboard" vs "/dashboardEVIL")
full_path.relative_to(resolved_dashboard)
except ValueError:
self._json_error(403, "Forbidden")
return
@@ -121,6 +147,13 @@ class HttpMixin:
ext = full_path.suffix.lower()
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 auth._auth_token:
content = content.replace(
b"<!-- AMC_AUTH_TOKEN -->",
f'<script>window.AMC_AUTH_TOKEN = "{auth._auth_token}";</script>'.encode(),
)
# No caching during development
self._send_bytes_response(
200,

View File

@@ -2,9 +2,10 @@ 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,
_CONTEXT_CACHE_MAX,
_codex_transcript_cache,
_context_usage_cache,
@@ -44,6 +45,11 @@ class SessionParsingMixin:
try:
for jsonl_file in CODEX_SESSIONS_DIR.rglob(f"*{session_id}*.jsonl"):
# Evict old entries if cache is full (simple FIFO)
if len(_codex_transcript_cache) >= _CODEX_CACHE_MAX:
keys_to_remove = list(_codex_transcript_cache.keys())[: _CODEX_CACHE_MAX // 5]
for k in keys_to_remove:
_codex_transcript_cache.pop(k, None)
_codex_transcript_cache[session_id] = str(jsonl_file)
return jsonl_file
except OSError:

189
amc_server/mixins/skills.py Normal file
View File

@@ -0,0 +1,189 @@
"""SkillsMixin: Enumerate available skills for Claude and Codex agents.
Skills are agent-global (not session-specific), loaded from well-known
filesystem locations for each agent type.
"""
import json
from pathlib import Path
class SkillsMixin:
"""Mixin for enumerating agent skills for autocomplete."""
def _serve_skills(self, agent: str) -> None:
"""Serve autocomplete config for an agent type.
Args:
agent: Agent type ('claude' or 'codex')
Response JSON:
{trigger: '/' or '$', skills: [{name, description}, ...]}
"""
if agent == "codex":
trigger = "$"
skills = self._enumerate_codex_skills()
else: # Default to claude
trigger = "/"
skills = self._enumerate_claude_skills()
# Sort alphabetically by name (case-insensitive)
skills.sort(key=lambda s: s["name"].lower())
self._send_json(200, {"trigger": trigger, "skills": skills})
def _enumerate_claude_skills(self) -> list[dict]:
"""Enumerate Claude skills from ~/.claude/skills/.
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 name and description fields.
Returns:
List of {name: str, description: str} dicts.
Empty list if directory doesn't exist or enumeration fails.
"""
skills = []
skills_dir = Path.home() / ".claude/skills"
if not skills_dir.exists():
return skills
for skill_dir in skills_dir.iterdir():
if not skill_dir.is_dir() or skill_dir.name.startswith("."):
continue
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()
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": meta["name"] or skill_dir.name,
"description": meta["description"] or f"Skill: {skill_dir.name}",
})
return skills
def _parse_frontmatter(self, content: str) -> dict:
"""Extract name and description from markdown YAML frontmatter.
Returns:
Dict with 'name' and 'description' keys (both str, may be empty).
"""
result = {"name": "", "description": ""}
lines = content.splitlines()
if not lines:
return result
# Check for YAML frontmatter
frontmatter_end = 0
if lines[0].strip() == "---":
for i, line in enumerate(lines[1:], start=1):
stripped = line.strip()
if stripped == "---":
frontmatter_end = i + 1
break
# 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 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 result
def _enumerate_codex_skills(self) -> list[dict]:
"""Enumerate Codex skills from cache and user directory.
Sources:
- ~/.codex/vendor_imports/skills-curated-cache.json (curated)
- ~/.codex/skills/*/ (user-installed)
Note: No deduplication — if curated and user skills share a name,
both appear in the list (per plan Known Limitations).
Returns:
List of {name: str, description: str} dicts.
Empty list if no skills found.
"""
skills = []
# 1. Curated skills from cache
cache_file = Path.home() / ".codex/vendor_imports/skills-curated-cache.json"
if cache_file.exists():
try:
data = json.loads(cache_file.read_text())
for skill in data.get("skills", []):
# Use 'id' preferentially, fall back to 'name'
name = skill.get("id") or skill.get("name", "")
# Use 'shortDescription' preferentially, fall back to 'description'
desc = skill.get("shortDescription") or skill.get("description", "")
if name:
skills.append({
"name": name,
"description": desc[:100] if desc else f"Skill: {name}",
})
except (json.JSONDecodeError, OSError):
# Continue without curated skills on parse error
pass
# 2. User-installed skills
user_skills_dir = Path.home() / ".codex/skills"
if user_skills_dir.exists():
for skill_dir in user_skills_dir.iterdir():
if not skill_dir.is_dir() or skill_dir.name.startswith("."):
continue
meta = {"name": "", "description": ""}
# Check SKILL.md for metadata
skill_md = skill_dir / "SKILL.md"
if skill_md.exists():
try:
content = skill_md.read_text()
meta = self._parse_frontmatter(content)
except OSError:
pass
skills.append({
"name": meta["name"] or skill_dir.name,
"description": meta["description"] or f"User skill: {skill_dir.name}",
})
return skills

360
amc_server/mixins/spawn.py Normal file
View File

@@ -0,0 +1,360 @@
import json
import os
import re
import subprocess
import time
import uuid
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,
)
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'],
'codex': ['codex', '--dangerously-bypass-approvals-and-sandbox'],
}
# Module-level cache for projects list (AC-33)
_projects_cache: list[str] = []
# Characters unsafe for Zellij pane/tab names: control chars, quotes, backticks
_UNSAFE_PANE_CHARS = re.compile(r'[\x00-\x1f\x7f"\'`]')
def _sanitize_pane_name(name):
"""Sanitize a string for use as a Zellij pane name.
Replaces control characters and quotes with underscores, collapses runs
of whitespace into a single space, and truncates to 64 chars.
"""
name = _UNSAFE_PANE_CHARS.sub('_', name)
name = re.sub(r'\s+', ' ', name).strip()
return name[:64] if name else 'unnamed'
def load_projects_cache():
"""Scan ~/projects/ and cache the list. Called on server start."""
global _projects_cache
try:
projects = []
for entry in PROJECTS_DIR.iterdir():
if entry.is_dir() and not entry.name.startswith('.'):
projects.append(entry.name)
projects.sort()
_projects_cache = projects
except OSError:
_projects_cache = []
class SpawnMixin:
def _handle_spawn(self):
"""POST /api/spawn handler."""
# Verify auth token (AC-38)
auth_header = self.headers.get('Authorization', '')
if not validate_auth_token(auth_header):
self._send_json(401, {'ok': False, 'error': 'Unauthorized', 'code': 'UNAUTHORIZED'})
return
# Parse JSON body
try:
content_length = int(self.headers.get('Content-Length', 0))
body = json.loads(self.rfile.read(content_length))
if not isinstance(body, dict):
self._json_error(400, 'Invalid JSON body')
return
except (json.JSONDecodeError, ValueError):
self._json_error(400, 'Invalid JSON body')
return
project = body.get('project', '')
agent_type = body.get('agent_type', '')
# Validate params (returns resolved_path to avoid TOCTOU)
validation = self._validate_spawn_params(project, agent_type)
if 'error' in validation:
self._send_json(400, {
'ok': False,
'error': validation['error'],
'code': validation['code'],
})
return
resolved_path = validation['resolved_path']
spawn_id = str(uuid.uuid4())
# Acquire _spawn_lock with 15s timeout
acquired = _spawn_lock.acquire(timeout=15)
if not acquired:
self._send_json(503, {
'ok': False,
'error': 'Server busy - another spawn in progress',
'code': 'SERVER_BUSY',
})
return
try:
# Check rate limit inside lock
# Use None sentinel to distinguish "never spawned" from "spawned at time 0"
# (time.monotonic() can be close to 0 on fresh process start)
now = time.monotonic()
last_spawn = _spawn_timestamps.get(project)
if last_spawn is not None and now - last_spawn < SPAWN_COOLDOWN_SEC:
remaining = SPAWN_COOLDOWN_SEC - (now - last_spawn)
self._send_json(429, {
'ok': False,
'error': f'Rate limited - wait {remaining:.0f}s before spawning in {project}',
'code': 'RATE_LIMITED',
})
return
# Execute spawn
result = self._spawn_agent_in_project_tab(
project, resolved_path, agent_type, spawn_id,
)
# Update timestamp only on success
if result.get('ok'):
_spawn_timestamps[project] = time.monotonic()
status_code = 200 if result.get('ok') else 500
result['spawn_id'] = spawn_id
self._send_json(status_code, result)
finally:
_spawn_lock.release()
def _validate_spawn_params(self, project, agent_type):
"""Validate spawn parameters. Returns resolved_path or error dict."""
if not project or not isinstance(project, str):
return {'error': 'Project name is required', 'code': 'MISSING_PROJECT'}
# Reject whitespace-only names
if not project.strip():
return {'error': 'Project name is required', 'code': 'MISSING_PROJECT'}
# Reject null bytes and control characters (U+0000-U+001F, U+007F)
if '\x00' in project or re.search(r'[\x00-\x1f\x7f]', project):
return {'error': 'Invalid project name', 'code': 'INVALID_PROJECT'}
# Reject path traversal characters (/, \, ..)
if '/' in project or '\\' in project or '..' in project:
return {'error': 'Invalid project name', 'code': 'INVALID_PROJECT'}
# Resolve symlinks and verify under PROJECTS_DIR
candidate = PROJECTS_DIR / project
try:
resolved = candidate.resolve()
except OSError:
return {'error': f'Project not found: {project}', 'code': 'PROJECT_NOT_FOUND'}
# Symlink escape check
try:
resolved.relative_to(PROJECTS_DIR.resolve())
except ValueError:
return {'error': 'Invalid project name', 'code': 'INVALID_PROJECT'}
if not resolved.is_dir():
return {'error': f'Project not found: {project}', 'code': 'PROJECT_NOT_FOUND'}
if agent_type not in AGENT_COMMANDS:
return {
'error': f'Invalid agent type: {agent_type}. Must be one of: {", ".join(sorted(AGENT_COMMANDS))}',
'code': 'INVALID_AGENT_TYPE',
}
return {'resolved_path': resolved}
def _check_zellij_session_exists(self):
"""Check if the target Zellij session exists."""
try:
result = subprocess.run(
[ZELLIJ_BIN, 'list-sessions'],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode != 0:
return False
# Strip ANSI escape codes (Zellij outputs colored text)
ansi_pattern = re.compile(r'\x1b\[[0-9;]*m')
output = ansi_pattern.sub('', result.stdout)
# Parse line-by-line to avoid substring false positives
for line in output.splitlines():
# Zellij outputs "session_name [Created ...]" or just "session_name"
session_name = line.strip().split()[0] if line.strip() else ''
if session_name == ZELLIJ_SESSION:
return True
return False
except FileNotFoundError:
return False
except subprocess.TimeoutExpired:
return False
except OSError:
return False
def _wait_for_session_file(self, spawn_id, timeout=10.0):
"""Poll for a session file matching spawn_id."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
for f in SESSIONS_DIR.glob('*.json'):
try:
data = json.loads(f.read_text())
if isinstance(data, dict) and data.get('spawn_id') == spawn_id:
return True
except (json.JSONDecodeError, OSError):
continue
except OSError:
pass
time.sleep(0.25)
return False
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 {
'ok': False,
'error': f'Zellij session "{ZELLIJ_SESSION}" not found',
'code': 'SESSION_NOT_FOUND',
}
# Create/switch to project tab
try:
result = subprocess.run(
[
ZELLIJ_BIN, '--session', ZELLIJ_SESSION,
'action', 'go-to-tab-name', '--create', project,
],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode != 0:
return {
'ok': False,
'error': f'Failed to create tab: {result.stderr.strip() or "unknown error"}',
'code': 'TAB_ERROR',
}
except FileNotFoundError:
return {'ok': False, 'error': f'Zellij not found at {ZELLIJ_BIN}', 'code': 'ZELLIJ_NOT_FOUND'}
except subprocess.TimeoutExpired:
return {'ok': False, 'error': 'Zellij tab creation timed out', 'code': 'TIMEOUT'}
except OSError as e:
return {'ok': False, 'error': str(e), 'code': 'SPAWN_ERROR'}
# Build agent command
agent_cmd = AGENT_COMMANDS[agent_type]
pane_name = _sanitize_pane_name(f'{agent_type}-{project}')
# Spawn pane with agent command
env = os.environ.copy()
env['AMC_SPAWN_ID'] = spawn_id
try:
result = subprocess.run(
[
ZELLIJ_BIN, '--session', ZELLIJ_SESSION,
'action', 'new-pane',
'--name', pane_name,
'--cwd', str(project_path),
'--',
] + agent_cmd,
env=env,
capture_output=True,
text=True,
timeout=5,
)
if result.returncode != 0:
return {
'ok': False,
'error': f'Failed to spawn pane: {result.stderr.strip() or "unknown error"}',
'code': 'SPAWN_ERROR',
}
except FileNotFoundError:
return {'ok': False, 'error': f'Zellij not found at {ZELLIJ_BIN}', 'code': 'ZELLIJ_NOT_FOUND'}
except subprocess.TimeoutExpired:
return {'ok': False, 'error': 'Pane spawn timed out', 'code': 'TIMEOUT'}
except OSError as e:
return {'ok': False, 'error': str(e), 'code': 'SPAWN_ERROR'}
# Wait for session file to appear
found = self._wait_for_session_file(spawn_id)
if not found:
LOGGER.warning(
'Session file not found for spawn_id=%s after timeout (agent may still be starting)',
spawn_id,
)
return {'ok': True, 'session_file_found': found}
def _handle_projects(self):
"""GET /api/projects - return cached projects list."""
self._send_json(200, {'ok': True, 'projects': list(_projects_cache)})
def _handle_projects_refresh(self):
"""POST /api/projects/refresh - refresh and return projects list."""
load_projects_cache()
self._send_json(200, {'ok': True, 'projects': list(_projects_cache)})
def _handle_health(self):
"""GET /api/health - check server and Zellij status."""
zellij_ok = self._check_zellij_session_exists()
self._send_json(200, {
'ok': True,
'zellij_session': ZELLIJ_SESSION,
'zellij_available': zellij_ok,
})

View File

@@ -2,17 +2,18 @@ import hashlib
import json
import subprocess
import time
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from amc_server.context import (
from amc_server.config import (
EVENTS_DIR,
SESSIONS_DIR,
STALE_EVENT_AGE,
STALE_STARTING_AGE,
ZELLIJ_BIN,
_state_lock,
_zellij_cache,
)
from amc_server.zellij import ZELLIJ_BIN, _zellij_cache
from amc_server.logging_utils import LOGGER
@@ -99,6 +100,9 @@ class StateMixin:
# Get active Zellij sessions for liveness check
active_zellij_sessions = self._get_active_zellij_sessions()
# Get set of transcript files with active processes (for dead detection)
active_transcript_files = self._get_active_transcript_files()
for f in SESSIONS_DIR.glob("*.json"):
try:
data = json.loads(f.read_text())
@@ -119,6 +123,31 @@ class StateMixin:
if context_usage:
data["context_usage"] = context_usage
# Capture turn token baseline on UserPromptSubmit (for per-turn token display)
# Only write once when the turn starts and we have token data
if (
data.get("last_event") == "UserPromptSubmit"
and "turn_start_tokens" not in data
and context_usage
and context_usage.get("current_tokens") is not None
):
data["turn_start_tokens"] = context_usage["current_tokens"]
# Persist to session file so it survives server restarts
try:
f.write_text(json.dumps(data, indent=2))
except OSError:
pass
# Track conversation file mtime for real-time update detection
conv_mtime = self._get_conversation_mtime(data)
if conv_mtime:
data["conversation_mtime_ns"] = conv_mtime
# Determine if session is "dead" (no longer interactable)
data["is_dead"] = self._is_session_dead(
data, active_zellij_sessions, active_transcript_files
)
sessions.append(data)
except (json.JSONDecodeError, OSError):
continue
@@ -126,8 +155,11 @@ class StateMixin:
LOGGER.exception("Failed processing session file %s", f)
continue
# Sort by last_event_at descending
sessions.sort(key=lambda s: s.get("last_event_at", ""), reverse=True)
# Sort by session_id for stable, deterministic ordering (no visual jumping)
sessions.sort(key=lambda s: s.get("session_id", ""))
# Dedupe same-pane sessions (handles --resume creating orphan + real session)
sessions = self._dedupe_same_pane_sessions(sessions)
# Clean orphan event logs (sessions persist until manually dismissed or SessionEnd)
self._cleanup_stale(sessions)
@@ -166,6 +198,165 @@ class StateMixin:
return None # Return None on error (don't clean up if we can't verify)
def _get_conversation_mtime(self, session_data):
"""Get the conversation file's mtime for real-time change detection."""
agent = session_data.get("agent")
if agent == "claude":
conv_file = self._get_claude_conversation_file(
session_data.get("session_id", ""),
session_data.get("project_dir", ""),
)
if conv_file:
try:
return conv_file.stat().st_mtime_ns
except OSError:
pass
elif agent == "codex":
transcript_path = session_data.get("transcript_path", "")
if transcript_path:
try:
return Path(transcript_path).stat().st_mtime_ns
except OSError:
pass
# Fallback to discovery
transcript_file = self._find_codex_transcript_file(session_data.get("session_id", ""))
if transcript_file:
try:
return transcript_file.stat().st_mtime_ns
except OSError:
pass
return None
def _get_active_transcript_files(self):
"""Get set of transcript files that have active processes.
Uses a batched lsof call to efficiently check which Codex transcript
files are currently open by a process.
Returns:
set: Absolute paths of transcript files with active processes.
"""
from amc_server.agents import CODEX_SESSIONS_DIR
if not CODEX_SESSIONS_DIR.exists():
return set()
# Find all recent transcript files
transcript_files = []
now = time.time()
cutoff = now - 3600 # Only check files modified in the last hour
for jsonl_file in CODEX_SESSIONS_DIR.rglob("*.jsonl"):
try:
if jsonl_file.stat().st_mtime > cutoff:
transcript_files.append(str(jsonl_file))
except OSError:
continue
if not transcript_files:
return set()
# Batch lsof check for all transcript files
active_files = set()
try:
# lsof with multiple files: returns PIDs for any that are open
result = subprocess.run(
["lsof", "-t"] + transcript_files,
capture_output=True,
text=True,
timeout=5,
)
# If any file is open, lsof returns 0
# We need to check which specific files are open
if result.returncode == 0 and result.stdout.strip():
# At least one file is open - check each one
for tf in transcript_files:
try:
check = subprocess.run(
["lsof", "-t", tf],
capture_output=True,
text=True,
timeout=2,
)
if check.returncode == 0 and check.stdout.strip():
active_files.add(tf)
except (subprocess.TimeoutExpired, Exception):
continue
except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
pass
return active_files
def _is_session_dead(self, session_data, active_zellij_sessions, active_transcript_files):
"""Determine if a session is 'dead' (no longer interactable).
A dead session cannot receive input and won't produce more output.
These should be shown separately from active sessions in the UI.
Args:
session_data: The session dict
active_zellij_sessions: Set of active zellij session names (or None)
active_transcript_files: Set of transcript file paths with active processes
Returns:
bool: True if the session is dead
"""
agent = session_data.get("agent")
zellij_session = session_data.get("zellij_session", "")
status = session_data.get("status", "")
# Sessions that are still starting are not dead (yet)
if status == "starting":
return False
if agent == "codex":
# Codex session is dead if no process has the transcript file open
transcript_path = session_data.get("transcript_path", "")
if not transcript_path:
return True # No transcript path = malformed, treat as dead
# Check cached set first (covers recently-modified files)
if transcript_path in active_transcript_files:
return False # Process is running
# For older files not in cached set, do explicit lsof check
# This handles long-idle but still-running processes
if self._is_file_open(transcript_path):
return False # Process is running
# No process running - it's dead
return True
elif agent == "claude":
# Claude session is dead if:
# 1. No zellij session attached, OR
# 2. The zellij session no longer exists
if not zellij_session:
return True
if active_zellij_sessions is not None:
return zellij_session not in active_zellij_sessions
# If we couldn't query zellij, assume alive (don't false-positive)
return False
# Unknown agent type - assume alive
return False
def _is_file_open(self, file_path):
"""Check if any process has a file open using lsof."""
try:
result = subprocess.run(
["lsof", "-t", file_path],
capture_output=True,
text=True,
timeout=2,
)
return result.returncode == 0 and result.stdout.strip()
except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
return False # Assume not open on error (conservative)
def _cleanup_stale(self, sessions):
"""Remove orphan event logs >24h and stale 'starting' sessions >1h."""
active_ids = {s.get("session_id") for s in sessions if s.get("session_id")}
@@ -193,3 +384,56 @@ class StateMixin:
f.unlink()
except (json.JSONDecodeError, OSError):
pass
def _dedupe_same_pane_sessions(self, sessions):
"""Remove orphan sessions when multiple sessions share the same Zellij pane.
This handles the --resume edge case where Claude creates a new session file
before resuming the old one, leaving an orphan with no context_usage.
When multiple sessions share (zellij_session, zellij_pane), keep the one with:
1. context_usage (has actual conversation data)
2. Higher conversation_mtime_ns (more recent activity)
"""
def session_score(s):
"""Score a session for dedup ranking: (has_context, mtime)."""
has_context = 1 if s.get("context_usage") else 0
mtime = s.get("conversation_mtime_ns") or 0
# Defensive: ensure mtime is numeric
if not isinstance(mtime, (int, float)):
mtime = 0
return (has_context, mtime)
# Group sessions by pane
pane_groups = defaultdict(list)
for s in sessions:
zs = s.get("zellij_session", "")
zp = s.get("zellij_pane", "")
if zs and zp:
pane_groups[(zs, zp)].append(s)
# Find orphans to remove
orphan_ids = set()
for group in pane_groups.values():
if len(group) <= 1:
continue
# Pick the best session: prefer context_usage, then highest mtime
group_sorted = sorted(group, key=session_score, reverse=True)
# Mark all but the best as orphans
for s in group_sorted[1:]:
session_id = s.get("session_id")
if not session_id:
continue # Skip sessions without valid IDs
orphan_ids.add(session_id)
# Also delete the orphan session file
try:
orphan_file = SESSIONS_DIR / f"{session_id}.json"
orphan_file.unlink(missing_ok=True)
except OSError:
pass
# Return filtered list
return [s for s in sessions if s.get("session_id") not in orphan_ids]

View File

@@ -1,15 +1,24 @@
import os
from http.server import ThreadingHTTPServer
from amc_server.context import DATA_DIR, PORT
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
def main():
configure_logging()
DATA_DIR.mkdir(parents=True, exist_ok=True)
LOGGER.info("Starting AMC server")
# Initialize spawn feature
load_projects_cache()
generate_auth_token()
start_projects_watcher()
server = ThreadingHTTPServer(("127.0.0.1", PORT), AMCHandler)
install_signal_handlers(server)
LOGGER.info("AMC server listening on http://127.0.0.1:%s", PORT)

View 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
View 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}

View File

@@ -82,10 +82,14 @@ def _extract_questions(hook):
"options": [],
}
for opt in q.get("options", []):
entry["options"].append({
opt_entry = {
"label": opt.get("label", ""),
"description": opt.get("description", ""),
})
}
# Include markdown preview if present
if opt.get("markdown"):
opt_entry["markdown"] = opt.get("markdown")
entry["options"].append(opt_entry)
result.append(entry)
return result
@@ -166,6 +170,8 @@ def main():
existing["last_event"] = f"PreToolUse({tool_name})"
existing["last_event_at"] = now
existing["pending_questions"] = _extract_questions(hook)
# Track when turn paused for duration calculation
existing["turn_paused_at"] = now
_atomic_write(session_file, existing)
_append_event(session_id, {
"event": f"PreToolUse({tool_name})",
@@ -185,6 +191,16 @@ def main():
existing["last_event"] = f"PostToolUse({tool_name})"
existing["last_event_at"] = now
existing.pop("pending_questions", None)
# Accumulate paused time for turn duration calculation
paused_at = existing.pop("turn_paused_at", None)
if paused_at:
try:
paused_start = datetime.fromisoformat(paused_at.replace("Z", "+00:00"))
paused_end = datetime.fromisoformat(now.replace("Z", "+00:00"))
paused_ms = int((paused_end - paused_start).total_seconds() * 1000)
existing["turn_paused_ms"] = existing.get("turn_paused_ms", 0) + paused_ms
except (ValueError, TypeError):
pass
_atomic_write(session_file, existing)
_append_event(session_id, {
"event": f"PostToolUse({tool_name})",
@@ -233,6 +249,25 @@ def main():
"zellij_pane": os.environ.get("ZELLIJ_PANE_ID", ""),
}
# Include spawn_id if present in environment (for spawn correlation)
spawn_id = os.environ.get("AMC_SPAWN_ID")
if spawn_id:
state["spawn_id"] = spawn_id
# Turn timing: track working time from user prompt to completion
if event == "UserPromptSubmit":
# New turn starting - reset turn timing
state["turn_started_at"] = now
state["turn_paused_ms"] = 0
else:
# Preserve turn timing from existing state
if "turn_started_at" in existing:
state["turn_started_at"] = existing["turn_started_at"]
if "turn_paused_ms" in existing:
state["turn_paused_ms"] = existing["turn_paused_ms"]
if "turn_paused_at" in existing:
state["turn_paused_at"] = existing["turn_paused_at"]
# Store prose question if detected
if prose_question:
state["pending_questions"] = [{

29
bin/amc-server-restart Executable file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Restart the AMC server cleanly
set -e
PORT=7400
# Find and kill existing server
PID=$(lsof -ti :$PORT 2>/dev/null || true)
if [[ -n "$PID" ]]; then
echo "Stopping AMC server (PID $PID)..."
kill "$PID" 2>/dev/null || true
sleep 1
fi
# Start server in background
echo "Starting AMC server on port $PORT..."
cd "$(dirname "$0")/.."
nohup python3 -m amc_server.server > /tmp/amc-server.log 2>&1 &
# Wait for startup
sleep 1
NEW_PID=$(lsof -ti :$PORT 2>/dev/null || true)
if [[ -n "$NEW_PID" ]]; then
echo "AMC server running (PID $NEW_PID)"
else
echo "Failed to start server. Check /tmp/amc-server.log"
exit 1
fi

View File

@@ -0,0 +1,115 @@
import { html, useState, useEffect, useRef } from '../lib/preact.js';
/**
* Shows live agent activity: elapsed time since user prompt, token usage.
* Visible when session is active/starting, pauses during needs_attention,
* shows final duration when done.
*
* @param {object} session - Session object with turn_started_at, turn_paused_at, turn_paused_ms, status
*/
export function AgentActivityIndicator({ session }) {
const [elapsed, setElapsed] = useState(0);
const intervalRef = useRef(null);
// Safely extract session fields (handles null/undefined session)
const status = session?.status;
const turn_started_at = session?.turn_started_at;
const turn_paused_at = session?.turn_paused_at;
const turn_paused_ms = session?.turn_paused_ms ?? 0;
const last_event_at = session?.last_event_at;
const context_usage = session?.context_usage;
const turn_start_tokens = session?.turn_start_tokens;
// Only show for sessions with turn timing
const hasTurnTiming = !!turn_started_at;
const isActive = status === 'active' || status === 'starting';
const isPaused = status === 'needs_attention';
const isDone = status === 'done';
useEffect(() => {
if (!hasTurnTiming) return;
const calculate = () => {
const startMs = new Date(turn_started_at).getTime();
const pausedMs = turn_paused_ms || 0;
if (isActive) {
// Running: current time - start - paused
return Date.now() - startMs - pausedMs;
} else if (isPaused && turn_paused_at) {
// Paused: frozen at pause time
const pausedAtMs = new Date(turn_paused_at).getTime();
return pausedAtMs - startMs - pausedMs;
} else if (isDone && last_event_at) {
// Done: final duration
const endMs = new Date(last_event_at).getTime();
return endMs - startMs - pausedMs;
}
return 0;
};
setElapsed(calculate());
// Only tick while active
if (isActive) {
intervalRef.current = setInterval(() => {
setElapsed(calculate());
}, 1000);
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [hasTurnTiming, isActive, isPaused, isDone, turn_started_at, turn_paused_at, turn_paused_ms, last_event_at]);
// Don't render if no turn timing or session is done with no activity
if (!hasTurnTiming) return null;
// Format elapsed time (clamp to 0 for safety)
const formatElapsed = (ms) => {
const totalSec = Math.max(0, Math.floor(ms / 1000));
if (totalSec < 60) return `${totalSec}s`;
const min = Math.floor(totalSec / 60);
const sec = totalSec % 60;
return `${min}m ${sec}s`;
};
// Format token count
const formatTokens = (count) => {
if (count == null) return null;
if (count >= 1000) return `${(count / 1000).toFixed(1)}k`;
return String(count);
};
// Calculate turn tokens (current - baseline from turn start)
const currentTokens = context_usage?.current_tokens;
const turnTokens = (currentTokens != null && turn_start_tokens != null)
? Math.max(0, currentTokens - turn_start_tokens)
: null;
const tokenDisplay = formatTokens(turnTokens);
return html`
<div class="flex items-center gap-2 font-mono text-label">
${isActive && html`
<span class="activity-spinner"></span>
`}
${isPaused && html`
<span class="h-2 w-2 rounded-full bg-attention"></span>
`}
${isDone && html`
<span class="h-2 w-2 rounded-full bg-done"></span>
`}
<span class="text-dim">
${isActive ? 'Working' : isPaused ? 'Paused' : 'Completed'}
</span>
<span class="text-bright tabular-nums">${formatElapsed(elapsed)}</span>
${tokenDisplay && html`
<span class="text-dim/70">·</span>
<span class="text-dim/90">${tokenDisplay} tokens</span>
`}
</div>
`;
}

View File

@@ -1,23 +1,31 @@
import { html, useState, useEffect, useCallback, useMemo, useRef } from '../lib/preact.js';
import { API_STATE, API_STREAM, API_DISMISS, API_RESPOND, API_CONVERSATION, POLL_MS, fetchWithTimeout } from '../utils/api.js';
import { API_STATE, API_STREAM, API_DISMISS, API_DISMISS_DEAD, API_RESPOND, API_CONVERSATION, API_HEALTH, POLL_MS, fetchWithTimeout, fetchSkills } from '../utils/api.js';
import { groupSessionsByProject } from '../utils/status.js';
import { Sidebar } from './Sidebar.js';
import { SessionCard } from './SessionCard.js';
import { Modal } from './Modal.js';
import { EmptyState } from './EmptyState.js';
import { ToastContainer, showToast, trackError, clearErrorCount } from './Toast.js';
import { SpawnModal } from './SpawnModal.js';
let optimisticMsgId = 0;
export function App() {
const [sessions, setSessions] = useState([]);
const [modalSession, setModalSession] = useState(null);
const [conversations, setConversations] = useState({});
const [conversationLoading, setConversationLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedProject, setSelectedProject] = useState(null);
const [sseConnected, setSseConnected] = useState(false);
const [deadSessionsCollapsed, setDeadSessionsCollapsed] = useState(true);
const [spawnModalOpen, setSpawnModalOpen] = useState(false);
const [zellijAvailable, setZellijAvailable] = useState(true);
const [newlySpawnedIds, setNewlySpawnedIds] = useState(new Set());
const pendingSpawnIdsRef = useRef(new Set());
const [skillsConfig, setSkillsConfig] = useState({ claude: null, codex: null });
// Silent conversation refresh (no loading state, used for background polling)
// Defined early so fetchState can reference it
// Background conversation refresh with error tracking
const refreshConversationSilent = useCallback(async (sessionId, projectDir, agent = 'claude') => {
try {
let url = API_CONVERSATION + encodeURIComponent(sessionId);
@@ -26,55 +34,103 @@ export function App() {
if (agent) params.set('agent', agent);
if (params.toString()) url += '?' + params.toString();
const response = await fetch(url);
if (!response.ok) return;
if (!response.ok) {
trackError(`conversation-${sessionId}`, `Failed to fetch conversation (HTTP ${response.status})`);
return;
}
const data = await response.json();
setConversations(prev => ({
...prev,
[sessionId]: data.messages || []
}));
clearErrorCount(`conversation-${sessionId}`); // Clear on success
} catch (err) {
// Silent failure for background refresh
trackError(`conversation-${sessionId}`, `Failed to fetch conversation: ${err.message}`);
}
}, []);
// Track last_event_at for each session to detect actual changes
const lastEventAtRef = useRef({});
// Refs for stable callback access (avoids recreation on state changes)
const sessionsRef = useRef(sessions);
const conversationsRef = useRef(conversations);
const modalSessionRef = useRef(null);
sessionsRef.current = sessions;
conversationsRef.current = conversations;
// Apply state payload from polling or SSE stream
const applyStateData = useCallback((data) => {
const newSessions = data.sessions || [];
const newSessionIds = new Set(newSessions.map(s => s.session_id));
setSessions(newSessions);
setError(null);
// Update modalSession if it's still open (to get latest pending_questions, etc.)
let modalId = null;
setModalSession(prev => {
if (!prev) return null;
modalId = prev.session_id;
const updatedSession = newSessions.find(s => s.session_id === prev.session_id);
return updatedSession || prev;
const modalId = modalSessionRef.current;
if (modalId) {
const updatedSession = newSessions.find(s => s.session_id === modalId);
if (updatedSession) {
setModalSession(updatedSession);
}
}
// Check for newly spawned sessions matching pending spawn IDs
if (pendingSpawnIdsRef.current.size > 0) {
const matched = new Set();
for (const session of newSessions) {
if (session.spawn_id && pendingSpawnIdsRef.current.has(session.spawn_id)) {
matched.add(session.session_id);
pendingSpawnIdsRef.current.delete(session.spawn_id);
}
}
if (matched.size > 0) {
setNewlySpawnedIds(prev => {
const next = new Set(prev);
for (const id of matched) next.add(id);
return next;
});
// Auto-clear highlight after animation duration (2.5s)
for (const id of matched) {
setTimeout(() => {
setNewlySpawnedIds(prev => {
const next = new Set(prev);
next.delete(id);
return next;
});
}, 2500);
}
}
}
// Clean up conversation cache for sessions that no longer exist
setConversations(prev => {
const activeIds = Object.keys(prev).filter(id => newSessionIds.has(id));
if (activeIds.length === Object.keys(prev).length) return prev; // No cleanup needed
const cleaned = {};
for (const id of activeIds) {
cleaned[id] = prev[id];
}
return cleaned;
});
// Only refresh conversations for sessions that have actually changed
// (compare last_event_at to avoid flooding the API)
// Refresh conversations for sessions that have actually changed
// Use conversation_mtime_ns for real-time updates (changes on every file write),
// falling back to last_event_at for sessions without mtime tracking
const prevEventMap = lastEventAtRef.current;
const nextEventMap = {};
for (const session of newSessions) {
const id = session.session_id;
const newEventAt = session.last_event_at || '';
nextEventMap[id] = newEventAt;
// Prefer mtime (changes on every write) over last_event_at (only on hook events)
const newKey = session.conversation_mtime_ns || session.last_event_at || '';
nextEventMap[id] = newKey;
// Only refresh if:
// 1. Session is active/attention AND
// 2. last_event_at has actually changed OR it's the currently open modal
if (session.status === 'active' || session.status === 'needs_attention') {
const oldEventAt = prevEventMap[id] || '';
if (newEventAt !== oldEventAt || id === modalId) {
const oldKey = prevEventMap[id] || '';
if (newKey !== oldKey) {
refreshConversationSilent(session.session_id, session.project_dir, session.agent || 'claude');
}
}
}
lastEventAtRef.current = nextEventMap;
setLoading(false);
@@ -89,22 +145,19 @@ export function App() {
}
const data = await response.json();
applyStateData(data);
clearErrorCount('state-fetch');
} catch (err) {
const msg = err.name === 'AbortError' ? 'Request timed out' : err.message;
console.error('Failed to fetch state:', msg);
trackError('state-fetch', `Failed to fetch state: ${msg}`);
setError(msg);
setLoading(false);
}
}, [applyStateData]);
// Fetch conversation for a session
const fetchConversation = useCallback(async (sessionId, projectDir, agent = 'claude', showLoading = false, force = false) => {
// Fetch conversation for a session (explicit fetch, e.g., on modal open)
const fetchConversation = useCallback(async (sessionId, projectDir, agent = 'claude', force = false) => {
// Skip if already fetched and not forcing refresh
if (!force && conversations[sessionId]) return;
if (showLoading) {
setConversationLoading(true);
}
if (!force && conversationsRef.current[sessionId]) return;
try {
let url = API_CONVERSATION + encodeURIComponent(sessionId);
@@ -114,7 +167,7 @@ export function App() {
if (params.toString()) url += '?' + params.toString();
const response = await fetch(url);
if (!response.ok) {
console.warn('Failed to fetch conversation for', sessionId);
trackError(`conversation-${sessionId}`, `Failed to fetch conversation (HTTP ${response.status})`);
return;
}
const data = await response.json();
@@ -122,22 +175,33 @@ export function App() {
...prev,
[sessionId]: data.messages || []
}));
clearErrorCount(`conversation-${sessionId}`);
} catch (err) {
console.error('Error fetching conversation:', err);
} finally {
if (showLoading) {
setConversationLoading(false);
trackError(`conversation-${sessionId}`, `Error fetching conversation: ${err.message}`);
}
}
}, [conversations]);
}, []);
// Respond to a session's pending question
// Respond to a session's pending question with optimistic update
const respondToSession = useCallback(async (sessionId, text, isFreeform = false, optionCount = 0) => {
const payload = { text };
if (isFreeform) {
payload.freeform = true;
payload.optionCount = optionCount;
}
// Optimistic update: immediately show user's message
const optimisticMsg = {
id: `optimistic-${++optimisticMsgId}`,
role: 'user',
content: text,
timestamp: new Date().toISOString(),
_optimistic: true, // Flag for identification
};
setConversations(prev => ({
...prev,
[sessionId]: [...(prev[sessionId] || []), optimisticMsg]
}));
try {
const res = await fetch(API_RESPOND + encodeURIComponent(sessionId), {
method: 'POST',
@@ -145,14 +209,22 @@ export function App() {
body: JSON.stringify(payload)
});
const data = await res.json();
if (data.ok) {
// Trigger refresh
fetchState();
if (!data.ok) {
throw new Error(data.error || 'Failed to send response');
}
clearErrorCount(`respond-${sessionId}`);
// SSE will push state update when Claude processes the message,
// which triggers conversation refresh via applyStateData
} catch (err) {
console.error('Error responding to session:', err);
// Remove optimistic message on failure
setConversations(prev => ({
...prev,
[sessionId]: (prev[sessionId] || []).filter(m => m !== optimisticMsg)
}));
trackError(`respond-${sessionId}`, `Failed to send message: ${err.message}`);
throw err; // Re-throw so SimpleInput/QuestionBlock can catch and show error
}
}, [fetchState]);
}, []);
// Dismiss a session
const dismissSession = useCallback(async (sessionId) => {
@@ -164,9 +236,26 @@ export function App() {
if (data.ok) {
// Trigger refresh
fetchState();
} else {
trackError(`dismiss-${sessionId}`, `Failed to dismiss session: ${data.error || 'Unknown error'}`);
}
} catch (err) {
console.error('Error dismissing session:', err);
trackError(`dismiss-${sessionId}`, `Error dismissing session: ${err.message}`);
}
}, [fetchState]);
// Dismiss all dead sessions
const dismissDeadSessions = useCallback(async () => {
try {
const res = await fetch(API_DISMISS_DEAD, { method: 'POST' });
const data = await res.json();
if (data.ok) {
fetchState();
} else {
trackError('dismiss-dead', `Failed to clear completed sessions: ${data.error || 'Unknown error'}`);
}
} catch (err) {
trackError('dismiss-dead', `Error clearing completed sessions: ${err.message}`);
}
}, [fetchState]);
@@ -182,7 +271,7 @@ export function App() {
try {
eventSource = new EventSource(API_STREAM);
} catch (err) {
console.error('Failed to initialize EventSource:', err);
trackError('sse-init', `Failed to initialize EventSource: ${err.message}`);
setSseConnected(false);
reconnectTimer = setTimeout(connect, 2000);
return;
@@ -192,6 +281,9 @@ export function App() {
if (stopped) return;
setSseConnected(true);
setError(null);
// Clear event cache on reconnect to force refresh of all conversations
// (handles updates missed during disconnect)
lastEventAtRef.current = {};
});
eventSource.addEventListener('state', (event) => {
@@ -199,8 +291,9 @@ export function App() {
try {
const data = JSON.parse(event.data);
applyStateData(data);
clearErrorCount('sse-parse');
} catch (err) {
console.error('Failed to parse SSE state payload:', err);
trackError('sse-parse', `Failed to parse SSE state payload: ${err.message}`);
}
});
@@ -242,6 +335,37 @@ export function App() {
return () => clearInterval(interval);
}, [fetchState, sseConnected]);
// Poll Zellij health status
useEffect(() => {
const checkHealth = async () => {
try {
const response = await fetchWithTimeout(API_HEALTH);
if (response.ok) {
const data = await response.json();
setZellijAvailable(data.zellij_available);
}
} catch {
// Server unreachable - handled by state fetch error
}
};
checkHealth();
const interval = setInterval(checkHealth, 30000);
return () => clearInterval(interval);
}, []);
// Fetch skills for autocomplete on mount
useEffect(() => {
const loadSkills = async () => {
const [claude, codex] = await Promise.all([
fetchSkills('claude'),
fetchSkills('codex')
]);
setSkillsConfig({ claude, codex });
};
loadSkills();
}, []);
// Group sessions by project
const projectGroups = groupSessionsByProject(sessions);
@@ -253,28 +377,35 @@ export function App() {
return projectGroups.filter(g => g.projectDir === selectedProject);
}, [projectGroups, selectedProject]);
// Split sessions into active and dead
const { activeSessions, deadSessions } = useMemo(() => {
const active = [];
const dead = [];
for (const group of filteredGroups) {
for (const session of group.sessions) {
if (session.is_dead) {
dead.push(session);
} else {
active.push(session);
}
}
}
return { activeSessions: active, deadSessions: dead };
}, [filteredGroups]);
// Handle card click - open modal and fetch conversation if not cached
const handleCardClick = useCallback(async (session) => {
modalSessionRef.current = session.session_id;
setModalSession(session);
// Fetch conversation if not already cached
if (!conversations[session.session_id]) {
await fetchConversation(session.session_id, session.project_dir, session.agent || 'claude', true);
if (!conversationsRef.current[session.session_id]) {
await fetchConversation(session.session_id, session.project_dir, session.agent || 'claude');
}
}, [conversations, fetchConversation]);
// Refresh conversation (force re-fetch, used after sending messages)
const refreshConversation = useCallback(async (sessionId, projectDir, agent = 'claude') => {
// Force refresh by clearing cache first
setConversations(prev => {
const updated = { ...prev };
delete updated[sessionId];
return updated;
});
await fetchConversation(sessionId, projectDir, agent, false, true);
}, [fetchConversation]);
const handleCloseModal = useCallback(() => {
modalSessionRef.current = null;
setModalSession(null);
}, []);
@@ -282,6 +413,17 @@ export function App() {
setSelectedProject(projectDir);
}, []);
const handleSpawnResult = useCallback((result) => {
if (result.success) {
showToast(`${result.agentType} agent spawned for ${result.project}`, 'success');
if (result.spawnId) {
pendingSpawnIdsRef.current.add(result.spawnId);
}
} else if (result.error) {
showToast(result.error, 'error');
}
}, []);
return html`
<!-- Sidebar -->
<${Sidebar}
@@ -342,9 +484,31 @@ export function App() {
`;
})()}
</div>
<div class="relative">
<button
disabled=${!zellijAvailable}
class="rounded-lg border border-active/40 bg-active/12 px-3 py-2 text-sm font-medium text-active transition-colors hover:bg-active/20 ${!zellijAvailable ? 'opacity-50 cursor-not-allowed' : ''}"
onClick=${() => setSpawnModalOpen(true)}
>
+ New Agent
</button>
<${SpawnModal}
isOpen=${spawnModalOpen}
onClose=${() => setSpawnModalOpen(false)}
onSpawn=${handleSpawnResult}
currentProject=${selectedProject}
/>
</div>
</div>
</header>
${!zellijAvailable && html`
<div class="border-b border-attention/50 bg-attention/10 px-6 py-2 text-sm text-attention">
<span class="font-medium">Zellij session not found.</span>
${' '}Agent spawning is unavailable. Start Zellij with: <code class="rounded bg-attention/15 px-1.5 py-0.5 font-mono text-micro">zellij attach infra</code>
</div>
`}
<main class="px-6 pb-6 pt-6">
${loading ? html`
<div class="glass-panel animate-fade-in-up flex items-center justify-center rounded-2xl py-24">
@@ -360,10 +524,10 @@ export function App() {
` : filteredGroups.length === 0 ? html`
<${EmptyState} />
` : html`
<!-- Sessions Grid (no project grouping header since sidebar shows selection) -->
<!-- Active Sessions Grid -->
${activeSessions.length > 0 ? html`
<div class="flex flex-wrap gap-4">
${filteredGroups.flatMap(group =>
group.sessions.map(session => html`
${activeSessions.map(session => html`
<${SessionCard}
key=${session.session_id}
session=${session}
@@ -372,10 +536,68 @@ export function App() {
onFetchConversation=${fetchConversation}
onRespond=${respondToSession}
onDismiss=${dismissSession}
isNewlySpawned=${newlySpawnedIds.has(session.session_id)}
autocompleteConfig=${skillsConfig[session.agent === 'codex' ? 'codex' : 'claude']}
/>
`)
)}
`)}
</div>
` : deadSessions.length > 0 ? html`
<div class="glass-panel flex items-center justify-center rounded-xl py-12 mb-6">
<div class="text-center">
<p class="font-display text-lg text-dim">No active sessions</p>
<p class="mt-1 font-mono text-micro text-dim/70">All sessions have completed</p>
</div>
</div>
` : ''}
<!-- Completed Sessions (Dead) - Collapsible -->
${deadSessions.length > 0 && html`
<div class="mt-8">
<button
onClick=${() => setDeadSessionsCollapsed(!deadSessionsCollapsed)}
class="group flex w-full items-center gap-3 rounded-lg border border-selection/50 bg-surface/50 px-4 py-3 text-left transition-colors hover:border-selection hover:bg-surface/80"
>
<svg
class="h-4 w-4 text-dim transition-transform ${deadSessionsCollapsed ? '' : 'rotate-90'}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
<span class="font-display text-sm font-medium text-dim">
Completed Sessions
</span>
<span class="rounded-full bg-done/15 px-2 py-0.5 font-mono text-micro tabular-nums text-done/70">
${deadSessions.length}
</span>
<div class="flex-1"></div>
<button
onClick=${(e) => { e.stopPropagation(); dismissDeadSessions(); }}
class="rounded-lg border border-selection/80 bg-bg/40 px-3 py-1.5 font-mono text-micro text-dim transition-colors hover:border-done/40 hover:bg-done/10 hover:text-bright"
>
Clear All
</button>
</button>
${!deadSessionsCollapsed && html`
<div class="mt-4 flex flex-wrap gap-4">
${deadSessions.map(session => html`
<${SessionCard}
key=${session.session_id}
session=${session}
onClick=${handleCardClick}
conversation=${conversations[session.session_id]}
onFetchConversation=${fetchConversation}
onRespond=${respondToSession}
onDismiss=${dismissSession}
autocompleteConfig=${skillsConfig[session.agent === 'codex' ? 'codex' : 'claude']}
/>
`)}
</div>
`}
</div>
`}
`}
</main>
</div>
@@ -383,10 +605,12 @@ export function App() {
<${Modal}
session=${modalSession}
conversations=${conversations}
conversationLoading=${conversationLoading}
onClose=${handleCloseModal}
onSendMessage=${respondToSession}
onRefreshConversation=${refreshConversation}
onFetchConversation=${fetchConversation}
onRespond=${respondToSession}
onDismiss=${dismissSession}
/>
<${ToastContainer} />
`;
}

View File

@@ -2,26 +2,33 @@ import { html } from '../lib/preact.js';
import { getUserMessageBg } from '../utils/status.js';
import { MessageBubble, filterDisplayMessages } from './MessageBubble.js';
export function ChatMessages({ messages, status }) {
function getMessageKey(msg, index) {
// Server-assigned ID (preferred)
if (msg.id) return msg.id;
// Fallback: role + timestamp + index (for legacy/edge cases)
return `${msg.role}-${msg.timestamp || ''}-${index}`;
}
export function ChatMessages({ messages, status, limit = 20 }) {
const userBgClass = getUserMessageBg(status);
if (!messages || messages.length === 0) {
return html`
<div class="flex h-full items-center justify-center rounded-xl border border-dashed border-selection/70 bg-bg/30 px-4 text-center text-sm text-dim">
No messages yet
No messages to show
</div>
`;
}
const allDisplayMessages = filterDisplayMessages(messages);
const displayMessages = allDisplayMessages.slice(-20);
const displayMessages = limit ? allDisplayMessages.slice(-limit) : allDisplayMessages;
const offset = allDisplayMessages.length - displayMessages.length;
return html`
<div class="space-y-2.5">
${displayMessages.map((msg, i) => html`
<${MessageBubble}
key=${`${msg.role}-${msg.timestamp || (offset + i)}`}
key=${getMessageKey(msg, offset + i)}
msg=${msg}
userBg=${userBgClass}
compact=${true}

View File

@@ -1,58 +0,0 @@
import { html, useState, useEffect } from '../lib/preact.js';
export function Header({ sessions }) {
const [clock, setClock] = useState(() => new Date());
useEffect(() => {
const timer = setInterval(() => setClock(new Date()), 30000);
return () => clearInterval(timer);
}, []);
const counts = {
attention: sessions.filter(s => s.status === 'needs_attention').length,
active: sessions.filter(s => s.status === 'active').length,
starting: sessions.filter(s => s.status === 'starting').length,
done: sessions.filter(s => s.status === 'done').length,
};
const total = sessions.length;
return html`
<header class="sticky top-0 z-50 px-4 pt-4 sm:px-6 sm:pt-6">
<div class="glass-panel rounded-2xl px-4 py-4 sm:px-6">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="min-w-0">
<div class="inline-flex items-center gap-2 rounded-full border border-starting/40 bg-starting/10 px-3 py-1 text-micro font-medium uppercase tracking-[0.24em] text-starting">
<span class="h-1.5 w-1.5 rounded-full bg-starting animate-float"></span>
Control Plane
</div>
<h1 class="mt-3 truncate font-display text-xl font-semibold text-bright sm:text-2xl">
Agent Mission Control
</h1>
<p class="mt-1 text-sm text-dim">
${total} live session${total === 1 ? '' : 's'} • Updated ${clock.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
</p>
</div>
<div class="grid grid-cols-2 gap-2 text-xs sm:grid-cols-4 sm:gap-3">
<div class="rounded-xl border border-attention/40 bg-attention/12 px-3 py-2 text-attention">
<div class="font-mono text-lg font-medium tabular-nums text-bright">${counts.attention}</div>
<div class="text-micro uppercase tracking-[0.16em]">Attention</div>
</div>
<div class="rounded-xl border border-active/40 bg-active/12 px-3 py-2 text-active">
<div class="font-mono text-lg font-medium tabular-nums text-bright">${counts.active}</div>
<div class="text-micro uppercase tracking-[0.16em]">Active</div>
</div>
<div class="rounded-xl border border-starting/40 bg-starting/12 px-3 py-2 text-starting">
<div class="font-mono text-lg font-medium tabular-nums text-bright">${counts.starting}</div>
<div class="text-micro uppercase tracking-[0.16em]">Starting</div>
</div>
<div class="rounded-xl border border-done/40 bg-done/12 px-3 py-2 text-done">
<div class="font-mono text-lg font-medium tabular-nums text-bright">${counts.done}</div>
<div class="text-micro uppercase tracking-[0.16em]">Done</div>
</div>
</div>
</div>
</div>
</header>
`;
}

View File

@@ -1,26 +1,31 @@
import { html, useState, useEffect, useRef, useCallback } from '../lib/preact.js';
import { getStatusMeta, getUserMessageBg } from '../utils/status.js';
import { formatDuration, formatTime } from '../utils/formatting.js';
import { MessageBubble, filterDisplayMessages } from './MessageBubble.js';
import { html, useState, useEffect, useCallback } from '../lib/preact.js';
import { SessionCard } from './SessionCard.js';
import { fetchSkills } from '../utils/api.js';
export function Modal({ session, conversations, conversationLoading, onClose, onSendMessage, onRefreshConversation }) {
const [inputValue, setInputValue] = useState('');
const [sending, setSending] = useState(false);
const [inputFocused, setInputFocused] = useState(false);
export function Modal({ session, conversations, onClose, onRespond, onFetchConversation, onDismiss }) {
const [closing, setClosing] = useState(false);
const inputRef = useRef(null);
const wasAtBottomRef = useRef(true);
const prevConversationLenRef = useRef(0);
const chatContainerRef = useRef(null);
const [autocompleteConfig, setAutocompleteConfig] = useState(null);
const conversation = session ? (conversations[session.session_id] || []) : [];
// Reset state when session changes
// Reset closing state when session changes
useEffect(() => {
setClosing(false);
prevConversationLenRef.current = 0;
}, [session?.session_id]);
// Load autocomplete skills when agent type changes
useEffect(() => {
if (!session) {
setAutocompleteConfig(null);
return;
}
let stale = false;
const agent = session.agent || 'claude';
fetchSkills(agent)
.then(config => { if (!stale) setAutocompleteConfig(config); })
.catch(() => { if (!stale) setAutocompleteConfig(null); });
return () => { stale = true; };
}, [session?.agent]);
// Animated close handler
const handleClose = useCallback(() => {
setClosing(true);
@@ -30,40 +35,6 @@ export function Modal({ session, conversations, conversationLoading, onClose, on
}, 200);
}, [onClose]);
// Track scroll position
useEffect(() => {
const container = chatContainerRef.current;
if (!container) return;
const handleScroll = () => {
const threshold = 50;
wasAtBottomRef.current = container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
};
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, []);
// Only scroll to bottom on NEW messages, and only if user was already at bottom
useEffect(() => {
const container = chatContainerRef.current;
if (!container || !conversation) return;
const hasNewMessages = conversation.length > prevConversationLenRef.current;
prevConversationLenRef.current = conversation.length;
if (hasNewMessages && wasAtBottomRef.current) {
container.scrollTop = container.scrollHeight;
}
}, [conversation]);
// Focus input when modal opens
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, [session?.session_id]);
// Lock body scroll when modal is open
useEffect(() => {
if (!session) return;
@@ -71,9 +42,9 @@ export function Modal({ session, conversations, conversationLoading, onClose, on
return () => {
document.body.style.overflow = '';
};
}, [!!session]);
}, [session?.session_id]);
// Handle keyboard events
// Handle escape key
useEffect(() => {
if (!session) return;
const handleKeyDown = (e) => {
@@ -81,146 +52,27 @@ export function Modal({ session, conversations, conversationLoading, onClose, on
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [!!session, handleClose]);
}, [session?.session_id, handleClose]);
if (!session) return null;
const hasPendingQuestions = session.pending_questions && session.pending_questions.length > 0;
const optionCount = hasPendingQuestions ? session.pending_questions[0]?.options?.length || 0 : 0;
const status = getStatusMeta(session.status);
const agent = session.agent === 'codex' ? 'codex' : 'claude';
const agentHeaderClass = agent === 'codex' ? 'agent-header-codex' : 'agent-header-claude';
const handleInputKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleSend = async () => {
const text = inputValue.trim();
if (!text || sending) return;
setSending(true);
try {
if (onSendMessage) {
await onSendMessage(session.session_id, text, true, optionCount);
}
setInputValue('');
if (onRefreshConversation) {
await onRefreshConversation(session.session_id, session.project_dir, agent);
}
} catch (err) {
console.error('Failed to send message:', err);
} finally {
setSending(false);
}
};
const displayMessages = filterDisplayMessages(conversation);
const conversation = conversations[session.session_id] || [];
return html`
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-[#02050d]/84 p-4 backdrop-blur-sm ${closing ? 'modal-backdrop-out' : 'modal-backdrop-in'}"
onClick=${(e) => e.target === e.currentTarget && handleClose()}
>
<div
class="glass-panel flex max-h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-selection/80 ${closing ? 'modal-panel-out' : 'modal-panel-in'}"
style=${{ borderLeftWidth: '3px', borderLeftColor: status.borderColor }}
onClick=${(e) => e.stopPropagation()}
>
<!-- Modal Header -->
<div class="flex items-center justify-between border-b px-5 py-4 ${agentHeaderClass}">
<div class="flex-1 min-w-0">
<div class="mb-1 flex items-center gap-3">
<h2 class="truncate font-display text-xl font-semibold text-bright">${session.project || session.name || session.session_id}</h2>
<div class="flex items-center gap-2 rounded-full border px-2.5 py-1 text-xs ${status.badge}">
<span class="h-2 w-2 rounded-full ${status.dot} ${status.spinning ? 'spinner-dot' : ''}" style=${{ color: status.borderColor }}></span>
<span class="font-mono uppercase tracking-[0.14em]">${status.label}</span>
</div>
<span class="rounded-full border px-2.5 py-1 font-mono text-micro uppercase tracking-[0.14em] ${agent === 'codex' ? 'border-emerald-400/45 bg-emerald-500/14 text-emerald-300' : 'border-violet-400/45 bg-violet-500/14 text-violet-300'}">
${agent}
</span>
</div>
<div class="flex items-center gap-4 text-sm text-dim">
<span class="truncate font-mono">${session.cwd || 'No working directory'}</span>
${session.started_at && html`
<span class="flex-shrink-0 font-mono">Running ${formatDuration(session.started_at)}</span>
`}
</div>
</div>
<button
class="ml-4 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl border border-selection/70 text-dim transition-colors duration-150 hover:border-done/35 hover:bg-done/10 hover:text-bright"
onClick=${handleClose}
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Modal Content -->
<div ref=${chatContainerRef} class="flex-1 overflow-y-auto overflow-x-hidden bg-surface p-5">
${conversationLoading ? html`
<div class="flex items-center justify-center py-12 animate-fade-in-up">
<div class="font-mono text-dim">Loading conversation...</div>
</div>
` : displayMessages.length > 0 ? html`
<div class="space-y-4">
${displayMessages.map((msg, i) => html`
<${MessageBubble}
key=${`${msg.role}-${msg.timestamp || i}`}
msg=${msg}
userBg=${getUserMessageBg(session.status)}
compact=${false}
formatTime=${formatTime}
<div class=${closing ? 'modal-panel-out' : 'modal-panel-in'} onClick=${(e) => e.stopPropagation()}>
<${SessionCard}
session=${session}
conversation=${conversation}
onFetchConversation=${onFetchConversation}
onRespond=${onRespond}
onDismiss=${onDismiss}
enlarged=${true}
autocompleteConfig=${autocompleteConfig}
/>
`)}
</div>
` : html`
<p class="text-dim text-center py-12">No conversation messages</p>
`}
</div>
<!-- Modal Footer -->
<div class="border-t border-selection/70 bg-bg/55 p-4">
${hasPendingQuestions && html`
<div class="mb-3 rounded-xl border border-attention/40 bg-attention/12 px-3 py-2 text-sm text-attention">
Agent is waiting for a response
</div>
`}
<div class="flex items-end gap-2.5">
<textarea
ref=${inputRef}
value=${inputValue}
onInput=${(e) => {
setInputValue(e.target.value);
e.target.style.height = 'auto';
e.target.style.height = Math.min(e.target.scrollHeight, 150) + 'px';
}}
onKeyDown=${handleInputKeyDown}
onFocus=${() => setInputFocused(true)}
onBlur=${() => setInputFocused(false)}
placeholder=${hasPendingQuestions ? "Type your response..." : "Send a message..."}
rows="1"
class="flex-1 resize-none overflow-hidden rounded-xl border border-selection/75 bg-bg/70 px-4 py-2 text-sm text-fg transition-colors duration-150 placeholder:text-dim focus:outline-none"
style=${{ minHeight: '42px', maxHeight: '150px', borderColor: inputFocused ? status.borderColor : undefined }}
disabled=${sending}
/>
<button
class="rounded-xl px-4 py-2 font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
style=${{ backgroundColor: status.borderColor, color: '#0a0f18' }}
onClick=${handleSend}
disabled=${sending || !inputValue.trim()}
>
${sending ? 'Sending...' : 'Send'}
</button>
</div>
<div class="mt-2 font-mono text-label text-dim">
Press Enter to send, Shift+Enter for new line, Escape to close
</div>
</div>
</div>
</div>
`;

View File

@@ -1,17 +1,23 @@
import { html } from '../lib/preact.js';
export function OptionButton({ number, label, description, onClick }) {
export function OptionButton({ number, label, description, selected, onClick, onMouseEnter, onFocus }) {
const selectedStyles = selected
? 'border-starting/60 bg-starting/15 shadow-sm'
: 'border-selection/70 bg-surface2/55';
return html`
<button
onClick=${onClick}
class="group w-full rounded-xl border border-selection/70 bg-surface2/55 p-3.5 text-left transition-[transform,border-color,background-color,box-shadow] duration-200 hover:-translate-y-0.5 hover:border-starting/55 hover:bg-surface2/90 hover:shadow-halo"
onMouseEnter=${onMouseEnter}
onFocus=${onFocus}
class="group w-full rounded-lg border px-3 py-2 text-left transition-[transform,border-color,background-color,box-shadow] duration-200 hover:-translate-y-0.5 hover:border-starting/55 hover:bg-surface2/90 hover:shadow-halo ${selectedStyles}"
>
<div class="flex items-baseline gap-2.5">
<span class="font-mono text-starting">${number}.</span>
<span class="font-medium text-bright">${label}</span>
<div class="flex items-baseline gap-2">
<span class="font-mono text-sm text-starting">${number}.</span>
<span class="text-sm font-medium text-bright">${label}</span>
</div>
${description && html`
<p class="mt-1 pl-5 text-sm text-dim">${description}</p>
<p class="mt-0.5 pl-4 text-xs text-dim">${description}</p>
`}
</button>
`;

View File

@@ -1,10 +1,15 @@
import { html, useState } from '../lib/preact.js';
import { html, useState, useRef } from '../lib/preact.js';
import { getStatusMeta } from '../utils/status.js';
import { OptionButton } from './OptionButton.js';
import { renderContent } from '../lib/markdown.js';
export function QuestionBlock({ questions, sessionId, status, onRespond }) {
const [freeformText, setFreeformText] = useState('');
const [focused, setFocused] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState(null);
const [previewIndex, setPreviewIndex] = useState(0);
const textareaRef = useRef(null);
const meta = getStatusMeta(status);
if (!questions || questions.length === 0) return null;
@@ -14,21 +19,58 @@ export function QuestionBlock({ questions, sessionId, status, onRespond }) {
const remainingCount = questions.length - 1;
const options = question.options || [];
const handleOptionClick = (optionLabel) => {
onRespond(sessionId, optionLabel, false, options.length);
};
// Check if any option has markdown preview content
const hasMarkdownPreviews = options.some(opt => opt.markdown);
const handleFreeformSubmit = (e) => {
e.preventDefault();
e.stopPropagation();
if (freeformText.trim()) {
onRespond(sessionId, freeformText.trim(), true, options.length);
setFreeformText('');
const handleOptionClick = async (optionLabel) => {
if (sending) return;
setSending(true);
setError(null);
try {
await onRespond(sessionId, optionLabel, false, options.length);
} catch (err) {
setError('Failed to send response');
console.error('QuestionBlock option error:', err);
} finally {
setSending(false);
}
};
const handleFreeformSubmit = async (e) => {
e.preventDefault();
e.stopPropagation();
if (freeformText.trim() && !sending) {
setSending(true);
setError(null);
try {
await onRespond(sessionId, freeformText.trim(), true, options.length);
setFreeformText('');
} catch (err) {
setError('Failed to send response');
console.error('QuestionBlock freeform error:', err);
} finally {
setSending(false);
// Refocus the textarea after submission
// Use setTimeout to ensure React has re-rendered with disabled=false
setTimeout(() => {
textareaRef.current?.focus();
}, 0);
}
}
};
// Side-by-side layout when options have markdown previews
if (hasMarkdownPreviews) {
const currentMarkdown = options[previewIndex]?.markdown || '';
return html`
<div class="space-y-3" onClick=${(e) => e.stopPropagation()}>
<div class="space-y-2.5" onClick=${(e) => e.stopPropagation()}>
${error && html`
<div class="rounded-lg border border-attention/40 bg-attention/12 px-3 py-1.5 text-sm text-attention">
${error}
</div>
`}
<!-- Question Header Badge -->
${question.header && html`
<span class="inline-flex rounded-full border px-2 py-1 font-mono text-micro uppercase tracking-[0.15em] ${meta.badge}">
@@ -39,24 +81,39 @@ export function QuestionBlock({ questions, sessionId, status, onRespond }) {
<!-- Question Text -->
<p class="font-medium text-bright">${question.question || question.text}</p>
<!-- Options -->
${options.length > 0 && html`
<div class="space-y-2">
<!-- Side-by-side: Options | Preview -->
<div class="flex gap-3">
<!-- Options List (left side) -->
<div class="w-2/5 space-y-1.5 shrink-0">
${options.map((opt, i) => html`
<${OptionButton}
key=${i}
number=${i + 1}
label=${opt.label || opt}
description=${opt.description}
selected=${previewIndex === i}
onMouseEnter=${() => setPreviewIndex(i)}
onFocus=${() => setPreviewIndex(i)}
onClick=${() => handleOptionClick(opt.label || opt)}
/>
`)}
</div>
`}
<!-- Preview Pane (right side) — fixed height prevents layout thrashing on hover -->
<div class="flex-1 rounded-lg border border-selection/50 bg-bg/60 p-3 h-[400px] overflow-auto">
${currentMarkdown
? (currentMarkdown.trimStart().startsWith('```')
? renderContent(currentMarkdown)
: html`<pre class="font-mono text-sm text-fg/90 whitespace-pre leading-relaxed">${currentMarkdown}</pre>`)
: html`<p class="text-dim text-sm italic">No preview for this option</p>`
}
</div>
</div>
<!-- Freeform Input -->
<form onSubmit=${handleFreeformSubmit} class="flex items-end gap-2.5">
<textarea
ref=${textareaRef}
value=${freeformText}
onInput=${(e) => {
setFreeformText(e.target.value);
@@ -75,13 +132,90 @@ export function QuestionBlock({ questions, sessionId, status, onRespond }) {
rows="1"
class="flex-1 resize-none overflow-hidden rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-fg transition-colors duration-150 placeholder:text-dim focus:outline-none"
style=${{ minHeight: '38px', maxHeight: '150px', borderColor: focused ? meta.borderColor : undefined }}
disabled=${sending}
/>
<button
type="submit"
class="shrink-0 rounded-xl px-3 py-2 text-sm font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110"
class="shrink-0 rounded-xl px-3 py-2 text-sm font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
style=${{ backgroundColor: meta.borderColor, color: '#0a0f18' }}
disabled=${sending || !freeformText.trim()}
>
Send
${sending ? 'Sending...' : 'Send'}
</button>
</form>
<!-- More Questions Indicator -->
${remainingCount > 0 && html`
<p class="font-mono text-label text-dim">+ ${remainingCount} more question${remainingCount > 1 ? 's' : ''} after this</p>
`}
</div>
`;
}
// Standard layout (no markdown previews)
return html`
<div class="space-y-2.5" onClick=${(e) => e.stopPropagation()}>
${error && html`
<div class="rounded-lg border border-attention/40 bg-attention/12 px-3 py-1.5 text-sm text-attention">
${error}
</div>
`}
<!-- Question Header Badge -->
${question.header && html`
<span class="inline-flex rounded-full border px-2 py-1 font-mono text-micro uppercase tracking-[0.15em] ${meta.badge}">
${question.header}
</span>
`}
<!-- Question Text -->
<p class="font-medium text-bright">${question.question || question.text}</p>
<!-- Options -->
${options.length > 0 && html`
<div class="space-y-1.5">
${options.map((opt, i) => html`
<${OptionButton}
key=${i}
number=${i + 1}
label=${opt.label || opt}
description=${opt.description}
onClick=${() => handleOptionClick(opt.label || opt)}
/>
`)}
</div>
`}
<!-- Freeform Input -->
<form onSubmit=${handleFreeformSubmit} class="flex items-end gap-2.5">
<textarea
ref=${textareaRef}
value=${freeformText}
onInput=${(e) => {
setFreeformText(e.target.value);
e.target.style.height = 'auto';
e.target.style.height = e.target.scrollHeight + 'px';
}}
onKeyDown=${(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleFreeformSubmit(e);
}
}}
onFocus=${() => setFocused(true)}
onBlur=${() => setFocused(false)}
placeholder="Type a response..."
rows="1"
class="flex-1 resize-none overflow-hidden rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-fg transition-colors duration-150 placeholder:text-dim focus:outline-none"
style=${{ minHeight: '38px', maxHeight: '150px', borderColor: focused ? meta.borderColor : undefined }}
disabled=${sending}
/>
<button
type="submit"
class="shrink-0 rounded-xl px-3 py-2 text-sm font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
style=${{ backgroundColor: meta.borderColor, color: '#0a0f18' }}
disabled=${sending || !freeformText.trim()}
>
${sending ? 'Sending...' : 'Send'}
</button>
</form>

View File

@@ -4,8 +4,9 @@ import { formatDuration, getContextUsageSummary } from '../utils/formatting.js';
import { ChatMessages } from './ChatMessages.js';
import { QuestionBlock } from './QuestionBlock.js';
import { SimpleInput } from './SimpleInput.js';
import { AgentActivityIndicator } from './AgentActivityIndicator.js';
export function SessionCard({ session, onClick, conversation, onFetchConversation, onRespond, onDismiss }) {
export function SessionCard({ session, onClick, conversation, onFetchConversation, onRespond, onDismiss, enlarged = false, autocompleteConfig = null, isNewlySpawned = false }) {
const hasQuestions = session.pending_questions && session.pending_questions.length > 0;
const statusMeta = getStatusMeta(session.status);
const agent = session.agent === 'codex' ? 'codex' : 'claude';
@@ -20,23 +21,79 @@ export function SessionCard({ session, onClick, conversation, onFetchConversatio
}, [session.session_id, session.project_dir, agent, conversation, onFetchConversation]);
const chatPaneRef = useRef(null);
const stickyToBottomRef = useRef(true); // Start in "sticky" mode
const scrollUpAccumulatorRef = useRef(0); // Track cumulative scroll-up distance
const prevConversationLenRef = useRef(0);
// Scroll chat pane to bottom when conversation loads or updates
// Track user intent via wheel events (only fires from actual user scrolling)
useEffect(() => {
const el = chatPaneRef.current;
if (el) el.scrollTop = el.scrollHeight;
if (!el) return;
const handleWheel = (e) => {
// User scrolling up - accumulate distance before disabling sticky
if (e.deltaY < 0) {
scrollUpAccumulatorRef.current += Math.abs(e.deltaY);
// Only disable sticky mode after scrolling up ~50px (meaningful intent)
if (scrollUpAccumulatorRef.current > 50) {
stickyToBottomRef.current = false;
}
}
// User scrolling down - reset accumulator and check if near bottom
if (e.deltaY > 0) {
scrollUpAccumulatorRef.current = 0;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
if (distanceFromBottom < 100) {
stickyToBottomRef.current = true;
}
}
};
el.addEventListener('wheel', handleWheel, { passive: true });
return () => el.removeEventListener('wheel', handleWheel);
}, []);
// Auto-scroll when conversation changes
useEffect(() => {
const el = chatPaneRef.current;
if (!el || !conversation) return;
const prevLen = prevConversationLenRef.current;
const currLen = conversation.length;
const hasNewMessages = currLen > prevLen;
const isFirstLoad = prevLen === 0 && currLen > 0;
// Check if user just submitted (always scroll for their own messages)
const lastMsg = conversation[currLen - 1];
const userJustSubmitted = hasNewMessages && lastMsg?.role === 'user';
prevConversationLenRef.current = currLen;
// Auto-scroll if in sticky mode, first load, or user just submitted
if (isFirstLoad || userJustSubmitted || (hasNewMessages && stickyToBottomRef.current)) {
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight;
});
}
}, [conversation]);
const handleDismissClick = (e) => {
e.stopPropagation();
onDismiss(session.session_id);
if (onDismiss) onDismiss(session.session_id);
};
// Container classes differ based on enlarged mode
const spawnClass = isNewlySpawned ? ' session-card-spawned' : '';
const containerClasses = enlarged
? 'glass-panel flex w-full max-w-[90vw] max-h-[90vh] flex-col overflow-hidden rounded-2xl border border-selection/80'
: 'glass-panel flex h-[850px] max-h-[850px] w-[600px] cursor-pointer flex-col overflow-hidden rounded-xl border border-selection/70 transition-[border-color,box-shadow] duration-200 hover:border-starting/35 hover:shadow-panel' + spawnClass;
return html`
<div
class="glass-panel flex h-[850px] max-h-[850px] w-[600px] cursor-pointer flex-col overflow-hidden rounded-xl border border-selection/70 transition-[border-color,box-shadow] duration-200 hover:border-starting/35 hover:shadow-panel"
class=${containerClasses}
style=${{ borderLeftWidth: '3px', borderLeftColor: statusMeta.borderColor }}
onClick=${() => onClick(session)}
onClick=${enlarged ? undefined : () => onClick && onClick(session)}
>
<!-- Card Header -->
<div class="shrink-0 border-b px-4 py-3 ${agentHeaderClass}">
@@ -53,19 +110,12 @@ export function SessionCard({ session, onClick, conversation, onFetchConversatio
<span class="rounded-full border px-2.5 py-1 font-mono text-micro uppercase tracking-[0.14em] ${agent === 'codex' ? 'border-emerald-400/45 bg-emerald-500/14 text-emerald-300' : 'border-violet-400/45 bg-violet-500/14 text-violet-300'}">
${agent}
</span>
${session.cwd && html`
${session.project_dir && html`
<span class="truncate rounded-full border border-selection bg-bg/40 px-2.5 py-1 font-mono text-micro text-dim">
${session.cwd.split('/').slice(-2).join('/')}
${session.project_dir.split('/').slice(-2).join('/')}
</span>
`}
</div>
${contextUsage && html`
<div class="mt-2 inline-flex max-w-full items-center gap-2 rounded-lg border border-selection/80 bg-bg/45 px-2.5 py-1.5 font-mono text-label text-dim" title=${contextUsage.title}>
<span class="text-bright">${contextUsage.headline}</span>
<span class="truncate">${contextUsage.detail}</span>
${contextUsage.trail && html`<span class="text-dim/80">${contextUsage.trail}</span>`}
</div>
`}
</div>
<div class="flex items-center gap-3 shrink-0 pt-0.5">
<span class="font-mono text-xs tabular-nums text-dim">${formatDuration(session.started_at)}</span>
@@ -86,11 +136,24 @@ export function SessionCard({ session, onClick, conversation, onFetchConversatio
<!-- Card Content Area (Chat) -->
<div ref=${chatPaneRef} class="flex-1 min-h-0 overflow-y-auto bg-surface px-4 py-4">
<${ChatMessages} messages=${conversation || []} status=${session.status} />
<${ChatMessages} messages=${conversation || []} status=${session.status} limit=${enlarged ? null : 20} />
</div>
<!-- Card Footer (Input or Questions) -->
<div class="shrink-0 border-t border-selection/70 bg-bg/55 p-4 ${hasQuestions ? 'max-h-[300px] overflow-y-auto' : ''}">
<!-- Card Footer (Status + Input/Questions) -->
<div class="shrink-0 border-t border-selection/70 bg-bg/55">
<!-- Session Status Area -->
<div class="flex items-center justify-between gap-3 px-4 py-2 border-b border-selection/50 bg-bg/60">
<${AgentActivityIndicator} session=${session} />
${contextUsage && html`
<div class="flex items-center gap-2 rounded-lg border border-selection/80 bg-bg/45 px-2.5 py-1.5 font-mono text-label text-dim" title=${contextUsage.title}>
<span class="text-bright">${contextUsage.headline}</span>
<span class="truncate">${contextUsage.detail}</span>
${contextUsage.trail && html`<span class="text-dim/80">${contextUsage.trail}</span>`}
</div>
`}
</div>
<!-- Actions Area -->
<div class="p-4">
${hasQuestions ? html`
<${QuestionBlock}
questions=${session.pending_questions}
@@ -103,9 +166,12 @@ export function SessionCard({ session, onClick, conversation, onFetchConversatio
sessionId=${session.session_id}
status=${session.status}
onRespond=${onRespond}
autocompleteConfig=${autocompleteConfig}
conversation=${conversation}
/>
`}
</div>
</div>
</div>
`;
}

View File

@@ -1,56 +0,0 @@
import { html } from '../lib/preact.js';
import { getStatusMeta, STATUS_PRIORITY } from '../utils/status.js';
import { SessionCard } from './SessionCard.js';
export function SessionGroup({ projectName, projectDir, sessions, onCardClick, conversations, onFetchConversation, onRespond, onDismiss }) {
if (sessions.length === 0) return null;
// Status summary for chips
const statusCounts = {};
for (const s of sessions) {
statusCounts[s.status] = (statusCounts[s.status] || 0) + 1;
}
// Group header dot uses the most urgent status
const worstStatus = sessions.reduce((worst, s) => {
return (STATUS_PRIORITY[s.status] ?? 99) < (STATUS_PRIORITY[worst] ?? 99) ? s.status : worst;
}, 'done');
const worstMeta = getStatusMeta(worstStatus);
return html`
<section class="mb-12">
<div class="mb-4 flex flex-wrap items-center gap-2.5 border-b border-selection/50 pb-3">
<span class="h-2.5 w-2.5 rounded-full ${worstMeta.dot}"></span>
<h2 class="font-display text-body font-semibold text-bright">${projectName}</h2>
<span class="rounded-full border border-selection/80 bg-bg/55 px-2 py-0.5 font-mono text-micro text-dim">
${sessions.length} agent${sessions.length === 1 ? '' : 's'}
</span>
${Object.entries(statusCounts).map(([status, count]) => {
const meta = getStatusMeta(status);
return html`
<span key=${status} class="rounded-full border px-2 py-0.5 font-mono text-micro ${meta.badge}">
${count} ${meta.label.toLowerCase()}
</span>
`;
})}
</div>
${projectDir && projectDir !== 'unknown' && html`
<div class="-mt-2 mb-3 truncate font-mono text-micro text-dim/60">${projectDir}</div>
`}
<div class="flex flex-wrap gap-4">
${sessions.map(session => html`
<${SessionCard}
key=${session.session_id}
session=${session}
onClick=${onCardClick}
conversation=${conversations[session.session_id]}
onFetchConversation=${onFetchConversation}
onRespond=${onRespond}
onDismiss=${onDismiss}
/>
`)}
</div>
</section>
`;
}

View File

@@ -1,30 +1,235 @@
import { html, useState } from '../lib/preact.js';
import { html, useState, useRef, useCallback, useMemo, useEffect } from '../lib/preact.js';
import { getStatusMeta } from '../utils/status.js';
import { getTriggerInfo as _getTriggerInfo, filteredSkills as _filteredSkills } from '../utils/autocomplete.js';
export function SimpleInput({ sessionId, status, onRespond }) {
export function SimpleInput({ sessionId, status, onRespond, autocompleteConfig = null, conversation }) {
const [text, setText] = useState('');
const [focused, setFocused] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState(null);
const [triggerInfo, setTriggerInfo] = useState(null);
const [showAutocomplete, setShowAutocomplete] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const textareaRef = useRef(null);
const autocompleteRef = useRef(null);
const historyIndexRef = useRef(-1);
const draftRef = useRef('');
const meta = getStatusMeta(status);
const handleSubmit = (e) => {
const userHistory = useMemo(
() => (conversation || []).filter(m => m.role === 'user').map(m => m.content),
[conversation]
);
const getTriggerInfo = useCallback((value, cursorPos) => {
return _getTriggerInfo(value, cursorPos, autocompleteConfig);
}, [autocompleteConfig]);
const filteredSkills = useMemo(() => {
return _filteredSkills(autocompleteConfig, triggerInfo);
}, [autocompleteConfig, triggerInfo]);
// Show/hide autocomplete based on trigger detection
useEffect(() => {
const shouldShow = triggerInfo !== null;
setShowAutocomplete(shouldShow);
// Reset selection when dropdown opens
if (shouldShow) {
setSelectedIndex(0);
}
}, [triggerInfo]);
// Clamp selectedIndex when filtered list changes
useEffect(() => {
if (filteredSkills.length > 0 && selectedIndex >= filteredSkills.length) {
setSelectedIndex(filteredSkills.length - 1);
}
}, [filteredSkills.length, selectedIndex]);
// Click outside dismisses dropdown
useEffect(() => {
if (!showAutocomplete) return;
const handleClickOutside = (e) => {
if (autocompleteRef.current && !autocompleteRef.current.contains(e.target) &&
textareaRef.current && !textareaRef.current.contains(e.target)) {
setShowAutocomplete(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showAutocomplete]);
// Scroll selected item into view when navigating with arrow keys
useEffect(() => {
if (showAutocomplete && autocompleteRef.current) {
const container = autocompleteRef.current;
const selectedEl = container.children[selectedIndex];
if (selectedEl) {
selectedEl.scrollIntoView({ block: 'nearest' });
}
}
}, [selectedIndex, showAutocomplete]);
// Insert a selected skill into the text
const insertSkill = useCallback((skill) => {
if (!triggerInfo || !autocompleteConfig) return;
const { trigger } = autocompleteConfig;
const { replaceStart, replaceEnd } = triggerInfo;
const before = text.slice(0, replaceStart);
const after = text.slice(replaceEnd);
const inserted = `${trigger}${skill.name} `;
setText(before + inserted + after);
setShowAutocomplete(false);
setTriggerInfo(null);
// Move cursor after inserted text
const newCursorPos = replaceStart + inserted.length;
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart = newCursorPos;
textareaRef.current.selectionEnd = newCursorPos;
textareaRef.current.focus();
}
}, 0);
}, [text, triggerInfo, autocompleteConfig]);
const handleSubmit = async (e) => {
e.preventDefault();
e.stopPropagation();
if (text.trim()) {
onRespond(sessionId, text.trim(), true, 0);
if (text.trim() && !sending) {
setSending(true);
setError(null);
try {
await onRespond(sessionId, text.trim(), true, 0);
setText('');
historyIndexRef.current = -1;
} catch (err) {
setError('Failed to send message');
console.error('SimpleInput send error:', err);
} finally {
setSending(false);
// Refocus the textarea after submission
// Use setTimeout to ensure React has re-rendered with disabled=false
setTimeout(() => {
textareaRef.current?.focus();
}, 0);
}
}
};
return html`
<form onSubmit=${handleSubmit} class="flex items-end gap-2.5" onClick=${(e) => e.stopPropagation()}>
<form onSubmit=${handleSubmit} class="flex flex-col gap-2" onClick=${(e) => e.stopPropagation()}>
${error && html`
<div class="rounded-lg border border-attention/40 bg-attention/12 px-3 py-1.5 text-sm text-attention">
${error}
</div>
`}
<div class="flex items-end gap-2.5">
<div class="relative flex-1">
<textarea
ref=${textareaRef}
value=${text}
onInput=${(e) => {
setText(e.target.value);
const value = e.target.value;
const cursorPos = e.target.selectionStart;
setText(value);
historyIndexRef.current = -1;
setTriggerInfo(getTriggerInfo(value, cursorPos));
e.target.style.height = 'auto';
e.target.style.height = e.target.scrollHeight + 'px';
}}
onKeyDown=${(e) => {
if (showAutocomplete) {
// Escape dismisses dropdown
if (e.key === 'Escape') {
e.preventDefault();
setShowAutocomplete(false);
return;
}
// Enter/Tab: select if matches exist, otherwise dismiss
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
if (filteredSkills.length > 0 && filteredSkills[selectedIndex]) {
insertSkill(filteredSkills[selectedIndex]);
} else {
setShowAutocomplete(false);
}
return;
}
// Arrow navigation
if (filteredSkills.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(i => Math.min(i + 1, filteredSkills.length - 1));
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(i => Math.max(i - 1, 0));
return;
}
}
}
// History navigation (only when autocomplete is closed)
if (e.key === 'ArrowUp' && !showAutocomplete &&
e.target.selectionStart === 0 && e.target.selectionEnd === 0 &&
userHistory.length > 0) {
e.preventDefault();
if (historyIndexRef.current === -1) {
draftRef.current = text;
historyIndexRef.current = userHistory.length - 1;
} else if (historyIndexRef.current > 0) {
historyIndexRef.current -= 1;
}
// Clamp if history shrank since last navigation
if (historyIndexRef.current >= userHistory.length) {
historyIndexRef.current = userHistory.length - 1;
}
const historyText = userHistory[historyIndexRef.current];
setText(historyText);
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart = historyText.length;
textareaRef.current.selectionEnd = historyText.length;
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
}
}, 0);
return;
}
if (e.key === 'ArrowDown' && !showAutocomplete &&
historyIndexRef.current !== -1) {
e.preventDefault();
historyIndexRef.current += 1;
let newText;
if (historyIndexRef.current >= userHistory.length) {
historyIndexRef.current = -1;
newText = draftRef.current;
} else {
newText = userHistory[historyIndexRef.current];
}
setText(newText);
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart = newText.length;
textareaRef.current.selectionEnd = newText.length;
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
}
}, 0);
return;
}
// Normal Enter-to-submit (only when dropdown is closed)
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
@@ -34,16 +239,50 @@ export function SimpleInput({ sessionId, status, onRespond }) {
onBlur=${() => setFocused(false)}
placeholder="Send a message..."
rows="1"
class="flex-1 resize-none overflow-hidden rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-fg transition-colors duration-150 placeholder:text-dim focus:outline-none"
class="w-full resize-none overflow-hidden rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-fg transition-colors duration-150 placeholder:text-dim focus:outline-none"
style=${{ minHeight: '38px', maxHeight: '150px', borderColor: focused ? meta.borderColor : undefined }}
disabled=${sending}
/>
${showAutocomplete && html`
<div
ref=${autocompleteRef}
class="absolute left-0 bottom-full mb-1 w-full max-h-48 overflow-y-auto rounded-lg border border-selection/75 bg-surface shadow-lg z-50"
>
${autocompleteConfig.skills.length === 0 ? html`
<div class="px-3 py-2 text-sm text-dim">No skills available</div>
` : filteredSkills.length === 0 ? html`
<div class="px-3 py-2 text-sm text-dim">No matching skills</div>
` : filteredSkills.map((skill, i) => html`
<div
key=${skill.name}
class="group relative px-3 py-1.5 cursor-pointer text-sm font-mono transition-colors ${
i === selectedIndex
? 'bg-selection/50 text-bright'
: 'text-fg hover:bg-selection/25'
}"
onClick=${() => insertSkill(skill)}
onMouseEnter=${() => setSelectedIndex(i)}
>
${autocompleteConfig.trigger}${skill.name}
${i === selectedIndex && skill.description && html`
<div class="absolute left-full top-0 ml-2 w-64 px-2.5 py-1.5 rounded-md border border-selection/75 bg-surface shadow-lg text-micro text-dim font-sans whitespace-normal z-50">
${skill.description}
</div>
`}
</div>
`)}
</div>
`}
</div>
<button
type="submit"
class="shrink-0 rounded-xl px-3 py-2 text-sm font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110"
class="shrink-0 rounded-xl px-3 py-2 text-sm font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
style=${{ backgroundColor: meta.borderColor, color: '#0a0f18' }}
disabled=${sending || !text.trim()}
>
Send
${sending ? 'Sending...' : 'Send'}
</button>
</div>
</form>
`;
}

View File

@@ -0,0 +1,241 @@
import { html, useState, useEffect, useCallback, useRef } from '../lib/preact.js';
import { API_PROJECTS, API_SPAWN, fetchWithTimeout, API_TIMEOUT_MS } from '../utils/api.js';
// Spawn needs longer timeout: pending spawn registry requires discovery cycle to run,
// plus server polls for session file confirmation
const SPAWN_TIMEOUT_MS = API_TIMEOUT_MS * 2;
export function SpawnModal({ isOpen, onClose, onSpawn, currentProject }) {
const [projects, setProjects] = useState([]);
const [selectedProject, setSelectedProject] = useState('');
const [agentType, setAgentType] = useState('claude');
const [loading, setLoading] = useState(false);
const [loadingProjects, setLoadingProjects] = useState(false);
const [closing, setClosing] = useState(false);
const [error, setError] = useState(null);
const needsProjectPicker = !currentProject;
const dropdownRef = useCallback((node) => {
if (node) dropdownNodeRef.current = node;
}, []);
const dropdownNodeRef = useRef(null);
// Click outside dismisses dropdown
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e) => {
if (dropdownNodeRef.current && !dropdownNodeRef.current.contains(e.target)) {
handleClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
// Reset state on open
useEffect(() => {
if (isOpen) {
setAgentType('claude');
setError(null);
setLoading(false);
setClosing(false);
}
}, [isOpen]);
// Fetch projects when needed
useEffect(() => {
if (isOpen && needsProjectPicker) {
setLoadingProjects(true);
fetchWithTimeout(API_PROJECTS)
.then(r => r.json())
.then(data => {
setProjects(data.projects || []);
setSelectedProject('');
})
.catch(err => setError(err.message))
.finally(() => setLoadingProjects(false));
}
}, [isOpen, needsProjectPicker]);
// Animated close handler
const handleClose = useCallback(() => {
if (loading) return;
setClosing(true);
setTimeout(() => {
setClosing(false);
onClose();
}, 200);
}, [loading, onClose]);
// Handle escape key
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e) => {
if (e.key === 'Escape') handleClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, handleClose]);
const handleSpawn = async () => {
const rawProject = currentProject || selectedProject;
if (!rawProject) {
setError('Please select a project');
return;
}
// Extract project name from full path (sidebar passes projectDir like "/Users/.../projects/amc")
const project = rawProject.includes('/') ? rawProject.split('/').pop() : rawProject;
setLoading(true);
setError(null);
try {
const response = await fetchWithTimeout(API_SPAWN, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${window.AMC_AUTH_TOKEN}`,
},
body: JSON.stringify({ project, agent_type: agentType }),
}, SPAWN_TIMEOUT_MS);
const data = await response.json();
if (data.ok) {
onSpawn({ success: true, project, agentType, spawnId: data.spawn_id });
handleClose();
} else {
setError(data.error || 'Spawn failed');
onSpawn({ error: data.error });
}
} catch (err) {
const msg = err.name === 'AbortError' ? 'Request timed out' : err.message;
setError(msg);
onSpawn({ error: msg });
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
const canSpawn = !loading && (currentProject || selectedProject);
return html`
<div
ref=${dropdownRef}
class="absolute right-0 top-full mt-2 z-50 glass-panel w-80 rounded-xl border border-selection/70 shadow-lg ${closing ? 'modal-panel-out' : 'modal-panel-in'}"
onClick=${(e) => e.stopPropagation()}
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-selection/70 px-4 py-3">
<h2 class="font-display text-sm font-semibold text-bright">Spawn Agent</h2>
<button
onClick=${handleClose}
disabled=${loading}
class="flex h-6 w-6 items-center justify-center rounded-lg border border-selection/80 text-dim transition-colors hover:border-done/40 hover:bg-done/10 hover:text-bright disabled:cursor-not-allowed disabled:opacity-50"
>
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Body -->
<div class="flex flex-col gap-3 px-4 py-3">
${needsProjectPicker && html`
<div class="flex flex-col gap-1.5">
<label class="text-label font-medium text-dim">Project</label>
${loadingProjects ? html`
<div class="flex items-center gap-2 rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-dim">
<span class="working-dots"><span>.</span><span>.</span><span>.</span></span>
Loading projects
</div>
` : projects.length === 0 ? html`
<div class="rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-dim">
No projects found in ~/projects/
</div>
` : html`
<select
value=${selectedProject}
onChange=${(e) => { setSelectedProject(e.target.value); setError(null); }}
class="w-full rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-fg transition-colors duration-150 focus:border-starting/60 focus:outline-none"
>
<option value="" disabled>Select a project...</option>
${projects.map(p => html`
<option key=${p} value=${p}>${p}</option>
`)}
</select>
`}
</div>
`}
${currentProject && html`
<div class="flex flex-col gap-1.5">
<label class="text-label font-medium text-dim">Project</label>
<div class="rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-bright">
${currentProject.includes('/') ? currentProject.split('/').pop() : currentProject}
</div>
</div>
`}
<!-- Agent type -->
<div class="flex flex-col gap-1.5">
<label class="text-label font-medium text-dim">Agent Type</label>
<div class="flex gap-2">
<button
onClick=${() => setAgentType('claude')}
class="flex-1 rounded-xl border px-3 py-2 text-sm font-medium transition-colors duration-150 ${
agentType === 'claude'
? 'border-violet-400/45 bg-violet-500/14 text-violet-300'
: 'border-selection/75 bg-bg/70 text-dim hover:border-selection hover:text-fg'
}"
>
Claude
</button>
<button
onClick=${() => setAgentType('codex')}
class="flex-1 rounded-xl border px-3 py-2 text-sm font-medium transition-colors duration-150 ${
agentType === 'codex'
? 'border-emerald-400/45 bg-emerald-500/14 text-emerald-300'
: 'border-selection/75 bg-bg/70 text-dim hover:border-selection hover:text-fg'
}"
>
Codex
</button>
</div>
</div>
${error && html`
<div class="rounded-lg border border-attention/40 bg-attention/12 px-3 py-1.5 text-sm text-attention">
${error}
</div>
`}
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-2 border-t border-selection/70 px-4 py-2.5">
<button
onClick=${handleClose}
disabled=${loading}
class="rounded-xl border border-selection/75 bg-bg/70 px-4 py-2 text-sm font-medium text-dim transition-colors hover:border-selection hover:text-fg disabled:cursor-not-allowed disabled:opacity-50"
>
Cancel
</button>
<button
onClick=${handleSpawn}
disabled=${!canSpawn}
class="rounded-xl px-4 py-2 text-sm font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:brightness-100 ${
agentType === 'claude'
? 'bg-violet-500 text-white'
: 'bg-emerald-500 text-white'
}"
>
${loading ? 'Spawning...' : 'Spawn'}
</button>
</div>
</div>
`;
}

View File

@@ -0,0 +1,125 @@
import { html, useState, useEffect, useCallback, useRef } from '../lib/preact.js';
/**
* Lightweight toast notification system.
* Tracks error counts and surfaces persistent issues.
*/
// Singleton state for toast management (shared across components)
let toastListeners = [];
let toastIdCounter = 0;
export function showToast(message, type = 'error', duration = 5000) {
const id = ++toastIdCounter;
const toast = { id, message, type, duration };
toastListeners.forEach(listener => listener(toast));
return id;
}
export function ToastContainer() {
const [toasts, setToasts] = useState([]);
const timeoutIds = useRef(new Map());
useEffect(() => {
const listener = (toast) => {
setToasts(prev => [...prev, toast]);
if (toast.duration > 0) {
const timeoutId = setTimeout(() => {
timeoutIds.current.delete(toast.id);
setToasts(prev => prev.filter(t => t.id !== toast.id));
}, toast.duration);
timeoutIds.current.set(toast.id, timeoutId);
}
};
toastListeners.push(listener);
return () => {
toastListeners = toastListeners.filter(l => l !== listener);
// Clear all pending timeouts on unmount
timeoutIds.current.forEach(id => clearTimeout(id));
timeoutIds.current.clear();
};
}, []);
const dismiss = useCallback((id) => {
// Clear auto-dismiss timeout if exists
const timeoutId = timeoutIds.current.get(id);
if (timeoutId) {
clearTimeout(timeoutId);
timeoutIds.current.delete(id);
}
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
if (toasts.length === 0) return null;
return html`
<div class="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none">
${toasts.map(toast => html`
<div
key=${toast.id}
class="pointer-events-auto flex items-start gap-3 rounded-xl border px-4 py-3 shadow-lg backdrop-blur-sm animate-fade-in-up ${
toast.type === 'error'
? 'border-attention/50 bg-attention/15 text-attention'
: toast.type === 'success'
? 'border-active/50 bg-active/15 text-active'
: 'border-starting/50 bg-starting/15 text-starting'
}"
style=${{ maxWidth: '380px' }}
>
<div class="flex-1 text-sm font-medium">${toast.message}</div>
<button
onClick=${() => dismiss(toast.id)}
class="shrink-0 rounded p-0.5 opacity-70 transition-opacity hover:opacity-100"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
`)}
</div>
`;
}
/**
* Error tracker for surfacing repeated failures.
* Tracks errors by key and shows toast after threshold.
*/
const errorCounts = {};
const ERROR_THRESHOLD = 3;
const ERROR_WINDOW_MS = 30000; // 30 second window
export function trackError(key, message, { log = true, threshold = ERROR_THRESHOLD } = {}) {
const now = Date.now();
// Always log
if (log) {
console.error(`[${key}]`, message);
}
// Track error count within window
if (!errorCounts[key]) {
errorCounts[key] = { count: 0, firstAt: now, lastToastAt: 0 };
}
const tracker = errorCounts[key];
// Reset if outside window
if (now - tracker.firstAt > ERROR_WINDOW_MS) {
tracker.count = 0;
tracker.firstAt = now;
}
tracker.count++;
// Surface toast after threshold, but not too frequently
if (tracker.count >= threshold && now - tracker.lastToastAt > ERROR_WINDOW_MS) {
showToast(`${message} (repeated ${tracker.count}x)`, 'error');
tracker.lastToastAt = now;
tracker.count = 0; // Reset after showing toast
}
}
export function clearErrorCount(key) {
delete errorCounts[key];
}

View File

@@ -99,6 +99,7 @@
</script>
<link rel="stylesheet" href="styles.css">
<!-- AMC_AUTH_TOKEN -->
</head>
<body class="min-h-screen text-fg antialiased">
<div id="app"></div>

View File

@@ -88,6 +88,25 @@ body {
animation: spin-ring 0.9s cubic-bezier(0.645, 0.045, 0.355, 1) infinite;
}
/* Agent activity spinner */
.activity-spinner {
width: 8px;
height: 8px;
border-radius: 50%;
background: #5fd0a4;
position: relative;
}
.activity-spinner::after {
content: '';
position: absolute;
inset: -3px;
border-radius: 50%;
border: 1.5px solid transparent;
border-top-color: #5fd0a4;
animation: spin-ring 0.9s cubic-bezier(0.645, 0.045, 0.355, 1) infinite;
}
/* Working indicator at bottom of chat */
@keyframes bounce-dot {
0%, 80%, 100% { transform: translateY(0); }
@@ -134,6 +153,16 @@ body {
animation: modalPanelOut 200ms ease-in forwards;
}
/* Spawn highlight animation - visual feedback when a newly spawned agent appears */
@keyframes spawn-highlight {
0% { box-shadow: 0 0 0 3px rgba(95, 208, 164, 0.6), 0 0 16px rgba(95, 208, 164, 0.15); }
100% { box-shadow: 0 0 0 0 transparent, 0 0 0 transparent; }
}
.session-card-spawned {
animation: spawn-highlight 2.5s ease-out;
}
/* Accessibility: disable continuous animations for motion-sensitive users */
@media (prefers-reduced-motion: reduce) {
.spinner-dot::after {
@@ -152,7 +181,8 @@ body {
animation: none;
}
.animate-float,
.animate-fade-in-up {
.animate-fade-in-up,
.session-card-spawned {
animation: none !important;
}
}

View File

@@ -0,0 +1,162 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { getTriggerInfo, filteredSkills } from '../utils/autocomplete.js';
const mockConfig = {
trigger: '/',
skills: [
{ name: 'commit', description: 'Create a git commit' },
{ name: 'review-pr', description: 'Review a pull request' },
{ name: 'comment', description: 'Add a comment' },
],
};
describe('getTriggerInfo', () => {
it('returns null when no autocompleteConfig', () => {
const result = getTriggerInfo('/hello', 1, null);
assert.equal(result, null);
});
it('returns null when autocompleteConfig is undefined', () => {
const result = getTriggerInfo('/hello', 1, undefined);
assert.equal(result, null);
});
it('detects trigger at position 0', () => {
const result = getTriggerInfo('/', 1, mockConfig);
assert.deepEqual(result, {
trigger: '/',
filterText: '',
replaceStart: 0,
replaceEnd: 1,
});
});
it('detects trigger after space', () => {
const result = getTriggerInfo('hello /co', 9, mockConfig);
assert.deepEqual(result, {
trigger: '/',
filterText: 'co',
replaceStart: 6,
replaceEnd: 9,
});
});
it('detects trigger after newline', () => {
const result = getTriggerInfo('line1\n/rev', 10, mockConfig);
assert.deepEqual(result, {
trigger: '/',
filterText: 'rev',
replaceStart: 6,
replaceEnd: 10,
});
});
it('returns null for non-trigger character', () => {
const result = getTriggerInfo('hello world', 5, mockConfig);
assert.equal(result, null);
});
it('returns null for wrong trigger (! when config expects /)', () => {
const result = getTriggerInfo('!commit', 7, mockConfig);
assert.equal(result, null);
});
it('returns null for trigger embedded in a word', () => {
const result = getTriggerInfo('path/to/file', 5, mockConfig);
assert.equal(result, null);
});
it('extracts filterText correctly', () => {
const result = getTriggerInfo('/commit', 7, mockConfig);
assert.equal(result.filterText, 'commit');
assert.equal(result.replaceStart, 0);
assert.equal(result.replaceEnd, 7);
});
it('filterText is lowercase', () => {
const result = getTriggerInfo('/CoMmIt', 7, mockConfig);
assert.equal(result.filterText, 'commit');
});
it('replaceStart and replaceEnd are correct for mid-input trigger', () => {
const result = getTriggerInfo('foo /bar', 8, mockConfig);
assert.equal(result.replaceStart, 4);
assert.equal(result.replaceEnd, 8);
});
it('works with a different trigger character', () => {
const codexConfig = { trigger: '!', skills: [] };
const result = getTriggerInfo('!test', 5, codexConfig);
assert.deepEqual(result, {
trigger: '!',
filterText: 'test',
replaceStart: 0,
replaceEnd: 5,
});
});
});
describe('filteredSkills', () => {
it('returns empty array without config', () => {
const info = { filterText: '' };
assert.deepEqual(filteredSkills(null, info), []);
});
it('returns empty array without triggerInfo', () => {
assert.deepEqual(filteredSkills(mockConfig, null), []);
});
it('returns empty array when both are null', () => {
assert.deepEqual(filteredSkills(null, null), []);
});
it('returns all skills with empty filter', () => {
const info = { filterText: '' };
const result = filteredSkills(mockConfig, info);
assert.equal(result.length, 3);
});
it('filters case-insensitively', () => {
const info = { filterText: 'com' };
const result = filteredSkills(mockConfig, info);
const names = result.map(s => s.name);
assert.ok(names.includes('commit'));
assert.ok(names.includes('comment'));
assert.ok(!names.includes('review-pr'));
});
it('matches anywhere in name', () => {
const info = { filterText: 'view' };
const result = filteredSkills(mockConfig, info);
assert.equal(result.length, 1);
assert.equal(result[0].name, 'review-pr');
});
it('sorts alphabetically', () => {
const info = { filterText: '' };
const result = filteredSkills(mockConfig, info);
const names = result.map(s => s.name);
assert.deepEqual(names, ['comment', 'commit', 'review-pr']);
});
it('returns empty array when no matches', () => {
const info = { filterText: 'zzz' };
const result = filteredSkills(mockConfig, info);
assert.deepEqual(result, []);
});
it('does not mutate the original skills array', () => {
const config = {
trigger: '/',
skills: [
{ name: 'zebra', description: 'z' },
{ name: 'alpha', description: 'a' },
],
};
const info = { filterText: '' };
filteredSkills(config, info);
assert.equal(config.skills[0].name, 'zebra');
assert.equal(config.skills[1].name, 'alpha');
});
});

View File

@@ -2,8 +2,14 @@
export const API_STATE = '/api/state';
export const API_STREAM = '/api/stream';
export const API_DISMISS = '/api/dismiss/';
export const API_DISMISS_DEAD = '/api/dismiss-dead';
export const API_RESPOND = '/api/respond/';
export const API_CONVERSATION = '/api/conversation/';
export const API_SKILLS = '/api/skills';
export const API_SPAWN = '/api/spawn';
export const API_PROJECTS = '/api/projects';
export const API_PROJECTS_REFRESH = '/api/projects/refresh';
export const API_HEALTH = '/api/health';
export const POLL_MS = 3000;
export const API_TIMEOUT_MS = 10000;
@@ -18,3 +24,16 @@ export async function fetchWithTimeout(url, options = {}, timeoutMs = API_TIMEOU
clearTimeout(timeoutId);
}
}
// Fetch autocomplete skills config for an agent type
export async function fetchSkills(agent) {
const url = `${API_SKILLS}?agent=${encodeURIComponent(agent)}`;
try {
const response = await fetchWithTimeout(url);
if (!response.ok) return null;
return await response.json();
} catch {
// Network error, timeout, or JSON parse failure - graceful degradation
return null;
}
}

View File

@@ -0,0 +1,48 @@
// Pure logic for autocomplete trigger detection and skill filtering.
// Extracted from SimpleInput.js for testability.
/**
* Detect if cursor is at a trigger position for autocomplete.
* Returns trigger info object or null.
*/
export function getTriggerInfo(value, cursorPos, autocompleteConfig) {
if (!autocompleteConfig) return null;
const { trigger } = autocompleteConfig;
// Find the start of the current "word" (after last whitespace before cursor)
let wordStart = cursorPos;
while (wordStart > 0 && !/\s/.test(value[wordStart - 1])) {
wordStart--;
}
// Check if word starts with this agent's trigger character
if (value[wordStart] === trigger) {
return {
trigger,
filterText: value.slice(wordStart + 1, cursorPos).toLowerCase(),
replaceStart: wordStart,
replaceEnd: cursorPos,
};
}
return null;
}
/**
* Filter and sort skills based on trigger info.
* Returns sorted array of matching skills.
*/
export function filteredSkills(autocompleteConfig, triggerInfo) {
if (!autocompleteConfig || !triggerInfo) return [];
const { skills } = autocompleteConfig;
const { filterText } = triggerInfo;
let filtered = filterText
? skills.filter(s => s.name.toLowerCase().includes(filterText))
: skills.slice();
// Server pre-sorts, but re-sort after filtering for stability
return filtered.sort((a, b) => a.name.localeCompare(b.name));
}

View File

@@ -74,27 +74,6 @@ export function groupSessionsByProject(sessions) {
groups.get(key).sessions.push(session);
}
const result = Array.from(groups.values());
// Sort groups: most urgent status first, then most recent activity
result.sort((a, b) => {
const aWorst = Math.min(...a.sessions.map(s => STATUS_PRIORITY[s.status] ?? 99));
const bWorst = Math.min(...b.sessions.map(s => STATUS_PRIORITY[s.status] ?? 99));
if (aWorst !== bWorst) return aWorst - bWorst;
const aRecent = Math.max(...a.sessions.map(s => new Date(s.last_event_at || 0).getTime()));
const bRecent = Math.max(...b.sessions.map(s => new Date(s.last_event_at || 0).getTime()));
return bRecent - aRecent;
});
// Sort sessions within each group: urgent first, then most recent
for (const group of result) {
group.sessions.sort((a, b) => {
const aPri = STATUS_PRIORITY[a.status] ?? 99;
const bPri = STATUS_PRIORITY[b.status] ?? 99;
if (aPri !== bPri) return aPri - bPri;
return (b.last_event_at || '').localeCompare(a.last_event_at || '');
});
}
return result;
// Return groups in API order (no status-based reordering)
return Array.from(groups.values());
}

View File

@@ -0,0 +1,214 @@
# Claude JSONL Format Specification
## File Format
- **Format:** Newline-delimited JSON (NDJSON/JSONL)
- **Encoding:** UTF-8
- **Line terminator:** `\n` (LF)
- **One JSON object per line** — no array wrapper
## Message Envelope (Common Fields)
Every line in a Claude JSONL file contains these fields:
```json
{
"parentUuid": "uuid-string or null",
"isSidechain": false,
"userType": "external",
"cwd": "/full/path/to/working/directory",
"sessionId": "session-uuid-v4",
"version": "2.1.20",
"gitBranch": "branch-name or empty string",
"type": "user|assistant|progress|system|summary|file-history-snapshot",
"message": { ... },
"uuid": "unique-message-uuid-v4",
"timestamp": "ISO-8601 timestamp"
}
```
### Field Reference
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | Yes | Message type identifier |
| `uuid` | string (uuid) | Yes* | Unique identifier for this event |
| `parentUuid` | string (uuid) or null | Yes | Links to parent message (null for root) |
| `timestamp` | string (ISO-8601) | Yes* | When event occurred (UTC) |
| `sessionId` | string (uuid) | Yes | Session identifier |
| `version` | string (semver) | Yes | Claude Code version (e.g., "2.1.20") |
| `cwd` | string (path) | Yes | Working directory at event time |
| `gitBranch` | string | No | Git branch name (empty if not in repo) |
| `isSidechain` | boolean | Yes | `true` for subagent sessions |
| `userType` | string | Yes | Always "external" for user sessions |
| `message` | object | Conditional | Message content (user/assistant types) |
| `agentId` | string | Conditional | Agent identifier (subagent sessions only) |
*May be null in metadata-only entries like `file-history-snapshot`
## Content Structure
### User Message Content
User messages have `message.content` as either:
**String (direct input):**
```json
{
"message": {
"role": "user",
"content": "Your question or instruction"
}
}
```
**Array (tool results):**
```json
{
"message": {
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01XYZ",
"content": "Tool output text"
}
]
}
}
```
### Assistant Message Content
Assistant messages always have `message.content` as an **array**:
```json
{
"message": {
"role": "assistant",
"type": "message",
"model": "claude-opus-4-5-20251101",
"id": "msg_bdrk_01Abc123",
"content": [
{"type": "thinking", "thinking": "..."},
{"type": "text", "text": "..."},
{"type": "tool_use", "id": "toolu_01XYZ", "name": "Read", "input": {...}}
],
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": {...}
}
}
```
## Content Block Types
### Text Block
```json
{
"type": "text",
"text": "Response text content"
}
```
### Thinking Block
```json
{
"type": "thinking",
"thinking": "Internal reasoning (extended thinking mode)",
"signature": "base64-signature (optional)"
}
```
### Tool Use Block
```json
{
"type": "tool_use",
"id": "toolu_01Abc123XYZ",
"name": "ToolName",
"input": {
"param1": "value1",
"param2": 123
}
}
```
### Tool Result Block
```json
{
"type": "tool_result",
"tool_use_id": "toolu_01Abc123XYZ",
"content": "Result text or structured output",
"is_error": false
}
```
## Usage Object
Token consumption reported in assistant messages:
```json
{
"usage": {
"input_tokens": 1000,
"output_tokens": 500,
"cache_creation_input_tokens": 200,
"cache_read_input_tokens": 400,
"cache_creation": {
"ephemeral_5m_input_tokens": 200,
"ephemeral_1h_input_tokens": 0
},
"service_tier": "standard"
}
}
```
| Field | Type | Description |
|-------|------|-------------|
| `input_tokens` | int | Input tokens consumed |
| `output_tokens` | int | Output tokens generated |
| `cache_creation_input_tokens` | int | Tokens used to create cache |
| `cache_read_input_tokens` | int | Tokens read from cache |
| `service_tier` | string | API tier ("standard", etc.) |
## Model Identifiers
Common model names in `message.model`:
| Model | Identifier |
|-------|------------|
| Claude Opus 4.5 | `claude-opus-4-5-20251101` |
| Claude Sonnet 4.5 | `claude-sonnet-4-5-20241022` |
| Claude Haiku 4.5 | `claude-haiku-4-5-20251001` |
## Version History
| Version | Changes |
|---------|---------|
| 2.1.20 | Extended thinking, permission modes, todos |
| 2.1.17 | Subagent support with agentId |
| 2.1.x | Progress events, hook metadata |
| 2.0.x | Basic message/tool_use/tool_result |
## Conversation Graph
Messages form a DAG (directed acyclic graph) via parent-child relationships:
```
Root (parentUuid: null)
├── User message (uuid: A)
│ └── Assistant (uuid: B, parentUuid: A)
│ ├── Progress: Tool (uuid: C, parentUuid: A)
│ └── Progress: Hook (uuid: D, parentUuid: A)
└── User message (uuid: E, parentUuid: B)
└── Assistant (uuid: F, parentUuid: E)
```
## Parsing Recommendations
1. **Line-by-line** — Don't load entire file into memory
2. **Skip invalid lines** — Wrap JSON.parse in try/catch
3. **Handle missing fields** — Check existence before access
4. **Ignore unknown types** — Format evolves with new event types
5. **Check content type** — User content can be string OR array
6. **Sum token variants** — Cache tokens may be in different fields

View File

@@ -0,0 +1,346 @@
# Claude JSONL Message Types
Complete reference for all message types in Claude Code session logs.
## Type: `user`
User input messages (prompts, instructions, tool results).
### Direct User Input
```json
{
"parentUuid": null,
"isSidechain": false,
"userType": "external",
"cwd": "/Users/dev/myproject",
"sessionId": "abc123-def456",
"version": "2.1.20",
"gitBranch": "main",
"type": "user",
"message": {
"role": "user",
"content": "Find all TODO comments in the codebase"
},
"uuid": "msg-001",
"timestamp": "2026-02-27T10:00:00.000Z",
"thinkingMetadata": {
"maxThinkingTokens": 31999
},
"todos": [],
"permissionMode": "bypassPermissions"
}
```
### Tool Results (Following Tool Calls)
```json
{
"parentUuid": "msg-002",
"type": "user",
"message": {
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01ABC",
"content": "src/api.py:45: # TODO: implement caching"
},
{
"type": "tool_result",
"tool_use_id": "toolu_01DEF",
"content": "src/utils.py:122: # TODO: add validation"
}
]
},
"uuid": "msg-003",
"timestamp": "2026-02-27T10:00:05.000Z"
}
```
**Parsing Note:** Check `typeof content === 'string'` vs `Array.isArray(content)` to distinguish user input from tool results.
## Type: `assistant`
Claude's responses including text, thinking, and tool invocations.
### Text Response
```json
{
"parentUuid": "msg-001",
"type": "assistant",
"message": {
"role": "assistant",
"type": "message",
"model": "claude-opus-4-5-20251101",
"id": "msg_bdrk_01Abc123",
"content": [
{
"type": "text",
"text": "I found 2 TODO comments in your codebase..."
}
],
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": {
"input_tokens": 1500,
"output_tokens": 200,
"cache_read_input_tokens": 800
}
},
"uuid": "msg-002",
"timestamp": "2026-02-27T10:00:02.000Z"
}
```
### With Thinking (Extended Thinking Mode)
```json
{
"type": "assistant",
"message": {
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "The user wants to find TODOs. I should use Grep to search for TODO patterns across all file types.",
"signature": "eyJhbGciOiJSUzI1NiJ9..."
},
{
"type": "text",
"text": "I'll search for TODO comments in your codebase."
}
]
}
}
```
### With Tool Calls
```json
{
"type": "assistant",
"message": {
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_01Grep123",
"name": "Grep",
"input": {
"pattern": "TODO",
"output_mode": "content"
}
}
],
"stop_reason": null
}
}
```
### Multiple Tool Calls (Parallel)
```json
{
"type": "assistant",
"message": {
"content": [
{
"type": "text",
"text": "I'll search for both TODOs and FIXMEs."
},
{
"type": "tool_use",
"id": "toolu_01A",
"name": "Grep",
"input": {"pattern": "TODO"}
},
{
"type": "tool_use",
"id": "toolu_01B",
"name": "Grep",
"input": {"pattern": "FIXME"}
}
]
}
}
```
## Type: `progress`
Progress events for hooks, tools, and async operations.
### Hook Progress
```json
{
"parentUuid": "msg-002",
"isSidechain": false,
"type": "progress",
"data": {
"type": "hook_progress",
"hookEvent": "PostToolUse",
"hookName": "PostToolUse:Grep",
"command": "node scripts/log-tool-use.js"
},
"parentToolUseID": "toolu_01Grep123",
"toolUseID": "toolu_01Grep123",
"timestamp": "2026-02-27T10:00:03.000Z",
"uuid": "prog-001"
}
```
### Bash Progress
```json
{
"type": "progress",
"data": {
"type": "bash_progress",
"status": "running",
"toolName": "Bash",
"command": "npm test"
}
}
```
### MCP Progress
```json
{
"type": "progress",
"data": {
"type": "mcp_progress",
"server": "playwright",
"tool": "browser_navigate",
"status": "complete"
}
}
```
## Type: `system`
System messages and metadata entries.
### Local Command
```json
{
"parentUuid": "msg-001",
"type": "system",
"subtype": "local_command",
"content": "<command-name>/usage</command-name>\n<command-args></command-args>",
"level": "info",
"timestamp": "2026-02-27T10:00:00.500Z",
"uuid": "sys-001",
"isMeta": false
}
```
### Turn Duration
```json
{
"type": "system",
"subtype": "turn_duration",
"slug": "project-session",
"durationMs": 65432,
"uuid": "sys-002",
"timestamp": "2026-02-27T10:01:05.000Z"
}
```
## Type: `summary`
End-of-session or context compression summaries.
```json
{
"type": "summary",
"summary": "Searched codebase for TODO comments, found 15 instances across 8 files. Prioritized by module.",
"leafUuid": "msg-010"
}
```
**Note:** `leafUuid` points to the last message included in this summary.
## Type: `file-history-snapshot`
File state tracking for undo/restore operations.
```json
{
"type": "file-history-snapshot",
"messageId": "snap-001",
"snapshot": {
"messageId": "snap-001",
"trackedFileBackups": {
"/src/api.ts": {
"path": "/src/api.ts",
"originalContent": "...",
"backupPath": "~/.claude/backups/..."
}
},
"timestamp": "2026-02-27T10:00:00.000Z"
},
"isSnapshotUpdate": false
}
```
## Codex Format (Alternative Agent)
Codex uses a different JSONL structure.
### Session Metadata (First Line)
```json
{
"type": "session_meta",
"timestamp": "2026-02-27T10:00:00.000Z",
"payload": {
"cwd": "/Users/dev/myproject",
"timestamp": "2026-02-27T10:00:00.000Z"
}
}
```
### Response Item (Messages)
```json
{
"type": "response_item",
"timestamp": "2026-02-27T10:00:05.000Z",
"payload": {
"type": "message",
"role": "assistant",
"content": [
{"text": "I found the issue..."}
]
}
}
```
### Function Call (Tool Use)
```json
{
"type": "response_item",
"payload": {
"type": "function_call",
"call_id": "call_abc123",
"name": "Grep",
"arguments": "{\"pattern\": \"TODO\"}"
}
}
```
### Reasoning (Thinking)
```json
{
"type": "response_item",
"payload": {
"type": "reasoning",
"summary": [
{"type": "summary_text", "text": "Analyzing the error..."}
]
}
}
```
## Message Type Summary
| Type | Frequency | Content |
|------|-----------|---------|
| `user` | Per prompt | User input or tool results |
| `assistant` | Per response | Text, thinking, tool calls |
| `progress` | Per hook/tool | Execution status |
| `system` | Occasional | Commands, metadata |
| `summary` | Session end | Conversation summary |
| `file-history-snapshot` | Start/end | File state tracking |

View File

@@ -0,0 +1,341 @@
# Tool Call Lifecycle
Complete documentation of how tool invocations flow through Claude JSONL logs.
## Lifecycle Overview
```
1. Assistant message with tool_use block
2. PreToolUse hook fires (optional)
3. Tool executes
4. PostToolUse hook fires (optional)
5. User message with tool_result block
6. Assistant processes result
```
## Phase 1: Tool Invocation
Claude requests a tool via `tool_use` content block:
```json
{
"type": "assistant",
"message": {
"role": "assistant",
"content": [
{
"type": "text",
"text": "I'll read that file for you."
},
{
"type": "tool_use",
"id": "toolu_01ReadFile123",
"name": "Read",
"input": {
"file_path": "/src/auth/login.ts",
"limit": 200
}
}
],
"stop_reason": null
},
"uuid": "msg-001"
}
```
### Tool Use Block Structure
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | `"tool_use"` | Yes | Block type identifier |
| `id` | string | Yes | Unique tool call ID (format: `toolu_*`) |
| `name` | string | Yes | Tool name |
| `input` | object | Yes | Tool parameters |
### Common Tool Names
| Tool | Purpose | Key Input Fields |
|------|---------|------------------|
| `Read` | Read file | `file_path`, `offset`, `limit` |
| `Edit` | Edit file | `file_path`, `old_string`, `new_string` |
| `Write` | Create file | `file_path`, `content` |
| `Bash` | Run command | `command`, `timeout` |
| `Glob` | Find files | `pattern`, `path` |
| `Grep` | Search content | `pattern`, `path`, `type` |
| `WebFetch` | Fetch URL | `url`, `prompt` |
| `WebSearch` | Search web | `query` |
| `Task` | Spawn subagent | `prompt`, `subagent_type` |
| `AskUserQuestion` | Ask user | `questions` |
## Phase 2: Hook Execution (Optional)
If hooks are configured, progress events are logged:
### PreToolUse Hook Input
```json
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../session.jsonl",
"cwd": "/Users/dev/myproject",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Read",
"tool_input": {
"file_path": "/src/auth/login.ts"
}
}
```
### Hook Progress Event
```json
{
"type": "progress",
"data": {
"type": "hook_progress",
"hookEvent": "PreToolUse",
"hookName": "security_check",
"status": "running"
},
"parentToolUseID": "toolu_01ReadFile123",
"toolUseID": "toolu_01ReadFile123",
"uuid": "prog-001"
}
```
### Hook Output (Decision)
```json
{
"decision": "allow",
"reason": "File read permitted",
"additionalContext": "Note: This file was recently modified"
}
```
## Phase 3: Tool Result
Tool output is wrapped in a user message:
```json
{
"type": "user",
"message": {
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01ReadFile123",
"content": "1\texport async function login(email: string, password: string) {\n2\t const user = await db.users.findByEmail(email);\n..."
}
]
},
"uuid": "msg-002",
"parentUuid": "msg-001"
}
```
### Tool Result Block Structure
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | `"tool_result"` | Yes | Block type identifier |
| `tool_use_id` | string | Yes | Matches `tool_use.id` |
| `content` | string | Yes | Tool output |
| `is_error` | boolean | No | True if tool failed |
### Error Results
```json
{
"type": "tool_result",
"tool_use_id": "toolu_01ReadFile123",
"content": "Error: File not found: /src/auth/login.ts",
"is_error": true
}
```
## Phase 4: Result Processing
Claude processes the result and continues:
```json
{
"type": "assistant",
"message": {
"content": [
{
"type": "thinking",
"thinking": "The login function looks correct. The issue might be in the middleware..."
},
{
"type": "text",
"text": "I see the login function. Let me check the middleware next."
},
{
"type": "tool_use",
"id": "toolu_01ReadMiddleware",
"name": "Read",
"input": {"file_path": "/src/auth/middleware.ts"}
}
]
},
"uuid": "msg-003",
"parentUuid": "msg-002"
}
```
## Parallel Tool Calls
Multiple tools can be invoked in a single message:
```json
{
"type": "assistant",
"message": {
"content": [
{"type": "tool_use", "id": "toolu_01A", "name": "Grep", "input": {"pattern": "TODO"}},
{"type": "tool_use", "id": "toolu_01B", "name": "Grep", "input": {"pattern": "FIXME"}},
{"type": "tool_use", "id": "toolu_01C", "name": "Glob", "input": {"pattern": "**/*.test.ts"}}
]
}
}
```
Results come back in the same user message:
```json
{
"type": "user",
"message": {
"content": [
{"type": "tool_result", "tool_use_id": "toolu_01A", "content": "Found 15 TODOs"},
{"type": "tool_result", "tool_use_id": "toolu_01B", "content": "Found 3 FIXMEs"},
{"type": "tool_result", "tool_use_id": "toolu_01C", "content": "tests/auth.test.ts\ntests/api.test.ts"}
]
}
}
```
## Codex Tool Format
Codex uses a different structure:
### Function Call
```json
{
"type": "response_item",
"payload": {
"type": "function_call",
"call_id": "call_abc123",
"name": "Read",
"arguments": "{\"file_path\": \"/src/auth/login.ts\"}"
}
}
```
**Note:** `arguments` is a JSON string that needs parsing.
### Function Result
```json
{
"type": "response_item",
"payload": {
"type": "function_call_result",
"call_id": "call_abc123",
"result": "File contents..."
}
}
```
## Tool Call Pairing
To reconstruct tool call history:
1. **Find tool_use blocks** in assistant messages
2. **Match by ID** to tool_result blocks in following user messages
3. **Handle parallel calls** — multiple tool_use can have multiple tool_result
```python
# Example: Pairing tool calls with results
tool_calls = {}
for line in jsonl_file:
event = json.loads(line)
if event['type'] == 'assistant':
for block in event['message']['content']:
if block['type'] == 'tool_use':
tool_calls[block['id']] = {
'name': block['name'],
'input': block['input'],
'timestamp': event['timestamp']
}
elif event['type'] == 'user':
content = event['message']['content']
if isinstance(content, list):
for block in content:
if block['type'] == 'tool_result':
call_id = block['tool_use_id']
if call_id in tool_calls:
tool_calls[call_id]['result'] = block['content']
tool_calls[call_id]['is_error'] = block.get('is_error', False)
```
## Missing Tool Results
Edge cases where tool results may be absent:
1. **Session interrupted** — User closed session mid-tool
2. **Tool timeout** — Long-running tool exceeded limits
3. **Hook blocked** — PreToolUse hook returned `block`
4. **Permission denied** — User denied tool permission
Handle by checking if tool_use has matching tool_result before assuming completion.
## Tool-Specific Formats
### Bash Tool
```json
{
"type": "tool_use",
"name": "Bash",
"input": {
"command": "npm test -- --coverage",
"timeout": 120000,
"description": "Run tests with coverage"
}
}
```
Result includes exit code context:
```json
{
"type": "tool_result",
"content": "PASS src/auth.test.ts\n...\nCoverage: 85%\n\n[Exit code: 0]"
}
```
### Task Tool (Subagent)
```json
{
"type": "tool_use",
"name": "Task",
"input": {
"description": "Research auth patterns",
"prompt": "Explore authentication implementations...",
"subagent_type": "Explore"
}
}
```
Result returns subagent output:
```json
{
"type": "tool_result",
"content": "## Research Findings\n\n1. JWT patterns...\n\nagentId: agent-abc123"
}
```

View File

@@ -0,0 +1,363 @@
# Subagent and Team Message Formats
Documentation for spawned agents, team coordination, and inter-agent messaging.
## Subagent Overview
Subagents are spawned via the `Task` tool and run in separate processes with their own transcripts.
### Spawn Relationship
```
Main Session (session-uuid.jsonl)
├── User message
├── Assistant: Task tool_use
├── [Subagent executes in separate process]
├── User message: tool_result with subagent output
└── ...
Subagent Session (session-uuid/subagents/agent-id.jsonl)
├── Subagent receives prompt
├── Subagent works (tool calls, etc.)
└── Subagent returns result
```
## Task Tool Invocation
Spawning a subagent:
```json
{
"type": "assistant",
"message": {
"content": [
{
"type": "tool_use",
"id": "toolu_01TaskSpawn",
"name": "Task",
"input": {
"description": "Research auth patterns",
"prompt": "Investigate authentication implementations in the codebase. Focus on JWT handling and session management.",
"subagent_type": "Explore",
"run_in_background": false
}
}
]
}
}
```
### Task Tool Input Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `description` | string | Yes | Short (3-5 word) description |
| `prompt` | string | Yes | Full task instructions |
| `subagent_type` | string | Yes | Agent type (Explore, Plan, etc.) |
| `run_in_background` | boolean | No | Run async without waiting |
| `model` | string | No | Override model (sonnet, opus, haiku) |
| `isolation` | string | No | "worktree" for isolated git copy |
| `team_name` | string | No | Team to join |
| `name` | string | No | Agent display name |
### Subagent Types
| Type | Tools Available | Use Case |
|------|-----------------|----------|
| `Explore` | Read-only tools | Research, search, analyze |
| `Plan` | Read-only tools | Design implementation plans |
| `general-purpose` | All tools | Full implementation |
| `claude-code-guide` | Docs tools | Answer Claude Code questions |
| Custom agents | Defined in `.claude/agents/` | Project-specific |
## Subagent Transcript Location
```
~/.claude/projects/<project-hash>/<session-id>/subagents/agent-<agent-id>.jsonl
```
## Subagent Message Format
Subagent transcripts have additional context fields:
```json
{
"parentUuid": null,
"isSidechain": true,
"userType": "external",
"cwd": "/Users/dev/myproject",
"sessionId": "subagent-session-uuid",
"version": "2.1.20",
"gitBranch": "main",
"agentId": "a3fecd5",
"type": "user",
"message": {
"role": "user",
"content": "Investigate authentication implementations..."
},
"uuid": "msg-001",
"timestamp": "2026-02-27T10:00:00.000Z"
}
```
### Key Differences from Main Session
| Field | Main Session | Subagent Session |
|-------|--------------|------------------|
| `isSidechain` | `false` | `true` |
| `agentId` | absent | present |
| `sessionId` | main session UUID | subagent session UUID |
## Task Result
When subagent completes, result returns to main session:
```json
{
"type": "user",
"message": {
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01TaskSpawn",
"content": "## Authentication Research Findings\n\n### JWT Implementation\n- Located in src/auth/jwt.ts\n- Uses RS256 algorithm\n...\n\nagentId: a3fecd5 (for resuming)"
}
]
}
}
```
**Note:** Result includes `agentId` for potential resumption.
## Background Tasks
For `run_in_background: true`:
```json
{
"type": "tool_use",
"name": "Task",
"input": {
"prompt": "Run comprehensive test suite",
"subagent_type": "general-purpose",
"run_in_background": true
}
}
```
Immediate result with task ID:
```json
{
"type": "tool_result",
"content": "Background task started.\nTask ID: task-abc123\nUse TaskOutput tool to check status."
}
```
## Team Coordination
Teams enable multiple agents working together.
### Team Creation (TeamCreate Tool)
```json
{
"type": "tool_use",
"name": "TeamCreate",
"input": {
"team_name": "auth-refactor",
"description": "Refactoring authentication system"
}
}
```
### Team Config File
Created at `~/.claude/teams/<team-name>/config.json`:
```json
{
"team_name": "auth-refactor",
"description": "Refactoring authentication system",
"created_at": "2026-02-27T10:00:00.000Z",
"members": [
{
"name": "team-lead",
"agentId": "agent-lead-123",
"agentType": "general-purpose"
},
{
"name": "researcher",
"agentId": "agent-research-456",
"agentType": "Explore"
}
]
}
```
### Spawning Team Members
```json
{
"type": "tool_use",
"name": "Task",
"input": {
"prompt": "Research existing auth implementations",
"subagent_type": "Explore",
"team_name": "auth-refactor",
"name": "researcher"
}
}
```
## Inter-Agent Messaging (SendMessage)
### Direct Message
```json
{
"type": "tool_use",
"name": "SendMessage",
"input": {
"type": "message",
"recipient": "researcher",
"content": "Please focus on JWT refresh token handling",
"summary": "JWT refresh priority"
}
}
```
### Broadcast to Team
```json
{
"type": "tool_use",
"name": "SendMessage",
"input": {
"type": "broadcast",
"content": "Critical: Found security vulnerability in token validation",
"summary": "Security alert"
}
}
```
### Shutdown Request
```json
{
"type": "tool_use",
"name": "SendMessage",
"input": {
"type": "shutdown_request",
"recipient": "researcher",
"content": "Task complete, please wrap up"
}
}
```
### Shutdown Response
```json
{
"type": "tool_use",
"name": "SendMessage",
"input": {
"type": "shutdown_response",
"request_id": "req-abc123",
"approve": true
}
}
```
## Hook Events for Subagents
### SubagentStart Hook Input
```json
{
"session_id": "main-session-uuid",
"transcript_path": "/path/to/main/session.jsonl",
"hook_event_name": "SubagentStart",
"agent_id": "a3fecd5",
"agent_type": "Explore"
}
```
### SubagentStop Hook Input
```json
{
"session_id": "main-session-uuid",
"hook_event_name": "SubagentStop",
"agent_id": "a3fecd5",
"agent_type": "Explore",
"agent_transcript_path": "/path/to/subagent/agent-a3fecd5.jsonl",
"last_assistant_message": "Research complete. Found 3 auth patterns..."
}
```
## AMC Spawn Tracking
AMC tracks spawned agents through:
### Pending Spawn Record
```json
// ~/.local/share/amc/pending_spawns/<spawn-id>.json
{
"spawn_id": "550e8400-e29b-41d4-a716-446655440000",
"project_path": "/Users/dev/myproject",
"agent_type": "claude",
"timestamp": 1708872000.123
}
```
### Session State with Spawn ID
```json
// ~/.local/share/amc/sessions/<session-id>.json
{
"session_id": "session-uuid",
"agent": "claude",
"project": "myproject",
"spawn_id": "550e8400-e29b-41d4-a716-446655440000",
"zellij_session": "main",
"zellij_pane": "3"
}
```
## Resuming Subagents
Subagents can be resumed using their agent ID:
```json
{
"type": "tool_use",
"name": "Task",
"input": {
"description": "Continue auth research",
"prompt": "Continue where you left off",
"subagent_type": "Explore",
"resume": "a3fecd5"
}
}
```
The resumed agent receives full previous context.
## Worktree Isolation
For isolated code changes:
```json
{
"type": "tool_use",
"name": "Task",
"input": {
"prompt": "Refactor auth module",
"subagent_type": "general-purpose",
"isolation": "worktree"
}
}
```
Creates temporary git worktree at `.claude/worktrees/<name>/`.
Result includes worktree info:
```json
{
"type": "tool_result",
"content": "Refactoring complete.\n\nWorktree: .claude/worktrees/auth-refactor\nBranch: claude/auth-refactor-abc123\n\nChanges made - worktree preserved for review."
}
```

View File

@@ -0,0 +1,475 @@
# Edge Cases and Error Handling
Comprehensive guide to edge cases, malformed input handling, and error recovery in Claude JSONL processing.
## Parsing Edge Cases
### 1. Invalid JSON Lines
**Scenario:** Corrupted or truncated JSON line.
```python
# BAD: Crashes on invalid JSON
for line in file:
data = json.loads(line) # Raises JSONDecodeError
# GOOD: Skip invalid lines
for line in file:
if not line.strip():
continue
try:
data = json.loads(line)
except json.JSONDecodeError:
continue # Skip malformed line
```
### 2. Content Type Ambiguity
**Scenario:** User message content can be string OR array.
```python
# BAD: Assumes string
user_text = message['content']
# GOOD: Check type
content = message['content']
if isinstance(content, str):
user_text = content
elif isinstance(content, list):
# This is tool results, not user input
user_text = None
```
### 3. Missing Optional Fields
**Scenario:** Fields may be absent in older versions.
```python
# BAD: Assumes field exists
tokens = message['usage']['cache_read_input_tokens']
# GOOD: Safe access
usage = message.get('usage', {})
tokens = usage.get('cache_read_input_tokens', 0)
```
### 4. Partial File Reads
**Scenario:** Reading last N bytes may cut first line.
```python
# When seeking to end - N bytes, first line may be partial
def read_tail(file_path, max_bytes=1_000_000):
with open(file_path, 'r') as f:
f.seek(0, 2) # End
size = f.tell()
if size > max_bytes:
f.seek(size - max_bytes)
f.readline() # Discard partial first line
else:
f.seek(0)
return f.readlines()
```
### 5. Non-Dict JSON Values
**Scenario:** Line contains valid JSON but not an object.
```python
# File might contain: 123, "string", [1,2,3], null
data = json.loads(line)
if not isinstance(data, dict):
continue # Skip non-object JSON
```
## Type Coercion Edge Cases
### Integer Conversion
```python
def safe_int(value):
"""Convert to int, rejecting booleans."""
# Python: isinstance(True, int) == True, so check explicitly
if isinstance(value, bool):
return None
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
if isinstance(value, str):
try:
return int(value)
except ValueError:
return None
return None
```
### Token Summation
```python
def sum_tokens(*values):
"""Sum token counts, handling None/missing."""
valid = [v for v in values if isinstance(v, (int, float)) and not isinstance(v, bool)]
return sum(valid) if valid else None
```
## Session State Edge Cases
### 1. Orphan Sessions
**Scenario:** Multiple sessions claim same Zellij pane (e.g., after --resume).
**Resolution:** Keep session with:
1. Highest priority: Has `context_usage` (indicates real work)
2. Second priority: Latest `conversation_mtime_ns`
```python
def dedupe_sessions(sessions):
by_pane = {}
for s in sessions:
key = (s['zellij_session'], s['zellij_pane'])
if key not in by_pane:
by_pane[key] = s
else:
existing = by_pane[key]
# Prefer session with context_usage
if s.get('context_usage') and not existing.get('context_usage'):
by_pane[key] = s
elif s.get('conversation_mtime_ns', 0) > existing.get('conversation_mtime_ns', 0):
by_pane[key] = s
return list(by_pane.values())
```
### 2. Dead Session Detection
**Claude:** Check Zellij session exists
```python
def is_claude_dead(session):
if session['status'] == 'starting':
return False # Benefit of doubt
zellij = session.get('zellij_session')
if not zellij:
return True
# Check if Zellij session exists
result = subprocess.run(['zellij', 'list-sessions'], capture_output=True)
return zellij not in result.stdout.decode()
```
**Codex:** Check if process has file open
```python
def is_codex_dead(session):
transcript = session.get('transcript_path')
if not transcript:
return True
# Check if any process has file open
result = subprocess.run(['lsof', transcript], capture_output=True)
return result.returncode != 0
```
### 3. Stale Session Cleanup
```python
ORPHAN_AGE_HOURS = 24
STARTING_AGE_HOURS = 1
def should_cleanup(session, now):
age = now - session['started_at']
if session['status'] == 'starting' and age > timedelta(hours=STARTING_AGE_HOURS):
return True # Stuck in starting
if session.get('is_dead') and age > timedelta(hours=ORPHAN_AGE_HOURS):
return True # Dead and old
return False
```
## Tool Call Edge Cases
### 1. Missing Tool Results
**Scenario:** Session interrupted between tool_use and tool_result.
```python
def pair_tool_calls(messages):
pending = {} # tool_use_id -> tool_use
for msg in messages:
if msg['type'] == 'assistant':
for block in msg['message'].get('content', []):
if block.get('type') == 'tool_use':
pending[block['id']] = block
elif msg['type'] == 'user':
content = msg['message'].get('content', [])
if isinstance(content, list):
for block in content:
if block.get('type') == 'tool_result':
tool_id = block.get('tool_use_id')
if tool_id in pending:
pending[tool_id]['result'] = block
# Any pending without result = interrupted
incomplete = [t for t in pending.values() if 'result' not in t]
return pending, incomplete
```
### 2. Parallel Tool Call Ordering
**Scenario:** Multiple tool_use in one message, results may come in different order.
```python
# Match by ID, not by position
tool_uses = [b for b in assistant_content if b['type'] == 'tool_use']
tool_results = [b for b in user_content if b['type'] == 'tool_result']
paired = {}
for result in tool_results:
paired[result['tool_use_id']] = result
for use in tool_uses:
result = paired.get(use['id'])
# result may be None if missing
```
### 3. Tool Error Results
```python
def is_tool_error(result_block):
return result_block.get('is_error', False)
def extract_error_message(result_block):
content = result_block.get('content', '')
if content.startswith('Error:'):
return content
return None
```
## Codex-Specific Edge Cases
### 1. Content Injection Filtering
Codex may include system context in messages that should be filtered:
```python
SKIP_PREFIXES = [
'<INSTRUCTIONS>',
'<environment_context>',
'<permissions instructions>',
'# AGENTS.md instructions'
]
def should_skip_content(text):
return any(text.startswith(prefix) for prefix in SKIP_PREFIXES)
```
### 2. Developer Role Filtering
```python
def parse_codex_message(payload):
role = payload.get('role')
if role == 'developer':
return None # Skip system/developer messages
return payload
```
### 3. Function Call Arguments Parsing
```python
def parse_arguments(arguments):
if isinstance(arguments, dict):
return arguments
if isinstance(arguments, str):
try:
return json.loads(arguments)
except json.JSONDecodeError:
return {'raw': arguments}
return {}
```
### 4. Tool Call Buffering
Codex tool calls need buffering until next message:
```python
class CodexParser:
def __init__(self):
self.pending_tools = []
def process_entry(self, entry):
payload = entry.get('payload', {})
ptype = payload.get('type')
if ptype == 'function_call':
self.pending_tools.append({
'name': payload['name'],
'input': self.parse_arguments(payload['arguments'])
})
return None # Don't emit yet
elif ptype == 'message' and payload.get('role') == 'assistant':
msg = self.create_message(payload)
if self.pending_tools:
msg['tool_calls'] = self.pending_tools
self.pending_tools = []
return msg
elif ptype == 'message' and payload.get('role') == 'user':
# Flush pending tools before user message
msgs = []
if self.pending_tools:
msgs.append({'role': 'assistant', 'tool_calls': self.pending_tools})
self.pending_tools = []
msgs.append(self.create_message(payload))
return msgs
```
## File System Edge Cases
### 1. Path Traversal Prevention
```python
import os
def validate_session_id(session_id):
# Must be basename only
if os.path.basename(session_id) != session_id:
raise ValueError("Invalid session ID")
# No special characters
if any(c in session_id for c in ['/', '\\', '..', '\x00']):
raise ValueError("Invalid session ID")
def validate_project_path(project_path, base_dir):
resolved = os.path.realpath(project_path)
base = os.path.realpath(base_dir)
if not resolved.startswith(base + os.sep):
raise ValueError("Path traversal detected")
```
### 2. File Not Found
```python
def read_session_file(path):
try:
with open(path, 'r') as f:
return f.read()
except FileNotFoundError:
return None
except PermissionError:
return None
except OSError:
return None
```
### 3. Empty Files
```python
def parse_jsonl(path):
with open(path, 'r') as f:
content = f.read()
if not content.strip():
return [] # Empty file
return [json.loads(line) for line in content.strip().split('\n') if line.strip()]
```
## Subprocess Edge Cases
### 1. Timeout Handling
```python
import subprocess
def run_with_timeout(cmd, timeout=5):
try:
result = subprocess.run(
cmd,
capture_output=True,
timeout=timeout,
text=True
)
return result.stdout
except subprocess.TimeoutExpired:
return None
except FileNotFoundError:
return None
except OSError:
return None
```
### 2. ANSI Code Stripping
```python
import re
ANSI_PATTERN = re.compile(r'\x1b\[[0-9;]*m')
def strip_ansi(text):
return ANSI_PATTERN.sub('', text)
```
## Cache Invalidation
### Mtime-Based Cache
```python
class FileCache:
def __init__(self, max_size=100):
self.cache = {}
self.max_size = max_size
def get(self, path):
if path not in self.cache:
return None
entry = self.cache[path]
stat = os.stat(path)
# Invalidate if file changed
if stat.st_mtime_ns != entry['mtime_ns'] or stat.st_size != entry['size']:
del self.cache[path]
return None
return entry['data']
def set(self, path, data):
# Evict oldest if full
if len(self.cache) >= self.max_size:
oldest = next(iter(self.cache))
del self.cache[oldest]
stat = os.stat(path)
self.cache[path] = {
'mtime_ns': stat.st_mtime_ns,
'size': stat.st_size,
'data': data
}
```
## Testing Edge Cases Checklist
- [ ] Empty JSONL file
- [ ] Single-line JSONL file
- [ ] Truncated JSON line
- [ ] Non-object JSON values (numbers, strings, arrays)
- [ ] Missing required fields
- [ ] Unknown message types
- [ ] Content as string vs array
- [ ] Boolean vs integer confusion
- [ ] Unicode in content
- [ ] Very long lines (>64KB)
- [ ] Concurrent file modifications
- [ ] Missing tool results
- [ ] Multiple tool calls in single message
- [ ] Session without Zellij pane
- [ ] Codex developer messages
- [ ] Path traversal attempts
- [ ] Symlink escape attempts

View File

@@ -0,0 +1,238 @@
# Quick Reference
Cheat sheet for common Claude JSONL operations.
## File Locations
```bash
# Claude sessions
~/.claude/projects/-Users-user-projects-myapp/*.jsonl
# Codex sessions
~/.codex/sessions/**/*.jsonl
# Subagent transcripts
~/.claude/projects/.../session-id/subagents/agent-*.jsonl
# AMC session state
~/.local/share/amc/sessions/*.json
```
## Path Encoding
```python
# Encode: /Users/dev/myproject -> -Users-dev-myproject
encoded = '-' + project_path.replace('/', '-')
# Decode: -Users-dev-myproject -> /Users/dev/myproject
decoded = encoded[1:].replace('-', '/')
```
## Message Type Quick ID
| If you see... | It's a... |
|---------------|-----------|
| `"type": "user"` + string content | User input |
| `"type": "user"` + array content | Tool results |
| `"type": "assistant"` | Claude response |
| `"type": "progress"` | Hook/tool execution |
| `"type": "summary"` | Session summary |
| `"type": "system"` | Metadata/commands |
## Content Block Quick ID
| Block Type | Key Fields |
|------------|------------|
| `text` | `text` |
| `thinking` | `thinking`, `signature` |
| `tool_use` | `id`, `name`, `input` |
| `tool_result` | `tool_use_id`, `content`, `is_error` |
## jq Recipes
```bash
# Count messages by type
jq -s 'group_by(.type) | map({type: .[0].type, count: length})' session.jsonl
# Extract all tool calls
jq -c 'select(.type=="assistant") | .message.content[]? | select(.type=="tool_use")' session.jsonl
# Get user messages only
jq -c 'select(.type=="user" and (.message.content | type)=="string")' session.jsonl
# Sum tokens
jq -s '[.[].message.usage? | select(.) | .input_tokens + .output_tokens] | add' session.jsonl
# List tools used
jq -c 'select(.type=="assistant") | .message.content[]? | select(.type=="tool_use") | .name' session.jsonl | sort | uniq -c
# Find errors
jq -c 'select(.type=="user") | .message.content[]? | select(.type=="tool_result" and .is_error==true)' session.jsonl
```
## Python Snippets
### Read JSONL
```python
import json
def read_jsonl(path):
with open(path) as f:
for line in f:
if line.strip():
try:
yield json.loads(line)
except json.JSONDecodeError:
continue
```
### Extract Conversation
```python
def extract_conversation(path):
messages = []
for event in read_jsonl(path):
if event['type'] == 'user':
content = event['message']['content']
if isinstance(content, str):
messages.append({'role': 'user', 'content': content})
elif event['type'] == 'assistant':
for block in event['message'].get('content', []):
if block.get('type') == 'text':
messages.append({'role': 'assistant', 'content': block['text']})
return messages
```
### Get Token Usage
```python
def get_token_usage(path):
total_input = 0
total_output = 0
for event in read_jsonl(path):
if event['type'] == 'assistant':
usage = event.get('message', {}).get('usage', {})
total_input += usage.get('input_tokens', 0)
total_output += usage.get('output_tokens', 0)
return {'input': total_input, 'output': total_output}
```
### Find Tool Calls
```python
def find_tool_calls(path):
tools = []
for event in read_jsonl(path):
if event['type'] == 'assistant':
for block in event['message'].get('content', []):
if block.get('type') == 'tool_use':
tools.append({
'name': block['name'],
'id': block['id'],
'input': block['input']
})
return tools
```
### Pair Tools with Results
```python
def pair_tools_results(path):
pending = {}
for event in read_jsonl(path):
if event['type'] == 'assistant':
for block in event['message'].get('content', []):
if block.get('type') == 'tool_use':
pending[block['id']] = {'use': block, 'result': None}
elif event['type'] == 'user':
content = event['message'].get('content', [])
if isinstance(content, list):
for block in content:
if block.get('type') == 'tool_result':
tool_id = block['tool_use_id']
if tool_id in pending:
pending[tool_id]['result'] = block
return pending
```
## Common Gotchas
| Gotcha | Solution |
|--------|----------|
| `content` can be string or array | Check `isinstance(content, str)` first |
| `usage` may be missing | Use `.get('usage', {})` |
| Booleans are ints in Python | Check `isinstance(v, bool)` before `isinstance(v, int)` |
| First line may be partial after seek | Call `readline()` to discard |
| Tool results in user messages | Check for `tool_result` type in array |
| Codex `arguments` is JSON string | Parse with `json.loads()` |
| Agent ID vs session ID | Agent ID survives rewrites, session ID is per-run |
## Status Values
| Field | Values |
|-------|--------|
| `status` | `starting`, `active`, `done` |
| `stop_reason` | `end_turn`, `max_tokens`, `tool_use`, null |
| `is_error` | `true`, `false` (tool results) |
## Token Fields
```python
# All possible token fields to sum
token_fields = [
'input_tokens',
'output_tokens',
'cache_creation_input_tokens',
'cache_read_input_tokens'
]
# Context window by model
context_windows = {
'claude-opus': 200_000,
'claude-sonnet': 200_000,
'claude-haiku': 200_000,
'claude-2': 100_000
}
```
## Useful Constants
```python
# File locations
CLAUDE_BASE = os.path.expanduser('~/.claude/projects')
CODEX_BASE = os.path.expanduser('~/.codex/sessions')
AMC_BASE = os.path.expanduser('~/.local/share/amc')
# Read limits
MAX_TAIL_BYTES = 1_000_000 # 1MB
MAX_LINES = 400 # For context extraction
# Timeouts
SUBPROCESS_TIMEOUT = 5 # seconds
SPAWN_COOLDOWN = 30 # seconds
# Session ages
ACTIVE_THRESHOLD_MINUTES = 2
ORPHAN_CLEANUP_HOURS = 24
STARTING_CLEANUP_HOURS = 1
```
## Debugging Commands
```bash
# Watch session file changes
tail -f ~/.claude/projects/-path-to-project/*.jsonl | jq -c
# Find latest session
ls -t ~/.claude/projects/-path-to-project/*.jsonl | head -1
# Count lines in session
wc -l session.jsonl
# Validate JSON
cat session.jsonl | while read line; do echo "$line" | jq . > /dev/null || echo "Invalid: $line"; done
# Pretty print last message
tail -1 session.jsonl | jq .
```

View File

@@ -0,0 +1,57 @@
# Claude JSONL Session Log Reference
Comprehensive documentation for parsing and processing Claude Code JSONL session logs in the AMC project.
## Overview
Claude Code stores all conversations as JSONL (JSON Lines) files — one JSON object per line. This documentation provides authoritative specifications for:
- Message envelope structure and common fields
- All message types (user, assistant, progress, system, summary, etc.)
- Content block types (text, tool_use, tool_result, thinking)
- Tool call lifecycle and result handling
- Subagent spawn and team coordination formats
- Edge cases, error handling, and recovery patterns
## Documents
| Document | Purpose |
|----------|---------|
| [01-format-specification.md](./01-format-specification.md) | Complete JSONL format spec with all fields |
| [02-message-types.md](./02-message-types.md) | Every message type with concrete examples |
| [03-tool-lifecycle.md](./03-tool-lifecycle.md) | Tool call flow from invocation to result |
| [04-subagent-teams.md](./04-subagent-teams.md) | Subagent and team message formats |
| [05-edge-cases.md](./05-edge-cases.md) | Error handling, malformed input, recovery |
| [06-quick-reference.md](./06-quick-reference.md) | Cheat sheet for common operations |
## File Locations
| Content | Location |
|---------|----------|
| Claude sessions | `~/.claude/projects/<encoded-path>/<session-id>.jsonl` |
| Codex sessions | `~/.codex/sessions/**/<session-id>.jsonl` |
| Subagent transcripts | `~/.claude/projects/<path>/<session-id>/subagents/agent-<id>.jsonl` |
| AMC session state | `~/.local/share/amc/sessions/<session-id>.json` |
| AMC event logs | `~/.local/share/amc/events/<session-id>.jsonl` |
## Path Encoding
Project paths are encoded by replacing `/` with `-` and adding a leading dash:
- `/Users/taylor/projects/amc``-Users-taylor-projects-amc`
## Key Principles
1. **NDJSON format** — Each line is complete, parseable JSON
2. **Append-only** — Sessions are written incrementally
3. **UUID linking** — Messages link via `uuid` and `parentUuid`
4. **Graceful degradation** — Always handle missing/unknown fields
5. **Type safety** — Validate types before use (arrays vs strings, etc.)
## Sources
- [Claude Code Hooks Reference](https://code.claude.com/docs/en/hooks.md)
- [Claude Code Headless Documentation](https://code.claude.com/docs/en/headless.md)
- [Anthropic Messages API Reference](https://docs.anthropic.com/en/api/messages)
- [Inside Claude Code: Session File Format](https://medium.com/@databunny/inside-claude-code-the-session-file-format-and-how-to-inspect-it-b9998e66d56b)
- [Community: claude-code-log](https://github.com/daaain/claude-code-log)
- [Community: claude-JSONL-browser](https://github.com/withLinda/claude-JSONL-browser)

View File

@@ -0,0 +1,503 @@
# Plan: Tool Result Display in AMC Dashboard
> **Status:** Draft — awaiting review and mockup phase
> **Author:** Claude + Taylor
> **Created:** 2026-02-27
## Summary
Add the ability to view tool call results (diffs, bash output, file contents) directly in the AMC dashboard conversation view. Currently, users see that a tool was called but cannot see what it did. This feature brings Claude Code's result visibility to the multi-agent dashboard.
### Goals
1. **See code changes as they happen** — diffs from Edit/Write tools always visible
2. **Debug agent behavior** — inspect Bash output, Read content, search results
3. **Match Claude Code UX** — familiar expand/collapse behavior with latest results expanded
### Non-Goals (v1)
- Codex agent support (different JSONL format — deferred to v2)
- Copy-to-clipboard functionality
- Virtual scrolling / performance optimization
- Editor integration (clicking paths to open files)
- Accessibility (keyboard navigation, focus management, ARIA labels — deferred to v2)
- Lazy-fetch API for tool results (consider for v2 if payload size becomes an issue)
---
## User Workflows
### Workflow 1: Watching an Active Session
1. User opens a session card showing an active Claude agent
2. Agent calls Edit tool to modify a file
3. User immediately sees the diff expanded below the tool call pill
4. Agent calls Bash to run tests
5. User sees bash output expanded, previous Edit diff stays expanded (it's a diff)
6. Agent sends a text message explaining results
7. Bash output collapses (new assistant message arrived), Edit diff stays expanded
### Workflow 2: Reviewing a Completed Session
1. User opens a completed session to review what the agent did
2. All tool calls are collapsed by default (no "latest" assistant message)
3. Exception: Edit/Write diffs are still expanded
4. User clicks a Bash tool call to see what command ran and its output
5. User clicks "Show full output" when output is truncated
6. Lightweight modal opens with full scrollable content
7. User closes modal and continues reviewing
### Workflow 3: Debugging a Failed Tool Call
1. Agent runs a Bash command that fails
2. Tool result block shows with red-tinted background
3. stderr content is visible, clearly marked as error
4. User can see what went wrong without leaving the dashboard
---
## Acceptance Criteria
### Display Behavior
- **AC-1:** Tool calls render as expandable elements showing tool name and summary
- **AC-2:** Clicking a collapsed tool call expands to show its result
- **AC-3:** Clicking an expanded tool call collapses it
- **AC-4:** In active sessions, tool results in the most recent assistant message are expanded by default
- **AC-5:** When a new assistant message arrives, previous non-diff tool results collapse unless the user has manually toggled them in that message
- **AC-6:** Edit and Write results remain expanded regardless of message age or session status (even if Write only has confirmation text)
- **AC-7:** In completed sessions, all non-diff tool results start collapsed
- **AC-8:** Tool calls without results display as non-expandable with muted styling; in active sessions, pending tool calls show a spinner to distinguish in-progress from permanently missing
### Diff Rendering
- **AC-9:** Edit/Write results display structuredPatch data as syntax-highlighted diff; falls back to raw content text if structuredPatch is malformed or absent
- **AC-10:** Diff additions render with VS Code dark theme green background (rgba(46, 160, 67, 0.15))
- **AC-11:** Diff deletions render with VS Code dark theme red background (rgba(248, 81, 73, 0.15))
- **AC-12:** Full file path displays above each diff block
- **AC-13:** Diff context lines use structuredPatch as-is (no recomputation)
### Other Tool Types
- **AC-14:** Bash results display stdout in monospace, stderr separately if present
- **AC-15:** Bash output with ANSI escape codes renders as colored HTML (via ansi_up)
- **AC-16:** Read results display file content with syntax highlighting based on file extension
- **AC-17:** Grep/Glob results display file list with match counts
- **AC-18:** Unknown tools (WebFetch, Task, etc.) use GenericResult fallback showing raw content
### Truncation
- **AC-19:** Long outputs truncate at configurable line/character thresholds (defaults tuned to approximate Claude Code behavior)
- **AC-20:** Truncated outputs show "Show full output (N lines)" link
- **AC-21:** Clicking "Show full output" opens a dedicated lightweight modal
- **AC-22:** Modal displays full content with syntax highlighting, scrollable
### Error States
- **AC-23:** Failed tool calls display with red-tinted background
- **AC-24:** Error content (stderr, error messages) is clearly distinguishable from success content
- **AC-25:** is_error flag from tool_result determines error state
### API Contract
- **AC-26:** /api/conversation response includes tool results nested in tool_calls
- **AC-27:** Each tool_call has: name, id, input, result (when available)
- **AC-28:** All tool results conform to a normalized envelope: `{ kind, status, content, is_error }` with tool-specific fields nested in `content`
---
## Architecture
### Why Two-Pass JSONL Parsing
The Claude Code JSONL stores tool_use and tool_result as separate entries linked by tool_use_id. To nest results inside tool_calls for the API response, the server must:
1. First pass: Build a map of tool_use_id → toolUseResult
2. Second pass: Parse messages, attaching results to matching tool_calls
This adds parsing overhead but keeps the API contract simple. Alternatives considered:
- **Streaming/incremental:** More complex, doesn't help since we need full conversation anyway
- **Client-side joining:** Shifts complexity to frontend, increases payload size
### Why Render Everything, Not Virtual Scroll
Sessions typically have 20-80 tool calls. Modern browsers handle hundreds of DOM elements efficiently. Virtual scrolling adds significant complexity (measuring, windowing, scroll position management) for marginal benefit.
Decision: Ship simple, measure real-world performance, optimize if >100ms render times observed.
### Why Dedicated Modal Over Inline Expansion
Full output can be thousands of lines. Inline expansion would:
- Push other content out of view
- Make scrolling confusing
- Lose context of surrounding conversation
A modal provides a focused reading experience without disrupting conversation layout.
### Why a Normalized Result Contract
Raw `toolUseResult` shapes vary wildly by tool type — Edit has `structuredPatch`, Bash has `stdout`/`stderr`, Glob has `filenames`. Passing these raw to the frontend means every renderer must know the exact JSONL format, and adding Codex support (v2) would require duplicating all that branching.
Instead, the server normalizes each result into a stable envelope:
```python
{
"kind": "diff" | "bash" | "file_content" | "file_list" | "generic",
"status": "success" | "error" | "pending",
"is_error": bool,
"content": { ... } # tool-specific fields, documented per kind
}
```
The frontend switches on `kind` (5 cases) rather than tool name (unbounded). This also gives us a clean seam for the `result_mode` query parameter if payload size becomes an issue later.
### Component Structure
```
MessageBubble
├── Content (text)
├── Thinking (existing)
└── ToolCallList (new)
└── ToolCallItem (repeated)
├── Header (pill: chevron, name, summary, status)
└── ResultContent (conditional)
├── DiffResult (for Edit/Write)
├── BashResult (for Bash)
├── FileListResult (for Glob/Grep)
└── GenericResult (fallback)
FullOutputModal (new, top-level)
├── Header (tool name, file path)
├── Content (full output, scrollable)
└── CloseButton
```
---
## Implementation Specifications
### IMP-SERVER: Parse and Attach Tool Results
**Fulfills:** AC-26, AC-27, AC-28
**Location:** `amc_server/mixins/conversation.py`
**Changes to `_parse_claude_conversation`:**
Two-pass parsing:
1. First pass: Scan all entries, build map of `tool_use_id``toolUseResult`
2. Second pass: Parse messages as before, but when encountering `tool_use`, lookup and attach result
**API query parameter:** `/api/conversation?result_mode=full` (default). Future option: `result_mode=preview` to return truncated previews and reduce payload size without an API-breaking change.
**Normalization step:** After looking up the raw `toolUseResult`, the server normalizes it into the stable envelope before attaching:
```python
{
"name": "Edit",
"id": "toolu_abc123",
"input": {"file_path": "...", "old_string": "...", "new_string": "..."},
"result": {
"kind": "diff",
"status": "success",
"is_error": False,
"content": {
"structuredPatch": [...],
"filePath": "...",
"text": "The file has been updated successfully."
}
}
}
```
**Normalized `kind` mapping:**
| kind | Source Tools | `content` Fields |
|------|-------------|-----------------|
| `diff` | Edit, Write | `structuredPatch`, `filePath`, `text` |
| `bash` | Bash | `stdout`, `stderr`, `interrupted` |
| `file_content` | Read | `file`, `type`, `text` |
| `file_list` | Glob, Grep | `filenames`, `numFiles`, `truncated`, `numLines` |
| `generic` | All others | `text` (raw content string) |
---
### IMP-TOOLCALL: Expandable Tool Call Component
**Fulfills:** AC-1, AC-2, AC-3, AC-4, AC-5, AC-6, AC-7, AC-8
**Location:** `dashboard/lib/markdown.js` (refactor `renderToolCalls`)
**New function: `ToolCallItem`**
Renders a single tool call with:
- Chevron for expand/collapse (when result exists and not Edit/Write)
- Tool name (bold, colored)
- Summary (from existing `getToolSummary`)
- Status icon (checkmark or X)
- Result content (when expanded)
**State Management:**
Track two sets per message: `autoExpanded` (system-controlled) and `userToggled` (manual clicks).
When new assistant message arrives:
- Compare latest assistant message ID to stored ID
- If different, reset `autoExpanded` to empty for previous messages
- `userToggled` entries are never reset — user intent is preserved
- Edit/Write tools bypass this logic (always expanded via CSS/logic)
Expand/collapse logic: a tool call is expanded if it is in `userToggled` (explicit click) OR in `autoExpanded` (latest message) OR is Edit/Write kind.
---
### IMP-DIFF: Diff Rendering Component
**Fulfills:** AC-9, AC-10, AC-11, AC-12, AC-13
**Location:** `dashboard/lib/markdown.js` (new function `renderDiff`)
**Add diff language to highlight.js:**
```javascript
import langDiff from 'https://esm.sh/highlight.js@11.11.1/lib/languages/diff';
hljs.registerLanguage('diff', langDiff);
```
**Diff Renderer:**
1. If `structuredPatch` is present and valid, convert to unified diff text:
- Each hunk: `@@ -oldStart,oldLines +newStart,newLines @@`
- Followed by hunk.lines array
2. If `structuredPatch` is missing or malformed, fall back to raw `content.text` in a monospace block
3. Syntax highlight with hljs diff language
4. Sanitize with DOMPurify before rendering
5. Wrap in container with file path header
**CSS styling:**
- Container: dark border, rounded corners
- Header: muted background, monospace font, full file path
- Content: monospace, horizontal scroll
- Additions: `background: rgba(46, 160, 67, 0.15)`
- Deletions: `background: rgba(248, 81, 73, 0.15)`
---
### IMP-BASH: Bash Output Component
**Fulfills:** AC-14, AC-15, AC-23, AC-24
**Location:** `dashboard/lib/markdown.js` (new function `renderBashResult`)
**ANSI-to-HTML conversion:**
```javascript
import AnsiUp from 'https://esm.sh/ansi_up';
const ansi = new AnsiUp();
const html = ansi.ansi_to_html(bashOutput);
```
The `ansi_up` library (zero dependencies, ~8KB) converts ANSI escape codes to styled HTML spans, preserving colored test output, progress indicators, and error highlighting from CLI tools.
**Renders:**
- `stdout` in monospace pre block with ANSI colors preserved
- `stderr` in separate block with error styling (if present)
- "Command interrupted" notice (if interrupted flag)
**Sanitization order (CRITICAL):** First convert ANSI to HTML via ansi_up, THEN sanitize with DOMPurify. Sanitizing before conversion would strip escape codes; sanitizing after preserves the styled spans while preventing XSS.
Error state: `is_error` or presence of stderr triggers error styling (red tint, left border).
---
### IMP-TRUNCATE: Output Truncation
**Fulfills:** AC-19, AC-20
**Truncation Thresholds (match Claude Code):**
| Tool Type | Max Lines | Max Chars |
|-----------|-----------|-----------|
| Bash stdout | 100 | 10000 |
| Bash stderr | 50 | 5000 |
| Read content | 500 | 50000 |
| Grep matches | 100 | 10000 |
| Glob files | 100 | 5000 |
**Note:** These thresholds need verification against Claude Code behavior. May require adjustment based on testing.
**Truncation Helper:**
Takes content string, returns `{ text, truncated, totalLines }`. If truncated, result renderers show "Show full output (N lines)" link.
---
### IMP-MODAL: Full Output Modal
**Fulfills:** AC-21, AC-22
**Location:** `dashboard/components/FullOutputModal.js` (new file)
**Structure:**
- Overlay (click to close)
- Modal container (click does NOT close)
- Header: title (tool name + file path), close button
- Content: scrollable pre/code block with syntax highlighting
**Integration:** Modal state managed at App level or ChatMessages level. "Show full output" link sets state with content + metadata.
---
### IMP-ERROR: Error State Styling
**Fulfills:** AC-23, AC-24, AC-25
**Styling:**
- Tool call header: red-tinted background when `result.is_error`
- Status icon: red X instead of green checkmark
- Bash stderr: red text, italic, distinct from stdout
- Overall: left border accent in error color
---
## Rollout Slices
### Slice 1: Design Mockups (Pre-Implementation)
**Goal:** Validate visual design before building
**Deliverables:**
1. Create `/mockups` test route with static data
2. Implement 3-4 design variants (card-based, minimal, etc.)
3. Use real tool result data from session JSONL
4. User reviews and selects preferred design
**Exit Criteria:** Design direction locked
---
### Slice 2: Server-Side Tool Result Parsing and Normalization
**Goal:** API returns normalized tool results nested in tool_calls
**Deliverables:**
1. Two-pass parsing in `_parse_claude_conversation`
2. Normalization layer: raw `toolUseResult``{ kind, status, is_error, content }` envelope
3. Tool results attached with `id` field
4. Unit tests for result attachment and normalization per tool type
5. Handle missing results gracefully (return tool_call without result)
6. Support `result_mode=full` query parameter (only mode for now, but wired up for future `preview`)
**Exit Criteria:** AC-26, AC-27, AC-28 pass
---
### Slice 3: Basic Expand/Collapse UI
**Goal:** Tool calls are expandable, show raw result content
**Deliverables:**
1. Refactor `renderToolCalls` to `ToolCallList` component
2. Implement expand/collapse with chevron
3. Track expanded state per message
4. Collapse on new assistant message
5. Keep Edit/Write always expanded
**Exit Criteria:** AC-1 through AC-8 pass
---
### Slice 4: Diff Rendering
**Goal:** Edit/Write show beautiful diffs
**Deliverables:**
1. Add diff language to highlight.js
2. Implement `renderDiff` function
3. VS Code dark theme styling
4. Full file path header
**Exit Criteria:** AC-9 through AC-13 pass
---
### Slice 5: Other Tool Types
**Goal:** Bash, Read, Glob, Grep render appropriately
**Deliverables:**
1. Import and configure `ansi_up` for ANSI-to-HTML conversion
2. `renderBashResult` with stdout/stderr separation and ANSI color preservation
3. `renderFileContent` for Read
4. `renderFileList` for Glob/Grep
5. `GenericResult` fallback for unknown tools (WebFetch, Task, etc.)
**Exit Criteria:** AC-14 through AC-18 pass
---
### Slice 6: Truncation and Modal
**Goal:** Long outputs truncate with modal expansion
**Deliverables:**
1. Truncation helper with Claude Code thresholds
2. "Show full output" link
3. `FullOutputModal` component
4. Syntax highlighting in modal
**Exit Criteria:** AC-19 through AC-22 pass
---
### Slice 7: Error States and Polish
**Goal:** Failed tools visually distinct, edge cases handled
**Deliverables:**
1. Error state styling (red tint)
2. Muted styling for missing results
3. Test with interrupted sessions
4. Cross-browser testing
**Exit Criteria:** AC-23 through AC-25 pass, feature complete
---
## Open Questions
1. ~~**Exact Claude Code truncation thresholds**~~**Resolved:** using reasonable defaults with a note to tune via testing. AC-19 updated.
2. **Performance with 100+ tool calls** — monitor after ship, optimize if needed
3. **Codex support timeline** — when should we prioritize v2? The normalized `kind` contract makes this easier: add Codex normalizers without touching renderers.
4. ~~**Lazy-fetch for large payloads**~~**Resolved:** `result_mode` query parameter wired into API contract. Only `full` implemented in v1; `preview` deferred.
---
## Appendix: Research Findings
### Claude Code JSONL Format
Tool calls and results are stored as separate entries:
```json
// Assistant sends tool_use
{"type": "assistant", "message": {"content": [{"type": "tool_use", "id": "toolu_abc", "name": "Edit", "input": {...}}]}}
// Result in separate user entry
{"type": "user", "message": {"content": [{"type": "tool_result", "tool_use_id": "toolu_abc", "content": "Success"}]}, "toolUseResult": {...}}
```
The `toolUseResult` object contains rich structured data varying by tool type.
### Missing Results Statistics
Across 55 sessions with 2,063 tool calls:
- 11 missing results (0.5%)
- Affected tools: Edit (4), Read (2), Bash (1), others
### Interrupt Handling
User interrupts create a separate user message:
```json
{"type": "user", "message": {"content": [{"type": "text", "text": "[Request interrupted by user for tool use]"}]}}
```
Tool results for completed tools are still present; the interrupt message indicates the turn ended early.

1212
plans/agent-spawning.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,382 @@
# Card/Modal Unification Plan
**Status:** Implemented
**Date:** 2026-02-26
**Author:** Claude + Taylor
---
## Executive Summary
Unify SessionCard and Modal into a single component with an `enlarged` prop, eliminating 165 lines of duplicated code and ensuring feature parity across both views.
---
## 1. Problem Statement
### 1.1 What's Broken
The AMC dashboard displays agent sessions as cards in a grid. Clicking a card opens a "modal" for a larger, focused view. These two views evolved independently, creating:
| Issue | Impact |
|-------|--------|
| **Duplicated rendering logic** | Modal.js reimplemented header, chat, input from scratch (227 lines) |
| **Feature drift** | Card had context usage display; modal didn't. Modal had timestamps; card didn't. |
| **Maintenance burden** | Every card change required parallel modal changes (often forgotten) |
| **Inconsistent UX** | Users see different information depending on view |
### 1.2 Why This Matters
The modal's purpose is simple: **show an enlarged view with more screen space for content**. It should not be a separate implementation with different features. Users clicking a card expect to see *the same thing, bigger* — not a different interface.
### 1.3 Root Cause
The modal was originally built as a separate component because it needed:
- Backdrop blur with click-outside-to-close
- Escape key handling
- Body scroll lock
- Entrance/exit animations
These concerns led developers to copy-paste card internals into the modal rather than compose them.
---
## 2. Goals and Non-Goals
### 2.1 Goals
1. **Zero duplicated rendering code** — Single source of truth for how sessions display
2. **Automatic feature parity** — Any card change propagates to modal without extra work
3. **Preserve modal behaviors** — Backdrop, escape key, animations, scroll lock
4. **Add missing features to both views** — Smart scroll, sending state feedback
### 2.2 Non-Goals
- Changing the visual design of either view
- Adding new features beyond parity + smart scroll + sending state
- Refactoring other components
---
## 3. User Workflows
### 3.1 Current User Journey
```
User sees session cards in grid
├─► Card shows: status, agent, cwd, context usage, last 20 messages, input
└─► User clicks card
└─► Modal opens with DIFFERENT layout:
- Combined status badge (dot inside)
- No context usage
- All messages with timestamps
- Different input implementation
- Keyboard hints shown
```
### 3.2 Target User Journey
```
User sees session cards in grid
├─► Card shows: status, agent, cwd, context usage, last 20 messages, input
└─► User clicks card
└─► Modal opens with SAME card, just bigger:
- Identical header layout
- Context usage visible
- All messages (not limited to 20)
- Same input components
- Same everything, more space
```
### 3.3 User Benefits
| Benefit | Rationale |
|---------|-----------|
| **Cognitive consistency** | Same information architecture in both views reduces learning curve |
| **Trust** | No features "hiding" in one view or the other |
| **Predictability** | Click = zoom, not "different interface" |
---
## 4. Design Decisions
### 4.1 Architecture: Shared Component with Prop
**Decision:** Add `enlarged` prop to SessionCard. Modal renders `<SessionCard enlarged={true} />`.
**Alternatives Considered:**
| Alternative | Rejected Because |
|-------------|------------------|
| Modal wraps Card with CSS transform | Breaks layout, accessibility issues, can't change message limit |
| Higher-order component | Unnecessary complexity for single boolean difference |
| Render props pattern | Overkill, harder to read |
| Separate "CardContent" extracted | Still requires prop to control limit, might as well be on SessionCard |
**Rationale:** A single boolean prop is the simplest solution that achieves all goals. The `enlarged` prop controls exactly two things: container sizing and message limit. Everything else is identical.
---
### 4.2 Message Limit: Card 20, Enlarged All
**Decision:** Card shows last 20 messages. Enlarged view shows all.
**Rationale:**
- Cards in a grid need bounded height for visual consistency
- 20 messages is enough context without overwhelming the card
- Enlarged view exists specifically to see more — no artificial limit makes sense
- Implementation: `limit` prop on ChatMessages (20 default, null for unlimited)
---
### 4.3 Header Layout: Keep Card's Multi-Row Style
**Decision:** Use the card's multi-row header layout for both views.
**Modal had:** Single row with combined status badge (dot inside badge)
**Card had:** Multi-row with separate dot, status badge, agent badge, cwd badge, context usage
**Rationale:**
- Card layout shows more information (context usage was missing from modal)
- Multi-row handles overflow gracefully with `flex-wrap`
- Consistent with the "modal = bigger card" philosophy
---
### 4.4 Spacing: Keep Tighter (Card Style)
**Decision:** Use card's tighter spacing (`px-4 py-3`, `space-y-2.5`) for both views.
**Modal had:** Roomier spacing (`px-5 py-4`, `space-y-4`)
**Rationale:**
- Tighter spacing is more information-dense
- Enlarged view gains space from larger container, not wider margins
- Consistent visual rhythm between views
---
### 4.5 Empty State Text: "No messages to show"
**Decision:** Standardize on "No messages to show" (neither original).
**Card had:** "No messages yet"
**Modal had:** "No conversation messages"
**Rationale:** "No messages to show" is neutral and accurate — doesn't imply timing ("yet") or specific terminology ("conversation").
---
## 5. Implementation Details
### 5.1 SessionCard.js Changes
```
BEFORE: SessionCard({ session, onClick, conversation, onFetchConversation, onRespond, onDismiss })
AFTER: SessionCard({ session, onClick, conversation, onFetchConversation, onRespond, onDismiss, enlarged = false })
```
**New behaviors controlled by `enlarged`:**
| Aspect | `enlarged=false` (card) | `enlarged=true` (modal) |
|--------|-------------------------|-------------------------|
| Container classes | `h-[850px] w-[600px] cursor-pointer hover:...` | `max-w-5xl max-h-[90vh]` |
| Click handler | `onClick(session)` | `undefined` (no-op) |
| Message limit | 20 | null (all) |
**New feature: Smart scroll tracking**
```js
// Track if user is at bottom
const wasAtBottomRef = useRef(true);
// On scroll, update tracking
const handleScroll = () => {
wasAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
};
// On new messages, only scroll if user was at bottom
if (hasNewMessages && wasAtBottomRef.current) {
el.scrollTop = el.scrollHeight;
}
```
**Rationale:** Users reading history shouldn't be yanked to bottom when new messages arrive. Only auto-scroll if they were already at the bottom (watching live updates).
---
### 5.2 Modal.js Changes
**Before:** 227 lines reimplementing header, chat, input, scroll, state management
**After:** 62 lines — backdrop wrapper only
```js
export function Modal({ session, conversations, onClose, onRespond, onFetchConversation, onDismiss }) {
// Closing animation state
// Body scroll lock
// Escape key handler
return html`
<div class="backdrop...">
<${SessionCard}
session=${session}
conversation=${conversations[session.session_id] || []}
onFetchConversation=${onFetchConversation}
onRespond=${onRespond}
onDismiss=${onDismiss}
onClick=${() => {}}
enlarged=${true}
/>
</div>
`;
}
```
**Preserved behaviors:**
- Backdrop blur (`bg-[#02050d]/84 backdrop-blur-sm`)
- Click outside to close
- Escape key handler
- Body scroll lock (`document.body.style.overflow = 'hidden'`)
- Entrance/exit animations (CSS classes)
---
### 5.3 ChatMessages.js Changes
```
BEFORE: ChatMessages({ messages, status })
AFTER: ChatMessages({ messages, status, limit = 20 })
```
**Logic change:**
```js
// Before: always slice to 20
const displayMessages = allDisplayMessages.slice(-20);
// After: respect limit prop
const displayMessages = limit ? allDisplayMessages.slice(-limit) : allDisplayMessages;
```
---
### 5.4 SimpleInput.js / QuestionBlock.js Changes
**New feature: Sending state feedback**
```js
const [sending, setSending] = useState(false);
const handleSubmit = async (e) => {
if (sending) return;
setSending(true);
try {
await onRespond(...);
} finally {
setSending(false);
}
};
// In render:
<button disabled=${sending}>
${sending ? 'Sending...' : 'Send'}
</button>
```
**Rationale:** Users need feedback that their message is being sent. Without this, they might click multiple times or think the UI is broken.
---
### 5.5 App.js Changes
**Removed (unused after refactor):**
- `conversationLoading` state — was only passed to Modal
- `refreshConversation` callback — was only used by Modal's custom send handler
**Modified:**
- `respondToSession` now refreshes conversation immediately after successful send
- Modal receives same props as SessionCard (onRespond, onFetchConversation, onDismiss)
---
## 6. Dependency Graph
```
App.js
├─► SessionCard (in grid)
│ ├─► ChatMessages (limit=20)
│ │ └─► MessageBubble
│ ├─► QuestionBlock (with sending state)
│ │ └─► OptionButton
│ └─► SimpleInput (with sending state)
└─► Modal (backdrop wrapper)
└─► SessionCard (enlarged=true)
├─► ChatMessages (limit=null)
│ └─► MessageBubble
├─► QuestionBlock (with sending state)
│ └─► OptionButton
└─► SimpleInput (with sending state)
```
**Key insight:** Modal no longer has its own rendering tree. It delegates entirely to SessionCard.
---
## 7. Metrics
| Metric | Before | After | Change |
|--------|--------|-------|--------|
| Modal.js lines | 227 | 62 | -73% |
| Total duplicated code | ~180 lines | 0 | -100% |
| Features requiring dual maintenance | All | None | -100% |
| Prop surface area (Modal) | 6 custom | 6 same as card | Aligned |
---
## 8. Verification Checklist
- [x] Card displays: status dot, status badge, agent badge, cwd, context usage, messages, input
- [x] Modal displays: identical to card, just larger
- [x] Card limits to 20 messages
- [x] Modal shows all messages
- [x] Smart scroll works in both views
- [x] "Sending..." feedback works in both views
- [x] Escape closes modal
- [x] Click outside closes modal
- [x] Entrance/exit animations work
- [x] Body scroll locked when modal open
---
## 9. Future Considerations
### 9.1 Potential Enhancements
| Enhancement | Rationale | Blocked By |
|-------------|-----------|------------|
| Keyboard navigation in card grid | Accessibility | None |
| Resize modal dynamically | User preference | None |
| Pin modal to side (split view) | Power user workflow | Design decision needed |
### 9.2 Maintenance Notes
- **Any SessionCard change** automatically applies to modal view
- **To add modal-only behavior**: Check `enlarged` prop (but avoid this — keep views identical)
- **To change message limit**: Modify the `limit` prop value in SessionCard's ChatMessages call
---
## 10. Lessons Learned
1. **Composition > Duplication** — When two UIs show the same data, compose them from shared components
2. **Props for variations** — A single boolean prop is often sufficient for "same thing, different context"
3. **Identify the actual differences** — Modal needed only: backdrop, escape key, scroll lock, animations. Everything else was false complexity.
4. **Feature drift is inevitable** — Duplicated code guarantees divergence over time. Only shared code stays in sync.

96
plans/input-history.md Normal file
View File

@@ -0,0 +1,96 @@
# Input History (Up/Down Arrow)
## Summary
Add shell-style up/down arrow navigation through past messages in SimpleInput. History is derived from the conversation data already parsed from session logs -- no new state management, no server changes.
## How It Works Today
1. Server parses JSONL session logs, extracts user messages with `role: "user"` (`conversation.py:57-66`)
2. App.js stores parsed conversations in `conversations` state, refreshed via SSE on `conversation_mtime_ns` change
3. SessionCard receives `conversation` as a prop but does **not** pass it to SimpleInput
4. SimpleInput has no awareness of past messages
## Step 1: Pipe Conversation to SimpleInput
Pass the conversation array from SessionCard into SimpleInput so it can derive history.
- `SessionCard.js:165-169` -- add `conversation` prop to SimpleInput
- Same for the QuestionBlock path if freeform input is used there (line 162) -- skip for now, QuestionBlock is option-based
**Files**: `dashboard/components/SessionCard.js`
## Step 2: Derive User Message History
Inside SimpleInput, filter conversation to user messages only.
```js
const userHistory = useMemo(
() => (conversation || []).filter(m => m.role === 'user').map(m => m.content),
[conversation]
);
```
This updates automatically whenever the session log changes (SSE triggers conversation refresh, new prop flows down).
**Files**: `dashboard/components/SimpleInput.js`
## Step 3: History Navigation State
Add refs for tracking position in history and preserving the draft.
```js
const historyIndexRef = useRef(-1); // -1 = not browsing
const draftRef = useRef(''); // saves in-progress text before browsing
```
Use refs (not state) because index changes don't need re-renders -- only `setText` triggers the visual update.
**Files**: `dashboard/components/SimpleInput.js`
## Step 4: ArrowUp/ArrowDown Keybinding
In the `onKeyDown` handler (after the autocomplete block, before Enter-to-submit), add history navigation:
- **ArrowUp**: only when autocomplete is closed AND cursor is at position 0 (prevents hijacking multiline cursor movement). On first press, save current text to `draftRef`. Walk backward through `userHistory`. Call `setText()` with the history entry.
- **ArrowDown**: walk forward through history. If past the newest entry, restore `draftRef` and reset index to -1.
- **Reset on submit**: set `historyIndexRef.current = -1` in `handleSubmit` after successful send.
- **Reset on manual edit**: in `onInput`, reset `historyIndexRef.current = -1` so typing after browsing exits history mode.
### Cursor position check
```js
const atStart = e.target.selectionStart === 0 && e.target.selectionEnd === 0;
```
Only intercept ArrowUp when `atStart` is true. This lets multiline text cursor movement work normally. ArrowDown can use similar logic (check if cursor is at end of text) or always navigate history when `historyIndexRef.current !== -1` (already browsing).
**Files**: `dashboard/components/SimpleInput.js`
## Step 5: Modal Parity
The Modal (`Modal.js:71`) also renders SimpleInput with `onRespond`. Verify it passes `conversation` through. The same SessionCard is used in enlarged mode, so this should work automatically if Step 1 is done correctly.
**Files**: `dashboard/components/Modal.js` (verify, likely no change needed)
## Non-Goals
- No localStorage persistence -- history comes from session logs which survive across page reloads
- No server changes -- conversation parsing already extracts what we need
- No new API endpoints
- No changes to QuestionBlock (option-based, not free-text history)
## Test Cases
| Scenario | Expected |
|----------|----------|
| Press up with empty input | Fills with most recent user message |
| Press up multiple times | Walks backward through user messages |
| Press down after browsing up | Walks forward; past newest restores draft |
| Press up with text in input | Saves text as draft, shows history |
| Press down past end | Restores saved draft |
| Type after browsing | Exits history mode (index resets) |
| Submit after browsing | Sends displayed text, resets index |
| Up arrow in multiline text (cursor not at pos 0) | Normal cursor movement, no history |
| New message arrives via SSE | userHistory updates, no index disruption |
| Session with no prior messages | Up arrow does nothing |

51
plans/model-selection.md Normal file
View File

@@ -0,0 +1,51 @@
# Model Selection & Display
## Summary
Add model visibility and control to the AMC dashboard. Users can see which model each agent is running, pick a model when spawning, and switch models mid-session.
## Models
| Label | Value sent to Claude Code |
|-------|--------------------------|
| Opus 4.6 | `opus` |
| Opus 4.5 | `claude-opus-4-5-20251101` |
| Sonnet 4.6 | `sonnet` |
| Haiku | `haiku` |
## Step 1: Display Current Model
Surface `context_usage.model` in `SessionCard.js`.
- Data already extracted by `parsing.py` (line 202) from conversation JSONL
- Already available via `/api/state` in `context_usage.model`
- Add model name formatter: `claude-opus-4-5-20251101` -> `Opus 4.5`
- Show in SessionCard (near agent badge or context usage area)
- Shows `null` until first assistant message
**Files**: `dashboard/components/SessionCard.js`
## Step 2: Model Picker at Spawn
Add model dropdown to `SpawnModal.js`. Pass to spawn API, which appends `--model <value>` to the claude command.
- Extend `/api/spawn` to accept optional `model` param
- Validate against allowed model list
- Prepend `--model {model}` to command in `AGENT_COMMANDS`
- Default: no flag (uses Claude Code's default)
**Files**: `dashboard/components/SpawnModal.js`, `amc_server/mixins/spawn.py`
## Step 3: Mid-Session Model Switch
Dropdown on SessionCard to change model for running sessions via Zellij.
- Send `/model <value>` to the agent's Zellij pane:
```bash
zellij -s {session} action write-chars "/model {value}" --pane-id {pane}
zellij -s {session} action write 10 --pane-id {pane}
```
- New endpoint: `POST /api/session/{id}/model` with `{"model": "opus"}`
- Only works when agent is idle (waiting for input). If mid-turn, command queues and applies after.
**Files**: `dashboard/components/SessionCard.js`, `amc_server/mixins/state.py` (or new mixin)

View File

@@ -0,0 +1,533 @@
# Subagent & Agent Team Visibility for AMC
> **Status**: Draft
> **Last Updated**: 2026-03-02
## Summary
Add visibility into Claude Code subagents (Task tool spawns and team members) within AMC session cards. A pill button shows active agent count; clicking opens a popover with names, status, and stats. Claude-only (Codex does not support subagents).
---
## User Workflow
1. User views a session card in AMC
2. Session status area shows: `[●] Working 2m 15s · 42k tokens 32% ctx [3 agents]`
3. User clicks "3 agents" button
4. Popover opens showing:
```
Explore-a250de ● running 12m 42,000 tokens
code-reviewer ○ completed 3m 18,500 tokens
action-wirer ○ completed 5m 23,500 tokens
```
5. Popover auto-updates every 2s while open
6. Button hidden when session has no subagents
---
## Acceptance Criteria
### Discovery
- **AC-1**: Subagent JSONL files discovered for Claude sessions at `{claude_projects}/{encoded_project_dir}/{session_id}/subagents/agent-*.jsonl`
- **AC-2**: Team members discovered from same location (team spawning uses Task tool, stores in subagents dir)
- **AC-3**: Codex sessions do not show subagent button (Codex does not support subagents)
### Status Detection
- **AC-4**: Subagent is "running" if parent session is not dead AND last assistant entry has `stop_reason != "end_turn"`
- **AC-5**: Subagent is "completed" if last assistant entry has `stop_reason == "end_turn"` OR parent session is dead
### Name Resolution
- **AC-6**: Team member names extracted from agentId format `{name}@{team_name}` (O(1) string split)
- **AC-7**: Non-team subagent names generated as `agent-{agentId_prefix}` (no parent session parsing required)
### Stats Extraction
- **AC-8**: Duration = first entry timestamp to last entry timestamp (or server time if running)
- **AC-9**: Tokens = sum of `input_tokens + output_tokens` from all assistant entries (excludes cache tokens)
### API
- **AC-10**: `/api/state` includes `subagent_count` and `subagent_running_count` for each Claude session
- **AC-11**: New endpoint `/api/sessions/{id}/subagents` returns full subagent list with name, status, duration_ms, tokens
- **AC-12**: Subagent endpoint supports session_id path param; returns 404 if session not found
### UI
- **AC-13**: Context usage displays as plain text (remove badge styling)
- **AC-14**: Agent count button appears as bordered pill to the right of context text
- **AC-15**: Button hidden when `subagent_count == 0`
- **AC-16**: Button shows running indicator: "3 agents" when none running, "3 agents (1 running)" when some running
- **AC-17**: Clicking button opens popover anchored to button
- **AC-18**: Popover shows list: name, status indicator, duration, token count per row
- **AC-19**: Running agents show filled indicator (●), completed show empty (○)
- **AC-20**: Popover polls `/api/sessions/{id}/subagents` every 2s while open
- **AC-21**: Popover closes on outside click or Escape key
- **AC-22**: Subagent rows are display-only (no click action in v1)
---
## Architecture
### Why This Structure
| Decision | Rationale | Fulfills |
|----------|-----------|----------|
| Aggregate counts in `/api/state` + detail endpoint | Minimizes payload size; hash stability (counts change less than durations) | AC-10, AC-11 |
| Claude-only | Codex lacks subagent infrastructure | AC-3 |
| Name from agentId pattern | Avoids expensive parent session parsing; team names encoded in agentId | AC-6, AC-7 |
| Input+output tokens only | Matches "work done" mental model; simpler than cache tracking | AC-9 |
| Auto-poll in popover | Real-time feel consistent with session card updates | AC-20 |
| Hide button when empty | Reduces visual noise for sessions without agents | AC-15 |
### Data Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ Backend (Python) │
│ │
│ _collect_sessions() │
│ │ │
│ ├── For each Claude session: │
│ │ └── _count_subagents(session_id, project_dir) │
│ │ ├── glob subagents/agent-*.jsonl │
│ │ ├── count files, check running status │
│ │ └── return (count, running_count) │
│ │ │
│ └── Attach subagent_count, subagent_running_count │
│ │
│ _serve_subagents(session_id) │
│ ├── _get_claude_session_dir(session_id, project_dir) │
│ ├── glob subagents/agent-*.jsonl │
│ ├── For each file: │
│ │ ├── Parse name from agentId │
│ │ ├── Determine status from stop_reason │
│ │ ├── Calculate duration from timestamps │
│ │ └── Sum tokens from assistant usage │
│ └── Return JSON list │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Frontend (Preact) │
│ │
│ SessionCard │
│ │ │
│ ├── Session Status Area: │
│ │ ├── AgentActivityIndicator (left) │
│ │ ├── Context text (center-right, plain) │
│ │ └── SubagentButton (far right, if count > 0) │
│ │ │
│ └── SubagentButton │
│ ├── Shows "{count} agents" or "{count} ({running})" │
│ ├── onClick: opens SubagentPopover │
│ └── SubagentPopover │
│ ├── Polls /api/sessions/{id}/subagents │
│ ├── Renders list with status indicators │
│ └── Closes on outside click or Escape │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### File Changes
| File | Change | ACs |
|------|--------|-----|
| `amc_server/mixins/subagent.py` | New mixin for subagent discovery and stats | AC-1,2,4-9 |
| `amc_server/mixins/state.py` | Call subagent mixin, attach counts to session | AC-10 |
| `amc_server/mixins/http.py` | Add route `/api/sessions/{id}/subagents` | AC-11,12 |
| `amc_server/handler.py` | Add SubagentMixin to handler class | - |
| `dashboard/components/SessionCard.js` | Update status area layout | AC-13,14 |
| `dashboard/components/SubagentButton.js` | New component for button + popover | AC-15-22 |
| `dashboard/utils/api.js` | Add `fetchSubagents(sessionId)` function | AC-20 |
---
## Implementation Specs
### IMP-1: SubagentMixin (Python)
**Fulfills:** AC-1, AC-2, AC-4, AC-5, AC-6, AC-7, AC-8, AC-9
```python
# amc_server/mixins/subagent.py
class SubagentMixin:
def _get_subagent_counts(self, session_id: str, project_dir: str) -> tuple[int, int]:
"""Return (total_count, running_count) for a Claude session."""
subagents_dir = self._get_subagents_dir(session_id, project_dir)
if not subagents_dir or not subagents_dir.exists():
return (0, 0)
total = 0
running = 0
for jsonl_file in subagents_dir.glob("agent-*.jsonl"):
total += 1
if self._is_subagent_running(jsonl_file):
running += 1
return (total, running)
def _get_subagents_dir(self, session_id: str, project_dir: str) -> Path | None:
"""Construct path to subagents directory."""
if not project_dir:
return None
encoded_dir = project_dir.replace("/", "-")
if not encoded_dir.startswith("-"):
encoded_dir = "-" + encoded_dir
return CLAUDE_PROJECTS_DIR / encoded_dir / session_id / "subagents"
def _is_subagent_running(self, jsonl_file: Path) -> bool:
"""Check if subagent is still running based on last assistant stop_reason."""
try:
# Read last few lines to find last assistant entry
entries = self._read_jsonl_tail_entries(jsonl_file, max_lines=20)
for entry in reversed(entries):
if entry.get("type") == "assistant":
stop_reason = entry.get("message", {}).get("stop_reason")
return stop_reason != "end_turn"
return True # No assistant entries yet = still starting
except Exception:
return False
def _get_subagent_list(self, session_id: str, project_dir: str, parent_is_dead: bool) -> list[dict]:
"""Return full subagent list with stats."""
subagents_dir = self._get_subagents_dir(session_id, project_dir)
if not subagents_dir or not subagents_dir.exists():
return []
result = []
for jsonl_file in subagents_dir.glob("agent-*.jsonl"):
subagent = self._parse_subagent(jsonl_file, parent_is_dead)
if subagent:
result.append(subagent)
# Sort: running first, then by name
result.sort(key=lambda s: (0 if s["status"] == "running" else 1, s["name"]))
return result
def _parse_subagent(self, jsonl_file: Path, parent_is_dead: bool) -> dict | None:
"""Parse a single subagent JSONL file."""
try:
entries = self._read_jsonl_tail_entries(jsonl_file, max_lines=500, max_bytes=512*1024)
if not entries:
return None
# Get agentId from first entry
first_entry = entries[0] if entries else {}
agent_id = first_entry.get("agentId", "")
# Resolve name
name = self._resolve_subagent_name(agent_id, jsonl_file)
# Determine status
is_running = False
if not parent_is_dead:
for entry in reversed(entries):
if entry.get("type") == "assistant":
stop_reason = entry.get("message", {}).get("stop_reason")
is_running = stop_reason != "end_turn"
break
status = "running" if is_running else "completed"
# Calculate duration
first_ts = first_entry.get("timestamp")
last_ts = entries[-1].get("timestamp") if entries else None
duration_ms = self._calculate_duration_ms(first_ts, last_ts, is_running)
# Sum tokens
tokens = self._sum_assistant_tokens(entries)
return {
"name": name,
"status": status,
"duration_ms": duration_ms,
"tokens": tokens,
}
except Exception:
return None
def _resolve_subagent_name(self, agent_id: str, jsonl_file: Path) -> str:
"""Extract display name from agentId or filename."""
# Team members: "reviewer-wcja@surgical-sync" -> "reviewer-wcja"
if "@" in agent_id:
return agent_id.split("@")[0]
# Regular subagents: use prefix from agentId
# agent_id like "a250dec6325c589be" -> "a250de"
prefix = agent_id[:6] if agent_id else "agent"
# Try to get subagent_type from filename if it contains it
# Filename: agent-acompact-b857538cac0d5172.jsonl -> might indicate "compact"
# For now, use generic fallback
return f"agent-{prefix}"
def _calculate_duration_ms(self, first_ts: str, last_ts: str, is_running: bool) -> int:
"""Calculate duration in milliseconds."""
if not first_ts:
return 0
try:
first = datetime.fromisoformat(first_ts.replace("Z", "+00:00"))
if is_running:
end = datetime.now(timezone.utc)
elif last_ts:
end = datetime.fromisoformat(last_ts.replace("Z", "+00:00"))
else:
return 0
return max(0, int((end - first).total_seconds() * 1000))
except Exception:
return 0
def _sum_assistant_tokens(self, entries: list[dict]) -> int:
"""Sum input_tokens + output_tokens from all assistant entries."""
total = 0
for entry in entries:
if entry.get("type") != "assistant":
continue
usage = entry.get("message", {}).get("usage", {})
input_tok = usage.get("input_tokens", 0) or 0
output_tok = usage.get("output_tokens", 0) or 0
total += input_tok + output_tok
return total
```
### IMP-2: State Integration (Python)
**Fulfills:** AC-10
```python
# In amc_server/mixins/state.py, within _collect_sessions():
# After computing is_dead, add:
if data.get("agent") == "claude":
subagent_count, subagent_running = self._get_subagent_counts(
data.get("session_id", ""),
data.get("project_dir", "")
)
if subagent_count > 0:
data["subagent_count"] = subagent_count
data["subagent_running_count"] = subagent_running
```
### IMP-3: Subagents Endpoint (Python)
**Fulfills:** AC-11, AC-12
```python
# In amc_server/mixins/http.py, add route handling:
def _route_request(self):
# ... existing routes ...
# /api/sessions/{id}/subagents
subagent_match = re.match(r"^/api/sessions/([^/]+)/subagents$", self.path)
if subagent_match:
session_id = subagent_match.group(1)
self._serve_subagents(session_id)
return
def _serve_subagents(self, session_id):
"""Serve subagent list for a specific session."""
# Find session to get project_dir and is_dead
session_file = SESSIONS_DIR / f"{session_id}.json"
if not session_file.exists():
self._send_json(404, {"error": "Session not found"})
return
try:
session_data = json.loads(session_file.read_text())
except (json.JSONDecodeError, OSError):
self._send_json(404, {"error": "Session not found"})
return
if session_data.get("agent") != "claude":
self._send_json(200, {"subagents": []})
return
parent_is_dead = session_data.get("is_dead", False)
subagents = self._get_subagent_list(
session_id,
session_data.get("project_dir", ""),
parent_is_dead
)
self._send_json(200, {"subagents": subagents})
```
### IMP-4: SubagentButton Component (JavaScript)
**Fulfills:** AC-14, AC-15, AC-16, AC-17, AC-18, AC-19, AC-20, AC-21, AC-22
```javascript
// dashboard/components/SubagentButton.js
import { html, useState, useEffect, useRef } from '../lib/preact.js';
import { fetchSubagents } from '../utils/api.js';
export function SubagentButton({ sessionId, count, runningCount }) {
const [isOpen, setIsOpen] = useState(false);
const [subagents, setSubagents] = useState([]);
const buttonRef = useRef(null);
const popoverRef = useRef(null);
// Format button label
const label = runningCount > 0
? `${count} agents (${runningCount} running)`
: `${count} agents`;
// Poll while open
useEffect(() => {
if (!isOpen) return;
const fetchData = async () => {
const data = await fetchSubagents(sessionId);
if (data?.subagents) {
setSubagents(data.subagents);
}
};
fetchData();
const interval = setInterval(fetchData, 2000);
return () => clearInterval(interval);
}, [isOpen, sessionId]);
// Close on outside click or Escape
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e) => {
if (popoverRef.current && !popoverRef.current.contains(e.target) &&
buttonRef.current && !buttonRef.current.contains(e.target)) {
setIsOpen(false);
}
};
const handleEscape = (e) => {
if (e.key === 'Escape') setIsOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen]);
const formatDuration = (ms) => {
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s`;
const min = Math.floor(sec / 60);
return `${min}m`;
};
const formatTokens = (count) => {
if (count >= 1000) return `${(count / 1000).toFixed(1)}k`;
return String(count);
};
return html`
<div class="relative">
<button
ref=${buttonRef}
onClick=${() => setIsOpen(!isOpen)}
class="rounded-lg border border-selection/80 bg-bg/45 px-2.5 py-1 font-mono text-label text-dim hover:border-starting/50 hover:text-bright transition-colors"
>
${label}
</button>
${isOpen && html`
<div
ref=${popoverRef}
class="absolute right-0 top-full mt-2 z-50 min-w-[280px] rounded-lg border border-selection/80 bg-surface shadow-lg"
>
<div class="p-2">
${subagents.length === 0 ? html`
<div class="text-center text-dim text-sm py-4">Loading...</div>
` : subagents.map(agent => html`
<div class="flex items-center gap-3 px-3 py-2 rounded hover:bg-bg/40">
<span class="w-2 h-2 rounded-full ${agent.status === 'running' ? 'bg-active' : 'border border-dim'}"></span>
<span class="flex-1 font-mono text-sm text-bright truncate">${agent.name}</span>
<span class="font-mono text-label text-dim">${formatDuration(agent.duration_ms)}</span>
<span class="font-mono text-label text-dim">${formatTokens(agent.tokens)}</span>
</div>
`)}
</div>
</div>
`}
</div>
`;
}
```
### IMP-5: SessionCard Status Area Update (JavaScript)
**Fulfills:** AC-13, AC-14, AC-15
```javascript
// In dashboard/components/SessionCard.js, update the Session Status Area:
// Replace the contextUsage badge with plain text + SubagentButton
<!-- Session Status Area -->
<div class="flex items-center justify-between gap-3 px-4 py-2 border-b border-selection/50 bg-bg/60">
<${AgentActivityIndicator} session=${session} />
<div class="flex items-center gap-3">
${contextUsage && html`
<span class="font-mono text-label text-dim" title=${contextUsage.title}>
${contextUsage.headline}
</span>
`}
${session.subagent_count > 0 && session.agent === 'claude' && html`
<${SubagentButton}
sessionId=${session.session_id}
count=${session.subagent_count}
runningCount=${session.subagent_running_count || 0}
/>
`}
</div>
</div>
```
### IMP-6: API Function (JavaScript)
**Fulfills:** AC-20
```javascript
// In dashboard/utils/api.js, add:
export async function fetchSubagents(sessionId) {
try {
const response = await fetch(`/api/sessions/${sessionId}/subagents`);
if (!response.ok) return null;
return await response.json();
} catch (e) {
console.error('Failed to fetch subagents:', e);
return null;
}
}
```
---
## Rollout Slices
### Slice 1: Backend Discovery (AC-1, AC-2, AC-4, AC-5, AC-6, AC-7, AC-8, AC-9, AC-10)
- Create `amc_server/mixins/subagent.py` with discovery and stats logic
- Integrate into `state.py` to add counts to session payload
- Unit tests for name resolution, status detection, token summing
### Slice 2: Backend Endpoint (AC-11, AC-12)
- Add `/api/sessions/{id}/subagents` route
- Return 404 for missing sessions, empty list for Codex
- Integration test with real session data
### Slice 3: Frontend Button (AC-13, AC-14, AC-15, AC-16)
- Update SessionCard status area layout
- Create SubagentButton component with label logic
- Test: button shows when count > 0, hidden when 0
### Slice 4: Frontend Popover (AC-17, AC-18, AC-19, AC-20, AC-21, AC-22)
- Add popover with polling
- Style running/completed indicators
- Test: popover opens, polls, closes on outside click/Escape

0
tests/e2e/__init__.py Normal file
View File

Binary file not shown.

View File

@@ -0,0 +1,614 @@
/**
* E2E integration tests for the autocomplete workflow.
*
* Validates the complete flow from typing a trigger character through
* skill selection and insertion, using a mock HTTP server that serves
* both dashboard files and the /api/skills endpoint.
*
* Test scenarios from bd-3cc:
* - Server serves /api/skills correctly
* - Dashboard loads skills on session open
* - Trigger character shows dropdown
* - Keyboard navigation works
* - Selection inserts skill
* - Edge cases (wrong trigger, empty skills, backspace, etc.)
*/
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { getTriggerInfo, filteredSkills } from '../../dashboard/utils/autocomplete.js';
// -- Mock server for /api/skills --
const CLAUDE_SKILLS_RESPONSE = {
trigger: '/',
skills: [
{ name: 'commit', description: 'Create a git commit' },
{ name: 'comment', description: 'Add a comment' },
{ name: 'review-pr', description: 'Review a pull request' },
{ name: 'help', description: 'Get help' },
{ name: 'init', description: 'Initialize project' },
],
};
const CODEX_SKILLS_RESPONSE = {
trigger: '$',
skills: [
{ name: 'lint', description: 'Lint code' },
{ name: 'deploy', description: 'Deploy to prod' },
{ name: 'test', description: 'Run tests' },
],
};
const EMPTY_SKILLS_RESPONSE = {
trigger: '/',
skills: [],
};
let server;
let serverUrl;
function startMockServer() {
return new Promise((resolve) => {
server = createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname === '/api/skills') {
const agent = url.searchParams.get('agent') || 'claude';
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
if (agent === 'codex') {
res.end(JSON.stringify(CODEX_SKILLS_RESPONSE));
} else if (agent === 'empty') {
res.end(JSON.stringify(EMPTY_SKILLS_RESPONSE));
} else {
res.end(JSON.stringify(CLAUDE_SKILLS_RESPONSE));
}
} else {
res.writeHead(404);
res.end('Not found');
}
});
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
serverUrl = `http://127.0.0.1:${port}`;
resolve();
});
});
}
function stopMockServer() {
return new Promise((resolve) => {
if (server) server.close(resolve);
else resolve();
});
}
// -- Helper: simulate fetching skills like the dashboard does --
async function fetchSkills(agent) {
const url = `${serverUrl}/api/skills?agent=${encodeURIComponent(agent)}`;
const response = await fetch(url);
if (!response.ok) return null;
return response.json();
}
// =============================================================
// Test Suite: Server -> Client Skills Fetch
// =============================================================
describe('E2E: Server serves /api/skills correctly', () => {
before(startMockServer);
after(stopMockServer);
it('fetches Claude skills with / trigger', async () => {
const config = await fetchSkills('claude');
assert.equal(config.trigger, '/');
assert.ok(config.skills.length > 0, 'should have skills');
assert.ok(config.skills.some(s => s.name === 'commit'));
});
it('fetches Codex skills with $ trigger', async () => {
const config = await fetchSkills('codex');
assert.equal(config.trigger, '$');
assert.ok(config.skills.some(s => s.name === 'lint'));
});
it('returns empty skills list when none exist', async () => {
const config = await fetchSkills('empty');
assert.equal(config.trigger, '/');
assert.deepEqual(config.skills, []);
});
it('each skill has name and description', async () => {
const config = await fetchSkills('claude');
for (const skill of config.skills) {
assert.ok(skill.name, 'skill should have name');
assert.ok(skill.description, 'skill should have description');
}
});
});
// =============================================================
// Test Suite: Dashboard loads skills on session open
// =============================================================
describe('E2E: Dashboard loads skills on session open', () => {
before(startMockServer);
after(stopMockServer);
it('loads Claude skills config matching server response', async () => {
const config = await fetchSkills('claude');
assert.equal(config.trigger, '/');
// Verify the config is usable by autocomplete functions
const info = getTriggerInfo('/com', 4, config);
assert.ok(info, 'should detect trigger in loaded config');
assert.equal(info.filterText, 'com');
});
it('loads Codex skills config matching server response', async () => {
const config = await fetchSkills('codex');
assert.equal(config.trigger, '$');
const info = getTriggerInfo('$li', 3, config);
assert.ok(info, 'should detect $ trigger');
assert.equal(info.filterText, 'li');
});
it('handles null/missing config gracefully', async () => {
// Simulate network failure
const info = getTriggerInfo('/test', 5, null);
assert.equal(info, null);
const skills = filteredSkills(null, { filterText: '' });
assert.deepEqual(skills, []);
});
});
// =============================================================
// Test Suite: Trigger character shows dropdown
// =============================================================
describe('E2E: Trigger character shows dropdown', () => {
const config = CLAUDE_SKILLS_RESPONSE;
it('Claude session: Type "/" -> dropdown appears with Claude skills', () => {
const info = getTriggerInfo('/', 1, config);
assert.ok(info, 'trigger should be detected');
assert.equal(info.trigger, '/');
const skills = filteredSkills(config, info);
assert.ok(skills.length > 0, 'should show skills');
});
it('Codex session: Type "$" -> dropdown appears with Codex skills', () => {
const codexConfig = CODEX_SKILLS_RESPONSE;
const info = getTriggerInfo('$', 1, codexConfig);
assert.ok(info, 'trigger should be detected');
assert.equal(info.trigger, '$');
const skills = filteredSkills(codexConfig, info);
assert.ok(skills.length > 0);
});
it('Claude session: Type "$" -> nothing happens (wrong trigger)', () => {
const info = getTriggerInfo('$', 1, config);
assert.equal(info, null, 'wrong trigger should not activate');
});
it('Type "/com" -> list filters to skills containing "com"', () => {
const info = getTriggerInfo('/com', 4, config);
assert.ok(info);
assert.equal(info.filterText, 'com');
const skills = filteredSkills(config, info);
const names = skills.map(s => s.name);
assert.ok(names.includes('commit'), 'should include commit');
assert.ok(names.includes('comment'), 'should include comment');
assert.ok(!names.includes('review-pr'), 'should not include review-pr');
assert.ok(!names.includes('help'), 'should not include help');
});
it('Mid-message: Type "please run /commit" -> autocomplete triggers on "/"', () => {
const input = 'please run /commit';
const info = getTriggerInfo(input, input.length, config);
assert.ok(info, 'should detect trigger mid-message');
assert.equal(info.trigger, '/');
assert.equal(info.filterText, 'commit');
assert.equal(info.replaceStart, 11);
assert.equal(info.replaceEnd, 18);
});
it('Trigger at start of line after newline', () => {
const input = 'first line\n/rev';
const info = getTriggerInfo(input, input.length, config);
assert.ok(info);
assert.equal(info.filterText, 'rev');
});
});
// =============================================================
// Test Suite: Keyboard navigation works
// =============================================================
describe('E2E: Keyboard navigation simulation', () => {
const config = CLAUDE_SKILLS_RESPONSE;
it('Arrow keys navigate through filtered list', () => {
const info = getTriggerInfo('/', 1, config);
const skills = filteredSkills(config, info);
// Simulate state: selectedIndex starts at 0
let selectedIndex = 0;
// ArrowDown moves to next
selectedIndex = Math.min(selectedIndex + 1, skills.length - 1);
assert.equal(selectedIndex, 1);
// ArrowDown again
selectedIndex = Math.min(selectedIndex + 1, skills.length - 1);
assert.equal(selectedIndex, 2);
// ArrowUp moves back
selectedIndex = Math.max(selectedIndex - 1, 0);
assert.equal(selectedIndex, 1);
// ArrowUp back to start
selectedIndex = Math.max(selectedIndex - 1, 0);
assert.equal(selectedIndex, 0);
// ArrowUp at top doesn't go negative
selectedIndex = Math.max(selectedIndex - 1, 0);
assert.equal(selectedIndex, 0);
});
it('ArrowDown clamps at list end', () => {
const info = getTriggerInfo('/com', 4, config);
const skills = filteredSkills(config, info);
// "com" matches commit and comment -> 2 skills
assert.equal(skills.length, 2);
let selectedIndex = 0;
// Down to 1
selectedIndex = Math.min(selectedIndex + 1, skills.length - 1);
assert.equal(selectedIndex, 1);
// Down again - clamped at 1
selectedIndex = Math.min(selectedIndex + 1, skills.length - 1);
assert.equal(selectedIndex, 1, 'should clamp at list end');
});
it('Enter selects the current skill', () => {
const info = getTriggerInfo('/', 1, config);
const skills = filteredSkills(config, info);
const selectedIndex = 0;
// Simulate Enter: select skill at selectedIndex
const selected = skills[selectedIndex];
assert.ok(selected, 'should have a skill to select');
assert.equal(selected.name, skills[0].name);
});
it('Escape dismisses without selection', () => {
// Simulate Escape: set showAutocomplete = false, no insertion
let showAutocomplete = true;
// Escape handler
showAutocomplete = false;
assert.equal(showAutocomplete, false, 'dropdown should close on Escape');
});
});
// =============================================================
// Test Suite: Selection inserts skill
// =============================================================
describe('E2E: Selection inserts skill', () => {
const config = CLAUDE_SKILLS_RESPONSE;
/**
* Simulate insertSkill logic from SimpleInput.js
*/
function simulateInsertSkill(text, triggerInfo, skill, trigger) {
const { replaceStart, replaceEnd } = triggerInfo;
const before = text.slice(0, replaceStart);
const after = text.slice(replaceEnd);
const inserted = `${trigger}${skill.name} `;
return {
newText: before + inserted + after,
newCursorPos: replaceStart + inserted.length,
};
}
it('Selected skill shows as "{trigger}skill-name " in input', () => {
const text = '/com';
const info = getTriggerInfo(text, 4, config);
const skills = filteredSkills(config, info);
const skill = skills.find(s => s.name === 'commit');
const { newText, newCursorPos } = simulateInsertSkill(text, info, skill, config.trigger);
assert.equal(newText, '/commit ', 'should insert trigger + skill name + space');
assert.equal(newCursorPos, 8, 'cursor should be after inserted text');
});
it('Inserting mid-message preserves surrounding text', () => {
const text = 'please run /com and continue';
const info = getTriggerInfo(text, 15, config); // cursor at end of "/com"
assert.ok(info);
const skill = { name: 'commit' };
const { newText } = simulateInsertSkill(text, info, skill, config.trigger);
assert.equal(newText, 'please run /commit and continue');
// Note: there's a double space because "and" was after the cursor position
// In real use, the cursor was at position 15 which is after "/com"
});
it('Inserting at start of input', () => {
const text = '/';
const info = getTriggerInfo(text, 1, config);
const skill = { name: 'help' };
const { newText, newCursorPos } = simulateInsertSkill(text, info, skill, config.trigger);
assert.equal(newText, '/help ');
assert.equal(newCursorPos, 6);
});
it('Inserting with filter text replaces trigger+filter', () => {
const text = '/review';
const info = getTriggerInfo(text, 7, config);
const skill = { name: 'review-pr' };
const { newText } = simulateInsertSkill(text, info, skill, config.trigger);
assert.equal(newText, '/review-pr ');
});
});
// =============================================================
// Test Suite: Verify alphabetical ordering
// =============================================================
describe('E2E: Verify alphabetical ordering of skills', () => {
it('Skills are returned sorted alphabetically', () => {
const config = CLAUDE_SKILLS_RESPONSE;
const info = getTriggerInfo('/', 1, config);
const skills = filteredSkills(config, info);
const names = skills.map(s => s.name);
for (let i = 1; i < names.length; i++) {
assert.ok(
names[i].localeCompare(names[i - 1]) >= 0,
`${names[i]} should come after ${names[i - 1]}`
);
}
});
it('Filtered results maintain alphabetical order', () => {
const config = CLAUDE_SKILLS_RESPONSE;
const info = getTriggerInfo('/com', 4, config);
const skills = filteredSkills(config, info);
const names = skills.map(s => s.name);
assert.deepEqual(names, ['comment', 'commit']);
});
});
// =============================================================
// Test Suite: Edge Cases
// =============================================================
describe('E2E: Edge cases', () => {
it('Session without skills shows empty list', () => {
const emptyConfig = EMPTY_SKILLS_RESPONSE;
const info = getTriggerInfo('/', 1, emptyConfig);
assert.ok(info, 'trigger still detected');
const skills = filteredSkills(emptyConfig, info);
assert.equal(skills.length, 0);
});
it('Single skill still shows in dropdown', () => {
const singleConfig = {
trigger: '/',
skills: [{ name: 'only-skill', description: 'The only one' }],
};
const info = getTriggerInfo('/', 1, singleConfig);
const skills = filteredSkills(singleConfig, info);
assert.equal(skills.length, 1);
assert.equal(skills[0].name, 'only-skill');
});
it('Multiple triggers in one message work independently', () => {
const config = CLAUDE_SKILLS_RESPONSE;
// User types: "first /commit then /review-pr finally"
// After first insertion, simulating second trigger
const text = 'first /commit then /rev';
// Cursor at end - should detect second trigger
const info = getTriggerInfo(text, text.length, config);
assert.ok(info, 'should detect second trigger');
assert.equal(info.filterText, 'rev');
assert.equal(info.replaceStart, 19); // position of second "/"
assert.equal(info.replaceEnd, text.length);
const skills = filteredSkills(config, info);
assert.ok(skills.some(s => s.name === 'review-pr'));
});
it('Backspace over trigger dismisses autocomplete', () => {
const config = CLAUDE_SKILLS_RESPONSE;
// Type "/" - trigger detected
let info = getTriggerInfo('/', 1, config);
assert.ok(info, 'trigger detected');
// Backspace - text is now empty
info = getTriggerInfo('', 0, config);
assert.equal(info, null, 'trigger dismissed after backspace');
});
it('Trigger embedded in word does not activate', () => {
const config = CLAUDE_SKILLS_RESPONSE;
const info = getTriggerInfo('path/to/file', 5, config);
assert.equal(info, null, 'should not trigger on path separator');
});
it('No matching skills after filtering shows empty list', () => {
const config = CLAUDE_SKILLS_RESPONSE;
const info = getTriggerInfo('/zzz', 4, config);
assert.ok(info, 'trigger still detected');
const skills = filteredSkills(config, info);
assert.equal(skills.length, 0, 'no skills match "zzz"');
});
it('Case-insensitive filtering works', () => {
const config = CLAUDE_SKILLS_RESPONSE;
const info = getTriggerInfo('/COM', 4, config);
assert.ok(info);
assert.equal(info.filterText, 'com'); // lowercased
const skills = filteredSkills(config, info);
assert.ok(skills.length >= 2, 'should match commit and comment');
});
it('Click outside dismisses (state simulation)', () => {
// Simulate: showAutocomplete=true, click outside sets it to false
let showAutocomplete = true;
// Simulate click outside handler
const clickTarget = { contains: () => false };
const textareaRef = { contains: () => false };
if (!clickTarget.contains('event') && !textareaRef.contains('event')) {
showAutocomplete = false;
}
assert.equal(showAutocomplete, false, 'click outside should dismiss');
});
});
// =============================================================
// Test Suite: Cross-agent isolation
// =============================================================
describe('E2E: Cross-agent trigger isolation', () => {
it('Claude trigger / does not activate in Codex config', () => {
const codexConfig = CODEX_SKILLS_RESPONSE;
const info = getTriggerInfo('/', 1, codexConfig);
assert.equal(info, null, '/ should not trigger for Codex');
});
it('Codex trigger $ does not activate in Claude config', () => {
const claudeConfig = CLAUDE_SKILLS_RESPONSE;
const info = getTriggerInfo('$', 1, claudeConfig);
assert.equal(info, null, '$ should not trigger for Claude');
});
it('Each agent gets its own skills list', async () => {
// This requires the mock server
await startMockServer();
try {
const claude = await fetchSkills('claude');
const codex = await fetchSkills('codex');
assert.equal(claude.trigger, '/');
assert.equal(codex.trigger, '$');
const claudeNames = claude.skills.map(s => s.name);
const codexNames = codex.skills.map(s => s.name);
// No overlap in default test data
assert.ok(!claudeNames.includes('lint'), 'Claude should not have Codex skills');
assert.ok(!codexNames.includes('commit'), 'Codex should not have Claude skills');
} finally {
await stopMockServer();
}
});
});
// =============================================================
// Test Suite: Full workflow simulation
// =============================================================
describe('E2E: Full autocomplete workflow', () => {
before(startMockServer);
after(stopMockServer);
it('complete flow: fetch -> type -> filter -> navigate -> select -> verify', async () => {
// Step 1: Fetch skills from server (like Modal.js does on session open)
const config = await fetchSkills('claude');
assert.equal(config.trigger, '/');
assert.ok(config.skills.length > 0);
// Step 2: User starts typing - no trigger yet
let text = 'hello ';
let cursorPos = text.length;
let info = getTriggerInfo(text, cursorPos, config);
assert.equal(info, null, 'no trigger yet');
// Step 3: User types trigger character
text = 'hello /';
cursorPos = text.length;
info = getTriggerInfo(text, cursorPos, config);
assert.ok(info, 'trigger detected');
let skills = filteredSkills(config, info);
assert.ok(skills.length === 5, 'all 5 skills shown');
// Step 4: User types filter text
text = 'hello /com';
cursorPos = text.length;
info = getTriggerInfo(text, cursorPos, config);
assert.ok(info);
assert.equal(info.filterText, 'com');
skills = filteredSkills(config, info);
assert.equal(skills.length, 2, 'filtered to 2 skills');
assert.deepEqual(skills.map(s => s.name), ['comment', 'commit']);
// Step 5: Arrow down to select "commit" (index 1)
let selectedIndex = 0; // starts on "comment"
selectedIndex = Math.min(selectedIndex + 1, skills.length - 1); // ArrowDown
assert.equal(selectedIndex, 1);
assert.equal(skills[selectedIndex].name, 'commit');
// Step 6: Press Enter to insert
const selected = skills[selectedIndex];
const { replaceStart, replaceEnd } = info;
const before = text.slice(0, replaceStart);
const after = text.slice(replaceEnd);
const inserted = `${config.trigger}${selected.name} `;
const newText = before + inserted + after;
const newCursorPos = replaceStart + inserted.length;
// Step 7: Verify insertion
assert.equal(newText, 'hello /commit ');
assert.equal(newCursorPos, 14);
// Step 8: Verify autocomplete closed (trigger info should be null for the new text)
// After insertion, cursor is at 14, no active trigger word
const postInfo = getTriggerInfo(newText, newCursorPos, config);
assert.equal(postInfo, null, 'autocomplete should be dismissed after selection');
});
it('complete flow with second trigger after first insertion', async () => {
const config = await fetchSkills('claude');
// After first insertion: "hello /commit "
let text = 'hello /commit ';
let cursorPos = text.length;
// User types more text and another trigger
text = 'hello /commit then /';
cursorPos = text.length;
let info = getTriggerInfo(text, cursorPos, config);
assert.ok(info, 'second trigger detected');
assert.equal(info.replaceStart, 19);
// Filter the second trigger
text = 'hello /commit then /rev';
cursorPos = text.length;
info = getTriggerInfo(text, cursorPos, config);
assert.ok(info);
assert.equal(info.filterText, 'rev');
const skills = filteredSkills(config, info);
assert.ok(skills.some(s => s.name === 'review-pr'));
// Select review-pr
const skill = skills.find(s => s.name === 'review-pr');
const before = text.slice(0, info.replaceStart);
const after = text.slice(info.replaceEnd);
const newText = before + `${config.trigger}${skill.name} ` + after;
assert.equal(newText, 'hello /commit then /review-pr ');
});
});

View File

@@ -0,0 +1,250 @@
"""E2E tests for the /api/skills endpoint.
Spins up a real AMC server on a random port and verifies the skills API
returns correct data for Claude and Codex agents, including trigger
characters, alphabetical sorting, and response format.
"""
import json
import socket
import tempfile
import threading
import time
import unittest
import urllib.request
from http.server import ThreadingHTTPServer
from pathlib import Path
from unittest.mock import patch
from amc_server.handler import AMCHandler
def _find_free_port():
"""Find an available port for the test server."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _get_json(url):
"""Fetch JSON from a URL, returning (status_code, parsed_json)."""
req = urllib.request.Request(url)
try:
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status, json.loads(resp.read())
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read())
class TestSkillsEndpointE2E(unittest.TestCase):
"""E2E tests: start a real server and hit /api/skills over HTTP."""
@classmethod
def setUpClass(cls):
"""Start a test server on a random port with mock skill data."""
cls.port = _find_free_port()
cls.base_url = f"http://127.0.0.1:{cls.port}"
# Create temp directories for skill data
cls.tmpdir = tempfile.mkdtemp()
cls.home = Path(cls.tmpdir)
# Claude skills
for name, desc in [
("commit", "Create a git commit"),
("review-pr", "Review a pull request"),
("comment", "Add a comment"),
]:
skill_dir = cls.home / ".claude/skills" / name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(desc)
# Codex curated skills
cache_dir = cls.home / ".codex/vendor_imports"
cache_dir.mkdir(parents=True, exist_ok=True)
cache = {
"skills": [
{"id": "lint", "shortDescription": "Lint code"},
{"id": "deploy", "shortDescription": "Deploy to prod"},
]
}
(cache_dir / "skills-curated-cache.json").write_text(json.dumps(cache))
# Codex user skill
codex_skill = cls.home / ".codex/skills/my-script"
codex_skill.mkdir(parents=True, exist_ok=True)
(codex_skill / "SKILL.md").write_text("Run my custom script")
# Patch Path.home() for the skills enumeration
cls.home_patcher = patch.object(Path, "home", return_value=cls.home)
cls.home_patcher.start()
# Start server in background thread
cls.server = ThreadingHTTPServer(("127.0.0.1", cls.port), AMCHandler)
cls.server_thread = threading.Thread(target=cls.server.serve_forever)
cls.server_thread.daemon = True
cls.server_thread.start()
# Wait for server to be ready
for _ in range(50):
try:
with socket.create_connection(("127.0.0.1", cls.port), timeout=0.1):
break
except OSError:
time.sleep(0.05)
@classmethod
def tearDownClass(cls):
"""Shut down the test server."""
cls.server.shutdown()
cls.server_thread.join(timeout=5)
cls.home_patcher.stop()
# -- Core: /api/skills serves correctly --
def test_skills_default_is_claude(self):
"""GET /api/skills without ?agent defaults to claude (/ trigger)."""
status, data = _get_json(f"{self.base_url}/api/skills")
self.assertEqual(status, 200)
self.assertEqual(data["trigger"], "/")
self.assertIsInstance(data["skills"], list)
def test_claude_skills_returned(self):
"""GET /api/skills?agent=claude returns Claude skills."""
status, data = _get_json(f"{self.base_url}/api/skills?agent=claude")
self.assertEqual(status, 200)
self.assertEqual(data["trigger"], "/")
names = [s["name"] for s in data["skills"]]
self.assertIn("commit", names)
self.assertIn("review-pr", names)
self.assertIn("comment", names)
def test_codex_skills_returned(self):
"""GET /api/skills?agent=codex returns Codex skills with $ trigger."""
status, data = _get_json(f"{self.base_url}/api/skills?agent=codex")
self.assertEqual(status, 200)
self.assertEqual(data["trigger"], "$")
names = [s["name"] for s in data["skills"]]
self.assertIn("lint", names)
self.assertIn("deploy", names)
self.assertIn("my-script", names)
def test_unknown_agent_defaults_to_claude(self):
"""Unknown agent type defaults to claude behavior."""
status, data = _get_json(f"{self.base_url}/api/skills?agent=unknown-agent")
self.assertEqual(status, 200)
self.assertEqual(data["trigger"], "/")
# -- Response format --
def test_response_has_trigger_and_skills_keys(self):
"""Response JSON has exactly trigger and skills keys."""
_, data = _get_json(f"{self.base_url}/api/skills?agent=claude")
self.assertIn("trigger", data)
self.assertIn("skills", data)
def test_each_skill_has_name_and_description(self):
"""Each skill object has name and description fields."""
_, data = _get_json(f"{self.base_url}/api/skills?agent=claude")
for skill in data["skills"]:
self.assertIn("name", skill)
self.assertIn("description", skill)
self.assertIsInstance(skill["name"], str)
self.assertIsInstance(skill["description"], str)
# -- Alphabetical sorting --
def test_claude_skills_alphabetically_sorted(self):
"""Claude skills are returned in alphabetical order."""
_, data = _get_json(f"{self.base_url}/api/skills?agent=claude")
names = [s["name"] for s in data["skills"]]
self.assertEqual(names, sorted(names, key=str.lower))
def test_codex_skills_alphabetically_sorted(self):
"""Codex skills are returned in alphabetical order."""
_, data = _get_json(f"{self.base_url}/api/skills?agent=codex")
names = [s["name"] for s in data["skills"]]
self.assertEqual(names, sorted(names, key=str.lower))
# -- Descriptions --
def test_claude_skill_descriptions(self):
"""Claude skills have correct descriptions from SKILL.md."""
_, data = _get_json(f"{self.base_url}/api/skills?agent=claude")
by_name = {s["name"]: s["description"] for s in data["skills"]}
self.assertEqual(by_name["commit"], "Create a git commit")
self.assertEqual(by_name["review-pr"], "Review a pull request")
def test_codex_curated_descriptions(self):
"""Codex curated skills have correct descriptions from cache."""
_, data = _get_json(f"{self.base_url}/api/skills?agent=codex")
by_name = {s["name"]: s["description"] for s in data["skills"]}
self.assertEqual(by_name["lint"], "Lint code")
self.assertEqual(by_name["deploy"], "Deploy to prod")
def test_codex_user_skill_description(self):
"""Codex user-installed skills have descriptions from SKILL.md."""
_, data = _get_json(f"{self.base_url}/api/skills?agent=codex")
by_name = {s["name"]: s["description"] for s in data["skills"]}
self.assertEqual(by_name["my-script"], "Run my custom script")
# -- CORS --
def test_cors_header_present(self):
"""Response includes Access-Control-Allow-Origin header."""
url = f"{self.base_url}/api/skills?agent=claude"
with urllib.request.urlopen(url, timeout=5) as resp:
cors = resp.headers.get("Access-Control-Allow-Origin")
self.assertEqual(cors, "*")
class TestSkillsEndpointEmptyE2E(unittest.TestCase):
"""E2E tests: server with no skills data."""
@classmethod
def setUpClass(cls):
cls.port = _find_free_port()
cls.base_url = f"http://127.0.0.1:{cls.port}"
# Empty home directory - no skills at all
cls.tmpdir = tempfile.mkdtemp()
cls.home = Path(cls.tmpdir)
cls.home_patcher = patch.object(Path, "home", return_value=cls.home)
cls.home_patcher.start()
cls.server = ThreadingHTTPServer(("127.0.0.1", cls.port), AMCHandler)
cls.server_thread = threading.Thread(target=cls.server.serve_forever)
cls.server_thread.daemon = True
cls.server_thread.start()
for _ in range(50):
try:
with socket.create_connection(("127.0.0.1", cls.port), timeout=0.1):
break
except OSError:
time.sleep(0.05)
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.server_thread.join(timeout=5)
cls.home_patcher.stop()
def test_empty_claude_skills(self):
"""Server with no Claude skills returns empty list."""
status, data = _get_json(f"{self.base_url}/api/skills?agent=claude")
self.assertEqual(status, 200)
self.assertEqual(data["trigger"], "/")
self.assertEqual(data["skills"], [])
def test_empty_codex_skills(self):
"""Server with no Codex skills returns empty list."""
status, data = _get_json(f"{self.base_url}/api/skills?agent=codex")
self.assertEqual(status, 200)
self.assertEqual(data["trigger"], "$")
self.assertEqual(data["skills"], [])
if __name__ == "__main__":
unittest.main()

617
tests/e2e_spawn.sh Executable file
View File

@@ -0,0 +1,617 @@
#!/usr/bin/env bash
set -euo pipefail
# E2E test script for the AMC spawn workflow.
# Tests the full flow from API call to Zellij pane creation.
#
# Usage:
# ./tests/e2e_spawn.sh # Safe mode (no actual spawning)
# ./tests/e2e_spawn.sh --spawn # Full test including real agent spawn
SERVER_URL="http://localhost:7400"
TEST_PROJECT="amc" # Must exist in ~/projects/
AUTH_TOKEN=""
SPAWN_MODE=false
PASSED=0
FAILED=0
SKIPPED=0
# Parse args
for arg in "$@"; do
case "$arg" in
--spawn) SPAWN_MODE=true ;;
*) echo "Unknown arg: $arg"; exit 2 ;;
esac
done
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
log_test() { echo -e "\n${GREEN}[TEST]${NC} $1"; }
log_pass() { echo -e "${GREEN}[PASS]${NC} $1"; ((PASSED++)); }
log_fail() { echo -e "${RED}[FAIL]${NC} $1"; ((FAILED++)); }
log_skip() { echo -e "${YELLOW}[SKIP]${NC} $1"; ((SKIPPED++)); }
# Track spawned panes for cleanup
SPAWNED_PANE_NAMES=()
cleanup() {
if [[ ${#SPAWNED_PANE_NAMES[@]} -gt 0 ]]; then
log_info "Cleaning up spawned panes..."
for pane_name in "${SPAWNED_PANE_NAMES[@]}"; do
# Best-effort: close panes we spawned during tests
zellij --session infra action close-pane --name "$pane_name" 2>/dev/null || true
done
fi
}
trap cleanup EXIT
# ---------------------------------------------------------------------------
# Pre-flight checks
# ---------------------------------------------------------------------------
preflight() {
log_test "Pre-flight checks"
# curl available?
if ! command -v curl &>/dev/null; then
log_error "curl not found"
exit 1
fi
# jq available?
if ! command -v jq &>/dev/null; then
log_error "jq not found (required for JSON assertions)"
exit 1
fi
# Server running?
if ! curl -sf "${SERVER_URL}/api/health" >/dev/null 2>&1; then
log_error "Server not running at ${SERVER_URL}"
log_error "Start with: python -m amc_server.server"
exit 1
fi
# Test project exists?
if [[ ! -d "$HOME/projects/${TEST_PROJECT}" ]]; then
log_error "Test project '${TEST_PROJECT}' not found at ~/projects/${TEST_PROJECT}"
exit 1
fi
log_pass "Pre-flight checks passed"
}
# ---------------------------------------------------------------------------
# Extract auth token from dashboard HTML
# ---------------------------------------------------------------------------
extract_auth_token() {
log_test "Extract auth token from dashboard"
local html
html=$(curl -sf "${SERVER_URL}/")
AUTH_TOKEN=$(echo "$html" | grep -o 'AMC_AUTH_TOKEN = "[^"]*"' | cut -d'"' -f2)
if [[ -z "$AUTH_TOKEN" ]]; then
log_error "Could not extract auth token from dashboard HTML"
log_error "Check that index.html contains <!-- AMC_AUTH_TOKEN --> placeholder"
exit 1
fi
log_pass "Auth token extracted (${AUTH_TOKEN:0:8}...)"
}
# ---------------------------------------------------------------------------
# Test: GET /api/health
# ---------------------------------------------------------------------------
test_health_endpoint() {
log_test "GET /api/health"
local response
response=$(curl -sf "${SERVER_URL}/api/health")
local ok
ok=$(echo "$response" | jq -r '.ok')
if [[ "$ok" != "true" ]]; then
log_fail "Health endpoint returned ok=$ok"
return
fi
# Must include zellij_available and zellij_session fields
local has_zellij_available has_zellij_session
has_zellij_available=$(echo "$response" | jq 'has("zellij_available")')
has_zellij_session=$(echo "$response" | jq 'has("zellij_session")')
if [[ "$has_zellij_available" != "true" || "$has_zellij_session" != "true" ]]; then
log_fail "Health response missing expected fields: $response"
return
fi
local zellij_available
zellij_available=$(echo "$response" | jq -r '.zellij_available')
log_pass "Health OK (zellij_available=$zellij_available)"
}
# ---------------------------------------------------------------------------
# Test: GET /api/projects
# ---------------------------------------------------------------------------
test_projects_endpoint() {
log_test "GET /api/projects"
local response
response=$(curl -sf "${SERVER_URL}/api/projects")
local ok
ok=$(echo "$response" | jq -r '.ok')
if [[ "$ok" != "true" ]]; then
log_fail "Projects endpoint returned ok=$ok"
return
fi
local project_count
project_count=$(echo "$response" | jq '.projects | length')
if [[ "$project_count" -lt 1 ]]; then
log_fail "No projects returned (expected at least 1)"
return
fi
# Verify test project is in the list
local has_test_project
has_test_project=$(echo "$response" | jq --arg p "$TEST_PROJECT" '[.projects[] | select(. == $p)] | length')
if [[ "$has_test_project" -lt 1 ]]; then
log_fail "Test project '$TEST_PROJECT' not in projects list"
return
fi
log_pass "Projects OK ($project_count projects, '$TEST_PROJECT' present)"
}
# ---------------------------------------------------------------------------
# Test: POST /api/projects/refresh
# ---------------------------------------------------------------------------
test_projects_refresh() {
log_test "POST /api/projects/refresh"
local response
response=$(curl -sf -X POST "${SERVER_URL}/api/projects/refresh")
local ok
ok=$(echo "$response" | jq -r '.ok')
if [[ "$ok" != "true" ]]; then
log_fail "Projects refresh returned ok=$ok"
return
fi
local project_count
project_count=$(echo "$response" | jq '.projects | length')
log_pass "Projects refresh OK ($project_count projects)"
}
# ---------------------------------------------------------------------------
# Test: Spawn without auth (should return 401)
# ---------------------------------------------------------------------------
test_spawn_no_auth() {
log_test "POST /api/spawn without auth (expect 401)"
local http_code body
body=$(curl -s -o /dev/null -w '%{http_code}' -X POST "${SERVER_URL}/api/spawn" \
-H "Content-Type: application/json" \
-d '{"project":"amc","agent_type":"claude"}')
if [[ "$body" != "401" ]]; then
log_fail "Expected HTTP 401, got $body"
return
fi
# Also verify the JSON error code
local response
response=$(curl -s -X POST "${SERVER_URL}/api/spawn" \
-H "Content-Type: application/json" \
-d '{"project":"amc","agent_type":"claude"}')
local code
code=$(echo "$response" | jq -r '.code')
if [[ "$code" != "UNAUTHORIZED" ]]; then
log_fail "Expected code UNAUTHORIZED, got $code"
return
fi
log_pass "Correctly rejected unauthorized request (401/UNAUTHORIZED)"
}
# ---------------------------------------------------------------------------
# Test: Spawn with wrong token (should return 401)
# ---------------------------------------------------------------------------
test_spawn_wrong_token() {
log_test "POST /api/spawn with wrong token (expect 401)"
local response
response=$(curl -s -X POST "${SERVER_URL}/api/spawn" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer totally-wrong-token" \
-d '{"project":"amc","agent_type":"claude"}')
local code
code=$(echo "$response" | jq -r '.code')
if [[ "$code" != "UNAUTHORIZED" ]]; then
log_fail "Expected UNAUTHORIZED, got $code"
return
fi
log_pass "Correctly rejected wrong token (UNAUTHORIZED)"
}
# ---------------------------------------------------------------------------
# Test: Spawn with malformed auth (no Bearer prefix)
# ---------------------------------------------------------------------------
test_spawn_malformed_auth() {
log_test "POST /api/spawn with malformed auth header"
local response
response=$(curl -s -X POST "${SERVER_URL}/api/spawn" \
-H "Content-Type: application/json" \
-H "Authorization: Token ${AUTH_TOKEN}" \
-d '{"project":"amc","agent_type":"claude"}')
local code
code=$(echo "$response" | jq -r '.code')
if [[ "$code" != "UNAUTHORIZED" ]]; then
log_fail "Expected UNAUTHORIZED for malformed auth, got $code"
return
fi
log_pass "Correctly rejected malformed auth header"
}
# ---------------------------------------------------------------------------
# Test: Spawn with invalid JSON body
# ---------------------------------------------------------------------------
test_spawn_invalid_json() {
log_test "POST /api/spawn with invalid JSON"
local response
response=$(curl -s -X POST "${SERVER_URL}/api/spawn" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
-d 'not valid json!!!')
local ok
ok=$(echo "$response" | jq -r '.ok')
if [[ "$ok" != "false" ]]; then
log_fail "Expected ok=false for invalid JSON, got ok=$ok"
return
fi
log_pass "Correctly rejected invalid JSON body"
}
# ---------------------------------------------------------------------------
# Test: Spawn with path traversal (should return 400/INVALID_PROJECT)
# ---------------------------------------------------------------------------
test_spawn_path_traversal() {
log_test "POST /api/spawn with path traversal"
local response
response=$(curl -s -X POST "${SERVER_URL}/api/spawn" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
-d '{"project":"../etc/passwd","agent_type":"claude"}')
local code
code=$(echo "$response" | jq -r '.code')
if [[ "$code" != "INVALID_PROJECT" ]]; then
log_fail "Expected INVALID_PROJECT for path traversal, got $code"
return
fi
log_pass "Correctly rejected path traversal (INVALID_PROJECT)"
}
# ---------------------------------------------------------------------------
# Test: Spawn with nonexistent project
# ---------------------------------------------------------------------------
test_spawn_nonexistent_project() {
log_test "POST /api/spawn with nonexistent project"
local response
response=$(curl -s -X POST "${SERVER_URL}/api/spawn" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
-d '{"project":"this-project-does-not-exist-xyz","agent_type":"claude"}')
local code
code=$(echo "$response" | jq -r '.code')
if [[ "$code" != "PROJECT_NOT_FOUND" ]]; then
log_fail "Expected PROJECT_NOT_FOUND, got $code"
return
fi
log_pass "Correctly rejected nonexistent project (PROJECT_NOT_FOUND)"
}
# ---------------------------------------------------------------------------
# Test: Spawn with invalid agent type
# ---------------------------------------------------------------------------
test_spawn_invalid_agent_type() {
log_test "POST /api/spawn with invalid agent type"
local response
response=$(curl -s -X POST "${SERVER_URL}/api/spawn" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
-d "{\"project\":\"${TEST_PROJECT}\",\"agent_type\":\"gpt5\"}")
local code
code=$(echo "$response" | jq -r '.code')
if [[ "$code" != "INVALID_AGENT_TYPE" ]]; then
log_fail "Expected INVALID_AGENT_TYPE, got $code"
return
fi
log_pass "Correctly rejected invalid agent type (INVALID_AGENT_TYPE)"
}
# ---------------------------------------------------------------------------
# Test: Spawn with missing project field
# ---------------------------------------------------------------------------
test_spawn_missing_project() {
log_test "POST /api/spawn with missing project"
local response
response=$(curl -s -X POST "${SERVER_URL}/api/spawn" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
-d '{"agent_type":"claude"}')
local code
code=$(echo "$response" | jq -r '.code')
if [[ "$code" != "MISSING_PROJECT" ]]; then
log_fail "Expected MISSING_PROJECT, got $code"
return
fi
log_pass "Correctly rejected missing project field (MISSING_PROJECT)"
}
# ---------------------------------------------------------------------------
# Test: Spawn with backslash in project name
# ---------------------------------------------------------------------------
test_spawn_backslash_project() {
log_test "POST /api/spawn with backslash in project name"
local response
response=$(curl -s -X POST "${SERVER_URL}/api/spawn" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
-d '{"project":"foo\\bar","agent_type":"claude"}')
local code
code=$(echo "$response" | jq -r '.code')
if [[ "$code" != "INVALID_PROJECT" ]]; then
log_fail "Expected INVALID_PROJECT for backslash, got $code"
return
fi
log_pass "Correctly rejected backslash in project name (INVALID_PROJECT)"
}
# ---------------------------------------------------------------------------
# Test: CORS preflight for /api/spawn
# ---------------------------------------------------------------------------
test_cors_preflight() {
log_test "OPTIONS /api/spawn (CORS preflight)"
local http_code headers
headers=$(curl -sI -X OPTIONS "${SERVER_URL}/api/spawn" 2>/dev/null)
http_code=$(echo "$headers" | head -1 | grep -o '[0-9][0-9][0-9]' | head -1)
if [[ "$http_code" != "204" ]]; then
log_fail "Expected HTTP 204 for OPTIONS, got $http_code"
return
fi
if ! echo "$headers" | grep -qi 'Access-Control-Allow-Methods'; then
log_fail "Missing Access-Control-Allow-Methods header"
return
fi
if ! echo "$headers" | grep -qi 'Authorization'; then
log_fail "Authorization not in Access-Control-Allow-Headers"
return
fi
log_pass "CORS preflight OK (204 with correct headers)"
}
# ---------------------------------------------------------------------------
# Test: Actual spawn (only with --spawn flag)
# ---------------------------------------------------------------------------
test_spawn_valid() {
if [[ "$SPAWN_MODE" != "true" ]]; then
log_skip "Actual spawn test (pass --spawn to enable)"
return
fi
log_test "POST /api/spawn with valid project (LIVE)"
# Check Zellij session first
if ! zellij list-sessions 2>/dev/null | grep -q '^infra'; then
log_skip "Zellij session 'infra' not found - cannot test live spawn"
return
fi
# Count session files before
local sessions_dir="$HOME/.local/share/amc/sessions"
local count_before=0
if [[ -d "$sessions_dir" ]]; then
count_before=$(find "$sessions_dir" -name '*.json' -maxdepth 1 2>/dev/null | wc -l | tr -d ' ')
fi
local response
response=$(curl -s -X POST "${SERVER_URL}/api/spawn" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
-d "{\"project\":\"${TEST_PROJECT}\",\"agent_type\":\"claude\"}")
local ok
ok=$(echo "$response" | jq -r '.ok')
if [[ "$ok" != "true" ]]; then
local error_code
error_code=$(echo "$response" | jq -r '.code // .error')
log_fail "Spawn failed: $error_code"
return
fi
# Verify spawn_id is returned
local spawn_id
spawn_id=$(echo "$response" | jq -r '.spawn_id')
if [[ -z "$spawn_id" || "$spawn_id" == "null" ]]; then
log_fail "No spawn_id in response"
return
fi
# Track for cleanup
SPAWNED_PANE_NAMES+=("claude-${TEST_PROJECT}")
# Verify session_file_found field
local session_found
session_found=$(echo "$response" | jq -r '.session_file_found')
log_info "session_file_found=$session_found, spawn_id=${spawn_id:0:8}..."
log_pass "Spawn successful (spawn_id=${spawn_id:0:8}...)"
}
# ---------------------------------------------------------------------------
# Test: Rate limiting (only with --spawn flag)
# ---------------------------------------------------------------------------
test_rate_limiting() {
if [[ "$SPAWN_MODE" != "true" ]]; then
log_skip "Rate limiting test (pass --spawn to enable)"
return
fi
log_test "Rate limiting on rapid spawn"
# Immediately try to spawn the same project again
local response
response=$(curl -s -X POST "${SERVER_URL}/api/spawn" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
-d "{\"project\":\"${TEST_PROJECT}\",\"agent_type\":\"claude\"}")
local code
code=$(echo "$response" | jq -r '.code')
if [[ "$code" == "RATE_LIMITED" ]]; then
log_pass "Rate limiting active (RATE_LIMITED returned)"
else
local ok
ok=$(echo "$response" | jq -r '.ok')
log_warn "Rate limiting not triggered (ok=$ok, code=$code) - cooldown may have expired"
log_pass "Rate limiting test completed (non-deterministic)"
fi
}
# ---------------------------------------------------------------------------
# Test: Dashboard shows agent after spawn (only with --spawn flag)
# ---------------------------------------------------------------------------
test_dashboard_shows_agent() {
if [[ "$SPAWN_MODE" != "true" ]]; then
log_skip "Dashboard agent visibility test (pass --spawn to enable)"
return
fi
log_test "Dashboard /api/state includes spawned agent"
# Give the session a moment to register
sleep 2
local response
response=$(curl -sf "${SERVER_URL}/api/state")
local session_count
session_count=$(echo "$response" | jq '.sessions | length')
if [[ "$session_count" -gt 0 ]]; then
log_pass "Dashboard shows $session_count session(s)"
else
log_warn "No sessions visible yet (agent may still be starting)"
log_pass "Dashboard state endpoint responsive"
fi
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
echo "========================================="
echo " AMC Spawn Workflow E2E Tests"
echo "========================================="
echo ""
if [[ "$SPAWN_MODE" == "true" ]]; then
log_warn "SPAWN MODE: will create real Zellij panes"
else
log_info "Safe mode (no actual spawning). Pass --spawn to test live spawn."
fi
preflight
extract_auth_token
# Read-only endpoint tests
test_health_endpoint
test_projects_endpoint
test_projects_refresh
# Auth / validation tests (no side effects)
test_spawn_no_auth
test_spawn_wrong_token
test_spawn_malformed_auth
test_spawn_invalid_json
test_spawn_path_traversal
test_spawn_nonexistent_project
test_spawn_invalid_agent_type
test_spawn_missing_project
test_spawn_backslash_project
# CORS
test_cors_preflight
# Live spawn tests (only with --spawn)
test_spawn_valid
test_rate_limiting
test_dashboard_shows_agent
# Summary
echo ""
echo "========================================="
echo " Results: ${PASSED} passed, ${FAILED} failed, ${SKIPPED} skipped"
echo "========================================="
if [[ "$FAILED" -gt 0 ]]; then
exit 1
fi
}
main "$@"

View File

@@ -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")

View File

@@ -172,5 +172,358 @@ class SessionControlMixinTests(unittest.TestCase):
handler._try_write_chars_inject.assert_called_once()
class TestParsePaneId(unittest.TestCase):
"""Tests for _parse_pane_id edge cases."""
def setUp(self):
self.handler = DummyControlHandler()
def test_empty_string_returns_none(self):
self.assertIsNone(self.handler._parse_pane_id(""))
def test_none_returns_none(self):
self.assertIsNone(self.handler._parse_pane_id(None))
def test_direct_int_string_parses(self):
self.assertEqual(self.handler._parse_pane_id("42"), 42)
def test_terminal_format_parses(self):
self.assertEqual(self.handler._parse_pane_id("terminal_5"), 5)
def test_plugin_format_parses(self):
self.assertEqual(self.handler._parse_pane_id("plugin_3"), 3)
def test_unknown_prefix_returns_none(self):
self.assertIsNone(self.handler._parse_pane_id("pane_7"))
def test_non_numeric_suffix_returns_none(self):
self.assertIsNone(self.handler._parse_pane_id("terminal_abc"))
def test_too_many_underscores_returns_none(self):
self.assertIsNone(self.handler._parse_pane_id("terminal_5_extra"))
def test_negative_int_parses(self):
# Edge case: negative numbers
self.assertEqual(self.handler._parse_pane_id("-1"), -1)
class TestGetSubmitEnterDelaySec(unittest.TestCase):
"""Tests for _get_submit_enter_delay_sec edge cases."""
def setUp(self):
self.handler = DummyControlHandler()
def test_unset_env_returns_default(self):
with patch.dict(os.environ, {}, clear=True):
result = self.handler._get_submit_enter_delay_sec()
self.assertEqual(result, 0.20)
def test_empty_string_returns_default(self):
with patch.dict(os.environ, {"AMC_SUBMIT_ENTER_DELAY_MS": ""}, clear=True):
result = self.handler._get_submit_enter_delay_sec()
self.assertEqual(result, 0.20)
def test_whitespace_only_returns_default(self):
with patch.dict(os.environ, {"AMC_SUBMIT_ENTER_DELAY_MS": " "}, clear=True):
result = self.handler._get_submit_enter_delay_sec()
self.assertEqual(result, 0.20)
def test_negative_value_returns_zero(self):
with patch.dict(os.environ, {"AMC_SUBMIT_ENTER_DELAY_MS": "-100"}, clear=True):
result = self.handler._get_submit_enter_delay_sec()
self.assertEqual(result, 0.0)
def test_value_over_2000_clamped(self):
with patch.dict(os.environ, {"AMC_SUBMIT_ENTER_DELAY_MS": "5000"}, clear=True):
result = self.handler._get_submit_enter_delay_sec()
self.assertEqual(result, 2.0) # 2000ms = 2.0s
def test_valid_ms_converted_to_seconds(self):
with patch.dict(os.environ, {"AMC_SUBMIT_ENTER_DELAY_MS": "500"}, clear=True):
result = self.handler._get_submit_enter_delay_sec()
self.assertEqual(result, 0.5)
def test_float_value_works(self):
with patch.dict(os.environ, {"AMC_SUBMIT_ENTER_DELAY_MS": "150.5"}, clear=True):
result = self.handler._get_submit_enter_delay_sec()
self.assertAlmostEqual(result, 0.1505)
def test_non_numeric_returns_default(self):
with patch.dict(os.environ, {"AMC_SUBMIT_ENTER_DELAY_MS": "fast"}, clear=True):
result = self.handler._get_submit_enter_delay_sec()
self.assertEqual(result, 0.20)
class TestAllowUnsafeWriteCharsFallback(unittest.TestCase):
"""Tests for _allow_unsafe_write_chars_fallback edge cases."""
def setUp(self):
self.handler = DummyControlHandler()
def test_unset_returns_false(self):
with patch.dict(os.environ, {}, clear=True):
self.assertFalse(self.handler._allow_unsafe_write_chars_fallback())
def test_empty_returns_false(self):
with patch.dict(os.environ, {"AMC_ALLOW_UNSAFE_WRITE_CHARS_FALLBACK": ""}, clear=True):
self.assertFalse(self.handler._allow_unsafe_write_chars_fallback())
def test_one_returns_true(self):
with patch.dict(os.environ, {"AMC_ALLOW_UNSAFE_WRITE_CHARS_FALLBACK": "1"}, clear=True):
self.assertTrue(self.handler._allow_unsafe_write_chars_fallback())
def test_true_returns_true(self):
with patch.dict(os.environ, {"AMC_ALLOW_UNSAFE_WRITE_CHARS_FALLBACK": "true"}, clear=True):
self.assertTrue(self.handler._allow_unsafe_write_chars_fallback())
def test_yes_returns_true(self):
with patch.dict(os.environ, {"AMC_ALLOW_UNSAFE_WRITE_CHARS_FALLBACK": "yes"}, clear=True):
self.assertTrue(self.handler._allow_unsafe_write_chars_fallback())
def test_on_returns_true(self):
with patch.dict(os.environ, {"AMC_ALLOW_UNSAFE_WRITE_CHARS_FALLBACK": "on"}, clear=True):
self.assertTrue(self.handler._allow_unsafe_write_chars_fallback())
def test_case_insensitive(self):
with patch.dict(os.environ, {"AMC_ALLOW_UNSAFE_WRITE_CHARS_FALLBACK": "TRUE"}, clear=True):
self.assertTrue(self.handler._allow_unsafe_write_chars_fallback())
def test_random_string_returns_false(self):
with patch.dict(os.environ, {"AMC_ALLOW_UNSAFE_WRITE_CHARS_FALLBACK": "maybe"}, clear=True):
self.assertFalse(self.handler._allow_unsafe_write_chars_fallback())
class TestDismissSession(unittest.TestCase):
"""Tests for _dismiss_session edge cases."""
def test_deletes_existing_session_file(self):
with tempfile.TemporaryDirectory() as tmpdir:
sessions_dir = Path(tmpdir)
sessions_dir.mkdir(exist_ok=True)
session_file = sessions_dir / "abc123.json"
session_file.write_text('{"session_id": "abc123"}')
handler = DummyControlHandler()
with patch.object(control, "SESSIONS_DIR", sessions_dir):
handler._dismiss_session("abc123")
self.assertFalse(session_file.exists())
self.assertEqual(handler.sent, [(200, {"ok": True})])
def test_handles_missing_file_gracefully(self):
with tempfile.TemporaryDirectory() as tmpdir:
sessions_dir = Path(tmpdir)
handler = DummyControlHandler()
with patch.object(control, "SESSIONS_DIR", sessions_dir):
handler._dismiss_session("nonexistent")
# Should still return success
self.assertEqual(handler.sent, [(200, {"ok": True})])
def test_path_traversal_sanitized(self):
with tempfile.TemporaryDirectory() as tmpdir:
sessions_dir = Path(tmpdir)
sessions_dir.mkdir(exist_ok=True)
# Create a file that should NOT be deleted (unused - documents test intent)
_secret_file = Path(tmpdir).parent / "secret.json"
handler = DummyControlHandler()
with patch.object(control, "SESSIONS_DIR", sessions_dir):
handler._dismiss_session("../secret")
# Secret file should not have been targeted
# (if it existed, it would still exist)
def test_tracks_dismissed_codex_session(self):
from amc_server.agents import _dismissed_codex_ids
_dismissed_codex_ids.clear()
with tempfile.TemporaryDirectory() as tmpdir:
sessions_dir = Path(tmpdir)
handler = DummyControlHandler()
with patch.object(control, "SESSIONS_DIR", sessions_dir):
handler._dismiss_session("codex-session-123")
self.assertIn("codex-session-123", _dismissed_codex_ids)
_dismissed_codex_ids.clear()
class TestTryWriteCharsInject(unittest.TestCase):
"""Tests for _try_write_chars_inject edge cases."""
def setUp(self):
self.handler = DummyControlHandler()
def test_successful_write_without_enter(self):
completed = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
with patch.object(control, "ZELLIJ_BIN", "/usr/bin/zellij"), \
patch("amc_server.mixins.control.subprocess.run", return_value=completed) as run_mock:
result = self.handler._try_write_chars_inject({}, "infra", "hello", send_enter=False)
self.assertEqual(result, {"ok": True})
# Should only be called once (no Enter)
self.assertEqual(run_mock.call_count, 1)
def test_successful_write_with_enter(self):
completed = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
with patch.object(control, "ZELLIJ_BIN", "/usr/bin/zellij"), \
patch("amc_server.mixins.control.subprocess.run", return_value=completed) as run_mock:
result = self.handler._try_write_chars_inject({}, "infra", "hello", send_enter=True)
self.assertEqual(result, {"ok": True})
# Should be called twice (write-chars + write Enter)
self.assertEqual(run_mock.call_count, 2)
def test_write_chars_failure_returns_error(self):
failed = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="write failed")
with patch.object(control, "ZELLIJ_BIN", "/usr/bin/zellij"), \
patch("amc_server.mixins.control.subprocess.run", return_value=failed):
result = self.handler._try_write_chars_inject({}, "infra", "hello", send_enter=False)
self.assertFalse(result["ok"])
self.assertIn("write", result["error"].lower())
def test_timeout_returns_error(self):
with patch.object(control, "ZELLIJ_BIN", "/usr/bin/zellij"), \
patch("amc_server.mixins.control.subprocess.run",
side_effect=subprocess.TimeoutExpired("cmd", 2)):
result = self.handler._try_write_chars_inject({}, "infra", "hello", send_enter=False)
self.assertFalse(result["ok"])
self.assertIn("timed out", result["error"].lower())
def test_zellij_not_found_returns_error(self):
with patch.object(control, "ZELLIJ_BIN", "/nonexistent/zellij"), \
patch("amc_server.mixins.control.subprocess.run",
side_effect=FileNotFoundError("No such file")):
result = self.handler._try_write_chars_inject({}, "infra", "hello", send_enter=False)
self.assertFalse(result["ok"])
self.assertIn("not found", result["error"].lower())
class TestRespondToSessionEdgeCases(unittest.TestCase):
"""Additional edge case tests for _respond_to_session."""
def _write_session(self, sessions_dir, session_id, **kwargs):
sessions_dir.mkdir(parents=True, exist_ok=True)
session_file = sessions_dir / f"{session_id}.json"
data = {"session_id": session_id, **kwargs}
session_file.write_text(json.dumps(data))
def test_invalid_json_body_returns_400(self):
handler = DummyControlHandler.__new__(DummyControlHandler)
handler.headers = {"Content-Length": "10"}
handler.rfile = io.BytesIO(b"not json!!")
handler.sent = []
handler.errors = []
with tempfile.TemporaryDirectory() as tmpdir:
with patch.object(control, "SESSIONS_DIR", Path(tmpdir)):
handler._respond_to_session("test")
self.assertEqual(handler.errors, [(400, "Invalid JSON body")])
def test_non_dict_body_returns_400(self):
raw = b'"just a string"'
handler = DummyControlHandler.__new__(DummyControlHandler)
handler.headers = {"Content-Length": str(len(raw))}
handler.rfile = io.BytesIO(raw)
handler.sent = []
handler.errors = []
with tempfile.TemporaryDirectory() as tmpdir:
with patch.object(control, "SESSIONS_DIR", Path(tmpdir)):
handler._respond_to_session("test")
self.assertEqual(handler.errors, [(400, "Invalid JSON body")])
def test_empty_text_returns_400(self):
handler = DummyControlHandler({"text": ""})
with tempfile.TemporaryDirectory() as tmpdir:
sessions_dir = Path(tmpdir)
self._write_session(sessions_dir, "test", zellij_session="s", zellij_pane="1")
with patch.object(control, "SESSIONS_DIR", sessions_dir):
handler._respond_to_session("test")
self.assertEqual(handler.errors, [(400, "Missing or empty 'text' field")])
def test_whitespace_only_text_returns_400(self):
handler = DummyControlHandler({"text": " \n\t "})
with tempfile.TemporaryDirectory() as tmpdir:
sessions_dir = Path(tmpdir)
self._write_session(sessions_dir, "test", zellij_session="s", zellij_pane="1")
with patch.object(control, "SESSIONS_DIR", sessions_dir):
handler._respond_to_session("test")
self.assertEqual(handler.errors, [(400, "Missing or empty 'text' field")])
def test_non_string_text_returns_400(self):
handler = DummyControlHandler({"text": 123})
with tempfile.TemporaryDirectory() as tmpdir:
sessions_dir = Path(tmpdir)
self._write_session(sessions_dir, "test", zellij_session="s", zellij_pane="1")
with patch.object(control, "SESSIONS_DIR", sessions_dir):
handler._respond_to_session("test")
self.assertEqual(handler.errors, [(400, "Missing or empty 'text' field")])
def test_missing_zellij_session_returns_400(self):
handler = DummyControlHandler({"text": "hello"})
with tempfile.TemporaryDirectory() as tmpdir:
sessions_dir = Path(tmpdir)
self._write_session(sessions_dir, "test", zellij_session="", zellij_pane="1")
with patch.object(control, "SESSIONS_DIR", sessions_dir):
handler._respond_to_session("test")
self.assertIn("missing Zellij pane info", handler.errors[0][1])
def test_missing_zellij_pane_returns_400(self):
handler = DummyControlHandler({"text": "hello"})
with tempfile.TemporaryDirectory() as tmpdir:
sessions_dir = Path(tmpdir)
self._write_session(sessions_dir, "test", zellij_session="sess", zellij_pane="")
with patch.object(control, "SESSIONS_DIR", sessions_dir):
handler._respond_to_session("test")
self.assertIn("missing Zellij pane info", handler.errors[0][1])
def test_invalid_pane_format_returns_400(self):
handler = DummyControlHandler({"text": "hello"})
with tempfile.TemporaryDirectory() as tmpdir:
sessions_dir = Path(tmpdir)
self._write_session(sessions_dir, "test", zellij_session="sess", zellij_pane="invalid_format_here")
with patch.object(control, "SESSIONS_DIR", sessions_dir):
handler._respond_to_session("test")
self.assertIn("Invalid pane format", handler.errors[0][1])
def test_invalid_option_count_treated_as_zero(self):
# optionCount that can't be parsed as int should default to 0
handler = DummyControlHandler({"text": "hello", "freeform": True, "optionCount": "not a number"})
with tempfile.TemporaryDirectory() as tmpdir:
sessions_dir = Path(tmpdir)
self._write_session(sessions_dir, "test", zellij_session="sess", zellij_pane="5")
with patch.object(control, "SESSIONS_DIR", sessions_dir):
handler._inject_text_then_enter = MagicMock(return_value={"ok": True})
handler._respond_to_session("test")
# With optionCount=0, freeform mode shouldn't trigger the "other" selection
# It should go straight to inject_text_then_enter
handler._inject_text_then_enter.assert_called_once()
if __name__ == "__main__":
unittest.main()

585
tests/test_conversation.py Normal file
View File

@@ -0,0 +1,585 @@
"""Tests for mixins/conversation.py edge cases.
Unit tests for conversation parsing from Claude Code and Codex JSONL files.
"""
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from amc_server.mixins.conversation import ConversationMixin, _is_system_injected
from amc_server.mixins.parsing import SessionParsingMixin
class DummyConversationHandler(ConversationMixin, SessionParsingMixin):
"""Minimal handler for testing conversation mixin."""
def __init__(self):
self.sent_responses = []
def _send_json(self, code, payload):
self.sent_responses.append((code, payload))
class TestParseCodexArguments(unittest.TestCase):
"""Tests for _parse_codex_arguments edge cases."""
def setUp(self):
self.handler = DummyConversationHandler()
def test_dict_input_returned_as_is(self):
result = self.handler._parse_codex_arguments({"key": "value"})
self.assertEqual(result, {"key": "value"})
def test_empty_dict_returned_as_is(self):
result = self.handler._parse_codex_arguments({})
self.assertEqual(result, {})
def test_json_string_parsed(self):
result = self.handler._parse_codex_arguments('{"key": "value"}')
self.assertEqual(result, {"key": "value"})
def test_invalid_json_string_returns_raw(self):
result = self.handler._parse_codex_arguments("not valid json")
self.assertEqual(result, {"raw": "not valid json"})
def test_empty_string_returns_raw(self):
result = self.handler._parse_codex_arguments("")
self.assertEqual(result, {"raw": ""})
def test_none_returns_empty_dict(self):
result = self.handler._parse_codex_arguments(None)
self.assertEqual(result, {})
def test_int_returns_empty_dict(self):
result = self.handler._parse_codex_arguments(42)
self.assertEqual(result, {})
def test_list_returns_empty_dict(self):
result = self.handler._parse_codex_arguments([1, 2, 3])
self.assertEqual(result, {})
class TestServeEvents(unittest.TestCase):
"""Tests for _serve_events edge cases."""
def setUp(self):
self.handler = DummyConversationHandler()
def test_path_traversal_sanitized(self):
with tempfile.TemporaryDirectory() as tmpdir:
events_dir = Path(tmpdir)
# Create a file that path traversal might try to access (unused - documents intent)
_secret_file = Path(tmpdir).parent / "secret.jsonl"
with patch("amc_server.mixins.conversation.EVENTS_DIR", events_dir):
# Try path traversal
self.handler._serve_events("../secret")
# Should have served response with sanitized id
self.assertEqual(len(self.handler.sent_responses), 1)
code, payload = self.handler.sent_responses[0]
self.assertEqual(code, 200)
self.assertEqual(payload["session_id"], "secret")
self.assertEqual(payload["events"], [])
def test_nonexistent_file_returns_empty_events(self):
with tempfile.TemporaryDirectory() as tmpdir:
with patch("amc_server.mixins.conversation.EVENTS_DIR", Path(tmpdir)):
self.handler._serve_events("nonexistent")
code, payload = self.handler.sent_responses[0]
self.assertEqual(payload["events"], [])
def test_empty_file_returns_empty_events(self):
with tempfile.TemporaryDirectory() as tmpdir:
events_dir = Path(tmpdir)
event_file = events_dir / "session123.jsonl"
event_file.write_text("")
with patch("amc_server.mixins.conversation.EVENTS_DIR", events_dir):
self.handler._serve_events("session123")
code, payload = self.handler.sent_responses[0]
self.assertEqual(payload["events"], [])
def test_invalid_json_lines_skipped(self):
with tempfile.TemporaryDirectory() as tmpdir:
events_dir = Path(tmpdir)
event_file = events_dir / "session123.jsonl"
event_file.write_text('{"valid": "event"}\nnot json\n{"another": "event"}\n')
with patch("amc_server.mixins.conversation.EVENTS_DIR", events_dir):
self.handler._serve_events("session123")
code, payload = self.handler.sent_responses[0]
self.assertEqual(len(payload["events"]), 2)
self.assertEqual(payload["events"][0], {"valid": "event"})
self.assertEqual(payload["events"][1], {"another": "event"})
class TestParseClaudeConversation(unittest.TestCase):
"""Tests for _parse_claude_conversation edge cases."""
def setUp(self):
self.handler = DummyConversationHandler()
def test_user_message_with_array_content_skipped(self):
# Array content is tool results, not human messages
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write(json.dumps({
"type": "user",
"message": {"content": [{"type": "tool_result"}]}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_get_claude_conversation_file", return_value=path):
messages = self.handler._parse_claude_conversation("session123", "/project")
self.assertEqual(messages, [])
finally:
path.unlink()
def test_user_message_with_string_content_included(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write(json.dumps({
"type": "user",
"timestamp": "2024-01-01T00:00:00Z",
"message": {"content": "Hello, Claude!"}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_get_claude_conversation_file", return_value=path):
messages = self.handler._parse_claude_conversation("session123", "/project")
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0]["role"], "user")
self.assertEqual(messages[0]["content"], "Hello, Claude!")
finally:
path.unlink()
def test_assistant_message_with_text_parts(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write(json.dumps({
"type": "assistant",
"timestamp": "2024-01-01T00:00:00Z",
"message": {
"content": [
{"type": "text", "text": "Part 1"},
{"type": "text", "text": "Part 2"},
]
}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_get_claude_conversation_file", return_value=path):
messages = self.handler._parse_claude_conversation("session123", "/project")
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0]["content"], "Part 1\nPart 2")
finally:
path.unlink()
def test_assistant_message_with_tool_use(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write(json.dumps({
"type": "assistant",
"message": {
"content": [
{"type": "tool_use", "name": "Read", "input": {"file_path": "/test"}},
]
}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_get_claude_conversation_file", return_value=path):
messages = self.handler._parse_claude_conversation("session123", "/project")
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0]["tool_calls"][0]["name"], "Read")
self.assertEqual(messages[0]["tool_calls"][0]["input"]["file_path"], "/test")
finally:
path.unlink()
def test_assistant_message_with_thinking(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write(json.dumps({
"type": "assistant",
"message": {
"content": [
{"type": "thinking", "thinking": "Let me consider..."},
{"type": "text", "text": "Here's my answer"},
]
}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_get_claude_conversation_file", return_value=path):
messages = self.handler._parse_claude_conversation("session123", "/project")
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0]["thinking"], "Let me consider...")
self.assertEqual(messages[0]["content"], "Here's my answer")
finally:
path.unlink()
def test_assistant_message_content_as_string_parts(self):
# Some entries might have string content parts instead of dicts
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write(json.dumps({
"type": "assistant",
"message": {
"content": ["plain string", {"type": "text", "text": "structured"}]
}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_get_claude_conversation_file", return_value=path):
messages = self.handler._parse_claude_conversation("session123", "/project")
self.assertEqual(messages[0]["content"], "plain string\nstructured")
finally:
path.unlink()
def test_missing_conversation_file_returns_empty(self):
with patch.object(self.handler, "_get_claude_conversation_file", return_value=None):
messages = self.handler._parse_claude_conversation("session123", "/project")
self.assertEqual(messages, [])
def test_non_dict_entry_skipped(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write('"just a string"\n')
f.write('123\n')
f.write('{"type": "user", "message": {"content": "valid"}}\n')
path = Path(f.name)
try:
with patch.object(self.handler, "_get_claude_conversation_file", return_value=path):
messages = self.handler._parse_claude_conversation("session123", "/project")
self.assertEqual(len(messages), 1)
finally:
path.unlink()
def test_non_list_content_in_assistant_skipped(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write(json.dumps({
"type": "assistant",
"message": {"content": "not a list"}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_get_claude_conversation_file", return_value=path):
messages = self.handler._parse_claude_conversation("session123", "/project")
self.assertEqual(messages, [])
finally:
path.unlink()
class TestIsSystemInjected(unittest.TestCase):
"""Tests for _is_system_injected filter."""
def test_system_reminder(self):
self.assertTrue(_is_system_injected("<system-reminder>\nSome reminder text\n</system-reminder>"))
def test_local_command_caveat(self):
self.assertTrue(_is_system_injected("<local-command-caveat>Caveat: The messages below...</local-command-caveat>"))
def test_available_deferred_tools(self):
self.assertTrue(_is_system_injected("<available-deferred-tools>\nAgent\nBash\n</available-deferred-tools>"))
def test_teammate_message(self):
self.assertTrue(_is_system_injected('<teammate-message teammate_id="reviewer" color="yellow">Review complete</teammate-message>'))
def test_leading_whitespace_stripped(self):
self.assertTrue(_is_system_injected(" \n <system-reminder>content</system-reminder>"))
def test_normal_user_message(self):
self.assertFalse(_is_system_injected("Hello, Claude!"))
def test_message_containing_tag_not_at_start(self):
self.assertFalse(_is_system_injected("Please check this <system-reminder> thing"))
def test_empty_string(self):
self.assertFalse(_is_system_injected(""))
def test_slash_command(self):
self.assertFalse(_is_system_injected("/commit"))
def test_multiline_user_message(self):
self.assertFalse(_is_system_injected("Fix this bug\n\nHere's the error:\nTypeError: foo is not a function"))
class TestClaudeSystemInjectedFiltering(unittest.TestCase):
"""Integration tests: system-injected messages filtered from Claude conversation."""
def setUp(self):
self.handler = DummyConversationHandler()
def _parse_with_messages(self, *user_contents):
"""Helper: write JSONL with user messages, parse, return results."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
for content in user_contents:
f.write(json.dumps({
"type": "user",
"timestamp": "2024-01-01T00:00:00Z",
"message": {"content": content}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_get_claude_conversation_file", return_value=path):
return self.handler._parse_claude_conversation("session123", "/project")
finally:
path.unlink()
def test_system_reminder_excluded(self):
messages = self._parse_with_messages(
"real question",
"<system-reminder>\nHook success\n</system-reminder>",
)
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0]["content"], "real question")
def test_local_command_caveat_excluded(self):
messages = self._parse_with_messages(
"<local-command-caveat>Caveat: generated by local commands</local-command-caveat>",
"what does this function do?",
)
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0]["content"], "what does this function do?")
def test_teammate_message_excluded(self):
messages = self._parse_with_messages(
'<teammate-message teammate_id="impl" color="green">Task done</teammate-message>',
"looks good, commit it",
)
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0]["content"], "looks good, commit it")
def test_all_system_messages_excluded_preserves_ids(self):
"""Message IDs should be sequential with no gaps from filtering."""
messages = self._parse_with_messages(
"first real message",
"<system-reminder>noise</system-reminder>",
"<available-deferred-tools>\nAgent\n</available-deferred-tools>",
"second real message",
)
self.assertEqual(len(messages), 2)
self.assertEqual(messages[0]["content"], "first real message")
self.assertEqual(messages[1]["content"], "second real message")
# IDs should be sequential (0, 1) not (0, 3)
self.assertTrue(messages[0]["id"].endswith("-0"))
self.assertTrue(messages[1]["id"].endswith("-1"))
def test_only_system_messages_returns_empty(self):
messages = self._parse_with_messages(
"<system-reminder>reminder</system-reminder>",
"<local-command-caveat>caveat</local-command-caveat>",
)
self.assertEqual(messages, [])
class TestParseCodexConversation(unittest.TestCase):
"""Tests for _parse_codex_conversation edge cases."""
def setUp(self):
self.handler = DummyConversationHandler()
def test_developer_role_skipped(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write(json.dumps({
"type": "response_item",
"payload": {
"type": "message",
"role": "developer",
"content": [{"text": "System instructions"}]
}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_find_codex_transcript_file", return_value=path):
messages = self.handler._parse_codex_conversation("session123")
self.assertEqual(messages, [])
finally:
path.unlink()
def test_injected_context_skipped(self):
skip_prefixes = [
"<INSTRUCTIONS>",
"<environment_context>",
"<permissions instructions>",
"# AGENTS.md instructions",
]
for prefix in skip_prefixes:
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write(json.dumps({
"type": "response_item",
"payload": {
"type": "message",
"role": "user",
"content": [{"text": f"{prefix} more content here"}]
}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_find_codex_transcript_file", return_value=path):
messages = self.handler._parse_codex_conversation("session123")
self.assertEqual(messages, [], f"Should skip content starting with {prefix}")
finally:
path.unlink()
def test_function_call_accumulated_to_next_assistant(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
# Tool call
f.write(json.dumps({
"type": "response_item",
"payload": {
"type": "function_call",
"name": "shell",
"arguments": '{"command": "ls"}'
}
}) + "\n")
# Assistant message
f.write(json.dumps({
"type": "response_item",
"payload": {
"type": "message",
"role": "assistant",
"content": [{"text": "Here are the files"}]
}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_find_codex_transcript_file", return_value=path):
messages = self.handler._parse_codex_conversation("session123")
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0]["tool_calls"][0]["name"], "shell")
self.assertEqual(messages[0]["content"], "Here are the files")
finally:
path.unlink()
def test_function_calls_flushed_before_user_message(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
# Tool call
f.write(json.dumps({
"type": "response_item",
"payload": {"type": "function_call", "name": "tool1", "arguments": "{}"}
}) + "\n")
# User message (tool calls should be flushed first)
f.write(json.dumps({
"type": "response_item",
"payload": {
"type": "message",
"role": "user",
"content": [{"text": "User response"}]
}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_find_codex_transcript_file", return_value=path):
messages = self.handler._parse_codex_conversation("session123")
# First message should be assistant with tool_calls (flushed)
# Second should be user
self.assertEqual(len(messages), 2)
self.assertEqual(messages[0]["role"], "assistant")
self.assertEqual(messages[0]["tool_calls"][0]["name"], "tool1")
self.assertEqual(messages[1]["role"], "user")
finally:
path.unlink()
def test_reasoning_creates_thinking_message(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write(json.dumps({
"type": "response_item",
"payload": {
"type": "reasoning",
"summary": [
{"type": "summary_text", "text": "Let me think..."},
{"type": "summary_text", "text": "I'll try this approach."},
]
}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_find_codex_transcript_file", return_value=path):
messages = self.handler._parse_codex_conversation("session123")
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0]["thinking"], "Let me think...\nI'll try this approach.")
finally:
path.unlink()
def test_pending_tool_calls_flushed_at_end(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
# Tool call with no following message
f.write(json.dumps({
"type": "response_item",
"payload": {"type": "function_call", "name": "final_tool", "arguments": "{}"}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_find_codex_transcript_file", return_value=path):
messages = self.handler._parse_codex_conversation("session123")
# Should flush pending tool calls at end
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0]["tool_calls"][0]["name"], "final_tool")
finally:
path.unlink()
def test_non_response_item_types_skipped(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write('{"type": "session_meta"}\n')
f.write('{"type": "event_msg"}\n')
f.write(json.dumps({
"type": "response_item",
"payload": {"type": "message", "role": "user", "content": [{"text": "Hello"}]}
}) + "\n")
path = Path(f.name)
try:
with patch.object(self.handler, "_find_codex_transcript_file", return_value=path):
messages = self.handler._parse_codex_conversation("session123")
self.assertEqual(len(messages), 1)
finally:
path.unlink()
def test_missing_transcript_file_returns_empty(self):
with patch.object(self.handler, "_find_codex_transcript_file", return_value=None):
messages = self.handler._parse_codex_conversation("session123")
self.assertEqual(messages, [])
class TestServeConversation(unittest.TestCase):
"""Tests for _serve_conversation routing."""
def setUp(self):
self.handler = DummyConversationHandler()
def test_routes_to_codex_parser(self):
with patch.object(self.handler, "_parse_codex_conversation", return_value=[]) as mock:
self.handler._serve_conversation("session123", "/project", agent="codex")
mock.assert_called_once_with("session123")
def test_routes_to_claude_parser_by_default(self):
with patch.object(self.handler, "_parse_claude_conversation", return_value=[]) as mock:
self.handler._serve_conversation("session123", "/project")
mock.assert_called_once_with("session123", "/project")
def test_sanitizes_session_id(self):
with patch.object(self.handler, "_parse_claude_conversation", return_value=[]):
self.handler._serve_conversation("../../../etc/passwd", "/project")
code, payload = self.handler.sent_responses[0]
self.assertEqual(payload["session_id"], "passwd")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,357 @@
"""Tests for conversation_mtime_ns feature in state.py.
This feature enables real-time dashboard updates by tracking the conversation
file's modification time, which changes on every write (tool call, message, etc.),
rather than relying solely on hook events which only fire at specific moments.
"""
import json
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from amc_server.mixins.state import StateMixin
from amc_server.mixins.parsing import SessionParsingMixin
from amc_server.mixins.discovery import SessionDiscoveryMixin
class CombinedMixin(StateMixin, SessionParsingMixin, SessionDiscoveryMixin):
"""Combined mixin for testing - mirrors AMCHandler's inheritance."""
pass
class TestGetConversationMtime(unittest.TestCase):
"""Tests for _get_conversation_mtime method."""
def setUp(self):
self.handler = CombinedMixin()
self.temp_dir = tempfile.mkdtemp()
def tearDown(self):
import shutil
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_claude_session_with_existing_file(self):
"""When conversation file exists, returns its mtime_ns."""
# Create a temp conversation file
conv_file = Path(self.temp_dir) / "test-session.jsonl"
conv_file.write_text('{"type": "user"}\n')
expected_mtime = conv_file.stat().st_mtime_ns
session_data = {
"agent": "claude",
"session_id": "test-session",
"project_dir": "/some/project",
}
with patch.object(
self.handler, "_get_claude_conversation_file", return_value=conv_file
):
result = self.handler._get_conversation_mtime(session_data)
self.assertEqual(result, expected_mtime)
def test_claude_session_file_not_found(self):
"""When _get_claude_conversation_file returns None, returns None."""
session_data = {
"agent": "claude",
"session_id": "nonexistent",
"project_dir": "/some/project",
}
with patch.object(
self.handler, "_get_claude_conversation_file", return_value=None
):
result = self.handler._get_conversation_mtime(session_data)
self.assertIsNone(result)
def test_claude_session_oserror_on_stat(self):
"""When stat() raises OSError, returns None gracefully."""
mock_file = MagicMock()
mock_file.stat.side_effect = OSError("Permission denied")
session_data = {
"agent": "claude",
"session_id": "test-session",
"project_dir": "/some/project",
}
with patch.object(
self.handler, "_get_claude_conversation_file", return_value=mock_file
):
result = self.handler._get_conversation_mtime(session_data)
self.assertIsNone(result)
def test_claude_session_missing_project_dir(self):
"""When project_dir is empty, _get_claude_conversation_file returns None."""
session_data = {
"agent": "claude",
"session_id": "test-session",
"project_dir": "",
}
# Real method will return None for empty project_dir
result = self.handler._get_conversation_mtime(session_data)
self.assertIsNone(result)
def test_claude_session_missing_session_id(self):
"""When session_id is empty, returns None."""
session_data = {
"agent": "claude",
"session_id": "",
"project_dir": "/some/project",
}
# _get_claude_conversation_file needs both session_id and project_dir
with patch.object(
self.handler, "_get_claude_conversation_file", return_value=None
):
result = self.handler._get_conversation_mtime(session_data)
self.assertIsNone(result)
def test_codex_session_with_transcript_path(self):
"""When transcript_path is provided and exists, returns its mtime_ns."""
transcript_file = Path(self.temp_dir) / "codex-transcript.jsonl"
transcript_file.write_text('{"type": "response_item"}\n')
expected_mtime = transcript_file.stat().st_mtime_ns
session_data = {
"agent": "codex",
"session_id": "codex-123",
"transcript_path": str(transcript_file),
}
result = self.handler._get_conversation_mtime(session_data)
self.assertEqual(result, expected_mtime)
def test_codex_session_transcript_path_missing_file(self):
"""When transcript_path points to nonexistent file, falls back to discovery."""
session_data = {
"agent": "codex",
"session_id": "codex-123",
"transcript_path": "/nonexistent/path.jsonl",
}
# Mock the discovery fallback
with patch.object(
self.handler, "_find_codex_transcript_file", return_value=None
):
result = self.handler._get_conversation_mtime(session_data)
self.assertIsNone(result)
def test_codex_session_discovery_fallback(self):
"""When transcript_path not provided, uses _find_codex_transcript_file."""
transcript_file = Path(self.temp_dir) / "discovered-transcript.jsonl"
transcript_file.write_text('{"type": "response_item"}\n')
expected_mtime = transcript_file.stat().st_mtime_ns
session_data = {
"agent": "codex",
"session_id": "codex-456",
# No transcript_path
}
with patch.object(
self.handler, "_find_codex_transcript_file", return_value=transcript_file
):
result = self.handler._get_conversation_mtime(session_data)
self.assertEqual(result, expected_mtime)
def test_codex_session_discovery_returns_none(self):
"""When discovery finds nothing, returns None."""
session_data = {
"agent": "codex",
"session_id": "codex-789",
}
with patch.object(
self.handler, "_find_codex_transcript_file", return_value=None
):
result = self.handler._get_conversation_mtime(session_data)
self.assertIsNone(result)
def test_codex_session_oserror_on_transcript_stat(self):
"""When stat() on discovered transcript raises OSError, returns None."""
mock_file = MagicMock()
mock_file.stat.side_effect = OSError("I/O error")
session_data = {
"agent": "codex",
"session_id": "codex-err",
}
with patch.object(
self.handler, "_find_codex_transcript_file", return_value=mock_file
):
result = self.handler._get_conversation_mtime(session_data)
self.assertIsNone(result)
def test_unknown_agent_returns_none(self):
"""When agent is neither 'claude' nor 'codex', returns None."""
session_data = {
"agent": "unknown_agent",
"session_id": "test-123",
"project_dir": "/some/project",
}
result = self.handler._get_conversation_mtime(session_data)
self.assertIsNone(result)
def test_missing_agent_returns_none(self):
"""When agent key is missing, returns None."""
session_data = {
"session_id": "test-123",
"project_dir": "/some/project",
}
result = self.handler._get_conversation_mtime(session_data)
self.assertIsNone(result)
def test_mtime_changes_on_file_modification(self):
"""Verify mtime actually changes when file is modified."""
conv_file = Path(self.temp_dir) / "changing-file.jsonl"
conv_file.write_text('{"type": "user"}\n')
mtime_1 = conv_file.stat().st_mtime_ns
# Small delay to ensure filesystem mtime granularity is captured
time.sleep(0.01)
conv_file.write_text('{"type": "user"}\n{"type": "assistant"}\n')
mtime_2 = conv_file.stat().st_mtime_ns
session_data = {
"agent": "claude",
"session_id": "test-session",
"project_dir": "/some/project",
}
with patch.object(
self.handler, "_get_claude_conversation_file", return_value=conv_file
):
result = self.handler._get_conversation_mtime(session_data)
self.assertEqual(result, mtime_2)
self.assertNotEqual(mtime_1, mtime_2)
class TestCollectSessionsIntegration(unittest.TestCase):
"""Integration tests verifying conversation_mtime_ns is included in session data."""
def setUp(self):
self.temp_dir = tempfile.mkdtemp()
self.sessions_dir = Path(self.temp_dir) / "sessions"
self.sessions_dir.mkdir()
def tearDown(self):
import shutil
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_collect_sessions_includes_mtime_when_available(self):
"""_collect_sessions adds conversation_mtime_ns when file exists."""
handler = CombinedMixin()
# Create a session file
session_file = self.sessions_dir / "test-session.json"
session_data = {
"session_id": "test-session",
"agent": "claude",
"project_dir": "/test/project",
"status": "active",
"last_event_at": "2024-01-01T00:00:00Z",
}
session_file.write_text(json.dumps(session_data))
# Create a conversation file
conv_file = Path(self.temp_dir) / "conversation.jsonl"
conv_file.write_text('{"type": "user"}\n')
expected_mtime = conv_file.stat().st_mtime_ns
with patch("amc_server.mixins.state.SESSIONS_DIR", self.sessions_dir), \
patch("amc_server.mixins.state.EVENTS_DIR", Path(self.temp_dir) / "events"), \
patch.object(handler, "_discover_active_codex_sessions"), \
patch.object(handler, "_get_active_zellij_sessions", return_value=None), \
patch.object(handler, "_get_context_usage_for_session", return_value=None), \
patch.object(handler, "_get_claude_conversation_file", return_value=conv_file):
sessions = handler._collect_sessions()
self.assertEqual(len(sessions), 1)
self.assertEqual(sessions[0]["session_id"], "test-session")
self.assertEqual(sessions[0]["conversation_mtime_ns"], expected_mtime)
def test_collect_sessions_omits_mtime_when_file_missing(self):
"""_collect_sessions does not add conversation_mtime_ns when file doesn't exist."""
handler = CombinedMixin()
session_file = self.sessions_dir / "no-conv-session.json"
session_data = {
"session_id": "no-conv-session",
"agent": "claude",
"project_dir": "/test/project",
"status": "active",
"last_event_at": "2024-01-01T00:00:00Z",
}
session_file.write_text(json.dumps(session_data))
with patch("amc_server.mixins.state.SESSIONS_DIR", self.sessions_dir), \
patch("amc_server.mixins.state.EVENTS_DIR", Path(self.temp_dir) / "events"), \
patch.object(handler, "_discover_active_codex_sessions"), \
patch.object(handler, "_get_active_zellij_sessions", return_value=None), \
patch.object(handler, "_get_context_usage_for_session", return_value=None), \
patch.object(handler, "_get_claude_conversation_file", return_value=None):
sessions = handler._collect_sessions()
self.assertEqual(len(sessions), 1)
self.assertNotIn("conversation_mtime_ns", sessions[0])
class TestDashboardChangeDetection(unittest.TestCase):
"""Tests verifying the dashboard uses mtime for change detection."""
def test_mtime_triggers_state_hash_change(self):
"""When conversation_mtime_ns changes, payload hash should change."""
# This is implicitly tested by the SSE mechanism:
# state.py builds payload with conversation_mtime_ns
# _serve_stream hashes payload and sends on change
# Simulate two state payloads with different mtimes
payload_1 = {
"sessions": [{"session_id": "s1", "conversation_mtime_ns": 1000}],
"server_time": "2024-01-01T00:00:00Z",
}
payload_2 = {
"sessions": [{"session_id": "s1", "conversation_mtime_ns": 2000}],
"server_time": "2024-01-01T00:00:00Z",
}
import hashlib
hash_1 = hashlib.sha1(json.dumps(payload_1).encode()).hexdigest()
hash_2 = hashlib.sha1(json.dumps(payload_2).encode()).hexdigest()
self.assertNotEqual(hash_1, hash_2)
def test_same_mtime_same_hash(self):
"""When mtime hasn't changed, hash should be stable."""
payload = {
"sessions": [{"session_id": "s1", "conversation_mtime_ns": 1000}],
"server_time": "2024-01-01T00:00:00Z",
}
import hashlib
hash_1 = hashlib.sha1(json.dumps(payload).encode()).hexdigest()
hash_2 = hashlib.sha1(json.dumps(payload).encode()).hexdigest()
self.assertEqual(hash_1, hash_2)
if __name__ == "__main__":
unittest.main()

361
tests/test_discovery.py Normal file
View File

@@ -0,0 +1,361 @@
"""Tests for mixins/discovery.py edge cases.
Unit tests for Codex session discovery and pane matching.
"""
import json
import os
import subprocess
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import patch, MagicMock
from amc_server.mixins.discovery import SessionDiscoveryMixin
from amc_server.mixins.parsing import SessionParsingMixin
class DummyDiscoveryHandler(SessionDiscoveryMixin, SessionParsingMixin):
"""Minimal handler for testing discovery mixin."""
pass
class TestGetCodexPaneInfo(unittest.TestCase):
"""Tests for _get_codex_pane_info edge cases."""
def setUp(self):
self.handler = DummyDiscoveryHandler()
# Clear cache before each test
from amc_server.agents import _codex_pane_cache
_codex_pane_cache["expires"] = 0
_codex_pane_cache["pid_info"] = {}
_codex_pane_cache["cwd_map"] = {}
def test_pgrep_failure_returns_empty(self):
failed = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="")
with patch("amc_server.mixins.discovery.subprocess.run", return_value=failed):
pid_info, cwd_map = self.handler._get_codex_pane_info()
self.assertEqual(pid_info, {})
self.assertEqual(cwd_map, {})
def test_no_codex_processes_returns_empty(self):
no_results = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
with patch("amc_server.mixins.discovery.subprocess.run", return_value=no_results):
pid_info, cwd_map = self.handler._get_codex_pane_info()
self.assertEqual(pid_info, {})
self.assertEqual(cwd_map, {})
def test_extracts_zellij_env_vars(self):
pgrep_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="12345\n", stderr="")
ps_result = subprocess.CompletedProcess(
args=[], returncode=0,
stdout="codex ZELLIJ_PANE_ID=7 ZELLIJ_SESSION_NAME=myproject",
stderr=""
)
lsof_result = subprocess.CompletedProcess(
args=[], returncode=0,
stdout="p12345\nn/Users/test/project",
stderr=""
)
def mock_run(args, **kwargs):
if args[0] == "pgrep":
return pgrep_result
elif args[0] == "ps":
return ps_result
elif args[0] == "lsof":
return lsof_result
return subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="")
with patch("amc_server.mixins.discovery.subprocess.run", side_effect=mock_run):
pid_info, cwd_map = self.handler._get_codex_pane_info()
self.assertIn("12345", pid_info)
self.assertEqual(pid_info["12345"]["pane_id"], "7")
self.assertEqual(pid_info["12345"]["zellij_session"], "myproject")
def test_cache_used_when_fresh(self):
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
# Should not call subprocess
with patch("amc_server.mixins.discovery.subprocess.run") as mock_run:
pid_info, cwd_map = self.handler._get_codex_pane_info()
mock_run.assert_not_called()
self.assertEqual(pid_info, {"cached": {"pane_id": "1", "zellij_session": "s"}})
def test_timeout_handled_gracefully(self):
with patch("amc_server.mixins.discovery.subprocess.run",
side_effect=subprocess.TimeoutExpired("cmd", 2)):
pid_info, cwd_map = self.handler._get_codex_pane_info()
self.assertEqual(pid_info, {})
self.assertEqual(cwd_map, {})
class TestMatchCodexSessionToPane(unittest.TestCase):
"""Tests for _match_codex_session_to_pane edge cases."""
def setUp(self):
self.handler = DummyDiscoveryHandler()
def test_lsof_match_found(self):
"""When lsof finds a PID with the session file open, use that match."""
pid_info = {
"12345": {"pane_id": "7", "zellij_session": "project"},
}
cwd_map = {}
lsof_result = subprocess.CompletedProcess(
args=[], returncode=0, stdout="12345\n", stderr=""
)
with patch("amc_server.mixins.discovery.subprocess.run", return_value=lsof_result):
session, pane = self.handler._match_codex_session_to_pane(
Path("/some/session.jsonl"), "/project", pid_info, cwd_map
)
self.assertEqual(session, "project")
self.assertEqual(pane, "7")
def test_cwd_fallback_when_lsof_fails(self):
"""When lsof doesn't find a match, fall back to CWD matching."""
pid_info = {}
cwd_map = {
"/home/user/project": {"session": "myproject", "pane_id": "3"},
}
lsof_result = subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr=""
)
with patch("amc_server.mixins.discovery.subprocess.run", return_value=lsof_result):
session, pane = self.handler._match_codex_session_to_pane(
Path("/some/session.jsonl"), "/home/user/project", pid_info, cwd_map
)
self.assertEqual(session, "myproject")
self.assertEqual(pane, "3")
def test_no_match_returns_empty_strings(self):
pid_info = {}
cwd_map = {}
lsof_result = subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr=""
)
with patch("amc_server.mixins.discovery.subprocess.run", return_value=lsof_result):
session, pane = self.handler._match_codex_session_to_pane(
Path("/some/session.jsonl"), "/unmatched/path", pid_info, cwd_map
)
self.assertEqual(session, "")
self.assertEqual(pane, "")
def test_cwd_normalized_for_matching(self):
"""CWD paths should be normalized for comparison."""
pid_info = {}
cwd_map = {
"/home/user/project": {"session": "proj", "pane_id": "1"},
}
lsof_result = subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr=""
)
with patch("amc_server.mixins.discovery.subprocess.run", return_value=lsof_result):
# Session CWD has trailing slash and extra dots
session, pane = self.handler._match_codex_session_to_pane(
Path("/some/session.jsonl"), "/home/user/./project/", pid_info, cwd_map
)
self.assertEqual(session, "proj")
def test_empty_session_cwd_no_match(self):
pid_info = {}
cwd_map = {"/some/path": {"session": "s", "pane_id": "1"}}
lsof_result = subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr=""
)
with patch("amc_server.mixins.discovery.subprocess.run", return_value=lsof_result):
session, pane = self.handler._match_codex_session_to_pane(
Path("/some/session.jsonl"), "", pid_info, cwd_map
)
self.assertEqual(session, "")
self.assertEqual(pane, "")
class TestDiscoverActiveCodexSessions(unittest.TestCase):
"""Tests for _discover_active_codex_sessions edge cases."""
def setUp(self):
self.handler = DummyDiscoveryHandler()
# Clear caches
from amc_server.agents import _codex_transcript_cache, _dismissed_codex_ids
_codex_transcript_cache.clear()
_dismissed_codex_ids.clear()
def test_skips_when_codex_sessions_dir_missing(self):
with patch("amc_server.mixins.discovery.CODEX_SESSIONS_DIR", Path("/nonexistent")):
# Should not raise
self.handler._discover_active_codex_sessions()
def test_skips_old_files(self):
"""Files older than CODEX_ACTIVE_WINDOW should be skipped."""
with tempfile.TemporaryDirectory() as tmpdir:
codex_dir = Path(tmpdir)
sessions_dir = Path(tmpdir) / "sessions"
sessions_dir.mkdir()
# Create an old transcript file
old_file = codex_dir / "old-12345678-1234-1234-1234-123456789abc.jsonl"
old_file.write_text('{"type": "session_meta", "payload": {"cwd": "/test"}}\n')
# Set mtime to 2 hours ago
old_time = time.time() - 7200
os.utime(old_file, (old_time, old_time))
with patch("amc_server.mixins.discovery.CODEX_SESSIONS_DIR", codex_dir), \
patch("amc_server.mixins.discovery.SESSIONS_DIR", sessions_dir):
self.handler._get_codex_pane_info = MagicMock(return_value=({}, {}))
self.handler._discover_active_codex_sessions()
# Should not have created a session file
self.assertEqual(list(sessions_dir.glob("*.json")), [])
def test_skips_dismissed_sessions(self):
"""Sessions in _dismissed_codex_ids should be skipped."""
from amc_server.agents import _dismissed_codex_ids
with tempfile.TemporaryDirectory() as tmpdir:
codex_dir = Path(tmpdir)
sessions_dir = Path(tmpdir) / "sessions"
sessions_dir.mkdir()
# Create a recent transcript file
session_id = "12345678-1234-1234-1234-123456789abc"
transcript = codex_dir / f"session-{session_id}.jsonl"
transcript.write_text('{"type": "session_meta", "payload": {"cwd": "/test"}}\n')
# Mark as dismissed
_dismissed_codex_ids[session_id] = True
with patch("amc_server.mixins.discovery.CODEX_SESSIONS_DIR", codex_dir), \
patch("amc_server.mixins.discovery.SESSIONS_DIR", sessions_dir):
self.handler._get_codex_pane_info = MagicMock(return_value=({}, {}))
self.handler._discover_active_codex_sessions()
# Should not have created a session file
self.assertEqual(list(sessions_dir.glob("*.json")), [])
def test_skips_non_uuid_filenames(self):
"""Files without a UUID in the name should be skipped."""
with tempfile.TemporaryDirectory() as tmpdir:
codex_dir = Path(tmpdir)
sessions_dir = Path(tmpdir) / "sessions"
sessions_dir.mkdir()
# Create a file without a UUID
no_uuid = codex_dir / "random-name.jsonl"
no_uuid.write_text('{"type": "session_meta", "payload": {"cwd": "/test"}}\n')
with patch("amc_server.mixins.discovery.CODEX_SESSIONS_DIR", codex_dir), \
patch("amc_server.mixins.discovery.SESSIONS_DIR", sessions_dir):
self.handler._get_codex_pane_info = MagicMock(return_value=({}, {}))
self.handler._discover_active_codex_sessions()
self.assertEqual(list(sessions_dir.glob("*.json")), [])
def test_skips_non_session_meta_first_line(self):
"""Files without session_meta as first line should be skipped."""
with tempfile.TemporaryDirectory() as tmpdir:
codex_dir = Path(tmpdir)
sessions_dir = Path(tmpdir) / "sessions"
sessions_dir.mkdir()
session_id = "12345678-1234-1234-1234-123456789abc"
transcript = codex_dir / f"session-{session_id}.jsonl"
# First line is not session_meta
transcript.write_text('{"type": "response_item", "payload": {}}\n')
with patch("amc_server.mixins.discovery.CODEX_SESSIONS_DIR", codex_dir), \
patch("amc_server.mixins.discovery.SESSIONS_DIR", sessions_dir):
self.handler._get_codex_pane_info = MagicMock(return_value=({}, {}))
self.handler._discover_active_codex_sessions()
self.assertEqual(list(sessions_dir.glob("*.json")), [])
def test_creates_session_file_for_valid_transcript(self):
"""Valid recent transcripts should create session files."""
with tempfile.TemporaryDirectory() as tmpdir:
codex_dir = Path(tmpdir)
sessions_dir = Path(tmpdir) / "sessions"
sessions_dir.mkdir()
session_id = "12345678-1234-1234-1234-123456789abc"
transcript = codex_dir / f"session-{session_id}.jsonl"
transcript.write_text(json.dumps({
"type": "session_meta",
"payload": {"cwd": "/test/project", "timestamp": "2024-01-01T00:00:00Z"}
}) + "\n")
with patch("amc_server.mixins.discovery.CODEX_SESSIONS_DIR", codex_dir), \
patch("amc_server.mixins.discovery.SESSIONS_DIR", sessions_dir):
self.handler._get_codex_pane_info = MagicMock(return_value=({}, {}))
self.handler._match_codex_session_to_pane = MagicMock(return_value=("proj", "5"))
self.handler._get_cached_context_usage = MagicMock(return_value=None)
self.handler._discover_active_codex_sessions()
session_file = sessions_dir / f"{session_id}.json"
self.assertTrue(session_file.exists())
data = json.loads(session_file.read_text())
self.assertEqual(data["session_id"], session_id)
self.assertEqual(data["agent"], "codex")
self.assertEqual(data["project"], "project")
self.assertEqual(data["zellij_session"], "proj")
self.assertEqual(data["zellij_pane"], "5")
def test_determines_status_by_file_age(self):
"""Recent files should be 'active', older ones 'done'."""
with tempfile.TemporaryDirectory() as tmpdir:
codex_dir = Path(tmpdir)
sessions_dir = Path(tmpdir) / "sessions"
sessions_dir.mkdir()
session_id = "12345678-1234-1234-1234-123456789abc"
transcript = codex_dir / f"session-{session_id}.jsonl"
transcript.write_text(json.dumps({
"type": "session_meta",
"payload": {"cwd": "/test"}
}) + "\n")
# Set mtime to 3 minutes ago (> 2 min threshold)
old_time = time.time() - 180
os.utime(transcript, (old_time, old_time))
with patch("amc_server.mixins.discovery.CODEX_SESSIONS_DIR", codex_dir), \
patch("amc_server.mixins.discovery.SESSIONS_DIR", sessions_dir):
self.handler._get_codex_pane_info = MagicMock(return_value=({}, {}))
self.handler._match_codex_session_to_pane = MagicMock(return_value=("", ""))
self.handler._get_cached_context_usage = MagicMock(return_value=None)
self.handler._discover_active_codex_sessions()
session_file = sessions_dir / f"{session_id}.json"
data = json.loads(session_file.read_text())
self.assertEqual(data["status"], "done")
if __name__ == "__main__":
unittest.main()

474
tests/test_hook.py Normal file
View File

@@ -0,0 +1,474 @@
"""Tests for bin/amc-hook functions.
These are unit tests for the pure functions in the hook script.
Edge cases are prioritized over happy paths.
"""
import json
import os
import tempfile
import types
import unittest
from pathlib import Path
from unittest.mock import patch
# Import the hook module (no .py extension, so use compile+exec pattern)
hook_path = Path(__file__).parent.parent / "bin" / "amc-hook"
amc_hook = types.ModuleType("amc_hook")
amc_hook.__file__ = str(hook_path)
# Load module code - this is safe, we're loading our own source file
code = compile(hook_path.read_text(), hook_path, "exec")
exec(code, amc_hook.__dict__) # noqa: S102 - loading local module
class TestDetectProseQuestion(unittest.TestCase):
"""Tests for _detect_prose_question edge cases."""
def test_none_input_returns_none(self):
self.assertIsNone(amc_hook._detect_prose_question(None))
def test_empty_string_returns_none(self):
self.assertIsNone(amc_hook._detect_prose_question(""))
def test_whitespace_only_returns_none(self):
self.assertIsNone(amc_hook._detect_prose_question(" \n\t "))
def test_no_question_mark_returns_none(self):
self.assertIsNone(amc_hook._detect_prose_question("This is a statement."))
def test_question_mark_in_middle_not_at_end_returns_none(self):
# Question mark exists but message doesn't END with one
self.assertIsNone(amc_hook._detect_prose_question("What? I said hello."))
def test_trailing_whitespace_after_question_still_detects(self):
result = amc_hook._detect_prose_question("Is this a question? \n\t")
self.assertEqual(result, "Is this a question?")
def test_question_in_last_paragraph_only(self):
msg = "First paragraph here.\n\nSecond paragraph is the question?"
result = amc_hook._detect_prose_question(msg)
self.assertEqual(result, "Second paragraph is the question?")
def test_multiple_paragraphs_question_not_in_last_returns_none(self):
# Question in first paragraph, statement in last
msg = "Is this a question?\n\nNo, this is the last paragraph."
self.assertIsNone(amc_hook._detect_prose_question(msg))
def test_truncates_long_question_to_max_length(self):
long_question = "x" * 600 + "?"
result = amc_hook._detect_prose_question(long_question)
self.assertLessEqual(len(result), amc_hook.MAX_QUESTION_LEN + 1) # +1 for ?
def test_long_question_tries_sentence_boundary(self):
# Create a message longer than MAX_QUESTION_LEN (500) with a sentence boundary
# The truncation takes the LAST MAX_QUESTION_LEN chars, then finds FIRST ". " within that
prefix = "a" * 500 + ". Sentence start. "
suffix = "Is this the question?"
msg = prefix + suffix
self.assertGreater(len(msg), amc_hook.MAX_QUESTION_LEN)
result = amc_hook._detect_prose_question(msg)
# Code finds FIRST ". " in truncated portion, so starts at "Sentence start"
self.assertTrue(
result.startswith("Sentence start"),
f"Expected to start with 'Sentence start', got: {result[:50]}"
)
def test_long_question_no_sentence_boundary_truncates_from_end(self):
# No period in the long text
long_msg = "a" * 600 + "?"
result = amc_hook._detect_prose_question(long_msg)
self.assertTrue(result.endswith("?"))
self.assertLessEqual(len(result), amc_hook.MAX_QUESTION_LEN + 1)
def test_single_character_question(self):
result = amc_hook._detect_prose_question("?")
self.assertEqual(result, "?")
def test_newlines_within_last_paragraph_preserved(self):
msg = "Intro.\n\nLine one\nLine two?"
result = amc_hook._detect_prose_question(msg)
self.assertIn("\n", result)
class TestExtractQuestions(unittest.TestCase):
"""Tests for _extract_questions edge cases."""
def test_empty_hook_returns_empty_list(self):
self.assertEqual(amc_hook._extract_questions({}), [])
def test_missing_tool_input_returns_empty_list(self):
self.assertEqual(amc_hook._extract_questions({"other": "data"}), [])
def test_tool_input_is_none_returns_empty_list(self):
self.assertEqual(amc_hook._extract_questions({"tool_input": None}), [])
def test_tool_input_is_list_returns_empty_list(self):
# tool_input should be dict, not list
self.assertEqual(amc_hook._extract_questions({"tool_input": []}), [])
def test_tool_input_is_string_json_parsed(self):
tool_input = json.dumps({"questions": [{"question": "Test?", "options": []}]})
result = amc_hook._extract_questions({"tool_input": tool_input})
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["question"], "Test?")
def test_tool_input_invalid_json_string_returns_empty(self):
result = amc_hook._extract_questions({"tool_input": "not valid json"})
self.assertEqual(result, [])
def test_questions_key_is_none_returns_empty(self):
result = amc_hook._extract_questions({"tool_input": {"questions": None}})
self.assertEqual(result, [])
def test_questions_key_missing_returns_empty(self):
result = amc_hook._extract_questions({"tool_input": {"other": "data"}})
self.assertEqual(result, [])
def test_option_without_markdown_excluded_from_output(self):
hook = {
"tool_input": {
"questions": [{
"question": "Pick one",
"options": [{"label": "A", "description": "Desc A"}],
}]
}
}
result = amc_hook._extract_questions(hook)
self.assertNotIn("markdown", result[0]["options"][0])
def test_option_with_markdown_included(self):
hook = {
"tool_input": {
"questions": [{
"question": "Pick one",
"options": [{"label": "A", "description": "Desc", "markdown": "```code```"}],
}]
}
}
result = amc_hook._extract_questions(hook)
self.assertEqual(result[0]["options"][0]["markdown"], "```code```")
def test_missing_question_fields_default_to_empty(self):
hook = {"tool_input": {"questions": [{}]}}
result = amc_hook._extract_questions(hook)
self.assertEqual(result[0]["question"], "")
self.assertEqual(result[0]["header"], "")
self.assertEqual(result[0]["options"], [])
def test_option_missing_fields_default_to_empty(self):
hook = {"tool_input": {"questions": [{"options": [{}]}]}}
result = amc_hook._extract_questions(hook)
self.assertEqual(result[0]["options"][0]["label"], "")
self.assertEqual(result[0]["options"][0]["description"], "")
class TestAtomicWrite(unittest.TestCase):
"""Tests for _atomic_write edge cases."""
def test_writes_to_nonexistent_file(self):
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "new_file.json"
amc_hook._atomic_write(path, {"key": "value"})
self.assertTrue(path.exists())
self.assertEqual(json.loads(path.read_text()), {"key": "value"})
def test_overwrites_existing_file(self):
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "existing.json"
path.write_text('{"old": "data"}')
amc_hook._atomic_write(path, {"new": "data"})
self.assertEqual(json.loads(path.read_text()), {"new": "data"})
def test_cleans_up_temp_file_on_replace_failure(self):
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "subdir" / "file.json"
# Parent doesn't exist, so mkstemp will fail
with self.assertRaises(FileNotFoundError):
amc_hook._atomic_write(path, {"data": "test"})
def test_no_partial_writes_on_failure(self):
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "file.json"
path.write_text('{"original": "data"}')
# Mock os.replace to fail after the temp file is written
_original_replace = os.replace # noqa: F841 - documents test setup
def failing_replace(src, dst):
raise PermissionError("Simulated failure")
with patch("os.replace", side_effect=failing_replace):
with self.assertRaises(PermissionError):
amc_hook._atomic_write(path, {"new": "data"})
# Original file should be unchanged
self.assertEqual(json.loads(path.read_text()), {"original": "data"})
class TestReadSession(unittest.TestCase):
"""Tests for _read_session edge cases."""
def test_nonexistent_file_returns_empty_dict(self):
result = amc_hook._read_session(Path("/nonexistent/path/file.json"))
self.assertEqual(result, {})
def test_empty_file_returns_empty_dict(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
f.write("")
path = Path(f.name)
try:
result = amc_hook._read_session(path)
self.assertEqual(result, {})
finally:
path.unlink()
def test_invalid_json_returns_empty_dict(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
f.write("not valid json {{{")
path = Path(f.name)
try:
result = amc_hook._read_session(path)
self.assertEqual(result, {})
finally:
path.unlink()
def test_valid_json_returned(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump({"session_id": "abc"}, f)
path = Path(f.name)
try:
result = amc_hook._read_session(path)
self.assertEqual(result, {"session_id": "abc"})
finally:
path.unlink()
class TestAppendEvent(unittest.TestCase):
"""Tests for _append_event edge cases."""
def test_creates_file_if_missing(self):
with tempfile.TemporaryDirectory() as tmpdir:
with patch.object(amc_hook, "EVENTS_DIR", Path(tmpdir)):
amc_hook._append_event("session123", {"event": "test"})
event_file = Path(tmpdir) / "session123.jsonl"
self.assertTrue(event_file.exists())
def test_appends_to_existing_file(self):
with tempfile.TemporaryDirectory() as tmpdir:
event_file = Path(tmpdir) / "session123.jsonl"
event_file.write_text('{"event": "first"}\n')
with patch.object(amc_hook, "EVENTS_DIR", Path(tmpdir)):
amc_hook._append_event("session123", {"event": "second"})
lines = event_file.read_text().strip().split("\n")
self.assertEqual(len(lines), 2)
self.assertEqual(json.loads(lines[1])["event"], "second")
def test_oserror_silently_ignored(self):
with patch.object(amc_hook, "EVENTS_DIR", Path("/nonexistent/path")):
# Should not raise
amc_hook._append_event("session123", {"event": "test"})
class TestMainHookPathTraversal(unittest.TestCase):
"""Tests for path traversal protection in main()."""
def test_session_id_with_path_traversal_sanitized(self):
with tempfile.TemporaryDirectory() as tmpdir:
sessions_dir = Path(tmpdir) / "sessions"
events_dir = Path(tmpdir) / "events"
sessions_dir.mkdir()
events_dir.mkdir()
# Create a legitimate session file to test that traversal doesn't reach it
legit_file = Path(tmpdir) / "secret.json"
legit_file.write_text('{"secret": "data"}')
hook_input = json.dumps({
"hook_event_name": "SessionStart",
"session_id": "../secret",
"cwd": "/test/project",
})
with patch.object(amc_hook, "SESSIONS_DIR", sessions_dir), \
patch.object(amc_hook, "EVENTS_DIR", events_dir), \
patch("sys.stdin.read", return_value=hook_input):
amc_hook.main()
# The sanitized session ID should be "secret" (basename of "../secret")
# and should NOT have modified the legit_file in parent dir
self.assertEqual(json.loads(legit_file.read_text()), {"secret": "data"})
class TestMainHookEmptyInput(unittest.TestCase):
"""Tests for main() with various empty/invalid inputs."""
def test_empty_stdin_returns_silently(self):
with patch("sys.stdin.read", return_value=""):
# Should not raise
amc_hook.main()
def test_whitespace_only_stdin_returns_silently(self):
with patch("sys.stdin.read", return_value=" \n\t "):
amc_hook.main()
def test_invalid_json_stdin_fails_silently(self):
with patch("sys.stdin.read", return_value="not json"):
amc_hook.main()
def test_missing_session_id_returns_silently(self):
with patch("sys.stdin.read", return_value='{"hook_event_name": "SessionStart"}'):
amc_hook.main()
def test_missing_event_name_returns_silently(self):
with patch("sys.stdin.read", return_value='{"session_id": "abc123"}'):
amc_hook.main()
def test_empty_session_id_after_sanitization_returns_silently(self):
# Edge case: session_id that becomes empty after basename()
with patch("sys.stdin.read", return_value='{"hook_event_name": "SessionStart", "session_id": "/"}'):
amc_hook.main()
class TestMainSessionEndDeletesFile(unittest.TestCase):
"""Tests for SessionEnd hook behavior."""
def test_session_end_deletes_existing_session_file(self):
with tempfile.TemporaryDirectory() as tmpdir:
sessions_dir = Path(tmpdir) / "sessions"
events_dir = Path(tmpdir) / "events"
sessions_dir.mkdir()
events_dir.mkdir()
session_file = sessions_dir / "abc123.json"
session_file.write_text('{"session_id": "abc123"}')
hook_input = json.dumps({
"hook_event_name": "SessionEnd",
"session_id": "abc123",
})
with patch.object(amc_hook, "SESSIONS_DIR", sessions_dir), \
patch.object(amc_hook, "EVENTS_DIR", events_dir), \
patch("sys.stdin.read", return_value=hook_input):
amc_hook.main()
self.assertFalse(session_file.exists())
def test_session_end_missing_file_no_error(self):
with tempfile.TemporaryDirectory() as tmpdir:
sessions_dir = Path(tmpdir) / "sessions"
events_dir = Path(tmpdir) / "events"
sessions_dir.mkdir()
events_dir.mkdir()
hook_input = json.dumps({
"hook_event_name": "SessionEnd",
"session_id": "nonexistent",
})
with patch.object(amc_hook, "SESSIONS_DIR", sessions_dir), \
patch.object(amc_hook, "EVENTS_DIR", events_dir), \
patch("sys.stdin.read", return_value=hook_input):
# Should not raise
amc_hook.main()
class TestMainPreToolUseWithoutExistingSession(unittest.TestCase):
"""Edge case: PreToolUse arrives but session file doesn't exist."""
def test_pre_tool_use_no_existing_session_returns_silently(self):
with tempfile.TemporaryDirectory() as tmpdir:
sessions_dir = Path(tmpdir) / "sessions"
events_dir = Path(tmpdir) / "events"
sessions_dir.mkdir()
events_dir.mkdir()
hook_input = json.dumps({
"hook_event_name": "PreToolUse",
"tool_name": "AskUserQuestion",
"session_id": "nonexistent",
"tool_input": {"questions": []},
})
with patch.object(amc_hook, "SESSIONS_DIR", sessions_dir), \
patch.object(amc_hook, "EVENTS_DIR", events_dir), \
patch("sys.stdin.read", return_value=hook_input):
amc_hook.main()
# No session file should be created
self.assertFalse((sessions_dir / "nonexistent.json").exists())
class TestMainStopWithProseQuestion(unittest.TestCase):
"""Tests for Stop hook detecting prose questions."""
def test_stop_with_prose_question_sets_needs_attention(self):
with tempfile.TemporaryDirectory() as tmpdir:
sessions_dir = Path(tmpdir) / "sessions"
events_dir = Path(tmpdir) / "events"
sessions_dir.mkdir()
events_dir.mkdir()
# Create existing session
session_file = sessions_dir / "abc123.json"
session_file.write_text(json.dumps({
"session_id": "abc123",
"status": "active",
}))
hook_input = json.dumps({
"hook_event_name": "Stop",
"session_id": "abc123",
"last_assistant_message": "What do you think about this approach?",
"cwd": "/test/project",
})
with patch.object(amc_hook, "SESSIONS_DIR", sessions_dir), \
patch.object(amc_hook, "EVENTS_DIR", events_dir), \
patch("sys.stdin.read", return_value=hook_input):
amc_hook.main()
data = json.loads(session_file.read_text())
self.assertEqual(data["status"], "needs_attention")
self.assertEqual(len(data["pending_questions"]), 1)
self.assertIn("approach?", data["pending_questions"][0]["question"])
class TestMainTurnTimingAccumulation(unittest.TestCase):
"""Tests for turn timing accumulation across pause/resume cycles."""
def test_post_tool_use_accumulates_paused_time(self):
with tempfile.TemporaryDirectory() as tmpdir:
sessions_dir = Path(tmpdir) / "sessions"
events_dir = Path(tmpdir) / "events"
sessions_dir.mkdir()
events_dir.mkdir()
# Create session with existing paused state
session_file = sessions_dir / "abc123.json"
session_file.write_text(json.dumps({
"session_id": "abc123",
"status": "needs_attention",
"turn_paused_at": "2024-01-01T00:00:00+00:00",
"turn_paused_ms": 5000, # Already had 5 seconds paused
}))
hook_input = json.dumps({
"hook_event_name": "PostToolUse",
"tool_name": "AskUserQuestion",
"session_id": "abc123",
})
with patch.object(amc_hook, "SESSIONS_DIR", sessions_dir), \
patch.object(amc_hook, "EVENTS_DIR", events_dir), \
patch("sys.stdin.read", return_value=hook_input):
amc_hook.main()
data = json.loads(session_file.read_text())
# Should have accumulated more paused time
self.assertGreater(data["turn_paused_ms"], 5000)
# turn_paused_at should be removed after resuming
self.assertNotIn("turn_paused_at", data)
if __name__ == "__main__":
unittest.main()

377
tests/test_http.py Normal file
View File

@@ -0,0 +1,377 @@
"""Tests for mixins/http.py edge cases.
Unit tests for HTTP routing and response handling.
"""
import io
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch, MagicMock
from amc_server.mixins.http import HttpMixin
class DummyHttpHandler(HttpMixin):
"""Minimal handler for testing HTTP mixin."""
def __init__(self):
self.response_code = None
self.headers_sent = {}
self.body_sent = b""
self.path = "/"
self.wfile = io.BytesIO()
def send_response(self, code):
self.response_code = code
def send_header(self, key, value):
self.headers_sent[key] = value
def end_headers(self):
pass
class TestSendBytesResponse(unittest.TestCase):
"""Tests for _send_bytes_response edge cases."""
def test_sends_correct_headers(self):
handler = DummyHttpHandler()
handler._send_bytes_response(200, b"test", content_type="text/plain")
self.assertEqual(handler.response_code, 200)
self.assertEqual(handler.headers_sent["Content-Type"], "text/plain")
self.assertEqual(handler.headers_sent["Content-Length"], "4")
def test_includes_extra_headers(self):
handler = DummyHttpHandler()
handler._send_bytes_response(
200, b"test",
extra_headers={"X-Custom": "value", "Cache-Control": "no-cache"}
)
self.assertEqual(handler.headers_sent["X-Custom"], "value")
self.assertEqual(handler.headers_sent["Cache-Control"], "no-cache")
def test_broken_pipe_returns_false(self):
handler = DummyHttpHandler()
handler.wfile.write = MagicMock(side_effect=BrokenPipeError())
result = handler._send_bytes_response(200, b"test")
self.assertFalse(result)
def test_connection_reset_returns_false(self):
handler = DummyHttpHandler()
handler.wfile.write = MagicMock(side_effect=ConnectionResetError())
result = handler._send_bytes_response(200, b"test")
self.assertFalse(result)
def test_os_error_returns_false(self):
handler = DummyHttpHandler()
handler.wfile.write = MagicMock(side_effect=OSError("write error"))
result = handler._send_bytes_response(200, b"test")
self.assertFalse(result)
class TestSendJson(unittest.TestCase):
"""Tests for _send_json edge cases."""
def test_includes_cors_header(self):
handler = DummyHttpHandler()
handler._send_json(200, {"key": "value"})
self.assertEqual(handler.headers_sent["Access-Control-Allow-Origin"], "*")
def test_sets_json_content_type(self):
handler = DummyHttpHandler()
handler._send_json(200, {"key": "value"})
self.assertEqual(handler.headers_sent["Content-Type"], "application/json")
def test_encodes_payload_as_json(self):
handler = DummyHttpHandler()
handler._send_json(200, {"key": "value"})
written = handler.wfile.getvalue()
self.assertEqual(json.loads(written), {"key": "value"})
class TestServeDashboardFile(unittest.TestCase):
"""Tests for _serve_dashboard_file edge cases."""
def test_nonexistent_file_returns_404(self):
handler = DummyHttpHandler()
handler.errors = []
def capture_error(code, message):
handler.errors.append((code, message))
handler._json_error = capture_error
with tempfile.TemporaryDirectory() as tmpdir:
with patch("amc_server.mixins.http.DASHBOARD_DIR", Path(tmpdir)):
handler._serve_dashboard_file("nonexistent.html")
self.assertEqual(len(handler.errors), 1)
self.assertEqual(handler.errors[0][0], 404)
def test_path_traversal_blocked(self):
handler = DummyHttpHandler()
handler.errors = []
def capture_error(code, message):
handler.errors.append((code, message))
handler._json_error = capture_error
with tempfile.TemporaryDirectory() as tmpdir:
# Create a file outside the dashboard dir that shouldn't be accessible (unused - documents intent)
_secret = Path(tmpdir).parent / "secret.txt"
with patch("amc_server.mixins.http.DASHBOARD_DIR", Path(tmpdir)):
handler._serve_dashboard_file("../secret.txt")
self.assertEqual(len(handler.errors), 1)
self.assertEqual(handler.errors[0][0], 403)
def test_correct_content_type_for_html(self):
handler = DummyHttpHandler()
with tempfile.TemporaryDirectory() as tmpdir:
html_file = Path(tmpdir) / "test.html"
html_file.write_text("<html></html>")
with patch("amc_server.mixins.http.DASHBOARD_DIR", Path(tmpdir)):
handler._serve_dashboard_file("test.html")
self.assertEqual(handler.headers_sent["Content-Type"], "text/html; charset=utf-8")
def test_correct_content_type_for_css(self):
handler = DummyHttpHandler()
with tempfile.TemporaryDirectory() as tmpdir:
css_file = Path(tmpdir) / "styles.css"
css_file.write_text("body {}")
with patch("amc_server.mixins.http.DASHBOARD_DIR", Path(tmpdir)):
handler._serve_dashboard_file("styles.css")
self.assertEqual(handler.headers_sent["Content-Type"], "text/css; charset=utf-8")
def test_correct_content_type_for_js(self):
handler = DummyHttpHandler()
with tempfile.TemporaryDirectory() as tmpdir:
js_file = Path(tmpdir) / "app.js"
js_file.write_text("console.log('hello')")
with patch("amc_server.mixins.http.DASHBOARD_DIR", Path(tmpdir)):
handler._serve_dashboard_file("app.js")
self.assertEqual(handler.headers_sent["Content-Type"], "application/javascript; charset=utf-8")
def test_unknown_extension_gets_octet_stream(self):
handler = DummyHttpHandler()
with tempfile.TemporaryDirectory() as tmpdir:
unknown_file = Path(tmpdir) / "data.xyz"
unknown_file.write_bytes(b"\x00\x01\x02")
with patch("amc_server.mixins.http.DASHBOARD_DIR", Path(tmpdir)):
handler._serve_dashboard_file("data.xyz")
self.assertEqual(handler.headers_sent["Content-Type"], "application/octet-stream")
def test_no_cache_headers_set(self):
handler = DummyHttpHandler()
with tempfile.TemporaryDirectory() as tmpdir:
html_file = Path(tmpdir) / "test.html"
html_file.write_text("<html></html>")
with patch("amc_server.mixins.http.DASHBOARD_DIR", Path(tmpdir)):
handler._serve_dashboard_file("test.html")
self.assertIn("no-cache", handler.headers_sent.get("Cache-Control", ""))
class TestDoGet(unittest.TestCase):
"""Tests for do_GET routing edge cases."""
def _make_handler(self, path):
handler = DummyHttpHandler()
handler.path = path
handler._serve_dashboard_file = MagicMock()
handler._serve_state = MagicMock()
handler._serve_stream = MagicMock()
handler._serve_events = MagicMock()
handler._serve_conversation = MagicMock()
handler._json_error = MagicMock()
return handler
def test_root_serves_index(self):
handler = self._make_handler("/")
handler.do_GET()
handler._serve_dashboard_file.assert_called_with("index.html")
def test_index_html_serves_index(self):
handler = self._make_handler("/index.html")
handler.do_GET()
handler._serve_dashboard_file.assert_called_with("index.html")
def test_static_file_served(self):
handler = self._make_handler("/components/App.js")
handler.do_GET()
handler._serve_dashboard_file.assert_called_with("components/App.js")
def test_path_traversal_in_static_blocked(self):
handler = self._make_handler("/../../etc/passwd")
handler.do_GET()
handler._json_error.assert_called_with(404, "Not Found")
def test_api_state_routed(self):
handler = self._make_handler("/api/state")
handler.do_GET()
handler._serve_state.assert_called_once()
def test_api_stream_routed(self):
handler = self._make_handler("/api/stream")
handler.do_GET()
handler._serve_stream.assert_called_once()
def test_api_events_routed_with_id(self):
handler = self._make_handler("/api/events/session-123")
handler.do_GET()
handler._serve_events.assert_called_with("session-123")
def test_api_events_url_decoded(self):
handler = self._make_handler("/api/events/session%20with%20spaces")
handler.do_GET()
handler._serve_events.assert_called_with("session with spaces")
def test_api_conversation_with_query_params(self):
handler = self._make_handler("/api/conversation/sess123?project_dir=/test&agent=codex")
handler.do_GET()
handler._serve_conversation.assert_called_with("sess123", "/test", "codex")
def test_api_conversation_defaults_to_claude(self):
handler = self._make_handler("/api/conversation/sess123")
handler.do_GET()
handler._serve_conversation.assert_called_with("sess123", "", "claude")
def test_unknown_api_path_returns_404(self):
handler = self._make_handler("/api/unknown")
handler.do_GET()
handler._json_error.assert_called_with(404, "Not Found")
class TestDoGetSpawnRoutes(unittest.TestCase):
"""Tests for spawn-related GET routes."""
def _make_handler(self, path):
handler = DummyHttpHandler()
handler.path = path
handler._serve_dashboard_file = MagicMock()
handler._serve_state = MagicMock()
handler._serve_stream = MagicMock()
handler._serve_events = MagicMock()
handler._serve_conversation = MagicMock()
handler._serve_skills = MagicMock()
handler._handle_projects = MagicMock()
handler._handle_health = MagicMock()
handler._json_error = MagicMock()
return handler
def test_api_projects_routed(self):
handler = self._make_handler("/api/projects")
handler.do_GET()
handler._handle_projects.assert_called_once()
def test_api_health_routed(self):
handler = self._make_handler("/api/health")
handler.do_GET()
handler._handle_health.assert_called_once()
class TestDoPost(unittest.TestCase):
"""Tests for do_POST routing edge cases."""
def _make_handler(self, path):
handler = DummyHttpHandler()
handler.path = path
handler._dismiss_dead_sessions = MagicMock()
handler._dismiss_session = MagicMock()
handler._respond_to_session = MagicMock()
handler._handle_spawn = MagicMock()
handler._handle_projects_refresh = MagicMock()
handler._json_error = MagicMock()
return handler
def test_dismiss_dead_routed(self):
handler = self._make_handler("/api/dismiss-dead")
handler.do_POST()
handler._dismiss_dead_sessions.assert_called_once()
def test_dismiss_session_routed(self):
handler = self._make_handler("/api/dismiss/session-abc")
handler.do_POST()
handler._dismiss_session.assert_called_with("session-abc")
def test_dismiss_url_decoded(self):
handler = self._make_handler("/api/dismiss/session%2Fwith%2Fslash")
handler.do_POST()
handler._dismiss_session.assert_called_with("session/with/slash")
def test_respond_routed(self):
handler = self._make_handler("/api/respond/session-xyz")
handler.do_POST()
handler._respond_to_session.assert_called_with("session-xyz")
def test_spawn_routed(self):
handler = self._make_handler("/api/spawn")
handler.do_POST()
handler._handle_spawn.assert_called_once()
def test_projects_refresh_routed(self):
handler = self._make_handler("/api/projects/refresh")
handler.do_POST()
handler._handle_projects_refresh.assert_called_once()
def test_unknown_post_path_returns_404(self):
handler = self._make_handler("/api/unknown")
handler.do_POST()
handler._json_error.assert_called_with(404, "Not Found")
class TestDoOptions(unittest.TestCase):
"""Tests for do_OPTIONS CORS preflight."""
def test_returns_204_with_cors_headers(self):
handler = DummyHttpHandler()
handler.do_OPTIONS()
self.assertEqual(handler.response_code, 204)
self.assertEqual(handler.headers_sent["Access-Control-Allow-Origin"], "*")
self.assertIn("POST", handler.headers_sent["Access-Control-Allow-Methods"])
self.assertIn("GET", handler.headers_sent["Access-Control-Allow-Methods"])
self.assertIn("Content-Type", handler.headers_sent["Access-Control-Allow-Headers"])
self.assertIn("Authorization", handler.headers_sent["Access-Control-Allow-Headers"])
class TestJsonError(unittest.TestCase):
"""Tests for _json_error helper."""
def test_sends_json_with_error(self):
handler = DummyHttpHandler()
handler._json_error(404, "Not Found")
written = handler.wfile.getvalue()
payload = json.loads(written)
self.assertEqual(payload, {"ok": False, "error": "Not Found"})
if __name__ == "__main__":
unittest.main()

635
tests/test_parsing.py Normal file
View File

@@ -0,0 +1,635 @@
"""Tests for mixins/parsing.py edge cases.
Unit tests for parsing helper functions and conversation file resolution.
"""
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from amc_server.mixins.parsing import SessionParsingMixin
class DummyParsingHandler(SessionParsingMixin):
"""Minimal handler for testing parsing mixin."""
pass
class TestToInt(unittest.TestCase):
"""Tests for _to_int edge cases."""
def setUp(self):
self.handler = DummyParsingHandler()
def test_none_returns_none(self):
self.assertIsNone(self.handler._to_int(None))
def test_bool_true_returns_none(self):
# Booleans are technically ints in Python, but we don't want to convert them
self.assertIsNone(self.handler._to_int(True))
def test_bool_false_returns_none(self):
self.assertIsNone(self.handler._to_int(False))
def test_int_returns_int(self):
self.assertEqual(self.handler._to_int(42), 42)
def test_negative_int_returns_int(self):
self.assertEqual(self.handler._to_int(-10), -10)
def test_zero_returns_zero(self):
self.assertEqual(self.handler._to_int(0), 0)
def test_float_truncates_to_int(self):
self.assertEqual(self.handler._to_int(3.7), 3)
def test_negative_float_truncates(self):
self.assertEqual(self.handler._to_int(-2.9), -2)
def test_string_int_parses(self):
self.assertEqual(self.handler._to_int("123"), 123)
def test_string_negative_parses(self):
self.assertEqual(self.handler._to_int("-456"), -456)
def test_string_with_whitespace_fails(self):
# Python's int() handles whitespace, but let's verify
self.assertEqual(self.handler._to_int(" 42 "), 42)
def test_string_float_fails(self):
# "3.14" can't be parsed by int()
self.assertIsNone(self.handler._to_int("3.14"))
def test_empty_string_returns_none(self):
self.assertIsNone(self.handler._to_int(""))
def test_non_numeric_string_returns_none(self):
self.assertIsNone(self.handler._to_int("abc"))
def test_list_returns_none(self):
self.assertIsNone(self.handler._to_int([1, 2, 3]))
def test_dict_returns_none(self):
self.assertIsNone(self.handler._to_int({"value": 42}))
class TestSumOptionalInts(unittest.TestCase):
"""Tests for _sum_optional_ints edge cases."""
def setUp(self):
self.handler = DummyParsingHandler()
def test_empty_list_returns_none(self):
self.assertIsNone(self.handler._sum_optional_ints([]))
def test_all_none_returns_none(self):
self.assertIsNone(self.handler._sum_optional_ints([None, None, None]))
def test_single_int_returns_that_int(self):
self.assertEqual(self.handler._sum_optional_ints([42]), 42)
def test_mixed_none_and_int_sums_ints(self):
self.assertEqual(self.handler._sum_optional_ints([None, 10, None, 20]), 30)
def test_all_ints_sums_all(self):
self.assertEqual(self.handler._sum_optional_ints([1, 2, 3, 4]), 10)
def test_includes_zero(self):
self.assertEqual(self.handler._sum_optional_ints([0, 5]), 5)
def test_negative_ints(self):
self.assertEqual(self.handler._sum_optional_ints([10, -3, 5]), 12)
def test_floats_ignored(self):
# Only integers are summed
self.assertEqual(self.handler._sum_optional_ints([10, 3.14, 5]), 15)
def test_strings_ignored(self):
self.assertEqual(self.handler._sum_optional_ints(["10", 5]), 5)
def test_only_non_ints_returns_none(self):
self.assertIsNone(self.handler._sum_optional_ints(["10", 3.14, None]))
class TestAsDict(unittest.TestCase):
"""Tests for _as_dict edge cases."""
def setUp(self):
self.handler = DummyParsingHandler()
def test_dict_returns_dict(self):
self.assertEqual(self.handler._as_dict({"key": "value"}), {"key": "value"})
def test_empty_dict_returns_empty_dict(self):
self.assertEqual(self.handler._as_dict({}), {})
def test_none_returns_empty_dict(self):
self.assertEqual(self.handler._as_dict(None), {})
def test_list_returns_empty_dict(self):
self.assertEqual(self.handler._as_dict([1, 2, 3]), {})
def test_string_returns_empty_dict(self):
self.assertEqual(self.handler._as_dict("not a dict"), {})
def test_int_returns_empty_dict(self):
self.assertEqual(self.handler._as_dict(42), {})
def test_bool_returns_empty_dict(self):
self.assertEqual(self.handler._as_dict(True), {})
class TestGetClaudeContextWindow(unittest.TestCase):
"""Tests for _get_claude_context_window edge cases."""
def setUp(self):
self.handler = DummyParsingHandler()
def test_none_model_returns_200k(self):
self.assertEqual(self.handler._get_claude_context_window(None), 200_000)
def test_empty_string_returns_200k(self):
self.assertEqual(self.handler._get_claude_context_window(""), 200_000)
def test_claude_2_returns_100k(self):
self.assertEqual(self.handler._get_claude_context_window("claude-2"), 100_000)
def test_claude_2_1_returns_100k(self):
self.assertEqual(self.handler._get_claude_context_window("claude-2.1"), 100_000)
def test_claude_3_returns_200k(self):
self.assertEqual(self.handler._get_claude_context_window("claude-3-opus-20240229"), 200_000)
def test_claude_35_returns_200k(self):
self.assertEqual(self.handler._get_claude_context_window("claude-3-5-sonnet-20241022"), 200_000)
def test_unknown_model_returns_200k(self):
self.assertEqual(self.handler._get_claude_context_window("some-future-model"), 200_000)
class TestGetClaudeConversationFile(unittest.TestCase):
"""Tests for _get_claude_conversation_file edge cases."""
def setUp(self):
self.handler = DummyParsingHandler()
def test_empty_project_dir_returns_none(self):
self.assertIsNone(self.handler._get_claude_conversation_file("session123", ""))
def test_none_project_dir_returns_none(self):
self.assertIsNone(self.handler._get_claude_conversation_file("session123", None))
def test_nonexistent_file_returns_none(self):
with tempfile.TemporaryDirectory() as tmpdir:
with patch("amc_server.mixins.parsing.CLAUDE_PROJECTS_DIR", Path(tmpdir)):
result = self.handler._get_claude_conversation_file("session123", "/some/project")
self.assertIsNone(result)
def test_existing_file_returns_path(self):
with tempfile.TemporaryDirectory() as tmpdir:
# Create the expected file structure
# project_dir "/foo/bar" becomes "-foo-bar"
encoded_dir = Path(tmpdir) / "-foo-bar"
encoded_dir.mkdir()
conv_file = encoded_dir / "session123.jsonl"
conv_file.write_text('{"type": "user"}\n')
with patch("amc_server.mixins.parsing.CLAUDE_PROJECTS_DIR", Path(tmpdir)):
result = self.handler._get_claude_conversation_file("session123", "/foo/bar")
self.assertEqual(result, conv_file)
def test_project_dir_without_leading_slash_gets_prefixed(self):
with tempfile.TemporaryDirectory() as tmpdir:
# project_dir "foo/bar" becomes "-foo-bar" (adds leading dash)
encoded_dir = Path(tmpdir) / "-foo-bar"
encoded_dir.mkdir()
conv_file = encoded_dir / "session123.jsonl"
conv_file.write_text('{"type": "user"}\n')
with patch("amc_server.mixins.parsing.CLAUDE_PROJECTS_DIR", Path(tmpdir)):
result = self.handler._get_claude_conversation_file("session123", "foo/bar")
self.assertEqual(result, conv_file)
class TestFindCodexTranscriptFile(unittest.TestCase):
"""Tests for _find_codex_transcript_file edge cases."""
def setUp(self):
self.handler = DummyParsingHandler()
def test_empty_session_id_returns_none(self):
self.assertIsNone(self.handler._find_codex_transcript_file(""))
def test_none_session_id_returns_none(self):
self.assertIsNone(self.handler._find_codex_transcript_file(None))
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.agents import _codex_transcript_cache
_codex_transcript_cache.clear()
result = self.handler._find_codex_transcript_file("abc123")
self.assertIsNone(result)
def test_cache_hit_returns_cached_path(self):
with tempfile.TemporaryDirectory() as tmpdir:
transcript_file = Path(tmpdir) / "abc123.jsonl"
transcript_file.write_text('{"type": "session_meta"}\n')
from amc_server.agents import _codex_transcript_cache
_codex_transcript_cache["abc123"] = str(transcript_file)
result = self.handler._find_codex_transcript_file("abc123")
self.assertEqual(result, transcript_file)
# Clean up
_codex_transcript_cache.clear()
def test_cache_hit_with_deleted_file_returns_none(self):
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")
self.assertIsNone(result)
_codex_transcript_cache.clear()
def test_cache_hit_with_none_returns_none(self):
from amc_server.agents import _codex_transcript_cache
_codex_transcript_cache["cached-none"] = None
result = self.handler._find_codex_transcript_file("cached-none")
self.assertIsNone(result)
_codex_transcript_cache.clear()
class TestReadJsonlTailEntries(unittest.TestCase):
"""Tests for _read_jsonl_tail_entries edge cases."""
def setUp(self):
self.handler = DummyParsingHandler()
def test_empty_file_returns_empty_list(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
path = Path(f.name)
try:
result = self.handler._read_jsonl_tail_entries(path)
self.assertEqual(result, [])
finally:
path.unlink()
def test_nonexistent_file_returns_empty_list(self):
result = self.handler._read_jsonl_tail_entries(Path("/nonexistent/file.jsonl"))
self.assertEqual(result, [])
def test_single_line_file(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write('{"key": "value"}\n')
path = Path(f.name)
try:
result = self.handler._read_jsonl_tail_entries(path)
self.assertEqual(result, [{"key": "value"}])
finally:
path.unlink()
def test_max_lines_limits_output(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
for i in range(100):
f.write(f'{{"n": {i}}}\n')
path = Path(f.name)
try:
result = self.handler._read_jsonl_tail_entries(path, max_lines=10)
self.assertEqual(len(result), 10)
# Should be the LAST 10 lines
self.assertEqual(result[-1], {"n": 99})
finally:
path.unlink()
def test_max_bytes_truncates_from_start(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
# Write many lines
for i in range(100):
f.write(f'{{"number": {i}}}\n')
path = Path(f.name)
try:
# Read only last 200 bytes
result = self.handler._read_jsonl_tail_entries(path, max_bytes=200)
# Should get some entries from the end
self.assertGreater(len(result), 0)
# All entries should be from near the end
for entry in result:
self.assertGreater(entry["number"], 80)
finally:
path.unlink()
def test_partial_first_line_skipped(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
# Write enough to trigger partial read
f.write('{"first": "line", "long_key": "' + "x" * 500 + '"}\n')
f.write('{"second": "line"}\n')
path = Path(f.name)
try:
# Read only last 100 bytes (will cut first line)
result = self.handler._read_jsonl_tail_entries(path, max_bytes=100)
# First line should be skipped (partial JSON)
self.assertEqual(len(result), 1)
self.assertEqual(result[0], {"second": "line"})
finally:
path.unlink()
def test_invalid_json_lines_skipped(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write('{"valid": "json"}\n')
f.write('this is not json\n')
f.write('{"another": "valid"}\n')
path = Path(f.name)
try:
result = self.handler._read_jsonl_tail_entries(path)
self.assertEqual(len(result), 2)
self.assertEqual(result[0], {"valid": "json"})
self.assertEqual(result[1], {"another": "valid"})
finally:
path.unlink()
def test_empty_lines_skipped(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write('{"first": 1}\n')
f.write('\n')
f.write('{"second": 2}\n')
path = Path(f.name)
try:
result = self.handler._read_jsonl_tail_entries(path)
self.assertEqual(len(result), 2)
finally:
path.unlink()
class TestParseClaudeContextUsageFromFile(unittest.TestCase):
"""Tests for _parse_claude_context_usage_from_file edge cases."""
def setUp(self):
self.handler = DummyParsingHandler()
def test_empty_file_returns_none(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
path = Path(f.name)
try:
result = self.handler._parse_claude_context_usage_from_file(path)
self.assertIsNone(result)
finally:
path.unlink()
def test_no_assistant_messages_returns_none(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write('{"type": "user", "message": {"content": "hello"}}\n')
path = Path(f.name)
try:
result = self.handler._parse_claude_context_usage_from_file(path)
self.assertIsNone(result)
finally:
path.unlink()
def test_assistant_without_usage_returns_none(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write('{"type": "assistant", "message": {"content": []}}\n')
path = Path(f.name)
try:
result = self.handler._parse_claude_context_usage_from_file(path)
self.assertIsNone(result)
finally:
path.unlink()
def test_extracts_usage_from_assistant_message(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write(json.dumps({
"type": "assistant",
"timestamp": "2024-01-01T00:00:00Z",
"message": {
"model": "claude-3-5-sonnet-20241022",
"usage": {
"input_tokens": 1000,
"output_tokens": 500,
"cache_read_input_tokens": 200,
"cache_creation_input_tokens": 100,
}
}
}) + "\n")
path = Path(f.name)
try:
result = self.handler._parse_claude_context_usage_from_file(path)
self.assertIsNotNone(result)
self.assertEqual(result["input_tokens"], 1000)
self.assertEqual(result["output_tokens"], 500)
self.assertEqual(result["cached_input_tokens"], 300) # 200 + 100
self.assertEqual(result["current_tokens"], 1800) # sum of all
self.assertEqual(result["window_tokens"], 200_000)
self.assertEqual(result["model"], "claude-3-5-sonnet-20241022")
finally:
path.unlink()
def test_uses_most_recent_assistant_message(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write(json.dumps({
"type": "assistant",
"message": {"usage": {"input_tokens": 100, "output_tokens": 50}}
}) + "\n")
f.write(json.dumps({
"type": "assistant",
"message": {"usage": {"input_tokens": 999, "output_tokens": 888}}
}) + "\n")
path = Path(f.name)
try:
result = self.handler._parse_claude_context_usage_from_file(path)
# Should use the last message
self.assertEqual(result["input_tokens"], 999)
self.assertEqual(result["output_tokens"], 888)
finally:
path.unlink()
def test_skips_assistant_with_no_current_tokens(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
# Last message has no usable tokens
f.write(json.dumps({
"type": "assistant",
"message": {"usage": {"input_tokens": 100, "output_tokens": 50}}
}) + "\n")
f.write(json.dumps({
"type": "assistant",
"message": {"usage": {}} # No tokens
}) + "\n")
path = Path(f.name)
try:
result = self.handler._parse_claude_context_usage_from_file(path)
# Should fall back to earlier message with valid tokens
self.assertEqual(result["input_tokens"], 100)
finally:
path.unlink()
class TestParseCodexContextUsageFromFile(unittest.TestCase):
"""Tests for _parse_codex_context_usage_from_file edge cases."""
def setUp(self):
self.handler = DummyParsingHandler()
def test_empty_file_returns_none(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
path = Path(f.name)
try:
result = self.handler._parse_codex_context_usage_from_file(path)
self.assertIsNone(result)
finally:
path.unlink()
def test_no_token_count_events_returns_none(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write('{"type": "response_item"}\n')
path = Path(f.name)
try:
result = self.handler._parse_codex_context_usage_from_file(path)
self.assertIsNone(result)
finally:
path.unlink()
def test_extracts_token_count_event(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write(json.dumps({
"type": "event_msg",
"timestamp": "2024-01-01T00:00:00Z",
"payload": {
"type": "token_count",
"info": {
"model_context_window": 128000,
"last_token_usage": {
"input_tokens": 5000,
"output_tokens": 2000,
"cached_input_tokens": 1000,
"total_tokens": 8000,
},
"total_token_usage": {
"total_tokens": 50000,
}
}
}
}) + "\n")
path = Path(f.name)
try:
result = self.handler._parse_codex_context_usage_from_file(path)
self.assertIsNotNone(result)
self.assertEqual(result["window_tokens"], 128000)
self.assertEqual(result["current_tokens"], 8000)
self.assertEqual(result["input_tokens"], 5000)
self.assertEqual(result["output_tokens"], 2000)
self.assertEqual(result["session_total_tokens"], 50000)
finally:
path.unlink()
def test_calculates_current_tokens_when_total_missing(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write(json.dumps({
"type": "event_msg",
"payload": {
"type": "token_count",
"info": {
"last_token_usage": {
"input_tokens": 100,
"output_tokens": 50,
# no total_tokens
}
}
}
}) + "\n")
path = Path(f.name)
try:
result = self.handler._parse_codex_context_usage_from_file(path)
# Should sum available tokens
self.assertEqual(result["current_tokens"], 150)
finally:
path.unlink()
class TestGetCachedContextUsage(unittest.TestCase):
"""Tests for _get_cached_context_usage edge cases."""
def setUp(self):
self.handler = DummyParsingHandler()
# Clear cache before each test
from amc_server.agents import _context_usage_cache
_context_usage_cache.clear()
def test_nonexistent_file_returns_none(self):
def mock_parser(path):
return {"tokens": 100}
result = self.handler._get_cached_context_usage(
Path("/nonexistent/file.jsonl"),
mock_parser
)
self.assertIsNone(result)
def test_caches_result_by_mtime_and_size(self):
call_count = [0]
def counting_parser(path):
call_count[0] += 1
return {"tokens": 100}
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write('{"data": "test"}\n')
path = Path(f.name)
try:
# First call - should invoke parser
result1 = self.handler._get_cached_context_usage(path, counting_parser)
self.assertEqual(call_count[0], 1)
self.assertEqual(result1, {"tokens": 100})
# Second call - should use cache
result2 = self.handler._get_cached_context_usage(path, counting_parser)
self.assertEqual(call_count[0], 1) # No additional call
self.assertEqual(result2, {"tokens": 100})
finally:
path.unlink()
def test_invalidates_cache_on_mtime_change(self):
import time
call_count = [0]
def counting_parser(path):
call_count[0] += 1
return {"tokens": call_count[0] * 100}
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write('{"data": "test"}\n')
path = Path(f.name)
try:
result1 = self.handler._get_cached_context_usage(path, counting_parser)
self.assertEqual(result1, {"tokens": 100})
# Modify file to change mtime
time.sleep(0.01)
path.write_text('{"data": "modified"}\n')
result2 = self.handler._get_cached_context_usage(path, counting_parser)
self.assertEqual(call_count[0], 2) # Parser called again
self.assertEqual(result2, {"tokens": 200})
finally:
path.unlink()
def test_parser_exception_returns_none(self):
def failing_parser(path):
raise ValueError("Parse error")
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write('{"data": "test"}\n')
path = Path(f.name)
try:
result = self.handler._get_cached_context_usage(path, failing_parser)
self.assertIsNone(result)
finally:
path.unlink()
if __name__ == "__main__":
unittest.main()

583
tests/test_skills.py Normal file
View File

@@ -0,0 +1,583 @@
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from amc_server.mixins.skills import SkillsMixin
class DummySkillsHandler(SkillsMixin):
def __init__(self):
self.sent = []
def _send_json(self, code, payload):
self.sent.append((code, payload))
class TestEnumerateClaudeSkills(unittest.TestCase):
"""Tests for _enumerate_claude_skills."""
def setUp(self):
self.handler = DummySkillsHandler()
def test_empty_directory(self):
"""Returns empty list when ~/.claude/skills doesn't exist."""
with tempfile.TemporaryDirectory() as tmpdir:
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_claude_skills()
self.assertEqual(result, [])
def test_reads_skill_md(self):
"""Reads description from SKILL.md."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / ".claude/skills/my-skill"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("Does something useful")
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_claude_skills()
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["name"], "my-skill")
self.assertEqual(result[0]["description"], "Does something useful")
def test_fallback_to_readme(self):
"""Falls back to README.md when SKILL.md is missing."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / ".claude/skills/readme-skill"
skill_dir.mkdir(parents=True)
(skill_dir / "README.md").write_text("# Header\n\nReadme description here")
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_claude_skills()
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["description"], "Readme description here")
def test_fallback_priority_order(self):
"""SKILL.md takes priority over skill.md, prompt.md, README.md."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / ".claude/skills/priority-skill"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("From SKILL.md")
(skill_dir / "README.md").write_text("From README.md")
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_claude_skills()
self.assertEqual(result[0]["description"], "From SKILL.md")
def test_skill_md_lowercase_fallback(self):
"""Falls back to skill.md when SKILL.md is missing."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / ".claude/skills/lower-skill"
skill_dir.mkdir(parents=True)
(skill_dir / "skill.md").write_text("From lowercase skill.md")
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_claude_skills()
self.assertEqual(result[0]["description"], "From lowercase skill.md")
def test_skips_hidden_dirs(self):
"""Ignores directories starting with dot."""
with tempfile.TemporaryDirectory() as tmpdir:
skills_dir = Path(tmpdir) / ".claude/skills"
skills_dir.mkdir(parents=True)
# Visible skill
visible = skills_dir / "visible"
visible.mkdir()
(visible / "SKILL.md").write_text("Visible skill")
# Hidden skill
hidden = skills_dir / ".hidden"
hidden.mkdir()
(hidden / "SKILL.md").write_text("Hidden skill")
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_claude_skills()
names = [s["name"] for s in result]
self.assertIn("visible", names)
self.assertNotIn(".hidden", names)
def test_skips_files_in_skills_dir(self):
"""Only processes directories, not loose files."""
with tempfile.TemporaryDirectory() as tmpdir:
skills_dir = Path(tmpdir) / ".claude/skills"
skills_dir.mkdir(parents=True)
(skills_dir / "not-a-dir.txt").write_text("stray file")
skill = skills_dir / "real-skill"
skill.mkdir()
(skill / "SKILL.md").write_text("A real skill")
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_claude_skills()
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["name"], "real-skill")
def test_truncates_description(self):
"""Description truncated to 100 chars."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / ".claude/skills/long-skill"
skill_dir.mkdir(parents=True)
long_desc = "A" * 200
(skill_dir / "SKILL.md").write_text(long_desc)
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_claude_skills()
self.assertEqual(len(result[0]["description"]), 100)
def test_skips_headers(self):
"""First non-header line used as description."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / ".claude/skills/header-skill"
skill_dir.mkdir(parents=True)
content = "# My Skill\n## Subtitle\n\nActual description"
(skill_dir / "SKILL.md").write_text(content)
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_claude_skills()
self.assertEqual(result[0]["description"], "Actual description")
def test_no_description_uses_fallback(self):
"""Empty/header-only skill uses 'Skill: name' fallback."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / ".claude/skills/empty-skill"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("# Only Headers\n## Nothing else")
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_claude_skills()
self.assertEqual(result[0]["description"], "Skill: empty-skill")
def test_frontmatter_name_overrides_dir_name(self):
"""Frontmatter name field takes priority over directory name."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / ".claude/skills/bug-hunter"
skill_dir.mkdir(parents=True)
content = '---\nname: "doodle:bug-hunter"\ndescription: Find bugs\n---\n'
(skill_dir / "SKILL.md").write_text(content)
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_claude_skills()
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["name"], "doodle:bug-hunter")
self.assertEqual(result[0]["description"], "Find bugs")
def test_name_preserved_across_fallback_files(self):
"""Name from SKILL.md is kept even when description comes from README.md."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / ".claude/skills/bug-hunter"
skill_dir.mkdir(parents=True)
# SKILL.md has name but no extractable description (headers only)
(skill_dir / "SKILL.md").write_text(
'---\nname: "doodle:bug-hunter"\n---\n# Bug Hunter\n## Overview'
)
# README.md provides the description
(skill_dir / "README.md").write_text("Find and fix obvious bugs")
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_claude_skills()
self.assertEqual(result[0]["name"], "doodle:bug-hunter")
self.assertEqual(result[0]["description"], "Find and fix obvious bugs")
def test_no_frontmatter_name_uses_dir_name(self):
"""Without name in frontmatter, falls back to directory name."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / ".claude/skills/plain-skill"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("---\ndescription: No name field\n---\n")
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_claude_skills()
self.assertEqual(result[0]["name"], "plain-skill")
def test_no_md_files_uses_fallback(self):
"""Skill dir with no markdown files uses fallback description."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / ".claude/skills/bare-skill"
skill_dir.mkdir(parents=True)
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_claude_skills()
self.assertEqual(result[0]["description"], "Skill: bare-skill")
def test_os_error_on_read_continues(self):
"""OSError when reading file doesn't crash enumeration."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / ".claude/skills/broken-skill"
skill_dir.mkdir(parents=True)
md = skill_dir / "SKILL.md"
md.write_text("content")
with patch.object(Path, "home", return_value=Path(tmpdir)), \
patch.object(Path, "read_text", side_effect=OSError("disk error")):
result = self.handler._enumerate_claude_skills()
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["description"], "Skill: broken-skill")
class TestParseFrontmatter(unittest.TestCase):
"""Tests for _parse_frontmatter."""
def setUp(self):
self.handler = DummySkillsHandler()
def _desc(self, content: str) -> str:
return self.handler._parse_frontmatter(content)["description"]
def _name(self, content: str) -> str:
return self.handler._parse_frontmatter(content)["name"]
def test_empty_content(self):
self.assertEqual(self._desc(""), "")
self.assertEqual(self._name(""), "")
def test_plain_text(self):
self.assertEqual(self._desc("Simple description"), "Simple description")
def test_yaml_frontmatter_description(self):
content = "---\ndescription: A skill for formatting\n---\nBody text"
self.assertEqual(self._desc(content), "A skill for formatting")
def test_yaml_frontmatter_quoted_description(self):
content = '---\ndescription: "Quoted desc"\n---\n'
self.assertEqual(self._desc(content), "Quoted desc")
def test_yaml_frontmatter_single_quoted_description(self):
content = "---\ndescription: 'Single quoted'\n---\n"
self.assertEqual(self._desc(content), "Single quoted")
def test_yaml_multiline_fold_indicator(self):
"""Handles >- style multi-line YAML."""
content = "---\ndescription: >-\n Multi-line folded text\n---\n"
self.assertEqual(self._desc(content), "Multi-line folded text")
def test_yaml_multiline_literal_indicator(self):
"""Handles |- style multi-line YAML."""
content = "---\ndescription: |-\n Literal block text\n---\n"
self.assertEqual(self._desc(content), "Literal block text")
def test_yaml_multiline_bare_fold(self):
"""Handles > without trailing dash."""
content = "---\ndescription: >\n Bare fold\n---\n"
self.assertEqual(self._desc(content), "Bare fold")
def test_yaml_multiline_bare_literal(self):
"""Handles | without trailing dash."""
content = "---\ndescription: |\n Bare literal\n---\n"
self.assertEqual(self._desc(content), "Bare literal")
def test_yaml_empty_description_falls_back_to_body(self):
"""Empty description in frontmatter falls back to body text."""
content = "---\ndescription:\n---\nFallback body line"
self.assertEqual(self._desc(content), "Fallback body line")
def test_skips_headers_and_empty_lines(self):
content = "# Title\n\n## Section\n\nActual content"
self.assertEqual(self._desc(content), "Actual content")
def test_skips_html_comments(self):
content = "<!-- comment -->\nReal content"
self.assertEqual(self._desc(content), "Real content")
def test_truncates_to_100_chars(self):
long_line = "B" * 150
self.assertEqual(len(self._desc(long_line)), 100)
def test_frontmatter_description_truncated(self):
desc = "C" * 150
content = f"---\ndescription: {desc}\n---\n"
self.assertEqual(len(self._desc(content)), 100)
def test_no_closing_frontmatter_extracts_description(self):
"""Unclosed frontmatter still extracts description from the loop."""
content = "---\ndescription: Orphaned\ntitle: Test"
self.assertEqual(self._desc(content), "Orphaned")
def test_body_only_headers_returns_empty(self):
content = "# H1\n## H2\n### H3"
self.assertEqual(self._desc(content), "")
# --- name field tests ---
def test_extracts_name_from_frontmatter(self):
content = "---\nname: doodle:bug-hunter\ndescription: Find bugs\n---\n"
self.assertEqual(self._name(content), "doodle:bug-hunter")
self.assertEqual(self._desc(content), "Find bugs")
def test_name_quoted(self):
content = '---\nname: "bmad:brainstorm"\n---\nBody'
self.assertEqual(self._name(content), "bmad:brainstorm")
def test_name_single_quoted(self):
content = "---\nname: 'my-prefix:skill'\n---\nBody"
self.assertEqual(self._name(content), "my-prefix:skill")
def test_no_name_field_returns_empty(self):
content = "---\ndescription: Just a description\n---\n"
self.assertEqual(self._name(content), "")
def test_name_truncated_to_100(self):
long_name = "N" * 150
content = f"---\nname: {long_name}\n---\n"
self.assertEqual(len(self._name(content)), 100)
class TestEnumerateCodexSkills(unittest.TestCase):
"""Tests for _enumerate_codex_skills."""
def setUp(self):
self.handler = DummySkillsHandler()
def test_reads_cache(self):
"""Reads skills from skills-curated-cache.json."""
with tempfile.TemporaryDirectory() as tmpdir:
cache_dir = Path(tmpdir) / ".codex/vendor_imports"
cache_dir.mkdir(parents=True)
cache = {
"skills": [
{"id": "lint", "shortDescription": "Lint code"},
{"name": "deploy", "description": "Deploy to prod"},
]
}
(cache_dir / "skills-curated-cache.json").write_text(json.dumps(cache))
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_codex_skills()
self.assertEqual(len(result), 2)
self.assertEqual(result[0]["name"], "lint")
self.assertEqual(result[0]["description"], "Lint code")
# Falls back to 'name' when 'id' is absent
self.assertEqual(result[1]["name"], "deploy")
self.assertEqual(result[1]["description"], "Deploy to prod")
def test_id_preferred_over_name(self):
"""Uses 'id' field preferentially over 'name'."""
with tempfile.TemporaryDirectory() as tmpdir:
cache_dir = Path(tmpdir) / ".codex/vendor_imports"
cache_dir.mkdir(parents=True)
cache = {"skills": [{"id": "the-id", "name": "the-name", "shortDescription": "desc"}]}
(cache_dir / "skills-curated-cache.json").write_text(json.dumps(cache))
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_codex_skills()
self.assertEqual(result[0]["name"], "the-id")
def test_short_description_preferred(self):
"""Uses 'shortDescription' preferentially over 'description'."""
with tempfile.TemporaryDirectory() as tmpdir:
cache_dir = Path(tmpdir) / ".codex/vendor_imports"
cache_dir.mkdir(parents=True)
cache = {"skills": [{"id": "sk", "shortDescription": "short", "description": "long version"}]}
(cache_dir / "skills-curated-cache.json").write_text(json.dumps(cache))
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_codex_skills()
self.assertEqual(result[0]["description"], "short")
def test_invalid_json(self):
"""Continues without cache if JSON is invalid."""
with tempfile.TemporaryDirectory() as tmpdir:
cache_dir = Path(tmpdir) / ".codex/vendor_imports"
cache_dir.mkdir(parents=True)
(cache_dir / "skills-curated-cache.json").write_text("{not valid json")
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_codex_skills()
self.assertEqual(result, [])
def test_combines_cache_and_user(self):
"""Returns both curated and user-installed skills."""
with tempfile.TemporaryDirectory() as tmpdir:
# Curated
cache_dir = Path(tmpdir) / ".codex/vendor_imports"
cache_dir.mkdir(parents=True)
cache = {"skills": [{"id": "curated-skill", "shortDescription": "Curated"}]}
(cache_dir / "skills-curated-cache.json").write_text(json.dumps(cache))
# User
user_skill = Path(tmpdir) / ".codex/skills/user-skill"
user_skill.mkdir(parents=True)
(user_skill / "SKILL.md").write_text("User installed skill")
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_codex_skills()
names = [s["name"] for s in result]
self.assertIn("curated-skill", names)
self.assertIn("user-skill", names)
self.assertEqual(len(result), 2)
def test_no_deduplication(self):
"""Duplicate names from cache and user both appear."""
with tempfile.TemporaryDirectory() as tmpdir:
# Curated with name "dupe"
cache_dir = Path(tmpdir) / ".codex/vendor_imports"
cache_dir.mkdir(parents=True)
cache = {"skills": [{"id": "dupe", "shortDescription": "From cache"}]}
(cache_dir / "skills-curated-cache.json").write_text(json.dumps(cache))
# User with same name "dupe"
user_skill = Path(tmpdir) / ".codex/skills/dupe"
user_skill.mkdir(parents=True)
(user_skill / "SKILL.md").write_text("From user dir")
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_codex_skills()
dupe_entries = [s for s in result if s["name"] == "dupe"]
self.assertEqual(len(dupe_entries), 2)
def test_empty_no_cache_no_user_dir(self):
"""Returns empty list when neither cache nor user dir exists."""
with tempfile.TemporaryDirectory() as tmpdir:
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_codex_skills()
self.assertEqual(result, [])
def test_skips_entries_without_name_or_id(self):
"""Cache entries without name or id are skipped."""
with tempfile.TemporaryDirectory() as tmpdir:
cache_dir = Path(tmpdir) / ".codex/vendor_imports"
cache_dir.mkdir(parents=True)
cache = {"skills": [{"shortDescription": "No name"}, {"id": "valid", "shortDescription": "OK"}]}
(cache_dir / "skills-curated-cache.json").write_text(json.dumps(cache))
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_codex_skills()
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["name"], "valid")
def test_missing_description_uses_fallback(self):
"""Cache entry without description uses 'Skill: name' fallback."""
with tempfile.TemporaryDirectory() as tmpdir:
cache_dir = Path(tmpdir) / ".codex/vendor_imports"
cache_dir.mkdir(parents=True)
cache = {"skills": [{"id": "bare"}]}
(cache_dir / "skills-curated-cache.json").write_text(json.dumps(cache))
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_codex_skills()
self.assertEqual(result[0]["description"], "Skill: bare")
def test_user_skill_without_skill_md_uses_fallback(self):
"""User skill dir without SKILL.md uses fallback description."""
with tempfile.TemporaryDirectory() as tmpdir:
user_skill = Path(tmpdir) / ".codex/skills/no-md"
user_skill.mkdir(parents=True)
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_codex_skills()
self.assertEqual(result[0]["description"], "User skill: no-md")
def test_user_skills_skip_hidden_dirs(self):
"""Hidden directories in user skills dir are skipped."""
with tempfile.TemporaryDirectory() as tmpdir:
user_dir = Path(tmpdir) / ".codex/skills"
user_dir.mkdir(parents=True)
(user_dir / "visible").mkdir()
(user_dir / ".hidden").mkdir()
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_codex_skills()
names = [s["name"] for s in result]
self.assertIn("visible", names)
self.assertNotIn(".hidden", names)
def test_description_truncated_to_100(self):
"""Codex cache description truncated to 100 chars."""
with tempfile.TemporaryDirectory() as tmpdir:
cache_dir = Path(tmpdir) / ".codex/vendor_imports"
cache_dir.mkdir(parents=True)
cache = {"skills": [{"id": "long", "shortDescription": "D" * 200}]}
(cache_dir / "skills-curated-cache.json").write_text(json.dumps(cache))
with patch.object(Path, "home", return_value=Path(tmpdir)):
result = self.handler._enumerate_codex_skills()
self.assertEqual(len(result[0]["description"]), 100)
class TestServeSkills(unittest.TestCase):
"""Tests for _serve_skills."""
def setUp(self):
self.handler = DummySkillsHandler()
def test_claude_trigger(self):
"""Returns / trigger for claude agent."""
with patch.object(self.handler, "_enumerate_claude_skills", return_value=[]):
self.handler._serve_skills("claude")
self.assertEqual(self.handler.sent[0][0], 200)
self.assertEqual(self.handler.sent[0][1]["trigger"], "/")
def test_codex_trigger(self):
"""Returns $ trigger for codex agent."""
with patch.object(self.handler, "_enumerate_codex_skills", return_value=[]):
self.handler._serve_skills("codex")
self.assertEqual(self.handler.sent[0][0], 200)
self.assertEqual(self.handler.sent[0][1]["trigger"], "$")
def test_default_to_claude(self):
"""Unknown agent defaults to claude (/ trigger)."""
with patch.object(self.handler, "_enumerate_claude_skills", return_value=[]):
self.handler._serve_skills("unknown-agent")
self.assertEqual(self.handler.sent[0][1]["trigger"], "/")
def test_alphabetical_sort(self):
"""Skills sorted alphabetically (case-insensitive)."""
skills = [
{"name": "Zebra", "description": "z"},
{"name": "alpha", "description": "a"},
{"name": "Beta", "description": "b"},
]
with patch.object(self.handler, "_enumerate_claude_skills", return_value=skills):
self.handler._serve_skills("claude")
result_names = [s["name"] for s in self.handler.sent[0][1]["skills"]]
self.assertEqual(result_names, ["alpha", "Beta", "Zebra"])
def test_response_format(self):
"""Response has trigger and skills keys."""
skills = [{"name": "test", "description": "A test skill"}]
with patch.object(self.handler, "_enumerate_claude_skills", return_value=skills):
self.handler._serve_skills("claude")
code, payload = self.handler.sent[0]
self.assertEqual(code, 200)
self.assertIn("trigger", payload)
self.assertIn("skills", payload)
self.assertEqual(len(payload["skills"]), 1)
def test_empty_skills_list(self):
"""Empty skill list still returns valid response."""
with patch.object(self.handler, "_enumerate_claude_skills", return_value=[]):
self.handler._serve_skills("claude")
payload = self.handler.sent[0][1]
self.assertEqual(payload["skills"], [])
self.assertEqual(payload["trigger"], "/")
if __name__ == "__main__":
unittest.main()

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