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...
v0.35.4 (2026-07-22)
fixes
- fix(telegram): stop the spurious
transport.edit.failed error=Nonewarning 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 toNone, indistinguishable at theedit()layer from a realeditMessageTextfailure — 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 aSUPERSEDEDoutbox disposition threaded through all three intentional-drop paths (same-key supersede,drop_pendingahead of a delete/replace, and aRetryAfterrequeue collision); the transport treats it as a benigntransport.edit.supersededno-op and returns the ref instead of logging a failure. Send/delete ops keep theNonefailure 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 acrosstests/test_telegram_queue.pyandtests/test_telegram_bridge.py#598 - fix(pi): surface Pi stderr and diagnose silent
rc=0no-agent_endexits. 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_eventsnow 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 logshad_events/resumedfields onpi.stream.no_agent_endso the issue-watcher can promote resumed failures to error tier.stderr_linesthreaded through the polymorphicstream_end_events(base + all 6 runners). Follow-ups (out of repo / separate): error-tier promotion inuntether-issue-watcher; a real 2-turn context-retention integration test (historically U4 only checked a filesystem side-effect). 4 new tests intests/test_pi_runner.py#565 - fix(pi): wire
auto_retry_start/auto_retry_endevents. 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 asnoteactions (“retrying provider (attempt N/M, ~Xs delay)” → “retry succeeded” / “retry exhausted: …”) with a stableretry_Naction 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 intests/test_pi_runner.py; docs §4.6 indocs/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_monitorson the first dropped thestall_monitor_active_suppressedbranch, so spurious stall warnings rose again while the Monitor was legitimately running. New_is_terminal_tool_result+ anis_terminalgate 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 owntimeout_msdeadline 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 inhas_live_background_workis 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 intests/test_claude_runner.py. rc7 extended the same bounded-keep pattern toAgent/Taskviabg_agent_deadlines+BG_AGENT_MAX_KEEP_S(15 min) and recognisedTaskas a background primitive; Bash-bg, ScheduleWakeup and RemoteTrigger terminal-detection remain deferred to #573 #374 - security: SSRF-validate
voice_transcription_base_urlbefore 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 existingtriggers/ssrf.py:validate_url_with_dnsvalidator is now applied at thetranscribe_voicechokepoint (async, DNS-aware), with a syncvalidate_urlfast-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 whenbase_urlis unset (the default publicapi.openai.compath). #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 anfcntl.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 withqdrant). The kernel releases the lock automatically on process death, so manualrmrecovery 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.Ztag with a dedicatedRELEASE_TAG_PATsecret instead of the workflowGITHUB_TOKEN, so the tag push actually triggersrelease.yml(GitHub suppresses downstream workflow runs forGITHUB_TOKEN-triggered events, which forced a manualgh workflow runon every release). Restores the unattended single-gate flow. Requires a one-time fine-scopedRELEASE_TAG_PATsecret (contents: write) and removal of the now-redundantpypienvironment reviewer. #376 - fix(claude): actually reap the leaked MCP
nodechildren the earlier sweep (PR #605) still missed. Fleet audit founddembrandt-mcpon nsd leaking one child per run — itsetpgids into its own process group (distinct PGID, still in Claude’s session) sokillpg(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 onproc.returncode is None, which fails once the leader exits, and the limbo capture never fires and used direct-children-only). New_capture_orphan_descendantscaptures 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/procstarttime birth-identity token (pid_starttime) verified before each signal, so a recycled PID is never killed. 4 new tests intests/test_claude_runner.py, 3 intests/test_subprocess.py, 3 intests/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 intests/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 intests/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_implnow setslast_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 addedlast_seen_alive_stostall_detected/stall_auto_cancel, since all liveness fields collapse toNoneonce the process is gone, which invites the “PID was never threaded” misdiagnosis. Fleet-verified: zeropid=Noneoccurrences 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_subprocessnow awaitsproc.aclose()on every exit path (all 6 engines covered from one point), replacing theRuntimeError: Event loop is closedtracebacks raised fromBaseSubprocessTransport.__del__at interpreter shutdown #599 - fix(claude): stop
catalog_staleness.detectedflooding journals — ~2,930 WARNINGs per 48h fleet-wide, 96% of one host’s total warnings.status=pendingatsystem.initis a startup race and now logs INFO with per-run dedup;needs-auth/failedkeep WARNING but dedup across runs via a process-lifetime(server, status)registry instead of resetting on every subprocess spawn #595 - fix(claude): accept
imageanddocumentcontent blocks in the stream schema. Reading binary media echoed them inside user-role messages and msgspec dropped the whole line asjsonl.msgspec.invalid#597 - fix(telegram): surface the failure reason in
transport.edit.failed. The Telegram description was previously only visible in a separate, uncorrelatedtelegram.api_errorline; 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-leveltransport.edit.noopreturning 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.enablednow logscrons=<active>(agreeing withtriggers.manager.updatedandtriggers.cron.started) pluscrons_configured=<raw TOML entries>; the raw count previously read as “N crons failed to load” during triage when the delta was just spentrun_onceentries #601 - fix(voice): harden transcription against transient provider blips. Widens
AsyncOpenAIretries 2→4 so a sub-15s connection blip self-heals before reaching the user, replaces the opaque “Connection error.” with an actionable hint forAPIConnectionError/APITimeoutError, and fixes an unreachableTimeoutErrorbranch shadowed byOSError#584 - fix(voice): make transcription failures diagnosable.
openai.transcribe.errornow logs the resolved endpoint and the exception__cause__, andvoice_transcription_api_keyis validated at config load — control characters (an embedded newline from concatenated key material) and non-latin-1 characters are rejected with aConfigErrorinstead 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_usewhen the lingering post-result process is SIGTERM’d; the next--resumethen returns 0 turns / $0 / an empty answer withrc=0— a silent failure the user only sees as “nothing happened”. New persistentQuarantineStore(session_quarantine.py, JSON sibling tountether.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_result→session.quarantined→session.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_resultrather 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_bashesandlive_remote_triggershad no deadline at all, so one entry madehas_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_countgeneralises_live_bg_agent_countso 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_detectedWARNs and 2frozen_ring_escalationWARNs — while completing successfully._has_pending_approval()inferred approval state from presentation data (the latest action’sinline_keyboarddetail), which an ExitPlanMode permission request does not carry; its watchdog-side twin_recent_event_is_control_requestreads 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 asClaudeStreamState.awaiting_user_approval()and reached from the engine-agnostic bridge by the sameengine_stateduck-typing already used forhas_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 behindif not frozen_escalate; merely skipping the escalation would leave an inflated counter that trips the instant the user clicks Approve), and_total_stall_warn_countmoved behind a single_count_stall_warning()helper called only where a WARNING is actually emitted, so the metric means what its name says. Theprocess_deadauto-cancel safety valve is unchanged and still checked first. 6 new tests #495 #499 #500 - fix(claude): accept the new
tool_progressheartbeat event. Claude Code emits a top-leveltool_progressframe while a long-running tool is in flight; msgspec rejected the unknown tag and dropped the line withjsonl.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-Nsuffixedtool_use_idplustool_name/elapsed_time_seconds/heartbeat). Added a permissiveStreamToolProgressMessageto 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_returncodeis assigned only in the base runner, butClaudeRunneroverridesrun_implwholesale and never wrote it back — so the bridge always sawNone,_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 eachsession.auto_continuewith the precedingsubprocess.exit:{rc=0: 47, rc=143: 2}— two auto-continues fired straight after a SIGTERM. Fixed the assignment and tightened the gate to requirerc=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, andrc=Nonestays 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.servicefive times in one evening, killing two live Claude runs withrc=-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. Newlive_engine_subprocess_count()is instrumented inmanage_subprocess— the single spawn point every runner shares — counted there rather than fromTelegramLoopState.running_tasksbecause 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, andmax_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. Newwait_for_session_handoff(sid, timeout_s)returnsfree/exited/timed_out, condition-based so the common case costs one dict lookup; liveness reuses the existingis_session_alive/_SESSION_STDINregistries (SessionLockMixin.session_lockscannot serve — it’s aWeakValueDictionary). 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: falsefor a synchronous run” — so the flag is normally absent from the Agent/Task input._register_background_handlegated onbool(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 onforced_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 literalFalse; over-registering is the safe direction (bounded by the 600s ceiling and the 900sBG_AGENT_MAX_KEEP_Sage-out), while under-registering force-kills live work and poisons the session. Bash is deliberately untouched — itsrun_in_backgroundflag is genuinely opt-in upstream. Verified live with 3 background subagents: passed through limbo untouched, exited naturallyrc=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 completereceived a user-facing “Auto-cancelled: session appears stuck (process_dead)” 6m51s later — nothing was lost, but the notice was pure noise. Theprocess_deadauto-cancel arm now checksstream.did_emit_completedfirst: a dead subprocess whose run already emitted itsCompletedEventis 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 — INFOprogress_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-shotlimbo_detectedwarning and loop exit with a periodicsubcountdown_tickINFO (~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/procevidence does not show the tree demonstrably idle, the SIGTERM is deferred and re-checked each poll. Bounded twice — background handles age out atBG_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/procis the only available evidence. On the handoff side:wait_for_session_handoffnow 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_STATEregistry +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 newquarantined_during_handoffre-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_timeoutgainedlive_background_work/bg_hold_extended/cpu_active/tree_activeso 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 introducedis_cpu_active/is_tree_cpu_activebut wired them into only one of the two gates, giving the extension gate ademonstrably_idleveto with no symmetricdemonstrably_busyveto on the grace cap. The cap now computesdemonstrably_busy = cpu_active is True or tree_active is Trueand applies the grace only whennot 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/procaccounting #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 andrate_limit_wait_untilstayed unlatched, so a throttled session read as idle to the watchdog and risked being mistaken for hung. A bare event now latchesDEFAULT_BARE_RATE_LIMIT_WAIT_S = 60swithretry_after_source="default", makingawaiting_rate_limit_retry()directionally correct, and the estimate surfaces as “waiting to retry (~60s)” #657 - fix(claude): let the
ExitPlanModeplan 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. Theplanfield of theExitPlanModeinput 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
/configRouting 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_returncodeon 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 inrun_impl’s try body right afterawait proc.wait()— so cancellation (/cancel,/new, drain), an exception in the task group / JSONL reader, or the early pipesRuntimeErrorall skip it and leaveproc_returncodeNone. On those paths_is_signal_death(None)is False andproc_returncode not in (0, None)passesNonethrough, 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 beforerun_impl’sfinally, so the code is available there with no extrawait(); the capture now happens in thefinally, guarded for the paths whereproc/streamwere never bound. New cancel-mid-flight harness test driving a realClaudeRunnersubprocess + ahang_before_resultfake-CLI scenario #667 - fix(claude): make the fail-closed handoff probe attributable and correct a stale settings comment (#633 follow-up).
session.handoff_check_failedbound onlyexc_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 bindsengine/session_id/chat_id, matching the siblingsession.handoffevents. Also corrects theserialize_session_ownercomment insettings.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 totest_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 restartissued 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)./restartrecords the originating chat for the precise case; SIGTERM relies on theactive_runs == 1heuristic. 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.
/qualityscreenshot 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>.zipdocument. Security: recursivedeny_globsapplied 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 anO_NOFOLLOWdescriptor withfstatvalidation (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 intests/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) andstream_idle_max_retriesnow 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 aclaude.stream_idle.auto_retrystructured log #572 - feat(telegram): new
[transports.telegram] voice_transcription_languageISO-639-1 hint, passed to the Whisper APIlanguageparameter. 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
ExitPlanModeimmediately after a denial (#126 lineage). Verified fixed upstream on CLI 2.1.215 — a deniedExitPlanModenow yields a clean text turn with no re-issue — so_DISCUSS_COOLDOWNand 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 anExitPlanModecontrol_request via the Telegram buttons and watch for an immediate re-issue #570 - changes(watchdog):
runner.limbo_detectednow 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 withlive_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
/cancelhint #654 - feat(fleet):
scripts/fleet-status.shgives a one-shot read-only version/state view across all 5 hosts, plus rollout attestation guardrails and a/pingverification playbook #627 - feat(scripts): SHA-bind the integration-test attestation marker.
scripts/run-integration-tests.shnow records thehead_shaof the tested commit (auto-derived from the script’s repo, overridable via--head-sha/UT_INTEGRATION_HEAD_SHA) and adev_bot_id(default overridable viaUT_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 intests/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.pyscenarios drive a realClaudeRunnerandhandle_messagethrough the real spawn / PTY / msgspec pipeline: the quarantine-and-fresh recovery, a healthy-resume negative control, the linger-scenario emission shape, and aresume_survives_sigtermleg modelling a prior owner that ignores SIGTERM — asserting the core invariant that exactly one spawn occurs and it is the fresh leg, never a--resumeof 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_handlealways does #634 - tests: raise the
anyio.fail_afterhang-guards in therun_main_looptests 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_continueis a single symptom-based predicate, and at the decision point the subprocess has already exited and the two upstream defects are observationally identical (aresultframe excludes the predicate rather than discriminating between them).background_observedcorrelates 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-watcherevent set and msgspec dedup signature incontrib/README-issue-watcher.md, closing the four monitoralready_trackedpatterns that had no watcher coverage. Addsjsonl.msgspec.invalid(per-gap dedup keyed on invalid-value@JSONPath),progress_edits.stall_auto_cancel, andprogress_edits.frozen_ring_escalation;stall_detectedis 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_idsis now a startupConfigErrorinstead 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 withallow_any_user = true, which is logged at INFO every boot (security.allow_any_user) so the deviation stays visible injournalctl. 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 inconfig_migrations.pynow relocates a top-levelallow_any_userkey into[transports.telegram]alongsidebot_token/chat_id. New_validate_allowed_user_ids_or_optin@model_validatorinTelegramTransportSettings. 4 new tests intests/test_settings.py(block + opt-out + populated + both-set) #377
changes
- feat: Gemini runner now passes
--skip-trustby 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 withyolofor the same “always headless” reason, so passing--skip-trustextends the same precedent.GeminiRunner.skip_trust(defaultTrue) is the runtime switch; opt out per deployment with[gemini] skip_trust = falseinuntether.toml(security-conscious operators who want Gemini’s project-local extension/MCP trust gate enforced). 2 new tests intests/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_rpsinuntether.tomlnow 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 ofhandle_message(still no restart-required, just verified); the only gap was[progress], whereMarkdownFormatter(max_actions, verbosity)andExecBridgeConfig.min_render_intervalwere baked in at startup intelegram/backend.py. Closed by addingMarkdownFormatter.refresh_from(progress_settings)andTelegramPresenter.refresh_progress_settings(), plus a newrunner_bridge._load_progress_settings()sibling helper thathandle_messageinvokes per-run; the runner bridge now refreshes the default presenter’s formatter (per-chat/verboseoverrides downstream of_resolve_presenterreconstruct from the refreshed defaults so they pick up the new values too) and threads the livemin_render_intervalinto eachProgressEditsinstance instead of the startup snapshot. Out of scope (entry-point limitation, documented on the issue): engine registration and command registration — those still requirepipx upgrade/ restart. 8 new tests intests/test_meta_line.py(TestMarkdownFormatterRefresh: max_actions, verbosity, negative-clamp, invalid-verbosity rejection, missing-attribute tolerance, presenter delegation; plus_load_progress_settingsdefaults / 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 = truefor an explicit kill-switch). Mechanism:ClaudeStreamState.result_received_atis armed bytranslate_claude_eventon everyStreamResultMessage; a newClaudeRunner._post_result_idle_watchdogtask started in therun_impltask group polls the timer and callsthis_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 excludeslast_event_type == "result"(locked bytest_skips_result_event_typefrom #34142’s regression set) so the clean exit will not phantom-resume the session. Approval-state guard: if_REQUEST_TO_SESSIONor_PENDING_ASK_REQUESTShas 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 supplementaryStartedEventcarryingmeta={"complete": "✓ turn complete"}(the supported pattern for late-arriving meta perrunner-development.md);markdown.format_meta_linerenders 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) andclaude.post_result_idle.closing_stdin(when the deadline passes cleanly). 6 new tests intests/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:tgpage expansion) + Tier 3 (last_fired_athistory +/statstriggered/manual breakdown). The/config → ⏰ Triggerspage now lists every cron and webhook configured for the current chat — for crons, the human-readable schedule viadescribe_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 (usingcrons_for_chat/webhooks_for_chatwith the bridgedefault_chat_idfallback), 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 recordstime.time()after every successful cron dispatch (triggers/cron.py:130post-dispatch_cron) and webhook fire (triggers/dispatcher.py:dispatch_webhookanddispatch_actionfor non-agent actions). Recording is best-effort —OSErrorwrites logtriggers.history.write_failedand swallow so a transient disk failure can’t break the cron loop or webhook server./statsnow appends(N triggered, M manual)per engine line and on the totals row when at least one count is > 0;DayBucketandAggregatedStatscarry additivetriggered_count/manual_countfields with.get(..., 0)fallbacks so existingstats.jsonfiles load cleanly.runner_bridge.handle_messageresolves the split viatriggered=bool(context and context.trigger_source)at the existingrecord_runcallsite. Newtriggers_history.jsonstate 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 acrosstests/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), andtests/test_trigger_dispatcher.py(2 webhook recording + history-failure resilience) #271 - feat: subscription-usage observability +
/usage debugsection. Promotes theclaude_usage.schema_mismatchstructlog 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 cumulativecountfield; newrunner_bridge.get_usage_schema_mismatch_count()exposes the same counter for the debug page). AddsUsageCacheStatstoutils/usage_cache.pytracking last successful fetch wall time, cache age, last-error class+message; populated byfetch_claude_usage_cachedon every fetch path including stale-while-error fallbacks. Adds_read_token_expiry_ms()totelegram/commands/usage.pyso the OAuth token expiry can be surfaced without raising on missing credentials. New/usage debuginvocation appends a🔧 debugblock (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 greppingjournalctl. 5 new tests intests/test_usage_cache.py::TestCacheStatsObservability(initial state, success records wall time, failure records last error, success-then-failure preserves wall time) andtests/test_command_engine_gates.py::TestUsageDebugMode(debug section appended only whenargs_text == "debug"); existingtest_schema_mismatch_warning_fires_oncerepurposed to assert per-call firing with cumulative counts #410 - feat:
CLAUDE_STREAM_IDLE_TIMEOUT_MSis now user-configurable via[watchdog] claude_stream_idle_timeout_msinuntether.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 viasetdefaultso shell-setCLAUDE_STREAM_IDLE_TIMEOUT_MSstill 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 withAPI Error: Stream idle timeout - partial response received, the_extract_erroroutput 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 intest_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_pausedAPI; cron scheduler skips its tick while paused (run_oncecrons are not consumed during the pause and fire on the next matching tick after resume); webhook server returns503 triggers paused(withRetry-After: 60) instead of dispatching, and the/healthendpoint 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/configtwo ways: a one-button toggle row at the bottom of the home page (only when triggers are configured) and a dedicated📡 Triggerspage (config:tg) with state + counts./pingswitches to a⏸ triggers paused: … (suspended)indicator while paused. 8 new tests intest_trigger_manager.py(TestPauseToggle), 2 intest_ping_command.py(paused/resumed indicators), 5 intest_config_command.py(TestTriggersPage) covering unavailable / empty / pause / resume / toast labels #294 - feat:
[claude]config gainsextra_args: list[str]— user-supplied upstream CLI flags passed through toclaudeverbatim. Mirrorscodex.extra_argsandpi.extra_args. Primary motivator is Claude-in-Chrome: Claude Code 2.1.x gates themcp__claude-in-chrome__*tool namespace behind--chrome(orCLAUDE_CODE_ENABLE_CFC=1), so Untether-spawned sessions never saw those tools in their catalogue. Settingextra_args = ["--chrome"]in~/.untether/untether.tomlnow 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 aConfigErrorso 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 intests/test_build_args.pycover argv ordering, permission-mode argv, multi-flag order preservation,build_runnerparsing, and reserved-flag rejection (individual flag +key=valueprefix 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 forkingutils/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 tofiltered_env) and by theenv_auditprobe (so user-allowed names aren’t false-flagged asclaude.env_audit.leaked_var). Oneenv_policy.user_extensionINFO 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 acrosstest_env_policy.py,test_env_audit.py,test_settings.py#409 - feat:
/triggercommand renamed to/listento 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/configgrew separate trigger pages./listenis now the canonical command;/triggercontinues to work as a deprecated alias for one release cycle and prepends a one-line deprecation notice on each invocation./config → 📡 Listenpage replaces the prior📡 Triggerpage; the home-page summary rendersListen: allinstead ofTrigger: all; bot command menu listslisten. Internal renames:telegram/trigger_mode.py→telegram/listen_mode.py;commands/trigger.py→commands/listen.py; typeTriggerMode→ListenMode;resolve_trigger_mode()→resolve_listen_mode(); ChatPrefsStore / TopicStateStore gain new*_listen_modemethods with legacy*_trigger_modealiases preserved for one cycle. Storage: msgspec field is still namedtrigger_modefor backward compat with existingtelegram_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 walksProgressTracker._actionsand bumpsevent_seqwhenever any open action’sstarted_atis older than 60 s, forcing a re-render with a fresh elapsed counter; (2)format_action_linegained anelapsed_secondskwarg that appends· <elapsed> · <key arg>for non-completed actions, regardless of the/verbosetoggle.format_verbose_detailgained dedicated branches forBashOutput(renders the last line ofresult_previewso 10-min Cloudflare deploy polls show→ Deploy Production: in_progressinstead of a static▸ BashOutput),KillShell,ScheduleWakeup(countdown + reason:→ fires in 4m 12s · "build check"), andMonitor(countdown remaining).ActionStategainedstarted_at/last_update_atwall-clock fields populated from theProgressTracker.clockcallable (defaults totime.monotonic; tests can pass a fake clock for deterministic assertions). The render pipeline (MarkdownFormatter.render_progress_parts,MarkdownPresenter.render_progress,Presenter.render_progressProtocol,TelegramPresenter.render_progress) all gained an optionalnow: float | Nonekwarg threaded fromrunner_bridge._run_loop. Newformat_duration/format_countdownhelpers inmarkdown.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 acrosstests/test_verbose_progress.py(BashOutput / KillShell / ScheduleWakeup / Monitor detail + long-running tail variants + format_duration helpers) andtests/test_exec_bridge.py(heartbeat-driven countdown mutation) #481 - feat: expected-wait stall suppression matrix — five new info-logged branches in
ProgressEdits._stall_monitorsuppress Telegram stall warnings during legitimate waits, gated by aif not frozen_escalatemaster gate so genuinely-frozen sessions still warn. Branches: (1)progress_edits.stall_post_result_suppressed—stream.last_event_type == "result"andengine_state.result_received_atarmed (the post-result idle watchdog from #333 is the legitimate owner of the silence); (2)progress_edits.stall_schedule_wakeup_suppressed—engine_state.live_wakeupshas any deadline in the future (Claude is parked waiting for an upstream timer); (3)progress_edits.stall_monitor_active_suppressed—engine_state.live_monitorshas 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 withinstall_threshold/2(the polling cycle is the proxy for “stdout is flowing”). The same 5 booleans gate the_STALL_MAX_WARNINGSauto-cancel arm with a newprogress_edits.stall_auto_cancel_suppressed_expected_waitlog — a session about to gracefully close (#470) or legitimately waiting on a timer must not be killed. structlog WARN events atrunner.py:1002(subprocess.liveness_stall) andrunner_bridge.py(progress_edits.stall_detected) remain unchanged sountether-issue-watcherand ops dashboards continue to receive them — only the chat-side surfacing decision changed. Bash/BashOutput suppression usestracker._actionsengine-agnostically (mirrors_has_running_mcp_tool); ScheduleWakeup / Monitor / post-result usegetattr(stream, "engine_state", None)duck-typing (Claude only today, no-ops cleanly for other engines). 11 new tests intests/test_exec_bridge.pycovering 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. Newloop_schedulermodule sibling ofat_scheduler(mirrors install/uninstall/active_count API) with persistence toactive_loops.jsonfor 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_handlebackground-task tracker) parse the canonical Probe-5-confirmed field names (cronnotcron_expression;idnottaskId/cronId) and bind upstream 8-character cron IDs via\bjob ([0-9a-f]{8})\b. Race avoidance gates fire onis_session_alive(added pre-#289 as3362ae9) — if the subprocess is still parked on a control_request, the fire path sleepsredundancy_check_intervaland retries instead of double-firing. Drop-on-busy viais_chat_busycallable mirrors upstream’s “no catch-up” semantic. Re-issue prompts wrap the original user prompt withLoop 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 callscost_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_exitpollsloop_scheduler.active_count()alongsidepending_at./canceland/newboth callcancel_pending_for_chatwhich writes the do-not-resume sentinel for the cancelled session (block only loop_scheduler--resume, not/continueper handover default). New_page_loop()sub-page in/configwith explicit cost+quota warning before turning ON; engine-aware (Claude only —LOOP_SUPPORTED_ENGINES = frozenset({"claude"}));💰 Set a budgetdeeplink toconfig:cufor 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 --resumedoes NOT restore session-scoped cron tasks inclaudev2.1.129/2.1.132 in--printmode (Probe 1), so Untether owns ALL firing across both CronCreate and ScheduleWakeup tool families. 58 new tests acrosstests/test_loop_scheduler.py(41),tests/test_claude_runner.py::TestLoopObservation(10 + 1 sync), andtests/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_groupintelegram/commands/media.pyresolved the run context from topic state only, never consulting the per-chatChatPrefsStore(/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 nodefault_projectand no projectchat_id(e.g. the channelo VPS, where the config validator forbids a projectchat_idequal totransports.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 sincemedia.pywas introduced (pre-0.35.0); the channelo single-project-DM deployment is the first config shape to expose it. Threadedchat_prefsthroughMediaGroupBuffer→_handle_media_groupand mirrored thebuild_message_contextladder (with a# keep in synccomment); regression testtest_media_group_uses_chat_prefs_bound_contextadded intests/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_skippedon the normal-completion path inrunner_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 therun_ok=Falsefailed-run branch — and both silently dropped the agent’s intended deliverable. rc20 extracts the surfacing logic into_surface_outbox_skippedinrunner_bridge.pyand wires it into both gap paths. Failed runs still skip the actual file send (preserving the original gating) but do a cheapscan_outbox()to collect skipped items and surface them so the user always learns what the agent intended to ship. Honours the existingoutbox_notify_skippedconfig flag and filters the…overflow pseudo-entry. Tests intests/test_exec_bridge.pycover failed-run surfacing,notify_skipped=falsesuppression, 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_detectedWARN to a paced INFO when_has_pending_approval()returned true, but the watchdog-side detector inrunner.py(which emitssubprocess.liveness_stall— the signaluntether-issue-watcheractually 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_requesthelper inrunner.pyusing the stream’srecent_eventsring buffer; plumbs the predicate into_watchdog_loopso when the last JSONL event iscontrol_requestit emitssubprocess.approval_pendingINFO instead ofliveness_stallWARN, 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 intests/test_exec_bridge.py(failed-run surfacing, suppression flag, two-tier first-reminder threshold, reworded copy) andtests/test_exec_runner.py(predicate truth-table coverage, watchdog demotion via integration with a fake codex script emittingcontrol_request, watchdog WARN still fires when nocontrol_requestis recent) #526
- #524 — outbox skipped items surfaced across all completion paths: rc19 (#555) added
- fix: rc19 —
/monitorcampaign 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 afterAskUserQuestiontext 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_echohelper. Regression tests intests/test_loop_coverage.py#528 - #525 — dedup
cancel.requestedtriple-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. Repeatcancel_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.warningconsolidated to singlesetup.summaryINFO perconfig.reload.applied. Previously every reload emitted onesetup.warningper engine not on PATH (5 WARNs on a single-engine host like channelo, which runs only Claude); the noise padded WARN filters inuntether-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//usageform #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.tomlfollows up withBash 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 120stimeout_s, force-exits withoutbox.fail_pending count=1(agent’s final answer silently dropped), then restarts. Axis 1 (commit12cf4ca):_DEFAULT_PREAMBLEinrunner_bridge.pynow warns agents thatuntether.tomlis hot-reloaded andsystemctl restartis unnecessary. Axis 2 (commit40dc6b7, paired with #548): Telegram confirmation message on successful reload (“♻️ Hot-reloaded …”) — closes the feedback-loop gap that drove agents to reach forsystemctl restartas 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-reloadeduntether.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_querybypasses 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. Allcallback.answered.latency_msvalues now stay near baseline regardless of click cadence #546
- #528 —
- fix: rc18 —
_post_result_idle_watchdogpost-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 viatask_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): whenreader_donefires whileproc.returncode is None, the new_post_result_subcountdownre-arms a stdout-closed countdown, defers on pending control_request / ask_question, then SIGTERMs the process group aftertimeout_s, 5 s grace, SIGKILL if still alive. Newtask_exitedreasons:reader_done_but_alive_timeout,subprocess_exited_during_subcountdown. Tier 2 (runner_bridge.py): new_POST_RESULT_LIMBO_THRESHOLD_S = 660.0class 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-shotprogress_edits.post_result_limbo_detectedwarning. Tier 3 (claude.py): newrunner.limbo_detectedwarning fired 30 s into the subcountdown when the subprocess is still alive — picked up automatically byuntether-issue-watcherforauto:error-reportfiling on future regressions. Task 4a (runner.py+claude.py):JsonlStreamState.lifecycle_state+_transition_lifecycle()helper emitssubprocess.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 intests/test_claude_runner.py, 3 intests/test_exec_bridge.py) #333 - #550 — AskUserQuestion final-keyboard clear: after the user answers the last question in a multi-question
AskUserQuestionflow, the inline keyboard on the question message is now stripped viactx.executor.edit(Approach A from the rc18 handover). Previously the buttons stayed clickable and firedask_question.flow_missingwarnings since the flow state was already cleaned up. Failure modes preserved:answer_ask_question_with_optionsreturningFalseleaves the buttons in place (so the user can retry);ctx.executor.editraising logsask_question.keyboard_clear_failedbut does NOT block the answer-sent return. 4 new tests intests/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_filesplumbing 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; newoutbox.delivered_pre_auto_continueinfo +outbox.auto_continue_delivery_failedwarning logs. Tier 1: the auto-continue Telegram notice text changed from⚠️ Auto-continuing — Claude stopped before processing tool resultsto🔁 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.summarynow includes a stablestall_suppressions=expected_wait:N,post_result:N,children_active:Nsummary 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
- #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
- fix: rc17 —
_post_result_idle_watchdogentry/exit/tick instrumentation (#333) +last_bg_bash_launched_atscalar (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 sessionb5c1c3e0-…withpending_wakeup=False— i.e. NOScheduleWakeupinvolved, so the #544 fix didn’t apply. Logs showedpost_result=True(sostate.result_received_atIS set),[watchdog]config used the defaultpost_result_idle_enabled=true, and the subprocess + children stayed alive (soreader_donewas NOT set) — yet zeroclaude.post_result_idle.closing_stdin/…deferredlog lines existed despite elapsed ≫ 600 s. Three of the four #333 candidates ruled out via logs + livepy-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_watchdognow emitsclaude.post_result_idle.task_started(session_id, timeout_s, poll_interval_s) at entry;claude.post_result_idle.tickevery 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; andclaude.post_result_idle.task_exited(reason ∈reader_done|stdin_closed|cancelled|loop_exited) in a guaranteedfinally. Per-ticktry/except(not loop-wide) mirrors_subprocess_watchdog/_drain_catalog_refreshconventions so a transient error never cancels the sibling_iter_jsonl_eventstask 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_atscalar:_clear_background_handle(claude.py:550) popslive_bg_basheson tool_result mirroring the original #507 ScheduleWakeup defect that #544 fixed via a scalar high-water-mark; newClaudeStreamState.last_bg_bash_launched_at: float | Noneis set in_register_background_handleat theBash + run_in_backgroundbranch, NOT cleared in_clear_background_handle, and reset on the same fresh-user-prompt path that resetslast_schedule_wakeup_arm_delay. Critically a LAUNCH tracker, not a LIFETIME tracker — bg-bashes can outlive multiple user turns (longnpm 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 intests/test_claude_runner.py(5 scalar lifecycle + 2 watchdog instrumentation coveringtask_started/tick/task_exitedordering and thereader_doneexit 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 —
ScheduleWakeuppost-result hold-open redux. The rc11 #507 fix added astate.live_wakeups_arm_delay: dict[str, float]populated in_register_background_handleand read in_post_result_idle_watchdogto shorten the 600 s timeout tomax_armed_delay + 60 swhen /loop is OFF. But the dict was wiped by_clear_background_handleon the ScheduleWakeup tool_result — which is the schedule-confirmation, not a terminal signal — so by the time the watchdog ticked (after theresultevent, which lands AFTER tool_result) the dict was empty and the dead-wakeup shortcut never engaged. Live impact: channelo VPS auditor-toolkit sessiond11739ee-…on rc15, 24+ min hold-open withpending_wakeup=Falsedespitelast_action='tool:ScheduleWakeup (done)'. Replaced the per-tool_id dict withClaudeStreamState.last_schedule_wakeup_arm_delay: float | None— a per-turn scalar high-water-mark (maxsemantics for multi-wakeup turns) that survives_clear_background_handleand resets on each fresh user prompt (StreamUserMessagewith non-tool_result content; mixed batches preserve the scalar). 4 new tests intests/test_claude_runner.pycover 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_eventlogs no longer dropretry_after_son subscription-cap (reset-window) throttles. The Claude CLI emits two shapes ofrate_limit_event: a full form carryingretry_after_ms(already covered) and a bare/reset-window form that carriesrequests_reset/tokens_resetISO timestamps but noretry_after_ms. Untether’s translate path only consumedretry_after_ms, so reset-window events fell into the “no retry hint” branch —retry_after_sstayedNone,ClaudeStreamState.rate_limit_total_snever 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 thebipchat that preceded a subscription-cap exhaustion across 3 chats — every event loggedretry_after_s=None cumulative_s=0.0despite the upstream payload containing actionable wait info. New_derive_retry_after_s(info)helper inrunners/claude.pypicks the EARLIER ofrequests_reset/tokens_reset(the rate limit lifts as soon as either budget refills), clamps ≥ 0, tolerates bothZand+00:00ISO suffixes, and returnsNonefor unparseable / missing timestamps. The translate path now falls back to the derived value whenretry_after_msisNoneand tracks which path fed the field via a newretry_after_source=retry_after_ms|reset_tslog key. The structuredclaude.rate_limit_eventis also enriched to include every presentRateLimitInfofield underinfo=...(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 viaerror_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 intests/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_sentper-session debounce. The opt-in[watchdog] notify_catalog_refresh = truepath (#365) previously enqueued onemcp_statuscontrol_request on everytool_resultbatch, with no minimum interval. The 2026-05-09 monitor audit observed this firing 183 times in a single ~18 min Claude run on thescoutproject — a “storm” that floods the runner’s stdin and Claude Code’s catalog-status query path. NewWatchdogSettings.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-sessionlast_catalog_refresh_queued_atmonotonic-clock check intranslate_claude_event’sStreamUserMessagearm. 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 throughClaudeRunner._init_state_from_settingsso live reloads pick up the value on next session. The existingtest_tool_result_queues_mcp_status_when_notify_enabledtest was updated to drivetime.monotonic()past the debounce window between the two queue assertions; 2 new tests intests/test_claude_runner.py(test_tool_result_debounces_back_to_back_batchesreproduces the ‘scout’ storm conditions — 10 batches 100 ms apart yield exactly 1 refresh — andtest_tool_result_debounce_disabled_with_zero_intervalconfirms the off-switch) #497 - fix: rc14 —
session.summarygains aliveness_stallsfield,cpu_activenow returns an accurate bool instead ofNone, 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_loopand the stall-monitor render path. (A)session.summarypreviously loggedstall_warnings=0despitesubprocess.liveness_stallfiring — by design:_total_stall_warn_countis the user-facing-threshold counter (runner_bridge.py:1143),subprocess.liveness_stallis the subprocess-health canary in the watchdog loop (runner.py:1023). Conflating them would break the user-facing invariant. NewJsonlStreamState.liveness_stalls: int(0 or 1 today —liveness_warnedlatches after the first warning; kept asintfor forward-compat) is surfaced as a newliveness_stalls=field in thesession.summarylog so the two signals can be observed independently. (B)prev_diagwas initialised toNoneand only assigned after the one-shot warning fired, sois_cpu_active(None, diag)always returnedNoneon the warning. Now takes a baseline snapshot on the first successful poll. SEMANTICS CAVEAT: the auto-kill check atrunner.py:1039iscpu_active is not True. TodayNonealways satisfies that, so the auto-kill path triggers (combined withtcp_established == 0). After this fixcpu_activeis an accurate bool — still-active processes returnTrue(skip kill); genuinely-idle ones returnFalse(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 themcp_server is not Nonearm renders ”⏳ Awaiting your approval ({mins} min)” instead,pending_approvalis excluded from_genuinely_stuck, and_tool_name = Noneinitialisation is lifted to the top of the message block to fix a latentUnboundLocalErrorthat would have hit other branches. 3 new / updated tests intests/test_exec_runner.py(test_jsonl_stream_state_defaults,test_liveness_stall_increments_counterdriving a real subprocess past_LIVENESS_TIMEOUT_SECONDS=0.2) andtests/test_exec_bridge.py(test_stall_fires_after_approval_thresholdupdated to assert the approval-aware message copy) #494 - fix: rc12 —
ExitPlanModeplan-body prepend (the rc11 #508 substantive-summary fix) is no longer subject to a cross-chat leak under concurrent Claude sessions. The rc11 implementation readrunner.current_stream.last_exitplanmode_planfrom the bridge AFTER receiving theresultevent, butcurrent_streamis the runner’s most-recently-arrived stream pointer — under two concurrent Claude sessions A and B, if A’sresultarrived while B was mid-translation, the bridge could read B’s plan body into A’s final answer. Moved the prepend onto the per-streamStreamResultMessagetranslation path inrunners/claude.pywhere the plan body is already captured intoClaudeStreamState.last_exitplanmode_planand is scoped to that exact stream’s state object. The bridge-side_prepend_exitplanmode_plancall 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 (failedresultevents don’t prepend). Live smoke on@untether_dev_botconfirmed 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_answerfailed on every paraphrased run, so the plan body was unconditionally concatenated in front of the post-approval text. Staging@hetz_lba1_botv0.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 MCPsearch_messagesliteral📋 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_plansubstring 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 intests/test_preamble.py(regression-locks the rc11 verbosity-driving phrases out of_DEFAULT_PREAMBLE, plus length-gate / body-cap / substring-skip cases) and 2 intests/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
resultanswer of 584 chars (the brief plan-body acknowledgement extracted via thelast_assistant_textempty-resultfallback), 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_PREAMBLEinrunner_bridge.pynow includes a Plan-mode requirements section instructing Claude that (A1) theExitPlanModeplanparameter 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 Createdsummary bullet now asks for inline key findings, not just a path pointer. Layer E (capture & re-emit) — newClaudeStreamState.last_exitplanmode_planfield is populated fromtool_use.input.planwhenever Claude callsExitPlanMode, captured in theStreamToolUseBlockarm oftranslate_claude_event. The previously-dead_outline_prefixmatcher inrunner_bridge.handle_messageis 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-approvalfinal_answerdoesn’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 acrosstests/test_preamble.py(A1/A2/A3 clauses present + 5_prepend_exitplanmode_plancases: short final, substring-skip, no-plan, empty, None) andtests/test_claude_runner.py(test_translate_exitplanmode_captures_plan_body,test_translate_exitplanmode_ignores_empty_plan_body) #508 - fix: rc11 —
ScheduleWakeupcalls outside/loop dynamic modeno longer hold the Claude session alive indefinitely. Live impact: session845cfcc3-…on staging v0.35.3rc10 sat post-result idle for 58 minutes before manual/cancel(peak_idle_seconds=3502.3,stall_warnings=15) — the upstreamScheduleWakeuptool 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_watchdogwaited the full 600 s timeout while_has_pending_wakeup()correctly suppressed stall auto-cancel. Fix: detect the dead-wakeup case inClaudeRunner._post_result_idle_watchdog(claude.py:2406) by reading the existingstate.live_wakeupsregistry (#481) plus a new parallelstate.live_wakeups_arm_delaydict that captures the originaldelaySecondsat arm time (the deadline value inlive_wakeupsis 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 tomin(timeout_s, max_armed_delay + 60.0)so the session closes within delay+grace instead of the default 600 s. The closing structlogclaude.post_result_idle.closing_stdingainseffective_timeout_sanddead_wakeupkeys so untether-issue-watcher can surface the new shortcut path. With/loopON, the shortcut never fires — legitimate background work keeps the full default timeout. 2 new regression tests intests/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_eventsnow breaks the read loop after aCompletedEvent, 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 oniter_json_lineswaiting 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 intests/test_exec_runner.py(test_base_iter_jsonl_breaks_on_did_emit_completed) using a stubiter_json_linesthat yields aTurnCompletedline then awaits an unfiredanyio.Event()— without the break the test hangs past the 2 sfail_afterdeadline #505 - fix: Claude schema now recognises
server_tool_useandadvisor_tool_resultcontent block types — Anthropic server-side tools (web_search, code_execution, computer_use, …) and the parent agent’sadvisor()meta-tool result blocks. Previously msgspec rejected the whole JSONL line withValidationError: Invalid value 'server_tool_use'(or'advisor_tool_result') and the runner silently dropped tool-use info — no progress action in Telegram, no entry instate.pending_actions, no input to verbose-mode rendering or cost tracking. Sampling 24h of staging traffic on 2026-05-08 showed pairedserver_tool_use+advisor_tool_resultevents firing across 5 different projects (auditor-toolkit, scout, brand-copilot, aushistory) and 5 different sessions. New msgspec structsStreamServerToolUseBlock(mirrorsStreamToolUseBlock: id/name/input) andStreamAdvisorToolResultBlock(mirrorsStreamToolResultBlock: tool_use_id/content/is_error) join theStreamContentBlockunion;translate_claude_event’s match arm for assistant content widens to share the existing tool_use body forserver_tool_use(_register_background_handleand_observe_loop_tool_usealready filter on tool name and no-op cleanly for unrecognised server tools), and the user-messageisinstancecheck widens to share the tool_result body foradvisor_tool_result. No new helpers, no new branches — server tools render via the existingformat_verbose_detail(web_search has a verbose handler; code_execution / computer_use fall back to▸ <tool_name>). 5 new tests: 3 intests/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 intests/test_claude_runner.py(test_translate_server_tool_use_block,test_translate_advisor_tool_result_block) cover translation,pending_actionslifecycle, andlast_tool_use_idstamping #489 - fix: AskUserQuestion multi-question flow no longer crashes Untether with
TypeErrorafter 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_messageconstructed aRenderedMessagefor the next question’s option-button keyboard but passed it to asend_plainpartial whosetext:kwarg expectsstr, raisingTypeError: sequence item 0: expected str instance, RenderedMessage foundinsidemarkdown.assemble_markdown_partsand propagating up to kill the entire Untether process (systemd auto-restarted in ~10s with no Telegram update loss thanks tooffset_persistence.py, but ALL active runs across all chats were lost). Refactored: the multi-question continuation logic is now a module-level helpersend_next_ask_question_messageintelegram/commands/ask_question.pythat callstransport.senddirectly with aRenderedMessagecarrying HTML parse_mode + inline_keyboard.route_messagecalls the helper for the text-reply continuation path; the callback-button continuation path still edits in place viactx.executor.edit(unchanged). 2 new regression tests intests/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 stampRunContext.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/atfires were the only trigger source whose footer was indistinguishable from a regular user-initiated run.at_scheduler.schedule_delayed_runnow wraps the captured chat context (or a freshRunContextif the chat is unmapped) withdataclasses.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 existingtriggered=bool(context and context.trigger_source)gate also picks up/atruns in the/statstriggered/manual breakdown, no extra wiring needed. 1 new test intests/test_at_command.py(test_handle_stamps_trigger_source_on_mapped_chat); the existingtest_handle_captures_global_default_when_unmappedextended to assert the trigger_source-only RunContext path; the existingtest_run_delayed_forwards_captured_context_and_engineupdated 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
ControlRewindFilesRequestandControlMcpMessageRequest(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 themcp_message.messagepayload (a compromised MCP server is the inherent MCP threat model, not specific to auto-approve), andrewind_filesis 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 intests/test_claude_control.py::TestAutoApproveSafetyInvariantthat 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_keyis nowSecretStr(parity withbot_tokenfrom #196). The value is masked inrepr()/str()/tracebacks and any accidental structlog serialisation. Access goes via.get_secret_value()at the sole transport boundary intelegram/loop.py:2208before passing to the OpenAI SDK; everything in between (TelegramBridgeConfig.update_from, hot-reload) handlesSecretStr | Noneend-to-end. Empty / whitespace-only configured values round-trip toNoneto preserve the priorNonEmptyStr | Nonecontract #378 - security: daily cost tracker no longer loses updates under concurrent calls.
cost_tracker._daily_costpreviously did an unguarded read-modify-write — two concurrentrecord_run_costcalls 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 athreading.Lock;get_daily_cost()also acquires the lock for snapshot consistency. Functions stay synchronous — the critical section is a single tuple assignment (sub-microsecond) andthreading.Lockcovers both async (cooperative) and threaded callers. NewThreadPoolExecutor-based fuzz test (16 workers × 200 calls) asserts atomicity #379 - security: prompt content moved out of INFO logs. The
runner.startlog used to carryprompt=<first 100 chars>. Prompts can contain credentials, PII, or proprietary code; INFO logs are typically the most broadly-accessible tier.runner.startnow keepsprompt_lenandargsonly; a newrunner.start_promptevent at DEBUG carries the preview when explicitly opted in #205 - security: Claude runner override of
runner.startno longer leaks prompt content at INFO.runners/claude.py:run_implhad its own duplicaterunner.startcall that was missed when the base runner was fixed for #205 — it kept emittingprompt=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+argsat INFO,runner.start_promptpreview at DEBUG. Argv redaction tightened too —redact_env_i_argsstripsenv -i KEY=VALpairs (#361 was already doing this forsubprocess.spawnbut not forrunner.start), and legacy-mode (nopermission_mode) argv has the trailing-- <prompt>collapsed to-- <prompt redacted>so prompt content never reaches INFO under any code path. 2 new regression tests intests/test_claude_runner.py(test_runner_start_does_not_log_prompt_at_infocovering control-channel mode,test_runner_start_redacts_legacy_mode_prompt_in_argscovering legacy-pmode) #478 - security: AMP runner default flipped —
dangerously_allow_allis nowFalseby default, requiring an explicit[amp] dangerously_allow_all = trueto 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
0o700mode and any pre-existing dir getschmod’d to0o700so other users on shared hosts can’t read Pi session JSONL #207 - security:
_sanitise_stderrregex 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 getno longer has a TOCTOU window betweenstat()andread_bytes(). The download path now opens the file once and reads at mostmax_download_bytes + 1bytes inside ananyio.to_thread.run_syncworker 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 genericsk-...regex didn’t match the project-key char set (underscore + hyphen). Added a dedicatedOPENAI_PROJECT_KEY_REapplied 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 pygmentsplus an--ignore-vuln CVE-2026-4539removal in CI’spip-auditstep #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
resultevent,_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_suppressedfires while the watchdog runs, (b) the auto-cancel_STALL_MAX_WARNINGSarm is also gated (so a session about to gracefully close cannot be SIGTERM’d), and (c) when the watchdog actually closes stdin it stampsClaudeStreamState.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 apost_result_closing_sentflag; structlog WARN events are unchanged sountether-issue-watchercontinues 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 intests/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_handleinrunners/claude.pyreaddelay_ms/timeout_msfrom the tool input, but the actual Claude Code stream-json schema (per #289 and the upstreamclaude-agent-sdk-pythonreference) emitsdelaySeconds(range 60–3600).live_wakeups[tool_id]membership-only checks (#346wedge detector) still worked because both branches populated the dict; deadlines fell to 0.0, breaking countdown rendering. Fixed by readingdelaySecondsfirst and keeping thedelay_ms/timeout_msfallbacks for backward compat with existing test fixtures. Necessary precursor to #481’s countdown rendering. 2 new regression tests intests/test_claude_runner.py(test_schedule_wakeup_reads_delaySeconds_field,test_schedule_wakeup_delay_ms_fallback_still_works) #481 - fix: rc11 —
claude.schemas.StreamToolResultBlock.contentandStreamAdvisorToolResultBlock.contentaccept a single dict (e.g.{"type": "text", "text": "..."}) in addition to the documentedstr/list[dict]/nullshapes. 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 withjsonl.msgspec.invalidand silently dropping all tool tracking for that turn (no progress action, nopending_actionsentry, no verbose-mode rendering)._normalize_tool_resultalready handled the dict shape, so no runner code change needed. 2 new regression tests intests/test_claude_schema.pycover both block types with dict content. Verified live against@untether_dev_bot— 0 msgspec errors after restart #501 - fix: rc11 —
config.loadedlog demoted from INFO → DEBUG.load_settings_if_exists()is called per-helper (footer, watchdog, progress, auto_continue, preamble, budget) on everyhandle_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 monitorconfig_loaded_burstalerts 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 withinhandle_messageto 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.summarynow recordslast_event_type=result(notcontrol_requestorcontrol_response) onok=Truecompleted Claude runs. Root cause atsrc/untether/runner.py:810—stream.last_event_typewas written unconditionally from the raw JSONLtypefield, including permission-flow control-channel traffic (Claude → Untethercontrol_requestand the parent-initiatedmcp_statuscontrol_responseon stdout from #365). Fix: skip the update whenetype is in {"control_request", "control_response"}so the field reflects the last stream event. The auto-continue gate atrunner_bridge.py:282still sees the raw"user"type because non-control events are unchanged.recent_eventsdeque 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 producessession.summary ... last_event_type=result ok=True(wascontrol_request/control_responseon rc13). 1 new regression test intests/test_exec_runner.py#502
docs
- docs: new
docs/faq/faq.md(originally landed asdocs/faq/index.mdin 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 onfeature/help-seo-geo-items-1-4inlittlebearapps/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">FAQPageblock with all 12 Q/A pairs for AI-citation surface (ChatGPT, Perplexity, Google AI Overviews) and SERP rich-snippet eligibility #477 - docs: new
## Loop modesection indocs/how-to/schedule-tasks.mdexplaining 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 indocs/reference/config.mdgains a Note callout that cumulative session cost is not capped — sessions can stack many runs via/continueand rack up many multiples ofmax_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), recommendsmax_cost_per_dayas the cross-session ceiling, and explicitly notesmax_cost_per_sessionis 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.tomland~/.config/monitor/untether-mac.toml) — Mac substrate switched fromlog show --predicate "process == \"untether\""(which returned zero lines: Mac Untether writes a file log, not Apple Unified Logging, and zsh’slogbuiltin 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 inuntether-mac.tomlupdated 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_headernow 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 assertingverify_authrejects 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
/healthcommand — live system + triggers + cost snapshot (v1). Consolidates RAM / swap (/proc/meminfo), the Untether process self-diagnostic (PID, RSS, FDs, children — reusesproc_diag.collect_proc_diag), trigger counts (cron/webhook IDs viaTriggerManager), today’s API cost (cost_tracker.get_daily_cost), and uptime (reuses/ping’s_STARTED_ATso 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 filesrc/untether/telegram/commands/health.py(~180 LOC), 12 unit tests intests/test_health_command.py, entry point registered inpyproject.toml. v1 scope only — v2 extras noted in the issue (/health --subtreetree walk,/health --costsper-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) emitresultand then park the subprocess waiting for the primitive to complete —_detect_stuck_after_tool_resultinsrc/untether/runner_bridge.pypreviously couldn’t distinguish that from a real hang (samelast_event_type=assistant, same frozen ring buffer, same CPU-active state). Now uses the tracking infrastructure from #347: duck-types againststream.engine_state.has_live_background_work()and returnsFalse(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. NewJsonlStreamState.engine_statefield (base class) carries the reference;ClaudeRunner.run_implpopulates it after creating both states. Newprogress_edits.stuck_after_tool_result.suppressedstructlog 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 intests/test_exec_bridge.py::TestStuckAfterToolResultDetectorcover 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).ClaudeStreamStateinsrc/untether/runners/claude.pygains 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 theStreamToolUseBlockbranch oftranslate_claude_eventparses the tool name +inputpayload (extractingtimeout_ms/delay_msfor deadline tracking); new_clear_background_handle()called from thetool_resultbranch removes the entry on explicit completion. New public helpershas_live_background_work()(gates #346’s wedge detector) andbackground_task_summary()(future footer rendering) complete the API surface. This PR is purely telemetry — no footer rendering, no/backgroundcommand, no control-channel hooks — those are v2 and will be filed as follow-ups once meta-threading throughProgressTrackeris confirmed safe for the other 5 engines. 11 new unit tests cover tool_use parsing for each of the 5 primitives, tool_result clearing, thehas_live_background_workdeadline-aware gate, andbackground_task_summarypluralisation #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 insrc/untether/utils/proc_diag.pyreads/proc/meminfowithout caching; newWatchdogSettings.prespawn_ram_warn_mb(default 2000) andprespawn_ram_block_mb(default 500) insrc/untether/settings.pyplus amodel_validatorthat rejects configurations wherewarn <= block(would make the warn tier unreachable); either tier set to0disables that tier and0 / 0disables the guard entirely. NewJsonlSubprocessRunner._check_prespawn_ram_guard()insrc/untether/runner.pyruns BEFOREmanage_subprocessso a blocked spawn costs nothing — on BLOCK, yieldsCompletedEvent(ok=False, error="🛑 Insufficient RAM …")and returns early without forking; on WARN, logssubprocess.prespawn.ram_warningstructured 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 ofOOMScoreAdjust=-100(#275’s Layer 1) by preventing the OOM scenario from arising in the first place #350 - feat: surface Claude
rate_limit_eventas a visible progress note instead of silent inactivity. When Anthropic throttles the API, Claude Code emits arate_limit_eventJSONL 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_eventinsrc/untether/runners/claude.pynow emits anote-kindActionEventpair (started + completed) rendered as⏳ Rate limited — retrying in Xs, withretry_after_ms,tokens_remaining, andrequests_remainingexposed via the actiondetailfor downstream consumers.ClaudeStreamStategainsrate_limit_total_s+rate_limit_countfields accruing across the session for future cost-footer annotation and/statssurfacing (deferred to a v2 follow-up). A newclaude.rate_limit_eventstructlog INFO line logsretry_after_s,count, andcumulative_sso staging greps can triage rate-limit-driven user reports. The existingtest_rate_limit_event_returns_empty(locked in the old silent behaviour) is re-scoped totest_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(newClassVar[frozenset[str]]insrc/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-inlinedRESTART_ONLY_KEYSset. When a restart-required key changes during hot-reload, the bot now posts a 🔄 notice (“SettingXchanged — restart required to take effect; run:systemctl --user restart untether”) in addition to the existingconfig.reload.transport_config_changedstructlog warning, so the operator doesn’t silently run on stale values.docs/reference/config.mdgains a comprehensive “Hot-reload vs restart-required” section and per-field 🔄 markers in the transport / topics tables #318- follow-up:
_notify_restart_requiredbroadcasts to everyruntime.project_chat_ids()plus anyallowed_user_idsadmin DM instead of a singlecfg.chat_idsend — in project-routed deploymentscfg.chat_idis the placeholder sentinel and every send failed withchat not found, so the user-visible warning never arrived. Per-chat failures are logged viaconfig.reload.restart_notify.failedand skipped;config.reload.restart_notify.sentemitstargets+sent_countfor observability. Falls back tocfg.chat_idonly when no routed targets exist.
- follow-up:
- feat:
[[triggers.crons]]now accepts an optionalpermission_modefield (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 viapermission_mode = "auto"without flipping the whole chat to auto. Precedence: cronpermission_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). NewVALID_PERMISSION_MODES_BY_ENGINEdict inrunners/run_options.pylets theCronConfigvalidator reject typos for engines with known value sets while staying forward-compatible for engines whose permission wiring is pending. A newtrigger.cron.permission_mode_overridestructlog INFO entry fires when the override actually changes the resolved value, for staging observability. #330 - callback-answer instrumentation for inline-keyboard presses — every
answerCallbackQuerynow emits acallback.answeredINFO event withlatency_ms(HTTP round-trip),total_ms(since dispatcher entry),early=true|false, andhas_toast. Lets staging greps distinguish “we were fast, Telegram was slow” from “we were slow” whenBotResponseTimeoutErroris reported client-side. Investigation of the existinganswer_earlypath confirmed it already fires before anybackend.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:
xhigheffort level added for Claude Code (Opus 4.7, Claude Code CLI v2.1.114+)._ENGINE_REASONING_LEVELS["claude"]insrc/untether/telegram/engine_overrides.pygainsxhighbetweenhighandmax. 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_levelsupdated to assertconfig:rs:xhiis now present for Claude;docs/reference/runners/claude/runner.md--effortlist now readslow/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 foursetdefaultreinforcements 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 ofutils/env_policy.py#198 - security: CI matrix values (
matrix.command,matrix.sync_args) now pass throughenv:instead of direct${{ }}interpolation inrun:blocks, eliminating a theoretical shell-injection vector should matrix values ever become dynamic (e.g. from PR labels) #195 - security:
bot_tokenis nowpydantic.SecretStrinTelegramTransportSettings— masks the value inrepr(),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 bypassesstr_strip_whitespace) #196 - security:
_HANDLED_REQUESTSinrunners/claude.pyswitched from asetcleared wholesale at 100 entries to an LRUOrderedDict(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 sharedutils/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.pymacOS 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_MSGSand_OUTLINE_REGISTRYinrunner_bridge.pygain companion timestamp maps and asweep_stale_registries()helper that prunes entries older than 1 hour. Sweep piggy-backs onProgressEdits._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_filevalidatesfile_path(from TelegramgetFile) against://,.., and leading/before URL construction — a tampered or spoofed getFile response that returned an attacker-controlled URL asfile_pathcould otherwise redirect the subsequent HTTP GET away fromapi.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’schild_process.spawn()pattern, used byworkerdvia@cloudflare/vitest-pool-workers) survived a SIGTERM’d Claude Code session. On lba-1 this orphaned 316workerdprocesses consuming 37 GB of RAM after 6 cascading Claude Code signal deaths._signal_processnow snapshots descendants viaproc_diag.find_descendants()beforekillpg(so/proc/<pid>/task/*/childrenis still readable), runs the existing pgroup kill, thenos.kill(pid, sig)on each captured PID best-effort (swallowingProcessLookupError/PermissionError). SIGKILL escalation walks the tree again. Graceful fallback to legacy pgroup-only behaviour on non-Linux hosts or/procread errors. Related upstream: anthropics/claude-code#43944, cloudflare/workers-sdk#8837 #275proc_diag._find_descendantsrenamed to publicfind_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
OSErrorpropagating through theanyiotask group, triggering a systemd restart loop.run_webhook_servernow catchesOSErrorfromTCPSite.start(), logs a structuredtriggers.server.bind_failedevent withhost/port/hint/fixfields, 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-shotclaude_usage.schema_mismatchwarning logs missing expected fields so upstream API drift is noticed instead of silently dropping the footer;_format_run_costnow renders zero-turn completions (if turns is not None:instead ofif turns:); Gemini runner extractsstats.total_cost_usdinto usage when present; AMPAmpResultschema gains atotal_cost_usdfield 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 = truecrons now persist their fired state torun_once_fired.json(sibling ofuntether.toml) — no longer re-fire on config hot-reload or process restart. Previously the TOML entry re-entered the active list on every reload becauseremove_cron()was in-memory only; editing any unrelated config setting would cause every already-fired one-shot to run again.TriggerManagernow takes an optionalconfig_pathargument, loads the fired set on init, persists onremove_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 setoverride and nopi.modelinuntether.toml). The Pi CLI’smessage_endevent carries"model": "..."alongside provider/usage; the runner now extracts this and emits a supplementaryStartedEventonce per session soProgressTracker.note_eventmerges 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_eventwas silently dropping the supplementaryStartedEventas a same-session duplicate, so the extracted model never reachedProgressTracker.note_event. The filter now emits duplicates through when the event carriesmeta; true duplicates (no meta) are still dropped. Unit tests intests/test_runner_utils.pypreviously passed because they calledtranslate_pi_eventdirectly, bypassing the base-runner filter — added a regression test covering the duplicate-with-meta path.
- follow-up:
- detect and recover from Claude Code hanging after an MCP
tool_resultvia stream-json / sdk-cli — root cause is upstream claude-code#39700 / #41086 combined with the undici idle-body timeout inmcp-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()inrunner.pyrecognises tool_result-equivalent events across all six engines (Claude, Codex, OpenCode, Pi, Gemini, AMP);JsonlStreamStategains alast_tool_result_atlatch 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) withcpu_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 logsprogress_edits.stuck_after_tool_resultwith diag; Tier 2 SIGTERMs MCP-adapter children whose/proc/<pid>/cmdlinecontainsmcp-remoteor@modelcontextprotocol(forces the SSE reader to error out and unblocks the parent engine); Tier 3 cancels viacancel_eventafterstuck_after_tool_result_recovery_delay(default 60 s) with a specific Telegram notice runners/claude.py:env()now setsCLAUDE_ENABLE_STREAM_WATCHDOG=1,CLAUDE_STREAM_IDLE_TIMEOUT_MS=60000,MCP_TOOL_TIMEOUT=120000, andMAX_MCP_OUTPUT_TOKENS=12000viasetdefault— reduces incidence while the detector is the safety net; user overrides via shell env or~/.claude/settings.jsonstill win- four new
[watchdog]config fields:detect_stuck_after_tool_result(defaultfalsefor this release, will defaulttrueonce 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
- new engine-agnostic
- fix:
CLAUDE_STREAM_IDLE_TIMEOUT_MSdefault raised from 60000ms to 300000ms (5 min). PR #323’s original 60s reinforcement of #322 proved too aggressive foropus · maxreasoning — 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 withpeak_idle_seconds=91.4). 300000ms matches the undici idle-body timeout that motivated #322 and Untether’s ownstuck_after_tool_result_timeoutdefault, so the upstream CLI watchdog and Untether’s detector now fire on compatible timescales. User-providedCLAUDE_STREAM_IDLE_TIMEOUT_MSstill wins viasetdefaultsemantics. Two new tests intests/test_claude_runner.pylock 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 fromutils/env_policy.filtered_env()— even when an upstream rc-file source, PAM/etc/environmentinjection, or wrapper script would otherwise re-introduce host vars after the parent’ssubprocess.spawn(env=…)is honoured. v0.35.2rc3 integration testing on@untether_dev_botproved the in-process filter holds (/proc/<untether-pid>/environclean) but a realBWS_ACCESS_TOKENstill reached Claude’s Bash-tool subprocess, undermining the headline #198 promise. Newwrap_with_env_i()helper inutils/subprocess.py; Clauderun_implswaps the resolvedcmdfor the wrap and passesenv=Noneto 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>/environsample on firstsystem.initemits aclaude.env_audit.leaked_varstructlog WARNING when any non-allowlisted name is observed; gated by new[security] env_audit = true(default true). Reusesutils/env_policy.is_allowed(promoted from private_is_allowed, with a back-compat alias) so the allowlist remains a single source of truth. Newutils/env_audit.py(~80 LOC); 9 unit tests intests/test_env_audit.pyplus 6 intests/test_claude_runner.pycovering 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, ignoringdefault_engine = "pi"(or similar) on the project bound to the chat.AtCommand.handle()now snapshotsRunContext(viaruntime.default_context_for_chat(chat_id)) and the resolved engine (viaruntime.resolve_engine(...)) at schedule time and threads both through_PendingAtto the fire-time_RUN_JOBcall, mirroringTriggerDispatcher.dispatch_cron’s freeze-at-dispatch behaviour. cwd is resolved correctly downstream because_run_enginederives it from the forwarded context. Re-routing the chat between/atand fire keeps the original mapping; cancel via/canceland re-issue to pick up changes. New_FakeRuntimetest fixture intests/test_at_command.pyplus 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.initevent ships each configured MCP server as{"name": "...", "status": "connected" | "pending" | "error" | "failed"}; Untether now logs a structuredcatalog_staleness.detectedWARNING once per (session, server, status) tuple whenever any server reports a non-connectedstatus at init time. Gated by newWatchdogSettings.detect_catalog_staleness(default true, observability only — no kill/recovery action). New_capture_mcp_catalog()helper insrc/untether/runners/claude.pysnapshots the raw list ontoClaudeStreamState.initial_mcp_serversfor future comparison work and dedups viaClaudeStreamState.catalog_staleness_logged: set[tuple[str, str, str]]. Companion experimental knobWatchdogSettings.notify_catalog_refresh(default false, opt-in) queues anmcp_statuscontrol_request on stdin after eachtool_result— the parent→CLI primitive documented in Anthropic’sclaude-agent-sdk-python(get_mcp_status()/reconnect_mcp_server()/toggle_mcp_server()). Drain happens inClaudeRunner._drain_catalog_refresh()alongside existing_drain_auto_approve/_drain_auto_deny, withcatalog.refresh_sentINFO on success andcatalog.refresh_failedWARN/ERROR on write errors. The upstream MCPnotifications/tools/list_changedmessage hinted at in the issue is server→client only per the MCP spec and therefore cannot be injected from outside;mcp_statusis the closest documented parent-side primitive. Request IDs use theut_catalog_refresh_<session_id>_<seq>namespace so they can’t collide with Claude Code’s ownreq_*IDs. Ten new tests intests/test_claude_runner.pycover: 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 fromWatchdogSettings. 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” onEdit/Write/Bashin plan mode. Resumed sessions where Claude skippedExitPlanModeand went straight into Edits previously re-prompted the user once per tool call — observed on@hetz_lba1_botv0.35.2rc1 as a 9-prompt repro for a single multi-file fix turn (oneEditper click, ~7 min wait between approvals, workflow effectively broken under--permission-mode plan)._DIFF_PREVIEW_TOOLSis now module-scoped insrc/untether/runners/claude.py;write_control_responseadds the session to_PLAN_EXIT_APPROVEDwhenever the approved tool isExitPlanModeor 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 intests/test_claude_control.pycover 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-initiatedset_permission_modecontrol request once the upstream primitive is wired #369
docs
- document
[triggers.server]port-conflict troubleshooting indocs/reference/triggers/triggers.mdwithss -tlnpdiagnosis step and theport = <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_approvedflag now short-circuits diff preview as well asExitPlanMode, so once the user approves a plan outline the nextEdit/Writeruns without a second approval prompt #283 -
scripts/healthcheck.shexits prematurely underset -e—pass()/fail()used((var++))which returns the pre-increment value, trippingset -eon the first call so only the first check ever ran and the script always exited 1. Also, the error-log count piped journalctl throughgrep -c ., which counted-- No entries --meta lines as matches, producing false-positive log-error counts on clean systems. Now uses explicitvar=$((var+1))assignment and filters meta lines withgrep -vc '^-- '#302 -
fix multipart webhooks returning HTTP 500 —
_process_webhookpre-read the request body for size/auth/rate-limit checks, leaving the stream empty when_parse_multipartcalledrequest.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 atfile_pathalongside the extracted file atfile_destination#280 -
fix webhook rate limiter never returning 429 —
_process_webhookawaited the downstream dispatch (Telegram outbox send,http_forwardnetwork call, etc.) before returning 202, which capped request throughput at the dispatch rate (~1/sec for private Telegram chats) and meant theTokenBucketLimiternever 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 againstrate_limit = 60now 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
jqfor 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; movedsanitize_prompt()to base runner class for all engines #194 -
security: redact bot token from structured log URLs —
_redact_event_dictnow 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
-
/pinguptime now resets on service restart — previously the module-level start time was cached across/restartcommands; nowreset_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=200andOOMPolicy=stopdefaults, which made Untether’s engine subprocesses preferred earlyoom/kernel OOM killer targets ahead of CLIclaude(oom_score_adj=0) and orphaned grandchildren actually consuming the RAM.contrib/untether.servicenow setsOOMScoreAdjust=-100(documents intent; the kernel clamps to the parent baseline for unprivileged users, typically 100) andOOMPolicy=continue(a single OOM-killed child no longer tears down the whole unit cgroup, which previously broke every live chat at once). Docs indocs/reference/dev-instance.mdupdated. Existing installs need to copy the unit file andsystemctl --user daemon-reload; staging picks up the change on the nextscripts/staging.sh installcycle #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
timezonefield with IANA timezone names (e.g."Australia/Melbourne") - global
default_timezonein[triggers]— per-crontimezoneoverrides it - DST-aware via Python’s
zoneinfomodule (zero new dependencies) - invalid timezone names rejected at config parse time with clear error messages
- per-cron
-
SSRF protection for trigger outbound requests — shared utility at
triggers/ssrf.pyblocks 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 handlingaction = "http_forward"— forward payload to another URL with SSRF protection, exponential backoff on 5xx, and header template renderingaction = "notify_only"— send a templated Telegram message with no agent runnotify_on_success/notify_on_failureflags 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-dataPOSTs with file uploads #278- file parts saved with sanitised filenames, atomic writes, deny-glob and path traversal protection
- configurable
file_destinationwith 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 headersfetch.type = "file_read"— read local file with path traversal protection and deny-globsfetch.parse_as— parse response asjson,text, orlines- fetched data injected into
prompt_templateviastore_asvariable (defaultfetch_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
TriggerManagerclass holds cron and webhook config; scheduler readsmanager.cronseach tick; webhook server resolves routes per-request viamanager.webhook_for_path() - supports add/remove/modify of crons and webhooks, auth/secret changes, action type, multipart/file settings, cron fetch, and timezones
last_fireddict preserved across swaps to prevent double-firing within the same minute- unauthenticated webhooks logged at
WARNINGon reload (previously only at startup) - 13 new tests in
test_trigger_manager.py; 2038 existing tests still pass
- new
-
hot-reload for Telegram bridge settings —
voice_transcription, file transfer,allowed_user_ids,show_resume_line, and message-timing settings now reload without a restart #286TelegramBridgeConfigunfrozen (keepsslots=True) and gains anupdate_from(settings)methodhandle_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 withrestart_required=trueroute_update()readscfg.allowed_user_idslive so allowlist changes take effect on the next message
-
/atcommand for one-shot delayed runs — schedule a prompt to run between 60s and 24h in the future with/at 30m Check the build; acceptsNs/Nm/Nhsuffixes #288- pending delays tracked in-memory (lost on restart — acceptable for one-shot use)
/canceldrops pending/attimers 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 asatentry point
-
run_oncecron flag —[[triggers.crons]]entries can setrun_once = trueto 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
/pingin 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 RunContextgainstrigger_sourcefield;ProgressTracker.note_eventmerges engine meta over the dispatcher-seeded trigger so it survivesTriggerManagerexposescrons_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_idtolast_update_id.jsonand 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=notifysystemd integration via stdlibsd_notify(socket.AF_UNIX, no dependency) —READY=1is sent after the firstgetUpdatessucceeds,STOPPING=1at the start of drainRestartSec=2incontrib/untether.service(was10) — faster restart after drain completescontrib/untether.servicealso addsNotifyAccess=main; existing installs must copy the unit file andsystemctl --user daemon-reload
- persist last Telegram
docs
- add update and uninstall guides + README transparency section #305
- new
docs/how-to/update.mdanddocs/how-to/uninstall.mdcovering 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
- new
- 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 behaviourcommands-and-directives.md:/pingdescription now mentions uptime reset and trigger summary (#234)runners/amp/runner.md: addsanitize_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_REQUESTSand_ASK_QUESTION_FLOWSwere 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_idcontextvar (get_run_channel_id/set_run_channel_id) toutils/paths.py get_pending_ask_request()andget_ask_question_flow()now acceptchannel_idand filter by it- session cleanup now also clears stale pending asks and flows
- added
- standalone override commands (
/planmode,/model,/reasoning) now preserve allEngineOverridesfields instead of resetting unrelated overrides #124 - register input for system-level auto-approved control requests (Initialize, HookCallback, McpMessage, RewindFiles, Interrupt) so
updatedInputis included in the response — prevents ZodError in Claude Code #123 - reduce Telegram API default timeout from 120s to 30s — a single ReadTimeout on
editMessageTextcould make the bot appear unresponsive for up to 2 minutes;getUpdateslong-poll now uses a dedicated timeout oftimeout_s + 20so network failures are detected faster #145 - OpenCode error runs now show the error message instead of an empty body —
CompletedEvent.answerfalls back tostate.last_tool_errorwhen no priorTextevents were emitted; covers bothStepFinishandstream_end_eventspaths #146, #150 - Pi
/continuenow captures the session ID fromSessionHeader—allow_id_promotionwasFalsefor 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 setsskip_reply=Truewhen outline messages were just deleted; also suppresses the redundant push notification after outline cleanup #148 - sanitise
text_linkentities with invalid URLs before sending to Telegram — localhost, loopback, file paths, and bare hostnames are converted tocodeentities 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_idfrompending_control_requestsfor direct callback routing - 5-minute safety timeout cleans up stale held requests
- buttons use real
- 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-approvalin headless mode — default tonever(auto-approve all) so Codex never blocks on terminal input;safepermission mode still usesuntrusted#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.jsonto detect the configured default model so the🏷footer always shows the model (e.g.openai/gpt-5.2) even without anuntether.tomloverride #221 - OpenCode model override hint —
/configand engine model sub-page now showprovider/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.metaso the footer shows the model even when no override is set #217 /planmodecommand 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/usageshowed Claude subscription data in non-Claude engine chats — now gated to subscription-supported engines with an engine-specific error message #215/exportshowed duplicate “Session Started” headers for resumed sessions — deduplicated so only the firstStartedEventrenders #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 /newcommand 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=userwhich matched the upstream bug detection, causing a death spiral where 4 killed sessions were immediately respawned into the same memory pressure #222 /newcommand triggers engine run instead of clearing sessions whentopics.enabled=false—/newwas only handled in_dispatch_builtin_commandwhen topics were enabled; moved/newout of thetopics.enabledgate to handle all modes (topic, chat session, stateless), mirroring how/ctxalready 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 /configAsk mode toggle inverted —_toggle_rowdefault wasFalsebut 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_sentflag inProgressEditsstripped ALL subsequent approval buttons, not just outline-related ones; now only strips buttons forDiscussApprovalactions #233 - prevent duplicate control response for already-handled requests #229 (#230)
- fix
render_markdownentity 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 /confignow reflects project-leveldefault_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 /configsub-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
/exportno longer returns sessions from wrong chat — session recording was not scoped by channel_id, so/exportin one chat could return another engine’s session data #33- fix
KillMode=control-groupbypassing drain and causing 150s restart delay —contrib/untether.servicenow usesKillMode=mixedwhich sends SIGTERM to the main process first (drain works), then SIGKILL to remaining cgroup processes (orphaned MCP servers, containers cleaned up instantly) #166process: 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
-xflag regression — double-dash separator inbuild_args()caused AMP to interpret-xas 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 currentut-dev-hf:chats #238 - investigation: orphaned
workerdprocesses 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_resolvedinfo log, elevate outline cleanup failures to WARNING, add credential redaction for OpenAI/GitHub API keys, add file transfer success logging, bindsession_idin 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
/continuecommand — 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) #135ResumeTokenextended withis_continue: bool = False- all 6 runners’
build_args()updated to handle continue tokens /continuehandled as reserved command in Telegram loop- new how-to guide:
docs/how-to/cross-environment-resume.md
/configUX 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_lineoverride via/configsettings; configurable via EngineOverrides #128 - cost budget settings — per-chat
budget_enabledandbudget_auto_canceloverrides 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-3→gemini-3; all engines populate model info fromStartedEvent.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.pywithscan_outbox(),cleanup_outbox(),deliver_outbox_files() ExecBridgeConfiggainssend_filecallback +outbox_config(transport-agnostic)- preamble updated with outbox instructions for all 6 engines
- config:
outbox_enabled,outbox_dir,outbox_max_files,outbox_cleanupin[transports.telegram.files]
- new module
- 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.pywithregister_progress(),unregister_progress(),load_active_progress(),clear_all_progress() runner_bridge.pyregisters on progress send, unregisters on ephemeral cleanuptelegram/loop.pycleans up orphans before sending startup message
- new module
- 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
/confighome page with engine-specific labels
- Codex: new “Approval policy” page — full auto (default) or safe (
- 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=userand auto-resumes the session #167AutoContinueSettingswithenabled(default true) andmax_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 outfor 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
/continuetests: 46 auto-router assertions (continue token handling, engine routing) + 53 build-args assertions (continue flags for all 6 engines) #135 - 195
/configtests 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_modeltests: 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:
/planmodeClaude-only,/usagesubscription-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 yoloin test chat config; Gemini CLI defaults to read-only mode with write tools disabled; set full access via/configforut-dev-hf: geminitest chat; U1 now passes in 56s (was 8–18 min stall) #244 - 10 new
/newcancellation tests:_cancel_chat_taskshelper (None, empty, matching, other chats, already cancelled, multiple), chat/newwith running task, cancel-only no sessions, no tasks no sessions, topic/newwith 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_returncodedefault onJsonlStreamState#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_SESSIONenv var: Claude runner now setsUNTETHER_SESSION=1in 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 --porcelainon 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=Truefor keyboard transitions (approval buttons appearing), log keyboard attach at info level and edit failures at warning level for diagnostics #104transport.edit.failedwarning inTelegramTransport.edit()whenwait=Trueedit returnsNoneprogress_edits.keyboard_attachinfo log on keyboard transitionsprogress_edits.keyboard_edit_failedwarning when keyboard edit fails- transport errors upgraded from debug to warning level
/usage429 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
- session registration (
- JSONL decode failure logged at warning level with truncated line content (first 200 chars)
- runner spawn now logs CLI args in
runner.startevent - no-events session warning:
session.summary.no_eventslogged 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,
/cancelrequires reply #99- stall auto-cancel: dead process detection (immediate), no-PID zombie cap (3 warnings), absolute cap (10 warnings)
- early PID threading:
last_pidset at subprocess spawn, polled byrun_runner_with_cancelbeforeStartedEvent - standalone
/cancelfallback: cancels single active run without requiring reply; prompts when multiple runs active queued_for_chat()method onThreadSchedulerfor 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
/procprocess diagnostics (CPU, RSS, TCP, FDs, children), progressive stall warnings, liveness watchdog, event timeline tracking, and session completion summary #97- new
utils/proc_diag.pymodule:collect_proc_diag(),format_diag(),is_cpu_active() JsonlStreamStatetrackslast_stdout_at,event_count,last_event_type,recent_eventsring buffer,stderr_capture- PID auto-injected into
StartedEvent.metavia base class (all engines) - progressive
_stall_monitor: repeating warnings every 3 min with fresh/procsnapshots 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.summarystructured log on every session completion[watchdog]config section:liveness_timeout,stall_auto_kill,stall_repeat_seconds
- new
- stream threading broken:
_ResumeLineProxyhidescurrent_streamfromProgressEdits, causingevent_count=0andlast_event_type=Nonefor all engines #98- add
current_streamproperty to_ResumeLineProxyand_PreludeRunner - set
self.current_stream = streamin Claude’s overriddenrun_impl - use
stream.stderr_captureinstead of separatestderr_linesin Claude’srun_impl
- add
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 fromrun_implfinally block
- extract
- stall monitor fails to detect stalls when no events arrive after session start; no Telegram notification #95
- initialise
_last_event_atfromclock()instead of0.0so threshold works from session start - send
⏳ No progress for N minTelegram notification on stall detection (previously journal-only)
- initialise
changes
- show token-only cost footer for Gemini and AMP —
_format_run_cost()no longer requirestotal_cost_usd; renders💰 26.0k in / 71 outwhen only token data is available #94- Gemini
_build_usage(): extractcached→cache_read_tokensandduration_msfrom StreamStats - AMP
_accumulate_usage(): accumulatecache_creation_input_tokensandcache_read_input_tokens
- Gemini
- 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 sharedpermission_modefield #90- home page shows “Approval mode” label and button when engine is Gemini
- sub-page with Read-only/Full access toggle
PERMISSION_MODE_SUPPORTED_ENGINESconstant for engine-aware gating
v0.33.5 (2026-03-07)
fixes
- downgrade
control_response.failedClosedResourceError from error to warning — race condition when Telegram callback arrives after session stdin closes;write_control_response()now returnsboolandsend_claude_control_response()propagates it #61- also downgrade
auto_approve_failedandauto_deny_failedfor consistency
- also downgrade
- 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
ClosedResourceErroriniter_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_rpsnow 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=process→KillMode=control-groupfix 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 < 200guard 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_idsis 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-pyis missing instead of crash-looping on startup #83
v0.33.0 (2026-03-06)
changes
- add effort control for Claude Code —
--effortflag with low/medium/high levels via/reasoningand/config#80 - show model version numbers in footer — e.g.
opus 4.6instead ofopus#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 /usageHTTP errors now show descriptive messages (e.g. “Rate limited by Anthropic — too many requests”) instead of bare status codes #81/usagenow 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-pydependency crashes service on startup after 0.32.0 upgrade #79markdown-it-pylinkify feature requires optionallinkify-it-pypackage- 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-modepassthrough for plan mode support #991 - add Amp CLI runner with mode selection and
--stream-json-inputsupport #988, #989 - add
/threadscommand for Amp thread management #993 - track Amp subagent
parent_tool_use_idin action detail #992 - redesign
/confighome page with grouped sections (Agent controls, Display, Routing), inline hints, and help links - add version information footer to
/confighome page - compact startup message — only show enabled features (topics, triggers), merge engine and default on one line
fixes
- Gemini CLI
-pflag compatibility (changed from boolean to string argument) #75 - Amp CLI
-xflag requires prompt as direct argument #76 - Amp CLI uses
--modenot--modelfor model override #77 - Amp
/threadstable parsing —threads list/searchdon’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
/configcommand (+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
/authcodex-only, move auth status to/stats auth#68 - add docs link to
/confighome page #69
fixes
- widen device code regex for real codex output format #40
- improve
/authinfo 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
/statscommand — persistent per-engine session statistics (runs, actions, duration) with today/week/all periods #41SessionStatsStorewith JSON persistence in config dir- auto-prune data older than 90 days
- recording hook in
runner_bridge.pyon run completion
- add
/authcommand — headless engine re-authentication via Telegram #40- runs
codex login --device-authand sends verification URL + device code /auth statuschecks CLI availability- concurrent guard and 16-minute timeout
- runs
- add API cost and subscription usage toggles to
/configmenu- per-chat persistent settings for
show_api_costandshow_subscription_usage
- per-chat persistent settings for
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_previewis enabled, previewable tools now route through interactive approval - default behaviour (diff_preview off) unchanged
- when
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
/configmenu — 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 viaEngineRunOptionsContextVar - home page layout: new “Diff preview” button alongside Verbose
fixes
- remove redundant local import of
get_run_optionsinclaude.pythat 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:dppresence 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; Googleresource_exhausted - API overload: Anthropic
overloaded_error(529), generic “server is overloaded” - server errors: 500
internal_server_error, 502bad gateway, 503service unavailable, 504gateway timeout - rate limits:
too many requests(extends existingrate limitpattern) - network:
connecttimeout, DNS failure, network unreachable - auth:
openai_api_key,google_api_key(extends existinganthropic_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
updatedInputresponses #51/configtoggle: “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_SESSIONentries cleaned up during session end
- session-alive check in
- 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
/configcommand 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 scalarnext_at- new
_pick_ready(now)selects from unblocked chats;retry_atstays global (429) - 7 group chats now update in parallel (~0s total) vs old 7 × 3s = 21s delay
changes
/configmodel sub-page — view current model override and clear it; button always visible on home page #47/configreasoning 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
/configinline 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_executionerror 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()onChatSessionStoreandTopicStateStore on_resume_failedcallback threaded throughhandle_message→_run_engine→wrap_on_resume_failed
- new
tests
- add
ErrorReturnstep type toScriptRunnermock 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_executionhint 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.serviceexample withKillMode=processandTimeoutStopSec=150for graceful shutdown #44 - update
docs/reference/dev-instance.mdwith systemd configuration section and graceful upgrade path - update
CLAUDE.mdwith 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_overflowchanged 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
/verbosecommand 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/AutoCompactionEndas progress actions with token counts #26 UNTETHER_CONFIG_PATHenv 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\ncaused 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.
SIGTERMfor rc=-15) and captured stderr excerpt - stream-ended-without-result errors include session context
runner.completedstructlog includesnum_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.mdredirect stub - fix stale version reference (0.19.0 → 0.23.4) in install tutorial and llms-full.txt
- regenerate
llms.txtandllms-full.txtwith 18 previously missing doc pages - add AI IDE context files:
AGENTS.md,.cursorrules,.github/copilot-instructions.md - update
.codex/AGENTS.mdwith correct project commands - add
ROADMAP.mdwith near/mid/future directional plans - update README documentation section with new guide links
- update
zensical.tomlnav with new how-to guides
v0.23.4 (2026-02-26)
fixes
- fix
test_doctor_voice_checksenv var leak from pydantic_settings #12UntetherSettings.model_validate()auto-loadsUNTETHER__*env vars, causingvoice_transcription_api_keyto 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_eventto Claude stream-json schema (CLI v2.1.45+) #8- new
StreamRateLimitMessageandRateLimitInfomsgspec structs - event is decoded cleanly and silently skipped (informational only)
- eliminates noisy
jsonl.msgspec.invalidwarning in logs
- new
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 catchesFileNotFoundErrorandhttpx.HTTPStatusError- post-run messages are delivered to Telegram even when usage data is unavailable
- add macOS Keychain support for
/usagecommand 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.metawith model info - Claude also includes
permissionModefromsystem.init - Codex/OpenCode use runner config since their JSONL streams don’t include model metadata
- all 4 engines (Claude, Codex, OpenCode, Pi) populate
- route telegram callback queries to command backends #116
- callback data format:
command_id:args...routes to registered command plugins - extracts
message_thread_idfrom callback for proper topic context - enables plugins to build interactive UX with inline keyboards
- callback data format:
v0.22.2 (2026-02-25)
fixes
- remove defunct Telegram notification scripts that caused CI/release workflows to report failure #9
- skip
uuid.uuid7test 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
phasevalues 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
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 configsubcommand #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 setcommand #109 - add session resume shorthand for pi runner #113
- expose
sender_idandrawfields onMessageReffor plugins #112
fixes
- recreate stale topic bindings when topic is deleted and recreated #127
- use stdout session header for pi runner #126
docs
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_lineoption 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_idto plugins via run context #99 - use tomli-w for config serialization #103
- add
voice_transcription_modelsetting 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
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
/topiccommand for binding threads to projects/branches, persistent resume tokens per topic, and/ctxfor 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
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 pluginscommand and public API docs #71
fixes
v0.10.0 (2026-01-08)
changes
- add transport registry with
--transportoverrides and auntether transportscommand #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/projectdirectives - route runs to git worktrees with
@branch— untether resolves or creates worktrees automatically - replies preserve context via
ctx: project @branchfooters, no need to repeat directives - set
default_projectto skip the/projectprefix entirely - per-project
default_engineandworktree_baseconfiguration
changes
- transport/presenter protocols plus transport-agnostic
exec_bridge - move telegram polling + wiring into
untether.telegramwith 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; useuntether.runner_bridgeanduntether.telegraminstead
docs
- add a projects/worktrees guide and document
untether initbehavior 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
untetherto 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
opencodecli with json event parsing and resume support #22 - add a pi agent runner via the
picli 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
claudecli with stream-json parsing and resume support #9 - auto-discover engine backends and generate cli subcommands from the registry #12
- add
BaseRunnersession locking plus aJsonlSubprocessRunnerhelper 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-idcli 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
- normalized event model (
- add
/cancelcommand 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
--profileflag; configure via[codex].profileonly
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