Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
- clarify /config default labels and remove redundant "Works with" lines [#119](https://github.com/littlebearapps/untether/issues/119)
- Codex: always pass `--ask-for-approval` in headless mode — default to `never` (auto-approve all) so Codex never blocks on terminal input; `safe` permission mode still uses `untrusted` [#184](https://github.com/littlebearapps/untether/issues/184)
- OpenCode: surface unsupported JSONL event types as visible Telegram warnings instead of silently dropping them — prevents silent 5-minute hangs when OpenCode emits new event types (e.g. `question`, `permission`) [#183](https://github.com/littlebearapps/untether/issues/183)
- stall warnings now succinct and accurate for long-running tools — truncate "Last:" to 80 chars, recognise `command:` prefix (Bash tools), reassuring "still running" message when CPU active, drop PID diagnostics from Telegram messages, only say "may be stuck" when genuinely stuck [#188](https://github.com/littlebearapps/untether/issues/188)
- frozen ring buffer escalation now uses tool-aware "still running" message when a known tool is actively running (main sleeping, CPU active on children), instead of alarming "No progress" message

### changes

Expand Down Expand Up @@ -60,6 +62,8 @@
- `AutoContinueSettings` with `enabled` (default true) and `max_retries` (default 1) in `[auto_continue]` config section
- detection based on protocol invariant: normal sessions always end with `last_event_type=result`
- sends "⚠️ Auto-continuing — Claude stopped before processing tool results" notification before resuming
- emoji button labels and edit-in-place for outline approval — ExitPlanMode buttons now show ✅/❌/📋 emoji prefixes; post-outline "Approve Plan"/"Deny" edits the "Asked Claude Code to outline the plan" message in-place instead of creating a second message [#186](https://github.com/littlebearapps/untether/issues/186)
- redesign startup message layout — version in parentheses, split engine info into "default engine" and "installed engines" lines, italic subheadings, renamed "projects" to "directories" (matching `dir:` footer label), added bug report link [#187](https://github.com/littlebearapps/untether/issues/187)

### tests

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ Rules in `.claude/rules/` auto-load when editing matching files:

## Tests

1765 unit tests, 80% coverage threshold. Integration testing against `@untether_dev_bot` is **mandatory before every release** — see `docs/reference/integration-testing.md` for the full playbook with per-release-type tier requirements (patch/minor/major). All integration test tiers are fully automated by Claude Code via Telegram MCP tools and Bash.
1766 unit tests, 80% coverage threshold. Integration testing against `@untether_dev_bot` is **mandatory before every release** — see `docs/reference/integration-testing.md` for the full playbook with per-release-type tier requirements (patch/minor/major). All integration test tiers are fully automated by Claude Code via Telegram MCP tools and Bash.

Key test files:

Expand Down
65 changes: 50 additions & 15 deletions src/untether/runner_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -727,7 +727,7 @@ async def _monitor() -> None:

async def _stall_monitor(self) -> None:
"""Periodically check for event stalls, log diagnostics, and notify."""
from .utils.proc_diag import collect_proc_diag, format_diag, is_cpu_active
from .utils.proc_diag import collect_proc_diag, is_cpu_active

while True:
await anyio.sleep(self._stall_check_interval)
Expand Down Expand Up @@ -927,41 +927,76 @@ async def _stall_monitor(self) -> None:
seconds_since_last_event=round(elapsed, 1),
pid=self.pid,
)
parts = [
f"⏳ No progress for {mins} min (CPU active, no new events)"
]
# When a known tool is running and main process is sleeping
# (waiting for child), use reassuring message instead of
# alarming "No progress" — the tool subprocess is working.
_frozen_tool = None
if last_action:
for _pfx in ("tool:", "note:", "command:"):
if last_action.startswith(_pfx):
_rest = last_action[len(_pfx) :]
_frozen_tool = (
"Bash"
if _pfx == "command:"
else _rest.split(" ", 1)[0].split(":", 1)[0]
)
break
if _frozen_tool and main_sleeping and cpu_active is True:
parts = [
f"⏳ {_frozen_tool} command still running ({mins} min)"
]
else:
parts = [
f"⏳ No progress for {mins} min (CPU active, no new events)"
]
elif mcp_server is not None:
parts = [f"⏳ MCP tool running: {mcp_server} ({mins} min)"]
else:
# Extract tool name from last running action for
# actionable stall messages ("Bash tool may be stuck"
# actionable stall messages ("Bash command still running"
# instead of generic "session may be stuck").
_tool_name = None
if last_action:
for _prefix in ("tool:", "note:"):
for _prefix in ("tool:", "note:", "command:"):
if last_action.startswith(_prefix):
_rest = last_action[len(_prefix) :]
_tool_name = _rest.split(" ", 1)[0].split(":", 1)[0]
_raw = _rest.split(" ", 1)[0].split(":", 1)[0]
# Map kind prefix to user-friendly name
_tool_name = "Bash" if _prefix == "command:" else _raw
break
if _tool_name and main_sleeping:
parts = [
f"⏳ {_tool_name} tool may be stuck ({mins} min, process waiting)"
]
if cpu_active is True:
parts = [
f"⏳ {_tool_name} command still running ({mins} min)"
]
else:
parts = [
f"⏳ {_tool_name} tool may be stuck ({mins} min, no CPU activity)"
]
elif cpu_active is True:
parts = [f"⏳ Still working ({mins} min, CPU active)"]
else:
parts = [f"⏳ No progress for {mins} min"]
if self._stall_warn_count > 1:
parts[0] += f" (warned {self._stall_warn_count}x)"
if (
# "session may be stuck" — only when genuinely stuck
# (no tool identified, cpu not active, not MCP/frozen)
_genuinely_stuck = (
not mcp_hung
and not frozen_escalate
and mcp_server is None
and not (_tool_name and main_sleeping)
):
and cpu_active is not True
)
if _genuinely_stuck:
parts.append("— session may be stuck.")
if last_action:
parts.append(f"Last: {last_action}")
if diag:
parts.append(f"PID {diag.pid}: {format_diag(diag)}")
_summary = (
last_action
if len(last_action) <= 80
else last_action[:77] + "..."
)
parts.append(f"Last: {_summary}")
parts.append("/cancel to stop.")
text = "\n".join(parts)
try:
Expand Down
15 changes: 10 additions & 5 deletions src/untether/runners/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -729,11 +729,11 @@ def translate_claude_event(
"buttons": [
[
{
"text": "Approve Plan",
"text": "Approve Plan",
"callback_data": f"claude_control:approve:{button_request_id}",
},
{
"text": "Deny",
"text": "Deny",
"callback_data": f"claude_control:deny:{button_request_id}",
},
],
Expand Down Expand Up @@ -839,11 +839,11 @@ def translate_claude_event(
button_rows: list[list[dict[str, str]]] = [
[
{
"text": "Approve",
"text": "Approve",
"callback_data": f"claude_control:approve:{request_id}",
},
{
"text": "Deny",
"text": "Deny",
"callback_data": f"claude_control:deny:{request_id}",
},
],
Expand All @@ -855,7 +855,7 @@ def translate_claude_event(
button_rows.append(
[
{
"text": "Pause & Outline Plan",
"text": "📋 Pause & Outline Plan",
"callback_data": f"claude_control:discuss:{request_id}",
},
]
Expand Down Expand Up @@ -1937,6 +1937,11 @@ def _cleanup_session_registries(session_id: str) -> None:
if session_id in _OUTLINE_PENDING:
cleaned.append("outline_pending")
_OUTLINE_PENDING.discard(session_id)
# Clean up discuss feedback ref (post-outline edit-instead-of-send tracking)
from ..telegram.commands.claude_control import _DISCUSS_FEEDBACK_REFS

if _DISCUSS_FEEDBACK_REFS.pop(session_id, None) is not None:
cleaned.append("discuss_feedback_ref")
stale = [k for k, v in _REQUEST_TO_SESSION.items() if v == session_id]
if stale:
cleaned.append(f"requests({len(stale)})")
Expand Down
26 changes: 14 additions & 12 deletions src/untether/telegram/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,9 @@ def _build_startup_message(
) -> str:
project_aliases = sorted(set(runtime.project_aliases()), key=str.lower)

header = f"\N{DOG} **untether v{__version__} is ready**"
header = f"\N{DOG} **untether is ready** (v{__version__})"

# enginemerged default + available on one line
# enginesseparate default and installed lines
available_engines = list(runtime.available_engine_ids())
missing_engines = list(runtime.missing_engine_ids())
misconfigured_engines = list(runtime.engine_ids_with_status("bad_config"))
Expand All @@ -128,23 +128,23 @@ def _build_startup_message(
engine_list = ", ".join(available_engines) if available_engines else "none"

details: list[str] = []
details.append(f"_default engine:_ `{runtime.default_engine}`")
if engine_notes:
details.append(
f"engine: `{runtime.default_engine}`"
f" · engines: `{engine_list} ({'; '.join(engine_notes)})`"
f"_installed engines:_ `{engine_list}` ({'; '.join(engine_notes)})"
)
else:
details.append(f"engine: `{runtime.default_engine}` · engines: `{engine_list}`")
details.append(f"_installed engines:_ `{engine_list}`")

# mode — derived from session_mode + topics
mode = _resolve_mode_label(session_mode, topics.enabled)
details.append(f"mode: `{mode}`")
details.append(f"_mode:_ `{mode}`")

# projects — listed by name
# directories — listed by name
if project_aliases:
details.append(f"projects: `{', '.join(project_aliases)}`")
details.append(f"_directories:_ `{', '.join(project_aliases)}`")
else:
details.append("projects: `none`")
details.append("_directories:_ `none`")

# topics — only shown when enabled
if topics.enabled:
Expand All @@ -154,18 +154,20 @@ def _build_startup_message(
scope_label = (
f"auto ({resolved_scope})" if topics.scope == "auto" else resolved_scope
)
details.append(f"topics: `enabled (scope={scope_label})`")
details.append(f"_topics:_ `enabled (scope={scope_label})`")

# triggers — only shown when enabled
if trigger_config and trigger_config.get("enabled"):
n_wh = len(trigger_config.get("webhooks", []))
n_cr = len(trigger_config.get("crons", []))
details.append(f"triggers: `enabled ({n_wh} webhooks, {n_cr} crons)`")
details.append(f"_triggers:_ `enabled ({n_wh} webhooks, {n_cr} crons)`")

_DOCS_URL = "https://littlebearapps.com/tools/untether/"
_ISSUES_URL = "https://github.com/littlebearapps/untether/issues"
footer = (
f"\n\nSend a message to start, or /config for settings."
f"\n\N{OPEN BOOK} [Click here for help guide]({_DOCS_URL})"
f"\n\N{OPEN BOOK} [Click here for help]({_DOCS_URL})"
f" | \N{BUG} [Click here to report a bug]({_ISSUES_URL})"
)

return header + "\n\n" + "\n\n".join(details) + footer
Expand Down
76 changes: 63 additions & 13 deletions src/untether/telegram/commands/claude_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from ...commands import CommandBackend, CommandContext, CommandResult
from ...logging import get_logger
from ...runner_bridge import delete_outline_messages
from ...runner_bridge import delete_outline_messages, register_ephemeral_message
from ...runners.claude import (
_ACTIVE_RUNNERS,
_DISCUSS_APPROVED,
Expand All @@ -15,9 +15,14 @@
send_claude_control_response,
set_discuss_cooldown,
)
from ...transport import MessageRef

logger = get_logger(__name__)

# Tracks the "📋 Asked Claude Code to outline the plan" message ref per session,
# so the post-outline approve/deny can edit it instead of sending a 2nd message.
_DISCUSS_FEEDBACK_REFS: dict[str, MessageRef] = {}


_DISCUSS_DENY_MESSAGE = (
"STOP. Do NOT call ExitPlanMode yet.\n\n"
Expand Down Expand Up @@ -136,10 +141,19 @@ async def handle(self, ctx: CommandContext) -> CommandResult | None:
request_id=request_id,
action=action,
)
return CommandResult(
text="📋 Asked Claude Code to outline the plan",

# Send feedback directly and store ref so post-outline approve/deny
# can edit this message instead of creating a second one.
ref = await ctx.executor.send(
"📋 Asked Claude Code to outline the plan",
notify=True,
)
if ref and session_id:
_DISCUSS_FEEDBACK_REFS[session_id] = ref
register_ephemeral_message(
ctx.message.channel_id, ctx.message.message_id, ref
)
return None

approved = action == "approve"

Expand All @@ -156,6 +170,7 @@ async def handle(self, ctx: CommandContext) -> CommandResult | None:
"claude_control.discuss_plan_session_ended",
session_id=session_id,
)
_DISCUSS_FEEDBACK_REFS.pop(session_id, None)
return CommandResult(
text=(
"⚠️ Session has ended — start a new run"
Expand All @@ -175,23 +190,34 @@ async def handle(self, ctx: CommandContext) -> CommandResult | None:
"claude_control.discuss_plan_approved",
session_id=session_id,
)
return CommandResult(
text="✅ Plan approved — Claude Code will proceed",
notify=True,
skip_reply=True,
)
action_text = "✅ Plan approved — Claude Code will proceed"
else:
_OUTLINE_PENDING.discard(session_id)
clear_discuss_cooldown(session_id)
logger.info(
"claude_control.discuss_plan_denied",
session_id=session_id,
)
return CommandResult(
text="❌ Plan denied — send a follow-up message with feedback",
notify=True,
skip_reply=True,
)
action_text = "❌ Plan denied — send a follow-up message with feedback"

# Edit the discuss feedback message instead of sending a new one
existing_ref = _DISCUSS_FEEDBACK_REFS.pop(session_id, None)
if existing_ref:
try:
await ctx.executor.edit(existing_ref, action_text)
return None
except Exception: # noqa: BLE001
logger.debug(
"claude_control.discuss_feedback_edit_failed",
session_id=session_id,
exc_info=True,
)
# Fallback: send as new message if edit failed or no ref stored
return CommandResult(
text=action_text,
notify=True,
skip_reply=True,
)

# Grab session_id before send_claude_control_response deletes it
session_id = _REQUEST_TO_SESSION.get(request_id)
Expand Down Expand Up @@ -233,6 +259,30 @@ async def handle(self, ctx: CommandContext) -> CommandResult | None:

had_outline = session_id in _OUTLINE_REGISTRY
await delete_outline_messages(session_id)
# Try to edit the discuss feedback message for outline-flow
# approve/deny (when outline was long enough to use real request_id
# instead of da: prefix).
existing_ref = _DISCUSS_FEEDBACK_REFS.pop(session_id, None)
if existing_ref:
action_text = (
"✅ Plan approved — Claude Code will proceed"
if approved
else "❌ Plan denied — send a follow-up message with feedback"
)
try:
await ctx.executor.edit(existing_ref, action_text)
logger.info(
"claude_control.sent",
request_id=request_id,
approved=approved,
)
return None
except Exception: # noqa: BLE001
logger.debug(
"claude_control.discuss_feedback_edit_failed",
session_id=session_id,
exc_info=True,
)

action_text = "✅ Approved" if approved else "❌ Denied"
logger.info(
Expand Down
6 changes: 3 additions & 3 deletions src/untether/telegram/commands/topics.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ async def _handle_new_command(
await reply(text="this command only works inside a topic.")
return
await store.clear_sessions(*tkey)
await reply(text="cleared stored sessions for this topic.")
await reply(text="\N{BROOM} cleared stored sessions for this topic.")


async def _handle_chat_new_command(
Expand All @@ -256,9 +256,9 @@ async def _handle_chat_new_command(
return
await store.clear_sessions(session_key[0], session_key[1])
if msg.chat_type == "private":
text = "cleared stored sessions for this chat."
text = "\N{BROOM} cleared stored sessions for this chat."
else:
text = "cleared stored sessions for you in this chat."
text = "\N{BROOM} cleared stored sessions for you in this chat."
await reply(text=text)


Expand Down
Loading
Loading