diff --git a/.claude/skills/implementation-workflow-delegator/SKILL.md b/.claude/skills/implementation-workflow-delegator/SKILL.md new file mode 100644 index 0000000..5a3f11a --- /dev/null +++ b/.claude/skills/implementation-workflow-delegator/SKILL.md @@ -0,0 +1,484 @@ +--- +name: implementation-workflow-delegator +description: | + Multi-agent orchestration workflow for implementing features via tmux subagents. + Use when implementing features from specs with multiple phases or tasks. + Triggers: "implement feature", "use agents", "delegate implementation", "spawn agents", + "/speckit.implement", multi-phase implementation, task-based development. + + CRITICAL: Main agent does NO implementation work. Only spawns subagents, tracks status, + creates commits, and updates task files. All coding delegated to subagents. + + Key constraints: The main agent shall NOT write code. The main agent shall NOT run tests. + The main agent shall delegate ALL work to subagents and wait for agentmail responses. +--- + +# Implementation Workflow Delegator + +Orchestrate feature implementation via tmux subagents. Main agent role: coordinator only. + +## Behavioral Requirements (EARS Format) + +### Prohibition Requirements [Ubiquitous] + +The main agent shall NOT write, edit, or modify application code directly. +The main agent shall NOT run tests, linters, or quality checks directly. +The main agent shall NOT read files for implementation purposes. +The main agent shall delegate ALL implementation work to subagents. + +### Phase Initiation [Event-Driven] + +When a phase begins, the main agent shall spawn an implementation subagent using `spawn_subagent.sh`. +When the implementation subagent is spawned, the main agent shall send `/clear` to reset context. +When the context is cleared, the main agent shall send the phase tasks via `send_command.sh`. + +### Waiting Protocol [Event-Driven] + +When task instructions are sent to a subagent, the main agent shall output "Waiting for agent completion. Check agentmail when ready." +When task instructions are sent, the main agent shall STOP and wait for user to say "check agentmail". +When the user says "check agentmail", the main agent shall call `mcp__agentmail__receive`. + +### Commit Creation [Event-Driven] + +When an implementation subagent reports completion via agentmail, the main agent shall create a git commit. +When creating a commit, the main agent shall include `Co-Authored-By: Claude Opus 4.5 `. + +### Review Spawning [Event-Driven] + +When a commit is created, the main agent shall spawn a Claude reviewer subagent. +When a commit is created, the main agent shall send `/new` to the existing `codex` window to clear context. +When spawning reviewers, the main agent shall include the commit hash and changed files in the task. +The main agent shall NOT spawn a new codex window; the `codex` window is always running. + +### Review Handling [State-Driven] + +While both reviewers return APPROVED, the main agent shall proceed to the next phase. +While Claude reviewer returns NEEDS_FIXES, the main agent shall spawn a fix subagent. +While only Codex returns NEEDS_FIXES, the main agent shall ask user whether to fix or proceed. + +### Fix Handling [Event-Driven] + +When a fix subagent completes, the main agent shall amend the previous commit. +When fixes are applied, the main agent shall re-run reviewers if changes are significant. + +### Task Tracking [Event-Driven] + +When a phase completes successfully, the main agent shall update tasks.md with session IDs and commit hash. +When updating tasks.md, the main agent shall mark all phase tasks as `[x]` complete. + +### Cleanup [Event-Driven] + +When a phase is fully complete (reviewed and tracked), the main agent shall exit all phase subagents using `exit_subagent.sh`. +The main agent shall NOT exit the `codex` window; it persists across all phases. + +### Agent Resume [Event-Driven] + +When follow-up work is needed on a previous agent's task, the main agent shall resume using `--resume {session-id}`. +When resuming an agent, the main agent shall NOT send `/clear` (context must be preserved). +When resuming, the main agent shall provide additional context via `send_command.sh`. +The session ID shall be obtained from the agent's previous agentmail response. + +### Completion [Event-Driven] + +When all phases complete, the main agent shall output a summary table with phases, commits, and test counts. + +## Workflow Diagram + +```mermaid +flowchart TD + subgraph Phase["Phase N Workflow"] + A[Phase Start] --> B[Spawn impl agent
send /clear
send tasks] + B --> C[/"STOP
Wait for user"/] + C -->|"check agentmail"| D[Receive agentmail
Create git commit] + D --> E[Spawn Claude reviewer
Send /new to codex] + E --> F[/"STOP
Wait for user"/] + F -->|"check agentmail"| G{Reviews
Received} + G -->|Both APPROVED| H[Update tasks.md] + G -->|NEEDS_FIXES| I[Spawn fix agent] + I --> J[/"STOP
Wait for user"/] + J -->|"check agentmail"| K[Amend commit] + K -->|Significant changes| E + K -->|Minor changes| H + H --> L[Cleanup agents
exit impl + review
keep codex running] + L --> M{More phases?} + M -->|Yes| A + M -->|No| N[Output Summary Table] + end + + subgraph Agents["Spawned Agents"] + B -.->|spawns| IMPL["agent-phase{N}-impl
• Reads tasks
• Writes code
• Runs tests"] + E -.->|spawns| REV["agent-phase{N}-review"] + E -.->|/new| CODEX["codex (persistent)"] + I -.->|spawns| FIX["agent-phase{N}-fix"] + end +``` + +## Agent Communication Flow + +```mermaid +sequenceDiagram + participant Main as Main Agent + participant Sub as Subagent (tmux) + participant Codex as Codex (persistent) + participant User as User + + Main->>Sub: spawn_subagent.sh + Main->>Sub: send_command.sh (tasks) + Main->>User: "Waiting... Check agentmail" + + Note over Sub: Works on tasks... + + Sub->>Main: agentmail (completion) + User->>Main: "check agentmail" + Main->>Main: mcp__agentmail__receive + Main->>Main: git commit + + par Review Phase + Main->>Sub: spawn_subagent.sh (reviewer) + Main->>Codex: send /new + Main->>Codex: send review task + end + + Main->>User: "Waiting... Check agentmail" + + par Reviews Complete + Sub->>Main: agentmail (APPROVED/NEEDS_FIXES) + Codex->>Main: agentmail (verdict) + end + + User->>Main: "check agentmail" + Main->>Sub: exit_subagent.sh + + Note over Codex: Stays running
(never exit) +``` + +## Workflow Per Phase + +```text +1. Spawn agent-phase{N}-impl +2. Send /clear +3. Send phase tasks via agentmail +4. STOP - Wait for user "check agentmail" +5. Receive completion message +6. Create git commit +7. Spawn reviewers (parallel) +8. STOP - Wait for user "check agentmail" +9. If NEEDS_FIXES: spawn fix agent, repeat from step 4 +10. Update tasks.md with session IDs +11. Clean up agents +12. Proceed to next phase +``` + +## Agent Spawning + +Use tmux skill scripts: + +```bash +# Spawn implementation agent +.claude/skills/tmux/scripts/spawn_subagent.sh agent-phase{N}-impl "claude --dangerously-skip-permissions" + +# Clear context +.claude/skills/tmux/scripts/send_command.sh agent-phase{N}-impl "/clear" + +# Send task +.claude/skills/tmux/scripts/send_command.sh agent-phase{N}-impl "Implement Phase {N}: {description} + +## Tasks +{task list from tasks.md} + +## Deliverables +{expected outputs} + +When complete, send agentmail to 'main' with: +1. YOUR session ID (from scratchpad path) +2. Task completion status +3. Test results summary +4. Issues found" +``` + +## Resuming Agents + +Agents can be resumed with their full conversation context using the `--resume` flag. + +### When to Resume + +- Agent needs additional context or clarification +- Follow-up task related to previous work +- Agent was exited prematurely +- Need to continue investigation with preserved context + +### Resume Pattern + +```bash +# Get session ID from previous agentmail response +# Example: "Session ID: b5c9dbe5-86f0-47b7-9552-400cb7acae90" + +# Resume agent with previous context +.claude/skills/tmux/scripts/spawn_subagent.sh agent-{name} "claude --dangerously-skip-permissions --resume {session-id}" + +# Send follow-up task (no /clear needed - context preserved) +.claude/skills/tmux/scripts/send_command.sh agent-{name} "Additional context: + +## New Information +{additional context} + +## Updated Task +{follow-up instructions} + +When complete, send agentmail to 'main' with: +1. YOUR session ID +2. Updated analysis/results" +``` + +### Session ID Sources + +Session IDs are provided in agentmail responses: +- Implementation agents: include in completion message +- Review agents: include in verdict message +- All agents should report: `Session ID: {uuid}` + +### Resume vs Fresh Spawn + +| Scenario | Action | +|----------|--------| +| New phase/task | Fresh spawn with `/clear` | +| Follow-up on same task | Resume with `--resume {id}` | +| Additional context needed | Resume with `--resume {id}` | +| Agent crashed/exited | Resume with `--resume {id}` | + +**Note**: When resuming, do NOT send `/clear` - this would defeat the purpose of preserving context. + +## Reviewer Pattern + +Spawn Claude reviewer and use existing codex window in parallel after commit: + +```bash +# Claude reviewer (spawn new) +.claude/skills/tmux/scripts/spawn_subagent.sh agent-phase{N}-review "claude --dangerously-skip-permissions" +.claude/skills/tmux/scripts/send_command.sh agent-phase{N}-review "/clear" +.claude/skills/tmux/scripts/send_command.sh agent-phase{N}-review "Review commit {hash} for {feature}. + +## Review Scope +{files changed} + +## Review Focus +{quality criteria} + +When complete, send agentmail to 'main' with: +1. YOUR session ID +2. APPROVED or NEEDS_FIXES +3. Issues list (if any)" + +# Codex reviewer (use existing window - NEVER spawn new) +# Prefer using /codex-assistant skill for cleaner integration +.claude/skills/tmux/scripts/send_command.sh codex "/clear" +.claude/skills/tmux/scripts/send_command.sh codex "Use /codex-assistant to review commit {hash}. + +Review focus: +1. {criteria 1} +2. {criteria 2} +3. {criteria 3} + +Output: APPROVED or NEEDS_FIXES with issues. + +When complete, send agentmail to 'main' with: +1. YOUR session ID +2. Codex verdict +3. Issues found (if any)" +``` + +**Important**: The `codex` window is persistent. Never exit or stop it. +**Preferred**: Use `/codex-assistant` skill instead of raw `codex exec` commands. + +## Handling Review Results + +| Claude | Codex | Action | +| ----------- | ----------- | -------------------------- | +| APPROVED | APPROVED | Proceed to next phase | +| APPROVED | NEEDS_FIXES | Ask user: fix or proceed | +| APPROVED | (pending) | Proceed (Claude is primary)| +| NEEDS_FIXES | * | Spawn fix agent | + +**Note**: If Claude approves but Codex is slow/pending, proceed to next phase. Late Codex reviews can be addressed in subsequent iterations if issues are significant. + +## Fix Agent Pattern + +```bash +.claude/skills/tmux/scripts/spawn_subagent.sh agent-phase{N}-fix "claude --dangerously-skip-permissions" +.claude/skills/tmux/scripts/send_command.sh agent-phase{N}-fix "/clear" +.claude/skills/tmux/scripts/send_command.sh agent-phase{N}-fix "Fix issues in {file} + +## Issues to Fix +{issue list from review} + +## Deliverables +1. Fix all issues +2. Run tests +3. Verify passing + +When complete, send agentmail to 'main' with: +1. YOUR session ID +2. Fixes applied +3. Test results" +``` + +After fix: amend commit, re-run reviewers if needed. + +## Task Tracking + +Update tasks.md after each phase: + +```markdown +## Phase {N}: {Name} + +**Implementer Session**: `{session-id}` +**Reviewer Sessions**: `{claude-id}` (claude), `{codex-id}` (codex) +**Commit**: `{hash}` + +- [x] T001 Task description +- [x] T002 Task description +``` + +## Agent Cleanup + +Always clean up after phase completion: + +```bash +.claude/skills/tmux/scripts/exit_subagent.sh agent-phase{N}-impl +.claude/skills/tmux/scripts/exit_subagent.sh agent-phase{N}-review +# NEVER exit codex - it persists across all phases +``` + +**Important**: Do NOT exit the `codex` window. It remains running for all phases. + +## Commit Pattern + +Create commit after implementation agent completes: + +```bash +git add {files} +git commit -m "$(cat <<'EOF' +{type}({scope}): {description} + +{body} + +Co-Authored-By: Claude Opus 4.5 +EOF +)" +``` + +If fixes applied, amend: + +```bash +git add {files} +git commit --amend -m "{updated message}" +``` + +## Communication Protocol + +Main agent outputs to user: + +- Phase start announcement +- "Waiting for agent completion. Check agentmail when ready." +- Review results summary table +- Phase completion with commit hash + +Never proceed without user saying "check agentmail". + +## Naming Conventions + +| Agent Type | Pattern | Lifecycle | Resumable | +| -------------- | ----------------------- | ---------------------------- | --------- | +| Implementation | `agent-phase{N}-impl` | Spawn per phase, exit after | Yes | +| Claude Review | `agent-phase{N}-review` | Spawn per phase, exit after | Yes | +| Codex Review | `codex` | Persistent, never exit | N/A | +| Fix | `agent-phase{N}-fix` | Spawn as needed, exit after | Yes | +| Ad-hoc | `agent-{task-name}` | Spawn as needed, exit after | Yes | + +## Summary Table Template + +After all phases complete: + +```markdown +| Phase | Description | Commit | Tests | +|-------|-------------|--------|-------| +| 1 | {desc} | `{hash}` | {count} | +| 2 | {desc} | `{hash}` | {count} | +... +``` + +## Quality Gates Pattern + +After all phases complete, run quality gates sequentially with a dedicated agent: + +```bash +.claude/skills/tmux/scripts/spawn_subagent.sh agent-qa-gates "claude --dangerously-skip-permissions" +.claude/skills/tmux/scripts/send_command.sh agent-qa-gates "/clear" +.claude/skills/tmux/scripts/send_command.sh agent-qa-gates "Run all quality gates SEQUENTIALLY: + +## Gate 1: Formatting +gofmt -l . + +## Gate 2: Dependencies +go mod verify + +## Gate 3: Static Analysis +go vet ./... + +## Gate 4: Tests +go test -v -race -coverprofile=coverage.out ./... + +## Gate 5: Vulnerability Check +govulncheck ./... + +## Gate 6: Security Scan +gosec ./... + +Report results in table format: +| Gate | Status | Details | +|------|--------|---------| + +When complete, send agentmail to 'main' with results." +``` + +## EARS Requirements Integration + +Use `/ears-translator` skill to formalize requirements before implementation: + +1. **Before implementation**: Translate spec/plan deliverables to EARS format +2. **During implementation**: Reference REQ-IDs in commits (e.g., "Satisfies: REQ-AM-001") +3. **After implementation**: Verify all EARS requirements satisfied + +```bash +# Ask subagent to translate requirements +.claude/skills/tmux/scripts/send_command.sh agent-spec "Use /ears-translator to review {spec-file} and translate deliverables to EARS format" +``` + +EARS patterns used: +- **Event-Driven**: When {trigger}, the system shall... +- **State-Driven**: While {condition}, the system shall... +- **Ubiquitous**: The system shall... (always active) +- **Unwanted Behavior**: If {error}, then the system shall... + +## Spec Amendment Pattern + +When adding new features to existing specs: + +1. Create amendment file: `specs/{feature}/amendments/{amendment-name}.md` +2. Link from parent spec +3. Include EARS requirements section +4. Include implementation plan with phases + +```markdown +# Spec Amendment: {Title} + +**Amendment ID**: AM-{feature}-{number} +**Parent Spec**: [spec.md](../spec.md) +**Status**: Draft + +## Requirements (EARS Format) +... + +## Implementation Plan +... +``` diff --git a/.claude/skills/tdd-implement/SKILL.md b/.claude/skills/tdd-implement/SKILL.md new file mode 100644 index 0000000..cc165e1 --- /dev/null +++ b/.claude/skills/tdd-implement/SKILL.md @@ -0,0 +1,147 @@ +--- +name: tdd-implement +description: Execute TDD implementation with a 3-agent team (QA, Implementor, Reviewer) following Red-Green-Review cycle. Use when Claude needs to (1) implement features using TDD methodology, (2) run a multi-agent team for code changes, (3) enforce minimal working code through test-driven development. Triggers on "tdd", "implement with tdd", "tdd team", "red green refactor", "tdd implement". +--- + +# TDD Team Implementation + +## User Input + +```text +$ARGUMENTS +``` + +Consider user input before proceeding (if not empty). + +## Prerequisites + +The lead shall read `.specify/memory/serena/index.md` for relevant memories before starting. +The lead shall read `.specify/memory/constitution.md` for project principles and constraints before starting. +The lead shall read spec/plan/tasks files for the current feature (if they exist). +The lead shall read `CLAUDE.md` for quality gates. + +## Lead Role — Coordination Only + +**CRITICAL**: The lead shall NOT write code, edit files, run tests, or fix issues directly. The lead is a coordinator: + +- The lead shall delegate ALL implementation work to **implementor**. +- The lead shall delegate ALL test writing to **qa**. +- The lead shall delegate ALL quality gate execution to **qa**. +- The lead shall delegate ALL review work to **reviewer**. +- The lead shall only read files for context, create tasks, assign work, and forward messages between agents. +- The lead shall send fix instructions to the appropriate agent — never apply fixes itself. +- If an agent fails after 3 attempts on the same issue, the lead shall spawn a replacement agent rather than doing the work. + +The lead's tools: TeamCreate, TaskCreate, TaskUpdate, TaskList, SendMessage, Read, Glob, Grep (read-only exploration). The lead shall not use Edit, Write, or Bash for code changes. + +## Core Principles + +Include in EVERY agent prompt. See [references/agent-rules.md](references/agent-rules.md) for full text. + +Each agent shall write the smallest amount of code that satisfies requirements and tests. +Each agent shall prefer simple solutions over clever ones. +Each agent shall not add features, handling, or abstractions beyond requirements or tests. +Each agent shall not add docstrings, comments, type hints, or refactoring beyond what the task demands. +Each agent shall treat requirements as the maximum scope ceiling. + +## Team + +Create via TeamCreate, then spawn 3 agents: + +| Role | Model | subagent_type | +|------|-------|---------------| +| qa | sonnet | general-purpose | +| implementor | sonnet | general-purpose | +| reviewer | opus | general-purpose | + +## Agent Communication Rules + +**CRITICAL**: Agents do not automatically report results or mark tasks done. Every agent prompt MUST include these instructions: + +1. **Task completion**: Every agent prompt shall end with: "When done, mark task #N as completed using TaskUpdate, then send a message to team-lead using SendMessage with type 'message' containing your results summary." +2. **QA reporting only**: QA prompts shall include: "Do NOT fix anything — only report results. Mark task as completed if all gates pass, leave in_progress if failures." +3. **Reviewer forwarding**: The lead shall forward reviewer issues to implementor via SendMessage with exact file:line references and code fixes. Never expect the reviewer to message the implementor directly. +4. **Re-verification after fix**: When sending fixes to implementor, include: "After fixing, run [specific verification command] to confirm clean, then message team-lead with results." +5. **Idle is normal**: Agents go idle after every turn. Do not treat idle notifications as errors or completion signals — wait for the actual SendMessage from the agent. + +## Workflow + +``` +RED → GREEN → QUALITY GATES → REVIEW → (REVISE loop, max 3) → DONE +``` + +### Phase 1: RED — Failing Tests + +Assign to **qa**. Use prompt from [references/qa-prompt.md](references/qa-prompt.md). + +The lead shall substitute `{REQUIREMENTS}` with requirements from user input or spec files. +The lead shall append to qa prompt: "When done, mark task #N as completed using TaskUpdate, then send a message to team-lead via SendMessage with test file paths and confirmation they fail." +When qa completes, the lead shall verify tests exist and fail via `go test ./...`. + +### Phase 2: GREEN — Minimal Implementation + +Assign to **implementor**. Use prompt from [references/implementor-prompt.md](references/implementor-prompt.md). + +The lead shall append to implementor prompt: "When done, mark task #N as completed using TaskUpdate, then send a message to team-lead via SendMessage with files changed and test results." +When implementor completes, the lead shall verify all tests pass. + +### Phase 3: Quality Gates + +Assign to **qa** (or spawn dedicated qa agent). The lead shall instruct qa: +- Run all quality gate commands +- Report results only — do NOT fix anything +- Mark task as completed if all pass, leave in_progress if failures +- Send results to team-lead via SendMessage + +The lead may run quality gates in parallel with the reviewer (Phase 4) since both are read-only. + +```bash +gofmt -l . +go vet ./... +go test -v -race ./... +govulncheck ./... +gosec ./... +``` + +If any quality gate fails, then the lead shall send exact error output to **implementor** via SendMessage for fix, including: "After fixing, run [failed command] to verify clean, then message team-lead." + +### Phase 4: Review + +Assign to **reviewer**. Use prompt from [references/reviewer-prompt.md](references/reviewer-prompt.md). + +The lead shall append: "Send your review to team-lead via SendMessage. Do NOT message implementor directly." +The reviewer shall return PASS or REVISE with specific file:line issues. + +### Phase 5: Revise (if needed) + +When reviewer returns REVISE, the lead shall forward specific issues to **implementor** via SendMessage with exact code suggestions and file:line references. +The lead shall include in the message: "After fixing, run [verification command] to confirm, then message team-lead." +The implementor shall fix only the flagged issues. +When implementor completes fixes, the lead shall re-run quality gates (Phase 3). +When quality gates pass, the lead shall re-send to **reviewer**. +If revise cycle exceeds 3 iterations, then the lead shall spawn a fresh implementor agent with accumulated context and fixes. + +## Task List + +The lead shall create these tasks at start: + +1. `Write failing tests (RED)` — qa +2. `Implement minimal code (GREEN)` — implementor, blocked by #1 +3. `Run quality gates` — blocked by #2 +4. `Review changes` — reviewer, blocked by #3 +5. `Apply review fixes` — blocked by #4 +6. `Final quality gates and cleanup` — blocked by #5 + +## Error Handling + +If qa writes tests that error on missing types/packages, then the lead shall instruct qa via SendMessage to write interface-based tests or use stub implementations so tests fail on assertions instead. +If implementor cannot make tests pass after 2 attempts, then the lead shall shutdown the implementor and spawn a fresh implementor agent with full context of the problem. +If reviewer suggests scope-expanding changes, then the lead shall reject them via SendMessage and request review within stated criteria only. +If any agent is unresponsive after 2 messages, then the lead shall shutdown that agent and spawn a replacement with the same role. + +## Completion + +When all phases complete, the lead shall assign final quality gates to **qa**. +When qa confirms all gates pass, the lead shall shutdown all agents via SendMessage type: "shutdown_request". +When all agents shut down, the lead shall clean up with TeamDelete. +The lead shall report: files changed, tests added, review outcome. diff --git a/.claude/skills/tdd-implement/references/agent-rules.md b/.claude/skills/tdd-implement/references/agent-rules.md new file mode 100644 index 0000000..2ec1827 --- /dev/null +++ b/.claude/skills/tdd-implement/references/agent-rules.md @@ -0,0 +1,14 @@ +# Agent Rules + +Include this block verbatim in every agent prompt. + +``` +MANDATORY RULES: +1. The agent shall write the smallest amount of code that satisfies requirements and passes tests. +2. The agent shall prefer simple solutions over clever ones — three similar lines are better than a premature abstraction. +3. The agent shall not add error handling, validation, configurability, or abstractions for scenarios not covered by requirements or tests. +4. The agent shall not add docstrings, comments, type annotations, or refactoring beyond what the task demands. +5. The agent shall treat requirements as the maximum scope and tests as the acceptance criteria — meet both, stop. +6. The agent shall not touch code unrelated to the current task — no drive-by refactors, no opportunistic cleanups. +7. The agent shall match the style and patterns already in the codebase — no new conventions. +``` diff --git a/.claude/skills/tdd-implement/references/implementor-prompt.md b/.claude/skills/tdd-implement/references/implementor-prompt.md new file mode 100644 index 0000000..424f162 --- /dev/null +++ b/.claude/skills/tdd-implement/references/implementor-prompt.md @@ -0,0 +1,35 @@ +# Implementor Agent Prompt + +Use as the task prompt for the **implementor** agent. Replace `{AGENT_RULES}` before sending. + +--- + +You are the Implementor agent. Tests have been written and are currently failing. Write the MINIMUM code to make all tests pass. + +{AGENT_RULES} + +TASK RULES: +The implementor shall read the failing tests first to understand expected behavior. +The implementor shall write only the code needed to pass the tests. +The implementor shall not add features, error handling, or abstractions not tested. +The implementor shall not refactor existing code unless a test requires it. +The implementor shall not add comments, docstrings, or type annotations beyond what exists. +The implementor shall write simple, direct code with no cleverness. +The implementor shall follow existing code patterns in the project. +The implementor shall use standard library only unless an external dependency is already approved in go.mod. + +STEPS: +1. Read the test files to understand expected behavior +2. Read existing source files that tests reference +3. Implement the minimum code to pass tests +4. Run `go test -v -race ./...` to confirm all tests pass +5. Run `gofmt -w .` to fix formatting +6. Run `go vet ./...` to check for issues +7. If tests fail, fix implementation until they pass +8. Mark your assigned task as completed using TaskUpdate +9. Send a message to team-lead using SendMessage (type: "message", recipient: "team-lead") with: files changed, test results, brief summary + +COMMUNICATION: +The implementor shall mark tasks completed via TaskUpdate when done. +The implementor shall send results to team-lead via SendMessage — plain text output is NOT visible to the team. +When the lead sends fix requests, the implementor shall fix, re-verify with the specified command, then message team-lead with confirmation. diff --git a/.claude/skills/tdd-implement/references/qa-prompt.md b/.claude/skills/tdd-implement/references/qa-prompt.md new file mode 100644 index 0000000..2ef4a62 --- /dev/null +++ b/.claude/skills/tdd-implement/references/qa-prompt.md @@ -0,0 +1,37 @@ +# QA Agent Prompt + +Use as the task prompt for the **qa** agent. Replace `{REQUIREMENTS}` and `{AGENT_RULES}` before sending. + +--- + +You are the QA agent. Write tests FIRST, before any implementation exists. + +{AGENT_RULES} + +TASK RULES: +The qa agent shall write tests that verify the requirements below. +The qa agent shall ensure tests fail when run (no implementation exists yet). +The qa agent shall write minimal, focused tests using Go's standard `testing` package. +The qa agent shall use existing test patterns from the project (read `*_test.go` files first). +The qa agent shall not write implementation code. +The qa agent shall not write helper utilities or test abstractions beyond what's needed. +The qa agent shall use Go's `testing` package and table-driven tests where appropriate. +The qa agent shall ensure tests fail on assertions, not on compilation errors — use interfaces or stub implementations for missing types if needed. +The qa agent shall use `t.Run` for subtests and descriptive test names (e.g., `TestSend_AmbiguousRecipient`). +The qa agent shall use `t.Helper()` in test helper functions. + +REQUIREMENTS: +{REQUIREMENTS} + +STEPS: +1. Read existing tests (`*_test.go`) to understand patterns and test helpers +2. Read existing source files to understand package structure +3. Write test file(s) for the requirements +4. Run `go test -v -race ./path/to/package/...` to confirm tests fail +5. Mark your assigned task as completed using TaskUpdate +6. Send a message to team-lead using SendMessage (type: "message", recipient: "team-lead") with: test file paths, summary of what each test covers, confirmation they fail + +COMMUNICATION: +The qa agent shall mark tasks completed via TaskUpdate when done. +The qa agent shall send results to team-lead via SendMessage — plain text output is NOT visible to the team. +When running quality gates (if assigned), the qa agent shall report results only — do NOT fix anything. Mark task completed if all pass, leave in_progress if failures. diff --git a/.claude/skills/tdd-implement/references/reviewer-prompt.md b/.claude/skills/tdd-implement/references/reviewer-prompt.md new file mode 100644 index 0000000..8fcec7d --- /dev/null +++ b/.claude/skills/tdd-implement/references/reviewer-prompt.md @@ -0,0 +1,39 @@ +# Reviewer Agent Prompt + +Use as the task prompt for the **reviewer** agent. Replace `{AGENT_RULES}` before sending. + +--- + +You are the Reviewer agent. Implementation is complete and tests pass. Review changes for maintainability and simplicity ONLY. + +{AGENT_RULES} + +REVIEW CRITERIA (priority order): +1. The reviewer shall verify the code does what the tests expect (correctness). +2. The reviewer shall flag unnecessary complexity that could be simpler. +3. The reviewer shall flag code that goes beyond what tests require (over-engineering). +4. The reviewer shall verify the code follows the project's established patterns (Go conventions, existing code style). +5. The reviewer shall flag edge cases that tests cover but implementation mishandles. +6. The reviewer shall verify `go vet` compliance and Go best practices are followed (error handling, naming, package structure). + +The reviewer shall not suggest adding features or error handling not covered by tests. +The reviewer shall not suggest refactoring for hypothetical future needs. +The reviewer shall not suggest adding documentation, comments, or type annotations. +The reviewer shall not suggest performance optimizations without evidence of a problem. +The reviewer shall not suggest "nice to have" improvements. + +STEPS: +1. Read the test files to understand requirements +2. Read the changed implementation files +3. Compare implementation against test expectations +4. Check for unnecessary complexity or over-engineering + +OUTPUT FORMAT: +- List specific issues with file:line references +- For each issue: what's wrong, exact fix (code if possible) +- Final verdict: **PASS** (no changes needed) or **REVISE** (list required changes only) + +COMMUNICATION: +The reviewer shall send review results to team-lead via SendMessage (type: "message", recipient: "team-lead") — plain text output is NOT visible to the team. +The reviewer shall NOT message implementor directly — the lead forwards issues. +The reviewer shall mark assigned task as completed via TaskUpdate when review is sent. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5dab98..655e87e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ jobs: - name: Setup Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: - go-version: '1.25.5' + go-version: '1.25.7' cache: true - name: Run tests @@ -80,7 +80,7 @@ jobs: - name: Setup Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: - go-version: '1.25.5' + go-version: '1.25.7' cache: true - name: Build binary diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e424e02..af6ff01 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,7 +28,7 @@ jobs: - name: Setup Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: - go-version: '1.25.5' + go-version: '1.25.7' cache: true # Enable Go module and build caching - name: Check formatting @@ -66,7 +66,7 @@ jobs: - name: Setup Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: - go-version: '1.25.5' + go-version: '1.25.7' cache: true - name: Run govulncheck diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index c5f2b7d..0f8e657 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -2,7 +2,7 @@ Sync Impact Report: 1.1.0 → 1.2.0 (MINOR) Modified: IV. Standard Library Preference - added approved external dependencies -Updated: Technology Constraints (Go 1.25.5), Quality Gates (govulncheck, gosec) +Updated: Technology Constraints (Go 1.25.7), Quality Gates (govulncheck, gosec) Templates: All compatible, no changes needed --> @@ -72,7 +72,7 @@ New dependencies require documented rationale in research.md with: ## Technology Constraints -- **Language**: Go 1.25.5 (minimum 1.21+ per IC-001) +- **Language**: Go 1.25.7 (minimum 1.21+ per IC-001) - **Storage**: JSONL files in `.agentmail/` directory (per-recipient files, state files) - **Platform**: macOS and Linux with tmux installed - **Build**: Standard `go build`, no CGO dependencies diff --git a/CLAUDE.md b/CLAUDE.md index 9f43a23..453e541 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,9 +56,9 @@ The following checks run in CI and must pass before merge: ## Testing in CI Environment -To run tests in a container matching CI (Go 1.25.5, Linux): +To run tests in a container matching CI (Go 1.25.7, Linux): ```bash -docker run --rm -v $(pwd):/app -w /app golang:1.25.5 go test -v -race ./... +docker run --rm -v $(pwd):/app -w /app golang:1.25.7 go test -v -race ./... ``` This helps catch issues that only manifest in the CI environment (e.g., running as root, different Go version). @@ -91,27 +91,29 @@ This checklist ensures documentation stays in sync with the codebase. ## Active Technologies - Go 1.21+ (per IC-001) + Standard library only (os/exec for tmux, encoding/json for JSONL) (001-agent-mail-structure) - JSONL file in `.agentmail/` directory (001-agent-mail-structure) -- Go 1.21+ (project uses Go 1.25.5) + GitHub Actions (yaml workflows), PaulHatch/semantic-version action for version calculation (002-github-ci-cd) +- Go 1.21+ (project uses Go 1.25.7) + GitHub Actions (yaml workflows), PaulHatch/semantic-version action for version calculation (002-github-ci-cd) - N/A (CI/CD configuration files only) (002-github-ci-cd) -- Go 1.21+ (per constitution IC-001, project uses Go 1.25.5) + Standard library only (os/exec, encoding/json, bufio, os) (003-recipients-help-stdin) +- Go 1.21+ (per constitution IC-001, project uses Go 1.25.7) + Standard library only (os/exec, encoding/json, bufio, os) (003-recipients-help-stdin) - JSONL files in `.agentmail/` directory (003-recipients-help-stdin) - Ruby (Homebrew formula DSL), YAML (GitHub Actions), Go 1.21+ (existing) + Homebrew (user-side), GitHub Actions, gh CLI (for cross-repo updates) (004-homebrew-distribution) - N/A (formula hosted in separate GitHub repo) (004-homebrew-distribution) -- Go 1.21+ (per constitution IC-001, project uses Go 1.25.5) + Standard library only (os, fmt, io - already used) (005-claude-hooks-integration) +- Go 1.21+ (per constitution IC-001, project uses Go 1.25.7) + Standard library only (os, fmt, io - already used) (005-claude-hooks-integration) - JSONL files in `.agentmail/` directory (005-claude-hooks-integration) -- Go 1.21+ (per constitution IC-001, project uses Go 1.25.5) + Standard library only (os/exec, encoding/json, syscall, time, os/signal) (006-mailman-daemon) +- Go 1.21+ (per constitution IC-001, project uses Go 1.25.7) + Standard library only (os/exec, encoding/json, syscall, time, os/signal) (006-mailman-daemon) - JSONL files - `.agentmail/mailman.pid` (PID), `.agentmail/recipients.jsonl` (state) (006-mailman-daemon) - Go 1.21+ (per IC-001) + Standard library only (os, filepath, syscall, encoding/json) (007-storage-restructure) - JSONL files in `.agentmail/` directory hierarchy (007-storage-restructure) - Go 1.21+ (per IC-001) + Standard library only (time, sync) (008-stale-agent-mailman) - JSONL files in `.agentmail/` (existing), in-memory tracker (new) (008-stale-agent-mailman) - Go 1.21+ (per constitution IC-001) + Standard library only (os, time, syscall) + fsnotify (external - requires justification) (009-watch-files) -- Go 1.25.5 (per go.mod, constitution requires 1.21+) + github.com/modelcontextprotocol/go-sdk (official MCP SDK) (010-mcp-server) +- Go 1.25.7 (per go.mod, constitution requires 1.21+) + github.com/modelcontextprotocol/go-sdk (official MCP SDK) (010-mcp-server) - JSONL files in `.agentmail/` directory (existing infrastructure), MCP server via STDIO transport (010-mcp-server) - Go 1.21+ (per constitution IC-001, project uses Go 1.25.3) + Standard library only (os/exec, encoding/json, syscall, time, os) (011-cleanup) - JSONL files in `.agentmail/` directory (recipients.jsonl, mailboxes/*.jsonl) (011-cleanup) -- Go 1.25.5 (minimum 1.21+ per IC-001) + Standard library only (os, os/exec, syscall, strconv, strings) (012-mailman-stop) +- Go 1.25.7 (minimum 1.21+ per IC-001) + Standard library only (os, os/exec, syscall, strconv, strings) (012-mailman-stop) - `.agentmail/mailman.pid` (existing PID file from 006-mailman-daemon) (012-mailman-stop) +- Go 1.25.7 (minimum 1.21+ per constitution IC-001) + Standard library only (`os/exec`, `encoding/json`, `strings`, `strconv`, `fmt`, `regexp`). Existing approved deps: `fsnotify`, `go-sdk` (MCP), `ff/v3` (CLI flags) (tmux-pane-addressing) +- JSONL files in `.agentmail/` directory — mailbox files renamed from `.jsonl` to `.jsonl` (tmux-pane-addressing) ## Recent Changes - 001-agent-mail-structure: Added Go 1.21+ (per IC-001) + Standard library only (os/exec for tmux, encoding/json for JSONL) diff --git a/README.md b/README.md index d219286..9b3e55b 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ A Go CLI tool for inter-agent communication within tmux sessions. Agents running ## Requirements -- Go 1.25.5 or later +- Go 1.25.7 or later - tmux (must be running inside a tmux session) - Linux or macOS @@ -94,7 +94,7 @@ agentmail receive ### send -Send a message to another agent (tmux window). +Send a message to another agent (tmux pane). ```bash agentmail send [flags] [] [] @@ -102,29 +102,49 @@ agentmail send [flags] [] [] **Arguments (positional or flags):** -- `` - Target tmux window name (required) +- `` - Target pane address (required). Accepts three forms: + - **Full**: `session:window.pane` (e.g., `AgentMail:editor.1`) + - **Medium**: `:window.pane` (e.g., `:editor.1`) — session inferred from current + - **Short**: `window` (e.g., `editor`) — backward compatible, resolves if single pane - `` - Message content (optional if using stdin) **Flags:** -- `-r, --recipient ` - Recipient tmux window name +- `-r, --recipient
` - Recipient pane address - `-m, --message ` - Message content Flags take precedence over positional arguments. +**Pane Addressing:** + +When using the short form (window name only), AgentMail resolves it to a specific pane: +- If the window has **one pane**: message is sent to that pane +- If the window has **multiple panes**: returns an ambiguity error listing all pane addresses + +Example ambiguity error: +``` +Ambiguous recipient: window 'editor' has 3 panes. Use AgentMail:editor.0, AgentMail:editor.1 or AgentMail:editor.2 +``` + **Examples:** ```bash # Send with positional arguments agentmail send agent-2 "Task completed successfully" +# Send to specific pane (full address) +agentmail send AgentMail:editor.1 "Review this code" + +# Send to specific pane (medium address) +agentmail send :editor.1 "Update from pane 0" + # Send with flags (equivalent) agentmail send -r agent-2 -m "Task completed successfully" -agentmail send --recipient agent-2 --message "Task completed" +agentmail send --recipient AgentMail:editor.0 --message "Task completed" # Send via stdin echo "Results from analysis" | agentmail send agent-2 -echo "Results" | agentmail send -r agent-2 +echo "Results" | agentmail send -r :editor.1 # Send multi-line content cat report.txt | agentmail send agent-2 @@ -148,6 +168,13 @@ agentmail receive [--hook] - `--hook` - Enable hook mode for Claude Code integration (see [Claude Code Hooks](#claude-code-hooks-manual-setup)) +**Pane-specific mailboxes:** + +Each tmux pane has its own mailbox. Messages are received per-pane, meaning: +- Pane `AgentMail:editor.0` has a separate mailbox from `AgentMail:editor.1` +- Messages sent to `:editor.0` are only visible when receiving from that specific pane +- This enables pane isolation for multi-pane workflows + **Output format (normal mode):** ```text @@ -172,12 +199,14 @@ Returns "No unread messages" if the mailbox is empty. ### recipients -List all available recipients (tmux windows in the current session). +List all available recipients (tmux panes in the current session). ```bash agentmail recipients ``` +Displays all pane addresses in the session in the format `session:window.pane`. The current pane is marked with `[you]`. + **Example output:** ```text @@ -721,10 +750,10 @@ gosec ./... ### Testing in CI Environment -To match the CI environment (Go 1.25.5, Linux): +To match the CI environment (Go 1.25.7, Linux): ```bash -docker run --rm -v $(pwd):/app -w /app golang:1.25.5 go test -v -race ./... +docker run --rm -v $(pwd):/app -w /app golang:1.25.7 go test -v -race ./... ``` ### CI/CD Pipeline diff --git a/go.mod b/go.mod index 3954b2d..06c5eba 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module agentmail -go 1.25.5 +go 1.25.7 require ( github.com/fsnotify/fsnotify v1.9.0 diff --git a/internal/cli/cleanup.go b/internal/cli/cleanup.go index 7dfc75e..3e2a627 100644 --- a/internal/cli/cleanup.go +++ b/internal/cli/cleanup.go @@ -44,7 +44,7 @@ type CleanupOptions struct { RepoRoot string // Repository root (defaults to "." if empty) SkipTmuxCheck bool // Skip real tmux check (for testing) MockInTmux bool // Mocked value for InTmux() when SkipTmuxCheck is true - MockWindows []string // Mock list of tmux windows (for testing, nil means use real tmux) + MockPanes []string // Mock list of tmux panes (for testing, nil means use real tmux) } // CleanupResult holds the counts from a cleanup operation @@ -91,25 +91,25 @@ func Cleanup(stdout, stderr io.Writer, opts CleanupOptions) int { // Phase 1: Clean offline recipients (US1) if inTmux { - // Get list of valid tmux windows - var windows []string + // Get list of valid tmux panes + var panes []string if isMocking { - windows = opts.MockWindows + panes = opts.MockPanes } else { var err error - windows, err = tmux.ListWindows() + panes, err = tmux.ListPanes() if err != nil { - fmt.Fprintf(stderr, "Warning: failed to list tmux windows: %v\n", err) + fmt.Fprintf(stderr, "Warning: failed to list tmux panes: %v\n", err) // Continue without offline cleanup - windows = nil + panes = nil } } - // Clean or count offline recipients if we have a window list - if windows != nil { + // Clean or count offline recipients if we have a pane list + if panes != nil { if opts.DryRun { // Dry-run mode: just count - count, err := mail.CountOfflineRecipients(repoRoot, windows) + count, err := mail.CountOfflineRecipients(repoRoot, panes) if err != nil { fmt.Fprintf(stderr, "Error counting offline recipients: %v\n", err) return 1 @@ -118,7 +118,7 @@ func Cleanup(stdout, stderr io.Writer, opts CleanupOptions) int { result.RecipientsRemoved += count } else { // Normal mode: actually remove - removed, err := mail.CleanOfflineRecipients(repoRoot, windows) + removed, err := mail.CleanOfflineRecipients(repoRoot, panes) if err != nil { fmt.Fprintf(stderr, "Error cleaning offline recipients: %v\n", err) return 1 diff --git a/internal/cli/cleanup_test.go b/internal/cli/cleanup_test.go index de1954a..8622bec 100644 --- a/internal/cli/cleanup_test.go +++ b/internal/cli/cleanup_test.go @@ -46,7 +46,7 @@ func TestCleanup_OfflineRecipientRemoval(t *testing.T) { RepoRoot: tmpDir, SkipTmuxCheck: true, MockInTmux: true, - MockWindows: []string{"agent-1"}, // Only agent-1 exists as tmux window + MockPanes: []string{"agent-1"}, // Only agent-1 exists as tmux window }) if exitCode != 0 { @@ -103,7 +103,7 @@ func TestCleanup_RetainsExistingWindowRecipients(t *testing.T) { RepoRoot: tmpDir, SkipTmuxCheck: true, MockInTmux: true, - MockWindows: []string{"agent-1", "agent-2", "agent-3"}, // All exist + MockPanes: []string{"agent-1", "agent-2", "agent-3"}, // All exist }) if exitCode != 0 { @@ -145,7 +145,7 @@ func TestCleanup_EmptyOrMissingRecipients(t *testing.T) { RepoRoot: tmpDir, SkipTmuxCheck: true, MockInTmux: true, - MockWindows: []string{"agent-1"}, + MockPanes: []string{"agent-1"}, }) if exitCode != 0 { @@ -178,7 +178,7 @@ func TestCleanup_EmptyOrMissingRecipients(t *testing.T) { RepoRoot: tmpDir, SkipTmuxCheck: true, MockInTmux: true, - MockWindows: []string{"agent-1"}, + MockPanes: []string{"agent-1"}, }) if exitCode != 0 { @@ -221,7 +221,7 @@ func TestCleanup_NonTmuxEnvironmentSkipsOfflineCheck(t *testing.T) { RepoRoot: tmpDir, SkipTmuxCheck: true, // Skip real tmux check MockInTmux: false, // Not in tmux - MockWindows: nil, // No windows (not used when not in tmux) + MockPanes: nil, // No windows (not used when not in tmux) }) if exitCode != 0 { @@ -282,7 +282,7 @@ func TestCleanup_OfflineRemovedCount(t *testing.T) { RepoRoot: tmpDir, SkipTmuxCheck: true, MockInTmux: true, - MockWindows: []string{"agent-1", "agent-3"}, // Only 2 exist + MockPanes: []string{"agent-1", "agent-3"}, // Only 2 exist }) if exitCode != 0 { @@ -521,7 +521,7 @@ func TestCleanup_StaleRecipientRemoval(t *testing.T) { RepoRoot: tmpDir, SkipTmuxCheck: true, MockInTmux: false, // Skip offline check to isolate stale test - MockWindows: nil, + MockPanes: nil, }) if exitCode != 0 { @@ -576,7 +576,7 @@ func TestCleanup_RetainsRecentRecipients(t *testing.T) { RepoRoot: tmpDir, SkipTmuxCheck: true, MockInTmux: false, // Skip offline check to isolate stale test - MockWindows: nil, + MockPanes: nil, }) if exitCode != 0 { @@ -626,7 +626,7 @@ func TestCleanup_CustomStaleHours(t *testing.T) { RepoRoot: tmpDir, SkipTmuxCheck: true, MockInTmux: false, // Skip offline check to isolate stale test - MockWindows: nil, + MockPanes: nil, }) if exitCode != 0 { @@ -686,7 +686,7 @@ func TestCleanup_OfflineAndStaleRemoval(t *testing.T) { RepoRoot: tmpDir, SkipTmuxCheck: true, MockInTmux: true, - MockWindows: []string{"agent-1", "agent-3"}, // Only agent-1 and agent-3 have windows + MockPanes: []string{"agent-1", "agent-3"}, // Only agent-1 and agent-3 have windows }) if exitCode != 0 { @@ -751,7 +751,7 @@ func TestCleanup_OldReadMessagesRemoved(t *testing.T) { RepoRoot: tmpDir, SkipTmuxCheck: true, MockInTmux: false, // Skip offline check to isolate message test - MockWindows: nil, + MockPanes: nil, }) if exitCode != 0 { @@ -822,7 +822,7 @@ func TestCleanup_UnreadMessagesNeverRemoved(t *testing.T) { RepoRoot: tmpDir, SkipTmuxCheck: true, MockInTmux: false, - MockWindows: nil, + MockPanes: nil, }) if exitCode != 0 { @@ -872,7 +872,7 @@ func TestCleanup_RecentReadMessagesRetained(t *testing.T) { RepoRoot: tmpDir, SkipTmuxCheck: true, MockInTmux: false, - MockWindows: nil, + MockPanes: nil, }) if exitCode != 0 { @@ -921,7 +921,7 @@ func TestCleanup_CustomDeliveredHours(t *testing.T) { RepoRoot: tmpDir, SkipTmuxCheck: true, MockInTmux: false, - MockWindows: nil, + MockPanes: nil, }) if exitCode != 0 { @@ -945,7 +945,7 @@ func TestCleanup_CustomDeliveredHours(t *testing.T) { RepoRoot: tmpDir, SkipTmuxCheck: true, MockInTmux: false, - MockWindows: nil, + MockPanes: nil, }) if exitCode != 0 { @@ -996,7 +996,7 @@ func TestCleanup_MessagesWithoutCreatedAtDeleted(t *testing.T) { RepoRoot: tmpDir, SkipTmuxCheck: true, MockInTmux: false, - MockWindows: nil, + MockPanes: nil, }) if exitCode != 0 { @@ -1068,7 +1068,7 @@ func TestCleanup_EmptyMailboxRemoved(t *testing.T) { RepoRoot: tmpDir, SkipTmuxCheck: true, MockInTmux: false, - MockWindows: nil, + MockPanes: nil, }) if exitCode != 0 { @@ -1117,7 +1117,7 @@ func TestCleanup_NonEmptyMailboxRetained(t *testing.T) { RepoRoot: tmpDir, SkipTmuxCheck: true, MockInTmux: false, - MockWindows: nil, + MockPanes: nil, }) if exitCode != 0 { @@ -1153,7 +1153,7 @@ func TestCleanup_NoMailboxesDirectory(t *testing.T) { RepoRoot: tmpDir, SkipTmuxCheck: true, MockInTmux: false, - MockWindows: nil, + MockPanes: nil, }) if exitCode != 0 { @@ -1192,7 +1192,7 @@ func TestCleanup_MailboxEmptiedByMessageCleanupIsRemoved(t *testing.T) { RepoRoot: tmpDir, SkipTmuxCheck: true, MockInTmux: false, - MockWindows: nil, + MockPanes: nil, }) if exitCode != 0 { @@ -1343,7 +1343,7 @@ func TestCleanup_OutputSummary(t *testing.T) { RepoRoot: tmpDir, SkipTmuxCheck: true, MockInTmux: true, - MockWindows: []string{"agent-1", "agent-3"}, // agent-2 doesn't have window + MockPanes: []string{"agent-1", "agent-3"}, // agent-2 doesn't have window }) if exitCode != 0 { @@ -1438,7 +1438,7 @@ func TestCleanup_DryRunMode(t *testing.T) { RepoRoot: tmpDir, SkipTmuxCheck: true, MockInTmux: true, - MockWindows: []string{"agent-1", "agent-3"}, // agent-2 doesn't have window + MockPanes: []string{"agent-1", "agent-3"}, // agent-2 doesn't have window }) if exitCode != 0 { @@ -1525,7 +1525,7 @@ func TestCleanup_WarningOnSkippedFiles(t *testing.T) { RepoRoot: tmpDir, SkipTmuxCheck: true, MockInTmux: false, - MockWindows: nil, + MockPanes: nil, }) if exitCode != 0 { @@ -1543,3 +1543,177 @@ func TestCleanup_WarningOnSkippedFiles(t *testing.T) { // The expected format when files are skipped is: // "Warning: Skipped N locked file(s)" } + +func TestCleanup_DryRunModeAllPhases(t *testing.T) { + tmpDir := t.TempDir() + + // Create .agentmail directory + agentmailDir := filepath.Join(tmpDir, ".agentmail") + if err := os.MkdirAll(agentmailDir, 0755); err != nil { + t.Fatalf("Failed to create .agentmail dir: %v", err) + } + + // Create mailboxes directory + mailboxDir := filepath.Join(agentmailDir, "mailboxes") + if err := os.MkdirAll(mailboxDir, 0755); err != nil { + t.Fatalf("Failed to create mailboxes dir: %v", err) + } + + now := time.Now() + oldTime := now.Add(-3 * time.Hour) + + // Create old read messages + messages := []mail.Message{ + {ID: "msg001", From: "sender", To: "mysession:agent.0", Message: "old read", ReadFlag: true, CreatedAt: oldTime}, + } + if err := mail.WriteAll(tmpDir, "mysession:agent.0", messages); err != nil { + t.Fatalf("WriteAll failed: %v", err) + } + + // Create empty mailbox + emptyFile := filepath.Join(mailboxDir, "mysession%3Aempty%2E0.jsonl") + if err := os.WriteFile(emptyFile, []byte(""), 0644); err != nil { + t.Fatalf("Failed to create empty mailbox: %v", err) + } + + // Create stale recipient + if err := mail.UpdateRecipientState(tmpDir, "mysession:stale.0", mail.StatusWork, false); err != nil { + t.Fatalf("Failed to create stale recipient: %v", err) + } + // Manually update timestamp to be old + recipients, _ := mail.ReadAllRecipients(tmpDir) + for i := range recipients { + if recipients[i].Recipient == "mysession:stale.0" { + recipients[i].UpdatedAt = now.Add(-72 * time.Hour) // 3 days old + } + } + mail.WriteAllRecipients(tmpDir, recipients) + + var stdout, stderr bytes.Buffer + exitCode := Cleanup( + &stdout, + &stderr, + CleanupOptions{ + DryRun: true, // Dry run mode + SkipTmuxCheck: true, + MockInTmux: true, + MockPanes: []string{"mysession:agent.0"}, // stale.0 and empty.0 not in list + RepoRoot: tmpDir, + StaleHours: 48, + DeliveredHours: 2, + }, + ) + + if exitCode != 0 { + t.Errorf("Expected exit code 0, got %d. Stderr: %s", exitCode, stderr.String()) + } + + stdoutStr := stdout.String() + + // Verify dry run indicated + if !strings.Contains(stdoutStr, "dry-run") { + t.Errorf("Expected 'dry-run' indication, got: %s", stdoutStr) + } + + // Verify counts are shown but nothing deleted + readMessages, _ := mail.ReadAll(tmpDir, "mysession:agent.0") + if len(readMessages) != 1 { + t.Errorf("Dry run should not delete messages, got %d messages", len(readMessages)) + } + + // Empty mailbox should still exist + if _, err := os.Stat(emptyFile); os.IsNotExist(err) { + t.Error("Dry run should not delete empty mailbox") + } + + // Stale recipient should still exist + recipients, _ = mail.ReadAllRecipients(tmpDir) + found := false + for _, r := range recipients { + if r.Recipient == "mysession:stale.0" { + found = true + } + } + if !found { + t.Error("Dry run should not delete stale recipient") + } +} + +func TestCleanup_NoRecipientsFile(t *testing.T) { + tmpDir := t.TempDir() + + // Create .agentmail directory but no recipients.jsonl + agentmailDir := filepath.Join(tmpDir, ".agentmail") + if err := os.MkdirAll(agentmailDir, 0755); err != nil { + t.Fatalf("Failed to create .agentmail dir: %v", err) + } + + var stdout, stderr bytes.Buffer + exitCode := Cleanup( + &stdout, + &stderr, + CleanupOptions{ + SkipTmuxCheck: true, + MockInTmux: true, + MockPanes: []string{}, + RepoRoot: tmpDir, + StaleHours: 48, + DeliveredHours: 2, + }, + ) + + if exitCode != 0 { + t.Errorf("Expected exit code 0 with no recipients file, got %d. Stderr: %s", exitCode, stderr.String()) + } + + // Should complete without error + if !strings.Contains(stdout.String(), "Cleanup complete") { + t.Errorf("Expected 'Cleanup complete', got: %s", stdout.String()) + } +} + +func TestCleanup_CustomStaleThreshold(t *testing.T) { + tmpDir := t.TempDir() + + // Create .agentmail directory + agentmailDir := filepath.Join(tmpDir, ".agentmail") + if err := os.MkdirAll(agentmailDir, 0755); err != nil { + t.Fatalf("Failed to create .agentmail dir: %v", err) + } + + now := time.Now() + + // Create recipient that's 25 hours old (stale with 24h threshold, fresh with 48h) + if err := mail.UpdateRecipientState(tmpDir, "mysession:agent.0", mail.StatusWork, false); err != nil { + t.Fatalf("Failed to create recipient: %v", err) + } + recipients, _ := mail.ReadAllRecipients(tmpDir) + for i := range recipients { + recipients[i].UpdatedAt = now.Add(-25 * time.Hour) + } + mail.WriteAllRecipients(tmpDir, recipients) + + var stdout, stderr bytes.Buffer + exitCode := Cleanup( + &stdout, + &stderr, + CleanupOptions{ + SkipTmuxCheck: true, + MockInTmux: true, + MockPanes: []string{"mysession:agent.0"}, // Agent exists in tmux + RepoRoot: tmpDir, + StaleHours: 24, // Custom threshold + DeliveredHours: 2, + }, + ) + + if exitCode != 0 { + t.Errorf("Expected exit code 0, got %d. Stderr: %s", exitCode, stderr.String()) + } + + // With 24h threshold, the 25h old recipient should be removed + recipients, _ = mail.ReadAllRecipients(tmpDir) + if len(recipients) != 0 { + t.Errorf("Expected stale recipient to be removed with 24h threshold, got %d recipients", len(recipients)) + } +} diff --git a/internal/cli/receive.go b/internal/cli/receive.go index 4e50c7d..0d849d8 100644 --- a/internal/cli/receive.go +++ b/internal/cli/receive.go @@ -12,11 +12,12 @@ import ( // ReceiveOptions configures the Receive command behavior. // Used for testing to mock tmux and file system operations. type ReceiveOptions struct { - SkipTmuxCheck bool // Skip tmux environment check - MockWindows []string // Mock list of tmux windows - MockReceiver string // Mock receiver window name - RepoRoot string // Repository root (defaults to current directory) - HookMode bool // Enable hook mode for Claude Code integration + SkipTmuxCheck bool // Skip tmux environment check + MockWindows []string // Mock list of tmux windows (deprecated) + MockReceiver string // Mock receiver window name (deprecated, use MockPaneAddress) + MockPaneAddress string // Mock receiver pane address + RepoRoot string // Repository root (defaults to current directory) + HookMode bool // Enable hook mode for Claude Code integration } // Receive implements the agentmail receive command. @@ -44,54 +45,44 @@ func Receive(stdout, stderr io.Writer, opts ReceiveOptions) int { } } - // Get receiver identity + // Get receiver identity (pane address) var receiver string - if opts.MockReceiver != "" { + if opts.MockPaneAddress != "" { + receiver = opts.MockPaneAddress + } else if opts.MockReceiver != "" { + // Backward compatibility for old tests receiver = opts.MockReceiver - } else { - var err error - receiver, err = tmux.GetCurrentWindow() - if err != nil { - // FR-004a: Hook mode exits silently on errors - if opts.HookMode { - return 0 + // Validate receiver exists if using old MockWindows API + if opts.MockWindows != nil { + receiverExists := false + for _, w := range opts.MockWindows { + if w == receiver { + receiverExists = true + break + } } - fmt.Fprintf(stderr, "error: failed to get current window: %v\n", err) - return 1 - } - } - - // Validate current window exists in tmux session - var receiverExists bool - if opts.MockWindows != nil { - for _, w := range opts.MockWindows { - if w == receiver { - receiverExists = true - break + if !receiverExists { + // FR-004a: Hook mode exits silently on errors + if opts.HookMode { + return 0 + } + fmt.Fprintf(stderr, "error: current window '%s' not found in tmux session\n", receiver) + return 1 } } } else { var err error - receiverExists, err = tmux.WindowExists(receiver) + receiver, err = tmux.GetCurrentPaneAddress() if err != nil { // FR-004a: Hook mode exits silently on errors if opts.HookMode { return 0 } - fmt.Fprintf(stderr, "error: failed to check window: %v\n", err) + fmt.Fprintf(stderr, "error: failed to get current pane: %v\n", err) return 1 } } - if !receiverExists { - // FR-004a: Hook mode exits silently on errors - if opts.HookMode { - return 0 - } - fmt.Fprintf(stderr, "error: current window '%s' not found in tmux session\n", receiver) - return 1 - } - // Determine repository root (find git root, not current directory) repoRoot := opts.RepoRoot if repoRoot == "" { diff --git a/internal/cli/receive_test.go b/internal/cli/receive_test.go index 190c810..565dbc3 100644 --- a/internal/cli/receive_test.go +++ b/internal/cli/receive_test.go @@ -6,6 +6,9 @@ import ( "path/filepath" "strings" "testing" + "time" + + "agentmail/internal/mail" ) // T028: Tests for receive command no-messages case @@ -488,3 +491,195 @@ func TestReceiveCommand_NormalMode_Unchanged(t *testing.T) { t.Errorf("Normal mode should not have hook prefix") } } + +// T008: Tests for pane address support in receive + +func TestReceiveCommand_PaneSpecificMailbox(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agentmail-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create message for specific pane + mailDir := filepath.Join(tmpDir, ".agentmail", "mailboxes") + if err := os.MkdirAll(mailDir, 0755); err != nil { + t.Fatalf("Failed to create mail dir: %v", err) + } + + content := `{"id":"testID1","from":"mysession:sender.0","to":"mysession:editor.1","message":"Hello pane 1","read_flag":false} +` + // Use sanitized filename + mailboxFile := filepath.Join(mailDir, "mysession%3Aeditor%2E1.jsonl") + if err := os.WriteFile(mailboxFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write mailbox file: %v", err) + } + + var stdout, stderr bytes.Buffer + exitCode := Receive(&stdout, &stderr, ReceiveOptions{ + SkipTmuxCheck: true, + MockPaneAddress: "mysession:editor.1", + RepoRoot: tmpDir, + }) + + if exitCode != 0 { + t.Errorf("Expected exit code 0, got %d. Stderr: %s", exitCode, stderr.String()) + } + + if !strings.Contains(stdout.String(), "Hello pane 1") { + t.Errorf("Expected message content, got: %s", stdout.String()) + } +} + +// T012: Hook mode pane isolation test + +func TestReceiveCommand_HookMode_PaneIsolation(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agentmail-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + mailDir := filepath.Join(tmpDir, ".agentmail", "mailboxes") + if err := os.MkdirAll(mailDir, 0755); err != nil { + t.Fatalf("Failed to create mail dir: %v", err) + } + + // Create message for pane 0 (sibling pane) + pane0Content := `{"id":"msg0","from":"mysession:sender.0","to":"mysession:editor.0","message":"Message for pane 0","read_flag":false} +` + pane0File := filepath.Join(mailDir, "mysession%3Aeditor%2E0.jsonl") + if err := os.WriteFile(pane0File, []byte(pane0Content), 0644); err != nil { + t.Fatalf("Failed to write pane 0 mailbox: %v", err) + } + + // Receive from pane 1 (different pane in same window) + var stdout, stderr bytes.Buffer + exitCode := Receive(&stdout, &stderr, ReceiveOptions{ + SkipTmuxCheck: true, + MockPaneAddress: "mysession:editor.1", + RepoRoot: tmpDir, + HookMode: true, + }) + + // Should exit 0 with no output (no messages for pane 1) + if exitCode != 0 { + t.Errorf("Expected exit code 0 (no messages for this pane), got %d", exitCode) + } + + if stdout.String() != "" { + t.Errorf("Expected no stdout output, got: %s", stdout.String()) + } + + if stderr.String() != "" { + t.Errorf("Expected no stderr output (pane isolation), got: %s", stderr.String()) + } +} + +func TestReceiveCommand_PaneIsolation_NoMessages(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agentmail-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + mailDir := filepath.Join(tmpDir, ".agentmail", "mailboxes") + if err := os.MkdirAll(mailDir, 0755); err != nil { + t.Fatalf("Failed to create mail dir: %v", err) + } + + // Create message for a different pane in the same window + otherPaneContent := `{"id":"msg1","from":"mysession:sender.0","to":"mysession:editor.0","message":"For pane 0","read_flag":false} +` + otherPaneFile := filepath.Join(mailDir, "mysession%3Aeditor%2E0.jsonl") + if err := os.WriteFile(otherPaneFile, []byte(otherPaneContent), 0644); err != nil { + t.Fatalf("Failed to write mailbox: %v", err) + } + + // Try to receive from pane 1 + var stdout, stderr bytes.Buffer + exitCode := Receive(&stdout, &stderr, ReceiveOptions{ + SkipTmuxCheck: true, + MockPaneAddress: "mysession:editor.1", + RepoRoot: tmpDir, + }) + + if exitCode != 0 { + t.Errorf("Expected exit code 0, got %d", exitCode) + } + + if !strings.Contains(stdout.String(), "No unread messages") { + t.Errorf("Expected 'No unread messages', got: %s", stdout.String()) + } +} + +func TestReceiveCommand_NoRepoRoot(t *testing.T) { + // Change to a temp directory without .git so FindGitRoot() fails + tmpDir := t.TempDir() + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current dir: %v", err) + } + defer os.Chdir(origDir) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to chdir: %v", err) + } + + var stdout, stderr bytes.Buffer + exitCode := Receive( + &stdout, + &stderr, + ReceiveOptions{ + SkipTmuxCheck: true, + MockPaneAddress: "mysession:receiver.0", + RepoRoot: "", // Force it to search for git root + }, + ) + + if exitCode != 1 { + t.Errorf("Expected exit code 1 for no repo root, got %d", exitCode) + } + + if !strings.Contains(stderr.String(), "not in a git repository") { + t.Errorf("Expected 'not in a git repository' error, got: %s", stderr.String()) + } +} + +func TestReceiveCommand_MultipleUnreadFIFO(t *testing.T) { + tmpDir := t.TempDir() + + // Create mailbox with multiple unread messages + messages := []mail.Message{ + {ID: "first123", From: "mysession:sender.0", To: "mysession:receiver.0", Message: "First message", ReadFlag: false, CreatedAt: time.Now().Add(-3 * time.Hour)}, + {ID: "second12", From: "mysession:sender.0", To: "mysession:receiver.0", Message: "Second message", ReadFlag: false, CreatedAt: time.Now().Add(-2 * time.Hour)}, + {ID: "third123", From: "mysession:sender.0", To: "mysession:receiver.0", Message: "Third message", ReadFlag: false, CreatedAt: time.Now().Add(-1 * time.Hour)}, + } + + if err := mail.WriteAll(tmpDir, "mysession:receiver.0", messages); err != nil { + t.Fatalf("Failed to create test messages: %v", err) + } + + var stdout, stderr bytes.Buffer + exitCode := Receive( + &stdout, + &stderr, + ReceiveOptions{ + SkipTmuxCheck: true, + MockPaneAddress: "mysession:receiver.0", + RepoRoot: tmpDir, + }, + ) + + if exitCode != 0 { + t.Errorf("Expected exit code 0, got %d. Stderr: %s", exitCode, stderr.String()) + } + + // Should receive oldest message first (FIFO) + if !strings.Contains(stdout.String(), "First message") { + t.Errorf("Expected 'First message' (oldest), got: %s", stdout.String()) + } + + if strings.Contains(stdout.String(), "Second message") || strings.Contains(stdout.String(), "Third message") { + t.Errorf("Should only receive oldest message, got: %s", stdout.String()) + } +} diff --git a/internal/cli/recipients.go b/internal/cli/recipients.go index e54ad93..63a9d53 100644 --- a/internal/cli/recipients.go +++ b/internal/cli/recipients.go @@ -10,15 +10,16 @@ import ( // RecipientsOptions configures the Recipients command behavior. type RecipientsOptions struct { - SkipTmuxCheck bool // Skip tmux environment check - MockWindows []string // Mock list of tmux windows - MockCurrent string // Mock current window name - MockIgnoreList map[string]bool // Mock ignore list (nil = load from file) - MockGitRoot string // Mock git root (for testing) + SkipTmuxCheck bool // Skip tmux environment check + MockPanes []string // Mock list of tmux panes + MockPaneAddress string // Mock current pane address + MockSession string // Mock session name + MockIgnoreList map[string]bool // Mock ignore list (nil = load from file) + MockGitRoot string // Mock git root (for testing) } // Recipients implements the agentmail recipients command. -// It lists all tmux windows with the current window marked "[you]". +// It lists all tmux panes with the current pane marked "[you]". func Recipients(stdout, stderr io.Writer, opts RecipientsOptions) int { // Validate running inside tmux if !opts.SkipTmuxCheck { @@ -28,33 +29,51 @@ func Recipients(stdout, stderr io.Writer, opts RecipientsOptions) int { } } - // Get list of windows - var windows []string - if opts.MockWindows != nil { - windows = opts.MockWindows + // Get list of panes + var panes []string + if opts.MockPanes != nil { + panes = opts.MockPanes } else { var err error - windows, err = tmux.ListWindows() + panes, err = tmux.ListPanes() if err != nil { - fmt.Fprintf(stderr, "error: failed to list windows: %v\n", err) + fmt.Fprintf(stderr, "error: failed to list panes: %v\n", err) return 1 } } - // Get current window - // In mock mode (MockWindows is set), use MockCurrent even if empty - var currentWindow string - if opts.MockWindows != nil { - currentWindow = opts.MockCurrent + // Get current pane address + var currentPane string + if opts.MockPaneAddress != "" { + currentPane = opts.MockPaneAddress } else { var err error - currentWindow, err = tmux.GetCurrentWindow() + currentPane, err = tmux.GetCurrentPaneAddress() if err != nil { - fmt.Fprintf(stderr, "error: failed to get current window: %v\n", err) + fmt.Fprintf(stderr, "error: failed to get current pane address: %v\n", err) return 1 } } + // Get current session for ignore matching + var currentSession string + if opts.MockSession != "" { + currentSession = opts.MockSession + } else { + // Try to extract session from current pane address + addr, err := tmux.ParseAddress(currentPane, "") + if err == nil { + currentSession = addr.Session + } else { + var sessionErr error + currentSession, sessionErr = tmux.GetCurrentSession() + if sessionErr != nil { + fmt.Fprintf(stderr, "error: failed to get current session: %v\n", sessionErr) + return 1 + } + } + } + // Load ignore list var ignoreList map[string]bool if opts.MockIgnoreList != nil { @@ -74,14 +93,14 @@ func Recipients(stdout, stderr io.Writer, opts RecipientsOptions) int { } } - // Output windows with current marked, filtering ignored windows - for _, window := range windows { - // Current window is always shown (per FR-004), even if in ignore list - if window == currentWindow { - fmt.Fprintf(stdout, "%s [you]\n", window) - } else if ignoreList == nil || !ignoreList[window] { - // Only show non-current windows if they're not in the ignore list - fmt.Fprintf(stdout, "%s\n", window) + // Output panes with current marked, filtering ignored panes + for _, pane := range panes { + // Current pane is always shown (per FR-004), even if in ignore list + if pane == currentPane { + fmt.Fprintf(stdout, "%s [you]\n", pane) + } else if !mail.IsIgnored(pane, ignoreList, currentSession) { + // Only show non-current panes if they're not ignored + fmt.Fprintf(stdout, "%s\n", pane) } } diff --git a/internal/cli/recipients_test.go b/internal/cli/recipients_test.go index 6c629ab..6483082 100644 --- a/internal/cli/recipients_test.go +++ b/internal/cli/recipients_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "os" + "path/filepath" "strings" "testing" ) @@ -29,9 +30,9 @@ func TestRecipientsCommand_ListsAllWindows(t *testing.T) { var stdout, stderr bytes.Buffer exitCode := Recipients(&stdout, &stderr, RecipientsOptions{ - SkipTmuxCheck: true, - MockWindows: []string{"main", "agent1", "agent2", "worker"}, - MockCurrent: "main", + SkipTmuxCheck: true, + MockPanes: []string{"main", "agent1", "agent2", "worker"}, + MockPaneAddress: "main", }) if exitCode != 0 { @@ -71,9 +72,9 @@ func TestRecipientsCommand_MarksCurrentWindow(t *testing.T) { var stdout, stderr bytes.Buffer exitCode := Recipients(&stdout, &stderr, RecipientsOptions{ - SkipTmuxCheck: true, - MockWindows: []string{"main", "agent1", "agent2"}, - MockCurrent: "agent1", + SkipTmuxCheck: true, + MockPanes: []string{"main", "agent1", "agent2"}, + MockPaneAddress: "agent1", }) if exitCode != 0 { @@ -127,9 +128,9 @@ func TestRecipientsCommand_EmptyWindowList(t *testing.T) { var stdout, stderr bytes.Buffer exitCode := Recipients(&stdout, &stderr, RecipientsOptions{ - SkipTmuxCheck: true, - MockWindows: []string{}, - MockCurrent: "", + SkipTmuxCheck: true, + MockPanes: []string{}, + MockPaneAddress: "AgentMail:test.0", }) if exitCode != 0 { @@ -151,9 +152,9 @@ func TestRecipientsCommand_SingleWindow(t *testing.T) { var stdout, stderr bytes.Buffer exitCode := Recipients(&stdout, &stderr, RecipientsOptions{ - SkipTmuxCheck: true, - MockWindows: []string{"main"}, - MockCurrent: "main", + SkipTmuxCheck: true, + MockPanes: []string{"main"}, + MockPaneAddress: "main", }) if exitCode != 0 { @@ -175,9 +176,9 @@ func TestRecipientsCommand_CurrentWindowNotInList(t *testing.T) { var stdout, stderr bytes.Buffer exitCode := Recipients(&stdout, &stderr, RecipientsOptions{ - SkipTmuxCheck: true, - MockWindows: []string{"agent1", "agent2"}, - MockCurrent: "orphan", + SkipTmuxCheck: true, + MockPanes: []string{"agent1", "agent2"}, + MockPaneAddress: "orphan", }) if exitCode != 0 { @@ -205,9 +206,9 @@ func TestRecipientsCommand_OutputFormat(t *testing.T) { var stdout, stderr bytes.Buffer exitCode := Recipients(&stdout, &stderr, RecipientsOptions{ - SkipTmuxCheck: true, - MockWindows: []string{"main", "agent1"}, - MockCurrent: "main", + SkipTmuxCheck: true, + MockPanes: []string{"main", "agent1"}, + MockPaneAddress: "main", }) if exitCode != 0 { @@ -236,10 +237,10 @@ func TestRecipientsCommand_ExcludesIgnoredWindows(t *testing.T) { var stdout, stderr bytes.Buffer exitCode := Recipients(&stdout, &stderr, RecipientsOptions{ - SkipTmuxCheck: true, - MockWindows: []string{"main", "agent1", "agent2", "worker"}, - MockCurrent: "main", - MockIgnoreList: map[string]bool{"agent1": true, "worker": true}, + SkipTmuxCheck: true, + MockPanes: []string{"main", "agent1", "agent2", "worker"}, + MockPaneAddress: "main", + MockIgnoreList: map[string]bool{"agent1": true, "worker": true}, }) if exitCode != 0 { @@ -279,10 +280,10 @@ func TestRecipientsCommand_HandlesMissingIgnoreFile(t *testing.T) { tempDir := t.TempDir() exitCode := Recipients(&stdout, &stderr, RecipientsOptions{ - SkipTmuxCheck: true, - MockWindows: []string{"main", "agent1", "agent2"}, - MockCurrent: "main", - MockGitRoot: tempDir, // Directory without .agentmailignore + SkipTmuxCheck: true, + MockPanes: []string{"main", "agent1", "agent2"}, + MockPaneAddress: "main", + MockGitRoot: tempDir, // Directory without .agentmailignore }) if exitCode != 0 { @@ -321,10 +322,10 @@ func TestRecipientsCommand_IgnoresEmptyLinesInIgnoreFile(t *testing.T) { } exitCode := Recipients(&stdout, &stderr, RecipientsOptions{ - SkipTmuxCheck: true, - MockWindows: []string{"main", "agent1", "agent2", "worker"}, - MockCurrent: "main", - MockGitRoot: tempDir, + SkipTmuxCheck: true, + MockPanes: []string{"main", "agent1", "agent2", "worker"}, + MockPaneAddress: "main", + MockGitRoot: tempDir, }) if exitCode != 0 { @@ -372,10 +373,10 @@ func TestRecipientsCommand_HandlesUnreadableIgnoreFile(t *testing.T) { defer os.Chmod(ignorePath, 0o644) exitCode := Recipients(&stdout, &stderr, RecipientsOptions{ - SkipTmuxCheck: true, - MockWindows: []string{"main", "agent1", "agent2"}, - MockCurrent: "main", - MockGitRoot: tempDir, + SkipTmuxCheck: true, + MockPanes: []string{"main", "agent1", "agent2"}, + MockPaneAddress: "main", + MockGitRoot: tempDir, }) if exitCode != 0 { @@ -401,10 +402,10 @@ func TestRecipientsCommand_CurrentWindowShownEvenIfIgnored(t *testing.T) { var stdout, stderr bytes.Buffer exitCode := Recipients(&stdout, &stderr, RecipientsOptions{ - SkipTmuxCheck: true, - MockWindows: []string{"main", "agent1", "agent2"}, - MockCurrent: "agent1", // Current window is in ignore list - MockIgnoreList: map[string]bool{"agent1": true, "agent2": true}, + SkipTmuxCheck: true, + MockPanes: []string{"main", "agent1", "agent2"}, + MockPaneAddress: "agent1", // Current window is in ignore list + MockIgnoreList: map[string]bool{"agent1": true, "agent2": true}, }) if exitCode != 0 { @@ -434,3 +435,105 @@ func TestRecipientsCommand_CurrentWindowShownEvenIfIgnored(t *testing.T) { t.Errorf("Expected 2 lines (main and agent1 [you]), got %d: %v", len(lines), lines) } } + +func TestRecipientsCommand_EmptyPaneList(t *testing.T) { + var stdout, stderr bytes.Buffer + exitCode := Recipients( + &stdout, + &stderr, + RecipientsOptions{ + SkipTmuxCheck: true, + MockPanes: []string{}, // No panes + MockPaneAddress: "mysession:agent.0", + MockSession: "mysession", + }, + ) + + if exitCode != 0 { + t.Errorf("Expected exit code 0, got %d. Stderr: %s", exitCode, stderr.String()) + } + + output := stdout.String() + // With an empty pane list, the loop has nothing to iterate over, + // so no output is produced (the current pane only appears if it's in the pane list) + if output != "" { + t.Errorf("Expected empty output with no panes, got: %s", output) + } +} + +func TestRecipientsCommand_NoIgnoreList(t *testing.T) { + var stdout, stderr bytes.Buffer + exitCode := Recipients( + &stdout, + &stderr, + RecipientsOptions{ + SkipTmuxCheck: true, + MockPanes: []string{"mysession:agent1.0", "mysession:agent2.0", "mysession:agent3.0"}, + MockPaneAddress: "mysession:agent1.0", + MockSession: "mysession", + MockIgnoreList: nil, // No ignore list + }, + ) + + if exitCode != 0 { + t.Errorf("Expected exit code 0, got %d. Stderr: %s", exitCode, stderr.String()) + } + + output := stdout.String() + + // All panes should be shown + if !strings.Contains(output, "agent2") { + t.Errorf("Expected agent2 in output, got: %s", output) + } + if !strings.Contains(output, "agent3") { + t.Errorf("Expected agent3 in output, got: %s", output) + } +} + +func TestRecipientsCommand_IgnoreListFromFile(t *testing.T) { + tmpDir := t.TempDir() + + // Create .agentmailignore file + ignoreContent := `# Test ignore file +mysession:agent2.0 +:agent3.0 +` + gitRoot := tmpDir + ignorePath := filepath.Join(gitRoot, ".agentmailignore") + if err := os.WriteFile(ignorePath, []byte(ignoreContent), 0644); err != nil { + t.Fatalf("Failed to create ignore file: %v", err) + } + + var stdout, stderr bytes.Buffer + exitCode := Recipients( + &stdout, + &stderr, + RecipientsOptions{ + SkipTmuxCheck: true, + MockPanes: []string{"mysession:agent1.0", "mysession:agent2.0", "mysession:agent3.0"}, + MockPaneAddress: "mysession:agent1.0", + MockSession: "mysession", + MockGitRoot: gitRoot, + MockIgnoreList: nil, // Load from file + }, + ) + + if exitCode != 0 { + t.Errorf("Expected exit code 0, got %d. Stderr: %s", exitCode, stderr.String()) + } + + output := stdout.String() + + // agent2 and agent3 should be ignored + if strings.Contains(output, "agent2") { + t.Errorf("agent2 should be ignored, got: %s", output) + } + if strings.Contains(output, "agent3") { + t.Errorf("agent3 should be ignored, got: %s", output) + } + + // agent1 (current) should still be shown + if !strings.Contains(output, "agent1") { + t.Errorf("Current agent should be shown, got: %s", output) + } +} diff --git a/internal/cli/send.go b/internal/cli/send.go index aaf7463..ffc9aa3 100644 --- a/internal/cli/send.go +++ b/internal/cli/send.go @@ -9,17 +9,39 @@ import ( "agentmail/internal/tmux" ) +// formatPaneList formats a list of pane addresses for error messages. +func formatPaneList(panes []string) string { + if len(panes) == 0 { + return "" + } + if len(panes) == 1 { + return panes[0] + } + if len(panes) == 2 { + return panes[0] + ", " + panes[1] + } + // For 3+ panes, use commas with "or" before the last one + parts := make([]string, len(panes)) + for i := 0; i < len(panes)-1; i++ { + parts[i] = panes[i] + } + return strings.Join(parts[:len(panes)-1], ", ") + " or " + panes[len(panes)-1] +} + // SendOptions configures the Send command behavior. // Used for testing to mock tmux and file system operations. type SendOptions struct { - SkipTmuxCheck bool // Skip tmux environment check - MockWindows []string // Mock list of tmux windows - MockSender string // Mock sender window name - RepoRoot string // Repository root (defaults to current directory) - MockIgnoreList map[string]bool // Mock ignore list (nil = load from file) - MockGitRoot string // Mock git root (for testing) - StdinContent string // Mock stdin content (empty = no stdin) - StdinIsPipe bool // Mock whether stdin is a pipe + SkipTmuxCheck bool // Skip tmux environment check + MockWindows []string // Mock list of tmux windows (deprecated, use MockPanes) + MockPanes []string // Mock list of full pane addresses + MockSender string // Mock sender window name (deprecated, use MockPaneAddress) + MockPaneAddress string // Mock sender pane address + MockSession string // Mock current session for address resolution + RepoRoot string // Repository root (defaults to current directory) + MockIgnoreList map[string]bool // Mock ignore list (nil = load from file) + MockGitRoot string // Mock git root (for testing) + StdinContent string // Mock stdin content (empty = no stdin) + StdinIsPipe bool // Mock whether stdin is a pipe } // Send implements the agentmail send command. @@ -85,44 +107,113 @@ func Send(args []string, stdin io.Reader, stdout, stderr io.Writer, opts SendOpt return 1 } - // Get sender identity + // Get sender identity (pane address) var sender string - if opts.MockSender != "" { + if opts.MockPaneAddress != "" { + sender = opts.MockPaneAddress + } else if opts.MockSender != "" { + // Backward compatibility for old tests sender = opts.MockSender } else { var err error - sender, err = tmux.GetCurrentWindow() + sender, err = tmux.GetCurrentPaneAddress() if err != nil { - fmt.Fprintf(stderr, "error: failed to get current window: %v\n", err) + fmt.Fprintf(stderr, "error: failed to get current pane: %v\n", err) return 1 } } - // T022: Validate recipient exists - var recipientExists bool - if opts.MockWindows != nil { - for _, w := range opts.MockWindows { - if w == recipient { - recipientExists = true - break - } + // Get current session for address resolution + var currentSession string + if opts.MockSession != "" { + currentSession = opts.MockSession + } else if opts.SkipTmuxCheck { + // In tests without mock session, extract from sender + addr, err := tmux.ParseAddress(sender, "") + if err == nil { + currentSession = addr.Session } } else { var err error - recipientExists, err = tmux.WindowExists(recipient) + currentSession, err = tmux.GetCurrentSession() if err != nil { - fmt.Fprintf(stderr, "error: failed to check recipient: %v\n", err) + fmt.Fprintf(stderr, "error: failed to get current session: %v\n", err) return 1 } } - if !recipientExists { + // Resolve recipient address + addr, err := tmux.ParseAddress(recipient, currentSession) + if err != nil { fmt.Fprintln(stderr, "error: recipient not found") return 1 } - // T029: Check if recipient is the sender (self-send not allowed) - if recipient == sender { + var resolvedRecipient string + if addr.Pane == -1 { + // Short form - need to resolve window name to pane address + var panes []string + if opts.MockPanes != nil { + panes = opts.MockPanes + } else if opts.MockWindows != nil { + // Backward compatibility for old tests + panes = opts.MockWindows + } else { + panes, err = tmux.ListPanes() + if err != nil { + fmt.Fprintf(stderr, "error: failed to list panes: %v\n", err) + return 1 + } + } + + // Find matching panes + var matches []string + for _, pane := range panes { + paneAddr, err := tmux.ParseAddress(pane, currentSession) + if err == nil && paneAddr.Window == addr.Window { + matches = append(matches, pane) + } + } + + if len(matches) == 0 { + fmt.Fprintln(stderr, "error: recipient not found") + return 1 + } else if len(matches) > 1 { + fmt.Fprintf(stderr, "Ambiguous recipient: window '%s' has %d panes. Use %s\n", + addr.Window, len(matches), formatPaneList(matches)) + return 1 + } + resolvedRecipient = matches[0] + } else { + // Full or medium form - validate pane exists + resolvedRecipient = tmux.FormatAddress(addr) + var paneExists bool + if opts.MockPanes != nil { + for _, p := range opts.MockPanes { + if p == resolvedRecipient { + paneExists = true + break + } + } + } else if opts.MockWindows != nil { + // Backward compatibility + paneExists = true + } else { + paneExists, err = tmux.PaneExists(resolvedRecipient) + if err != nil { + fmt.Fprintf(stderr, "error: failed to check recipient: %v\n", err) + return 1 + } + } + + if !paneExists { + fmt.Fprintln(stderr, "error: recipient not found") + return 1 + } + } + + // Check if recipient is the sender (self-send not allowed) + if resolvedRecipient == sender { fmt.Fprintln(stderr, "error: recipient not found") return 1 } @@ -146,8 +237,8 @@ func Send(args []string, stdin io.Reader, stdout, stderr io.Writer, opts SendOpt } } - // T030: Check if recipient is in ignore list - if ignoreList != nil && ignoreList[recipient] { + // Check if recipient is in ignore list + if mail.IsIgnored(resolvedRecipient, ignoreList, currentSession) { fmt.Fprintln(stderr, "error: recipient not found") return 1 } @@ -169,11 +260,11 @@ func Send(args []string, stdin io.Reader, stdout, stderr io.Writer, opts SendOpt } } - // T023: Store message + // Store message msg := mail.Message{ ID: id, From: sender, - To: recipient, + To: resolvedRecipient, Message: message, ReadFlag: false, } diff --git a/internal/cli/send_test.go b/internal/cli/send_test.go index c1d1a46..5116f14 100644 --- a/internal/cli/send_test.go +++ b/internal/cli/send_test.go @@ -550,3 +550,290 @@ func TestSendCommand_NoMessageProvided_Regression(t *testing.T) { t.Errorf("FR-011 Regression: Expected empty stdout, got: %s", stdout.String()) } } + +// T007: Tests for pane address support + +func TestSendCommand_FullPaneAddress(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agentmail-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + var stdout, stderr bytes.Buffer + exitCode := Send([]string{"mysession:editor.1", "Hello from pane"}, nil, &stdout, &stderr, SendOptions{ + SkipTmuxCheck: true, + MockPaneAddress: "mysession:sender.0", + MockSession: "mysession", + MockPanes: []string{"mysession:sender.0", "mysession:editor.1"}, + RepoRoot: tmpDir, + }) + + if exitCode != 0 { + t.Errorf("Expected exit code 0, got %d. Stderr: %s", exitCode, stderr.String()) + } + + if !strings.Contains(stdout.String(), "Message #") { + t.Errorf("Expected message confirmation, got: %s", stdout.String()) + } +} + +func TestSendCommand_MediumPaneAddress(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agentmail-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + var stdout, stderr bytes.Buffer + exitCode := Send([]string{":editor.1", "Hello"}, nil, &stdout, &stderr, SendOptions{ + SkipTmuxCheck: true, + MockPaneAddress: "mysession:sender.0", + MockSession: "mysession", + MockPanes: []string{"mysession:sender.0", "mysession:editor.1"}, + RepoRoot: tmpDir, + }) + + if exitCode != 0 { + t.Errorf("Expected exit code 0, got %d. Stderr: %s", exitCode, stderr.String()) + } +} + +func TestSendCommand_ShortAddressSinglePane(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agentmail-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + var stdout, stderr bytes.Buffer + exitCode := Send([]string{"editor", "Hello"}, nil, &stdout, &stderr, SendOptions{ + SkipTmuxCheck: true, + MockPaneAddress: "mysession:sender.0", + MockSession: "mysession", + MockPanes: []string{"mysession:sender.0", "mysession:editor.0"}, + RepoRoot: tmpDir, + }) + + if exitCode != 0 { + t.Errorf("Expected exit code 0, got %d. Stderr: %s", exitCode, stderr.String()) + } +} + +func TestSendCommand_ShortAddressMultiPaneAmbiguity(t *testing.T) { + var stdout, stderr bytes.Buffer + exitCode := Send([]string{"editor", "Hello"}, nil, &stdout, &stderr, SendOptions{ + SkipTmuxCheck: true, + MockPaneAddress: "mysession:sender.0", + MockSession: "mysession", + MockPanes: []string{"mysession:sender.0", "mysession:editor.0", "mysession:editor.1"}, + }) + + if exitCode != 1 { + t.Errorf("Expected exit code 1 for ambiguous recipient, got %d", exitCode) + } + + stderrStr := stderr.String() + if !strings.Contains(stderrStr, "Ambiguous recipient: window 'editor' has 2 panes") { + t.Errorf("Expected ambiguity error, got: %s", stderrStr) + } + + if !strings.Contains(stderrStr, "mysession:editor.0") || !strings.Contains(stderrStr, "mysession:editor.1") { + t.Errorf("Expected pane addresses in error message, got: %s", stderrStr) + } +} + +func TestSendCommand_DottedWindowNameShortForm(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agentmail-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + var stdout, stderr bytes.Buffer + exitCode := Send([]string{"my.app", "Hello"}, nil, &stdout, &stderr, SendOptions{ + SkipTmuxCheck: true, + MockPaneAddress: "mysession:sender.0", + MockSession: "mysession", + MockPanes: []string{"mysession:sender.0", "mysession:my.app.0"}, + RepoRoot: tmpDir, + }) + + if exitCode != 0 { + t.Errorf("Expected exit code 0, got %d. Stderr: %s", exitCode, stderr.String()) + } +} + +func TestSendCommand_SelfSendRejectionWithPanes(t *testing.T) { + var stdout, stderr bytes.Buffer + exitCode := Send([]string{"mysession:sender.0", "Hello"}, nil, &stdout, &stderr, SendOptions{ + SkipTmuxCheck: true, + MockPaneAddress: "mysession:sender.0", + MockSession: "mysession", + MockPanes: []string{"mysession:sender.0"}, + }) + + if exitCode != 1 { + t.Errorf("Expected exit code 1 for self-send, got %d", exitCode) + } + + if !strings.Contains(stderr.String(), "error: recipient not found") { + t.Errorf("Expected 'recipient not found' error, got: %s", stderr.String()) + } +} + +func TestSendCommand_IgnoreListWithPaneAddresses(t *testing.T) { + var stdout, stderr bytes.Buffer + exitCode := Send([]string{"mysession:editor.1", "Hello"}, nil, &stdout, &stderr, SendOptions{ + SkipTmuxCheck: true, + MockPaneAddress: "mysession:sender.0", + MockSession: "mysession", + MockPanes: []string{"mysession:sender.0", "mysession:editor.1"}, + MockIgnoreList: map[string]bool{"mysession:editor.1": true}, + }) + + if exitCode != 1 { + t.Errorf("Expected exit code 1 for ignored recipient, got %d", exitCode) + } + + if !strings.Contains(stderr.String(), "error: recipient not found") { + t.Errorf("Expected 'recipient not found' error, got: %s", stderr.String()) + } +} + +// T009: Backward compatibility tests + +func TestSendCommand_BackwardCompat_SinglePaneWindow(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agentmail-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + var stdout, stderr bytes.Buffer + exitCode := Send([]string{"editor", "Hello"}, nil, &stdout, &stderr, SendOptions{ + SkipTmuxCheck: true, + MockPaneAddress: "mysession:sender.0", + MockSession: "mysession", + MockPanes: []string{"mysession:sender.0", "mysession:editor.0"}, + RepoRoot: tmpDir, + }) + + if exitCode != 0 { + t.Errorf("Backward compat: single-pane window should resolve, got exit code %d. Stderr: %s", exitCode, stderr.String()) + } +} + +func TestSendCommand_BackwardCompat_MultiPaneAmbiguityFormat(t *testing.T) { + var stdout, stderr bytes.Buffer + exitCode := Send([]string{"logs", "Hello"}, nil, &stdout, &stderr, SendOptions{ + SkipTmuxCheck: true, + MockPaneAddress: "mysession:sender.0", + MockSession: "mysession", + MockPanes: []string{"mysession:sender.0", "mysession:logs.0", "mysession:logs.1", "mysession:logs.2"}, + }) + + if exitCode != 1 { + t.Errorf("Expected exit code 1 for ambiguous recipient, got %d", exitCode) + } + + stderrStr := stderr.String() + expectedFormat := "Ambiguous recipient: window 'logs' has 3 panes" + if !strings.Contains(stderrStr, expectedFormat) { + t.Errorf("Expected error message containing %q, got: %s", expectedFormat, stderrStr) + } + + // Verify all pane addresses are listed + for _, pane := range []string{"mysession:logs.0", "mysession:logs.1", "mysession:logs.2"} { + if !strings.Contains(stderrStr, pane) { + t.Errorf("Expected pane address %s in error message, got: %s", pane, stderrStr) + } + } +} + +func TestSendCommand_BackwardCompat_DottedWindowName(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agentmail-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + var stdout, stderr bytes.Buffer + exitCode := Send([]string{"logs.1", "Test message"}, nil, &stdout, &stderr, SendOptions{ + SkipTmuxCheck: true, + MockPaneAddress: "mysession:sender.0", + MockSession: "mysession", + MockPanes: []string{"mysession:sender.0", "mysession:logs.1.0"}, + RepoRoot: tmpDir, + }) + + if exitCode != 0 { + t.Errorf("Dotted window name 'logs.1' should be treated as short form, got exit code %d. Stderr: %s", exitCode, stderr.String()) + } +} + +func TestSendCommand_StdinPipeEmptyContent(t *testing.T) { + tmpDir := t.TempDir() + + var stdout, stderr bytes.Buffer + exitCode := Send( + []string{"mysession:receiver.0"}, + nil, // No stdin + &stdout, + &stderr, + SendOptions{ + SkipTmuxCheck: true, + MockPaneAddress: "mysession:sender.0", + MockSession: "mysession", + MockPanes: []string{"mysession:sender.0", "mysession:receiver.0"}, + RepoRoot: tmpDir, + StdinContent: "", // Empty stdin + StdinIsPipe: true, + }, + ) + + if exitCode != 1 { + t.Errorf("Expected exit code 1 for empty stdin, got %d", exitCode) + } + + if !strings.Contains(stderr.String(), "no message provided") { + t.Errorf("Expected 'no message provided' error, got: %s", stderr.String()) + } +} + +func TestSendCommand_GitRootNotFound(t *testing.T) { + // Change to a temp directory without .git so FindGitRoot() fails + tmpDir := t.TempDir() + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current dir: %v", err) + } + defer os.Chdir(origDir) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to chdir: %v", err) + } + + var stdout, stderr bytes.Buffer + exitCode := Send( + []string{"mysession:receiver.0", "test message"}, + nil, + &stdout, + &stderr, + SendOptions{ + SkipTmuxCheck: true, + MockPaneAddress: "mysession:sender.0", + MockSession: "mysession", + MockPanes: []string{"mysession:sender.0", "mysession:receiver.0"}, + RepoRoot: "", // Force it to search for git root + MockGitRoot: "", // No git root + }, + ) + + if exitCode != 1 { + t.Errorf("Expected exit code 1 for no git root, got %d", exitCode) + } + + if !strings.Contains(stderr.String(), "not in a git repository") { + t.Errorf("Expected 'not in a git repository' error, got: %s", stderr.String()) + } +} diff --git a/internal/cli/status.go b/internal/cli/status.go index 04aa622..89dfd59 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -11,9 +11,9 @@ import ( // StatusOptions configures the Status command behavior. // Used for testing to mock tmux and file system operations. type StatusOptions struct { - SkipTmuxCheck bool // Skip tmux environment check - MockWindow string // Mock current window name - RepoRoot string // Repository root (defaults to finding git root) + SkipTmuxCheck bool // Skip tmux environment check + MockPaneAddress string // Mock current pane address + RepoRoot string // Repository root (defaults to finding git root) } // ValidateStatus checks if the provided status is a valid status value. @@ -78,15 +78,15 @@ func Status(args []string, stdout, stderr io.Writer, opts StatusOptions) int { return 1 } - // Get current window name - var window string - if opts.MockWindow != "" { - window = opts.MockWindow + // Get current pane address + var paneAddress string + if opts.MockPaneAddress != "" { + paneAddress = opts.MockPaneAddress } else { var err error - window, err = tmux.GetCurrentWindow() + paneAddress, err = tmux.GetCurrentPaneAddress() if err != nil { - fmt.Fprintf(stderr, "error: failed to get current window: %v\n", err) + fmt.Fprintf(stderr, "error: failed to get current pane address: %v\n", err) return 1 } } @@ -107,7 +107,7 @@ func Status(args []string, stdout, stderr io.Writer, opts StatusOptions) int { resetNotified := (status == mail.StatusWork || status == mail.StatusOffline) // Update recipient state using existing infrastructure - if err := mail.UpdateRecipientState(repoRoot, window, status, resetNotified); err != nil { + if err := mail.UpdateRecipientState(repoRoot, paneAddress, status, resetNotified); err != nil { fmt.Fprintf(stderr, "error: failed to update status: %v\n", err) return 1 } diff --git a/internal/cli/status_test.go b/internal/cli/status_test.go index b3b37ca..449520b 100644 --- a/internal/cli/status_test.go +++ b/internal/cli/status_test.go @@ -28,9 +28,9 @@ func TestStatusCommand_Ready(t *testing.T) { var stdout, stderr bytes.Buffer exitCode := Status([]string{"ready"}, &stdout, &stderr, StatusOptions{ - SkipTmuxCheck: true, - MockWindow: "agent-1", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-1", + RepoRoot: tmpDir, }) if exitCode != 0 { @@ -80,9 +80,9 @@ func TestStatusCommand_Work(t *testing.T) { var stdout, stderr bytes.Buffer exitCode := Status([]string{"work"}, &stdout, &stderr, StatusOptions{ - SkipTmuxCheck: true, - MockWindow: "agent-1", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-1", + RepoRoot: tmpDir, }) if exitCode != 0 { @@ -132,9 +132,9 @@ func TestStatusCommand_Offline(t *testing.T) { var stdout, stderr bytes.Buffer exitCode := Status([]string{"offline"}, &stdout, &stderr, StatusOptions{ - SkipTmuxCheck: true, - MockWindow: "agent-1", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-1", + RepoRoot: tmpDir, }) if exitCode != 0 { @@ -209,9 +209,9 @@ func TestStatusCommand_InvalidStatus(t *testing.T) { var stdout, stderr bytes.Buffer exitCode := Status([]string{"foo"}, &stdout, &stderr, StatusOptions{ - SkipTmuxCheck: true, - MockWindow: "agent-1", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-1", + RepoRoot: tmpDir, }) if exitCode != 1 { @@ -287,9 +287,9 @@ func TestStatusCommand_NotifiedResetOnWorkOffline(t *testing.T) { // Transition to work - should reset notified to false exitCode := Status([]string{"work"}, &stdout, &stderr, StatusOptions{ - SkipTmuxCheck: true, - MockWindow: "agent-1", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-1", + RepoRoot: tmpDir, }) if exitCode != 0 { @@ -344,9 +344,9 @@ func TestStatusCommand_NotifiedResetOnOffline(t *testing.T) { // Transition to offline - should reset notified to false exitCode := Status([]string{"offline"}, &stdout, &stderr, StatusOptions{ - SkipTmuxCheck: true, - MockWindow: "agent-1", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-1", + RepoRoot: tmpDir, }) if exitCode != 0 { @@ -398,9 +398,9 @@ func TestStatusCommand_NotifiedPreservedOnReady(t *testing.T) { // Transition to ready - should NOT reset notified exitCode := Status([]string{"ready"}, &stdout, &stderr, StatusOptions{ - SkipTmuxCheck: true, - MockWindow: "agent-1", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-1", + RepoRoot: tmpDir, }) if exitCode != 0 { @@ -431,8 +431,8 @@ func TestStatusCommand_MissingArgument(t *testing.T) { var stdout, stderr bytes.Buffer exitCode := Status([]string{}, &stdout, &stderr, StatusOptions{ - SkipTmuxCheck: true, - MockWindow: "agent-1", + SkipTmuxCheck: true, + MockPaneAddress: "agent-1", }) if exitCode != 1 { @@ -465,9 +465,9 @@ func TestStatusCommand_Integration(t *testing.T) { // 1. Set ready exitCode := Status([]string{"ready"}, &stdout, &stderr, StatusOptions{ - SkipTmuxCheck: true, - MockWindow: "agent-1", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-1", + RepoRoot: tmpDir, }) if exitCode != 0 { t.Errorf("Step 1 (ready): Expected exit code 0, got %d", exitCode) @@ -482,9 +482,9 @@ func TestStatusCommand_Integration(t *testing.T) { stdout.Reset() stderr.Reset() exitCode = Status([]string{"work"}, &stdout, &stderr, StatusOptions{ - SkipTmuxCheck: true, - MockWindow: "agent-1", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-1", + RepoRoot: tmpDir, }) if exitCode != 0 { t.Errorf("Step 2 (work): Expected exit code 0, got %d", exitCode) @@ -499,9 +499,9 @@ func TestStatusCommand_Integration(t *testing.T) { stdout.Reset() stderr.Reset() exitCode = Status([]string{"ready"}, &stdout, &stderr, StatusOptions{ - SkipTmuxCheck: true, - MockWindow: "agent-1", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-1", + RepoRoot: tmpDir, }) if exitCode != 0 { t.Errorf("Step 3 (ready): Expected exit code 0, got %d", exitCode) @@ -516,9 +516,9 @@ func TestStatusCommand_Integration(t *testing.T) { stdout.Reset() stderr.Reset() exitCode = Status([]string{"offline"}, &stdout, &stderr, StatusOptions{ - SkipTmuxCheck: true, - MockWindow: "agent-1", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-1", + RepoRoot: tmpDir, }) if exitCode != 0 { t.Errorf("Step 4 (offline): Expected exit code 0, got %d", exitCode) @@ -560,9 +560,9 @@ func TestStatusCommand_MultipleAgents(t *testing.T) { // Add agent-1 status exitCode := Status([]string{"work"}, &stdout, &stderr, StatusOptions{ - SkipTmuxCheck: true, - MockWindow: "agent-1", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-1", + RepoRoot: tmpDir, }) if exitCode != 0 { @@ -601,3 +601,105 @@ func TestStatusCommand_MultipleAgents(t *testing.T) { t.Errorf("Expected agent-2 status 'ready' (unchanged), got %s", agent2.Status) } } + +func TestStatusCommand_MissingStatusArg(t *testing.T) { + tmpDir := t.TempDir() + + var stdout, stderr bytes.Buffer + exitCode := Status( + []string{}, // No status argument + &stdout, + &stderr, + StatusOptions{ + SkipTmuxCheck: true, + MockPaneAddress: "mysession:agent.0", + RepoRoot: tmpDir, + }, + ) + + if exitCode != 1 { + t.Errorf("Expected exit code 1 for missing status arg, got %d", exitCode) + } + + if !strings.Contains(stderr.String(), "missing required argument") { + t.Errorf("Expected 'missing required argument' error, got: %s", stderr.String()) + } +} + +func TestStatusCommand_AllValidStatuses(t *testing.T) { + tmpDir := t.TempDir() + + testCases := []struct { + status string + expected string + }{ + {"ready", mail.StatusReady}, + {"work", mail.StatusWork}, + {"offline", mail.StatusOffline}, + } + + for _, tc := range testCases { + t.Run(tc.status, func(t *testing.T) { + var stdout, stderr bytes.Buffer + exitCode := Status( + []string{tc.status}, + &stdout, + &stderr, + StatusOptions{ + SkipTmuxCheck: true, + MockPaneAddress: "mysession:agent.0", + RepoRoot: tmpDir, + }, + ) + + if exitCode != 0 { + t.Errorf("Expected exit code 0 for status '%s', got %d. Stderr: %s", tc.status, exitCode, stderr.String()) + } + + // Verify status was set + recipients, err := mail.ReadAllRecipients(tmpDir) + if err != nil { + t.Fatalf("Failed to read recipients: %v", err) + } + + found := false + for _, r := range recipients { + if r.Recipient == "mysession:agent.0" { + found = true + if r.Status != tc.expected { + t.Errorf("Expected status '%s', got '%s'", tc.expected, r.Status) + } + } + } + + if !found { + t.Error("Recipient not found after status update") + } + }) + } +} + +func TestStatusCommand_NonTmuxSilentExit(t *testing.T) { + tmpDir := t.TempDir() + + var stdout, stderr bytes.Buffer + exitCode := Status( + []string{"ready"}, + &stdout, + &stderr, + StatusOptions{ + SkipTmuxCheck: false, // Use real tmux check + RepoRoot: tmpDir, + }, + ) + + // Should exit silently with code 0 when not in tmux (status is a no-op outside tmux) + if exitCode != 0 { + t.Errorf("Expected exit code 0 when not in tmux, got %d", exitCode) + } + + // Should have no error output + if stderr.String() != "" { + t.Errorf("Expected empty stderr, got: %s", stderr.String()) + } +} diff --git a/internal/daemon/loop.go b/internal/daemon/loop.go index 833162d..cefb4b9 100644 --- a/internal/daemon/loop.go +++ b/internal/daemon/loop.go @@ -22,7 +22,7 @@ const StatelessNotifyInterval = 60 * time.Second // It uses in-memory storage that resets on daemon restart. type StatelessTracker struct { mu sync.Mutex // Protects concurrent access - lastNotified map[string]time.Time // Window name → last notification time + lastNotified map[string]time.Time // Pane address → last notification time notifyInterval time.Duration // Minimum interval between notifications } @@ -34,13 +34,13 @@ func NewStatelessTracker(interval time.Duration) *StatelessTracker { } } -// ShouldNotify returns true if the window is eligible for notification (T011). -// Returns true if: (a) window not in tracker, or (b) interval elapsed since last notification. -func (t *StatelessTracker) ShouldNotify(window string) bool { +// ShouldNotify returns true if the pane is eligible for notification (T011). +// Returns true if: (a) pane not in tracker, or (b) interval elapsed since last notification. +func (t *StatelessTracker) ShouldNotify(pane string) bool { t.mu.Lock() defer t.mu.Unlock() - lastTime, exists := t.lastNotified[window] + lastTime, exists := t.lastNotified[pane] if !exists { return true } @@ -48,29 +48,29 @@ func (t *StatelessTracker) ShouldNotify(window string) bool { return time.Since(lastTime) >= t.notifyInterval } -// MarkNotified records that a notification was sent to the window (T012). -func (t *StatelessTracker) MarkNotified(window string) { +// MarkNotified records that a notification was sent to the pane (T012). +func (t *StatelessTracker) MarkNotified(pane string) { t.mu.Lock() defer t.mu.Unlock() - t.lastNotified[window] = time.Now() + t.lastNotified[pane] = time.Now() } -// Cleanup removes entries for windows that are no longer active (T013). -func (t *StatelessTracker) Cleanup(activeWindows []string) { +// Cleanup removes entries for panes that are no longer active (T013). +func (t *StatelessTracker) Cleanup(activePanes []string) { t.mu.Lock() defer t.mu.Unlock() - // Build a set of active windows for O(1) lookup - activeSet := make(map[string]struct{}, len(activeWindows)) - for _, w := range activeWindows { + // Build a set of active panes for O(1) lookup + activeSet := make(map[string]struct{}, len(activePanes)) + for _, w := range activePanes { activeSet[w] = struct{}{} } // Remove entries not in the active set - for window := range t.lastNotified { - if _, exists := activeSet[window]; !exists { - delete(t.lastNotified, window) + for pane := range t.lastNotified { + if _, exists := activeSet[pane]; !exists { + delete(t.lastNotified, pane) } } } @@ -93,17 +93,17 @@ func (opts *LoopOptions) log(format string, args ...interface{}) { // NotifyFunc is the function signature for notifying an agent. type NotifyFunc func(window string) error -// WindowCheckerFunc is the function signature for checking if a window exists. -type WindowCheckerFunc func(window string) (bool, error) +// PaneCheckerFunc is the function signature for checking if a window exists. +type PaneCheckerFunc func(window string) (bool, error) // NotifyAgent sends a notification to an agent's tmux window. // Notification protocol: // 1. tmux send-keys -t "Check your agentmail" // 2. time.Sleep(1 * time.Second) // 3. tmux send-keys -t Enter -func NotifyAgent(window string) error { +func NotifyAgent(pane string) error { // Send the notification message - if err := tmux.SendKeys(window, "Check your agentmail"); err != nil { + if err := tmux.SendKeys(pane, "Check your agentmail"); err != nil { return err } @@ -111,7 +111,7 @@ func NotifyAgent(window string) error { time.Sleep(1 * time.Second) // Send Enter to execute the command - if err := tmux.SendEnter(window); err != nil { + if err := tmux.SendEnter(pane); err != nil { return err } @@ -127,17 +127,17 @@ func CheckAndNotify(opts LoopOptions) error { // In test mode, skip actual notifications but still update flags return CheckAndNotifyWithNotifier(opts, nil, nil) } - return CheckAndNotifyWithNotifier(opts, NotifyAgent, tmux.WindowExists) + return CheckAndNotifyWithNotifier(opts, NotifyAgent, tmux.PaneExists) } // CheckAndNotifyWithNotifier performs a single notification cycle with a custom notifier. // This allows for testing without actual tmux calls. // When notify is non-nil, it will be called for each agent that should be notified. -// When windowChecker is non-nil, it will be used to verify window existence before notifying. +// When paneChecker is non-nil, it will be used to verify window existence before notifying. // The function handles two types of agents: // - Phase 1: Stated agents (with recipient state in recipients.jsonl) // - Phase 2: Stateless agents (mailbox but no recipient state) -func CheckAndNotifyWithNotifier(opts LoopOptions, notify NotifyFunc, windowChecker WindowCheckerFunc) error { +func CheckAndNotifyWithNotifier(opts LoopOptions, notify NotifyFunc, paneChecker PaneCheckerFunc) error { opts.log("Starting notification cycle") // ========================================================================= @@ -255,9 +255,9 @@ func CheckAndNotifyWithNotifier(opts LoopOptions, notify NotifyFunc, windowCheck continue } - // Check if window exists before attempting notification - if windowChecker != nil { - exists, err := windowChecker(mailboxRecipient) + // Check if pane exists before attempting notification + if paneChecker != nil { + exists, err := paneChecker(mailboxRecipient) if err != nil { opts.log("Error checking window existence for %q: %v", mailboxRecipient, err) continue diff --git a/internal/mail/ignore.go b/internal/mail/ignore.go index 7bc8e49..594753a 100644 --- a/internal/mail/ignore.go +++ b/internal/mail/ignore.go @@ -1,6 +1,7 @@ package mail import ( + "agentmail/internal/tmux" "bufio" "errors" "os" @@ -51,3 +52,39 @@ func LoadIgnoreList(gitRoot string) (map[string]bool, error) { } return ignored, scanner.Err() } + +// IsIgnored checks if a pane address matches any pattern in the ignore list. +func IsIgnored(address string, ignoreList map[string]bool, currentSession string) bool { + if ignoreList == nil { + return false + } + + // Check exact match on full address + if ignoreList[address] { + return true + } + + // Parse the address to extract components + addr, err := tmux.ParseAddress(address, currentSession) + if err != nil { + return false + } + + // Check medium form (:window.pane) if it matches current session + for pattern := range ignoreList { + if strings.HasPrefix(pattern, ":") { + // Parse medium form pattern with current session + patternAddr, err := tmux.ParseAddress(pattern, currentSession) + if err == nil && patternAddr.Session == addr.Session && patternAddr.Window == addr.Window && patternAddr.Pane == addr.Pane { + return true + } + } + } + + // Check short form (window name only) + if ignoreList[addr.Window] { + return true + } + + return false +} diff --git a/internal/mail/ignore_test.go b/internal/mail/ignore_test.go index 6fff0b9..9be5eb2 100644 --- a/internal/mail/ignore_test.go +++ b/internal/mail/ignore_test.go @@ -264,3 +264,107 @@ another-ignored t.Error("Should not find 'not-ignored' in ignore list") } } + +// Tests for IsIgnored function with pane addresses + +func TestIsIgnored_FullMatch(t *testing.T) { + ignoreList := map[string]bool{ + "mysession:editor.1": true, + } + + if !IsIgnored("mysession:editor.1", ignoreList, "mysession") { + t.Error("Should match full pane address") + } + + if IsIgnored("mysession:editor.0", ignoreList, "mysession") { + t.Error("Should not match different pane") + } +} + +func TestIsIgnored_MediumFormMatch(t *testing.T) { + ignoreList := map[string]bool{ + ":editor.1": true, + } + + if !IsIgnored("mysession:editor.1", ignoreList, "mysession") { + t.Error("Should match medium form against current session") + } + + if IsIgnored("othersession:editor.1", ignoreList, "mysession") { + t.Error("Should not match different session") + } + + if IsIgnored("mysession:editor.0", ignoreList, "mysession") { + t.Error("Should not match different pane") + } +} + +func TestIsIgnored_ShortFormMatch(t *testing.T) { + ignoreList := map[string]bool{ + "editor": true, + } + + if !IsIgnored("mysession:editor.0", ignoreList, "mysession") { + t.Error("Should match window name against any pane") + } + + if !IsIgnored("mysession:editor.1", ignoreList, "mysession") { + t.Error("Should match window name against any pane") + } + + if !IsIgnored("mysession:editor.99", ignoreList, "mysession") { + t.Error("Should match window name against any pane") + } + + if IsIgnored("mysession:logs.0", ignoreList, "mysession") { + t.Error("Should not match different window") + } +} + +func TestIsIgnored_NoMatch(t *testing.T) { + ignoreList := map[string]bool{ + "other": true, + } + + if IsIgnored("mysession:editor.0", ignoreList, "mysession") { + t.Error("Should not match when window name differs") + } +} + +func TestIsIgnored_MultiplePatterns(t *testing.T) { + ignoreList := map[string]bool{ + "mysession:logs.0": true, + ":editor.1": true, + "debug": true, + } + + if !IsIgnored("mysession:logs.0", ignoreList, "mysession") { + t.Error("Should match full form") + } + + if !IsIgnored("mysession:editor.1", ignoreList, "mysession") { + t.Error("Should match medium form") + } + + if !IsIgnored("mysession:debug.0", ignoreList, "mysession") { + t.Error("Should match short form") + } + + if !IsIgnored("othersession:debug.5", ignoreList, "mysession") { + t.Error("Should match short form regardless of session") + } +} + +func TestIsIgnored_EmptyIgnoreList(t *testing.T) { + ignoreList := map[string]bool{} + + if IsIgnored("mysession:editor.0", ignoreList, "mysession") { + t.Error("Should not match with empty ignore list") + } +} + +func TestIsIgnored_NilIgnoreList(t *testing.T) { + if IsIgnored("mysession:editor.0", nil, "mysession") { + t.Error("Should not match with nil ignore list") + } +} diff --git a/internal/mail/mailbox.go b/internal/mail/mailbox.go index 4b0aba6..35f7763 100644 --- a/internal/mail/mailbox.go +++ b/internal/mail/mailbox.go @@ -1,6 +1,7 @@ package mail import ( + "agentmail/internal/tmux" "encoding/json" "errors" "io" @@ -11,6 +12,12 @@ import ( "time" ) +// fileFd returns the file descriptor as an int for use with syscall.Flock. +// The uintptr-to-int conversion is safe on all supported 64-bit platforms. +func fileFd(f *os.File) int { + return int(f.Fd()) // #nosec G115 -- file descriptors fit in int on 64-bit platforms +} + // RootDir is the root directory for AgentMail storage const RootDir = ".agentmail" @@ -59,7 +66,7 @@ func isLockContention(err error) bool { // Returns the underlying error immediately for non-transient errors (e.g., EBADF, ENOLCK). func TryLockWithTimeout(file *os.File, timeout time.Duration) error { // Try non-blocking lock first - err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + err := syscall.Flock(fileFd(file), syscall.LOCK_EX|syscall.LOCK_NB) if err == nil { return nil } @@ -73,7 +80,7 @@ func TryLockWithTimeout(file *os.File, timeout time.Duration) error { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { time.Sleep(10 * time.Millisecond) - err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + err = syscall.Flock(fileFd(file), syscall.LOCK_EX|syscall.LOCK_NB) if err == nil { return nil } @@ -109,7 +116,7 @@ func Append(repoRoot string, msg Message) error { // Build file path for recipient with path traversal protection (G304) mailDir := filepath.Join(repoRoot, MailDir) - filePath, err := safePath(mailDir, msg.To+".jsonl") + filePath, err := safePath(mailDir, tmux.SanitizeForFilename(msg.To)+".jsonl") if err != nil { return err } @@ -121,7 +128,7 @@ func Append(repoRoot string, msg Message) error { } // Acquire exclusive lock on the file - if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil { + if err := syscall.Flock(fileFd(file), syscall.LOCK_EX); err != nil { _ = file.Close() // G104: error intentionally ignored in cleanup path return err } @@ -132,7 +139,7 @@ func Append(repoRoot string, msg Message) error { // Marshal message to JSON data, err := json.Marshal(msg) if err != nil { - _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path + _ = syscall.Flock(fileFd(file), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path _ = file.Close() return err } @@ -141,8 +148,8 @@ func Append(repoRoot string, msg Message) error { _, err = file.Write(append(data, '\n')) // Unlock before close (correct order) - _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) // G104: unlock errors don't affect the write result - _ = file.Close() // G104: close errors don't affect the write result + _ = syscall.Flock(fileFd(file), syscall.LOCK_UN) // G104: unlock errors don't affect the write result + _ = file.Close() // G104: close errors don't affect the write result return err } @@ -150,7 +157,7 @@ func Append(repoRoot string, msg Message) error { func ReadAll(repoRoot string, recipient string) ([]Message, error) { // Build file path with path traversal protection (G304) mailDir := filepath.Join(repoRoot, MailDir) - filePath, err := safePath(mailDir, recipient+".jsonl") + filePath, err := safePath(mailDir, tmux.SanitizeForFilename(recipient)+".jsonl") if err != nil { return nil, err } @@ -166,10 +173,10 @@ func ReadAll(repoRoot string, recipient string) ([]Message, error) { defer file.Close() // Acquire shared lock - if err := syscall.Flock(int(file.Fd()), syscall.LOCK_SH); err != nil { + if err := syscall.Flock(fileFd(file), syscall.LOCK_SH); err != nil { return nil, err } - defer syscall.Flock(int(file.Fd()), syscall.LOCK_UN) + defer syscall.Flock(fileFd(file), syscall.LOCK_UN) data, err := io.ReadAll(file) if err != nil { @@ -244,7 +251,7 @@ func WriteAll(repoRoot string, recipient string, messages []Message) error { // Build file path with path traversal protection (G304) mailDir := filepath.Join(repoRoot, MailDir) - filePath, err := safePath(mailDir, recipient+".jsonl") + filePath, err := safePath(mailDir, tmux.SanitizeForFilename(recipient)+".jsonl") if err != nil { return err } @@ -256,7 +263,7 @@ func WriteAll(repoRoot string, recipient string, messages []Message) error { } // Acquire exclusive lock - if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil { + if err := syscall.Flock(fileFd(file), syscall.LOCK_EX); err != nil { _ = file.Close() // G104: error intentionally ignored in cleanup path return err } @@ -265,8 +272,8 @@ func WriteAll(repoRoot string, recipient string, messages []Message) error { writeErr := writeAllLocked(file, messages) // Unlock before close (correct order) - _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) // G104: unlock errors don't affect the write result - _ = file.Close() // G104: close errors don't affect the write result + _ = syscall.Flock(fileFd(file), syscall.LOCK_UN) // G104: unlock errors don't affect the write result + _ = file.Close() // G104: close errors don't affect the write result return writeErr } @@ -277,7 +284,7 @@ func WriteAll(repoRoot string, recipient string, messages []Message) error { func CleanOldMessages(repoRoot string, recipient string, threshold time.Duration) (int, error) { // Build file path with path traversal protection (G304) mailDir := filepath.Join(repoRoot, MailDir) - filePath, err := safePath(mailDir, recipient+".jsonl") + filePath, err := safePath(mailDir, tmux.SanitizeForFilename(recipient)+".jsonl") if err != nil { return 0, err } @@ -292,7 +299,7 @@ func CleanOldMessages(repoRoot string, recipient string, threshold time.Duration } // Acquire exclusive lock for atomic read-modify-write - if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil { + if err := syscall.Flock(fileFd(file), syscall.LOCK_EX); err != nil { _ = file.Close() // G104: error intentionally ignored in cleanup path return 0, err } @@ -300,7 +307,7 @@ func CleanOldMessages(repoRoot string, recipient string, threshold time.Duration // Read all messages while holding lock data, err := os.ReadFile(filePath) // #nosec G304 - path validated by safePath if err != nil { - _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path + _ = syscall.Flock(fileFd(file), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path _ = file.Close() return 0, err } @@ -313,7 +320,7 @@ func CleanOldMessages(repoRoot string, recipient string, threshold time.Duration } var msg Message if err := json.Unmarshal([]byte(line), &msg); err != nil { - _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path + _ = syscall.Flock(fileFd(file), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path _ = file.Close() return 0, err } @@ -350,8 +357,8 @@ func CleanOldMessages(repoRoot string, recipient string, threshold time.Duration } // Unlock before close (correct order) - _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) // G104: unlock errors don't affect the write result - _ = file.Close() // G104: close errors don't affect the write result + _ = syscall.Flock(fileFd(file), syscall.LOCK_UN) // G104: unlock errors don't affect the write result + _ = file.Close() // G104: close errors don't affect the write result return removedCount, writeErr } @@ -365,7 +372,7 @@ func MarkAsRead(repoRoot string, recipient string, messageID string) error { // Build file path with path traversal protection (G304) mailDir := filepath.Join(repoRoot, MailDir) - filePath, err := safePath(mailDir, recipient+".jsonl") + filePath, err := safePath(mailDir, tmux.SanitizeForFilename(recipient)+".jsonl") if err != nil { return err } @@ -380,7 +387,7 @@ func MarkAsRead(repoRoot string, recipient string, messageID string) error { } // Acquire exclusive lock for atomic read-modify-write - if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil { + if err := syscall.Flock(fileFd(file), syscall.LOCK_EX); err != nil { _ = file.Close() // G104: error intentionally ignored in cleanup path return err } @@ -388,7 +395,7 @@ func MarkAsRead(repoRoot string, recipient string, messageID string) error { // Read all messages while holding lock data, err := os.ReadFile(filePath) // #nosec G304 - path validated by safePath if err != nil { - _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path + _ = syscall.Flock(fileFd(file), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path _ = file.Close() return err } @@ -401,7 +408,7 @@ func MarkAsRead(repoRoot string, recipient string, messageID string) error { } var msg Message if err := json.Unmarshal([]byte(line), &msg); err != nil { - _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path + _ = syscall.Flock(fileFd(file), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path _ = file.Close() return err } @@ -420,8 +427,8 @@ func MarkAsRead(repoRoot string, recipient string, messageID string) error { writeErr := writeAllLocked(file, messages) // Unlock before close (correct order) - _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) // G104: unlock errors don't affect the write result - _ = file.Close() // G104: close errors don't affect the write result + _ = syscall.Flock(fileFd(file), syscall.LOCK_UN) // G104: unlock errors don't affect the write result + _ = file.Close() // G104: close errors don't affect the write result return writeErr } @@ -439,7 +446,7 @@ func RemoveEmptyMailboxes(repoRoot string) (int, error) { for _, recipient := range recipients { // Build file path with path traversal protection (G304) - filePath, err := safePath(mailDir, recipient+".jsonl") + filePath, err := safePath(mailDir, tmux.SanitizeForFilename(recipient)+".jsonl") if err != nil { continue // Skip invalid paths } @@ -519,7 +526,7 @@ func CountEmptyMailboxes(repoRoot string) (int, error) { count := 0 for _, recipient := range recipients { - filePath, err := safePath(mailDir, recipient+".jsonl") + filePath, err := safePath(mailDir, tmux.SanitizeForFilename(recipient)+".jsonl") if err != nil { continue } diff --git a/internal/mail/mailbox_test.go b/internal/mail/mailbox_test.go index da4a150..23d33e8 100644 --- a/internal/mail/mailbox_test.go +++ b/internal/mail/mailbox_test.go @@ -557,3 +557,299 @@ func TestTryLockWithTimeout_Timeout(t *testing.T) { t.Errorf("TryLockWithTimeout should wait for timeout, elapsed: %v", elapsed) } } + +// Tests for pane address support + +func TestAppend_WithPaneAddress(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agentmail-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + mailDir := filepath.Join(tmpDir, ".agentmail", "mailboxes") + if err := os.MkdirAll(mailDir, 0755); err != nil { + t.Fatalf("Failed to create mail dir: %v", err) + } + + msg := Message{ + ID: "testID01", + From: "mysession:sender.0", + To: "mysession:editor.0", + Message: "Hello from pane", + ReadFlag: false, + } + + err = Append(tmpDir, msg) + if err != nil { + t.Fatalf("Append failed: %v", err) + } + + // Verify file is created with sanitized filename + sanitizedFilename := "mysession%3Aeditor%2E0.jsonl" + filePath := filepath.Join(mailDir, sanitizedFilename) + if _, err := os.Stat(filePath); os.IsNotExist(err) { + t.Fatalf("File should exist with sanitized name: %s", sanitizedFilename) + } + + // Verify message can be read back + messages, err := ReadAll(tmpDir, "mysession:editor.0") + if err != nil { + t.Fatalf("ReadAll failed: %v", err) + } + + if len(messages) != 1 { + t.Fatalf("Expected 1 message, got %d", len(messages)) + } + + if messages[0].To != "mysession:editor.0" { + t.Errorf("Expected To 'mysession:editor.0', got '%s'", messages[0].To) + } +} + +func TestReadAll_WithPaneAddress(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agentmail-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + mailDir := filepath.Join(tmpDir, ".agentmail", "mailboxes") + if err := os.MkdirAll(mailDir, 0755); err != nil { + t.Fatalf("Failed to create mail dir: %v", err) + } + + // Write test data with sanitized filename + content := `{"id":"id1","from":"mysession:sender.0","to":"mysession:editor.1","message":"Hello","read_flag":false} +` + sanitizedFilename := "mysession%3Aeditor%2E1.jsonl" + filePath := filepath.Join(mailDir, sanitizedFilename) + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + messages, err := ReadAll(tmpDir, "mysession:editor.1") + if err != nil { + t.Fatalf("ReadAll failed: %v", err) + } + + if len(messages) != 1 { + t.Fatalf("Expected 1 message, got %d", len(messages)) + } + + if messages[0].To != "mysession:editor.1" { + t.Errorf("Expected To 'mysession:editor.1', got '%s'", messages[0].To) + } +} + +func TestListMailboxRecipients_ReturnsDecodedAddresses(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agentmail-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + mailDir := filepath.Join(tmpDir, ".agentmail", "mailboxes") + if err := os.MkdirAll(mailDir, 0755); err != nil { + t.Fatalf("Failed to create mail dir: %v", err) + } + + // Create mailbox files with encoded pane addresses + files := []string{ + "mysession%3Aeditor%2E0.jsonl", + "mysession%3Aeditor%2E1.jsonl", + "s%3Amy.app%2E0.jsonl", + } + + for _, filename := range files { + filePath := filepath.Join(mailDir, filename) + if err := os.WriteFile(filePath, []byte(""), 0644); err != nil { + t.Fatalf("Failed to create file %s: %v", filename, err) + } + } + + recipients, err := ListMailboxRecipients(tmpDir) + if err != nil { + t.Fatalf("ListMailboxRecipients failed: %v", err) + } + + if len(recipients) != 3 { + t.Fatalf("Expected 3 recipients, got %d", len(recipients)) + } + + // Verify decoded addresses + expected := []string{"mysession:editor.0", "mysession:editor.1", "s:my.app.0"} + for _, exp := range expected { + found := false + for _, rec := range recipients { + if rec == exp { + found = true + break + } + } + if !found { + t.Errorf("Expected recipient '%s' not found in list %v", exp, recipients) + } + } +} + +// Tests for cleanup functions + +func TestCleanOldMessages(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agentmail-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + mailDir := filepath.Join(tmpDir, ".agentmail", "mailboxes") + if err := os.MkdirAll(mailDir, 0755); err != nil { + t.Fatalf("Failed to create mail dir: %v", err) + } + + // Create messages with different ages + oldTime := time.Now().Add(-48 * time.Hour) + recentTime := time.Now().Add(-30 * time.Minute) + + messages := []Message{ + {ID: "old1", From: "sender", To: "mysession:editor.0", Message: "Old read", ReadFlag: true, CreatedAt: oldTime}, + {ID: "recent1", From: "sender", To: "mysession:editor.0", Message: "Recent read", ReadFlag: true, CreatedAt: recentTime}, + {ID: "unread1", From: "sender", To: "mysession:editor.0", Message: "Unread old", ReadFlag: false, CreatedAt: oldTime}, + } + + if err := WriteAll(tmpDir, "mysession:editor.0", messages); err != nil { + t.Fatalf("Failed to write messages: %v", err) + } + + // Clean messages older than 1 hour + removed, err := CleanOldMessages(tmpDir, "mysession:editor.0", 1*time.Hour) + if err != nil { + t.Fatalf("CleanOldMessages failed: %v", err) + } + + if removed != 1 { + t.Errorf("Expected 1 message removed, got %d", removed) + } + + // Verify remaining messages + remaining, err := ReadAll(tmpDir, "mysession:editor.0") + if err != nil { + t.Fatalf("ReadAll failed: %v", err) + } + + if len(remaining) != 2 { + t.Errorf("Expected 2 remaining messages, got %d", len(remaining)) + } +} + +func TestCountOldMessages(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agentmail-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + mailDir := filepath.Join(tmpDir, ".agentmail", "mailboxes") + if err := os.MkdirAll(mailDir, 0755); err != nil { + t.Fatalf("Failed to create mail dir: %v", err) + } + + oldTime := time.Now().Add(-48 * time.Hour) + recentTime := time.Now().Add(-30 * time.Minute) + + messages := []Message{ + {ID: "old1", From: "sender", To: "mysession:editor.0", Message: "Old read", ReadFlag: true, CreatedAt: oldTime}, + {ID: "recent1", From: "sender", To: "mysession:editor.0", Message: "Recent read", ReadFlag: true, CreatedAt: recentTime}, + {ID: "unread1", From: "sender", To: "mysession:editor.0", Message: "Unread", ReadFlag: false, CreatedAt: oldTime}, + } + + if err := WriteAll(tmpDir, "mysession:editor.0", messages); err != nil { + t.Fatalf("Failed to write messages: %v", err) + } + + count, err := CountOldMessages(tmpDir, "mysession:editor.0", 1*time.Hour) + if err != nil { + t.Fatalf("CountOldMessages failed: %v", err) + } + + if count != 1 { + t.Errorf("Expected count 1, got %d", count) + } +} + +func TestRemoveEmptyMailboxes(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agentmail-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + mailDir := filepath.Join(tmpDir, ".agentmail", "mailboxes") + if err := os.MkdirAll(mailDir, 0755); err != nil { + t.Fatalf("Failed to create mail dir: %v", err) + } + + // Create empty mailbox + emptyFile := filepath.Join(mailDir, "mysession%3Aeditor%2E0.jsonl") + if err := os.WriteFile(emptyFile, []byte(""), 0644); err != nil { + t.Fatalf("Failed to create empty file: %v", err) + } + + // Create non-empty mailbox + messages := []Message{ + {ID: "msg1", From: "sender", To: "mysession:editor.1", Message: "Hello", ReadFlag: false}, + } + if err := WriteAll(tmpDir, "mysession:editor.1", messages); err != nil { + t.Fatalf("Failed to write messages: %v", err) + } + + removed, err := RemoveEmptyMailboxes(tmpDir) + if err != nil { + t.Fatalf("RemoveEmptyMailboxes failed: %v", err) + } + + if removed != 1 { + t.Errorf("Expected 1 mailbox removed, got %d", removed) + } + + // Verify empty file is gone + if _, err := os.Stat(emptyFile); !os.IsNotExist(err) { + t.Error("Empty mailbox file should be removed") + } +} + +func TestCountEmptyMailboxes(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agentmail-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + mailDir := filepath.Join(tmpDir, ".agentmail", "mailboxes") + if err := os.MkdirAll(mailDir, 0755); err != nil { + t.Fatalf("Failed to create mail dir: %v", err) + } + + // Create empty mailbox + emptyFile := filepath.Join(mailDir, "mysession%3Aeditor%2E0.jsonl") + if err := os.WriteFile(emptyFile, []byte(""), 0644); err != nil { + t.Fatalf("Failed to create empty file: %v", err) + } + + // Create non-empty mailbox + messages := []Message{ + {ID: "msg1", From: "sender", To: "mysession:editor.1", Message: "Hello", ReadFlag: false}, + } + if err := WriteAll(tmpDir, "mysession:editor.1", messages); err != nil { + t.Fatalf("Failed to write messages: %v", err) + } + + count, err := CountEmptyMailboxes(tmpDir) + if err != nil { + t.Fatalf("CountEmptyMailboxes failed: %v", err) + } + + if count != 1 { + t.Errorf("Expected count 1, got %d", count) + } +} diff --git a/internal/mail/recipients.go b/internal/mail/recipients.go index 9597b16..052d73d 100644 --- a/internal/mail/recipients.go +++ b/internal/mail/recipients.go @@ -1,6 +1,7 @@ package mail import ( + "agentmail/internal/tmux" "encoding/json" "os" "path/filepath" @@ -121,7 +122,7 @@ func WriteAllRecipients(repoRoot string, recipients []RecipientState) error { } // Acquire exclusive lock - if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil { + if err := syscall.Flock(fileFd(file), syscall.LOCK_EX); err != nil { _ = file.Close() // G104: error intentionally ignored in cleanup path return err } @@ -130,8 +131,8 @@ func WriteAllRecipients(repoRoot string, recipients []RecipientState) error { writeErr := writeAllRecipientsLocked(file, recipients) // Unlock before close (correct order) - _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) // G104: unlock errors don't affect the write result - _ = file.Close() // G104: close errors don't affect the write result + _ = syscall.Flock(fileFd(file), syscall.LOCK_UN) // G104: unlock errors don't affect the write result + _ = file.Close() // G104: close errors don't affect the write result return writeErr } @@ -154,7 +155,7 @@ func UpdateRecipientState(repoRoot string, recipient string, status string, rese } // Acquire exclusive lock for atomic read-modify-write - if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil { + if err := syscall.Flock(fileFd(file), syscall.LOCK_EX); err != nil { _ = file.Close() // G104: error intentionally ignored in cleanup path return err } @@ -162,7 +163,7 @@ func UpdateRecipientState(repoRoot string, recipient string, status string, rese // Read all recipient states while holding lock data, err := os.ReadFile(filePath) // #nosec G304 - path is constructed from constant if err != nil && !os.IsNotExist(err) { - _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path + _ = syscall.Flock(fileFd(file), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path _ = file.Close() return err } @@ -176,7 +177,7 @@ func UpdateRecipientState(repoRoot string, recipient string, status string, rese } var state RecipientState if err := json.Unmarshal([]byte(line), &state); err != nil { - _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path + _ = syscall.Flock(fileFd(file), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path _ = file.Close() return err } @@ -213,8 +214,8 @@ func UpdateRecipientState(repoRoot string, recipient string, status string, rese writeErr := writeAllRecipientsLocked(file, recipients) // Unlock before close (correct order) - _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) // G104: unlock errors don't affect the write result - _ = file.Close() // G104: close errors don't affect the write result + _ = syscall.Flock(fileFd(file), syscall.LOCK_UN) // G104: unlock errors don't affect the write result + _ = file.Close() // G104: close errors don't affect the write result return writeErr } @@ -242,7 +243,9 @@ func ListMailboxRecipients(repoRoot string) ([]string, error) { continue } // Extract recipient name (remove .jsonl suffix) - recipient := strings.TrimSuffix(name, ".jsonl") + encodedRecipient := strings.TrimSuffix(name, ".jsonl") + // Decode pane address from filename + recipient := tmux.UnsanitizeFromFilename(encodedRecipient) recipients = append(recipients, recipient) } @@ -265,7 +268,7 @@ func CleanStaleStates(repoRoot string, threshold time.Duration) (int, error) { } // Acquire exclusive lock for atomic read-modify-write - if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil { + if err := syscall.Flock(fileFd(file), syscall.LOCK_EX); err != nil { _ = file.Close() // G104: error intentionally ignored in cleanup path return 0, err } @@ -273,7 +276,7 @@ func CleanStaleStates(repoRoot string, threshold time.Duration) (int, error) { // Read all recipient states while holding lock data, err := os.ReadFile(filePath) // #nosec G304 - path is constructed from constant if err != nil && !os.IsNotExist(err) { - _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path + _ = syscall.Flock(fileFd(file), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path _ = file.Close() return 0, err } @@ -287,7 +290,7 @@ func CleanStaleStates(repoRoot string, threshold time.Duration) (int, error) { } var state RecipientState if err := json.Unmarshal([]byte(line), &state); err != nil { - _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path + _ = syscall.Flock(fileFd(file), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path _ = file.Close() return 0, err } @@ -314,8 +317,8 @@ func CleanStaleStates(repoRoot string, threshold time.Duration) (int, error) { } // Unlock before close (correct order) - _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) // G104: unlock errors don't affect the write result - _ = file.Close() // G104: close errors don't affect the write result + _ = syscall.Flock(fileFd(file), syscall.LOCK_UN) // G104: unlock errors don't affect the write result + _ = file.Close() // G104: close errors don't affect the write result return removedCount, writeErr } @@ -335,7 +338,7 @@ func SetNotifiedAt(repoRoot string, recipient string, notifiedAt time.Time) erro } // Acquire exclusive lock for atomic read-modify-write - if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil { + if err := syscall.Flock(fileFd(file), syscall.LOCK_EX); err != nil { _ = file.Close() // G104: error intentionally ignored in cleanup path return err } @@ -343,7 +346,7 @@ func SetNotifiedAt(repoRoot string, recipient string, notifiedAt time.Time) erro // Read all recipient states while holding lock data, err := os.ReadFile(filePath) // #nosec G304 - path is constructed from constant if err != nil { - _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path + _ = syscall.Flock(fileFd(file), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path _ = file.Close() return err } @@ -357,7 +360,7 @@ func SetNotifiedAt(repoRoot string, recipient string, notifiedAt time.Time) erro } var state RecipientState if err := json.Unmarshal([]byte(line), &state); err != nil { - _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path + _ = syscall.Flock(fileFd(file), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path _ = file.Close() return err } @@ -377,7 +380,7 @@ func SetNotifiedAt(repoRoot string, recipient string, notifiedAt time.Time) erro if !found { // Recipient doesn't exist, don't create it - _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path + _ = syscall.Flock(fileFd(file), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path _ = file.Close() return nil } @@ -386,8 +389,8 @@ func SetNotifiedAt(repoRoot string, recipient string, notifiedAt time.Time) erro writeErr := writeAllRecipientsLocked(file, recipients) // Unlock before close (correct order) - _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) // G104: unlock errors don't affect the write result - _ = file.Close() // G104: close errors don't affect the write result + _ = syscall.Flock(fileFd(file), syscall.LOCK_UN) // G104: unlock errors don't affect the write result + _ = file.Close() // G104: close errors don't affect the write result return writeErr } @@ -514,7 +517,7 @@ func UpdateLastReadAt(repoRoot string, recipient string, timestamp int64) error } // Acquire exclusive lock for atomic read-modify-write (FR-020) - if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil { + if err := syscall.Flock(fileFd(file), syscall.LOCK_EX); err != nil { _ = file.Close() // G104: error intentionally ignored in cleanup path return err } @@ -522,7 +525,7 @@ func UpdateLastReadAt(repoRoot string, recipient string, timestamp int64) error // Read all recipient states while holding lock data, err := os.ReadFile(filePath) // #nosec G304 - path is constructed from constant if err != nil && !os.IsNotExist(err) { - _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path + _ = syscall.Flock(fileFd(file), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path _ = file.Close() return err } @@ -536,7 +539,7 @@ func UpdateLastReadAt(repoRoot string, recipient string, timestamp int64) error } var state RecipientState if err := json.Unmarshal([]byte(line), &state); err != nil { - _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path + _ = syscall.Flock(fileFd(file), syscall.LOCK_UN) // G104: error intentionally ignored in cleanup path _ = file.Close() return err } @@ -570,7 +573,7 @@ func UpdateLastReadAt(repoRoot string, recipient string, timestamp int64) error writeErr := writeAllRecipientsLocked(file, recipients) // Unlock before close (correct order) - _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) // G104: unlock errors don't affect the write result - _ = file.Close() // G104: close errors don't affect the write result + _ = syscall.Flock(fileFd(file), syscall.LOCK_UN) // G104: unlock errors don't affect the write result + _ = file.Close() // G104: close errors don't affect the write result return writeErr } diff --git a/internal/mail/recipients_pane_test.go b/internal/mail/recipients_pane_test.go new file mode 100644 index 0000000..bb71cc4 --- /dev/null +++ b/internal/mail/recipients_pane_test.go @@ -0,0 +1,254 @@ +package mail + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +// ============================================================================= +// T005: Tests for recipients.go with pane addresses +// ============================================================================= + +func TestUpdateRecipientState_WithPaneAddress(t *testing.T) { + tmpDir := t.TempDir() + + agentmailDir := filepath.Join(tmpDir, ".agentmail") + if err := os.Mkdir(agentmailDir, 0755); err != nil { + t.Fatalf("Failed to create .agentmail dir: %v", err) + } + + // Update recipient using pane address + err := UpdateRecipientState(tmpDir, "mysession:editor.1", StatusReady, false) + if err != nil { + t.Fatalf("UpdateRecipientState failed: %v", err) + } + + // Verify recipient was added with pane address + recipients, err := ReadAllRecipients(tmpDir) + if err != nil { + t.Fatalf("ReadAllRecipients failed: %v", err) + } + + if len(recipients) != 1 { + t.Fatalf("Expected 1 recipient, got %d", len(recipients)) + } + + if recipients[0].Recipient != "mysession:editor.1" { + t.Errorf("Expected recipient 'mysession:editor.1', got '%s'", recipients[0].Recipient) + } + if recipients[0].Status != StatusReady { + t.Errorf("Expected status ready, got %s", recipients[0].Status) + } +} + +func TestReadAllRecipients_MultiplePanes(t *testing.T) { + tmpDir := t.TempDir() + + agentmailDir := filepath.Join(tmpDir, ".agentmail") + if err := os.Mkdir(agentmailDir, 0755); err != nil { + t.Fatalf("Failed to create .agentmail dir: %v", err) + } + + now := time.Now().Truncate(time.Second) + states := []RecipientState{ + { + Recipient: "mysession:editor.0", + Status: StatusReady, + UpdatedAt: now, + NotifiedAt: time.Time{}, + }, + { + Recipient: "mysession:editor.1", + Status: StatusWork, + UpdatedAt: now, + NotifiedAt: time.Now(), + }, + { + Recipient: "mysession:terminal.0", + Status: StatusOffline, + UpdatedAt: now, + NotifiedAt: time.Time{}, + }, + } + + filePath := filepath.Join(agentmailDir, "recipients.jsonl") + file, err := os.Create(filePath) + if err != nil { + t.Fatalf("Failed to create file: %v", err) + } + for _, state := range states { + data, _ := json.Marshal(state) + file.Write(append(data, '\n')) + } + file.Close() + + recipients, err := ReadAllRecipients(tmpDir) + if err != nil { + t.Fatalf("ReadAllRecipients failed: %v", err) + } + + if len(recipients) != 3 { + t.Fatalf("Expected 3 recipients, got %d", len(recipients)) + } + + // Verify each pane is tracked separately + expected := map[string]string{ + "mysession:editor.0": StatusReady, + "mysession:editor.1": StatusWork, + "mysession:terminal.0": StatusOffline, + } + + for _, r := range recipients { + expectedStatus, ok := expected[r.Recipient] + if !ok { + t.Errorf("Unexpected recipient: %s", r.Recipient) + continue + } + if r.Status != expectedStatus { + t.Errorf("Recipient %s: expected status %s, got %s", r.Recipient, expectedStatus, r.Status) + } + } +} + +func TestUpdateLastReadAt_WithPaneAddress(t *testing.T) { + tmpDir := t.TempDir() + + agentmailDir := filepath.Join(tmpDir, ".agentmail") + if err := os.Mkdir(agentmailDir, 0755); err != nil { + t.Fatalf("Failed to create .agentmail dir: %v", err) + } + + // Create initial recipient with pane address + now := time.Now().Truncate(time.Second) + existing := RecipientState{ + Recipient: "mysession:editor.1", + Status: StatusWork, + UpdatedAt: now, + NotifiedAt: time.Now(), + LastReadAt: 1000000000000, + } + filePath := filepath.Join(agentmailDir, "recipients.jsonl") + file, err := os.Create(filePath) + if err != nil { + t.Fatalf("Failed to create file: %v", err) + } + data, _ := json.Marshal(existing) + file.Write(append(data, '\n')) + file.Close() + + // Update LastReadAt using pane address + newTimestamp := int64(1704067200000) + err = UpdateLastReadAt(tmpDir, "mysession:editor.1", newTimestamp) + if err != nil { + t.Fatalf("UpdateLastReadAt failed: %v", err) + } + + // Verify LastReadAt was updated + recipients, err := ReadAllRecipients(tmpDir) + if err != nil { + t.Fatalf("ReadAllRecipients failed: %v", err) + } + + if len(recipients) != 1 { + t.Fatalf("Expected 1 recipient, got %d", len(recipients)) + } + + if recipients[0].Recipient != "mysession:editor.1" { + t.Errorf("Expected recipient 'mysession:editor.1', got '%s'", recipients[0].Recipient) + } + if recipients[0].LastReadAt != newTimestamp { + t.Errorf("Expected LastReadAt %d, got %d", newTimestamp, recipients[0].LastReadAt) + } +} + +func TestCleanOfflineRecipients_WithPaneAddresses(t *testing.T) { + tmpDir := t.TempDir() + + agentmailDir := filepath.Join(tmpDir, ".agentmail") + if err := os.Mkdir(agentmailDir, 0755); err != nil { + t.Fatalf("Failed to create .agentmail dir: %v", err) + } + + now := time.Now() + recipients := []RecipientState{ + {Recipient: "mysession:editor.0", Status: StatusReady, UpdatedAt: now, NotifiedAt: time.Time{}}, + {Recipient: "mysession:editor.1", Status: StatusWork, UpdatedAt: now, NotifiedAt: time.Time{}}, + {Recipient: "mysession:stale.0", Status: StatusReady, UpdatedAt: now, NotifiedAt: time.Time{}}, + } + if err := WriteAllRecipients(tmpDir, recipients); err != nil { + t.Fatalf("WriteAllRecipients failed: %v", err) + } + + // Clean offline recipients - keep only editor.0 and editor.1 + validPanes := []string{"mysession:editor.0", "mysession:editor.1"} + _, err := CleanOfflineRecipients(tmpDir, validPanes) + if err != nil { + t.Fatalf("CleanOfflineRecipients failed: %v", err) + } + + // Verify only valid panes remain + readBack, err := ReadAllRecipients(tmpDir) + if err != nil { + t.Fatalf("ReadAllRecipients failed: %v", err) + } + + if len(readBack) != 2 { + t.Fatalf("Expected 2 recipients after cleanup, got %d", len(readBack)) + } + + for _, r := range readBack { + if r.Recipient != "mysession:editor.0" && r.Recipient != "mysession:editor.1" { + t.Errorf("Unexpected recipient after cleanup: %s", r.Recipient) + } + } +} + +func TestSetNotifiedAt_WithPaneAddress(t *testing.T) { + tmpDir := t.TempDir() + + agentmailDir := filepath.Join(tmpDir, ".agentmail") + if err := os.Mkdir(agentmailDir, 0755); err != nil { + t.Fatalf("Failed to create .agentmail dir: %v", err) + } + + now := time.Now() + recipients := []RecipientState{ + {Recipient: "mysession:editor.0", Status: StatusReady, UpdatedAt: now, NotifiedAt: time.Time{}}, + {Recipient: "mysession:editor.1", Status: StatusWork, UpdatedAt: now, NotifiedAt: time.Time{}}, + } + if err := WriteAllRecipients(tmpDir, recipients); err != nil { + t.Fatalf("WriteAllRecipients failed: %v", err) + } + + // Set notified timestamp for one pane + notifiedTime := time.Now() + err := SetNotifiedAt(tmpDir, "mysession:editor.1", notifiedTime) + if err != nil { + t.Fatalf("SetNotifiedAt failed: %v", err) + } + + // Verify only editor.1 has NotifiedAt set + readBack, err := ReadAllRecipients(tmpDir) + if err != nil { + t.Fatalf("ReadAllRecipients failed: %v", err) + } + + for _, r := range readBack { + switch r.Recipient { + case "mysession:editor.0": + if !r.NotifiedAt.IsZero() { + t.Error("editor.0 should not have NotifiedAt set") + } + case "mysession:editor.1": + if r.NotifiedAt.IsZero() { + t.Error("editor.1 should have NotifiedAt set") + } + if r.NotifiedAt.Unix() != notifiedTime.Unix() { + t.Errorf("editor.1 NotifiedAt mismatch: expected %v, got %v", notifiedTime.Unix(), r.NotifiedAt.Unix()) + } + } + } +} diff --git a/internal/mail/recipients_test.go b/internal/mail/recipients_test.go index ea9b35f..e1744a3 100644 --- a/internal/mail/recipients_test.go +++ b/internal/mail/recipients_test.go @@ -1125,3 +1125,168 @@ func TestUpdateLastReadAt_PreservesOtherRecipients(t *testing.T) { } } } + +func TestCountOfflineRecipients(t *testing.T) { + tempDir := t.TempDir() + + // Create .agentmail directory + agentmailDir := filepath.Join(tempDir, ".agentmail") + if err := os.Mkdir(agentmailDir, 0755); err != nil { + t.Fatalf("Failed to create .agentmail dir: %v", err) + } + + // Create recipient states via UpdateRecipientState + if err := UpdateRecipientState(tempDir, "mysession:agent-1.0", StatusWork, false); err != nil { + t.Fatalf("Failed to create agent-1: %v", err) + } + if err := UpdateRecipientState(tempDir, "mysession:agent-2.0", StatusOffline, false); err != nil { + t.Fatalf("Failed to create agent-2: %v", err) + } + if err := UpdateRecipientState(tempDir, "mysession:agent-3.0", StatusOffline, false); err != nil { + t.Fatalf("Failed to create agent-3: %v", err) + } + if err := UpdateRecipientState(tempDir, "mysession:agent-4.0", StatusWork, false); err != nil { + t.Fatalf("Failed to create agent-4: %v", err) + } + + // Mock valid panes - only agent-1 and agent-4 are still in tmux + // agent-2 and agent-3 no longer exist (offline/closed) + validPanes := []string{"mysession:agent-1.0", "mysession:agent-4.0"} + + count, err := CountOfflineRecipients(tempDir, validPanes) + if err != nil { + t.Fatalf("CountOfflineRecipients failed: %v", err) + } + + if count != 2 { + t.Errorf("Expected 2 offline recipients (not in validPanes), got %d", count) + } +} + +func TestCountStaleStates(t *testing.T) { + tempDir := t.TempDir() + + // Create .agentmail directory + agentmailDir := filepath.Join(tempDir, ".agentmail") + if err := os.Mkdir(agentmailDir, 0755); err != nil { + t.Fatalf("Failed to create .agentmail dir: %v", err) + } + + // Manually create recipient states with specific timestamps + now := time.Now() + recipients := []RecipientState{ + {Recipient: "mysession:agent-1.0", Status: StatusWork, UpdatedAt: now.Add(-1 * time.Hour)}, // 1 hour old - fresh + {Recipient: "mysession:agent-2.0", Status: StatusWork, UpdatedAt: now.Add(-8 * 24 * time.Hour)}, // 8 days old - stale + {Recipient: "mysession:agent-3.0", Status: StatusOffline, UpdatedAt: now.Add(-10 * 24 * time.Hour)}, // 10 days old - stale + {Recipient: "mysession:agent-4.0", Status: StatusWork, UpdatedAt: now.Add(-2 * time.Hour)}, // 2 hours old - fresh + } + + // Write recipients manually + filePath := filepath.Join(tempDir, RecipientsFile) + file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + t.Fatalf("Failed to create recipients file: %v", err) + } + defer file.Close() + + if err := writeAllRecipientsLocked(file, recipients); err != nil { + t.Fatalf("Failed to write recipients: %v", err) + } + + // Test with 7 day threshold + count, err := CountStaleStates(tempDir, 7*24*time.Hour) + if err != nil { + t.Fatalf("CountStaleStates failed: %v", err) + } + + if count != 2 { + t.Errorf("Expected 2 stale states (>7 days), got %d", count) + } +} + +func TestShouldNotify_ReadyNeverNotified(t *testing.T) { + r := RecipientState{ + Recipient: "agent-1", + Status: StatusReady, + UpdatedAt: time.Now(), + NotifiedAt: time.Time{}, // Zero value = never notified + } + + if !r.ShouldNotify() { + t.Error("ShouldNotify should return true for ready agent never notified") + } +} + +func TestShouldNotify_ReadyRecentlyNotified(t *testing.T) { + r := RecipientState{ + Recipient: "agent-1", + Status: StatusReady, + UpdatedAt: time.Now(), + NotifiedAt: time.Now().Add(-30 * time.Second), // Notified 30s ago (within 60s debounce) + } + + if r.ShouldNotify() { + t.Error("ShouldNotify should return false for ready agent notified within 60s") + } +} + +func TestShouldNotify_ReadyDebounceElapsed(t *testing.T) { + r := RecipientState{ + Recipient: "agent-1", + Status: StatusReady, + UpdatedAt: time.Now(), + NotifiedAt: time.Now().Add(-65 * time.Second), // Notified 65s ago (beyond 60s debounce) + } + + if !r.ShouldNotify() { + t.Error("ShouldNotify should return true for ready agent after 60s debounce") + } +} + +func TestShouldNotify_WorkRecentlyUpdated(t *testing.T) { + r := RecipientState{ + Recipient: "agent-1", + Status: StatusWork, + UpdatedAt: time.Now().Add(-30 * time.Minute), // Changed to work 30 min ago (within 1 hour) + } + + if r.ShouldNotify() { + t.Error("ShouldNotify should return false for work agent within 1 hour of status change") + } +} + +func TestShouldNotify_WorkProtectionElapsed(t *testing.T) { + r := RecipientState{ + Recipient: "agent-1", + Status: StatusWork, + UpdatedAt: time.Now().Add(-65 * time.Minute), // Changed to work 65 min ago (beyond 1 hour) + } + + if !r.ShouldNotify() { + t.Error("ShouldNotify should return true for work agent after 1 hour protection") + } +} + +func TestShouldNotify_OfflineRecentlyUpdated(t *testing.T) { + r := RecipientState{ + Recipient: "agent-1", + Status: StatusOffline, + UpdatedAt: time.Now().Add(-45 * time.Minute), // Changed to offline 45 min ago (within 1 hour) + } + + if r.ShouldNotify() { + t.Error("ShouldNotify should return false for offline agent within 1 hour of status change") + } +} + +func TestShouldNotify_OfflineProtectionElapsed(t *testing.T) { + r := RecipientState{ + Recipient: "agent-1", + Status: StatusOffline, + UpdatedAt: time.Now().Add(-2 * time.Hour), // Changed to offline 2 hours ago (beyond 1 hour) + } + + if !r.ShouldNotify() { + t.Error("ShouldNotify should return true for offline agent after 1 hour protection") + } +} diff --git a/internal/mcp/handlers.go b/internal/mcp/handlers.go index 93b07f4..6779634 100644 --- a/internal/mcp/handlers.go +++ b/internal/mcp/handlers.go @@ -16,12 +16,12 @@ import ( type HandlerOptions struct { // SkipTmuxCheck disables tmux validation (for testing). SkipTmuxCheck bool - // MockReceiver is the mock receiver window name (for testing). - MockReceiver string - // MockSender is the mock sender window name (for testing). - MockSender string - // MockWindows is the mock list of tmux windows (for testing). - MockWindows []string + // MockPaneAddress is the mock pane address for sender/receiver (for testing). + MockPaneAddress string + // MockSession is the mock session name (for testing). + MockSession string + // MockPanes is the mock list of tmux panes (for testing). + MockPanes []string // MockIgnoreList is the mock ignore list (for testing). MockIgnoreList map[string]bool // RepoRoot is the repository root (defaults to git root). @@ -58,7 +58,7 @@ type SendResponse struct { // ReceiveResponse represents a successful receive response with a message. type ReceiveResponse struct { - From string `json:"from"` // Sender window name + From string `json:"from"` // Sender pane address ID string `json:"id"` // Message ID Message string `json:"message"` // Message content } @@ -80,8 +80,8 @@ type ListRecipientsResponse struct { // RecipientInfo represents a single recipient in the list-recipients response. type RecipientInfo struct { - Name string `json:"name"` // Window name - IsCurrent bool `json:"is_current"` // True if this is the caller's window + Address string `json:"address"` // Pane address (session:window.pane) + IsCurrent bool `json:"is_current"` // True if this is the caller's pane } // doSend implements the send handler logic. @@ -102,41 +102,97 @@ func doSend(ctx context.Context, recipient, message string) (any, error) { return nil, fmt.Errorf("message exceeds maximum size of 64KB") } - // Get sender identity + // Get sender identity (pane address) var sender string - if opts.MockSender != "" { - sender = opts.MockSender + if opts.MockPaneAddress != "" { + sender = opts.MockPaneAddress } else { var err error - sender, err = tmux.GetCurrentWindow() + sender, err = tmux.GetCurrentPaneAddress() if err != nil { - return nil, fmt.Errorf("failed to get current window: %w", err) + return nil, fmt.Errorf("failed to get current pane address: %w", err) } } - // FR-009: Validate recipient exists - var recipientExists bool - if opts.MockWindows != nil { - for _, w := range opts.MockWindows { - if w == recipient { - recipientExists = true - break - } - } + // Get current session for address resolution + var currentSession string + if opts.MockSession != "" { + currentSession = opts.MockSession } else { - var err error - recipientExists, err = tmux.WindowExists(recipient) - if err != nil { - return nil, fmt.Errorf("failed to check recipient: %w", err) + // Try to extract session from sender address + addr, err := tmux.ParseAddress(sender, "") + if err == nil { + currentSession = addr.Session + } else { + var sessionErr error + currentSession, sessionErr = tmux.GetCurrentSession() + if sessionErr != nil { + return nil, fmt.Errorf("failed to get current session: %w", sessionErr) + } } } - if !recipientExists { + // Parse recipient address + addr, err := tmux.ParseAddress(recipient, currentSession) + if err != nil { return nil, fmt.Errorf("recipient not found") } + // Resolve recipient address + var resolvedRecipient string + if addr.Pane == -1 { + // Short form - need to resolve window name to pane address + var panes []string + if opts.MockPanes != nil { + panes = opts.MockPanes + } else { + panes, err = tmux.ListPanes() + if err != nil { + return nil, fmt.Errorf("failed to list panes: %w", err) + } + } + + // Find matching panes + var matches []string + for _, pane := range panes { + paneAddr, parseErr := tmux.ParseAddress(pane, currentSession) + if parseErr == nil && paneAddr.Window == addr.Window { + matches = append(matches, pane) + } + } + + if len(matches) == 0 { + return nil, fmt.Errorf("recipient not found") + } else if len(matches) > 1 { + // Ambiguous - suggest all matching panes + return nil, fmt.Errorf("Ambiguous recipient: window '%s' has %d panes. Use %s", addr.Window, len(matches), formatAddressList(matches)) + } + resolvedRecipient = matches[0] + } else { + // Full or medium form - validate pane exists + fullAddr := tmux.FormatAddress(addr) + var exists bool + if opts.MockPanes != nil { + for _, p := range opts.MockPanes { + if p == fullAddr { + exists = true + break + } + } + } else { + exists, err = tmux.PaneExists(fullAddr) + if err != nil { + return nil, fmt.Errorf("failed to check recipient: %w", err) + } + } + if !exists { + return nil, fmt.Errorf("recipient not found") + } + resolvedRecipient = fullAddr + } + // Check if sending to self (not allowed) - if recipient == sender { + if resolvedRecipient == sender { return nil, fmt.Errorf("cannot send message to self") } @@ -145,21 +201,17 @@ func doSend(ctx context.Context, recipient, message string) (any, error) { if opts.MockIgnoreList != nil { ignoreList = opts.MockIgnoreList } else { - // Determine git root for loading ignore list. - // Errors are intentionally ignored: if we can't find git root or load - // the ignore list, we proceed without filtering - this is acceptable - // as the ignore list is optional. gitRoot := opts.RepoRoot if gitRoot == "" { - gitRoot, _ = mail.FindGitRoot() // Error ignored: proceed without ignore list + gitRoot, _ = mail.FindGitRoot() } if gitRoot != "" { - ignoreList, _ = mail.LoadIgnoreList(gitRoot) // Error ignored: proceed without ignore list + ignoreList, _ = mail.LoadIgnoreList(gitRoot) } } // Check if recipient is in ignore list - if ignoreList != nil && ignoreList[recipient] { + if mail.IsIgnored(resolvedRecipient, ignoreList, currentSession) { return nil, fmt.Errorf("recipient not found") } @@ -182,7 +234,7 @@ func doSend(ctx context.Context, recipient, message string) (any, error) { msg := mail.Message{ ID: id, From: sender, - To: recipient, + To: resolvedRecipient, Message: message, ReadFlag: false, } @@ -197,6 +249,28 @@ func doSend(ctx context.Context, recipient, message string) (any, error) { }, nil } +// formatAddressList formats a list of addresses for error messages. +func formatAddressList(addresses []string) string { + if len(addresses) == 0 { + return "" + } + if len(addresses) == 1 { + return addresses[0] + } + result := "" + for i, addr := range addresses { + if i > 0 { + if i == len(addresses)-1 { + result += ", or " + } else { + result += ", " + } + } + result += addr + } + return result +} + // sendParams holds the unmarshaled parameters for the send tool. type sendParams struct { Recipient string `json:"recipient"` @@ -255,15 +329,15 @@ func doReceive(ctx context.Context) (any, error) { opts = &HandlerOptions{} } - // Get receiver identity + // Get receiver identity (pane address) var receiver string - if opts.MockReceiver != "" { - receiver = opts.MockReceiver + if opts.MockPaneAddress != "" { + receiver = opts.MockPaneAddress } else { var err error - receiver, err = tmux.GetCurrentWindow() + receiver, err = tmux.GetCurrentPaneAddress() if err != nil { - return nil, fmt.Errorf("failed to get current window: %w", err) + return nil, fmt.Errorf("failed to get current pane address: %w", err) } } @@ -364,15 +438,15 @@ func doStatus(ctx context.Context, status string) (any, error) { return nil, fmt.Errorf("Invalid status: %s. Valid: ready, work, offline", status) } - // Get agent identity (current tmux window) + // Get agent identity (current tmux pane address) var agent string - if opts.MockReceiver != "" { - agent = opts.MockReceiver + if opts.MockPaneAddress != "" { + agent = opts.MockPaneAddress } else { var err error - agent, err = tmux.GetCurrentWindow() + agent, err = tmux.GetCurrentPaneAddress() if err != nil { - return nil, fmt.Errorf("failed to get current window: %w", err) + return nil, fmt.Errorf("failed to get current pane address: %w", err) } } @@ -450,35 +524,52 @@ func handleStatus(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolR } // doListRecipients implements the list-recipients handler logic. -// It returns all available agents (tmux windows) with the current window marked. -// Ignored windows are excluded, but current window is always shown. +// It returns all available agents (tmux panes) with the current pane marked. +// Ignored panes are excluded, but current pane is always shown. func doListRecipients(ctx context.Context) (any, error) { opts := getHandlerOptions() if opts == nil { opts = &HandlerOptions{} } - // Get current window (agent identity) - var currentWindow string - if opts.MockReceiver != "" { - currentWindow = opts.MockReceiver + // Get current pane (agent identity) + var currentPane string + if opts.MockPaneAddress != "" { + currentPane = opts.MockPaneAddress } else { var err error - currentWindow, err = tmux.GetCurrentWindow() + currentPane, err = tmux.GetCurrentPaneAddress() if err != nil { - return nil, fmt.Errorf("failed to get current window: %w", err) + return nil, fmt.Errorf("failed to get current pane address: %w", err) + } + } + + // Get current session for ignore matching + var currentSession string + if opts.MockSession != "" { + currentSession = opts.MockSession + } else { + addr, err := tmux.ParseAddress(currentPane, "") + if err == nil { + currentSession = addr.Session + } else { + var sessionErr error + currentSession, sessionErr = tmux.GetCurrentSession() + if sessionErr != nil { + return nil, fmt.Errorf("failed to get current session: %w", sessionErr) + } } } - // Get list of all windows - var windows []string - if opts.MockWindows != nil { - windows = opts.MockWindows + // Get list of all panes + var panes []string + if opts.MockPanes != nil { + panes = opts.MockPanes } else { var err error - windows, err = tmux.ListWindows() + panes, err = tmux.ListPanes() if err != nil { - return nil, fmt.Errorf("failed to list windows: %w", err) + return nil, fmt.Errorf("failed to list panes: %w", err) } } @@ -487,32 +578,28 @@ func doListRecipients(ctx context.Context) (any, error) { if opts.MockIgnoreList != nil { ignoreList = opts.MockIgnoreList } else { - // Determine git root for loading ignore list. - // Errors are intentionally ignored: if we can't find git root or load - // the ignore list, we proceed without filtering - this is acceptable - // as the ignore list is optional. gitRoot := opts.RepoRoot if gitRoot == "" { - gitRoot, _ = mail.FindGitRoot() // Error ignored: proceed without ignore list + gitRoot, _ = mail.FindGitRoot() } if gitRoot != "" { - ignoreList, _ = mail.LoadIgnoreList(gitRoot) // Error ignored: proceed without ignore list + ignoreList, _ = mail.LoadIgnoreList(gitRoot) } } - // Build recipients list, filtering ignored windows but always including current + // Build recipients list, filtering ignored panes but always including current recipients := []RecipientInfo{} - for _, window := range windows { - // Current window is always shown (even if in ignore list) - if window == currentWindow { + for _, pane := range panes { + // Current pane is always shown (even if in ignore list) + if pane == currentPane { recipients = append(recipients, RecipientInfo{ - Name: window, + Address: pane, IsCurrent: true, }) - } else if ignoreList == nil || !ignoreList[window] { - // Only show non-current windows if they're not in the ignore list + } else if !mail.IsIgnored(pane, ignoreList, currentSession) { + // Only show non-current panes if they're not ignored recipients = append(recipients, RecipientInfo{ - Name: window, + Address: pane, IsCurrent: false, }) } diff --git a/internal/mcp/handlers_test.go b/internal/mcp/handlers_test.go index d7accfc..9ee1d49 100644 --- a/internal/mcp/handlers_test.go +++ b/internal/mcp/handlers_test.go @@ -73,9 +73,9 @@ func TestReceiveHandler_ReturnsOldestUnreadMessage(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "agent-2", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-2", + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -127,9 +127,9 @@ func TestReceiveHandler_NoMessagesReturnsEmptyStatus(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "agent-2", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-2", + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -179,9 +179,9 @@ func TestReceiveHandler_AllMessagesReadReturnsEmptyStatus(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "agent-2", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-2", + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -229,9 +229,9 @@ func TestReceiveHandler_MessageMarkedAsReadAfterReceive(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "agent-2", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-2", + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -270,9 +270,9 @@ func TestReceiveHandler_ResponseFieldsMatchDataModel(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "receiver-agent", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "receiver-agent", + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -324,9 +324,9 @@ func TestReceiveHandler_OutputMatchesCLIFormat(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "cli-receiver", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "cli-receiver", + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -380,9 +380,9 @@ func TestReceiveHandler_ConsecutiveReceivesFIFOOrder(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "agent-b", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-b", + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -447,9 +447,9 @@ func TestReceiveHandler_SkipsReadMessages(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "agent-2", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-2", + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -481,9 +481,9 @@ func TestReceiveHandler_EmptyResponseStructure(t *testing.T) { // Configure handler for testing (empty mailbox) SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "agent-2", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-2", + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -524,9 +524,9 @@ func TestReceiveHandler_MCPClientIntegration(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "mcp-receiver", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "mcp-receiver", + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -582,10 +582,10 @@ func TestSendHandler_DeliversMessageAndReturnsID(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockSender: "agent-sender", - MockWindows: []string{"agent-sender", "agent-receiver"}, - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-sender", + MockPanes: []string{"agent-sender", "agent-receiver"}, + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -643,10 +643,10 @@ func TestSendHandler_InvalidRecipientReturnsError(t *testing.T) { // Configure handler for testing - nonexistent-agent not in MockWindows SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockSender: "agent-sender", - MockWindows: []string{"agent-sender", "agent-receiver"}, - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-sender", + MockPanes: []string{"agent-sender", "agent-receiver"}, + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -684,10 +684,10 @@ func TestSendHandler_OversizedMessageReturnsError(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockSender: "agent-sender", - MockWindows: []string{"agent-sender", "agent-receiver"}, - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-sender", + MockPanes: []string{"agent-sender", "agent-receiver"}, + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -728,10 +728,10 @@ func TestSendHandler_EmptyMessageReturnsError(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockSender: "agent-sender", - MockWindows: []string{"agent-sender", "agent-receiver"}, - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-sender", + MockPanes: []string{"agent-sender", "agent-receiver"}, + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -769,10 +769,10 @@ func TestSendHandler_SendToSelfReturnsError(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockSender: "agent-self", - MockWindows: []string{"agent-self"}, - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-self", + MockPanes: []string{"agent-self"}, + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -816,11 +816,11 @@ func TestSendHandler_IgnoredRecipientReturnsError(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockSender: "agent-sender", - MockWindows: []string{"agent-sender", "ignored-agent"}, - RepoRoot: tmpDir, - MockIgnoreList: map[string]bool{"ignored-agent": true}, + SkipTmuxCheck: true, + MockPaneAddress: "agent-sender", + MockPanes: []string{"agent-sender", "ignored-agent"}, + RepoRoot: tmpDir, + MockIgnoreList: map[string]bool{"ignored-agent": true}, }) defer SetHandlerOptions(nil) @@ -854,10 +854,10 @@ func TestSendHandler_ResponseFormat(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockSender: "agent-sender", - MockWindows: []string{"agent-sender", "agent-receiver"}, - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-sender", + MockPanes: []string{"agent-sender", "agent-receiver"}, + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -902,11 +902,10 @@ func TestSendHandler_MessageReadableViaCLI(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockSender: "mcp-sender", - MockReceiver: "cli-receiver", - MockWindows: []string{"mcp-sender", "cli-receiver"}, - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "mcp-sender", + MockPanes: []string{"mcp-sender", "cli-receiver"}, + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -929,6 +928,14 @@ func TestSendHandler_MessageReadableViaCLI(t *testing.T) { } // Now receive the message via MCP receive handler (simulates CLI receive) + // Update handler options to receive as cli-receiver + SetHandlerOptions(&HandlerOptions{ + SkipTmuxCheck: true, + MockPaneAddress: "cli-receiver", + MockPanes: []string{"mcp-sender", "cli-receiver"}, + RepoRoot: tmpDir, + }) + receiveResult, err := receiveHandler(ctx, &mcp.CallToolRequest{}) if err != nil { t.Fatalf("receiveHandler returned error: %v", err) @@ -964,10 +971,10 @@ func TestSendHandler_MCPClientIntegration(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockSender: "mcp-client-sender", - MockWindows: []string{"mcp-client-sender", "mcp-client-receiver"}, - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "mcp-client-sender", + MockPanes: []string{"mcp-client-sender", "mcp-client-receiver"}, + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -1032,10 +1039,10 @@ func TestSendHandler_ExactlyMaxSizeSucceeds(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockSender: "agent-sender", - MockWindows: []string{"agent-sender", "agent-receiver"}, - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-sender", + MockPanes: []string{"agent-sender", "agent-receiver"}, + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -1091,9 +1098,9 @@ func TestStatusHandler_UpdatesStatusAndReturnsOk(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "test-agent", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "test-agent", + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -1151,9 +1158,9 @@ func TestStatusHandler_InvalidValueReturnsError(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "test-agent", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "test-agent", + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -1216,9 +1223,9 @@ func TestStatusHandler_ResetsNotifiedFlagOnWorkOrOffline(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "test-agent", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "test-agent", + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -1286,9 +1293,9 @@ func TestStatusHandler_ReadyDoesNotResetNotifiedFlag(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "test-agent", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "test-agent", + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -1327,9 +1334,9 @@ func TestStatusHandler_ResponseFormat(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "test-agent", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "test-agent", + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -1374,9 +1381,9 @@ func TestStatusHandler_MCPClientIntegration(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "mcp-status-agent", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "mcp-status-agent", + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -1433,9 +1440,9 @@ func TestStatusHandler_AllValidStatuses(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "test-agent", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "test-agent", + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -1487,10 +1494,10 @@ func TestListRecipientsHandler_ReturnsAllAgents(t *testing.T) { // Configure handler for testing with multiple windows SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "agent-1", - MockWindows: []string{"agent-1", "agent-2", "agent-3"}, - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-1", + MockPanes: []string{"agent-1", "agent-2", "agent-3"}, + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -1529,7 +1536,7 @@ func TestListRecipientsHandler_ReturnsAllAgents(t *testing.T) { // Verify all windows are present found := make(map[string]bool) for _, r := range response.Recipients { - found[r.Name] = true + found[r.Address] = true } for _, expected := range []string{"agent-1", "agent-2", "agent-3"} { if !found[expected] { @@ -1545,10 +1552,10 @@ func TestListRecipientsHandler_CurrentWindowMarkedIsCurrent(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "agent-2", - MockWindows: []string{"agent-1", "agent-2", "agent-3"}, - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-2", + MockPanes: []string{"agent-1", "agent-2", "agent-3"}, + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -1574,14 +1581,14 @@ func TestListRecipientsHandler_CurrentWindowMarkedIsCurrent(t *testing.T) { // Verify current window is marked correctly currentCount := 0 for _, r := range response.Recipients { - if r.Name == "agent-2" { + if r.Address == "agent-2" { if !r.IsCurrent { t.Error("Current window 'agent-2' should have is_current: true") } currentCount++ } else { if r.IsCurrent { - t.Errorf("Non-current window '%s' should have is_current: false", r.Name) + t.Errorf("Non-current window '%s' should have is_current: false", r.Address) } } } @@ -1599,11 +1606,11 @@ func TestListRecipientsHandler_IgnoredWindowsExcluded(t *testing.T) { // Configure handler for testing with ignored windows SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "agent-1", - MockWindows: []string{"agent-1", "agent-2", "ignored-agent", "agent-3"}, - MockIgnoreList: map[string]bool{"ignored-agent": true}, - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-1", + MockPanes: []string{"agent-1", "agent-2", "ignored-agent", "agent-3"}, + MockIgnoreList: map[string]bool{"ignored-agent": true}, + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -1633,7 +1640,7 @@ func TestListRecipientsHandler_IgnoredWindowsExcluded(t *testing.T) { // Verify ignored window is not in the list for _, r := range response.Recipients { - if r.Name == "ignored-agent" { + if r.Address == "ignored-agent" { t.Error("Ignored window 'ignored-agent' should not be in recipients list") } } @@ -1646,11 +1653,11 @@ func TestListRecipientsHandler_CurrentWindowShownEvenIfIgnored(t *testing.T) { // Configure handler where current window is in ignore list SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "ignored-current", - MockWindows: []string{"agent-1", "ignored-current", "agent-2"}, - MockIgnoreList: map[string]bool{"ignored-current": true}, - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "ignored-current", + MockPanes: []string{"agent-1", "ignored-current", "agent-2"}, + MockIgnoreList: map[string]bool{"ignored-current": true}, + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -1681,7 +1688,7 @@ func TestListRecipientsHandler_CurrentWindowShownEvenIfIgnored(t *testing.T) { // Verify current window is in the list and marked as current found := false for _, r := range response.Recipients { - if r.Name == "ignored-current" { + if r.Address == "ignored-current" { found = true if !r.IsCurrent { t.Error("Current window 'ignored-current' should have is_current: true") @@ -1700,10 +1707,10 @@ func TestListRecipientsHandler_ResponseFormat(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "test-agent", - MockWindows: []string{"test-agent", "other-agent"}, - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "test-agent", + MockPanes: []string{"test-agent", "other-agent"}, + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -1740,8 +1747,8 @@ func TestListRecipientsHandler_ResponseFormat(t *testing.T) { if !ok { t.Fatalf("recipient %d is not an object", i) } - if _, ok := recipient["name"]; !ok { - t.Errorf("recipient %d missing 'name' field", i) + if _, ok := recipient["address"]; !ok { + t.Errorf("recipient %d missing 'address' field", i) } if _, ok := recipient["is_current"]; !ok { t.Errorf("recipient %d missing 'is_current' field", i) @@ -1757,10 +1764,10 @@ func TestListRecipientsHandler_EmptyWindowsList(t *testing.T) { // Configure handler with no windows (edge case) // MockReceiver must be non-empty to be recognized as mocked SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "test-window", - MockWindows: []string{}, - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "test-window", + MockPanes: []string{}, + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -1796,10 +1803,10 @@ func TestListRecipientsHandler_MCPClientIntegration(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "mcp-agent", - MockWindows: []string{"mcp-agent", "other-agent"}, - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "mcp-agent", + MockPanes: []string{"mcp-agent", "other-agent"}, + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -1851,11 +1858,11 @@ func TestListRecipientsHandler_MultipleIgnoredWindowsExcluded(t *testing.T) { // Configure handler with multiple ignored windows SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "agent-1", - MockWindows: []string{"agent-1", "ignored-1", "agent-2", "ignored-2", "agent-3"}, - MockIgnoreList: map[string]bool{"ignored-1": true, "ignored-2": true}, - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-1", + MockPanes: []string{"agent-1", "ignored-1", "agent-2", "ignored-2", "agent-3"}, + MockIgnoreList: map[string]bool{"ignored-1": true, "ignored-2": true}, + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -1880,8 +1887,8 @@ func TestListRecipientsHandler_MultipleIgnoredWindowsExcluded(t *testing.T) { // Verify neither ignored window is in the list for _, r := range response.Recipients { - if r.Name == "ignored-1" || r.Name == "ignored-2" { - t.Errorf("Ignored window '%s' should not be in recipients list", r.Name) + if r.Address == "ignored-1" || r.Address == "ignored-2" { + t.Errorf("Ignored window '%s' should not be in recipients list", r.Address) } } } @@ -1893,10 +1900,10 @@ func TestListRecipientsHandler_SingleWindow(t *testing.T) { // Configure handler with only one window SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "solo-agent", - MockWindows: []string{"solo-agent"}, - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "solo-agent", + MockPanes: []string{"solo-agent"}, + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -1921,8 +1928,8 @@ func TestListRecipientsHandler_SingleWindow(t *testing.T) { // Should be marked as current if len(response.Recipients) > 0 { - if response.Recipients[0].Name != "solo-agent" { - t.Errorf("Expected name 'solo-agent', got '%s'", response.Recipients[0].Name) + if response.Recipients[0].Address != "solo-agent" { + t.Errorf("Expected name 'solo-agent', got '%s'", response.Recipients[0].Address) } if !response.Recipients[0].IsCurrent { t.Error("Solo window should be marked as current") @@ -1978,10 +1985,10 @@ func TestSendHandler_MissingRecipientReturnsError(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockSender: "agent-sender", - MockWindows: []string{"agent-sender", "agent-receiver"}, - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-sender", + MockPanes: []string{"agent-sender", "agent-receiver"}, + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -2015,10 +2022,10 @@ func TestSendHandler_MissingMessageReturnsError(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockSender: "agent-sender", - MockWindows: []string{"agent-sender", "agent-receiver"}, - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-sender", + MockPanes: []string{"agent-sender", "agent-receiver"}, + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -2052,9 +2059,9 @@ func TestStatusHandler_MissingStatusReturnsError(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "test-agent", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "test-agent", + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -2098,9 +2105,9 @@ func TestReceiveHandler_NoMockReceiverWithoutTmuxReturnsError(t *testing.T) { // Configure handler for testing - NO MockReceiver set, SkipTmuxCheck false // This will cause tmux.GetCurrentWindow() to fail if not in tmux SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: false, // Enable tmux check - MockReceiver: "", // No mock receiver - RepoRoot: tmpDir, + SkipTmuxCheck: false, // Enable tmux check + MockPaneAddress: "", // No mock receiver + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -2122,8 +2129,8 @@ func TestReceiveHandler_NoMockReceiverWithoutTmuxReturnsError(t *testing.T) { t.Fatalf("receiveHandler error content is not TextContent, got %T", result.Content[0]) } - if !strings.Contains(textContent.Text, "failed to get current window") { - t.Errorf("Expected error to contain 'failed to get current window', got: %s", textContent.Text) + if !strings.Contains(textContent.Text, "failed to get current pane address") { + t.Errorf("Expected error to contain 'failed to get current pane address', got: %s", textContent.Text) } } @@ -2132,9 +2139,9 @@ func TestReceiveHandler_InvalidRepoRootReturnsError(t *testing.T) { // Configure handler with no RepoRoot and MockReceiver set // The handler will try to find git root which should fail in a non-existent directory SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "test-agent", - RepoRoot: "", // Empty - will try to find git root + SkipTmuxCheck: true, + MockPaneAddress: "test-agent", + RepoRoot: "", // Empty - will try to find git root }) defer SetHandlerOptions(nil) @@ -2188,10 +2195,10 @@ func TestSendHandler_NoMockSenderWithoutTmuxReturnsError(t *testing.T) { // Configure handler - NO MockSender set, SkipTmuxCheck false SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: false, - MockSender: "", // No mock sender - MockWindows: []string{"agent-sender", "agent-receiver"}, // Mock windows still set for recipient check - RepoRoot: tmpDir, + SkipTmuxCheck: false, + MockPaneAddress: "", // No mock sender + MockPanes: []string{"agent-sender", "agent-receiver"}, // Mock windows still set for recipient check + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -2213,8 +2220,8 @@ func TestSendHandler_NoMockSenderWithoutTmuxReturnsError(t *testing.T) { t.Fatalf("sendHandler error content is not TextContent, got %T", result.Content[0]) } - if !strings.Contains(textContent.Text, "failed to get current window") { - t.Errorf("Expected error to contain 'failed to get current window', got: %s", textContent.Text) + if !strings.Contains(textContent.Text, "failed to get current pane address") { + t.Errorf("Expected error to contain 'failed to get current pane address', got: %s", textContent.Text) } } @@ -2222,10 +2229,10 @@ func TestSendHandler_NoMockSenderWithoutTmuxReturnsError(t *testing.T) { func TestSendHandler_InvalidRepoRootReturnsError(t *testing.T) { // Configure handler with no RepoRoot SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockSender: "agent-sender", - MockWindows: []string{"agent-sender", "agent-receiver"}, - RepoRoot: "", // Empty - will try to find git root + SkipTmuxCheck: true, + MockPaneAddress: "agent-sender", + MockPanes: []string{"agent-sender", "agent-receiver"}, + RepoRoot: "", // Empty - will try to find git root }) defer SetHandlerOptions(nil) @@ -2279,9 +2286,9 @@ func TestStatusHandler_NoMockReceiverWithoutTmuxReturnsError(t *testing.T) { // Configure handler - NO MockReceiver set, SkipTmuxCheck false SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: false, // Enable tmux check - MockReceiver: "", // No mock receiver - RepoRoot: tmpDir, + SkipTmuxCheck: false, // Enable tmux check + MockPaneAddress: "", // No mock receiver + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -2303,8 +2310,8 @@ func TestStatusHandler_NoMockReceiverWithoutTmuxReturnsError(t *testing.T) { t.Fatalf("statusHandler error content is not TextContent, got %T", result.Content[0]) } - if !strings.Contains(textContent.Text, "failed to get current window") { - t.Errorf("Expected error to contain 'failed to get current window', got: %s", textContent.Text) + if !strings.Contains(textContent.Text, "failed to get current pane address") { + t.Errorf("Expected error to contain 'failed to get current pane address', got: %s", textContent.Text) } } @@ -2312,9 +2319,9 @@ func TestStatusHandler_NoMockReceiverWithoutTmuxReturnsError(t *testing.T) { func TestStatusHandler_InvalidRepoRootReturnsError(t *testing.T) { // Configure handler with no RepoRoot SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "test-agent", - RepoRoot: "", // Empty - will try to find git root + SkipTmuxCheck: true, + MockPaneAddress: "test-agent", + RepoRoot: "", // Empty - will try to find git root }) defer SetHandlerOptions(nil) @@ -2368,10 +2375,10 @@ func TestListRecipientsHandler_NoMockWindowsWithoutTmuxReturnsError(t *testing.T // Configure handler - NO MockWindows and NO MockReceiver, SkipTmuxCheck false SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: false, // Enable tmux check - MockReceiver: "", // No mock receiver - MockWindows: nil, // No mock windows - will try real tmux - RepoRoot: tmpDir, + SkipTmuxCheck: false, // Enable tmux check + MockPaneAddress: "", // No mock receiver + MockPanes: nil, // No mock windows - will try real tmux + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -2393,8 +2400,8 @@ func TestListRecipientsHandler_NoMockWindowsWithoutTmuxReturnsError(t *testing.T t.Fatalf("listRecipientsHandler error content is not TextContent, got %T", result.Content[0]) } - if !strings.Contains(textContent.Text, "failed to get current window") && - !strings.Contains(textContent.Text, "failed to list windows") { + if !strings.Contains(textContent.Text, "failed to get current pane address") && + !strings.Contains(textContent.Text, "failed to list panes") { t.Errorf("Expected error related to tmux failure, got: %s", textContent.Text) } } @@ -2413,10 +2420,10 @@ func TestListRecipientsHandler_ListWindowsFailsReturnsError(t *testing.T) { // Configure handler with MockReceiver but NO MockWindows // This will use MockReceiver for current window but will try real tmux.ListWindows() SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "test-agent", - MockWindows: nil, // nil triggers real tmux.ListWindows() call - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "test-agent", + MockPanes: nil, // nil triggers real tmux.ListWindows() call + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -2438,8 +2445,8 @@ func TestListRecipientsHandler_ListWindowsFailsReturnsError(t *testing.T) { t.Fatalf("listRecipientsHandler error content is not TextContent, got %T", result.Content[0]) } - if !strings.Contains(textContent.Text, "failed to list windows") { - t.Errorf("Expected error to contain 'failed to list windows', got: %s", textContent.Text) + if !strings.Contains(textContent.Text, "failed to list panes") { + t.Errorf("Expected error to contain 'failed to list panes', got: %s", textContent.Text) } } @@ -2450,10 +2457,10 @@ func TestSendHandler_InvalidJSONArgumentsReturnsError(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockSender: "agent-sender", - MockWindows: []string{"agent-sender", "agent-receiver"}, - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-sender", + MockPanes: []string{"agent-sender", "agent-receiver"}, + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -2494,9 +2501,9 @@ func TestStatusHandler_InvalidJSONArgumentsReturnsError(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "test-agent", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "test-agent", + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -2537,10 +2544,10 @@ func TestSendHandler_NilParamsReturnsError(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockSender: "agent-sender", - MockWindows: []string{"agent-sender", "agent-receiver"}, - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-sender", + MockPanes: []string{"agent-sender", "agent-receiver"}, + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -2578,9 +2585,9 @@ func TestStatusHandler_NilParamsReturnsError(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockReceiver: "test-agent", - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "test-agent", + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -2618,10 +2625,10 @@ func TestSendHandler_NilArgumentsReturnsError(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockSender: "agent-sender", - MockWindows: []string{"agent-sender", "agent-receiver"}, - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-sender", + MockPanes: []string{"agent-sender", "agent-receiver"}, + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -2666,11 +2673,10 @@ func TestToolInvocations_CompleteWithinTwoSeconds(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockSender: "agent-sender", - MockReceiver: "agent-receiver", - MockWindows: []string{"agent-sender", "agent-receiver", "agent-other"}, - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-sender", + MockPanes: []string{"agent-sender", "agent-receiver", "agent-other"}, + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -2739,11 +2745,10 @@ func TestServer_100ConsecutiveInvocations(t *testing.T) { // Configure handler for testing SetHandlerOptions(&HandlerOptions{ - SkipTmuxCheck: true, - MockSender: "agent-sender", - MockReceiver: "agent-receiver", - MockWindows: []string{"agent-sender", "agent-receiver"}, - RepoRoot: tmpDir, + SkipTmuxCheck: true, + MockPaneAddress: "agent-sender", + MockPanes: []string{"agent-sender", "agent-receiver"}, + RepoRoot: tmpDir, }) defer SetHandlerOptions(nil) @@ -2804,3 +2809,73 @@ func TestServer_100ConsecutiveInvocations(t *testing.T) { len(errors), strings.Join(errors[:maxErrors], "\n")) } } + +func TestFormatAddressList_Empty(t *testing.T) { + result := formatAddressList([]string{}) + if result != "" { + t.Errorf("Expected empty string for empty list, got: %s", result) + } +} + +func TestFormatAddressList_Single(t *testing.T) { + result := formatAddressList([]string{"mysession:agent.0"}) + if result != "mysession:agent.0" { + t.Errorf("Expected 'mysession:agent.0', got: %s", result) + } +} + +func TestFormatAddressList_Two(t *testing.T) { + result := formatAddressList([]string{"mysession:agent.0", "mysession:agent.1"}) + expected := "mysession:agent.0, or mysession:agent.1" + if result != expected { + t.Errorf("Expected '%s', got: %s", expected, result) + } +} + +func TestFormatAddressList_Multiple(t *testing.T) { + result := formatAddressList([]string{"mysession:agent.0", "mysession:agent.1", "mysession:agent.2"}) + expected := "mysession:agent.0, mysession:agent.1, or mysession:agent.2" + if result != expected { + t.Errorf("Expected '%s', got: %s", expected, result) + } +} + +func TestListRecipientsHandler_NoPanes(t *testing.T) { + tmpDir := setupTestMailbox(t) + defer os.RemoveAll(tmpDir) + + SetHandlerOptions(&HandlerOptions{ + SkipTmuxCheck: true, + RepoRoot: tmpDir, + MockPanes: []string{}, // No panes + MockPaneAddress: "mysession:sender.0", + MockSession: "mysession", + }) + defer SetHandlerOptions(nil) + + ctx := context.Background() + req := &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{ + Name: ToolListRecipients, + }, + } + + result, err := listRecipientsHandler(ctx, req) + if err != nil { + t.Errorf("listRecipientsHandler should not error with no panes: %v", err) + } + if result == nil { + t.Fatal("Expected result for no panes") + } + + if len(result.Content) == 0 { + t.Fatal("Expected content in result") + } + + // Check first content item + textContent := result.Content[0].(*mcp.TextContent) + + if textContent.Text != `{"recipients":[]}` { + t.Errorf("Expected '{\"recipients\":[]}', got: %s", textContent.Text) + } +} diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 096e32e..41b4a3d 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -21,7 +21,7 @@ const ( // SendArgs represents the input parameters for the send tool. type SendArgs struct { - // Recipient is the tmux window name of the recipient agent. + // Recipient is the pane address of the recipient agent (session:window.pane, :window.pane, or window). Recipient string `json:"recipient"` // Message is the message content to send (max 64KB). Message string `json:"message"` @@ -49,7 +49,7 @@ func sendToolSchema() json.RawMessage { "properties": { "recipient": { "type": "string", - "description": "The tmux window name of the recipient agent" + "description": "The pane address of the recipient agent (session:window.pane, :window.pane, or window)" }, "message": { "type": "string", @@ -106,7 +106,7 @@ func RegisterTools(s *Server) { // Register send tool with explicit schema mcpServer.AddTool(&mcp.Tool{ Name: ToolSend, - Description: "Send a message to another agent in a tmux window", + Description: "Send a message to another agent in a tmux pane", InputSchema: sendToolSchema(), }, sendHandler) @@ -127,7 +127,7 @@ func RegisterTools(s *Server) { // Register list-recipients tool with explicit schema mcpServer.AddTool(&mcp.Tool{ Name: ToolListRecipients, - Description: "List all available agents that can receive messages", + Description: "List all available agent panes that can receive messages", InputSchema: listRecipientsToolSchema(), }, listRecipientsHandler) } diff --git a/internal/mcp/tools_test.go b/internal/mcp/tools_test.go index 901c1ea..4b64508 100644 --- a/internal/mcp/tools_test.go +++ b/internal/mcp/tools_test.go @@ -148,7 +148,7 @@ func TestSendTool_SchemaValidation(t *testing.T) { } // Verify description - expectedDesc := "Send a message to another agent in a tmux window" + expectedDesc := "Send a message to another agent in a tmux pane" if sendTool.Description != expectedDesc { t.Errorf("send tool description mismatch: got %q, want %q", sendTool.Description, expectedDesc) } @@ -364,7 +364,7 @@ func TestListRecipientsTool_SchemaValidation(t *testing.T) { } // Verify description - expectedDesc := "List all available agents that can receive messages" + expectedDesc := "List all available agent panes that can receive messages" if listRecipientsTool.Description != expectedDesc { t.Errorf("list-recipients tool description mismatch: got %q, want %q", listRecipientsTool.Description, expectedDesc) } diff --git a/internal/tmux/address.go b/internal/tmux/address.go new file mode 100644 index 0000000..9ce3ac4 --- /dev/null +++ b/internal/tmux/address.go @@ -0,0 +1,95 @@ +package tmux + +import ( + "errors" + "fmt" + "regexp" + "strconv" + "strings" +) + +// PaneAddress represents a parsed tmux pane address. +type PaneAddress struct { + Session string + Window string + Pane int + Full string +} + +var ( + fullFormPattern = regexp.MustCompile(`^([^:]+):(.+)\.(\d+)$`) + mediumFormPattern = regexp.MustCompile(`^:(.+)\.(\d+)$`) +) + +// ParseAddress parses a tmux pane address string. +func ParseAddress(input string, currentSession string) (*PaneAddress, error) { + if input == "" { + return nil, errors.New("empty address") + } + + // Try full form: session:window.pane + if matches := fullFormPattern.FindStringSubmatch(input); matches != nil { + pane, err := strconv.Atoi(matches[3]) + if err != nil { + return nil, errors.New("invalid pane number") + } + addr := &PaneAddress{ + Session: matches[1], + Window: matches[2], + Pane: pane, + } + addr.Full = FormatAddress(addr) + return addr, nil + } + + // Try medium form: :window.pane + if matches := mediumFormPattern.FindStringSubmatch(input); matches != nil { + pane, err := strconv.Atoi(matches[2]) + if err != nil { + return nil, errors.New("invalid pane number") + } + addr := &PaneAddress{ + Session: currentSession, + Window: matches[1], + Pane: pane, + } + addr.Full = FormatAddress(addr) + return addr, nil + } + + // Short form: window (no colon, no pane) + if !strings.Contains(input, ":") && !strings.HasPrefix(input, ".") { + return &PaneAddress{ + Session: "", + Window: input, + Pane: -1, + Full: input, + }, nil + } + + return nil, errors.New("invalid address format") +} + +// FormatAddress formats a PaneAddress as a string. +func FormatAddress(addr *PaneAddress) string { + return fmt.Sprintf("%s:%s.%d", addr.Session, addr.Window, addr.Pane) +} + +// SanitizeForFilename converts a pane address to a filesystem-safe filename. +func SanitizeForFilename(address string) string { + result := strings.ReplaceAll(address, "%", "%25") + result = strings.ReplaceAll(result, ":", "%3A") + lastDot := strings.LastIndex(result, ".") + if lastDot != -1 { + result = result[:lastDot] + "%2E" + result[lastDot+1:] + } + return result +} + +// UnsanitizeFromFilename converts a sanitized filename back to a pane address. +func UnsanitizeFromFilename(filename string) string { + result := strings.ReplaceAll(filename, "%2E", ".") + result = strings.ReplaceAll(result, "%3A", ":") + result = strings.ReplaceAll(result, "%25", "%") + return result +} diff --git a/internal/tmux/address_test.go b/internal/tmux/address_test.go new file mode 100644 index 0000000..0d7f586 --- /dev/null +++ b/internal/tmux/address_test.go @@ -0,0 +1,353 @@ +package tmux + +import ( + "testing" +) + +// Tests for ParseAddress + +func TestParseAddress_FullForm(t *testing.T) { + tests := []struct { + name string + input string + currentSession string + wantSession string + wantWindow string + wantPane int + wantErr bool + }{ + { + name: "basic full form", + input: "mysession:editor.0", + currentSession: "", + wantSession: "mysession", + wantWindow: "editor", + wantPane: 0, + wantErr: false, + }, + { + name: "full form with pane 1", + input: "mysession:editor.1", + currentSession: "", + wantSession: "mysession", + wantWindow: "editor", + wantPane: 1, + wantErr: false, + }, + { + name: "full form with dotted window name", + input: "mysession:my.app.0", + currentSession: "", + wantSession: "mysession", + wantWindow: "my.app", + wantPane: 0, + wantErr: false, + }, + { + name: "full form multi-digit pane", + input: "s:w.12", + currentSession: "", + wantSession: "s", + wantWindow: "w", + wantPane: 12, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + addr, err := ParseAddress(tt.input, tt.currentSession) + if (err != nil) != tt.wantErr { + t.Errorf("ParseAddress() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err == nil { + if addr.Session != tt.wantSession { + t.Errorf("ParseAddress() Session = %v, want %v", addr.Session, tt.wantSession) + } + if addr.Window != tt.wantWindow { + t.Errorf("ParseAddress() Window = %v, want %v", addr.Window, tt.wantWindow) + } + if addr.Pane != tt.wantPane { + t.Errorf("ParseAddress() Pane = %v, want %v", addr.Pane, tt.wantPane) + } + } + }) + } +} + +func TestParseAddress_MediumForm(t *testing.T) { + tests := []struct { + name string + input string + currentSession string + wantSession string + wantWindow string + wantPane int + wantErr bool + }{ + { + name: "medium form", + input: ":editor.1", + currentSession: "mysession", + wantSession: "mysession", + wantWindow: "editor", + wantPane: 1, + wantErr: false, + }, + { + name: "medium form with dotted window", + input: ":my.app.0", + currentSession: "mysession", + wantSession: "mysession", + wantWindow: "my.app", + wantPane: 0, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + addr, err := ParseAddress(tt.input, tt.currentSession) + if (err != nil) != tt.wantErr { + t.Errorf("ParseAddress() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err == nil { + if addr.Session != tt.wantSession { + t.Errorf("ParseAddress() Session = %v, want %v", addr.Session, tt.wantSession) + } + if addr.Window != tt.wantWindow { + t.Errorf("ParseAddress() Window = %v, want %v", addr.Window, tt.wantWindow) + } + if addr.Pane != tt.wantPane { + t.Errorf("ParseAddress() Pane = %v, want %v", addr.Pane, tt.wantPane) + } + } + }) + } +} + +func TestParseAddress_ShortForm(t *testing.T) { + tests := []struct { + name string + input string + currentSession string + wantSession string + wantWindow string + wantPane int + wantErr bool + }{ + { + name: "short form simple window", + input: "editor", + currentSession: "", + wantSession: "", + wantWindow: "editor", + wantPane: -1, + wantErr: false, + }, + { + name: "short form with dots in window name", + input: "my.app", + currentSession: "", + wantSession: "", + wantWindow: "my.app", + wantPane: -1, + wantErr: false, + }, + { + name: "short form with dots like logs.1", + input: "logs.1", + currentSession: "", + wantSession: "", + wantWindow: "logs.1", + wantPane: -1, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + addr, err := ParseAddress(tt.input, tt.currentSession) + if (err != nil) != tt.wantErr { + t.Errorf("ParseAddress() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err == nil { + if addr.Session != tt.wantSession { + t.Errorf("ParseAddress() Session = %v, want %v", addr.Session, tt.wantSession) + } + if addr.Window != tt.wantWindow { + t.Errorf("ParseAddress() Window = %v, want %v", addr.Window, tt.wantWindow) + } + if addr.Pane != tt.wantPane { + t.Errorf("ParseAddress() Pane = %v, want %v", addr.Pane, tt.wantPane) + } + } + }) + } +} + +func TestParseAddress_Invalid(t *testing.T) { + tests := []struct { + name string + input string + currentSession string + }{ + { + name: "empty string", + input: "", + currentSession: "", + }, + { + name: "colon alone", + input: ":", + currentSession: "", + }, + { + name: "starts with dot", + input: ".1", + currentSession: "", + }, + { + name: "invalid pane number", + input: "session:window.notanumber", + currentSession: "", + }, + { + name: "empty pane after dot", + input: "session:window.", + currentSession: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseAddress(tt.input, tt.currentSession) + if err == nil { + t.Errorf("ParseAddress() should return error for invalid input %q", tt.input) + } + }) + } +} + +// Tests for FormatAddress + +func TestFormatAddress(t *testing.T) { + tests := []struct { + name string + addr *PaneAddress + want string + }{ + { + name: "basic address", + addr: &PaneAddress{Session: "s", Window: "w", Pane: 0}, + want: "s:w.0", + }, + { + name: "address with dotted window", + addr: &PaneAddress{Session: "mysession", Window: "my.app", Pane: 1}, + want: "mysession:my.app.1", + }, + { + name: "address with multi-digit pane", + addr: &PaneAddress{Session: "s", Window: "w", Pane: 12}, + want: "s:w.12", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FormatAddress(tt.addr) + if got != tt.want { + t.Errorf("FormatAddress() = %v, want %v", got, tt.want) + } + }) + } +} + +// Tests for SanitizeForFilename + +func TestSanitizeForFilename(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "basic address", + input: "mysession:editor.0", + want: "mysession%3Aeditor%2E0", + }, + { + name: "address with percent character", + input: "my%session:win.0", + want: "my%25session%3Awin%2E0", + }, + { + name: "address with dotted window name", + input: "s:my.app.0", + want: "s%3Amy.app%2E0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SanitizeForFilename(tt.input) + if got != tt.want { + t.Errorf("SanitizeForFilename() = %v, want %v", got, tt.want) + } + }) + } +} + +// Tests for UnsanitizeFromFilename + +func TestUnsanitizeFromFilename_Roundtrip(t *testing.T) { + tests := []struct { + name string + address string + }{ + { + name: "basic address", + address: "mysession:editor.0", + }, + { + name: "address with percent", + address: "my%session:win.0", + }, + { + name: "address with dotted window", + address: "s:my.app.0", + }, + { + name: "complex address", + address: "my%session:my.app.window.12", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sanitized := SanitizeForFilename(tt.address) + unsanitized := UnsanitizeFromFilename(sanitized) + if unsanitized != tt.address { + t.Errorf("Roundtrip failed: original=%q, sanitized=%q, unsanitized=%q", tt.address, sanitized, unsanitized) + } + }) + } +} + +// Test for collision resistance + +func TestSanitizeForFilename_NoCollisions(t *testing.T) { + addr1 := "s_a:w.0" + addr2 := "s:a_w.0" + + sanitized1 := SanitizeForFilename(addr1) + sanitized2 := SanitizeForFilename(addr2) + + if sanitized1 == sanitized2 { + t.Errorf("Collision detected: %q and %q both sanitize to %q", addr1, addr2, sanitized1) + } +} diff --git a/internal/tmux/sendkeys.go b/internal/tmux/sendkeys.go index 8549798..c933f13 100644 --- a/internal/tmux/sendkeys.go +++ b/internal/tmux/sendkeys.go @@ -4,24 +4,24 @@ import ( "os/exec" ) -// SendKeys sends text to the specified tmux window. -// It executes: tmux send-keys -t "" -func SendKeys(window, text string) error { +// SendKeys sends text to the specified tmux target. +// It executes: tmux send-keys -t "" +func SendKeys(target, text string) error { if !InTmux() { return ErrNotInTmux } - cmd := exec.Command("tmux", "send-keys", "-t", window, text) + cmd := exec.Command("tmux", "send-keys", "-t", target, text) // #nosec G204 -- target is a tmux pane address, not shell-interpolated return cmd.Run() } -// SendEnter sends an Enter keypress to the specified tmux window. -// It executes: tmux send-keys -t Enter -func SendEnter(window string) error { +// SendEnter sends an Enter keypress to the specified tmux target. +// It executes: tmux send-keys -t Enter +func SendEnter(target string) error { if !InTmux() { return ErrNotInTmux } - cmd := exec.Command("tmux", "send-keys", "-t", window, "Enter") + cmd := exec.Command("tmux", "send-keys", "-t", target, "Enter") // #nosec G204 -- target is a tmux pane address, not shell-interpolated return cmd.Run() } diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index 37bc3f5..aa74d4e 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -108,3 +108,96 @@ func WindowExists(name string) (bool, error) { return false, nil } + +// GetCurrentPaneAddress returns the pane address of the current pane. +func GetCurrentPaneAddress() (string, error) { + paneID, err := GetCurrentPaneID() + if err != nil { + return "", err + } + + cmd := exec.Command("tmux", "display-message", "-t", paneID, "-p", "#{session_name}:#{window_name}.#{pane_index}") // #nosec G204 - paneID validated by GetCurrentPaneID + output, err := cmd.Output() + if err != nil { + return "", err + } + + return strings.TrimSpace(string(output)), nil +} + +// GetCurrentSession returns the name of the current tmux session. +func GetCurrentSession() (string, error) { + paneID, err := GetCurrentPaneID() + if err != nil { + return "", err + } + + cmd := exec.Command("tmux", "display-message", "-t", paneID, "-p", "#{session_name}") // #nosec G204 - paneID validated by GetCurrentPaneID + output, err := cmd.Output() + if err != nil { + return "", err + } + + return strings.TrimSpace(string(output)), nil +} + +// ListPanes returns a list of all pane addresses in the current session. +func ListPanes() ([]string, error) { + if !InTmux() { + return nil, ErrNotInTmux + } + + cmd := exec.Command("tmux", "list-panes", "-s", "-F", "#{session_name}:#{window_name}.#{pane_index}") + output, err := cmd.Output() + if err != nil { + return nil, err + } + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + var panes []string + for _, line := range lines { + if line != "" { + panes = append(panes, line) + } + } + + return panes, nil +} + +// ListAllPanes returns a list of all pane addresses across all sessions. +func ListAllPanes() ([]string, error) { + if !InTmux() { + return nil, ErrNotInTmux + } + + cmd := exec.Command("tmux", "list-panes", "-a", "-F", "#{session_name}:#{window_name}.#{pane_index}") + output, err := cmd.Output() + if err != nil { + return nil, err + } + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + var panes []string + for _, line := range lines { + if line != "" { + panes = append(panes, line) + } + } + + return panes, nil +} + +// PaneExists checks if a pane with the given address exists. +func PaneExists(address string) (bool, error) { + if !InTmux() { + return false, ErrNotInTmux + } + + cmd := exec.Command("tmux", "display-message", "-t", address, "-p", "#{pane_id}") // #nosec G204 - address is parsed/validated by callers via ParseAddress + err := cmd.Run() + if err != nil { + return false, nil + } + + return true, nil +} diff --git a/internal/tmux/tmux_test.go b/internal/tmux/tmux_test.go index aa4ab64..aa4b64f 100644 --- a/internal/tmux/tmux_test.go +++ b/internal/tmux/tmux_test.go @@ -111,3 +111,92 @@ func TestGetCurrentWindow_NoPaneID(t *testing.T) { t.Errorf("GetCurrentWindow() should return ErrNoPaneID when TMUX_PANE is empty, got: %v", err) } } + +// T002: Tests for new pane-aware tmux functions + +func TestGetCurrentPaneAddress_NotInTmux(t *testing.T) { + t.Setenv("TMUX", "") + t.Setenv("TMUX_PANE", "") + + _, err := GetCurrentPaneAddress() + if err != ErrNotInTmux { + t.Errorf("GetCurrentPaneAddress() should return ErrNotInTmux when not in tmux, got: %v", err) + } +} + +func TestGetCurrentPaneAddress_NoPaneID(t *testing.T) { + t.Setenv("TMUX", "/tmp/tmux-501/default,12345,0") + t.Setenv("TMUX_PANE", "") + + _, err := GetCurrentPaneAddress() + if err != ErrNoPaneID { + t.Errorf("GetCurrentPaneAddress() should return ErrNoPaneID when TMUX_PANE is empty, got: %v", err) + } +} + +func TestGetCurrentSession_NotInTmux(t *testing.T) { + t.Setenv("TMUX", "") + t.Setenv("TMUX_PANE", "") + + _, err := GetCurrentSession() + if err != ErrNotInTmux { + t.Errorf("GetCurrentSession() should return ErrNotInTmux when not in tmux, got: %v", err) + } +} + +func TestGetCurrentSession_NoPaneID(t *testing.T) { + t.Setenv("TMUX", "/tmp/tmux-501/default,12345,0") + t.Setenv("TMUX_PANE", "") + + _, err := GetCurrentSession() + if err != ErrNoPaneID { + t.Errorf("GetCurrentSession() should return ErrNoPaneID when TMUX_PANE is empty, got: %v", err) + } +} + +func TestListPanes_NotInTmux(t *testing.T) { + t.Setenv("TMUX", "") + + _, err := ListPanes() + if err != ErrNotInTmux { + t.Errorf("ListPanes() should return ErrNotInTmux when not in tmux, got: %v", err) + } +} + +func TestListAllPanes_NotInTmux(t *testing.T) { + t.Setenv("TMUX", "") + + _, err := ListAllPanes() + if err != ErrNotInTmux { + t.Errorf("ListAllPanes() should return ErrNotInTmux when not in tmux, got: %v", err) + } +} + +func TestPaneExists_NotInTmux(t *testing.T) { + t.Setenv("TMUX", "") + + _, err := PaneExists("mysession:editor.0") + if err != ErrNotInTmux { + t.Errorf("PaneExists() should return ErrNotInTmux when not in tmux, got: %v", err) + } +} + +func TestGetCurrentPaneAddress_InvalidPaneID(t *testing.T) { + t.Setenv("TMUX", "/tmp/tmux-501/default,12345,0") + t.Setenv("TMUX_PANE", "invalid") + + _, err := GetCurrentPaneAddress() + if err != ErrInvalidPaneID { + t.Errorf("GetCurrentPaneAddress() should return ErrInvalidPaneID for invalid pane ID, got: %v", err) + } +} + +func TestGetCurrentSession_InvalidPaneID(t *testing.T) { + t.Setenv("TMUX", "/tmp/tmux-501/default,12345,0") + t.Setenv("TMUX_PANE", "not-a-pane-id") + + _, err := GetCurrentSession() + if err != ErrInvalidPaneID { + t.Errorf("GetCurrentSession() should return ErrInvalidPaneID for invalid pane ID, got: %v", err) + } +} diff --git a/specs/012-mailman-stop/plan.md b/specs/012-mailman-stop/plan.md index 52751fd..a072a8d 100644 --- a/specs/012-mailman-stop/plan.md +++ b/specs/012-mailman-stop/plan.md @@ -9,7 +9,7 @@ Add `agentmail mailman stop` subcommand to gracefully terminate the mailman daem ## Technical Context -**Language/Version**: Go 1.25.5 (minimum 1.21+ per IC-001) +**Language/Version**: Go 1.25.7 (minimum 1.21+ per IC-001) **Primary Dependencies**: Standard library only (os for file operations) **Storage**: `.agentmail/.stop` (new stop signal file), `.agentmail/mailman.pid` (existing) **Testing**: `go test -v -race -cover` with >= 80% coverage diff --git a/specs/tmux-pane-addressing/checklists/requirements.md b/specs/tmux-pane-addressing/checklists/requirements.md new file mode 100644 index 0000000..b274086 --- /dev/null +++ b/specs/tmux-pane-addressing/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Tmux Pane Addressing + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-02-13 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All checklist items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`. +- The spec references tmux-specific concepts (sessions, windows, panes) which are domain terms, not implementation details. +- Address format `session:window.pane` is a user-facing concept aligned with tmux's own target syntax. diff --git a/specs/tmux-pane-addressing/contracts/cli.md b/specs/tmux-pane-addressing/contracts/cli.md new file mode 100644 index 0000000..ddfa5e5 --- /dev/null +++ b/specs/tmux-pane-addressing/contracts/cli.md @@ -0,0 +1,124 @@ +# CLI Contract: Tmux Pane Addressing + +**Date**: 2026-02-13 + +## Changed Commands + +### `agentmail send ` + +**Before**: `` is a tmux window name. +**After**: `` is one of: +- Full pane address: `session:window.pane` (e.g., `AgentMail:editor.1`) +- Medium address: `:window.pane` (e.g., `:editor.1`) — colon prefix, session inferred from current +- Short address: `window` (e.g., `editor`) — backward compatible, even for names with dots (e.g., `my.app`) + +**Behavior changes**: +- Short address with single-pane window: resolves to full address, delivers message. No visible change to user. +- Short address with multi-pane window: returns error with suggestion: + ``` + Error: Ambiguous recipient: window 'editor' has 3 panes. Use AgentMail:editor.0, AgentMail:editor.1, or AgentMail:editor.2 + ``` + Exit code: 1 +- The `From` field in stored messages always uses full pane address format. +- Recipient validation uses `tmux display-message -t
` instead of `tmux list-windows`. + +**Exit codes**: No change (0 success, 1 error, 2 environment error). + +**Output format**: No change (`Message # sent`). + +### `agentmail receive` + +**Before**: Looks up mailbox by current window name. +**After**: Looks up mailbox by current full pane address. + +**Behavior changes**: +- The receiver identity is determined by `GetCurrentPaneAddress()` instead of `GetCurrentWindow()`. +- Messages from old window-name-based mailboxes are not visible (orphaned by design). +- Output `From:` line shows full pane address of sender. + +**Hook mode**: Same behavior, but polls the pane-specific mailbox. + +**Exit codes**: No change. + +### `agentmail recipients` + +**Before**: Lists tmux window names, marks current with `[you]`. +**After**: Lists full pane addresses, marks current pane with `[you]`. + +**Output example**: +``` +AgentMail:editor.0 +AgentMail:editor.1 +AgentMail:code.0 [you] +AgentMail:tests.0 +``` + +### `agentmail status ` + +**Before**: Updates recipient state keyed by window name. +**After**: Updates recipient state keyed by full pane address. + +**No visible output change.** + +### `agentmail cleanup` + +**Before**: Cleans up window-name-based mailboxes and recipient states. +**After**: Cleans up pane-address-based mailboxes and recipient states. Also removes orphaned legacy mailbox files (files whose names do not match the percent-encoded pane address pattern `%3A%2E.jsonl`). + +### `agentmail mailman start` + +**Before**: Daemon tracks windows and notifies by window name. +**After**: Daemon tracks panes and notifies by pane address using `tmux send-keys -t `. + +## New Tmux Functions + +### `GetCurrentPaneAddress() (string, error)` + +Returns the full `session:window.pane` address of the calling process's pane. + +### `GetCurrentSession() (string, error)` + +Returns the current tmux session name. + +### `ListPanes() ([]string, error)` + +Returns all pane addresses in the current session as `session:window.pane` strings. This is the default discovery scope for recipients, MCP list-recipients, and daemon. + +### `ListAllPanes() ([]string, error)` + +Returns all pane addresses across all sessions. Not exposed in any command or MCP tool; available for potential future use. + +### `PaneExists(address string) (bool, error)` + +Checks if a specific pane exists by targeting it with `tmux display-message`. + +## MCP Tool Changes + +### `send` tool + +**Schema change**: `recipient` parameter description updated to accept pane addresses. +**Behavior**: Same resolution as CLI send command. + +### `receive` tool + +**Behavior**: Uses current pane address instead of window name. + +### `status` tool + +**Behavior**: Tracks status at pane level. + +### `list-recipients` tool + +**Response change**: Returns pane addresses instead of window names. + +```json +{ + "recipients": [ + {"address": "AgentMail:editor.0", "is_current": false}, + {"address": "AgentMail:editor.1", "is_current": false}, + {"address": "AgentMail:code.0", "is_current": true} + ] +} +``` + +**Breaking change**: The `window` field is replaced by `address` (full pane address). No backward-compatible `window` field is included. diff --git a/specs/tmux-pane-addressing/data-model.md b/specs/tmux-pane-addressing/data-model.md new file mode 100644 index 0000000..1ac69b8 --- /dev/null +++ b/specs/tmux-pane-addressing/data-model.md @@ -0,0 +1,124 @@ +# Data Model: Tmux Pane Addressing + +**Feature Branch**: `tmux-pane-addressing` +**Date**: 2026-02-13 + +## Entity Changes + +### PaneAddress (New) + +Represents a parsed tmux pane address with its components. + +| Field | Type | Description | Example | +|---------|--------|------------------------------------|----------------| +| Session | string | Tmux session name | `AgentMail` | +| Window | string | Tmux window name | `editor` | +| Pane | int | Pane index within the window | `1` | +| Full | string | Canonical `session:window.pane` | `AgentMail:editor.1` | + +**Validation rules**: +- Session: non-empty string +- Window: non-empty string +- Pane: non-negative integer +- Full: must match pattern `:.` where pane is a non-negative integer + +**Construction**: +- `ParseAddress(input, currentSession)` → PaneAddress or error +- `FormatAddress(session, window, pane)` → string (`session:window.pane`) + +### Message (Modified) + +The `From` and `To` fields change from window names to full pane addresses. + +| Field | Type | Before | After | +|-----------|-----------|---------------------|--------------------------------| +| ID | string | 8-char base62 | No change | +| From | string | Window name | Full pane address (`session:window.pane`) | +| To | string | Window name | Full pane address (`session:window.pane`) | +| Message | string | Body text | No change | +| ReadFlag | bool | Read status | No change | +| CreatedAt | time.Time | Timestamp | No change | + +**JSON wire format**: Unchanged structure, only the content of `from`/`to` changes. + +**Backward compatibility**: Old messages with window-name-only `from`/`to` are stored in orphaned mailbox files that are not queried by the new code. They are effectively inaccessible via normal commands and should be cleaned up with `agentmail cleanup`. + +### RecipientState (Modified) + +The `Recipient` field changes from window name to full pane address. + +| Field | Type | Before | After | +|------------|-----------|---------------|--------------------------------| +| Recipient | string | Window name | Full pane address (`session:window.pane`) | +| Status | string | No change | No change | +| UpdatedAt | time.Time | No change | No change | +| NotifiedAt | time.Time | No change | No change | +| LastReadAt | int64 | No change | No change | + +## Storage Changes + +### Mailbox Files + +| Aspect | Before | After | +|----------|-------------------------------------|---------------------------------------------| +| Path | `.agentmail/mailboxes/.jsonl` | `.agentmail/mailboxes/.jsonl` | +| Example | `.agentmail/mailboxes/editor.jsonl` | `.agentmail/mailboxes/AgentMail%3Aeditor%2E0.jsonl` | +| Key | Window name | Percent-encoded full pane address | + +**Sanitization**: Percent-encode structural characters: `%` → `%25`, `:` → `%3A`, `.` (pane separator) → `%2E`. This encoding is collision-safe (injective) and reversible. `ListMailboxRecipients()` returns canonical pane addresses by decoding filenames — all public APIs use canonical addresses, not raw filenames. + +### Recipients File + +| Aspect | Before | After | +|----------|-------------------------------------|---------------------------------------------| +| Path | `.agentmail/recipients.jsonl` | No change | +| Key | Window name in `recipient` field | Full pane address in `recipient` field | +| Example | `{"recipient":"editor",...}` | `{"recipient":"AgentMail:editor.0",...}` | + +### Ignore List + +| Aspect | Before | After | +|----------|-------------------------------------|---------------------------------------------| +| Path | `.agentmailignore` | No change | +| Format | One window name per line | One address per line (full, medium `:window.pane`, or short window name) | +| Matching | Exact window name match | Match against full pane address; short names match all panes of that window; medium `:window.pane` matches that specific pane in the current session | + +## Address Resolution Flow + +``` +Input Address Resolution Steps Output +───────────────────────────────────────────────────────────────────────── +"editor" 1. No ":" → short form mysession:editor.0 +(single pane) 2. List panes for window "editor" (if 1 pane) + 3. Single pane → resolve to full address + +"editor" 1. No ":" → short form ERROR: ambiguous +(multi pane) 2. List panes for window "editor" + 3. Multiple panes → ambiguity error + +"my.app" 1. No ":" → short form mysession:my.app.0 +(single pane) 2. Window name = "my.app" (dots are literal) (if 1 pane) + 3. Single pane → resolve to full address + +"logs.1" 1. No ":" → short form mysession:logs.1.0 +(single pane) 2. Window name = "logs.1" (NOT medium form) (if 1 pane) + 3. Unambiguous: no colon prefix + +":editor.1" 1. Starts with ":" → medium form mysession:editor.1 + 2. Strip ":", split on last "." + 3. Pane = 1, prepend current session + +"mysession:editor.1" 1. Has ":" (not at pos 0) → full form mysession:editor.1 + 2. Parse session, window, pane + 3. Validate pane exists +``` + +## Tmux Query Commands + +| Purpose | Command | +|--------------------------|-----------------------------------------------------------------------------| +| Get current pane address | `tmux display-message -t $TMUX_PANE -p '#{session_name}:#{window_name}.#{pane_index}'` | +| List panes in session | `tmux list-panes -s -F '#{session_name}:#{window_name}.#{pane_index}'` | +| List all panes | `tmux list-panes -a -F '#{session_name}:#{window_name}.#{pane_index}'` | +| Check pane exists | `tmux display-message -t '
' -p '#{pane_id}'` (exit code 0 = exists) | +| Get current session | `tmux display-message -t $TMUX_PANE -p '#{session_name}'` | diff --git a/specs/tmux-pane-addressing/plan.md b/specs/tmux-pane-addressing/plan.md new file mode 100644 index 0000000..7d2e4b1 --- /dev/null +++ b/specs/tmux-pane-addressing/plan.md @@ -0,0 +1,104 @@ +# Implementation Plan: Tmux Pane Addressing + +**Branch**: `tmux-pane-addressing` | **Date**: 2026-02-13 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/tmux-pane-addressing/spec.md` + +## Summary + +Update AgentMail to use full tmux pane addresses (`session:window.pane`) instead of window names for all agent identification. This affects the tmux integration layer, mail storage, all CLI commands, MCP server, Claude hooks, and the mailman daemon. The approach introduces an address parsing module, extends tmux query functions to return pane-level data, and updates all consumers to use pane addresses. No new dependencies are required. Backward compatibility is maintained for window-name-only addressing when the target window has a single pane. + +## Technical Context + +**Language/Version**: Go 1.25.7 (minimum 1.21+ per constitution IC-001) +**Primary Dependencies**: Standard library only (`os/exec`, `encoding/json`, `strings`, `strconv`, `fmt`, `regexp`). Existing approved deps: `fsnotify`, `go-sdk` (MCP), `ff/v3` (CLI flags) +**Storage**: JSONL files in `.agentmail/` directory — mailbox files renamed from `.jsonl` to `.jsonl` (collision-safe encoding) +**Testing**: `go test -v -race -coverprofile=coverage.out ./...` with >= 80% coverage +**Target Platform**: macOS and Linux with tmux installed +**Project Type**: Single Go project (CLI tool) +**Performance Goals**: N/A — address parsing adds negligible overhead (string operations only) +**Constraints**: Standard library preference (Constitution IV), no CGO +**Scale/Scope**: ~15 files modified, 1 new file created, ~500-800 lines of new/changed code + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. CLI-First Design | PASS | All pane addressing accessible via CLI commands. Text I/O protocol unchanged. Exit codes unchanged. | +| II. Simplicity (YAGNI) | PASS | Feature justified by demonstrated need (agents in separate panes). No migration complexity (orphan old files). Address parsing is minimal string logic. | +| III. Test Coverage | PASS | All new functions will have unit tests. Integration tests for CLI flows. 80% coverage target maintained. | +| IV. Standard Library Preference | PASS | No new external dependencies. All address parsing uses `strings`, `strconv`, `fmt`. | +| Quality Gate 1: gofmt | Will verify | All new code formatted with gofmt. | +| Quality Gate 2: go mod verify | Will verify | No module changes expected. | +| Quality Gate 3: go vet | Will verify | All new code passes vet. | +| Quality Gate 4: Tests >= 80% | Will verify | New tests for address parsing, tmux queries, updated CLI/MCP flows. | +| Quality Gate 5: govulncheck | Will verify | No new dependencies to introduce vulnerabilities. | +| Quality Gate 6: gosec | Will verify | Address sanitization prevents path traversal. Input validation on pane indices. | +| Quality Gate 7: Spec compliance | Will verify | All acceptance scenarios from spec.md covered by tests. | + +**Post-Phase 1 Re-check**: PASS — No design decisions violate any constitution principles. No complexity tracking entries needed. + +## Project Structure + +### Documentation (this feature) + +```text +specs/tmux-pane-addressing/ +├── spec.md # Feature specification +├── plan.md # This file +├── research.md # Phase 0: research findings +├── data-model.md # Phase 1: entity and storage changes +├── quickstart.md # Phase 1: implementation guide +├── contracts/ +│ └── cli.md # Phase 1: CLI and MCP contract changes +├── checklists/ +│ └── requirements.md # Spec quality checklist +├── tasks.md # Phase 2 output (/speckit.tasks) +└── worklog.md # Implementation log (/speckit.implement) +``` + +### Source Code (repository root) + +```text +internal/ +├── tmux/ +│ ├── address.go # NEW: PaneAddress struct, ParseAddress, SanitizeForFilename +│ ├── address_test.go # NEW: Address parsing tests +│ ├── tmux.go # MODIFY: Add GetCurrentPaneAddress, GetCurrentSession, ListPanes, ListAllPanes, PaneExists +│ ├── tmux_test.go # MODIFY: Add tests for new functions +│ └── sendkeys.go # MODIFY: Accept pane addresses in SendKeys +├── mail/ +│ ├── mailbox.go # MODIFY: Path construction uses sanitized pane addresses +│ ├── mailbox_test.go # MODIFY: Tests with pane-based paths +│ ├── recipients.go # MODIFY: Key by pane address +│ ├── recipients_test.go # MODIFY: Tests with pane-based keys +│ └── ignore.go # MODIFY: Support pane-level ignore matching +├── cli/ +│ ├── send.go # MODIFY: Address parsing for recipient, pane address for sender +│ ├── send_test.go # MODIFY: Pane address test cases +│ ├── receive.go # MODIFY: Pane address for receiver +│ ├── receive_test.go # MODIFY: Pane address test cases +│ ├── recipients.go # MODIFY: List panes instead of windows +│ ├── recipients_test.go # MODIFY: Pane address test cases +│ ├── status.go # MODIFY: Pane address for agent +│ ├── status_test.go # MODIFY: Pane address test cases +│ ├── cleanup.go # MODIFY: Pane-level cleanup +│ └── cleanup_test.go # MODIFY: Pane address test cases +├── mcp/ +│ ├── handlers.go # MODIFY: Use pane addresses in all handlers +│ ├── handlers_test.go # MODIFY: Pane address test cases +│ ├── tools.go # MODIFY: Update descriptions, response types +│ └── server.go # NO CHANGE +└── daemon/ + ├── loop.go # MODIFY: Pane-level notification, pane-level tracking + ├── loop_test.go # MODIFY: Pane address test cases + ├── watcher.go # NO CHANGE (file watching is address-agnostic) + └── daemon.go # NO CHANGE (lifecycle is address-agnostic) +``` + +**Structure Decision**: Existing Go project structure is preserved. One new file (`internal/tmux/address.go` + test) is added for address parsing. All other changes are modifications to existing files. No structural reorganization needed. + +## Complexity Tracking + +No constitution violations. No complexity justifications needed. diff --git a/specs/tmux-pane-addressing/quickstart.md b/specs/tmux-pane-addressing/quickstart.md new file mode 100644 index 0000000..7c5d17b --- /dev/null +++ b/specs/tmux-pane-addressing/quickstart.md @@ -0,0 +1,85 @@ +# Quickstart: Tmux Pane Addressing + +**Feature Branch**: `tmux-pane-addressing` +**Date**: 2026-02-13 + +## Overview + +This feature updates AgentMail to use full tmux pane addresses (`session:window.pane`) instead of window names only. Each pane becomes a distinct addressable agent. + +## File Change Map + +Changes are organized by layer, bottom-up: + +### Layer 1: Address Parsing (new) + +| File | Action | Description | +|------|--------|-------------| +| `internal/tmux/address.go` | **Create** | `PaneAddress` struct, `ParseAddress()`, `FormatAddress()`, `SanitizeForFilename()`, `UnsanitizeFromFilename()` | +| `internal/tmux/address_test.go` | **Create** | Unit tests for all address parsing cases | + +### Layer 2: Tmux Integration (modify) + +| File | Action | Description | +|------|--------|-------------| +| `internal/tmux/tmux.go` | **Modify** | Add `GetCurrentPaneAddress()`, `GetCurrentSession()`, `ListPanes()`, `ListAllPanes()` (not exposed in commands), `PaneExists()` | +| `internal/tmux/tmux_test.go` | **Modify** | Add tests for new tmux functions | +| `internal/tmux/sendkeys.go` | **Modify** | Update `SendKeys()` to accept pane addresses | + +### Layer 3: Mail Storage (modify) + +| File | Action | Description | +|------|--------|-------------| +| `internal/mail/mailbox.go` | **Modify** | Update `safePath()` and all path construction to use sanitized pane addresses | +| `internal/mail/recipients.go` | **Modify** | Update all functions to key by pane address instead of window name | +| `internal/mail/ignore.go` | **Modify** | Update `LoadIgnoreList()` to support pane-level matching | +| `internal/mail/mailbox_test.go` | **Modify** | Update tests for pane-based paths | +| `internal/mail/recipients_test.go` | **Modify** | Update tests for pane-based keys | + +### Layer 4: CLI Commands (modify) + +| File | Action | Description | +|------|--------|-------------| +| `internal/cli/send.go` | **Modify** | Use address parsing for recipient, `GetCurrentPaneAddress()` for sender | +| `internal/cli/receive.go` | **Modify** | Use `GetCurrentPaneAddress()` for receiver identity | +| `internal/cli/recipients.go` | **Modify** | Use `ListPanes()` instead of `ListWindows()` | +| `internal/cli/status.go` | **Modify** | Use `GetCurrentPaneAddress()` for agent identity | +| `internal/cli/cleanup.go` | **Modify** | Update offline detection to use pane addresses | +| `internal/cli/*_test.go` | **Modify** | Update all CLI tests with pane address mocks | + +### Layer 5: MCP Server (modify) + +| File | Action | Description | +|------|--------|-------------| +| `internal/mcp/handlers.go` | **Modify** | Update all handlers to use pane addresses | +| `internal/mcp/tools.go` | **Modify** | Update tool descriptions and response types | +| `internal/mcp/handlers_test.go` | **Modify** | Update MCP handler tests | + +### Layer 6: Daemon (modify) + +| File | Action | Description | +|------|--------|-------------| +| `internal/daemon/loop.go` | **Modify** | Update notification loop to use pane addresses, target panes with `send-keys` | +| `internal/daemon/watcher.go` | **No change** | File watching is address-agnostic | +| `internal/daemon/daemon.go` | **No change** | Daemon lifecycle is address-agnostic | + +## Implementation Order + +1. **Address parsing** (`internal/tmux/address.go`) — no dependencies, foundation for everything +2. **Tmux query functions** (`internal/tmux/tmux.go`) — depends on address parsing +3. **Mail storage** (`internal/mail/`) — depends on address format for filenames +4. **CLI commands** (`internal/cli/`) — depends on tmux and mail layers +5. **MCP server** (`internal/mcp/`) — depends on tmux and mail layers (parallel with CLI) +6. **Daemon** (`internal/daemon/`) — depends on tmux and mail layers (parallel with CLI/MCP) + +## Key Design Decisions + +1. **Always full address**: `From`/`To` fields always store `session:window.pane`, even for single-pane windows +2. **Breaking change, no migration**: Old mailbox files are ignored; `cleanup` removes them. No dual-read, no upgrade procedure. +3. **Filename sanitization**: Percent-encoding (`%` → `%25`, `:` → `%3A`, `.` → `%2E`) — collision-safe and reversible +4. **Medium form requires colon**: `:window.pane` (not `window.pane`) to avoid ambiguity with dotted window names +5. **Short form backward compat**: Single-pane window auto-resolves; multi-pane returns ambiguity error +6. **Discovery scope**: Current session only for recipients/MCP list; cross-session send requires full address +7. **MCP breaking change**: `list-recipients` uses `address` field (replaces `window`, no deprecated field) +8. **Standard library only**: No new dependencies needed +9. **Type boundary**: All public APIs use canonical pane addresses; percent-encoding is internal to storage layer only diff --git a/specs/tmux-pane-addressing/research.md b/specs/tmux-pane-addressing/research.md new file mode 100644 index 0000000..dd9a4db --- /dev/null +++ b/specs/tmux-pane-addressing/research.md @@ -0,0 +1,107 @@ +# Research: Tmux Pane Addressing + +**Feature Branch**: `tmux-pane-addressing` +**Date**: 2026-02-13 + +## R1: Tmux Pane Query Commands + +**Decision**: Use `tmux display-message -t $TMUX_PANE -p '#{session_name}:#{window_name}.#{pane_index}'` to get the current agent's full pane address. + +**Rationale**: Live testing confirms this command returns the exact `session:window.pane` format needed (e.g., `AgentMail:main.0`). The `$TMUX_PANE` variable (e.g., `%0`) is immutable per process lifetime and already used by AgentMail for `GetCurrentWindow()`. Extending the format string to include session and pane index is a minimal change. + +**Alternatives considered**: +- Querying session, window, and pane separately with three commands — rejected (3x exec overhead, race condition risk between queries) +- Using `tmux list-panes` to find current pane — rejected (requires filtering, more complex than direct query) + +## R2: Listing All Panes (for Recipients and Validation) + +**Decision**: Use `tmux list-panes -s -F '#{session_name}:#{window_name}.#{pane_index}'` for listing panes in the current session, and `tmux list-panes -a -F '...'` for cross-session listing. + +**Rationale**: Live testing confirms these commands return one `session:window.pane` entry per line. The `-s` flag lists all panes across all windows in the current session. The `-a` flag lists all panes across all sessions. + +**Alternatives considered**: +- Iterating `list-windows` then `list-panes` per window — rejected (N+1 exec calls, slower) +- Using window count to detect multi-pane windows — rejected (less direct than listing panes) + +## R3: Pane Existence Validation + +**Decision**: Use `tmux display-message -t 'session:window.pane' -p '#{pane_id}'` to check if a pane exists. Non-zero exit code means the pane does not exist. + +**Rationale**: This leverages tmux's own target resolution. If the pane doesn't exist, tmux returns an error (exit code 1). This is simpler and more reliable than listing all panes and searching. + +**Alternatives considered**: +- Listing all panes and checking membership — rejected (more expensive, especially for cross-session) +- Using `tmux has-session` — rejected (only checks session, not pane) + +## R4: Address Parsing Strategy + +**Decision**: Implement a single `ParseAddress` function that handles three forms: +1. Full: `session:window.pane` → contains `:` with non-empty session prefix, used as-is +2. Medium: `:window.pane` → starts with `:`, prepend current session +3. Short: `window` → no `:` prefix, resolve to full address (single-pane) or error (multi-pane) + +**Rationale**: The address format mirrors tmux's native `-t` target syntax. The leading `:` for medium form matches tmux's own shorthand (`:window.pane` means "current session"). This eliminates the ambiguity with dot-containing window names identified in code review. + +**Parsing rules**: +- Starts with `:` → medium format (strip leading `:`, split on last `.` to extract integer pane index) +- Contains `:` (not at position 0) → full format (split on first `:`, then split remainder on last `.`) +- No `:` → short format (entire string is window name, even if it contains dots) + +**Edge case resolution**: Window names like `my.app` or `logs.1` are unambiguously short form because they lack a `:` prefix. To target pane 1 of `logs`, use `:logs.1` (medium) or `mysession:logs.1` (full). This was changed from the original design (which used `window.pane` without `:`) after code review identified the collision between medium-form parsing and dotted window names. + +**Alternatives considered**: +- Using `window.pane` without colon prefix for medium form — rejected (ambiguous with dotted window names like `logs.1`) +- Using a regex — rejected (harder to maintain, less clear than structured parsing) +- Requiring full addresses always — rejected (breaks backward compatibility) + +## R5: Filename Sanitization + +**Decision**: Use percent-encoding for structural characters in mailbox filenames: `%` → `%25`, `:` → `%3A`, `.` (pane separator only, i.e., the last `.` before the pane index) → `%2E`. + +**Rationale**: The original design (`:`→`_`, `.`→`-`) was identified in code review as non-injective: session/window names containing `_` or `-` could produce collisions (e.g., `s_a:w.0` and `s:a_w.0` both map to `s_a_w-0.jsonl`). Percent-encoding is collision-safe by definition since the escape character `%` is itself escaped first. + +**Mapping**: `mysession:editor.0` → `mysession%3Aeditor%2E0.jsonl` + +**Reversibility**: Fully reversible by decoding `%XX` sequences. `ListMailboxRecipients()` MUST return canonical pane addresses (by decoding filenames), not raw sanitized filenames, to prevent double-encoding and ensure a single type boundary: all public APIs use canonical addresses, percent-encoding is internal to the storage layer only. + +**Alternatives considered**: +- Simple character replacement (`:`→`_`, `.`→`-`) — rejected after review (non-injective, causes mailbox aliasing/cross-delivery) +- Hash-based filenames — rejected (not human-readable, requires index file) + +## R6: Backward Compatibility and Migration + +**Decision**: No migration of existing mailbox files. Old window-name-based files are orphaned. New messages exclusively use pane-based filenames. + +**Rationale**: Aligns with YAGNI principle (Constitution II). Migration adds complexity for a transition that happens once. Old files can be cleaned up with `agentmail cleanup`. The `cleanup` command already handles removing empty and orphaned mailboxes. + +**Alternatives considered**: +- Auto-migration on first run — rejected (complex, error-prone for multi-pane windows) +- Dual-read (check both old and new paths) — rejected (ongoing complexity for temporary benefit) + +## R7: tmux Control Mode (-CC) Impact + +**Decision**: No special handling needed for tmux control mode. + +**Rationale**: Control mode clients are standard tmux clients. The same `display-message`, `list-panes`, and target syntax work identically. `$TMUX_PANE` is set for control mode sessions. AgentMail's approach of querying tmux via `os/exec` is transparent to client mode. + +## R8: Discovery Scope + +**Decision**: Recipient discovery (`ListPanes()`, `agentmail recipients`, MCP `list-recipients`) is scoped to the current tmux session only. Cross-session send is supported when the user provides a full `session:window.pane` address. + +**Rationale**: The current codebase uses `tmux list-windows` (current session only). Extending to `list-panes -s` (current session) is the natural equivalent. Cross-session discovery via `list-panes -a` would expose panes from unrelated projects, creating noise and potential security concerns. Cross-session send is still possible with explicit addressing, which is the right trade-off: you must know the target to send cross-session. + +**Alternatives considered**: +- Full cross-session discovery via `ListAllPanes()` — rejected (noisy, security concern, breaks current session-scoped model) +- `ListAllPanes()` is still implemented for potential future use but not exposed in any command or MCP tool + +## R9: Breaking Change — No Storage or MCP Backward Compatibility + +**Decision**: This is a breaking change for storage format and MCP response schema. No data migration, no MCP backward-compatible fields, no upgrade procedure. The MCP `list-recipients` response replaces `window` with `address`. Old mailbox files are ignored. Note: CLI addressing backward compatibility IS maintained — short-form window names (FR-003) continue to work for single-pane windows. + +**Rationale**: AgentMail is pre-1.0 infrastructure used by agents, not end users. Breaking changes to storage and wire format are acceptable when they maintain code simplicity (Constitution II — YAGNI). Adding dual-read logic or deprecated fields adds ongoing maintenance burden for a one-time transition. CLI backward compat for short window names is preserved because it's zero-cost (handled by the parser) and prevents breaking existing scripts. + +## R11: No New External Dependencies + +**Decision**: All changes use Go standard library only. No new dependencies required. + +**Rationale**: The feature extends existing `os/exec`-based tmux queries with different format strings and adds string parsing logic (including `net/url` or manual percent-encoding). Both are well-served by the standard library. Constitution IV compliance maintained. diff --git a/specs/tmux-pane-addressing/spec.md b/specs/tmux-pane-addressing/spec.md new file mode 100644 index 0000000..ba127b9 --- /dev/null +++ b/specs/tmux-pane-addressing/spec.md @@ -0,0 +1,176 @@ +# Feature Specification: Tmux Pane Addressing + +**Feature Branch**: `tmux-pane-addressing` +**Created**: 2026-02-13 +**Status**: Implemented +**Input**: User description: "Update agentmail to honor tmux panes. Sometimes tmux windows have panes and agent full address will be session:window.pane. Agentmail shall honor these addresses and correctly work with them including mcp and agent hooks." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Send Message to a Specific Pane (Priority: P1) + +An agent running in one tmux pane needs to send a message to another agent running in a different pane within the same or different window. The sender specifies the recipient using the full address format `session:window.pane` and AgentMail delivers the message to that specific pane's mailbox. + +**Why this priority**: This is the core capability. Without pane-level send, agents in separate panes cannot communicate directly. + +**Independent Test**: Can be fully tested by running two agents in separate panes, sending a message from one to the other using the full address, and verifying delivery. + +**Acceptance Scenarios**: + +1. **Given** two agents running in panes `mysession:editor.0` and `mysession:editor.1`, **When** pane 0 runs `agentmail send "mysession:editor.1" "hello"`, **Then** the message is stored in the mailbox for `mysession:editor.1` and a message ID is returned. +2. **Given** an agent in `mysession:code.0`, **When** it runs `agentmail send "mysession:tests.0" "run tests"`, **Then** the message is delivered to the pane in a different window. +3. **Given** a recipient address `mysession:editor.1` where pane 1 does not exist, **When** `agentmail send "mysession:editor.1" "hello"` is run, **Then** the system returns a "recipient not found" error. + +--- + +### User Story 2 - Receive Messages Addressed to Current Pane (Priority: P1) + +An agent running in a tmux pane calls `agentmail receive` and gets messages addressed specifically to its full pane address. The system automatically determines the agent's full address from the tmux environment. + +**Why this priority**: Receiving is the counterpart to sending. Agents must be able to retrieve messages addressed to their pane. + +**Independent Test**: Can be fully tested by sending a message to a pane address, then running `agentmail receive` from within that pane and verifying the correct message is returned. + +**Acceptance Scenarios**: + +1. **Given** an agent in pane `mysession:editor.1` with an unread message addressed to `mysession:editor.1`, **When** the agent runs `agentmail receive`, **Then** it reads the oldest unread message and marks it as read. +2. **Given** an agent in pane `mysession:editor.1` with no unread messages, **When** the agent runs `agentmail receive`, **Then** it returns "No unread messages". +3. **Given** messages addressed to `mysession:editor.0` and `mysession:editor.1`, **When** the agent in pane 1 runs `agentmail receive`, **Then** it only receives messages addressed to `mysession:editor.1`, not those for pane 0. + +--- + +### User Story 3 - Backward-Compatible Window-Only Addressing (Priority: P1) + +An agent sends a message using just a window name (e.g., `agentmail send editor "hello"`). The system continues to work as it does today for windows that have only a single pane. When a window has multiple panes, the system resolves the short name to the appropriate address. + +**Why this priority**: Existing workflows and scripts must not break. Backward compatibility is essential for adoption. + +**Independent Test**: Can be tested by sending a message using the old window-name-only format and verifying it is delivered correctly. + +**Acceptance Scenarios**: + +1. **Given** a window `editor` with a single pane, **When** an agent runs `agentmail send editor "hello"`, **Then** the message is delivered to that window's sole pane (equivalent to the current behavior). +2. **Given** a window `editor` with multiple panes (0, 1, 2), **When** an agent runs `agentmail send editor "hello"`, **Then** the system returns an error indicating the address is ambiguous and suggesting the user specify a pane (e.g., "Ambiguous recipient: window 'editor' has 3 panes. Use mysession:editor.0, mysession:editor.1, or mysession:editor.2"). +3. **Given** a window `editor` with multiple panes, **When** an agent runs `agentmail send "mysession:editor.1" "hello"`, **Then** the message is delivered to pane 1 specifically. + +--- + +### User Story 4 - MCP Server Pane-Aware Operations (Priority: P2) + +The MCP server tools (send, receive, status, list-recipients) support pane-level addressing. AI agents using the MCP interface can send to and receive from specific panes. + +**Why this priority**: MCP integration is the primary interface for AI agents. Without pane-aware MCP tools, agents cannot use pane addressing through the standard MCP protocol. + +**Independent Test**: Can be tested by invoking MCP send/receive tools with pane addresses and verifying correct behavior. + +**Acceptance Scenarios**: + +1. **Given** the MCP server is running in pane `mysession:code.0`, **When** an AI agent calls the `send` tool with recipient `mysession:code.1`, **Then** the message is delivered to pane 1's mailbox. +2. **Given** the MCP server is running, **When** an AI agent calls the `list-recipients` tool, **Then** the response includes per-pane entries for windows that have multiple panes, showing the full `session:window.pane` address for each. +3. **Given** the MCP server is running in pane `mysession:code.0`, **When** an AI agent calls the `receive` tool, **Then** it receives messages addressed to `mysession:code.0`. + +--- + +### User Story 5 - Claude Hooks Pane-Aware Polling (Priority: P2) + +The Claude hooks integration correctly identifies the current pane and polls for messages addressed to that pane's full address. + +**Why this priority**: Hooks provide the automatic notification mechanism for Claude Code agents. Without pane awareness, agents in different panes of the same window would receive each other's messages. + +**Independent Test**: Can be tested by running the receive command in hook mode from a specific pane and verifying it only returns messages addressed to that pane. + +**Acceptance Scenarios**: + +1. **Given** a Claude agent in pane `mysession:work.1` with hook mode enabled, **When** a message arrives for `mysession:work.1`, **Then** the hook outputs the notification to stderr and exits with code 2. +2. **Given** a Claude agent in pane `mysession:work.1` with hook mode enabled, **When** a message arrives for `mysession:work.0` (a different pane in the same window), **Then** the hook exits silently with code 0 (message is not for this pane). + +--- + +### User Story 6 - Recipient Listing Shows Pane Details (Priority: P2) + +When listing recipients, the system shows individual panes as distinct addressable entities. Agents can discover the full addresses of all reachable panes. + +**Why this priority**: Discoverability is important for agents to know what addresses to use when sending messages. + +**Independent Test**: Can be tested by running `agentmail recipients` in a session with multi-pane windows and verifying each pane appears as a separate entry. + +**Acceptance Scenarios**: + +1. **Given** a tmux session with window `editor` having panes 0, 1, and 2, **When** an agent runs `agentmail recipients`, **Then** the output lists `mysession:editor.0`, `mysession:editor.1`, and `mysession:editor.2` as separate recipients. +2. **Given** a tmux session with a single-pane window `monitor`, **When** an agent runs `agentmail recipients`, **Then** the output lists `mysession:monitor.0` with the full address format. + +--- + +### Edge Cases + +- What happens when a pane is closed while messages are still in its mailbox? Messages remain in storage. If a new pane is later created at the same address, it can read those messages. This is a known data-leak risk when pane identity is reused by a different agent. Users should run `agentmail cleanup` to remove stale mailboxes for closed panes. +- What happens when a window is renamed? The address changes. Existing messages addressed to the old name remain in storage under the old address. New messages must use the new address. +- What happens when the address contains special characters in the session or window name? The system percent-encodes structural characters (`%`, `:`, `.`) for safe file storage while preserving the original address in message data. +- What happens when the session name is omitted using the medium form (e.g., `:editor.1`)? The system assumes the current session and resolves to the full address. The leading `:` is required to distinguish medium form from a short window name. +- What happens with a window name containing dots (e.g., `my.app`)? It is treated as a short-form window name. Without a `:` prefix, no pane parsing is attempted, avoiding ambiguity. +- What happens with an invalid address format like just `.1`? The system rejects this as an invalid address format. +- What happens when an agent sends to its own pane address? The system rejects self-sends, consistent with current behavior. +- What happens during a rolling upgrade when some agents use the old version and some use the new version? This is a breaking change — agents on different versions cannot communicate. All agents must be upgraded together. + +## Clarifications + +### Session 2026-02-13 + +- Q: What should happen to existing mailbox files created before pane addressing? → A: No migration. This is a breaking change. Old window-name-based mailbox files are ignored by the new code and can be manually deleted or cleaned up via `agentmail cleanup`. No dual-read, no upgrade procedure needed — users simply upgrade and start fresh. +- Q: Should the `From` field always use the full pane address, even for single-pane windows? → A: Yes. Always use full `session:window.pane` format in `From` for consistency and trivial reply addressing. +- Q: What sanitization scheme for pane addresses in mailbox filenames? → A: Percent-encode structural characters: `%` → `%25`, `:` → `%3A`, `.` (pane separator) → `%2E` (e.g., `mysession:editor.0` → `mysession%3Aeditor%2E0.jsonl`). Collision-safe and reversible. +- Q: How is medium form distinguished from short form with dotted window names? → A: Medium form requires a leading colon: `:window.pane` (e.g., `:editor.1`). Without `:`, the input is always treated as a short-form window name, even if it contains dots. This eliminates parsing ambiguity. +- Q: What is the discovery scope for recipients and MCP list? → A: Current session only. `ListPanes()` lists panes in the current session. Cross-session send is supported by providing a full `session:window.pane` address, but discovery does not enumerate other sessions. +- Q: What happens to MCP response schema for list-recipients? → A: Breaking change. The response uses `address` (full pane address) instead of `window`. No backward-compatible `window` field — this is a clean break to maintain code simplicity. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST support the full address format `session:window.pane` for identifying tmux panes as message recipients and senders. +- **FR-002**: The system MUST automatically determine the current agent's full pane address (`session:window.pane`) from the tmux environment when sending or receiving messages. The `From` field in stored messages MUST always use the full pane address format, even when the sender's window has only one pane. +- **FR-003**: The system MUST accept the short form `window` (window name only) for backward compatibility. When the target window has a single pane, the system resolves it to the full address. When the target window has multiple panes, the system returns an error indicating the address is ambiguous. +- **FR-004**: The system MUST accept the medium form `:window.pane` (colon prefix, no session name) and resolve the session from the current tmux environment. The leading colon distinguishes medium form from short-form window names that may contain dots. +- **FR-005**: The system MUST store messages in per-pane mailbox files, using sanitized full addresses as filenames. +- **FR-006**: The system MUST validate that the target pane exists in the tmux session before accepting a send operation. +- **FR-007**: The `receive` command MUST return only messages addressed to the calling pane's full address. +- **FR-008**: The `recipients` command MUST list all panes across all windows in the current session, showing full `session:window.pane` addresses. Cross-session panes are not included in discovery. +- **FR-009**: The MCP server send tool MUST accept pane addresses and deliver messages to the specified pane. +- **FR-010**: The MCP server receive tool MUST return messages addressed to the current pane's full address. +- **FR-011**: The MCP server list-recipients tool MUST return per-pane entries with full addresses for the current session. Response uses the `address` field (breaking change from previous `window` field). +- **FR-012**: The Claude hooks integration MUST identify the current pane and poll only for messages addressed to that pane. +- **FR-013**: The system MUST prevent self-sends (sending to the agent's own pane address). +- **FR-014**: The system MUST sanitize addresses for safe file system storage using percent-encoding: `%` → `%25`, `:` → `%3A`, `.` (pane separator only) → `%2E` (e.g., `mysession:editor.0` → `mysession%3Aeditor%2E0.jsonl`). This encoding is collision-safe and reversible. The original address MUST be preserved in message data (`From`/`To` fields). +- **FR-015**: The recipient state tracking (status, last-read-at) MUST operate at the pane level, not the window level. +- **FR-016**: The `.agentmailignore` functionality MUST support pane-level addresses (full `session:window.pane` and medium `:window.pane` forms) in addition to short window names. A short window name entry ignores all panes of that window. A full or medium address entry ignores only that specific pane. +- **FR-017**: The `cleanup` command MUST operate on pane-level mailboxes and recipient states. +- **FR-018**: The `mailman` daemon MUST track and notify at the pane level. +- **FR-019**: The system MUST NOT migrate existing window-name-based mailbox files. This is a breaking change. Old files are ignored by the new code and eligible for removal via `agentmail cleanup`. All new messages use pane-based mailbox files exclusively. +- **FR-020**: The `cleanup` command MUST detect and remove legacy mailbox files that do not match the percent-encoded pane address filename pattern. + +### Key Entities + +- **Pane Address**: The full identifier for a tmux pane, in the format `session:window.pane`. This is the canonical form used for message routing and mailbox storage. The pane index is the tmux pane number (e.g., 0, 1, 2). +- **Short Address**: A backward-compatible address using only the window name. Resolves to a full pane address when the window has a single pane. +- **Medium Address**: An address using `:window.pane` format (colon prefix, no session name). The session is inferred from the current tmux environment. The leading colon is required to disambiguate from short-form window names containing dots. +- **Mailbox**: A per-pane JSONL file storing messages addressed to a specific pane. Filename is derived from the sanitized full address. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Agents in separate panes of the same window can exchange messages without cross-delivery (100% message isolation between panes). +- **SC-002**: Existing single-pane workflows continue to work without modification (full backward compatibility with window-name-only addressing). +- **SC-003**: All AgentMail commands (send, receive, recipients, status, cleanup) correctly operate at pane granularity. +- **SC-004**: MCP server tools correctly handle pane addresses for send, receive, status, and list-recipients operations. +- **SC-005**: Claude hooks correctly identify and poll for the current pane's messages only, with no false notifications from sibling panes. +- **SC-006**: Test coverage remains at or above 80% with pane-level test cases included. + +## Assumptions + +- Tmux pane indices are integers assigned by tmux (0, 1, 2, etc.) and are stable for the lifetime of the pane. +- The `TMUX_PANE` environment variable (e.g., `%3`) is available and can be used to query the current pane's index, window, and session via tmux commands. +- Tmux session names, window names, and pane indices can be reliably queried using `tmux display-message` with appropriate format strings. +- The address format `session:window.pane` uses `:` as the session-window separator and `.` as the window-pane separator, which are consistent with tmux's own target syntax. +- Single-pane windows are the common case. The ambiguity error for multi-pane windows with short addresses is an acceptable trade-off for safety. +- Cross-session messaging is supported by specifying the full `session:window.pane` address, since tmux allows targeting panes in other sessions. diff --git a/specs/tmux-pane-addressing/tasks.md b/specs/tmux-pane-addressing/tasks.md new file mode 100644 index 0000000..3c75744 --- /dev/null +++ b/specs/tmux-pane-addressing/tasks.md @@ -0,0 +1,240 @@ +# Tasks: Tmux Pane Addressing + +**Input**: Design documents from `/specs/tmux-pane-addressing/` +**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/cli.md + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +--- + +## Phase 1: Foundational — Address Parsing & Tmux Layer + +**Purpose**: Create the PaneAddress type and all new tmux query functions. This is the foundation that ALL user stories depend on. + +**CRITICAL**: No user story work can begin until this phase is complete. + +- [ ] T001 Create PaneAddress struct with ParseAddress(), FormatAddress(), SanitizeForFilename(), and UnsanitizeFromFilename() functions in internal/tmux/address.go. PaneAddress has fields: Session (string), Window (string), Pane (int), Full (string). ParseAddress(input, currentSession) handles three forms: full (contains ":" not at position 0), medium (starts with ":"), short (no ":" — entire input is window name, even if it contains dots). FormatAddress returns "session:window.pane". SanitizeForFilename percent-encodes structural characters: "%" → "%25", ":" → "%3A", "." (pane separator only, the last dot) → "%2E" (e.g., "mysession:editor.0" → "mysession%3Aeditor%2E0"). UnsanitizeFromFilename reverses the encoding to recover canonical addresses. Include comprehensive unit tests in internal/tmux/address_test.go covering: full/medium/short parsing, window names containing dots (e.g., "my.app", "logs.1"), medium form ":editor.1", invalid formats (e.g., ".1", empty string, ":" alone), sanitization roundtrips proving no collisions (e.g., "s_a:w.0" vs "s:a_w.0" produce different filenames), names containing "%" characters. + +- [ ] T002 Add GetCurrentPaneAddress(), GetCurrentSession(), ListPanes(), ListAllPanes(), and PaneExists() functions to internal/tmux/tmux.go. GetCurrentPaneAddress() runs `tmux display-message -t $TMUX_PANE -p '#{session_name}:#{window_name}.#{pane_index}'` and returns the full address string. GetCurrentSession() runs `tmux display-message -t $TMUX_PANE -p '#{session_name}'`. ListPanes() runs `tmux list-panes -s -F '#{session_name}:#{window_name}.#{pane_index}'` (current session only — this is the default discovery scope). ListAllPanes() runs `tmux list-panes -a -F '...'` (all sessions — not exposed in commands/MCP, available for future use). PaneExists(address) runs `tmux display-message -t '
' -p '#{pane_id}'` and returns true if exit code is 0. Note: PaneExists and ListAllPanes do NOT require TMUX_PANE validation since they work with explicit targets. GetCurrentPaneAddress, GetCurrentSession, and ListPanes DO require TMUX_PANE via existing GetCurrentPaneID(). Add tests in internal/tmux/tmux_test.go. + +- [ ] T003 Update SendKeys() and SendEnter() in internal/tmux/sendkeys.go to accept full pane addresses (e.g., "mysession:editor.1") as the target parameter instead of just window names. The tmux `-t` flag already supports this format natively. Update tests if they exist. + +- [ ] T004 Update internal/mail/mailbox.go path construction: modify all calls to safePath() that build mailbox file paths to use SanitizeForFilename() on pane addresses. Currently paths are built as `msg.To + ".jsonl"` — change to `tmux.SanitizeForFilename(msg.To) + ".jsonl"`. This affects Append(), ReadAll(), FindUnread(), MarkAsRead(), WriteAll(), CleanOldMessages(). IMPORTANT type boundary: ListMailboxRecipients() MUST return canonical pane addresses (by calling UnsanitizeFromFilename on each filename), NOT raw percent-encoded filenames. All public APIs deal in canonical addresses; percent-encoding is internal to the storage layer. Update tests in internal/mail/mailbox_test.go to use pane-address-style recipient strings and verify ListMailboxRecipients returns decoded addresses. + +- [ ] T005 Update internal/mail/recipients.go: change all functions that key on window name to use full pane addresses instead. ReadAllRecipients(), UpdateRecipientState(), WriteAllRecipients(), CleanStaleStates(), SetNotifiedAt(), UpdateLastReadAt(), and CleanOfflineRecipients() all use the `Recipient` field which was previously a window name — now it will be a full pane address. CleanOfflineRecipients() must compare recipient pane addresses against valid pane addresses (passed by caller) instead of window names. Update tests in internal/mail/recipients_test.go. + +- [ ] T006 Update internal/mail/ignore.go: modify LoadIgnoreList() to return a map that supports pane-level matching. Add a new IsIgnored(address string, ignoreList map[string]bool, currentSession string) function that checks: (1) exact match on full pane address (e.g., "mysession:editor.1"), (2) match on medium form ":window.pane" resolved against currentSession, (3) match on window name part only (short form matches all panes of that window). This ensures ".agentmailignore" entries like "editor" ignore all panes of the editor window, ":editor.1" ignores pane 1 in the current session, and "mysession:editor.1" ignores only that specific pane. + +**Checkpoint**: Foundation ready — all address parsing, tmux queries, mail storage, and ignore matching use pane addresses. User story implementation can begin. + +--- + +## Phase 2: User Story 1 — Send Message to a Specific Pane (Priority: P1) MVP + +**Goal**: Agents can send messages to specific panes using full, medium, or short address formats. + +**Independent Test**: Send a message from one pane to another using `agentmail send "session:window.pane" "message"` and verify the message is stored in the correct pane-specific mailbox file. + +### Implementation for User Story 1 + +- [ ] T007 [US1] Update internal/cli/send.go: Replace GetCurrentWindow() call with GetCurrentPaneAddress() for sender identification. Replace WindowExists() recipient validation with address resolution logic: (1) call ParseAddress(recipient, currentSession) to determine address form, (2) for full/medium forms call PaneExists() to validate, (3) for short form call ListPanes() to find matching panes — if exactly one pane matches the window name resolve to full address, if multiple panes match return ambiguity error with suggestion listing all matching pane addresses. Note: medium form now requires leading ":" (e.g., ":editor.1"). Update self-send check to compare full pane addresses. Update ignore list check to use IsIgnored(). Add MockPaneAddress, MockSession, MockPanes fields to SendOptions for testing. Update tests in internal/cli/send_test.go with cases: full address send, medium address ":editor.1" send, short address single-pane resolve, short address multi-pane ambiguity error, dotted window name "my.app" treated as short form, invalid address rejection, self-send rejection, ignore list with pane addresses. + +**Checkpoint**: `agentmail send` works with pane addresses. Messages are stored in pane-specific mailbox files. + +--- + +## Phase 3: User Story 2 — Receive Messages Addressed to Current Pane (Priority: P1) + +**Goal**: Agents receive only messages addressed to their specific pane. + +**Independent Test**: Send a message to a pane address, run `agentmail receive` from that pane, verify correct message returned and pane isolation. + +### Implementation for User Story 2 + +- [ ] T008 [US2] Update internal/cli/receive.go: Replace GetCurrentWindow() with GetCurrentPaneAddress() for receiver identification. The receiver's full pane address is used to look up the mailbox (FindUnread now uses sanitized pane address for file path). Update window existence validation to use PaneExists() on the receiver's own address. Update LastReadAt call to use pane address. Add MockPaneAddress to ReceiveOptions. Update tests in internal/cli/receive_test.go with cases: receive from pane-specific mailbox, pane isolation (messages to different pane not returned), no unread messages, hook mode with pane address. + +**Checkpoint**: `agentmail receive` returns only messages for the calling pane. + +--- + +## Phase 4: User Story 3 — Backward-Compatible Window-Only Addressing (Priority: P1) + +**Goal**: Existing `agentmail send "message"` commands continue to work for single-pane windows and produce clear errors for multi-pane windows. + +**Independent Test**: Run `agentmail send editor "hello"` with editor having a single pane — should succeed. Run the same with editor having multiple panes — should return ambiguity error with suggestions. + +### Implementation for User Story 3 + +- [ ] T009 [US3] Verify and add backward compatibility tests in internal/cli/send_test.go: Add specific test cases that verify short-form addressing behavior: (1) window with single pane resolves and delivers, (2) window with multiple panes returns ambiguity error message containing all pane addresses as suggestions, (3) medium form ":window.pane" resolves correctly with inferred session, (4) dotted window name "logs.1" is treated as short form (NOT medium form), (5) dotted window name "my.app" with single pane resolves correctly. The core logic was implemented in T007 — this task focuses on ensuring the acceptance scenarios from spec.md US3 are explicitly covered by tests. Also verify that the ambiguity error message format matches: "Ambiguous recipient: window '' has N panes. Use , , ..." + +**Checkpoint**: All backward-compatible addressing scenarios pass. + +--- + +## Phase 5: User Story 4 — MCP Server Pane-Aware Operations (Priority: P2) + +**Goal**: MCP server tools handle pane-level addressing for AI agent integration. + +**Independent Test**: Call MCP send tool with a pane address, verify delivery. Call MCP list-recipients, verify pane-level entries. + +### Implementation for User Story 4 + +- [ ] T010 [P] [US4] Update MCP tool schemas in internal/mcp/tools.go: Update the `send` tool description to document that `recipient` accepts pane addresses (full `session:window.pane`, medium `:window.pane`, short `window` forms). Update ListRecipientsResponse: replace `Window` field with `Address` field (string, canonical pane address) and add `IsCurrent` bool. This is a breaking change — no deprecated `Window` field. Update description strings for all tools to reference pane-level addressing. + +- [ ] T011 [US4] Update MCP handlers in internal/mcp/handlers.go: (1) doSend() — replace GetCurrentWindow() with GetCurrentPaneAddress() for sender, replace WindowExists() with the same address resolution logic as CLI send (ParseAddress + PaneExists/ListPanes), update self-send and ignore checks. (2) doReceive() — replace GetCurrentWindow() with GetCurrentPaneAddress() for receiver. (3) doStatus() — replace GetCurrentWindow() with GetCurrentPaneAddress() for agent identity. (4) doListRecipients() — replace ListWindows() with ListPanes() (current session scope), use full pane addresses in response with Address field (replacing Window), mark current pane with IsCurrent. Add MockPaneAddress, MockSession, MockPanes to HandlerOptions. Update tests in internal/mcp/handlers_test.go with pane address test cases for all four handlers. + +**Checkpoint**: All MCP tools work with pane addresses. + +--- + +## Phase 6: User Story 5 — Claude Hooks Pane-Aware Polling (Priority: P2) + +**Goal**: Hook mode correctly identifies current pane and only polls that pane's mailbox. + +**Independent Test**: Run receive in hook mode from a specific pane, verify it only checks that pane's mailbox and ignores sibling panes. + +### Implementation for User Story 5 + +- [ ] T012 [US5] Verify hook mode pane isolation in internal/cli/receive_test.go: The core hook mode logic already uses the same receive path updated in T008. Add explicit test cases that verify: (1) hook mode with message for current pane outputs notification to stderr and exits code 2, (2) hook mode with message only for a sibling pane (different pane in same window) returns exit code 0 with no output, (3) hook mode when not in tmux exits silently code 0. These tests confirm FR-012 and SC-005. + +**Checkpoint**: Hook mode notifications are pane-isolated. + +--- + +## Phase 7: User Story 6 — Recipient Listing Shows Pane Details (Priority: P2) + +**Goal**: `agentmail recipients` lists all panes with full addresses. + +**Independent Test**: Run `agentmail recipients` in a session with multi-pane windows, verify each pane listed separately with full address. + +### Implementation for User Story 6 + +- [ ] T013 [US6] Update internal/cli/recipients.go: Replace ListWindows() with ListPanes() to get all pane addresses. Replace GetCurrentWindow() with GetCurrentPaneAddress() for marking current pane with "[you]". Update ignore list filtering to use IsIgnored() with full pane addresses. Output each pane address on its own line. Add MockPanes, MockPaneAddress to RecipientsOptions. Update tests in internal/cli/recipients_test.go with cases: multi-pane window shows separate entries, single-pane window shows full address, current pane marked with "[you]", ignored panes filtered, current pane shown even if in ignore list. + +**Checkpoint**: `agentmail recipients` shows pane-level listing. + +--- + +## Phase 8: Daemon & Cleanup — Cross-Cutting Pane Updates (Priority: P2) + +**Goal**: The mailman daemon and cleanup command operate at pane granularity. + +### Implementation + +- [ ] T014 [P] Update internal/cli/status.go: Replace GetCurrentWindow() with GetCurrentPaneAddress() for agent identity. The rest of the status logic (UpdateRecipientState with status and resetNotified) is unchanged since recipients.go was already updated in T005 to key by pane address. Add MockPaneAddress to StatusOptions. Update tests in internal/cli/status_test.go. + +- [ ] T015 [P] Update internal/cli/cleanup.go: (1) Phase 1 (offline recipients) — replace ListWindows() with ListPanes() for valid pane list, pass pane addresses to CleanOfflineRecipients(). (2) Phase 2 (stale recipients) — no change needed (CleanStaleStates works on timestamps, already updated in T005). (3) Phase 3 (old messages) — ListMailboxRecipients() now returns canonical pane addresses (decoded from filenames), iterate and clean. (4) Phase 4 (empty mailboxes) — no change needed. (5) Add orphaned legacy file detection (FR-020): validate each mailbox filename against the percent-encoded pane address pattern (`%3A%2E.jsonl`). Files that do not match this pattern are legacy window-name files and should be removed. A simple "%3A" substring check is insufficient — use a pattern match to avoid false positives from malformed files. Update tests in internal/cli/cleanup_test.go with cases: valid pane file kept, legacy "editor.jsonl" removed, malformed file with "%3A" but no "%2E" removed. + +- [ ] T016 Update internal/daemon/loop.go: (1) Replace ListWindows() with ListPanes() (current session scope) in CheckAndNotifyWithNotifier(). (2) Update NotifyAgent() to accept pane addresses and pass them to SendKeys(). (3) Phase 1 (stated agents) — recipients are now keyed by pane address, compare against ListPanes() output. (4) Phase 2 (stateless agents) — ListMailboxRecipients() returns canonical pane addresses (decoded), check against recipient state by pane address. (5) StatelessTracker keys change from window names to pane addresses. (6) Cleanup tracker entries for closed panes (compare against ListPanes()). Update tests in internal/daemon/loop_test.go. + +**Checkpoint**: Daemon notifies at pane level. Cleanup removes pane-level data. + +--- + +## Phase 9: Polish & Validation + +**Purpose**: Final validation, documentation, and quality gates. + +- [ ] T017 Run full test suite: `go test -v -race -coverprofile=coverage.out ./...` and verify >= 80% coverage. Fix any failing tests. +- [ ] T018 Run quality gates: `gofmt -l .`, `go vet ./...`, `go mod verify`. Fix any issues. +- [ ] T019 Update README.md: Document pane addressing format (`session:window.pane`), backward compatibility behavior, updated command examples with pane addresses, and the address resolution rules (full/medium/short). +- [ ] T020 Update spec status: Mark spec.md status as "Implemented". + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Foundational)**: No dependencies — start immediately +- **Phase 2-4 (US1, US2, US3 — P1 stories)**: Depend on Phase 1. Execute sequentially (US1 → US2 → US3) since US2 depends on US1 for send/receive integration, and US3 validates US1 backward compat. +- **Phase 5-7 (US4, US5, US6 — P2 stories)**: Depend on Phase 1. Can run in parallel with each other after Phase 1. +- **Phase 8 (Daemon & Cleanup)**: Depends on Phase 1. Can run in parallel with P2 stories. +- **Phase 9 (Polish)**: Depends on all previous phases. + +### User Story Dependencies + +- **US1 (Send)**: Depends on Phase 1 only +- **US2 (Receive)**: Depends on Phase 1 only (but logically benefits from US1 being done) +- **US3 (Backward Compat)**: Depends on US1 (tests validate US1 behavior) +- **US4 (MCP)**: Depends on Phase 1 only +- **US5 (Hooks)**: Depends on US2 (hooks use receive path) +- **US6 (Recipients)**: Depends on Phase 1 only + +### Parallel Opportunities + +Within Phase 1: +- T001 (address parsing) must complete first +- T002 and T003 can run in parallel after T001 +- T004, T005, T006 can run in parallel after T001 + +After Phase 1 completes: +- US4 (T010-T011), US6 (T013), and Phase 8 (T014-T016) can all run in parallel +- US5 (T012) can run after US2 (T008) completes + +--- + +## Parallel Example: Phase 1 + +```text +Sequential: + T001 (address.go) → then parallel: + T002 (tmux.go new functions) + T003 (sendkeys.go update) + → then parallel: + T004 (mailbox.go paths) + T005 (recipients.go keys) + T006 (ignore.go matching) +``` + +## Parallel Example: After Phase 1 + +```text +After Phase 1 completes, these can run in parallel: + Stream A: T007 (US1 send) → T008 (US2 receive) → T009 (US3 compat) → T012 (US5 hooks) + Stream B: T010 + T011 (US4 MCP) + Stream C: T013 (US6 recipients) + Stream D: T014 + T015 + T016 (daemon/cleanup/status) +``` + +--- + +## Implementation Strategy + +### MVP First (User Stories 1-3) + +1. Complete Phase 1: Foundational address parsing + tmux + mail storage +2. Complete Phase 2: US1 — Send with pane addresses +3. Complete Phase 3: US2 — Receive with pane addresses +4. Complete Phase 4: US3 — Backward compatibility verification +5. **STOP and VALIDATE**: Core send/receive with pane isolation works +6. At this point, agents in separate panes can communicate + +### Incremental Delivery + +1. Phase 1 → Foundation ready +2. US1 + US2 + US3 → Core pane messaging works (MVP!) +3. US4 → MCP integration for AI agents +4. US5 → Hook notifications pane-isolated +5. US6 → Discoverability via recipients listing +6. Phase 8 → Daemon and cleanup pane-aware +7. Phase 9 → Polish and validate + +--- + +## Notes + +- [P] tasks = different files, no dependencies on incomplete tasks +- [Story] label maps task to specific user story for traceability +- Each user story is independently testable after its phase completes +- Commit after each task or logical group +- The Message struct in internal/mail/message.go does NOT need modification — only the content of From/To fields changes, not the struct definition +- SendOptions, ReceiveOptions, etc. need new mock fields (MockPaneAddress, MockPanes, MockSession) to replace MockSender/MockWindows/MockReceiver +- This is a BREAKING CHANGE: no data migration, no MCP backward-compat fields, no upgrade procedure. Old mailbox files are ignored. MCP list-recipients replaces `window` with `address`. +- All public APIs use canonical pane addresses (e.g., "mysession:editor.0"). Percent-encoding is internal to the mail storage layer only — never exposed to callers. diff --git a/specs/tmux-pane-addressing/worklog.md b/specs/tmux-pane-addressing/worklog.md new file mode 100644 index 0000000..10369a5 --- /dev/null +++ b/specs/tmux-pane-addressing/worklog.md @@ -0,0 +1,8 @@ +# Worklog: Tmux Pane Addressing + +**Feature Branch**: `tmux-pane-addressing` +**Started**: 2026-02-13 +**Status**: In Progress + +--- +