SLOPSHOPPER

node-close

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

guardcommandtoastprocesstimer
v1.3.0MITupdated 2026-10-19AnnihilationWizard/node-close
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · node-close
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 › /node-close ⎿ node-close: Cannot list processes. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

node-close

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

Side-by-side Claude Code session: on the left three finished test runs leave Node runners, workers and esbuild holding 940 MB; on the right each run's leftovers are closed when it finishes and a toast reports the count

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.

Features

  • Ends leftovers at once: After a Bash or PowerShell command finishes, Node processes it started that are still running are sent SIGTERM, then SIGKILL after one second if they stay (taskkill /T on Windows).
  • Takes their tools too: A Node's workers, and tools a Node project installed under node_modules such as esbuild, swc or turbo, go with it, even when the Node that started them has already exited.
  • Only what the command left: A process counts only when it is new since the command started and has lost its parent, re-parented to launchd/init or, on Windows, pointing at a parent that no longer exists. It must also carry this Claude Code's 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).
  • Keeps what you meant to keep: Node running before the command, your own servers, other sessions and processes still owned by a running parent are never touched.
  • Follows background commands: A background command's processes are tracked. Once it stops, any Node it left is ended within 30 seconds; when the session ends, its Node trees are ended. Claude Code usually cleans these up itself; this is a fallback for when it does not.
  • Respects on-purpose background work: Commands with &, nohup, disown, setsid, pm2, forever, --daemon, --detach or Start-Process keep their processes; Claude is told the PIDs instead.
  • Tells Claude and you: Claude gets the PIDs that were ended, and a toast shows the count.
  • Finds orphans from anywhere: /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.
SituationWhat node-close does
npx vitest run finishes, its runner and workers keep runningEnds them, tells Claude which PIDs
A process ignores SIGTERMKills 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 backgroundLeaves 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 NodeNot checked

Install

In a Claude Code terminal session, run:

/plugin install node-close --marketplace AnnihilationWizard/node-close
  1. Answer y to add the github:AnnihilationWizard/node-close marketplace.
  2. Choose a scope. The user scope loads the mod in every session.
  3. Claude Code confirms 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" }
}

Usage

Once installed, there is nothing to configure. Ask Claude to run your tests; when the command finishes, any Node it left running is closed.

CommandEffect
/node-closeList 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 allEnd 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.

Limitations

  • Only commands Claude runs through Bash or PowerShell are checked, not commands you run yourself.
  • On Windows, a helper reads other processes' memory. Windows does not list another process's environment, so PowerShell compiles a small C# class (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.
  • Nothing runs when Claude Code is killed outright (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.
  • On Windows, /node-close shows run time but not CPU use, which Win32_Process does not give.
  • Background commands are checked every 30 seconds while any are tracked; on Windows each check takes about a second of PowerShell.
  • A command is checked only when it may start Node: it names 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.
  • A Node that keeps its parent alive, for example under a daemon that stays running, is not counted as a leftover.
  • An & inside quotes, such as in a URL, is read as background work, so that command's leftovers are reported instead of ended.
  • Processes are listed with ps on macOS and Linux and with PowerShell's Win32_Process on Windows; if neither works, nothing is checked.
  • The /plugin install command is available in terminal sessions only. A user-scope install also loads in the desktop app's Code tab.

Development

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.

Project Structure

.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

Contributing

Bug reports and ideas are welcome. Read CONTRIBUTING.md before opening an issue or pull request, and follow SECURITY.md for security reports.

License

MIT License. Copyright © 2026 AnnihilationWizard.

Source 2 files
hooks/register.ts 761 lines
1import { 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}
761
types/index.d.ts 17 lines
1// 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