End the Node processes a finished command left running, such as test runners and esbuild; /node-close finds orphaned ones

<strong>Finished commands leave no Node behind.</strong><br> A Claude Code mod that ends the Node processes a finished command left running, such as test runners, their workers and esbuild, and finds orphaned Node however it was left with <code>/node-close</code>.
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/Version-1.3.0-green.svg" alt="Version 1.3.0"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT License"></a> <a href="https://code.claude.com/docs"><img src="https://img.shields.io/badge/Platform-Claude%20Code-D97757.svg" alt="Platform: Claude Code"></a> <a href="hooks/register.ts"><img src="https://img.shields.io/badge/Type-Function%20hooks%20mod-6E7681.svg" alt="Function hooks mod"></a> <a href="hooks/register.test.ts"><img src="https://img.shields.io/badge/Tests-35%20passing-3FB950.svg" alt="35 tests passing"></a>
<a href="#features">Features</a> · <a href="#install">Install</a> · <a href="#usage">Usage</a> · <a href="#development">Development</a> · <a href="CONTRIBUTING.md">Contributing</a> · <a href="CHANGELOG.md">Changelog</a>
Illustration of a session before and after.
When Claude runs tests or builds, the command can finish while Node keeps running: a test runner that never exits, a worker pool, an esbuild service. Claude Code moves on to the next step, and the leftovers pile up and hold memory. node-close checks right after each finished shell command and ends the Node processes that command started and left behind. It follows background commands too, and ends what they leave once they stop or the session ends.
Some leftovers outlive any hook: Claude Code killed outright, an IDE task, another tool. For those, /node-close lists every Node whose parent is gone, with its CPU use and run time, and /node-close kill ends the ones you choose.
[!NOTE] node-close is built on Claude Code's function-hooks mod API, which is in early access and may change between releases.
SIGTERM, then SIGKILL after one second if they stay (taskkill /T on Windows).node_modules such as esbuild, swc or turbo, go with it, even when the Node that started them has already exited.CLAUDE_PID, which Claude Code sets in every shell it starts, so a Node another session or you start in the meantime is left alone. On Windows the mod reads it from each candidate's memory with a small helper PowerShell compiles (64-bit processes only).&, nohup, disown, setsid, pm2, forever, --daemon, --detach or Start-Process keep their processes; Claude is told the PIDs instead./node-close lists every Node whose parent is gone, with CPU use and run time, whoever started it; /node-close kill ends the ones you name.| Situation | What node-close does |
|---|---|
npx vitest run finishes, its runner and workers keep running | Ends them, tells Claude which PIDs |
A process ignores SIGTERM | Kills it after one second; reports any that still remain |
nohup node server.js & | Keeps it, tells Claude how to end it later |
npm run dev with run_in_background, or moved to the background | Leaves it running; ends what it leaves once it stops, or at session end |
An esbuild spinning after its tsx watch and session are gone | /node-close lists it with its CPU use; /node-close kill <PID> ends it |
git status, ls and other commands that cannot start Node | Not checked |
In a Claude Code terminal session, run:
/plugin install node-close --marketplace AnnihilationWizard/node-close
y to add the github:AnnihilationWizard/node-close marketplace.Installed node-close. Plugin is now active. The mod works in the current session without a restart.To update later, run claude plugin update, then /reload-plugins.
git clone https://github.com/AnnihilationWizard/node-close.git
claude --plugin-dir ./node-close
To load a clone in every session, add it to the env block of ~/.claude/settings.json:
{
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/node-close" }
}
Once installed, there is nothing to configure. Ask Claude to run your tests; when the command finishes, any Node it left running is closed.
| Command | Effect |
|---|---|
/node-close | List every orphaned Node and Node-project tool on the machine, with what runs beneath each indented under it, and their PID, CPU use, run time and command |
/node-close kill <PID ...> | End the listed processes you name, with what runs beneath them; any argument that is not a PID ends nothing and shows the usage |
/node-close kill all | End every listed process |
/node-close lists processes Claude did not start too, such as a server you run under launchd, so check the list before kill all. Only you can run kill: when Claude or another plugin runs the command, it is refused.
Add-Type) that opens each candidate with PROCESS_VM_READ and reads CLAUDE_PID from its environment block. Antivirus may flag this pattern. If PowerShell refuses the helper, the mod falls back to the older rule, where any Node that appeared while the command ran and lost its parent counts, another session's included, and tells Claude so once. A 32-bit process, or one it cannot open (run as administrator, or by another user), is kept and reported instead of ended.CLAUDE_PID is Claude Code's own, undocumented variable. If a Claude Code release stops setting it, leftovers are no longer ended: each one is kept and reported to Claude as carrying no CLAUDE_PID, so the change shows instead of passing silently. The same note appears for a Node started outside Claude Code, such as from your terminal, while a command runs.SIGKILL, a crash, the app force-quit), so its background commands can still be left behind. Use /node-close in the next session to find them./node-close shows run time but not CPU use, which Win32_Process does not give.node, npm, npx, pnpm, yarn, bun, a common test or build tool, a .js, .ts or .sh file, or make. A Node started by some other wrapper is not cleaned up.& inside quotes, such as in a URL, is read as background work, so that command's leftovers are reported instead of ended.ps on macOS and Linux and with PowerShell's Win32_Process on Windows; if neither works, nothing is checked./plugin install command is available in terminal sessions only. A user-scope install also loads in the desktop app's Code tab.claude plugin validate . # manifest, marketplace file and hooks module
claude plugin test . # hooks/*.test.ts against the engine
After Claude Code first loads the mod, it writes the API types to .claude-plugin/types/ (git-ignored). Type-check against them with TypeScript 5.4 or newer:
npx -p typescript@5 tsc -p .
Run claude --plugin-dir . to try changes. In an interactive session the folder is watched, and saving a file reloads the mod.
.claude-plugin/ plugin.json manifest and marketplace.json
hooks/ hooks.json, the hooks module and its tests
types/ state contract for tracked background processes
docs/images/ README illustration
Bug reports and ideas are welcome. Read CONTRIBUTING.md before opening an issue or pull request, and follow SECURITY.md for security reports.
MIT License. Copyright © 2026 AnnihilationWizard.
hooks/register.ts 761 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Hook, Register, Timer } from 'claude-code'
3
4import type { Tracked } from '../types'
5
6// A shell command that may start Node: a package manager, a runner, a test
7// tool, or a script. Other commands are not checked, so most commands cost
8// no process listing.
9const MAY_START_NODE =
10 /\b(node|nodejs|npm|npx|pnpm|pnpx|yarn|bun|bunx|corepack|tsx|ts-node|vitest|jest|mocha|ava|playwright|cypress|vite|next|nuxt|astro|turbo|nx|lerna|webpack|rollup|esbuild|tsc|eslint|prettier|make)\b|\.(c|m)?(js|ts)\b|\.sh\b/i
11
12// A command that leaves a process running on purpose: a trailing or inner `&`
13// (not `&&`, `&>`, `>&` or `|&`), or a tool that daemonizes. Its leftovers are
14// reported, not ended.
15const KEEPS_RUNNING =
16 /(^|[^&>|])&(?![&>])|\b(nohup|disown|setsid|pm2|forever|daemon(ize)?)\b|start-process|--daemon\b|--detach\b/i
17
18// `started`: when the process started, in seconds since the epoch, where the
19// listing gives it.
20export type Proc = { pid: number; ppid: number; command: string; started?: number }
21
22// `ps` elapsed time, [[dd-]hh:]mm:ss, in seconds.
23export const elapsedSeconds = (etime: string): number => {
24 const [days, clock] = etime.includes('-') ? etime.split('-') : ['0', etime]
25 const parts = (clock ?? '').split(':').map(Number).reverse()
26 return Number(days) * 86_400 + (parts[2] ?? 0) * 3600 + (parts[1] ?? 0) * 60 + (parts[0] ?? 0)
27}
28
29// A Windows FILETIME (100 ns since 1601) in seconds since the epoch.
30const fromFileTime = (filetime: string) => Math.floor(Number(filetime) / 10_000_000) - 11_644_473_600
31
32// Lines of a process listing, read at `now` (milliseconds since the epoch):
33// `pid ppid etime command` from `ps`, `pid ppid filetime command` from the
34// PowerShell listing below (lines end in CRLF; 0 when Windows gives no
35// CreationDate), or plain `pid ppid command`. `ps`'s elapsed time is used,
36// not its start time, whose format follows the locale.
37export const parsePs = (stdout: string, now = Date.now()): Proc[] =>
38 stdout.split(/\r?\n/).flatMap(line => {
39 const timed = /^\s*(\d+)\s+(\d+)\s+((?:\d+-)?(?:\d+:)?\d+:\d+)\s+(.*)$/.exec(line)
40 if (timed)
41 return [{ pid: Number(timed[1]), ppid: Number(timed[2]), command: timed[4] ?? '', started: Math.floor(now / 1000) - elapsedSeconds(timed[3]!) }]
42 const filetime = /^\s*(\d+)\s+(\d+)\s+(\d{1,19})(?:\s+(.*))?$/.exec(line)
43 if (filetime && (filetime[3] === '0' || filetime[3]!.length >= 17))
44 return [
45 {
46 pid: Number(filetime[1]),
47 ppid: Number(filetime[2]),
48 command: filetime[4] ?? '',
49 ...(filetime[3] === '0' ? {} : { started: fromFileTime(filetime[3]!) }),
50 },
51 ]
52 const m = /^\s*(\d+)\s+(\d+)\s*(.*)$/.exec(line)
53 return m ? [{ pid: Number(m[1]), ppid: Number(m[2]), command: m[3] ?? '' }] : []
54 })
55
56// The executable part of a command line: the text before the first option.
57const executable = (command: string) => command.split(/\s-/, 1)[0] ?? ''
58
59// Whether the executable is Node: the text before the first option ends a
60// path segment named `node`, so `zsh -c node x.js` (the shell) is not counted.
61export const isNode = (command: string) => /(^|[\\/"'])node(js)?(\.exe)?["']?(\s|$)/i.test(executable(command))
62
63// Whether the process is Node or a tool a Node project installed: an
64// executable under `node_modules`, such as esbuild's service, swc or turbo.
65export const isNodeFamily = (command: string) =>
66 isNode(command) || /[\\/]node_modules[\\/]/i.test(executable(command))
67
68const hasLostParent = (p: Proc, byPid: ReadonlyMap<number, Proc>) =>
69 p.ppid === 1 || p.ppid === 0 || !byPid.has(p.ppid)
70
71// The processes among `isOwn` that are left behind: in a tree of own processes
72// whose top has lost its parent (re-parented to launchd/init, or on Windows
73// pointing at a parent that no longer exists), or any tree when `isOrphanOnly`
74// is false, and with Node or a Node project's tool at or above them in that
75// tree, so a Node's esbuild or worker goes with it.
76const strays = (procs: readonly Proc[], isOwn: (p: Proc) => boolean, isOrphanOnly = true): Proc[] => {
77 const byPid = new Map(procs.map(p => [p.pid, p]))
78 return procs.filter(p => {
79 if (!isOwn(p)) return false
80 let hasNode = isNodeFamily(p.command)
81 let top = p
82 const seen = new Set([p.pid])
83 for (;;) {
84 const parent = byPid.get(top.ppid)
85 if (parent === undefined || !isOwn(parent) || seen.has(parent.pid)) break
86 seen.add(parent.pid)
87 top = parent
88 hasNode ||= isNodeFamily(top.command)
89 }
90 return hasNode && (!isOrphanOnly || hasLostParent(top, byPid))
91 })
92}
93
94// The processes a command left behind: new since `before`, orphaned, Node or
95// beneath Node. A new process whose parent still runs (a parallel command, a
96// background task) is not counted.
97export const leftovers = (before: ReadonlySet<number>, procs: readonly Proc[]): Proc[] =>
98 strays(procs, p => !before.has(p.pid))
99
100// A listing's own process: `ps` or PowerShell, with the arguments above.
101const isListing = (command: string) => /pid=,ppid=|Get-CimInstance Win32_Process \| ForEach-Object/.test(command)
102
103// The processes above a listing: Claude Code itself and what started it.
104const ownChain = (procs: readonly Proc[], byPid: ReadonlyMap<number, Proc>): Set<number> => {
105 const chain = new Set<number>()
106 for (const p of procs.filter(q => isListing(q.command)))
107 for (let up = byPid.get(p.ppid); up !== undefined && !chain.has(up.pid); up = byPid.get(up.ppid)) chain.add(up.pid)
108 return chain
109}
110
111// Whether the executable is Claude Code: `claude`, or `claude.exe` on Windows.
112const isClaude = (command: string) => /(^|[\\/"'])claude(\.exe)?["']?(\s|$)/i.test(executable(command))
113
114// The Claude Code running this listing: the nearest `claude` above it (on
115// Windows a `cmd.exe` can sit between), else its direct parent. Never a
116// Claude Code further up, which a nested `claude -p` runs beneath.
117export const ownClaude = (procs: readonly Proc[]): Set<number> => {
118 const byPid = new Map(procs.map(p => [p.pid, p]))
119 const own = new Set<number>()
120 for (const listing of procs.filter(p => isListing(p.command))) {
121 let found: number | undefined
122 for (let up = byPid.get(listing.ppid), seen = new Set<number>(); up !== undefined && !seen.has(up.pid); up = byPid.get(up.ppid)) {
123 seen.add(up.pid)
124 if (isClaude(up.command)) {
125 found = up.pid
126 break
127 }
128 }
129 own.add(found ?? listing.ppid)
130 }
131 return own
132}
133
134// `ps` with each process's environment, on macOS and Linux.
135export const psEnv = (pids: readonly number[]): string[] => ['ps', 'eww', '-o', 'pid=,command=', '-p', pids.join(',')]
136
137// Windows does not list another process's environment, so a small C# class,
138// compiled by PowerShell's Add-Type, reads CLAUDE_PID from each process's
139// environment block (the PEB's process parameters). 64-bit processes only:
140// a 32-bit (WOW64) one, or one it cannot open, is left out and so counts as
141// carrying none. One line, with no single quote, so it passes inside a
142// PowerShell single-quoted string as the listing's own command does.
143export const WIN_ENV_CLASS =
144 'using System; using System.Runtime.InteropServices; using System.Text; public static class NodeCloseEnv { ' +
145 '[DllImport("kernel32.dll")] static extern IntPtr OpenProcess(int access, bool inherit, int pid); ' +
146 '[DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h); ' +
147 '[DllImport("kernel32.dll")] static extern bool IsWow64Process(IntPtr h, out bool wow64); ' +
148 '[DllImport("kernel32.dll")] static extern bool ReadProcessMemory(IntPtr h, IntPtr address, byte[] buffer, IntPtr size, out IntPtr read); ' +
149 '[DllImport("ntdll.dll")] static extern int NtQueryInformationProcess(IntPtr h, int infoClass, byte[] info, int length, out int returned); ' +
150 'static byte[] Read(IntPtr h, long address, int size) { byte[] buffer = new byte[size]; IntPtr read; ' +
151 'return ReadProcessMemory(h, new IntPtr(address), buffer, new IntPtr(size), out read) && read.ToInt64() == size ? buffer : null; } ' +
152 'public static string Get(int pid, string name) { if (IntPtr.Size != 8) return null; ' +
153 'IntPtr h = OpenProcess(0x1000 | 0x0010, false, pid); if (h == IntPtr.Zero) return null; ' +
154 'try { bool wow64; if (!IsWow64Process(h, out wow64) || wow64) return null; ' +
155 'byte[] basic = new byte[48]; int returned; if (NtQueryInformationProcess(h, 0, basic, basic.Length, out returned) != 0) return null; ' +
156 'byte[] parameters = Read(h, BitConverter.ToInt64(basic, 8) + 0x20, 8); if (parameters == null) return null; ' +
157 'long block = BitConverter.ToInt64(parameters, 0); ' +
158 'byte[] environment = Read(h, block + 0x80, 8); byte[] size = Read(h, block + 0x3F0, 8); ' +
159 'if (environment == null || size == null) return null; ' +
160 'long length = BitConverter.ToInt64(size, 0); if (length <= 0 || length > 1048576) return null; ' +
161 'byte[] variables = Read(h, BitConverter.ToInt64(environment, 0), (int)length); if (variables == null) return null; ' +
162 'foreach (string entry in Encoding.Unicode.GetString(variables).Split((char)0)) ' +
163 'if (entry.StartsWith(name + "=", StringComparison.OrdinalIgnoreCase)) return entry.Substring(name.Length + 1); ' +
164 'return ""; } finally { CloseHandle(h); } } }'
165
166// The PowerShell that prints `pid value` for each process whose environment
167// it read (an empty value when CLAUDE_PID is absent).
168export const winEnv = (pids: readonly number[]): string[] => [
169 'powershell.exe',
170 '-NoProfile',
171 '-NonInteractive',
172 '-Command',
173 `$ErrorActionPreference = 'Stop'; Add-Type -TypeDefinition '${WIN_ENV_CLASS}'; ` +
174 `foreach ($p in @(${pids.join(',')})) { $v = [NodeCloseEnv]::Get($p, 'CLAUDE_PID'); if ($null -ne $v) { "$p $v" } }`,
175]
176
177// Lines of `pid value` from winEnv; a line with no value names no CLAUDE_PID.
178export const parseWinClaudePids = (stdout: string): Map<number, number> =>
179 new Map(
180 stdout.split(/\r?\n/).flatMap(line => {
181 const m = /^\s*(\d+)\s+(\d+)\s*$/.exec(line)
182 return m ? [[Number(m[1]), Number(m[2])] as const] : []
183 }),
184 )
185
186// Whether PowerShell refused to read environments the last time (Add-Type
187// blocked, say by antivirus): leftovers then fall back to the older rule.
188let isWindowsEnvRefused = false
189let hasToldRefusal = false
190
191// The CLAUDE_PID each process inherited: Claude Code sets it in every Bash
192// shell it starts, and it stays with the shell's descendants, orphaned too.
193export const parseClaudePids = (stdout: string): Map<number, number> =>
194 new Map(
195 stdout.split(/\r?\n/).flatMap(line => {
196 const m = /^\s*(\d+)\s.*\sCLAUDE_PID=(\d+)(\s|$)/.exec(line)
197 return m ? [[Number(m[1]), Number(m[2])] as const] : []
198 }),
199 )
200
201// Of `candidates`, the ones this Claude Code started: a process whose
202// CLAUDE_PID is the Claude Code that ran this listing (its direct parent,
203// not a Claude Code further up: a `claude -p` started from another session
204// runs beneath that session's), and what runs beneath one. Another
205// session's or agent's Node names its own Claude Code, and one started
206// outside Claude Code names none, so both are left alone. Where
207// environments cannot be read, or no listing is found, every candidate is
208// kept, as before.
209//
210// `unknown` holds the candidates neither marked as ours nor as another's:
211// no CLAUDE_PID at or above them. They are kept and reported, not ended, so
212// if Claude Code ever stops setting CLAUDE_PID the mod says so on every
213// leftover instead of going quiet.
214export const owners = (
215 procs: readonly Proc[],
216 candidates: readonly Proc[],
217 claudePids: ReadonlyMap<number, number> | undefined,
218): { ours: Proc[]; unknown: Proc[] } => {
219 const own = ownClaude(procs)
220 if (claudePids === undefined || own.size === 0) return { ours: [...candidates], unknown: [] }
221 const byPid = new Map(candidates.map(c => [c.pid, c]))
222 // The nearest CLAUDE_PID at or above `p` among the candidates.
223 const claudeOf = (p: Proc): number | undefined => {
224 for (let q: Proc | undefined = p, seen = new Set<number>(); q !== undefined && !seen.has(q.pid); q = byPid.get(q.ppid)) {
225 seen.add(q.pid)
226 const claude = claudePids.get(q.pid)
227 if (claude !== undefined) return claude
228 }
229 return undefined
230 }
231 const ours: Proc[] = []
232 const unknown: Proc[] = []
233 for (const p of candidates) {
234 const claude = claudeOf(p)
235 if (claude === undefined) unknown.push(p)
236 else if (own.has(claude)) ours.push(p)
237 }
238 return { ours, unknown }
239}
240
241export const mine = (
242 procs: readonly Proc[],
243 candidates: readonly Proc[],
244 claudePids: ReadonlyMap<number, number> | undefined,
245): Proc[] => owners(procs, candidates, claudePids).ours
246
247// Every orphaned Node on the machine, whoever started it: Node or a Node
248// project's tool whose parent is gone, and everything beneath one. Claude
249// Code itself is never one, even where its parent is launchd.
250export const orphans = (procs: readonly Proc[]): Proc[] => {
251 const byPid = new Map(procs.map(p => [p.pid, p]))
252 const own = ownChain(procs, byPid)
253 const found = new Map(
254 procs
255 .filter(p => p.pid > 1 && !own.has(p.pid) && isNodeFamily(p.command) && hasLostParent(p, byPid))
256 .map(p => [p.pid, p]),
257 )
258 let grew = true
259 while (grew) {
260 grew = false
261 for (const p of procs)
262 if (!found.has(p.pid) && !own.has(p.pid) && found.has(p.ppid)) {
263 found.set(p.pid, p)
264 grew = true
265 }
266 }
267 return [...found.values()]
268}
269
270// The listing's order: each orphan, then what runs beneath it, with how deep.
271export const tree = (procs: readonly Proc[]): { proc: Proc; depth: number }[] => {
272 const pids = new Set(procs.map(p => p.pid))
273 const out: { proc: Proc; depth: number }[] = []
274 const visit = (p: Proc, depth: number) => {
275 out.push({ proc: p, depth })
276 for (const child of procs) if (child.ppid === p.pid && child.pid !== p.pid) visit(child, depth + 1)
277 }
278 for (const root of procs.filter(p => !pids.has(p.ppid))) visit(root, 0)
279 return out
280}
281
282// How far apart two listings may place one start time: each derives it from
283// its own clock reading and whole seconds.
284export const START_SLACK_S = 2
285
286// Whether `a` and `b` started at the same moment, as far as both are known.
287const sameStart = (a: Tracked, b: Tracked) =>
288 a.started === undefined || b.started === undefined || Math.abs(a.started - b.started) <= START_SLACK_S
289
290// The same process: same PID and start, or, where a start is unknown, same
291// command line.
292const isSame = (a: Tracked, b: Tracked) =>
293 a.pid === b.pid && (a.started !== undefined && b.started !== undefined ? sameStart(a, b) : a.command === b.command)
294
295// A process as tracked: PID, command line and start.
296const toTracked = ({ pid, command, started }: Proc): Tracked => (started === undefined ? { pid, command } : { pid, command, started })
297
298// The tracked processes still running, with every process now beneath one.
299// Matched by PID and start, not command line: a shell that runs one command
300// often replaces itself with it (`zsh -c 'node x.js'` becomes `node x.js`
301// under the same PID and start), while a reused PID starts later.
302export const grow = (tracked: readonly Tracked[], procs: readonly Proc[]): Proc[] => {
303 const found = new Map(procs.filter(p => tracked.some(t => t.pid === p.pid && sameStart(t, p))).map(p => [p.pid, p]))
304 let grew = true
305 while (grew) {
306 grew = false
307 for (const p of procs)
308 if (!found.has(p.pid) && found.has(p.ppid)) {
309 found.set(p.pid, p)
310 grew = true
311 }
312 }
313 return [...found.values()]
314}
315
316// How processes are listed: `ps` on macOS and Linux; on Windows, where `ps`
317// is missing or is Git Bash's (no -x), PowerShell's Win32_Process.
318export const PS: readonly string[] = ['ps', '-axww', '-o', 'pid=,ppid=,etime=,command=']
319export const CIM: readonly string[] = [
320 'powershell.exe',
321 '-NoProfile',
322 '-NonInteractive',
323 '-Command',
324 'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId) $(if ($_.CreationDate) { $_.CreationDate.ToFileTimeUtc() } else { 0 }) $($_.CommandLine)" }',
325]
326// `ps` with CPU use and elapsed time, for the `/node-close` listing.
327export const PS_USAGE: readonly string[] = ['ps', '-axww', '-o', 'pid=,ppid=,pcpu=,etime=,command=']
328// On Windows, seconds since each process started, from its CreationDate
329// (absent for some system processes). Win32_Process has no CPU percentage.
330export const CIM_USAGE: readonly string[] = [
331 'powershell.exe',
332 '-NoProfile',
333 '-NonInteractive',
334 '-Command',
335 'Get-CimInstance Win32_Process | Where-Object CreationDate | ForEach-Object { "$($_.ProcessId) $([long]((Get-Date) - $_.CreationDate).TotalSeconds)" }',
336]
337
338// The listing that worked last, tried first next time.
339let lister: readonly string[] | undefined
340
341const tryList = async ($: EngineInterface, argv: readonly string[]): Promise<Proc[] | undefined> => {
342 try {
343 const { exitCode, stdout } = await $.process.run(argv)
344 const procs = exitCode === 0 ? parsePs(stdout) : []
345 return procs.length > 0 ? procs : undefined
346 } catch {
347 return undefined
348 }
349}
350
351// Every process running, or undefined when no listing works.
352const list = async ($: EngineInterface): Promise<Proc[] | undefined> => {
353 for (const argv of lister === undefined ? [PS, CIM] : [lister, lister === PS ? CIM : PS]) {
354 const procs = await tryList($, argv)
355 if (procs !== undefined) {
356 lister = argv
357 return procs
358 }
359 }
360 return undefined
361}
362
363export type Usage = { cpu: string; elapsed: string }
364
365// Lines of `pid ppid pcpu etime command` from PS_USAGE.
366export const parseUsage = (stdout: string): Map<number, Usage> =>
367 new Map(
368 stdout.split(/\r?\n/).flatMap(line => {
369 const m = /^\s*(\d+)\s+\d+\s+([\d.]+)\s+(\S+)/.exec(line)
370 return m ? [[Number(m[1]), { cpu: `${m[2]}%`, elapsed: m[3] ?? '' }] as const] : []
371 }),
372 )
373
374// Seconds as `ps` writes elapsed time: [[dd-]hh:]mm:ss.
375export const formatElapsed = (seconds: number) => {
376 const two = (n: number) => String(n).padStart(2, '0')
377 const days = Math.floor(seconds / 86_400)
378 const hours = Math.floor((seconds % 86_400) / 3600)
379 const clock = `${two(Math.floor((seconds % 3600) / 60))}:${two(seconds % 60)}`
380 return days > 0 ? `${days}-${two(hours)}:${clock}` : hours > 0 ? `${two(hours)}:${clock}` : clock
381}
382
383// Lines of `pid seconds` from CIM_USAGE: elapsed time, no CPU use.
384export const parseCimUsage = (stdout: string): Map<number, Usage> =>
385 new Map(
386 stdout.split(/\r?\n/).flatMap(line => {
387 const m = /^\s*(\d+)\s+(\d+)\s*$/.exec(line)
388 return m ? [[Number(m[1]), { cpu: '-', elapsed: formatElapsed(Number(m[2])) }] as const] : []
389 }),
390 )
391
392// CPU use and elapsed time by PID: both from `ps`, elapsed time alone on
393// Windows.
394const usage = async ($: EngineInterface): Promise<Map<number, Usage>> => {
395 const isWindows = lister === CIM
396 try {
397 const { exitCode, stdout } = await $.process.run(isWindows ? CIM_USAGE : PS_USAGE)
398 if (exitCode !== 0) return new Map()
399 return isWindows ? parseCimUsage(stdout) : parseUsage(stdout)
400 } catch {
401 return new Map()
402 }
403}
404
405// The CLAUDE_PID of each of `pids`, or undefined where environments cannot
406// be read: on Windows, or when `ps` fails.
407const claudePidsOf = async ($: EngineInterface, pids: readonly number[]): Promise<Map<number, number> | undefined> => {
408 if (pids.length === 0) return undefined
409 if (lister === CIM) {
410 try {
411 const { exitCode, stdout } = await $.process.run(winEnv(pids))
412 isWindowsEnvRefused = exitCode !== 0
413 return exitCode === 0 ? parseWinClaudePids(stdout) : undefined
414 } catch {
415 isWindowsEnvRefused = true
416 return undefined
417 }
418 }
419 try {
420 const { exitCode, stdout } = await $.process.run(psEnv(pids))
421 // `ps -p` exits 1 when some PIDs are gone; the rest are still listed.
422 return exitCode === 0 || stdout.trim() !== '' ? parseClaudePids(stdout) : undefined
423 } catch {
424 return undefined
425 }
426}
427
428// Of `candidates`, the ones this Claude Code started (see `mine`).
429const owned = async (
430 $: EngineInterface,
431 procs: readonly Proc[],
432 candidates: readonly Proc[],
433): Promise<{ ours: Proc[]; unknown: Proc[] }> =>
434 candidates.length === 0
435 ? { ours: [], unknown: [] }
436 : owners(procs, candidates, await claudePidsOf($, candidates.map(p => p.pid)))
437
438const end = async ($: EngineInterface, pids: readonly number[], isForced: boolean) => {
439 if (pids.length === 0) return
440 const argv =
441 lister === CIM
442 ? ['taskkill', '/T', ...(isForced ? ['/F'] : []), ...pids.flatMap(pid => ['/PID', String(pid)])]
443 : ['kill', isForced ? '-KILL' : '-TERM', ...pids.map(String)]
444 try {
445 await $.process.run(argv)
446 } catch {
447 // Checked by listing again.
448 }
449}
450
451// How long ended processes get to exit before they are killed outright.
452export const GRACE_MS = 1000
453
454// Ends processes: TERM, a grace, then KILL for any still there. Resolves the
455// ones that survived both.
456const close = async ($: EngineInterface, targets: readonly Proc[], graceMs = GRACE_MS): Promise<Proc[]> => {
457 const isTarget = (p: Proc) => targets.some(t => isSame(t, p))
458 await end($, targets.map(p => p.pid), false)
459 await $.clock.sleep(graceMs)
460 const stuck = ((await list($)) ?? []).filter(isTarget)
461 if (stuck.length === 0) return []
462 await end($, stuck.map(p => p.pid), true)
463 return ((await list($)) ?? []).filter(isTarget)
464}
465
466const plural = (n: number) => `${n} Node process${n === 1 ? '' : 'es'}`
467
468// How many of `procs` are Node, and how many are helpers beneath one (a
469// Windows console host, a shell): "1 Node process and 1 helper process".
470export const count = (procs: readonly Proc[]) => {
471 const node = procs.filter(p => isNodeFamily(p.command)).length
472 const helpers = procs.length - node
473 return [
474 ...(node > 0 || helpers === 0 ? [plural(node)] : []),
475 ...(helpers > 0 ? [`${helpers} helper process${helpers === 1 ? '' : 'es'}`] : []),
476 ].join(' and ')
477}
478
479const short = (command: string) => (command.length > 80 ? `${command.slice(0, 77)}...` : command)
480
481const describe = (procs: readonly Proc[]) => procs.map(p => `${p.pid} (${short(p.command)})`).join(', ')
482
483const killHint = (procs: readonly Proc[]) =>
484 lister === CIM
485 ? `Stop-Process -Id ${procs.map(p => p.pid).join(',')} -Force`
486 : `kill ${procs.map(p => p.pid).join(' ')}`
487
488// The processes of background commands, kept across reloads in the session.
489const tracked = atom({ plugin: 'node-close', key: 'tracked' } as const, [] as Tracked[])
490
491// How often the processes of background commands are checked.
492export const SWEEP_MS = 30_000
493
494let sweeper: Timer | undefined
495
496// Notes for Claude from a sweep, added to the next shell command's result.
497let notes: string[] = []
498
499// Ends what stopped background commands left: the tracked processes whose
500// tree lost its parent. Keeps tracking the rest and what is beneath it.
501const sweep = async ($: EngineInterface) => {
502 const known = await read($, tracked)
503 if (known.length === 0) return stopSweeping()
504 const procs = await list($)
505 if (procs === undefined) return
506 const alive = grow(known, procs)
507 const isAlive = (p: Proc) => alive.some(a => a.pid === p.pid)
508 const left = strays(procs, isAlive)
509 const stuck = left.length > 0 ? await close($, left) : []
510 const kept = alive.filter(p => !left.some(l => l.pid === p.pid) || stuck.some(s => s.pid === p.pid))
511 await update($, tracked, () => kept.map(toTracked))
512 if (kept.length === 0) stopSweeping()
513 if (left.length === 0) return
514 const ended = left.filter(p => !stuck.some(q => q.pid === p.pid))
515 const closed = ended.length
516 if (closed > 0) $.ui.toast(`${$.plugin.name}: closed ${count(ended)} a stopped background command left`)
517 notes.push(
518 stuck.length === 0
519 ? `${$.plugin.name}: a background command stopped but left ${count(left)} running, now ended: ${describe(left)}.`
520 : `${$.plugin.name}: a background command stopped but left ${count(left)} running; ${count(ended)} ended, ` +
521 `${stuck.length} would not exit: ${describe(stuck)}. End them with \`${killHint(stuck)}\`.`,
522 )
523}
524
525const startSweeping = ($: EngineInterface) => {
526 sweeper ??= $.clock.every(SWEEP_MS, () => {
527 sweep($).catch(() => {})
528 })
529}
530
531const stopSweeping = () => {
532 sweeper?.cancel()
533 sweeper = undefined
534}
535
536const track = async ($: EngineInterface, procs: readonly Proc[]) => {
537 if (procs.length === 0) return
538 await update($, tracked, known => [
539 ...known,
540 ...procs.filter(p => !known.some(k => k.pid === p.pid && sameStart(k, p))).map(toTracked),
541 ])
542 startSweeping($)
543}
544
545// The shells whose commands are checked. PowerShell is Claude Code's shell
546// tool on Windows; it is not in every build's tool types, so it is matched by
547// name rather than by a typed matcher.
548const SHELLS: ReadonlySet<string> = new Set(['Bash', 'PowerShell'])
549
550const withNotes = <R extends { readonly context?: readonly string[] }>(ran: R, more: readonly string[] = []): R => {
551 const all = [...notes, ...more]
552 notes = []
553 return all.length === 0 ? ran : { ...ran, context: [...(ran.context ?? []), ...all] }
554}
555
556const guard: Hook<'tool.call'> = async ($, e, next) => {
557 if (!SHELLS.has(e.tool)) return next(e)
558 const { command, run_in_background } = e as { command?: unknown; run_in_background?: unknown }
559 if (typeof command !== 'string' || !MAY_START_NODE.test(command)) return withNotes(await next(e))
560
561 const before = await list($)
562 const ran = await next(e)
563 if (before === undefined || ran.deny !== undefined) return withNotes(ran)
564 const isBefore = new Set(before.map(p => p.pid))
565 const isKept = KEEPS_RUNNING.test(command)
566
567 // A background command, or one that outran its timeout or that the person
568 // backgrounded, is still running: its processes are tracked, and ended once
569 // the command stops and leaves them, or when the session ends.
570 const isBackground =
571 run_in_background === true ||
572 typeof (ran.result as { backgroundTaskId?: unknown } | undefined)?.backgroundTaskId === 'string'
573 const after = (await list($)) ?? []
574 if (isBackground) {
575 if (!isKept) await track($, (await owned($, after, after.filter(p => !isBefore.has(p.pid)))).ours)
576 return withNotes(ran)
577 }
578
579 // Only what this Claude Code started: a Node another session started in
580 // the meantime has lost its parent too, but is not this command's.
581 const { ours: left, unknown } = await owned($, after, leftovers(isBefore, after))
582 const refused =
583 isWindowsEnvRefused && !hasToldRefusal
584 ? [
585 `${$.plugin.name}: PowerShell refused the helper that reads which Claude Code started a process ` +
586 `(Add-Type was blocked, possibly by antivirus), so on Windows any Node that appears during a command ` +
587 `and loses its parent counts as its leftover, another session's included.`,
588 ]
589 : []
590 if (refused.length > 0) hasToldRefusal = true
591 const unsure =
592 unknown.length === 0
593 ? refused
594 : [
595 ...refused,
596 `${$.plugin.name}: ${count(unknown)} appeared while this command ran and lost ${unknown.length === 1 ? 'its' : 'their'} parent, ` +
597 `but carry no CLAUDE_PID, so it cannot tell whether this command started them; they were kept: ${describe(unknown)}. ` +
598 `If they are this command's, end them with \`${killHint(unknown)}\`.`,
599 ]
600 if (left.length === 0) return withNotes(ran, unsure)
601
602 if (isKept)
603 return withNotes(ran, [
604 `${$.plugin.name}: this command finished and left ${count(left)} running on purpose, so they were kept: ` +
605 `${describe(left)}. End them with \`${killHint(left)}\` when they are no longer needed.`,
606 ...unsure,
607 ])
608
609 const stuck = await close($, left)
610 const ended = left.filter(p => !stuck.some(q => q.pid === p.pid))
611 const closed = ended.length
612 if (closed > 0) $.ui.toast(`${$.plugin.name}: closed ${count(ended)} left by the last command`)
613 return withNotes(ran, [
614 stuck.length === 0
615 ? `${$.plugin.name}: this command finished but left ${count(left)} running, now ended: ${describe(left)}.`
616 : `${$.plugin.name}: this command finished but left ${count(left)} running; ${count(ended)} ended, ` +
617 `${stuck.length} would not exit: ${describe(stuck)}. End them with \`${killHint(stuck)}\`.`,
618 ...unsure,
619 ])
620}
621
622// Reasons the process goes on after the session ends, its background
623// commands with it: nothing is ended then.
624const GOES_ON: ReadonlySet<string> = new Set(['clear', 'resume'])
625
626// At the end of the session, every Node tree beneath a background command
627// Claude started is ended, orphaned or not: nothing it started is meant to
628// outlive the session.
629const atEnd: Hook<'session.end'> = async ($, e, next) => {
630 stopSweeping()
631 if (GOES_ON.has(e.reason)) return next(e)
632 const known = await read($, tracked)
633 if (known.length === 0) return next(e)
634 const procs = await list($)
635 if (procs !== undefined) {
636 const alive = grow(known, procs)
637 const left = strays(procs, p => alive.some(a => a.pid === p.pid), false)
638 if (left.length > 0) {
639 await end($, left.map(p => p.pid), false)
640 // Kill outright what has not exited, if the session's short exit bound
641 // leaves time to look.
642 if (next.budget.remainingMs > 1500) {
643 await $.clock.sleep(500, { signal: next.signal })
644 const stuck = ((await list($)) ?? []).filter(p => left.some(l => isSame(l, p)))
645 await end($, stuck.map(p => p.pid), true)
646 }
647 }
648 }
649 await update($, tracked, () => [])
650 return next(e)
651}
652
653const COMMAND = 'node-close'
654
655// Origins where the person themself ran the command: only they may end
656// processes Claude did not start.
657const PERSON: ReadonlySet<string> = new Set(['composer', 'bridge'])
658
659const scan = async ($: EngineInterface): Promise<Proc[] | undefined> => {
660 const procs = await list($)
661 return procs === undefined ? undefined : orphans(procs)
662}
663
664// `/node-close` lists every orphaned Node on the machine; `/node-close kill
665// <PID ...>` or `kill all` ends listed ones, when the person runs it.
666// What `/node-close` was asked: list, kill some PIDs, kill all, or nothing
667// it understands. Anything after `kill` that is not a PID makes the whole
668// request unclear, so nothing is ended.
669export const parseArgs = (args: string): { list: true } | { kill: 'all' | number[] } | { usage: string } => {
670 const words = args.trim().split(/\s+/).filter(Boolean)
671 if (words.length === 0) return { list: true }
672 if (words[0]?.toLowerCase() !== 'kill') return { usage: `Unknown argument: ${words[0]}` }
673 const rest = words.slice(1)
674 if (rest.length === 1 && rest[0]?.toLowerCase() === 'all') return { kill: 'all' }
675 if (rest.length === 0) return { usage: 'Name the PIDs to end, or all.' }
676 const bad = rest.filter(w => !/^\d+$/.test(w))
677 if (bad.length > 0) return { usage: `Not a PID: ${bad.slice(0, 3).join(' ')}${bad.length > 3 ? ' ...' : ''}` }
678 return { kill: rest.map(Number) }
679}
680
681const USAGE = `Usage: /${COMMAND} | /${COMMAND} kill <PID ...> | /${COMMAND} kill all`
682
683const command: Hook<'command.run'> = async ($, e) => {
684 const asked = parseArgs(e.args ?? '')
685 if ('usage' in asked) return { text: `${asked.usage}\n${USAGE}\nNothing was ended.` }
686 const found = await scan($)
687 if (found === undefined) return { text: `Cannot list processes.` }
688
689 if ('list' in asked) {
690 if (found.length === 0) return { text: `No orphaned Node processes are running.` }
691 const use = await usage($)
692 const ordered = tree(found)
693 const roots = ordered.filter(r => r.depth === 0).map(r => r.proc)
694 const beneath = found.length - roots.length
695 const rows = ordered.map(({ proc: p, depth }) => {
696 const u = use.get(p.pid)
697 const indent = depth > 0 ? `${' '.repeat(depth - 1)}└─ ` : ''
698 return `${String(p.pid).padStart(7)} ${(u?.cpu ?? '-').padStart(6)} ${(u?.elapsed ?? '-').padStart(11)} ${indent}${short(p.command)}`
699 })
700 return {
701 text: [
702 `${count(roots)} whose parent is gone` +
703 (beneath > 0 ? `, with ${beneath} process${beneath === 1 ? '' : 'es'} beneath ${roots.length === 1 ? 'it' : 'them'}` : '') +
704 ` (not necessarily started by Claude):`,
705 // A code block keeps the columns and the backslashes of Windows paths.
706 '```',
707 ` PID CPU ELAPSED COMMAND`,
708 ...rows,
709 '```',
710 ...(lister === CIM ? ['CPU use is not available on Windows.'] : []),
711 `End them with \`/${COMMAND} kill <PID ...>\` or \`/${COMMAND} kill all\`.`,
712 ].join('\n'),
713 }
714 }
715
716 if (!PERSON.has(e.origin?.kind ?? ""))
717 return { text: `Only you can end these processes; run \`/${COMMAND} kill\` yourself.` }
718
719 const pids = asked.kill === 'all' ? found.map(p => p.pid) : asked.kill
720 // A named PID takes what runs beneath it, as `taskkill /T` does on Windows.
721 const named = new Set(pids.filter(pid => found.some(p => p.pid === pid)))
722 let grew = true
723 while (grew) {
724 grew = false
725 for (const p of found)
726 if (!named.has(p.pid) && named.has(p.ppid)) {
727 named.add(p.pid)
728 grew = true
729 }
730 }
731 const targets = found.filter(p => named.has(p.pid))
732 const skipped = pids.filter(pid => !found.some(p => p.pid === pid))
733 const stuck = targets.length > 0 ? await close($, targets) : []
734 return {
735 text: [
736 targets.length > 0 ? `Ended ${count(targets.filter(p => !stuck.some(q => q.pid === p.pid)))}.` : `Nothing ended.`,
737 ...(stuck.length > 0 ? [`Would not exit: ${describe(stuck)}.`] : []),
738 ...(skipped.length > 0 ? [`Skipped, not an orphaned Node process: ${skipped.join(' ')}.`] : []),
739 ].join('\n'),
740 }
741}
742
743export const register: Register = on => {
744 on('session.start', async ($, e, next) => {
745 await $.command.register({
746 name: COMMAND,
747 description: 'List orphaned Node processes; kill <PID ...> or kill all ends them',
748 argumentHint: '[kill <PID ...> | kill all]',
749 })
750 // A reload keeps the tracked processes but not the timer.
751 if ((await read($, tracked)).length > 0) startSweeping($)
752 return next(e)
753 })
754 // A hook that throws must not block the shell: fall through to the command.
755 on('tool.call', guard).catch(($, e, next) => next(e))
756 on('session.end', atEnd).catch(($, e, next) => next(e))
757 // A listing that fails says so rather than passing to an engine with no
758 // command of this name.
759 on('command.run', { command: COMMAND }, command).catch(() => ({ text: 'Could not list processes.' }))
760}
761types/index.d.ts 17 lines1// A process node-close keeps track of: the PID, the command line it was seen
2// with, and when it started (seconds since the epoch, when the listing gives
3// it), so a reused PID is not mistaken for it while a shell that replaces
4// itself with Node still is.
5export type Tracked = { pid: number; command: string; started?: number }
6
7declare module 'claude-code' {
8 interface PluginState {
9 'node-close': {
10 // The processes of background commands Claude started this session, and
11 // every process seen beneath them since: what a stopped background task
12 // or the session's end may leave behind.
13 tracked: Tracked[]
14 }
15 }
16}
17