Pigeon Inter-session messaging for Claude Code. Send and receive pmail between sessions running in different projects. Quick Reference All commands go through , a shorthand for . Set this at the top of execution: Then use it for all commands below. Command Router Parse the user's input after (or ) and run the matching command: | User says | Run | |-----------|-----| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | When the user just says "check mail", "read mail", "inbox", "any mail?", or "any pmail?" - run . W…

\\x1e'\n local count\n count=$(sqlite3 \"$MAIL_DB\" \"SELECT COUNT(*) FROM messages WHERE to_project='${pid}' AND read=0;\")\n [ \"${count:-0}\" -eq 0 ] && return 0\n # Query each message individually to preserve multi-line bodies\n local ids\n ids=$(sqlite3 \"$MAIL_DB\" \"SELECT id FROM messages WHERE to_project='${pid}' AND read=0 ORDER BY timestamp ASC;\")\n echo \"id | from_project | subject | body | timestamp\"\n while read -r msg_id; do\n [ -z \"$msg_id\" ] && continue\n local from_hash subj body ts from_name attachments\n from_hash=$(sqlite3 \"$MAIL_DB\" \"SELECT from_project FROM messages WHERE id=${msg_id};\")\n subj=$(sqlite3 \"$MAIL_DB\" \"SELECT subject FROM messages WHERE id=${msg_id};\")\n body=$(sqlite3 \"$MAIL_DB\" \"SELECT body FROM messages WHERE id=${msg_id};\")\n ts=$(sqlite3 \"$MAIL_DB\" \"SELECT timestamp FROM messages WHERE id=${msg_id};\")\n attachments=$(sqlite3 \"$MAIL_DB\" \"SELECT COALESCE(attachments,'') FROM messages WHERE id=${msg_id};\")\n from_name=$(display_name \"$from_hash\")\n echo \"${msg_id} | ${from_name} (${from_hash}) | ${subj} | ${body} | ${ts}\"\n if [ -n \"$attachments\" ]; then\n while IFS= read -r apath; do\n [ -z \"$apath\" ] && continue\n local astat=\"missing\"\n [ -e \"$apath\" ] && astat=\"$(wc -c \u003c \"$apath\" | tr -d ' ') bytes\"\n echo \" [Attached: ${apath} (${astat})]\"\n done \u003c\u003c\u003c \"$attachments\"\n fi\n done \u003c\u003c\u003c \"$ids\"\n sqlite3 \"$MAIL_DB\" \\\n \"UPDATE messages SET read=1 WHERE to_project='${pid}' AND read=0;\"\n # Clear signal file\n rm -f \"/tmp/pigeon_signal_${pid}\"\n}\n\nread_one() {\n local msg_id=\"$1\"\n if ! [[ \"$msg_id\" =~ ^[0-9]+$ ]]; then\n echo \"Error: message ID must be numeric\" >&2\n return 1\n fi\n init_db\n local exists\n exists=$(sqlite3 \"$MAIL_DB\" \"SELECT COUNT(*) FROM messages WHERE id=${msg_id};\")\n [ \"${exists:-0}\" -eq 0 ] && return 0\n local from_hash to_hash subj body ts from_name to_name attachments\n from_hash=$(sqlite3 \"$MAIL_DB\" \"SELECT from_project FROM messages WHERE id=${msg_id};\")\n to_hash=$(sqlite3 \"$MAIL_DB\" \"SELECT to_project FROM messages WHERE id=${msg_id};\")\n subj=$(sqlite3 \"$MAIL_DB\" \"SELECT subject FROM messages WHERE id=${msg_id};\")\n body=$(sqlite3 \"$MAIL_DB\" \"SELECT body FROM messages WHERE id=${msg_id};\")\n ts=$(sqlite3 \"$MAIL_DB\" \"SELECT timestamp FROM messages WHERE id=${msg_id};\")\n attachments=$(sqlite3 \"$MAIL_DB\" \"SELECT COALESCE(attachments,'') FROM messages WHERE id=${msg_id};\")\n from_name=$(display_name \"$from_hash\")\n to_name=$(display_name \"$to_hash\")\n echo \"id | from_project | to_project | subject | body | timestamp\"\n echo \"${msg_id} | ${from_name} (${from_hash}) | ${to_name} (${to_hash}) | ${subj} | ${body} | ${ts}\"\n if [ -n \"$attachments\" ]; then\n while IFS= read -r apath; do\n [ -z \"$apath\" ] && continue\n local astat=\"missing\"\n [ -e \"$apath\" ] && astat=\"$(wc -c \u003c \"$apath\" | tr -d ' ') bytes\"\n echo \" [Attached: ${apath} (${astat})]\"\n done \u003c\u003c\u003c \"$attachments\"\n fi\n sqlite3 \"$MAIL_DB\" \\\n \"UPDATE messages SET read=1 WHERE id=${msg_id};\"\n}\n\nsend() {\n local priority=\"normal\"\n local -a attach_paths=()\n # Parse flags before positional args\n while [ $# -gt 0 ]; do\n case \"$1\" in\n --urgent) priority=\"urgent\"; shift ;;\n --attach) shift; local resolved; resolved=$(resolve_attach \"$1\") || return 1; attach_paths+=(\"$resolved\"); shift ;;\n *) break ;;\n esac\n done\n local to_input=\"${1:?to_project required}\"\n local subject=\"${2:-no subject}\"\n local body\n body=$(read_body \"${3:-}\")\n if [ -z \"$body\" ]; then\n echo \"Error: message body cannot be empty\" >&2\n return 1\n fi\n init_db\n register_project\n local from_id to_id\n from_id=$(get_project_id)\n to_id=$(resolve_target \"$to_input\")\n local safe_subject safe_body safe_attachments\n safe_subject=$(sql_escape \"$subject\")\n safe_body=$(sql_escape \"$body\")\n # Join attachment paths with newlines\n local attachments=\"\"\n if [ ${#attach_paths[@]} -gt 0 ]; then\n attachments=$(IFS=

Pigeon Inter-session messaging for Claude Code. Send and receive pmail between sessions running in different projects. Quick Reference All commands go through , a shorthand for . Set this at the top of execution: Then use it for all commands below. Command Router Parse the user's input after (or ) and run the matching command: | User says | Run | |-----------|-----| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | When the user just says "check mail", "read mail", "inbox", "any mail?", or "any pmail?" - run . W…

\\n'; echo \"${attach_paths[*]}\")\n fi\n safe_attachments=$(sql_escape \"$attachments\")\n sqlite3 \"$MAIL_DB\" \\\n \"INSERT INTO messages (from_project, to_project, subject, body, priority, attachments) VALUES ('${from_id}', '${to_id}', '${safe_subject}', '${safe_body}', '${priority}', '${safe_attachments}');\"\n # Signal the recipient\n touch \"/tmp/pigeon_signal_${to_id}\"\n local to_name\n to_name=$(display_name \"$to_id\")\n local attach_note=\"\"\n [ ${#attach_paths[@]} -gt 0 ] && attach_note=\" [${#attach_paths[@]} attachment(s)]\"\n echo \"Sent to ${to_name} (${to_id}): ${subject}${attach_note}$([ \"$priority\" = \"urgent\" ] && echo \" [URGENT]\" || true)\"\n}\n\nsent() {\n local limit=\"${1:-20}\"\n init_db\n register_project\n local pid\n pid=$(get_project_id)\n local rows\n rows=$(sqlite3 -separator '|' \"$MAIL_DB\" \\\n \"SELECT id, to_project, subject, timestamp FROM messages WHERE from_project='${pid}' ORDER BY timestamp DESC LIMIT ${limit};\")\n [ -z \"$rows\" ] && echo \"No sent messages\" && return 0\n echo \"id | to | subject | timestamp\"\n while IFS='|' read -r id to_hash subj ts; do\n local to_name\n to_name=$(display_name \"$to_hash\")\n echo \"${id} | ${to_name} (${to_hash}) | ${subj} | ${ts}\"\n done \u003c\u003c\u003c \"$rows\"\n}\n\nsearch() {\n local keyword=\"$1\"\n if [ -z \"$keyword\" ]; then\n echo \"Error: search keyword required\" >&2\n return 1\n fi\n init_db\n register_project\n local pid\n pid=$(get_project_id)\n local safe_keyword\n safe_keyword=$(sql_escape \"$keyword\")\n local rows\n rows=$(sqlite3 -separator '|' \"$MAIL_DB\" \\\n \"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;\")\n [ -z \"$rows\" ] && return 0\n echo \"id | from | subject | status | timestamp\"\n while IFS='|' read -r id from_hash subj status ts; do\n local from_name\n from_name=$(display_name \"$from_hash\")\n echo \"${id} | ${from_name} (${from_hash}) | ${subj} | ${status} | ${ts}\"\n done \u003c\u003c\u003c \"$rows\"\n}\n\nlist_all() {\n init_db\n register_project\n local pid\n pid=$(get_project_id)\n local limit=\"${1:-20}\"\n if ! [[ \"$limit\" =~ ^[0-9]+$ ]]; then\n limit=20\n fi\n local rows\n rows=$(sqlite3 -separator '|' \"$MAIL_DB\" \\\n \"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};\")\n [ -z \"$rows\" ] && return 0\n echo \"id | from | subject | status | timestamp\"\n while IFS='|' read -r id from_hash subj status ts; do\n local from_name\n from_name=$(display_name \"$from_hash\")\n echo \"${id} | ${from_name} (${from_hash}) | ${subj} | ${status} | ${ts}\"\n done \u003c\u003c\u003c \"$rows\"\n}\n\nclear_old() {\n init_db\n local days=\"${1:-7}\"\n if ! [[ \"$days\" =~ ^[0-9]+$ ]]; then\n days=7\n fi\n local deleted\n deleted=$(sqlite3 \"$MAIL_DB\" \\\n \"DELETE FROM messages WHERE read=1 AND timestamp \u003c datetime('now', '-${days} days'); SELECT changes();\")\n echo \"Cleared ${deleted} read messages older than ${days} days\"\n}\n\nreply() {\n local -a attach_paths=()\n # Parse flags before positional args\n while [ $# -gt 0 ]; do\n case \"$1\" in\n --attach) shift; local resolved; resolved=$(resolve_attach \"$1\") || return 1; attach_paths+=(\"$resolved\"); shift ;;\n *) break ;;\n esac\n done\n local msg_id=\"$1\"\n local body\n body=$(read_body \"${2:-}\")\n if ! [[ \"$msg_id\" =~ ^[0-9]+$ ]]; then\n echo \"Error: message ID must be numeric\" >&2\n return 1\n fi\n if [ -z \"$body\" ]; then\n echo \"Error: reply body cannot be empty\" >&2\n return 1\n fi\n init_db\n register_project\n local orig\n orig=$(sqlite3 -separator '|' \"$MAIL_DB\" \"SELECT from_project, subject, thread_id FROM messages WHERE id=${msg_id};\")\n if [ -z \"$orig\" ]; then\n echo \"Error: message #${msg_id} not found\" >&2\n return 1\n fi\n local orig_from_hash orig_subject orig_thread\n orig_from_hash=$(echo \"$orig\" | cut -d'|' -f1)\n orig_subject=$(echo \"$orig\" | cut -d'|' -f2)\n orig_thread=$(echo \"$orig\" | cut -d'|' -f3)\n # Thread ID: inherit from parent, or use parent's ID as thread root\n local thread_id=\"${orig_thread:-$msg_id}\"\n local from_id\n from_id=$(get_project_id)\n local safe_subject safe_body safe_attachments\n safe_subject=$(sql_escape \"Re: ${orig_subject}\")\n safe_body=$(sql_escape \"$body\")\n local attachments=\"\"\n if [ ${#attach_paths[@]} -gt 0 ]; then\n attachments=$(IFS=

Pigeon Inter-session messaging for Claude Code. Send and receive pmail between sessions running in different projects. Quick Reference All commands go through , a shorthand for . Set this at the top of execution: Then use it for all commands below. Command Router Parse the user's input after (or ) and run the matching command: | User says | Run | |-----------|-----| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | When the user just says "check mail", "read mail", "inbox", "any mail?", or "any pmail?" - run . W…

\\n'; echo \"${attach_paths[*]}\")\n fi\n safe_attachments=$(sql_escape \"$attachments\")\n sqlite3 \"$MAIL_DB\" \\\n \"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}');\"\n # Signal the recipient\n touch \"/tmp/pigeon_signal_${orig_from_hash}\"\n local orig_name\n orig_name=$(display_name \"$orig_from_hash\")\n local attach_note=\"\"\n [ ${#attach_paths[@]} -gt 0 ] && attach_note=\" [${#attach_paths[@]} attachment(s)]\"\n echo \"Replied to ${orig_name} (${orig_from_hash}): Re: ${orig_subject}${attach_note}\"\n}\n\nthread() {\n local msg_id=\"$1\"\n if ! [[ \"$msg_id\" =~ ^[0-9]+$ ]]; then\n echo \"Error: message ID must be numeric\" >&2\n return 1\n fi\n init_db\n # Find the thread root: either the message itself or its thread_id\n local thread_root\n thread_root=$(sqlite3 \"$MAIL_DB\" \"SELECT COALESCE(thread_id, id) FROM messages WHERE id=${msg_id};\" 2>/dev/null)\n [ -z \"$thread_root\" ] && echo \"Message not found\" && return 1\n # Get all message IDs in this thread (root + replies)\n local ids\n ids=$(sqlite3 \"$MAIL_DB\" \\\n \"SELECT id FROM messages WHERE id=${thread_root} OR thread_id=${thread_root} ORDER BY timestamp ASC;\")\n [ -z \"$ids\" ] && echo \"No thread found\" && return 0\n local msg_count=0\n echo \"=== Thread #${thread_root} ===\"\n while read -r tid; do\n [ -z \"$tid\" ] && continue\n local from_hash body ts from_name attachments\n from_hash=$(sqlite3 \"$MAIL_DB\" \"SELECT from_project FROM messages WHERE id=${tid};\")\n body=$(sqlite3 \"$MAIL_DB\" \"SELECT body FROM messages WHERE id=${tid};\")\n ts=$(sqlite3 \"$MAIL_DB\" \"SELECT timestamp FROM messages WHERE id=${tid};\")\n attachments=$(sqlite3 \"$MAIL_DB\" \"SELECT COALESCE(attachments,'') FROM messages WHERE id=${tid};\")\n from_name=$(display_name \"$from_hash\")\n echo \"\"\n echo \"--- #${tid} ${from_name} @ ${ts} ---\"\n echo \"${body}\"\n if [ -n \"$attachments\" ]; then\n while IFS= read -r apath; do\n [ -z \"$apath\" ] && continue\n local astat=\"missing\"\n [ -e \"$apath\" ] && astat=\"$(wc -c \u003c \"$apath\" | tr -d ' ') bytes\"\n echo \" [Attached: ${apath} (${astat})]\"\n done \u003c\u003c\u003c \"$attachments\"\n fi\n msg_count=$((msg_count + 1))\n done \u003c\u003c\u003c \"$ids\"\n echo \"\"\n echo \"=== End of thread (${msg_count} messages) ===\"\n}\n\nbroadcast() {\n local subject=\"$1\"\n local body=\"$2\"\n if [ -z \"$body\" ]; then\n echo \"Error: message body cannot be empty\" >&2\n return 1\n fi\n init_db\n register_project\n local from_id\n from_id=$(get_project_id)\n local targets\n targets=$(sqlite3 \"$MAIL_DB\" \\\n \"SELECT hash FROM projects WHERE hash != '${from_id}' ORDER BY name;\")\n local count=0\n local safe_subject safe_body\n safe_subject=$(sql_escape \"$subject\")\n safe_body=$(sql_escape \"$body\")\n while IFS= read -r target_hash; do\n [ -z \"$target_hash\" ] && continue\n sqlite3 \"$MAIL_DB\" \\\n \"INSERT INTO messages (from_project, to_project, subject, body) VALUES ('${from_id}', '${target_hash}', '${safe_subject}', '${safe_body}');\"\n touch \"/tmp/pigeon_signal_${target_hash}\"\n count=$((count + 1))\n done \u003c\u003c\u003c \"$targets\"\n echo \"Broadcast to ${count} project(s): ${subject}\"\n}\n\nstatus() {\n init_db\n register_project\n local pid\n pid=$(get_project_id)\n local unread total\n unread=$(sqlite3 \"$MAIL_DB\" \"SELECT COUNT(*) FROM messages WHERE to_project='${pid}' AND read=0;\")\n total=$(sqlite3 \"$MAIL_DB\" \"SELECT COUNT(*) FROM messages WHERE to_project='${pid}';\")\n echo \"Inbox: ${unread} unread / ${total} total\"\n if [ \"${unread:-0}\" -gt 0 ]; then\n local senders\n senders=$(sqlite3 -separator '|' \"$MAIL_DB\" \\\n \"SELECT from_project, COUNT(*) FROM messages WHERE to_project='${pid}' AND read=0 GROUP BY from_project ORDER BY COUNT(*) DESC;\")\n while IFS='|' read -r from_hash cnt; do\n local from_name\n from_name=$(display_name \"$from_hash\")\n echo \" ${from_name} (${from_hash}): ${cnt} message(s)\"\n done \u003c\u003c\u003c \"$senders\"\n fi\n}\n\npurge() {\n init_db\n if [ \"${1:-}\" = \"--all\" ]; then\n local count\n count=$(sqlite3 \"$MAIL_DB\" \"DELETE FROM messages; SELECT changes();\")\n echo \"Purged all ${count} message(s) from database\"\n else\n register_project\n local pid\n pid=$(get_project_id)\n local count\n count=$(sqlite3 \"$MAIL_DB\" \\\n \"DELETE FROM messages WHERE to_project='${pid}' OR from_project='${pid}'; SELECT changes();\")\n local name\n name=$(project_name)\n echo \"Purged ${count} message(s) for ${name} (${pid})\"\n fi\n}\n\nalias_project() {\n local old_name=\"$1\"\n local new_name=\"$2\"\n if [ -z \"$old_name\" ] || [ -z \"$new_name\" ]; then\n echo \"Error: both old and new project names required\" >&2\n return 1\n fi\n init_db\n # Resolve old name to hash, then update the display name\n local old_hash\n old_hash=$(resolve_target \"$old_name\")\n local safe_new\n safe_new=$(sql_escape \"$new_name\")\n local safe_old\n safe_old=$(sql_escape \"$old_name\")\n sqlite3 \"$MAIL_DB\" \\\n \"UPDATE projects SET name='${safe_new}' WHERE hash='${old_hash}';\"\n # Also update path if it matches the old name (phantom projects)\n sqlite3 \"$MAIL_DB\" \\\n \"UPDATE projects SET path='${safe_new}' WHERE hash='${old_hash}' AND path='${safe_old}';\"\n echo \"Renamed '${old_name}' -> '${new_name}' (hash: ${old_hash})\"\n}\n\nlist_projects() {\n init_db\n register_project\n local rows\n rows=$(sqlite3 -separator '|' \"$MAIL_DB\" \\\n \"SELECT hash, name, path FROM projects ORDER BY name;\")\n [ -z \"$rows\" ] && echo \"No known projects\" && return 0\n local my_id\n my_id=$(get_project_id)\n while IFS='|' read -r hash name path; do\n local marker=\"\"\n [ \"$hash\" = \"$my_id\" ] && marker=\" (you)\"\n echo \"\"\n # Show identicon if available\n if [ -f \"$SCRIPT_DIR/identicon.sh\" ]; then\n bash \"$SCRIPT_DIR/identicon.sh\" \"$path\" --compact 2>/dev/null || true\n fi\n echo \"${name} ${hash}${marker}\"\n echo \"${path}\"\n done \u003c\u003c\u003c \"$rows\"\n}\n\n# Migrate old basename-style messages to hash IDs\nmigrate() {\n init_db\n register_project\n echo \"Migrating old messages to hash-based IDs...\"\n # Find all unique project names in messages that aren't 6-char hex hashes\n local old_names\n old_names=$(sqlite3 \"$MAIL_DB\" \\\n \"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]*';\")\n if [ -z \"$old_names\" ]; then\n echo \"No messages need migration.\"\n return 0\n fi\n local count=0\n while IFS= read -r old_name; do\n [ -z \"$old_name\" ] && continue\n # Try to find the project path - check common locations\n local found_path=\"\"\n for base_dir in \"$HOME/projects\" \"$HOME/Projects\" \"$HOME/code\" \"$HOME/Code\" \"$HOME/dev\" \"$HOME/repos\"; do\n if [ -d \"${base_dir}/${old_name}\" ]; then\n found_path=$(cd \"${base_dir}/${old_name}\" && pwd -P)\n break\n fi\n done\n\n local new_hash\n if [ -n \"$found_path\" ]; then\n new_hash=$(printf '%s' \"$found_path\" | shasum -a 256 | cut -c1-6)\n local safe_name safe_path\n safe_name=$(sql_escape \"$old_name\")\n safe_path=$(sql_escape \"$found_path\")\n sqlite3 \"$MAIL_DB\" \\\n \"INSERT OR IGNORE INTO projects (hash, name, path) VALUES ('${new_hash}', '${safe_name}', '${safe_path}');\"\n else\n # Can't find directory - hash the name itself\n new_hash=$(printf '%s' \"$old_name\" | shasum -a 256 | cut -c1-6)\n local safe_name\n safe_name=$(sql_escape \"$old_name\")\n sqlite3 \"$MAIL_DB\" \\\n \"INSERT OR IGNORE INTO projects (hash, name, path) VALUES ('${new_hash}', '${safe_name}', '${safe_name}');\"\n fi\n\n local safe_old\n safe_old=$(sql_escape \"$old_name\")\n sqlite3 \"$MAIL_DB\" \"UPDATE messages SET from_project='${new_hash}' WHERE from_project='${safe_old}';\"\n sqlite3 \"$MAIL_DB\" \"UPDATE messages SET to_project='${new_hash}' WHERE to_project='${safe_old}';\"\n echo \" ${old_name} -> ${new_hash}$([ -n \"$found_path\" ] && echo \" (${found_path})\" || echo \" (name only)\")\"\n count=$((count + 1))\n done \u003c\u003c\u003c \"$old_names\"\n echo \"Migrated ${count} project name(s).\"\n}\n\n# ============================================================================\n# Dispatch\n# ============================================================================\n\ncase \"${1:-help}\" in\n init) init_db && echo \"Mail database initialized at $MAIL_DB\" ;;\n count) count_unread ;;\n unread) list_unread ;;\n read) if [ -n \"${2:-}\" ]; then read_one \"$2\"; else read_mail; fi ;;\n send) shift; send \"$@\" ;;\n reply) shift; reply \"$@\" ;;\n sent) sent \"${2:-20}\" ;;\n thread) thread \"${2:?message_id required}\" ;;\n list) list_all \"${2:-20}\" ;;\n clear) clear_old \"${2:-7}\" ;;\n broadcast) broadcast \"${2:-no subject}\" \"${3:?body required}\" ;;\n search) search \"${2:?keyword required}\" ;;\n status) status ;;\n purge) purge \"${2:-}\" ;;\n alias) alias_project \"${2:?old name required}\" \"${3:?new name required}\" ;;\n projects) list_projects ;;\n migrate) migrate ;;\n id) init_db; register_project; echo \"$(project_name) $(get_project_id)\" ;;\n help)\n echo \"Usage: mail-db.sh \u003ccommand> [args]\"\n echo \"\"\n echo \"Commands:\"\n echo \" init Initialize database\"\n echo \" id Show this project's name and hash\"\n echo \" count Count unread messages\"\n echo \" unread List unread messages (brief)\"\n echo \" read [id] Read messages and mark as read\"\n echo \" send [--urgent] [--attach \u003cpath>]... \u003cto> \u003csubj> \u003cbody|-> Send with optional attachments\"\n echo \" reply [--attach \u003cpath>]... \u003cid> \u003cbody|-> Reply with optional attachments\"\n echo \" sent [limit] Show sent messages (outbox)\"\n echo \" thread \u003cid> View full conversation thread\"\n echo \" list [limit] List recent messages (default 20)\"\n echo \" clear [days] Clear read messages older than N days\"\n echo \" broadcast \u003csubj> \u003cbody> Send to all known projects\"\n echo \" search \u003ckeyword> Search messages by keyword\"\n echo \" status Inbox summary\"\n echo \" purge [--all] Delete all messages for this project\"\n echo \" alias \u003cold> \u003cnew> Rename project display name\"\n echo \" projects List known projects with identicons\"\n echo \" migrate Convert old basename messages to hash IDs\"\n ;;\n *) echo \"Unknown command: $1. Run with 'help' for usage.\" >&2; exit 1 ;;\nesac\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":27815,"content_sha256":"d4dfd55196962e61dd32a3f6b47c44f7eea636564dbd1fb770cedc508fb16b07"},{"filename":"scripts/test-mail.sh","content":"#!/bin/bash\n# test-mail.sh - Test harness for mail-ops\n# Outputs: number of passing test cases\n# Each test prints PASS/FAIL and we count PASSes at the end\n\nset -uo pipefail\n\nMAIL_DB=\"$HOME/.claude/pmail.db\"\nMAIL_SCRIPT=\"$(dirname \"$0\")/mail-db.sh\"\nHOOK_SCRIPT=\"$(dirname \"$0\")/../../hooks/check-mail.sh\"\n# Resolve relative to repo root if needed\nif [ ! -f \"$HOOK_SCRIPT\" ]; then\n HOOK_SCRIPT=\"$(cd \"$(dirname \"$0\")/../../..\" && pwd)/hooks/check-mail.sh\"\nfi\n\nPASS=0\nFAIL=0\nTOTAL=0\n\nassert() {\n local name=\"$1\"\n local expected=\"$2\"\n local actual=\"$3\"\n TOTAL=$((TOTAL + 1))\n if [ \"$expected\" = \"$actual\" ]; then\n echo \"PASS: $name\"\n PASS=$((PASS + 1))\n else\n echo \"FAIL: $name (expected='$expected', actual='$actual')\"\n FAIL=$((FAIL + 1))\n fi\n}\n\nassert_contains() {\n local name=\"$1\"\n local needle=\"$2\"\n local haystack=\"$3\"\n TOTAL=$((TOTAL + 1))\n if echo \"$haystack\" | grep -qF \"$needle\"; then\n echo \"PASS: $name\"\n PASS=$((PASS + 1))\n else\n echo \"FAIL: $name (expected to contain '$needle')\"\n FAIL=$((FAIL + 1))\n fi\n}\n\nassert_not_empty() {\n local name=\"$1\"\n local value=\"$2\"\n TOTAL=$((TOTAL + 1))\n if [ -n \"$value\" ]; then\n echo \"PASS: $name\"\n PASS=$((PASS + 1))\n else\n echo \"FAIL: $name (was empty)\"\n FAIL=$((FAIL + 1))\n fi\n}\n\nassert_empty() {\n local name=\"$1\"\n local value=\"$2\"\n TOTAL=$((TOTAL + 1))\n if [ -z \"$value\" ]; then\n echo \"PASS: $name\"\n PASS=$((PASS + 1))\n else\n echo \"FAIL: $name (expected empty, got '$value')\"\n FAIL=$((FAIL + 1))\n fi\n}\n\nassert_exit_code() {\n local name=\"$1\"\n local expected=\"$2\"\n local actual=\"$3\"\n TOTAL=$((TOTAL + 1))\n if [ \"$expected\" = \"$actual\" ]; then\n echo \"PASS: $name\"\n PASS=$((PASS + 1))\n else\n echo \"FAIL: $name (exit code expected=$expected, actual=$actual)\"\n FAIL=$((FAIL + 1))\n fi\n}\n\n# No-op: cooldown was removed, but tests still call this\nclear_cooldown() { :; }\n\n# --- Setup: clean slate ---\nrm -f \"$MAIL_DB\"\n\necho \"=== Basic Operations ===\"\n\n# T1: Init creates database\nbash \"$MAIL_SCRIPT\" init >/dev/null 2>&1\nassert \"init creates database\" \"true\" \"$([ -f \"$MAIL_DB\" ] && echo true || echo false)\"\n\n# T2: Count on empty inbox\nresult=$(bash \"$MAIL_SCRIPT\" count)\nassert \"empty inbox count is 0\" \"0\" \"$result\"\n\n# T3: Send a message\nresult=$(bash \"$MAIL_SCRIPT\" send \"test-project\" \"Hello\" \"World\" 2>&1)\nassert_contains \"send succeeds\" \"Sent to test-project\" \"$result\"\n\n# T4: Count after send (we're in claude-mods, sent to test-project)\nresult=$(bash \"$MAIL_SCRIPT\" count)\nassert \"count still 0 for sender project\" \"0\" \"$result\"\n\n# T5: Send to self\nresult=$(bash \"$MAIL_SCRIPT\" send \"claude-mods\" \"Self mail\" \"Testing self-send\" 2>&1)\nassert_contains \"self-send succeeds\" \"Sent to claude-mods\" \"$result\"\n\n# T6: Count after self-send\nresult=$(bash \"$MAIL_SCRIPT\" count)\nassert \"count is 1 after self-send\" \"1\" \"$result\"\n\n# T7: Unread shows message\nresult=$(bash \"$MAIL_SCRIPT\" unread)\nassert_contains \"unread shows subject\" \"Self mail\" \"$result\"\n\n# T8: Read marks as read\nbash \"$MAIL_SCRIPT\" read >/dev/null 2>&1\nresult=$(bash \"$MAIL_SCRIPT\" count)\nassert \"count is 0 after read\" \"0\" \"$result\"\n\n# T9: List shows read messages\nresult=$(bash \"$MAIL_SCRIPT\" list)\nassert_contains \"list shows read status\" \"read\" \"$result\"\n\n# T10: Projects lists known projects\nresult=$(bash \"$MAIL_SCRIPT\" projects)\nassert_contains \"projects lists claude-mods\" \"claude-mods\" \"$result\"\nassert_contains \"projects lists test-project\" \"test-project\" \"$result\"\n\necho \"\"\necho \"=== Edge Cases ===\"\n\n# T11: Empty body - should fail gracefully\nresult=$(bash \"$MAIL_SCRIPT\" send \"target\" \"subject\" \"\" 2>&1)\nexit_code=$?\n# Empty body should either fail or send empty - document the behavior\nTOTAL=$((TOTAL + 1))\nif [ $exit_code -ne 0 ] || echo \"$result\" | grep -qiE \"error|required|empty\"; then\n echo \"PASS: empty body rejected or warned\"\n PASS=$((PASS + 1))\nelse\n echo \"FAIL: empty body accepted silently\"\n FAIL=$((FAIL + 1))\nfi\n\n# T12: Missing arguments to send\nresult=$(bash \"$MAIL_SCRIPT\" send 2>&1)\nexit_code=$?\nassert_exit_code \"send with no args fails\" \"1\" \"$exit_code\"\n\n# T13: SQL injection in subject\nbash \"$MAIL_SCRIPT\" send \"claude-mods\" \"'; DROP TABLE messages; --\" \"injection test\" >/dev/null 2>&1\nresult=$(bash \"$MAIL_SCRIPT\" count)\n# If table still exists and count works, injection failed (good)\nTOTAL=$((TOTAL + 1))\nif [ -n \"$result\" ] && [ \"$result\" -ge 0 ] 2>/dev/null; then\n echo \"PASS: SQL injection in subject blocked\"\n PASS=$((PASS + 1))\nelse\n echo \"FAIL: SQL injection may have succeeded\"\n FAIL=$((FAIL + 1))\nfi\n\n# T14: SQL injection in body\nbash \"$MAIL_SCRIPT\" send \"claude-mods\" \"test\" \"'); DELETE FROM messages; --\" >/dev/null 2>&1\nresult=$(bash \"$MAIL_SCRIPT\" count)\nTOTAL=$((TOTAL + 1))\nif [ -n \"$result\" ] && [ \"$result\" -ge 0 ] 2>/dev/null; then\n echo \"PASS: SQL injection in body blocked\"\n PASS=$((PASS + 1))\nelse\n echo \"FAIL: SQL injection in body may have succeeded\"\n FAIL=$((FAIL + 1))\nfi\n\n# T15: SQL injection in project name\nbash \"$MAIL_SCRIPT\" send \"'; DROP TABLE messages; --\" \"test\" \"injection via project\" >/dev/null 2>&1\nresult=$(bash \"$MAIL_SCRIPT\" count)\nTOTAL=$((TOTAL + 1))\nif [ -n \"$result\" ] && [ \"$result\" -ge 0 ] 2>/dev/null; then\n echo \"PASS: SQL injection in project name blocked\"\n PASS=$((PASS + 1))\nelse\n echo \"FAIL: SQL injection in project name may have succeeded\"\n FAIL=$((FAIL + 1))\nfi\n\n# T16: Special characters in body (newlines, quotes, backslashes)\nbash \"$MAIL_SCRIPT\" send \"claude-mods\" \"special chars\" 'Line1\\nLine2 \"quoted\" and back\\\\slash' >/dev/null 2>&1\nresult=$(bash \"$MAIL_SCRIPT\" read 2>&1)\nassert_contains \"special chars preserved\" \"special chars\" \"$result\"\n\n# T17: Very long message body (1000+ chars)\nlong_body=$(python3 -c \"print('x' * 2000)\" 2>/dev/null || printf '%0.s.' $(seq 1 2000))\nbash \"$MAIL_SCRIPT\" send \"claude-mods\" \"long msg\" \"$long_body\" >/dev/null 2>&1\nresult=$(bash \"$MAIL_SCRIPT\" count)\nassert \"long message accepted\" \"1\" \"$result\"\nbash \"$MAIL_SCRIPT\" read >/dev/null 2>&1\n\n# T18: Unicode in subject and body\nbash \"$MAIL_SCRIPT\" send \"claude-mods\" \"Unicode test\" \"Hello from Tokyo\" >/dev/null 2>&1\nresult=$(bash \"$MAIL_SCRIPT\" read 2>&1)\nassert_contains \"unicode in body\" \"Tokyo\" \"$result\"\n\n# T19: Read by specific ID\nbash \"$MAIL_SCRIPT\" send \"claude-mods\" \"ID test\" \"Read me by ID\" >/dev/null 2>&1\nmsg_id=$(sqlite3 \"$MAIL_DB\" \"SELECT id FROM messages WHERE subject='ID test' AND read=0 LIMIT 1;\")\nresult=$(bash \"$MAIL_SCRIPT\" read \"$msg_id\" 2>&1)\nassert_contains \"read by ID works\" \"Read me by ID\" \"$result\"\n\n# T20: Read by invalid ID\nresult=$(bash \"$MAIL_SCRIPT\" read 99999 2>&1)\nassert_empty \"read invalid ID returns nothing\" \"$result\"\n\necho \"\"\necho \"=== Hook Tests ===\"\n\n# T21: Hook silent on empty inbox\nbash \"$MAIL_SCRIPT\" read >/dev/null 2>&1 # clear any unread\nclear_cooldown\nresult=$(bash \"$HOOK_SCRIPT\" 2>&1)\nassert_empty \"hook silent when no mail\" \"$result\"\n\n# T22: Hook delivers message inline (does NOT auto-read)\nbash \"$MAIL_SCRIPT\" send \"claude-mods\" \"Hook test\" \"Should trigger hook\" >/dev/null 2>&1\nclear_cooldown\nresult=$(bash \"$HOOK_SCRIPT\" 2>&1)\nassert_contains \"hook shows INCOMING MAIL\" \"INCOMING MAIL\" \"$result\"\nassert_contains \"hook shows subject\" \"Hook test\" \"$result\"\nassert_contains \"hook shows body\" \"Should trigger hook\" \"$result\"\n# Signal cleared after first delivery, so second call is silent\nresult2=$(bash \"$HOOK_SCRIPT\" 2>&1)\nassert_empty \"hook silent after signal cleared\" \"$result2\"\n# But messages are still unread (hook does NOT auto-read)\nunread_count=$(bash \"$MAIL_SCRIPT\" count 2>&1)\nassert_contains \"messages persist unread after hook\" \"1\" \"$unread_count\"\n# Manually mark read for cleanup\nbash \"$MAIL_SCRIPT\" read >/dev/null 2>&1\n\n# T23: Hook with missing database\nclear_cooldown\nbackup_db=\"${MAIL_DB}.testbak\"\nmv \"$MAIL_DB\" \"$backup_db\"\nresult=$(bash \"$HOOK_SCRIPT\" 2>&1)\nexit_code=$?\nassert_exit_code \"hook exits 0 with missing db\" \"0\" \"$exit_code\"\nassert_empty \"hook silent with missing db\" \"$result\"\nmv \"$backup_db\" \"$MAIL_DB\"\n\necho \"\"\necho \"=== Cleanup ===\"\n\n# T24: Clear old messages\nbash \"$MAIL_SCRIPT\" read >/dev/null 2>&1 # mark all as read\nresult=$(bash \"$MAIL_SCRIPT\" clear 0 2>&1)\nassert_contains \"clear reports deleted count\" \"Cleared\" \"$result\"\n\n# T25: Count after clear\nresult=$(bash \"$MAIL_SCRIPT\" count)\nassert \"count 0 after clear\" \"0\" \"$result\"\n\n# T26: Help command\nresult=$(bash \"$MAIL_SCRIPT\" help 2>&1)\nassert_contains \"help shows usage\" \"Usage\" \"$result\"\n\n# T27: Unknown command\nresult=$(bash \"$MAIL_SCRIPT\" nonexistent 2>&1)\nexit_code=$?\nassert_exit_code \"unknown command fails\" \"1\" \"$exit_code\"\n\necho \"\"\necho \"=== Input Validation ===\"\n\n# T28: Non-numeric message ID rejected\nresult=$(bash \"$MAIL_SCRIPT\" read \"abc\" 2>&1)\nexit_code=$?\nassert_exit_code \"non-numeric ID rejected\" \"1\" \"$exit_code\"\n\n# T29: SQL injection via message ID\nbash \"$MAIL_SCRIPT\" send \"claude-mods\" \"id-inject-test\" \"before injection\" >/dev/null 2>&1\nresult=$(bash \"$MAIL_SCRIPT\" read \"1 OR 1=1\" 2>&1)\nexit_code=$?\nassert_exit_code \"SQL injection via ID rejected\" \"1\" \"$exit_code\"\n\n# T30: Non-numeric limit in list\nresult=$(bash \"$MAIL_SCRIPT\" list \"abc\" 2>&1)\nexit_code=$?\nassert_exit_code \"non-numeric limit handled\" \"0\" \"$exit_code\"\n\n# T31: Non-numeric days in clear\nresult=$(bash \"$MAIL_SCRIPT\" clear \"abc\" 2>&1)\nassert_contains \"non-numeric days handled\" \"Cleared\" \"$result\"\n\n# T32: Single quotes in subject preserved\nbash \"$MAIL_SCRIPT\" read >/dev/null 2>&1 # clear unread\nbash \"$MAIL_SCRIPT\" send \"claude-mods\" \"it's working\" \"body with 'quotes'\" >/dev/null 2>&1\nresult=$(bash \"$MAIL_SCRIPT\" read 2>&1)\nassert_contains \"single quotes in subject\" \"it's working\" \"$result\"\n\n# T33: Double quotes in body preserved\nbash \"$MAIL_SCRIPT\" send \"claude-mods\" \"quotes\" 'She said \"hello\"' >/dev/null 2>&1\nresult=$(bash \"$MAIL_SCRIPT\" read 2>&1)\nassert_contains \"double quotes in body\" \"hello\" \"$result\"\n\n# T34: Project name with spaces (edge case)\nbash \"$MAIL_SCRIPT\" send \"my project\" \"spaces\" \"project name has spaces\" >/dev/null 2>&1\nresult=$(bash \"$MAIL_SCRIPT\" projects)\nassert_contains \"project with spaces stored\" \"my project\" \"$result\"\n\n# T35: Multiple rapid sends\nfor i in 1 2 3 4 5; do\n bash \"$MAIL_SCRIPT\" send \"claude-mods\" \"rapid-$i\" \"rapid fire test $i\" >/dev/null 2>&1\ndone\nresult=$(bash \"$MAIL_SCRIPT\" count)\nassert \"5 rapid sends all counted\" \"5\" \"$result\"\nbash \"$MAIL_SCRIPT\" read >/dev/null 2>&1\n\n# T36: Init is idempotent\nbash \"$MAIL_SCRIPT\" init >/dev/null 2>&1\nbash \"$MAIL_SCRIPT\" init >/dev/null 2>&1\nresult=$(bash \"$MAIL_SCRIPT\" count)\nassert \"init idempotent\" \"0\" \"$result\"\n\n# T37: Empty subject defaults\nresult=$(bash \"$MAIL_SCRIPT\" send \"claude-mods\" \"\" \"empty subject body\" 2>&1)\nassert_contains \"empty subject accepted\" \"Sent to claude-mods\" \"$result\"\nbash \"$MAIL_SCRIPT\" read >/dev/null 2>&1\n\necho \"\"\necho \"=== Reply ===\"\n\n# T38: Reply to a message\nbash \"$MAIL_SCRIPT\" send \"claude-mods\" \"Original msg\" \"Please reply\" >/dev/null 2>&1\nmsg_id=$(sqlite3 \"$MAIL_DB\" \"SELECT id FROM messages WHERE subject='Original msg' AND read=0 LIMIT 1;\")\nbash \"$MAIL_SCRIPT\" read \"$msg_id\" >/dev/null 2>&1\nresult=$(bash \"$MAIL_SCRIPT\" reply \"$msg_id\" \"Here is my reply\" 2>&1)\nassert_contains \"reply succeeds\" \"Replied to claude-mods\" \"$result\"\nassert_contains \"reply has Re: prefix\" \"Re: Original msg\" \"$result\"\n\n# T39: Reply to nonexistent message\nresult=$(bash \"$MAIL_SCRIPT\" reply 99999 \"reply to nothing\" 2>&1)\nexit_code=$?\nassert_exit_code \"reply to nonexistent fails\" \"1\" \"$exit_code\"\n\n# T40: Reply with empty body\nresult=$(bash \"$MAIL_SCRIPT\" reply \"$msg_id\" \"\" 2>&1)\nexit_code=$?\nassert_exit_code \"reply with empty body fails\" \"1\" \"$exit_code\"\n\n# T41: Reply with non-numeric ID\nresult=$(bash \"$MAIL_SCRIPT\" reply \"abc\" \"body\" 2>&1)\nexit_code=$?\nassert_exit_code \"reply with non-numeric ID fails\" \"1\" \"$exit_code\"\n\n# Clean up\nbash \"$MAIL_SCRIPT\" read >/dev/null 2>&1\n\necho \"\"\necho \"=== Priority & Search ===\"\n\n# T38: Send urgent message\nresult=$(bash \"$MAIL_SCRIPT\" send --urgent \"claude-mods\" \"Server down\" \"Production is on fire\" 2>&1)\nassert_contains \"urgent send succeeds\" \"URGENT\" \"$result\"\n\n# T39: Hook delivers urgent message with marker\nclear_cooldown\nresult=$(bash \"$HOOK_SCRIPT\" 2>&1)\nassert_contains \"hook shows URGENT\" \"URGENT\" \"$result\"\nassert_contains \"hook shows urgent body\" \"Production is on fire\" \"$result\"\n\n# T40: Normal send still works after priority feature\nresult=$(bash \"$MAIL_SCRIPT\" send \"claude-mods\" \"Normal msg\" \"not urgent\" 2>&1)\nTOTAL=$((TOTAL + 1))\nif echo \"$result\" | grep -qvF \"URGENT\"; then\n echo \"PASS: normal send has no URGENT tag\"\n PASS=$((PASS + 1))\nelse\n echo \"FAIL: normal send incorrectly tagged URGENT\"\n FAIL=$((FAIL + 1))\nfi\nbash \"$MAIL_SCRIPT\" read >/dev/null 2>&1\n\n# T41: Search by keyword in subject\nbash \"$MAIL_SCRIPT\" send \"claude-mods\" \"API endpoint changed\" \"details here\" >/dev/null 2>&1\nbash \"$MAIL_SCRIPT\" send \"claude-mods\" \"unrelated\" \"nothing relevant\" >/dev/null 2>&1\nresult=$(bash \"$MAIL_SCRIPT\" search \"API\" 2>&1)\nassert_contains \"search finds by subject\" \"API endpoint\" \"$result\"\n\n# T42: Search by keyword in body\nresult=$(bash \"$MAIL_SCRIPT\" search \"relevant\" 2>&1)\nassert_contains \"search finds by body\" \"unrelated\" \"$result\"\n\n# T43: Search with no results\nresult=$(bash \"$MAIL_SCRIPT\" search \"xyznonexistent\" 2>&1)\nassert_empty \"search no results is empty\" \"$result\"\n\n# T44: Search with no keyword fails\nresult=$(bash \"$MAIL_SCRIPT\" search 2>&1)\nexit_code=$?\nassert_exit_code \"search no keyword fails\" \"1\" \"$exit_code\"\n\nbash \"$MAIL_SCRIPT\" read >/dev/null 2>&1\n\necho \"\"\necho \"=== Broadcast & Status ===\"\n\n# Setup: ensure multiple projects exist\nbash \"$MAIL_SCRIPT\" send \"project-a\" \"setup\" \"creating project-a\" >/dev/null 2>&1\nbash \"$MAIL_SCRIPT\" send \"project-b\" \"setup\" \"creating project-b\" >/dev/null 2>&1\n\n# T42: Broadcast sends to all known projects except self\nresult=$(bash \"$MAIL_SCRIPT\" broadcast \"Announcement\" \"Main is frozen\" 2>&1)\nassert_contains \"broadcast reports count\" \"Broadcast to\" \"$result\"\n\n# T43: Broadcast doesn't send to self\nself_count=$(sqlite3 \"$MAIL_DB\" \"SELECT COUNT(*) FROM messages WHERE to_project='claude-mods' AND subject='Announcement';\")\nassert \"broadcast skips self\" \"0\" \"$self_count\"\n\n# T44: Broadcast with empty body fails\nresult=$(bash \"$MAIL_SCRIPT\" broadcast \"test\" \"\" 2>&1)\nexit_code=$?\nassert_exit_code \"broadcast empty body fails\" \"1\" \"$exit_code\"\n\n# T45: Status shows inbox summary\nbash \"$MAIL_SCRIPT\" send \"claude-mods\" \"Status test 1\" \"msg1\" >/dev/null 2>&1\nbash \"$MAIL_SCRIPT\" send \"claude-mods\" \"Status test 2\" \"msg2\" >/dev/null 2>&1\nresult=$(bash \"$MAIL_SCRIPT\" status 2>&1)\nassert_contains \"status shows unread count\" \"unread\" \"$result\"\nassert_contains \"status shows Inbox\" \"Inbox\" \"$result\"\n\n# T46: Status on empty inbox\nbash \"$MAIL_SCRIPT\" read >/dev/null 2>&1\nresult=$(bash \"$MAIL_SCRIPT\" status 2>&1)\nassert_contains \"status shows 0 unread\" \"0 unread\" \"$result\"\n\necho \"\"\necho \"=== Alias (Rename) ===\"\n\n# Setup: send messages with old project name\nbash \"$MAIL_SCRIPT\" send \"old-project\" \"before rename\" \"testing alias\" >/dev/null 2>&1\nbash \"$MAIL_SCRIPT\" send \"claude-mods\" \"from old\" \"message from old name\" >/dev/null 2>&1\n\n# T47: Alias renames in all messages\nresult=$(bash \"$MAIL_SCRIPT\" alias \"old-project\" \"new-project\" 2>&1)\nassert_contains \"alias reports rename\" \"Renamed\" \"$result\"\nassert_contains \"alias shows old name\" \"old-project\" \"$result\"\nassert_contains \"alias shows new name\" \"new-project\" \"$result\"\n\n# T48: Old project name no longer appears\nresult=$(bash \"$MAIL_SCRIPT\" projects)\nTOTAL=$((TOTAL + 1))\nif echo \"$result\" | grep -qF \"old-project\"; then\n echo \"FAIL: old project name still present after alias\"\n FAIL=$((FAIL + 1))\nelse\n echo \"PASS: old project name removed after alias\"\n PASS=$((PASS + 1))\nfi\n\n# T49: New project name appears\nassert_contains \"new project name present\" \"new-project\" \"$result\"\n\n# T50: Alias with missing args fails\nresult=$(bash \"$MAIL_SCRIPT\" alias \"only-one\" 2>&1)\nexit_code=$?\nassert_exit_code \"alias with missing arg fails\" \"1\" \"$exit_code\"\n\n# Clean up\nbash \"$MAIL_SCRIPT\" read >/dev/null 2>&1\n\necho \"\"\necho \"=== Hook ===\"\n\n# T52: Hook delivers without auto-read\nbash \"$MAIL_SCRIPT\" send \"claude-mods\" \"hook test\" \"testing hook\" >/dev/null 2>&1\nresult1=$(bash \"$HOOK_SCRIPT\" 2>&1)\nassert_contains \"hook delivers message\" \"INCOMING MAIL\" \"$result1\"\n\n# T53: Signal cleared after delivery, second call silent\nresult2=$(bash \"$HOOK_SCRIPT\" 2>&1)\nassert_empty \"hook silent after signal cleared (2)\" \"$result2\"\n# Messages still unread - verify then clean up\nbash \"$MAIL_SCRIPT\" read >/dev/null 2>&1\n\necho \"\"\necho \"=== Attachments ===\"\n\n# Create temp files for attachment tests\nATTACH_DIR=$(mktemp -d)\necho \"file one content\" > \"$ATTACH_DIR/file1.txt\"\necho \"file two content\" > \"$ATTACH_DIR/file2.txt\"\nmkdir -p \"$ATTACH_DIR/sub dir\"\necho \"spaced path\" > \"$ATTACH_DIR/sub dir/spaced.txt\"\n\n# T: Send with single attachment\nresult=$(bash \"$MAIL_SCRIPT\" send --attach \"$ATTACH_DIR/file1.txt\" \"claude-mods\" \"attach test\" \"one file\" 2>&1)\nassert_contains \"send with attachment succeeds\" \"1 attachment\" \"$result\"\n\n# T: Attachment path stored as absolute\nlast_id=$(sqlite3 \"$MAIL_DB\" \"SELECT id FROM messages ORDER BY id DESC LIMIT 1;\")\nstored=$(sqlite3 \"$MAIL_DB\" \"SELECT attachments FROM messages WHERE id=${last_id};\")\nassert_contains \"attachment path is absolute\" \"$ATTACH_DIR/file1.txt\" \"$stored\"\n\n# T: Read shows attachment with size\nresult=$(bash \"$MAIL_SCRIPT\" read \"$last_id\" 2>&1)\nassert_contains \"read shows Attached\" \"[Attached:\" \"$result\"\nassert_contains \"read shows file size\" \"bytes\" \"$result\"\n\n# T: Send with multiple attachments\nresult=$(bash \"$MAIL_SCRIPT\" send --attach \"$ATTACH_DIR/file1.txt\" --attach \"$ATTACH_DIR/file2.txt\" \"claude-mods\" \"multi attach\" \"two files\" 2>&1)\nassert_contains \"send with 2 attachments\" \"2 attachment\" \"$result\"\n\n# T: Multiple attachment paths stored correctly\nlast_id=$(sqlite3 \"$MAIL_DB\" \"SELECT id FROM messages ORDER BY id DESC LIMIT 1;\")\nattach_count=$(sqlite3 \"$MAIL_DB\" \"SELECT attachments FROM messages WHERE id=${last_id};\" | grep -c '.')\nassert \"two attachment paths stored\" \"2\" \"$attach_count\"\n\n# T: No trailing empty line in stored attachments\ntrailing=$(sqlite3 \"$MAIL_DB\" \"SELECT attachments FROM messages WHERE id=${last_id};\" | tail -1)\nassert_not_empty \"no trailing empty line\" \"$trailing\"\n\n# T: Nonexistent file rejected\nresult=$(bash \"$MAIL_SCRIPT\" send --attach \"/tmp/nonexistent_$.txt\" \"claude-mods\" \"fail\" \"body\" 2>&1)\nexit_code=$?\nassert_contains \"nonexistent attach rejected\" \"not found\" \"$result\"\nassert_exit_code \"nonexistent attach exits 1\" \"1\" \"$exit_code\"\n\n# T: Send without attachment still works (no regression)\nresult=$(bash \"$MAIL_SCRIPT\" send \"claude-mods\" \"no attach\" \"plain message\" 2>&1)\nassert_contains \"send without attach works\" \"Sent to\" \"$result\"\nlast_id=$(sqlite3 \"$MAIL_DB\" \"SELECT id FROM messages ORDER BY id DESC LIMIT 1;\")\nstored=$(sqlite3 \"$MAIL_DB\" \"SELECT COALESCE(attachments,'') FROM messages WHERE id=${last_id};\")\nassert \"no-attach message has empty attachments\" \"\" \"$stored\"\n\n# T: Reply with attachment via dispatch\nbase_id=$(sqlite3 \"$MAIL_DB\" \"SELECT id FROM messages ORDER BY id DESC LIMIT 1;\")\nresult=$(bash \"$MAIL_SCRIPT\" reply --attach \"$ATTACH_DIR/file1.txt\" \"$base_id\" \"reply with file\" 2>&1)\nassert_contains \"reply with attachment succeeds\" \"1 attachment\" \"$result\"\n\n# T: Reply attachment stored correctly\nlast_id=$(sqlite3 \"$MAIL_DB\" \"SELECT id FROM messages ORDER BY id DESC LIMIT 1;\")\nstored=$(sqlite3 \"$MAIL_DB\" \"SELECT attachments FROM messages WHERE id=${last_id};\")\nassert_contains \"reply attachment path stored\" \"$ATTACH_DIR/file1.txt\" \"$stored\"\n\n# T: Attachment with spaces in path\nresult=$(bash \"$MAIL_SCRIPT\" send --attach \"$ATTACH_DIR/sub dir/spaced.txt\" \"claude-mods\" \"spaced path\" \"path has spaces\" 2>&1)\nassert_contains \"spaced path attachment succeeds\" \"1 attachment\" \"$result\"\nlast_id=$(sqlite3 \"$MAIL_DB\" \"SELECT id FROM messages ORDER BY id DESC LIMIT 1;\")\nstored=$(sqlite3 \"$MAIL_DB\" \"SELECT attachments FROM messages WHERE id=${last_id};\")\nassert_contains \"spaced path preserved\" \"sub dir/spaced.txt\" \"$stored\"\n\n# T: Hook shows attachments\nbash \"$MAIL_SCRIPT\" send --attach \"$ATTACH_DIR/file1.txt\" \"claude-mods\" \"hook attach\" \"check hook\" >/dev/null 2>&1\nclear_cooldown\nresult=$(bash \"$HOOK_SCRIPT\" 2>&1)\nassert_contains \"hook shows attachment\" \"[Attached:\" \"$result\"\nassert_contains \"hook shows Read hint\" \"Use Read tool\" \"$result\"\nbash \"$MAIL_SCRIPT\" read >/dev/null 2>&1\n\n# T: Mixed flags - --urgent with --attach\nresult=$(bash \"$MAIL_SCRIPT\" send --urgent --attach \"$ATTACH_DIR/file1.txt\" \"claude-mods\" \"urgent+attach\" \"both flags\" 2>&1)\nassert_contains \"urgent+attach shows attachment\" \"1 attachment\" \"$result\"\nassert_contains \"urgent+attach shows URGENT\" \"URGENT\" \"$result\"\n\n# T: Deleted file shows as missing\nVANISH=\"$ATTACH_DIR/vanish.txt\"\necho \"temporary\" > \"$VANISH\"\nbash \"$MAIL_SCRIPT\" send --attach \"$VANISH\" \"claude-mods\" \"vanish test\" \"file will disappear\" >/dev/null 2>&1\nrm -f \"$VANISH\"\nlast_id=$(sqlite3 \"$MAIL_DB\" \"SELECT id FROM messages ORDER BY id DESC LIMIT 1;\")\nresult=$(bash \"$MAIL_SCRIPT\" read \"$last_id\" 2>&1)\nassert_contains \"deleted file shows missing\" \"missing\" \"$result\"\n\n# Clean up temp dir\nrm -rf \"$ATTACH_DIR\"\nbash \"$MAIL_SCRIPT\" read >/dev/null 2>&1\n\necho \"\"\necho \"=== Purge ===\"\n\n# T54: Purge removes messages for current project\nbash \"$MAIL_SCRIPT\" send \"claude-mods\" \"purge test 1\" \"msg1\" >/dev/null 2>&1\nbash \"$MAIL_SCRIPT\" send \"claude-mods\" \"purge test 2\" \"msg2\" >/dev/null 2>&1\n# Insert a message not involving claude-mods at all\nsqlite3 \"$MAIL_DB\" \"INSERT INTO messages (from_project, to_project, subject, body) VALUES ('alpha', 'beta', 'unrelated', 'should survive');\"\nresult=$(bash \"$MAIL_SCRIPT\" purge 2>&1)\nassert_contains \"purge reports count\" \"Purged\" \"$result\"\n\n# T55: Unrelated project messages survive purge\nother_count=$(sqlite3 \"$MAIL_DB\" \"SELECT COUNT(*) FROM messages WHERE from_project='alpha';\")\nassert \"unrelated messages survive purge\" \"1\" \"$other_count\"\n\n# T56: Purge --all removes everything\nbash \"$MAIL_SCRIPT\" send \"claude-mods\" \"test\" \"body\" >/dev/null 2>&1\nresult=$(bash \"$MAIL_SCRIPT\" purge --all 2>&1)\nassert_contains \"purge --all reports count\" \"Purged all\" \"$result\"\ntotal=$(sqlite3 \"$MAIL_DB\" \"SELECT COUNT(*) FROM messages;\")\nassert \"purge --all empties db\" \"0\" \"$total\"\n\necho \"\"\necho \"=== Per-Project Disable ===\"\n\n# T52: Hook respects .claude/pigeon.disable\nbash \"$MAIL_SCRIPT\" send \"claude-mods\" \"disable test\" \"should not appear\" >/dev/null 2>&1\nclear_cooldown\nmkdir -p .claude\ntouch .claude/pigeon.disable\nresult=$(bash \"$HOOK_SCRIPT\" 2>&1)\nassert_empty \"hook silent when disabled\" \"$result\"\n\n# T53: Hook delivers after re-enable\nrm -f .claude/pigeon.disable\nclear_cooldown\nresult=$(bash \"$HOOK_SCRIPT\" 2>&1)\nassert_contains \"hook works after re-enable\" \"INCOMING MAIL\" \"$result\"\n\necho \"\"\necho \"=== Results ===\"\necho \"Passed: $PASS / $TOTAL\"\necho \"Failed: $FAIL / $TOTAL\"\necho \"\"\necho \"$PASS\"\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":22941,"content_sha256":"d327c14de10f09748f33d7d022bb1d2042e4ac0e4f84f25d9de7bf069a35d6da"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"Pigeon","type":"text"}]},{"type":"paragraph","content":[{"text":"Inter-session messaging for Claude Code. Send and receive pmail between sessions running in different projects.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Quick Reference","type":"text"}]},{"type":"paragraph","content":[{"text":"All commands go through ","type":"text"},{"text":"MAIL","type":"text","marks":[{"type":"code_inline"}]},{"text":", a shorthand for ","type":"text"},{"text":"bash \"$HOME/.claude/pigeon/mail-db.sh\"","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"Set this at the top of execution:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"MAIL=\"$HOME/.claude/pigeon/mail-db.sh\"","type":"text"}]},{"type":"paragraph","content":[{"text":"Then use it for all commands below.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Command Router","type":"text"}]},{"type":"paragraph","content":[{"text":"Parse the user's input after ","type":"text"},{"text":"pigeon","type":"text","marks":[{"type":"code_inline"}]},{"text":" (or ","type":"text"},{"text":"/pigeon","type":"text","marks":[{"type":"code_inline"}]},{"text":") and run the matching command:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"User says","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Run","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pigeon read","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bash \"$MAIL\" read","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pigeon read 42","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bash \"$MAIL\" read 42","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pigeon send \u003cproject> \"\u003csubject>\" \"\u003cbody>\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bash \"$MAIL\" send \"\u003cproject>\" \"\u003csubject>\" \"\u003cbody>\"","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pigeon send --urgent \u003cproject> \"\u003csubject>\" \"\u003cbody>\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bash \"$MAIL\" send --urgent \"\u003cproject>\" \"\u003csubject>\" \"\u003cbody>\"","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pigeon send --attach \u003cpath> \u003cproject> \"\u003csubject>\" \"\u003cbody>\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bash \"$MAIL\" send --attach \"\u003cpath>\" \"\u003cproject>\" \"\u003csubject>\" \"\u003cbody>\"","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pigeon reply \u003cid> \"\u003cbody>\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bash \"$MAIL\" reply \u003cid> \"\u003cbody>\"","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pigeon reply --attach \u003cpath> \u003cid> \"\u003cbody>\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bash \"$MAIL\" reply --attach \"\u003cpath>\" \u003cid> \"\u003cbody>\"","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pigeon broadcast \"\u003csubject>\" \"\u003cbody>\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bash \"$MAIL\" broadcast \"\u003csubject>\" \"\u003cbody>\"","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pigeon search \u003ckeyword>","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bash \"$MAIL\" search \"\u003ckeyword>\"","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pigeon status","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bash \"$MAIL\" status","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pigeon unread","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bash \"$MAIL\" unread","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pigeon list","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bash \"$MAIL\" list","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pigeon list 50","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bash \"$MAIL\" list 50","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pigeon projects","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bash \"$MAIL\" projects","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pigeon clear","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bash \"$MAIL\" clear","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pigeon clear 7","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bash \"$MAIL\" clear 7","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pigeon alias \u003cold> \u003cnew>","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bash \"$MAIL\" alias \"\u003cold>\" \"\u003cnew>\"","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pigeon purge","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bash \"$MAIL\" purge","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pigeon purge --all","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bash \"$MAIL\" purge --all","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pigeon id","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bash \"$MAIL\" id","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pigeon migrate","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bash \"$MAIL\" migrate","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pigeon init","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bash \"$MAIL\" init","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]},{"type":"paragraph","content":[{"text":"When the user just says \"check mail\", \"read mail\", \"inbox\", \"any mail?\", or \"any pmail?\" - run ","type":"text"},{"text":"bash \"$MAIL\" read","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"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 ","type":"text"},{"text":"bash \"$MAIL\" send","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Project Identity","type":"text"}]},{"type":"paragraph","content":[{"text":"Each project gets a stable 6-character hash ID derived from its ","type":"text"},{"text":"git root commit","type":"text","marks":[{"type":"strong"}]},{"text":" (the very first commit in the repo). This means:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"IDs survive directory renames, moves, and clones","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Case-insensitive filesystems (macOS) don't cause collisions","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Every clone of the same repo shares the same identity","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"For non-git directories, falls back to a hash of the canonical path (","type":"text"},{"text":"pwd -P","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]},{"type":"paragraph","content":[{"text":"Use ","type":"text"},{"text":"pigeon id","type":"text","marks":[{"type":"code_inline"}]},{"text":" to see your project's name and hash:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"claude-mods 7663d6","type":"text"}]},{"type":"paragraph","content":[{"text":"When sending messages, you can address projects by ","type":"text"},{"text":"name","type":"text","marks":[{"type":"strong"}]},{"text":", ","type":"text"},{"text":"hash","type":"text","marks":[{"type":"strong"}]},{"text":", or ","type":"text"},{"text":"path","type":"text","marks":[{"type":"strong"}]},{"text":" - they all resolve to the same hash ID.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Identicons","type":"text"}]},{"type":"paragraph","content":[{"text":"Each project hash renders as a unique pixel-art identicon (11x11 symmetric grid using Unicode half-block characters). Run ","type":"text"},{"text":"identicon.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" to see yours, or view all projects with ","type":"text"},{"text":"pigeon projects","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Passive Notification (Hook)","type":"text"}]},{"type":"paragraph","content":[{"text":"A global PreToolUse hook checks for pmail on every tool call (no cooldown). Silent when inbox is empty.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"=== PMAIL: 3 unread message(s) ===\n From: some-api | Auth endpoints ready\n From: frontend | Need updated types\n ... and 1 more\nUse pigeon read to read messages.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Attachments","type":"text"}]},{"type":"paragraph","content":[{"text":"Send file references with ","type":"text"},{"text":"--attach \u003cpath>","type":"text","marks":[{"type":"code_inline"}]},{"text":" (repeatable). Paths are resolved to absolute and stored as references - files are not copied.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Send with one attachment\npigeon send --attach src/config.ts my-api \"Config update\" \"Updated the auth config\"\n\n# Send with multiple attachments\npigeon send --attach src/schema.sql --attach docs/API.md my-api \"Schema + docs\" \"See attached\"\n\n# Reply with attachment\npigeon reply --attach output/report.json 42 \"Here's the analysis\"","type":"text"}]},{"type":"paragraph","content":[{"text":"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 ","type":"text"},{"text":"(missing)","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"When to Send","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"You've completed work another session depends on","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"An API contract or shared interface changed","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"A shared branch (main) is broken or fixed","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"You need input from a session working on a different project","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Per-Project Disable","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"touch .claude/pigeon.disable # Disable hook notifications\nrm .claude/pigeon.disable # Re-enable","type":"text"}]},{"type":"paragraph","content":[{"text":"Only the hook is disabled - you can still send messages from the project.","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"Installation","type":"text"}]},{"type":"paragraph","content":[{"text":"Pigeon requires two things: ","type":"text"},{"text":"scripts","type":"text","marks":[{"type":"strong"}]},{"text":" (the mail engine) and a ","type":"text"},{"text":"hook","type":"text","marks":[{"type":"strong"}]},{"text":" (passive notifications). Both install globally - one setup, every project gets pmail.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Prerequisites","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"sqlite3","type":"text","marks":[{"type":"code_inline"}]},{"text":" - ships with macOS, most Linux distros, and Git Bash on Windows. No install needed.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 1: Copy Scripts","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"mkdir -p ~/.claude/pigeon\ncp skills/pigeon/scripts/mail-db.sh ~/.claude/pigeon/\ncp hooks/check-mail.sh ~/.claude/pigeon/\nchmod +x ~/.claude/pigeon/mail-db.sh ~/.claude/pigeon/check-mail.sh","type":"text"}]},{"type":"paragraph","content":[{"text":"This gives you the pmail commands. You can now send and read messages manually:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"bash ~/.claude/pigeon/mail-db.sh init # Create database\nbash ~/.claude/pigeon/mail-db.sh status # Check it works","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 2: Enable the Hook","type":"text"}]},{"type":"paragraph","content":[{"text":"Add a ","type":"text"},{"text":"hooks","type":"text","marks":[{"type":"code_inline"}]},{"text":" block to ","type":"text"},{"text":"~/.claude/settings.json","type":"text","marks":[{"type":"code_inline"}]},{"text":". This makes Claude check for pmail automatically on every tool call:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"json"},"content":[{"text":"{\n \"hooks\": {\n \"PreToolUse\": [\n {\n \"matcher\": \"*\",\n \"hooks\": [\n {\n \"type\": \"command\",\n \"command\": \"bash \\\"$HOME/.claude/pigeon/check-mail.sh\\\"\",\n \"timeout\": 5\n }\n ]\n }\n ]\n }\n}","type":"text"}]},{"type":"paragraph","content":[{"text":"Important:","type":"text","marks":[{"type":"strong"}]},{"text":" If you already have a ","type":"text"},{"text":"hooks","type":"text","marks":[{"type":"code_inline"}]},{"text":" section in your settings, merge the PreToolUse entry into the existing array - don't replace the whole block.","type":"text"}]},{"type":"paragraph","content":[{"text":"Without this step, pigeon still works but you have to check manually (","type":"text"},{"text":"pigeon read","type":"text","marks":[{"type":"code_inline"}]},{"text":"). With the hook, unread pmail appears automatically.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"What Gets Created","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"~/.claude/\n settings.json # Hook config (you edit this)\n pmail.db # Message store (auto-created on first use)\n pigeon/\n mail-db.sh # All pmail commands (send, read, reply, etc.)\n check-mail.sh # PreToolUse hook (silent when inbox empty)","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Verify","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Check your project identity\nbash ~/.claude/pigeon/mail-db.sh id\n\n# Send yourself a test message (use your project name from above)\nbash ~/.claude/pigeon/mail-db.sh send \"my-project\" \"Test\" \"Hello from pigeon\"\n\n# Check it arrived\nbash ~/.claude/pigeon/mail-db.sh read\n\n# Clean up\nbash ~/.claude/pigeon/mail-db.sh purge --all","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Uninstall","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"rm -rf ~/.claude/pigeon ~/.claude/pmail.db\n# Then remove the hooks.PreToolUse entry from ~/.claude/settings.json","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Database","type":"text"}]},{"type":"paragraph","content":[{"text":"Single SQLite file at ","type":"text"},{"text":"~/.claude/pmail.db","type":"text","marks":[{"type":"code_inline"}]},{"text":". Auto-created on first ","type":"text"},{"text":"init","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"send","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"sql"},"content":[{"text":"CREATE TABLE messages (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n from_project TEXT NOT NULL, -- 6-char hash ID\n to_project TEXT NOT NULL, -- 6-char hash ID\n subject TEXT DEFAULT '',\n body TEXT NOT NULL,\n timestamp TEXT DEFAULT (datetime('now')),\n read INTEGER DEFAULT 0,\n priority TEXT DEFAULT 'normal'\n);\n\nCREATE TABLE projects (\n hash TEXT PRIMARY KEY, -- 6-char ID (git root commit or path hash)\n name TEXT NOT NULL, -- Display name (basename of project dir)\n path TEXT NOT NULL, -- Canonical path\n registered TEXT DEFAULT (datetime('now'))\n);","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Troubleshooting","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Issue","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Fix","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"sqlite3: not found","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Ships with macOS, Linux, and Git Bash on Windows. Run ","type":"text"},{"text":"sqlite3 --version","type":"text","marks":[{"type":"code_inline"}]},{"text":" to check.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Hook not firing","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Ensure ","type":"text"},{"text":"hooks","type":"text","marks":[{"type":"code_inline"}]},{"text":" block is in ","type":"text"},{"text":"~/.claude/settings.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" (Step 2 above)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Hook fires but no notification","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Working as intended - hook is silent when inbox is empty","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Messages not arriving","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Target must be a known name, hash, or path. Use ","type":"text"},{"text":"pigeon projects","type":"text","marks":[{"type":"code_inline"}]},{"text":" to see registered projects","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Upgraded from basename IDs","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Run ","type":"text"},{"text":"pigeon migrate","type":"text","marks":[{"type":"code_inline"}]},{"text":" to convert old messages to hash-based IDs","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Changed display name","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Use ","type":"text"},{"text":"pigeon alias old-name new-name","type":"text","marks":[{"type":"code_inline"}]},{"text":" to update the project's display name","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Want to disable for one project","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"touch .claude/pigeon.disable","type":"text","marks":[{"type":"code_inline"}]},{"text":" in that project's root","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Check your project ID","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Run ","type":"text"},{"text":"pigeon id","type":"text","marks":[{"type":"code_inline"}]},{"text":" to see name and 6-char hash","type":"text"}]}]}]}]},{"type":"hr","attrs":{"markup":"---"}}]},"metadata":{"date":"2026-06-05","name":"pigeon","author":"@skillopedia","source":{"stars":21,"repo_name":"claude-mods","origin_url":"https://github.com/0xdarkmatter/claude-mods/blob/HEAD/skills/pigeon/SKILL.md","repo_owner":"0xdarkmatter","body_sha256":"a4fb60930e4c7ac3909f44ef1b4b112b7d01bec2fcc022e802b7944c72ee12ee","cluster_key":"38640abf153c8af1c76546fb5ebbcbc8f73f49db24ef5d682e68efe414937fe5","clean_bundle":{"format":"clean-skill-bundle-v1","source":"0xdarkmatter/claude-mods/skills/pigeon/SKILL.md","attachments":[{"id":"7961287a-5323-5227-bcee-508512ff10ae","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7961287a-5323-5227-bcee-508512ff10ae/attachment","path":"assets/.gitkeep","size":0,"sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","contentType":"text/plain; charset=utf-8"},{"id":"71c6c50e-a473-5e61-a028-e24f1ab87879","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/71c6c50e-a473-5e61-a028-e24f1ab87879/attachment","path":"references/.gitkeep","size":0,"sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","contentType":"text/plain; charset=utf-8"},{"id":"1e9282e2-f9c2-5eff-b39b-f0dac035ebda","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1e9282e2-f9c2-5eff-b39b-f0dac035ebda/attachment.sh","path":"scripts/identicon.sh","size":5007,"sha256":"126511e616cf7989e3f8f3c62e1321fb3c208b53e9de691b31d6ec7bc52837d0","contentType":"application/x-sh; charset=utf-8"},{"id":"3b317d01-ad19-578b-af61-870e56c2e296","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/3b317d01-ad19-578b-af61-870e56c2e296/attachment.sh","path":"scripts/mail-db.sh","size":27815,"sha256":"d4dfd55196962e61dd32a3f6b47c44f7eea636564dbd1fb770cedc508fb16b07","contentType":"application/x-sh; charset=utf-8"},{"id":"ca197b33-8a47-51c8-9175-e649a5ab2e4a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ca197b33-8a47-51c8-9175-e649a5ab2e4a/attachment.sh","path":"scripts/test-mail.sh","size":22941,"sha256":"d327c14de10f09748f33d7d022bb1d2042e4ac0e4f84f25d9de7bf069a35d6da","contentType":"application/x-sh; charset=utf-8"}],"bundle_sha256":"d9e0306e867c3028330a74edeed83dbe5abe0bed386956f9e71fc13e694dd481","attachment_count":5,"text_attachments":3,"attachment_storage":"skillopedia-attachments-v1","binary_attachments":2,"excluded_attachments":[]},"cluster_size":1,"skill_md_path":"skills/pigeon/SKILL.md","import_metadata":{"date":"2026-06-05","author":"@skillopedia","version":"v1","category":"data-analytics","category_label":"Data"},"exact_dupes_collapsed_into_this":0},"license":"MIT","version":"v1","category":"data-analytics","metadata":{"author":"claude-mods","related-skills":"sqlite-ops"},"import_tag":"clean-skills-v1","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.","allowed-tools":"Read Bash Grep"}},"renderedAt":1782987720660}

Pigeon Inter-session messaging for Claude Code. Send and receive pmail between sessions running in different projects. Quick Reference All commands go through , a shorthand for . Set this at the top of execution: Then use it for all commands below. Command Router Parse the user's input after (or ) and run the matching command: | User says | Run | |-----------|-----| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | When the user just says "check mail", "read mail", "inbox", "any mail?", or "any pmail?" - run . W…