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

Shows background tasks and dev servers left running for over 20 minutes, with Stop / Snooze / Hide.
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.
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.
/config)language: auto (default; follows LANG, else the Windows UI language or regional format), ru or en./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.
hooks/register.tsx 280 lines1import { 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}
280types/index.d.ts 22 lines1export 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