bot-process-control
Bot Process Control
Manage the Gmail Commander bot daemon and scheduled digest via launchd.
Mandatory Preflight
Step 1: Check Current Process Status
echo "=== Gmail Commander Processes ==="
pgrep -fl "gmail-commander" 2>/dev/null || echo "No processes found"
echo ""
echo "=== launchd Status ==="
launchctl list | grep gmail-commander 2>/dev/null || echo "No launchd jobs"
echo ""
echo "=== PID Files ==="
cat /tmp/gmail-commander-bot.pid 2>/dev/null && echo " (bot)" || echo "No bot PID file"
cat /tmp/gmail-digest.pid 2>/dev/null && echo " (digest)" || echo "No digest PID file"
Two Services
| Service | Type | Trigger | PID File |
|---|---|---|---|
| Bot Daemon | KeepAlive | Always-on (grammY polling) | /tmp/gmail-commander-bot.pid |
| Digest | StartInterval | Every 6 hours (21600s) | /tmp/gmail-digest.pid |
launchd Plist Templates
Bot Daemon — com.terryli.gmail-commander-bot.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.terryli.gmail-commander-bot</string>
<key>ProgramArguments</key>
<array>
<string>{{HOME}}/own/amonic/bin/gmail-commander-bot</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>NetworkState</key>
<true/>
</dict>
<key>StandardOutPath</key>
<string>{{HOME}}/.local/state/launchd-logs/gmail-commander-bot/stdout.log</string>
<key>StandardErrorPath</key>
<string>{{HOME}}/.local/state/launchd-logs/gmail-commander-bot/stderr.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>{{HOME}}/.local/share/mise/shims:/usr/local/bin:/usr/bin:/bin</string>
</dict>
<key>ThrottleInterval</key>
<integer>10</integer>
</dict>
</plist>
Scheduled Digest — com.terryli.gmail-commander-digest.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.terryli.gmail-commander-digest</string>
<key>ProgramArguments</key>
<array>
<string>{{HOME}}/own/amonic/bin/gmail-commander-digest</string>
</array>
<key>StartInterval</key>
<integer>21600</integer>
<key>StandardOutPath</key>
<string>{{HOME}}/.local/state/launchd-logs/gmail-commander-digest/stdout.log</string>
<key>StandardErrorPath</key>
<string>{{HOME}}/.local/state/launchd-logs/gmail-commander-digest/stderr.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>{{HOME}}/.local/share/mise/shims:/usr/local/bin:/usr/bin:/bin</string>
</dict>
</dict>
</plist>
Quick Operations
Start Bot
launchctl load ~/Library/LaunchAgents/com.terryli.gmail-commander-bot.plist
Stop Bot
launchctl unload ~/Library/LaunchAgents/com.terryli.gmail-commander-bot.plist
Restart Bot
launchctl unload ~/Library/LaunchAgents/com.terryli.gmail-commander-bot.plist
launchctl load ~/Library/LaunchAgents/com.terryli.gmail-commander-bot.plist
Force Kill (Emergency)
pkill -f "gmail-commander.*bot.ts"
rm -f /tmp/gmail-commander-bot.pid
View Logs
# Recent bot output (centralized launchd logs)
tail -50 ~/.local/state/launchd-logs/gmail-commander-bot/stderr.log
# Recent digest output
tail -50 ~/.local/state/launchd-logs/gmail-commander-digest/stderr.log
# Audit log (NDJSON, app-managed)
cat $PROJECT_DIR/logs/audit/$(date +%Y-%m-%d).ndjson | jq .
# OAuth token refresher log
tail -20 ~/.local/state/launchd-logs/gmail-oauth-refresher/stderr.log
System Resources (Expected)
- Memory: ~20-30 MB RSS (Bun runtime + grammY)
- CPU: Negligible (idle polling, wakes on message)
- Network: Minimal (single long-poll connection to Telegram API)
- Disk: ~1 MB/day audit logs (14-day rotation)
Telegram Commands
| Command | Description |
|---|---|
| /inbox | Show recent inbox emails |
| /search | Search emails (Gmail query syntax) |
| /read | Read email by ID |
| /compose | Compose a new email |
| /reply | Reply to an email |
| /abort | Cancel current compose/reply action |
| /drafts | List draft emails |
| /digest | Run email digest now |
| /status | Bot status and stats |
| /help | Show all commands |
Note:
/abortcancels any in-progress compose or reply session. Works at any step in the flow.
OAuth Token Management
Two-Layer Token Architecture
Browser Auth (one-time, interactive)
→ Google issues: access_token (1h TTL) + refresh_token (7d TTL in Testing mode)
→ Saved to: ~/.claude/tools/gmail-tokens/<GMAIL_OP_UUID>.json
Silent Refresh (automatic, no browser)
→ Uses refresh_token to get new access_token
→ Fails with invalid_grant when refresh_token itself expires
Hourly Token Refresher (launchd)
A compiled Swift binary runs hourly to proactively refresh the access token:
| File | Path |
|---|---|
| Source | ~/.claude/automation/gmail-token-refresher/main.swift |
| Binary | ~/.claude/automation/gmail-token-refresher/gmail-oauth-token-hourly-refresher |
| Plist | ~/Library/LaunchAgents/com.terryli.gmail-oauth-token-hourly-refresher.plist |
| Log | $PROJECT_DIR/logs/token-refresher.log |
Why hourly: Access tokens expire every 1 hour. Refreshing hourly keeps the token perpetually valid. Frequent refresh also increases the chance Google issues a new refresh_token, resetting its 7-day clock.
Verify it's running:
launchctl list | grep gmail-oauth-token
tail -5 $PROJECT_DIR/logs/token-refresher.log
Credentials source: GMAIL_OP_UUID item in 1Password Claude Automation vault (fields: client_id, client_secret). Accessed via service account token — no biometric prompt required.
Diagnosing invalid_grant
invalid_grant means the refresh token itself expired (not just the access token):
# Symptom in audit log:
cat $PROJECT_DIR/logs/audit/$(date +%Y-%m-%d).ndjson | jq 'select(.event == "gmail.error")'
# → "Token expired, refreshing...\nError: invalid_grant\n"
# Check token file age:
ls -la ~/.claude/tools/gmail-tokens/<GMAIL_OP_UUID>.json
Fix:
# 1. Delete expired token
rm ~/.claude/tools/gmail-tokens/<GMAIL_OP_UUID>.json
# 2. Trigger browser re-auth (opens Google consent page)
source $PROJECT_DIR/.env.launchd
$PLUGIN_DIR/scripts/gmail-cli/gmail list -n 1
# 3. Restart bot
launchctl unload ~/Library/LaunchAgents/com.terryli.gmail-commander-bot.plist
launchctl load ~/Library/LaunchAgents/com.terryli.gmail-commander-bot.plist
Root cause: Google OAuth apps in Testing mode issue refresh tokens with 7-day TTL. Permanent fix: publish the Google Cloud OAuth app (Google Cloud Console → OAuth consent screen → Publish app).
Diagnosing Stale PID Lock
If the bot exits uncleanly, the PID file may block restart:
# Symptom: launchctl shows bot loaded but PID is dead
kill -0 $(cat /tmp/gmail-commander-bot.pid) 2>&1
# → "No such process"
# Fix: restart via launchctl (acquireLock handles stale PIDs automatically)
launchctl unload ~/Library/LaunchAgents/com.terryli.gmail-commander-bot.plist
launchctl load ~/Library/LaunchAgents/com.terryli.gmail-commander-bot.plist
Post-Change Checklist
- YAML frontmatter valid (no colons in description)
- Trigger keywords current
- Path patterns use $HOME not hardcoded paths
- launchd plist templates match actual launcher scripts
- OAuth token refresher launchd service loaded and running