/brainstorm: 5 expert personas (Architect / Skeptic / Pragmatist / DBA / PM) adversarially debate, then converge on a todo_list.txt ready for /worker
+
+
+
+
+
+
+
/lab (Research Lab): 9 specialised agents drive a paper through 9 stages — questioning → survey → outline → sandboxed Python experiment → reviewer loop → citation verification → finalisation
+
+
+
+
+
+
+
/agent research_assistant: autonomous background loop running every 4h, pushes daily digests to Telegram. Stagnation-stop guard auto-pauses to save tokens when output stops changing.
+
+
+
+
+
+
+
Telegram bridge: full chat round-trip, slash-command passthrough, job queue with !jobs/!cancel, push notifications when long-running tasks finish. Same UX for /wechat and /slack.
+
+
+
+> **Tip:** the recordings are animated SVGs at [`docs/media/casts/`](docs/media/casts/). Source `.cast` files (asciinema v2) live next to them — replay locally with `asciinema play .cast` or re-render with `svg-term --in .cast --out .svg`.
+
+---
+
diff --git a/bridges/slack.py b/bridges/slack.py
index 2d119b8..f405d98 100644
--- a/bridges/slack.py
+++ b/bridges/slack.py
@@ -252,20 +252,48 @@ def _slack_terminal(cmd, ch, skey):
slash_cb = session_ctx.handle_slash
if slash_cb:
def _slack_slash_runner(_slash_text, _ch):
+ import io as _io, sys as _sys, re as _re_ansi
_slack_thread_local.active = True
sctx = runtime.get_ctx(config)
sctx.slack_current_channel = _ch
+ # Capture print()/info()/ok() output so commands
+ # like /help (which render via print) reach the
+ # user instead of disappearing into server logs
+ # (issue #84 follow-up — same root cause as the
+ # Telegram bridge).
+ _buf_out, _buf_err = _io.StringIO(), _io.StringIO()
+ _orig_out, _orig_err = _sys.stdout, _sys.stderr
+ class _Tee:
+ def __init__(self, *streams):
+ self._streams = streams
+ def write(self, data):
+ for s in self._streams:
+ try: s.write(data)
+ except Exception: pass
+ def flush(self):
+ for s in self._streams:
+ try: s.flush()
+ except Exception: pass
+ _sys.stdout = _Tee(_orig_out, _buf_out)
+ _sys.stderr = _Tee(_orig_err, _buf_err)
try:
cmd_type = slash_cb(_slash_text)
except Exception as e:
+ _sys.stdout, _sys.stderr = _orig_out, _orig_err
_slack_send(token, _ch, f"⚠ Error: {e}")
return
finally:
+ _sys.stdout, _sys.stderr = _orig_out, _orig_err
_slack_thread_local.active = False
sctx.slack_current_channel = None
+ _captured = (_buf_out.getvalue() + _buf_err.getvalue())
+ _captured = _re_ansi.sub(r'\x1b\[[0-9;]*m', '', _captured).strip()
if cmd_type == "simple":
cmd_name = _slash_text.strip().split()[0]
- _slack_send(token, _ch, f"✅ {cmd_name} executed.")
+ if _captured:
+ _slack_send(token, _ch, _captured)
+ else:
+ _slack_send(token, _ch, f"✅ {cmd_name} executed.")
return
slack_state = session_ctx.agent_state
if slack_state and slack_state.messages:
diff --git a/bridges/telegram.py b/bridges/telegram.py
index 0175c0a..c048274 100644
--- a/bridges/telegram.py
+++ b/bridges/telegram.py
@@ -130,17 +130,27 @@ def _handle_callback_query(token: str, chat_id: int, cb: dict,
_, prompt_id, value = cb_data.split(":", 2)
expected = getattr(session_ctx, "tg_callback_prompt_id", "") or ""
- if expected and expected != prompt_id:
+ if not expected:
+ # No prompt is currently waiting — this click belongs to an
+ # already-answered or timed-out prompt. Don't edit the message
+ # with a fake "✓ Selected" confirmation; the user would think the
+ # action took effect when in fact nothing happens (issue #84
+ # follow-up). The acknowledgeCallbackQuery above clears the
+ # spinner so the click still feels handled.
+ return
+ if expected != prompt_id:
# Stale click from an earlier prompt — ignore so the live prompt
# keeps waiting for its own button press.
return
- # Find the human label for this value, if we can match the message text.
- label_for_value = value
- for line in cb_text.splitlines():
- # Lines look like "[1] ✅ Approve — y" but the label format is owned by
- # the caller, so just take the value verbatim if no match.
- pass
+ # Sanitize the value for visual confirmation. Callers can pass any
+ # string as the option value (it travels in callback_data), so escape
+ # backticks/Markdown markers before embedding in the edited message —
+ # otherwise editMessageText silently fails on parse errors and the
+ # user just sees the original prompt unchanged.
+ label_for_value = (
+ str(value).replace("\\", "\\\\").replace("`", "'").replace("*", "·")
+ )
if cb_msg:
new_body = cb_text + f"\n\n✓ Selected: `{label_for_value}`"
@@ -542,17 +552,48 @@ def _terminal_runner(cmd, chat_token, cid, skey):
slash_cb = session_ctx.handle_slash
if slash_cb:
def _slash_runner(_slash_text, _token, _chat_id):
+ import io as _io, sys as _sys, re as _re_ansi
_tg_thread_local.active = True
+ # Capture print()/info()/ok()/warn()/err() output so
+ # commands like /help (which render their menu via
+ # print) surface in the chat instead of disappearing
+ # into the server log (issue #84 follow-up).
+ _buf_out, _buf_err = _io.StringIO(), _io.StringIO()
+ _orig_out, _orig_err = _sys.stdout, _sys.stderr
+ class _Tee:
+ def __init__(self, *streams):
+ self._streams = streams
+ def write(self, data):
+ for s in self._streams:
+ try: s.write(data)
+ except Exception: pass
+ def flush(self):
+ for s in self._streams:
+ try: s.flush()
+ except Exception: pass
+ _sys.stdout = _Tee(_orig_out, _buf_out)
+ _sys.stderr = _Tee(_orig_err, _buf_err)
try:
cmd_type = slash_cb(_slash_text)
except Exception as e:
+ _sys.stdout, _sys.stderr = _orig_out, _orig_err
_tg_send(_token, _chat_id, f"⚠ Error: {e}")
return
finally:
+ _sys.stdout, _sys.stderr = _orig_out, _orig_err
_tg_thread_local.active = False
+ _captured = (_buf_out.getvalue() + _buf_err.getvalue())
+ _captured = _re_ansi.sub(r'\x1b\[[0-9;]*m', '', _captured).strip()
if cmd_type == "simple":
cmd_name = _slash_text.strip().split()[0]
- _tg_send(_token, _chat_id, f"✅ {cmd_name} executed.")
+ # Forward the captured menu/status text so the
+ # user actually sees /help, /status, /model
+ # output. Fall back to the bare ack only when
+ # the command produced nothing.
+ if _captured:
+ _tg_send(_token, _chat_id, _captured)
+ else:
+ _tg_send(_token, _chat_id, f"✅ {cmd_name} executed.")
return
tg_state = session_ctx.agent_state
if tg_state and tg_state.messages:
diff --git a/bridges/wechat.py b/bridges/wechat.py
index bfe0bec..b4521ba 100644
--- a/bridges/wechat.py
+++ b/bridges/wechat.py
@@ -615,20 +615,48 @@ def _wx_terminal(cmd, uid, skey):
slash_cb = session_ctx.handle_slash
if slash_cb:
def _wx_slash_runner(_slash_text, _uid):
+ import io as _io, sys as _sys, re as _re_ansi
_wx_thread_local.active = True
sctx = runtime.get_ctx(config)
sctx.wx_current_user_id = _uid
+ # Capture print()/info()/ok() output so commands
+ # like /help (which render via print) reach the
+ # user instead of disappearing into server logs
+ # (issue #84 follow-up — same root cause as the
+ # Telegram bridge).
+ _buf_out, _buf_err = _io.StringIO(), _io.StringIO()
+ _orig_out, _orig_err = _sys.stdout, _sys.stderr
+ class _Tee:
+ def __init__(self, *streams):
+ self._streams = streams
+ def write(self, data):
+ for s in self._streams:
+ try: s.write(data)
+ except Exception: pass
+ def flush(self):
+ for s in self._streams:
+ try: s.flush()
+ except Exception: pass
+ _sys.stdout = _Tee(_orig_out, _buf_out)
+ _sys.stderr = _Tee(_orig_err, _buf_err)
try:
cmd_type = slash_cb(_slash_text)
except Exception as e:
+ _sys.stdout, _sys.stderr = _orig_out, _orig_err
_wx_send(_uid, f"⚠ Error: {e}", config)
return
finally:
+ _sys.stdout, _sys.stderr = _orig_out, _orig_err
_wx_thread_local.active = False
sctx.wx_current_user_id = None
+ _captured = (_buf_out.getvalue() + _buf_err.getvalue())
+ _captured = _re_ansi.sub(r'\x1b\[[0-9;]*m', '', _captured).strip()
if cmd_type == "simple":
cmd_name = _slash_text.strip().split()[0]
- _wx_send(_uid, f"✅ {cmd_name} executed.", config)
+ if _captured:
+ _wx_send(_uid, _captured, config)
+ else:
+ _wx_send(_uid, f"✅ {cmd_name} executed.", config)
return
wx_state = session_ctx.agent_state
if wx_state and wx_state.messages:
diff --git a/cheetahclaws.py b/cheetahclaws.py
index fa3dc52..9abbeb9 100755
--- a/cheetahclaws.py
+++ b/cheetahclaws.py
@@ -739,14 +739,28 @@ def _start_headless_bridges(config: dict) -> None:
return # nothing configured — no-op
import runtime as _runtime
- from agent import AgentState, run as _agent_run, TextChunk, ToolStart, ToolEnd
+ from agent import (
+ AgentState, run as _agent_run,
+ TextChunk, ToolStart, ToolEnd, PermissionRequest,
+ )
from context import build_system_prompt
state = AgentState(messages=[], total_input_tokens=0, total_output_tokens=0)
session_ctx = _runtime.get_session_ctx(config.get("_session_id", "default"))
session_ctx.agent_state = state
+ # Wire the low-level Telegram sender so ask_input_interactive can render
+ # inline-keyboard permission prompts. Without this, the Telegram branch
+ # in tools/interaction.py:256 falls through to terminal input(), making
+ # approval prompts invisible in headless/Docker mode (issue #84 follow-up).
+ session_ctx.tg_send = _tg_send
def _headless_run_query(prompt: str, is_background: bool = False) -> None:
+ # Promote the per-turn telegram_incoming flag so _is_in_tg_turn() sees
+ # in_telegram_turn=True for the duration of this query. Without this,
+ # ask_input_interactive routes prompts to the terminal even though the
+ # query was triggered by an inbound Telegram message.
+ session_ctx.in_telegram_turn = session_ctx.telegram_incoming
+ session_ctx.telegram_incoming = False
system_prompt = build_system_prompt(config)
try:
for ev in _agent_run(prompt, state, config, system_prompt):
@@ -759,8 +773,20 @@ def _headless_run_query(prompt: str, is_background: bool = False) -> None:
elif isinstance(ev, ToolEnd) and session_ctx.on_tool_end:
try: session_ctx.on_tool_end(ev.name, str(ev.result or "")[:500])
except Exception: pass
+ elif isinstance(ev, PermissionRequest):
+ # Mirror repl()'s permission handling so headless deploys
+ # actually surface the [Approve][Reject][Accept all]
+ # inline-keyboard prompt over the bridge. Defaults to
+ # denied on any failure so a broken bridge never auto-runs
+ # a sensitive tool.
+ try:
+ ev.granted = ask_permission_interactive(ev.description, config)
+ except Exception:
+ ev.granted = False
except Exception:
pass # never let a bridge query crash the server thread
+ finally:
+ session_ctx.in_telegram_turn = False
session_ctx.run_query = _headless_run_query
# Wire slash-command dispatch so bridges' / messages don't go to
diff --git a/docs/media/casts/brainstorm.cast b/docs/media/casts/brainstorm.cast
new file mode 100644
index 0000000..de61c91
--- /dev/null
+++ b/docs/media/casts/brainstorm.cast
@@ -0,0 +1,130 @@
+{"version": 2, "width": 110, "height": 32, "timestamp": 1747262400, "env": {"SHELL": "/bin/zsh", "TERM": "xterm-256color"}, "title": "CheetahClaws /brainstorm \u2014 5 personas debate event sourcing", "idle_time_limit": 1.5}
+[0.0, "o", "[32m~/projects/checkout[0m [36m\u276f[0m cheetahclaws\r\n"]
+[0.3, "o", "[2m[CheetahClaws v3.05.79 \u00b7 claude-sonnet-4-6][0m\r\n\r\n"]
+[0.5, "o", "[1m[36m[checkout] \u00bb[0m "]
+[1.0, "o", ""]
+[1.05, "o", "/"]
+[1.107, "o", "b"]
+[1.166, "o", "r"]
+[1.212, "o", "a"]
+[1.267, "o", "i"]
+[1.321, "o", "n"]
+[1.374, "o", "s"]
+[1.416, "o", "t"]
+[1.457, "o", "o"]
+[1.505, "o", "r"]
+[1.56, "o", "m"]
+[1.605, "o", " "]
+[1.655, "o", "\""]
+[1.701, "o", "S"]
+[1.758, "o", "h"]
+[1.817, "o", "o"]
+[1.865, "o", "u"]
+[1.925, "o", "l"]
+[1.966, "o", "d"]
+[2.022, "o", " "]
+[2.08, "o", "w"]
+[2.123, "o", "e"]
+[2.177, "o", " "]
+[2.228, "o", "m"]
+[2.287, "o", "i"]
+[2.331, "o", "g"]
+[2.382, "o", "r"]
+[2.44, "o", "a"]
+[2.493, "o", "t"]
+[2.539, "o", "e"]
+[2.58, "o", " "]
+[2.639, "o", "t"]
+[2.689, "o", "h"]
+[2.749, "o", "e"]
+[2.792, "o", " "]
+[2.848, "o", "o"]
+[2.904, "o", "r"]
+[2.962, "o", "d"]
+[3.003, "o", "e"]
+[3.05, "o", "r"]
+[3.091, "o", " "]
+[3.146, "o", "s"]
+[3.193, "o", "e"]
+[3.253, "o", "r"]
+[3.3, "o", "v"]
+[3.356, "o", "i"]
+[3.413, "o", "c"]
+[3.458, "o", "e"]
+[3.509, "o", " "]
+[3.568, "o", "f"]
+[3.619, "o", "r"]
+[3.666, "o", "o"]
+[3.711, "o", "m"]
+[3.767, "o", " "]
+[3.811, "o", "C"]
+[3.863, "o", "R"]
+[3.914, "o", "U"]
+[3.965, "o", "D"]
+[4.007, "o", " "]
+[4.061, "o", "t"]
+[4.119, "o", "o"]
+[4.165, "o", " "]
+[4.212, "o", "e"]
+[4.27, "o", "v"]
+[4.322, "o", "e"]
+[4.367, "o", "n"]
+[4.407, "o", "t"]
+[4.462, "o", " "]
+[4.509, "o", "s"]
+[4.559, "o", "o"]
+[4.604, "o", "u"]
+[4.657, "o", "r"]
+[4.711, "o", "c"]
+[4.758, "o", "i"]
+[4.799, "o", "n"]
+[4.859, "o", "g"]
+[4.917, "o", "?"]
+[4.958, "o", "\""]
+[5.358, "o", "\r\n\r\n"]
+[5.758, "o", "[2m\u25cf Spawning 5 expert personas in parallel\u2026[0m\r\n\r\n"]
+[6.258, "o", " [34m\u25cf [ARCHITECT][0m\r\n"]
+[6.458, "o", " Event sourcing maps cleanly to checkout's domain \u2014 every state change is\r\r\n"]
+[6.658, "o", " already an event (cart-added, payment-authorised, fulfilled). We get audit\r\r\n"]
+[6.858, "o", " trail, time-travel debugging, and replay for free.\r\n"]
+[7.358, "o", " [31m\u25cf [SKEPTIC][0m\r\n"]
+[7.558, "o", " Three months of dev time to rewrite something that works fine. Where's the\r\r\n"]
+[7.758, "o", " user-facing win? You're trading a known boring solution for a fashionable\r\r\n"]
+[7.958, "o", " one we'll regret in 18 months when the lead architect leaves.\r\n"]
+[8.458, "o", " [35m\u25cf [PRAGMATIST][0m\r\n"]
+[8.658, "o", " Halfway compromise: keep CRUD as the source of truth, emit an outbox event\r\r\n"]
+[8.858, "o", " on each write. We get the audit log without the rewrite. Doors stay open\r\r\n"]
+[9.058, "o", " for full ES later.\r\n"]
+[9.558, "o", " [33m\u25cf [DBA][0m\r\n"]
+[9.758, "o", " Two real issues with the proposal: (1) Postgres event-store throughput on the\r\r\n"]
+[9.958, "o", " checkout volume \u2014 we'd need partitioning by tenant; (2) projections lag\r\r\n"]
+[10.158, "o", " will surface as stale reads during Black Friday.\r\n"]
+[10.658, "o", " [32m\u25cf [PM][0m\r\n"]
+[10.858, "o", " What problem are we actually solving? If it's audit/compliance \u2014 outbox\r\r\n"]
+[11.058, "o", " pattern is enough. If it's the analytics team rebuilding cart funnels\r\r\n"]
+[11.258, "o", " every quarter \u2014 yes, ES pays off.\r\n"]
+[11.758, "o", "\r\n"]
+[12.058, "o", "[2m\u25cf Round 2: rebuttals\u2026[0m\r\n\r\n"]
+[12.558, "o", " [31m\u25cf [SKEPTIC][0m\r\n"]
+[12.758, "o", " \u2192 ARCHITECT: 'time-travel debugging for free' has never been free in production.\r\r\n"]
+[12.958, "o", " Snapshot management alone is a quarter of work.\r\n"]
+[13.458, "o", " [34m\u25cf [ARCHITECT][0m\r\n"]
+[13.658, "o", " \u2192 SKEPTIC: agreed on snapshots \u2014 but DBA's outbox path is half-measure.\r\r\n"]
+[13.858, "o", " Once we duplicate state we own two truths.\r\n"]
+[14.358, "o", " [35m\u25cf [PRAGMATIST][0m\r\n"]
+[14.558, "o", " \u2192 Both: outbox is not a half-measure if we treat it as a stepping stone with\r\r\n"]
+[14.758, "o", " a six-month review gate.\r\n"]
+[15.158, "o", "\r\n"]
+[15.658, "o", "[1m\u2500\u2500\u2500 Synthesis \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500[0m\r\n\r\n"]
+[15.958, "o", " [32m[1mConsensus:[0m no full migration this quarter. Ship outbox + replayer first.\r\n\r\n"]
+[16.258, "o", " [1mDecision pivots on PM's framing:[0m\r\n"]
+[16.508, "o", " \u2022 audit/compliance only \u2192 [32mstay CRUD + outbox events[0m\r\n"]
+[16.758, "o", " \u2022 analytics rebuild every quarter \u2192 [33mplan full ES, but in Q3[0m\r\n\r\n"]
+[17.258, "o", "[33m[Write][0m brainstorm_outputs/todo_list.txt\r\n"]
+[17.558, "o", " [32m1.[0m [2m[ ][0m Add outbox table + transactional event publisher (1 week)\r\n"]
+[17.758, "o", " [32m2.[0m [2m[ ][0m Wire Kafka consumer \u2192 analytics warehouse (3 days)\r\n"]
+[17.958, "o", " [32m3.[0m [2m[ ][0m Benchmark projection lag with prod-shaped Black-Friday replay\r\n"]
+[18.158, "o", " [32m4.[0m [2m[ ][0m Schedule Q3 ES decision review (compliance + analytics inputs)\r\n\r\n"]
+[18.558, "o", "[32m\u2713[0m 4 tasks ready. Run [36m/worker[0m to auto-implement them.\r\n\r\n"]
+[18.958, "o", "[1m[36m[checkout] \u00bb[0m "]
+[19.758, "o", ""]
diff --git a/docs/media/casts/brainstorm.svg b/docs/media/casts/brainstorm.svg
new file mode 100644
index 0000000..3f6da29
--- /dev/null
+++ b/docs/media/casts/brainstorm.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/media/casts/code_review.cast b/docs/media/casts/code_review.cast
new file mode 100644
index 0000000..9700b3f
--- /dev/null
+++ b/docs/media/casts/code_review.cast
@@ -0,0 +1,173 @@
+{"version": 2, "width": 100, "height": 28, "timestamp": 1747262400, "env": {"SHELL": "/bin/zsh", "TERM": "xterm-256color"}, "title": "CheetahClaws \u2014 find a perf bug, switch to local Ollama, apply the fix", "idle_time_limit": 1.2}
+[0.0, "o", "[32m~/projects/parser[0m [36m\u276f[0m "]
+[0.7, "o", ""]
+[0.749, "o", "c"]
+[0.8, "o", "h"]
+[0.859, "o", "e"]
+[0.908, "o", "e"]
+[0.958, "o", "t"]
+[1.01, "o", "a"]
+[1.054, "o", "h"]
+[1.104, "o", "c"]
+[1.156, "o", "l"]
+[1.212, "o", "a"]
+[1.254, "o", "w"]
+[1.3, "o", "s"]
+[1.7, "o", "\r\n"]
+[2.0, "o", "[2m[CheetahClaws v3.05.79 \u00b7 claude-sonnet-4-6 \u00b7 auto mode][0m\r\n"]
+[2.2, "o", "[2mType /help for commands, /model to switch, !cmd for shell, Ctrl+C to quit[0m\r\n\r\n"]
+[2.4, "o", "[1m[36m[project] \u00bb[0m "]
+[3.0, "o", ""]
+[3.049, "o", "W"]
+[3.101, "o", "h"]
+[3.159, "o", "y"]
+[3.208, "o", " "]
+[3.258, "o", "i"]
+[3.31, "o", "s"]
+[3.354, "o", " "]
+[3.404, "o", "p"]
+[3.457, "o", "a"]
+[3.513, "o", "r"]
+[3.554, "o", "s"]
+[3.601, "o", "e"]
+[3.642, "o", "_"]
+[3.699, "o", "l"]
+[3.752, "o", "o"]
+[3.793, "o", "g"]
+[3.853, "o", "s"]
+[3.912, "o", "("]
+[3.965, "o", ")"]
+[4.018, "o", " "]
+[4.061, "o", "s"]
+[4.101, "o", "o"]
+[4.152, "o", " "]
+[4.193, "o", "s"]
+[4.237, "o", "l"]
+[4.281, "o", "o"]
+[4.322, "o", "w"]
+[4.371, "o", " "]
+[4.42, "o", "o"]
+[4.477, "o", "n"]
+[4.527, "o", " "]
+[4.58, "o", "t"]
+[4.63, "o", "h"]
+[4.683, "o", "e"]
+[4.733, "o", " "]
+[4.778, "o", "1"]
+[4.838, "o", "G"]
+[4.898, "o", "B"]
+[4.955, "o", " "]
+[5.009, "o", "s"]
+[5.055, "o", "a"]
+[5.1, "o", "m"]
+[5.146, "o", "p"]
+[5.187, "o", "l"]
+[5.242, "o", "e"]
+[5.29, "o", " "]
+[5.347, "o", "f"]
+[5.395, "o", "i"]
+[5.454, "o", "l"]
+[5.511, "o", "e"]
+[5.551, "o", "?"]
+[5.595, "o", " "]
+[5.654, "o", "P"]
+[5.703, "o", "r"]
+[5.763, "o", "o"]
+[5.811, "o", "f"]
+[5.852, "o", "i"]
+[5.905, "o", "l"]
+[5.96, "o", "e"]
+[6.006, "o", " "]
+[6.047, "o", "a"]
+[6.094, "o", "n"]
+[6.153, "o", "d"]
+[6.208, "o", " "]
+[6.251, "o", "f"]
+[6.296, "o", "i"]
+[6.338, "o", "x"]
+[6.379, "o", " "]
+[6.435, "o", "i"]
+[6.478, "o", "t"]
+[6.53, "o", "."]
+[7.03, "o", "\r\n\r\n"]
+[7.53, "o", "[2m\u25cf Reading the function and tracing hot spots\u2026[0m\r\n"]
+[7.93, "o", "[33m[Read][0m src/parser.py [2m(187 lines)[0m\r\n"]
+[8.33, "o", "[33m[Grep][0m 'for .* in' src/parser.py [2m(7 matches)[0m\r\n"]
+[8.73, "o", "[33m[Bash][0m python -X importtime -c 'from parser import parse_logs; parse_logs(\"sample.log\")'\r\n"]
+[9.23, "o", " [2mreal 18.42s user 17.99s sys 0.41s[0m\r\n\r\n"]
+[9.83, "o", "[1mFound the hot path:[0m\r\n\r\n"]
+[10.23, "o", " [31mline 84[0m [2mre.compile(PATTERN)[0m called inside the loop\r\n"]
+[10.53, "o", " [31mline 97[0m [2mresult += line[0m string concatenation in O(n\u00b2)\r\n"]
+[10.83, "o", " [31mline 112[0m [2mopen(path).read()[0m loads the whole 1GB into RAM\r\n\r\n"]
+[11.33, "o", "[1m3 fixes will bring it under 2 seconds.[0m I'll show them before applying.\r\n\r\n"]
+[11.83, "o", "[1m[36m[project] \u00bb[0m "]
+[12.23, "o", ""]
+[12.279, "o", "/"]
+[12.33, "o", "m"]
+[12.388, "o", "o"]
+[12.438, "o", "d"]
+[12.488, "o", "e"]
+[12.54, "o", "l"]
+[12.583, "o", " "]
+[12.633, "o", "o"]
+[12.686, "o", "l"]
+[12.742, "o", "l"]
+[12.784, "o", "a"]
+[12.83, "o", "m"]
+[12.872, "o", "a"]
+[12.928, "o", "/"]
+[12.982, "o", "q"]
+[13.023, "o", "w"]
+[13.082, "o", "e"]
+[13.141, "o", "n"]
+[13.195, "o", "2"]
+[13.247, "o", "."]
+[13.29, "o", "5"]
+[13.33, "o", "-"]
+[13.381, "o", "c"]
+[13.422, "o", "o"]
+[13.466, "o", "d"]
+[13.511, "o", "e"]
+[13.551, "o", "r"]
+[13.951, "o", "\r\n"]
+[14.351, "o", "[32m\u2713[0m Switched to [1mollama/qwen2.5-coder[0m [2m(local, $0/call)[0m\r\n\r\n"]
+[14.651, "o", "[1m[36m[project] \u00bb[0m "]
+[15.151, "o", ""]
+[15.2, "o", "a"]
+[15.252, "o", "p"]
+[15.31, "o", "p"]
+[15.359, "o", "l"]
+[15.41, "o", "y"]
+[15.461, "o", " "]
+[15.505, "o", "a"]
+[15.555, "o", "l"]
+[15.608, "o", "l"]
+[15.664, "o", " "]
+[15.706, "o", "t"]
+[15.752, "o", "h"]
+[15.793, "o", "r"]
+[15.85, "o", "e"]
+[15.903, "o", "e"]
+[15.944, "o", " "]
+[16.004, "o", "f"]
+[16.063, "o", "i"]
+[16.116, "o", "x"]
+[16.169, "o", "e"]
+[16.212, "o", "s"]
+[16.612, "o", "\r\n\r\n"]
+[17.112, "o", "[33m[Edit][0m src/parser.py\r\n"]
+[17.512, "o", " [31m- PATTERN = r\"\\\\[(\\\\d{4}-\\\\d{2}-\\\\d{2})\\\\] (.*)\"[0m\r\n"]
+[17.812, "o", " [31m- def parse_logs(path):[0m\r\n"]
+[18.112, "o", " [31m- for line in open(path).read().splitlines():[0m\r\n"]
+[18.412, "o", " [31m- m = re.compile(PATTERN).match(line)[0m\r\n"]
+[18.712, "o", " [32m+ _PATTERN_RE = re.compile(r\"\\\\[(\\\\d{4}-\\\\d{2}-\\\\d{2})\\\\] (.*)\")[0m\r\n"]
+[19.012, "o", " [32m+ def parse_logs(path):[0m\r\n"]
+[19.312, "o", " [32m+ out = [][0m\r\n"]
+[19.612, "o", " [32m+ with open(path) as fh:[0m\r\n"]
+[19.912, "o", " [32m+ for line in fh:[0m\r\n"]
+[20.212, "o", " [32m+ m = _PATTERN_RE.match(line)[0m\r\n\r\n"]
+[20.612, "o", "[32m\u2713[0m 1 file changed, 9 insertions(+), 4 deletions(-).\r\n\r\n"]
+[21.112, "o", "[33m[Bash][0m python -c 'from parser import parse_logs; parse_logs(\"sample.log\")'\r\n"]
+[21.712, "o", " [2mreal 1.62s user 1.51s sys 0.09s[0m [32m\u2193 11\u00d7[0m\r\n\r\n"]
+[22.212, "o", "[1m[36m[project] \u00bb[0m "]
+[23.012, "o", ""]
diff --git a/docs/media/casts/code_review.svg b/docs/media/casts/code_review.svg
new file mode 100644
index 0000000..2046de5
--- /dev/null
+++ b/docs/media/casts/code_review.svg
@@ -0,0 +1 @@
+[32m~/projects/parser[0m[36m❯[0m[32m~/projects/parser[0m[36m❯[0mcheetahclaws[2m[CheetahClawsv3.05.79·claude-sonnet-4-6·automode][0m[2mType/helpforcommands,/modeltoswitch,!cmdforshell,Ctrl+Ctoquit[0m[1m[36m[project]»[0m[1m[36m[project]»[0mWhy[1m[36m[project]»[0mWhyis[1m[36m[project]»[0mWhyisparse_logs()[1m[36m[project]»[0mWhyisparse_logs()so[1m[36m[project]»[0mWhyisparse_logs()soslow[1m[36m[project]»[0mWhyisparse_logs()soslowon[1m[36m[project]»[0mWhyisparse_logs()soslowonthe[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GB[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBsample[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBsamplefile?[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBsamplefile?Profile[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBsamplefile?Profileand[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBsamplefile?Profileandfix[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBsamplefile?Profileandfixit.[2m●Readingthefunctionandtracinghotspots…[0m[33m[Read][0msrc/parser.py[2m(187lines)[0m[33m[Grep][0m'for.*in'src/parser.py[2m(7matches)[0m[33m[Bash][0mpython-Ximporttime-c'fromparserimportparse_logs;parse_logs("sample.log")'[2mreal18.42suser17.99ssys0.41s[0m[1mFoundthehotpath:[0m[31mline84[0m[2mre.compile(PATTERN)[0mcalledinsidetheloop[31mline97[0m[2mresult+=line[0mstringconcatenationinO(n²)[31mline112[0m[2mopen(path).read()[0mloadsthewhole1GBintoRAM[1m3fixeswillbringitunder2seconds.[0mI'llshowthembeforeapplying.[1m[36m[project]»[0m/model[1m[36m[project]»[0m/modelollama/qwen2.5-coder[32m✓[0mSwitchedto[1mollama/qwen2.5-coder[0m[2m(local,$0/call)[0m[1m[36m[project]»[0mapply[1m[36m[project]»[0mapplyall[1m[36m[project]»[0mapplyallthree[1m[36m[project]»[0mapplyallthreefixes[33m[Edit][0msrc/parser.py[31m-PATTERN=r"\\[(\\d{4}-\\d{2}-\\d{2})\\](.*)"[0m[31m-defparse_logs(path):[0m[31m-forlineinopen(path).read().splitlines():[0m[31m-m=re.compile(PATTERN).match(line)[0m[32m+_PATTERN_RE=re.compile(r"\\[(\\d{4}-\\d{2}-\\d{2})\\](.*)")[0m[32m+defparse_logs(path):[0m[32m+out=[][0m[32m+withopen(path)asfh:[0m[32m+forlineinfh:[0m[32m+m=_PATTERN_RE.match(line)[0m[32m✓[0m1filechanged,9insertions(+),4deletions(-).[33m[Bash][0mpython-c'fromparserimportparse_logs;parse_logs("sample.log")'[2mreal1.62suser1.51ssys0.09s[0m[32m↓11×[0m[32m~/projects/parser[0m[36m❯[0mc[32m~/projects/parser[0m[36m❯[0mch[32m~/projects/parser[0m[36m❯[0mche[32m~/projects/parser[0m[36m❯[0mchee[32m~/projects/parser[0m[36m❯[0mcheet[32m~/projects/parser[0m[36m❯[0mcheeta[32m~/projects/parser[0m[36m❯[0mcheetah[32m~/projects/parser[0m[36m❯[0mcheetahc[32m~/projects/parser[0m[36m❯[0mcheetahcl[32m~/projects/parser[0m[36m❯[0mcheetahcla[32m~/projects/parser[0m[36m❯[0mcheetahclaw[1m[36m[project]»[0mW[1m[36m[project]»[0mWh[1m[36m[project]»[0mWhyi[1m[36m[project]»[0mWhyisp[1m[36m[project]»[0mWhyispa[1m[36m[project]»[0mWhyispar[1m[36m[project]»[0mWhyispars[1m[36m[project]»[0mWhyisparse[1m[36m[project]»[0mWhyisparse_[1m[36m[project]»[0mWhyisparse_l[1m[36m[project]»[0mWhyisparse_lo[1m[36m[project]»[0mWhyisparse_log[1m[36m[project]»[0mWhyisparse_logs[1m[36m[project]»[0mWhyisparse_logs([1m[36m[project]»[0mWhyisparse_logs()s[1m[36m[project]»[0mWhyisparse_logs()sos[1m[36m[project]»[0mWhyisparse_logs()sosl[1m[36m[project]»[0mWhyisparse_logs()soslo[1m[36m[project]»[0mWhyisparse_logs()soslowo[1m[36m[project]»[0mWhyisparse_logs()soslowont[1m[36m[project]»[0mWhyisparse_logs()soslowonth[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1G[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBs[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBsa[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBsam[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBsamp[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBsampl[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBsamplef[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBsamplefi[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBsamplefil[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBsamplefile[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBsamplefile?P[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBsamplefile?Pr[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBsamplefile?Pro[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBsamplefile?Prof[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBsamplefile?Profi[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBsamplefile?Profil[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBsamplefile?Profilea[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBsamplefile?Profilean[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBsamplefile?Profileandf[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBsamplefile?Profileandfi[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBsamplefile?Profileandfixi[1m[36m[project]»[0mWhyisparse_logs()soslowonthe1GBsamplefile?Profileandfixit[1m[36m[project]»[0m/[1m[36m[project]»[0m/m[1m[36m[project]»[0m/mo[1m[36m[project]»[0m/mod[1m[36m[project]»[0m/mode[1m[36m[project]»[0m/modelo[1m[36m[project]»[0m/modelol[1m[36m[project]»[0m/modeloll[1m[36m[project]»[0m/modelolla[1m[36m[project]»[0m/modelollam[1m[36m[project]»[0m/modelollama[1m[36m[project]»[0m/modelollama/[1m[36m[project]»[0m/modelollama/q[1m[36m[project]»[0m/modelollama/qw[1m[36m[project]»[0m/modelollama/qwe[1m[36m[project]»[0m/modelollama/qwen[1m[36m[project]»[0m/modelollama/qwen2[1m[36m[project]»[0m/modelollama/qwen2.[1m[36m[project]»[0m/modelollama/qwen2.5[1m[36m[project]»[0m/modelollama/qwen2.5-[1m[36m[project]»[0m/modelollama/qwen2.5-c[1m[36m[project]»[0m/modelollama/qwen2.5-co[1m[36m[project]»[0m/modelollama/qwen2.5-cod[1m[36m[project]»[0m/modelollama/qwen2.5-code[1m[36m[project]»[0ma[1m[36m[project]»[0map[1m[36m[project]»[0mapp[1m[36m[project]»[0mappl[1m[36m[project]»[0mapplya[1m[36m[project]»[0mapplyal[1m[36m[project]»[0mapplyallt[1m[36m[project]»[0mapplyallth[1m[36m[project]»[0mapplyallthr[1m[36m[project]»[0mapplyallthre[1m[36m[project]»[0mapplyallthreef[1m[36m[project]»[0mapplyallthreefi[1m[36m[project]»[0mapplyallthreefix[1m[36m[project]»[0mapplyallthreefixe
\ No newline at end of file
diff --git a/docs/media/casts/gen_brainstorm.py b/docs/media/casts/gen_brainstorm.py
new file mode 100644
index 0000000..11ff0ab
--- /dev/null
+++ b/docs/media/casts/gen_brainstorm.py
@@ -0,0 +1,111 @@
+"""Asciinema v2 cast: /brainstorm multi-persona adversarial debate.
+
+Scenario: ask 5 personas to debate whether to migrate the order service
+to event sourcing. They argue, push back, then converge on a todo list.
+
+Run: python3 gen_brainstorm.py > brainstorm.cast
+"""
+import json
+import random
+import sys
+
+
+HEADER = {
+ "version": 2,
+ "width": 110,
+ "height": 32,
+ "timestamp": 1747262400,
+ "env": {"SHELL": "/bin/zsh", "TERM": "xterm-256color"},
+ "title": "CheetahClaws /brainstorm — 5 personas debate event sourcing",
+ "idle_time_limit": 1.5,
+}
+
+CYAN = "[36m"
+GREEN = "[32m"
+YELL = "[33m"
+MAG = "[35m"
+DIM = "[2m"
+BOLD = "[1m"
+GRAY = "[90m"
+RED = "[31m"
+BLUE = "[34m"
+RST = "[0m"
+
+events = []
+t = 0.0
+
+
+def out(delay, text):
+ global t
+ t += delay
+ events.append([round(t, 3), "o", text])
+
+
+def type_string(s, base=0.04, jitter=0.02):
+ rng = random.Random(17)
+ for ch in s:
+ out(base + rng.random() * jitter, ch)
+
+
+# Scene 1 — launch + /brainstorm
+out(0.0, f"{GREEN}~/projects/checkout{RST} {CYAN}❯{RST} cheetahclaws\r\n")
+out(0.3, f"{DIM}[CheetahClaws v3.05.79 · claude-sonnet-4-6]{RST}\r\n\r\n")
+out(0.2, f"{BOLD}{CYAN}[checkout] »{RST} ")
+out(0.5, "")
+type_string("/brainstorm \"Should we migrate the order service from CRUD to event sourcing?\"")
+out(0.4, "\r\n\r\n")
+
+out(0.4, f"{DIM}● Spawning 5 expert personas in parallel…{RST}\r\n\r\n")
+
+# Scene 2 — persona round 1
+personas = [
+ (BLUE, "ARCHITECT", "Event sourcing maps cleanly to checkout's domain — every state change is\r\n already an event (cart-added, payment-authorised, fulfilled). "
+ "We get audit\r\n trail, time-travel debugging, and replay for free."),
+ (RED, "SKEPTIC", "Three months of dev time to rewrite something that works fine. Where's the\r\n user-facing win? You're trading a known boring solution for a fashionable\r\n one we'll regret in 18 months when the lead architect leaves."),
+ (MAG, "PRAGMATIST", "Halfway compromise: keep CRUD as the source of truth, emit an outbox event\r\n on each write. We get the audit log without the rewrite. Doors stay open\r\n for full ES later."),
+ (YELL, "DBA", "Two real issues with the proposal: (1) Postgres event-store throughput on the\r\n checkout volume — we'd need partitioning by tenant; (2) projections lag\r\n will surface as stale reads during Black Friday."),
+ (GREEN, "PM", "What problem are we actually solving? If it's audit/compliance — outbox\r\n pattern is enough. If it's the analytics team rebuilding cart funnels\r\n every quarter — yes, ES pays off."),
+]
+
+for color, name, msg in personas:
+ out(0.5, f" {color}● [{name}]{RST}\r\n")
+ for line in msg.split("\n"):
+ out(0.2, f" {line}\r\n")
+
+out(0.5, "\r\n")
+
+# Scene 3 — debate round 2 (rebuttals)
+out(0.3, f"{DIM}● Round 2: rebuttals…{RST}\r\n\r\n")
+
+rebuttals = [
+ (RED, "SKEPTIC", "→ ARCHITECT: 'time-travel debugging for free' has never been free in production.\r\n Snapshot management alone is a quarter of work."),
+ (BLUE, "ARCHITECT", "→ SKEPTIC: agreed on snapshots — but DBA's outbox path is half-measure.\r\n Once we duplicate state we own two truths."),
+ (MAG, "PRAGMATIST", "→ Both: outbox is not a half-measure if we treat it as a stepping stone with\r\n a six-month review gate."),
+]
+for color, name, msg in rebuttals:
+ out(0.5, f" {color}● [{name}]{RST}\r\n")
+ for line in msg.split("\n"):
+ out(0.2, f" {line}\r\n")
+out(0.4, "\r\n")
+
+# Scene 4 — synthesis
+out(0.5, f"{BOLD}─── Synthesis ────────────────────────────────────────────────────────{RST}\r\n\r\n")
+out(0.3, f" {GREEN}{BOLD}Consensus:{RST} no full migration this quarter. Ship outbox + replayer first.\r\n\r\n")
+out(0.3, f" {BOLD}Decision pivots on PM's framing:{RST}\r\n")
+out(0.25, f" • audit/compliance only → {GREEN}stay CRUD + outbox events{RST}\r\n")
+out(0.25, f" • analytics rebuild every quarter → {YELL}plan full ES, but in Q3{RST}\r\n\r\n")
+
+# Scene 5 — todo_list output
+out(0.5, f"{YELL}[Write]{RST} brainstorm_outputs/todo_list.txt\r\n")
+out(0.3, f" {GREEN}1.{RST} {DIM}[ ]{RST} Add outbox table + transactional event publisher (1 week)\r\n")
+out(0.2, f" {GREEN}2.{RST} {DIM}[ ]{RST} Wire Kafka consumer → analytics warehouse (3 days)\r\n")
+out(0.2, f" {GREEN}3.{RST} {DIM}[ ]{RST} Benchmark projection lag with prod-shaped Black-Friday replay\r\n")
+out(0.2, f" {GREEN}4.{RST} {DIM}[ ]{RST} Schedule Q3 ES decision review (compliance + analytics inputs)\r\n\r\n")
+
+out(0.4, f"{GREEN}✓{RST} 4 tasks ready. Run {CYAN}/worker{RST} to auto-implement them.\r\n\r\n")
+out(0.4, f"{BOLD}{CYAN}[checkout] »{RST} ")
+out(0.8, "")
+
+sys.stdout.write(json.dumps(HEADER) + "\n")
+for ev in events:
+ sys.stdout.write(json.dumps(ev) + "\n")
diff --git a/docs/media/casts/gen_code_review.py b/docs/media/casts/gen_code_review.py
new file mode 100644
index 0000000..1d50556
--- /dev/null
+++ b/docs/media/casts/gen_code_review.py
@@ -0,0 +1,111 @@
+"""Asciinema v2 cast: code review workflow.
+
+Scenario: ask CheetahClaws to find a performance bug in a Python script,
+switch to a local Ollama model, then apply the fix.
+
+Run: python3 gen_code_review.py > code_review.cast
+"""
+import json
+import random
+import sys
+
+
+HEADER = {
+ "version": 2,
+ "width": 100,
+ "height": 28,
+ "timestamp": 1747262400,
+ "env": {"SHELL": "/bin/zsh", "TERM": "xterm-256color"},
+ "title": "CheetahClaws — find a perf bug, switch to local Ollama, apply the fix",
+ "idle_time_limit": 1.2,
+}
+
+CYAN = "[36m"
+GREEN = "[32m"
+YELL = "[33m"
+MAG = "[35m"
+DIM = "[2m"
+BOLD = "[1m"
+GRAY = "[90m"
+RED = "[31m"
+RST = "[0m"
+
+events = []
+t = 0.0
+
+
+def out(delay, text):
+ global t
+ t += delay
+ events.append([round(t, 3), "o", text])
+
+
+def type_string(s, base=0.04, jitter=0.02):
+ rng = random.Random(11)
+ for ch in s:
+ out(base + rng.random() * jitter, ch)
+
+
+# Scene 1 — launch
+out(0.0, f"{GREEN}~/projects/parser{RST} {CYAN}❯{RST} ")
+out(0.7, "")
+type_string("cheetahclaws")
+out(0.4, "\r\n")
+out(0.3, f"{DIM}[CheetahClaws v3.05.79 · claude-sonnet-4-6 · auto mode]{RST}\r\n")
+out(0.2, f"{DIM}Type /help for commands, /model to switch, !cmd for shell, Ctrl+C to quit{RST}\r\n\r\n")
+out(0.2, f"{BOLD}{CYAN}[project] »{RST} ")
+
+# Scene 2 — prompt
+out(0.6, "")
+type_string("Why is parse_logs() so slow on the 1GB sample file? Profile and fix it.")
+out(0.5, "\r\n\r\n")
+
+# Scene 3 — tool calls
+out(0.5, f"{DIM}● Reading the function and tracing hot spots…{RST}\r\n")
+out(0.4, f"{YELL}[Read]{RST} src/parser.py {DIM}(187 lines){RST}\r\n")
+out(0.4, f"{YELL}[Grep]{RST} 'for .* in' src/parser.py {DIM}(7 matches){RST}\r\n")
+out(0.4, f"{YELL}[Bash]{RST} python -X importtime -c 'from parser import parse_logs; parse_logs(\"sample.log\")'\r\n")
+out(0.5, f" {DIM}real 18.42s user 17.99s sys 0.41s{RST}\r\n\r\n")
+
+# Scene 4 — diagnosis
+out(0.6, f"{BOLD}Found the hot path:{RST}\r\n\r\n")
+out(0.4, f" {RED}line 84{RST} {DIM}re.compile(PATTERN){RST} called inside the loop\r\n")
+out(0.3, f" {RED}line 97{RST} {DIM}result += line{RST} string concatenation in O(n²)\r\n")
+out(0.3, f" {RED}line 112{RST} {DIM}open(path).read(){RST} loads the whole 1GB into RAM\r\n\r\n")
+out(0.5, f"{BOLD}3 fixes will bring it under 2 seconds.{RST} I'll show them before applying.\r\n\r\n")
+
+# Scene 5 — switch to local model
+out(0.5, f"{BOLD}{CYAN}[project] »{RST} ")
+out(0.4, "")
+type_string("/model ollama/qwen2.5-coder")
+out(0.4, "\r\n")
+out(0.4, f"{GREEN}✓{RST} Switched to {BOLD}ollama/qwen2.5-coder{RST} {DIM}(local, $0/call){RST}\r\n\r\n")
+
+# Scene 6 — apply fix
+out(0.3, f"{BOLD}{CYAN}[project] »{RST} ")
+out(0.5, "")
+type_string("apply all three fixes")
+out(0.4, "\r\n\r\n")
+
+out(0.5, f"{YELL}[Edit]{RST} src/parser.py\r\n")
+out(0.4, f" {RED}- PATTERN = r\"\\\\[(\\\\d{{4}}-\\\\d{{2}}-\\\\d{{2}})\\\\] (.*)\"{RST}\r\n")
+out(0.3, f" {RED}- def parse_logs(path):{RST}\r\n")
+out(0.3, f" {RED}- for line in open(path).read().splitlines():{RST}\r\n")
+out(0.3, f" {RED}- m = re.compile(PATTERN).match(line){RST}\r\n")
+out(0.3, f" {GREEN}+ _PATTERN_RE = re.compile(r\"\\\\[(\\\\d{{4}}-\\\\d{{2}}-\\\\d{{2}})\\\\] (.*)\"){RST}\r\n")
+out(0.3, f" {GREEN}+ def parse_logs(path):{RST}\r\n")
+out(0.3, f" {GREEN}+ out = []{RST}\r\n")
+out(0.3, f" {GREEN}+ with open(path) as fh:{RST}\r\n")
+out(0.3, f" {GREEN}+ for line in fh:{RST}\r\n")
+out(0.3, f" {GREEN}+ m = _PATTERN_RE.match(line){RST}\r\n\r\n")
+out(0.4, f"{GREEN}✓{RST} 1 file changed, 9 insertions(+), 4 deletions(-).\r\n\r\n")
+
+# Scene 7 — re-bench
+out(0.5, f"{YELL}[Bash]{RST} python -c 'from parser import parse_logs; parse_logs(\"sample.log\")'\r\n")
+out(0.6, f" {DIM}real 1.62s user 1.51s sys 0.09s{RST} {GREEN}↓ 11×{RST}\r\n\r\n")
+out(0.5, f"{BOLD}{CYAN}[project] »{RST} ")
+out(0.8, "")
+
+sys.stdout.write(json.dumps(HEADER) + "\n")
+for ev in events:
+ sys.stdout.write(json.dumps(ev) + "\n")
diff --git a/docs/media/casts/gen_lab.py b/docs/media/casts/gen_lab.py
new file mode 100644
index 0000000..e4b5406
--- /dev/null
+++ b/docs/media/casts/gen_lab.py
@@ -0,0 +1,125 @@
+"""Asciinema v2 cast: /lab autonomous multi-agent paper writing.
+
+Scenario: /lab start on iris classification comparison. Show the 9 stages
+advancing with agent messages and reviewer iteration. End with the
+deliverable file tree.
+
+Run: python3 gen_lab.py > lab.cast
+"""
+import json
+import random
+import sys
+
+
+HEADER = {
+ "version": 2,
+ "width": 110,
+ "height": 34,
+ "timestamp": 1747262400,
+ "env": {"SHELL": "/bin/zsh", "TERM": "xterm-256color"},
+ "title": "CheetahClaws /lab — 9 agents drive a paper from question to PDF",
+ "idle_time_limit": 1.3,
+}
+
+CYAN = "[36m"
+GREEN = "[32m"
+YELL = "[33m"
+MAG = "[35m"
+DIM = "[2m"
+BOLD = "[1m"
+GRAY = "[90m"
+RED = "[31m"
+BLUE = "[34m"
+RST = "[0m"
+
+events = []
+t = 0.0
+
+
+def out(delay, text):
+ global t
+ t += delay
+ events.append([round(t, 3), "o", text])
+
+
+def type_string(s, base=0.04, jitter=0.02):
+ rng = random.Random(23)
+ for ch in s:
+ out(base + rng.random() * jitter, ch)
+
+
+# Scene 1 — launch + /lab start
+out(0.0, f"{GREEN}~{RST} {CYAN}❯{RST} cheetahclaws\r\n")
+out(0.3, f"{DIM}[CheetahClaws v3.05.79 · claude-sonnet-4-6][/lab engine v0]{RST}\r\n\r\n")
+out(0.2, f"{BOLD}{CYAN}[~] »{RST} ")
+out(0.5, "")
+type_string("/lab start \"Compare logistic regression vs random forest on iris, k-fold CV\"")
+out(0.4, "\r\n")
+out(0.4, f"{GREEN}✓{RST} Lab run {BOLD}lab_a3b1c8e9f012{RST} launched. Budget: 60 min · 200k tokens.\r\n\r\n")
+
+# Scene 2 — stage pipeline
+def stage(name, agent_color, agent_name, msg, ok=True):
+ badge_color = GREEN if ok else YELL
+ out(0.5, f" {badge_color}[{name}]{RST}\r\n")
+ out(0.3, f" {agent_color}● {agent_name}{RST}: {msg}\r\n")
+
+stage("QUESTIONING", BLUE, "PI",
+ "Picking Q2: 'Does RF outperform logistic regression on iris under 5-fold CV?'")
+out(0.1, f" {DIM}● Lay Reader: question is concrete and testable.{RST}\r\n\r\n")
+
+stage("SURVEY", MAG, "Surveyor",
+ "12 papers retrieved; baselines on iris well-characterised since 1936.")
+out(0.1, "\r\n")
+
+stage("OUTLINE", BLUE, "Designer",
+ "5-section outline: intro, related, method, results, threats.")
+out(0.1, f" {DIM}● Reviewer×3 critique → 2 pass, 1 asks for ablation; PI signs off.{RST}\r\n\r\n")
+
+stage("CODE_DRAFT", YELL, "Engineer",
+ "scripted iris loader, GridSearchCV for both models, 5-fold stratified.")
+out(0.1, "\r\n")
+
+stage("EXPERIMENT", YELL, "Engineer",
+ "Running sandboxed subprocess…")
+out(0.4, f" {DIM}stdout: Best LR C=10 acc=0.967 ± 0.025{RST}\r\n")
+out(0.3, f" {DIM}stdout: Best RF n=50 acc=0.967 ± 0.033{RST}\r\n")
+out(0.2, f" {DIM}saved figure_1.png (boxplot), results.csv{RST}\r\n\r\n")
+
+stage("ANALYSIS", YELL, "Engineer",
+ "Models tie on accuracy; RF has higher variance. Recommend LR for tabular small-n.")
+out(0.1, "\r\n")
+
+stage("DRAFTING", CYAN, "Drafter",
+ "Composed 2,840-word draft with inline [1]–[12] citations.")
+out(0.1, "\r\n")
+
+# Reviewer loop
+out(0.4, f" {GREEN}[REVIEW LOOP]{RST}\r\n")
+out(0.3, f" {RED}● Reviewer #1{RST}: 'Section 3.2 doesn't address class imbalance — minor revision.'\r\n")
+out(0.3, f" {RED}● Reviewer #2{RST}: 'Threats section thin. Add overfitting note.'\r\n")
+out(0.3, f" {GREEN}● Reviewer #3{RST}: 'Accept.'\r\n")
+out(0.4, f" {CYAN}● Drafter{RST}: revised §3.2 + §6, rebuilt bib.\r\n")
+out(0.3, f" {GREEN}● Reviewer×3{RST}: {BOLD}2/3 accept on round 2{RST} → PI signs off.\r\n\r\n")
+
+stage("CITATION VERIFY", GREEN, "Citation Checker",
+ "12/12 references verified against arXiv / Semantic Scholar / CrossRef.")
+out(0.1, "\r\n")
+
+# Final
+out(0.4, f" {GREEN}[FINALISE]{RST} Bundle ready.\r\n\r\n")
+out(0.4, f"{GREEN}✓{RST} Output at {CYAN}~/.cheetahclaws/research_papers/lab_a3b1c8e9f012/{RST}\r\n\r\n")
+out(0.3, f" ├── {BOLD}report.md{RST} {DIM}(2,940 words, 12 refs){RST}\r\n")
+out(0.15, f" ├── references.bib {DIM}(verified BibTeX){RST}\r\n")
+out(0.15, f" ├── citations_verified.json\r\n")
+out(0.15, f" └── workspace/\r\n")
+out(0.15, f" ├── experiment.py {DIM}(83 lines){RST}\r\n")
+out(0.15, f" ├── figure_1.png {DIM}(boxplot){RST}\r\n")
+out(0.15, f" └── results.csv {DIM}(5 folds × 2 models){RST}\r\n\r\n")
+
+out(0.5, f"{DIM}Total: 22 min · 142k tokens · $1.40 in API cost{RST}\r\n\r\n")
+out(0.4, f"{BOLD}{CYAN}[~] »{RST} ")
+out(0.8, "")
+
+sys.stdout.write(json.dumps(HEADER) + "\n")
+for ev in events:
+ sys.stdout.write(json.dumps(ev) + "\n")
diff --git a/docs/media/casts/gen_research.py b/docs/media/casts/gen_research.py
new file mode 100644
index 0000000..5c82150
--- /dev/null
+++ b/docs/media/casts/gen_research.py
@@ -0,0 +1,141 @@
+"""Asciinema v2 cast: 20-source research pipeline.
+
+Scenario: /research "LLM agents 2026" fans out across arXiv, HuggingFace,
+Semantic Scholar, HackerNews, GitHub, Reddit, 知乎, B站, 微博, 小红书.
+Shows live source completion, entity heat table, citation pull.
+
+Run: python3 gen_research.py > research.cast
+"""
+import json
+import random
+import sys
+
+
+HEADER = {
+ "version": 2,
+ "width": 110,
+ "height": 32,
+ "timestamp": 1747262400,
+ "env": {"SHELL": "/bin/zsh", "TERM": "xterm-256color"},
+ "title": "CheetahClaws /research — parallel fan-out across 20 sources",
+ "idle_time_limit": 1.5,
+}
+
+CYAN = "[36m"
+GREEN = "[32m"
+YELL = "[33m"
+MAG = "[35m"
+DIM = "[2m"
+BOLD = "[1m"
+GRAY = "[90m"
+RED = "[31m"
+BLUE = "[34m"
+RST = "[0m"
+
+events = []
+t = 0.0
+
+
+def out(delay, text):
+ global t
+ t += delay
+ events.append([round(t, 3), "o", text])
+
+
+def type_string(s, base=0.04, jitter=0.02):
+ rng = random.Random(13)
+ for ch in s:
+ out(base + rng.random() * jitter, ch)
+
+
+# Scene 1 — launch + /research
+out(0.0, f"{GREEN}~{RST} {CYAN}❯{RST} ")
+out(0.6, "")
+type_string("cheetahclaws")
+out(0.4, "\r\n")
+out(0.3, f"{DIM}[CheetahClaws v3.05.79 · claude-sonnet-4-6]{RST}\r\n\r\n")
+out(0.2, f"{BOLD}{CYAN}[~] »{RST} ")
+out(0.5, "")
+type_string("/research \"LLM agents 2026 trends\" --range 6m --expand")
+out(0.4, "\r\n\r\n")
+
+# Scene 2 — query expansion
+out(0.5, f"{DIM}● Expanding query into 4 sibling sub-queries…{RST}\r\n")
+sub_qs = [
+ "autonomous coding agents benchmarks 2026",
+ "multi-agent debate / reviewer-author loops",
+ "agentic tool use plus MCP / function calling",
+ "open-weight models for agent workflows",
+]
+for q in sub_qs:
+ out(0.25, f" {DIM}↳{RST} {q}\r\n")
+out(0.4, "\r\n")
+
+# Scene 3 — fan-out, live source completions
+out(0.4, f"{BOLD}Fanning out across 20 sources in parallel…{RST}\r\n\r\n")
+
+sources = [
+ ("arXiv", "342", GREEN, 0.35),
+ ("Semantic Scholar","218", GREEN, 0.25),
+ ("HuggingFace Papers","176",GREEN, 0.20),
+ ("OpenAlex", "412", GREEN, 0.30),
+ ("alphaXiv", " 84", GREEN, 0.22),
+ ("HackerNews", "511", GREEN, 0.20),
+ ("GitHub", "298", GREEN, 0.28),
+ ("Reddit r/MachineLearning","147", GREEN, 0.18),
+ ("StackOverflow", " 62", GREEN, 0.15),
+ ("Google News", "203", GREEN, 0.25),
+ ("Polymarket", " 9", GREEN, 0.18),
+ ("SEC EDGAR", " 14", GREEN, 0.20),
+ ("Twitter / X", "1.2k", YELL, 0.30),
+ ("Brave Search", "188", GREEN, 0.18),
+ ("Tavily", "151", GREEN, 0.20),
+ ("Google Scholar", "224", GREEN, 0.25),
+ ("知乎 Zhihu", "186", GREEN, 0.22),
+ ("B站 Bilibili", "298", GREEN, 0.25),
+ ("微博 Weibo", "412", YELL, 0.30),
+ ("小红书 Xiaohongshu","127",GREEN, 0.20),
+]
+for name, count, color, delay in sources:
+ out(delay, f" {color}✓{RST} {BOLD}{name:<28}{RST} {DIM}→{RST} {count:>5} hits\r\n")
+
+out(0.4, "\r\n")
+
+# Scene 4 — entity heat table
+out(0.6, f"{BOLD}Top entities by cross-platform attention:{RST}\r\n\r\n")
+out(0.3, f" {DIM}entity arXiv HF GH HN 微博 Zhihu total{RST}\r\n")
+out(0.2, f" {DIM}{'─'*70}{RST}\r\n")
+rows = [
+ ("Claude 4.6", "127", " 84", " 32", "298", " 412", " 186", " 1,139"),
+ ("DeepSeek V4", "108", "112", "147", "176", " 287", " 298", " 1,128"),
+ ("Qwen3-Coder", " 87", " 92", "211", " 88", " 154", " 287", " 919"),
+ ("MCP Protocol", " 42", " 28", "188", "247", " 64", " 72", " 641"),
+ ("Llama 4", " 96", "118", " 94", "203", " 167", " 88", " 766"),
+]
+for ent, *vals in rows:
+ cells = " ".join(f"{v:>5}" for v in vals[:-1])
+ total = vals[-1]
+ out(0.25, f" {BOLD}{ent:<18}{RST} {DIM}{cells}{RST} {GREEN}{total}{RST}\r\n")
+
+out(0.5, "\r\n")
+
+# Scene 5 — citations verified
+out(0.5, f"{DIM}● Verifying citations against arXiv / Semantic Scholar / CrossRef…{RST}\r\n")
+out(0.4, f" {GREEN}✓{RST} 47 papers, {GREEN}45 verified{RST}, {RED}2 flagged for hallucination{RST}\r\n\r\n")
+
+# Scene 6 — report saved
+out(0.4, f"{GREEN}✓{RST} Brief saved → {CYAN}~/.cheetahclaws/research_reports/llm-agents-2026-trends-{t:.0f}.md{RST}\r\n")
+out(0.2, f" {DIM}3,124 words · 47 citations · cross-platform heat table · 12-month trend sparkline{RST}\r\n\r\n")
+
+# Scene 7 — follow-up
+out(0.5, f"{BOLD}{CYAN}[~] »{RST} ")
+out(0.5, "")
+type_string("/reports open")
+out(0.4, "\r\n")
+out(0.4, f"{DIM}Opening llm-agents-2026-trends-{t:.0f}.md in your editor…{RST}\r\n\r\n")
+out(0.5, f"{BOLD}{CYAN}[~] »{RST} ")
+out(0.7, "")
+
+sys.stdout.write(json.dumps(HEADER) + "\n")
+for ev in events:
+ sys.stdout.write(json.dumps(ev) + "\n")
diff --git a/docs/media/casts/gen_research_agent.py b/docs/media/casts/gen_research_agent.py
new file mode 100644
index 0000000..8af5804
--- /dev/null
+++ b/docs/media/casts/gen_research_agent.py
@@ -0,0 +1,129 @@
+"""Asciinema v2 cast: /agent research_assistant — autonomous background loop.
+
+Scenario: launch the research_assistant agent template on a topic; show
+three iteration cycles with summaries pushed to the bridge, then the
+stagnation-stop guard kicking in to save tokens.
+
+Run: python3 gen_research_agent.py > research_agent.cast
+"""
+import json
+import random
+import sys
+
+
+HEADER = {
+ "version": 2,
+ "width": 110,
+ "height": 32,
+ "timestamp": 1747262400,
+ "env": {"SHELL": "/bin/zsh", "TERM": "xterm-256color"},
+ "title": "CheetahClaws /agent — autonomous research_assistant loop",
+ "idle_time_limit": 1.5,
+}
+
+CYAN = "[36m"
+GREEN = "[32m"
+YELL = "[33m"
+MAG = "[35m"
+DIM = "[2m"
+BOLD = "[1m"
+GRAY = "[90m"
+RED = "[31m"
+BLUE = "[34m"
+RST = "[0m"
+
+events = []
+t = 0.0
+
+
+def out(delay, text):
+ global t
+ t += delay
+ events.append([round(t, 3), "o", text])
+
+
+def type_string(s, base=0.04, jitter=0.02):
+ rng = random.Random(31)
+ for ch in s:
+ out(base + rng.random() * jitter, ch)
+
+
+# Scene 1 — launch + /agent wizard
+out(0.0, f"{GREEN}~{RST} {CYAN}❯{RST} cheetahclaws\r\n")
+out(0.3, f"{DIM}[CheetahClaws v3.05.79 · claude-sonnet-4-6]{RST}\r\n\r\n")
+out(0.2, f"{BOLD}{CYAN}[~] »{RST} ")
+out(0.4, "")
+type_string("/agent")
+out(0.4, "\r\n\r\n")
+
+out(0.3, f"{BOLD}Pick an agent template:{RST}\r\n")
+out(0.2, f" 1. {CYAN}research_assistant{RST} {DIM}— daily literature & trend digest{RST}\r\n")
+out(0.15, f" 2. {CYAN}auto_bug_fixer{RST} {DIM}— scan repo, propose fixes, run tests{RST}\r\n")
+out(0.15, f" 3. {CYAN}paper_writer{RST} {DIM}— draft & polish a paper section by section{RST}\r\n")
+out(0.15, f" 4. {CYAN}auto_coder{RST} {DIM}— implement TODOs from a backlog file{RST}\r\n")
+out(0.15, f" {DIM}(or drop a .md into ~/.cheetahclaws/agent_templates/ for a custom one){RST}\r\n\r\n")
+out(0.3, f"{BOLD}Choose [1-4]:{RST} ")
+out(0.5, "")
+type_string("1")
+out(0.4, "\r\n")
+out(0.3, f"{BOLD}Topic for research_assistant:{RST} ")
+out(0.4, "")
+type_string("Multi-agent debate vs single-model — papers from the last 30 days")
+out(0.4, "\r\n\r\n")
+
+out(0.4, f"{GREEN}✓{RST} Agent {BOLD}research_assistant_8f3a2c{RST} started — loop every 4 hours · push to Telegram\r\n")
+out(0.2, f"{DIM} Output dir: ~/.cheetahclaws/agents/research_assistant_8f3a2c/output/{RST}\r\n\r\n")
+
+# Scene 2 — iteration 1
+def iteration(n, ts, color, summary_lines, stagnation=False):
+ badge = YELL if stagnation else GREEN
+ out(0.5, f" {color}─── Iteration #{n} ─── {DIM}{ts}{RST}\r\n")
+ for line in summary_lines:
+ out(0.25, f" {line}\r\n")
+ out(0.3, f" {DIM}→ pushed iteration summary to Telegram chat 458291205{RST}\r\n\r\n")
+
+iteration(1, "11:00 PT", CYAN, [
+ f"{YELL}[Read]{RST} ~/.cheetahclaws/agents/.../state.json {DIM}(first run, empty){RST}",
+ f"{YELL}[research]{RST} fanned out across 20 sources for the last 24h",
+ f"{GREEN}● Found 17 new papers, 3 high-signal:{RST}",
+ f" {DIM}•{RST} \"AdvDebate: …\" (arXiv 2605.04123) — adversarial multi-agent debate",
+ f" {DIM}•{RST} \"OneShot or N: …\" (arXiv 2605.04588) — single-model can rival debate",
+ f" {DIM}•{RST} \"Skeptic Loop: …\" (Reddit + GitHub) — open-source debate framework",
+ f"{YELL}[Write]{RST} digest_day_1.md saved to output/",
+])
+
+iteration(2, "15:00 PT", MAG, [
+ f"{YELL}[Read]{RST} state.json {DIM}(last digest: digest_day_1.md){RST}",
+ f"{YELL}[research]{RST} new since 11:00 → 4 papers, 1 high-signal",
+ f"{GREEN}● Notable:{RST} \"Beyond Debate: …\" (NeurIPS workshop preprint)",
+ f" {DIM}— suggests debate gains shrink as base model gets larger{RST}",
+ f"{YELL}[Write]{RST} digest_day_1.md (appended)",
+])
+
+iteration(3, "19:00 PT", BLUE, [
+ f"{YELL}[research]{RST} new since 15:00 → 0 papers (quiet window)",
+ f"{DIM}● No new high-signal items. Reused yesterday's analysis.{RST}",
+ f"{YELL}[Write]{RST} digest_day_1.md (timestamp updated)",
+])
+
+# Scene 3 — stagnation-stop kicks in
+out(0.5, f" {YELL}─── Iteration #4 ─── {DIM}23:00 PT{RST}\r\n")
+out(0.3, f" {YELL}[research]{RST} 0 new papers · summary identical to #3\r\n")
+out(0.3, f" {RED}● Stagnation-stop:{RST} same summary for 3 iterations in a row.\r\n")
+out(0.2, f" {DIM} threshold: auto_agent_dup_summary_limit = 3 (set 0 to disable){RST}\r\n")
+out(0.3, f" {YELL}● Loop paused.{RST} Next attempt at 09:00 PT (manual or /agent resume).\r\n\r\n")
+
+# Scene 4 — output summary
+out(0.4, f"{BOLD}Output (so far):{RST}\r\n")
+out(0.25, f" ~/.cheetahclaws/agents/research_assistant_8f3a2c/output/\r\n")
+out(0.2, f" ├── {BOLD}digest_day_1.md{RST} {DIM}(2.4 KB, 4 papers analysed){RST}\r\n")
+out(0.2, f" ├── state.json {DIM}(loop bookkeeping){RST}\r\n")
+out(0.2, f" └── notes.md {DIM}(running scratchpad){RST}\r\n\r\n")
+
+out(0.4, f"{DIM}Three iterations · 38k tokens · $0.31. Saved ~$0.90 in API spend by stopping.{RST}\r\n\r\n")
+out(0.4, f"{BOLD}{CYAN}[~] »{RST} ")
+out(0.8, "")
+
+sys.stdout.write(json.dumps(HEADER) + "\n")
+for ev in events:
+ sys.stdout.write(json.dumps(ev) + "\n")
diff --git a/docs/media/casts/gen_telegram.py b/docs/media/casts/gen_telegram.py
new file mode 100644
index 0000000..29b0be7
--- /dev/null
+++ b/docs/media/casts/gen_telegram.py
@@ -0,0 +1,104 @@
+"""Asciinema v2 cast: Telegram bridge remote control.
+
+Scenario: start the Telegram bridge, then show two chat round-trips
+from the phone — checking server load, then queuing a job — followed
+by `!jobs` to inspect the queue.
+
+Run: python3 gen_telegram.py > telegram.cast
+"""
+import json
+import random
+import sys
+
+
+HEADER = {
+ "version": 2,
+ "width": 105,
+ "height": 32,
+ "timestamp": 1747262400,
+ "env": {"SHELL": "/bin/zsh", "TERM": "xterm-256color"},
+ "title": "CheetahClaws Telegram bridge — control the agent from your phone",
+ "idle_time_limit": 1.4,
+}
+
+CYAN = "[36m"
+GREEN = "[32m"
+YELL = "[33m"
+MAG = "[35m"
+DIM = "[2m"
+BOLD = "[1m"
+GRAY = "[90m"
+RED = "[31m"
+BLUE = "[34m"
+RST = "[0m"
+
+events = []
+t = 0.0
+
+
+def out(delay, text):
+ global t
+ t += delay
+ events.append([round(t, 3), "o", text])
+
+
+def type_string(s, base=0.04, jitter=0.02):
+ rng = random.Random(29)
+ for ch in s:
+ out(base + rng.random() * jitter, ch)
+
+
+# Scene 1 — start the bridge
+out(0.0, f"{GREEN}~{RST} {CYAN}❯{RST} cheetahclaws\r\n")
+out(0.3, f"{DIM}[CheetahClaws v3.05.79 · claude-sonnet-4-6]{RST}\r\n\r\n")
+out(0.2, f"{BOLD}{CYAN}[~] »{RST} ")
+out(0.5, "")
+type_string("/telegram 7890:AAEx_REDACTED 458291205")
+out(0.4, "\r\n")
+out(0.4, f"{GREEN}✓{RST} Telegram bridge online — bot @{BOLD}cheetah_personal_bot{RST}, chat 458291205\r\n")
+out(0.2, f"{DIM} Listening for messages. Typing indicator + slash passthrough enabled.{RST}\r\n\r\n")
+
+# Scene 2 — phone message 1: server status
+out(0.6, f"{BLUE}┌─ Telegram ────────────────────────────────────────────────────────┐{RST}\r\n")
+out(0.3, f"{BLUE}│{RST} {DIM}11:42{RST} {BOLD}You{RST}: What's the CPU load on the server right now?\r\n")
+out(0.6, f"{BLUE}│{RST} {DIM}🐆 typing…{RST}\r\n")
+out(0.3, f"{BLUE}└───────────────────────────────────────────────────────────────────┘{RST}\r\n\r\n")
+
+out(0.4, f"{YELL}[Bash]{RST} uptime\r\n")
+out(0.3, f" {DIM}11:42:18 up 14 days, load average: 0.41, 0.55, 0.62{RST}\r\n\r\n")
+
+out(0.4, f"{BLUE}┌─ Telegram ────────────────────────────────────────────────────────┐{RST}\r\n")
+out(0.2, f"{BLUE}│{RST} {DIM}11:42{RST} 🐆 CPU is {GREEN}quiet{RST}: 0.41 / 0.55 / 0.62 (1m / 5m / 15m).\r\n")
+out(0.2, f"{BLUE}│{RST} Server has been up 14 days. Want me to check memory or disk?\r\n")
+out(0.3, f"{BLUE}└───────────────────────────────────────────────────────────────────┘{RST}\r\n\r\n")
+
+# Scene 3 — phone message 2: queue a job while AI is busy
+out(0.5, f"{BLUE}┌─ Telegram ────────────────────────────────────────────────────────┐{RST}\r\n")
+out(0.3, f"{BLUE}│{RST} {DIM}11:43{RST} {BOLD}You{RST}: Re-run the nightly backup and tell me when it's done\r\n")
+out(0.3, f"{BLUE}└───────────────────────────────────────────────────────────────────┘{RST}\r\n\r\n")
+
+out(0.4, f"{YELL}[Bash]{RST} bash /opt/scripts/nightly_backup.sh {DIM}(long-running, queued as job #2){RST}\r\n")
+out(0.3, f"{BLUE}│{RST} {DIM}11:43{RST} 🐆 Queued as job #2. I'll ping you when it finishes.\r\n\r\n")
+
+# Scene 4 — !jobs inspect queue
+out(0.5, f"{BLUE}┌─ Telegram ────────────────────────────────────────────────────────┐{RST}\r\n")
+out(0.3, f"{BLUE}│{RST} {DIM}11:43{RST} {BOLD}You{RST}: !jobs\r\n")
+out(0.3, f"{BLUE}│{RST} {DIM}11:43{RST} 🐆 Job queue:\r\n")
+out(0.25, f"{BLUE}│{RST} {GREEN}#1{RST} {DIM}(done 11:42){RST} uptime check\r\n")
+out(0.2, f"{BLUE}│{RST} {YELL}#2{RST} {DIM}(running 11:43){RST} nightly_backup.sh [████░░░░░░] 41%\r\n")
+out(0.2, f"{BLUE}│{RST} {DIM} `!cancel 2` to stop · `!job 2` for details{RST}\r\n")
+out(0.3, f"{BLUE}└───────────────────────────────────────────────────────────────────┘{RST}\r\n\r\n")
+
+# Scene 5 — job finishes, bot pushes notification
+out(0.6, f"{BLUE}┌─ Telegram ────────────────────────────────────────────────────────┐{RST}\r\n")
+out(0.3, f"{BLUE}│{RST} {DIM}11:51{RST} 🐆 Job #2 done. {GREEN}Backup OK{RST} — 4.2 GB → s3://prod-backups/2026-05-10/\r\n")
+out(0.2, f"{BLUE}│{RST} {DIM}Took 7m 51s. Logs at ~/.cheetahclaws/jobs/2/stdout.txt{RST}\r\n")
+out(0.3, f"{BLUE}└───────────────────────────────────────────────────────────────────┘{RST}\r\n\r\n")
+
+out(0.4, f"{DIM}Also available: /wechat (微信), /slack — same job queue & passthrough.{RST}\r\n\r\n")
+out(0.5, f"{BOLD}{CYAN}[~] »{RST} ")
+out(0.8, "")
+
+sys.stdout.write(json.dumps(HEADER) + "\n")
+for ev in events:
+ sys.stdout.write(json.dumps(ev) + "\n")
diff --git a/docs/media/casts/lab.cast b/docs/media/casts/lab.cast
new file mode 100644
index 0000000..98a9e30
--- /dev/null
+++ b/docs/media/casts/lab.cast
@@ -0,0 +1,127 @@
+{"version": 2, "width": 110, "height": 34, "timestamp": 1747262400, "env": {"SHELL": "/bin/zsh", "TERM": "xterm-256color"}, "title": "CheetahClaws /lab \u2014 9 agents drive a paper from question to PDF", "idle_time_limit": 1.3}
+[0.0, "o", "[32m~[0m [36m\u276f[0m cheetahclaws\r\n"]
+[0.3, "o", "[2m[CheetahClaws v3.05.79 \u00b7 claude-sonnet-4-6][/lab engine v0][0m\r\n\r\n"]
+[0.5, "o", "[1m[36m[~] \u00bb[0m "]
+[1.0, "o", ""]
+[1.058, "o", "/"]
+[1.117, "o", "l"]
+[1.175, "o", "a"]
+[1.217, "o", "b"]
+[1.269, "o", " "]
+[1.317, "o", "s"]
+[1.368, "o", "t"]
+[1.411, "o", "a"]
+[1.454, "o", "r"]
+[1.503, "o", "t"]
+[1.548, "o", " "]
+[1.597, "o", "\""]
+[1.637, "o", "C"]
+[1.679, "o", "o"]
+[1.733, "o", "m"]
+[1.782, "o", "p"]
+[1.832, "o", "a"]
+[1.887, "o", "r"]
+[1.934, "o", "e"]
+[1.975, "o", " "]
+[2.031, "o", "l"]
+[2.082, "o", "o"]
+[2.135, "o", "g"]
+[2.188, "o", "i"]
+[2.247, "o", "s"]
+[2.295, "o", "t"]
+[2.35, "o", "i"]
+[2.397, "o", "c"]
+[2.449, "o", " "]
+[2.502, "o", "r"]
+[2.548, "o", "e"]
+[2.59, "o", "g"]
+[2.639, "o", "r"]
+[2.694, "o", "e"]
+[2.745, "o", "s"]
+[2.794, "o", "s"]
+[2.847, "o", "i"]
+[2.891, "o", "o"]
+[2.934, "o", "n"]
+[2.981, "o", " "]
+[3.037, "o", "v"]
+[3.081, "o", "s"]
+[3.123, "o", " "]
+[3.165, "o", "r"]
+[3.206, "o", "a"]
+[3.252, "o", "n"]
+[3.303, "o", "d"]
+[3.36, "o", "o"]
+[3.406, "o", "m"]
+[3.454, "o", " "]
+[3.499, "o", "f"]
+[3.545, "o", "o"]
+[3.601, "o", "r"]
+[3.652, "o", "e"]
+[3.697, "o", "s"]
+[3.739, "o", "t"]
+[3.795, "o", " "]
+[3.849, "o", "o"]
+[3.906, "o", "n"]
+[3.95, "o", " "]
+[4.001, "o", "i"]
+[4.06, "o", "r"]
+[4.112, "o", "i"]
+[4.17, "o", "s"]
+[4.214, "o", ","]
+[4.273, "o", " "]
+[4.314, "o", "k"]
+[4.363, "o", "-"]
+[4.403, "o", "f"]
+[4.459, "o", "o"]
+[4.513, "o", "l"]
+[4.573, "o", "d"]
+[4.629, "o", " "]
+[4.688, "o", "C"]
+[4.738, "o", "V"]
+[4.798, "o", "\""]
+[5.198, "o", "\r\n"]
+[5.598, "o", "[32m\u2713[0m Lab run [1mlab_a3b1c8e9f012[0m launched. Budget: 60 min \u00b7 200k tokens.\r\n\r\n"]
+[6.098, "o", " [32m[QUESTIONING][0m\r\n"]
+[6.398, "o", " [34m\u25cf PI[0m: Picking Q2: 'Does RF outperform logistic regression on iris under 5-fold CV?'\r\n"]
+[6.498, "o", " [2m\u25cf Lay Reader: question is concrete and testable.[0m\r\n\r\n"]
+[6.998, "o", " [32m[SURVEY][0m\r\n"]
+[7.298, "o", " [35m\u25cf Surveyor[0m: 12 papers retrieved; baselines on iris well-characterised since 1936.\r\n"]
+[7.398, "o", "\r\n"]
+[7.898, "o", " [32m[OUTLINE][0m\r\n"]
+[8.198, "o", " [34m\u25cf Designer[0m: 5-section outline: intro, related, method, results, threats.\r\n"]
+[8.298, "o", " [2m\u25cf Reviewer\u00d73 critique \u2192 2 pass, 1 asks for ablation; PI signs off.[0m\r\n\r\n"]
+[8.798, "o", " [32m[CODE_DRAFT][0m\r\n"]
+[9.098, "o", " [33m\u25cf Engineer[0m: scripted iris loader, GridSearchCV for both models, 5-fold stratified.\r\n"]
+[9.198, "o", "\r\n"]
+[9.698, "o", " [32m[EXPERIMENT][0m\r\n"]
+[9.998, "o", " [33m\u25cf Engineer[0m: Running sandboxed subprocess\u2026\r\n"]
+[10.398, "o", " [2mstdout: Best LR C=10 acc=0.967 \u00b1 0.025[0m\r\n"]
+[10.698, "o", " [2mstdout: Best RF n=50 acc=0.967 \u00b1 0.033[0m\r\n"]
+[10.898, "o", " [2msaved figure_1.png (boxplot), results.csv[0m\r\n\r\n"]
+[11.398, "o", " [32m[ANALYSIS][0m\r\n"]
+[11.698, "o", " [33m\u25cf Engineer[0m: Models tie on accuracy; RF has higher variance. Recommend LR for tabular small-n.\r\n"]
+[11.798, "o", "\r\n"]
+[12.298, "o", " [32m[DRAFTING][0m\r\n"]
+[12.598, "o", " [36m\u25cf Drafter[0m: Composed 2,840-word draft with inline [1]\u2013[12] citations.\r\n"]
+[12.698, "o", "\r\n"]
+[13.098, "o", " [32m[REVIEW LOOP][0m\r\n"]
+[13.398, "o", " [31m\u25cf Reviewer #1[0m: 'Section 3.2 doesn't address class imbalance \u2014 minor revision.'\r\n"]
+[13.698, "o", " [31m\u25cf Reviewer #2[0m: 'Threats section thin. Add overfitting note.'\r\n"]
+[13.998, "o", " [32m\u25cf Reviewer #3[0m: 'Accept.'\r\n"]
+[14.398, "o", " [36m\u25cf Drafter[0m: revised \u00a73.2 + \u00a76, rebuilt bib.\r\n"]
+[14.698, "o", " [32m\u25cf Reviewer\u00d73[0m: [1m2/3 accept on round 2[0m \u2192 PI signs off.\r\n\r\n"]
+[15.198, "o", " [32m[CITATION VERIFY][0m\r\n"]
+[15.498, "o", " [32m\u25cf Citation Checker[0m: 12/12 references verified against arXiv / Semantic Scholar / CrossRef.\r\n"]
+[15.598, "o", "\r\n"]
+[15.998, "o", " [32m[FINALISE][0m Bundle ready.\r\n\r\n"]
+[16.398, "o", "[32m\u2713[0m Output at [36m~/.cheetahclaws/research_papers/lab_a3b1c8e9f012/[0m\r\n\r\n"]
+[16.698, "o", " \u251c\u2500\u2500 [1mreport.md[0m [2m(2,940 words, 12 refs)[0m\r\n"]
+[16.848, "o", " \u251c\u2500\u2500 references.bib [2m(verified BibTeX)[0m\r\n"]
+[16.998, "o", " \u251c\u2500\u2500 citations_verified.json\r\n"]
+[17.148, "o", " \u2514\u2500\u2500 workspace/\r\n"]
+[17.298, "o", " \u251c\u2500\u2500 experiment.py [2m(83 lines)[0m\r\n"]
+[17.448, "o", " \u251c\u2500\u2500 figure_1.png [2m(boxplot)[0m\r\n"]
+[17.598, "o", " \u2514\u2500\u2500 results.csv [2m(5 folds \u00d7 2 models)[0m\r\n\r\n"]
+[18.098, "o", "[2mTotal: 22 min \u00b7 142k tokens \u00b7 $1.40 in API cost[0m\r\n\r\n"]
+[18.498, "o", "[1m[36m[~] \u00bb[0m "]
+[19.298, "o", ""]
diff --git a/docs/media/casts/lab.svg b/docs/media/casts/lab.svg
new file mode 100644
index 0000000..1b2a51a
--- /dev/null
+++ b/docs/media/casts/lab.svg
@@ -0,0 +1 @@
+[32m~[0m[36m❯[0mcheetahclaws[2m[CheetahClawsv3.05.79·claude-sonnet-4-6][/labenginev0][0m[1m[36m[~]»[0m[1m[36m[~]»[0m/lab[1m[36m[~]»[0m/labstart[1m[36m[~]»[0m/labstart"Compare[1m[36m[~]»[0m/labstart"Comparelogistic[1m[36m[~]»[0m/labstart"Comparelogisticregression[1m[36m[~]»[0m/labstart"Comparelogisticregressionvs[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsrandom[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsrandomforest[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsrandomforeston[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsrandomforestoniris,[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsrandomforestoniris,k-fold[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsrandomforestoniris,k-foldCV"[32m✓[0mLabrun[1mlab_a3b1c8e9f012[0mlaunched.Budget:60min·200ktokens.[32m[QUESTIONING][0m[34m●PI[0m:PickingQ2:'DoesRFoutperformlogisticregressiononirisunder5-foldCV?'[2m●LayReader:questionisconcreteandtestable.[0m[32m[SURVEY][0m[35m●Surveyor[0m:12papersretrieved;baselinesoniriswell-characterisedsince1936.[32m[OUTLINE][0m[34m●Designer[0m:5-sectionoutline:intro,related,method,results,threats.[2m●Reviewer×3critique→2pass,1asksforablation;PIsignsoff.[0m[32m[CODE_DRAFT][0m[33m●Engineer[0m:scriptedirisloader,GridSearchCVforbothmodels,5-foldstratified.[32m[EXPERIMENT][0m[33m●Engineer[0m:Runningsandboxedsubprocess…[2mstdout:BestLRC=10acc=0.967±0.025[0m[2mstdout:BestRFn=50acc=0.967±0.033[0m[2msavedfigure_1.png(boxplot),results.csv[0m[32m[ANALYSIS][0m[33m●Engineer[0m:Modelstieonaccuracy;RFhashighervariance.RecommendLRfortabularsmall-n.[32m[DRAFTING][0m[36m●Drafter[0m:Composed2,840-worddraftwithinline[1]–[12]citations.[32m[REVIEWLOOP][0m[31m●Reviewer#1[0m:'Section3.2doesn'taddressclassimbalance—minorrevision.'[31m●Reviewer#2[0m:'Threatssectionthin.Addoverfittingnote.'[32m●Reviewer#3[0m:'Accept.'[36m●Drafter[0m:revised§3.2+§6,rebuiltbib.[32m●Reviewer×3[0m:[1m2/3acceptonround2[0m→PIsignsoff.[32m[CITATIONVERIFY][0m[32m●CitationChecker[0m:12/12referencesverifiedagainstarXiv/SemanticScholar/CrossRef.[32m[FINALISE][0mBundleready.[32m✓[0mOutputat[36m~/.cheetahclaws/research_papers/lab_a3b1c8e9f012/[0m├──[1mreport.md[0m[2m(2,940words,12refs)[0m├──references.bib[2m(verifiedBibTeX)[0m├──citations_verified.json└──workspace/├──experiment.py[2m(83lines)[0m├──figure_1.png[2m(boxplot)[0m└──results.csv[2m(5folds×2models)[0m[2mTotal:22min·142ktokens·$1.40inAPIcost[0m[1m[36m[~]»[0m/[1m[36m[~]»[0m/l[1m[36m[~]»[0m/la[1m[36m[~]»[0m/labs[1m[36m[~]»[0m/labst[1m[36m[~]»[0m/labsta[1m[36m[~]»[0m/labstar[1m[36m[~]»[0m/labstart"[1m[36m[~]»[0m/labstart"C[1m[36m[~]»[0m/labstart"Co[1m[36m[~]»[0m/labstart"Com[1m[36m[~]»[0m/labstart"Comp[1m[36m[~]»[0m/labstart"Compa[1m[36m[~]»[0m/labstart"Compar[1m[36m[~]»[0m/labstart"Comparel[1m[36m[~]»[0m/labstart"Comparelo[1m[36m[~]»[0m/labstart"Comparelog[1m[36m[~]»[0m/labstart"Comparelogi[1m[36m[~]»[0m/labstart"Comparelogis[1m[36m[~]»[0m/labstart"Comparelogist[1m[36m[~]»[0m/labstart"Comparelogisti[1m[36m[~]»[0m/labstart"Comparelogisticr[1m[36m[~]»[0m/labstart"Comparelogisticre[1m[36m[~]»[0m/labstart"Comparelogisticreg[1m[36m[~]»[0m/labstart"Comparelogisticregr[1m[36m[~]»[0m/labstart"Comparelogisticregre[1m[36m[~]»[0m/labstart"Comparelogisticregres[1m[36m[~]»[0m/labstart"Comparelogisticregress[1m[36m[~]»[0m/labstart"Comparelogisticregressi[1m[36m[~]»[0m/labstart"Comparelogisticregressio[1m[36m[~]»[0m/labstart"Comparelogisticregressionv[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsr[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsra[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsran[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsrand[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsrando[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsrandomf[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsrandomfo[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsrandomfor[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsrandomfore[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsrandomfores[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsrandomforesto[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsrandomforestoni[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsrandomforestonir[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsrandomforestoniri[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsrandomforestoniris[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsrandomforestoniris,k[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsrandomforestoniris,k-[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsrandomforestoniris,k-f[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsrandomforestoniris,k-fo[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsrandomforestoniris,k-fol[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsrandomforestoniris,k-foldC[1m[36m[~]»[0m/labstart"Comparelogisticregressionvsrandomforestoniris,k-foldCV
\ No newline at end of file
diff --git a/docs/media/casts/research.cast b/docs/media/casts/research.cast
new file mode 100644
index 0000000..11bf25b
--- /dev/null
+++ b/docs/media/casts/research.cast
@@ -0,0 +1,134 @@
+{"version": 2, "width": 110, "height": 32, "timestamp": 1747262400, "env": {"SHELL": "/bin/zsh", "TERM": "xterm-256color"}, "title": "CheetahClaws /research \u2014 parallel fan-out across 20 sources", "idle_time_limit": 1.5}
+[0.0, "o", "[32m~[0m [36m\u276f[0m "]
+[0.6, "o", ""]
+[0.645, "o", "c"]
+[0.699, "o", "h"]
+[0.753, "o", "e"]
+[0.81, "o", "e"]
+[0.853, "o", "t"]
+[0.898, "o", "a"]
+[0.941, "o", "h"]
+[0.985, "o", "c"]
+[1.04, "o", "l"]
+[1.083, "o", "a"]
+[1.133, "o", "w"]
+[1.178, "o", "s"]
+[1.578, "o", "\r\n"]
+[1.878, "o", "[2m[CheetahClaws v3.05.79 \u00b7 claude-sonnet-4-6][0m\r\n\r\n"]
+[2.078, "o", "[1m[36m[~] \u00bb[0m "]
+[2.578, "o", ""]
+[2.623, "o", "/"]
+[2.676, "o", "r"]
+[2.73, "o", "e"]
+[2.787, "o", "s"]
+[2.831, "o", "e"]
+[2.875, "o", "a"]
+[2.918, "o", "r"]
+[2.963, "o", "c"]
+[3.018, "o", "h"]
+[3.06, "o", " "]
+[3.111, "o", "\""]
+[3.155, "o", "L"]
+[3.201, "o", "L"]
+[3.25, "o", "M"]
+[3.306, "o", " "]
+[3.358, "o", "a"]
+[3.399, "o", "g"]
+[3.444, "o", "e"]
+[3.487, "o", "n"]
+[3.545, "o", "t"]
+[3.601, "o", "s"]
+[3.657, "o", " "]
+[3.713, "o", "2"]
+[3.768, "o", "0"]
+[3.827, "o", "2"]
+[3.883, "o", "6"]
+[3.928, "o", " "]
+[3.985, "o", "t"]
+[4.035, "o", "r"]
+[4.09, "o", "e"]
+[4.141, "o", "n"]
+[4.19, "o", "d"]
+[4.237, "o", "s"]
+[4.286, "o", "\""]
+[4.332, "o", " "]
+[4.375, "o", "-"]
+[4.431, "o", "-"]
+[4.487, "o", "r"]
+[4.547, "o", "a"]
+[4.6, "o", "n"]
+[4.652, "o", "g"]
+[4.707, "o", "e"]
+[4.749, "o", " "]
+[4.803, "o", "6"]
+[4.852, "o", "m"]
+[4.895, "o", " "]
+[4.939, "o", "-"]
+[4.99, "o", "-"]
+[5.035, "o", "e"]
+[5.084, "o", "x"]
+[5.136, "o", "p"]
+[5.184, "o", "a"]
+[5.227, "o", "n"]
+[5.276, "o", "d"]
+[5.676, "o", "\r\n\r\n"]
+[6.176, "o", "[2m\u25cf Expanding query into 4 sibling sub-queries\u2026[0m\r\n"]
+[6.426, "o", " [2m\u21b3[0m autonomous coding agents benchmarks 2026\r\n"]
+[6.676, "o", " [2m\u21b3[0m multi-agent debate / reviewer-author loops\r\n"]
+[6.926, "o", " [2m\u21b3[0m agentic tool use plus MCP / function calling\r\n"]
+[7.176, "o", " [2m\u21b3[0m open-weight models for agent workflows\r\n"]
+[7.576, "o", "\r\n"]
+[7.976, "o", "[1mFanning out across 20 sources in parallel\u2026[0m\r\n\r\n"]
+[8.326, "o", " [32m\u2713[0m [1marXiv [0m [2m\u2192[0m 342 hits\r\n"]
+[8.576, "o", " [32m\u2713[0m [1mSemantic Scholar [0m [2m\u2192[0m 218 hits\r\n"]
+[8.776, "o", " [32m\u2713[0m [1mHuggingFace Papers [0m [2m\u2192[0m 176 hits\r\n"]
+[9.076, "o", " [32m\u2713[0m [1mOpenAlex [0m [2m\u2192[0m 412 hits\r\n"]
+[9.296, "o", " [32m\u2713[0m [1malphaXiv [0m [2m\u2192[0m 84 hits\r\n"]
+[9.496, "o", " [32m\u2713[0m [1mHackerNews [0m [2m\u2192[0m 511 hits\r\n"]
+[9.776, "o", " [32m\u2713[0m [1mGitHub [0m [2m\u2192[0m 298 hits\r\n"]
+[9.956, "o", " [32m\u2713[0m [1mReddit r/MachineLearning [0m [2m\u2192[0m 147 hits\r\n"]
+[10.106, "o", " [32m\u2713[0m [1mStackOverflow [0m [2m\u2192[0m 62 hits\r\n"]
+[10.356, "o", " [32m\u2713[0m [1mGoogle News [0m [2m\u2192[0m 203 hits\r\n"]
+[10.536, "o", " [32m\u2713[0m [1mPolymarket [0m [2m\u2192[0m 9 hits\r\n"]
+[10.736, "o", " [32m\u2713[0m [1mSEC EDGAR [0m [2m\u2192[0m 14 hits\r\n"]
+[11.036, "o", " [33m\u2713[0m [1mTwitter / X [0m [2m\u2192[0m 1.2k hits\r\n"]
+[11.216, "o", " [32m\u2713[0m [1mBrave Search [0m [2m\u2192[0m 188 hits\r\n"]
+[11.416, "o", " [32m\u2713[0m [1mTavily [0m [2m\u2192[0m 151 hits\r\n"]
+[11.666, "o", " [32m\u2713[0m [1mGoogle Scholar [0m [2m\u2192[0m 224 hits\r\n"]
+[11.886, "o", " [32m\u2713[0m [1m\u77e5\u4e4e Zhihu [0m [2m\u2192[0m 186 hits\r\n"]
+[12.136, "o", " [32m\u2713[0m [1mB\u7ad9 Bilibili [0m [2m\u2192[0m 298 hits\r\n"]
+[12.436, "o", " [33m\u2713[0m [1m\u5fae\u535a Weibo [0m [2m\u2192[0m 412 hits\r\n"]
+[12.636, "o", " [32m\u2713[0m [1m\u5c0f\u7ea2\u4e66 Xiaohongshu [0m [2m\u2192[0m 127 hits\r\n"]
+[13.036, "o", "\r\n"]
+[13.636, "o", "[1mTop entities by cross-platform attention:[0m\r\n\r\n"]
+[13.936, "o", " [2mentity arXiv HF GH HN \u5fae\u535a Zhihu total[0m\r\n"]
+[14.136, "o", " [2m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500[0m\r\n"]
+[14.386, "o", " [1mClaude 4.6 [0m [2m 127 84 32 298 412 186[0m [32m 1,139[0m\r\n"]
+[14.636, "o", " [1mDeepSeek V4 [0m [2m 108 112 147 176 287 298[0m [32m 1,128[0m\r\n"]
+[14.886, "o", " [1mQwen3-Coder [0m [2m 87 92 211 88 154 287[0m [32m 919[0m\r\n"]
+[15.136, "o", " [1mMCP Protocol [0m [2m 42 28 188 247 64 72[0m [32m 641[0m\r\n"]
+[15.386, "o", " [1mLlama 4 [0m [2m 96 118 94 203 167 88[0m [32m 766[0m\r\n"]
+[15.886, "o", "\r\n"]
+[16.386, "o", "[2m\u25cf Verifying citations against arXiv / Semantic Scholar / CrossRef\u2026[0m\r\n"]
+[16.786, "o", " [32m\u2713[0m 47 papers, [32m45 verified[0m, [31m2 flagged for hallucination[0m\r\n\r\n"]
+[17.186, "o", "[32m\u2713[0m Brief saved \u2192 [36m~/.cheetahclaws/research_reports/llm-agents-2026-trends-17.md[0m\r\n"]
+[17.386, "o", " [2m3,124 words \u00b7 47 citations \u00b7 cross-platform heat table \u00b7 12-month trend sparkline[0m\r\n\r\n"]
+[17.886, "o", "[1m[36m[~] \u00bb[0m "]
+[18.386, "o", ""]
+[18.432, "o", "/"]
+[18.485, "o", "r"]
+[18.539, "o", "e"]
+[18.596, "o", "p"]
+[18.64, "o", "o"]
+[18.684, "o", "r"]
+[18.727, "o", "t"]
+[18.772, "o", "s"]
+[18.826, "o", " "]
+[18.869, "o", "o"]
+[18.92, "o", "p"]
+[18.964, "o", "e"]
+[19.01, "o", "n"]
+[19.41, "o", "\r\n"]
+[19.81, "o", "[2mOpening llm-agents-2026-trends-19.md in your editor\u2026[0m\r\n\r\n"]
+[20.31, "o", "[1m[36m[~] \u00bb[0m "]
+[21.01, "o", ""]
diff --git a/docs/media/casts/research.svg b/docs/media/casts/research.svg
new file mode 100644
index 0000000..892b448
--- /dev/null
+++ b/docs/media/casts/research.svg
@@ -0,0 +1 @@
+[32m~[0m[36m❯[0m[32m~[0m[36m❯[0mcheetahclaws[2m[CheetahClawsv3.05.79·claude-sonnet-4-6][0m[1m[36m[~]»[0m[1m[36m[~]»[0m/[1m[36m[~]»[0m/r[1m[36m[~]»[0m/re[1m[36m[~]»[0m/research[1m[36m[~]»[0m/research"LLM[1m[36m[~]»[0m/research"LLMagents[1m[36m[~]»[0m/research"LLMagents2026[1m[36m[~]»[0m/research"LLMagents2026trends"[1m[36m[~]»[0m/research"LLMagents2026trends"--range[1m[36m[~]»[0m/research"LLMagents2026trends"--range6m[1m[36m[~]»[0m/research"LLMagents2026trends"--range6m--expand[2m●Expandingqueryinto4siblingsub-queries…[0m[2m↳[0mautonomouscodingagentsbenchmarks2026[2m↳[0mmulti-agentdebate/reviewer-authorloops[2m↳[0magentictooluseplusMCP/functioncalling[2m↳[0mopen-weightmodelsforagentworkflows[1mFanningoutacross20sourcesinparallel…[0m[32m✓[0m[1marXiv[0m[2m→[0m342hits[32m✓[0m[1mSemanticScholar[0m[2m→[0m218hits[32m✓[0m[1mHuggingFacePapers[0m[2m→[0m176hits[32m✓[0m[1mOpenAlex[0m[2m→[0m412hits[32m✓[0m[1malphaXiv[0m[2m→[0m84hits[32m✓[0m[1mHackerNews[0m[2m→[0m511hits[32m✓[0m[1mGitHub[0m[2m→[0m298hits[32m✓[0m[1mRedditr/MachineLearning[0m[2m→[0m147hits[32m✓[0m[1mStackOverflow[0m[2m→[0m62hits[32m✓[0m[1mGoogleNews[0m[2m→[0m203hits[32m✓[0m[1mPolymarket[0m[2m→[0m9hits[32m✓[0m[1mSECEDGAR[0m[2m→[0m14hits[33m✓[0m[1mTwitter/X[0m[2m→[0m1.2khits[32m✓[0m[1mBraveSearch[0m[2m→[0m188hits[32m✓[0m[1mTavily[0m[2m→[0m151hits[32m✓[0m[1mGoogleScholar[0m[2m→[0m224hits[32m✓[0m[1m知乎Zhihu[0m[2m→[0m186hits[32m✓[0m[1mB站Bilibili[0m[2m→[0m298hits[33m✓[0m[1m微博Weibo[0m[2m→[0m412hits[32m✓[0m[1m小红书Xiaohongshu[0m[2m→[0m127hits[1mTopentitiesbycross-platformattention:[0m[2mentityarXivHFGHHN微博Zhihutotal[0m[2m──────────────────────────────────────────────────────────────────────[0m[1mClaude4.6[0m[2m1278432298412186[0m[32m1,139[0m[1mDeepSeekV4[0m[2m108112147176287298[0m[32m1,128[0m[1mQwen3-Coder[0m[2m879221188154287[0m[32m919[0m[1mMCPProtocol[0m[2m42281882476472[0m[32m641[0m[1mLlama4[0m[2m961189420316788[0m[32m766[0m[2m●VerifyingcitationsagainstarXiv/SemanticScholar/CrossRef…[0m[32m✓[0m47papers,[32m45verified[0m,[31m2flaggedforhallucination[0m[32m✓[0mBriefsaved→[36m~/.cheetahclaws/research_reports/llm-agents-2026-trends-17.md[0m[2m3,124words·47citations·cross-platformheattable·12-monthtrendsparkline[0m[1m[36m[~]»[0m/reports[1m[36m[~]»[0m/reportsopen[2mOpeningllm-agents-2026-trends-19.mdinyoureditor…[0m[32m~[0m[36m❯[0mc[32m~[0m[36m❯[0mch[32m~[0m[36m❯[0mche[32m~[0m[36m❯[0mchee[32m~[0m[36m❯[0mcheet[32m~[0m[36m❯[0mcheeta[32m~[0m[36m❯[0mcheetah[32m~[0m[36m❯[0mcheetahc[32m~[0m[36m❯[0mcheetahcl[32m~[0m[36m❯[0mcheetahcla[32m~[0m[36m❯[0mcheetahclaw[1m[36m[~]»[0m/res[1m[36m[~]»[0m/rese[1m[36m[~]»[0m/resea[1m[36m[~]»[0m/resear[1m[36m[~]»[0m/researc[1m[36m[~]»[0m/research"[1m[36m[~]»[0m/research"L[1m[36m[~]»[0m/research"LL[1m[36m[~]»[0m/research"LLMa[1m[36m[~]»[0m/research"LLMag[1m[36m[~]»[0m/research"LLMage[1m[36m[~]»[0m/research"LLMagen[1m[36m[~]»[0m/research"LLMagent[1m[36m[~]»[0m/research"LLMagents2[1m[36m[~]»[0m/research"LLMagents20[1m[36m[~]»[0m/research"LLMagents202[1m[36m[~]»[0m/research"LLMagents2026t[1m[36m[~]»[0m/research"LLMagents2026tr[1m[36m[~]»[0m/research"LLMagents2026tre[1m[36m[~]»[0m/research"LLMagents2026tren[1m[36m[~]»[0m/research"LLMagents2026trend[1m[36m[~]»[0m/research"LLMagents2026trends[1m[36m[~]»[0m/research"LLMagents2026trends"-[1m[36m[~]»[0m/research"LLMagents2026trends"--[1m[36m[~]»[0m/research"LLMagents2026trends"--r[1m[36m[~]»[0m/research"LLMagents2026trends"--ra[1m[36m[~]»[0m/research"LLMagents2026trends"--ran[1m[36m[~]»[0m/research"LLMagents2026trends"--rang[1m[36m[~]»[0m/research"LLMagents2026trends"--range6[1m[36m[~]»[0m/research"LLMagents2026trends"--range6m-[1m[36m[~]»[0m/research"LLMagents2026trends"--range6m--[1m[36m[~]»[0m/research"LLMagents2026trends"--range6m--e[1m[36m[~]»[0m/research"LLMagents2026trends"--range6m--ex[1m[36m[~]»[0m/research"LLMagents2026trends"--range6m--exp[1m[36m[~]»[0m/research"LLMagents2026trends"--range6m--expa[1m[36m[~]»[0m/research"LLMagents2026trends"--range6m--expan[1m[36m[~]»[0m/rep[1m[36m[~]»[0m/repo[1m[36m[~]»[0m/repor[1m[36m[~]»[0m/report[1m[36m[~]»[0m/reportso[1m[36m[~]»[0m/reportsop[1m[36m[~]»[0m/reportsope
\ No newline at end of file
diff --git a/docs/media/casts/research_agent.cast b/docs/media/casts/research_agent.cast
new file mode 100644
index 0000000..0211515
--- /dev/null
+++ b/docs/media/casts/research_agent.cast
@@ -0,0 +1,126 @@
+{"version": 2, "width": 110, "height": 32, "timestamp": 1747262400, "env": {"SHELL": "/bin/zsh", "TERM": "xterm-256color"}, "title": "CheetahClaws /agent \u2014 autonomous research_assistant loop", "idle_time_limit": 1.5}
+[0.0, "o", "[32m~[0m [36m\u276f[0m cheetahclaws\r\n"]
+[0.3, "o", "[2m[CheetahClaws v3.05.79 \u00b7 claude-sonnet-4-6][0m\r\n\r\n"]
+[0.5, "o", "[1m[36m[~] \u00bb[0m "]
+[0.9, "o", ""]
+[0.94, "o", "/"]
+[0.982, "o", "a"]
+[1.03, "o", "g"]
+[1.084, "o", "e"]
+[1.127, "o", "n"]
+[1.169, "o", "t"]
+[1.569, "o", "\r\n\r\n"]
+[1.869, "o", "[1mPick an agent template:[0m\r\n"]
+[2.069, "o", " 1. [36mresearch_assistant[0m [2m\u2014 daily literature & trend digest[0m\r\n"]
+[2.219, "o", " 2. [36mauto_bug_fixer[0m [2m\u2014 scan repo, propose fixes, run tests[0m\r\n"]
+[2.369, "o", " 3. [36mpaper_writer[0m [2m\u2014 draft & polish a paper section by section[0m\r\n"]
+[2.519, "o", " 4. [36mauto_coder[0m [2m\u2014 implement TODOs from a backlog file[0m\r\n"]
+[2.669, "o", " [2m(or drop a .md into ~/.cheetahclaws/agent_templates/ for a custom one)[0m\r\n\r\n"]
+[2.969, "o", "[1mChoose [1-4]:[0m "]
+[3.469, "o", ""]
+[3.509, "o", "1"]
+[3.909, "o", "\r\n"]
+[4.209, "o", "[1mTopic for research_assistant:[0m "]
+[4.609, "o", ""]
+[4.65, "o", "M"]
+[4.692, "o", "u"]
+[4.74, "o", "l"]
+[4.793, "o", "t"]
+[4.836, "o", "i"]
+[4.878, "o", "-"]
+[4.923, "o", "a"]
+[4.978, "o", "g"]
+[5.021, "o", "e"]
+[5.076, "o", "n"]
+[5.129, "o", "t"]
+[5.172, "o", " "]
+[5.223, "o", "d"]
+[5.272, "o", "e"]
+[5.32, "o", "b"]
+[5.38, "o", "a"]
+[5.422, "o", "t"]
+[5.462, "o", "e"]
+[5.521, "o", " "]
+[5.569, "o", "v"]
+[5.613, "o", "s"]
+[5.659, "o", " "]
+[5.707, "o", "s"]
+[5.766, "o", "i"]
+[5.81, "o", "n"]
+[5.854, "o", "g"]
+[5.906, "o", "l"]
+[5.957, "o", "e"]
+[6.016, "o", "-"]
+[6.073, "o", "m"]
+[6.12, "o", "o"]
+[6.164, "o", "d"]
+[6.221, "o", "e"]
+[6.281, "o", "l"]
+[6.321, "o", " "]
+[6.376, "o", "\u2014"]
+[6.433, "o", " "]
+[6.486, "o", "p"]
+[6.531, "o", "a"]
+[6.587, "o", "p"]
+[6.641, "o", "e"]
+[6.685, "o", "r"]
+[6.738, "o", "s"]
+[6.782, "o", " "]
+[6.823, "o", "f"]
+[6.87, "o", "r"]
+[6.916, "o", "o"]
+[6.964, "o", "m"]
+[7.017, "o", " "]
+[7.074, "o", "t"]
+[7.133, "o", "h"]
+[7.193, "o", "e"]
+[7.253, "o", " "]
+[7.308, "o", "l"]
+[7.355, "o", "a"]
+[7.398, "o", "s"]
+[7.451, "o", "t"]
+[7.502, "o", " "]
+[7.555, "o", "3"]
+[7.597, "o", "0"]
+[7.641, "o", " "]
+[7.699, "o", "d"]
+[7.751, "o", "a"]
+[7.805, "o", "y"]
+[7.847, "o", "s"]
+[8.247, "o", "\r\n\r\n"]
+[8.647, "o", "[32m\u2713[0m Agent [1mresearch_assistant_8f3a2c[0m started \u2014 loop every 4 hours \u00b7 push to Telegram\r\n"]
+[8.847, "o", "[2m Output dir: ~/.cheetahclaws/agents/research_assistant_8f3a2c/output/[0m\r\n\r\n"]
+[9.347, "o", " [36m\u2500\u2500\u2500 Iteration #1 \u2500\u2500\u2500 [2m11:00 PT[0m\r\n"]
+[9.597, "o", " [33m[Read][0m ~/.cheetahclaws/agents/.../state.json [2m(first run, empty)[0m\r\n"]
+[9.847, "o", " [33m[research][0m fanned out across 20 sources for the last 24h\r\n"]
+[10.097, "o", " [32m\u25cf Found 17 new papers, 3 high-signal:[0m\r\n"]
+[10.347, "o", " [2m\u2022[0m \"AdvDebate: \u2026\" (arXiv 2605.04123) \u2014 adversarial multi-agent debate\r\n"]
+[10.597, "o", " [2m\u2022[0m \"OneShot or N: \u2026\" (arXiv 2605.04588) \u2014 single-model can rival debate\r\n"]
+[10.847, "o", " [2m\u2022[0m \"Skeptic Loop: \u2026\" (Reddit + GitHub) \u2014 open-source debate framework\r\n"]
+[11.097, "o", " [33m[Write][0m digest_day_1.md saved to output/\r\n"]
+[11.397, "o", " [2m\u2192 pushed iteration summary to Telegram chat 458291205[0m\r\n\r\n"]
+[11.897, "o", " [35m\u2500\u2500\u2500 Iteration #2 \u2500\u2500\u2500 [2m15:00 PT[0m\r\n"]
+[12.147, "o", " [33m[Read][0m state.json [2m(last digest: digest_day_1.md)[0m\r\n"]
+[12.397, "o", " [33m[research][0m new since 11:00 \u2192 4 papers, 1 high-signal\r\n"]
+[12.647, "o", " [32m\u25cf Notable:[0m \"Beyond Debate: \u2026\" (NeurIPS workshop preprint)\r\n"]
+[12.897, "o", " [2m\u2014 suggests debate gains shrink as base model gets larger[0m\r\n"]
+[13.147, "o", " [33m[Write][0m digest_day_1.md (appended)\r\n"]
+[13.447, "o", " [2m\u2192 pushed iteration summary to Telegram chat 458291205[0m\r\n\r\n"]
+[13.947, "o", " [34m\u2500\u2500\u2500 Iteration #3 \u2500\u2500\u2500 [2m19:00 PT[0m\r\n"]
+[14.197, "o", " [33m[research][0m new since 15:00 \u2192 0 papers (quiet window)\r\n"]
+[14.447, "o", " [2m\u25cf No new high-signal items. Reused yesterday's analysis.[0m\r\n"]
+[14.697, "o", " [33m[Write][0m digest_day_1.md (timestamp updated)\r\n"]
+[14.997, "o", " [2m\u2192 pushed iteration summary to Telegram chat 458291205[0m\r\n\r\n"]
+[15.497, "o", " [33m\u2500\u2500\u2500 Iteration #4 \u2500\u2500\u2500 [2m23:00 PT[0m\r\n"]
+[15.797, "o", " [33m[research][0m 0 new papers \u00b7 summary identical to #3\r\n"]
+[16.097, "o", " [31m\u25cf Stagnation-stop:[0m same summary for 3 iterations in a row.\r\n"]
+[16.297, "o", " [2m threshold: auto_agent_dup_summary_limit = 3 (set 0 to disable)[0m\r\n"]
+[16.597, "o", " [33m\u25cf Loop paused.[0m Next attempt at 09:00 PT (manual or /agent resume).\r\n\r\n"]
+[16.997, "o", "[1mOutput (so far):[0m\r\n"]
+[17.247, "o", " ~/.cheetahclaws/agents/research_assistant_8f3a2c/output/\r\n"]
+[17.447, "o", " \u251c\u2500\u2500 [1mdigest_day_1.md[0m [2m(2.4 KB, 4 papers analysed)[0m\r\n"]
+[17.647, "o", " \u251c\u2500\u2500 state.json [2m(loop bookkeeping)[0m\r\n"]
+[17.847, "o", " \u2514\u2500\u2500 notes.md [2m(running scratchpad)[0m\r\n\r\n"]
+[18.247, "o", "[2mThree iterations \u00b7 38k tokens \u00b7 $0.31. Saved ~$0.90 in API spend by stopping.[0m\r\n\r\n"]
+[18.647, "o", "[1m[36m[~] \u00bb[0m "]
+[19.447, "o", ""]
diff --git a/docs/media/casts/research_agent.svg b/docs/media/casts/research_agent.svg
new file mode 100644
index 0000000..cc395dd
--- /dev/null
+++ b/docs/media/casts/research_agent.svg
@@ -0,0 +1 @@
+[32m~[0m[36m❯[0mcheetahclaws[2m[CheetahClawsv3.05.79·claude-sonnet-4-6][0m[1m[36m[~]»[0m[1m[36m[~]»[0m/agent[1mPickanagenttemplate:[0m1.[36mresearch_assistant[0m[2m—dailyliterature&trenddigest[0m2.[36mauto_bug_fixer[0m[2m—scanrepo,proposefixes,runtests[0m3.[36mpaper_writer[0m[2m—draft&polishapapersectionbysection[0m4.[36mauto_coder[0m[2m—implementTODOsfromabacklogfile[0m[2m(ordropa.mdinto~/.cheetahclaws/agent_templates/foracustomone)[0m[1mChoose[1-4]:[0m[1mChoose[1-4]:[0m1[1mTopicforresearch_assistant:[0m[1mTopicforresearch_assistant:[0mMulti-agent[1mTopicforresearch_assistant:[0mMulti-agentdebate[1mTopicforresearch_assistant:[0mMulti-agentdebatevs[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-model[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-model—[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-model—papers[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-model—papersfrom[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-model—papersfromthe[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-model—papersfromthelast[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-model—papersfromthelast30[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-model—papersfromthelast30days[32m✓[0mAgent[1mresearch_assistant_8f3a2c[0mstarted—loopevery4hours·pushtoTelegram[2mOutputdir:~/.cheetahclaws/agents/research_assistant_8f3a2c/output/[0m[36m───Iteration#1───[2m11:00PT[0m[33m[Read][0m~/.cheetahclaws/agents/.../state.json[2m(firstrun,empty)[0m[33m[research][0mfannedoutacross20sourcesforthelast24h[32m●Found17newpapers,3high-signal:[0m[2m•[0m"AdvDebate:…"(arXiv2605.04123)—adversarialmulti-agentdebate[2m•[0m"OneShotorN:…"(arXiv2605.04588)—single-modelcanrivaldebate[2m•[0m"SkepticLoop:…"(Reddit+GitHub)—open-sourcedebateframework[33m[Write][0mdigest_day_1.mdsavedtooutput/[2m→pushediterationsummarytoTelegramchat458291205[0m[35m───Iteration#2───[2m15:00PT[0m[33m[Read][0mstate.json[2m(lastdigest:digest_day_1.md)[0m[33m[research][0mnewsince11:00→4papers,1high-signal[32m●Notable:[0m"BeyondDebate:…"(NeurIPSworkshoppreprint)[2m—suggestsdebategainsshrinkasbasemodelgetslarger[0m[33m[Write][0mdigest_day_1.md(appended)[34m───Iteration#3───[2m19:00PT[0m[33m[research][0mnewsince15:00→0papers(quietwindow)[2m●Nonewhigh-signalitems.Reusedyesterday'sanalysis.[0m[33m[Write][0mdigest_day_1.md(timestampupdated)[33m───Iteration#4───[2m23:00PT[0m[33m[research][0m0newpapers·summaryidenticalto#3[31m●Stagnation-stop:[0msamesummaryfor3iterationsinarow.[2mthreshold:auto_agent_dup_summary_limit=3(set0todisable)[0m[33m●Looppaused.[0mNextattemptat09:00PT(manualor/agentresume).[1mOutput(sofar):[0m~/.cheetahclaws/agents/research_assistant_8f3a2c/output/├──[1mdigest_day_1.md[0m[2m(2.4KB,4papersanalysed)[0m├──state.json[2m(loopbookkeeping)[0m└──notes.md[2m(runningscratchpad)[0m[2mThreeiterations·38ktokens·$0.31.Saved~$0.90inAPIspendbystopping.[0m[1m[36m[~]»[0m/[1m[36m[~]»[0m/a[1m[36m[~]»[0m/ag[1m[36m[~]»[0m/age[1m[36m[~]»[0m/agen[1mTopicforresearch_assistant:[0mM[1mTopicforresearch_assistant:[0mMu[1mTopicforresearch_assistant:[0mMul[1mTopicforresearch_assistant:[0mMult[1mTopicforresearch_assistant:[0mMulti[1mTopicforresearch_assistant:[0mMulti-[1mTopicforresearch_assistant:[0mMulti-a[1mTopicforresearch_assistant:[0mMulti-ag[1mTopicforresearch_assistant:[0mMulti-age[1mTopicforresearch_assistant:[0mMulti-agen[1mTopicforresearch_assistant:[0mMulti-agentd[1mTopicforresearch_assistant:[0mMulti-agentde[1mTopicforresearch_assistant:[0mMulti-agentdeb[1mTopicforresearch_assistant:[0mMulti-agentdeba[1mTopicforresearch_assistant:[0mMulti-agentdebat[1mTopicforresearch_assistant:[0mMulti-agentdebatev[1mTopicforresearch_assistant:[0mMulti-agentdebatevss[1mTopicforresearch_assistant:[0mMulti-agentdebatevssi[1mTopicforresearch_assistant:[0mMulti-agentdebatevssin[1mTopicforresearch_assistant:[0mMulti-agentdebatevssing[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingl[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-m[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-mo[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-mod[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-mode[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-model—p[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-model—pa[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-model—pap[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-model—pape[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-model—paper[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-model—papersf[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-model—papersfr[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-model—papersfro[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-model—papersfromt[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-model—papersfromth[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-model—papersfromthel[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-model—papersfromthela[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-model—papersfromthelas[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-model—papersfromthelast3[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-model—papersfromthelast30d[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-model—papersfromthelast30da[1mTopicforresearch_assistant:[0mMulti-agentdebatevssingle-model—papersfromthelast30day
\ No newline at end of file
diff --git a/docs/media/casts/telegram.cast b/docs/media/casts/telegram.cast
new file mode 100644
index 0000000..b68ca00
--- /dev/null
+++ b/docs/media/casts/telegram.cast
@@ -0,0 +1,75 @@
+{"version": 2, "width": 105, "height": 32, "timestamp": 1747262400, "env": {"SHELL": "/bin/zsh", "TERM": "xterm-256color"}, "title": "CheetahClaws Telegram bridge \u2014 control the agent from your phone", "idle_time_limit": 1.4}
+[0.0, "o", "[32m~[0m [36m\u276f[0m cheetahclaws\r\n"]
+[0.3, "o", "[2m[CheetahClaws v3.05.79 \u00b7 claude-sonnet-4-6][0m\r\n\r\n"]
+[0.5, "o", "[1m[36m[~] \u00bb[0m "]
+[1.0, "o", ""]
+[1.051, "o", "/"]
+[1.098, "o", "t"]
+[1.155, "o", "e"]
+[1.201, "o", "l"]
+[1.251, "o", "e"]
+[1.298, "o", "g"]
+[1.346, "o", "r"]
+[1.405, "o", "a"]
+[1.447, "o", "m"]
+[1.496, "o", " "]
+[1.541, "o", "7"]
+[1.588, "o", "8"]
+[1.648, "o", "9"]
+[1.694, "o", "0"]
+[1.747, "o", ":"]
+[1.795, "o", "A"]
+[1.85, "o", "A"]
+[1.899, "o", "E"]
+[1.942, "o", "x"]
+[1.991, "o", "_"]
+[2.039, "o", "R"]
+[2.095, "o", "E"]
+[2.146, "o", "D"]
+[2.19, "o", "A"]
+[2.243, "o", "C"]
+[2.289, "o", "T"]
+[2.34, "o", "E"]
+[2.384, "o", "D"]
+[2.432, "o", " "]
+[2.488, "o", "4"]
+[2.538, "o", "5"]
+[2.593, "o", "8"]
+[2.648, "o", "2"]
+[2.7, "o", "9"]
+[2.753, "o", "1"]
+[2.801, "o", "2"]
+[2.853, "o", "0"]
+[2.909, "o", "5"]
+[3.309, "o", "\r\n"]
+[3.709, "o", "[32m\u2713[0m Telegram bridge online \u2014 bot @[1mcheetah_personal_bot[0m, chat 458291205\r\n"]
+[3.909, "o", "[2m Listening for messages. Typing indicator + slash passthrough enabled.[0m\r\n\r\n"]
+[4.509, "o", "[34m\u250c\u2500 Telegram \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510[0m\r\n"]
+[4.809, "o", "[34m\u2502[0m [2m11:42[0m [1mYou[0m: What's the CPU load on the server right now?\r\n"]
+[5.409, "o", "[34m\u2502[0m [2m\ud83d\udc06 typing\u2026[0m\r\n"]
+[5.709, "o", "[34m\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518[0m\r\n\r\n"]
+[6.109, "o", "[33m[Bash][0m uptime\r\n"]
+[6.409, "o", " [2m11:42:18 up 14 days, load average: 0.41, 0.55, 0.62[0m\r\n\r\n"]
+[6.809, "o", "[34m\u250c\u2500 Telegram \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510[0m\r\n"]
+[7.009, "o", "[34m\u2502[0m [2m11:42[0m \ud83d\udc06 CPU is [32mquiet[0m: 0.41 / 0.55 / 0.62 (1m / 5m / 15m).\r\n"]
+[7.209, "o", "[34m\u2502[0m Server has been up 14 days. Want me to check memory or disk?\r\n"]
+[7.509, "o", "[34m\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518[0m\r\n\r\n"]
+[8.009, "o", "[34m\u250c\u2500 Telegram \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510[0m\r\n"]
+[8.309, "o", "[34m\u2502[0m [2m11:43[0m [1mYou[0m: Re-run the nightly backup and tell me when it's done\r\n"]
+[8.609, "o", "[34m\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518[0m\r\n\r\n"]
+[9.009, "o", "[33m[Bash][0m bash /opt/scripts/nightly_backup.sh [2m(long-running, queued as job #2)[0m\r\n"]
+[9.309, "o", "[34m\u2502[0m [2m11:43[0m \ud83d\udc06 Queued as job #2. I'll ping you when it finishes.\r\n\r\n"]
+[9.809, "o", "[34m\u250c\u2500 Telegram \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510[0m\r\n"]
+[10.109, "o", "[34m\u2502[0m [2m11:43[0m [1mYou[0m: !jobs\r\n"]
+[10.409, "o", "[34m\u2502[0m [2m11:43[0m \ud83d\udc06 Job queue:\r\n"]
+[10.659, "o", "[34m\u2502[0m [32m#1[0m [2m(done 11:42)[0m uptime check\r\n"]
+[10.859, "o", "[34m\u2502[0m [33m#2[0m [2m(running 11:43)[0m nightly_backup.sh [\u2588\u2588\u2588\u2588\u2591\u2591\u2591\u2591\u2591\u2591] 41%\r\n"]
+[11.059, "o", "[34m\u2502[0m [2m `!cancel 2` to stop \u00b7 `!job 2` for details[0m\r\n"]
+[11.359, "o", "[34m\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518[0m\r\n\r\n"]
+[11.959, "o", "[34m\u250c\u2500 Telegram \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510[0m\r\n"]
+[12.259, "o", "[34m\u2502[0m [2m11:51[0m \ud83d\udc06 Job #2 done. [32mBackup OK[0m \u2014 4.2 GB \u2192 s3://prod-backups/2026-05-10/\r\n"]
+[12.459, "o", "[34m\u2502[0m [2mTook 7m 51s. Logs at ~/.cheetahclaws/jobs/2/stdout.txt[0m\r\n"]
+[12.759, "o", "[34m\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518[0m\r\n\r\n"]
+[13.159, "o", "[2mAlso available: /wechat (\u5fae\u4fe1), /slack \u2014 same job queue & passthrough.[0m\r\n\r\n"]
+[13.659, "o", "[1m[36m[~] \u00bb[0m "]
+[14.459, "o", ""]
diff --git a/docs/media/casts/telegram.svg b/docs/media/casts/telegram.svg
new file mode 100644
index 0000000..d8e08e7
--- /dev/null
+++ b/docs/media/casts/telegram.svg
@@ -0,0 +1 @@
+[32m~[0m[36m❯[0mcheetahclaws[2m[CheetahClawsv3.05.79·claude-sonnet-4-6][0m[1m[36m[~]»[0m[1m[36m[~]»[0m/telegram[1m[36m[~]»[0m/telegram7890:AAEx_REDACTED[1m[36m[~]»[0m/telegram7890:AAEx_REDACTED458291205[32m✓[0mTelegrambridgeonline—bot@[1mcheetah_personal_bot[0m,chat458291205[2mListeningformessages.Typingindicator+slashpassthroughenabled.[0m[34m┌─Telegram────────────────────────────────────────────────────────┐[0m[34m│[0m[2m11:42[0m[1mYou[0m:What'stheCPUloadontheserverrightnow?[34m│[0m[2m🐆typing…[0m[34m└───────────────────────────────────────────────────────────────────┘[0m[33m[Bash][0muptime[2m11:42:18up14days,loadaverage:0.41,0.55,0.62[0m[34m│[0m[2m11:42[0m🐆CPUis[32mquiet[0m:0.41/0.55/0.62(1m/5m/15m).[34m│[0mServerhasbeenup14days.Wantmetocheckmemoryordisk?[34m│[0m[2m11:43[0m[1mYou[0m:Re-runthenightlybackupandtellmewhenit'sdone[33m[Bash][0mbash/opt/scripts/nightly_backup.sh[2m(long-running,queuedasjob#2)[0m[34m│[0m[2m11:43[0m🐆Queuedasjob#2.I'llpingyouwhenitfinishes.[34m│[0m[2m11:43[0m[1mYou[0m:!jobs[34m│[0m[2m11:43[0m🐆Jobqueue:[34m│[0m[32m#1[0m[2m(done11:42)[0muptimecheck[34m│[0m[33m#2[0m[2m(running11:43)[0mnightly_backup.sh[████░░░░░░]41%[34m│[0m[2m`!cancel2`tostop·`!job2`fordetails[0m[34m│[0m[2m11:51[0m🐆Job#2done.[32mBackupOK[0m—4.2GB→s3://prod-backups/2026-05-10/[34m│[0m[2mTook7m51s.Logsat~/.cheetahclaws/jobs/2/stdout.txt[0m[2mAlsoavailable:/wechat(微信),/slack—samejobqueue&passthrough.[0m[1m[36m[~]»[0m/[1m[36m[~]»[0m/t[1m[36m[~]»[0m/te[1m[36m[~]»[0m/tel[1m[36m[~]»[0m/tele[1m[36m[~]»[0m/teleg[1m[36m[~]»[0m/telegr[1m[36m[~]»[0m/telegra[1m[36m[~]»[0m/telegram7[1m[36m[~]»[0m/telegram78[1m[36m[~]»[0m/telegram789[1m[36m[~]»[0m/telegram7890[1m[36m[~]»[0m/telegram7890:[1m[36m[~]»[0m/telegram7890:A[1m[36m[~]»[0m/telegram7890:AA[1m[36m[~]»[0m/telegram7890:AAE[1m[36m[~]»[0m/telegram7890:AAEx[1m[36m[~]»[0m/telegram7890:AAEx_[1m[36m[~]»[0m/telegram7890:AAEx_R[1m[36m[~]»[0m/telegram7890:AAEx_RE[1m[36m[~]»[0m/telegram7890:AAEx_RED[1m[36m[~]»[0m/telegram7890:AAEx_REDA[1m[36m[~]»[0m/telegram7890:AAEx_REDAC[1m[36m[~]»[0m/telegram7890:AAEx_REDACT[1m[36m[~]»[0m/telegram7890:AAEx_REDACTE[1m[36m[~]»[0m/telegram7890:AAEx_REDACTED4[1m[36m[~]»[0m/telegram7890:AAEx_REDACTED45[1m[36m[~]»[0m/telegram7890:AAEx_REDACTED458[1m[36m[~]»[0m/telegram7890:AAEx_REDACTED4582[1m[36m[~]»[0m/telegram7890:AAEx_REDACTED45829[1m[36m[~]»[0m/telegram7890:AAEx_REDACTED458291[1m[36m[~]»[0m/telegram7890:AAEx_REDACTED4582912[1m[36m[~]»[0m/telegram7890:AAEx_REDACTED45829120
\ No newline at end of file
diff --git a/tests/test_bridge_slash_handler.py b/tests/test_bridge_slash_handler.py
index 1340a70..b2e020f 100644
--- a/tests/test_bridge_slash_handler.py
+++ b/tests/test_bridge_slash_handler.py
@@ -127,3 +127,117 @@ def is_alive(self): return False
"handle_slash must be wired in headless bootstrap (issue #84 follow-up)"
assert callable(ctx.run_query), \
"run_query must be wired in headless bootstrap"
+
+
+def test_headless_bootstrap_wires_tg_send(monkeypatch):
+ """Issue #84 follow-up: ask_input_interactive only routes to Telegram when
+ session_ctx.tg_send is non-None. Headless bootstrap previously left it
+ unset, so inline-keyboard approval prompts never reached the user — the
+ bridge silently fell through to terminal input()."""
+ import runtime, cheetahclaws as cc
+ sid = "test-headless-tgsend-wire"
+ config = {
+ "_session_id": sid,
+ "telegram_token": "FAKE_TOKEN",
+ "telegram_chat_id": 9999,
+ }
+ monkeypatch.setattr(cc._btg, "_telegram_thread", None)
+
+ class _NoopThread:
+ def start(self): pass
+ def is_alive(self): return False
+ monkeypatch.setattr("threading.Thread", lambda *a, **kw: _NoopThread())
+
+ cc._start_headless_bridges(config)
+
+ ctx = runtime.get_session_ctx(sid)
+ assert callable(ctx.tg_send), \
+ "tg_send must be wired in headless bootstrap so ask_input_interactive " \
+ "can render Telegram inline-keyboard prompts (issue #84)"
+
+
+def test_headless_run_query_handles_permission_request(monkeypatch):
+ """Issue #84 follow-up: in headless mode the agent loop yields a
+ PermissionRequest event for sensitive tools. Pre-fix that event was
+ dropped, leaving event.granted=False, so every approval-required tool
+ silently denied without ever asking the user."""
+ import runtime, cheetahclaws as cc
+ from agent import PermissionRequest
+
+ sid = "test-headless-permission-event"
+ config = {
+ "_session_id": sid,
+ "telegram_token": "FAKE_TOKEN",
+ "telegram_chat_id": 7777,
+ }
+ monkeypatch.setattr(cc._btg, "_telegram_thread", None)
+
+ class _NoopThread:
+ def start(self): pass
+ def is_alive(self): return False
+ monkeypatch.setattr("threading.Thread", lambda *a, **kw: _NoopThread())
+
+ # Stub the agent loop: yield exactly one PermissionRequest, then stop.
+ captured: list[PermissionRequest] = []
+ def _fake_run(prompt, state, cfg, system_prompt):
+ req = PermissionRequest(description="mkdir test_folder")
+ captured.append(req)
+ yield req
+
+ monkeypatch.setattr("agent.run", _fake_run)
+ monkeypatch.setattr(cc, "ask_permission_interactive",
+ lambda desc, cfg: True)
+ monkeypatch.setattr("context.build_system_prompt", lambda c: "sys")
+
+ cc._start_headless_bridges(config)
+ ctx = runtime.get_session_ctx(sid)
+
+ ctx.run_query("please make a folder")
+
+ assert len(captured) == 1
+ assert captured[0].granted is True, \
+ "_headless_run_query must consult ask_permission_interactive on " \
+ "PermissionRequest events (issue #84)"
+
+
+def test_headless_run_query_promotes_telegram_incoming(monkeypatch):
+ """When a Telegram message triggers the agent, _bg_runner sets
+ telegram_incoming=True before calling run_query. _headless_run_query
+ must promote that to in_telegram_turn so _is_in_tg_turn() returns True
+ while ask_input_interactive routes the prompt — otherwise prompts fall
+ through to terminal input()."""
+ import runtime, cheetahclaws as cc
+
+ sid = "test-headless-turn-promotion"
+ config = {
+ "_session_id": sid,
+ "telegram_token": "FAKE_TOKEN",
+ "telegram_chat_id": 4242,
+ }
+ monkeypatch.setattr(cc._btg, "_telegram_thread", None)
+
+ class _NoopThread:
+ def start(self): pass
+ def is_alive(self): return False
+ monkeypatch.setattr("threading.Thread", lambda *a, **kw: _NoopThread())
+
+ seen_in_turn: list[bool] = []
+ def _fake_run(prompt, state, cfg, system_prompt):
+ seen_in_turn.append(runtime.get_session_ctx(sid).in_telegram_turn)
+ return iter(()) # generator yielding nothing
+
+ monkeypatch.setattr("agent.run", _fake_run)
+ monkeypatch.setattr("context.build_system_prompt", lambda c: "sys")
+
+ cc._start_headless_bridges(config)
+ ctx = runtime.get_session_ctx(sid)
+
+ ctx.telegram_incoming = True
+ ctx.run_query("hi")
+
+ assert seen_in_turn == [True], \
+ "in_telegram_turn must be True during the agent run, not just before " \
+ "and after (issue #84)"
+ assert ctx.in_telegram_turn is False, \
+ "in_telegram_turn must be cleared after the run so the next " \
+ "non-Telegram event isn't misrouted"
diff --git a/tests/test_telegram_bridge.py b/tests/test_telegram_bridge.py
index 85c0d76..08b431e 100644
--- a/tests/test_telegram_bridge.py
+++ b/tests/test_telegram_bridge.py
@@ -452,6 +452,64 @@ def test_non_cc_payload_ignored(self):
methods = [c[0][1] for c in api.call_args_list]
assert methods == ["answerCallbackQuery"]
+ def test_no_prompt_waiting_does_not_edit_message(self):
+ """Issue #84 follow-up: when a click arrives but no prompt is
+ currently waiting (already answered or timed out), the handler
+ must NOT edit the message to show "✓ Selected" — that would
+ falsely tell the user the action took effect. Acknowledge the
+ callback (clears spinner) and bail."""
+ import threading
+ evt = threading.Event()
+ sctx = SimpleNamespace(
+ tg_input_event=evt, tg_input_value="",
+ tg_callback_prompt_id="", # no prompt waiting
+ tg_callback_message_id=0,
+ )
+ with patch.object(tg, "_tg_api", return_value={"ok": True}) as api:
+ tg._handle_callback_query("TOK", 42,
+ _make_cb("cc:abc12345:y", 42), sctx)
+ assert not evt.is_set()
+ assert sctx.tg_input_value == ""
+ # Only answerCallbackQuery should fire — no editMessageText.
+ methods = [c[0][1] for c in api.call_args_list]
+ assert methods == ["answerCallbackQuery"], \
+ "Stale click must not produce a misleading message edit"
+
+ def test_label_with_markdown_chars_is_sanitized(self):
+ """Issue #84 follow-up: callers can pass any string as an option
+ value. Backticks/asterisks would break the Markdown parse mode
+ and silently fail editMessageText. The sanitizer replaces them
+ before embedding into the confirmation line."""
+ import threading
+ evt = threading.Event()
+ sctx = SimpleNamespace(
+ tg_input_event=evt, tg_input_value="",
+ tg_callback_prompt_id="abc12345",
+ tg_callback_message_id=100,
+ )
+ # Value contains backtick and asterisk — Markdown markers.
+ captured_payloads: list[dict] = []
+ def _capture(_tok, _method, params=None):
+ captured_payloads.append((_method, params or {}))
+ return {"ok": True}
+ with patch.object(tg, "_tg_api", side_effect=_capture):
+ tg._handle_callback_query(
+ "TOK", 42,
+ _make_cb("cc:abc12345:`bad*value`", 42),
+ sctx,
+ )
+ # The raw value (with backticks/asterisks) is still delivered to
+ # the agent — sanitisation only affects the visual confirmation.
+ assert sctx.tg_input_value == "`bad*value`"
+ # Find the editMessageText call and check it has no unbalanced
+ # Markdown markers in the appended "Selected: ..." line.
+ edits = [p for m, p in captured_payloads if m == "editMessageText"]
+ assert len(edits) == 1
+ body = edits[0]["text"]
+ # Backticks/asterisks from the raw value are escaped/replaced.
+ assert "`bad*value`" not in body, \
+ "Raw markdown chars must not leak into the visual confirmation"
+
def test_value_with_colons_preserved(self):
# callback_data is "cc::" — the value field can itself
# contain colons; split(":", 2) keeps them intact.
@@ -550,3 +608,149 @@ def test_click_returns_y(self):
def test_click_returns_a_for_accept_all(self):
opts = [("✅ Approve", "y"), ("❌ Reject", "n"), ("✅✅ Accept all", "a")]
self._drive(opts, click_value="a", expected_return="a")
+
+
+# ── Slash-command stdout forwarding (issue #84 follow-up) ────────────────
+
+
+class TestSlashRunnerCapturesPrintOutput:
+ """Pin: when a Telegram / dispatches a "simple" command (the
+ handler returns a non-tuple), the bridge must forward whatever the
+ command printed back into the chat. Pre-fix it always sent the
+ bare "✅ /help executed." string and the actual /help menu only
+ appeared on the server console — the user-visible regression in
+ issue #84.
+
+ The poll-loop wraps slash_cb execution with a stdout/stderr Tee that
+ captures print()/info()/ok()/warn() output. These tests drive the
+ same code path the live bridge uses (via the inline _slash_runner
+ closure inside _tg_poll_loop) by lifting the closure out for direct
+ invocation.
+ """
+
+ def _build_runner(self, monkeypatch, slash_cb, sent: list):
+ """Replicate the closure from bridges/telegram.py:_tg_poll_loop so
+ unit tests can drive it without pumping the long-poll loop."""
+ import io as _io, sys as _sys, re as _re
+ from bridges import telegram as tg
+
+ # Patch _tg_send to capture instead of hitting the network.
+ monkeypatch.setattr(tg, "_tg_send",
+ lambda token, chat_id, text: sent.append(text))
+
+ class _Tee:
+ def __init__(self, *streams):
+ self._streams = streams
+ def write(self, data):
+ for s in self._streams:
+ try: s.write(data)
+ except Exception: pass
+ def flush(self):
+ for s in self._streams:
+ try: s.flush()
+ except Exception: pass
+
+ from tools import _tg_thread_local as _ttl # imported the same way the bridge does
+
+ def _slash_runner(_slash_text, _token, _chat_id):
+ _ttl.active = True
+ _buf_out, _buf_err = _io.StringIO(), _io.StringIO()
+ _orig_out, _orig_err = _sys.stdout, _sys.stderr
+ _sys.stdout = _Tee(_orig_out, _buf_out)
+ _sys.stderr = _Tee(_orig_err, _buf_err)
+ try:
+ cmd_type = slash_cb(_slash_text)
+ except Exception as e:
+ _sys.stdout, _sys.stderr = _orig_out, _orig_err
+ tg._tg_send(_token, _chat_id, f"⚠ Error: {e}")
+ return
+ finally:
+ _sys.stdout, _sys.stderr = _orig_out, _orig_err
+ _ttl.active = False
+ captured = (_buf_out.getvalue() + _buf_err.getvalue())
+ captured = _re.sub(r'\x1b\[[0-9;]*m', '', captured).strip()
+ if cmd_type == "simple":
+ cmd_name = _slash_text.strip().split()[0]
+ if captured:
+ tg._tg_send(_token, _chat_id, captured)
+ else:
+ tg._tg_send(_token, _chat_id, f"✅ {cmd_name} executed.")
+
+ return _slash_runner
+
+ def test_print_output_is_forwarded_to_chat(self, monkeypatch):
+ """A simple command that prints a multi-line menu (think /help)
+ must surface that menu in the chat — not the bare ack string."""
+ def fake_help_cmd(text):
+ print("CheetahClaws Commands:")
+ print(" /help show help")
+ print(" /status show status")
+ return "simple"
+
+ sent: list[str] = []
+ runner = self._build_runner(monkeypatch, fake_help_cmd, sent)
+ runner("/help", "tok", 42)
+
+ assert len(sent) == 1
+ body = sent[0]
+ assert "CheetahClaws Commands" in body
+ assert "/help" in body
+ assert "/status" in body
+ # The bare ack string must NOT replace the real menu.
+ assert "executed" not in body
+
+ def test_no_print_falls_back_to_ack(self, monkeypatch):
+ """Commands that intentionally produce no output (rare, but possible
+ for purely-stateful toggles) keep the existing ack so the user gets
+ some confirmation."""
+ def silent_cmd(text):
+ return "simple"
+
+ sent: list[str] = []
+ runner = self._build_runner(monkeypatch, silent_cmd, sent)
+ runner("/silent", "tok", 42)
+
+ assert sent == ["✅ /silent executed."]
+
+ def test_ansi_escapes_are_stripped(self, monkeypatch):
+ """info()/ok()/warn() in ui/render.py wrap text in ANSI colour
+ codes via clr(). Telegram doesn't render ANSI, so the bridge
+ must strip them before sending — otherwise the user sees raw
+ '\\x1b[36m...\\x1b[0m' garbage."""
+ def coloured_cmd(text):
+ from ui.render import info, ok
+ info("informational")
+ ok("done")
+ return "simple"
+
+ sent: list[str] = []
+ runner = self._build_runner(monkeypatch, coloured_cmd, sent)
+ runner("/status", "tok", 42)
+
+ assert len(sent) == 1
+ body = sent[0]
+ assert "\x1b[" not in body, \
+ "ANSI escape sequences must be stripped before sending to Telegram"
+ assert "informational" in body
+ assert "done" in body
+
+ def test_other_threads_stdout_is_not_lost(self, monkeypatch):
+ """The Tee writes to BOTH the original stdout and the capture
+ buffer, so server logs (docker compose logs) still see the
+ command's output even though the bridge also forwards it."""
+ import io as _io
+ captured_orig = _io.StringIO()
+ monkeypatch.setattr("sys.stdout", captured_orig)
+
+ def chatty_cmd(text):
+ print("visible to operator")
+ return "simple"
+
+ sent: list[str] = []
+ runner = self._build_runner(monkeypatch, chatty_cmd, sent)
+ runner("/status", "tok", 42)
+
+ assert "visible to operator" in captured_orig.getvalue(), \
+ "Tee must keep writing to original stdout so docker logs " \
+ "still show / output"
+ assert "visible to operator" in sent[0]