git:20260924.2b5da98 to git:20260926.ec86c63
65 added, 50 removed. Audit B to A.
# Gmail Commander Plugin
- > Gmail + Telegram bot lifecycle — email triage, interactive commands, voice digest, 1Password OAuth.
+ > Gmail CLI + draft builder for interactive use, plus the shared Telegram bot code that an unattended deployment imports. 1Password OAuth.
**Hub**: [Root CLAUDE.md](../../CLAUDE.md) | **Sibling**: [calcom-commander CLAUDE.md](../calcom-commander/CLAUDE.md)
- **Skill**: [bot-process-control](./skills/bot-process-control/SKILL.md) — start/stop/restart/status/logs, launchd plist management, OAuth token diagnosis.
+ **Skill**: [bot-process-control](./skills/bot-process-control/SKILL.md) — where the bot and digest actually run, how to check them, and what never to start on a workstation.
- ## Architecture
+ ## Where things run
- Two independent processes sharing `scripts/lib/`:
+ Nothing in this plugin runs as a background job on a workstation any more. The laptop launchd jobs `com.terryli.gmail-commander-bot` and `com.terryli.gmail-commander-digest` were retired on 2026-09-24; do not recreate them, and do not add launchd templates back to this plugin.
- | Process | Entry | Trigger | Purpose |
- | ---------- | ------------------- | ------------------------ | ------------------------------ |
- | Bot Daemon | `scripts/bot.ts` | launchd KeepAlive | Interactive Telegram commands |
- | Digest | `scripts/digest.ts` | launchd StartInterval 6h | Scheduled email triage + voice |
+ | Component | Where it runs | Entry point in this plugin |
+ | ------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------- |
+ | Gmail CLI (`gmail`) | Interactively, wherever an agent or the operator calls it | `scripts/gmail-cli/` (compiled to `gmail`) |
+ | Draft builder | Interactively | `scripts/gmail-draft.ts` |
+ | Interactive bot | A private Restate tenant on an always-on Mac mini (`GmailBot`, `GmailBotChat`) | `scripts/bot.ts` → `buildBot()`, `scripts/lib/` |
+ | Scheduled digest | The same tenant (`GmailDigest`), with its own implementation | none — see below |
+ | OAuth token minting | The same tenant (`GmailAuth`), from a refresh token held in its secret store | none |
- Gmail CLI (absorbed from gmail-tools): `scripts/gmail-cli/` — separate `package.json` + build process.
+ The tenant is deployed from a **private repository**, and every operational task for it — deploy, restart, logs, secret rotation, OAuth re-consent — follows that repository's runbook. This public plugin deliberately carries no host names, deploy commands or credential locations for it.
+ ### The contract the deployment depends on
+
+ The deployment bundles this plugin's code at deploy time and ships the CLI binary, so the following are load-bearing. Changing them changes what runs in production, silently, on the next deploy:
+
+ - `scripts/bot.ts` exports `buildBot()`, which returns `{ bot, state, config }` with every command, callback and the compose/reply session flow registered, and starts no transport. The tenant owns its own durable `getUpdates` chain and feeds each update in through `bot.handleUpdate()`.
+ - Importing `scripts/bot.ts` must never take the PID lock or start polling. `main()` is guarded by `import.meta.main` for exactly that reason: Telegram permits one `getUpdates` consumer per token, and a second poller makes both unreliable (the tenant pages on the resulting `409 Conflict`).
+ - `scripts/lib/*` is imported through `bot.ts` (bot factory, commands, callbacks, state, audit, Gmail client, formatting, chunking, agent router, circuit breaker, triage). Keep every export behaviour-identical.
+ - `scripts/lib/gmail-client.ts` shells out to the `gmail` binary. `GMAIL_CLI_BIN` points it at the shipped copy; without it the default is the marketplace path, which exists only on a workstation.
+ - `scripts/gmail-cli/` is shipped as a directory. Build the binary from the committed lockfile (`bun install --frozen-lockfile && bun run build`) so every build resolves the same dependency tree.
+
+ The scheduled digest does **not** use this plugin's code. The former `scripts/digest.ts` entry point, its Kokoro voice briefing (`scripts/lib/tts-client.ts`) and the laptop-only Stop hook `hooks/bot-shutdown-notify.ts` were removed on 2026-09-26 once nothing imported them.
+
## Bot Commands (10 total)
+ SSoT: `BOT_COMMANDS` in [`scripts/lib/commands.ts`](./scripts/lib/commands.ts).
+
| 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 in-progress compose/reply at any step |
| `/drafts` | List draft emails |
- | `/digest` | Run email digest now |
+ | `/digest` | Triage the last N hours now (default 6) |
| `/status` | Bot status and stats |
| `/help` | Show all commands |
- ## OAuth Token Architecture
+ ## OAuth Token Architecture (interactive CLI)
- Two-layer token system:
+ Two-layer token system for the CLI on a workstation:
```
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
- Hourly Refresher (automatic, launchd, no browser)
- → Swift binary: gmail-oauth-token-hourly-refresher
- → Reads: refresh_token from token JSON + client credentials from local cache
- → Writes: new access_token (+ expiry_date) back to token JSON
+ Silent refresh (automatic, no browser)
+ → The CLI refreshes an expired access_token itself on the next call
+ → An optional operator-local hourly refresher can keep the cached token warm
```
### Credential Cache (TCC Anti-Pattern Fix)
- The hourly refresher uses a **two-file** strategy to avoid macOS TCC prompts:
+ The CLI uses a **two-file** strategy so repeated calls never prompt:
| File | Contents | Changes? |
| ----------------------------- | ---------------------------------------- | ------------------------------ |
- | `<uuid>.json` | access_token, refresh_token, expiry_date | Every hour |
+ | `<uuid>.json` | access_token, refresh_token, expiry_date | On every refresh |
| `<uuid>.app-credentials.json` | client_id, client_secret | Never (static OAuth app creds) |
- `client_id`/`client_secret` are fetched from 1Password **once** on first run and cached locally. All subsequent hourly runs read only local files → no `op` subprocess → no TCC prompt.
+ `client_id`/`client_secret` are fetched from 1Password **once** on first run and cached locally. Subsequent runs read only local files → no `op` subprocess → no TCC prompt.
To force a fresh 1Password lookup (e.g., after rotating OAuth app credentials):
```bash
rm ~/.claude/tools/gmail-tokens/<uuid>.app-credentials.json
```
### Diagnosing `invalid_grant`
- The refresh_token has a 7-day TTL in Google OAuth Testing mode. When it expires, the bot logs `invalid_grant`. Fix: delete the token file and re-auth via browser. See [bot-process-control SKILL.md](./skills/bot-process-control/SKILL.md#diagnosing-invalid_grant) for full diagnosis and fix steps.
+ The refresh_token has a 7-day TTL in Google OAuth Testing mode. When it expires the CLI reports `invalid_grant`. Fix for the CLI: move the token file aside and re-consent via browser — see [gmail-access SKILL.md](./skills/gmail-access/SKILL.md#diagnosing-invalid_grant). The deployment holds its own refresh token; renewing that one is a runbook task in the private repository.
## Environment Variables
- | Variable | Required | Description |
- | ----------------------------- | -------- | ----------------------------------------------------------------------------------------- |
- | `GMAIL_OP_UUID` | Yes | 1Password item UUID for OAuth credentials |
- | `TELEGRAM_BOT_TOKEN` | Yes | Telegram bot token |
- | `TELEGRAM_CHAT_ID` | Yes | Authorized chat ID |
- | `HAIKU_MODEL` | Yes | Claude model for triage (e.g. `claude-haiku-4-5-20251001`) |
- | `OP_SERVICE_ACCOUNT_TOKEN` | Yes | 1Password service account (biometric-free) |
- | `AUDIT_DIR` | No | Audit log directory (default: `~/own/amonic/logs/audit`, app-managed) |
- | `BOT_STATE_FILE` | No | Bot state file path |
- | `GMAIL_OP_VAULT` | No | 1Password vault (default: `Claude Automation`) |
- | `GMAIL_COMMANDER_PROJECT_DIR` | No | Project directory resolver; overrides auto-detection when running outside the plugin tree |
- | `KOKORO_HOST` | No | Kokoro TTS host for voice digest (default: `localhost`; set in ~/.config/gmail-commander/config or the env) |
- | `KOKORO_PORT` | No | Kokoro TTS port (default: `8090`) |
+ | Variable | Read by | Required | Description |
+ | -------------------------- | -------------------------------- | -------- | -------------------------------------------------------------------------- |
+ | `GMAIL_OP_UUID` | CLI, `lib/gmail-client.ts` | Yes | 1Password item UUID for OAuth credentials; also names the token cache file |
+ | `GMAIL_OP_VAULT` | CLI | No | 1Password vault (default: `Employee`) |
+ | `OP_SERVICE_ACCOUNT_TOKEN` | `op` (first run only) | No | 1Password service account for biometric-free `op read` |
+ | `TELEGRAM_BOT_TOKEN` | `lib/bot-factory.ts`, formatting | Bot | Telegram bot token |
+ | `TELEGRAM_CHAT_ID` | `lib/bot-factory.ts`, formatting | Bot | The one authorized chat ID |
+ | `HAIKU_MODEL` | `lib/agent-router.ts`, commands | Bot | Claude model for triage and free-text routing |
+ | `GMAIL_CLI_BIN` | `lib/gmail-client.ts` | No | Path to the `gmail` binary (default: the marketplace path) |
+ | `CLAUDE_BIN` | agent router, commands | No | Explicit `claude` executable for the Agent SDK |
+ | `AGENT_TIMEOUT_MS` | `lib/agent-router.ts` | No | Per-query timeout for free-text routing |
+ | `AUDIT_DIR` | `bot.ts`, `lib/audit.ts` | No | NDJSON audit log directory |
+ | `BOT_STATE_FILE` | `bot.ts`, `lib/state.ts` | No | Bot state file path |
+ In the deployment these are injected from its own secret store at deploy time. None of them belongs in a workstation env file on the bot's behalf.
+
## Canonical Gmail draft builder + guard (2026-07-23)
- **Every Gmail draft create/replace goes through `scripts/gmail-draft.ts`** — enforced by the
- PreToolUse(Bash) hook `hooks/gmail-draft-guard.sh`, which BLOCKS ad-hoc drafts-API writes
- (escape hatch: prefix the command with `GMAIL_DRAFT_ADHOC_OK=1`; read-only GET fetches pass).
+ **Every Gmail draft create/replace goes through `scripts/gmail-draft.ts`** — enforced by the PreToolUse(Bash) hook `hooks/gmail-draft-guard.sh`, which BLOCKS ad-hoc drafts-API writes (escape hatch: prefix the command with `GMAIL_DRAFT_ADHOC_OK=1`; read-only GET fetches pass).
- → **Four guards now stand between a composed message and a draft** (builder enforcement, mojibake
- detection, a builder test gate, and a post-write read-back). What each is _observed_ to do, how
- two of them were caught silently not working, and the both-directions checks to re-run after
- touching any of them: [`docs/draft-integrity-guards.md`](./docs/draft-integrity-guards.md).
+ → **Four guards now stand between a composed message and a draft** (builder enforcement, mojibake detection, a builder test gate, and a post-write read-back). What each is _observed_ to do, how two of them were caught silently not working, and the both-directions checks to re-run after touching any of them: [`docs/draft-integrity-guards.md`](./docs/draft-integrity-guards.md).
- **Why (regression 2026-07-23):** Gmail re-encodes ingested `text/plain` raw messages and
- hard-folds long lines at ~72 columns, so ad-hoc drafts (python + MIMEText — often built from
- markdown a formatter hook had already re-wrapped) show forced mid-paragraph line breaks in the
- compose window. The builder is structurally immune: it unwraps blank-line-separated paragraphs
- and produces `multipart/alternative` with a `text/html` part (source newlines never render — the
- draft reflows exactly like one composed in Gmail's own editor).
+ **Why (regression 2026-07-23):** Gmail re-encodes ingested `text/plain` raw messages and hard-folds long lines at ~72 columns, so ad-hoc drafts (python + MIMEText — often built from markdown a formatter hook had already re-wrapped) show forced mid-paragraph line breaks in the compose window. The builder is structurally immune: it unwraps blank-line-separated paragraphs and produces `multipart/alternative` with a `text/html` part (source newlines never render — the draft reflows exactly like one composed in Gmail's own editor).
```bash
bun $HOME/.claude/plugins/marketplaces/cc-skills/plugins/gmail-commander/scripts/gmail-draft.ts \
--account <tokenbase> # token base name in ~/.claude/tools/gmail-tokens/
--body <file.md> # body text; paragraphs unwrap, URLs auto-link
- --from 'Name <addr@gmail.com>' \
+ --from 'Name <addr@example.com>' \
[--reply-to <messageId>] # derives Subject/In-Reply-To/References/threadId
[--to a@b] [--cc c@d] [--subject '…'] [--replace <staleDraftId>]
# stdout: {"draftId":"…","threadId":"…","account":"…"}
```
- Gotcha the builder also absorbs: Gmail's `drafts.update` rejects with `400 Message not a draft`
- on threaded drafts — the tool always creates-then-deletes (`--replace`) instead of updating.
+ Gotcha the builder also absorbs: Gmail's `drafts.update` rejects with `400 Message not a draft` on threaded drafts — the tool always creates-then-deletes (`--replace`) instead of updating.
+ ## Hooks
+
+ | Hook | Event | Purpose |
+ | ---------------------------- | ----------------- | ------------------------------------------------------------------- |
+ | `gmail-draft-guard.sh` | PreToolUse (Bash) | Blocks ad-hoc drafts-API writes; drafts go through `gmail-draft.ts` |
+ | `gmail-mojibake-detector.sh` | PreToolUse (Bash) | Blocks drafts whose Subject or body carries UTF-8-as-Latin-1 bytes |
+
## Conventions
- **Hooks**: Use `$HOME`-based paths, never `$CLAUDE_PLUGIN_ROOT`
- **Skills**: Follow Suite Pattern (Template F) with mandatory preflight
- **CLI paths**: `$HOME/.claude/plugins/marketplaces/cc-skills/plugins/gmail-commander/scripts/gmail-cli/gmail`
+ - **CLI build**: from the committed `scripts/gmail-cli/bun.lock` with `bun install --frozen-lockfile && bun run build`; the compiled binary itself is gitignored
- **Sender alignment**: Auto-detect for replies, AskUserQuestion for new emails
- - **Secrets**: daemons read `~/own/amonic/.env.launchd` (gitignored, hand-maintained), sourced by the launcher scripts in `~/own/amonic/bin/`; interactive CLI use takes `GMAIL_OP_UUID` from the calling shell. OAuth client credentials come from 1Password (`op read` via `OP_SERVICE_ACCOUNT_TOKEN`)
+ - **Secrets**: interactive CLI use takes `GMAIL_OP_UUID` from the calling shell or the command line; OAuth client credentials come from 1Password (`op read`, optionally via `OP_SERVICE_ACCOUNT_TOKEN`). The deployment's secrets live in its own secret store and are documented only in its private repository
## Skills
- [bot-process-control](./skills/bot-process-control/SKILL.md)
- [email-triage](./skills/email-triage/SKILL.md)
- [gmail-access](./skills/gmail-access/SKILL.md)
- [health](./skills/health/SKILL.md)
- [interactive-bot](./skills/interactive-bot/SKILL.md)
- [setup](./skills/setup/SKILL.md)