Skip to content
Open
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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions src/lecode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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}
Expand All @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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())
Expand All @@ -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)
Expand Down Expand Up @@ -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())
Expand All @@ -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

Expand Down
73 changes: 73 additions & 0 deletions src/lecode/extras/herdr.py
Original file line number Diff line number Diff line change
@@ -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)
16 changes: 16 additions & 0 deletions src/lecode/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand All @@ -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:
Expand All @@ -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"))
Expand All @@ -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 ------------------------------------------------------------------
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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)")
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
97 changes: 97 additions & 0 deletions tests/test_herdr.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading