When a turn that did real work ends, ping the user out of band (Telegram if a bot token + TELEGRAM_CHAT_ID are set, else email via CLAUDE_PING_EMAIL + a…

Five small Claude Code mods (function-hook plugins). They run in the terminal CLI (no GUI panes), and they are written to work the same on every machine: Linux or macOS, any username, any home directory. Nothing personal is baked into the source. Per-machine values (a Telegram chat id, an email address) come from the environment, so the same checkout is safe to push publicly and hand to other people.
| Mod | What it does | How it shows |
|---|---|---|
dot-guard | Blocks a git add that would stage your whole $HOME work-tree (dot add -A, dot add ., dot add *). One fat-finger away from committing your entire home directory. | Denies the tool call with a reason. |
emdash-watch | Flags em-dashes the model writes into public-facing prose (.md, .html, .css, .txt, .rst). Internal files (CLAUDE.md, anything under memory/ or .claude/) are exempt. | Non-blocking note appended to the write's result, visible to the model only. |
home-path-guard | Flags a hardcoded user home (/home/<user>, /Users/<user>) written into a file, nudging $HOME / ~. Machine-local files (*.local.*) are exempt. | Non-blocking note to the model. |
box-status | Status line showing which box you are on, which out-of-band channel it can reach you on (telegram / email / none), and whether passwordless sudo works. | One status-line entry under the prompt. |
idle-ping | When a turn that did real work ends, pings you out of band so you can step away. Telegram when a bot token and chat id are present, otherwise email. | Sends a message; logs to the debug sink when no channel is configured. |
Clone this repo to the same relative path on each box, for example:
git clone <your-remote> ~/apps/claude-mods
Then point Claude Code at the five folders. The cleanest cross-machine way is CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json (which uses ~, so the one line works on every machine):
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/apps/claude-mods/dot-guard:~/apps/claude-mods/emdash-watch:~/apps/claude-mods/box-status:~/apps/claude-mods/home-path-guard:~/apps/claude-mods/idle-ping"
}
}
(On Windows the list separator is ; instead of :.)
Or load them for a single session without touching settings:
claude --plugin-dir ~/apps/claude-mods/dot-guard --plugin-dir ~/apps/claude-mods/idle-ping
idle-ping reads its target from the environment, never from source:
TELEGRAM_BOT_TOKEN (and optionally TELEGRAM_CHAT_ID) from ~/.claude/channels/telegram/.env, and TELEGRAM_CHAT_ID from the process environment if not in that file. Keep that file out of version control (mode 600).CLAUDE_PING_EMAIL to the recipient. The mod sends through the first mailer it finds on PATH (msmtp, sendmail, mail, mailx).box-status reports the same channel resolution, so a glance at the status line tells you whether a ping would actually go out on this box.
dot-guard.wrappers (default dot): comma-separated command names that run git with --work-tree=$HOME. Set it to your own dotfiles alias if it is not dot.idle-ping.minTools (default 2): how many tool calls a turn needs before it counts as real work worth a ping. Raise it to ping less often.Each mod is a folder with .claude-plugin/plugin.json, hooks/hooks.json, and hooks/register.ts. Check and test one with:
claude plugin validate <mod>
claude plugin test <mod>hooks/register.ts 76 lines1import type { Register } from 'claude-code'
2
3async function shortHost($: any): Promise<string> {
4 const r = await $.process.run(['hostname', '-s'], { timeoutMs: 4000 }).catch(() => undefined)
5 return r?.stdout?.trim()?.split('.')[0] || (await $.env.get('HOSTNAME')) || 'host'
6}
7
8async function envVarInFile($: any, file: string, key: string): Promise<string | undefined> {
9 const txt = await $.fs.read(file).catch(() => '')
10 const m = new RegExp(`(?:^|\\n)\\s*(?:export\\s+)?${key}\\s*=\\s*["']?([^"'\\n]+)`).exec(txt)
11 return m ? m[1].trim() : undefined
12}
13
14async function firstOnPath($: any, names: string[]): Promise<string | undefined> {
15 for (const n of names) {
16 const r = await $.process.run(['command', '-v', n], { timeoutMs: 4000 }).catch(() => undefined)
17 if (r?.exitCode === 0 && r.stdout.trim()) return n
18 }
19 return undefined
20}
21
22async function sendPing($: any, host: string, body: string): Promise<void> {
23 const home = (await $.env.get('HOME')) ?? ''
24 const tgEnv = `${home}/.claude/channels/telegram/.env`
25
26 // 1) Telegram: token from the (gitignored) channel env file; chat id from env or that file.
27 const token = home ? await envVarInFile($, tgEnv, 'TELEGRAM_BOT_TOKEN') : undefined
28 const chatId = (await $.env.get('TELEGRAM_CHAT_ID')) || (home ? await envVarInFile($, tgEnv, 'TELEGRAM_CHAT_ID') : undefined)
29 if (token && chatId) {
30 await $.process.run(
31 ['curl', '-sS', '-m', '10', '-X', 'POST',
32 `https://api.telegram.org/bot${token}/sendMessage`,
33 '--data-urlencode', `chat_id=${chatId}`,
34 '--data-urlencode', `text=${body}`],
35 { timeoutMs: 12000 },
36 )
37 return
38 }
39
40 // 2) Email: recipient from env, sent through whatever mailer the box has.
41 const to = await $.env.get('CLAUDE_PING_EMAIL')
42 if (to) {
43 const subject = `Claude Code idle @ ${host}`
44 const mailer = await firstOnPath($, ['msmtp', 'sendmail', 'mail', 'mailx'])
45 if (mailer === 'mail' || mailer === 'mailx') {
46 await $.process.run([mailer, '-s', subject, to], { stdin: body, timeoutMs: 12000 })
47 return
48 }
49 if (mailer) {
50 await $.process.run([mailer, '-t'], { stdin: `To: ${to}\nSubject: ${subject}\n\n${body}\n`, timeoutMs: 12000 })
51 return
52 }
53 }
54
55 $.ui.log(`idle-ping: no channel on this box. Set TELEGRAM_CHAT_ID (+ the telegram .env), or CLAUDE_PING_EMAIL and a mailer. Would have sent: ${body}`, { to: 'debug' })
56}
57
58export const register: Register = (on, options) => {
59 const minTools = typeof options.minTools === 'number' ? options.minTools : 2
60 let tools = 0
61
62 on('turn.start', ($, e, next) => { tools = 0; return next(e) })
63 on('tool.call', ($, e: any, next) => { if (!e.agentId) tools++; return next(e) })
64
65 on('turn.complete', async ($, e, next) => {
66 const r = await next(e)
67 if (e.agentId || e.isAborted || tools < minTools) return r
68 const host = await shortHost($)
69 const secs = Math.round(e.durationMs / 1000)
70 const summary = (e.answer || '(no visible answer)').replace(/\s+/g, ' ').trim().slice(0, 180)
71 // ponytail: detached; the send shouldn't eat the turn's completion budget.
72 void sendPing($, host, `[claude-code @ ${host}] idle after ${secs}s. ${summary}`).catch(() => {})
73 return r
74 })
75}
76