feat(runners): add runner_bridge context window display, enhance claude/codex/opencode runners and telegram session handling
CI / build (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / ruff (push) Has been cancelled
CI / format (push) Has been cancelled
CI / ty (push) Has been cancelled
CI / notify-commit (push) Has been cancelled

This commit is contained in:
2026-06-04 22:11:02 -04:00
parent 7e3bc363f9
commit b6c8e63f4e
10 changed files with 148 additions and 4 deletions
+2
View File
@@ -240,6 +240,8 @@ class MarkdownFormatter:
def _format_footer(self, state: ProgressState) -> str | None: def _format_footer(self, state: ProgressState) -> str | None:
lines: list[str] = [] lines: list[str] = []
if state.usage_line:
lines.append(state.usage_line)
if state.context_line: if state.context_line:
lines.append(state.context_line) lines.append(state.context_line)
if state.resume_line: if state.resume_line:
+3
View File
@@ -25,6 +25,7 @@ class ProgressState:
resume: ResumeToken | None resume: ResumeToken | None
resume_line: str | None resume_line: str | None
context_line: str | None context_line: str | None
usage_line: str | None = None
class ProgressTracker: class ProgressTracker:
@@ -82,6 +83,7 @@ class ProgressTracker:
*, *,
resume_formatter: Callable[[ResumeToken], str] | None = None, resume_formatter: Callable[[ResumeToken], str] | None = None,
context_line: str | None = None, context_line: str | None = None,
usage_line: str | None = None,
) -> ProgressState: ) -> ProgressState:
resume_line: str | None = None resume_line: str | None = None
if self.resume is not None and resume_formatter is not None: if self.resume is not None and resume_formatter is not None:
@@ -96,4 +98,5 @@ class ProgressTracker:
resume=self.resume, resume=self.resume,
resume_line=resume_line, resume_line=resume_line,
context_line=context_line, context_line=context_line,
usage_line=usage_line,
) )
+58
View File
@@ -25,6 +25,61 @@ from .transport import (
logger = get_logger(__name__) logger = get_logger(__name__)
_CONTEXT_WINDOWS: dict[str, int] = {
"claude": 200_000,
"pi": 200_000,
}
def _fuzzy_tokens(n: int) -> str:
if n < 1000:
return str(n)
k = n / 1000
formatted = f"{k:.2f}".rstrip("0").rstrip(".")
return f"{formatted}k"
def _format_usage_line(usage: dict | None, *, engine: str = "") -> str:
if not usage:
return "ctx: n/a"
inner: dict = usage.get("usage") or {}
is_claude_nested = bool(inner)
if is_claude_nested:
input_tokens: int | None = inner.get("input_tokens")
_out: int | None = inner.get("output_tokens")
ctx_tokens: int | None = (
(input_tokens + _out)
if input_tokens is not None and _out is not None
else (input_tokens or _out)
)
output_tokens: int | None = None # folded into ctx_tokens
else:
# codex: use last_usage (per-request) for accurate context window display.
# cumulative usage.input_tokens grows across API calls and exceeds window size.
last: dict = usage.get("last_usage") or {}
last_input: int | None = last.get("input_tokens")
last_cached: int | None = last.get("cached_input_tokens")
if last_input is not None:
ctx_tokens = last_input + (last_cached or 0)
else:
ctx_tokens = None
output_tokens = None
if ctx_tokens is None:
return "ctx: n/a"
ctx_str = _fuzzy_tokens(ctx_tokens)
context_window = _CONTEXT_WINDOWS.get(engine)
if context_window:
max_str = _fuzzy_tokens(context_window)
parts = [f"ctx: {ctx_str}/{max_str}"]
else:
parts = [f"ctx: {ctx_str}"]
if output_tokens is not None:
parts.append(f"out: {_fuzzy_tokens(output_tokens)}")
cost = usage.get("total_cost_usd")
if cost is not None:
parts.append(f"${cost:.4f}")
return " · ".join(parts)
def _log_runner_event(evt: TakopiEvent) -> None: def _log_runner_event(evt: TakopiEvent) -> None:
for line in render_event_cli(evt): for line in render_event_cli(evt):
@@ -499,6 +554,7 @@ async def handle_message(
state = progress_tracker.snapshot( state = progress_tracker.snapshot(
resume_formatter=runner.format_resume, resume_formatter=runner.format_resume,
context_line=context_line, context_line=context_line,
usage_line=_format_usage_line(None, engine=runner.engine),
) )
final_rendered = cfg.presenter.render_final( final_rendered = cfg.presenter.render_final(
state, state,
@@ -535,6 +591,7 @@ async def handle_message(
state = progress_tracker.snapshot( state = progress_tracker.snapshot(
resume_formatter=runner.format_resume, resume_formatter=runner.format_resume,
context_line=context_line, context_line=context_line,
usage_line=_format_usage_line(None, engine=runner.engine),
) )
final_rendered = cfg.presenter.render_progress( final_rendered = cfg.presenter.render_progress(
state, state,
@@ -589,6 +646,7 @@ async def handle_message(
state = progress_tracker.snapshot( state = progress_tracker.snapshot(
resume_formatter=runner.format_resume, resume_formatter=runner.format_resume,
context_line=context_line, context_line=context_line,
usage_line=_format_usage_line(completed.usage, engine=completed.engine),
) )
final_rendered = cfg.presenter.render_final( final_rendered = cfg.presenter.render_final(
state, state,
+21 -1
View File
@@ -10,6 +10,7 @@ from typing import Any
import msgspec import msgspec
from ..backends import EngineBackend, EngineConfig from ..backends import EngineBackend, EngineConfig
from ..config import ConfigError
from ..events import EventFactory from ..events import EventFactory
from ..logging import get_logger from ..logging import get_logger
from ..model import Action, ActionKind, EngineId, ResumeToken, TakopiEvent from ..model import Action, ActionKind, EngineId, ResumeToken, TakopiEvent
@@ -288,6 +289,7 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
allowed_tools: list[str] | None = None allowed_tools: list[str] | None = None
dangerously_skip_permissions: bool = False dangerously_skip_permissions: bool = False
use_api_billing: bool = False use_api_billing: bool = False
extra_args: list[str] | None = None
session_title: str = "claude" session_title: str = "claude"
logger = logger logger = logger
@@ -311,6 +313,8 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
args.extend(["--allowedTools", allowed_tools]) args.extend(["--allowedTools", allowed_tools])
if self.dangerously_skip_permissions is True: if self.dangerously_skip_permissions is True:
args.append("--dangerously-skip-permissions") args.append("--dangerously-skip-permissions")
if self.extra_args:
args.extend(self.extra_args)
args.append("--") args.append("--")
args.append(prompt) args.append(prompt)
return args return args
@@ -455,7 +459,11 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
def build_runner(config: EngineConfig, _config_path: Path) -> Runner: def build_runner(config: EngineConfig, _config_path: Path) -> Runner:
claude_cmd = shutil.which("claude") or "claude" cmd_override = config.get("claude_cmd")
if isinstance(cmd_override, str) and cmd_override:
claude_cmd = shutil.which(cmd_override) or cmd_override
else:
claude_cmd = shutil.which("claude") or "claude"
model = config.get("model") model = config.get("model")
if "allowed_tools" in config: if "allowed_tools" in config:
@@ -464,6 +472,17 @@ def build_runner(config: EngineConfig, _config_path: Path) -> Runner:
allowed_tools = DEFAULT_ALLOWED_TOOLS allowed_tools = DEFAULT_ALLOWED_TOOLS
dangerously_skip_permissions = config.get("dangerously_skip_permissions") is True dangerously_skip_permissions = config.get("dangerously_skip_permissions") is True
use_api_billing = config.get("use_api_billing") is True use_api_billing = config.get("use_api_billing") is True
extra_args_raw = config.get("extra_args")
if extra_args_raw is None:
extra_args = None
elif isinstance(extra_args_raw, list) and all(
isinstance(x, str) for x in extra_args_raw
):
extra_args = list(extra_args_raw)
else:
raise ConfigError(
f"Invalid `claude.extra_args` in {_config_path}; expected a list of strings."
)
title = str(model) if model is not None else "claude" title = str(model) if model is not None else "claude"
return ClaudeRunner( return ClaudeRunner(
@@ -472,6 +491,7 @@ def build_runner(config: EngineConfig, _config_path: Path) -> Runner:
allowed_tools=allowed_tools, allowed_tools=allowed_tools,
dangerously_skip_permissions=dangerously_skip_permissions, dangerously_skip_permissions=dangerously_skip_permissions,
use_api_billing=use_api_billing, use_api_billing=use_api_billing,
extra_args=extra_args,
session_title=title, session_title=title,
) )
+11 -3
View File
@@ -585,13 +585,16 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
title="turn started", title="turn started",
) )
] ]
case codex_schema.TurnCompleted(usage=usage): case codex_schema.TurnCompleted(usage=usage, last_usage=last_usage):
resume_for_completed = found_session or resume resume_for_completed = found_session or resume
usage_dict = msgspec.to_builtins(usage)
if last_usage is not None:
usage_dict["last_usage"] = msgspec.to_builtins(last_usage)
return [ return [
factory.completed_ok( factory.completed_ok(
answer=state.final_answer or "", answer=state.final_answer or "",
resume=resume_for_completed, resume=resume_for_completed,
usage=msgspec.to_builtins(usage), usage=usage_dict,
) )
] ]
case codex_schema.ItemCompleted( case codex_schema.ItemCompleted(
@@ -664,7 +667,12 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
def build_runner(config: EngineConfig, config_path: Path) -> Runner: def build_runner(config: EngineConfig, config_path: Path) -> Runner:
codex_cmd = "codex" cmd_value = config.get("cmd")
if cmd_value is not None and not isinstance(cmd_value, str):
raise ConfigError(
f"Invalid `codex.cmd` in {config_path}; expected a string."
)
codex_cmd = cmd_value if isinstance(cmd_value, str) else "codex"
extra_args_value = config.get("extra_args") extra_args_value = config.get("extra_args")
if extra_args_value is None: if extra_args_value is None:
+16
View File
@@ -308,6 +308,7 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
opencode_cmd: str = "opencode" opencode_cmd: str = "opencode"
model: str | None = None model: str | None = None
extra_args: list[str] | None = None
session_title: str = "opencode" session_title: str = "opencode"
logger = logger logger = logger
@@ -335,6 +336,8 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
model = run_options.model model = run_options.model
if model is not None: if model is not None:
args.extend(["--model", str(model)]) args.extend(["--model", str(model)])
if self.extra_args:
args.extend(self.extra_args)
args.extend(["--", prompt]) args.extend(["--", prompt])
return args return args
@@ -486,11 +489,24 @@ def build_runner(config: EngineConfig, config_path: Path) -> Runner:
f"Invalid `opencode.model` in {config_path}; expected a string." f"Invalid `opencode.model` in {config_path}; expected a string."
) )
extra_args_raw = config.get("extra_args")
if extra_args_raw is None:
extra_args = None
elif isinstance(extra_args_raw, list) and all(
isinstance(x, str) for x in extra_args_raw
):
extra_args = list(extra_args_raw)
else:
raise ConfigError(
f"Invalid `opencode.extra_args` in {config_path}; expected a list of strings."
)
title = str(model) if model is not None else "opencode" title = str(model) if model is not None else "opencode"
return OpenCodeRunner( return OpenCodeRunner(
opencode_cmd=opencode_cmd, opencode_cmd=opencode_cmd,
model=model, model=model,
extra_args=extra_args,
session_title=title, session_title=title,
) )
+1
View File
@@ -54,6 +54,7 @@ class TurnStarted(msgspec.Struct, tag="turn.started", kw_only=True):
class TurnCompleted(msgspec.Struct, tag="turn.completed", kw_only=True): class TurnCompleted(msgspec.Struct, tag="turn.completed", kw_only=True):
usage: Usage usage: Usage
last_usage: Usage | None = None
class TurnFailed(msgspec.Struct, tag="turn.failed", kw_only=True): class TurnFailed(msgspec.Struct, tag="turn.failed", kw_only=True):
+13
View File
@@ -90,6 +90,19 @@ class ChatSessionStore(JsonStateStore[_ChatSessionsState]):
chat.sessions[token.engine] = _SessionState(resume=token.value) chat.sessions[token.engine] = _SessionState(resume=token.value)
self._save_locked() self._save_locked()
async def get_any_session_resume(
self, chat_id: int, owner_id: int | None
) -> ResumeToken | None:
async with self._lock:
self._reload_locked_if_needed()
chat = self._get_chat_locked(chat_id, owner_id)
if chat is None:
return None
for engine, entry in chat.sessions.items():
if entry.resume:
return ResumeToken(engine=engine, value=entry.resume)
return None
async def clear_sessions(self, chat_id: int, owner_id: int | None) -> None: async def clear_sessions(self, chat_id: int, owner_id: int | None) -> None:
async with self._lock: async with self._lock:
self._reload_locked_if_needed() self._reload_locked_if_needed()
+10
View File
@@ -717,6 +717,11 @@ class ResumeResolver:
topic_key[1], topic_key[1],
engine_for_session, engine_for_session,
) )
if stored is None:
stored = await self._topic_store.get_any_session_resume(
topic_key[0],
topic_key[1],
)
if stored is not None: if stored is not None:
resume_token = stored resume_token = stored
if ( if (
@@ -729,6 +734,11 @@ class ResumeResolver:
chat_session_key[1], chat_session_key[1],
engine_for_session, engine_for_session,
) )
if stored is None:
stored = await self._chat_session_store.get_any_session_resume(
chat_session_key[0],
chat_session_key[1],
)
if stored is not None: if stored is not None:
resume_token = stored resume_token = stored
return ResumeDecision(resume_token=resume_token, handled_by_running_task=False) return ResumeDecision(resume_token=resume_token, handled_by_running_task=False)
+13
View File
@@ -174,6 +174,19 @@ class TopicStateStore(JsonStateStore[_TopicState]):
return None return None
return ResumeToken(engine=engine, value=entry.resume) return ResumeToken(engine=engine, value=entry.resume)
async def get_any_session_resume(
self, chat_id: int, thread_id: int
) -> ResumeToken | None:
async with self._lock:
self._reload_locked_if_needed()
thread = self._get_thread_locked(chat_id, thread_id)
if thread is None:
return None
for engine, entry in thread.sessions.items():
if entry.resume:
return ResumeToken(engine=engine, value=entry.resume)
return None
async def get_default_engine(self, chat_id: int, thread_id: int) -> str | None: async def get_default_engine(self, chat_id: int, thread_id: int) -> str | None:
async with self._lock: async with self._lock:
self._reload_locked_if_needed() self._reload_locked_if_needed()