Claude Skill

pigeon

Inter-session pmail - send and receive messages between Claude Code sessions running in different project directories. Uses global SQLite database at ~/.claude/pmail.db. Triggers on: mail, pmail, send message, check mail, inbox, inter-session, message another session, pigeon.

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download 0xdarkmatter-claude-mods-skills_pigeon-3dfaf0b.zip · 22 KB
Part of 0xdarkmatter/claude-mods — 94 skills

Install

skills CLI npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/pigeon
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
Git git clone https://github.com/0xDarkMatter/claude-mods.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Pigeon

Inter-session messaging for Claude Code. Send and receive pmail between sessions running in different projects.

Quick Reference

All commands go through MAIL, a shorthand for bash "$HOME/.claude/pigeon/mail-db.sh".

Set this at the top of execution:

MAIL="$HOME/.claude/pigeon/mail-db.sh"

Then use it for all commands below.

Command Router

Parse the user's input after pigeon (or /pigeon) and run the matching command:

User says Run
pigeon read bash "$MAIL" read
pigeon read 42 bash "$MAIL" read 42
pigeon send <project> "<subject>" "<body>" bash "$MAIL" send "<project>" "<subject>" "<body>"
pigeon send --urgent <project> "<subject>" "<body>" bash "$MAIL" send --urgent "<project>" "<subject>" "<body>"
pigeon send --attach <path> <project> "<subject>" "<body>" bash "$MAIL" send --attach "<path>" "<project>" "<subject>" "<body>"
pigeon reply <id> "<body>" bash "$MAIL" reply <id> "<body>"
pigeon reply --attach <path> <id> "<body>" bash "$MAIL" reply --attach "<path>" <id> "<body>"
pigeon broadcast "<subject>" "<body>" bash "$MAIL" broadcast "<subject>" "<body>"
pigeon search <keyword> bash "$MAIL" search "<keyword>"
pigeon status bash "$MAIL" status
pigeon unread bash "$MAIL" unread
pigeon list bash "$MAIL" list
pigeon list 50 bash "$MAIL" list 50
pigeon projects bash "$MAIL" projects
pigeon clear bash "$MAIL" clear
pigeon clear 7 bash "$MAIL" clear 7
pigeon alias <old> <new> bash "$MAIL" alias "<old>" "<new>"
pigeon purge bash "$MAIL" purge
pigeon purge --all bash "$MAIL" purge --all
pigeon id bash "$MAIL" id
pigeon migrate bash "$MAIL" migrate
pigeon init bash "$MAIL" init

When the user just says "check mail", "read mail", "inbox", "any mail?", or "any pmail?" - run bash "$MAIL" read.

When the user says "send mail to X", "send pmail to X", or "message X" - parse out the project name, subject, and body, then run bash "$MAIL" send.

Project Identity

Each project gets a stable 6-character hash ID derived from its git root commit (the very first commit in the repo). This means:

  • IDs survive directory renames, moves, and clones
  • Case-insensitive filesystems (macOS) don't cause collisions
  • Every clone of the same repo shares the same identity

For non-git directories, falls back to a hash of the canonical path (pwd -P).

Use pigeon id to see your project's name and hash:

claude-mods 7663d6

When sending messages, you can address projects by name, hash, or path - they all resolve to the same hash ID.

Identicons

Each project hash renders as a unique pixel-art identicon (11x11 symmetric grid using Unicode half-block characters). Run identicon.sh to see yours, or view all projects with pigeon projects.

Passive Notification (Hook)

A global PreToolUse hook checks for pmail on every tool call (no cooldown). Silent when inbox is empty. When mail is waiting it prints one JSON envelope, and Claude Code passes its additionalContext to the model:

{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"=== INCOMING PMAIL (1 message(s)) ===\n..."}}

What the model reads:

=== INCOMING PMAIL (1 message(s)) ===

--- #42 from some-api (a1b2c3) @ 2026-09-30 10:12:04 ---
Subject: Auth endpoints ready
Login and refresh are live on staging.

=== ACTION REQUIRED: Inform the user about these messages and ask if they want to reply. ===
=== Then run: pigeon read (to mark as read) ===
=== To reply: pigeon reply <id> "message" ===

The JSON form is required. For PreToolUse, plain stdout goes to Claude Code's debug log and never reaches the model. Claude Code caps additionalContext at 10,000 chars, so the hook truncates message bodies at about 9,000 and points at pigeon read. The header and footer are always kept. Delivery does not mark mail read. The signal file is cleared, so each new send triggers one notice.

Attachments

Send file references with --attach <path> (repeatable). Paths are resolved to absolute and stored as references - files are not copied.

# Send with one attachment
pigeon send --attach src/config.ts my-api "Config update" "Updated the auth config"

# Send with multiple attachments
pigeon send --attach src/schema.sql --attach docs/API.md my-api "Schema + docs" "See attached"

# Reply with attachment
pigeon reply --attach output/report.json 42 "Here's the analysis"

Recipients see attachment paths with file sizes and can read them directly with the Read tool. If a file has been moved or deleted since sending, it shows as (missing).

When to Send

  • You've completed work another session depends on
  • An API contract or shared interface changed
  • A shared branch (main) is broken or fixed
  • You need input from a session working on a different project

Per-Project Disable

touch .claude/pigeon.disable    # Disable hook notifications
rm .claude/pigeon.disable       # Re-enable

Only the hook is disabled - you can still send messages from the project.


Installation

Pigeon requires two things: scripts (the mail engine) and a hook (passive notifications). Both install globally - one setup, every project gets pmail.

Prerequisites

  • sqlite3 - ships with macOS, most Linux distros, and Git Bash on Windows. No install needed.
  • jq - the hook uses it to build the JSON envelope Claude Code requires (brew install jq, scoop install jq, apt install jq). Without jq the hook stays silent, and pigeon read still works.

Step 1: Copy Scripts

mkdir -p ~/.claude/pigeon
cp skills/pigeon/scripts/mail-db.sh ~/.claude/pigeon/
cp hooks/check-mail.sh ~/.claude/pigeon/
chmod +x ~/.claude/pigeon/mail-db.sh ~/.claude/pigeon/check-mail.sh

This gives you the pmail commands. You can now send and read messages manually:

bash ~/.claude/pigeon/mail-db.sh init      # Create database
bash ~/.claude/pigeon/mail-db.sh status    # Check it works

Step 2: Enable the Hook

Add a hooks block to ~/.claude/settings.json. This makes Claude check for pmail automatically on every tool call:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "bash \"$HOME/.claude/pigeon/check-mail.sh\"",
            "timeout": 5
          }
        ]
      }
    ]
  }
}

Important: If you already have a hooks section in your settings, merge the PreToolUse entry into the existing array - don't replace the whole block.

Without this step, pigeon still works but you have to check manually (pigeon read). With the hook, unread pmail appears automatically.

What Gets Created

~/.claude/
  settings.json            # Hook config (you edit this)
  pmail.db                 # Message store (auto-created on first use)
  pigeon/
    mail-db.sh             # All pmail commands (send, read, reply, etc.)
    check-mail.sh          # PreToolUse hook (silent when inbox empty)

Verify

# Check your project identity
bash ~/.claude/pigeon/mail-db.sh id

# Send yourself a test message (use your project name from above)
bash ~/.claude/pigeon/mail-db.sh send "my-project" "Test" "Hello from pigeon"

# Check it arrived
bash ~/.claude/pigeon/mail-db.sh read

# Clean up
bash ~/.claude/pigeon/mail-db.sh purge --all

Uninstall

rm -rf ~/.claude/pigeon ~/.claude/pmail.db
# Then remove the hooks.PreToolUse entry from ~/.claude/settings.json

Database

Single SQLite file at ~/.claude/pmail.db. Auto-created on first init or send.

CREATE TABLE messages (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    from_project TEXT NOT NULL,   -- 6-char hash ID
    to_project TEXT NOT NULL,     -- 6-char hash ID
    subject TEXT DEFAULT '',
    body TEXT NOT NULL,
    timestamp TEXT DEFAULT (datetime('now')),
    read INTEGER DEFAULT 0,
    priority TEXT DEFAULT 'normal'
);

CREATE TABLE projects (
    hash TEXT PRIMARY KEY,        -- 6-char ID (git root commit or path hash)
    name TEXT NOT NULL,           -- Display name (basename of project dir)
    path TEXT NOT NULL,           -- Canonical path
    registered TEXT DEFAULT (datetime('now'))
);

Troubleshooting

Issue Fix
sqlite3: not found Ships with macOS, Linux, and Git Bash on Windows. Run sqlite3 --version to check.
Hook not firing Ensure hooks block is in ~/.claude/settings.json (Step 2 above)
Hook fires but no notification Working as intended - hook is silent when inbox is empty
Mail is unread but Claude never mentions it The hook must print JSON additionalContext, because plain PreToolUse stdout only reaches the debug log. Reinstall check-mail.sh from this repo and check jq --version
Messages not arriving Target must be a known name, hash, or path. Use pigeon projects to see registered projects
Upgraded from basename IDs Run pigeon migrate to convert old messages to hash-based IDs
Changed display name Use pigeon alias old-name new-name to update the project's display name
Want to disable for one project touch .claude/pigeon.disable in that project's root
Check your project ID Run pigeon id to see name and 6-char hash
Files (claude-mods)
  • assets
    • .gitkeep 0 B · in bundle
  • references
    • .gitkeep 0 B · in bundle
  • scripts
    • identicon.sh 4.9 KB
      #!/usr/bin/env bash
      # Generate a symmetric pixel art identicon from a hash
      # Usage: bash identicon.sh <path_or_string> [--compact]
      #
      # 11x11 pixel grid (mirrored from 6 columns), rendered with Unicode
      # half-block characters for double vertical resolution. Each project
      # gets a unique colored portrait derived from sha256 of its canonical path.
      
      set -e
      
      INPUT="${1:-$PWD}"
      COMPACT=false
      [[ "${2:-}" == "--compact" || "${1:-}" == "--compact" ]] && COMPACT=true
      [[ "${1:-}" == "--compact" ]] && INPUT="$PWD"
      
      # Identity: git root commit hash > canonical path hash
      # This must match mail-db.sh project_hash() logic
      if [ -d "$INPUT" ]; then
          CANONICAL=$(cd "$INPUT" && pwd -P)
          ROOT_COMMIT=$(git -C "$INPUT" rev-list --max-parents=0 HEAD 2>/dev/null | head -1)
          if [ -n "$ROOT_COMMIT" ]; then
              # Use full root commit for visual entropy, short ID from first 6
              HASH=$(printf '%s' "$ROOT_COMMIT" | shasum -a 256 | cut -c1-40)
              SHORT="${ROOT_COMMIT:0:6}"
          else
              HASH=$(printf '%s' "$CANONICAL" | shasum -a 256 | cut -c1-40)
              SHORT="${HASH:0:6}"
          fi
      else
          CANONICAL="$INPUT"
          HASH=$(printf '%s' "$CANONICAL" | shasum -a 256 | cut -c1-40)
          SHORT="${HASH:0:6}"
      fi
      NAME=$(basename "$CANONICAL")
      
      # --- Color palette ---
      # Two colors per identicon: foreground + accent, from different hash regions
      FG_IDX=$(( $(printf '%d' "0x${HASH:6:2}") % 7 ))
      BG_IDX=$(( $(printf '%d' "0x${HASH:8:2}") % 4 ))
      
      # Foreground: vivid ANSI colors
      FG_CODES=(31 32 33 34 35 36 91)
      FG="\033[${FG_CODES[$FG_IDX]}m"
      
      # Shade characters: full, dark, medium, light
      CHARS=("█" "▓" "▒" "░")
      
      RESET="\033[0m"
      DIM="\033[2m"
      
      # --- Build 11x12 pixel grid ---
      # 6 columns generated, mirrored to 11 (c0 c1 c2 c3 c4 c5 c4 c3 c2 c1 c0)
      # 12 rows, rendered as 6 lines using half-block characters
      # Each cell has 2 bits (4 shade levels): 6 cols * 12 rows = 72 cells = 144 bits
      # We have 160 bits from 40 hex chars
      
      declare -a GRID  # GRID[row*6+col] = shade level (0-3)
      
      bit_pos=0
      for row in $(seq 0 11); do
          for col in $(seq 0 5); do
              hex_pos=$((bit_pos / 4))
              bit_offset=$((bit_pos % 4))
              hex_char="${HASH:$hex_pos:1}"
              nibble=$(printf '%d' "0x${hex_char}")
      
              # Extract 2 bits for shade level
              if [ $bit_offset -le 2 ]; then
                  shade=$(( (nibble >> bit_offset) & 3 ))
              else
                  # Straddle nibble boundary
                  next_char="${HASH:$((hex_pos+1)):1}"
                  next_nibble=$(printf '%d' "0x${next_char}")
                  shade=$(( ((nibble >> bit_offset) | (next_nibble << (4 - bit_offset))) & 3 ))
              fi
      
              GRID[$((row * 6 + col))]=$shade
              bit_pos=$((bit_pos + 2))
          done
      done
      
      # --- Render with half-blocks ---
      # Each output line combines two pixel rows using ▀▄█ and space
      # Top pixel = upper half, Bottom pixel = lower half
      #
      # Both filled  = █ (full block)
      # Top only     = ▀ (upper half)
      # Bottom only  = ▄ (lower half)
      # Neither      = " " (space)
      
      get_mirrored_col() {
          local col=$1
          # Mirror pattern: 0 1 2 3 4 5 4 3 2 1 0
          if [ $col -le 5 ]; then
              echo $col
          else
              echo $((10 - col))
          fi
      }
      
      render_cell() {
          local top_shade=$1
          local bot_shade=$2
      
          # Threshold: shades 0-1 = filled, 2-3 = empty (gives ~50% fill)
          local top_on=$(( top_shade <= 1 ? 1 : 0 ))
          local bot_on=$(( bot_shade <= 1 ? 1 : 0 ))
      
          if [ $top_on -eq 1 ] && [ $bot_on -eq 1 ]; then
              # Both filled - use shade of top for character choice
              printf '%s' "${CHARS[$top_shade]}"
          elif [ $top_on -eq 1 ]; then
              printf '▀'
          elif [ $bot_on -eq 1 ]; then
              printf '▄'
          else
              printf ' '
          fi
      }
      
      # Width: 11 columns, each 1 char wide = 11 chars inside frame
      BORDER_TOP="${DIM}┌───────────┐${RESET}"
      BORDER_BOT="${DIM}└───────────┘${RESET}"
      
      if [ "$COMPACT" = true ]; then
          # Compact: no frame, just the icon + hash
          for line in $(seq 0 5); do
              top_row=$((line * 2))
              bot_row=$((line * 2 + 1))
              printf '%b' "${FG}"
              for col in $(seq 0 10); do
                  src_col=$(get_mirrored_col $col)
                  top_shade=${GRID[$((top_row * 6 + src_col))]}
                  bot_shade=${GRID[$((bot_row * 6 + src_col))]}
                  render_cell $top_shade $bot_shade
              done
              printf '%b\n' "${RESET}"
          done
          echo -e "${FG}${SHORT}${RESET}"
      else
          # Framed display
          echo -e "$BORDER_TOP"
          for line in $(seq 0 5); do
              top_row=$((line * 2))
              bot_row=$((line * 2 + 1))
              printf '%b' "${DIM}│${RESET}${FG}"
              for col in $(seq 0 10); do
                  src_col=$(get_mirrored_col $col)
                  top_shade=${GRID[$((top_row * 6 + src_col))]}
                  bot_shade=${GRID[$((bot_row * 6 + src_col))]}
                  render_cell $top_shade $bot_shade
              done
              printf '%b\n' "${RESET}${DIM}│${RESET}"
          done
          echo -e "$BORDER_BOT"
          echo -e " ${FG}${NAME}${RESET} ${DIM}${SHORT}${RESET}"
      fi
      
    • mail-db.sh 28.1 KB
      #!/bin/bash
      # mail-db.sh - SQLite pmail database operations
      # Global mail database at ~/.claude/pmail.db
      # Project identity: 6-char ID derived from git root commit (stable across
      # renames, moves, clones) with fallback to canonical path hash for non-git dirs.
      
      set -euo pipefail
      
      MAIL_DB="$HOME/.claude/pmail.db"
      SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      
      # ============================================================================
      # SQLite access - the one choke point every query goes through
      # ============================================================================
      
      # Guard: this function deliberately SHADOWS the sqlite3 binary. Windows' native
      # sqlite3.exe (the one Git Bash finds at C:\Windows\sqlite3) writes its output in
      # text mode, so every "\n" - row separators AND newlines stored inside a value -
      # arrives as "\r\n". Command substitution strips only the final newline, so every
      # line-by-line parser here kept a stray "\r" on all but its last line: a message
      # with four attachments showed the first three as "(missing)" although the stored
      # paths were clean. Stripping CR here fixes every read site at once; `pipefail`
      # (set above) keeps sqlite3's own exit status, which the `|| ALTER TABLE`
      # migrations depend on. Don't bypass it with `command sqlite3` at a call site.
      sqlite3() {
        command sqlite3 "$@" | tr -d '\r'
      }
      
      # ============================================================================
      # Identity - git-rooted project IDs
      # ============================================================================
      
      # Get canonical path (resolves symlinks + case on macOS)
      canonical_path() {
        if [ -d "${1:-$PWD}" ]; then
          (cd "${1:-$PWD}" && pwd -P)
        else
          printf '%s' "${1:-$PWD}"
        fi
      }
      
      # Resolve to the canonical main-repository root. If the given dir is inside
      # a git worktree, returns the MAIN repo's top-level directory rather than the
      # worktree's. This prevents pigeon from registering a worktree as if it were
      # the project (a worktree session would otherwise INSERT OR REPLACE the main
      # repo's projects row with the worktree's name + path).
      #
      # Mechanism:
      #   - `git rev-parse --git-common-dir` returns the canonical .git directory:
      #       - For a main repo: same as --git-dir (e.g. /repo/.git)
      #       - For a worktree: the main repo's .git (e.g. /repo/.git, NOT
      #         /repo/.git/worktrees/<wt-name>)
      #   - Strip trailing /.git to get the main repo's top-level directory.
      #   - Bare repos / non-git dirs fall back to canonical_path.
      resolve_main_repo() {
        local dir="${1:-$PWD}"
        if [ ! -d "$dir" ]; then
          canonical_path "$dir"
          return
        fi
        local commondir
        commondir=$(git -C "$dir" rev-parse --git-common-dir 2>/dev/null)
        if [ -z "$commondir" ]; then
          # Not a git repo — fall through
          canonical_path "$dir"
          return
        fi
        # commondir may be relative to $dir; make it absolute and canonical.
        case "$commondir" in
          /*) ;;  # absolute
          *)  commondir=$(cd "$dir" && cd "$commondir" 2>/dev/null && pwd -P) ;;
        esac
        # Strip trailing /.git to get the main repo's top-level (non-bare repos).
        # Bare repos: commondir IS the repo top-level, no /.git suffix.
        case "$commondir" in
          */.git)  dirname "$commondir" ;;
          *)       printf '%s' "$commondir" ;;
        esac
      }
      
      # Generate 6-char project ID
      # Priority: git root commit hash > canonical path hash
      project_hash() {
        local dir="${1:-$PWD}"
      
        # Try git root commit (first commit in repo history)
        if [ -d "$dir" ]; then
          local root_commit
          root_commit=$(git -C "$dir" rev-list --max-parents=0 HEAD 2>/dev/null | head -1)
          if [ -n "$root_commit" ]; then
            echo "${root_commit:0:6}"
            return 0
          fi
        fi
      
        # Fallback: hash of canonical path
        local path
        path=$(canonical_path "$dir")
        printf '%s' "$path" | shasum -a 256 | cut -c1-6
      }
      
      # Get display name (basename of the MAIN-REPO top-level — never a worktree's).
      project_name() {
        basename "$(resolve_main_repo "${1:-$PWD}")"
      }
      
      # ============================================================================
      # Database
      # ============================================================================
      
      init_db() {
        mkdir -p "$(dirname "$MAIL_DB")"
        sqlite3 "$MAIL_DB" <<'SQL'
      CREATE TABLE IF NOT EXISTS messages (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          from_project TEXT NOT NULL,
          to_project TEXT NOT NULL,
          subject TEXT DEFAULT '',
          body TEXT NOT NULL,
          timestamp TEXT DEFAULT (datetime('now')),
          read INTEGER DEFAULT 0,
          priority TEXT DEFAULT 'normal'
      );
      CREATE INDEX IF NOT EXISTS idx_unread ON messages(to_project, read);
      CREATE INDEX IF NOT EXISTS idx_timestamp ON messages(timestamp);
      
      CREATE TABLE IF NOT EXISTS projects (
          hash TEXT PRIMARY KEY,
          name TEXT NOT NULL,
          path TEXT NOT NULL,
          registered TEXT DEFAULT (datetime('now'))
      );
      SQL
        # Migration: add priority column if missing
        sqlite3 "$MAIL_DB" "SELECT priority FROM messages LIMIT 0;" 2>/dev/null || \
          sqlite3 "$MAIL_DB" "ALTER TABLE messages ADD COLUMN priority TEXT DEFAULT 'normal';" 2>/dev/null
        # Migration: create projects table if missing (for existing installs)
        sqlite3 "$MAIL_DB" "SELECT hash FROM projects LIMIT 0;" 2>/dev/null || \
          sqlite3 "$MAIL_DB" "CREATE TABLE IF NOT EXISTS projects (hash TEXT PRIMARY KEY, name TEXT NOT NULL, path TEXT NOT NULL, registered TEXT DEFAULT (datetime('now')));" 2>/dev/null
        # Migration: add thread_id column if missing
        sqlite3 "$MAIL_DB" "SELECT thread_id FROM messages LIMIT 0;" 2>/dev/null || \
          sqlite3 "$MAIL_DB" "ALTER TABLE messages ADD COLUMN thread_id INTEGER REFERENCES messages(id);" 2>/dev/null
        # Migration: add attachments column if missing
        sqlite3 "$MAIL_DB" "SELECT attachments FROM messages LIMIT 0;" 2>/dev/null || \
          sqlite3 "$MAIL_DB" "ALTER TABLE messages ADD COLUMN attachments TEXT DEFAULT '';" 2>/dev/null
      }
      
      sql_escape() {
        printf '%s' "$1" | sed "s/'/''/g"
      }
      
      # Resolve attachment path to absolute, validate existence
      resolve_attach() {
        local p="$1"
        if [ ! -e "$p" ]; then
          echo "Error: attachment not found: $p" >&2
          return 1
        fi
        (cd "$(dirname "$p")" && echo "$(pwd -P)/$(basename "$p")")
      }
      
      # Read body from argument or stdin (use - or omit for stdin)
      read_body() {
        local arg="$1"
        if [ "$arg" = "-" ] || [ -z "$arg" ]; then
          cat
        else
          printf '%s' "$arg"
        fi
      }
      
      # Register current project in the projects table (idempotent).
      # Always registers the main repo's top-level — a worktree session must NOT
      # overwrite the main repo's row with the worktree's path/name.
      register_project() {
        local hash name path
        hash=$(project_hash "${1:-$PWD}")
        name=$(sql_escape "$(project_name "${1:-$PWD}")")
        path=$(sql_escape "$(resolve_main_repo "${1:-$PWD}")")
        sqlite3 "$MAIL_DB" \
          "INSERT OR REPLACE INTO projects (hash, name, path) VALUES ('${hash}', '${name}', '${path}');"
      }
      
      # Get project ID for current directory
      get_project_id() {
        project_hash "${1:-$PWD}"
      }
      
      # Resolve a user-supplied name/hash to a project hash
      # Accepts: hash (6 chars), project name, or path
      resolve_target() {
        local target="$1"
        local safe_target
        safe_target=$(sql_escape "$target")
      
        # 1. Exact hash match
        if [[ ${#target} -eq 6 ]] && [[ "$target" =~ ^[0-9a-f]+$ ]]; then
          local found
          found=$(sqlite3 "$MAIL_DB" "SELECT hash FROM projects WHERE hash='${safe_target}';")
          if [ -n "$found" ]; then
            echo "$found"
            return 0
          fi
        fi
      
        # 2. Name match (case-insensitive)
        local by_name
        by_name=$(sqlite3 "$MAIL_DB" "SELECT hash FROM projects WHERE LOWER(name)=LOWER('${safe_target}') ORDER BY registered DESC LIMIT 1;")
        if [ -n "$by_name" ]; then
          echo "$by_name"
          return 0
        fi
      
        # 3. Path match - target might be a directory
        if [ -d "$target" ]; then
          local hash
          hash=$(project_hash "$target")
          echo "$hash"
          return 0
        fi
      
        # 4. Generate hash from target as a string (for unknown projects)
        # Register it so replies work
        local hash
        hash=$(printf '%s' "$target" | shasum -a 256 | cut -c1-6)
        sqlite3 "$MAIL_DB" \
          "INSERT OR IGNORE INTO projects (hash, name, path) VALUES ('${hash}', '${safe_target}', '${safe_target}');"
        echo "$hash"
      }
      
      # Look up display name for a hash
      display_name() {
        local hash="$1"
        local name
        name=$(sqlite3 "$MAIL_DB" "SELECT name FROM projects WHERE hash='${hash}';")
        if [ -n "$name" ]; then
          echo "$name"
        else
          echo "$hash"
        fi
      }
      
      # ============================================================================
      # Identicon display (inline, compact)
      # ============================================================================
      
      show_identicon() {
        local target="${1:-$PWD}"
        if [ -f "$SCRIPT_DIR/identicon.sh" ]; then
          bash "$SCRIPT_DIR/identicon.sh" "$target"
        fi
      }
      
      # ============================================================================
      # Mail operations
      # ============================================================================
      
      count_unread() {
        init_db
        register_project
        local pid
        pid=$(get_project_id)
        sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${pid}' AND read=0;"
      }
      
      list_unread() {
        init_db
        register_project
        local pid
        pid=$(get_project_id)
        local rows
        rows=$(sqlite3 -separator '|' "$MAIL_DB" \
          "SELECT id, from_project, subject, timestamp FROM messages WHERE to_project='${pid}' AND read=0 ORDER BY timestamp DESC;")
        [ -z "$rows" ] && return 0
        while IFS='|' read -r id from_hash subj ts; do
          local from_name
          from_name=$(display_name "$from_hash")
          echo "${id} | ${from_name} (${from_hash}) | ${subj} | ${ts}"
        done <<< "$rows"
      }
      
      read_mail() {
        init_db
        register_project
        local pid
        pid=$(get_project_id)
        # Use ASCII record separator (0x1E) to avoid splitting on pipes/newlines in body
        local RS=$'\x1e'
        local count
        count=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${pid}' AND read=0;")
        [ "${count:-0}" -eq 0 ] && return 0
        # Query each message individually to preserve multi-line bodies
        local ids
        ids=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages WHERE to_project='${pid}' AND read=0 ORDER BY timestamp ASC;")
        echo "id | from_project | subject | body | timestamp"
        while read -r msg_id; do
          [ -z "$msg_id" ] && continue
          local from_hash subj body ts from_name attachments
          from_hash=$(sqlite3 "$MAIL_DB" "SELECT from_project FROM messages WHERE id=${msg_id};")
          subj=$(sqlite3 "$MAIL_DB" "SELECT subject FROM messages WHERE id=${msg_id};")
          body=$(sqlite3 "$MAIL_DB" "SELECT body FROM messages WHERE id=${msg_id};")
          ts=$(sqlite3 "$MAIL_DB" "SELECT timestamp FROM messages WHERE id=${msg_id};")
          attachments=$(sqlite3 "$MAIL_DB" "SELECT COALESCE(attachments,'') FROM messages WHERE id=${msg_id};")
          from_name=$(display_name "$from_hash")
          echo "${msg_id} | ${from_name} (${from_hash}) | ${subj} | ${body} | ${ts}"
          if [ -n "$attachments" ]; then
            while IFS= read -r apath; do
              [ -z "$apath" ] && continue
              local astat="missing"
              [ -e "$apath" ] && astat="$(wc -c < "$apath" | tr -d ' ') bytes"
              echo "  [Attached: ${apath} (${astat})]"
            done <<< "$attachments"
          fi
        done <<< "$ids"
        sqlite3 "$MAIL_DB" \
          "UPDATE messages SET read=1 WHERE to_project='${pid}' AND read=0;"
        # Clear signal file
        rm -f "/tmp/pigeon_signal_${pid}"
      }
      
      read_one() {
        local msg_id="$1"
        if ! [[ "$msg_id" =~ ^[0-9]+$ ]]; then
          echo "Error: message ID must be numeric" >&2
          return 1
        fi
        init_db
        local exists
        exists=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE id=${msg_id};")
        [ "${exists:-0}" -eq 0 ] && return 0
        local from_hash to_hash subj body ts from_name to_name attachments
        from_hash=$(sqlite3 "$MAIL_DB" "SELECT from_project FROM messages WHERE id=${msg_id};")
        to_hash=$(sqlite3 "$MAIL_DB" "SELECT to_project FROM messages WHERE id=${msg_id};")
        subj=$(sqlite3 "$MAIL_DB" "SELECT subject FROM messages WHERE id=${msg_id};")
        body=$(sqlite3 "$MAIL_DB" "SELECT body FROM messages WHERE id=${msg_id};")
        ts=$(sqlite3 "$MAIL_DB" "SELECT timestamp FROM messages WHERE id=${msg_id};")
        attachments=$(sqlite3 "$MAIL_DB" "SELECT COALESCE(attachments,'') FROM messages WHERE id=${msg_id};")
        from_name=$(display_name "$from_hash")
        to_name=$(display_name "$to_hash")
        echo "id | from_project | to_project | subject | body | timestamp"
        echo "${msg_id} | ${from_name} (${from_hash}) | ${to_name} (${to_hash}) | ${subj} | ${body} | ${ts}"
        if [ -n "$attachments" ]; then
          while IFS= read -r apath; do
            [ -z "$apath" ] && continue
            local astat="missing"
            [ -e "$apath" ] && astat="$(wc -c < "$apath" | tr -d ' ') bytes"
            echo "  [Attached: ${apath} (${astat})]"
          done <<< "$attachments"
        fi
        sqlite3 "$MAIL_DB" \
          "UPDATE messages SET read=1 WHERE id=${msg_id};"
      }
      
      send() {
        local priority="normal"
        local -a attach_paths=()
        # Parse flags before positional args
        while [ $# -gt 0 ]; do
          case "$1" in
            --urgent) priority="urgent"; shift ;;
            --attach) shift; local resolved; resolved=$(resolve_attach "$1") || return 1; attach_paths+=("$resolved"); shift ;;
            *) break ;;
          esac
        done
        local to_input="${1:?to_project required}"
        local subject="${2:-no subject}"
        local body
        body=$(read_body "${3:-}")
        if [ -z "$body" ]; then
          echo "Error: message body cannot be empty" >&2
          return 1
        fi
        init_db
        register_project
        local from_id to_id
        from_id=$(get_project_id)
        to_id=$(resolve_target "$to_input")
        local safe_subject safe_body safe_attachments
        safe_subject=$(sql_escape "$subject")
        safe_body=$(sql_escape "$body")
        # Join attachment paths with newlines
        local attachments=""
        if [ ${#attach_paths[@]} -gt 0 ]; then
          attachments=$(IFS=$'\n'; echo "${attach_paths[*]}")
        fi
        safe_attachments=$(sql_escape "$attachments")
        sqlite3 "$MAIL_DB" \
          "INSERT INTO messages (from_project, to_project, subject, body, priority, attachments) VALUES ('${from_id}', '${to_id}', '${safe_subject}', '${safe_body}', '${priority}', '${safe_attachments}');"
        # Signal the recipient
        touch "/tmp/pigeon_signal_${to_id}"
        local to_name
        to_name=$(display_name "$to_id")
        local attach_note=""
        [ ${#attach_paths[@]} -gt 0 ] && attach_note=" [${#attach_paths[@]} attachment(s)]"
        echo "Sent to ${to_name} (${to_id}): ${subject}${attach_note}$([ "$priority" = "urgent" ] && echo " [URGENT]" || true)"
      }
      
      sent() {
        local limit="${1:-20}"
        init_db
        register_project
        local pid
        pid=$(get_project_id)
        local rows
        rows=$(sqlite3 -separator '|' "$MAIL_DB" \
          "SELECT id, to_project, subject, timestamp FROM messages WHERE from_project='${pid}' ORDER BY timestamp DESC LIMIT ${limit};")
        [ -z "$rows" ] && echo "No sent messages" && return 0
        echo "id | to | subject | timestamp"
        while IFS='|' read -r id to_hash subj ts; do
          local to_name
          to_name=$(display_name "$to_hash")
          echo "${id} | ${to_name} (${to_hash}) | ${subj} | ${ts}"
        done <<< "$rows"
      }
      
      search() {
        local keyword="$1"
        if [ -z "$keyword" ]; then
          echo "Error: search keyword required" >&2
          return 1
        fi
        init_db
        register_project
        local pid
        pid=$(get_project_id)
        local safe_keyword
        safe_keyword=$(sql_escape "$keyword")
        local rows
        rows=$(sqlite3 -separator '|' "$MAIL_DB" \
          "SELECT id, from_project, subject, CASE WHEN read=0 THEN 'UNREAD' ELSE 'read' END, timestamp FROM messages WHERE to_project='${pid}' AND (subject LIKE '%${safe_keyword}%' OR body LIKE '%${safe_keyword}%') ORDER BY timestamp DESC LIMIT 20;")
        [ -z "$rows" ] && return 0
        echo "id | from | subject | status | timestamp"
        while IFS='|' read -r id from_hash subj status ts; do
          local from_name
          from_name=$(display_name "$from_hash")
          echo "${id} | ${from_name} (${from_hash}) | ${subj} | ${status} | ${ts}"
        done <<< "$rows"
      }
      
      list_all() {
        init_db
        register_project
        local pid
        pid=$(get_project_id)
        local limit="${1:-20}"
        if ! [[ "$limit" =~ ^[0-9]+$ ]]; then
          limit=20
        fi
        local rows
        rows=$(sqlite3 -separator '|' "$MAIL_DB" \
          "SELECT id, from_project, subject, CASE WHEN read=0 THEN 'UNREAD' ELSE 'read' END, timestamp FROM messages WHERE to_project='${pid}' ORDER BY timestamp DESC LIMIT ${limit};")
        [ -z "$rows" ] && return 0
        echo "id | from | subject | status | timestamp"
        while IFS='|' read -r id from_hash subj status ts; do
          local from_name
          from_name=$(display_name "$from_hash")
          echo "${id} | ${from_name} (${from_hash}) | ${subj} | ${status} | ${ts}"
        done <<< "$rows"
      }
      
      clear_old() {
        init_db
        local days="${1:-7}"
        if ! [[ "$days" =~ ^[0-9]+$ ]]; then
          days=7
        fi
        local deleted
        deleted=$(sqlite3 "$MAIL_DB" \
          "DELETE FROM messages WHERE read=1 AND timestamp < datetime('now', '-${days} days'); SELECT changes();")
        echo "Cleared ${deleted} read messages older than ${days} days"
      }
      
      reply() {
        local -a attach_paths=()
        # Parse flags before positional args
        while [ $# -gt 0 ]; do
          case "$1" in
            --attach) shift; local resolved; resolved=$(resolve_attach "$1") || return 1; attach_paths+=("$resolved"); shift ;;
            *) break ;;
          esac
        done
        local msg_id="$1"
        local body
        body=$(read_body "${2:-}")
        if ! [[ "$msg_id" =~ ^[0-9]+$ ]]; then
          echo "Error: message ID must be numeric" >&2
          return 1
        fi
        if [ -z "$body" ]; then
          echo "Error: reply body cannot be empty" >&2
          return 1
        fi
        init_db
        register_project
        local orig
        orig=$(sqlite3 -separator '|' "$MAIL_DB" "SELECT from_project, subject, thread_id FROM messages WHERE id=${msg_id};")
        if [ -z "$orig" ]; then
          echo "Error: message #${msg_id} not found" >&2
          return 1
        fi
        local orig_from_hash orig_subject orig_thread
        orig_from_hash=$(echo "$orig" | cut -d'|' -f1)
        orig_subject=$(echo "$orig" | cut -d'|' -f2)
        orig_thread=$(echo "$orig" | cut -d'|' -f3)
        # Thread ID: inherit from parent, or use parent's ID as thread root
        local thread_id="${orig_thread:-$msg_id}"
        local from_id
        from_id=$(get_project_id)
        local safe_subject safe_body safe_attachments
        safe_subject=$(sql_escape "Re: ${orig_subject}")
        safe_body=$(sql_escape "$body")
        local attachments=""
        if [ ${#attach_paths[@]} -gt 0 ]; then
          attachments=$(IFS=$'\n'; echo "${attach_paths[*]}")
        fi
        safe_attachments=$(sql_escape "$attachments")
        sqlite3 "$MAIL_DB" \
          "INSERT INTO messages (from_project, to_project, subject, body, thread_id, attachments) VALUES ('${from_id}', '${orig_from_hash}', '${safe_subject}', '${safe_body}', ${thread_id}, '${safe_attachments}');"
        # Signal the recipient
        touch "/tmp/pigeon_signal_${orig_from_hash}"
        local orig_name
        orig_name=$(display_name "$orig_from_hash")
        local attach_note=""
        [ ${#attach_paths[@]} -gt 0 ] && attach_note=" [${#attach_paths[@]} attachment(s)]"
        echo "Replied to ${orig_name} (${orig_from_hash}): Re: ${orig_subject}${attach_note}"
      }
      
      thread() {
        local msg_id="$1"
        if ! [[ "$msg_id" =~ ^[0-9]+$ ]]; then
          echo "Error: message ID must be numeric" >&2
          return 1
        fi
        init_db
        # Find the thread root: either the message itself or its thread_id
        local thread_root
        thread_root=$(sqlite3 "$MAIL_DB" "SELECT COALESCE(thread_id, id) FROM messages WHERE id=${msg_id};" 2>/dev/null)
        [ -z "$thread_root" ] && echo "Message not found" && return 1
        # Get all message IDs in this thread (root + replies)
        local ids
        ids=$(sqlite3 "$MAIL_DB" \
          "SELECT id FROM messages WHERE id=${thread_root} OR thread_id=${thread_root} ORDER BY timestamp ASC;")
        [ -z "$ids" ] && echo "No thread found" && return 0
        local msg_count=0
        echo "=== Thread #${thread_root} ==="
        while read -r tid; do
          [ -z "$tid" ] && continue
          local from_hash body ts from_name attachments
          from_hash=$(sqlite3 "$MAIL_DB" "SELECT from_project FROM messages WHERE id=${tid};")
          body=$(sqlite3 "$MAIL_DB" "SELECT body FROM messages WHERE id=${tid};")
          ts=$(sqlite3 "$MAIL_DB" "SELECT timestamp FROM messages WHERE id=${tid};")
          attachments=$(sqlite3 "$MAIL_DB" "SELECT COALESCE(attachments,'') FROM messages WHERE id=${tid};")
          from_name=$(display_name "$from_hash")
          echo ""
          echo "--- #${tid} ${from_name} @ ${ts} ---"
          echo "${body}"
          if [ -n "$attachments" ]; then
            while IFS= read -r apath; do
              [ -z "$apath" ] && continue
              local astat="missing"
              [ -e "$apath" ] && astat="$(wc -c < "$apath" | tr -d ' ') bytes"
              echo "  [Attached: ${apath} (${astat})]"
            done <<< "$attachments"
          fi
          msg_count=$((msg_count + 1))
        done <<< "$ids"
        echo ""
        echo "=== End of thread (${msg_count} messages) ==="
      }
      
      broadcast() {
        local subject="$1"
        local body="$2"
        if [ -z "$body" ]; then
          echo "Error: message body cannot be empty" >&2
          return 1
        fi
        init_db
        register_project
        local from_id
        from_id=$(get_project_id)
        local targets
        targets=$(sqlite3 "$MAIL_DB" \
          "SELECT hash FROM projects WHERE hash != '${from_id}' ORDER BY name;")
        local count=0
        local safe_subject safe_body
        safe_subject=$(sql_escape "$subject")
        safe_body=$(sql_escape "$body")
        while IFS= read -r target_hash; do
          [ -z "$target_hash" ] && continue
          sqlite3 "$MAIL_DB" \
            "INSERT INTO messages (from_project, to_project, subject, body) VALUES ('${from_id}', '${target_hash}', '${safe_subject}', '${safe_body}');"
          touch "/tmp/pigeon_signal_${target_hash}"
          count=$((count + 1))
        done <<< "$targets"
        echo "Broadcast to ${count} project(s): ${subject}"
      }
      
      status() {
        init_db
        register_project
        local pid
        pid=$(get_project_id)
        local unread total
        unread=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${pid}' AND read=0;")
        total=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${pid}';")
        echo "Inbox: ${unread} unread / ${total} total"
        if [ "${unread:-0}" -gt 0 ]; then
          local senders
          senders=$(sqlite3 -separator '|' "$MAIL_DB" \
            "SELECT from_project, COUNT(*) FROM messages WHERE to_project='${pid}' AND read=0 GROUP BY from_project ORDER BY COUNT(*) DESC;")
          while IFS='|' read -r from_hash cnt; do
            local from_name
            from_name=$(display_name "$from_hash")
            echo "  ${from_name} (${from_hash}): ${cnt} message(s)"
          done <<< "$senders"
        fi
      }
      
      purge() {
        init_db
        if [ "${1:-}" = "--all" ]; then
          local count
          count=$(sqlite3 "$MAIL_DB" "DELETE FROM messages; SELECT changes();")
          echo "Purged all ${count} message(s) from database"
        else
          register_project
          local pid
          pid=$(get_project_id)
          local count
          count=$(sqlite3 "$MAIL_DB" \
            "DELETE FROM messages WHERE to_project='${pid}' OR from_project='${pid}'; SELECT changes();")
          local name
          name=$(project_name)
          echo "Purged ${count} message(s) for ${name} (${pid})"
        fi
      }
      
      alias_project() {
        local old_name="$1"
        local new_name="$2"
        if [ -z "$old_name" ] || [ -z "$new_name" ]; then
          echo "Error: both old and new project names required" >&2
          return 1
        fi
        init_db
        # Resolve old name to hash, then update the display name
        local old_hash
        old_hash=$(resolve_target "$old_name")
        local safe_new
        safe_new=$(sql_escape "$new_name")
        local safe_old
        safe_old=$(sql_escape "$old_name")
        sqlite3 "$MAIL_DB" \
          "UPDATE projects SET name='${safe_new}' WHERE hash='${old_hash}';"
        # Also update path if it matches the old name (phantom projects)
        sqlite3 "$MAIL_DB" \
          "UPDATE projects SET path='${safe_new}' WHERE hash='${old_hash}' AND path='${safe_old}';"
        echo "Renamed '${old_name}' -> '${new_name}' (hash: ${old_hash})"
      }
      
      list_projects() {
        init_db
        register_project
        local rows
        rows=$(sqlite3 -separator '|' "$MAIL_DB" \
          "SELECT hash, name, path FROM projects ORDER BY name;")
        [ -z "$rows" ] && echo "No known projects" && return 0
        local my_id
        my_id=$(get_project_id)
        while IFS='|' read -r hash name path; do
          local marker=""
          [ "$hash" = "$my_id" ] && marker=" (you)"
          echo ""
          # Show identicon if available
          if [ -f "$SCRIPT_DIR/identicon.sh" ]; then
            bash "$SCRIPT_DIR/identicon.sh" "$path" --compact 2>/dev/null || true
          fi
          echo "${name} ${hash}${marker}"
          echo "${path}"
        done <<< "$rows"
      }
      
      # Migrate old basename-style messages to hash IDs
      migrate() {
        init_db
        register_project
        echo "Migrating old messages to hash-based IDs..."
        # Find all unique project names in messages that aren't 6-char hex hashes
        local old_names
        old_names=$(sqlite3 "$MAIL_DB" \
          "SELECT DISTINCT from_project FROM messages WHERE LENGTH(from_project) != 6 OR from_project GLOB '*[^0-9a-f]*' UNION SELECT DISTINCT to_project FROM messages WHERE LENGTH(to_project) != 6 OR to_project GLOB '*[^0-9a-f]*';")
        if [ -z "$old_names" ]; then
          echo "No messages need migration."
          return 0
        fi
        local count=0
        while IFS= read -r old_name; do
          [ -z "$old_name" ] && continue
          # Try to find the project path - check common locations
          local found_path=""
          for base_dir in "$HOME/projects" "$HOME/Projects" "$HOME/code" "$HOME/Code" "$HOME/dev" "$HOME/repos"; do
            if [ -d "${base_dir}/${old_name}" ]; then
              found_path=$(cd "${base_dir}/${old_name}" && pwd -P)
              break
            fi
          done
      
          local new_hash
          if [ -n "$found_path" ]; then
            new_hash=$(printf '%s' "$found_path" | shasum -a 256 | cut -c1-6)
            local safe_name safe_path
            safe_name=$(sql_escape "$old_name")
            safe_path=$(sql_escape "$found_path")
            sqlite3 "$MAIL_DB" \
              "INSERT OR IGNORE INTO projects (hash, name, path) VALUES ('${new_hash}', '${safe_name}', '${safe_path}');"
          else
            # Can't find directory - hash the name itself
            new_hash=$(printf '%s' "$old_name" | shasum -a 256 | cut -c1-6)
            local safe_name
            safe_name=$(sql_escape "$old_name")
            sqlite3 "$MAIL_DB" \
              "INSERT OR IGNORE INTO projects (hash, name, path) VALUES ('${new_hash}', '${safe_name}', '${safe_name}');"
          fi
      
          local safe_old
          safe_old=$(sql_escape "$old_name")
          sqlite3 "$MAIL_DB" "UPDATE messages SET from_project='${new_hash}' WHERE from_project='${safe_old}';"
          sqlite3 "$MAIL_DB" "UPDATE messages SET to_project='${new_hash}' WHERE to_project='${safe_old}';"
          echo "  ${old_name} -> ${new_hash}$([ -n "$found_path" ] && echo " (${found_path})" || echo " (name only)")"
          count=$((count + 1))
        done <<< "$old_names"
        echo "Migrated ${count} project name(s)."
      }
      
      # ============================================================================
      # Dispatch
      # ============================================================================
      
      case "${1:-help}" in
        init)       init_db && echo "Mail database initialized at $MAIL_DB" ;;
        count)      count_unread ;;
        unread)     list_unread ;;
        read)       if [ -n "${2:-}" ]; then read_one "$2"; else read_mail; fi ;;
        send)       shift; send "$@" ;;
        reply)      shift; reply "$@" ;;
        sent)       sent "${2:-20}" ;;
        thread)     thread "${2:?message_id required}" ;;
        list)       list_all "${2:-20}" ;;
        clear)      clear_old "${2:-7}" ;;
        broadcast)  broadcast "${2:-no subject}" "${3:?body required}" ;;
        search)     search "${2:?keyword required}" ;;
        status)     status ;;
        purge)      purge "${2:-}" ;;
        alias)      alias_project "${2:?old name required}" "${3:?new name required}" ;;
        projects)   list_projects ;;
        migrate)    migrate ;;
        id)         init_db; register_project; echo "$(project_name) $(get_project_id)" ;;
        help)
          echo "Usage: mail-db.sh <command> [args]"
          echo ""
          echo "Commands:"
          echo "  init                    Initialize database"
          echo "  id                      Show this project's name and hash"
          echo "  count                   Count unread messages"
          echo "  unread                  List unread messages (brief)"
          echo "  read [id]               Read messages and mark as read"
          echo "  send [--urgent] [--attach <path>]... <to> <subj> <body|->  Send with optional attachments"
          echo "  reply [--attach <path>]... <id> <body|->  Reply with optional attachments"
          echo "  sent [limit]            Show sent messages (outbox)"
          echo "  thread <id>             View full conversation thread"
          echo "  list [limit]            List recent messages (default 20)"
          echo "  clear [days]            Clear read messages older than N days"
          echo "  broadcast <subj> <body> Send to all known projects"
          echo "  search <keyword>        Search messages by keyword"
          echo "  status                  Inbox summary"
          echo "  purge [--all]           Delete all messages for this project"
          echo "  alias <old> <new>       Rename project display name"
          echo "  projects                List known projects with identicons"
          echo "  migrate                 Convert old basename messages to hash IDs"
          ;;
        *)          echo "Unknown command: $1. Run with 'help' for usage." >&2; exit 1 ;;
      esac
      
    • test-mail.sh 25.5 KB
      #!/bin/bash
      # test-mail.sh - Integration test harness for pigeon mail-ops (mail-db.sh + the
      # check-mail.sh delivery hook).
      #
      # Outputs: the passing-case count on the final line; exits 0 on a fully green
      # run, 1 if any assertion FAILed (see the tail). Machine-readable last line.
      #
      # RUNTIME / TIMEOUT — this is a HEAVYWEIGHT suite, not a unit test. Every one of
      # its ~90 assertions shells out to a fresh `bash mail-db.sh <cmd>`, and each of
      # those spawns git + sqlite3 + sed a handful of times. On Windows/Git-Bash that
      # is ~0.8s per call, so the whole suite legitimately takes 1-2 MINUTES. It is
      # NOT hung when it is slow. Invoke it with a generous timeout (>=180s); a short
      # `timeout 15` will kill it mid-run and look exactly like an indefinite hang
      # (rc=124) even though it would have completed. That false "hang" is why callers
      # must budget real wall-clock, not evidence the script blocks.
      #
      # HERMETIC — the suite is fully self-contained so it is safe headless in CI and
      # cannot be perturbed by (or perturb) a live pigeon session:
      #   * stdin is closed (exec </dev/null) so no read/cat can ever block on a TTY;
      #   * it runs from a throwaway cwd named "claude-mods" so project identity, the
      #     /tmp/pigeon_signal_* file, and the .claude/pigeon.disable toggle all live
      #     in an isolated namespace — no cross-session signal-file races (a live
      #     check-mail hook clearing the shared signal used to flake the hook tests),
      #     and it no longer writes .claude/ into the real repo.
      
      set -uo pipefail
      
      # Never block on stdin: this suite runs headless in CI with stdin closed, and a
      # stray interactive read (e.g. mail-db's `read_body` cat path) would hang it.
      exec </dev/null
      
      # Resolve script paths to ABSOLUTE before we change directory below — otherwise
      # the cd would break the relative "$(dirname "$0")" lookups.
      SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      MAIL_SCRIPT="$SELF_DIR/mail-db.sh"
      HOOK_SCRIPT="$SELF_DIR/../../hooks/check-mail.sh"
      if [ ! -f "$HOOK_SCRIPT" ]; then
        HOOK_SCRIPT="$(cd "$SELF_DIR/../../.." && pwd)/hooks/check-mail.sh"
      fi
      
      # Sandbox everything under one throwaway dir, removed on exit:
      #   * HOME  -> so the global DB ("$HOME/.claude/pmail.db") is a private copy.
      #     Without this, the `rm -f "$MAIL_DB"` clean-slate below would delete the
      #     caller's REAL pmail.db when the suite is run without a HOME override.
      #   * cwd   -> a dir literally named "claude-mods" so project identity resolves
      #     to that name (the assertions expect it) while its /tmp/pigeon_signal_*
      #     hash is unique to this run. mail-db.sh and check-mail.sh both derive
      #     identity from $PWD the same way, so they agree inside this sandbox and it
      #     stays hermetic against any concurrently-running pigeon session's hook.
      TEST_WORKDIR="$(mktemp -d)"
      trap 'rm -rf "$TEST_WORKDIR"' EXIT
      export HOME="$TEST_WORKDIR/home"
      mkdir -p "$HOME" "$TEST_WORKDIR/claude-mods"
      cd "$TEST_WORKDIR/claude-mods"
      
      MAIL_DB="$HOME/.claude/pmail.db"
      
      PASS=0
      FAIL=0
      TOTAL=0
      
      assert() {
        local name="$1"
        local expected="$2"
        local actual="$3"
        TOTAL=$((TOTAL + 1))
        if [ "$expected" = "$actual" ]; then
          echo "PASS: $name"
          PASS=$((PASS + 1))
        else
          echo "FAIL: $name (expected='$expected', actual='$actual')"
          FAIL=$((FAIL + 1))
        fi
      }
      
      assert_contains() {
        local name="$1"
        local needle="$2"
        local haystack="$3"
        TOTAL=$((TOTAL + 1))
        if echo "$haystack" | grep -qF "$needle"; then
          echo "PASS: $name"
          PASS=$((PASS + 1))
        else
          echo "FAIL: $name (expected to contain '$needle')"
          FAIL=$((FAIL + 1))
        fi
      }
      
      assert_not_empty() {
        local name="$1"
        local value="$2"
        TOTAL=$((TOTAL + 1))
        if [ -n "$value" ]; then
          echo "PASS: $name"
          PASS=$((PASS + 1))
        else
          echo "FAIL: $name (was empty)"
          FAIL=$((FAIL + 1))
        fi
      }
      
      assert_empty() {
        local name="$1"
        local value="$2"
        TOTAL=$((TOTAL + 1))
        if [ -z "$value" ]; then
          echo "PASS: $name"
          PASS=$((PASS + 1))
        else
          echo "FAIL: $name (expected empty, got '$value')"
          FAIL=$((FAIL + 1))
        fi
      }
      
      assert_exit_code() {
        local name="$1"
        local expected="$2"
        local actual="$3"
        TOTAL=$((TOTAL + 1))
        if [ "$expected" = "$actual" ]; then
          echo "PASS: $name"
          PASS=$((PASS + 1))
        else
          echo "FAIL: $name (exit code expected=$expected, actual=$actual)"
          FAIL=$((FAIL + 1))
        fi
      }
      
      # No-op: cooldown was removed, but tests still call this
      clear_cooldown() { :; }
      
      # --- Setup: clean slate ---
      rm -f "$MAIL_DB"
      
      echo "=== Basic Operations ==="
      
      # T1: Init creates database
      bash "$MAIL_SCRIPT" init >/dev/null 2>&1
      assert "init creates database" "true" "$([ -f "$MAIL_DB" ] && echo true || echo false)"
      
      # T2: Count on empty inbox
      result=$(bash "$MAIL_SCRIPT" count)
      assert "empty inbox count is 0" "0" "$result"
      
      # T3: Send a message
      result=$(bash "$MAIL_SCRIPT" send "test-project" "Hello" "World" 2>&1)
      assert_contains "send succeeds" "Sent to test-project" "$result"
      
      # T4: Count after send (we're in claude-mods, sent to test-project)
      result=$(bash "$MAIL_SCRIPT" count)
      assert "count still 0 for sender project" "0" "$result"
      
      # T5: Send to self
      result=$(bash "$MAIL_SCRIPT" send "claude-mods" "Self mail" "Testing self-send" 2>&1)
      assert_contains "self-send succeeds" "Sent to claude-mods" "$result"
      
      # T6: Count after self-send
      result=$(bash "$MAIL_SCRIPT" count)
      assert "count is 1 after self-send" "1" "$result"
      
      # T7: Unread shows message
      result=$(bash "$MAIL_SCRIPT" unread)
      assert_contains "unread shows subject" "Self mail" "$result"
      
      # T8: Read marks as read
      bash "$MAIL_SCRIPT" read >/dev/null 2>&1
      result=$(bash "$MAIL_SCRIPT" count)
      assert "count is 0 after read" "0" "$result"
      
      # T9: List shows read messages
      result=$(bash "$MAIL_SCRIPT" list)
      assert_contains "list shows read status" "read" "$result"
      
      # T10: Projects lists known projects
      result=$(bash "$MAIL_SCRIPT" projects)
      assert_contains "projects lists claude-mods" "claude-mods" "$result"
      assert_contains "projects lists test-project" "test-project" "$result"
      
      echo ""
      echo "=== Edge Cases ==="
      
      # T11: Empty body - should fail gracefully
      result=$(bash "$MAIL_SCRIPT" send "target" "subject" "" 2>&1)
      exit_code=$?
      # Empty body should either fail or send empty - document the behavior
      TOTAL=$((TOTAL + 1))
      if [ $exit_code -ne 0 ] || echo "$result" | grep -qiE "error|required|empty"; then
        echo "PASS: empty body rejected or warned"
        PASS=$((PASS + 1))
      else
        echo "FAIL: empty body accepted silently"
        FAIL=$((FAIL + 1))
      fi
      
      # T12: Missing arguments to send
      result=$(bash "$MAIL_SCRIPT" send 2>&1)
      exit_code=$?
      assert_exit_code "send with no args fails" "1" "$exit_code"
      
      # T13: SQL injection in subject
      bash "$MAIL_SCRIPT" send "claude-mods" "'; DROP TABLE messages; --" "injection test" >/dev/null 2>&1
      result=$(bash "$MAIL_SCRIPT" count)
      # If table still exists and count works, injection failed (good)
      TOTAL=$((TOTAL + 1))
      if [ -n "$result" ] && [ "$result" -ge 0 ] 2>/dev/null; then
        echo "PASS: SQL injection in subject blocked"
        PASS=$((PASS + 1))
      else
        echo "FAIL: SQL injection may have succeeded"
        FAIL=$((FAIL + 1))
      fi
      
      # T14: SQL injection in body
      bash "$MAIL_SCRIPT" send "claude-mods" "test" "'); DELETE FROM messages; --" >/dev/null 2>&1
      result=$(bash "$MAIL_SCRIPT" count)
      TOTAL=$((TOTAL + 1))
      if [ -n "$result" ] && [ "$result" -ge 0 ] 2>/dev/null; then
        echo "PASS: SQL injection in body blocked"
        PASS=$((PASS + 1))
      else
        echo "FAIL: SQL injection in body may have succeeded"
        FAIL=$((FAIL + 1))
      fi
      
      # T15: SQL injection in project name
      bash "$MAIL_SCRIPT" send "'; DROP TABLE messages; --" "test" "injection via project" >/dev/null 2>&1
      result=$(bash "$MAIL_SCRIPT" count)
      TOTAL=$((TOTAL + 1))
      if [ -n "$result" ] && [ "$result" -ge 0 ] 2>/dev/null; then
        echo "PASS: SQL injection in project name blocked"
        PASS=$((PASS + 1))
      else
        echo "FAIL: SQL injection in project name may have succeeded"
        FAIL=$((FAIL + 1))
      fi
      
      # T16: Special characters in body (newlines, quotes, backslashes)
      bash "$MAIL_SCRIPT" send "claude-mods" "special chars" 'Line1\nLine2 "quoted" and back\\slash' >/dev/null 2>&1
      result=$(bash "$MAIL_SCRIPT" read 2>&1)
      assert_contains "special chars preserved" "special chars" "$result"
      
      # T17: Very long message body (1000+ chars)
      # Pure-bash body generation via `printf -v` + brace expansion — spawns no
      # external process. The previous `python3 -c` call is a Windows headless
      # LANDMINE: `python3` there resolves to the Microsoft Store App-Execution-Alias
      # stub (a reparse-point shim), which can block instead of failing fast, so a
      # CI runner without real Python could stall here. Never reintroduce an external
      # interpreter for something bash can do itself.
      printf -v long_body 'x%.0s' {1..2000}
      bash "$MAIL_SCRIPT" send "claude-mods" "long msg" "$long_body" >/dev/null 2>&1
      result=$(bash "$MAIL_SCRIPT" count)
      assert "long message accepted" "1" "$result"
      bash "$MAIL_SCRIPT" read >/dev/null 2>&1
      
      # T18: Unicode in subject and body
      bash "$MAIL_SCRIPT" send "claude-mods" "Unicode test" "Hello from Tokyo" >/dev/null 2>&1
      result=$(bash "$MAIL_SCRIPT" read 2>&1)
      assert_contains "unicode in body" "Tokyo" "$result"
      
      # T19: Read by specific ID
      bash "$MAIL_SCRIPT" send "claude-mods" "ID test" "Read me by ID" >/dev/null 2>&1
      msg_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages WHERE subject='ID test' AND read=0 LIMIT 1;")
      result=$(bash "$MAIL_SCRIPT" read "$msg_id" 2>&1)
      assert_contains "read by ID works" "Read me by ID" "$result"
      
      # T20: Read by invalid ID
      result=$(bash "$MAIL_SCRIPT" read 99999 2>&1)
      assert_empty "read invalid ID returns nothing" "$result"
      
      echo ""
      echo "=== Hook Tests ==="
      
      # T21: Hook silent on empty inbox
      bash "$MAIL_SCRIPT" read >/dev/null 2>&1  # clear any unread
      clear_cooldown
      result=$(bash "$HOOK_SCRIPT" 2>&1)
      assert_empty "hook silent when no mail" "$result"
      
      # T22: Hook delivers message inline (does NOT auto-read)
      bash "$MAIL_SCRIPT" send "claude-mods" "Hook test" "Should trigger hook" >/dev/null 2>&1
      clear_cooldown
      result=$(bash "$HOOK_SCRIPT" 2>&1)
      assert_contains "hook shows INCOMING PMAIL" "INCOMING PMAIL" "$result"
      assert_contains "hook shows subject" "Hook test" "$result"
      assert_contains "hook shows body" "Should trigger hook" "$result"
      # Signal cleared after first delivery, so second call is silent
      result2=$(bash "$HOOK_SCRIPT" 2>&1)
      assert_empty "hook silent after signal cleared" "$result2"
      # But messages are still unread (hook does NOT auto-read)
      unread_count=$(bash "$MAIL_SCRIPT" count 2>&1)
      assert_contains "messages persist unread after hook" "1" "$unread_count"
      # Manually mark read for cleanup
      bash "$MAIL_SCRIPT" read >/dev/null 2>&1
      
      # T23: Hook with missing database
      clear_cooldown
      backup_db="${MAIL_DB}.testbak"
      mv "$MAIL_DB" "$backup_db"
      result=$(bash "$HOOK_SCRIPT" 2>&1)
      exit_code=$?
      assert_exit_code "hook exits 0 with missing db" "0" "$exit_code"
      assert_empty "hook silent with missing db" "$result"
      mv "$backup_db" "$MAIL_DB"
      
      echo ""
      echo "=== Cleanup ==="
      
      # T24: Clear old messages
      bash "$MAIL_SCRIPT" read >/dev/null 2>&1  # mark all as read
      result=$(bash "$MAIL_SCRIPT" clear 0 2>&1)
      assert_contains "clear reports deleted count" "Cleared" "$result"
      
      # T25: Count after clear
      result=$(bash "$MAIL_SCRIPT" count)
      assert "count 0 after clear" "0" "$result"
      
      # T26: Help command
      result=$(bash "$MAIL_SCRIPT" help 2>&1)
      assert_contains "help shows usage" "Usage" "$result"
      
      # T27: Unknown command
      result=$(bash "$MAIL_SCRIPT" nonexistent 2>&1)
      exit_code=$?
      assert_exit_code "unknown command fails" "1" "$exit_code"
      
      echo ""
      echo "=== Input Validation ==="
      
      # T28: Non-numeric message ID rejected
      result=$(bash "$MAIL_SCRIPT" read "abc" 2>&1)
      exit_code=$?
      assert_exit_code "non-numeric ID rejected" "1" "$exit_code"
      
      # T29: SQL injection via message ID
      bash "$MAIL_SCRIPT" send "claude-mods" "id-inject-test" "before injection" >/dev/null 2>&1
      result=$(bash "$MAIL_SCRIPT" read "1 OR 1=1" 2>&1)
      exit_code=$?
      assert_exit_code "SQL injection via ID rejected" "1" "$exit_code"
      
      # T30: Non-numeric limit in list
      result=$(bash "$MAIL_SCRIPT" list "abc" 2>&1)
      exit_code=$?
      assert_exit_code "non-numeric limit handled" "0" "$exit_code"
      
      # T31: Non-numeric days in clear
      result=$(bash "$MAIL_SCRIPT" clear "abc" 2>&1)
      assert_contains "non-numeric days handled" "Cleared" "$result"
      
      # T32: Single quotes in subject preserved
      bash "$MAIL_SCRIPT" read >/dev/null 2>&1  # clear unread
      bash "$MAIL_SCRIPT" send "claude-mods" "it's working" "body with 'quotes'" >/dev/null 2>&1
      result=$(bash "$MAIL_SCRIPT" read 2>&1)
      assert_contains "single quotes in subject" "it's working" "$result"
      
      # T33: Double quotes in body preserved
      bash "$MAIL_SCRIPT" send "claude-mods" "quotes" 'She said "hello"' >/dev/null 2>&1
      result=$(bash "$MAIL_SCRIPT" read 2>&1)
      assert_contains "double quotes in body" "hello" "$result"
      
      # T34: Project name with spaces (edge case)
      bash "$MAIL_SCRIPT" send "my project" "spaces" "project name has spaces" >/dev/null 2>&1
      result=$(bash "$MAIL_SCRIPT" projects)
      assert_contains "project with spaces stored" "my project" "$result"
      
      # T35: Multiple rapid sends
      for i in 1 2 3 4 5; do
        bash "$MAIL_SCRIPT" send "claude-mods" "rapid-$i" "rapid fire test $i" >/dev/null 2>&1
      done
      result=$(bash "$MAIL_SCRIPT" count)
      assert "5 rapid sends all counted" "5" "$result"
      bash "$MAIL_SCRIPT" read >/dev/null 2>&1
      
      # T36: Init is idempotent
      bash "$MAIL_SCRIPT" init >/dev/null 2>&1
      bash "$MAIL_SCRIPT" init >/dev/null 2>&1
      result=$(bash "$MAIL_SCRIPT" count)
      assert "init idempotent" "0" "$result"
      
      # T37: Empty subject defaults
      result=$(bash "$MAIL_SCRIPT" send "claude-mods" "" "empty subject body" 2>&1)
      assert_contains "empty subject accepted" "Sent to claude-mods" "$result"
      bash "$MAIL_SCRIPT" read >/dev/null 2>&1
      
      echo ""
      echo "=== Reply ==="
      
      # T38: Reply to a message
      bash "$MAIL_SCRIPT" send "claude-mods" "Original msg" "Please reply" >/dev/null 2>&1
      msg_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages WHERE subject='Original msg' AND read=0 LIMIT 1;")
      bash "$MAIL_SCRIPT" read "$msg_id" >/dev/null 2>&1
      result=$(bash "$MAIL_SCRIPT" reply "$msg_id" "Here is my reply" 2>&1)
      assert_contains "reply succeeds" "Replied to claude-mods" "$result"
      assert_contains "reply has Re: prefix" "Re: Original msg" "$result"
      
      # T39: Reply to nonexistent message
      result=$(bash "$MAIL_SCRIPT" reply 99999 "reply to nothing" 2>&1)
      exit_code=$?
      assert_exit_code "reply to nonexistent fails" "1" "$exit_code"
      
      # T40: Reply with empty body
      result=$(bash "$MAIL_SCRIPT" reply "$msg_id" "" 2>&1)
      exit_code=$?
      assert_exit_code "reply with empty body fails" "1" "$exit_code"
      
      # T41: Reply with non-numeric ID
      result=$(bash "$MAIL_SCRIPT" reply "abc" "body" 2>&1)
      exit_code=$?
      assert_exit_code "reply with non-numeric ID fails" "1" "$exit_code"
      
      # Clean up
      bash "$MAIL_SCRIPT" read >/dev/null 2>&1
      
      echo ""
      echo "=== Priority & Search ==="
      
      # T38: Send urgent message
      result=$(bash "$MAIL_SCRIPT" send --urgent "claude-mods" "Server down" "Production is on fire" 2>&1)
      assert_contains "urgent send succeeds" "URGENT" "$result"
      
      # T39: Hook delivers urgent message with marker
      clear_cooldown
      result=$(bash "$HOOK_SCRIPT" 2>&1)
      assert_contains "hook shows URGENT" "URGENT" "$result"
      assert_contains "hook shows urgent body" "Production is on fire" "$result"
      
      # T40: Normal send still works after priority feature
      result=$(bash "$MAIL_SCRIPT" send "claude-mods" "Normal msg" "not urgent" 2>&1)
      TOTAL=$((TOTAL + 1))
      if echo "$result" | grep -qvF "URGENT"; then
        echo "PASS: normal send has no URGENT tag"
        PASS=$((PASS + 1))
      else
        echo "FAIL: normal send incorrectly tagged URGENT"
        FAIL=$((FAIL + 1))
      fi
      bash "$MAIL_SCRIPT" read >/dev/null 2>&1
      
      # T41: Search by keyword in subject
      bash "$MAIL_SCRIPT" send "claude-mods" "API endpoint changed" "details here" >/dev/null 2>&1
      bash "$MAIL_SCRIPT" send "claude-mods" "unrelated" "nothing relevant" >/dev/null 2>&1
      result=$(bash "$MAIL_SCRIPT" search "API" 2>&1)
      assert_contains "search finds by subject" "API endpoint" "$result"
      
      # T42: Search by keyword in body
      result=$(bash "$MAIL_SCRIPT" search "relevant" 2>&1)
      assert_contains "search finds by body" "unrelated" "$result"
      
      # T43: Search with no results
      result=$(bash "$MAIL_SCRIPT" search "xyznonexistent" 2>&1)
      assert_empty "search no results is empty" "$result"
      
      # T44: Search with no keyword fails
      result=$(bash "$MAIL_SCRIPT" search 2>&1)
      exit_code=$?
      assert_exit_code "search no keyword fails" "1" "$exit_code"
      
      bash "$MAIL_SCRIPT" read >/dev/null 2>&1
      
      echo ""
      echo "=== Broadcast & Status ==="
      
      # Setup: ensure multiple projects exist
      bash "$MAIL_SCRIPT" send "project-a" "setup" "creating project-a" >/dev/null 2>&1
      bash "$MAIL_SCRIPT" send "project-b" "setup" "creating project-b" >/dev/null 2>&1
      
      # T42: Broadcast sends to all known projects except self
      result=$(bash "$MAIL_SCRIPT" broadcast "Announcement" "Main is frozen" 2>&1)
      assert_contains "broadcast reports count" "Broadcast to" "$result"
      
      # T43: Broadcast doesn't send to self
      self_count=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='claude-mods' AND subject='Announcement';")
      assert "broadcast skips self" "0" "$self_count"
      
      # T44: Broadcast with empty body fails
      result=$(bash "$MAIL_SCRIPT" broadcast "test" "" 2>&1)
      exit_code=$?
      assert_exit_code "broadcast empty body fails" "1" "$exit_code"
      
      # T45: Status shows inbox summary
      bash "$MAIL_SCRIPT" send "claude-mods" "Status test 1" "msg1" >/dev/null 2>&1
      bash "$MAIL_SCRIPT" send "claude-mods" "Status test 2" "msg2" >/dev/null 2>&1
      result=$(bash "$MAIL_SCRIPT" status 2>&1)
      assert_contains "status shows unread count" "unread" "$result"
      assert_contains "status shows Inbox" "Inbox" "$result"
      
      # T46: Status on empty inbox
      bash "$MAIL_SCRIPT" read >/dev/null 2>&1
      result=$(bash "$MAIL_SCRIPT" status 2>&1)
      assert_contains "status shows 0 unread" "0 unread" "$result"
      
      echo ""
      echo "=== Alias (Rename) ==="
      
      # Setup: send messages with old project name
      bash "$MAIL_SCRIPT" send "old-project" "before rename" "testing alias" >/dev/null 2>&1
      bash "$MAIL_SCRIPT" send "claude-mods" "from old" "message from old name" >/dev/null 2>&1
      
      # T47: Alias renames in all messages
      result=$(bash "$MAIL_SCRIPT" alias "old-project" "new-project" 2>&1)
      assert_contains "alias reports rename" "Renamed" "$result"
      assert_contains "alias shows old name" "old-project" "$result"
      assert_contains "alias shows new name" "new-project" "$result"
      
      # T48: Old project name no longer appears
      result=$(bash "$MAIL_SCRIPT" projects)
      TOTAL=$((TOTAL + 1))
      if echo "$result" | grep -qF "old-project"; then
        echo "FAIL: old project name still present after alias"
        FAIL=$((FAIL + 1))
      else
        echo "PASS: old project name removed after alias"
        PASS=$((PASS + 1))
      fi
      
      # T49: New project name appears
      assert_contains "new project name present" "new-project" "$result"
      
      # T50: Alias with missing args fails
      result=$(bash "$MAIL_SCRIPT" alias "only-one" 2>&1)
      exit_code=$?
      assert_exit_code "alias with missing arg fails" "1" "$exit_code"
      
      # Clean up
      bash "$MAIL_SCRIPT" read >/dev/null 2>&1
      
      echo ""
      echo "=== Hook ==="
      
      # T52: Hook delivers without auto-read
      bash "$MAIL_SCRIPT" send "claude-mods" "hook test" "testing hook" >/dev/null 2>&1
      result1=$(bash "$HOOK_SCRIPT" 2>&1)
      assert_contains "hook delivers message" "INCOMING PMAIL" "$result1"
      
      # T53: Signal cleared after delivery, second call silent
      result2=$(bash "$HOOK_SCRIPT" 2>&1)
      assert_empty "hook silent after signal cleared (2)" "$result2"
      # Messages still unread - verify then clean up
      bash "$MAIL_SCRIPT" read >/dev/null 2>&1
      
      echo ""
      echo "=== Attachments ==="
      
      # Create temp files for attachment tests
      ATTACH_DIR=$(mktemp -d)
      echo "file one content" > "$ATTACH_DIR/file1.txt"
      echo "file two content" > "$ATTACH_DIR/file2.txt"
      mkdir -p "$ATTACH_DIR/sub dir"
      echo "spaced path" > "$ATTACH_DIR/sub dir/spaced.txt"
      
      # T: Send with single attachment
      result=$(bash "$MAIL_SCRIPT" send --attach "$ATTACH_DIR/file1.txt" "claude-mods" "attach test" "one file" 2>&1)
      assert_contains "send with attachment succeeds" "1 attachment" "$result"
      
      # T: Attachment path stored as absolute
      last_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;")
      stored=$(sqlite3 "$MAIL_DB" "SELECT attachments FROM messages WHERE id=${last_id};")
      assert_contains "attachment path is absolute" "$ATTACH_DIR/file1.txt" "$stored"
      
      # T: Read shows attachment with size
      result=$(bash "$MAIL_SCRIPT" read "$last_id" 2>&1)
      assert_contains "read shows Attached" "[Attached:" "$result"
      assert_contains "read shows file size" "bytes" "$result"
      
      # T: Send with multiple attachments
      result=$(bash "$MAIL_SCRIPT" send --attach "$ATTACH_DIR/file1.txt" --attach "$ATTACH_DIR/file2.txt" "claude-mods" "multi attach" "two files" 2>&1)
      assert_contains "send with 2 attachments" "2 attachment" "$result"
      
      # T: Multiple attachment paths stored correctly
      last_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;")
      attach_count=$(sqlite3 "$MAIL_DB" "SELECT attachments FROM messages WHERE id=${last_id};" | grep -c '.')
      assert "two attachment paths stored" "2" "$attach_count"
      
      # T: No trailing empty line in stored attachments
      trailing=$(sqlite3 "$MAIL_DB" "SELECT attachments FROM messages WHERE id=${last_id};" | tail -1)
      assert_not_empty "no trailing empty line" "$trailing"
      
      # T: Nonexistent file rejected
      result=$(bash "$MAIL_SCRIPT" send --attach "/tmp/nonexistent_$$.txt" "claude-mods" "fail" "body" 2>&1)
      exit_code=$?
      assert_contains "nonexistent attach rejected" "not found" "$result"
      assert_exit_code "nonexistent attach exits 1" "1" "$exit_code"
      
      # T: Send without attachment still works (no regression)
      result=$(bash "$MAIL_SCRIPT" send "claude-mods" "no attach" "plain message" 2>&1)
      assert_contains "send without attach works" "Sent to" "$result"
      last_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;")
      stored=$(sqlite3 "$MAIL_DB" "SELECT COALESCE(attachments,'') FROM messages WHERE id=${last_id};")
      assert "no-attach message has empty attachments" "" "$stored"
      
      # T: Reply with attachment via dispatch
      base_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;")
      result=$(bash "$MAIL_SCRIPT" reply --attach "$ATTACH_DIR/file1.txt" "$base_id" "reply with file" 2>&1)
      assert_contains "reply with attachment succeeds" "1 attachment" "$result"
      
      # T: Reply attachment stored correctly
      last_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;")
      stored=$(sqlite3 "$MAIL_DB" "SELECT attachments FROM messages WHERE id=${last_id};")
      assert_contains "reply attachment path stored" "$ATTACH_DIR/file1.txt" "$stored"
      
      # T: Attachment with spaces in path
      result=$(bash "$MAIL_SCRIPT" send --attach "$ATTACH_DIR/sub dir/spaced.txt" "claude-mods" "spaced path" "path has spaces" 2>&1)
      assert_contains "spaced path attachment succeeds" "1 attachment" "$result"
      last_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;")
      stored=$(sqlite3 "$MAIL_DB" "SELECT attachments FROM messages WHERE id=${last_id};")
      assert_contains "spaced path preserved" "sub dir/spaced.txt" "$stored"
      
      # T: Hook shows attachments
      bash "$MAIL_SCRIPT" send --attach "$ATTACH_DIR/file1.txt" "claude-mods" "hook attach" "check hook" >/dev/null 2>&1
      clear_cooldown
      result=$(bash "$HOOK_SCRIPT" 2>&1)
      assert_contains "hook shows attachment" "[Attached:" "$result"
      assert_contains "hook shows Read hint" "Use Read tool" "$result"
      bash "$MAIL_SCRIPT" read >/dev/null 2>&1
      
      # T: Mixed flags - --urgent with --attach
      result=$(bash "$MAIL_SCRIPT" send --urgent --attach "$ATTACH_DIR/file1.txt" "claude-mods" "urgent+attach" "both flags" 2>&1)
      assert_contains "urgent+attach shows attachment" "1 attachment" "$result"
      assert_contains "urgent+attach shows URGENT" "URGENT" "$result"
      
      # T: Deleted file shows as missing
      VANISH="$ATTACH_DIR/vanish.txt"
      echo "temporary" > "$VANISH"
      bash "$MAIL_SCRIPT" send --attach "$VANISH" "claude-mods" "vanish test" "file will disappear" >/dev/null 2>&1
      rm -f "$VANISH"
      last_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;")
      result=$(bash "$MAIL_SCRIPT" read "$last_id" 2>&1)
      assert_contains "deleted file shows missing" "missing" "$result"
      
      # Clean up temp dir
      rm -rf "$ATTACH_DIR"
      bash "$MAIL_SCRIPT" read >/dev/null 2>&1
      
      echo ""
      echo "=== Purge ==="
      
      # T54: Purge removes messages for current project
      bash "$MAIL_SCRIPT" send "claude-mods" "purge test 1" "msg1" >/dev/null 2>&1
      bash "$MAIL_SCRIPT" send "claude-mods" "purge test 2" "msg2" >/dev/null 2>&1
      # Insert a message not involving claude-mods at all
      sqlite3 "$MAIL_DB" "INSERT INTO messages (from_project, to_project, subject, body) VALUES ('alpha', 'beta', 'unrelated', 'should survive');"
      result=$(bash "$MAIL_SCRIPT" purge 2>&1)
      assert_contains "purge reports count" "Purged" "$result"
      
      # T55: Unrelated project messages survive purge
      other_count=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE from_project='alpha';")
      assert "unrelated messages survive purge" "1" "$other_count"
      
      # T56: Purge --all removes everything
      bash "$MAIL_SCRIPT" send "claude-mods" "test" "body" >/dev/null 2>&1
      result=$(bash "$MAIL_SCRIPT" purge --all 2>&1)
      assert_contains "purge --all reports count" "Purged all" "$result"
      total=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages;")
      assert "purge --all empties db" "0" "$total"
      
      echo ""
      echo "=== Per-Project Disable ==="
      
      # T52: Hook respects .claude/pigeon.disable
      bash "$MAIL_SCRIPT" send "claude-mods" "disable test" "should not appear" >/dev/null 2>&1
      clear_cooldown
      mkdir -p .claude
      touch .claude/pigeon.disable
      result=$(bash "$HOOK_SCRIPT" 2>&1)
      assert_empty "hook silent when disabled" "$result"
      
      # T53: Hook delivers after re-enable
      rm -f .claude/pigeon.disable
      clear_cooldown
      result=$(bash "$HOOK_SCRIPT" 2>&1)
      assert_contains "hook works after re-enable" "INCOMING PMAIL" "$result"
      
      echo ""
      echo "=== Results ==="
      echo "Passed: $PASS / $TOTAL"
      echo "Failed: $FAIL / $TOTAL"
      echo ""
      # Machine-readable last line: the passing-case count.
      echo "$PASS"
      
      # Propagate real failures as a non-zero exit — the previous final `echo` masked
      # every FAIL behind exit 0, so a broken suite still looked green to any caller.
      [ "$FAIL" -eq 0 ] || exit 1
      
  • tests
    • run.sh 6.3 KB
      #!/usr/bin/env bash
      # Behavioural tests for pigeon. HOME is isolated before any production script
      # runs so ~/.claude/pmail.db always resolves inside the disposable sandbox.
      
      set -uo pipefail
      
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      SKILL="$(dirname "$HERE")"
      MAIL="$SKILL/scripts/mail-db.sh"
      SB="$(mktemp -d)"
      trap 'rm -rf "$SB"' EXIT
      export HOME="$SB/home"
      mkdir -p "$HOME"
      DB="$HOME/.claude/pmail.db"
      
      PASS=0; FAIL=0
      ok() { PASS=$((PASS+1)); printf '  PASS  %s\n' "$1"; }
      no() { FAIL=$((FAIL+1)); printf '  FAIL  %s\n' "$1"; }
      expect_eq() { [[ "$2" == "$3" ]] && ok "$1" || no "$1 (want '$2', got '$3')"; }
      
      echo "=== pigeon behavioural self-test ==="
      
      command -v sqlite3 >/dev/null 2>&1 || { echo "sqlite3 is required" >&2; exit 1; }
      bash -n "$MAIL" && ok "bash -n mail-db.sh" || no "bash -n mail-db.sh"
      # NOTE: scripts/test-mail.sh (the legacy harness) is deliberately NOT invoked
      # here — it blocks indefinitely (rc=124) and would hang CI. The focused
      # corruption guards below are the authoritative signal; see the spawned
      # follow-up task for the test-mail.sh hang itself.
      
      echo "-- migration idempotency and schema --"
      rm -f "$DB"
      bash "$MAIL" migrate >/dev/null
      schema_first="$(sqlite3 "$DB" ".schema")"
      bash "$MAIL" migrate >/dev/null
      schema_second="$(sqlite3 "$DB" ".schema")"
      expect_eq "second migration leaves schema identical" "$schema_first" "$schema_second"
      
      tables="$(sqlite3 "$DB" "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('messages','projects') ORDER BY name;" | tr -d '\r')"
      expect_eq "expected tables exist" $'messages\nprojects' "$tables"
      
      columns="$(sqlite3 "$DB" "SELECT name FROM pragma_table_info('messages') WHERE name IN ('priority','thread_id','attachments') ORDER BY name;" | tr -d '\r')"
      expect_eq "migration columns exist once" $'attachments\npriority\nthread_id' "$columns"
      
      echo "-- round trip --"
      bash "$MAIL" send "$(pwd)" "round trip" "isolated body" >/dev/null
      expect_eq "unread count after send" "1" "$(bash "$MAIL" count)"
      read_out="$(bash "$MAIL" read)"
      case "$read_out" in *"round trip"*"isolated body"*) ok "read returns sent message";; *) no "read omitted sent message";; esac
      expect_eq "read marks message read" "0" "$(bash "$MAIL" count)"
      
      echo "-- attachments --"
      # Regression: Windows' sqlite3.exe emits "\r\n", which left a stray "\r" on every
      # attachment path but the last, so existing files read back as "(missing)".
      # Two or more attachments are needed: the LAST line always survived.
      printf 'alpha' > "$SB/att-one.txt"
      printf 'beta!' > "$SB/att-two.txt"
      bash "$MAIL" send --attach "$SB/att-one.txt" --attach "$SB/att-two.txt" "$(pwd)" "attach trip" "two files" >/dev/null
      att_out="$(bash "$MAIL" read)"
      case "$att_out" in *"(missing)"*) no "every existing attachment resolves (got: (missing))";; *) ok "every existing attachment resolves";; esac
      expect_eq "each attachment reports its size" "2" "$(printf '%s\n' "$att_out" | grep -c '(5 bytes)')"
      
      echo "-- hook delivery (PreToolUse additionalContext envelope) --"
      # Regression: check-mail.sh used to echo plain text, which Claude Code sends to
      # the debug log for PreToolUse - the model never saw a single notification. The
      # hook must print exactly one JSON envelope (contract at the top of the hook).
      # It runs from a throwaway non-git dir so its /tmp/pigeon_signal_* name is
      # unique to this run and a live pigeon session's hook can't clear it mid-test.
      HOOK="$(cd "$SKILL/../.." && pwd)/hooks/check-mail.sh"
      if ! command -v jq >/dev/null 2>&1; then
        echo "  SKIP  hook envelope tests (jq not installed)"
      else
        mkdir -p "$SB/hookproj"
        hook() { (cd "$SB/hookproj" && bash "$HOOK" </dev/null); }
        mail_hookproj() { (cd "$SB/hookproj" && bash "$MAIL" send "$(pwd)" "$1" "$2" </dev/null >/dev/null); }
        envelope_event() { printf '%s' "$1" | jq -rs 'if length == 1 then .[0].hookSpecificOutput.hookEventName else "not-one-json-value" end' 2>/dev/null; }
        # tr: Windows jq.exe writes -r output in text mode (every "\n" becomes "\r\n").
        # Test for CRs inside the string with envelope_has_cr, never on this output.
        envelope_ctx() { printf '%s' "$1" | jq -r '.hookSpecificOutput.additionalContext' 2>/dev/null | tr -d '\r'; }
        envelope_has_cr() { printf '%s' "$1" | jq '.hookSpecificOutput.additionalContext | test("\r")' 2>/dev/null | tr -d '\r'; }
      
        expect_eq "hook silent with no mail" "" "$(hook)"
      
        mail_hookproj "envelope trip" $'line one\nsays "quoted" \\ back'
        hook_out="$(hook)"
        expect_eq "hook prints exactly one PreToolUse envelope" "PreToolUse" "$(envelope_event "$hook_out")"
        ctx="$(envelope_ctx "$hook_out")"
        case "$ctx" in
          *"INCOMING PMAIL"*"envelope trip"*'says "quoted" \ back'*"ACTION REQUIRED"*) ok "additionalContext carries header, message and footer";;
          *) no "additionalContext missing delivery text (got: ${ctx:0:200})";;
        esac
        expect_eq "hook silent once its signal is cleared" "" "$(hook)"
      
        # Over the 10,000-char additionalContext cap Claude Code shows the model only a
        # 2,000-char preview, so the hook truncates bodies but keeps the footer.
        mail_hookproj "big one" "$(head -c 12000 /dev/zero | tr '\0' x)"
        ctx="$(envelope_ctx "$(hook)")"
        [[ -n "$ctx" && "${#ctx}" -lt 10000 ]] && ok "oversized mail stays under the 10k cap (${#ctx} chars)" || no "oversized mail is ${#ctx} chars"
        case "$ctx" in *"truncated"*"pigeon read"*) ok "truncation points at pigeon read";; *) no "truncation notice missing";; esac
        expect_eq "footer survives truncation" '=== To reply: pigeon reply <id> "message" ===' "$(printf '%s\n' "$ctx" | tail -n 1)"
      
        # Regression (Windows): the hook has its own attachment loop, so it carried the
        # same sqlite3.exe "\r\n" bug mail-db.sh had - every path but the last showed
        # "(missing)", and multi-line bodies reached the model with stray CRs.
        (cd "$SB/hookproj" && bash "$MAIL" read </dev/null >/dev/null)
        (cd "$SB/hookproj" && bash "$MAIL" send --attach "$SB/att-one.txt" --attach "$SB/att-two.txt" "$(pwd)" "hook attach" $'two\nlines' </dev/null >/dev/null)
        hook_out="$(hook)"
        ctx="$(envelope_ctx "$hook_out")"
        case "$ctx" in *"(missing)"*) no "hook resolves every existing attachment (got: (missing))";; *) ok "hook resolves every existing attachment";; esac
        expect_eq "hook reports each attachment's size" "2" "$(printf '%s\n' "$ctx" | grep -c '(5 bytes)')"
        expect_eq "hook context carries no CR" "false" "$(envelope_has_cr "$hook_out")"
      fi
      
      echo ""
      echo "=== $PASS passed, $FAIL failed ==="
      [[ "$FAIL" -eq 0 ]]
      
  • SKILL.md 9.7 KB
    ---
    name: pigeon
    description: "Inter-session pmail - send and receive messages between Claude Code sessions running in different project directories. Uses global SQLite database at ~/.claude/pmail.db. Triggers on: mail, pmail, send message, check mail, inbox, inter-session, message another session, pigeon."
    license: MIT
    allowed-tools: "Read Bash Grep"
    metadata:
      author: claude-mods
      related-skills: sqlite-ops
    ---
    
    # Pigeon
    
    Inter-session messaging for Claude Code. Send and receive pmail between sessions running in different projects.
    
    ## Quick Reference
    
    All commands go through `MAIL`, a shorthand for `bash "$HOME/.claude/pigeon/mail-db.sh"`.
    
    Set this at the top of execution:
    
    ```bash
    MAIL="$HOME/.claude/pigeon/mail-db.sh"
    ```
    
    Then use it for all commands below.
    
    ## Command Router
    
    Parse the user's input after `pigeon` (or `/pigeon`) and run the matching command:
    
    | User says | Run |
    |-----------|-----|
    | `pigeon read` | `bash "$MAIL" read` |
    | `pigeon read 42` | `bash "$MAIL" read 42` |
    | `pigeon send <project> "<subject>" "<body>"` | `bash "$MAIL" send "<project>" "<subject>" "<body>"` |
    | `pigeon send --urgent <project> "<subject>" "<body>"` | `bash "$MAIL" send --urgent "<project>" "<subject>" "<body>"` |
    | `pigeon send --attach <path> <project> "<subject>" "<body>"` | `bash "$MAIL" send --attach "<path>" "<project>" "<subject>" "<body>"` |
    | `pigeon reply <id> "<body>"` | `bash "$MAIL" reply <id> "<body>"` |
    | `pigeon reply --attach <path> <id> "<body>"` | `bash "$MAIL" reply --attach "<path>" <id> "<body>"` |
    | `pigeon broadcast "<subject>" "<body>"` | `bash "$MAIL" broadcast "<subject>" "<body>"` |
    | `pigeon search <keyword>` | `bash "$MAIL" search "<keyword>"` |
    | `pigeon status` | `bash "$MAIL" status` |
    | `pigeon unread` | `bash "$MAIL" unread` |
    | `pigeon list` | `bash "$MAIL" list` |
    | `pigeon list 50` | `bash "$MAIL" list 50` |
    | `pigeon projects` | `bash "$MAIL" projects` |
    | `pigeon clear` | `bash "$MAIL" clear` |
    | `pigeon clear 7` | `bash "$MAIL" clear 7` |
    | `pigeon alias <old> <new>` | `bash "$MAIL" alias "<old>" "<new>"` |
    | `pigeon purge` | `bash "$MAIL" purge` |
    | `pigeon purge --all` | `bash "$MAIL" purge --all` |
    | `pigeon id` | `bash "$MAIL" id` |
    | `pigeon migrate` | `bash "$MAIL" migrate` |
    | `pigeon init` | `bash "$MAIL" init` |
    
    When the user just says "check mail", "read mail", "inbox", "any mail?", or "any pmail?" - run `bash "$MAIL" read`.
    
    When the user says "send mail to X", "send pmail to X", or "message X" - parse out the project name, subject, and body, then run `bash "$MAIL" send`.
    
    ## Project Identity
    
    Each project gets a stable 6-character hash ID derived from its **git root commit** (the very first commit in the repo). This means:
    
    - IDs survive directory renames, moves, and clones
    - Case-insensitive filesystems (macOS) don't cause collisions
    - Every clone of the same repo shares the same identity
    
    For non-git directories, falls back to a hash of the canonical path (`pwd -P`).
    
    Use `pigeon id` to see your project's name and hash:
    
    ```
    claude-mods 7663d6
    ```
    
    When sending messages, you can address projects by **name**, **hash**, or **path** - they all resolve to the same hash ID.
    
    ### Identicons
    
    Each project hash renders as a unique pixel-art identicon (11x11 symmetric grid using Unicode half-block characters). Run `identicon.sh` to see yours, or view all projects with `pigeon projects`.
    
    ## Passive Notification (Hook)
    
    A global PreToolUse hook checks for pmail on every tool call (no cooldown). Silent when inbox is empty. When mail is waiting it prints one JSON envelope, and Claude Code passes its `additionalContext` to the model:
    
    ```json
    {"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"=== INCOMING PMAIL (1 message(s)) ===\n..."}}
    ```
    
    What the model reads:
    
    ```
    === INCOMING PMAIL (1 message(s)) ===
    
    --- #42 from some-api (a1b2c3) @ 2026-09-30 10:12:04 ---
    Subject: Auth endpoints ready
    Login and refresh are live on staging.
    
    === ACTION REQUIRED: Inform the user about these messages and ask if they want to reply. ===
    === Then run: pigeon read (to mark as read) ===
    === To reply: pigeon reply <id> "message" ===
    ```
    
    The JSON form is required. For PreToolUse, plain stdout goes to Claude Code's debug log and never reaches the model. Claude Code caps `additionalContext` at 10,000 chars, so the hook truncates message bodies at about 9,000 and points at `pigeon read`. The header and footer are always kept. Delivery does not mark mail read. The signal file is cleared, so each new send triggers one notice.
    
    ## Attachments
    
    Send file references with `--attach <path>` (repeatable). Paths are resolved to absolute and stored as references - files are not copied.
    
    ```bash
    # Send with one attachment
    pigeon send --attach src/config.ts my-api "Config update" "Updated the auth config"
    
    # Send with multiple attachments
    pigeon send --attach src/schema.sql --attach docs/API.md my-api "Schema + docs" "See attached"
    
    # Reply with attachment
    pigeon reply --attach output/report.json 42 "Here's the analysis"
    ```
    
    Recipients see attachment paths with file sizes and can read them directly with the Read tool. If a file has been moved or deleted since sending, it shows as `(missing)`.
    
    ## When to Send
    
    - You've completed work another session depends on
    - An API contract or shared interface changed
    - A shared branch (main) is broken or fixed
    - You need input from a session working on a different project
    
    ## Per-Project Disable
    
    ```bash
    touch .claude/pigeon.disable    # Disable hook notifications
    rm .claude/pigeon.disable       # Re-enable
    ```
    
    Only the hook is disabled - you can still send messages from the project.
    
    ---
    
    ## Installation
    
    Pigeon requires two things: **scripts** (the mail engine) and a **hook** (passive notifications). Both install globally - one setup, every project gets pmail.
    
    ### Prerequisites
    
    - `sqlite3` - ships with macOS, most Linux distros, and Git Bash on Windows. No install needed.
    - `jq` - the hook uses it to build the JSON envelope Claude Code requires (`brew install jq`, `scoop install jq`, `apt install jq`). Without `jq` the hook stays silent, and `pigeon read` still works.
    
    ### Step 1: Copy Scripts
    
    ```bash
    mkdir -p ~/.claude/pigeon
    cp skills/pigeon/scripts/mail-db.sh ~/.claude/pigeon/
    cp hooks/check-mail.sh ~/.claude/pigeon/
    chmod +x ~/.claude/pigeon/mail-db.sh ~/.claude/pigeon/check-mail.sh
    ```
    
    This gives you the pmail commands. You can now send and read messages manually:
    
    ```bash
    bash ~/.claude/pigeon/mail-db.sh init      # Create database
    bash ~/.claude/pigeon/mail-db.sh status    # Check it works
    ```
    
    ### Step 2: Enable the Hook
    
    Add a `hooks` block to `~/.claude/settings.json`. This makes Claude check for pmail automatically on every tool call:
    
    ```json
    {
      "hooks": {
        "PreToolUse": [
          {
            "matcher": "*",
            "hooks": [
              {
                "type": "command",
                "command": "bash \"$HOME/.claude/pigeon/check-mail.sh\"",
                "timeout": 5
              }
            ]
          }
        ]
      }
    }
    ```
    
    **Important:** If you already have a `hooks` section in your settings, merge the PreToolUse entry into the existing array - don't replace the whole block.
    
    Without this step, pigeon still works but you have to check manually (`pigeon read`). With the hook, unread pmail appears automatically.
    
    ### What Gets Created
    
    ```
    ~/.claude/
      settings.json            # Hook config (you edit this)
      pmail.db                 # Message store (auto-created on first use)
      pigeon/
        mail-db.sh             # All pmail commands (send, read, reply, etc.)
        check-mail.sh          # PreToolUse hook (silent when inbox empty)
    ```
    
    ### Verify
    
    ```bash
    # Check your project identity
    bash ~/.claude/pigeon/mail-db.sh id
    
    # Send yourself a test message (use your project name from above)
    bash ~/.claude/pigeon/mail-db.sh send "my-project" "Test" "Hello from pigeon"
    
    # Check it arrived
    bash ~/.claude/pigeon/mail-db.sh read
    
    # Clean up
    bash ~/.claude/pigeon/mail-db.sh purge --all
    ```
    
    ### Uninstall
    
    ```bash
    rm -rf ~/.claude/pigeon ~/.claude/pmail.db
    # Then remove the hooks.PreToolUse entry from ~/.claude/settings.json
    ```
    
    ## Database
    
    Single SQLite file at `~/.claude/pmail.db`. Auto-created on first `init` or `send`.
    
    ```sql
    CREATE TABLE messages (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        from_project TEXT NOT NULL,   -- 6-char hash ID
        to_project TEXT NOT NULL,     -- 6-char hash ID
        subject TEXT DEFAULT '',
        body TEXT NOT NULL,
        timestamp TEXT DEFAULT (datetime('now')),
        read INTEGER DEFAULT 0,
        priority TEXT DEFAULT 'normal'
    );
    
    CREATE TABLE projects (
        hash TEXT PRIMARY KEY,        -- 6-char ID (git root commit or path hash)
        name TEXT NOT NULL,           -- Display name (basename of project dir)
        path TEXT NOT NULL,           -- Canonical path
        registered TEXT DEFAULT (datetime('now'))
    );
    ```
    
    ## Troubleshooting
    
    | Issue | Fix |
    |-------|-----|
    | `sqlite3: not found` | Ships with macOS, Linux, and Git Bash on Windows. Run `sqlite3 --version` to check. |
    | Hook not firing | Ensure `hooks` block is in `~/.claude/settings.json` (Step 2 above) |
    | Hook fires but no notification | Working as intended - hook is silent when inbox is empty |
    | Mail is unread but Claude never mentions it | The hook must print JSON `additionalContext`, because plain PreToolUse stdout only reaches the debug log. Reinstall `check-mail.sh` from this repo and check `jq --version` |
    | Messages not arriving | Target must be a known name, hash, or path. Use `pigeon projects` to see registered projects |
    | Upgraded from basename IDs | Run `pigeon migrate` to convert old messages to hash-based IDs |
    | Changed display name | Use `pigeon alias old-name new-name` to update the project's display name |
    | Want to disable for one project | `touch .claude/pigeon.disable` in that project's root |
    | Check your project ID | Run `pigeon id` to see name and 6-char hash |
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related