home / skills / terrylica / cc-skills / bot-process-control

This skill manages the Gmail Commander bot daemon and digest schedules, enabling start, stop, restart, status, and log access.

npx playbooks add skill terrylica/cc-skills --skill bot-process-control

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
8.2 KB
---
name: bot-process-control
description: Gmail Commander daemon lifecycle - start, stop, restart, status, logs, launchd plist management. TRIGGERS - bot start, bot stop, bot restart, bot status, bot logs, launchd, daemon, process control, gmail-commander service.
allowed-tools: Read, Bash, Grep, Glob
---

# Bot Process Control

Manage the Gmail Commander bot daemon and scheduled digest via launchd.

## Mandatory Preflight

### Step 1: Check Current Process Status

```bash
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
<?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}}/own/amonic/logs/bot-stdout.log</string>
    <key>StandardErrorPath</key>
    <string>{{HOME}}/own/amonic/logs/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
<?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}}/own/amonic/logs/digest-stdout.log</string>
    <key>StandardErrorPath</key>
    <string>{{HOME}}/own/amonic/logs/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

```bash
launchctl load ~/Library/LaunchAgents/com.terryli.gmail-commander-bot.plist
```

### Stop Bot

```bash
launchctl unload ~/Library/LaunchAgents/com.terryli.gmail-commander-bot.plist
```

### Restart Bot

```bash
launchctl unload ~/Library/LaunchAgents/com.terryli.gmail-commander-bot.plist
launchctl load ~/Library/LaunchAgents/com.terryli.gmail-commander-bot.plist
```

### Force Kill (Emergency)

```bash
pkill -f "gmail-commander.*bot.ts"
rm -f /tmp/gmail-commander-bot.pid
```

### View Logs

```bash
# Recent bot output
tail -50 $PROJECT_DIR/logs/bot-stderr.log

# Recent digest output
tail -50 $PROJECT_DIR/logs/digest-stderr.log

# Audit log (NDJSON)
cat $PROJECT_DIR/logs/audit/$(date +%Y-%m-%d).ndjson | jq .

# OAuth token refresher log
tail -20 $PROJECT_DIR/logs/token-refresher.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**: `/abort` cancels 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**:

```bash
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):

```bash
# 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**:

```bash
# 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:

```bash
# 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

Overview

This skill manages the Gmail Commander daemon and its scheduled digest on macOS using launchd. It provides lifecycle commands to start, stop, restart, check status, view logs, and manage launchd plist files for both the always-on bot and the periodic digest. It focuses on safe process control, PID handling, and simple troubleshooting steps.

How this skill works

The skill inspects running processes, PID files, and launchd job listings to determine service state. It can load/unload the two launchd plists (bot daemon and scheduled digest), tail relevant logs, and perform emergency force-kill and PID cleanup when needed. It also includes plist templates and resource expectations to help validate deployments.

When to use it

  • Deploy or update the Gmail Commander bot or digest on a macOS host
  • Start, stop, or restart the daemon after code changes or configuration edits
  • Check process and launchd status during incident triage
  • Inspect recent stderr/stdout output and audit NDJSON logs for debugging
  • Perform emergency shutdown and PID cleanup when the process is unresponsive

Best practices

  • Use launchd plists with $HOME-based paths instead of hardcoded absolute paths to support multiple users
  • Verify PID files in /tmp before killing processes; remove stale PID files after confirming process termination
  • Keep separate plists for the bot (KeepAlive) and digest (StartInterval) to control lifecycle independently
  • Redirect stdout/stderr to dedicated log files and rotate logs (audit NDJSON) to avoid disk growth
  • Confirm environment PATH in plist EnvironmentVariables to match runtime dependencies (bun, node, etc.)

Example use cases

  • Install and enable com.terryli.gmail-commander-bot.plist to run the grammY-based bot as a persistent service
  • Deploy com.terryli.gmail-commander-digest.plist for a 6-hour scheduled digest run
  • Restart the bot after pulling code changes and validate by tailing bot-stderr.log
  • During an outage, run pgrep and check /tmp/*.pid to locate stuck processes, then pkill and remove stale PID files
  • Audit daily activity by parsing the NDJSON audit log for a specific date

FAQ

How do I check whether the bot or digest is running?

Run pgrep -fl gmail-commander, check launchctl list for gmail-commander jobs, and inspect /tmp/gmail-commander-bot.pid and /tmp/gmail-digest.pid for active PID files.

What if launchctl load fails after plist changes?

Unload the job, validate plist syntax and EnvironmentVariables, replace hardcoded paths with $HOME, then load again. Tail stderr logs for immediate errors.

When should I force kill the bot?

Only if the bot is unresponsive to unload/load and pgrep shows a hung process. Use pkill -f with the process pattern and remove the corresponding /tmp PID file after confirming termination.