Browse Source

feat(skills): Add agentmail - inter-session messaging via SQLite

Global ~/.claude/mail.db enables Claude Code sessions in different
project directories to send/receive messages. PreToolUse hook provides
passive notification on every tool call. Zero dependencies beyond
sqlite3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
0xDarkMatter 1 week ago
parent
commit
d3d27fb321

+ 4 - 2
.claude-plugin/plugin.json

@@ -111,7 +111,8 @@
       "skills/tool-discovery",
       "skills/typescript-ops",
       "skills/unfold-admin",
-      "skills/vue-ops"
+      "skills/vue-ops",
+      "skills/agentmail"
     ],
     "rules": [
       "rules/cli-tools.md",
@@ -123,7 +124,8 @@
     "hooks": [
       "hooks/pre-commit-lint.sh",
       "hooks/post-edit-format.sh",
-      "hooks/dangerous-cmd-warn.sh"
+      "hooks/dangerous-cmd-warn.sh",
+      "hooks/check-mail.sh"
     ],
     "output-styles": [
       "output-styles/vesper.md",

+ 39 - 0
hooks/check-mail.sh

@@ -0,0 +1,39 @@
+#!/bin/bash
+# hooks/check-mail.sh
+# PreToolUse hook - checks for unread inter-session mail
+# Runs on every tool call. Silent when inbox is empty.
+# Matcher: * (all tools)
+#
+# Configuration in .claude/settings.json or .claude/settings.local.json:
+# {
+#   "hooks": {
+#     "PreToolUse": [{
+#       "matcher": "*",
+#       "hooks": ["bash hooks/check-mail.sh"]
+#     }]
+#   }
+# }
+
+MAIL_DB="$HOME/.claude/mail.db"
+
+# Skip if no database exists yet
+[ -f "$MAIL_DB" ] || exit 0
+
+PROJECT=$(basename "$PWD")
+
+# Single fast query - count unread
+UNREAD=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${PROJECT}' AND read=0;" 2>/dev/null)
+
+# Silent exit if no mail
+[ "${UNREAD:-0}" -eq 0 ] && exit 0
+
+# Show notification with preview of first 3 messages
+echo ""
+echo "=== MAIL: ${UNREAD} unread message(s) ==="
+sqlite3 -separator '  ' "$MAIL_DB" \
+  "SELECT '  From: ' || from_project || '  |  ' || subject FROM messages WHERE to_project='${PROJECT}' AND read=0 ORDER BY timestamp DESC LIMIT 3;" 2>/dev/null
+if [ "$UNREAD" -gt 3 ]; then
+  echo "  ... and $((UNREAD - 3)) more"
+fi
+echo "Use /mail to read messages."
+echo "==="

+ 164 - 0
skills/agentmail/SKILL.md

@@ -0,0 +1,164 @@
+---
+name: agentmail
+description: "Inter-session mail - send and receive messages between Claude Code sessions running in different project directories. Uses global SQLite database at ~/.claude/mail.db. Triggers on: mail, send message, check mail, inbox, inter-session, message another session, agentmail."
+allowed-tools: "Read Bash Grep"
+related-skills: [sqlite-ops]
+---
+
+# AgentMail
+
+Inter-session messaging for Claude Code. Sessions running in different project directories can send and receive messages through a shared SQLite database.
+
+## Architecture
+
+```
+~/.claude/mail.db          # Global message store (auto-created)
+
+Session A (claude-mods)    Session B (some-api)     Session C (frontend)
+    |                          |                        |
+    +-- check-mail hook -------+-- check-mail hook -----+-- check-mail hook
+    |   (PreToolUse, silent    |                        |
+    |    when empty)           |                        |
+    |                          |                        |
+    +-- /mail send some-api ---+--> unread message -----+
+         "API changed"             appears next
+                                   tool call
+```
+
+## Project Identity
+
+Project name = `basename` of current working directory. No configuration needed.
+
+- `X:\Forge\claude-mods` -> `claude-mods`
+- `X:\Forge\some-api` -> `some-api`
+
+## Commands
+
+All commands use the helper script at `skills/agentmail/scripts/mail-db.sh`.
+
+### Check for Mail
+
+The `check-mail.sh` hook runs automatically on every tool call. When unread messages exist, it outputs a notification. No action needed from user or assistant.
+
+To manually check:
+
+```bash
+bash skills/agentmail/scripts/mail-db.sh count
+```
+
+### Read Messages
+
+Read all unread messages and mark them as read:
+
+```bash
+bash skills/agentmail/scripts/mail-db.sh read
+```
+
+Read a specific message by ID:
+
+```bash
+bash skills/agentmail/scripts/mail-db.sh read 42
+```
+
+### Send a Message
+
+```bash
+bash skills/agentmail/scripts/mail-db.sh send "<target-project>" "<subject>" "<body>"
+```
+
+**Examples:**
+
+```bash
+# Simple notification
+bash skills/agentmail/scripts/mail-db.sh send "some-api" "Auth ready" "OAuth2 endpoints are implemented and tested on branch feature/oauth2"
+
+# Request for action
+bash skills/agentmail/scripts/mail-db.sh send "frontend" "API contract changed" "The /api/users endpoint now returns {data: User[], meta: {total: number}} instead of a flat array. See commit abc123."
+
+# Broadcast to multiple projects
+for project in frontend some-api; do
+  bash skills/agentmail/scripts/mail-db.sh send "$project" "Main is broken" "Do not merge until fix lands - CI is red"
+done
+```
+
+### List Messages
+
+Show recent messages (read and unread):
+
+```bash
+bash skills/agentmail/scripts/mail-db.sh list        # Last 20
+bash skills/agentmail/scripts/mail-db.sh list 50      # Last 50
+```
+
+### List Known Projects
+
+Show all projects that have sent or received mail:
+
+```bash
+bash skills/agentmail/scripts/mail-db.sh projects
+```
+
+### Cleanup
+
+Delete old read messages:
+
+```bash
+bash skills/agentmail/scripts/mail-db.sh clear        # Older than 7 days
+bash skills/agentmail/scripts/mail-db.sh clear 30      # Older than 30 days
+```
+
+## Passive Notification (Hook)
+
+The `hooks/check-mail.sh` hook provides passive notification. It:
+
+1. Runs on every tool call (PreToolUse matcher: `*`)
+2. Checks `~/.claude/mail.db` for unread messages where `to_project` matches current directory name
+3. Outputs nothing if inbox is empty (zero overhead)
+4. Shows count + preview of up to 3 messages when mail exists
+
+### Hook Output Example
+
+```
+=== MAIL: 2 unread message(s) ===
+  From: some-api  |  Auth endpoints ready
+  From: frontend  |  Need updated types
+Use /mail to read messages.
+===
+```
+
+## When to Use
+
+**Send messages when:**
+- 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
+
+**The hook handles receiving automatically.** When this skill triggers from a user saying "check mail" or "read messages", run the read command.
+
+## Database
+
+Single SQLite file at `~/.claude/mail.db`. Schema:
+
+```sql
+CREATE TABLE 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
+);
+```
+
+Database is auto-created on first use. Not inside any git repo - no gitignore needed.
+
+## Troubleshooting
+
+| Issue | Fix |
+|-------|-----|
+| `sqlite3: not found` | Install sqlite3 (ships with most OS installs, Git Bash on Windows) |
+| Hook not firing | Check hook is registered in `.claude/settings.json` or `.claude/settings.local.json` |
+| Wrong project name | Hook uses `basename $PWD` - ensure cwd is the project root |
+| Messages not arriving | Check `to_project` matches target's directory basename exactly |

+ 135 - 0
skills/agentmail/scripts/mail-db.sh

@@ -0,0 +1,135 @@
+#!/bin/bash
+# mail-db.sh - SQLite mail database operations
+# Global mail database at ~/.claude/mail.db
+# Project identity derived from basename of working directory
+
+set -euo pipefail
+
+MAIL_DB="$HOME/.claude/mail.db"
+
+# Ensure database and schema exist
+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
+);
+CREATE INDEX IF NOT EXISTS idx_unread ON messages(to_project, read);
+CREATE INDEX IF NOT EXISTS idx_timestamp ON messages(timestamp);
+SQL
+}
+
+# Get project name from cwd
+get_project() {
+  basename "$PWD"
+}
+
+# Count unread messages for current project
+count_unread() {
+  init_db
+  local project
+  project=$(get_project)
+  sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${project}' AND read=0;"
+}
+
+# List unread messages (brief) for current project
+list_unread() {
+  init_db
+  local project
+  project=$(get_project)
+  sqlite3 -separator ' | ' "$MAIL_DB" \
+    "SELECT id, from_project, subject, timestamp FROM messages WHERE to_project='${project}' AND read=0 ORDER BY timestamp DESC;"
+}
+
+# Read all unread messages (full) and mark as read
+read_mail() {
+  init_db
+  local project
+  project=$(get_project)
+  sqlite3 -header -separator ' | ' "$MAIL_DB" \
+    "SELECT id, from_project, subject, body, timestamp FROM messages WHERE to_project='${project}' AND read=0 ORDER BY timestamp ASC;"
+  sqlite3 "$MAIL_DB" \
+    "UPDATE messages SET read=1 WHERE to_project='${project}' AND read=0;"
+}
+
+# Read a single message by ID and mark as read
+read_one() {
+  local msg_id="$1"
+  init_db
+  sqlite3 -header -separator ' | ' "$MAIL_DB" \
+    "SELECT id, from_project, to_project, subject, body, timestamp FROM messages WHERE id=${msg_id};"
+  sqlite3 "$MAIL_DB" \
+    "UPDATE messages SET read=1 WHERE id=${msg_id};"
+}
+
+# Send a message
+send() {
+  local to_project="$1"
+  local subject="$2"
+  local body="$3"
+  init_db
+  local from_project
+  from_project=$(get_project)
+  sqlite3 "$MAIL_DB" \
+    "INSERT INTO messages (from_project, to_project, subject, body) VALUES ('${from_project}', '${to_project}', '${subject}', '${body}');"
+  echo "Sent to ${to_project}: ${subject}"
+}
+
+# List all messages (read and unread) for current project
+list_all() {
+  init_db
+  local project
+  project=$(get_project)
+  local limit="${1:-20}"
+  sqlite3 -header -separator ' | ' "$MAIL_DB" \
+    "SELECT id, from_project, subject, CASE WHEN read=0 THEN 'UNREAD' ELSE 'read' END as status, timestamp FROM messages WHERE to_project='${project}' ORDER BY timestamp DESC LIMIT ${limit};"
+}
+
+# Clear old read messages (default: older than 7 days)
+clear_old() {
+  init_db
+  local days="${1:-7}"
+  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"
+}
+
+# List all known projects (that have sent or received mail)
+list_projects() {
+  init_db
+  sqlite3 "$MAIL_DB" \
+    "SELECT DISTINCT from_project FROM messages UNION SELECT DISTINCT to_project FROM messages ORDER BY 1;"
+}
+
+# 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)       send "${2:?to_project required}" "${3:-no subject}" "${4:?body required}" ;;
+  list)       list_all "${2:-20}" ;;
+  clear)      clear_old "${2:-7}" ;;
+  projects)   list_projects ;;
+  help)
+    echo "Usage: mail-db.sh <command> [args]"
+    echo ""
+    echo "Commands:"
+    echo "  init                    Initialize database"
+    echo "  count                   Count unread messages"
+    echo "  unread                  List unread messages (brief)"
+    echo "  read [id]               Read messages and mark as read"
+    echo "  send <to> <subj> <body> Send a message"
+    echo "  list [limit]            List recent messages (default 20)"
+    echo "  clear [days]            Clear read messages older than N days"
+    echo "  projects                List known projects"
+    ;;
+  *)          echo "Unknown command: $1. Run with 'help' for usage." >&2; exit 1 ;;
+esac

+ 269 - 0
skills/agentmail/scripts/test-mail.sh

@@ -0,0 +1,269 @@
+#!/bin/bash
+# test-mail.sh - Test harness for mail-ops
+# Outputs: number of passing test cases
+# Each test prints PASS/FAIL and we count PASSes at the end
+
+set -uo pipefail
+
+MAIL_DB="$HOME/.claude/mail.db"
+MAIL_SCRIPT="$(dirname "$0")/mail-db.sh"
+HOOK_SCRIPT="$(dirname "$0")/../../hooks/check-mail.sh"
+# Resolve relative to repo root if needed
+if [ ! -f "$HOOK_SCRIPT" ]; then
+  HOOK_SCRIPT="$(cd "$(dirname "$0")/../../.." && pwd)/hooks/check-mail.sh"
+fi
+
+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
+}
+
+# --- 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)
+long_body=$(python3 -c "print('x' * 2000)" 2>/dev/null || printf '%0.s.' $(seq 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
+result=$(bash "$HOOK_SCRIPT" 2>&1)
+assert_empty "hook silent when no mail" "$result"
+
+# T22: Hook shows notification
+bash "$MAIL_SCRIPT" send "claude-mods" "Hook test" "Should trigger hook" >/dev/null 2>&1
+result=$(bash "$HOOK_SCRIPT" 2>&1)
+assert_contains "hook shows MAIL notification" "MAIL" "$result"
+assert_contains "hook shows message count" "1 unread" "$result"
+
+# T23: Hook with missing database
+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 "=== Results ==="
+echo "Passed: $PASS / $TOTAL"
+echo "Failed: $FAIL / $TOTAL"
+echo ""
+echo "$PASS"