SLOPSHOPPER

idle-ping

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…

newguardprocess
v0.1.0no licenseupdated 2026-10-04theagitist/claude-mods/idle-ping
A shopper browsing a rack in a slop shop
README

claude-mods

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.

The mods

ModWhat it doesHow it shows
dot-guardBlocks 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-watchFlags 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-guardFlags 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-statusStatus 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-pingWhen 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.

Install (per machine)

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

Per-machine configuration (idle-ping)

idle-ping reads its target from the environment, never from source:

  • Telegram (preferred when set): it reads 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).
  • Email (fallback): set CLAUDE_PING_EMAIL to the recipient. The mod sends through the first mailer it finds on PATH (msmtp, sendmail, mail, mailx).
  • If neither is configured, it writes what it would have sent to the debug log and does nothing else.

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.

Settings knobs

  • 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.

Developing

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>
Source 1 files
hooks/register.ts 76 lines
1import 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