Browse Source

feat(skills): Add signal-file hook, threading, and truncation-safe reads to agentmail

Replace cooldown-based hook with event-driven signal file approach -
sender touches /tmp/agentmail_signal_<hash>, hook does stat check before
SQLite. Add message threading via thread_id column, stdin body support,
outbox (sent command), and per-field SELECT queries to fix multi-line
body truncation. Tests updated for signal semantics (76/76 passing).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
0xDarkMatter 1 week ago
parent
commit
db77c4367c

+ 52 - 42
hooks/check-mail.sh

@@ -1,18 +1,15 @@
 #!/bin/bash
 #!/bin/bash
 # hooks/check-mail.sh
 # hooks/check-mail.sh
-# PreToolUse hook - checks for unread inter-session mail
-# Runs on every tool call. Silent when inbox is empty.
-# Uses hash-based project identity (resolves case sensitivity).
+# PreToolUse hook - event-driven mail delivery with thread context.
+# Checks a signal file (stat, nanoseconds) before touching SQLite.
+# Silent when no signal. Delivers full thread context for each message.
 
 
 MAIL_DB="$HOME/.claude/mail.db"
 MAIL_DB="$HOME/.claude/mail.db"
-COOLDOWN_SECONDS=10
+MAIL_SCRIPT="$HOME/.claude/agentmail/mail-db.sh"
 
 
 # Skip if disabled for this project
 # Skip if disabled for this project
 [ -f ".claude/agentmail.disable" ] && exit 0
 [ -f ".claude/agentmail.disable" ] && exit 0
 
 
-# Skip if no database exists yet
-[ -f "$MAIL_DB" ] || exit 0
-
 # Project identity: git root commit hash, fallback to path hash
 # Project identity: git root commit hash, fallback to path hash
 ROOT_COMMIT=$(git rev-list --max-parents=0 HEAD 2>/dev/null | head -1)
 ROOT_COMMIT=$(git rev-list --max-parents=0 HEAD 2>/dev/null | head -1)
 if [ -n "$ROOT_COMMIT" ]; then
 if [ -n "$ROOT_COMMIT" ]; then
@@ -22,28 +19,23 @@ else
   PROJECT_HASH=$(printf '%s' "$CANONICAL" | shasum -a 256 | cut -c1-6)
   PROJECT_HASH=$(printf '%s' "$CANONICAL" | shasum -a 256 | cut -c1-6)
 fi
 fi
 
 
-COOLDOWN_FILE="/tmp/agentmail_${PROJECT_HASH}"
+SIGNAL="/tmp/agentmail_signal_${PROJECT_HASH}"
 
 
-# Cooldown: skip if checked recently (within COOLDOWN_SECONDS)
-if [ -f "$COOLDOWN_FILE" ]; then
-  last_check=$(stat -c %Y "$COOLDOWN_FILE" 2>/dev/null || stat -f %m "$COOLDOWN_FILE" 2>/dev/null || echo 0)
-  now=$(date +%s)
-  if [ $((now - last_check)) -lt $COOLDOWN_SECONDS ]; then
-    exit 0
-  fi
-fi
-touch "$COOLDOWN_FILE"
+# Fast path: no signal file = no mail. Stat check only, no SQLite.
+[ -f "$SIGNAL" ] || exit 0
 
 
-# Single fast query - count unread
-UNREAD=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${PROJECT_HASH}' AND read=0;" 2>/dev/null)
+# Signal exists - check DB to confirm
+[ -f "$MAIL_DB" ] || exit 0
 
 
-# Silent exit if no mail
-[ "${UNREAD:-0}" -eq 0 ] && exit 0
+UNREAD=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${PROJECT_HASH}' AND read=0;" 2>/dev/null)
 
 
-# Check for urgent messages
-URGENT=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${PROJECT_HASH}' AND read=0 AND priority='urgent';" 2>/dev/null)
+if [ "${UNREAD:-0}" -eq 0 ]; then
+  # Signal was stale, clean up
+  rm -f "$SIGNAL"
+  exit 0
+fi
 
 
-# Resolve display names for preview
+# Resolve display name for a hash
 show_from() {
 show_from() {
   local hash="$1"
   local hash="$1"
   local name
   local name
@@ -51,25 +43,43 @@ show_from() {
   [ -n "$name" ] && echo "$name" || echo "$hash"
   [ -n "$name" ] && echo "$name" || echo "$hash"
 }
 }
 
 
-# Show notification with preview of first 3 messages
+# Deliver each message with thread context
 echo ""
 echo ""
-if [ "${URGENT:-0}" -gt 0 ]; then
-  echo "=== URGENT MAIL: ${UNREAD} unread (${URGENT} urgent) ==="
-else
-  echo "=== MAIL: ${UNREAD} unread message(s) ==="
-fi
+echo "=== INCOMING MAIL (${UNREAD} message(s)) ==="
 
 
-# Preview messages with resolved names
-while IFS='|' read -r from_hash priority subject; do
+while read -r msg_id; do
+  [ -z "$msg_id" ] && continue
+  from_hash=$(sqlite3 "$MAIL_DB" "SELECT from_project FROM messages WHERE id=${msg_id};" 2>/dev/null)
+  priority=$(sqlite3 "$MAIL_DB" "SELECT priority FROM messages WHERE id=${msg_id};" 2>/dev/null)
+  subject=$(sqlite3 "$MAIL_DB" "SELECT subject FROM messages WHERE id=${msg_id};" 2>/dev/null)
+  body=$(sqlite3 "$MAIL_DB" "SELECT body FROM messages WHERE id=${msg_id};" 2>/dev/null)
+  timestamp=$(sqlite3 "$MAIL_DB" "SELECT timestamp FROM messages WHERE id=${msg_id};" 2>/dev/null)
+  thread_id=$(sqlite3 "$MAIL_DB" "SELECT thread_id FROM messages WHERE id=${msg_id};" 2>/dev/null)
   from_name=$(show_from "$from_hash")
   from_name=$(show_from "$from_hash")
-  prefix=""
-  [ "$priority" = "urgent" ] && prefix="[!] "
-  echo "  ${prefix}From: ${from_name}  |  ${subject}"
-done < <(sqlite3 -separator '|' "$MAIL_DB" \
-  "SELECT from_project, priority, subject FROM messages WHERE to_project='${PROJECT_HASH}' AND read=0 ORDER BY priority DESC, timestamp DESC LIMIT 3;" 2>/dev/null)
+  urgent=""
+  [ "$priority" = "urgent" ] && urgent=" [URGENT]"
 
 
-if [ "$UNREAD" -gt 3 ]; then
-  echo "  ... and $((UNREAD - 3)) more"
-fi
-echo "Use agentmail read to read messages."
-echo "==="
+  echo ""
+  echo "--- #${msg_id} from ${from_name} (${from_hash})${urgent} @ ${timestamp} ---"
+  echo "Subject: ${subject}"
+  echo "${body}"
+
+  # Show thread context if this is part of a conversation
+  if [ -n "$thread_id" ]; then
+    thread_root="$thread_id"
+    thread_count=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE id=${thread_root} OR thread_id=${thread_root};" 2>/dev/null)
+    if [ "${thread_count:-0}" -gt 1 ]; then
+      echo ""
+      echo "[Thread #${thread_root} - ${thread_count} messages. Run: agentmail thread ${thread_root}]"
+    fi
+  fi
+done < <(sqlite3 "$MAIL_DB" \
+  "SELECT id FROM messages WHERE to_project='${PROJECT_HASH}' AND read=0 ORDER BY priority DESC, timestamp ASC;" 2>/dev/null)
+
+echo ""
+echo "=== ACTION REQUIRED: Inform the user about these messages and ask if they want to reply. ==="
+echo "=== Then run: agentmail read (to mark as read) ==="
+echo "=== To reply: agentmail reply <id> \"message\" ==="
+
+# Clear signal (new sends will re-create it)
+rm -f "$SIGNAL"

+ 2 - 2
skills/agentmail/SKILL.md

@@ -76,7 +76,7 @@ Each project hash renders as a unique pixel-art identicon (11x11 symmetric grid
 
 
 ## Passive Notification (Hook)
 ## Passive Notification (Hook)
 
 
-A global PreToolUse hook checks for mail on every tool call (10-second cooldown). Silent when inbox is empty.
+A global PreToolUse hook checks for mail on every tool call (no cooldown). Silent when inbox is empty.
 
 
 ```
 ```
 === MAIL: 3 unread message(s) ===
 === MAIL: 3 unread message(s) ===
@@ -130,7 +130,7 @@ bash ~/.claude/agentmail/mail-db.sh status    # Check it works
 
 
 ### Step 2: Enable the Hook
 ### Step 2: Enable the Hook
 
 
-Add a `hooks` block to `~/.claude/settings.json`. This makes Claude check for mail automatically on every tool call (with a 10-second cooldown so it doesn't slow anything down):
+Add a `hooks` block to `~/.claude/settings.json`. This makes Claude check for mail automatically on every tool call (with a no cooldown so it doesn't slow anything down):
 
 
 ```json
 ```json
 {
 {

+ 119 - 31
skills/agentmail/scripts/mail-db.sh

@@ -81,12 +81,25 @@ SQL
   # Migration: create projects table if missing (for existing installs)
   # Migration: create projects table if missing (for existing installs)
   sqlite3 "$MAIL_DB" "SELECT hash FROM projects LIMIT 0;" 2>/dev/null || \
   sqlite3 "$MAIL_DB" "SELECT hash FROM projects LIMIT 0;" 2>/dev/null || \
     sqlite3 "$MAIL_DB" "CREATE TABLE IF NOT EXISTS projects (hash TEXT PRIMARY KEY, name TEXT NOT NULL, path TEXT NOT NULL, registered TEXT DEFAULT (datetime('now')));" 2>/dev/null
     sqlite3 "$MAIL_DB" "CREATE TABLE IF NOT EXISTS projects (hash TEXT PRIMARY KEY, name TEXT NOT NULL, path TEXT NOT NULL, registered TEXT DEFAULT (datetime('now')));" 2>/dev/null
+  # Migration: add thread_id column if missing
+  sqlite3 "$MAIL_DB" "SELECT thread_id FROM messages LIMIT 0;" 2>/dev/null || \
+    sqlite3 "$MAIL_DB" "ALTER TABLE messages ADD COLUMN thread_id INTEGER REFERENCES messages(id);" 2>/dev/null
 }
 }
 
 
 sql_escape() {
 sql_escape() {
   printf '%s' "$1" | sed "s/'/''/g"
   printf '%s' "$1" | sed "s/'/''/g"
 }
 }
 
 
+# Read body from argument or stdin (use - or omit for stdin)
+read_body() {
+  local arg="$1"
+  if [ "$arg" = "-" ] || [ -z "$arg" ]; then
+    cat
+  else
+    printf '%s' "$arg"
+  fi
+}
+
 # Register current project in the projects table (idempotent)
 # Register current project in the projects table (idempotent)
 register_project() {
 register_project() {
   local hash name path
   local hash name path
@@ -200,20 +213,29 @@ read_mail() {
   register_project
   register_project
   local pid
   local pid
   pid=$(get_project_id)
   pid=$(get_project_id)
-  local rows
-  rows=$(sqlite3 -separator '|' "$MAIL_DB" \
-    "SELECT id, from_project, subject, body, timestamp FROM messages WHERE to_project='${pid}' AND read=0 ORDER BY timestamp ASC;")
-  if [ -z "$rows" ]; then
-    return 0
-  fi
+  # Use ASCII record separator (0x1E) to avoid splitting on pipes/newlines in body
+  local RS=$'\x1e'
+  local count
+  count=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${pid}' AND read=0;")
+  [ "${count:-0}" -eq 0 ] && return 0
+  # Query each message individually to preserve multi-line bodies
+  local ids
+  ids=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages WHERE to_project='${pid}' AND read=0 ORDER BY timestamp ASC;")
   echo "id | from_project | subject | body | timestamp"
   echo "id | from_project | subject | body | timestamp"
-  while IFS='|' read -r id from_hash subj body ts; do
-    local from_name
+  while read -r msg_id; do
+    [ -z "$msg_id" ] && continue
+    local from_hash subj body ts from_name
+    from_hash=$(sqlite3 "$MAIL_DB" "SELECT from_project FROM messages WHERE id=${msg_id};")
+    subj=$(sqlite3 "$MAIL_DB" "SELECT subject FROM messages WHERE id=${msg_id};")
+    body=$(sqlite3 "$MAIL_DB" "SELECT body FROM messages WHERE id=${msg_id};")
+    ts=$(sqlite3 "$MAIL_DB" "SELECT timestamp FROM messages WHERE id=${msg_id};")
     from_name=$(display_name "$from_hash")
     from_name=$(display_name "$from_hash")
-    echo "${id} | ${from_name} (${from_hash}) | ${subj} | ${body} | ${ts}"
-  done <<< "$rows"
+    echo "${msg_id} | ${from_name} (${from_hash}) | ${subj} | ${body} | ${ts}"
+  done <<< "$ids"
   sqlite3 "$MAIL_DB" \
   sqlite3 "$MAIL_DB" \
     "UPDATE messages SET read=1 WHERE to_project='${pid}' AND read=0;"
     "UPDATE messages SET read=1 WHERE to_project='${pid}' AND read=0;"
+  # Clear signal file
+  rm -f "/tmp/agentmail_signal_${pid}"
 }
 }
 
 
 read_one() {
 read_one() {
@@ -223,18 +245,19 @@ read_one() {
     return 1
     return 1
   fi
   fi
   init_db
   init_db
-  local row
-  row=$(sqlite3 -separator '|' "$MAIL_DB" \
-    "SELECT id, from_project, to_project, subject, body, timestamp FROM messages WHERE id=${msg_id};")
-  if [ -n "$row" ]; then
-    echo "id | from_project | to_project | subject | body | timestamp"
-    local id from_hash to_hash subj body ts
-    IFS='|' read -r id from_hash to_hash subj body ts <<< "$row"
-    local from_name to_name
-    from_name=$(display_name "$from_hash")
-    to_name=$(display_name "$to_hash")
-    echo "${id} | ${from_name} (${from_hash}) | ${to_name} (${to_hash}) | ${subj} | ${body} | ${ts}"
-  fi
+  local exists
+  exists=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE id=${msg_id};")
+  [ "${exists:-0}" -eq 0 ] && return 0
+  local from_hash to_hash subj body ts from_name to_name
+  from_hash=$(sqlite3 "$MAIL_DB" "SELECT from_project FROM messages WHERE id=${msg_id};")
+  to_hash=$(sqlite3 "$MAIL_DB" "SELECT to_project FROM messages WHERE id=${msg_id};")
+  subj=$(sqlite3 "$MAIL_DB" "SELECT subject FROM messages WHERE id=${msg_id};")
+  body=$(sqlite3 "$MAIL_DB" "SELECT body FROM messages WHERE id=${msg_id};")
+  ts=$(sqlite3 "$MAIL_DB" "SELECT timestamp FROM messages WHERE id=${msg_id};")
+  from_name=$(display_name "$from_hash")
+  to_name=$(display_name "$to_hash")
+  echo "id | from_project | to_project | subject | body | timestamp"
+  echo "${msg_id} | ${from_name} (${from_hash}) | ${to_name} (${to_hash}) | ${subj} | ${body} | ${ts}"
   sqlite3 "$MAIL_DB" \
   sqlite3 "$MAIL_DB" \
     "UPDATE messages SET read=1 WHERE id=${msg_id};"
     "UPDATE messages SET read=1 WHERE id=${msg_id};"
 }
 }
@@ -247,7 +270,8 @@ send() {
   fi
   fi
   local to_input="${1:?to_project required}"
   local to_input="${1:?to_project required}"
   local subject="${2:-no subject}"
   local subject="${2:-no subject}"
-  local body="${3:?body required}"
+  local body
+  body=$(read_body "${3:-}")
   if [ -z "$body" ]; then
   if [ -z "$body" ]; then
     echo "Error: message body cannot be empty" >&2
     echo "Error: message body cannot be empty" >&2
     return 1
     return 1
@@ -262,11 +286,31 @@ send() {
   safe_body=$(sql_escape "$body")
   safe_body=$(sql_escape "$body")
   sqlite3 "$MAIL_DB" \
   sqlite3 "$MAIL_DB" \
     "INSERT INTO messages (from_project, to_project, subject, body, priority) VALUES ('${from_id}', '${to_id}', '${safe_subject}', '${safe_body}', '${priority}');"
     "INSERT INTO messages (from_project, to_project, subject, body, priority) VALUES ('${from_id}', '${to_id}', '${safe_subject}', '${safe_body}', '${priority}');"
+  # Signal the recipient
+  touch "/tmp/agentmail_signal_${to_id}"
   local to_name
   local to_name
   to_name=$(display_name "$to_id")
   to_name=$(display_name "$to_id")
   echo "Sent to ${to_name} (${to_id}): ${subject}$([ "$priority" = "urgent" ] && echo " [URGENT]" || true)"
   echo "Sent to ${to_name} (${to_id}): ${subject}$([ "$priority" = "urgent" ] && echo " [URGENT]" || true)"
 }
 }
 
 
+sent() {
+  local limit="${1:-20}"
+  init_db
+  register_project
+  local pid
+  pid=$(get_project_id)
+  local rows
+  rows=$(sqlite3 -separator '|' "$MAIL_DB" \
+    "SELECT id, to_project, subject, timestamp FROM messages WHERE from_project='${pid}' ORDER BY timestamp DESC LIMIT ${limit};")
+  [ -z "$rows" ] && echo "No sent messages" && return 0
+  echo "id | to | subject | timestamp"
+  while IFS='|' read -r id to_hash subj ts; do
+    local to_name
+    to_name=$(display_name "$to_hash")
+    echo "${id} | ${to_name} (${to_hash}) | ${subj} | ${ts}"
+  done <<< "$rows"
+}
+
 search() {
 search() {
   local keyword="$1"
   local keyword="$1"
   if [ -z "$keyword" ]; then
   if [ -z "$keyword" ]; then
@@ -326,7 +370,8 @@ clear_old() {
 
 
 reply() {
 reply() {
   local msg_id="$1"
   local msg_id="$1"
-  local body="$2"
+  local body
+  body=$(read_body "${2:-}")
   if ! [[ "$msg_id" =~ ^[0-9]+$ ]]; then
   if ! [[ "$msg_id" =~ ^[0-9]+$ ]]; then
     echo "Error: message ID must be numeric" >&2
     echo "Error: message ID must be numeric" >&2
     return 1
     return 1
@@ -338,26 +383,65 @@ reply() {
   init_db
   init_db
   register_project
   register_project
   local orig
   local orig
-  orig=$(sqlite3 -separator '|' "$MAIL_DB" "SELECT from_project, subject FROM messages WHERE id=${msg_id};")
+  orig=$(sqlite3 -separator '|' "$MAIL_DB" "SELECT from_project, subject, thread_id FROM messages WHERE id=${msg_id};")
   if [ -z "$orig" ]; then
   if [ -z "$orig" ]; then
     echo "Error: message #${msg_id} not found" >&2
     echo "Error: message #${msg_id} not found" >&2
     return 1
     return 1
   fi
   fi
-  local orig_from_hash orig_subject
+  local orig_from_hash orig_subject orig_thread
   orig_from_hash=$(echo "$orig" | cut -d'|' -f1)
   orig_from_hash=$(echo "$orig" | cut -d'|' -f1)
   orig_subject=$(echo "$orig" | cut -d'|' -f2)
   orig_subject=$(echo "$orig" | cut -d'|' -f2)
+  orig_thread=$(echo "$orig" | cut -d'|' -f3)
+  # Thread ID: inherit from parent, or use parent's ID as thread root
+  local thread_id="${orig_thread:-$msg_id}"
   local from_id
   local from_id
   from_id=$(get_project_id)
   from_id=$(get_project_id)
   local safe_subject safe_body
   local safe_subject safe_body
   safe_subject=$(sql_escape "Re: ${orig_subject}")
   safe_subject=$(sql_escape "Re: ${orig_subject}")
   safe_body=$(sql_escape "$body")
   safe_body=$(sql_escape "$body")
   sqlite3 "$MAIL_DB" \
   sqlite3 "$MAIL_DB" \
-    "INSERT INTO messages (from_project, to_project, subject, body) VALUES ('${from_id}', '${orig_from_hash}', '${safe_subject}', '${safe_body}');"
+    "INSERT INTO messages (from_project, to_project, subject, body, thread_id) VALUES ('${from_id}', '${orig_from_hash}', '${safe_subject}', '${safe_body}', ${thread_id});"
+  # Signal the recipient
+  touch "/tmp/agentmail_signal_${orig_from_hash}"
   local orig_name
   local orig_name
   orig_name=$(display_name "$orig_from_hash")
   orig_name=$(display_name "$orig_from_hash")
   echo "Replied to ${orig_name} (${orig_from_hash}): Re: ${orig_subject}"
   echo "Replied to ${orig_name} (${orig_from_hash}): Re: ${orig_subject}"
 }
 }
 
 
+thread() {
+  local msg_id="$1"
+  if ! [[ "$msg_id" =~ ^[0-9]+$ ]]; then
+    echo "Error: message ID must be numeric" >&2
+    return 1
+  fi
+  init_db
+  # Find the thread root: either the message itself or its thread_id
+  local thread_root
+  thread_root=$(sqlite3 "$MAIL_DB" "SELECT COALESCE(thread_id, id) FROM messages WHERE id=${msg_id};" 2>/dev/null)
+  [ -z "$thread_root" ] && echo "Message not found" && return 1
+  # Get all message IDs in this thread (root + replies)
+  local ids
+  ids=$(sqlite3 "$MAIL_DB" \
+    "SELECT id FROM messages WHERE id=${thread_root} OR thread_id=${thread_root} ORDER BY timestamp ASC;")
+  [ -z "$ids" ] && echo "No thread found" && return 0
+  local msg_count=0
+  echo "=== Thread #${thread_root} ==="
+  while read -r tid; do
+    [ -z "$tid" ] && continue
+    local from_hash body ts from_name
+    from_hash=$(sqlite3 "$MAIL_DB" "SELECT from_project FROM messages WHERE id=${tid};")
+    body=$(sqlite3 "$MAIL_DB" "SELECT body FROM messages WHERE id=${tid};")
+    ts=$(sqlite3 "$MAIL_DB" "SELECT timestamp FROM messages WHERE id=${tid};")
+    from_name=$(display_name "$from_hash")
+    echo ""
+    echo "--- #${tid} ${from_name} @ ${ts} ---"
+    echo "${body}"
+    msg_count=$((msg_count + 1))
+  done <<< "$ids"
+  echo ""
+  echo "=== End of thread (${msg_count} messages) ==="
+}
+
 broadcast() {
 broadcast() {
   local subject="$1"
   local subject="$1"
   local body="$2"
   local body="$2"
@@ -380,6 +464,7 @@ broadcast() {
     [ -z "$target_hash" ] && continue
     [ -z "$target_hash" ] && continue
     sqlite3 "$MAIL_DB" \
     sqlite3 "$MAIL_DB" \
       "INSERT INTO messages (from_project, to_project, subject, body) VALUES ('${from_id}', '${target_hash}', '${safe_subject}', '${safe_body}');"
       "INSERT INTO messages (from_project, to_project, subject, body) VALUES ('${from_id}', '${target_hash}', '${safe_subject}', '${safe_body}');"
+    touch "/tmp/agentmail_signal_${target_hash}"
     count=$((count + 1))
     count=$((count + 1))
   done <<< "$targets"
   done <<< "$targets"
   echo "Broadcast to ${count} project(s): ${subject}"
   echo "Broadcast to ${count} project(s): ${subject}"
@@ -532,7 +617,9 @@ case "${1:-help}" in
   unread)     list_unread ;;
   unread)     list_unread ;;
   read)       if [ -n "${2:-}" ]; then read_one "$2"; else read_mail; fi ;;
   read)       if [ -n "${2:-}" ]; then read_one "$2"; else read_mail; fi ;;
   send)       shift; send "$@" ;;
   send)       shift; send "$@" ;;
-  reply)      reply "${2:?message_id required}" "${3:?body required}" ;;
+  reply)      reply "${2:?message_id required}" "${3:-}" ;;
+  sent)       sent "${2:-20}" ;;
+  thread)     thread "${2:?message_id required}" ;;
   list)       list_all "${2:-20}" ;;
   list)       list_all "${2:-20}" ;;
   clear)      clear_old "${2:-7}" ;;
   clear)      clear_old "${2:-7}" ;;
   broadcast)  broadcast "${2:-no subject}" "${3:?body required}" ;;
   broadcast)  broadcast "${2:-no subject}" "${3:?body required}" ;;
@@ -552,9 +639,10 @@ case "${1:-help}" in
     echo "  count                   Count unread messages"
     echo "  count                   Count unread messages"
     echo "  unread                  List unread messages (brief)"
     echo "  unread                  List unread messages (brief)"
     echo "  read [id]               Read messages and mark as read"
     echo "  read [id]               Read messages and mark as read"
-    echo "  send [--urgent] <to> <subj> <body>"
-    echo "                          Send a message (to = name, hash, or path)"
-    echo "  reply <id> <body>       Reply to a message"
+    echo "  send [--urgent] <to> <subj> <body|->  Send (- or pipe for stdin body)"
+    echo "  reply <id> <body|->     Reply (- or pipe for stdin body)"
+    echo "  sent [limit]            Show sent messages (outbox)"
+    echo "  thread <id>             View full conversation thread"
     echo "  list [limit]            List recent messages (default 20)"
     echo "  list [limit]            List recent messages (default 20)"
     echo "  clear [days]            Clear read messages older than N days"
     echo "  clear [days]            Clear read messages older than N days"
     echo "  broadcast <subj> <body> Send to all known projects"
     echo "  broadcast <subj> <body> Send to all known projects"

+ 25 - 36
skills/agentmail/scripts/test-mail.sh

@@ -85,21 +85,8 @@ assert_exit_code() {
   fi
   fi
 }
 }
 
 
-# Helper: clear hook cooldown so next hook call fires
-# Uses git root commit hash (matches check-mail.sh identity logic)
-clear_cooldown() {
-  local root_commit
-  root_commit=$(git rev-list --max-parents=0 HEAD 2>/dev/null | head -1)
-  if [ -n "$root_commit" ]; then
-    rm -f "/tmp/agentmail_${root_commit:0:6}" 2>/dev/null
-  else
-    local canonical
-    canonical=$(cd "$PWD" && pwd -P)
-    local hash
-    hash=$(printf '%s' "$canonical" | shasum -a 256 | cut -c1-6)
-    rm -f "/tmp/agentmail_${hash}" 2>/dev/null
-  fi
-}
+# No-op: cooldown was removed, but tests still call this
+clear_cooldown() { :; }
 
 
 # --- Setup: clean slate ---
 # --- Setup: clean slate ---
 rm -f "$MAIL_DB"
 rm -f "$MAIL_DB"
@@ -242,12 +229,21 @@ clear_cooldown
 result=$(bash "$HOOK_SCRIPT" 2>&1)
 result=$(bash "$HOOK_SCRIPT" 2>&1)
 assert_empty "hook silent when no mail" "$result"
 assert_empty "hook silent when no mail" "$result"
 
 
-# T22: Hook shows notification
+# T22: Hook delivers message inline (does NOT auto-read)
 bash "$MAIL_SCRIPT" send "claude-mods" "Hook test" "Should trigger hook" >/dev/null 2>&1
 bash "$MAIL_SCRIPT" send "claude-mods" "Hook test" "Should trigger hook" >/dev/null 2>&1
 clear_cooldown
 clear_cooldown
 result=$(bash "$HOOK_SCRIPT" 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"
+assert_contains "hook shows INCOMING MAIL" "INCOMING MAIL" "$result"
+assert_contains "hook shows subject" "Hook test" "$result"
+assert_contains "hook shows body" "Should trigger hook" "$result"
+# Signal cleared after first delivery, so second call is silent
+result2=$(bash "$HOOK_SCRIPT" 2>&1)
+assert_empty "hook silent after signal cleared" "$result2"
+# But messages are still unread (hook does NOT auto-read)
+unread_count=$(bash "$MAIL_SCRIPT" count 2>&1)
+assert_contains "messages persist unread after hook" "1" "$unread_count"
+# Manually mark read for cleanup
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
 
 
 # T23: Hook with missing database
 # T23: Hook with missing database
 clear_cooldown
 clear_cooldown
@@ -374,12 +370,11 @@ echo "=== Priority & Search ==="
 result=$(bash "$MAIL_SCRIPT" send --urgent "claude-mods" "Server down" "Production is on fire" 2>&1)
 result=$(bash "$MAIL_SCRIPT" send --urgent "claude-mods" "Server down" "Production is on fire" 2>&1)
 assert_contains "urgent send succeeds" "URGENT" "$result"
 assert_contains "urgent send succeeds" "URGENT" "$result"
 
 
-# T39: Hook highlights urgent
+# T39: Hook delivers urgent message with marker
 clear_cooldown
 clear_cooldown
 result=$(bash "$HOOK_SCRIPT" 2>&1)
 result=$(bash "$HOOK_SCRIPT" 2>&1)
 assert_contains "hook shows URGENT" "URGENT" "$result"
 assert_contains "hook shows URGENT" "URGENT" "$result"
-assert_contains "hook shows [!] prefix" "[!]" "$result"
-bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+assert_contains "hook shows urgent body" "Production is on fire" "$result"
 
 
 # T40: Normal send still works after priority feature
 # T40: Normal send still works after priority feature
 result=$(bash "$MAIL_SCRIPT" send "claude-mods" "Normal msg" "not urgent" 2>&1)
 result=$(bash "$MAIL_SCRIPT" send "claude-mods" "Normal msg" "not urgent" 2>&1)
@@ -482,22 +477,17 @@ assert_exit_code "alias with missing arg fails" "1" "$exit_code"
 bash "$MAIL_SCRIPT" read >/dev/null 2>&1
 bash "$MAIL_SCRIPT" read >/dev/null 2>&1
 
 
 echo ""
 echo ""
-echo "=== Performance ==="
+echo "=== Hook ==="
 
 
-# T52: Hook cooldown - second call within cooldown is silent
-bash "$MAIL_SCRIPT" send "claude-mods" "cooldown test" "testing cooldown" >/dev/null 2>&1
-# Clear cooldown file for this project
-clear_cooldown
-# First call should show mail
+# T52: Hook delivers without auto-read
+bash "$MAIL_SCRIPT" send "claude-mods" "hook test" "testing hook" >/dev/null 2>&1
 result1=$(bash "$HOOK_SCRIPT" 2>&1)
 result1=$(bash "$HOOK_SCRIPT" 2>&1)
-assert_contains "hook fires on first call" "MAIL" "$result1"
+assert_contains "hook delivers message" "INCOMING MAIL" "$result1"
 
 
-# T53: Second call within cooldown is silent (cooldown file exists from first call)
+# T53: Signal cleared after delivery, second call silent
 result2=$(bash "$HOOK_SCRIPT" 2>&1)
 result2=$(bash "$HOOK_SCRIPT" 2>&1)
-assert_empty "hook silent during cooldown" "$result2"
-
-# Cleanup
-clear_cooldown
+assert_empty "hook silent after signal cleared (2)" "$result2"
+# Messages still unread - verify then clean up
 bash "$MAIL_SCRIPT" read >/dev/null 2>&1
 bash "$MAIL_SCRIPT" read >/dev/null 2>&1
 
 
 echo ""
 echo ""
@@ -533,12 +523,11 @@ touch .claude/agentmail.disable
 result=$(bash "$HOOK_SCRIPT" 2>&1)
 result=$(bash "$HOOK_SCRIPT" 2>&1)
 assert_empty "hook silent when disabled" "$result"
 assert_empty "hook silent when disabled" "$result"
 
 
-# T53: Hook works again after removing disable file
+# T53: Hook delivers after re-enable
 rm -f .claude/agentmail.disable
 rm -f .claude/agentmail.disable
 clear_cooldown
 clear_cooldown
 result=$(bash "$HOOK_SCRIPT" 2>&1)
 result=$(bash "$HOOK_SCRIPT" 2>&1)
-assert_contains "hook works after re-enable" "MAIL" "$result"
-bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+assert_contains "hook works after re-enable" "INCOMING MAIL" "$result"
 
 
 echo ""
 echo ""
 echo "=== Results ==="
 echo "=== Results ==="