Bash memory rules: Node commands run in claude-cmd.slice, and runs that would starve the machine are refused

Personal Claude Code mods: plugins of function hooks that restyle the transcript, add rows around the prompt and react to tool calls. Built and used on Claude Code 2.1.287+, Linux.
| Mod | What it does | Commands | Needs | ||
|---|---|---|---|---|---|
| quiet-bash | One-line tool rows (✓ <description>, red ✗ exit N on failure, +N −M on edits, duration for calls ≥10 s). Hides every tool's result block (Bash output, Edit diffs, WebFetch, MCP and Agent results; interactive tools, SendUserFile and image Reads keep theirs), finished read-only rows (Read/Grep/Glob, and calls the engine ran read-only like ls or git status, and every Claude in Chrome step but navigate), collapsed tool groups unless one failed, and timed-out GitLab pipeline-wait notices. Inline PNG thumbnails under rows that read, sent or wrote a PNG (at most 3 written ones per call), relative paths included; a group holding a thumbnail stays open, even under /quiet | /quiet brings it all back; /thumb [big] <path> shows a PNG (relative to the session's directory) as thumb #N: <path> | ImageMagick (magick) for thumbnails; a terminal with kitty graphics (see herdr) | ||
| quiet-spinner | Plain spinner words (thinking, writing, running, waiting, preparing) and Took 1m 4s instead of the whimsical ones | – | – | ||
| usage-percent | Row under the prompt: `ctx 34% \ | 5h 41% \ | wk 86%, yellow from 80 %, red from 95 %. In Nx repos also memory in the middle (claude.slice usage + app.slice pressure, yellow from 20 %, red from 50 %) and on the right the session's own nx serve projects (▶ admin api; a bare nx serve or run-many -t serve shows ▶ serve) and the branch's pipeline (ci ⏳ test, ⏸` when it waits on a manual job or an approval; none for Azure remotes) | – | gh / glab logged in for the pipeline; systemd claude.slice for memory |
| reminder-log | Tallies the reminders Claude Code injects for the model, per session; drops the token counter and repeated commit attribution blocks (sent again after a compaction) | /reminders prints the tally of the last 30 days | – | ||
| mr-banner | Colored card with a link under each MR/PR created, merged, approved or reviewed | – | GitLab MCP server named gitlab, or glab / gh | ||
| coderabbit-band | Band above the prompt with the open CodeRabbit threads (by severity) and nitpicks of the current branch's GitLab MR, with a link. Shows only when something is open | /coderabbit hides it until the counts change | glab logged in; GitLab remote | ||
| redact | Secrets in prompts and tool output reach the model as ‹secret:…› tokens; only Write/Edit turn them back into the real value. See redact | – | betterleaks on PATH | ||
| mem-guard | Bash commands that run Node tools go into a memory-capped claude-cmd.slice scope. Refused, with the fix in the message: nx affected/run-many without --parallel=1, a pnpm script that has a :lite twin, jest without a worker cap (--maxWorkers=N, -w N or --runInBand; a percentage doesn't count), pkill -f/pgrep -f with an unbracketed pattern, ci:local, heavy runs and dev-server starts while the slice is above 75 % or under pressure, a third heavy command or a third dev server at once, and writes by a check-runner subagent. See mem-guard | – | Linux with systemd: a user claude-cmd.slice, ps; see mem-guard | ||
| handover | Skill plus mod: the handover skill writes a one-sentence handover for a cold session to ~/.claude/handover.md; the mod files it per session under ~/.claude/handovers/<session id>.md, so parallel sessions in one repo never overwrite each other. A band above the prompt shows it; after /clear that session's sentence waits in the prompt (Tab takes it) and the next prompt spends it. A new terminal suggests nothing but lists the repo's open sentences (newest first, 14 days) | /handover-copy copies it and hides the band; /handover lists, /handover N puts one in the prompt | – | ||
| herdr-notify | GNOME popup when Claude waits for a permission, an answer or a new prompt (a Notification hook, not a plugin). Skipped while that pane is the focused one in herdr. Clicking it raises Ghostty and focuses that session's herdr pane. Hook: `"Notification": [{"matcher": "permission_prompt\ | idle_prompt\ | elicitation_dialog", "hooks": [{"type": "command", "command": "bash \"$HOME/.claude/skills/herdr-notify/notify.sh\""}]}]` | – | herdr, Ghostty, notify-send, jq |
| stack-down | On /clear and exit (also logout, a finished -p run, SIGINT/SIGTERM/SIGHUP), stops what the session left running in its repo: the nx processes it started (with their workers), and the repo's Docker Compose projects (compose down, volumes kept) unless another Claude session works in the same repo. Only for a session at a repo's top level (git rev-parse --show-toplevel); one started above the repos, such as ~/Dev, stops nothing | – | docker compose, git | ||
| review-walk | Skill plus mod: /review-walk takes the findings of the reviews already run in the conversation (/code-review, a repo's own review skill, CodeRabbit threads, MR comments), merges and ranks them, and asks fix / issue / skip one finding at a time; fixes wait until every finding is decided. The mod puts Finding N/M, a progress bar and the decisions so far over each question, and a band above the prompt between them. Decisions you authorised up front ("apply every Fix") are recorded through its ReviewWalkDecide tool instead of a question. The walk starts only after the skill runs, so a review on its own draws no band | /review-walk | A review run first: it walks findings, it never reviews. Any review skill or source works |
The model sees exactly what it would without them: the mods change what is drawn, except reminder-log, which drops two kinds of injected reminders, redact, which hides secrets, mem-guard, which runs Node commands inside a systemd scope and refuses some with a mem-guard: … error, and stack-down, which stops the session's own servers and, when no other session is in the repo, its Compose stacks when the session ends.
Three mods call out on their own:
get_merge_request on the gitlab MCP server for the link and title. gh/glab commands cost nothing extra.glab for GitLab origin remotes: a 60 s timer checks the branch, and a fetch (glab mr view plus all pages of the MR's discussions) runs every minute for 15 min after a git push, every 5 min while the band shows something, every 15 min otherwise, and once 5 s after a thread is resolved.ps and pwdx for nx serve processes, and docker inspect once per container a server runs in, to read its compose project directory. The pipeline (gh run list / glab ci get) is fetched when HEAD moves, 15 s after a git push, every minute while it runs and every 5 min otherwise.From the marketplace (gets updates; herdr-notify and herdr-link-toast are not in it):
claude plugin marketplace add schreibse/claude-code-mods
claude plugin install quiet-bash@schreibse-mods # per mod
Then turn on updates: /plugin → Marketplaces → schreibse-mods → Enable auto-update. See Updates.
From a clone, to adapt a mod or work on it. Claude Code loads every plugin folder under ~/.claude/skills/ at session start. Don't install the same mod from the marketplace too: the installed copy silently replaces the folder.
~/.claude/skills: clone straight into it: ``sh git clone https://github.com/schreibse/claude-code-mods ~/.claude/skills ``sh git clone https://github.com/schreibse/claude-code-mods ~/src/claude-code-mods ln -s ~/src/claude-code-mods/quiet-bash ~/.claude/skills/quiet-bash # per mod ``New sessions pick them up; a running one needs /exit and claude --continue.
Leave a mod out: claude plugin uninstall <mod>@schreibse-mods, or delete or unlink its folder. Try one for a single session: claude --plugin-dir ~/src/claude-code-mods/<mod>.
Where they run. Built and used only in the terminal CLI on Fedora (Linux, systemd, cgroup v2). The engine also loads user-scope mods in the local session the desktop app starts for its Code tab, and draws them there (desktop surface); untested here. See Setting up for what each mod needs.
This repo is the skills folder itself, so .gitignore ignores everything and re-includes each mod: a new mod needs a !/<name>/ line or git won't see it. .claude-plugin/types/ is generated by the engine and stays untracked.
Each mod has its own version (plugin.json) and a CHANGELOG.md in its folder; each release is a git tag <mod>--v<version>.
Plugin updated: <mod> · Run /reload-plugins to apply. Without it nothing tells you; check by hand: ``sh claude plugin marketplace update schreibse-mods claude plugin update quiet-bash@schreibse-mods # per mod; "already at the latest version" if none ``CHANGELOG.md. Read every version you skipped: a major version means you have to act (a new hook in settings.json, a new dependency, a removed command).git -C ~/.claude/skills pull --ff-only, then read the changelogs of the mods whose version moved (git -C ~/.claude/skills diff ORIG_HEAD -- '*/.claude-plugin/plugin.json').betterleaks scans every prompt and tool result before it reaches the model. Each value it flags becomes ‹secret:xxxxxxxx› and stays hidden wherever it appears later, even where the scanner would not recognise it again.
| The model's call | What happens |
|---|---|
| Write, Edit, NotebookEdit with a token | the real value is written to the file |
| Write that would drop a secret the file holds | refused: use Edit |
| Agent, SendMessage, TodoWrite with a token | passed on as the token |
| Any other tool with a token (Bash, WebFetch, MCP …) | refused, so the value never leaves |
| A token the mod no longer knows (after a restart) | refused, never restored wrongly |
Cut-short output. Before Bash or any tool with a file_path/path (Read, Grep, Edit …) runs, every file the call names (Bash: the first 10 words that could be paths, files up to 1 MB) is scanned whole, so cut -c1-60 .env, cut -d= -f2 or a Read of a few lines from a PEM key still come back as tokens. Multi-line secrets are hidden line by line. Bash words resolve against the directory each segment runs in (cd sub && cut -c1-40 .env), and ~/, $HOME/ and ${HOME}/ expand to the home directory.
Rules. betterleaks' defaults plus redact/betterleaks.toml: Sentry DSN keys, and client secrets too short for the generic rules. A client secret containing dev, local, test, example, changeme or placeholder stays visible, so local dev clients keep working in commands.
Not covered:
git show HEAD:.env | cut …, printenv | cut …); the transcript file's structured tool records, which keep real values (the model does not read them).cut -c1-40 *.env), quoted paths with spaces and a cd inside a subshell ((cd sub && cut -c1-40 .env)).client-secret rule needs some entropy (≥ 3.0), so short, repetitive client secrets stay visible.base64, rev, xxd) or sent (curl -T file). redact keeps secrets out of the model's context by accident; it is not a sandbox against a model trying to get them.‹secret:…› token is refused like any other tool call carrying one.The vault lives in session memory: /exit forgets it.
The status line shows redacted N once a value is hidden: N counts vault entries, so a PEM key counts once per line. A toast per call names the rules that hid new values. Without betterleaks the status line says off (no betterleaks) and nothing is hidden. A betterleaks run that fails leaves that call unredacted (values already in the vault stay hidden): one toast per failure streak, the status says off (betterleaks), and the next call scans again.
Every Bash command that names a Node tool (node, npx, pnpm, npm, npm exec, yarn, nx, jest, vitest, playwright, tsc, ngc) runs as systemd-run --user --scope --slice=claude-cmd.slice -p MemoryMax=30% -p MemorySwapMax=4% -- bash -c '…', so an overrun dies with exit 137 instead of taking the desktop down. Leading cd … && stay outside the wrapper, so the shell's directory still moves.
The rules read what each part of a command runs (after &&, |, ;, &, $( ), inside bash -c '…', following cd), never words it only mentions, so a commit message saying pkill -f passes. Not looked into: xargs, make, eval, scripts.
At once, counted per scope in claude-cmd.slice from one ps scan (skipped when ps fails):
tsc, ngc, an nx task); the nx daemon and dev servers don't count;nx serve, nx run x:serve, a serve* script): the API and one app.A command counts as heavy for these checks when it runs one of those tools, docker compose up, or a test, build, lint, typecheck, e2e or serve* script.
check-runner subagents (an agent type of the owner's) only run checks: git commands that change the tree or history, sed -i, --write/--fix, starting or stopping servers or processes, and writes outside /tmp are refused with "Report the failure instead of fixing it".
Assumes this setup; the messages name it:
claude-cmd.slice user unit. Without it systemd makes the slice with no limit of its own: each command is still capped at 30 % of RAM, but the headroom check has nothing to measure and stays off: ``ini # ~/.config/systemd/user/claude-cmd.slice [Slice] MemoryMax=30% MemorySwapMax=4% ` A full slice can livelock without ever reaching the OOM killer, so let systemd-oomd kill a scope in it under sustained pressure: systemctl --user set-property claude-cmd.slice ManagedOOMMemoryPressure=kill ManagedOOMMemoryPressureLimit=50%. systemd turns a percentage into bytes of the machine's RAM (on 27 GiB: 8.2G and 1.1G), so the same unit fits any machine. Check what a machine gets: systemd-run --user --scope -q -p MemoryMax=30% -- sh -c 'cat /sys/fs/cgroup$(cut -d: -f3 /proc/self/cgroup)/memory.max'`fuser -k 4700/tcp.:lite twin (lint:affected:lite, typecheck:lite, test:affected:lite) and a slow ci:local script.git@work:org/repo) reads as ci no login (work) though you are logged in.Under herdr (here inside Ghostty), quiet-bash's thumbnails draw only their alt text, and links in mr-banner, coderabbit-band and Claude's own output aren't clickable.
Cause: Claude Code turns on kitty graphics only for terminals whose XTVERSION answer is kitty (≥ 0.28) or starts with ghostty, and hyperlinks only for terminals it recognises. herdr forwards both, but answers with its own name, so Claude Code turns both off.
Fix: force both on, inside herdr only, before Claude Code starts (it reads them at startup):
# ~/.bashrc.d/herdr-terminal.sh
if [ -n "$HERDR_ENV" ]; then
export CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1
export FORCE_HYPERLINK=1
fi
Also turn on herdr's kitty graphics passthrough (off by default) in ~/.config/herdr/config.toml:
[experimental]
kitty_graphics = true
Then herdr server reload-config, open a new herdr pane (old panes keep the old environment) and start Claude Code there. Check it: /thumb /path/to/some.png should draw the picture.
herdr opens a ctrl+clicked link in the background with no feedback, and ignores file:// links altogether. herdr-link-toast opens http(s) links with xdg-open and shows a toast. It also opens the path in quiet-bash's thumbnail captions, which link to http://localhost/open-file/<path> because file:// isn't clickable in herdr. Such a link opens only when the path ends in .png; any other gets a "Could not open link" toast. Needs python3 ≥ 3.9. Install:
herdr plugin link ~/.claude/skills/herdr-link-toast
Without herdr, in plain kitty or Ghostty, none of this is needed. Terminals without the kitty graphics protocol show the thumbnail's alt text (its path).
Read before installing for someone. The mods were written for one machine; several carry its names and need Linux.
1. Check the host. Mods need Claude Code 2.1.287+. They load in the terminal CLI and, from ~/.claude/skills, in a desktop app's local Code-tab session. A desktop-app session can also be given a folder through CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json. Nothing here has been tried in the desktop app, VS Code, JetBrains, macOS, WSL or native Windows: say so to the person instead of promising it works.
2. Pick mods by platform.
| Works anywhere | Needs Linux | Never on macOS or Windows |
|---|---|---|
quiet-spinner, reminder-log, mr-banner, coderabbit-band, redact (with betterleaks), handover, review-walk; usage-percent's ctx/5h/wk and pipeline parts | usage-percent's memory and dev-server parts (cgroup v2, /proc, pwdx; they stay empty elsewhere); stack-down (ps, /proc/<pid>/cwd, setsid) | mem-guard: it wraps every Node command in systemd-run, so without systemd every node/pnpm/nx call fails. Leave it out. herdr-notify, herdr-link-toast: Linux, herdr, Ghostty, GNOME |
/, ~). There, quiet-bash thumbnails and redact's Bash pre-scan find nothing, and handover leaves spent files behind./handover-copy can't copy from the desktop app.3. Adapt to the OS you are on. The mods do not detect the OS; you are running on it, so adapt the person's copy and test it there. The plugin API has no OS call: on Windows $.env.get('OS') is Windows_NT, elsewhere uname -s says Darwin or Linux. Known gaps:
:lite, pkill -f, ci:local), skip scoped() and the ps/cgroup checks when systemd-run is missing.HOME may be unset (USERPROFILE), and spent sentences are removed with rm./ and ~ only, not C:\….pwdx (lsof -a -d cwd -p <pid> instead); the memory zone needs cgroup v2 and stays empty.Write each OS change as its own commit with a test, so it can come back upstream.
4. Replace the owner's names. Names in this README and in the messages are examples from the owner's repos; use the person's own. The API project there is ec-api, not api, and the dev-server rules only work with the real project names: read them from the repo (npx nx show projects).
| Mod | Owner-specific | Where |
|---|---|---|
| mem-guard | claude-cmd.slice and its 30 % / 4 % limits; port 4700; :lite scripts; ci:local "~45 min"; the check-runner agent type; 2 heavy commands, 2 dev servers | hooks/rules.ts, hooks/register.ts |
| usage-percent | claude.slice (usage) and app.slice (pressure) under the user manager; Nx repos only (nx.json) | hooks/register.tsx |
| stack-down | another session is a process whose command is claude (native install); an npm install runs as node …/cli.js, goes unseen, and its repo's Compose stacks go down anyway | hooks/targets.ts |
| mr-banner, coderabbit-band, quiet-bash | the GitLab MCP server named gitlab (mcp__gitlab__…) | hooks/*.ts(x) |
| coderabbit-band, usage-percent | any remote that isn't GitHub (or Azure) is taken for GitLab | hooks/register.tsx |
| herdr-notify | Ghostty's desktop entry com.mitchellh.ghostty | notify.sh |
5. Install and check. Install as above (from a clone when you adapted a mod: an update replaces a marketplace copy), then run claude plugin validate <mod> and claude plugin test <mod> for each mod you install. Start a new session and check what the person should now see: the usage row, a tool row with ✓, and for redact, nothing until it hides a value, then redacted N in the status line.
Each mod is .claude-plugin/plugin.json, hooks/hooks.json and hooks/register.ts(x), plus types/index.d.ts when it keeps session state. Per mod:
claude plugin validate <mod> # what it hooks and calls, and what the engine would refuse
claude plugin test <mod> # its *.test.ts(x)
tsc -p <mod> # once the engine has laid .claude-plugin/types/
Edits in ~/.claude/skills hot-reload into running sessions that loaded the mod.
Marketplace users only get a change once the mod's version moves, so a change meant for them bumps it in the same commit:
version in <mod>/.claude-plugin/plugin.json: major when users must act, minor for a feature, patch for a fix.## <version> — <date> to <mod>/CHANGELOG.md, with an Action needed line on a major.claude plugin validate --strict . (the marketplace) and claude plugin validate --strict <mod>.claude plugin tag --push <mod>.gh release create <mod>--v<version> --verify-tag --title "<mod> <version>" --notes-file <entry>, with the new changelog entry as the notes; links in it must be absolute.hooks/register.ts 81 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { checkRunnerRefusal, crowdedRefusal, invocations, isHeavy, isServe, parsePressure, refusal, runningRefusal, scoped } from './rules'
4import type { Invocation, Pressure, ScriptsByDir } from './rules'
5
6let cmdSlice = ''
7let home = ''
8
9// Stays empty while claude-cmd.slice is inactive, so it is looked up again on the next call.
10async function resolveCmdSlice($: EngineInterface): Promise<string> {
11 if (cmdSlice === '') {
12 const group = await $.process.run(['systemctl', '--user', 'show', 'claude-cmd.slice', '-p', 'ControlGroup', '--value']).catch(() => null)
13 const path = group?.exitCode === 0 ? group.stdout.trim() : ''
14 cmdSlice = path === '' ? '' : `/sys/fs/cgroup${path}`
15 }
16 return cmdSlice
17}
18
19async function readPressure($: EngineInterface): Promise<Pressure | null> {
20 const slice = await resolveCmdSlice($)
21 if (slice === '') {
22 return null
23 }
24 const read = (file: string) => $.fs.read(`${slice}/${file}`)
25 const [current, max, pressure] = await Promise.all([read('memory.current'), read('memory.max'), read('memory.pressure')]).catch(() => ['', '', ''])
26 return parsePressure(current ?? '', max ?? '', pressure ?? '')
27}
28
29async function scriptsIn($: EngineInterface, dir: string): Promise<Record<string, string>> {
30 const manifest = await $.fs.read(`${dir}/package.json`).catch(() => '{}')
31 try {
32 return (JSON.parse(manifest) as { scripts?: Record<string, string> }).scripts ?? {}
33 } catch {
34 return {}
35 }
36}
37
38async function scriptsOf($: EngineInterface, calls: readonly Invocation[]): Promise<ScriptsByDir> {
39 const dirs = [...new Set(calls.map(call => call.dir))]
40 const scripts = await Promise.all(dirs.map(dir => scriptsIn($, dir)))
41 return Object.fromEntries(dirs.map((dir, i) => [dir, scripts[i] ?? {}]))
42}
43
44export const register: Register = on => {
45 on('session.start', async ($, e, next) => {
46 await resolveCmdSlice($)
47 home = (await $.env.get('HOME')) ?? ''
48 return next(e)
49 })
50
51 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
52 if (e.agentId !== undefined) {
53 const agents = await $.agent.list().catch(() => [])
54 if (agents.find(agent => agent.id === e.agentId)?.type === 'check-runner') {
55 const writeRefusal = checkRunnerRefusal(e.command)
56 if (writeRefusal !== null) {
57 return { deny: `mem-guard: ${writeRefusal}` }
58 }
59 }
60 }
61 const calls = invocations(e.command, { cwd: await $.session.cwd(), home })
62 const refused = refusal(calls, await scriptsOf($, calls))
63 if (refused !== null) {
64 return { deny: `mem-guard: ${refused}` }
65 }
66 if (isHeavy(calls) || isServe(calls)) {
67 const pressure = await readPressure($)
68 const crowded = pressure === null ? null : crowdedRefusal(pressure)
69 if (crowded !== null) {
70 return { deny: `mem-guard: ${crowded}` }
71 }
72 const ps = await $.process.run(['ps', '-eo', 'cgroup:250=,args=', '--cols', '600']).catch(() => null)
73 const busyRefusal = ps?.exitCode === 0 ? runningRefusal(calls, ps.stdout) : null
74 if (busyRefusal !== null) {
75 return { deny: `mem-guard: ${busyRefusal}` }
76 }
77 }
78 return next({ ...e, command: scoped(e.command) })
79 })
80}
81hooks/rules.ts 303 lines1export type Pressure = { usedBytes: number; maxBytes: number; psiAvg10: number }
2export type Shell = { cwd: string; home: string }
3export type ScriptsByDir = Record<string, Record<string, string>>
4export type Invocation = { line: string; tool: string; call: string; script: string | null; dir: string }
5
6const GIB = 1024 ** 3
7const FULL_SHARE = 0.75
8const PSI_LIMIT = 20
9
10const HEAVY_TOOLS = ['nx', 'jest', 'vitest', 'playwright', 'tsc', 'ngc']
11const NODE_TOOLS = ['node', 'npx', 'pnpm', 'npm', 'yarn', ...HEAVY_TOOLS]
12const PREFIX = /^(?:(?:sudo|time|nice|env|exec|command|then|do|else|if|while|until|\{|!)\s+|timeout\s+\S+\s+|\w+=\S*\s+)*/
13const PNPM_FLAGS = String.raw`(?:(?:--filter|-F|-C|--dir)\s+\S+\s+|-[\w-]+(?:=\S+)?\s+)*`
14const RUNNER = new RegExp(String.raw`^(?:npx\s+(?:-\S+\s+)*|npm\s+(?:exec|x)\s+(?:-\S+\s+)*|pnpm\s+${PNPM_FLAGS}(?:exec\s+|dlx\s+|run\s+)?|yarn\s+(?:run\s+)?|node\s+(?=\S*node_modules\/\.bin\/)|)(?:\S*node_modules\/\.bin\/)?`)
15const SCRIPT = new RegExp(String.raw`^(?:pnpm\s+${PNPM_FLAGS}(?:run\s+)?|npm\s+run\s+|yarn\s+(?:run\s+)?)([\w:-]+)`)
16const SHELL_C = /^(?:ba)?sh\s+(?:-\w+\s+)*-c\s+(?:'([^']*)'|"((?:[^"\\]|\\.)*)")/
17const CD = /^cd(?:\s+(\S+))?$/
18const NX_LIGHT = /^nx\s+(show|graph|reset|daemon|report|list|--version)\b/
19const NX_TEST = /^nx\s+(?:test\b|run\s+\S+:test(?::\S+)?(?=\s|$))|\s(?:-t|--targets?)[= ]\s*(?:\S*,)?test(?=[,\s]|$)/
20const PARALLEL_ONE = /--parallel[= ]1([^0-9]|$)/
21const WORKER_CAP = /\s(?:--runInBand|-i)(?=\s|$)|\s(?:--maxWorkers|--max-workers|-w)(?:=|\s+)(?![^\s%]*%)\S/
22const JEST_NO_RUN = /\s(?:--listTests|--showConfig|--clearCache|--version|-v|--help|-h)(?=\s|$)/
23const KILL_BY_NAME = /\s(?:-[a-zA-Z]*f[a-zA-Z]*|--full)(?=\s|$)/
24const KILL_VALUE_FLAG = /^(?:-[GgPstuU]|--(?:signal|euid|uid|group|pgroup|parent|session|terminal|ns|nslist))$/
25const WORD = /(?:'[^']*'|"[^"]*"|[^\s'"])+/g
26
27// Splits on control operators and command substitutions outside quotes; a heredoc body is no command.
28export function segments(command: string): string[] {
29 const parts: string[] = []
30 const heredocs: string[] = []
31 let part = ''
32 let quote: '' | "'" | '"' = ''
33 const opened: Array<'' | '"'> = []
34 const cut = () => {
35 parts.push(part.trim())
36 part = ''
37 }
38 for (let i = 0; i < command.length; i++) {
39 const c = command[i] ?? ''
40 const two = command.slice(i, i + 2)
41 if (quote === "'") {
42 quote = c === "'" ? '' : quote
43 part += c
44 } else if (c === '\\') {
45 part += two
46 i++
47 } else if (two === '$(') {
48 cut()
49 opened.push(quote)
50 quote = ''
51 i++
52 } else if (c === '`') {
53 cut()
54 } else if (quote === '"') {
55 quote = c === '"' ? '' : quote
56 part += c
57 } else if (c === "'" || c === '"') {
58 quote = c
59 part += c
60 } else if (two === '<<') {
61 const tag = /^<<-?\s*(['"]?)(\w+)\1/.exec(command.slice(i))?.[2]
62 if (tag !== undefined) {
63 heredocs.push(tag)
64 }
65 part += two
66 i++
67 } else if (c === '\n' && heredocs.length > 0) {
68 cut()
69 for (const tag of heredocs.splice(0)) {
70 const end = new RegExp(String.raw`\n\t*${tag}(?=\n|$)`).exec(command.slice(i))
71 i = end === null ? command.length : i + end.index + end[0].length - 1
72 }
73 } else if (two === '&&' || two === '||') {
74 cut()
75 i++
76 } else if (c === '(') {
77 cut()
78 opened.push('')
79 } else if (c === ')') {
80 cut()
81 quote = opened.pop() ?? ''
82 } else if (';|&\n'.includes(c)) {
83 cut()
84 } else {
85 part += c
86 }
87 }
88 cut()
89 return parts.filter(p => p.length > 0)
90}
91
92function resolvePath(dir: string, home: string, target = '~'): string {
93 const raw = target.replace(/^(['"])(.*)\1$/, '$2')
94 if (raw === '-') {
95 return dir
96 }
97 const path = raw.startsWith('~') ? home + raw.slice(1) : raw.startsWith('/') ? raw : `${dir}/${raw}`
98 const parts: string[] = []
99 for (const part of path.split('/')) {
100 if (part === '..') {
101 parts.pop()
102 } else if (part !== '' && part !== '.') {
103 parts.push(part)
104 }
105 }
106 return `/${parts.join('/')}`
107}
108
109// `FOO=1 timeout 60 pnpm exec jest x` → line `pnpm exec jest x`, tool `jest`, call `jest x`; a `bash -c '…'` is opened up.
110export function invocations(command: string, shell: Shell): Invocation[] {
111 let dir = shell.cwd
112 return segments(command).flatMap(part => {
113 const line = part.replace(PREFIX, '').replace(/[\s}]+$/, '')
114 const cd = CD.exec(line)
115 if (cd !== null) {
116 dir = resolvePath(dir, shell.home, cd[1])
117 }
118 const inner = SHELL_C.exec(line)
119 const nested = inner === null ? [] : invocations(inner[1] ?? (inner[2] ?? '').replace(/\\(.)/g, '$1'), { ...shell, cwd: dir })
120 const call = line.replace(RUNNER, '')
121 return [{ line, tool: call.split(/\s/)[0] ?? '', call, script: SCRIPT.exec(line)?.[1] ?? null, dir }, ...nested]
122 })
123}
124
125function liteSibling(script: string, scripts: Record<string, string>): string | null {
126 if (script.endsWith(':lite')) {
127 return null
128 }
129 return scripts[`${script}:lite`] === undefined ? null : script
130}
131
132function words(line: string): string[] {
133 return line.match(WORD) ?? []
134}
135
136// The first operand that is neither a flag nor a flag's value.
137function killPattern(line: string): string {
138 const args = words(line).slice(1)
139 for (let i = 0; i < args.length; i++) {
140 const arg = args[i] ?? ''
141 if (KILL_VALUE_FLAG.test(arg)) {
142 i++
143 } else if (!arg.startsWith('-')) {
144 return arg
145 }
146 }
147 return ''
148}
149
150function invocationRefusal({ line, tool, call, script }: Invocation, scripts: Record<string, string>): string | null {
151 if (tool === 'nx' && /^nx\s+(affected|run-many)\b/.test(call) && !PARALLEL_ONE.test(call)) {
152 return "nx affected/run-many must run with --parallel=1. Prefer the repo's :lite script (pnpm run lint:affected:lite, typecheck:lite, test:affected:lite)."
153 }
154 const base = script === null ? null : liteSibling(script, scripts)
155 if (base !== null) {
156 return `use 'pnpm run ${base}:lite' instead of '${script}'.`
157 }
158 if (script === 'ci:local') {
159 return 'ci:local runs everything in sequence (~45 min here). Push and let the pipeline be the gate.'
160 }
161 if (/^(?:\S*\/)?p(kill|grep)\s/.test(line) && KILL_BY_NAME.test(line) && !killPattern(line).includes('[')) {
162 return "pkill -f / pgrep -f with a plain pattern matches its own wrapper and kills it (exit 143). Stop it by port (fuser -k 4700/tcp), by saved PID, or bracket the pattern: pkill -f '[n]x serve'."
163 }
164 const isJest = (tool === 'jest' && !JEST_NO_RUN.test(call)) || (tool === 'nx' && NX_TEST.test(call))
165 if (isJest && !WORKER_CAP.test(call)) {
166 return 'jest runs need --maxWorkers=2 (or --runInBand): its pool otherwise sizes itself off the CPU count, per task.'
167 }
168 return null
169}
170
171export function refusal(calls: readonly Invocation[], scripts: ScriptsByDir): string | null {
172 for (const call of calls) {
173 const refused = invocationRefusal(call, scripts[call.dir] ?? {})
174 if (refused !== null) {
175 return refused
176 }
177 }
178 return null
179}
180
181export function isHeavy(calls: readonly Invocation[]): boolean {
182 return calls.some(
183 ({ line, tool, call, script }) =>
184 (HEAVY_TOOLS.includes(tool) && !NX_LIGHT.test(call)) ||
185 (script !== null && /^(test|build|lint|typecheck|e2e|serve)/.test(script) && !script.endsWith(':lite')) ||
186 /^docker(-compose|\s+compose)\b.*\sup\b/.test(line),
187 )
188}
189
190export function crowdedRefusal(pressure: Pressure): string | null {
191 const used = pressure.usedBytes / GIB
192 const max = pressure.maxBytes / GIB
193 if (pressure.usedBytes <= pressure.maxBytes * FULL_SHARE && pressure.psiAvg10 <= PSI_LIMIT) {
194 return null
195 }
196 return `claude-cmd.slice is at ${used.toFixed(1)}/${max.toFixed(0)}G, pressure ${pressure.psiAvg10.toFixed(0)}%. Wait for a running build or test to finish, or push and let CI run it.`
197}
198
199function runsNode({ line, tool }: Invocation): boolean {
200 const program = (line.split(/\s/)[0] ?? '').replace(/^.*\//, '')
201 return NODE_TOOLS.includes(program) || NODE_TOOLS.includes(tool)
202}
203
204// Leading `cd`s stay outside the wrapper so the shell's working directory still moves.
205export function scoped(command: string): string {
206 const cds = /^(?:\s*cd(?:\s+(?:'[^']*'|"[^"]*"|[^\s;&|'"]+))?\s*(?:&&|;))*/.exec(command)?.[0] ?? ''
207 const rest = command.slice(cds.length).trimStart()
208 if (/^systemd-run/.test(rest) || !invocations(rest, { cwd: '/', home: '' }).some(runsNode)) {
209 return command
210 }
211 const quoted = `'${rest.replace(/'/g, `'\\''`)}'`
212 const wrapped = `systemd-run --user --scope -q --slice=claude-cmd.slice --expand-environment=no -p MemoryMax=30% -p MemorySwapMax=4% -- bash -c ${quoted}`
213 return cds === '' ? wrapped : `${cds.trim()} ${wrapped}`
214}
215
216export function parsePressure(current: string, max: string, pressure: string): Pressure | null {
217 const usedBytes = Number(current.trim())
218 const maxBytes = Number(max.trim())
219 const psiAvg10 = Number(/^some avg10=([\d.]+)/m.exec(pressure)?.[1] ?? NaN)
220 return [usedBytes, maxBytes, psiAvg10].every(Number.isFinite) ? { usedBytes, maxBytes, psiAvg10 } : null
221}
222
223const MAX_HEAVY_AT_ONCE = 2
224// A test runner, a build or an nx task; the nx daemon and a dev server stay out, they live for hours.
225const HEAVY_PROCESS =
226 /\/jest(?:-cli)?\/bin\/|jest-worker\/build\/workers|\/vitest\/|playwright\/(?:cli\.js|lib\/common\/process)|\/typescript\/(?:bin|lib)\/tsc\b|\/compiler-cli\/bundles\/src\/bin\/ngc|nx\/dist\/bin\/run-executor\.js/
227const SERVE = /\bnx(?:\.js)?\s+serve\b|:serve(?::\S+)?(?:\s|$)/
228
229/** Each scope in claude-cmd.slice with its processes' args, from `ps -eo cgroup=,args=`. */
230function scopesOf(ps: string): string[][] {
231 const scopes = new Map<string, string[]>()
232 for (const row of ps.split('\n')) {
233 const match = /\/claude-cmd\.slice\/(run-[^\s/]+\.scope)\S*\s+(.*)$/.exec(row)
234 if (match) {
235 scopes.set(match[1], [...(scopes.get(match[1]) ?? []), match[2]])
236 }
237 }
238 return [...scopes.values()]
239}
240
241/** Heavy commands running in claude-cmd.slice, one per scope. */
242export function heavyScopes(ps: string): number {
243 return scopesOf(ps).filter(args => !args.some(arg => SERVE.test(arg)) && args.some(arg => HEAVY_PROCESS.test(arg))).length
244}
245
246/** Dev servers running in claude-cmd.slice, one per scope. */
247export function serveScopes(ps: string): number {
248 return scopesOf(ps).filter(args => args.some(arg => SERVE.test(arg))).length
249}
250
251export function isServe(calls: readonly Invocation[]): boolean {
252 return calls.some(({ call, script }) => SERVE.test(call) || (script !== null && /^serve\b/.test(script)))
253}
254
255// The API and one app.
256const MAX_SERVES_AT_ONCE = 2
257
258export function serveRefusal(running: number): string | null {
259 return running < MAX_SERVES_AT_ONCE
260 ? null
261 : `${running} dev servers are already running (at most ${MAX_SERVES_AT_ONCE}: the API and one app). Stop one by its port first (fuser -k 4700/tcp).`
262}
263
264export function concurrencyRefusal(running: number): string | null {
265 return running < MAX_HEAVY_AT_ONCE
266 ? null
267 : `${running} heavy commands are already running (at most ${MAX_HEAVY_AT_ONCE} at once). Wait for one to finish.`
268}
269
270/** A dev server start counts against the dev servers in `ps`, anything else against the heavy commands. */
271export function runningRefusal(calls: readonly Invocation[], ps: string): string | null {
272 return isServe(calls) ? serveRefusal(serveScopes(ps)) : concurrencyRefusal(heavyScopes(ps))
273}
274
275const RUNNER_WRITES: ReadonlyArray<readonly [RegExp, string]> = [
276 [/(?:^|[\s;&|(])git\s+(?:-[Cc]\s+\S+\s+|-\S+\s+)*(?:checkout|restore|reset|stash|clean|commit|push|rebase|revert|apply|cherry-pick|merge|switch|rm|mv|add)\b/, 'changes the working tree or git history'],
277 [/(?:^|[\s;&|(])sed\s+(?:\S+\s+)*?(?:-[a-zA-Z]*i|--in-place)\b/, 'edits a file in place'],
278 [/\s--(?:write|fix)(?:[=\s]|$)/, 'rewrites files'],
279 [/(?:^|[\s;&|(])(?:systemctl\s+(?:--user\s+)?(?:start|stop|restart|kill|reset-failed)|systemd-run\s.*--unit|docker(?:\s+compose)?\s+(?:start|stop|restart|kill|rm|down|up)|pkill|killall|kill|fuser\s+-k)\b/, 'starts or stops a server or process'],
280 [/(?:^|[^<>&\d])>>?(?!>)\s*(?!\/tmp\/|\/dev\/)[^\s&]/, 'writes a file outside /tmp'],
281]
282const FILE_WRITERS = ['tee', 'rm', 'touch', 'cp', 'mv']
283
284/** Whether a tee, rm, touch, cp or mv names a path outside /tmp and /dev; a cp's source is only read. */
285function writesOutsideTmp(command: string): boolean {
286 return segments(command).some(part => {
287 const [path = '', ...args] = words(part.replace(PREFIX, ''))
288 const program = path.replace(/^.*\//, '')
289 if (!FILE_WRITERS.includes(program)) {
290 return false
291 }
292 const operands = args.filter((arg, i) => !/^-|^\d*[<>]/.test(arg) && !/^\d*[<>]+&?$/.test(args[i - 1] ?? ''))
293 return operands.slice(program === 'cp' ? 1 : 0).some(arg => !/^['"]?\/(?:tmp|dev)\//.test(arg))
294 })
295}
296
297/** A check-runner runs checks and reports; anything that changes files, git or servers is refused. */
298export function checkRunnerRefusal(command: string): string | null {
299 const reason = RUNNER_WRITES.find(([pattern]) => pattern.test(command))?.[1] ?? (writesOutsideTmp(command) ? 'writes a file outside /tmp' : null)
300 return reason === null ? null : `a check-runner only runs checks: this command ${reason}. Report the failure instead of fixing it.`
301}
302
303