Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
{
"name": "watchdog",
"description": "Self-referential loop for Claude Code that re-feeds the user's prompt until the task truly stops producing file edits. Uses a headless Haiku classifier to judge convergence, requires the agent to actually call tools before exit (no pure-text 'done' claims), and is hidden from the agent so it cannot cheat. Apache 2.0, derived from ralph-loop.",
"version": "1.2.0",
"version": "1.2.1",
"author": {
"name": "Jonyan Dunh",
"email": "jonyandunh@outlook.com"
Expand All @@ -30,5 +30,5 @@
]
}
],
"version": "1.2.0"
"version": "1.2.1"
}
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "watchdog",
"version": "1.2.0",
"version": "1.2.1",
"description": "Self-referential loop for Claude Code. Re-feeds the same prompt after every turn until files actually stop changing.",
"author": {
"name": "Jonyan Dunh",
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
# the plugin against a local marketplace. None of this should ever land
# in the repo.
.claude/
/tmp/
23 changes: 20 additions & 3 deletions hooks/stop-hook.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
// See the NOTICE file at the repo root for a full summary of changes.

const { readStdinSync } = require('../lib/stdin');
const { info, warn, success, stop } = require('../lib/log');
const { info, warn, success, stop, debug } = require('../lib/log');
const {
getStateFilePath,
exists,
Expand Down Expand Up @@ -57,29 +57,42 @@ function blockAndRefeed(prompt) {
}

function main() {
const t0 = Date.now();
debug(`stop-hook.js entry — pid=${process.pid}, ppid=${process.ppid}, cwd=${process.cwd()}`);

// 0. Find THIS session's Claude Code PID by walking process ancestry.
// This is our state-file naming key. If we can't find it (extremely
// unusual — should only happen outside a real Claude Code session),
// there is nothing we can safely act on, so allow the stop.
const claudePid = findClaudePid();
if (!claudePid) allowStop();
if (!claudePid) {
debug('stop-hook.js: findClaudePid returned null, allowing stop');
allowStop();
}

const stateFile = getStateFilePath(process.cwd(), claudePid);
debug(`stop-hook.js: stateFile=${stateFile}`);

// 1. No state file => no active watchdog for this Claude Code session
// => allow. This is also the natural recursion guard: when our
// headless `claude -p --model haiku ...` subprocess's own Stop hook
// fires, its findClaudePid() returns the HAIKU subprocess's PID
// (not the main session's), so the lookup below misses and the
// recursive hook exits silently without touching anything.
if (!exists(stateFile)) allowStop();
if (!exists(stateFile)) {
debug('stop-hook.js: no state file for this claudePid, allowing stop (likely Haiku recursion)');
allowStop();
}

const state = read(stateFile);
if (!isValid(state)) {
warn('State file corrupted (missing iteration / max_iterations / prompt)');
remove(stateFile);
allowStop();
}
debug(
`stop-hook.js: state loaded — iteration=${state.iteration}, max=${state.max_iterations}, prompt_head='${(state.prompt || '').slice(0, 60)}'`
);

// 2. Read hook input (JSON piped in on stdin by Claude Code).
let hookInput;
Expand Down Expand Up @@ -109,6 +122,9 @@ function main() {
let toolUses;
try {
toolUses = currentTurnToolUses(transcriptPath);
debug(
`stop-hook.js: extracted ${toolUses.length} tool_use entries from transcript — ${toolUses.map((t) => t.tool).join(', ')}`
);
} catch (err) {
if (err.code === 'TRANSCRIPT_NOT_FOUND') {
warn('Transcript file not found');
Expand Down Expand Up @@ -169,6 +185,7 @@ function main() {
allowStop();
}

debug(`stop-hook.js: total hook latency ${Date.now() - t0}ms, decision=block (continue loop)`);
blockAndRefeed(state.prompt);
}

Expand Down
126 changes: 115 additions & 11 deletions lib/claude-pid.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,27 @@
// gets its own fresh Claude Code process with a fresh PID, and its
// own Stop hook's ancestry walk terminates at THAT fresh PID, not the
// main session's PID. No explicit recursion guard needed.
//
// ======================================================================
// Performance note (the Windows fast path):
// ======================================================================
// On Linux, every step of the walk is two file reads from /proc — near
// free. On macOS/BSD each step spawns `ps` twice, which is ~10-30 ms
// per level and still fast enough.
//
// Windows is different. A PowerShell cold start is 1-2 seconds. If we
// did the naïve "spawn a PowerShell per level for comm, then another
// for ppid" walk, a 5-level ancestry would need ten PowerShell spawns
// and cost 10-20 seconds *per hook invocation*, blowing past Claude
// Code's internal hook timeout.
//
// So on Windows we do ONE PowerShell spawn that walks the full
// ancestry in-process (via WMI) and prints a tab-separated table of
// `(pid, name, ppid)` tuples. We parse that in JS. ~1-2 seconds total.

const fs = require('fs');
const cp = require('child_process');
const { debug } = require('./log');

const MAX_WALK_DEPTH = 10;

Expand All @@ -36,12 +54,14 @@ function readProcComm(pid) {
const out = cp.execFileSync('ps', ['-o', 'comm=', '-p', String(pid)], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
});
// macOS returns the full executable path in `comm`. Take the basename.
return out.trim().split('/').pop();
}
// Windows: callers should NOT hit this path — use readAncestryWindows
// instead for batch efficiency. Kept here only as a safety fallback.
if (platform === 'win32') {
// Use PowerShell CIM — more reliable than the deprecated wmic.
const out = cp.execFileSync(
'powershell',
[
Expand All @@ -53,6 +73,7 @@ function readProcComm(pid) {
{
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 10000,
}
);
return out.trim();
Expand All @@ -75,11 +96,13 @@ function readProcPpid(pid) {
const out = cp.execFileSync('ps', ['-o', 'ppid=', '-p', String(pid)], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
});
const n = parseInt(out.trim(), 10);
return Number.isFinite(n) ? n : null;
}
if (platform === 'win32') {
// See readProcComm comment — not the primary Windows path.
const out = cp.execFileSync(
'powershell',
[
Expand All @@ -91,6 +114,7 @@ function readProcPpid(pid) {
{
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 10000,
}
);
const n = parseInt(out.trim(), 10);
Expand All @@ -102,6 +126,53 @@ function readProcPpid(pid) {
return null;
}

// Windows batch path: walk the full ancestry in ONE PowerShell process.
// Returns an array of {pid, name, ppid} tuples ordered child → parent,
// or null on failure. Used by findClaudePid() on Windows to avoid
// spawning PowerShell per ancestry level.
function readAncestryWindows(startPid) {
const script = `$ErrorActionPreference='SilentlyContinue'
$curr = ${startPid}
$out = @()
for ($i = 0; $i -lt ${MAX_WALK_DEPTH}; $i++) {
if ($curr -le 1) { break }
$p = Get-CimInstance Win32_Process -Filter "ProcessId=$curr"
if (-not $p) { break }
$out += "$curr\`t$($p.Name)\`t$($p.ParentProcessId)"
$next = [int]$p.ParentProcessId
if ($next -eq $curr) { break }
$curr = $next
}
$out -join "\`n"`;

try {
const out = cp.execFileSync(
'powershell',
['-NoProfile', '-NonInteractive', '-Command', script],
{
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 15000,
}
);
return out
.trim()
.split(/\r?\n/)
.filter(Boolean)
.map((line) => {
const [pidStr, name, ppidStr] = line.split('\t');
return {
pid: parseInt(pidStr, 10),
name: (name || '').trim(),
ppid: parseInt(ppidStr, 10),
};
});
} catch (err) {
debug(`readAncestryWindows failed: ${err && err.message}`);
return null;
}
}

// Does this process name identify the Claude Code CLI? Comm names differ
// across platforms and install methods:
// Linux / WSL — "claude"
Expand All @@ -128,21 +199,53 @@ function isClaudeProcessName(name) {
function findClaudePid() {
const override = process.env.WATCHDOG_CLAUDE_PID;
if (override && /^[1-9]\d*$/.test(override)) {
return parseInt(override, 10);
const pid = parseInt(override, 10);
debug(`findClaudePid: WATCHDOG_CLAUDE_PID override = ${pid}`);
return pid;
}

let pid = process.ppid;
for (let depth = 0; depth < MAX_WALK_DEPTH && pid && pid > 1; depth++) {
const comm = readProcComm(pid);
if (isClaudeProcessName(comm)) {
return pid;
const start = Date.now();
let result = null;

if (process.platform === 'win32') {
// Windows fast path — one PowerShell process for the whole walk.
const ancestors = readAncestryWindows(process.ppid);
debug(
`findClaudePid[win32]: readAncestryWindows returned ${
ancestors ? ancestors.length + ' entries' : 'null'
}`
);
if (ancestors) {
for (const entry of ancestors) {
if (isClaudeProcessName(entry.name)) {
result = entry.pid;
break;
}
}
}
} else {
// POSIX — per-level walk (fast on Linux via /proc, acceptable on macOS).
let pid = process.ppid;
for (let depth = 0; depth < MAX_WALK_DEPTH && pid && pid > 1; depth++) {
const comm = readProcComm(pid);
if (isClaudeProcessName(comm)) {
result = pid;
break;
}
const nextPid = readProcPpid(pid);
if (!nextPid || nextPid === pid) break;
pid = nextPid;
}
const nextPid = readProcPpid(pid);
if (!nextPid || nextPid === pid) break;
pid = nextPid;
}

return null;
const elapsed = Date.now() - start;
debug(
`findClaudePid took ${elapsed}ms on ${process.platform}, returned ${
result === null ? 'null' : result
}`
);

return result;
}

module.exports = {
Expand All @@ -151,4 +254,5 @@ module.exports = {
_isClaudeProcessName: isClaudeProcessName,
_readProcComm: readProcComm,
_readProcPpid: readProcPpid,
_readAncestryWindows: readAncestryWindows,
};
Loading
Loading