Skip to main content
Reference Last updated: 23 July 2026

changelog

<!-- Covers rc1 through rc14 (the full v0.35.4 release-candidate cycle). Date is the finalisation date; adjust if the dev→master release merge slips to anoth...

Nathan Schram
By Nathan Schram Founder, Little Bear Apps

v0.35.4 (2026-07-22)

fixes

  • fix(telegram): stop the spurious transport.edit.failed error=None warning that fired after every answered AskUserQuestion. The keyboard-clear edit is coalesced out of the outbox by a concurrent same-message progress edit; the losing op was resolved to None, indistinguishable at the edit() layer from a real editMessageText failure — which is also why PR #608’s HTTP-layer error capture never populated it (a superseded op never makes an HTTP call, so there is no api_error to record). Edit ops now opt into a SUPERSEDED outbox disposition threaded through all three intentional-drop paths (same-key supersede, drop_pending ahead of a delete/replace, and a RetryAfter requeue collision); the transport treats it as a benign transport.edit.superseded no-op and returns the ref instead of logging a failure. Send/delete ops keep the None failure contract (the sentinel is opt-in per op, so a superseded replace-send can never be misread as a sent message). Root-caused with gpt-5.6-sol. 5 new tests across tests/test_telegram_queue.py and tests/test_telegram_bridge.py #598
  • fix(pi): surface Pi stderr and diagnose silent rc=0 no-agent_end exits. Untether was blind to why Pi exited on the stream-end fallback path — the hardcoded “pi finished without an agent_end event” message hid both the failure category and Pi’s own stderr (this is what made the original “resume is broken” report so hard to diagnose; the real cause was a transient MCP-cold startup crash, not a resume-token bug — fix options A/B/C from the original issue were explicitly NOT implemented). stream_end_events now distinguishes a zero-translated-events startup/early-exit crash (with a “session may have failed to load on resume” hint on resumed runs) from a genuinely truncated stream, appends a sanitised tail of captured stderr, and logs had_events/resumed fields on pi.stream.no_agent_end so the issue-watcher can promote resumed failures to error tier. stderr_lines threaded through the polymorphic stream_end_events (base + all 6 runners). Follow-ups (out of repo / separate): error-tier promotion in untether-issue-watcher; a real 2-turn context-retention integration test (historically U4 only checked a filesystem side-effect). 4 new tests in tests/test_pi_runner.py #565
  • fix(pi): wire auto_retry_start / auto_retry_end events. They were schema-defined but never translated, so transient provider retries were invisible in Telegram and starved the liveness watchdog of event activity during backoff. They now render as note actions (“retrying provider (attempt N/M, ~Xs delay)” → “retry succeeded” / “retry exhausted: …”) with a stable retry_N action id across the start/end pair. A dedicated stall-watchdog “retry-in-progress is CPU-active” suppression branch is deferred (provider delays sit well under the stall threshold and the translated events already refresh the idle timer). 5 new tests in tests/test_pi_runner.py; docs §4.6 in docs/reference/runners/pi/untether-events.md #460
  • fix(claude): clear a background-task handle only on a terminal tool_result, not the first one. A long-running Monitor streams multiple interim tool_results; clearing live_monitors on the first dropped the stall_monitor_active_suppressed branch, so spurious stall warnings rose again while the Monitor was legitimately running. New _is_terminal_tool_result + an is_terminal gate on _clear_background_handle: a Monitor with a live deadline keeps its handle through interim stdout lines, clearing only on an error result or once its own timeout_ms deadline passes (the result text is arbitrary command stdout, so it is deliberately NOT scanned for “completed”/“done” markers — that would false-clear on a build printing “Done”; the deadline in has_live_background_work is the reliable, bounded backstop, so no leak). All other primitives (Bash-bg / Agent-bg / ScheduleWakeup / RemoteTrigger) keep the pre-existing clear-on-result behaviour. Full terminal-detection for those (KillShell, subprocess exit, deadline sweeps) is the v0.35.5 lifecycle refactor — doing it here without that infrastructure would risk the inverse failure (a handle that never clears, wedging the post-result idle watchdog). 5 new/updated tests in tests/test_claude_runner.py. rc7 extended the same bounded-keep pattern to Agent/Task via bg_agent_deadlines + BG_AGENT_MAX_KEEP_S (15 min) and recognised Task as a background primitive; Bash-bg, ScheduleWakeup and RemoteTrigger terminal-detection remain deferred to #573 #374
  • security: SSRF-validate voice_transcription_base_url before any outbound transcription call (Telegram audit 2026-04-20 §SSRF). A misconfigured or filesystem-edited base URL could exfiltrate voice audio to an internal service. The existing triggers/ssrf.py:validate_url_with_dns validator is now applied at the transcribe_voice chokepoint (async, DNS-aware), with a sync validate_url fast-fail at config load for obvious cases (non-http scheme, private-IP literal). New optional [transports.telegram] voice_transcription_url_allowlist (CIDR/IP strings) opts in to private endpoints such as an Azure private-link range. Skipped when base_url is unset (the default public api.openai.com path). #381
  • security: refuse to bind the webhook server when any webhook has auth = "none" on a non-loopback host (Telegram audit 2026-04-20 §ASI07). An unauthenticated webhook on a public interface is a remote-agent-run primitive. The initial-bind guard (run_webhook_server) and the hot-reload path (TriggerManager.update) both refuse/drop such routes while leaving polling, commands, and crons running. Opt in for local demos with [triggers] allow_unauthenticated_webhooks = true. #382
  • lockfile: replace the os.kill(pid, 0) liveness check with an fcntl.flock(2) advisory lock so a reused PID can no longer make a stale lock look valid forever — the bug crash-restart-looped services indefinitely (hit twice in 24h on lba-1, once via a cross-project collision with qdrant). The kernel releases the lock automatically on process death, so manual rm recovery is no longer needed; the lock fd is held for the process lifetime and stays non-inheritable so it can’t leak into spawned engine subprocesses. #459
  • release: push the auto-created vX.Y.Z tag with a dedicated RELEASE_TAG_PAT secret instead of the workflow GITHUB_TOKEN, so the tag push actually triggers release.yml (GitHub suppresses downstream workflow runs for GITHUB_TOKEN-triggered events, which forced a manual gh workflow run on every release). Restores the unattended single-gate flow. Requires a one-time fine-scoped RELEASE_TAG_PAT secret (contents: write) and removal of the now-redundant pypi environment reviewer. #376
  • fix(claude): actually reap the leaked MCP node children the earlier sweep (PR #605) still missed. Fleet audit found dembrandt-mcp on nsd leaking one child per run — it setpgids into its own process group (distinct PGID, still in Claude’s session) so killpg(claude_pgid) structurally can’t reach it, and the fallback descendant-PID snapshot was never populated on a fast clean rc=0 run (the reader-done capture is gated on proc.returncode is None, which fails once the leader exits, and the limbo capture never fires and used direct-children-only). New _capture_orphan_descendants captures the recursive descendant tree at the result event — the one point where the CLI is guaranteed alive with every MCP child spawned — plus fixes the limbo capture to walk recursively; the post-exit sweep then reaches pgroup escapees by recorded PID. Hardened against PID reuse with a /proc starttime birth-identity token (pid_starttime) verified before each signal, so a recycled PID is never killed. 4 new tests in tests/test_claude_runner.py, 3 in tests/test_subprocess.py, 3 in tests/test_proc_diag.py #590
  • fix(bridge): auto-resend once on an empty-result no-op resume instead of leaving the user to re-nudge manually. Resuming a session whose prior turn ended on a tool_result can make the CLI return an immediate 0-turn/$0/empty completion; the surfacing note (PR #606) told the user to resend, but Untether now resends the original prompt automatically against the same session (single-shot, guarded by _empty_resent_count, mutually exclusive with auto-continue). A ”↻ retrying automatically…” notice is shown early. Opt-out via [auto_continue] resend_empty_resume = false. 4 new + 1 updated tests in tests/test_exec_bridge.py #596
  • fix(telegram): stop re-scanning/re-logging stale outbox directories on every run. Skipped directories (an agent that wrote e.g. screenshots/ into .untether-outbox/) are now moved once to .untether-outbox/.skipped/ (collision-suffixed) and the skip notice tells the user where they went; the graveyard is excluded from future scans, and an outbox containing ONLY a directory now reaches cleanup. Verified live on the fleet. 7 tests in tests/test_outbox_delivery.py #600
  • fix(claude): deliver the final answer the moment a successful result arrives instead of waiting for the subprocess to exit. A run holding lingering MCP children blocked delivery for up to the full 600s post-result watchdog, and one answer was lost entirely when the user /cancel’d a run that had already completed. Adds [watchdog] post_result_limbo_grace (default 60s, 0=off) so a fully-quiescent limbo subprocess is SIGTERMed after the grace instead of the full timeout. Verified on @untether_dev_bot: answer delivered 23s before subprocess exit #591
  • fix(claude): bound the pre-result watchdog dead zone with a new [watchdog] pre_result_silence_timeout (default 3600s, 0=off). A run whose stream went silent before its first result was previously unbounded — an 8-day zombie Claude subprocess on mac leaked its session lock and MCP children. Suppressed while permission/ask requests are pending so plan-approval waits stay safe #592
  • fix(watchdog): enforce teardown on stall auto-cancel and thread the spawn PID into stall diagnostics. ClaudeRunner.run_impl now sets last_pid, and auto-cancel polls 30s for natural death before killing the subprocess directly (descendant-aware) — a post-OOM zombie previously lingered 14m52s after the cancel decision. rc10 added last_seen_alive_s to stall_detected / stall_auto_cancel, since all liveness fields collapse to None once the process is gone, which invites the “PID was never threaded” misdiagnosis. Fleet-verified: zero pid=None occurrences across 16 sessions, decision-to-reap 10.0s versus the originally reported 14m52s #593
  • fix(runners): release the asyncio subprocess transport at run end. manage_subprocess now awaits proc.aclose() on every exit path (all 6 engines covered from one point), replacing the RuntimeError: Event loop is closed tracebacks raised from BaseSubprocessTransport.__del__ at interpreter shutdown #599
  • fix(claude): stop catalog_staleness.detected flooding journals — ~2,930 WARNINGs per 48h fleet-wide, 96% of one host’s total warnings. status=pending at system.init is a startup race and now logs INFO with per-run dedup; needs-auth / failed keep WARNING but dedup across runs via a process-lifetime (server, status) registry instead of resetting on every subprocess spawn #595
  • fix(claude): accept image and document content blocks in the stream schema. Reading binary media echoed them inside user-role messages and msgspec dropped the whole line as jsonl.msgspec.invalid #597
  • fix(telegram): surface the failure reason in transport.edit.failed. The Telegram description was previously only visible in a separate, uncorrelated telegram.api_error line; it is now recorded per method/chat/message at the client layer on every failure path. “Message is not modified” is normalised to an info-level transport.edit.noop returning the original ref — the edit’s intent is already satisfied, so it must not read as a failure #598
  • fix(telegram): report cron counts consistently at startup. triggers.enabled now logs crons=<active> (agreeing with triggers.manager.updated and triggers.cron.started) plus crons_configured=<raw TOML entries>; the raw count previously read as “N crons failed to load” during triage when the delta was just spent run_once entries #601
  • fix(voice): harden transcription against transient provider blips. Widens AsyncOpenAI retries 2→4 so a sub-15s connection blip self-heals before reaching the user, replaces the opaque “Connection error.” with an actionable hint for APIConnectionError / APITimeoutError, and fixes an unreachable TimeoutError branch shadowed by OSError #584
  • fix(voice): make transcription failures diagnosable. openai.transcribe.error now logs the resolved endpoint and the exception __cause__, and voice_transcription_api_key is validated at config load — control characters (an embedded newline from concatenated key material) and non-latin-1 characters are rejected with a ConfigError instead of surfacing hours later as a connection error on every voice note #594
  • fix(bridge): close the runner generator in the pump task and shield early final delivery, so a final message already in flight is not lost when the run tears down #614
  • fix(bridge): raise the delivery shield bound from 15s to 60s so multi-chunk final messages complete before the shield expires #618
  • fix(claude): quarantine poisoned sessions instead of resuming them, and recover on a fresh session. An upstream background-subagent/message-queue defect (reproduced on CLI 2.1.211) leaves the last turn dangling on an unresolved tool_use when the lingering post-result process is SIGTERM’d; the next --resume then returns 0 turns / $0 / an empty answer with rc=0 — a silent failure the user only sees as “nothing happened”. New persistent QuarantineStore (session_quarantine.py, JSON sibling to untether.toml, survives restart, 7-day prune) marks such sessions so they are never resumed, and the original message is auto-resent once against a fresh session. Structured event chain: runner.empty_resultsession.quarantinedsession.auto_resend_fresh / session.resume_diverted_fresh. Hardened against malformed timestamps, non-dict JSON, and partial writes. Opt out via [auto_continue] empty_resume_fresh = false. Claude runner only #631
  • fix(claude): quarantine proactively on forced_teardown_after_result rather than waiting to observe an empty result. A session SIGTERM’d after delivering its result may already have a dangling turn upstream, so the next message diverts to a fresh session before any empty resume is seen — turning a silent amnesia event into a deliberate, announced one. Opt out via [auto_continue] quarantine_on_forced_teardown = false #632
  • fix(claude): age out background-task handles so a single stale entry can’t wedge the post-result watchdog. live_bg_bashes and live_remote_triggers had no deadline at all, so one entry made has_live_background_work() return True for the rest of the run — suppressing the post-result watchdog and leaving the process lingering in limbo, precisely the state that gets SIGTERM’d and poisons the session. The missing age-out was feeding the empty-resume defect this whole milestone is about. Both now carry parallel deadline maps following the rc7 bg-agent pattern, and _live_bounded_handle_count generalises _live_bg_agent_count so the expiry rule lives in one place. Ceilings are deliberately generous (1h) — this is a last-resort backstop, not a prediction #573
  • fix(watchdog): stop the stall detector false-alarming on sessions parked on an unanswered approval. A 5h session waiting on an ExitPlanMode approval emitted 88 progress_edits.stall_detected WARNs and 2 frozen_ring_escalation WARNs — while completing successfully. _has_pending_approval() inferred approval state from presentation data (the latest action’s inline_keyboard detail), which an ExitPlanMode permission request does not carry; its watchdog-side twin _recent_event_is_control_request reads the newest ring-buffer entry, which after hours of waiting is a stale user/result frame. Both are “most recent thing” heuristics and both go stale. Switched to the authoritative signal — _REQUEST_TO_SESSION, populated on interception and popped on answer — exposed as ClaudeStreamState.awaiting_user_approval() and reached from the engine-agnostic bridge by the same engine_state duck-typing already used for has_live_background_work, so other engines degrade to False. Two non-obvious follow-on fixes were required: the frozen-ring counter is now held at zero for the duration of an expected wait (an approval wait produces no JSONL by definition, so it climbed past every suppressor sitting behind if not frozen_escalate; merely skipping the escalation would leave an inflated counter that trips the instant the user clicks Approve), and _total_stall_warn_count moved behind a single _count_stall_warning() helper called only where a WARNING is actually emitted, so the metric means what its name says. The process_dead auto-cancel safety valve is unchanged and still checked first. 6 new tests #495 #499 #500
  • fix(claude): accept the new tool_progress heartbeat event. Claude Code emits a top-level tool_progress frame while a long-running tool is in flight; msgspec rejected the unknown tag and dropped the line with jsonl.msgspec.invalid (275 rejects in a 24h fleet window). Reproduced on CLI 2.1.214 with a >30s Bash command and the exact shape captured (a -heartbeat-N suffixed tool_use_id plus tool_name / elapsed_time_seconds / heartbeat). Added a permissive StreamToolProgressMessage to the top-level union following the #489/#597 precedent — no runner change needed, translate’s fallback already ignores unrecognised events. Untether renders its own elapsed-time tail (#481), so the upstream heartbeat is redundant for progress; the schema entry just stops the line being dropped. Fleet-verified: 275 rejections in the pre-fix 24h baseline → 0 across 4 runs / ~80 tool actions. 3 new tests #637
  • fix(claude): make auto-continue’s signal-death suppression actually work. stream.proc_returncode is assigned only in the base runner, but ClaudeRunner overrides run_impl wholesale and never wrote it back — so the bridge always saw None, _is_signal_death(None) returned False, and the death-spiral guard that #589’s OOM analysis depended on did not exist in practice, for the only engine auto-continue applies to. Fleet evidence (nsd, 14 days) correlating each session.auto_continue with the preceding subprocess.exit: {rc=0: 47, rc=143: 2} — two auto-continues fired straight after a SIGTERM. Fixed the assignment and tightened the gate to require rc=0 (the docstring always claimed the upstream bug exits cleanly, but only signal deaths were excluded); zero real events fall in the 1..128 range, so narrowing costs no genuine recovery, and rc=None stays eligible as a fail-open. One existing test asserting the pre-fix behaviour was inverted with the fleet evidence recorded. 8 new rc-gate table tests #640
  • fix: guard against the OOM killer with concurrency-aware pre-spawn accounting. The nsd OOM killer struck untether.service five times in one evening, killing two live Claude runs with rc=-9, each holding 10–17 MCP node children. The existing #350 pre-spawn RAM guard is per-spawn and count-blind: N chats each pass the free-RAM check independently, then collectively exhaust the host. New live_engine_subprocess_count() is instrumented in manage_subprocess — the single spawn point every runner shares — counted there rather than from TelegramLoopState.running_tasks because what consumes memory is a live subprocess, not a queued task, and floored at zero so an unbalanced decrement can’t permanently disable the guard. prespawn_ram_per_run_reserve_mb (default 750) scales the block threshold with concurrency, and max_concurrent_engine_runs (default 0 = unlimited) is a hard ceiling. Preferred over the cgroup limits because it fails a run with a readable Telegram message instead of letting the kernel SIGKILL a live session mid-task; the systemd half already existed but was commented out with no guidance, and now carries a host-RAM sizing table pointing at the in-app knobs as the primary mitigation #589
  • fix(claude): serialise session ownership so two subprocesses can never hold one session id — prevention, where #631/#632 are recovery. A follow-up message could spawn --resume <sid> while the previous subprocess for that session was still alive in post-result limbo (reproductions show a resume 6s after the prior process was SIGTERM’d) — exactly the condition that leaves the upstream turn dangling. New wait_for_session_handoff(sid, timeout_s) returns free / exited / timed_out, condition-based so the common case costs one dict lookup; liveness reuses the existing is_session_alive / _SESSION_STDIN registries (SessionLockMixin.session_locks cannot serve — it’s a WeakValueDictionary). The gate lives in the bridge alongside the quarantine-divert block so it applies uniformly to recursive auto-continue / auto-resend re-entries. Four corrections from adversarial review: fail closed on a probe exception (divert fresh, WARN), do not quarantine on timeout (busy ≠ corrupt — a 7-day marker is far too destructive), a final liveness probe after the loop so polling granularity doesn’t mislabel an exited owner, and an explicit regression test proving auto-continue re-entry doesn’t burn the full budget. Config: [auto_continue] serialize_session_owner (default true), session_handoff_timeout_s (default 30, bounded 0–300). 11 new tests #633
  • fix(claude): register subagents that run in the background by default. Claude Code has run subagents in the background by default since v2.1.198 — the tool contract reads “Subagents run in the background by default; pass run_in_background: false for a synchronous run” — so the flag is normally absent from the Agent/Task input. _register_background_handle gated on bool(raw_input.get("run_in_background")), reading omission as foreground, and therefore never registered a real subagent. Measured on nsd: 11/11 Agent calls across the 5 quarantined sessions omit the key. Failure chain: no handle → has_live_background_work() False → the post-result watchdog applies the 60s limbo grace instead of the 600s timeout → SIGTERM of a subprocess whose subagents are still working → quarantine on forced_teardown_after_result → the user’s next message diverts to a fresh, contextless session. New _agent_runs_in_background() treats a call as background unless the caller explicitly opted out with literal False; over-registering is the safe direction (bounded by the 600s ceiling and the 900s BG_AGENT_MAX_KEEP_S age-out), while under-registering force-kills live work and poisons the session. Bash is deliberately untouched — its run_in_background flag is genuinely opt-in upstream. Verified live with 3 background subagents: passed through limbo untouched, exited naturally rc=0, no quarantine, follow-up resumed the same session #646
  • fix(watchdog): stop alarming the user after a run has already delivered successfully. A run that had posted its final answer with ✓ turn complete received a user-facing “Auto-cancelled: session appears stuck (process_dead)” 6m51s later — nothing was lost, but the notice was pure noise. The process_dead auto-cancel arm now checks stream.did_emit_completed first: a dead subprocess whose run already emitted its CompletedEvent is a normally-completed run being reaped late (the detector was racing the subcountdown’s returncode poll), so it is reaped through the same cancel machinery but silently — INFO progress_edits.reaped_after_delivery, no WARN, no notice. The gate is directional and regression-locked: a dead process on a run that never completed still alarms. #614 made the user-initiated cancelled-after-delivery path quiet; this makes the watchdog-initiated variant actually quiet too. Also closes the ~6-minute observability blackout between the one-shot limbo_detected warning and loop exit with a periodic subcountdown_tick INFO (~30s, tunable). 2 new tests #650
  • fix(claude): stop the 600s post-result ceiling killing live subagent work, and announce every fresh-session divert. The subcountdown deadline is now liveness-aware: while has_live_background_work() is true and /proc evidence does not show the tree demonstrably idle, the SIGTERM is deferred and re-checked each poll. Bounded twice — background handles age out at BG_AGENT_MAX_KEEP_S, and the hold never exceeds [watchdog] post_result_bg_max_hold (default 1800s, 0 disables). Upstream runs subagents in the background by default since Claude Code v2.1.198 and never signals their completion on stream-json, so /proc is the only available evidence. On the handoff side: wait_for_session_handoff now logs entry and exit (the wait could previously absorb minutes with no journal trace at all); when the base 30s wait times out and the owner has live background work (new _SESSION_BG_STATE registry + session_live_bg_count()), the user is told why the reply is delayed and the wait extends, bounded by new [auto_continue] session_handoff_bg_timeout_s (default 600s). Every fresh-session divert is now announced — handoff_timeout, entry-time quarantine, and the new quarantined_during_handoff re-check, which matters because the ceiling can quarantine the owner while the handoff wait is in flight and resuming it would poison the next turn. sigterm_after_timeout gained live_background_work / bg_hold_extended / cpu_active / tree_active so the “tracked and killed anyway” cohort is identifiable regardless of which divert label lands. 8 new tests #647
  • fix(claude): don’t apply the 60s limbo grace to a demonstrably busy post-result process. The #591 limbo-grace cap gated only on not live_bg — the absence of a registered background handle — while its comment claimed “fully quiescent”. A CPU-active, tree-active process with no registered handle was therefore misclassified as idle, SIGTERM’d at 60s, quarantined, and diverted to a fresh contextless session; one observed case lost the full context of a 23m52s / 38-step turn, killed 70s after delivering its result while doing exactly what it had told the user it would do. Not a regression — an incomplete fix: rc10 introduced is_cpu_active / is_tree_cpu_active but wired them into only one of the two gates, giving the extension gate a demonstrably_idle veto with no symmetric demonstrably_busy veto on the grace cap. The cap now computes demonstrably_busy = cpu_active is True or tree_active is True and applies the grace only when not live_bg and not demonstrably_busy. Single-sample CPU flapping (latching “busy seen recently” over a window) is deliberately deferred pending production evidence. Written TDD — the failing test first — plus a boundary test and an end-to-end test driving a real CPU-busy child through real /proc accounting #655
  • fix(claude): latch a conservative wait on a bare rate_limit_event. When every timing field arrives null, the #518 fallback didn’t cover the no-field case and rate_limit_wait_until stayed unlatched, so a throttled session read as idle to the watchdog and risked being mistaken for hung. A bare event now latches DEFAULT_BARE_RATE_LIMIT_WAIT_S = 60s with retry_after_source="default", making awaiting_rate_limit_retry() directionally correct, and the estimate surfaces as “waiting to retry (~60s)” #657
  • fix(claude): let the ExitPlanMode plan input satisfy the outline gate. On plan-file CLIs the model writes no chat text at all, so the text-based gate (max_text_len_since_cooldown < 200) could never be satisfied and deny-looped until Claude gave up. The plan field of the ExitPlanMode input now satisfies the gate, and the plan body renders as the standalone outline message with the usual Approve / Deny / Let’s discuss buttons #659
  • fix(config): resolve the engine’s real default model on the /config Routing line instead of a generic placeholder. get_engine_default_model() reads the OpenCode and Pi settings files for the actual configured default; the placeholder hints are kept as a fallback when no settings file is readable #475
  • fix(claude): capture proc_returncode on the cancellation path, not only the happy path (#640 follow-up). #640 mirrored the subprocess return code onto the stream state, but the assignment sits in run_impl’s try body right after await proc.wait() — so cancellation (/cancel, /new, drain), an exception in the task group / JSONL reader, or the early pipes RuntimeError all skip it and leave proc_returncode None. On those paths _is_signal_death(None) is False and proc_returncode not in (0, None) passes None through, so the auto-continue death-spiral guard stays inert exactly when a run died messily. manage_subprocess.__aexit__ already runs a shielded, bounded terminate+reap before run_impl’s finally, so the code is available there with no extra wait(); the capture now happens in the finally, guarded for the paths where proc/stream were never bound. New cancel-mid-flight harness test driving a real ClaudeRunner subprocess + a hang_before_result fake-CLI scenario #667
  • fix(claude): make the fail-closed handoff probe attributable and correct a stale settings comment (#633 follow-up). session.handoff_check_failed bound only exc_info=True, so a persistent probe failure — which silently makes every resume divert fresh, presenting to the user as total loss of conversational continuity — collapsed into one indistinguishable report under the issue-watcher’s signature dedup. It now binds engine / session_id / chat_id, matching the sibling session.handoff events. Also corrects the serialize_session_owner comment in settings.py, which still described the abandoned “quarantine on timeout” behaviour: the implementation deliberately diverts fresh WITHOUT quarantining (a busy session is not a known-corrupt one, and a 7-day marker is too destructive), now documented as an intentional deviation from the #633 W4 design. Attribution assertions added to test_633_probe_failure_fails_closed #668

changes

  • shutdown: shorten the graceful-drain timeout from 120s to 10s when the sole active run is the session that triggered the restart (the #547 self-restart deadlock — systemctl restart issued from inside the only active run can never self-complete), and flush queued outbox sends with a bounded 5s window before close so an already-queued final message isn’t dropped on the way out (a message the hard-cancelled run never enqueued can’t be recovered). /restart records the originating chat for the precise case; SIGTERM relies on the active_runs == 1 heuristic. Follow-up to #547 axis 3. #559
  • feat(telegram): optionally deliver skipped outbox directories as a zip. Agents that emit an image folder (e.g. /quality screenshot audits) previously only ever learned the folder was skipped/archived — it never reached Telegram. New [transports.telegram.files] outbox_deliver_directories = "zip" (default "off") bundles each skipped directory’s deliverable members into a single <name>.zip document. Security: recursive deny_globs applied to every member (defaults broadened to cover .env.*, *.key, id_rsa/id_ed25519, .netrc, .npmrc, .pypirc), symlinked files and deny-globbed subdirs (.git/.ssh) pruned, members read through an O_NOFOLLOW descriptor with fstat validation (closes the stat→write TOCTOU), and per-member / total-input / member-count / traversal / final-zip-size / attachment-count all capped. No deliverable members, an oversize zip, a build error, or a send failure fall back to the #600 archive. Compression runs off the event loop. 8 tests in tests/test_outbox_delivery.py #628
  • feat(watchdog): bounded auto-resume for Type-A stream-idle timeouts. #438 shipped the Type-A / Type-B classification but deferred auto-retry pending upstream Anthropic stabilisation; new [watchdog] stream_idle_auto_retry (default off) and stream_idle_max_retries now auto-resume a mid-generation stream-idle stall a bounded number of times. Type-B (cold-start, zero-byte) is never retried — it’s an upstream outage, not a local miscalibration — and both signal-death and cost-budget guards apply, so a retry can’t spiral under memory pressure or blow a daily budget. Emits a claude.stream_idle.auto_retry structured log #572
  • feat(telegram): new [transports.telegram] voice_transcription_language ISO-639-1 hint, passed to the Whisper API language parameter. Short utterances were being detected as the wrong language and transcribed accordingly — “Continue” came back as “계속”. Validated at config parse time and hot-reloadable with the rest of the bridge settings #638
  • changes(claude): retire the progressive-cooldown workaround. The 30/60/90/120s escalation existed to absorb Claude Code v2.1.72–2.1.74 re-issuing ExitPlanMode immediately after a denial (#126 lineage). Verified fixed upstream on CLI 2.1.215 — a denied ExitPlanMode now yields a clean text turn with no re-issue — so _DISCUSS_COOLDOWN and its escalation ladder are removed. The text-based outline gate (mark_outline_pending) survives and is unaffected. If the upstream loop ever regresses, the repro is: deny an ExitPlanMode control_request via the Telegram buttons and watch for an immediate re-issue #570
  • changes(watchdog): runner.limbo_detected now logs at INFO when the same evaluation shows live background work or a busy process tree, reserving WARNING for the genuinely quiescent stuck case. Under rc10’s liveness-aware ceiling a healthy long-running session routinely trips this path, so a WARNING was misleading operators and the issue-watcher alike. The event is enriched with live_background_work / cpu_active / tree_active #653
  • feat(telegram): tell the user why a follow-up is queued. A message arriving behind a lingering post-result Claude session could sit on a bare “queued” for up to 30 minutes with no explanation — one observed case held a voice note for 5m36s in silence. The notice now states the background-task count, that context will carry over, and the /cancel hint #654
  • feat(fleet): scripts/fleet-status.sh gives a one-shot read-only version/state view across all 5 hosts, plus rollout attestation guardrails and a /ping verification playbook #627
  • feat(scripts): SHA-bind the integration-test attestation marker. scripts/run-integration-tests.sh now records the head_sha of the tested commit (auto-derived from the script’s repo, overridable via --head-sha / UT_INTEGRATION_HEAD_SHA) and a dev_bot_id (default overridable via UT_DEV_BOT_ID) into the marker JSON, so the fleet-rollout gate can bind an attestation to the exact commit being rolled rather than a bare version string; tiers and notes are preserved. 5 tests in tests/test_attestation_marker.py #674

tests

  • tests: deterministic fault-injection harness for the no-op empty resume. New tests/fake_clis/fake_claude_noop_resume.py scenarios drive a real ClaudeRunner and handle_message through the real spawn / PTY / msgspec pipeline: the quarantine-and-fresh recovery, a healthy-resume negative control, the linger-scenario emission shape, and a resume_survives_sigterm leg modelling a prior owner that ignores SIGTERM — asserting the core invariant that exactly one spawn occurs and it is the fresh leg, never a --resume of a session that still had a live owner. Converts the manual B-RESUME integration procedure into deterministic coverage. Two existing tests that constructed background state by bare set membership (bypassing the registration path) now register deadlines as _register_background_handle always does #634
  • tests: raise the anyio.fail_after hang-guards in the run_main_loop tests from 2s to 30s. They flaked on cold coverage runs and on loaded CI runners (reproduced on the CI 3.12 runner); the root cause was timing, not test-order state. These guards exist to catch hangs, not to race the loop #641

docs

  • docs: codify the post-result watchdog as a permanent mitigation rather than retire-able dead code. Both upstream defects (claude-code#39700, #30333) are NOT_PLANNED, so the watchdog/SIGTERM path is permanent. Recorded in the Claude runner reference and the config reference, including the constraint that #527’s detector refactor must not drop the kill path — the detector decides messaging, the watchdog decides survival #569
  • docs: keep auto-continue’s #34142 path, documented as symptom-based. Retiring it is not implementable as proposed: _should_auto_continue is a single symptom-based predicate, and at the decision point the subprocess has already exited and the two upstream defects are observationally identical (a result frame excludes the predicate rather than discriminating between them). background_observed correlates with the NOT_PLANNED #30333 but is unsound as a gate — it also fires for Monitor, background Bash, ScheduleWakeup and RemoteTrigger — so gating on it would silently drop recovery for a defect upstream declined to fix. Verdict recorded, plus the cohort markers (background_observed, proc_returncode, event_count) needed before any future narrowing #568
  • docs: document the untether-issue-watcher event set and msgspec dedup signature in contrib/README-issue-watcher.md, closing the four monitor already_tracked patterns that had no watcher coverage. Adds jsonl.msgspec.invalid (per-gap dedup keyed on invalid-value@JSONPath), progress_edits.stall_auto_cancel, and progress_edits.frozen_ring_escalation; stall_detected is deliberately watcher-excluded because the monitor owns it. The daemon script itself is out-of-repo and deployed to all 5 fleet hosts #639

v0.35.3 (2026-05-20)

breaking

  • security: empty [transports.telegram] allowed_user_ids is now a startup ConfigError instead of a silent insecure default. Previously, an unset or empty allowlist meant any Telegram user who knew the bot username could send commands — a real production-bot footgun. Operators who want an open bot (demos, hackathons, dev) must opt in explicitly with allow_any_user = true, which is logged at INFO every boot (security.allow_any_user) so the deviation stays visible in journalctl. Existing deployments already configured with a populated allowlist are unaffected; deployments running with an empty allowlist will fail to start until the operator either populates the list or sets the opt-out flag. Migration is a one-line config edit. The legacy-config migration in config_migrations.py now relocates a top-level allow_any_user key into [transports.telegram] alongside bot_token / chat_id. New _validate_allowed_user_ids_or_optin @model_validator in TelegramTransportSettings. 4 new tests in tests/test_settings.py (block + opt-out + populated + both-set) #377

changes

  • feat: Gemini runner now passes --skip-trust by default so headless runs work outside ~/.gemini/trustedFolders.json. Gemini CLI rejects runs from any directory not in the trust list — even with --approval-mode yolo — and there is no interactive prompt path in headless usage, so projects outside the trust list silently failed before any agent output. Untether already runs Gemini with yolo for the same “always headless” reason, so passing --skip-trust extends the same precedent. GeminiRunner.skip_trust (default True) is the runtime switch; opt out per deployment with [gemini] skip_trust = false in untether.toml (security-conscious operators who want Gemini’s project-local extension/MCP trust gate enforced). 2 new tests in tests/test_build_args.py::TestGeminiBuildArgs (test_skip_trust_default_includes_flag, test_skip_trust_opt_out_omits_flag) #471
  • feat: hot-reload [progress] settings — editing [progress].max_actions, [progress].verbosity, [progress].min_render_interval, or [progress].group_chat_rps in untether.toml now applies on the next run without restarting the bot. Companion to the trigger hot-reload (#294) and bridge hot-reload (#286/#318) shipped earlier this milestone. The four settings groups in scope for #269 each had a different starting state: [footer] and [cost] were already reading fresh per-call from _load_footer_settings() / load_settings_if_exists() (no work needed); [watchdog] was already reading fresh per-run via _load_watchdog_settings() at the top of handle_message (still no restart-required, just verified); the only gap was [progress], where MarkdownFormatter(max_actions, verbosity) and ExecBridgeConfig.min_render_interval were baked in at startup in telegram/backend.py. Closed by adding MarkdownFormatter.refresh_from(progress_settings) and TelegramPresenter.refresh_progress_settings(), plus a new runner_bridge._load_progress_settings() sibling helper that handle_message invokes per-run; the runner bridge now refreshes the default presenter’s formatter (per-chat /verbose overrides downstream of _resolve_presenter reconstruct from the refreshed defaults so they pick up the new values too) and threads the live min_render_interval into each ProgressEdits instance instead of the startup snapshot. Out of scope (entry-point limitation, documented on the issue): engine registration and command registration — those still require pipx upgrade / restart. 8 new tests in tests/test_meta_line.py (TestMarkdownFormatterRefresh: max_actions, verbosity, negative-clamp, invalid-verbosity rejection, missing-attribute tolerance, presenter delegation; plus _load_progress_settings defaults / error-fallback covers). Full suite: 2511 passed #269
  • feat: Claude post-result idle timeout + ”✓ turn complete” UX hint (Option D hybrid). Closes the “session looks stuck for 36 min after final message” gap by combining (a) an immediate footer signal so the user knows the turn is done, and (b) a server-side timer that closes stdin when the bidirectional Claude CLI sits idle past the new [watchdog].post_result_idle_timeout (default 600s, range 30s–1h; gated by [watchdog].post_result_idle_enabled = true for an explicit kill-switch). Mechanism: ClaudeStreamState.result_received_at is armed by translate_claude_event on every StreamResultMessage; a new ClaudeRunner._post_result_idle_watchdog task started in the run_impl task group polls the timer and calls this_proc_stdin.aclose() once the deadline passes — same mechanism as the normal-flow exit on line 2412, just earlier. The CLI hits stdin EOF and exits gracefully (rc=0); the auto-continue safety gate already excludes last_event_type == "result" (locked by test_skips_result_event_type from #34142’s regression set) so the clean exit will not phantom-resume the session. Approval-state guard: if _REQUEST_TO_SESSION or _PENDING_ASK_REQUESTS has live entries for this session the timer re-arms instead of closing — prevents orphaning a button-click control_response that’s mid-flight. UX hint #1 is delivered via a supplementary StartedEvent carrying meta={"complete": "✓ turn complete"} (the supported pattern for late-arriving meta per runner-development.md); markdown.format_meta_line renders it in the footer alongside model/effort/permission/trigger so the user immediately sees the turn boundary. Successful results emit the hint; errored results don’t (no false “complete” tag on a failure). Two structlog events for ops: claude.post_result_idle.deferred (when the approval guard fires) and claude.post_result_idle.closing_stdin (when the deadline passes cleanly). 6 new tests in tests/test_claude_runner.py (test_translate_result_arms_post_result_idle_timer, test_translate_result_emits_turn_complete_meta, test_translate_result_skips_complete_meta_on_error, test_post_result_idle_watchdog_fires_when_clean, test_post_result_idle_watchdog_defers_when_pending_approval, test_meta_line_renders_turn_complete_marker, test_meta_line_omits_complete_when_absent) #333
  • feat: trigger visibility Tier 2 (/config:tg page expansion) + Tier 3 (last_fired_at history + /stats triggered/manual breakdown). The /config → ⏰ Triggers page now lists every cron and webhook configured for the current chat — for crons, the human-readable schedule via describe_cron(schedule, timezone), project, engine, and last-fired relative time; for webhooks, path, auth scheme, project, engine, and last-fired. Lists are scoped to the current chat (using crons_for_chat / webhooks_for_chat with the bridge default_chat_id fallback), capped at 10 entries with a “…and N more (see untether.toml)” overflow marker, and omitted entirely when the chat has no triggers (the pause/resume controls remain at the top regardless). Tier 3 adds a new persistent JSON history store (src/untether/triggers/history.py) at <config_path>.with_name("triggers_history.json") that records time.time() after every successful cron dispatch (triggers/cron.py:130 post-dispatch_cron) and webhook fire (triggers/dispatcher.py:dispatch_webhook and dispatch_action for non-agent actions). Recording is best-effort — OSError writes log triggers.history.write_failed and swallow so a transient disk failure can’t break the cron loop or webhook server. /stats now appends (N triggered, M manual) per engine line and on the totals row when at least one count is > 0; DayBucket and AggregatedStats carry additive triggered_count / manual_count fields with .get(..., 0) fallbacks so existing stats.json files load cleanly. runner_bridge.handle_message resolves the split via triggered=bool(context and context.trigger_source) at the existing record_run callsite. New triggers_history.json state file is created on demand and survives restart; renaming a trigger ID in TOML leaves a stale entry that operators can manually delete (no auto-prune to avoid losing data on transient TOML errors). 28 new tests across tests/test_triggers_history.py (10), tests/test_session_stats.py::triggered/manual (7), tests/test_stats_command.py (3), tests/test_config_command.py::TestTriggersPagePerChat (7), tests/test_trigger_cron.py (2 cron-firing + history-failure resilience), and tests/test_trigger_dispatcher.py (2 webhook recording + history-failure resilience) #271
  • feat: subscription-usage observability + /usage debug section. Promotes the claude_usage.schema_mismatch structlog warning from one-shot per-process to per-call counter so the issue-watcher fires on ongoing API-shape drift, not just the first hit (the structured event now carries a cumulative count field; new runner_bridge.get_usage_schema_mismatch_count() exposes the same counter for the debug page). Adds UsageCacheStats to utils/usage_cache.py tracking last successful fetch wall time, cache age, last-error class+message; populated by fetch_claude_usage_cached on every fetch path including stale-while-error fallbacks. Adds _read_token_expiry_ms() to telegram/commands/usage.py so the OAuth token expiry can be surfaced without raising on missing credentials. New /usage debug invocation appends a 🔧 debug block (HTML-formatted) showing: last successful fetch (UTC ISO timestamp + age + freshness label), last error (class + message, truncated), OAuth token expiry (with hh/mm-until-expiry), and the cumulative schema-mismatch counter — operator-facing signal so the next time the subscription footer goes silent the root cause is visible without grepping journalctl. 5 new tests in tests/test_usage_cache.py::TestCacheStatsObservability (initial state, success records wall time, failure records last error, success-then-failure preserves wall time) and tests/test_command_engine_gates.py::TestUsageDebugMode (debug section appended only when args_text == "debug"); existing test_schema_mismatch_warning_fires_once repurposed to assert per-call firing with cumulative counts #410
  • feat: CLAUDE_STREAM_IDLE_TIMEOUT_MS is now user-configurable via [watchdog] claude_stream_idle_timeout_ms in untether.toml (default 300000 ms / 5 min, range 30 s – 30 min). Deployments that hit upstream Anthropic API stalls on long opus 4.7 1M plan-mode generations (Type-A mid-generation stalls) can raise this to 600000–900000 ms to ride out longer SSE silences. Untether’s Claude runner reads the value via setdefault so shell-set CLAUDE_STREAM_IDLE_TIMEOUT_MS still wins. Settings load failure falls back to the hardcoded 300000 ms default with a debug log entry. Type-A vs Type-B classification on the failure message: when the run fails with API Error: Stream idle timeout - partial response received, the _extract_error output now appends a one-line classification: Type-A (mid-generation, num_turns ≥ 1 && duration_api_ms > 0) suggests raising the timeout; Type-B (cold-start zero-byte stall, num_turns ≤ 1 && duration_api_ms == 0) explicitly tells the user that raising the timeout will NOT help — it’s an upstream API outage, not a local watchdog miscalibration. Auto-retry deferred to v0.35.4 pending upstream Anthropic stabilisation. 5 new tests in test_claude_runner.py (test_extract_error_type_a_*, test_extract_error_type_b_*, test_extract_error_unrelated_*, test_env_stream_idle_timeout_configured_value, test_env_stream_idle_timeout_settings_load_failure_falls_back) #438
  • feat: master pause/resume toggle for the trigger system (crons + webhooks). Adds TriggerManager.pause() / resume() / is_paused API; cron scheduler skips its tick while paused (run_once crons are not consumed during the pause and fire on the next matching tick after resume); webhook server returns 503 triggers paused (with Retry-After: 60) instead of dispatching, and the /health endpoint surfaces {"status":"paused","paused":true} so external monitors can distinguish paused-but-up from healthy. Pause is in-memory only — restart auto-resumes (the safe default). Wired into /config two ways: a one-button toggle row at the bottom of the home page (only when triggers are configured) and a dedicated 📡 Triggers page (config:tg) with state + counts. /ping switches to a ⏸ triggers paused: … (suspended) indicator while paused. 8 new tests in test_trigger_manager.py (TestPauseToggle), 2 in test_ping_command.py (paused/resumed indicators), 5 in test_config_command.py (TestTriggersPage) covering unavailable / empty / pause / resume / toast labels #294
  • feat: [claude] config gains extra_args: list[str] — user-supplied upstream CLI flags passed through to claude verbatim. Mirrors codex.extra_args and pi.extra_args. Primary motivator is Claude-in-Chrome: Claude Code 2.1.x gates the mcp__claude-in-chrome__* tool namespace behind --chrome (or CLAUDE_CODE_ENABLE_CFC=1), so Untether-spawned sessions never saw those tools in their catalogue. Setting extra_args = ["--chrome"] in ~/.untether/untether.toml now enables Claude-in-Chrome end-to-end without forking Untether or touching the LaunchAgent/systemd env. Flags Untether manages internally (-p, --print, --output-format, --input-format, --resume/-r, --continue/-c, --permission-mode, --permission-prompt-tool) are rejected at config-load with a ConfigError so duplicate-argv surprises fail fast instead of at runtime. The user-supplied args land on argv after Untether’s managed stream-json prelude and before resume / model / effort / allowed-tools / permission flags, so the trailing -p <prompt> (or stdin prompt under permission-mode) is never displaced. 8 new unit tests in tests/test_build_args.py cover argv ordering, permission-mode argv, multi-flag order preservation, build_runner parsing, and reserved-flag rejection (individual flag + key=value prefix form) #407
  • feat: user-extensible engine-subprocess env allowlist — two new [security] keys let self-installed Untether users thread credential-manager tokens (1Password, Doppler, Vault, Infisical, …) into engine subprocesses without forking utils/env_policy.py. env_extra_allow: list[str] admits exact names (e.g. OP_SERVICE_ACCOUNT_TOKEN); env_extra_prefix_allow: list[str] admits whole families (e.g. VAULT_* via ["VAULT_"]). Both are validated against [A-Z_][A-Z0-9_]* at config-load — empty / whitespace / lowercase / leading-digit entries are rejected. Honoured by the Claude and Pi runners (the engines that opt in to filtered_env) and by the env_audit probe (so user-allowed names aren’t false-flagged as claude.env_audit.leaked_var). One env_policy.user_extension INFO log per process at first runner spawn. BWS_ACCESS_TOKEN (Bitwarden Secrets Manager — common enough to ship by default) is also promoted into the built-in _EXACT_ALLOW. 19 new tests across test_env_policy.py, test_env_audit.py, test_settings.py #409
  • feat: /trigger command renamed to /listen to disambiguate from the webhook/cron triggers system. The chat-level message-routing command (all / mentions / clear) shared its name with the unrelated [triggers] TOML section, which became increasingly confusing as /config grew separate trigger pages. /listen is now the canonical command; /trigger continues to work as a deprecated alias for one release cycle and prepends a one-line deprecation notice on each invocation. /config → 📡 Listen page replaces the prior 📡 Trigger page; the home-page summary renders Listen: all instead of Trigger: all; bot command menu lists listen. Internal renames: telegram/trigger_mode.pytelegram/listen_mode.py; commands/trigger.pycommands/listen.py; type TriggerModeListenMode; resolve_trigger_mode()resolve_listen_mode(); ChatPrefsStore / TopicStateStore gain new *_listen_mode methods with legacy *_trigger_mode aliases preserved for one cycle. Storage: msgspec field is still named trigger_mode for backward compat with existing telegram_chat_prefs_state.json / telegram_topics_state.json — no migration needed #297
  • feat: long-running tool visibility — Bash, BashOutput, ScheduleWakeup, Monitor, and any other tool > 60 s now surfaces a heartbeat-driven elapsed-time tail on the progress message (▸ Bash · 3m 47s · npm run build) so a glancing user can answer “is it alive? what is it doing? for how long?” without waiting for the next JSONL event. Two coordinated upgrades: (1) a 30 s heartbeat tick (new [progress] heartbeat_interval, range 5–120 s, default 30) folded into the existing stall monitor — every tick walks ProgressTracker._actions and bumps event_seq whenever any open action’s started_at is older than 60 s, forcing a re-render with a fresh elapsed counter; (2) format_action_line gained an elapsed_seconds kwarg that appends · <elapsed> · <key arg> for non-completed actions, regardless of the /verbose toggle. format_verbose_detail gained dedicated branches for BashOutput (renders the last line of result_preview so 10-min Cloudflare deploy polls show → Deploy Production: in_progress instead of a static ▸ BashOutput), KillShell, ScheduleWakeup (countdown + reason: → fires in 4m 12s · "build check"), and Monitor (countdown remaining). ActionState gained started_at / last_update_at wall-clock fields populated from the ProgressTracker.clock callable (defaults to time.monotonic; tests can pass a fake clock for deterministic assertions). The render pipeline (MarkdownFormatter.render_progress_parts, MarkdownPresenter.render_progress, Presenter.render_progress Protocol, TelegramPresenter.render_progress) all gained an optional now: float | None kwarg threaded from runner_bridge._run_loop. New format_duration / format_countdown helpers in markdown.py. Strict “rolling stdout sub-line ≤ every 5 s” cannot be achieved without upstream Claude Code changes — the BashOutput-polling path is the proxy and refreshes at each polling cycle (~15 s in practice). 22 new tests across tests/test_verbose_progress.py (BashOutput / KillShell / ScheduleWakeup / Monitor detail + long-running tail variants + format_duration helpers) and tests/test_exec_bridge.py (heartbeat-driven countdown mutation) #481
  • feat: expected-wait stall suppression matrix — five new info-logged branches in ProgressEdits._stall_monitor suppress Telegram stall warnings during legitimate waits, gated by a if not frozen_escalate master gate so genuinely-frozen sessions still warn. Branches: (1) progress_edits.stall_post_result_suppressedstream.last_event_type == "result" and engine_state.result_received_at armed (the post-result idle watchdog from #333 is the legitimate owner of the silence); (2) progress_edits.stall_schedule_wakeup_suppressedengine_state.live_wakeups has any deadline in the future (Claude is parked waiting for an upstream timer); (3) progress_edits.stall_monitor_active_suppressedengine_state.live_monitors has any future deadline; (4) progress_edits.stall_bash_grace_suppressed — most-recent action is Bash/BashOutput/KillShell within the new [watchdog] bash_grace_seconds (range 5–300 s, default 60) startup window; (5) progress_edits.stall_long_bash_suppressed — recent BashOutput within stall_threshold/2 (the polling cycle is the proxy for “stdout is flowing”). The same 5 booleans gate the _STALL_MAX_WARNINGS auto-cancel arm with a new progress_edits.stall_auto_cancel_suppressed_expected_wait log — a session about to gracefully close (#470) or legitimately waiting on a timer must not be killed. structlog WARN events at runner.py:1002 (subprocess.liveness_stall) and runner_bridge.py (progress_edits.stall_detected) remain unchanged so untether-issue-watcher and ops dashboards continue to receive them — only the chat-side surfacing decision changed. Bash/BashOutput suppression uses tracker._actions engine-agnostically (mirrors _has_running_mcp_tool); ScheduleWakeup / Monitor / post-result use getattr(stream, "engine_state", None) duck-typing (Claude only today, no-ops cleanly for other engines). 11 new tests in tests/test_exec_bridge.py covering each suppression branch, the auto-cancel block, the closing-message idempotency, the heartbeat countdown mutation, and the frozen-ring precedence (post-result + ScheduleWakeup) #481
  • feat: /loop and ScheduleWakeup support — opt-in observation of Claude Code’s session-scoped scheduling tools so iterations keep firing after the subprocess exits. Default OFF — users opt in per chat via /config → 🔁 Loop mode. New loop_scheduler module sibling of at_scheduler (mirrors install/uninstall/active_count API) with persistence to active_loops.json for restart resilience. Observer hooks in the Claude runner’s JSONL stream-translation path (_observe_loop_tool_use / _observe_loop_tool_result, sibling functions to the existing _register_background_handle / _clear_background_handle background-task tracker) parse the canonical Probe-5-confirmed field names (cron not cron_expression; id not taskId/cronId) and bind upstream 8-character cron IDs via \bjob ([0-9a-f]{8})\b. Race avoidance gates fire on is_session_alive (added pre-#289 as 3362ae9) — if the subprocess is still parked on a control_request, the fire path sleeps redundancy_check_interval and retries instead of double-firing. Drop-on-busy via is_chat_busy callable mirrors upstream’s “no catch-up” semantic. Re-issue prompts wrap the original user prompt with Loop iteration N: <prompt>. Do the task now; do not summarize old results unless necessary. (per Probe 3 result + consensus revision). Cost protection delegated entirely to existing [cost_budget] infrastructure — every loop fire calls cost_tracker.record_run_cost, every loop iteration is subject to the same daily/per-run caps as manual runs. New [loop] config section provides runaway-safety caps (max_iterations, max_total_duration_hours, expiry_days) but explicitly NOT cost caps. Drain integration in _drain_and_exit polls loop_scheduler.active_count() alongside pending_at. /cancel and /new both call cancel_pending_for_chat which writes the do-not-resume sentinel for the cancelled session (block only loop_scheduler --resume, not /continue per handover default). New _page_loop() sub-page in /config with explicit cost+quota warning before turning ON; engine-aware (Claude only — LOOP_SUPPORTED_ENGINES = frozenset({"claude"})); 💰 Set a budget deeplink to config:cu for one-tap budget setup. 5 doc files updated (schedule-tasks how-to, cost-budgets callout, troubleshooting symptom table, FAQ Q, config reference [loop] section). Empirically grounded — claude --resume does NOT restore session-scoped cron tasks in claude v2.1.129/2.1.132 in --print mode (Probe 1), so Untether owns ALL firing across both CronCreate and ScheduleWakeup tool families. 58 new tests across tests/test_loop_scheduler.py (41), tests/test_claude_runner.py::TestLoopObservation (10 + 1 sync), and tests/test_config_command.py::TestLoopMode (7) #289

fixes

  • fix: media-group file uploads (two or more files sent together) failed with “no project context available for file upload” on single-project DM deployments. _handle_media_group in telegram/commands/media.py resolved the run context from topic state only, never consulting the per-chat ChatPrefsStore (/ctx-bound context) — unlike the single-file path (loop.py:build_message_context), which has a topic-bound → chat-bound → topic-merged-default fallback ladder. On a host with no default_project and no project chat_id (e.g. the channelo VPS, where the config validator forbids a project chat_id equal to transports.telegram.chat_id), the /ctx-bound chat context is the only project resolver, so media groups resolved no project while single-file uploads worked. Not a v0.35.3 regression — the gap has existed since media.py was introduced (pre-0.35.0); the channelo single-project-DM deployment is the first config shape to expose it. Threaded chat_prefs through MediaGroupBuffer_handle_media_group and mirrored the build_message_context ladder (with a # keep in sync comment); regression test test_media_group_uses_chat_prefs_bound_context added in tests/test_telegram_media_command.py. PR #563 #562
  • fix: rc20 — outbox + watchdog approval-pending follow-ups completing the rc19 patches that landed in only one code path. PR #556. Covers #524 and #526.
    • #524 — outbox skipped items surfaced across all completion paths: rc19 (#555) added _surface_outbox_skipped on the normal-completion path in runner_bridge.handle_message. /monitor audits on 2026-05-18 caught the regression still firing because two adjacent paths were untouched — the pre-auto-continue delivery (subprocess 1 stuck-after-tool-result recovery) and the run_ok=False failed-run branch — and both silently dropped the agent’s intended deliverable. rc20 extracts the surfacing logic into _surface_outbox_skipped in runner_bridge.py and wires it into both gap paths. Failed runs still skip the actual file send (preserving the original gating) but do a cheap scan_outbox() to collect skipped items and surface them so the user always learns what the agent intended to ship. Honours the existing outbox_notify_skipped config flag and filters the overflow pseudo-entry. Tests in tests/test_exec_bridge.py cover failed-run surfacing, notify_skipped=false suppression, and the only-overflow filter #524
    • #526 — approval-pending stalls demoted in the watchdog-side detector too: rc19 demoted the bridge-side progress_edits.stall_detected WARN to a paced INFO when _has_pending_approval() returned true, but the watchdog-side detector in runner.py (which emits subprocess.liveness_stall — the signal untether-issue-watcher actually files on) was untouched, so the daemon kept filing GitHub issues on routine approval-pending sessions and the nsd audit (2026-05-18) showed a user cancelling a productive 15-minute investigation because the chat-side reassurance came too late (1800s threshold). rc20 adds _recent_event_is_control_request helper in runner.py using the stream’s recent_events ring buffer; plumbs the predicate into _watchdog_loop so when the last JSONL event is control_request it emits subprocess.approval_pending INFO instead of liveness_stall WARN, skips the auto-kill branch entirely, and paces INFO emission once per 30 min via shared _APPROVAL_PENDING_REFIRE_S. Splits _STALL_THRESHOLD_APPROVAL_FIRST (600s) and the existing 1800s refire so the user gets a reassuring “tap a button above” chat message at 10 min on first occurrence — fixes the nsd-style early cancellation. Rewords the chat-side approval reminder copy to make the “tap a button above to proceed (no action needed otherwise)” affordance explicit. Tests in tests/test_exec_bridge.py (failed-run surfacing, suppression flag, two-tier first-reminder threshold, reworded copy) and tests/test_exec_runner.py (predicate truth-table coverage, watchdog demotion via integration with a fake codex script emitting control_request, watchdog WARN still fires when no control_request is recent) #526
  • fix: rc19 — /monitor campaign issue sweep, 7 additional issues bundled from staging audits (2026-05-13 through 2026-05-16). PR #555. Covers #523, #525, #528, #532, #546, #547, and #548.
    • #528 — ↩️ Answered: echo no longer truncated to 100 chars after AskUserQuestion text replies. The agent path was unaffected and always received the complete text; only the user-facing confirmation was truncated, so users couldn’t see whether their full message reached the agent. Replaced the hard [:100] slice with a 300-char soft cap + ellipsis via new _format_answered_echo helper. Regression tests in tests/test_loop_coverage.py #528
    • #525 — dedup cancel.requested triple-fire with 1-second TTL on (chat_id, progress_message_id) across all three cancel entry points (text-reply, text-fallback, callback). Telegram duplicate callback deliveries before keyboard clearing produced 3× fan-out of one user intent. Repeat cancel_requested.set() was benign today, but log noise + future side-effectful cancel actions would inherit the fan-out. Per-test autouse fixture clears the module-level dict between tests so test reuse of the same (chat_id, msg_id) isn’t surprised by silent drops #525
    • #532 — per-engine setup.warning consolidated to single setup.summary INFO per config.reload.applied. Previously every reload emitted one setup.warning per engine not on PATH (5 WARNs on a single-engine host like channelo, which runs only Claude); the noise padded WARN filters in untether-issue-watcher, /monitor, and Grafana with intentional install state. The summary INFO captures the same install state for diagnostics without the WARN noise #532
    • #523 — slash-command typo recognition for leading-dot variants (.new, .usage, …). Previously dispatched a full agent run and incurred the full Claude cost when a user mistyped a slash command. Now the bot recognises the typo pattern and replies with a correction suggesting the proper /new / /usage form #523
    • #547 axes 1+2 — agent self-restart pattern broken at source. Documented incident (2026-05-16 15:25-15:30 AEST): an agent editing untether.toml follows up with Bash systemctl --user restart untether, unaware Untether already hot-reloaded the change. The restart is issued from inside the only active run, the graceful drain has nothing it CAN drain, drain waits the full 120s timeout_s, force-exits with outbox.fail_pending count=1 (agent’s final answer silently dropped), then restarts. Axis 1 (commit 12cf4ca): _DEFAULT_PREAMBLE in runner_bridge.py now warns agents that untether.toml is hot-reloaded and systemctl restart is unnecessary. Axis 2 (commit 40dc6b7, paired with #548): Telegram confirmation message on successful reload (“♻️ Hot-reloaded …”) — closes the feedback-loop gap that drove agents to reach for systemctl restart as a safety blanket. Axis 3 (defensive drain-timeout heuristic that shortens the 120s drain when the only active session IS the one triggering the shutdown) deferred to v0.35.4 as #559 #547
    • #548 — hot-reload success Telegram notification with explicit “no restart needed” framing. config_watch.handle_reload() now sends a brief confirmation to the chat that triggered the reload (e.g. “♻️ Hot-reloaded untether.toml — No restart needed.”), so the next agent turn sees positive confirmation in context. Closes the “did this actually work?” gap that drove the #547 self-restart pattern #548
    • #546 — answer_callback_query bypasses the outbox to restore the ~220ms callback latency baseline under rapid-click clusters. The 6-10× latency escalation on the 2nd/3rd click (1.4-2.9s) was caused by outbox serialisation. All callback.answered.latency_ms values now stay near baseline regardless of click cadence #546
  • fix: rc18 — _post_result_idle_watchdog post-result hang root cause + AskUserQuestion final-keyboard clear + auto-continue outbox+UX. Three independent rc18 fixes shipped together. Covers #333, #550, and #551.
    • #333 — post-result hang fix (Tier 1+2+3 + Task 4a): rc17 (#549) added entry/exit/tick instrumentation to the watchdog; that instrumentation caught the limbo on channelo session 8876c902 (2026-05-17, 26.6 min wasted). Root cause: when Claude Code v2.1.143 closes stdout while keeping the subprocess alive, the watchdog exited early via task_exited reason=reader_done, bypassing the 600 s countdown — and stall-detector suppression cascades (post_result + MCP-heartbeat-driven children-active) hid the limbo from auto-cancel indefinitely. Tier 1 (claude.py): when reader_done fires while proc.returncode is None, the new _post_result_subcountdown re-arms a stdout-closed countdown, defers on pending control_request / ask_question, then SIGTERMs the process group after timeout_s, 5 s grace, SIGKILL if still alive. New task_exited reasons: reader_done_but_alive_timeout, subprocess_exited_during_subcountdown. Tier 2 (runner_bridge.py): new _POST_RESULT_LIMBO_THRESHOLD_S = 660.0 class const + _post_result_idle_age_seconds() helper; when post-result idle age exceeds the threshold AND no other expected-wait flag is set, the stall detector stops suppressing auto-cancel. One-shot progress_edits.post_result_limbo_detected warning. Tier 3 (claude.py): new runner.limbo_detected warning fired 30 s into the subcountdown when the subprocess is still alive — picked up automatically by untether-issue-watcher for auto:error-report filing on future regressions. Task 4a (runner.py + claude.py): JsonlStreamState.lifecycle_state + _transition_lifecycle() helper emits subprocess.state.<name> info logs at every transition (reader_eof, subcountdown, limbo, sigterm_sent, sigkill_sent, exited). Permanent canary for future hang-class issues. 7 new tests (4 in tests/test_claude_runner.py, 3 in tests/test_exec_bridge.py) #333
    • #550 — AskUserQuestion final-keyboard clear: after the user answers the last question in a multi-question AskUserQuestion flow, the inline keyboard on the question message is now stripped via ctx.executor.edit (Approach A from the rc18 handover). Previously the buttons stayed clickable and fired ask_question.flow_missing warnings since the flow state was already cleaned up. Failure modes preserved: answer_ask_question_with_options returning False leaves the buttons in place (so the user can retry); ctx.executor.edit raising logs ask_question.keyboard_clear_failed but does NOT block the answer-sent return. 4 new tests in tests/test_ask_user_question.py #550
    • #551 — auto-continue outbox + UX (Tier 0 + Tier 1): Tier 0: outbox files written by subprocess 1 during the stuck-after-tool-results window are now delivered BEFORE subprocess 2 spawns, eliminating the ~3.6% silent loss observed on lba-1. The pre-swap call mirrors the existing deliver_outbox_files plumbing at the final-message site (cleanup=True so subprocess 2 starts fresh). Failure to deliver does NOT block auto-continue — the recovery is more important than any single batch of files; new outbox.delivered_pre_auto_continue info + outbox.auto_continue_delivery_failed warning logs. Tier 1: the auto-continue Telegram notice text changed from ⚠️ Auto-continuing — Claude stopped before processing tool results to 🔁 Auto-resuming session after upstream Claude Code event. The 🔁 prefix signals recovery rather than failure and discourages users from /cancel-ing the salvage. Task 4b (runner.py + runner_bridge.py): JsonlStreamState.stall_suppression_counts: dict[str, int] + _bump_stall_suppression() helper increments per-suppression-reason counters at three sites (expected_wait, post_result, children_active). session.summary now includes a stable stall_suppressions=expected_wait:N,post_result:N,children_active:N summary line so log audits can spot suppression cascades without parsing nested JSON. Stretch tiers (#551 Tier 2/3/4 — catalog-staleness suppression window, rate-limit-aware deferral, registry preservation) deferred to a future patch #551
  • fix: rc17 — _post_result_idle_watchdog entry/exit/tick instrumentation (#333) + last_bg_bash_launched_at scalar (latent #347 sibling defect). Channelo VPS on rc16 (which already shipped the #544 ScheduleWakeup arm-delay scalar) hit a 43+ min post-result hang on session b5c1c3e0-… with pending_wakeup=False — i.e. NO ScheduleWakeup involved, so the #544 fix didn’t apply. Logs showed post_result=True (so state.result_received_at IS set), [watchdog] config used the default post_result_idle_enabled=true, and the subprocess + children stayed alive (so reader_done was NOT set) — yet zero claude.post_result_idle.closing_stdin / …deferred log lines existed despite elapsed ≫ 600 s. Three of the four #333 candidates ruled out via logs + live py-spy dump; the remaining “task crashed silently / never started” candidate cannot be discriminated without entry/exit instrumentation. The CHANGELOG line in rc16 deferred #333 to v0.35.4 pending instrumentation — rc17 lands the instrumentation now and overrides that deferral. Instrumentation: _post_result_idle_watchdog now emits claude.post_result_idle.task_started (session_id, timeout_s, poll_interval_s) at entry; claude.post_result_idle.tick every iteration (armed, elapsed_s, effective_timeout_s, dead_wakeup, pending_requests, pending_asks, would_close, last_bg_bash_launched_at_age_s, last_schedule_wakeup_arm_delay); claude.post_result_idle.tick_error (warning + exc_info) on transient per-tick failures with one-interval backoff; and claude.post_result_idle.task_exited (reason ∈ reader_done | stdin_closed | cancelled | loop_exited) in a guaranteed finally. Per-tick try/except (not loop-wide) mirrors _subprocess_watchdog / _drain_catalog_refresh conventions so a transient error never cancels the sibling _iter_jsonl_events task in the task group. Verbose by design — at 30 s poll × hours of session = O(120) lines, trivial; rate-limiting now would create ambiguity in the next reproduction. last_bg_bash_launched_at scalar: _clear_background_handle (claude.py:550) pops live_bg_bashes on tool_result mirroring the original #507 ScheduleWakeup defect that #544 fixed via a scalar high-water-mark; new ClaudeStreamState.last_bg_bash_launched_at: float | None is set in _register_background_handle at the Bash + run_in_background branch, NOT cleared in _clear_background_handle, and reset on the same fresh-user-prompt path that resets last_schedule_wakeup_arm_delay. Critically a LAUNCH tracker, not a LIFETIME tracker — bg-bashes can outlive multiple user turns (long npm install, tail -f) so per-turn reset is correct. Observability-only today; the bridge’s existing _has_fresh_bash_output / _has_recent_bash_action (runner_bridge.py:1738, 1753) remain the higher-fidelity bash-liveness proxies and the new scalar deliberately does NOT replace them in any suppression path. 7 new tests in tests/test_claude_runner.py (5 scalar lifecycle + 2 watchdog instrumentation covering task_started/tick/task_exited ordering and the reader_done exit path). The actual fix for whatever the new instrumentation reveals lands in a follow-up rc — rc17 is the diagnostic #333 (cross-ref #544, #347, #374)
  • fix: rc16 — ScheduleWakeup post-result hold-open redux. The rc11 #507 fix added a state.live_wakeups_arm_delay: dict[str, float] populated in _register_background_handle and read in _post_result_idle_watchdog to shorten the 600 s timeout to max_armed_delay + 60 s when /loop is OFF. But the dict was wiped by _clear_background_handle on the ScheduleWakeup tool_result — which is the schedule-confirmation, not a terminal signal — so by the time the watchdog ticked (after the result event, which lands AFTER tool_result) the dict was empty and the dead-wakeup shortcut never engaged. Live impact: channelo VPS auditor-toolkit session d11739ee-… on rc15, 24+ min hold-open with pending_wakeup=False despite last_action='tool:ScheduleWakeup (done)'. Replaced the per-tool_id dict with ClaudeStreamState.last_schedule_wakeup_arm_delay: float | None — a per-turn scalar high-water-mark (max semantics for multi-wakeup turns) that survives _clear_background_handle and resets on each fresh user prompt (StreamUserMessage with non-tool_result content; mixed batches preserve the scalar). 4 new tests in tests/test_claude_runner.py cover the full tool_use → tool_result → result lifecycle (the #507 unit tests bypassed _clear_background_handle, which is why this slipped through), multi-wakeup max selection, new-turn reset, and the mixed-batch edge case. The two existing #507 tests now seed the scalar instead of the dict. The broader background-task-lifecycle refactor (terminal-vs-arm signal per primitive + deadline-expiry sweeps) tracked in #374 stays in v0.35.4; the sibling defect where the 600 s safety-net watchdog silently doesn’t fire stays in #333 for v0.35.4 pending entry/exit instrumentation #544
  • fix: rc14 — claude.rate_limit_event logs no longer drop retry_after_s on subscription-cap (reset-window) throttles. The Claude CLI emits two shapes of rate_limit_event: a full form carrying retry_after_ms (already covered) and a bare/reset-window form that carries requests_reset / tokens_reset ISO timestamps but no retry_after_ms. Untether’s translate path only consumed retry_after_ms, so reset-window events fell into the “no retry hint” branch — retry_after_s stayed None, ClaudeStreamState.rate_limit_total_s never accumulated, and the chat surfaced the generic ”⏳ Rate limited — waiting to retry” with no actionable wait time. The rc13 audit observed this firing across a 5-event burst on the bip chat that preceded a subscription-cap exhaustion across 3 chats — every event logged retry_after_s=None cumulative_s=0.0 despite the upstream payload containing actionable wait info. New _derive_retry_after_s(info) helper in runners/claude.py picks the EARLIER of requests_reset / tokens_reset (the rate limit lifts as soon as either budget refills), clamps ≥ 0, tolerates both Z and +00:00 ISO suffixes, and returns None for unparseable / missing timestamps. The translate path now falls back to the derived value when retry_after_ms is None and tracks which path fed the field via a new retry_after_source=retry_after_ms|reset_ts log key. The structured claude.rate_limit_event is also enriched to include every present RateLimitInfo field under info=... (requests_limit, requests_remaining, requests_reset, tokens_limit, tokens_remaining, tokens_reset, retry_after_ms) so future audits can see what upstream actually sent. The two subscription-error message variants observed in the audit (“out of extra usage”, “hit your limit”) already map to the same friendly hint via error_hints.py:52-60, so no work is needed there. Pre-emptive 75/90% budget warnings are out of scope for this fix — deferred as a discrete feature. 4 new tests in tests/test_claude_runner.py (test_translate_rate_limit_event_derives_retry_after_from_reset_ts, test_translate_rate_limit_event_prefers_earlier_reset_when_both_present, test_translate_rate_limit_event_retry_after_ms_takes_precedence, test_translate_rate_limit_event_handles_unparseable_reset_ts); all four existing tests still pass #518
  • fix: rc14 — catalog.refresh_sent per-session debounce. The opt-in [watchdog] notify_catalog_refresh = true path (#365) previously enqueued one mcp_status control_request on every tool_result batch, with no minimum interval. The 2026-05-09 monitor audit observed this firing 183 times in a single ~18 min Claude run on the scout project — a “storm” that floods the runner’s stdin and Claude Code’s catalog-status query path. New WatchdogSettings.catalog_refresh_min_interval_s (default 5.0 s, range 0–60 s; 0 disables the gate and restores pre-#497 behaviour) drives a per-session last_catalog_refresh_queued_at monotonic-clock check in translate_claude_event’s StreamUserMessage arm. Burst tool_results now produce one refresh per 5 s window instead of one per batch — 183 fires / 1080 s collapses to ≤ 216 in the worst case, typically far fewer once tool_results cluster. The setting is plumbed through ClaudeRunner._init_state_from_settings so live reloads pick up the value on next session. The existing test_tool_result_queues_mcp_status_when_notify_enabled test was updated to drive time.monotonic() past the debounce window between the two queue assertions; 2 new tests in tests/test_claude_runner.py (test_tool_result_debounces_back_to_back_batches reproduces the ‘scout’ storm conditions — 10 batches 100 ms apart yield exactly 1 refresh — and test_tool_result_debounce_disabled_with_zero_interval confirms the off-switch) #497
  • fix: rc14 — session.summary gains a liveness_stalls field, cpu_active now returns an accurate bool instead of None, and approval-aware stall messages get their own friendly copy. Three sub-fixes addressing the rc13 audit’s “20-min ExitPlanMode approval-wait peak_idle” findings, bundled together because they share _watchdog_loop and the stall-monitor render path. (A) session.summary previously logged stall_warnings=0 despite subprocess.liveness_stall firing — by design: _total_stall_warn_count is the user-facing-threshold counter (runner_bridge.py:1143), subprocess.liveness_stall is the subprocess-health canary in the watchdog loop (runner.py:1023). Conflating them would break the user-facing invariant. New JsonlStreamState.liveness_stalls: int (0 or 1 today — liveness_warned latches after the first warning; kept as int for forward-compat) is surfaced as a new liveness_stalls= field in the session.summary log so the two signals can be observed independently. (B) prev_diag was initialised to None and only assigned after the one-shot warning fired, so is_cpu_active(None, diag) always returned None on the warning. Now takes a baseline snapshot on the first successful poll. SEMANTICS CAVEAT: the auto-kill check at runner.py:1039 is cpu_active is not True. Today None always satisfies that, so the auto-kill path triggers (combined with tcp_established == 0). After this fix cpu_active is an accurate bool — still-active processes return True (skip kill); genuinely-idle ones return False (kill, same as before). Auto-kill becomes more accurate, not more aggressive. (C) threshold_reason = "pending_approval" was already computed for threshold selection (runner_bridge.py:1110) but never used in message assembly, so users saw the same generic “No progress for N min — session may be stuck” copy that genuine hangs produce. New branch above the mcp_server is not None arm renders ”⏳ Awaiting your approval ({mins} min)” instead, pending_approval is excluded from _genuinely_stuck, and _tool_name = None initialisation is lifted to the top of the message block to fix a latent UnboundLocalError that would have hit other branches. 3 new / updated tests in tests/test_exec_runner.py (test_jsonl_stream_state_defaults, test_liveness_stall_increments_counter driving a real subprocess past _LIVENESS_TIMEOUT_SECONDS=0.2) and tests/test_exec_bridge.py (test_stall_fires_after_approval_threshold updated to assert the approval-aware message copy) #494
  • fix: rc12 — ExitPlanMode plan-body prepend (the rc11 #508 substantive-summary fix) is no longer subject to a cross-chat leak under concurrent Claude sessions. The rc11 implementation read runner.current_stream.last_exitplanmode_plan from the bridge AFTER receiving the result event, but current_stream is the runner’s most-recently-arrived stream pointer — under two concurrent Claude sessions A and B, if A’s result arrived while B was mid-translation, the bridge could read B’s plan body into A’s final answer. Moved the prepend onto the per-stream StreamResultMessage translation path in runners/claude.py where the plan body is already captured into ClaudeStreamState.last_exitplanmode_plan and is scoped to that exact stream’s state object. The bridge-side _prepend_exitplanmode_plan call is removed; the per-stream path runs the same helper with the per-stream plan body. 3 new regression tests cover the per-stream prepend, concurrent-state isolation (two streams’ plan bodies cannot mix), and error-path skip (failed result events don’t prepend). Live smoke on @untether_dev_bot confirmed the #508 UX is preserved #510
  • fix: rc13 — plan-mode research/audit completions no longer ship 25k–42k char (~8–12 Telegram message) finals. The rc11 fix for #508 (Layer A preamble + Layer E plan-body re-emit) was directionally right but tuned too verbose: A1 told Claude to “expand the bullets into a substantive summary” for research/audit tasks (plan bodies ballooned to 2–5k chars), A2 told Claude “your next assistant message MUST repeat the substantive findings” (post-approval text ballooned to 0.5–2k chars and was paraphrased rather than literal-copied), and Layer E’s substring skip rule body in final_answer failed on every paraphrased run, so the plan body was unconditionally concatenated in front of the post-approval text. Staging @hetz_lba1_bot v0.35.3rc12 over 48 h showed aushistory finals at 14k / 16k / 28k / 35k / 42k chars and scout finals at 26k / 27k chars — the 42k case matches the 11-message user repro. The Telegram MCP search_messages literal 📋 Plan (approved): returned hits on every recent plan-mode completion, confirming Layer E was the load-bearing over-firer rather than preamble alone. rc13 retunes both layers to CLI-style brevity: A1 becomes “concise 3–5 bullets; plan is shown for approval, not as the final deliverable” (drops the “expand into substantive summary” license); A2 becomes “brief CLI-style summary, 3–7 bullets or 1–2 short paragraphs, ~500–1500 chars, do NOT re-paste the full plan content”; A3 (## Summary ### Plan/Document Created) becomes “Path AND a 3–5 bullet headline summary, not a re-paste of the full content”. Layer E’s _prepend_exitplanmode_plan substring check is replaced with a length gate (len(final_answer) < 600) so a real CLI-style summary skips the prepend entirely; substring check stays as a cheap belt-and-braces second skip; the plan body is capped at 1500 chars + …\n\n(plan truncated — shown in full during approval) when Layer E does fire (preserving the original #508 UX for genuinely-empty post-approval results without re-introducing runaway concatenation). 7 new / updated tests in tests/test_preamble.py (regression-locks the rc11 verbosity-driving phrases out of _DEFAULT_PREAMBLE, plus length-gate / body-cap / substring-skip cases) and 2 in tests/test_claude_runner.py (test_translate_result_skips_prepend_when_answer_substantive, test_translate_result_caps_long_plan_body_when_prepending) #515
  • fix: rc11 — research/audit plan-mode runs no longer surface a short final Telegram message that just points to a plan file. Live user impact: 5m30s scout-project research run on staging v0.35.3rc10 produced a result answer of 584 chars (the brief plan-body acknowledgement extracted via the last_assistant_text empty-result fallback), with the substantive findings only available in ~/.claude/plans/<topic>.md — unhelpful on a phone where files cannot easily be opened. Two-layer fix per gpt-5.2 + gemini-3.1-pro consensus and an advisor pass: Layer A (preamble)_DEFAULT_PREAMBLE in runner_bridge.py now includes a Plan-mode requirements section instructing Claude that (A1) the ExitPlanMode plan parameter MUST contain a 3–5 bullet substantive summary, never just a file path; (A2) the post-approval next assistant message MUST repeat the substantive findings (the plan-body messages on Telegram disappear after approval, so post-approval text is the only thing the user retains); and (A3) the ### Plan/Document Created summary bullet now asks for inline key findings, not just a path pointer. Layer E (capture & re-emit) — new ClaudeStreamState.last_exitplanmode_plan field is populated from tool_use.input.plan whenever Claude calls ExitPlanMode, captured in the StreamToolUseBlock arm of translate_claude_event. The previously-dead _outline_prefix matcher in runner_bridge.handle_message is replaced with a new _prepend_exitplanmode_plan(final_answer, plan_body) helper that prepends the plan body with a 📋 Plan (approved): header + separator when the post-approval final_answer doesn’t already contain it (substring-only gate; no length threshold — the live repro had answer_len=584, larger than any sensible threshold). Skip rule covers the case where Layer A causes Claude to repeat the plan content in its post-approval text, avoiding duplication. 8 new tests across tests/test_preamble.py (A1/A2/A3 clauses present + 5 _prepend_exitplanmode_plan cases: short final, substring-skip, no-plan, empty, None) and tests/test_claude_runner.py (test_translate_exitplanmode_captures_plan_body, test_translate_exitplanmode_ignores_empty_plan_body) #508
  • fix: rc11 — ScheduleWakeup calls outside /loop dynamic mode no longer hold the Claude session alive indefinitely. Live impact: session 845cfcc3-… on staging v0.35.3rc10 sat post-result idle for 58 minutes before manual /cancel (peak_idle_seconds=3502.3, stall_warnings=15) — the upstream ScheduleWakeup tool is documented as only firing under /loop dynamic mode, so calling it outside that mode is a silent no-op, the agent’s turn ended, and Untether’s _post_result_idle_watchdog waited the full 600 s timeout while _has_pending_wakeup() correctly suppressed stall auto-cancel. Fix: detect the dead-wakeup case in ClaudeRunner._post_result_idle_watchdog (claude.py:2406) by reading the existing state.live_wakeups registry (#481) plus a new parallel state.live_wakeups_arm_delay dict that captures the original delaySeconds at arm time (the deadline value in live_wakeups is hard to invert after it passes). When a wakeup is armed AND _loop_enabled_for_chat(get_run_channel_id()) returns False, the watchdog cuts its effective timeout to min(timeout_s, max_armed_delay + 60.0) so the session closes within delay+grace instead of the default 600 s. The closing structlog claude.post_result_idle.closing_stdin gains effective_timeout_s and dead_wakeup keys so untether-issue-watcher can surface the new shortcut path. With /loop ON, the shortcut never fires — legitimate background work keeps the full default timeout. 2 new regression tests in tests/test_claude_runner.py (test_dead_schedule_wakeup_shortens_post_result_timeout, test_active_loop_preserves_default_post_result_timeout) #507
  • fix: rc11 — base JsonlSubprocessRunner._iter_jsonl_events now breaks the read loop after a CompletedEvent, mirroring Claude’s override (added during #502). Defensive hardening — without the break, any non-Claude engine subprocess (Codex, OpenCode, Pi, Gemini, AMP) that emits its terminal event AND has a child inheriting the stdout fd (MCP server, backgrounded shell, …) would block on iter_json_lines waiting for an EOF that never comes; proc.wait() is then never reached and the task group hangs. Not yet observed in production because Claude is the only engine known to spawn long-lived MCP children today, but the test prototyped during #502 work confirmed the bug exists in the base path. Per-engine audit (codex/opencode/pi/gemini/amp) confirmed each emits exactly one terminal event with no post-completion events, so the unconditional break is safe. 1 new regression test in tests/test_exec_runner.py (test_base_iter_jsonl_breaks_on_did_emit_completed) using a stub iter_json_lines that yields a TurnCompleted line then awaits an unfired anyio.Event() — without the break the test hangs past the 2 s fail_after deadline #505
  • fix: Claude schema now recognises server_tool_use and advisor_tool_result content block types — Anthropic server-side tools (web_search, code_execution, computer_use, …) and the parent agent’s advisor() meta-tool result blocks. Previously msgspec rejected the whole JSONL line with ValidationError: Invalid value 'server_tool_use' (or 'advisor_tool_result') and the runner silently dropped tool-use info — no progress action in Telegram, no entry in state.pending_actions, no input to verbose-mode rendering or cost tracking. Sampling 24h of staging traffic on 2026-05-08 showed paired server_tool_use + advisor_tool_result events firing across 5 different projects (auditor-toolkit, scout, brand-copilot, aushistory) and 5 different sessions. New msgspec structs StreamServerToolUseBlock (mirrors StreamToolUseBlock: id/name/input) and StreamAdvisorToolResultBlock (mirrors StreamToolResultBlock: tool_use_id/content/is_error) join the StreamContentBlock union; translate_claude_event’s match arm for assistant content widens to share the existing tool_use body for server_tool_use (_register_background_handle and _observe_loop_tool_use already filter on tool name and no-op cleanly for unrecognised server tools), and the user-message isinstance check widens to share the tool_result body for advisor_tool_result. No new helpers, no new branches — server tools render via the existing format_verbose_detail (web_search has a verbose handler; code_execution / computer_use fall back to ▸ <tool_name>). 5 new tests: 3 in tests/test_claude_schema.py (test_decode_server_tool_use_block, test_decode_advisor_tool_result_block, test_decode_advisor_tool_result_block_minimal) cover schema round-trip including optional-field defaults; 2 in tests/test_claude_runner.py (test_translate_server_tool_use_block, test_translate_advisor_tool_result_block) cover translation, pending_actions lifecycle, and last_tool_use_id stamping #489
  • fix: AskUserQuestion multi-question flow no longer crashes Untether with TypeError after answering question 1 of N via the “Other” → text-reply path. Observed live on staging (@hetz_lba1_bot, v0.35.2) on 2026-05-08: route_message constructed a RenderedMessage for the next question’s option-button keyboard but passed it to a send_plain partial whose text: kwarg expects str, raising TypeError: sequence item 0: expected str instance, RenderedMessage found inside markdown.assemble_markdown_parts and propagating up to kill the entire Untether process (systemd auto-restarted in ~10s with no Telegram update loss thanks to offset_persistence.py, but ALL active runs across all chats were lost). Refactored: the multi-question continuation logic is now a module-level helper send_next_ask_question_message in telegram/commands/ask_question.py that calls transport.send directly with a RenderedMessage carrying HTML parse_mode + inline_keyboard. route_message calls the helper for the text-reply continuation path; the callback-button continuation path still edits in place via ctx.executor.edit (unchanged). 2 new regression tests in tests/test_ask_user_question.py (test_send_next_ask_question_message_uses_rendered_message, test_send_next_ask_question_message_no_thread) covering thread-aware and thread-less SendOptions #488
  • fix: /at-scheduled runs now stamp RunContext.trigger_source = "at:<token>" so the run footer shows ⏰ at:<token> provenance, mirroring the ⏰ cron:<id> and ⚡ webhook:<id> markers already added in #271 (rc4) and Tier 2/3 (rc5). Closes the gap noted in the 2026-04-25 Codex sweep comment on #271, where /at fires were the only trigger source whose footer was indistinguishable from a regular user-initiated run. at_scheduler.schedule_delayed_run now wraps the captured chat context (or a fresh RunContext if the chat is unmapped) with dataclasses.replace(context, trigger_source=f"at:{token}") after the token is generated; runner_bridge.handle_message’s existing icon-prefix tuple is extended from ("cron:",) to ("cron:", "at:") so the alarm-clock icon renders for both (semantically a one-shot delayed cron). record_run’s existing triggered=bool(context and context.trigger_source) gate also picks up /at runs in the /stats triggered/manual breakdown, no extra wiring needed. 1 new test in tests/test_at_command.py (test_handle_stamps_trigger_source_on_mapped_chat); the existing test_handle_captures_global_default_when_unmapped extended to assert the trigger_source-only RunContext path; the existing test_run_delayed_forwards_captured_context_and_engine updated since the captured context is no longer reference-equal to the original (it now carries the stamped trigger_source) #271
  • security: auto-approve scope review for Claude ControlRewindFilesRequest and ControlMcpMessageRequest (src/untether/runners/claude.py:_AUTO_APPROVE_TYPES). Both subtypes were verified safe under the present upstream Claude Code 2.1.x trust model: Untether is a transport pass-through that never inspects the mcp_message.message payload (a compromised MCP server is the inherent MCP threat model, not specific to auto-approve), and rewind_files is user-initiated upstream (the model cannot trigger it autonomously) and does not touch Untether’s per-session approval state (_PLAN_EXIT_APPROVED, _DISCUSS_APPROVED). Added a multi-paragraph safety-invariant comment near the auto-approve gate documenting the re-audit trigger (upstream semantic change to either subtype) plus 3 regression-lock tests in tests/test_claude_control.py::TestAutoApproveSafetyInvariant that fail loudly if the auto-approve path starts inspecting payloads. Audit memo: docs/audits/2026-04-27-380-auto-approve-scope-review.md #380
  • security: voice_transcription_api_key is now SecretStr (parity with bot_token from #196). The value is masked in repr()/str()/tracebacks and any accidental structlog serialisation. Access goes via .get_secret_value() at the sole transport boundary in telegram/loop.py:2208 before passing to the OpenAI SDK; everything in between (TelegramBridgeConfig.update_from, hot-reload) handles SecretStr | None end-to-end. Empty / whitespace-only configured values round-trip to None to preserve the prior NonEmptyStr | None contract #378
  • security: daily cost tracker no longer loses updates under concurrent calls. cost_tracker._daily_cost previously did an unguarded read-modify-write — two concurrent record_run_cost calls could both read (today, X), both write (today, X + cost), and lose one run’s cost. Under attack this defeats the per-day budget gate. Wrapped the RMW in a threading.Lock; get_daily_cost() also acquires the lock for snapshot consistency. Functions stay synchronous — the critical section is a single tuple assignment (sub-microsecond) and threading.Lock covers both async (cooperative) and threaded callers. New ThreadPoolExecutor-based fuzz test (16 workers × 200 calls) asserts atomicity #379
  • security: prompt content moved out of INFO logs. The runner.start log used to carry prompt=<first 100 chars>. Prompts can contain credentials, PII, or proprietary code; INFO logs are typically the most broadly-accessible tier. runner.start now keeps prompt_len and args only; a new runner.start_prompt event at DEBUG carries the preview when explicitly opted in #205
  • security: Claude runner override of runner.start no longer leaks prompt content at INFO. runners/claude.py:run_impl had its own duplicate runner.start call that was missed when the base runner was fixed for #205 — it kept emitting prompt=prompt[:100] + "…" for every Claude session. Five live runs during the v0.35.3 follow-up E2E pass confirmed it leaked the first ~100 chars of the Untether preamble at INFO; not user content in practice, but spec violation. The override now mirrors the base impl: prompt_len + args at INFO, runner.start_prompt preview at DEBUG. Argv redaction tightened too — redact_env_i_args strips env -i KEY=VAL pairs (#361 was already doing this for subprocess.spawn but not for runner.start), and legacy-mode (no permission_mode) argv has the trailing -- <prompt> collapsed to -- <prompt redacted> so prompt content never reaches INFO under any code path. 2 new regression tests in tests/test_claude_runner.py (test_runner_start_does_not_log_prompt_at_info covering control-channel mode, test_runner_start_redacts_legacy_mode_prompt_in_args covering legacy -p mode) #478
  • security: AMP runner default flipped — dangerously_allow_all is now False by default, requiring an explicit [amp] dangerously_allow_all = true to opt in. Previously, AMP runs ran with no permission controls unless the operator went out of their way to disable them — backwards from how every other engine ships. Untether’s own permission layer remains the primary control; AMP’s permission system is a defence-in-depth that’s now on by default #206
  • security: Pi session directories are created with explicit 0o700 mode and any pre-existing dir gets chmod’d to 0o700 so other users on shared hosts can’t read Pi session JSONL #207
  • security: _sanitise_stderr regex extended to cover macOS (/Users/<user>/, /private/var/...), container roots (/app/, /workspace/), and other absolute paths beyond /home/<user>/ (/var/, /tmp/, /opt/, /srv/, /etc/, /usr/local/, /root/). Path:line markers (:42) survive sanitisation so stack traces remain useful #208
  • security: /file get no longer has a TOCTOU window between stat() and read_bytes(). The download path now opens the file once and reads at most max_download_bytes + 1 bytes inside an anyio.to_thread.run_sync worker so a file that grows mid-read can’t slip past the cap. Also keeps the event loop unblocked on slow disks #211
  • security: structlog token redaction now covers OpenAI project keys (sk-proj-...). The generic sk-... regex didn’t match the project-key char set (underscore + hyphen). Added a dedicated OPENAI_PROJECT_KEY_RE applied before the generic pattern #213
  • security: Pygments bumped 2.19.2 → 2.20.0 to clear CVE-2026-4539 (ReDoS in AdlLexer). Transitive dep — uv lock --upgrade-package pygments plus an --ignore-vuln CVE-2026-4539 removal in CI’s pip-audit step #402
  • security(secrets): placeholder bot-token strings replaced with <BOT_ID>:<BOT_TOKEN> in user-facing onboarding text and tutorials (telegram/onboarding.py, docs/tutorials/install.md, llms-full.txt) so the GitHub secret-scanner stops flagging the format. Test fixtures kept as-is — operator dismisses those alerts as “used in tests” #403
  • fix: Claude post-result idle no longer emits stall noise + adds a clean closing message. After Claude emits a result event, _post_result_idle_watchdog (#333) keeps stdin open for [watchdog] post_result_idle_timeout (default 600 s) so multi-turn sessions don’t pay a respawn cost; previously the existing stall monitor would still tick during that window and surface “no progress for 10 min” warnings — pure noise to the user, since the watchdog was the legitimate owner of the silence. Now (a) progress_edits.stall_post_result_suppressed fires while the watchdog runs, (b) the auto-cancel _STALL_MAX_WARNINGS arm is also gated (so a session about to gracefully close cannot be SIGTERM’d), and (c) when the watchdog actually closes stdin it stamps ClaudeStreamState.post_result_closed_at + post_result_idle_minutes, which the bridge’s heartbeat tick polls and uses to fire one (and only one) Telegram message: ✓ turn complete · session closed after Nm idle — gives the user a clean end-state signal instead of inferring from silence. Idempotency is enforced via a post_result_closing_sent flag; structlog WARN events are unchanged so untether-issue-watcher continues to see them. Genuinely-frozen post-result sessions (frozen-ring escalation) still warn — the suppression is precisely scoped, not a blanket disable. 4 new tests in tests/test_exec_bridge.py (test_stall_post_result_suppressed_when_result_armed, test_stall_post_result_blocks_auto_cancel, test_stall_post_result_overridden_by_frozen_ring, test_post_result_closing_message_sent, test_post_result_closing_message_idempotent) #470
  • fix: ScheduleWakeup deadline was always 0.0 in production. _register_background_handle in runners/claude.py read delay_ms/timeout_ms from the tool input, but the actual Claude Code stream-json schema (per #289 and the upstream claude-agent-sdk-python reference) emits delaySeconds (range 60–3600). live_wakeups[tool_id] membership-only checks (#346 wedge detector) still worked because both branches populated the dict; deadlines fell to 0.0, breaking countdown rendering. Fixed by reading delaySeconds first and keeping the delay_ms/timeout_ms fallbacks for backward compat with existing test fixtures. Necessary precursor to #481’s countdown rendering. 2 new regression tests in tests/test_claude_runner.py (test_schedule_wakeup_reads_delaySeconds_field, test_schedule_wakeup_delay_ms_fallback_still_works) #481
  • fix: rc11 — claude.schemas.StreamToolResultBlock.content and StreamAdvisorToolResultBlock.content accept a single dict (e.g. {"type": "text", "text": "..."}) in addition to the documented str / list[dict] / null shapes. Claude Code emits this dict-form occasionally (14 occurrences observed in 24 h of staging logs prior to the fix) — msgspec was rejecting the entire JSONL line with jsonl.msgspec.invalid and silently dropping all tool tracking for that turn (no progress action, no pending_actions entry, no verbose-mode rendering). _normalize_tool_result already handled the dict shape, so no runner code change needed. 2 new regression tests in tests/test_claude_schema.py cover both block types with dict content. Verified live against @untether_dev_bot — 0 msgspec errors after restart #501
  • fix: rc11 — config.loaded log demoted from INFO → DEBUG. load_settings_if_exists() is called per-helper (footer, watchdog, progress, auto_continue, preamble, budget) on every handle_message — fires 4–6 times per processed message by design (#269 hot-reload). At INFO this floods structlog at ~80 events per session and triggers monitor config_loaded_burst alerts even though the underlying behaviour is correct (the 2026-05-09 audit caught 183 reloads in 3 s on aushistory). Demoting suppresses the noise without changing reload semantics. The proper fix — caching settings within handle_message to do one parse instead of N — is deferred to v0.35.4 (#506) since it touches helper signatures and was out of bug-fix-rc11 scope #498
  • fix: rc14 — session.summary now records last_event_type=result (not control_request or control_response) on ok=True completed Claude runs. Root cause at src/untether/runner.py:810stream.last_event_type was written unconditionally from the raw JSONL type field, including permission-flow control-channel traffic (Claude → Untether control_request and the parent-initiated mcp_status control_response on stdout from #365). Fix: skip the update when etype is in {"control_request", "control_response"} so the field reflects the last stream event. The auto-continue gate at runner_bridge.py:282 still sees the raw "user" type because non-control events are unchanged. recent_events deque still records control entries — useful for the stall-diagnostic timeline that surfaced the bug in the audit. Verified via @untether_dev_bot: plan-mode prompt → ExitPlanMode → approve → completion produces session.summary ... last_event_type=result ok=True (was control_request / control_response on rc13). 1 new regression test in tests/test_exec_runner.py #502

docs

  • docs: new docs/faq/faq.md (originally landed as docs/faq/index.md in rc6; renamed in rc9 #483 so the help-centre URL is /help/untether/faq/ rather than /help/untether/index/) with 12 H2 question-shaped FAQs covering install, supported engines, API keys, data flow, interactive approvals, crash recovery, cost budgets, voice notes, update, uninstall, and support channels. Sourced from README + real common-channel topics; no placeholders. Companion to the marketing-site FAQPage Schema.org pipeline shipped on feature/help-seo-geo-items-1-4 in littlebearapps/littlebearapps.com — the docs-sync mapping (scripts/docs-sync.config.ts) lands separately on the marketing-site repo. Once both PRs merge, https://untether.littlebearapps.com/help/untether/faq/ will surface a <script type="application/ld+json"> FAQPage block with all 12 Q/A pairs for AI-citation surface (ChatGPT, Perplexity, Google AI Overviews) and SERP rich-snippet eligibility #477
  • docs: new ## Loop mode section in docs/how-to/schedule-tasks.md explaining the observe-and-fire-on-resume architecture, runaway caps, and per-fire cost ranges (cache-warm vs cold). Cost-budgets how-to gets a Loop-mode + budgets warning callout. Troubleshooting how-to gets a “Loop didn’t fire / loop fired too many times” symptom table. FAQ gets a new H2 “Does /loop work via Untether?” (verifies against .claude/rules/help-faq.md: 13 H2s, all question-shaped). Config reference gets a new [loop] section between [watchdog] and [auto_continue] with the explicit “cost limits are NOT in [loop]” pointer to [cost_budget] #289
  • docs: [cost_budget] reference doc in docs/reference/config.md gains a Note callout that cumulative session cost is not capped — sessions can stack many runs via /continue and rack up many multiples of max_cost_per_run. Cites the rc13 audit’s $100+ session on legal-librarian-local (5 sub-runs, each individually under any reasonable per-run cap), recommends max_cost_per_day as the cross-session ceiling, and explicitly notes max_cost_per_session is not provided (file a feature request if needed). Closes #517 as docs-only per the rc13 audit handover plan and addresses the #491 / #492 / #493 / #504 cost-outlier monitor family (all closed against this entry) #522
  • ops: out-of-repo monitor configs (~/.config/monitor/untether-fleet.toml and ~/.config/monitor/untether-mac.toml) — Mac substrate switched from log show --predicate "process == \"untether\"" (which returned zero lines: Mac Untether writes a file log, not Apple Unified Logging, and zsh’s log builtin shadowed /usr/bin/log) to a BSD-date + awk file-tail against ~/.untether/untether.log. End-to-end smoke test: lba-1=171 nsd=24 channelo=27 mac=17 (Mac was previously 0 — the fleet meta-target was silently a 3-host audit). Configs are LOCAL (not in the Untether repo); Plan A comment in untether-mac.toml updated to reference the correct path so future Plan B↔A swaps don’t pick the wrong file #530

tests

  • tests: tests/test_trigger_auth.py::test_malformed_bearer_header now constructs the "Basic ..." auth header at runtime instead of using the literal "Basic dXNlcjpwYXNz" string. The literal triggered GitHub’s secret-scanning generic-Basic-auth detector even though the value was a unit-test fixture asserting verify_auth rejects Basic auth (Untether webhooks only accept Bearer + HMAC). The corresponding alert is dismissed in the GitHub UI as “Used in tests / false positive” #404

v0.35.2 (2026-04-20)

changes

  • feat: new /health command — live system + triggers + cost snapshot (v1). Consolidates RAM / swap (/proc/meminfo), the Untether process self-diagnostic (PID, RSS, FDs, children — reuses proc_diag.collect_proc_diag), trigger counts (cron/webhook IDs via TriggerManager), today’s API cost (cost_tracker.get_daily_cost), and uptime (reuses /ping’s _STARTED_AT so there’s only one monotonic counter) into a compact Telegram HTML message. Each section degrades gracefully — unavailable data sources (non-Linux, no trigger_manager, no cost tracker) show a fallback or omit rather than erroring out. New file src/untether/telegram/commands/health.py (~180 LOC), 12 unit tests in tests/test_health_command.py, entry point registered in pyproject.toml. v1 scope only — v2 extras noted in the issue (/health --subtree tree walk, /health --costs per-project breakdown, workerd group detection, colour-coded warning markers) are deferred to follow-ups #348
  • fix: wedge detector (#322) no longer fires during legitimate background work. Claude Code v2.1.72+ primitives (Monitor, Bash run_in_background=true, Agent run_in_background=true, ScheduleWakeup, RemoteTrigger) emit result and then park the subprocess waiting for the primitive to complete — _detect_stuck_after_tool_result in src/untether/runner_bridge.py previously couldn’t distinguish that from a real hang (same last_event_type=assistant, same frozen ring buffer, same CPU-active state). Now uses the tracking infrastructure from #347: duck-types against stream.engine_state.has_live_background_work() and returns False (suppressed) when any primitive’s deadline is still in the future. Engines that don’t expose an engine_state (Codex, OpenCode, Pi, Gemini, AMP) see no behaviour change — the check no-ops. New JsonlStreamState.engine_state field (base class) carries the reference; ClaudeRunner.run_impl populates it after creating both states. New progress_edits.stuck_after_tool_result.suppressed structlog INFO entry fires when the gate kicks in, so staging greps can tell “we skipped detection because Monitor was armed” apart from “detection didn’t trigger”. Four new tests in tests/test_exec_bridge.py::TestStuckAfterToolResultDetector cover the monitor-armed / monitor-expired / engine_state-absent / bg_bash-active cases #346
  • feat: per-session tracking of Claude Code’s long-running background primitives — Monitor, Bash run_in_background=true, Agent run_in_background=true, ScheduleWakeup, RemoteTrigger (v1 infrastructure). ClaudeStreamState in src/untether/runners/claude.py gains five new collections (live_monitors: dict[str, float], live_bg_bashes: set[str], live_bg_agents: set[str], live_wakeups: dict[str, float], live_remote_triggers: set[str]) keyed by tool_use_id. New _register_background_handle() called from the StreamToolUseBlock branch of translate_claude_event parses the tool name + input payload (extracting timeout_ms / delay_ms for deadline tracking); new _clear_background_handle() called from the tool_result branch removes the entry on explicit completion. New public helpers has_live_background_work() (gates #346’s wedge detector) and background_task_summary() (future footer rendering) complete the API surface. This PR is purely telemetry — no footer rendering, no /background command, no control-channel hooks — those are v2 and will be filed as follow-ups once meta-threading through ProgressTracker is confirmed safe for the other 5 engines. 11 new unit tests cover tool_use parsing for each of the 5 primitives, tool_result clearing, the has_live_background_work deadline-aware gate, and background_task_summary pluralisation #347
  • feat: pre-spawn RAM guard refuses or warns when spawning a new engine subprocess on a near-OOM host. When a parallel heavy run (e.g. vitest-pool-workers with 100+ workerd children) has already consumed most available RAM, the guard prevents doomed Node startup failures that would otherwise leak memory to other chats via OOM-kill side effects. New mem_available_kb() helper in src/untether/utils/proc_diag.py reads /proc/meminfo without caching; new WatchdogSettings.prespawn_ram_warn_mb (default 2000) and prespawn_ram_block_mb (default 500) in src/untether/settings.py plus a model_validator that rejects configurations where warn <= block (would make the warn tier unreachable); either tier set to 0 disables that tier and 0 / 0 disables the guard entirely. New JsonlSubprocessRunner._check_prespawn_ram_guard() in src/untether/runner.py runs BEFORE manage_subprocess so a blocked spawn costs nothing — on BLOCK, yields CompletedEvent(ok=False, error="🛑 Insufficient RAM …") and returns early without forking; on WARN, logs subprocess.prespawn.ram_warning structured entry. Eight new unit tests cover the meminfo parser, the validator ordering rule, and the runner guard’s ALLOW/WARN/BLOCK/DISABLED branches. Works downstream of OOMScoreAdjust=-100 (#275’s Layer 1) by preventing the OOM scenario from arising in the first place #350
  • feat: surface Claude rate_limit_event as a visible progress note instead of silent inactivity. When Anthropic throttles the API, Claude Code emits a rate_limit_event JSONL message; the runner previously returned an empty list for this event kind so the user saw no feedback on Telegram — the session appeared to hang or, if they hit /cancel, disappear without a cost footer. translate_claude_event in src/untether/runners/claude.py now emits a note-kind ActionEvent pair (started + completed) rendered as ⏳ Rate limited — retrying in Xs, with retry_after_ms, tokens_remaining, and requests_remaining exposed via the action detail for downstream consumers. ClaudeStreamState gains rate_limit_total_s + rate_limit_count fields accruing across the session for future cost-footer annotation and /stats surfacing (deferred to a v2 follow-up). A new claude.rate_limit_event structlog INFO line logs retry_after_s, count, and cumulative_s so staging greps can triage rate-limit-driven user reports. The existing test_rate_limit_event_returns_empty (locked in the old silent behaviour) is re-scoped to test_rate_limit_event_decodes_correctly (schema tag only); three new tests cover visible-render, multi-throttle accumulation, and missing-retry-hint fallback #349
  • feat: restart-required vs hot-reloadable settings are now structurally surfaced. TelegramTransportSettings.RESTART_REQUIRED_FIELDS (new ClassVar[frozenset[str]] in src/untether/settings.py) is the single source of truth for which transport fields need a process restart (bot_token, chat_id, session_mode, topics, message_overflow); telegram/loop.py:handle_reload() now consumes that ClassVar instead of the previously-inlined RESTART_ONLY_KEYS set. When a restart-required key changes during hot-reload, the bot now posts a 🔄 notice (“Setting X changed — restart required to take effect; run: systemctl --user restart untether”) in addition to the existing config.reload.transport_config_changed structlog warning, so the operator doesn’t silently run on stale values. docs/reference/config.md gains a comprehensive “Hot-reload vs restart-required” section and per-field 🔄 markers in the transport / topics tables #318
    • follow-up: _notify_restart_required broadcasts to every runtime.project_chat_ids() plus any allowed_user_ids admin DM instead of a single cfg.chat_id send — in project-routed deployments cfg.chat_id is the placeholder sentinel and every send failed with chat not found, so the user-visible warning never arrived. Per-chat failures are logged via config.reload.restart_notify.failed and skipped; config.reload.restart_notify.sent emits targets + sent_count for observability. Falls back to cfg.chat_id only when no routed targets exist.
  • feat: [[triggers.crons]] now accepts an optional permission_mode field (default | plan | auto | acceptEdits | bypassPermissions) that overrides the chat / engine default for that cron’s run only. Crons firing into plan-mode chats can now declare themselves autonomous via permission_mode = "auto" without flipping the whole chat to auto. Precedence: cron permission_mode > per-chat /planmode > engine config default. Claude-only for this release; Codex + Gemini completion is tracked in #331, and the broader all-engines + webhooks extension in #332 (v0.35.5). New VALID_PERMISSION_MODES_BY_ENGINE dict in runners/run_options.py lets the CronConfig validator reject typos for engines with known value sets while staying forward-compatible for engines whose permission wiring is pending. A new trigger.cron.permission_mode_override structlog INFO entry fires when the override actually changes the resolved value, for staging observability. #330
  • callback-answer instrumentation for inline-keyboard presses — every answerCallbackQuery now emits a callback.answered INFO event with latency_ms (HTTP round-trip), total_ms (since dispatcher entry), early=true|false, and has_toast. Lets staging greps distinguish “we were fast, Telegram was slow” from “we were slow” when BotResponseTimeoutError is reported client-side. Investigation of the existing answer_early path confirmed it already fires before any backend.handle() work; added a regression test (test_early_answer_fires_before_slow_handle) locking the ordering invariant in so future refactors can’t reintroduce the timeout window. Telegram-transport reference docs gained a callback-answering section with the structured-log schema and triage guidance #247
  • feat: xhigh effort level added for Claude Code (Opus 4.7, Claude Code CLI v2.1.114+). _ENGINE_REASONING_LEVELS["claude"] in src/untether/telegram/engine_overrides.py gains xhigh between high and max. Button scaffolding in /config > Reasoning, the "xhi" action-key, and descriptive help text already existed from #272’s Codex work, so this is a single-tuple edit plus docs/test refresh. test_reasoning_shows_claude_levels updated to assert config:rs:xhi is now present for Claude; docs/reference/runners/claude/runner.md --effort list now reads low/medium/high/xhigh/max #351

fixes

  • security: Claude and Pi engine subprocesses no longer inherit the parent’s full environment — only allowlisted variables (basic OS essentials, AI/cloud provider keys, Claude/MCP namespaces, Node/Python/UV/NPM runtime vars) pass through via the new utils/env_policy.filtered_env() helper. Random third-party tokens that happen to live in the parent env (AWS, Stripe, DigitalOcean, DATABASE_URL, personal app tokens, etc.) are no longer available to engine subprocesses or their MCP servers — reduces the blast radius of any tool-call or MCP that exfiltrates process env. PR #323’s four setdefault reinforcements for the stuck-after-tool_result watchdog are preserved on top of the filtered env. Other engines (Codex, Gemini, OpenCode, AMP) keep the default inherit-everything behaviour for this release; extending to them is tracked as part of #332 (v0.35.5). Adding a new engine or MCP that relies on an unfamiliar variable is documented at the top of utils/env_policy.py #198
  • security: CI matrix values (matrix.command, matrix.sync_args) now pass through env: instead of direct ${{ }} interpolation in run: blocks, eliminating a theoretical shell-injection vector should matrix values ever become dynamic (e.g. from PR labels) #195
  • security: bot_token is now pydantic.SecretStr in TelegramTransportSettings — masks the value in repr(), str(), tracebacks, and any accidental structlog serialisation. Raw value is unwrapped via .get_secret_value() at the transport boundary (require_telegram, backend.lock_token/build_and_run, cli/doctor, cli/onboarding_cmd). A field_validator preserves the pre-change NonEmptyStr contract (whitespace-only tokens still rejected, since SecretStr bypasses str_strip_whitespace) #196
  • security: _HANDLED_REQUESTS in runners/claude.py switched from a set cleared wholesale at 100 entries to an LRU OrderedDict (max 200, oldest-first eviction) — closes the small window where a duplicate Telegram callback delivered just after a .clear() would be misclassified as “request not found” rather than “duplicate” #197
  • security: Codex auth subprocess output is now html.escape()’d before being wrapped in <pre> in the HTML-mode Telegram reply — prevents a crafted error message from injecting Telegram entities (<b>, <a>, etc) into the rendered response #199
  • security: voice transcription error paths (telegram/voice.py) and command-dispatch error paths (telegram/commands/dispatch.py) now send sanitised text via shared utils/error_display.user_safe_error() — strips URLs and absolute paths, caps length, and falls back when sanitised text is empty. Full exception detail still goes to structlog #200 #201
  • security: removed global bandit skips for B603/B607 in pyproject.toml; the three remaining subprocess sites (telegram/backend.py:_detect_cli_version, telegram/commands/usage.py macOS Keychain lookup, utils/git.py:_run_git) are annotated inline with # nosec + per-site justification — CI now flags any NEW subprocess call site by default #202
  • security: _EPHEMERAL_MSGS and _OUTLINE_REGISTRY in runner_bridge.py gain companion timestamp maps and a sweep_stale_registries() helper that prunes entries older than 1 hour. Sweep piggy-backs on ProgressEdits._stall_monitor’s existing 60-second tick — handles runs that crash or exit abnormally without firing the normal cleanup path #203
  • security: telegram/client_api.py:download_file validates file_path (from Telegram getFile) against ://, .., and leading / before URL construction — a tampered or spoofed getFile response that returned an attacker-controlled URL as file_path could otherwise redirect the subsequent HTTP GET away from api.telegram.org #204
  • engine subprocess cleanup now walks the process tree and signals descendants in separate process groups — previously os.killpg(proc.pid, SIGTERM) only reached the parent’s direct pgroup, so grandchildren spawned with fresh sessions (Node’s child_process.spawn() pattern, used by workerd via @cloudflare/vitest-pool-workers) survived a SIGTERM’d Claude Code session. On lba-1 this orphaned 316 workerd processes consuming 37 GB of RAM after 6 cascading Claude Code signal deaths. _signal_process now snapshots descendants via proc_diag.find_descendants() before killpg (so /proc/<pid>/task/*/children is still readable), runs the existing pgroup kill, then os.kill(pid, sig) on each captured PID best-effort (swallowing ProcessLookupError/PermissionError). SIGKILL escalation walks the tree again. Graceful fallback to legacy pgroup-only behaviour on non-Linux hosts or /proc read errors. Related upstream: anthropics/claude-code#43944, cloudflare/workers-sdk#8837 #275
    • proc_diag._find_descendants renamed to public find_descendants (private alias kept for back-compat with existing test imports)
  • webhook server now degrades gracefully when it can’t bind its port — previously a port conflict (e.g. another process on the default 9876) crashed the entire bot (polling, commands, crons included) via an uncaught OSError propagating through the anyio task group, triggering a systemd restart loop. run_webhook_server now catches OSError from TCPSite.start(), logs a structured triggers.server.bind_failed event with host/port/hint/fix fields, and returns normally so the rest of the bot stays up #320
  • cost footer accuracy and engine cost parity — 60-second TTL cache on the Claude subscription-usage fetch (utils/usage_cache.py) with stale-while-error fallback smooths transient 429s and rate-limit windows; a one-shot claude_usage.schema_mismatch warning logs missing expected fields so upstream API drift is noticed instead of silently dropping the footer; _format_run_cost now renders zero-turn completions (if turns is not None: instead of if turns:); Gemini runner extracts stats.total_cost_usd into usage when present; AMP AmpResult schema gains a total_cost_usd field and the runner surfaces it through the usage dict when AMP emits one; added an OpenCode regression test locking in that token counts still render when cost is zero (free-tier runs) #316
    • persisting the daily cost accumulator across restarts was part of the issue’s “nice-to-have” scope and is deferred to a follow-up to keep this change focused on accuracy + parity
  • run_once = true crons now persist their fired state to run_once_fired.json (sibling of untether.toml) — no longer re-fire on config hot-reload or process restart. Previously the TOML entry re-entered the active list on every reload because remove_cron() was in-memory only; editing any unrelated config setting would cause every already-fired one-shot to run again. TriggerManager now takes an optional config_path argument, loads the fired set on init, persists on remove_cron(), and auto-cleans fired-state entries whose cron id no longer appears in the TOML so ids can be safely reused. Related: #269 (hot-reload), #294 (master pause toggle) #317
  • Pi footer now shows the model name when the user relies on the default config model (no /model set override and no pi.model in untether.toml). The Pi CLI’s message_end event carries "model": "..." alongside provider/usage; the runner now extracts this and emits a supplementary StartedEvent once per session so ProgressTracker.note_event merges it into the tracker meta. Priority preserved: run_options.model > self.model > JSONL fallback. Completes the work begun in #235 #225
    • follow-up: JsonlSubprocessRunner.handle_started_event was silently dropping the supplementary StartedEvent as a same-session duplicate, so the extracted model never reached ProgressTracker.note_event. The filter now emits duplicates through when the event carries meta; true duplicates (no meta) are still dropped. Unit tests in tests/test_runner_utils.py previously passed because they called translate_pi_event directly, bypassing the base-runner filter — added a regression test covering the duplicate-with-meta path.
  • detect and recover from Claude Code hanging after an MCP tool_result via stream-json / sdk-cli — root cause is upstream claude-code#39700 / #41086 combined with the undici idle-body timeout in mcp-remote (geelen/mcp-remote#226, #107) talking to Cloudflare’s remote MCP servers. The symptom “MCP tool may be hung: cloudflare-observability” was misleading — the MCP had already returned its result; the engine was silent after ingesting it #322
    • new engine-agnostic _classify_jsonl_event() in runner.py recognises tool_result-equivalent events across all six engines (Claude, Codex, OpenCode, Pi, Gemini, AMP); JsonlStreamState gains a last_tool_result_at latch cleared only on an assistant-turn event
    • new ProgressEdits._detect_stuck_after_tool_result() fires when the latch has been set for ≥ stuck_after_tool_result_timeout (default 300 s, matches undici’s 5-minute idle-body timeout) with cpu_active=True, frozen ring buffer ≥ 3, and no pending approval — ExitPlanMode-, Bash-, and subagent-safe
    • tiered recovery in ProgressEdits._handle_stuck_after_tool_result(): Tier 1 logs progress_edits.stuck_after_tool_result with diag; Tier 2 SIGTERMs MCP-adapter children whose /proc/<pid>/cmdline contains mcp-remote or @modelcontextprotocol (forces the SSE reader to error out and unblocks the parent engine); Tier 3 cancels via cancel_event after stuck_after_tool_result_recovery_delay (default 60 s) with a specific Telegram notice
    • runners/claude.py:env() now sets CLAUDE_ENABLE_STREAM_WATCHDOG=1, CLAUDE_STREAM_IDLE_TIMEOUT_MS=60000, MCP_TOOL_TIMEOUT=120000, and MAX_MCP_OUTPUT_TOKENS=12000 via setdefault — reduces incidence while the detector is the safety net; user overrides via shell env or ~/.claude/settings.json still win
    • four new [watchdog] config fields: detect_stuck_after_tool_result (default false for this release, will default true once validated), stuck_after_tool_result_timeout, stuck_after_tool_result_recovery_enabled, stuck_after_tool_result_recovery_delay
    • utils/proc_diag.py:read_cmdline() helper for identifying adapter children; 17 new tests across engine-matrix classifier, detector gates, and tier-1/2/3 state machine
  • fix: CLAUDE_STREAM_IDLE_TIMEOUT_MS default raised from 60000ms to 300000ms (5 min). PR #323’s original 60s reinforcement of #322 proved too aggressive for opus · max reasoning — legitimate chain-of-thought expansion produces 60–120s SSE-idle windows between output deltas, tripping the upstream Claude CLI stream watchdog and aborting runs with “API Error: Stream idle timeout - partial response received” (observed on staging mid-reasoning with peak_idle_seconds=91.4). 300000ms matches the undici idle-body timeout that motivated #322 and Untether’s own stuck_after_tool_result_timeout default, so the upstream CLI watchdog and Untether’s detector now fire on compatible timescales. User-provided CLAUDE_STREAM_IDLE_TIMEOUT_MS still wins via setdefault semantics. Two new tests in tests/test_claude_runner.py lock in the new default and the user-override path #342
  • security: Claude exec is now wrapped with env -i KEY=VAL … so the resolved environment at exec time is exactly the allowlist from utils/env_policy.filtered_env() — even when an upstream rc-file source, PAM /etc/environment injection, or wrapper script would otherwise re-introduce host vars after the parent’s subprocess.spawn(env=…) is honoured. v0.35.2rc3 integration testing on @untether_dev_bot proved the in-process filter holds (/proc/<untether-pid>/environ clean) but a real BWS_ACCESS_TOKEN still reached Claude’s Bash-tool subprocess, undermining the headline #198 promise. New wrap_with_env_i() helper in utils/subprocess.py; Claude run_impl swaps the resolved cmd for the wrap and passes env=None to the subprocess so we don’t double-set. Pi runner left unchanged — Pi was already clean per the test report. Companion runtime audit (also new this rc): a one-shot /proc/<claude_pid>/environ sample on first system.init emits a claude.env_audit.leaked_var structlog WARNING when any non-allowlisted name is observed; gated by new [security] env_audit = true (default true). Reuses utils/env_policy.is_allowed (promoted from private _is_allowed, with a back-compat alias) so the allowlist remains a single source of truth. New utils/env_audit.py (~80 LOC); 9 unit tests in tests/test_env_audit.py plus 6 in tests/test_claude_runner.py covering the wrap helper, the audit gate, dedup-per-session, and the disabled-via-settings path #361 #198
  • fix: /at <duration> <prompt> now respects the chat’s project mapping and engine — previously the delayed run fired on the global default engine with no cwd, ignoring default_engine = "pi" (or similar) on the project bound to the chat. AtCommand.handle() now snapshots RunContext (via runtime.default_context_for_chat(chat_id)) and the resolved engine (via runtime.resolve_engine(...)) at schedule time and threads both through _PendingAt to the fire-time _RUN_JOB call, mirroring TriggerDispatcher.dispatch_cron’s freeze-at-dispatch behaviour. cwd is resolved correctly downstream because _run_engine derives it from the forwarded context. Re-routing the chat between /at and fire keeps the original mapping; cancel via /cancel and re-issue to pick up changes. New _FakeRuntime test fixture in tests/test_at_command.py plus three new tests covering project-bound capture, unmapped-chat global-default capture, and fire-time forwarding #362
  • feat: MCP catalog observability (P0#2 of #365). Claude Code’s system.init event ships each configured MCP server as {"name": "...", "status": "connected" | "pending" | "error" | "failed"}; Untether now logs a structured catalog_staleness.detected WARNING once per (session, server, status) tuple whenever any server reports a non-connected status at init time. Gated by new WatchdogSettings.detect_catalog_staleness (default true, observability only — no kill/recovery action). New _capture_mcp_catalog() helper in src/untether/runners/claude.py snapshots the raw list onto ClaudeStreamState.initial_mcp_servers for future comparison work and dedups via ClaudeStreamState.catalog_staleness_logged: set[tuple[str, str, str]]. Companion experimental knob WatchdogSettings.notify_catalog_refresh (default false, opt-in) queues an mcp_status control_request on stdin after each tool_result — the parent→CLI primitive documented in Anthropic’s claude-agent-sdk-python (get_mcp_status() / reconnect_mcp_server() / toggle_mcp_server()). Drain happens in ClaudeRunner._drain_catalog_refresh() alongside existing _drain_auto_approve / _drain_auto_deny, with catalog.refresh_sent INFO on success and catalog.refresh_failed WARN/ERROR on write errors. The upstream MCP notifications/tools/list_changed message hinted at in the issue is server→client only per the MCP spec and therefore cannot be injected from outside; mcp_status is the closest documented parent-side primitive. Request IDs use the ut_catalog_refresh_<session_id>_<seq> namespace so they can’t collide with Claude Code’s own req_* IDs. Ten new tests in tests/test_claude_runner.py cover: all-connected no-op, non-connected warning emission, per-session dedup, disabled-setting suppression, queue-on-tool_result (enabled + disabled paths), no-resume defensive no-op, drain serialisation, empty-queue no-op, ClosedResourceError recovery, and new_state propagation from WatchdogSettings. No behaviour change for non-Claude engines #365
  • fix: the plan-bypass set populated by an approved ExitPlanMode (#283) is now also populated by a plain “Approve” on Edit/Write/Bash in plan mode. Resumed sessions where Claude skipped ExitPlanMode and went straight into Edits previously re-prompted the user once per tool call — observed on @hetz_lba1_bot v0.35.2rc1 as a 9-prompt repro for a single multi-file fix turn (one Edit per click, ~7 min wait between approvals, workflow effectively broken under --permission-mode plan). _DIFF_PREVIEW_TOOLS is now module-scoped in src/untether/runners/claude.py; write_control_response adds the session to _PLAN_EXIT_APPROVED whenever the approved tool is ExitPlanMode or in _DIFF_PREVIEW_TOOLS, so the first approval in a turn unlocks the rest of that session’s diff_preview tools. Six new parametrized tests in tests/test_claude_control.py cover Edit/Write/Bash/ExitPlanMode population, the deny-doesn’t-populate negative, and the non-diff-tool no-op. Verified end-to-end on @untether_dev_bot. Follow-up #370 will migrate this to a parent-initiated set_permission_mode control request once the upstream primitive is wired #369

docs

  • document [triggers.server] port-conflict troubleshooting in docs/reference/triggers/triggers.md with ss -tlnp diagnosis step and the port = <N> remediation #320

v0.35.1 (2026-04-15)

fixes

  • diff preview approval gate no longer blocks edits after a plan is approved — the _discuss_approved flag now short-circuits diff preview as well as ExitPlanMode, so once the user approves a plan outline the next Edit/Write runs without a second approval prompt #283

  • scripts/healthcheck.sh exits prematurely under set -epass()/fail() used ((var++)) which returns the pre-increment value, tripping set -e on the first call so only the first check ever ran and the script always exited 1. Also, the error-log count piped journalctl through grep -c ., which counted -- No entries -- meta lines as matches, producing false-positive log-error counts on clean systems. Now uses explicit var=$((var+1)) assignment and filters meta lines with grep -vc '^-- ' #302

  • fix multipart webhooks returning HTTP 500 — _process_webhook pre-read the request body for size/auth/rate-limit checks, leaving the stream empty when _parse_multipart called request.multipart(). Now the multipart reader is constructed from the cached raw body, so multipart uploads work end-to-end; also short-circuits the post-parse raw-body write so the MIME envelope isn’t duplicated at file_path alongside the extracted file at file_destination #280

  • fix webhook rate limiter never returning 429 — _process_webhook awaited the downstream dispatch (Telegram outbox send, http_forward network call, etc.) before returning 202, which capped request throughput at the dispatch rate (~1/sec for private Telegram chats) and meant the TokenBucketLimiter never saw a real burst. Dispatch is now fire-and-forget with exception logging, so the rate limiter drains the bucket correctly and a burst of 80 requests against rate_limit = 60 now yields 60 × 202 + 20 × 429 #281

  • security: validate callback query sender in group chats — reject button presses from unauthorised users; prevents malicious group members from approving/denying other users’ tool requests #192

    • also validate sender on cancel button callback — the cancel handler was routed directly, bypassing the dispatch validation
  • security: escape release tag name in notify-website CI workflow — use jq for proper JSON encoding instead of direct interpolation, preventing JSON injection from crafted tag names #193

  • security: sanitise flag-like prompts in Gemini and AMP runners — prompts starting with - are space-prefixed to prevent CLI flag injection; moved sanitize_prompt() to base runner class for all engines #194

  • security: redact bot token from structured log URLs — _redact_event_dict now strips bot tokens embedded in Telegram API endpoint strings, preventing credential leakage to log files and aggregation systems #190

  • security: cap JSONL line buffer at 10 MB — unbounded readline() on engine stdout could consume all available memory if an engine emitted a single very long line (e.g. base64 image in a tool result); now truncates and logs a warning #191

  • reduce stall warning false positives during Agent subagent work — tree CPU tracking across process descendants, child-aware 15 min threshold when child processes or elevated TCP detected, early diagnostic collection for CPU baseline, total stall warning counter that persists through recovery, improved “Waiting for child processes” notification messages #264

  • /ping uptime now resets on service restart — previously the module-level start time was cached across /restart commands; now reset_uptime() is called on each service start #234

  • add 38 missing structlog calls across 13 files — comprehensive logging audit covering auth verification, rate limiting, SSRF validation, codex runner lifecycle, topic state mutations, CLI error paths, and config validation in all engine runners #299

  • systemd: stop Untether being the preferred OOM victim — systemd user services inherit OOMScoreAdjust=200 and OOMPolicy=stop defaults, which made Untether’s engine subprocesses preferred earlyoom/kernel OOM killer targets ahead of CLI claude (oom_score_adj=0) and orphaned grandchildren actually consuming the RAM. contrib/untether.service now sets OOMScoreAdjust=-100 (documents intent; the kernel clamps to the parent baseline for unprivileged users, typically 100) and OOMPolicy=continue (a single OOM-killed child no longer tears down the whole unit cgroup, which previously broke every live chat at once). Docs in docs/reference/dev-instance.md updated. Existing installs need to copy the unit file and systemctl --user daemon-reload; staging picks up the change on the next scripts/staging.sh install cycle #275

changes

  • timezone support for cron triggers — cron schedules can now be evaluated in a specific timezone instead of the server’s system time (usually UTC) #270

    • per-cron timezone field with IANA timezone names (e.g. "Australia/Melbourne")
    • global default_timezone in [triggers] — per-cron timezone overrides it
    • DST-aware via Python’s zoneinfo module (zero new dependencies)
    • invalid timezone names rejected at config parse time with clear error messages
  • SSRF protection for trigger outbound requests — shared utility at triggers/ssrf.py blocks private/reserved IP ranges, validates URL schemes, and checks DNS resolution to prevent server-side request forgery in upcoming webhook forwarding and cron data-fetch features #276

    • blocks loopback, RFC 1918, link-local, CGN, multicast, reserved, IPv6 equivalents, and IPv4-mapped IPv6 bypass
    • DNS resolution validation catches DNS rebinding attacks (hostname → private IP)
    • configurable allowlist for admins who need to hit local services
    • timeout and response-size clamping utilities
  • non-agent webhook actions — webhooks can now perform lightweight actions without spawning an agent run #277

    • action = "file_write" — write POST body to disk with atomic writes, path traversal protection, deny-glob enforcement, and on-conflict handling
    • action = "http_forward" — forward payload to another URL with SSRF protection, exponential backoff on 5xx, and header template rendering
    • action = "notify_only" — send a templated Telegram message with no agent run
    • notify_on_success / notify_on_failure flags for Telegram visibility on all action types
    • default action = "agent_run" preserves full backward compatibility
  • multipart form data support for webhooks — webhooks can now accept multipart/form-data POSTs with file uploads #278

    • file parts saved with sanitised filenames, atomic writes, deny-glob and path traversal protection
    • configurable file_destination with template variables, max_file_size_bytes (default 50 MB)
    • form fields available as template variables alongside file metadata
  • data-fetch cron triggers — cron triggers can now pull data from external sources before rendering the prompt #279

    • fetch.type = "http_get" / "http_post" — fetch URL with SSRF protection, configurable timeout and headers
    • fetch.type = "file_read" — read local file with path traversal protection and deny-globs
    • fetch.parse_as — parse response as json, text, or lines
    • fetched data injected into prompt_template via store_as variable (default fetch_result)
    • on_failure = "abort" (default) sends failure notification; "run_with_error" injects error into prompt
    • all fetched data prefixed with untrusted-data marker
  • hot-reload for trigger configuration — editing untether.toml [triggers] applies changes immediately without restarting Untether or killing active runs #269 (#285)

    • new TriggerManager class holds cron and webhook config; scheduler reads manager.crons each tick; webhook server resolves routes per-request via manager.webhook_for_path()
    • supports add/remove/modify of crons and webhooks, auth/secret changes, action type, multipart/file settings, cron fetch, and timezones
    • last_fired dict preserved across swaps to prevent double-firing within the same minute
    • unauthenticated webhooks logged at WARNING on reload (previously only at startup)
    • 13 new tests in test_trigger_manager.py; 2038 existing tests still pass
  • hot-reload for Telegram bridge settingsvoice_transcription, file transfer, allowed_user_ids, show_resume_line, and message-timing settings now reload without a restart #286

    • TelegramBridgeConfig unfrozen (keeps slots=True) and gains an update_from(settings) method
    • handle_reload() now applies changes in-place and refreshes cached loop-state copies; restart-only keys (bot_token, chat_id, session_mode, topics, message_overflow) still warn with restart_required=true
    • route_update() reads cfg.allowed_user_ids live so allowlist changes take effect on the next message
  • /at command for one-shot delayed runs — schedule a prompt to run between 60s and 24h in the future with /at 30m Check the build; accepts Ns/Nm/Nh suffixes #288

    • pending delays tracked in-memory (lost on restart — acceptable for one-shot use)
    • /cancel drops pending /at timers before they fire
    • per-chat cap of 20 pending delays; graceful drain cancels pending scopes on shutdown
    • new module telegram/at_scheduler.py; command registered as at entry point
  • run_once cron flag[[triggers.crons]] entries can set run_once = true to fire once then auto-disable; the cron stays in the TOML and re-activates on the next config reload or restart #288

  • trigger visibility improvements (Tier 1) — surface configured triggers in the Telegram UI #271

    • /ping in a chat with active triggers appends ⏰ triggers: 1 cron (daily-review, 9:00 AM daily (Melbourne))
    • trigger-initiated runs show provenance in the meta footer: 🏷 opus 4.6 · plan · ⏰ cron:daily-review
    • new describe_cron(schedule, timezone) utility renders common cron patterns in plain English; falls back to the raw expression for complex schedules
    • RunContext gains trigger_source field; ProgressTracker.note_event merges engine meta over the dispatcher-seeded trigger so it survives
    • TriggerManager exposes crons_for_chat(), webhooks_for_chat(), cron_ids(), webhook_ids() helpers
  • faster, cleaner restarts (Tier 1) — restart gap reduced from ~15-30s to ~5s with no lost messages #287

    • persist last Telegram update_id to last_update_id.json and resume polling from the saved offset on startup; Telegram retains undelivered updates for 24h, so the polling gap no longer drops or re-processes messages
    • Type=notify systemd integration via stdlib sd_notify (socket.AF_UNIX, no dependency) — READY=1 is sent after the first getUpdates succeeds, STOPPING=1 at the start of drain
    • RestartSec=2 in contrib/untether.service (was 10) — faster restart after drain completes
    • contrib/untether.service also adds NotifyAccess=main; existing installs must copy the unit file and systemctl --user daemon-reload

docs

  • add update and uninstall guides + README transparency section #305
    • new docs/how-to/update.md and docs/how-to/uninstall.md covering pipx, pip, and source installs, plus config/data/systemd cleanup
    • README: “What Untether accesses” section (network, filesystem, process, credentials), update/uninstall one-liners in Quick Start, and cross-links throughout install/how-to pages
  • comprehensive v0.35.1 documentation audit — 8 gap fills across 121 files #306
    • group-chat.md: document callback sender validation in groups (#192)
    • security.md: cross-reference button validation, fix misleading SSRF allowlist claim, add bot token auto-redaction tip (#190)
    • plan-mode.md: document auto-approval after plan approval (#283)
    • interactive-approval.md: admonition linking to plan bypass behaviour
    • commands-and-directives.md: /ping description now mentions uptime reset and trigger summary (#234)
    • runners/amp/runner.md: add sanitize_prompt() note matching Pi/Gemini runners (#194)
    • troubleshooting.md: document 10 MB engine output line cap (#191)
    • glossary.md: add delayed run, webhook action, and hot-reload entries

v0.35.0 (2026-03-31)

fixes

  • render plan outline as formatted text instead of raw markdown — outline messages now use render_markdown() + split_markdown_body() so headings, bold, code, and lists display properly in Telegram #139
  • add approve/deny buttons to the last outline message — users no longer need to scroll back up past long outlines to find the buttons #140
  • delete outline messages on approve/deny — outline and notification messages are cleaned up immediately via module-level _OUTLINE_REGISTRY, and stale approval keyboard on the progress message is suppressed #141
  • scope AskUserQuestion pending requests by channel_id — _PENDING_ASK_REQUESTS and _ASK_QUESTION_FLOWS were global dicts with no chat scoping; a pending ask in one chat would steal the next message from any other chat, causing cross-chat contamination and lost messages #144
    • added channel_id contextvar (get_run_channel_id/set_run_channel_id) to utils/paths.py
    • get_pending_ask_request() and get_ask_question_flow() now accept channel_id and filter by it
    • session cleanup now also clears stale pending asks and flows
  • standalone override commands (/planmode, /model, /reasoning) now preserve all EngineOverrides fields instead of resetting unrelated overrides #124
  • register input for system-level auto-approved control requests (Initialize, HookCallback, McpMessage, RewindFiles, Interrupt) so updatedInput is included in the response — prevents ZodError in Claude Code #123
  • reduce Telegram API default timeout from 120s to 30s — a single ReadTimeout on editMessageText could make the bot appear unresponsive for up to 2 minutes; getUpdates long-poll now uses a dedicated timeout of timeout_s + 20 so network failures are detected faster #145
  • OpenCode error runs now show the error message instead of an empty body — CompletedEvent.answer falls back to state.last_tool_error when no prior Text events were emitted; covers both StepFinish and stream_end_events paths #146, #150
  • Pi /continue now captures the session ID from SessionHeaderallow_id_promotion was False for continue runs, preventing the resume token from being populated #147
  • post-outline approval no longer fails with “message to be replied not found” — the “Approve Plan” button on outline messages uses the real ExitPlanMode request_id, so the regular approve path now sets skip_reply=True when outline messages were just deleted; also suppresses the redundant push notification after outline cleanup #148
  • sanitise text_link entities with invalid URLs before sending to Telegram — localhost, loopback, file paths, and bare hostnames are converted to code entities instead, preventing silent 400 errors that drop the entire final message #157
  • fix duplicate approval buttons after “Pause & Outline Plan” — both the progress message and outline message showed approve/deny buttons simultaneously; now only the outline message has approval buttons (with Cancel), progress keeps cancel-only; outline state resets properly for future ExitPlanMode requests #163
  • hold ExitPlanMode request open after outline so post-outline Approve/Deny buttons persist — instead of auto-denying (which caused Claude to exit ~7s later), the control request is never responded to, keeping Claude alive while the user reads the outline #114, #117
    • buttons use real request_id from pending_control_requests for direct callback routing
    • 5-minute safety timeout cleans up stale held requests
  • suppress stall auto-cancel when CPU is active — extended thinking phases produce no JSONL events but the process is alive and busy; is_cpu_active() check prevents false-positive kills #114
  • fix stall notification suppression when main process sleeping — CPU-active suppression now checks process_state; when main process is sleeping (state=S) but children are CPU-active (hung Bash tool), notifications fire instead of being suppressed; stall message now shows tool name (“Bash tool may be stuck”) instead of generic “session may be stuck” #168
  • suppress redundant cost footer on error runs — diagnostic context line already contains cost data, footer no longer duplicates it #120
  • clarify /config default labels and remove redundant “Works with” lines #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
  • 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
  • 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
    • 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
  • OpenCode model name missing from footer when using default model — build_runner() now reads ~/.config/opencode/opencode.json to detect the configured default model so the 🏷 footer always shows the model (e.g. openai/gpt-5.2) even without an untether.toml override #221
  • OpenCode model override hint — /config and engine model sub-page now show provider/model (e.g. openai/gpt-4o) instead of the unhelpful “from provider config”, guiding users to use the required provider-prefixed format #220
  • Codex footer missing model name — Codex runner always includes model in StartedEvent.meta so the footer shows the model even when no override is set #217
  • /planmode command worked in non-Claude engine chats — now gated to Claude-only with a helpful message; Codex/Gemini users are directed to /config → Approval policy #216
  • /usage showed Claude subscription data in non-Claude engine chats — now gated to subscription-supported engines with an engine-specific error message #215
  • /export showed duplicate “Session Started” headers for resumed sessions — deduplicated so only the first StartedEvent renders #218
  • Gemini CLI prompt injection — prompts starting with - were parsed as flags when passed via -p <value>; now uses --prompt=<value> to bind the value directly #219
  • /new command now cancels running processes before clearing sessions — previously only cleared resume tokens, leaving old Claude/Codex/OpenCode processes running (~400 MB each), worsening memory pressure and triggering earlyoom kills #222
  • auto-continue no longer triggers on signal deaths (rc=143/SIGTERM, rc=137/SIGKILL) — earlyoom kills have last_event_type=user which matched the upstream bug detection, causing a death spiral where 4 killed sessions were immediately respawned into the same memory pressure #222
  • /new command triggers engine run instead of clearing sessions when topics.enabled=false/new was only handled in _dispatch_builtin_command when topics were enabled; moved /new out of the topics.enabled gate to handle all modes (topic, chat session, stateless), mirroring how /ctx already works; also removed unreachable early routing code #236
  • Gemini engine stuck at “starting · 0s” — Gemini CLI outputs a non-JSON warning (MCP issues detected...) on stdout before the first JSONL event, corrupting the line; decode_jsonl() now strips non-JSON prefixes by finding the first { and retrying parse #231
  • /config Ask mode toggle inverted — _toggle_row default was False but display default was “on”, causing the button to show “Ask: off” when the effective state was on; pressing it appeared to do nothing #232
  • diff preview approval buttons not rendered after outline flow — _outline_sent flag in ProgressEdits stripped ALL subsequent approval buttons, not just outline-related ones; now only strips buttons for DiscussApproval actions #233
  • prevent duplicate control response for already-handled requests #229 (#230)
  • fix render_markdown entity overflow when text ends with a fenced code block — entity offsets now clamped to the UTF-16 text length after trailing newline stripping, preventing Telegram 400 errors #59
  • /config now reflects project-level default_engine — previously showed Claude-specific buttons (Plan mode, Ask mode, etc.) for chats routed to Codex/Pi via project config #60
  • non-Claude runners (Codex, Pi) now populate model name in StartedEvent.meta — footer previously showed permission mode only (e.g. 🏷 plan) without the model #62
  • fix liveness watchdog false positive auto-cancel on long-running sessions — actively working sessions with CPU activity and TCP connections were being killed during extended thinking/processing phases #115
  • fix reply-to resume when emoji prefix is present — the ↩️ prefix on resume footer lines broke all 6 engine regexes; extract_resume() now strips emoji prefixes before matching #134
  • /config sub-pages now show resolved on/off values instead of “default” — body text now matches the toggle button state using _resolve_default(), removing the confusing mismatch #152
  • expired control requests now auto-denied after 5-minute timeout — previously the timeout cleanup removed local tracking but did not send a deny response, leaving the Claude subprocess blocked indefinitely on stdin #32
  • /export no longer returns sessions from wrong chat — session recording was not scoped by channel_id, so /export in one chat could return another engine’s session data #33
  • fix KillMode=control-group bypassing drain and causing 150s restart delay — contrib/untether.service now uses KillMode=mixed which sends SIGTERM to the main process first (drain works), then SIGKILL to remaining cgroup processes (orphaned MCP servers, containers cleaned up instantly) #166
    • process: orphaned children survive across restarts, accumulating memory (#88)
    • control-group: kills all processes simultaneously, bypassing drain (#166)
    • mixed: best of both — graceful drain then forced cleanup
  • AMP CLI -x flag regression — double-dash separator in build_args() caused AMP to interpret -x as a subcommand name instead of a flag, breaking execute mode for all prompts #245

docs

  • update integration test chat IDs from stale ut-dev: to current ut-dev-hf: chats #238
  • investigation: orphaned workerd processes from Bash tool children are upstream Claude Code bug — Untether’s process group cleanup is correct; Claude Code spawns Bash tool shells in their own session group which Untether cannot reach; no TTY/SIGHUP cascade in headless mode #257

changes

  • logging audit: fill gaps in structlog coverage — elevate settings loader failures from DEBUG to WARNING (footer, watchdog, auto-continue, preamble), add access control drop logging, add executor handle.engine_resolved info log, elevate outline cleanup failures to WARNING, add credential redaction for OpenAI/GitHub API keys, add file transfer success logging, bind session_id in structlog context vars, add media group/cost tracker/cancel debug logging #254
  • CI: expand ruff lint rules from 7 to 18 — add ASYNC, LOG, I (isort), PT, RET, RUF (full), FURB, PIE, FLY, FA, ISC rule sets; auto-fix 42 import sorts, clean 73 stale noqa directives, fix unused vars and useless conditionals; per-file ignores for test-specific patterns #255
  • Gemini: default to --approval-mode yolo (full access) when no override is set — headless mode has no interactive approval path, so the CLI’s read-only default disabled write tools entirely, causing multi-minute stalls as Gemini cascaded through sub-agents #244, #248
  • expand error hints coverage — add model not found, context length exceeded, authentication, content safety, CLI not installed, SSL/TLS, invalid request, disk/permission, AMP-specific auth, Gemini result status, and account suspension error categories #246
  • /continue command — cross-environment resume; pick up the most recent CLI session from Telegram using each engine’s native continue flag (--continue, resume --last, --resume latest); supported for Claude, Codex, OpenCode, Pi, Gemini (not AMP) #135
    • ResumeToken extended with is_continue: bool = False
    • all 6 runners’ build_args() updated to handle continue tokens
    • /continue handled as reserved command in Telegram loop
    • new how-to guide: docs/how-to/cross-environment-resume.md
  • /config UX overhaul — 2-column toggle pattern replaces all 3-button rows with single [✓ Feature: on] toggle + [Clear] for better mobile tap targets; merged Engine + Model into single page; max 2 buttons per row on home page; plan mode 2+1 split layout #132
  • resume line toggle — per-chat show_resume_line override via /config settings; configurable via EngineOverrides #128
  • cost budget settings — per-chat budget_enabled and budget_auto_cancel overrides on Cost & Usage page in /config #129
  • model metadata improvements — shorten model display names in footer: claude-opus-4-6[1m]opus 4.6 (1M), auto-gemini-3gemini-3; all engines populate model info from StartedEvent.meta #132
  • resume line formatting — visual separation with blank line and ↩️ prefix in final message footer #127
  • agent-initiated file delivery — agents write files to .untether-outbox/ during a run; Untether sends them as Telegram documents on completion with 📎 filename (size) captions; flat scan, deny-glob security, size limits, auto-cleanup #143
    • new module telegram/outbox_delivery.py with scan_outbox(), cleanup_outbox(), deliver_outbox_files()
    • ExecBridgeConfig gains send_file callback + outbox_config (transport-agnostic)
    • preamble updated with outbox instructions for all 6 engines
    • config: outbox_enabled, outbox_dir, outbox_max_files, outbox_cleanup in [transports.telegram.files]
  • orphan progress message cleanup on restart — active progress messages are persisted to active_progress.json; on startup, orphan messages from a prior instance are edited to show “⚠️ interrupted by restart” with no keyboard #149
    • new module telegram/progress_persistence.py with register_progress(), unregister_progress(), load_active_progress(), clear_all_progress()
    • runner_bridge.py registers on progress send, unregisters on ephemeral cleanup
    • telegram/loop.py cleans up orphans before sending startup message
  • expand pre-run permission policies for Codex CLI and Gemini CLI in /config #131
    • Codex: new “Approval policy” page — full auto (default) or safe (--ask-for-approval untrusted)
    • Gemini: expanded approval mode from 2 to 3 tiers — read-only, edit files (--approval-mode auto_edit), full access
    • both engines show “Agent controls” section on /config home page with engine-specific labels
  • suppress stall Telegram notifications when CPU-active; heartbeat re-render keeps elapsed time counter ticking during extended thinking phases #121
  • temporary debug logging for hold-open callback routing — will be removed after dogfooding confirms #118 is resolved
  • auto-continue mitigation for Claude Code bug — when Claude Code exits after receiving tool results without processing them (bugs #34142, #30333), Untether detects via last_event_type=user and auto-resumes the session #167
    • 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
  • 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
  • show token usage counts for non-Claude engines — completion footer now displays 💰 26.0k in / 71 out for Codex, OpenCode, Pi, Gemini, and Amp when token data is available #36
  • include CLI versions in startup diagnostics — startup message now shows detected engine CLI versions for easier debugging of outdated or mismatched tools #38

tests

  • 8 new outline UX tests: markdown rendering with entities, approval keyboard on last chunk, multi-chunk keyboard placement, ref tracking, deletion on approval transition, deletion on keyboard change, safety-net cleanup, no double-deletion #139, #140, #141
  • 22 new outbox delivery tests: scan (empty, single, sorted, max_files, deny globs, size limit, empty file, symlink, subdir), cleanup (delete, keep unsent, already gone), delivery (send, cleanup, no-cleanup, empty, send failure), integration (after completion, disabled, error run) #143
  • 4 new cross-chat ask isolation tests: pending ask scoped by channel, correct channel returned, flow scoped by channel, translate registers with channel_id #144
  • 99 new /continue tests: 46 auto-router assertions (continue token handling, engine routing) + 53 build-args assertions (continue flags for all 6 engines) #135
  • 195 /config tests covering home page, all sub-pages, toggle actions, callback routing, button layout, engine-aware visibility #132
  • 7 new OpenCode error message tests: Error event with no prior text, process_error_events, stream_end_events, last_tool_error fallback on StepFinish, last_text takes priority over tool error, tool error status captures last_tool_error, stream_end_events fallback #146, #150
  • 3 new Pi /continue tests: allow_id_promotion flag, session ID promotion from SessionHeader, normal resume no promotion #147
  • 3 new timeout tests: default 30s timeout, getUpdates per-request timeout, sendMessage uses default #145
  • 3 new discuss-approval skip_reply tests: approve and deny results set skip_reply=True, dispatch callback skip_reply sends without reply_to #148
  • 8 new progress persistence tests: register/load roundtrip, unregister, missing file, corrupt file, non-dict, multiple entries, clear all, clear nonexistent #149
  • 2 new dual-button tests: outline strips approval from progress, outline state resets on approval disappear #163
  • hold-open outline flow: new tests for hold-open path, real request_id buttons, pending cleanup, approval routing #114
  • stall suppression: tests for CPU-active auto-cancel, notification suppression when cpu_active=True, notification fires when cpu_active=False #114, #121
  • cost footer: tests for suppression on error runs, display on success runs #120
  • 10 new auto-continue tests: detection function (bug scenario, non-claude engine, cancelled session, normal result, no resume, max retries) + settings validation (defaults, bounds) #167
  • 2 new stall sleeping-process tests: notification not suppressed when main process sleeping (state=S), stall message includes tool name #168
  • 8 new _read_opencode_default_model tests: valid config, missing file, invalid JSON, empty model, no model key, build_runner fallback, untether config priority, no OC config #221
  • engine command gate tests: /planmode Claude-only, /usage subscription-engine-only #215, #216
  • export dedup test: duplicate started events deduplicated in markdown export #218
  • Gemini --prompt= build_args test #219
  • Gemini integration test stall diagnosed — root cause was missing --approval-mode yolo in test chat config; Gemini CLI defaults to read-only mode with write tools disabled; set full access via /config for ut-dev-hf: gemini test chat; U1 now passes in 56s (was 8–18 min stall) #244
  • 10 new /new cancellation tests: _cancel_chat_tasks helper (None, empty, matching, other chats, already cancelled, multiple), chat /new with running task, cancel-only no sessions, no tasks no sessions, topic /new with running task #222
  • 12 new auto-continue signal death tests: _is_signal_death (SIGTERM, SIGKILL, negative, normal, None), _should_auto_continue (rc=143, rc=137, rc=-9, rc=-15 blocked; rc=0, rc=None, rc=1 allowed), proc_returncode default on JsonlStreamState #222

docs

  • document OpenCode lack of auto-compaction as a known limitation — long sessions accumulate unbounded context with no automatic trimming; added to runner docs and integration testing playbook #150

v0.34.4 (2026-03-09)

fixes

  • preamble hook awareness: add constraint to default preamble instructing Claude that if hooks fire at session end, the final response must still contain the user’s requested content — hook concerns are secondary and should be noted after main content, never instead of it #107
    • addresses content displacement when Claude Code plugin Stop hooks (e.g. PitchDocs context-guard) consume the final Telegram message with meta-commentary instead of user-requested content
  • UNTETHER_SESSION env var: Claude runner now sets UNTETHER_SESSION=1 in subprocess environment, enabling Claude Code hooks to detect Untether sessions and adjust behaviour (e.g. PitchDocs context-guard skips blocking Stop hooks in Telegram) #107

docs

  • audit: PitchDocs context-guard interference analysis — root cause (false positive from git status --porcelain on untracked hook infrastructure), cross-project comparison (BIP/Scout/Brand Copilot/littlebearapps.com), recommendations for both Untether and PitchDocs #107

v0.34.3 (2026-03-08)

fixes

  • tool-aware stall threshold: 10-minute threshold (_STALL_THRESHOLD_TOOL = 600s) when a tool action is started but not completed, preventing false stall warnings during long-running Bash commands, Agent tasks, and TaskOutput waits #105
    • three-tier system: normal (5 min), running tool (10 min), pending approval (30 min)
    • _has_running_tool() checks most recent action state
    • stall threshold selection logged at info level with reason
  • progress message edit failure: log warning and fall back to sending a new message when the initial “queued” → “starting” edit fails, preventing stuck “queued” messages #103
  • approval keyboard edit failure: use wait=True for keyboard transitions (approval buttons appearing), log keyboard attach at info level and edit failures at warning level for diagnostics #104
    • transport.edit.failed warning in TelegramTransport.edit() when wait=True edit returns None
    • progress_edits.keyboard_attach info log on keyboard transitions
    • progress_edits.keyboard_edit_failed warning when keyboard edit fails
    • transport errors upgraded from debug to warning level
  • /usage 429 rate limit: downgrade from error to warning level, preventing untether-issue-watcher noise for transient rate limits #89

changes

  • session cleanup structured reporting: _cleanup_session_registries() now logs cleaned registry names at info level for post-mortem analysis #93
    • session registration (claude_runner.registered, session_stdin.registered) upgraded to info level
  • JSONL decode failure logged at warning level with truncated line content (first 200 chars)
  • runner spawn now logs CLI args in runner.start event
  • no-events session warning: session.summary.no_events logged when a non-cancelled session completes with zero events

tests

  • new test coverage for tool-aware stall threshold, keyboard edit failure recovery, edit-fail fallback send, session cleanup tracking, stderr sanitisation #85, build args validation, loop coverage

v0.34.2 (2026-03-08)

fixes

  • stall monitor loops forever after laptop sleep — no auto-cancel, /cancel requires reply #99
    • stall auto-cancel: dead process detection (immediate), no-PID zombie cap (3 warnings), absolute cap (10 warnings)
    • early PID threading: last_pid set at subprocess spawn, polled by run_runner_with_cancel before StartedEvent
    • standalone /cancel fallback: cancels single active run without requiring reply; prompts when multiple runs active
    • queued_for_chat() method on ThreadScheduler for standalone cancel of queued jobs
    • approval-aware stall threshold: 30 min when waiting for user approval (inline keyboard detected), 5 min otherwise

v0.34.1 (2026-03-07)

fixes

  • session stall diagnostics: add /proc process diagnostics (CPU, RSS, TCP, FDs, children), progressive stall warnings, liveness watchdog, event timeline tracking, and session completion summary #97
    • new utils/proc_diag.py module: collect_proc_diag(), format_diag(), is_cpu_active()
    • JsonlStreamState tracks last_stdout_at, event_count, last_event_type, recent_events ring buffer, stderr_capture
    • PID auto-injected into StartedEvent.meta via base class (all engines)
    • progressive _stall_monitor: repeating warnings every 3 min with fresh /proc snapshots and Telegram notifications
    • liveness watchdog: detects alive-but-silent subprocesses after 10 min with diagnostics; optional auto-kill (off by default, triple safety gate)
    • session.summary structured log on every session completion
    • [watchdog] config section: liveness_timeout, stall_auto_kill, stall_repeat_seconds
  • stream threading broken: _ResumeLineProxy hides current_stream from ProgressEdits, causing event_count=0 and last_event_type=None for all engines #98
    • add current_stream property to _ResumeLineProxy and _PreludeRunner
    • set self.current_stream = stream in Claude’s overridden run_impl
    • use stream.stderr_capture instead of separate stderr_lines in Claude’s run_impl

v0.34.0 (2026-03-07)

fixes

  • ExitPlanMode stuck after cancel + resume: stale outline_guard not cleaned up #93
    • extract _cleanup_session_registries() helper, call from run_impl finally block
  • stall monitor fails to detect stalls when no events arrive after session start; no Telegram notification #95
    • initialise _last_event_at from clock() instead of 0.0 so threshold works from session start
    • send ⏳ No progress for N min Telegram notification on stall detection (previously journal-only)

changes

  • show token-only cost footer for Gemini and AMP — _format_run_cost() no longer requires total_cost_usd; renders 💰 26.0k in / 71 out when only token data is available #94
    • Gemini _build_usage(): extract cachedcache_read_tokens and duration_ms from StreamStats
    • AMP _accumulate_usage(): accumulate cache_creation_input_tokens and cache_read_input_tokens
  • add Gemini CLI approval mode toggle in /config — “read-only” (default, write tools blocked) or “full access” (--approval-mode=yolo); tied into existing plan mode infrastructure via shared permission_mode field #90
    • home page shows “Approval mode” label and button when engine is Gemini
    • sub-page with Read-only/Full access toggle
    • PERMISSION_MODE_SUPPORTED_ENGINES constant for engine-aware gating

v0.33.5 (2026-03-07)

fixes

  • downgrade control_response.failed ClosedResourceError from error to warning — race condition when Telegram callback arrives after session stdin closes; write_control_response() now returns bool and send_claude_control_response() propagates it #61
    • also downgrade auto_approve_failed and auto_deny_failed for consistency
  • add subprocess watchdog — detects orphaned child processes (e.g. MCP servers) holding stdout pipes open after parent exits; kills process group after grace period #91
  • add stall monitor — warns when no progress events arrive for 5 minutes; clears on recovery #92
  • handle ClosedResourceError in iter_bytes_lines() on abrupt pipe close

v0.33.4 (2026-03-06)

fixes

  • add render debouncing to batch rapid progress events — configurable min_render_interval (default 2.0s) prevents flooding Telegram edits #88
    • first render is never debounced; subsequent renders sleep for remaining interval
    • group_chat_rps now configurable in [progress] (default 20/60, matching Telegram limit)
  • make approval notification sends non-blocking — transport.send() for push notifications runs in a background task instead of stalling the render loop #88

docs

  • document KillMode=processKillMode=control-group fix for systemd service files — orphaned MCP servers accumulate across restarts, consuming 10+ GB #88

v0.33.3 (2026-03-06)

fixes

  • block ExitPlanMode after cooldown expires when no outline has been written — adds outline guard check before time-based cooldown #87
    • _OUTLINE_PENDING + max_text_len_since_cooldown < 200 guard fires regardless of cooldown expiry
    • strengthened deny/escalation messages with consequence warnings and concrete framing

v0.33.2 (2026-03-06)

fixes

  • warn at startup when allowed_user_ids is empty — any chat member can run commands without filtering #84
  • sanitise subprocess stderr before exposing to Telegram — redact absolute file paths and URLs #85
  • truncate prompts to 100 chars in INFO logs to reduce sensitive data exposure #86

v0.33.1 (2026-03-06)

fixes

  • fall back to plain commonmark renderer when linkify-it-py is missing instead of crash-looping on startup #83

v0.33.0 (2026-03-06)

changes

  • add effort control for Claude Code — --effort flag with low/medium/high levels via /reasoning and /config #80
  • show model version numbers in footer — e.g. opus 4.6 instead of opus #80
  • show effort level in meta line between model and permission mode (e.g. opus 4.6 · medium · plan) #80
  • rename all user-facing “Claude” to “Claude Code” for product clarity #81
    • error messages, button labels, config descriptions, notification text
    • engine IDs ("claude") and model/subscription references unchanged

fixes

  • signal error hints (SIGTERM/SIGKILL/SIGABRT) no longer hardcode /claude — now engine-agnostic #81
  • config reasoning page showed bare “Claude” instead of “Claude Code” due to .capitalize() #81
  • /usage HTTP errors now show descriptive messages (e.g. “Rate limited by Anthropic — too many requests”) instead of bare status codes #81
  • /usage now handles ConnectError and TimeoutException with specific recovery guidance #81
  • add error hints for “finished without a result event” and “finished but no session_id” — covers all 6 engines #81

docs

  • update 27 documentation files with Claude Code naming
  • update troubleshooting guide with new error hint categories (process/session errors)
  • update inline settings guide — reasoning now shows Claude Code and Codex as supported
  • update model-reasoning guide with Claude Code effort levels

tests

  • add 8 new error hint tests (signal engine-agnostic, cross-engine process/session errors)
  • update model version tests for _short_model_name() (e.g. opus 4.6)
  • add effort/meta line tests for format_meta_line()
  • update config command tests for Claude Code reasoning support

v0.32.1 (2026-03-06)

fixes

  • missing linkify-it-py dependency crashes service on startup after 0.32.0 upgrade #79
    • markdown-it-py linkify feature requires optional linkify-it-py package
    • changed dependency to markdown-it-py[linkify] to include the extra

docs

  • cross-platform process management instructions — platform tabs for restart/logs, contextualise systemd as Linux-specific

v0.32.0 (2026-03-06)

changes

  • add Gemini CLI runner with --approval-mode passthrough for plan mode support #991
  • add Amp CLI runner with mode selection and --stream-json-input support #988, #989
  • add /threads command for Amp thread management #993
  • track Amp subagent parent_tool_use_id in action detail #992
  • redesign /config home page with grouped sections (Agent controls, Display, Routing), inline hints, and help links
  • add version information footer to /config home page
  • compact startup message — only show enabled features (topics, triggers), merge engine and default on one line

fixes

  • Gemini CLI -p flag compatibility (changed from boolean to string argument) #75
  • Amp CLI -x flag requires prompt as direct argument #76
  • Amp CLI uses --mode not --model for model override #77
  • Amp /threads table parsing — threads list/search don’t support --json #78
  • standardise unrecognised-event debug logging across all engine runners
  • add structured logging for cost budget alerts and exceeded events
  • improve atomic JSON state write error handling and logging
  • add timeout and generic exception handlers to voice transcription
  • add structured logging for plugin load errors
  • improve config cleanup error logging with error type details

docs

  • update README engine compatibility table with Gemini CLI and Amp columns
  • add [gemini] and [amp] configuration sections to config reference
  • various doc formatting and link updates

tests

  • add comprehensive tests for redesigned /config command (+199 lines)
  • simplify startup message generation tests
  • add cross-engine test coverage for Gemini and Amp runners

v0.31.0 (2026-03-05)

changes

  • merge API cost and subscription usage into unified “Cost & usage” config page #67
  • make /auth codex-only, move auth status to /stats auth #68
  • add docs link to /config home page #69

fixes

  • widen device code regex for real codex output format #40
  • improve /auth info message wording #70
  • put Cost & usage and Trigger on same row in /config #71
  • 5 optimisations from 4-engine test sweep #72

docs

  • add triggers/webhooks/cron architecture and how-to documentation
  • expand trigger mode and group chat documentation

v0.30.0 (2026-03-04)

changes

  • add /stats command — persistent per-engine session statistics (runs, actions, duration) with today/week/all periods #41
    • SessionStatsStore with JSON persistence in config dir
    • auto-prune data older than 90 days
    • recording hook in runner_bridge.py on run completion
  • add /auth command — headless engine re-authentication via Telegram #40
    • runs codex login --device-auth and sends verification URL + device code
    • /auth status checks CLI availability
    • concurrent guard and 16-minute timeout
  • add API cost and subscription usage toggles to /config menu
    • per-chat persistent settings for show_api_cost and show_subscription_usage

fixes

  • diff preview on approval buttons was dead code — Edit/Write/Bash were always auto-approved before reaching the diff preview path #52
    • when diff_preview is enabled, previewable tools now route through interactive approval
    • default behaviour (diff_preview off) unchanged

tests

  • 16 new diff preview gate tests (parametrised across tools and settings)
  • 18 new session stats storage tests (record, aggregate, persist, prune, corrupt file)
  • 13 new stats command tests (formatting, duration, handle with args)
  • 13 new auth command tests (ANSI stripping, device code parsing, concurrent guard, status)

v0.29.0 (2026-03-03)

changes

  • add diff preview toggle to /config menu — per-chat persistent setting to enable/disable diff previews in tool approval messages #58
    • Claude-only; default is on (matches existing behaviour)
    • stored in EngineOverrides, gated via EngineRunOptions ContextVar
    • home page layout: new “Diff preview” button alongside Verbose

fixes

  • remove redundant local import of get_run_options in claude.py that shadowed the module-level import

tests

  • 25 new tests: diff preview config page (18), gating logic (4), engine override merge (2), toast labels (3)
  • updated home button test to assert config:dp presence for Claude

v0.28.1 (2026-03-03)

changes

  • add 20 new API/LLM error hints for graceful failure during provider outages #54
    • subscription limits: Claude “out of extra usage” / “hit your limit” — tells user session is saved, wait for reset
    • billing errors: OpenAI insufficient_quota, billing_hard_limit_reached; Google resource_exhausted
    • API overload: Anthropic overloaded_error (529), generic “server is overloaded”
    • server errors: 500 internal_server_error, 502 bad gateway, 503 service unavailable, 504 gateway timeout
    • rate limits: too many requests (extends existing rate limit pattern)
    • network: connecttimeout, DNS failure, network unreachable
    • auth: openai_api_key, google_api_key (extends existing anthropic_api_key)

fixes

  • deduplicate error messages when answer and error share the same first line (e.g. Claude subscription limits showed “You’re out of extra usage” twice) #55
  • remove Approve/Deny buttons from AskUserQuestion option keyboards — only option buttons and “Other (type reply)” shown #56
  • push notification for AskUserQuestion now says “Question from Claude” instead of “Action required — approval needed” #57

tests

  • 19 new tests for API error hint patterns: subscription limits, billing, overload, server errors, network, ordering
  • 2 new tests for error/answer deduplication in runner_bridge #55
  • negative assertions for Approve/Deny absence in option button test #56

v0.28.0 (2026-03-02)

changes

  • interactive ask mode — AskUserQuestion renders option buttons in Telegram, sequential multi-question flows (1 of N), “Other (type reply)” fallback, and structured updatedInput responses #51
    • /config toggle: “Ask mode” sub-page (Claude-only) to enable/disable interactive questions
    • dynamic preamble encourages or discourages AskUserQuestion based on toggle state
    • auto-deny when toggle is OFF — Claude proceeds with defaults instead of asking
  • Gemini CLI and Amp engine runners added (coming soon — not yet released for production use)

fixes

  • synthetic Approve Plan button now returns an error when session has already ended, instead of silently succeeding #50
    • session-alive check in da: button handler (claude_control.py)
    • stale _REQUEST_TO_SESSION entries cleaned up during session end
  • ReadTimeout in usage footer no longer kills final message delivery — chat appeared frozen when Anthropic usage API was slow #53

tests

  • 27 new tests for ask mode: option button rendering, multi-question flow management, structured answer responses, config toggle, auto-deny when OFF
  • 4 new tests for synthetic approve after session ends (#50): dead approve, dead deny, active approve, session cleanup

docs

  • updated inline-settings how-to, interactive-control tutorial, README, and CLAUDE.md for ask mode
  • added ask mode to /config command description and features list
  • Gemini CLI and Amp listed as “coming soon” in README engines table

v0.27.1 (2026-03-02)

fixes

  • add ReadTimeout error hint for transient network timeouts #15
  • resolve all ty type checker warnings (109 → 0)

docs

  • fix PyPI logo rendering — use absolute raw GitHub URL so SVG displays on PyPI
  • add Upgrading section to README with uv/pipx upgrade + restart commands
  • point project URLs to GitHub for PyPI verified details

v0.27.0 (2026-03-01)

fixes

  • per-chat outbox pacing — progress edits to different chats no longer serialise through a single global timer; each chat tracks its own rate-limit window independently #48
    • _next_at[chat_id] dict replaces scalar next_at
    • new _pick_ready(now) selects from unblocked chats; retry_at stays global (429)
    • 7 group chats now update in parallel (~0s total) vs old 7 × 3s = 21s delay

changes

  • /config model sub-page — view current model override and clear it; button always visible on home page #47
  • /config reasoning sub-page — select reasoning level (minimal/low/medium/high/xhigh) via buttons; only visible when engine supports reasoning (Codex) #47

tests

  • 7 per-chat pacing tests: independent chats, private vs group intervals, global retry_at, cross-chat priority, same-chat pacing, 7 concurrent chats, chat_id=None independence
  • 54 model + reasoning /config tests: sub-page rendering, toggle actions, engine-aware visibility, toast mappings, override persistence, cross-field preservation

v0.26.0 (2026-03-01)

changes

  • /config inline settings menu — BotFather-style inline keyboard for toggling plan mode, verbose, engine, and trigger; edits message in-place #47
    • confirmation toasts on toggle actions (e.g. “Plan mode: off”)
    • auto-return to home page after setting changes
    • engine-aware plan mode — hidden for non-Claude engines

docs

  • comprehensive tutorials and how-to guides — 15 new/expanded guides covering daily use, interactive control, messaging, cost management, security, and operations
  • inline settings how-to (docs/how-to/inline-settings.md)

tests

  • add 62-test suite for /config (toast permutations, engine-aware visibility, auto-return, callback dispatch)

v0.25.3 (2026-03-01)

fixes

  • increase SIGTERM→SIGKILL grace period from 2s to 10s — gives engines time to flush session transcripts before forced kill #45
  • add error_during_execution error hint — users see actionable recovery guidance when a session fails to load #45
  • auto-clear broken session on failed resume — when a resumed run fails with 0 turns, the saved token is automatically cleared so the next message starts fresh #45
    • new clear_engine_session() on ChatSessionStore and TopicStateStore
    • on_resume_failed callback threaded through handle_message_run_enginewrap_on_resume_failed

tests

  • add ErrorReturn step type to ScriptRunner mock for simulating engine failures
  • add 4 auto-clear unit tests (zero-turn error, success, partial turns, new session)
  • add SIGTERM→SIGKILL 10s timeout assertion test
  • add 2 error_during_execution hint tests (resumed and new session variants)
  • integration-tested across Claude, Codex, and OpenCode via untether-dev

v0.25.2 (2026-03-01)

fixes

  • add actionable error hints for SIGTERM/SIGKILL/SIGABRT signals — users now see recovery guidance instead of raw exit codes #44

docs

  • add contrib/untether.service example with KillMode=process and TimeoutStopSec=150 for graceful shutdown #44
  • update docs/reference/dev-instance.md with systemd configuration section and graceful upgrade path
  • update CLAUDE.md with graceful upgrade comment

tests

  • add 5 signal hint tests (SIGTERM, SIGKILL, SIGABRT, case insensitivity, no false positives)

v0.25.1 (2026-03-01)

changes

  • default message_overflow changed from "trim" to "split" — long final responses now split across multiple Telegram messages instead of being truncated #42

v0.25.0 (2026-02-28)

changes

  • /verbose command and [progress] config — per-chat verbose toggle shows tool details (file paths, commands, patterns) in progress messages; global verbosity and max_actions settings #25
  • Pi context compaction events — render AutoCompactionStart/AutoCompactionEnd as progress actions with token counts #26
  • UNTETHER_CONFIG_PATH env var — override config file location for multi-instance setups #27
  • ExceptionGroup unwrapping, transport resilience, and debug logging improvements #30

fixes

  • outline not visible in Pause & Outline Plan flow — outline was scrolled off by max_actions truncation and lost in final message #28
  • footer double-spacing — sulguk trailing \n\n caused blank lines between footer items (context/meta/resume) #29

docs

  • add dev instance quickref (docs/reference/dev-instance.md) documenting production vs dev separation
  • add dev workflow rule (.claude/rules/dev-workflow.md) preventing accidental production restarts
  • update CLAUDE.md and README with verbose mode, Pi compaction, and config path features

tests

  • add test suites for verbose command, verbose progress formatting, config path env var, cooldown bypass, and Pi compaction (44 new tests)

v0.24.0 (2026-02-27)

changes

  • agent context preamble — configurable [preamble] injects Telegram context into every runner prompt, informing agents they’re on Telegram and requesting structured end-of-task summaries; engine-agnostic (Claude, Codex, OpenCode, Pi) #21
  • post-outline Approve/Deny buttons — after “Pause & Outline Plan”, Claude writes the outline then Approve/Deny buttons appear automatically in Telegram; no need to type “approved” #22

fixes

  • improved discuss denial message for resumed sessions — explicitly tells Claude to rewrite the outline even if one exists in prior context #23
  • discuss cooldown state cleaned up on session end — prevents stale cooldown leaking into resumed runs #23

docs

  • update plan-mode how-to with post-outline approval flow
  • update control-channel rule with new registries and discuss-approval mechanism
  • update CLAUDE.md feature list with preamble and discuss buttons
  • update site URL to https://littlebearapps.com/tools/untether/

v0.23.5 (2026-02-27)

changes

  • enrich error reporting in Telegram messages and structlog across all engines #14
    • Claude errors now show session ID, resumed/new status, turn count, cost, and API duration
    • non-zero exit codes show signal name (e.g. SIGTERM for rc=-15) and captured stderr excerpt
    • stream-ended-without-result errors include session context
    • runner.completed structlog includes num_turns, total_cost_usd, duration_api_ms
  • compact startup message formatting with hard breaks #14

docs

  • comprehensive documentation audit and upgrade #13
    • add how-to guides: interactive approval, plan mode, cost budgets, webhooks & cron
    • expand schedule-tasks guide with cron and webhook trigger coverage
    • remove orphaned docs/user-guide.md redirect stub
    • fix stale version reference (0.19.0 → 0.23.4) in install tutorial and llms-full.txt
    • regenerate llms.txt and llms-full.txt with 18 previously missing doc pages
    • add AI IDE context files: AGENTS.md, .cursorrules, .github/copilot-instructions.md
    • update .codex/AGENTS.md with correct project commands
    • add ROADMAP.md with near/mid/future directional plans
    • update README documentation section with new guide links
    • update zensical.toml nav with new how-to guides

v0.23.4 (2026-02-26)

fixes

  • fix test_doctor_voice_checks env var leak from pydantic_settings #12
    • UntetherSettings.model_validate() auto-loads UNTETHER__* env vars, causing voice_transcription_api_key to leak into test
    • added monkeypatch.delenv() for the pydantic_settings env var before constructing test settings

docs

  • add macOS Keychain credential info to install tutorial, troubleshooting guide, and command reference #7

v0.23.3 (2026-02-26)

fixes

  • add rate_limit_event to Claude stream-json schema (CLI v2.1.45+) #8
    • new StreamRateLimitMessage and RateLimitInfo msgspec structs
    • event is decoded cleanly and silently skipped (informational only)
    • eliminates noisy jsonl.msgspec.invalid warning in logs

v0.23.2 (2026-02-26)

fixes

  • fix crash when Claude OAuth credentials file missing (macOS Keychain, API key auth) #7
    • _maybe_append_usage_footer() now catches FileNotFoundError and httpx.HTTPStatusError
    • post-run messages are delivered to Telegram even when usage data is unavailable
  • add macOS Keychain support for /usage command and subscription usage footer #7
    • on macOS, Claude Code stores OAuth credentials in the Keychain, not on disk
    • _read_access_token() now tries the file first, then falls back to macOS Keychain

v0.23.1 (2026-02-26)

changes

  • restructure startup message: one field per line, always show all status fields
    • list project names instead of count
    • always show mode, topics, triggers, resume lines, voice, and files status
    • add voice and files enabled/disabled status
  • update PyPI description and keywords to reflect current feature set

v0.23.0 (2026-02-26)

changes

  • refresh startup message: dog emoji, version number, conditional diagnostics, project count
    • only shows mode/topics/triggers/engines lines when they carry signal
    • removes resume lines: field (config detail, not actionable)
  • add model + permission mode footer on final messages (🏷 sonnet · plan)
    • all 4 engines (Claude, Codex, OpenCode, Pi) populate StartedEvent.meta with model info
    • Claude also includes permissionMode from system.init
    • Codex/OpenCode use runner config since their JSONL streams don’t include model metadata
  • route telegram callback queries to command backends #116
    • callback data format: command_id:args... routes to registered command plugins
    • extracts message_thread_id from callback for proper topic context
    • enables plugins to build interactive UX with inline keyboards

v0.22.2 (2026-02-25)

fixes

  • remove defunct Telegram notification scripts that caused CI/release workflows to report failure #9
  • skip uuid.uuid7 test on Python < 3.14 (only available in 3.14+) #10
  • fix PyPI metadata: PEP 639 SPDX license, absolute doc links, remove deprecated classifier #11

v0.22.1 (2026-02-10)

fixes

  • preserve ordered list numbering when nested list indentation is malformed in telegram render output #202

v0.22.0 (2026-02-10)

changes

  • support Codex phase values and unknown action kinds in commentary rendering #201

v0.21.5 (2026-02-08)

fixes

  • dedupe redelivered telegram updates to prevent duplicate runs in DMs #198

changes

  • read package version from metadata instead of a hardcoded __version__ constant

docs

  • rotate telegram invite link

v0.21.4 (2026-01-22)

changes

  • add allowed user gate to telegram #179

v0.21.3 (2026-01-21)

fixes

  • ignore implicit topic root replies in telegram #175

v0.21.2 (2026-01-20)

fixes

  • clear chat sessions on cwd change #172

docs

  • add untether-slack plugin to reference #168

v0.21.1 (2026-01-18)

fixes

  • separate telegram voice transcription client #166
  • disable telegram link previews by default #160

docs

  • align engine terminology in telegram and docs #162
  • add untether-discord plugin to plugins reference #164

v0.21.0 (2026-01-16)

changes

  • add untether config subcommand #153
  • make telegram /ctx work everywhere #159
  • improve telegram command planning and testability #158
  • simplify telegram loop and jsonl runner #155
  • refactor telegram schemas and parsing with msgspec #156

tests

  • improve coverage and raise threshold to 80% #154
  • stabilize mutmut runs and extend telegram coverage #157

docs

  • add opengraph meta fallbacks #150

v0.20.0 (2026-01-15)

changes

  • add telegram mentions-only trigger mode #142
  • add telegram /model and /reasoning overrides #147
  • coalesce forwarded telegram messages #146
  • export plugin utilities for transport development #137

fixes

  • handle forwarded uploads for telegram #149
  • preserve directives for voice transcripts #141
  • resolve claude.cmd via shutil.which on windows #124

docs

  • add untether-scripts plugin to plugins list #140

v0.19.0 (2026-01-15)

changes

  • overhaul onboarding with persona-based setup flows #132
  • add queued cancel placeholder for Telegram runs #136
  • prefix Telegram voice transcriptions for agent awareness #135

docs

  • refresh onboarding docs with new widgets and hero flow #138
  • fix docs site mobile layout and font consistency #139
  • link to untether.dev docs site

v0.18.0 (2026-01-13)

changes

  • add per-chat and per-topic default agent via /agent set command #109
  • add session resume shorthand for pi runner #113
  • expose sender_id and raw fields on MessageRef for plugins #112

fixes

  • recreate stale topic bindings when topic is deleted and recreated #127
  • use stdout session header for pi runner #126

docs

  • restructure docs into diataxis format and switch to zensical #121 #125

v0.17.1 (2026-01-12)

fixes

  • fix telegram /new command crash #106
  • track telegram sessions for plugin runs #107
  • align telegram prompt upload resume flow #105

v0.17.0 (2026-01-12)

changes

  • add chat session mode (session_mode = "chat") for auto-resume per chat without replying, reset with /new #102
  • add message_overflow = "split" to send long responses as multiple messages instead of trimming #101
  • add show_resume_line option to hide resume lines when auto-resume is available #100
  • add auto_put_mode = "prompt" to start a run with the caption after uploading a file #97
  • expose thread_id to plugins via run context #99
  • use tomli-w for config serialization #103
  • add voice_transcription_model setting for local whisper servers #98

docs

  • document chat sessions, message overflow, and voice transcription model settings

v0.16.0 (2026-01-12)

fixes

  • harden telegram file transfer handling #84

changes

  • simplify runtime, config, and telegram internals #85
  • refactor telegram boundary types #90

docs

  • add tips section to user guide
  • rework readme

v0.15.0 (2026-01-11)

changes

  • add telegram file transfer support #83

docs

  • document telegram file transfers #83

v0.14.1 (2026-01-10)

changes

  • add topic scope and thread-aware replies for telegram topics #81

docs

  • update telegram topics docs and user guide for topic scoping #81

v0.14.0 (2026-01-10)

changes

  • add telegram forum topics support with /topic command for binding threads to projects/branches, persistent resume tokens per topic, and /ctx for inspecting or updating bindings #80
  • add inline cancel button to progress messages #79
  • add config hot-reload via watchfiles #78

docs

  • add user guide and telegram topics documentation #80

v0.13.0 (2026-01-09)

changes

  • add per-project chat routing #76

fixes

  • hardcode codex exec flags #75
  • reuse project root for current branch when resolving worktrees #77

docs

  • normalize casing in the readme and changelog

v0.12.0 (2026-01-09)

changes

  • add optional telegram voice note transcription (routes transcript like typed text) #74

fixes

  • fix plugin allowlist matching and windows session paths #72

docs

  • document telegram voice transcription settings #74

v0.11.0 (2026-01-08)

changes

  • add entrypoint-based plugins for engines/transports plus a untether plugins command and public API docs #71

fixes

  • create pi sessions under the run base dir #68
  • skip git repo checks for codex runs #66

v0.10.0 (2026-01-08)

changes

  • add transport registry with --transport overrides and a untether transports command #69
  • migrate config loading to pydantic-settings and move telegram credentials under [transports.telegram] #65
  • include project aliases in the telegram slash-command menu with validation and limits #67

fixes

  • validate worktree roots instead of treating nested paths as worktrees #63
  • harden onboarding with clearer config errors, safe backups, and refreshed command menu wording #70

docs

  • add architecture and lifecycle diagrams
  • call out the default worktrees directory #64
  • document the transport registry and onboarding changes #69

v0.9.0 (2026-01-07)

projects and worktrees

  • register repos with untether init <alias> and target them via /project directives
  • route runs to git worktrees with @branch — untether resolves or creates worktrees automatically
  • replies preserve context via ctx: project @branch footers, no need to repeat directives
  • set default_project to skip the /project prefix entirely
  • per-project default_engine and worktree_base configuration

changes

  • transport/presenter protocols plus transport-agnostic exec_bridge
  • move telegram polling + wiring into untether.telegram with transport/presenter adapters
  • list configured projects in the startup banner

fixes

  • render ctx: footer lines consistently (backticked + hard breaks) and include them in final messages

breaking

  • remove untether.bridge; use untether.runner_bridge and untether.telegram instead

docs

  • add a projects/worktrees guide and document untether init behavior in the readme

v0.8.0 (2026-01-05)

changes

  • queue telegram requests with rate limits and retry-after backoff #54

docs

  • improve documentation coverage #52
  • align runner guide with factory pattern
  • add missing pr links in the changelog

v0.7.0 (2026-01-04)

changes

  • migrate logging to structlog with structured pipelines and redaction #46
  • add msgspec schemas for jsonl decoding across runners #37

v0.6.0 (2026-01-03)

changes

  • interactive onboarding: run untether to set up bot token, chat id, and default engine via guided prompts #39
  • lockfile to prevent multiple untether instances from racing the same bot token #30
  • re-run onboarding anytime with untether --onboard

v0.5.3 (2026-01-02)

changes

  • default claude allowed tools to ["Bash", "Read", "Edit", "Write"] when not configured #29

v0.5.2 (2026-01-02)

changes

  • show not installed agents in the startup banner (while hiding them from slash commands)

fixes

  • treat codex reconnect notices as non-fatal progress updates instead of errors #27
  • avoid crashes when codex tool/file-change events omit error fields #27

v0.5.1 (2026-01-02)

changes

  • relax telegram ACL to check chat id only, enabling use in group chats and channels #26
  • improve onboarding documentation and add tests #25

v0.5.0 (2026-01-02)

changes

  • add an opencode runner via the opencode cli with json event parsing and resume support #22
  • add a pi agent runner via the pi cli with jsonl streaming and resume support #24
  • document the opencode and pi runners, event mappings, and stream capture tips

fixes

  • fix path relativization so progress output does not strip sibling directories #23
  • reduce noisy debug logging from markdown_it/httpcore

v0.4.0 (2026-01-02)

changes

  • add auto-router runner selection with configurable default engine #15
  • make auto-router the default entrypoint; subcommands or /{engine} prefixes override for new threads
  • add /cancel + /{engine} command menu sync on startup
  • show engine name in progress and final message headers
  • omit progress/action log lines from final output for cleaner answers #21

fixes

  • improve codex exec error rendering with stderr extraction #18
  • preserve markdown formatting and resume footer when trimming long responses #20

v0.3.0 (2026-01-01)

changes

  • add a claude code runner via the claude cli with stream-json parsing and resume support #9
  • auto-discover engine backends and generate cli subcommands from the registry #12
  • add BaseRunner session locking plus a JsonlSubprocessRunner helper for jsonl subprocess engines
  • add jsonl stream parsing and subprocess helpers for runners
  • lazily allocate per-session locks and streamline backend setup/install metadata
  • improve startup message formatting and markdown rendering
  • add a debug onboarding helper for setup troubleshooting

breaking

  • runner implementations must define explicit resume parsing/formatting (no implicit standard resume pattern)

fixes

  • stop leaking a hidden engine-id cli option on engine subcommands

docs

  • add a runner guide plus claude code docs (runner, events, stream-json cheatsheet)
  • clarify the claude runner file layout and add guidance for jsonl-based runners
  • document “minimal” runner mode: started+completed only, completed-only actions allowed

v0.2.0 (2025-12-31)

changes

  • introduce runner protocol for multi-engine support #7
    • normalized event model (started, action, completed)
    • actions with stable ids, lifecycle phases, and structured details
    • engine-agnostic bridge and renderer
  • add /cancel command with progress message targeting #4
  • migrate async runtime from asyncio to anyio #6
  • stream runner events via async iterators (natural backpressure)
  • per-thread job queues with serialization for same-thread runs
  • render resume as codex resume <token> command lines
  • various rendering improvements including file edits

breaking

  • require python 3.14+
  • remove --profile flag; configure via [codex].profile only

fixes

  • serialize new sessions once resume token is known
  • preserve resume tokens in error renders #3
  • preserve file-change paths in action events #2
  • terminate codex process groups on cancel (posix)
  • correct resume command matching in bridge

v0.1.0 (2025-12-29)

features

  • telegram bot bridge for openai codex cli via codex exec
  • stateless session resume via `codex resume <token>` lines
  • real-time progress updates with ~2s throttling
  • full markdown rendering with telegram entities (markdown-it-py + sulguk)
  • per-session serialization to prevent race conditions
  • interactive onboarding guide for first-time setup
  • codex profile configuration
  • automatic telegram token redaction in logs
  • cli options: --debug, --final-notify, --version
Was this helpful?

Related Articles