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
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": "claudius",
"version": "5.12.1",
"version": "5.13.0",
"description": "Collection of specialized development agents and skills for Claude Code",
"author": {
"name": "lklimek",
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <job-id>` CLI mode — searches every workspace's `jobs/<job-id>.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
Expand Down
83 changes: 80 additions & 3 deletions scripts/agent-watchdog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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]
Expand All @@ -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.
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Comment on lines +2216 to +2219
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:
Comment on lines +2246 to +2257
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)
Expand Down Expand Up @@ -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: "
Expand Down
2 changes: 2 additions & 0 deletions skills/codex-crew/references/sandbox-and-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ state/<workspace-slug>-<hash>/

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 <dir> --job-id <id>`) carry a real, independently verifiable `pid` — cross-check with `ps -p <pid>` or `/proc/<pid>`; 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 <job-id>` searches every known workspace's `jobs/<job-id>.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**.
Expand Down
87 changes: 87 additions & 0 deletions tests/test_agent_watchdog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading