HookStack
Back to catalogue
WorkflowStop

Auto-disable Stop hook after N failures

A flaky Stop hook can't trap your session

Per-session deduplication mechanism: uses $PPID as a stable session identifier, counts consecutive failures in /tmp, and automatically disables the hook after ≥3 failures without a fix. Shows reactivation instructions. Resets the counter on success. Prevents infinite asyncRewake re-wake loops.

What does the Auto-disable Stop hook after N failures hook do?

Auto-disable Stop hook after N failures is a Claude Code Stop hook. It fires automatically at that lifecycle event — outside the model, so it can't be skipped or forgotten. A flaky Stop hook can't trap your session.

As a Stop hook it runs after the action, reacting to what just happened rather than blocking it. Because it is a deterministic Node.js script, it executes on every matching event without relying on the model to remember — the guarantee that makes agentic workflows safe to automate.

Use cases

  • Prevent a flaky Stop hook from blocking a session indefinitely via asyncRewake
  • Automatically suspend quality checks when errors are known and being fixed
  • Protect any potentially unstable Stop hook without changing its core logic

Tags

#dedup#session#auto-disable#asyncRewake#ppid#resilience

settings.json fragment

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "asyncRewake": true,
            "command": "node .claude/hooks/quality-check.mjs",
            "type": "command"
          }
        ]
      }
    ]
  }
}

Script · .claude/hooks/session-dedup-autodisable.mjs

#!/usr/bin/env node
// @hookstack stop-session-dedup-autodisable
// Auto-désactive les hooks Stop qui ont échoué ≥ N fois de suite (Stop)
//
// Contrat partagé : un hook Stop qui veut bénéficier du watchdog incrémente
// /tmp/claude-hook-counters/<slug>.counter à chaque échec (et le supprime en cas
// de succès), puis vérifie l'absence de <slug>.disabled avant de s'exécuter.
// Ce hook pose le marqueur .disabled dès que le compteur atteint MAX_FAILURES.
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";

const MAX_FAILURES = 3;

export function run({
	exists = existsSync,
	readdir = readdirSync,
	readFile = readFileSync,
	writeFile = writeFileSync,
	counterDir = "/tmp/claude-hook-counters",
} = {}) {
	if (!exists(counterDir)) return null;

	try {
		const counters = readdir(counterDir).filter((f) => f.endsWith(".counter"));
		const disabled = [];
		for (const f of counters) {
			let count = 0;
			try {
				count = parseInt(readFile(join(counterDir, f), "utf8").trim(), 10) || 0;
			} catch {
				continue;
			}
			if (count < MAX_FAILURES) continue;
			const slug = f.replace(".counter", "");
			const marker = join(counterDir, `${slug}.disabled`);
			if (!exists(marker)) writeFile(marker, "");
			disabled.push(slug);
		}

		if (!disabled.length) return null;

		const message =
			`[session-dedup] Hooks désactivés (${MAX_FAILURES}+ échecs) : ${disabled.join(", ")}\n` +
			`[session-dedup] Supprimez ${counterDir}/<slug>.disabled (et .counter) pour réactiver.\n`;
		return { disabled, message };
	} catch {
		// Erreur de lecture — ignorer silencieusement
		return null;
	}
}

/* v8 ignore next 4 */
if (process.argv[1] === fileURLToPath(import.meta.url)) {
	const result = run();
	if (result?.message) process.stderr.write(result.message);
}

Learn more

Related hooks