cli-agent-runner 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """Agent Runner — restart-on-exit supervisor for autonomous CLI agents."""
2
+
3
+ __version__ = "0.0.1"
@@ -0,0 +1,200 @@
1
+ """Documentation generator — replaces <!-- gen:NAME --> ... <!-- /gen:NAME -->
2
+ content blocks in docs/*.md from registered renderers.
3
+
4
+ The marker primitive `replace_block` is intentionally separate from the
5
+ renderer registry so the substitution rule is testable in isolation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ import re
12
+ import typing
13
+ from collections.abc import Callable
14
+ from pathlib import Path
15
+
16
+ from agent_runner.config import (
17
+ AgentConfig,
18
+ Config,
19
+ PromptConfig,
20
+ RuntimeConfig,
21
+ VcsConfig,
22
+ )
23
+ from agent_runner.defenses import catalog
24
+ from agent_runner.events import KNOWN_EVENT_KINDS
25
+ from agent_runner.monitor import AUTO_STOP_ALERTS, KNOWN_ALERT_KINDS
26
+
27
+ _SECTIONS = [
28
+ ("agent", AgentConfig),
29
+ ("runtime", RuntimeConfig),
30
+ ("prompt", PromptConfig),
31
+ ("vcs", VcsConfig),
32
+ ]
33
+
34
+
35
+ def _type_label(t: typing.Any) -> str:
36
+ # dataclasses.fields exposes `.type` as a string (PEP 563), so we render
37
+ # the raw annotation. Strip ``Path`` / ``Path | None`` wrappers cosmetically.
38
+ s = str(t).replace("pathlib.", "")
39
+ return s
40
+
41
+
42
+ def _default_label(field: dataclasses.Field) -> str:
43
+ if field.default is not dataclasses.MISSING:
44
+ return repr(field.default)
45
+ if field.default_factory is not dataclasses.MISSING: # type: ignore[misc]
46
+ return repr(field.default_factory())
47
+ return "—"
48
+
49
+
50
+ def render_config_schema_table() -> str:
51
+ """Markdown sub-sections per Config dataclass with field/type/default."""
52
+ parts: list[str] = []
53
+ for name, dc in _SECTIONS:
54
+ parts.append(f"### `[{name}]`")
55
+ parts.append("")
56
+ parts.append("| Field | Type | Default |")
57
+ parts.append("|---|---|---|")
58
+ for f in dataclasses.fields(dc):
59
+ parts.append(f"| `{f.name}` | `{_type_label(f.type)}` | {_default_label(f)} |")
60
+ parts.append("")
61
+ return "\n".join(parts).rstrip()
62
+
63
+
64
+ def replace_block(text: str, name: str, new_content: str) -> str:
65
+ """Replace the body between ``<!-- gen:NAME -->`` and ``<!-- /gen:NAME -->``.
66
+
67
+ The opening / closing markers themselves are preserved. Returns the
68
+ original text unchanged when the opening marker is absent. Raises
69
+ ``ValueError`` when the opening marker is present without a matching close.
70
+ """
71
+ open_tag = f"<!-- gen:{name} -->"
72
+ close_tag = f"<!-- /gen:{name} -->"
73
+ if open_tag not in text:
74
+ return text
75
+ if close_tag not in text:
76
+ raise ValueError(f"<!-- gen:{name} --> has no matching close tag")
77
+ pattern = re.compile(
78
+ re.escape(open_tag) + r".*?" + re.escape(close_tag),
79
+ re.DOTALL,
80
+ )
81
+ return pattern.sub(f"{open_tag}\n{new_content}\n{close_tag}", text)
82
+
83
+
84
+ def _default_cfg() -> Config:
85
+ """Build a default Config for doc rendering — defaults only, no user values."""
86
+ return Config(
87
+ agent=AgentConfig(command=["agent"], prompt_arg_template=[]),
88
+ runtime=RuntimeConfig(
89
+ work_dir=Path("."),
90
+ log_dir=Path("./logs"),
91
+ ),
92
+ prompt=PromptConfig(file=Path("./prompt.md")),
93
+ vcs=VcsConfig(),
94
+ )
95
+
96
+
97
+ def render_defenses_table() -> str:
98
+ """Markdown table of the defense catalog. Renders defaults only."""
99
+ cfg = _default_cfg()
100
+ lines = [
101
+ "| Defense | Codifies | Guarded by |",
102
+ "|---|---|---|",
103
+ ]
104
+ for d in catalog(cfg):
105
+ codifies = d.codifies or "—"
106
+ guarded = str(d.guarded_by) if d.guarded_by is not None else "—"
107
+ lines.append(f"| `{d.name}` | {codifies} | `{guarded}` |")
108
+ return "\n".join(lines)
109
+
110
+
111
+ def render_alert_kinds_list() -> str:
112
+ """Flat bullet list of all known alert kinds, alphabetised."""
113
+ return "\n".join(f"- `{k}`" for k in sorted(KNOWN_ALERT_KINDS))
114
+
115
+
116
+ def render_detector_list() -> str:
117
+ """Bullet list of detectors; auto-stop kinds flagged inline."""
118
+ lines: list[str] = []
119
+ for k in sorted(KNOWN_ALERT_KINDS):
120
+ suffix = " — **auto-stop**" if k in AUTO_STOP_ALERTS else ""
121
+ lines.append(f"- `{k}`{suffix}")
122
+ return "\n".join(lines)
123
+
124
+
125
+ def render_event_kinds_list() -> str:
126
+ """Flat bullet list of all known event kinds, alphabetised."""
127
+ return "\n".join(f"- `{k}`" for k in sorted(KNOWN_EVENT_KINDS))
128
+
129
+
130
+ def render_verb_table() -> str:
131
+ """Walk the argparse subparsers and render a verb table."""
132
+ from agent_runner.cli import _build_parser
133
+
134
+ parser = _build_parser()
135
+ # Find the sub-parsers action — there's exactly one.
136
+ sub_action = next(a for a in parser._actions if a.__class__.__name__ == "_SubParsersAction")
137
+ rows = [
138
+ "| Verb | Description |",
139
+ "|---|---|",
140
+ ]
141
+ for verb, _sp in sub_action.choices.items():
142
+ # Argparse stores help text via `sub_action._choices_actions` indexed by add order.
143
+ help_text = (
144
+ next(
145
+ (c.help for c in sub_action._choices_actions if c.dest == verb),
146
+ "",
147
+ )
148
+ or ""
149
+ )
150
+ rows.append(f"| `{verb}` | {help_text} |")
151
+ return "\n".join(rows)
152
+
153
+
154
+ RENDERERS: dict[str, Callable[[], str]] = {
155
+ "defenses-table": render_defenses_table,
156
+ "alert-kinds": render_alert_kinds_list,
157
+ "detector-list": render_detector_list,
158
+ "event-kinds": render_event_kinds_list,
159
+ "config-schema": render_config_schema_table,
160
+ "verb-table": render_verb_table,
161
+ }
162
+
163
+ _GEN_OPEN = re.compile(r"<!-- gen:([a-z0-9-]+) -->")
164
+
165
+
166
+ def render(docs_dir: Path, *, write: bool = True) -> dict[Path, str]:
167
+ """Render every ``<!-- gen:NAME -->`` block in ``docs_dir/*.md``.
168
+
169
+ Returns a {path: rendered_text} mapping. When ``write=True`` also writes
170
+ the rewritten text back to each path.
171
+
172
+ Raises ``ValueError`` when a marker references an unknown renderer name.
173
+ """
174
+ out: dict[Path, str] = {}
175
+ for md in sorted(docs_dir.glob("*.md")):
176
+ text = md.read_text(encoding="utf-8")
177
+ for match in _GEN_OPEN.finditer(text):
178
+ name = match.group(1)
179
+ if name not in RENDERERS:
180
+ raise ValueError(
181
+ f"{md.name}: unknown gen marker {name!r} — valid names: {sorted(RENDERERS)}"
182
+ )
183
+ try:
184
+ text = replace_block(text, name, RENDERERS[name]())
185
+ except ValueError as e:
186
+ raise ValueError(f"{md.name}: {e}") from e
187
+ out[md] = text
188
+ if write:
189
+ md.write_text(text, encoding="utf-8")
190
+ return out
191
+
192
+
193
+ def main() -> int:
194
+ repo_root = Path(__file__).resolve().parent.parent
195
+ render(docs_dir=repo_root / "docs")
196
+ return 0
197
+
198
+
199
+ if __name__ == "__main__": # pragma: no cover
200
+ raise SystemExit(main())
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,127 @@
1
+ """Agent subprocess management — ONLY module that spawns the claude CLI.
2
+
3
+ Defenses encoded here:
4
+ - R725: SIGTERM handler reaps process group before runner exits
5
+ - R1128: ROUND_TIMEOUT is wall-clock hard wall (no activity-based extension)
6
+ - #307: start_new_session=True isolates subprocess in its own pgrp
7
+ - env injection: DISABLE_AUTOUPDATER=1 + CLAUDE_CODE_EFFORT_LEVEL caller-provided
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import signal
14
+ import subprocess # noqa: TID251 — sanctioned subprocess caller
15
+ import time
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ REAP_GRACE_S = 5
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class RunResult:
25
+ exit_code: int
26
+ duration_s: float
27
+ timed_out: bool
28
+ pid: int
29
+
30
+
31
+ def _build_argv(command: list[str], prompt_arg_template: list[str], prompt: str) -> list[str]:
32
+ """Build full argv: command + prompt args (with {prompt} substituted)."""
33
+ return list(command) + [a.replace("{prompt}", prompt) for a in prompt_arg_template]
34
+
35
+
36
+ def _kill_pgroup(proc: subprocess.Popen) -> None:
37
+ pgid = proc.pid
38
+ try:
39
+ os.killpg(pgid, signal.SIGTERM)
40
+ except OSError:
41
+ pass
42
+ deadline = time.time() + REAP_GRACE_S
43
+ while time.time() < deadline and proc.poll() is None:
44
+ time.sleep(0.1)
45
+ try:
46
+ os.killpg(pgid, signal.SIGKILL)
47
+ except OSError:
48
+ pass
49
+ try:
50
+ proc.wait(timeout=10)
51
+ except subprocess.TimeoutExpired:
52
+ pass
53
+
54
+
55
+ def run(
56
+ *,
57
+ command: list[str],
58
+ prompt_arg_template: list[str],
59
+ prompt: str,
60
+ timeout_s: int,
61
+ log_path: Path,
62
+ env_extra: dict[str, str],
63
+ ) -> RunResult:
64
+ """Spawn the agent subprocess and wait for exit or timeout.
65
+
66
+ Wall-clock timeout (R1128). On timeout: SIGTERM pgroup → REAP_GRACE_S → SIGKILL.
67
+ """
68
+ argv = _build_argv(command, prompt_arg_template, prompt)
69
+ env = {**os.environ, **env_extra}
70
+ log_path.parent.mkdir(parents=True, exist_ok=True)
71
+ log_file = log_path.open("w", encoding="utf-8")
72
+ start = time.time()
73
+ proc = subprocess.Popen(
74
+ argv,
75
+ env=env,
76
+ stdin=subprocess.DEVNULL,
77
+ stdout=log_file,
78
+ stderr=subprocess.STDOUT,
79
+ start_new_session=True,
80
+ )
81
+ try:
82
+ while True:
83
+ ret = proc.poll()
84
+ now = time.time()
85
+ if ret is not None:
86
+ duration = now - start
87
+ return RunResult(exit_code=ret, duration_s=duration, timed_out=False, pid=proc.pid)
88
+ if now - start > timeout_s:
89
+ _kill_pgroup(proc)
90
+ duration = time.time() - start
91
+ exit_code = proc.returncode if proc.returncode is not None else -1
92
+ return RunResult(
93
+ exit_code=exit_code, duration_s=duration, timed_out=True, pid=proc.pid
94
+ )
95
+ time.sleep(0.2)
96
+ finally:
97
+ log_file.close()
98
+
99
+
100
+ CRITICAL_ENV_DEFAULTS: dict[str, str] = {
101
+ "DISABLE_AUTOUPDATER": "1", # do not let claude self-update mid-loop
102
+ "CLAUDE_CODE_EFFORT_LEVEL": "xhigh", # full effort, not default
103
+ }
104
+
105
+
106
+ def merge_critical_envs(user_env: dict[str, str]) -> dict[str, str]:
107
+ """Merge user env with CRITICAL_ENV_DEFAULTS — critical always wins."""
108
+ merged = dict(user_env)
109
+ merged.update(CRITICAL_ENV_DEFAULTS)
110
+ return merged
111
+
112
+
113
+ def install_sigterm_reaper(reaper: Callable[[], None]) -> object:
114
+ """Install a SIGTERM handler that calls ``reaper()`` first.
115
+
116
+ R725 defense: when supervisor receives SIGTERM (e.g. systemctl stop, manual
117
+ kill), bash wrapper would otherwise respawn fresh runner while old claude
118
+ keeps running → two claudes race on the same git tree, second commit can
119
+ swallow first commit's chat-room entry. Reaper terminates pgroup first.
120
+
121
+ Returns the previous SIGTERM handler so caller can restore it.
122
+ """
123
+
124
+ def _handler(_signum: int, _frame: object) -> None:
125
+ reaper()
126
+
127
+ return signal.signal(signal.SIGTERM, _handler)
agent_runner/api.py ADDED
@@ -0,0 +1,331 @@
1
+ """Public Python API mirroring CLI verbs.
2
+
3
+ Every CLI subcommand has a corresponding api function. CLI files do
4
+ ``api.X(...)`` and format the returned dataclass for display. External
5
+ agents (Phase 3 outer Claude Code) can `from agent_runner import api`
6
+ and skip CLI text parsing entirely.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import signal
12
+ import subprocess # noqa: TID251 — api uses systemctl + ssh, both subprocess
13
+ import sys
14
+ import time
15
+ from collections.abc import Iterator
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from agent_runner import lifecycle
20
+ from agent_runner.api_types import (
21
+ InitResult,
22
+ InstallResult,
23
+ ProjectState,
24
+ ServiceMode,
25
+ ServiceStatus,
26
+ select_path,
27
+ )
28
+ from agent_runner.config import load_config
29
+ from agent_runner.lifecycle import (
30
+ PIDFile,
31
+ detect_service_mode,
32
+ pid_alive,
33
+ send_signal_to_pid,
34
+ )
35
+ from agent_runner.scaffold import scaffold_project
36
+ from agent_runner.service_unit import (
37
+ monitor_unit_filename,
38
+ render_monitor_unit,
39
+ render_serve_unit,
40
+ serve_unit_filename,
41
+ )
42
+
43
+
44
+ def _project_name(work_dir: Path) -> str:
45
+ return work_dir.resolve().name or "default"
46
+
47
+
48
+ def _log_dir(work_dir: Path) -> Path:
49
+ """Return the configured log_dir from agent-runner.toml.
50
+
51
+ Falls back to the conventional ~/.agent-runner/<project>/logs only when
52
+ the toml is missing. This keeps `api.status` / `api.stop` aligned with
53
+ where `serve_cmd.py` actually writes serve.pid.
54
+ """
55
+ cfg_path = work_dir / "agent-runner.toml"
56
+ if cfg_path.exists():
57
+ return load_config(cfg_path).runtime.log_dir
58
+ return Path.home() / ".agent-runner" / _project_name(work_dir) / "logs"
59
+
60
+
61
+ def _venv_bin() -> Path:
62
+ """Where this Python interpreter lives — for ExecStart."""
63
+ return Path(sys.executable).parent
64
+
65
+
66
+ def _systemctl_user(*args: str) -> None:
67
+ subprocess.run(["systemctl", "--user", *args], check=False)
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # init / install / uninstall
72
+
73
+
74
+ def init(work_dir: Path | None = None, *, force: bool = False, commit: bool = True) -> InitResult:
75
+ if work_dir is None:
76
+ work_dir = Path.cwd()
77
+ return scaffold_project(work_dir, force=force, commit=commit)
78
+
79
+
80
+ def install(
81
+ work_dir: Path | None = None, *, system: bool = False, with_monitor: bool = False
82
+ ) -> InstallResult:
83
+ if work_dir is None:
84
+ work_dir = Path.cwd()
85
+ if system:
86
+ raise NotImplementedError("--system install not yet implemented in Phase 2")
87
+ cfg_path = work_dir / "agent-runner.toml"
88
+ cfg = load_config(cfg_path)
89
+ project = _project_name(work_dir)
90
+
91
+ units_dir = lifecycle._user_systemd_dir()
92
+ units_dir.mkdir(parents=True, exist_ok=True)
93
+
94
+ serve_path = units_dir / serve_unit_filename(project)
95
+ serve_path.write_text(render_serve_unit(cfg, venv_bin=_venv_bin()))
96
+
97
+ monitor_path: Path | None = None
98
+ if with_monitor:
99
+ monitor_path = units_dir / monitor_unit_filename(project)
100
+ monitor_path.write_text(render_monitor_unit(cfg, venv_bin=_venv_bin()))
101
+
102
+ _systemctl_user("daemon-reload")
103
+ _systemctl_user("enable", serve_unit_filename(project))
104
+ _systemctl_user("start", serve_unit_filename(project))
105
+ if with_monitor:
106
+ _systemctl_user("enable", monitor_unit_filename(project))
107
+ _systemctl_user("start", monitor_unit_filename(project))
108
+
109
+ return InstallResult(
110
+ unit_path=serve_path, monitor_unit_path=monitor_path, enabled=True, started=True
111
+ )
112
+
113
+
114
+ def uninstall(work_dir: Path | None = None) -> bool:
115
+ if work_dir is None:
116
+ work_dir = Path.cwd()
117
+ project = _project_name(work_dir)
118
+ units_dir = lifecycle._user_systemd_dir()
119
+ serve = units_dir / serve_unit_filename(project)
120
+ monitor = units_dir / monitor_unit_filename(project)
121
+ for p in (serve, monitor):
122
+ if p.exists():
123
+ _systemctl_user("stop", p.name)
124
+ _systemctl_user("disable", p.name)
125
+ p.unlink(missing_ok=True)
126
+ _systemctl_user("daemon-reload")
127
+ return True
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Lifecycle: start / stop / kill / cancel / restart / status
132
+
133
+
134
+ def start(project: str | Path) -> ServiceStatus:
135
+ pname = _resolve_project(project)
136
+ log_dir = _log_dir_for_project(project)
137
+ mode = detect_service_mode(pname, log_dir=log_dir)
138
+ if mode == ServiceMode.SYSTEMD_USER:
139
+ _systemctl_user("start", serve_unit_filename(pname))
140
+ return status(project)
141
+
142
+
143
+ def stop(project: str | Path) -> ServiceStatus:
144
+ pname = _resolve_project(project)
145
+ log_dir = _log_dir_for_project(project)
146
+ mode = detect_service_mode(pname, log_dir=log_dir)
147
+ if mode == ServiceMode.SYSTEMD_USER:
148
+ _systemctl_user("stop", serve_unit_filename(pname))
149
+ return status(project)
150
+ pid = PIDFile(log_dir / "serve.pid").read()
151
+ if pid is not None:
152
+ send_signal_to_pid(pid, signal.SIGTERM)
153
+ return status(project)
154
+
155
+
156
+ def kill(project: str | Path) -> ServiceStatus:
157
+ pname = _resolve_project(project)
158
+ log_dir = _log_dir_for_project(project)
159
+ mode = detect_service_mode(pname, log_dir=log_dir)
160
+ if mode == ServiceMode.SYSTEMD_USER:
161
+ _systemctl_user("kill", "--signal=SIGTERM", serve_unit_filename(pname))
162
+ return status(project)
163
+ pid = PIDFile(log_dir / "serve.pid").read()
164
+ if pid is None:
165
+ return status(project)
166
+ send_signal_to_pid(pid, signal.SIGTERM)
167
+ deadline = time.time() + 5
168
+ alive = True
169
+ while time.time() < deadline:
170
+ alive = pid_alive(pid)
171
+ if not alive:
172
+ break
173
+ time.sleep(0.1)
174
+ if alive:
175
+ send_signal_to_pid(pid, signal.SIGKILL)
176
+ return ServiceStatus(mode=ServiceMode.PID_FILE, active=alive, pid=pid)
177
+
178
+
179
+ def cancel(project: str | Path) -> bool:
180
+ pname = _resolve_project(project)
181
+ log_dir = _log_dir_for_project(project)
182
+ mode = detect_service_mode(pname, log_dir=log_dir)
183
+ if mode == ServiceMode.SYSTEMD_USER:
184
+ _systemctl_user("kill", "--signal=SIGUSR1", serve_unit_filename(pname))
185
+ return True
186
+ pid = PIDFile(log_dir / "serve.pid").read()
187
+ if pid is None:
188
+ return False
189
+ return send_signal_to_pid(pid, signal.SIGUSR1)
190
+
191
+
192
+ def restart(project: str | Path, *, force: bool = False) -> ServiceStatus:
193
+ if force:
194
+ kill(project)
195
+ else:
196
+ stop(project)
197
+ return start(project)
198
+
199
+
200
+ def status(project: str | Path) -> ServiceStatus:
201
+ pname = _resolve_project(project)
202
+ log_dir = _log_dir_for_project(project)
203
+ mode = detect_service_mode(pname, log_dir=log_dir)
204
+ if mode == ServiceMode.PID_FILE:
205
+ pid = PIDFile(log_dir / "serve.pid").read()
206
+ return ServiceStatus(mode=mode, active=pid is not None and pid_alive(pid), pid=pid)
207
+ if mode == ServiceMode.SYSTEMD_USER:
208
+ unit = lifecycle._user_systemd_dir() / serve_unit_filename(pname)
209
+ return ServiceStatus(mode=mode, active=True, unit_file=unit)
210
+ return ServiceStatus(mode=ServiceMode.NONE, active=False)
211
+
212
+
213
+ def _resolve_project(project: str | Path) -> str:
214
+ if isinstance(project, Path):
215
+ return _project_name(project)
216
+ if "/" in project or "\\" in project:
217
+ return _project_name(Path(project))
218
+ return project
219
+
220
+
221
+ def _log_dir_for_project(project: str | Path) -> Path:
222
+ if isinstance(project, Path):
223
+ return _log_dir(project)
224
+ p = Path.cwd() if project == _project_name(Path.cwd()) else None
225
+ if p is not None:
226
+ return _log_dir(p)
227
+ return Path.home() / ".agent-runner" / project / "logs"
228
+
229
+
230
+ # ---------------------------------------------------------------------------
231
+ # Observation: peek / monitor_loop / _poll_once
232
+ #
233
+ # Imported lazily to avoid pulling monitor + defenses at module load time
234
+ # for callers that only use lifecycle verbs.
235
+
236
+ from agent_runner import defenses, monitor # noqa: E402
237
+
238
+
239
+ def peek(
240
+ project: str | Path | None = None,
241
+ *,
242
+ round: int | str | None = None,
243
+ log: bool = False,
244
+ events: int | None = None,
245
+ select: str | None = None,
246
+ ) -> ProjectState | Any:
247
+ """Build a ProjectState snapshot. With select, return that subtree."""
248
+ from agent_runner import round_view
249
+
250
+ work_dir = project if isinstance(project, Path) else Path.cwd()
251
+ cfg = load_config(work_dir / "agent-runner.toml")
252
+ log_dir = cfg.runtime.log_dir
253
+ src = monitor.LocalSource(log_dir=log_dir)
254
+ base_state = monitor.assemble_project_state(src, project=_project_name(work_dir))
255
+ parsed_events = monitor.parse_events_from_jsonl_files(src.events_files())
256
+ round_num = round_view.resolve_round_arg(round, log_dir)
257
+ current: Any = base_state.current_round
258
+ if round_num is not None:
259
+ current = round_view.build_round_view(log_dir, round_num, parsed_events, want_log=log)
260
+ if current is None:
261
+ raise KeyError(f"round {round_num} not found under {log_dir}/rounds/")
262
+ recent = parsed_events[-events:] if events else []
263
+
264
+ state = ProjectState(
265
+ project=base_state.project,
266
+ status=base_state.status,
267
+ defenses=[
268
+ {
269
+ "name": d.name,
270
+ "value": d.value,
271
+ "codifies": d.codifies,
272
+ "guarded_by": str(d.guarded_by) if d.guarded_by else None,
273
+ "current_state": d.current_state,
274
+ }
275
+ for d in defenses.catalog(cfg)
276
+ ],
277
+ current_round=current,
278
+ recent_rounds=base_state.recent_rounds,
279
+ orphan=base_state.orphan,
280
+ system=base_state.system,
281
+ service=status(project if project is not None else work_dir),
282
+ recent_events=recent,
283
+ )
284
+ return state if select is None else select_path(state, select)
285
+
286
+
287
+ def _poll_once(project: str | Path, *, host: str | None) -> list[monitor.Alert]:
288
+ work_dir = project if isinstance(project, Path) else Path.cwd()
289
+ cfg = load_config(work_dir / "agent-runner.toml")
290
+ src: monitor.StateSource
291
+ if host is None:
292
+ src = monitor.LocalSource(log_dir=cfg.runtime.log_dir)
293
+ else:
294
+ src = monitor.RemoteSource(host=host, project=_project_name(work_dir))
295
+ events = monitor.parse_events_from_jsonl_files(src.events_files())
296
+ metrics = monitor.parse_events_from_jsonl_files(src.metrics_files())
297
+ log_tails = monitor.load_round_log_tails(src.rounds_dir())
298
+ return monitor.run_all_detectors(
299
+ events=events,
300
+ metrics=metrics,
301
+ log_tails=log_tails,
302
+ round_timeout_s=cfg.runtime.round_timeout_s,
303
+ )
304
+
305
+
306
+ def monitor_loop(
307
+ project: str | Path | None = None, *, host: str | None = None, interval_s: int = 30
308
+ ) -> Iterator[monitor.Alert]:
309
+ """Yield alerts as they're detected. Caller decides what to do.
310
+
311
+ The loop dedups alerts by (detector, json.dumps(context)) within session.
312
+ """
313
+ import json as _json
314
+
315
+ seen: set[str] = set()
316
+ work_dir = project if isinstance(project, Path) else Path.cwd()
317
+ cfg = load_config(work_dir / "agent-runner.toml")
318
+ while True:
319
+ for alert in _poll_once(work_dir, host=host):
320
+ key = f"{alert.detector}:{_json.dumps(alert.context, sort_keys=True)}"
321
+ if key in seen:
322
+ continue
323
+ seen.add(key)
324
+ yield alert
325
+ monitor.on_alert(
326
+ alert,
327
+ project=_project_name(work_dir),
328
+ host=host,
329
+ log_dir=cfg.runtime.log_dir,
330
+ )
331
+ time.sleep(interval_s)