diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f7187b6..93cf4b2 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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" @@ -30,5 +30,5 @@ ] } ], - "version": "1.2.0" + "version": "1.2.1" } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index c6288d0..2c6c8d5 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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", diff --git a/.gitignore b/.gitignore index 70f9b44..da5a5a3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ # the plugin against a local marketplace. None of this should ever land # in the repo. .claude/ +/tmp/ diff --git a/hooks/stop-hook.js b/hooks/stop-hook.js index b1b66f7..bb8220a 100644 --- a/hooks/stop-hook.js +++ b/hooks/stop-hook.js @@ -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, @@ -57,14 +57,21 @@ 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 @@ -72,7 +79,10 @@ function main() { // 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)) { @@ -80,6 +90,9 @@ function main() { 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; @@ -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'); @@ -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); } diff --git a/lib/claude-pid.js b/lib/claude-pid.js index 30fcd0d..cacbb28 100644 --- a/lib/claude-pid.js +++ b/lib/claude-pid.js @@ -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; @@ -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', [ @@ -53,6 +73,7 @@ function readProcComm(pid) { { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + timeout: 10000, } ); return out.trim(); @@ -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', [ @@ -91,6 +114,7 @@ function readProcPpid(pid) { { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + timeout: 10000, } ); const n = parseInt(out.trim(), 10); @@ -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" @@ -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 = { @@ -151,4 +254,5 @@ module.exports = { _isClaudeProcessName: isClaudeProcessName, _readProcComm: readProcComm, _readProcPpid: readProcPpid, + _readAncestryWindows: readAncestryWindows, }; diff --git a/lib/judge.js b/lib/judge.js index bdbd6da..7aaf7f6 100644 --- a/lib/judge.js +++ b/lib/judge.js @@ -4,6 +4,29 @@ // short-lived `claude -p --model haiku --no-session-persistence` subprocess // whether any of them directly modified a project file. The output is a // single distinctive marker token: FILE_CHANGES or NO_FILE_CHANGES. +// +// Prompt delivery: +// Prompt is passed via **stdin**, not as a positional argv arg. On +// Windows, spawnSync with `shell: true` (required to find claude.cmd +// through PATHEXT) forwards the whole command line through cmd.exe, +// whose quoting rules destroy complex strings containing newlines, +// CJK characters, double quotes, or JSON braces. The prompt text is +// exactly that kind of payload — a templated classifier prompt with +// a JSON-serialized tool_use array embedded in it. Passing it via +// stdin sidesteps cmd.exe entirely: the shell never sees the text. +// On Linux/macOS (shell: false), stdin works identically and we get +// a single code path across OSes. The `claude -p` CLI reads stdin +// when no positional prompt arg is provided. +// +// Performance notes: +// - No pre-flight `claude --version` probe. On Windows it took an extra +// ~2-3 s (PowerShell cold-start through shell:true) for zero value: +// if the CLI is missing, the real call below will fail with ENOENT +// and we just classify that as CLI_MISSING. +// - 30-second timeout on the real Haiku call. If the Claude CLI ever +// hangs — network stall, auth refresh, etc. — the hook aborts cleanly +// instead of running past Claude Code's internal hook timeout and +// leaving the user with a stuck loop. const { spawnSync } = require('child_process'); const { @@ -11,6 +34,9 @@ const { MARKER_FILE_CHANGES, MARKER_NO_FILE_CHANGES, } = require('./constants'); +const { debug } = require('./log'); + +const HAIKU_TIMEOUT_MS = 30000; const VERDICT = Object.freeze({ FILE_CHANGES: 'FILE_CHANGES', @@ -35,49 +61,67 @@ function parseVerdict(rawOutput) { return VERDICT.AMBIGUOUS; } -function claudeCliAvailable() { - // Cheapest cross-platform probe: try to run `claude --version`. On Windows - // we need shell:true so .cmd shims resolve. - const isWindows = process.platform === 'win32'; - const result = spawnSync('claude', ['--version'], { - stdio: 'ignore', - shell: isWindows, - }); - return result.status === 0; -} - // Ask Haiku. Returns one of the VERDICT constants plus (for the normal cases) // the raw output string for logging/ambiguous fallback diagnostics. function askHaiku(toolUses) { - if (!claudeCliAvailable()) { - return { verdict: VERDICT.CLI_MISSING, raw: null }; - } - const promptText = JUDGMENT_PROMPT_TEMPLATE(JSON.stringify(toolUses)); const isWindows = process.platform === 'win32'; + debug(`askHaiku: starting subprocess (timeout=${HAIKU_TIMEOUT_MS}ms, tools=${toolUses.length})`); + const start = Date.now(); + const result = spawnSync( 'claude', - ['-p', '--model', 'haiku', '--no-session-persistence', promptText], + ['-p', '--model', 'haiku', '--no-session-persistence'], { - input: '', // close stdin — avoids the 3-second "no stdin data" warning + input: promptText, // prompt via stdin — avoids cmd.exe quoting on Windows encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], shell: isWindows, maxBuffer: 1024 * 1024, + timeout: HAIKU_TIMEOUT_MS, } ); - if (result.error || result.status !== 0) { + const elapsed = Date.now() - start; + debug( + `askHaiku subprocess finished in ${elapsed}ms (exit=${result.status}, signal=${ + result.signal || 'none' + })` + ); + + if (result.error) { + // ENOENT = `claude` not in PATH + if (result.error.code === 'ENOENT') { + debug('askHaiku: claude CLI not found in PATH (ENOENT)'); + return { verdict: VERDICT.CLI_MISSING, raw: null }; + } + // ETIMEDOUT = hit our HAIKU_TIMEOUT_MS guard + debug(`askHaiku: spawn error ${result.error.code || '?'}: ${result.error.message}`); return { verdict: VERDICT.CLI_FAILED, exitCode: result.status, - error: result.error && result.error.message, + error: result.error.message, raw: result.stdout, }; } - return { verdict: parseVerdict(result.stdout), raw: result.stdout }; + if (result.status !== 0) { + debug( + `askHaiku: subprocess exited ${result.status} (stderr: ${(result.stderr || '').slice(0, 200)})` + ); + return { + verdict: VERDICT.CLI_FAILED, + exitCode: result.status, + raw: result.stdout, + }; + } + + const verdict = parseVerdict(result.stdout); + debug( + `askHaiku verdict: ${verdict} (raw head: ${(result.stdout || '').slice(0, 80).replace(/\n/g, ' ')})` + ); + return { verdict, raw: result.stdout }; } // claudeCliAvailable() is intentionally NOT exported — it is an internal diff --git a/lib/log.js b/lib/log.js index 2c4d49c..55dad2b 100644 --- a/lib/log.js +++ b/lib/log.js @@ -1,27 +1,79 @@ 'use strict'; -// Diagnostic logging. Every message goes to stderr so it never leaks into -// the agent's context (Claude Code captures slash command stdout as the -// user turn, stderr is shown to the user as local command output only). +// Diagnostic logging. Every user-visible message goes to stderr so it +// never leaks into the agent's context (Claude Code captures slash +// command stdout as the user turn, stderr is shown to the user as local +// command output only). +// +// Optional file logging: set CLAUDE_CODE_WATCHDOG_LOG_ENABLED=1 to mirror +// every log line to a file at `os.tmpdir() + '/claude-code-watchdog.log'` +// (override via CLAUDE_CODE_WATCHDOG_LOG_FILE). The file is shared across +// all concurrent watchdog invocations; each line is prefixed with the +// current script's PID so you can grep by session. +// +// The debug() level is file-only — it never touches stderr — so we can +// sprinkle it liberally through hot paths without polluting the user's +// terminal when logging is disabled. + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const LOG_ENABLED = process.env.CLAUDE_CODE_WATCHDOG_LOG_ENABLED === '1'; +const LOG_FILE = + process.env.CLAUDE_CODE_WATCHDOG_LOG_FILE || + path.join(os.tmpdir(), 'claude-code-watchdog.log'); + +function writeFileLog(level, message) { + if (!LOG_ENABLED) return; + try { + const ts = new Date().toISOString(); + const line = `${ts} [pid=${process.pid}] ${level.padEnd(5)} ${message}\n`; + fs.appendFileSync(LOG_FILE, line); + } catch { + // Never break the real hook path on log-write failure. + } +} function info(message) { process.stderr.write(`ℹ️ Watchdog: ${message}\n`); + writeFileLog('info', message); } function warn(message) { process.stderr.write(`⚠️ Watchdog: ${message}\n`); + writeFileLog('warn', message); } function success(message) { process.stderr.write(`✅ Watchdog: ${message}\n`); + writeFileLog('ok', message); } function stop(message) { process.stderr.write(`🛑 Watchdog: ${message}\n`); + writeFileLog('stop', message); } function error(message) { process.stderr.write(`❌ Error: ${message}\n`); + writeFileLog('error', message); +} + +// File-only verbose trace. Use freely in hot paths — zero overhead when +// logging is disabled (short-circuits on the LOG_ENABLED constant). +function debug(message) { + writeFileLog('debug', message); } -module.exports = { info, warn, success, stop, error }; +module.exports = { + info, + warn, + success, + stop, + error, + debug, + // Exposed for tests. + _LOG_ENABLED: LOG_ENABLED, + _LOG_FILE: LOG_FILE, +}; diff --git a/scripts/setup-watchdog.js b/scripts/setup-watchdog.js index e2dd5b0..7663e1e 100644 --- a/scripts/setup-watchdog.js +++ b/scripts/setup-watchdog.js @@ -12,7 +12,7 @@ // coreutils with a single cross-platform Node file. See NOTICE at the // repo root for the full change list. -const { error } = require('../lib/log'); +const { error, debug } = require('../lib/log'); const { create } = require('../lib/state'); const { findClaudePid } = require('../lib/claude-pid'); @@ -107,12 +107,15 @@ function main() { process.exit(1); } - create({ + const { filePath } = create({ cwd: process.cwd(), claudePid, prompt, maxIterations: parsed.maxIterations, }); + debug( + `setup-watchdog.js: created state file ${filePath} — claudePid=${claudePid}, max=${parsed.maxIterations}, prompt_head='${prompt.slice(0, 60)}'` + ); // Output ONLY the user's prompt to stdout. Everything Claude Code captures // from stdout becomes the first user turn of the loop, and the agent must diff --git a/scripts/stop-watchdog.js b/scripts/stop-watchdog.js index f057352..f4036e0 100644 --- a/scripts/stop-watchdog.js +++ b/scripts/stop-watchdog.js @@ -9,7 +9,7 @@ // Copyright Anthropic, PBC. Licensed under the Apache License, Version 2.0. // Node.js rewrite by Jonyan Dunh, 2026. -const { error } = require('../lib/log'); +const { error, debug } = require('../lib/log'); const { getStateFilePath, exists, read, remove } = require('../lib/state'); const { findClaudePid } = require('../lib/claude-pid'); @@ -26,8 +26,10 @@ function main() { } const filePath = getStateFilePath(process.cwd(), claudePid); + debug(`stop-watchdog.js: targeting state file ${filePath} (claudePid=${claudePid})`); if (!exists(filePath)) { + debug('stop-watchdog.js: state file does not exist'); process.stdout.write('No active watchdog for this session.\n'); process.exit(0); } @@ -37,6 +39,7 @@ function main() { const iter = state && Number.isInteger(state.iteration) ? state.iteration : '?'; remove(filePath); + debug(`stop-watchdog.js: removed state file at iteration ${iter}`); process.stdout.write(`Cancelled watchdog (was at iteration ${iter}).\n`); process.exit(0); } diff --git a/test/log.test.js b/test/log.test.js new file mode 100644 index 0000000..0ee82eb --- /dev/null +++ b/test/log.test.js @@ -0,0 +1,142 @@ +'use strict'; + +// Unit tests for lib/log.js — specifically the optional file-logging +// path gated by CLAUDE_CODE_WATCHDOG_LOG_ENABLED=1. +// +// We can't import lib/log.js directly and then flip the env var, because +// the module captures LOG_ENABLED at load time. So each test spawns a +// throwaway node subprocess with the env var set, runs a one-liner that +// calls each log level, and then reads back the log file. + +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +let tmpDir; + +before(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'watchdog-log-test-')); +}); + +after(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function runChildWithLogging(snippet, extraEnv = {}) { + return spawnSync('node', ['-e', snippet], { + env: Object.assign({}, process.env, extraEnv), + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +test('with logging DISABLED (default) the log file is NOT created', () => { + const logFile = path.join(tmpDir, 'default-disabled.log'); + // Explicitly ensure the env var is unset in the child. + const env = Object.assign({}, process.env); + delete env.CLAUDE_CODE_WATCHDOG_LOG_ENABLED; + env.CLAUDE_CODE_WATCHDOG_LOG_FILE = logFile; + const result = spawnSync( + 'node', + ['-e', `const l=require('./lib/log'); l.info('x'); l.debug('y');`], + { env, encoding: 'utf8', cwd: path.resolve(__dirname, '..') } + ); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /Watchdog: x/); + // Debug should be silent when disabled. + assert.doesNotMatch(result.stderr, /y/); + assert.equal(fs.existsSync(logFile), false); +}); + +test('with logging ENABLED, info/warn/success/stop/error all mirror to the log file', () => { + const logFile = path.join(tmpDir, 'enabled-mirror.log'); + const snippet = ` + const l = require('./lib/log'); + l.info('info msg'); + l.warn('warn msg'); + l.success('ok msg'); + l.stop('stop msg'); + l.error('err msg'); + `; + const result = runChildWithLogging(snippet, { + CLAUDE_CODE_WATCHDOG_LOG_ENABLED: '1', + CLAUDE_CODE_WATCHDOG_LOG_FILE: logFile, + }); + assert.equal(result.status, 0, result.stderr); + + assert.equal(fs.existsSync(logFile), true); + const contents = fs.readFileSync(logFile, 'utf8'); + assert.match(contents, /info msg/); + assert.match(contents, /warn msg/); + assert.match(contents, /ok msg/); + assert.match(contents, /stop msg/); + assert.match(contents, /err msg/); + // Every line should have the structured prefix. + const lines = contents.trim().split('\n'); + assert.equal(lines.length, 5); + for (const line of lines) { + assert.match(line, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z \[pid=\d+\] \w+\s+/); + } +}); + +test('debug() is FILE-ONLY — never touches stderr even when enabled', () => { + const logFile = path.join(tmpDir, 'debug-silent.log'); + const snippet = ` + const l = require('./lib/log'); + l.debug('secret trace'); + `; + const result = runChildWithLogging(snippet, { + CLAUDE_CODE_WATCHDOG_LOG_ENABLED: '1', + CLAUDE_CODE_WATCHDOG_LOG_FILE: logFile, + }); + assert.equal(result.status, 0); + assert.doesNotMatch(result.stderr, /secret trace/); + // But it IS in the file. + assert.match(fs.readFileSync(logFile, 'utf8'), /secret trace/); +}); + +test('log file is appended-to, not overwritten (multi-process safety)', () => { + const logFile = path.join(tmpDir, 'append.log'); + const snippet = (msg) => `require('./lib/log').info('${msg}');`; + runChildWithLogging(snippet('first'), { + CLAUDE_CODE_WATCHDOG_LOG_ENABLED: '1', + CLAUDE_CODE_WATCHDOG_LOG_FILE: logFile, + }); + runChildWithLogging(snippet('second'), { + CLAUDE_CODE_WATCHDOG_LOG_ENABLED: '1', + CLAUDE_CODE_WATCHDOG_LOG_FILE: logFile, + }); + runChildWithLogging(snippet('third'), { + CLAUDE_CODE_WATCHDOG_LOG_ENABLED: '1', + CLAUDE_CODE_WATCHDOG_LOG_FILE: logFile, + }); + const contents = fs.readFileSync(logFile, 'utf8'); + const lines = contents.trim().split('\n'); + assert.equal(lines.length, 3); + assert.match(lines[0], /first/); + assert.match(lines[1], /second/); + assert.match(lines[2], /third/); +}); + +test('log-write failures do not crash the caller', () => { + // Point the log file at an unwritable location (a path inside a file) + // and confirm info() still completes cleanly. + const blocker = path.join(tmpDir, 'blocker'); + fs.writeFileSync(blocker, 'x'); + const unwritable = path.join(blocker, 'cannot-write.log'); + const snippet = ` + const l = require('./lib/log'); + l.info('this should still work'); + console.log('after info'); + `; + const result = runChildWithLogging(snippet, { + CLAUDE_CODE_WATCHDOG_LOG_ENABLED: '1', + CLAUDE_CODE_WATCHDOG_LOG_FILE: unwritable, + }); + assert.equal(result.status, 0); + assert.match(result.stdout, /after info/); + assert.match(result.stderr, /this should still work/); +});