From 5c1a95833e705ecd5b102ad13562129f7eac2559 Mon Sep 17 00:00:00 2001 From: Mouhand-Kaddo Date: Tue, 15 Sep 2026 14:01:45 +0400 Subject: [PATCH] feat: integrate with herdr --- README.md | 2 + src/lecode/cli.py | 14 ++++++ src/lecode/extras/herdr.py | 73 ++++++++++++++++++++++++++++ src/lecode/tui/app.py | 16 +++++++ tests/conftest.py | 7 +++ tests/test_herdr.py | 97 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 209 insertions(+) create mode 100644 src/lecode/extras/herdr.py create mode 100644 tests/test_herdr.py diff --git a/README.md b/README.md index d32d8c5..09d251a 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,8 @@ non-tty `--setup`) · `3` max turns / max loop iterations / context overflow. mid-turn; OSC 8 hyperlinks; `/copy` (OSC 52 / pbcopy / xclip). - **Status signals** — start/stop/git-conflict events over a Unix socket, for external status bars and scripts. +- **Herdr** — inside a Herdr pane, lecode reports its idle, working, and + blocked state automatically; elsewhere it is a no-op. - **Doctor** — `/doctor` health-checks the install: external binaries, config, provider connectivity, MCP servers, memory, hooks, telemetry. - **Telemetry (opt-in)** — Sentry/GlitchTip error reports and OpenTelemetry diff --git a/src/lecode/cli.py b/src/lecode/cli.py index 6be7534..d2222a6 100644 --- a/src/lecode/cli.py +++ b/src/lecode/cli.py @@ -26,6 +26,7 @@ from lecode.config.loader import config_dir, find_config_file, load_config from lecode.config.models import AuthPolicy, Config from lecode.deps import find_missing_binaries, format_missing_error +from lecode.extras import herdr from lecode.extras.background import BACKGROUND_EXTRA from lecode.extras.chain import ChainResult, run_chain from lecode.extras.loop_mode import ( @@ -352,6 +353,7 @@ def run_headless( err=True, ) _fire_cli_hook(runtime.hooks, SESSION_END) + herdr.release() return EXIT_ERROR user_message: ChatMessage = {"role": "user", "content": prompt} @@ -360,7 +362,9 @@ def run_headless( {"role": "system", "content": runtime.system_prompt}, user_message, ] + herdr.report("idle", session_id=session.id) signals.emit(START) + herdr.report("working", session_id=session.id) try: result = asyncio.run(_run_with_mcp(runtime, client, runner, messages)) except ProviderError as e: @@ -373,6 +377,8 @@ def run_headless( finally: _fire_cli_hook(runtime.hooks, SESSION_END) signals.emit(STOP) + herdr.report("idle") + herdr.release() shutdown_telemetry() typer.echo(result.final_text) @@ -484,7 +490,9 @@ async def _loop() -> LoopResult: await background.shutdown() await _aclose(client) + herdr.report("idle", session_id=session.id) signals.emit(START) + herdr.report("working", session_id=session.id) _fire_cli_hook(runtime.hooks, SESSION_START) try: result = asyncio.run(_loop()) @@ -498,6 +506,8 @@ async def _loop() -> LoopResult: finally: _fire_cli_hook(runtime.hooks, SESSION_END) signals.emit(STOP) + herdr.report("idle") + herdr.release() shutdown_telemetry() if result.stop_reason == "error": typer.echo(f"error: {result.error}", err=True) @@ -587,7 +597,9 @@ async def _chain() -> ChainResult: await background.shutdown() await _aclose(client) + herdr.report("idle", session_id=session.id) signals.emit(START) + herdr.report("working", session_id=session.id) _fire_cli_hook(runtime.hooks, SESSION_START) try: asyncio.run(_chain()) @@ -601,6 +613,8 @@ async def _chain() -> ChainResult: finally: _fire_cli_hook(runtime.hooks, SESSION_END) signals.emit(STOP) + herdr.report("idle") + herdr.release() shutdown_telemetry() return EXIT_OK diff --git a/src/lecode/extras/herdr.py b/src/lecode/extras/herdr.py new file mode 100644 index 0000000..7e52730 --- /dev/null +++ b/src/lecode/extras/herdr.py @@ -0,0 +1,73 @@ +"""Best-effort lifecycle reporting for lecode running inside Herdr.""" + +from __future__ import annotations + +import contextlib +import itertools +import os +import subprocess + +SOURCE = "herdr:lecode" +AGENT = "lecode" +_TIMEOUT_S = 2.0 +_sequence = itertools.count(1) + + +def report(state: str, *, message: str | None = None, session_id: str | None = None) -> None: + """Report lifecycle state to Herdr, or do nothing outside a Herdr pane.""" + target = _target() + if target is None: + return + binary, pane_id = target + argv = [ + binary, + "pane", + "report-agent", + pane_id, + "--source", + SOURCE, + "--agent", + AGENT, + "--state", + state, + "--seq", + str(next(_sequence)), + ] + if message: + argv.extend(["--message", message]) + if session_id: + argv.extend(["--agent-session-id", session_id]) + _run(argv) + + +def release() -> None: + """Release Herdr lifecycle authority when the lecode session exits.""" + target = _target() + if target is None: + return + binary, pane_id = target + _run( + [ + binary, + "pane", + "release-agent", + pane_id, + "--source", + SOURCE, + "--agent", + AGENT, + ] + ) + + +def _target() -> tuple[str, str] | None: + if os.environ.get("HERDR_ENV") != "1": + return None + binary = os.environ.get("HERDR_BIN_PATH") + pane_id = os.environ.get("HERDR_PANE_ID") + return (binary, pane_id) if binary and pane_id else None + + +def _run(argv: list[str]) -> None: + with contextlib.suppress(OSError, subprocess.SubprocessError): + subprocess.run(argv, check=False, capture_output=True, timeout=_TIMEOUT_S) diff --git a/src/lecode/tui/app.py b/src/lecode/tui/app.py index 712f5b2..7de2ec7 100644 --- a/src/lecode/tui/app.py +++ b/src/lecode/tui/app.py @@ -59,6 +59,7 @@ from lecode.config.models import PermissionMode, ThinkingLevel from lecode.context.agents import parse_mentions from lecode.context.resources import load_text +from lecode.extras import herdr from lecode.extras.background import BACKGROUND_EXTRA from lecode.extras.chain import run_chain from lecode.extras.loop_mode import run_plan_loop @@ -441,6 +442,7 @@ def switch_session(self, session: Session) -> bool: hooks.handlers.get(SESSION_END) or hooks.handlers.get(SESSION_START) ): self._spawn(self._switch_hooks(old_session, session)) + herdr.report("idle", session_id=session.id) return True def _reload_history(self) -> None: @@ -1050,6 +1052,7 @@ async def run(self, *, input: Input | None = None, output: Output | None = None) self._runtime.ctx.approval_callback = self._request_approval self._runtime.ctx.question_callback = self._request_question await self._fire_hook(SESSION_START) + herdr.report("idle", session_id=self._session.id) # MCP attaches in the background so the chat opens immediately; # per-server status lands in the feed when the connect finishes. if self._runtime.ctx.extras.get(MCP_EXTRA) is None: @@ -1095,6 +1098,7 @@ async def run(self, *, input: Input | None = None, output: Output | None = None) if self._session_lock is not None: self._session_lock.release() self._session_lock = None + herdr.release() self.print_totals() return EXIT_OK @@ -1362,6 +1366,7 @@ async def _request_approval( if reason: self._feed.info(reason) # doom-loop coach reasons land here too self._feed.permission(approval_prompt_text(tool_name, target)) + herdr.report("blocked", message=f"approval needed: {tool_name}") future = self._approval.request(tool_name, target, reason) self._status.state = StatusLineState.AWAITING_APPROVAL self._invalidate() @@ -1372,6 +1377,7 @@ async def _request_approval( finally: self._approval.cancel() self._status.state = StatusLineState.RUNNING + herdr.report("working") self._invalidate() def _render_question(self) -> None: @@ -1393,6 +1399,7 @@ async def _request_question(self, questions: list[dict[str, Any]]) -> list[dict[ """``ctx.question_callback``: inline arrow-key picker during a turn.""" future = self._question.request(questions) self._render_question() + herdr.report("blocked", message="answer needed: ask_user") self._status.state = StatusLineState.QUESTION self._invalidate() self._spawn(self._notifier.approval_needed("ask_user")) @@ -1402,6 +1409,7 @@ async def _request_question(self, questions: list[dict[str, Any]]) -> list[dict[ finally: self._question.cancel() self._status.state = StatusLineState.RUNNING + herdr.report("working") self._invalidate() # -- turns ------------------------------------------------------------------ @@ -1475,6 +1483,7 @@ async def _run_turn(self, text: MessageContent, *, overlay: str | None = None) - self._feed.stream_start() self._activity("thinking") self._signals.emit(START) + herdr.report("working", session_id=self._session.id) result = None cancelled = False try: @@ -1487,6 +1496,7 @@ async def _run_turn(self, text: MessageContent, *, overlay: str | None = None) - finally: self._feed.stream_end() self._signals.emit(STOP) + herdr.report("idle") self._status.state = StatusLineState.IDLE self._status.activity = None if result is not None: @@ -1529,6 +1539,7 @@ async def _run_subagent_turn(self, name: str, prompt: str) -> None: query — the exchange is not persisted to the session.""" self._status.state = StatusLineState.RUNNING self._activity(f"@{name} working") + herdr.report("working", session_id=self._session.id) outcome: SubagentOutcome | None = None try: outcome = await run_subagent( @@ -1545,6 +1556,7 @@ async def _run_subagent_turn(self, name: str, prompt: str) -> None: self._feed.info("turn cancelled") finally: self._status.state = StatusLineState.IDLE + herdr.report("idle") self._status.activity = None if outcome is not None: self._feed.assistant_text(outcome.text) @@ -1572,6 +1584,7 @@ async def run_iteration(prompt: str) -> str: async def _loop() -> None: self._status.state = StatusLineState.RUNNING + herdr.report("working", session_id=self._session.id) try: result = await run_plan_loop( run_iteration, @@ -1589,6 +1602,7 @@ async def _loop() -> None: finally: self._feed.stream_end() self._status.state = StatusLineState.IDLE + herdr.report("idle") self._status.activity = None if result.stop_reason == "done": self._feed.info(f"loop done: plan complete after {result.iterations} iteration(s)") @@ -1624,6 +1638,7 @@ def on_phase(phase: str, output: str) -> None: self._status.state = StatusLineState.RUNNING self._activity("chain running") + herdr.report("working", session_id=self._session.id) try: result = await run_chain( factory, @@ -1640,6 +1655,7 @@ def on_phase(phase: str, output: str) -> None: return finally: self._status.state = StatusLineState.IDLE + herdr.report("idle") self._status.activity = None if result.final: self._last_response = result.final diff --git a/tests/conftest.py b/tests/conftest.py index 260c046..3921b50 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,6 +18,13 @@ def tool_ctx(tmp_path, monkeypatch) -> ToolContext: return ToolContext(cwd=tmp_path, config=config, permission_checker=checker, auto_approve=True) +@pytest.fixture(autouse=True) +def _disable_herdr_integration(monkeypatch): + """Keep tests from reporting into the developer's live Herdr pane.""" + for name in ("HERDR_ENV", "HERDR_BIN_PATH", "HERDR_PANE_ID"): + monkeypatch.delenv(name, raising=False) + + @pytest.fixture(autouse=True) def _reset_sse_starlette_shutdown_state(): """Reset sse-starlette's process-global shutdown state before each test. diff --git a/tests/test_herdr.py b/tests/test_herdr.py new file mode 100644 index 0000000..30e5d13 --- /dev/null +++ b/tests/test_herdr.py @@ -0,0 +1,97 @@ +"""Tests for the optional Herdr lifecycle reporter.""" + +from __future__ import annotations + +import json + +from tests.test_tui_app import make_app + +from lecode.extras import herdr + + +def _fake_herdr(tmp_path): + log = tmp_path / "calls.jsonl" + binary = tmp_path / "herdr" + binary.write_text( + "#!/usr/bin/env python3\n" + "import json, os, sys\n" + "with open(os.environ['HERDR_LOG'], 'a') as output:\n" + " json.dump(sys.argv[1:], output)\n" + " output.write('\\n')\n", + encoding="utf-8", + ) + binary.chmod(0o755) + return binary, log + + +def _calls(log): + return [json.loads(line) for line in log.read_text(encoding="utf-8").splitlines()] + + +def test_reports_lifecycle_and_releases(tmp_path, monkeypatch): + binary, log = _fake_herdr(tmp_path) + monkeypatch.setenv("HERDR_ENV", "1") + monkeypatch.setenv("HERDR_BIN_PATH", str(binary)) + monkeypatch.setenv("HERDR_PANE_ID", "w1:p1") + monkeypatch.setenv("HERDR_LOG", str(log)) + + herdr.report("working", session_id="session-1") + herdr.report("blocked", message="approval needed: bash") + herdr.report("idle") + herdr.release() + + calls = _calls(log) + assert calls[0][:10] == [ + "pane", + "report-agent", + "w1:p1", + "--source", + "herdr:lecode", + "--agent", + "lecode", + "--state", + "working", + "--seq", + ] + assert "--agent-session-id" in calls[0] + assert "session-1" in calls[0] + assert calls[1][calls[1].index("--state") + 1] == "blocked" + assert calls[1][calls[1].index("--message") + 1] == "approval needed: bash" + assert calls[2][calls[2].index("--state") + 1] == "idle" + assert calls[3] == [ + "pane", + "release-agent", + "w1:p1", + "--source", + "herdr:lecode", + "--agent", + "lecode", + ] + sequences = [int(call[call.index("--seq") + 1]) for call in calls[:3]] + assert sequences[0] < sequences[1] < sequences[2] + + +def test_is_inert_outside_herdr(tmp_path, monkeypatch): + _, log = _fake_herdr(tmp_path) + monkeypatch.setenv("HERDR_LOG", str(log)) + + herdr.report("working") + herdr.release() + + assert not log.exists() + + +async def test_tui_turn_reports_working_and_idle(tmp_path, monkeypatch): + binary, log = _fake_herdr(tmp_path) + monkeypatch.setenv("HERDR_ENV", "1") + monkeypatch.setenv("HERDR_BIN_PATH", str(binary)) + monkeypatch.setenv("HERDR_PANE_ID", "w1:p1") + monkeypatch.setenv("HERDR_LOG", str(log)) + app, _, _ = make_app(tmp_path, monkeypatch, [{"text": "hi"}]) + + await app._submit("hello") + assert app._turn_task is not None + await app._turn_task + + states = [call[call.index("--state") + 1] for call in _calls(log)] + assert states == ["working", "idle"]