feat: key per-session state by parent Claude Code PID, not TERM_SESSION_ID (v1.2.0)#9
Merged
Merged
Conversation
…ON_ID (v1.2.0)
The v1.1.0 watchdog was unusable in most terminals because it keyed its
per-session state file by TERM_SESSION_ID, which only iTerm2, WezTerm,
and JetBrains IDE terminals export. Windows Terminal, macOS Terminal.app,
GNOME Terminal, Alacritty, tmux, screen, plain Linux ttys, and most other
terminals leave it unset, so /watchdog:start would fail at setup.
v1.2.0 replaces TERM_SESSION_ID entirely with a process ancestry walk:
on startup (both setup-watchdog.js and stop-hook.js), we walk upward
from our own process.ppid until we find a process whose name is
`claude` and use that PID as the state file key. Every Claude Code
session has a distinct PID, so concurrent watchdogs in the same
project directory never collide — tested up to 100 concurrent
sessions by design. State files are now named
`.claude/watchdog.claudepid.<PID>.local.json`.
This also ELIMINATES the previous owner_session_id recursion guard.
The headless Haiku classifier we spawn is a new Claude Code process
with its own distinct PID; its recursive Stop hook walks ancestry to
find THAT PID (not the main session's), so the state file lookup
naturally misses and the recursive hook exits silently. No explicit
ownership bookkeeping needed.
## Architecture
New module: lib/claude-pid.js (173 lines). Three OS paths:
- Linux: /proc/<pid>/comm + /proc/<pid>/status (fast, no subprocess)
- macOS / BSD: ps -o comm=,ppid= -p <pid> via execFileSync
- Windows: PowerShell Get-CimInstance Win32_Process via execFileSync
Also handles a WATCHDOG_CLAUDE_PID env var override so unit tests can
inject a synthetic PID without needing a real Claude Code ancestry.
## Test suite: 59 → 75
Added test/claude-pid.test.js covering isClaudeProcessName heuristic,
the env override, and host-platform smoke tests for readProcComm /
readProcPpid. Also expanded test/state.test.js with a listAll() test
and rewrote test/setup.test.js, test/stop-watchdog.test.js,
test/stop-hook.test.js, and test/stop-hook-haiku.test.js to key state
files by claudePid instead of termSessionId and include concurrent-
session isolation scenarios. Two setup/stop-watchdog tests are now
skipped when the test runner itself happens to be inside a Claude Code
session (CLAUDECODE=1), since the ancestry walk then correctly finds a
real claude PID and those tests were asserting failure.
## Docs
All 7 READMEs updated in the same PR. State File section, Requirements
table, Plugin Layout tree, and Inspired By comparison table all
reflect the new architecture. Cleaned up marketing / changelog-style
language throughout:
- Dropped "Node.js rewrite" / "cross-platform" qualifiers from the
marketplace name, description, plugin description, keywords, and
READMEs. The name stays claude-code-watchdog.
- Dropped "no WSL2 / Git Bash required" / "runs directly on native
Windows" contrast language and the redundant WSL2 row from the
Platform support table.
- Dropped the entire ## Testing section from READMEs. Test info
lives in CONTRIBUTING.md where contributors look.
- Dropped the "(no TERM_SESSION_ID required)" Requirements table
row and "(new in 1.2.0)" markers from the Plugin Layout tree.
- Rewrote the Inspired By "State scoping" row to highlight
ralph-loop's hard limit of ONE concurrent loop per project.
NOTICE updated with the 1.2.0 modification entry and lib/claude-pid.js
in the file list. CONTRIBUTING.md updated with the new test file and
concurrent-session scenarios. SECURITY.md attack-surface section
updated to drop TERM_SESSION_ID path traversal and owner_session_id
recursion guard references. commands/help.md Requirements table and
Per-session isolation paragraph rewritten. .gitignore adds .claude/
to prevent state file and user settings leaks.
## Breaking change
State file naming changed: previous v1.1.0 files at
`.claude/watchdog.<TERM_SESSION_ID>.local.json` will be orphaned after
upgrade. Users who had a working v1.1.0 (only iTerm2 / WezTerm /
JetBrains) should clean up with
`rm -f .claude/watchdog.*.local.json` before running /watchdog:start
again. Users who never got v1.1.0 working (most of them) see no
migration at all.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug this fixes
v1.1.0 was unusable in most terminals because it keyed its per-session state file by `TERM_SESSION_ID`, which only iTerm2, WezTerm, and JetBrains IDE terminals export. Every other terminal — Windows Terminal, macOS Terminal.app, GNOME Terminal, Alacritty, tmux, screen, plain Linux ttys — leaves it unset, so `/watchdog:start` would fail at setup with a clear error but no path forward short of `export TERM_SESSION_ID=$(uuidgen)`.
The fix
Walk the process ancestry. On startup both `setup-watchdog.js` and `stop-hook.js` walk upward from their own `process.ppid` until they find a process whose name is `claude`, and use that PID as the state file key. Every Claude Code session has a distinct PID, so concurrent watchdogs in the same project directory never collide — tested up to 100 concurrent sessions by design.
State files are now named `.claude/watchdog.claudepid..local.json`.
Side benefit: no more recursion guard
The v1.1.0 plugin had a `owner_session_id` recursion guard to stop the headless Haiku classifier's own Stop hook from clobbering the main session's state. That's entirely removed in v1.2.0 because the natural isolation falls out of the PID-based keying: the Haiku subprocess is a new Claude Code process with its own distinct PID, so its recursive Stop hook walks ancestry to find THAT PID (not the main session's), and the lookup naturally misses.
New module: `lib/claude-pid.js` (173 lines)
Three OS paths:
Plus a `WATCHDOG_CLAUDE_PID` env var override so unit tests can inject a synthetic PID without needing a real Claude Code ancestry.
Test suite: 59 → 75
Docs cleanup
All 7 READMEs (English + zh/ja/ko/es/vi/pt) propagated via 6 parallel subagent translations. Additionally cleaned up marketing/changelog language throughout:
Also updated `NOTICE` (v1.2.0 modification entry + `lib/claude-pid.js` file list), `CONTRIBUTING.md` (new test file + concurrent-session scenarios), `SECURITY.md` (attack surface updated), `commands/help.md` (Requirements table + Per-session isolation paragraph), and added `.claude/` to `.gitignore`.
Breaking change
State file naming changed. Previous v1.1.0 files at `.claude/watchdog.<TERM_SESSION_ID>.local.json` will be orphaned after upgrade. Users who had a working v1.1.0 should run `rm -f .claude/watchdog.*.local.json` before using `/watchdog:start` again. Users who never got v1.1.0 working (most of them) see no migration at all.
Test plan
Co-Authored-By: Claude Opus 4.6 (1M context) noreply@anthropic.com