diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 3faec6c..64d7ec3 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "claudius", - "version": "5.12.1", + "version": "5.13.0", "description": "Collection of specialized development agents and skills for Claude Code", "author": { "name": "lklimek", diff --git a/CHANGELOG.md b/CHANGELOG.md index ee236aa..8d47e18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). This project use ## [Unreleased] +## [5.13.0] - 2026-07-20 + +### Added + +- **`scripts/agent-watchdog.py`**: new one-shot `--dump-job ` CLI mode — searches every workspace's `jobs/.json` directly (bypassing all team/session/ownership discovery), prints the full record (`id`, `status`, `phase`, `pid`, `startedAt`, `completedAt`, `errorMessage`, `result.rawOutput`, `result.touchedFiles`) for each match, and exits without starting the persistent poll loop. Exits 1 with a clear stderr message (no traceback) when the job isn't found or its record is malformed. Replaces the hand-rolled copy-paste `python3` snippet in `skills/codex-crew/references/sandbox-and-recovery.md` § On-Disk Job State with a tested, reusable flag — ported from a doctrine gap found in a user-level `codex-monitoring` skill. + ## [5.12.1] - 2026-07-20 ### Fixed diff --git a/scripts/agent-watchdog.py b/scripts/agent-watchdog.py index 3ac4a05..79209c2 100644 --- a/scripts/agent-watchdog.py +++ b/scripts/agent-watchdog.py @@ -214,6 +214,7 @@ class Options: team_dir: Path | None = None session_id: str = "" + dump_job: str = "" tasks_dir: Path | None = None projects_dir: Path = field( default_factory=lambda: Path.home() / ".claude" / "projects" @@ -2098,7 +2099,8 @@ def poll_once(self, now: int | None = None) -> list[str]: USAGE = """agent-watchdog.py -- edge-triggered agent-stall watchdog (silent when healthy) -Usage: agent-watchdog.py [--session-id ID] [--team-dir DIR] [--tasks-dir DIR] +Usage: agent-watchdog.py [--dump-job ID] [--session-id ID] [--team-dir DIR] + [--tasks-dir DIR] [--projects-dir DIR] [--worktrees DIR] [--watch-subagents] [--no-gone] [--gone-polls N] [--stall-secs N] [--resume-secs N] [--poll-secs N] @@ -2107,6 +2109,7 @@ def poll_once(self, now: int | None = None) -> list[str]: agent's worktree/cwd. An idle agent owning no in_progress task is never flagged. --session-id (default $CLAUDE_SESSION_ID) scopes ALL discovery to THIS session's team; precedence --team-dir > --session-id > $CLAUDE_SESSION_ID > newest autodetect. +--dump-job ID prints every matching Codex job record once and exits. --worktrees precedence: flag > $CLAUDIUS_WORKTREE_ROOT > .claude/worktrees. --codex-job-recency-secs defaults to 604800 (7 days); older job files are not parsed. Emits ONLY transition lines to stdout; diagnostics to stderr. @@ -2141,6 +2144,7 @@ def parse_args(argv: Sequence[str], env: Mapping[str, str] | None = None) -> Opt ) index = 0 value_flags = { + "--dump-job", "--team-dir", "--session-id", "--tasks-dir", @@ -2170,7 +2174,9 @@ def parse_args(argv: Sequence[str], env: Mapping[str, str] | None = None) -> Opt if index + 1 >= len(argv): die(f"{argument} needs a value") value = argv[index + 1] - if argument == "--team-dir": + if argument == "--dump-job": + options.dump_job = value + elif argument == "--team-dir": options.team_dir = Path(value) elif argument == "--session-id": options.session_id = value @@ -2205,6 +2211,75 @@ def parse_args(argv: Sequence[str], env: Mapping[str, str] | None = None) -> Opt return options +def dump_job(job_id: str, env: Mapping[str, str] | None = None) -> int: + """Print every Codex job record matching an id and return its exit status.""" + state_root = codex_state_root(env) + try: + matches = sorted(state_root.glob(f"*/jobs/{job_id}.json")) + except OSError as error: + print( + f"agent-watchdog: unable to search for job {job_id!r} under " + f"{state_root} ({type(error).__name__})", + file=sys.stderr, + flush=True, + ) + return 1 + if not matches: + print( + f"agent-watchdog: no job {job_id!r} found under {state_root}", + file=sys.stderr, + flush=True, + ) + return 1 + + printed = False + fields = ( + "id", + "status", + "phase", + "pid", + "startedAt", + "completedAt", + "errorMessage", + ) + for path in matches: + try: + with path.open(encoding="utf-8") as handle: + record = json.load(handle) + if not isinstance(record, dict): + raise TypeError("expected a JSON object") + except ( + OSError, + UnicodeError, + json.JSONDecodeError, + RecursionError, + TypeError, + ) as error: + print( + f"agent-watchdog: unable to read job {job_id!r} at {path} " + f"({type(error).__name__})", + file=sys.stderr, + flush=True, + ) + continue + + print(f"path = {path}") + for field_name in fields: + print( + f"{field_name} = " + f"{json.dumps(record.get(field_name), ensure_ascii=False)}" + ) + result = record.get("result") + if isinstance(result, dict): + for field_name in ("rawOutput", "touchedFiles"): + print( + f"{field_name} = " + f"{json.dumps(result.get(field_name), ensure_ascii=False)}" + ) + printed = True + return 0 if printed else 1 + + def _command_exists(command: str, env: Mapping[str, str] | None = None) -> bool: environment = os.environ if env is None else env path = environment.get("PATH", os.defpath) @@ -2274,8 +2349,10 @@ def _write_crash_log( def main(argv: Sequence[str] | None = None) -> int: - """Run the persistent monitor until interrupted.""" + """Run a one-shot inspection or the persistent monitor.""" options = parse_args(sys.argv[1:] if argv is None else argv) + if options.dump_job: + return dump_job(options.dump_job) monitor = Watchdog(options) print( "agent-watchdog: " diff --git a/skills/codex-crew/references/sandbox-and-recovery.md b/skills/codex-crew/references/sandbox-and-recovery.md index 1820f7b..4c8795c 100644 --- a/skills/codex-crew/references/sandbox-and-recovery.md +++ b/skills/codex-crew/references/sandbox-and-recovery.md @@ -67,6 +67,8 @@ state/-/ Per-job `.json` fields worth reading: `id`, `status` (`pending` | `running` | `completed` | `failed`), `phase`, `errorMessage`, `startedAt`, `completedAt`, `workspaceRoot`, `logFile`, `pid`, `result`. `pid` is `null` for some job classes (running inside the shared app-server), but `task-worker`-class jobs (spawned as `codex-companion.mjs task-worker --cwd --job-id `) carry a real, independently verifiable `pid` — cross-check with `ps -p ` or `/proc/`; a populated `pid` with no matching process means the Codex engine crashed silently while `status` stays stuck at `"running"` forever (the record is never updated on crash). `result.rawOutput` is the full final report text — read it directly instead of waiting for the wrapper agent to relay it, and `result.touchedFiles` lists the worktree/files the job actually edited. +**One-shot lookup by job id**: `scripts/agent-watchdog.py --dump-job ` searches every known workspace's `jobs/.json` directly (no team/session/worktree setup needed) and prints the full record — including `result.rawOutput`/`result.touchedFiles` — then exits. Prefer this over hand-rolling the `python3 -c "..."` read yourself; it's tested and handles the not-found and malformed-record cases cleanly (exit 1, clear stderr, no traceback). + **Memory discipline (long sessions).** The watchdog polls repeatedly. Do **not** parse `state.json` per poll — it embeds every job and grows over the session. Instead: - Use the newest mtime under `jobs/` (or `state.json`'s mtime) as the cheap **activity clock**. diff --git a/tests/test_agent_watchdog.py b/tests/test_agent_watchdog.py index 39f08d5..55a71b2 100644 --- a/tests/test_agent_watchdog.py +++ b/tests/test_agent_watchdog.py @@ -2034,6 +2034,93 @@ def test_cli_zero_arguments_exit_1(tmp_path: Path) -> None: ) +def test_dump_job_prints_full_record_without_starting_monitor( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """The one-shot CLI prints the complete requested job view and exits.""" + ws = workspace(tmp_path) + state_dir, env = codex_store( + tmp_path, + ws, + [ + { + "id": "dump-me", + "status": "completed", + "phase": "finalizing", + "pid": 1234, + "startedAt": "2026-07-20T10:00:00Z", + "completedAt": "2026-07-20T10:05:00Z", + "errorMessage": None, + "result": { + "rawOutput": "done\nwith details", + "touchedFiles": ["scripts/example.py", "tests/test_example.py"], + }, + } + ], + ) + monkeypatch.setenv("CLAUDE_PLUGIN_DATA", env["CLAUDE_PLUGIN_DATA"]) + + def fail_monitor(_options: watchdog.Options) -> None: + raise AssertionError("dump mode must not start the persistent monitor") + + monkeypatch.setattr(watchdog, "Watchdog", fail_monitor) + + assert watchdog.main(["--dump-job", "dump-me"]) == 0 + + assert capsys.readouterr().out.splitlines() == [ + f"path = {state_dir / 'jobs' / 'dump-me.json'}", + 'id = "dump-me"', + 'status = "completed"', + 'phase = "finalizing"', + "pid = 1234", + 'startedAt = "2026-07-20T10:00:00Z"', + 'completedAt = "2026-07-20T10:05:00Z"', + "errorMessage = null", + 'rawOutput = "done\\nwith details"', + 'touchedFiles = ["scripts/example.py", "tests/test_example.py"]', + ] + + +def test_dump_job_not_found_is_a_clean_error( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """An unknown dump id reports the searched root without a traceback.""" + plugin_data = tmp_path / "plugin-data" + monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(plugin_data)) + + assert watchdog.main(["--dump-job", "missing-job"]) == 1 + + captured = capsys.readouterr() + assert captured.out == "" + assert f"no job 'missing-job' found under {plugin_data / 'state'}" in captured.err + assert "Traceback" not in captured.err + + +def test_dump_job_reports_malformed_record( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """A corrupt matching job file produces a clear one-shot error.""" + ws = workspace(tmp_path) + state_dir, env = codex_store(tmp_path, ws, [{"id": "broken-job"}]) + job_path = state_dir / "jobs" / "broken-job.json" + job_path.write_text('{"id": "broken-job"', encoding="utf-8") + monkeypatch.setenv("CLAUDE_PLUGIN_DATA", env["CLAUDE_PLUGIN_DATA"]) + + assert watchdog.main(["--dump-job", "broken-job"]) == 1 + + captured = capsys.readouterr() + assert captured.out == "" + assert str(job_path) in captured.err + assert "JSONDecodeError" in captured.err + assert "Traceback" not in captured.err + + def test_cli_preserves_all_flags_and_defaults(tmp_path: Path) -> None: """Every Bash flag and default remains available on the Python CLI.""" defaults = watchdog.parse_args(