SLOPSHOPPER

bg-watchdog

Watches background tasks and dev servers started in the session; past 20 minutes offers Stop / Snooze, /bg-tasks lists all / Следит за фоновыми задачами и…

newpanebandguardcommandtoast
★ 1v1.0.1MITupdated 2026-10-26ArtyaEXE/claude-mods/bg-watchdog
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bg-watchdog
│ ┃ Background processes ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing is hanging: this session has no │ ┃ background tasks or dev servers. ⏺ Read(src/auth.ts) │ ┃ Refresh ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /bg-tasks │ ⎿ bg-watchdog: Opened the background processes pane. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Background processes
Nothing is hanging: this session has no background tasks or dev servers. Refresh
README

bg-watchdog

Shows background tasks and dev servers left running for over 20 minutes, with Stop / Snooze / Hide.

English · Русский

English

Background shells, monitors and dev servers started during a session tend to outlive their use and slow the machine down. This mod tracks the session's background tasks and, once a minute, the processes of common dev runtimes (node, python, bun, deno, java, ruby, php, dotnet, go) listening on a port. Only processes started during this session are shown, so servers you started yourself earlier are never touched. Anything running past 20 minutes gets a bar above the prompt: Stop, Snooze 20 min or Hide.

/bg-tasks opens a pane with everything tracked.

What it touches

Once a minute runs a read-only PowerShell query (Get-NetTCPConnection, Get-Process, Get-CimInstance Win32_Process). Stops something only when you press Stop: a background task through TaskStop, a process through taskkill /PID <pid> /T /F. Adds the /bg-tasks command. The port scan works on Windows only.

Settings (/config)

  • language: auto (default; follows LANG, else the Windows UI language or regional format), ru or en.

Install

/plugin install bg-watchdog --marketplace ArtyaEXE/claude-mods

Part of claude-mods. MIT license.


Русский

Показывает фоновые задачи и dev-серверы, которые висят дольше 20 минут, с кнопками «Остановить / Отложить / Скрыть».

Фоновые shell-команды, мониторы и dev-серверы, запущенные за сессию, часто переживают свою пользу и тормозят компьютер. Мод отслеживает фоновые задачи сессии и раз в минуту — процессы популярных dev-рантаймов (node, python, bun, deno, java, ruby, php, dotnet, go), слушающие порт. Показываются только процессы, запущенные во время этой сессии: серверы, которые ты поднял раньше сам, мод не трогает. Всё, что висит дольше 20 минут, получает плашку над полем ввода: Остановить, Отложить 20 мин или Скрыть.

/bg-tasks открывает панель со всем, что отслеживается.

Что мод делает в системе

Раз в минуту выполняет PowerShell-запрос только на чтение (Get-NetTCPConnection, Get-Process, Get-CimInstance Win32_Process). Останавливает что-либо только по нажатию «Остановить»: фоновую задачу через TaskStop, процесс через taskkill /PID <pid> /T /F. Добавляет команду /bg-tasks. Сканирование портов работает только на Windows.

Настройки (/config)

  • language: auto (по умолчанию; по LANG, иначе по языку интерфейса или региональному формату Windows), ru или en.

Установка

/plugin install bg-watchdog --marketplace ArtyaEXE/claude-mods

Часть claude-mods. Лицензия MIT.

Source 2 files
hooks/register.tsx 280 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Watched } from '../types'
5
6type Lang = 'ru' | 'en'
7
8// `ru` / `en` as set; `auto`: the locale variables first, then Windows' UI language or regional format.
9async function resolveLang($: EngineInterface, setting: unknown): Promise<Lang> {
10  if (setting === 'ru' || setting === 'en') return setting
11  const env = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
12  if (env) return env.toLowerCase().startsWith('ru') ? 'ru' : 'en'
13  try {
14    const ran = await $.process.run(
15      ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', '(Get-UICulture).Name; (Get-Culture).Name'],
16      { timeoutMs: 10_000 },
17    )
18    if (ran.exitCode === 0 && /^ru\b/im.test(ran.stdout)) return 'ru'
19  } catch {
20    // Not Windows: English.
21  }
22
23  return 'en'
24}
25
26const STALE_MS = 20 * 60_000
27const SNOOZE_MS = 20 * 60_000
28const TICK_MS = 60_000
29const PANE = 'bg-watchdog'
30
31const items = atom({ plugin: 'bg-watchdog', key: 'items' } as const, [] as Watched[], { shape: 'items-1' })
32const snoozed = atom({ plugin: 'bg-watchdog', key: 'snoozed' } as const, {} as Record<string, number>)
33const now = atom({ plugin: 'bg-watchdog', key: 'now' } as const, 0)
34
35// Listening processes of the usual dev runtimes, with their start time and ports, as JSON.
36const SCAN = String.raw`
37$dev = '^(node|python|pythonw|py|bun|deno|java|ruby|php|dotnet|uvicorn|go)$'
38Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue |
39  Group-Object OwningProcess |
40  ForEach-Object {
41    $p = Get-Process -Id $_.Name -ErrorAction SilentlyContinue
42    if ($p -and $p.ProcessName -match $dev) {
43      $cmd = (Get-CimInstance Win32_Process -Filter "ProcessId=$($_.Name)" -ErrorAction SilentlyContinue).CommandLine
44      [pscustomobject]@{
45        pid   = [int]$_.Name
46        name  = $p.ProcessName
47        start = ([datetimeoffset]$p.StartTime).ToUnixTimeMilliseconds()
48        ports = @($_.Group | ForEach-Object { $_.LocalPort } | Sort-Object -Unique)
49        cmd   = "$cmd"
50      }
51    }
52  } | ConvertTo-Json -Compress -Depth 3
53`
54
55type Scanned = { pid: number; name: string; start: number; ports: number | number[]; cmd: string }
56
57const STRINGS = {
58  ru: {
59    minutes: (m: number) => (m < 60 ? `${m} мин` : `${Math.floor(m / 60)} ч ${m % 60} мин`),
60    port: 'порт',
61    taskStopFailed: (id: string) => `bg-watchdog: не смог остановить задачу ${id}`,
62    killFailed: (pid: number, why: string) => `bg-watchdog: taskkill ${pid} не прошёл: ${why}`,
63    stopped: (label: string) => `bg-watchdog: остановлено: ${label}`,
64    command: 'Фоновые задачи и dev-серверы этой сессии',
65    paneTitle: 'Фоновые процессы',
66    opened: 'Открыл панель фоновых процессов.',
67    hanging: (age: string) => `Висит ${age}:`,
68    stop: 'Остановить',
69    snooze: 'Отложить 20 мин',
70    hide: 'Скрыть',
71    more: (n: number) => `…и ещё ${n}, всё в /bg-tasks`,
72    empty: 'Ничего не висит: фоновых задач и dev-серверов этой сессии нет.',
73    task: 'задача',
74    refresh: 'Обновить',
75  },
76  en: {
77    minutes: (m: number) => (m < 60 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`),
78    port: 'port',
79    taskStopFailed: (id: string) => `bg-watchdog: could not stop task ${id}`,
80    killFailed: (pid: number, why: string) => `bg-watchdog: taskkill ${pid} failed: ${why}`,
81    stopped: (label: string) => `bg-watchdog: stopped: ${label}`,
82    command: "This session's background tasks and dev servers",
83    paneTitle: 'Background processes',
84    opened: 'Opened the background processes pane.',
85    hanging: (age: string) => `Running for ${age}:`,
86    stop: 'Stop',
87    snooze: 'Snooze 20 min',
88    hide: 'Hide',
89    more: (n: number) => `…and ${n} more, all in /bg-tasks`,
90    empty: "Nothing is hanging: this session has no background tasks or dev servers.",
91    task: 'task',
92    refresh: 'Refresh',
93  },
94}
95
96let lang: Lang = 'en'
97
98const minutes = (ms: number) => STRINGS[lang].minutes(Math.floor(ms / 60_000))
99
100const short = (s: string, n = 70) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
101
102const describe = (w: Watched) => (w.ports?.length ? `${w.label} (${STRINGS[lang].port} ${w.ports.join(', ')})` : w.label)
103
104let sessionStartedAt = 0
105
106async function scanPorts($: EngineInterface) {
107  const ran = await $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', SCAN], {
108    timeoutMs: 20_000,
109  })
110  if (ran.exitCode !== 0) return
111  const text = ran.stdout.trim()
112  const parsed: Scanned[] = text === '' ? [] : [JSON.parse(text)].flat()
113  // Only what was started while this session ran: the person's own long-lived servers are not tails.
114  const fresh: Watched[] = parsed
115    .filter(p => p.start >= sessionStartedAt)
116    .map(p => ({
117      id: `pid:${p.pid}`,
118      kind: 'port',
119      label: short(p.cmd.trim() || p.name),
120      startedAt: p.start,
121      pid: p.pid,
122      ports: [p.ports].flat(),
123    }))
124  await update($, items, list => [...list.filter(w => w.kind === 'task'), ...fresh])
125}
126
127async function tick($: EngineInterface) {
128  await update($, now, () => Date.now())
129  try {
130    await scanPorts($)
131  } catch {
132    // A failed scan keeps the last list.
133  }
134}
135
136async function stop($: EngineInterface, w: Watched) {
137  if (w.kind === 'task' && w.taskId !== undefined) {
138    const ran = await $.tool.call({ tool: 'TaskStop', task_id: w.taskId })
139    if (ran.deny !== undefined || ran.isError === true) {
140      $.ui.toast(STRINGS[lang].taskStopFailed(w.taskId))
141
142      return
143    }
144  } else if (w.pid !== undefined) {
145    const ran = await $.process.run(['taskkill.exe', '/PID', String(w.pid), '/T', '/F'])
146    if (ran.exitCode !== 0) {
147      $.ui.toast(STRINGS[lang].killFailed(w.pid, short(ran.stderr.trim(), 120)))
148
149      return
150    }
151  }
152  await update($, items, list => list.filter(one => one.id !== w.id))
153  $.ui.toast(STRINGS[lang].stopped(short(w.label, 50)))
154}
155
156async function snooze($: EngineInterface, w: Watched, until: number) {
157  await update($, snoozed, map => ({ ...map, [w.id]: until }))
158}
159
160export const register: Register = (on, options) => {
161  on('session.start', async ($, e, next) => {
162    const started = await next(e)
163    sessionStartedAt = (await $.session.usage()).startedAt
164    lang = await resolveLang($, (options as { language?: unknown }).language)
165    await $.command.register({ name: 'bg-tasks', description: STRINGS[lang].command })
166    void tick($)
167    $.clock.every(TICK_MS, () => void tick($))
168
169    return started
170  })
171
172  on('command.run', { command: 'bg-tasks' }, async $ => {
173    void tick($)
174    await $.ui.open({ id: PANE, title: STRINGS[lang].paneTitle, focus: true, closeOnEscape: true })
175
176    return { text: STRINGS[lang].opened }
177  })
178
179  // A command or monitor moved to the background: remember when it started.
180  on('tool.call', async ($, e, next) => {
181    const ran = await next(e)
182    if (e.agentId !== undefined || ran.deny !== undefined || ran.isError === true) return ran
183    if (e.tool !== 'Bash' && e.tool !== 'PowerShell' && e.tool !== 'Monitor') return ran
184
185    const result = (ran.result ?? {}) as { backgroundTaskId?: string; taskId?: string }
186    const taskId = result.backgroundTaskId ?? (e.tool === 'Monitor' ? result.taskId : undefined)
187    if (taskId === undefined) return ran
188
189    const input = e as { command?: unknown; description?: unknown }
190    const label = short(String(input.description ?? input.command ?? e.tool))
191    const at = Date.now()
192    await update($, items, list => [
193      ...list.filter(w => w.id !== `task:${taskId}`),
194      { id: `task:${taskId}`, kind: 'task' as const, label, startedAt: at, taskId },
195    ])
196
197    return ran
198  })
199
200  // Each turn's end lists the work still in flight: what is not there has finished.
201  on('classic.Stop', async ($, e, next) => {
202    const live = e.background_tasks
203    if (live !== undefined) {
204      const ids = new Set(live.map(t => t.id))
205      const at = Date.now()
206      await update($, items, list => {
207        const kept = list.filter(w => w.kind !== 'task' || (w.taskId !== undefined && ids.has(w.taskId)))
208        const known = new Set(kept.map(w => w.taskId))
209        const added: Watched[] = live
210          .filter(t => !known.has(t.id) && (t.type === 'shell' || t.type === 'monitor'))
211          .map(t => ({
212            id: `task:${t.id}`,
213            kind: 'task',
214            label: short(t.description || t.command || t.type),
215            startedAt: at,
216            taskId: t.id,
217          }))
218
219        return [...kept, ...added]
220      })
221    }
222
223    return next(e)
224  })
225
226  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
227    const below = await next(e)
228    if (e.props.hasSurvey || e.props.view.agentId !== undefined) return below
229
230    const t = (await read($, now)) || Date.now()
231    const snoozes = await read($, snoozed)
232    const stale = (await read($, items)).filter(w => t - w.startedAt >= STALE_MS && (snoozes[w.id] ?? 0) <= t)
233    if (stale.length === 0) return below
234
235    const { Box, Button, Text } = $.ui.resolve(e)
236    const L = STRINGS[lang]
237    const shown = stale.slice(0, 3)
238
239    return (
240      <Box flexDirection="column">
241        {shown.map(w => (
242          <Box flexDirection="row" gap={1}>
243            <Text>
244              <Text color="yellow">{L.hanging(minutes(t - w.startedAt))}</Text> {short(describe(w), Math.max(20, e.props.bodyColumns - 60))}
245            </Text>
246            <Button key={`stop-${w.id}`} variant="primary" label={L.stop} onPress={() => stop($, w)} />
247            <Button key={`snooze-${w.id}`} label={L.snooze} onPress={() => snooze($, w, Date.now() + SNOOZE_MS)} />
248            <Button key={`hide-${w.id}`} label={L.hide} onPress={() => snooze($, w, Number.MAX_SAFE_INTEGER)} />
249          </Box>
250        ))}
251        {stale.length > shown.length && <Text dimColor>{L.more(stale.length - shown.length)}</Text>}
252        {below}
253      </Box>
254    )
255  })
256
257  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
258    const { Box, Button, Text } = $.ui.resolve(e)
259    const L = STRINGS[lang]
260    const t = (await read($, now)) || Date.now()
261    const list = await read($, items)
262
263    return (
264      <Box flexDirection="column">
265        {list.length === 0 && <Text dimColor>{L.empty}</Text>}
266        {list.map(w => (
267          <Box flexDirection="column">
268            <Text>
269              <Text color={t - w.startedAt >= STALE_MS ? 'yellow' : undefined}>{minutes(t - w.startedAt)}</Text>{' '}
270              <Text dimColor>{w.kind === 'task' ? L.task : `PID ${w.pid}`}</Text> {short(describe(w), Math.max(20, e.props.bodyColumns - 20))}
271            </Text>
272            <Button key={`pane-stop-${w.id}`} label={L.stop} onPress={() => stop($, w)} />
273          </Box>
274        ))}
275        <Button key="refresh" plain label={L.refresh} onPress={() => tick($)} />
276      </Box>
277    )
278  })
279}
280
types/index.d.ts 22 lines
1export type Watched = {
2  // `task:<taskId>` for a background task of this session, `pid:<pid>` for a listening process.
3  id: string
4  kind: 'task' | 'port'
5  label: string
6  startedAt: number
7  taskId?: string
8  pid?: number
9  ports?: number[]
10}
11
12declare module 'claude-code' {
13  interface PluginState {
14    'bg-watchdog': {
15      items: Shaped<Watched[]>
16      // id -> until when it is snoozed (ms since epoch).
17      snoozed: Record<string, number>
18      now: number
19    }
20  }
21}
22