Skip to content

Commit 678c3c4

Browse files
nathanschramclaude
andcommitted
feat: v0.35.1rc4 — /at command, hot-reload bridge config, trigger visibility, restart Tier 1 (#271, #286, #287, #288)
Bundles four rc4 features plus a CHANGELOG entry for #283 (diff_preview gate, already on dev as 8c04904). Full details in CHANGELOG.md. #269/#285 hot-reload triggers: merged separately as PR #285 (squash-merged to dev); this commit extends TriggerManager with rc4 helpers (remove_cron, crons_for_chat, webhooks_for_chat, cron_ids, webhook_ids) for Features 4b and 5 below. #288 — /at command and run_once cron flag: - new telegram/at_scheduler.py — module-level task-group + run_job holder; schedule_delayed_run(), cancel_pending_for_chat(), active_count(); per-chat cap of 20 pending delays - new telegram/commands/at.py — AtCommand backend, /at <duration> <prompt> with Ns/Nm/Nh suffixes, 60s-24h range - /cancel integration via cancel_pending_for_chat() - drain integration via active_count() in _drain_and_exit - entry-point at = untether.telegram.commands.at:BACKEND - CronConfig.run_once: bool = False; scheduler removes cron after fire if run_once=True; re-enters on reload/restart #286 — unfreeze TelegramBridgeConfig: - drop frozen=True (slots preserved); add update_from(settings) method - route_update() reads cfg.allowed_user_ids live; handle_reload() calls update_from() and refreshes state.forward_coalesce_s / media_group_debounce_s - restart-only keys still warn (bot_token, chat_id, session_mode, topics, message_overflow); others hot-reload #271 — trigger visibility Tier 1: - new triggers/describe.py — describe_cron(schedule, timezone) utility - /ping shows per-chat trigger indicator when triggers target the chat - RunContext.trigger_source field; dispatcher sets it to cron:<id>/webhook:<id>; runner_bridge seeds progress_tracker.meta['trigger'] with icon + source; ProgressTracker.note_event merges engine meta over dispatcher meta - format_meta_line() appends 'trigger' to footer parts - CommandContext gains trigger_manager, default_chat_id fields (default None); populated by telegram/commands/dispatch.py from cfg #287 — graceful restart Tier 1: - new sdnotify.py — stdlib sd_notify client (READY=1 / STOPPING=1); poll_updates sends READY=1 after _send_startup succeeds; _drain_and_exit sends STOPPING=1 at drain start - new telegram/offset_persistence.py — DebouncedOffsetWriter; loads saved update_id on startup, persists via on_offset_advanced callback in poll_incoming; flushes in poll_updates finally block - contrib/untether.service: Type=notify, NotifyAccess=main, RestartSec=2 Tests: +224 tests added across 6 new test files and 6 extended files; 2164 total tests pass with 81.55% coverage. Context files (CLAUDE.md, .claude/rules/*) and human docs (README, triggers reference, dev-instance, integration-testing, webhooks-and-cron how-to, commands-and-directives) updated. rc4 integration test scenarios R1-R10 added to integration-testing.md. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 3931d6b commit 678c3c4

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+2374
-46
lines changed

.claude/rules/runner-development.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ factory.completed_ok(answer=..., resume=token, usage=...)
3434

3535
Do NOT construct `StartedEvent`, `ActionEvent`, `CompletedEvent` dataclasses directly.
3636

37+
## RunContext trigger_source (#271)
38+
39+
`RunContext` has a `trigger_source: str | None` field. Dispatchers set it to `"cron:<id>"` or `"webhook:<id>"`; `runner_bridge.handle_message` seeds `progress_tracker.meta["trigger"] = "<icon> <source>"`. Engine `StartedEvent.meta` merges over (not replaces) the trigger key via `ProgressTracker.note_event`. Runners themselves should NOT set `meta["trigger"]`; that's reserved for dispatchers.
40+
3741
## Session locking
3842

3943
- `SessionLockMixin` provides `lock_for(token) -> anyio.Semaphore`

.claude/rules/telegram-transport.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,22 @@ Agents write files to `.untether-outbox/` during a run. On completion, `outbox_d
5959

6060
`progress_persistence.py` tracks active progress messages in `active_progress.json`. On startup, orphan messages from a prior instance are edited to "⚠️ interrupted by restart" with keyboard removed.
6161

62+
## Telegram update_id persistence (#287)
63+
64+
`offset_persistence.py` persists the last confirmed Telegram `update_id` to `last_update_id.json` (sibling to config). On startup, `poll_updates` loads the saved offset and passes `offset=saved+1` to `getUpdates` so restarts don't drop or re-process updates within Telegram's 24h retention window. Writes are debounced (5s interval, 100-update cap) via `DebouncedOffsetWriter` — see its docstring for the crash/replay tradeoff. Flush happens automatically in the `poll_updates` finally block.
65+
66+
## TelegramBridgeConfig hot-reload (#286)
67+
68+
`TelegramBridgeConfig` is unfrozen (slots preserved) as of rc4. `update_from(settings)` applies a reloaded `TelegramTransportSettings` to the live config; `handle_reload()` in `loop.py` calls it and refreshes the two cached copies in `TelegramLoopState`. `route_update()` reads `cfg.allowed_user_ids` live so allowlist changes take effect on the next message. Restart-only keys (`bot_token`, `chat_id`, `session_mode`, `topics`, `message_overflow`) still warn with `restart_required=true`.
69+
70+
## sd_notify (#287)
71+
72+
`untether.sdnotify.notify(message)` sends `READY=1`/`STOPPING=1` to systemd's notify socket (stdlib only — no dependency). `NOTIFY_SOCKET` absent → no-op False. `poll_updates` sends `READY=1` after `_send_startup` succeeds; `_drain_and_exit` sends `STOPPING=1` at drain start. Requires `Type=notify` + `NotifyAccess=main` in the systemd unit (see `contrib/untether.service`).
73+
74+
## /at command (#288)
75+
76+
`telegram/at_scheduler.py` is a module-level holder for the task group + `run_job` closure; `install()` is called from `run_main_loop` once both are available. `AtCommand.handle` calls `schedule_delayed_run(chat_id, thread_id, delay_s, prompt)` which starts an anyio task that sleeps then dispatches. Pending delays tracked in `_PENDING`; `/cancel` drops them via `cancel_pending_for_chat(chat_id)`. Drain integration via `at_scheduler.active_count()`. No persistence — restart cancels all pending delays (documented in issue body).
77+
6278
## Plan outline rendering
6379

6480
Plan outlines render as formatted Telegram text via `render_markdown()` + `split_markdown_body()`. Approval buttons (✅/❌/📋) appear on the last outline message. Outline and notification messages are cleaned up on approve/deny via `_OUTLINE_REGISTRY`.

CHANGELOG.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
# changelog
22

3-
## v0.35.1 (2026-04-03)
3+
## v0.35.1 (2026-04-14)
44

55
### fixes
66

7+
- diff preview approval gate no longer blocks edits after a plan is approved — the `_discuss_approved` flag now short-circuits diff preview as well as `ExitPlanMode`, so once the user approves a plan outline the next `Edit`/`Write` runs without a second approval prompt [#283](https://github.com/littlebearapps/untether/issues/283)
8+
79
- fix multipart webhooks returning HTTP 500 — `_process_webhook` pre-read the request body for size/auth/rate-limit checks, leaving the stream empty when `_parse_multipart` called `request.multipart()`. Now the multipart reader is constructed from the cached raw body, so multipart uploads work end-to-end; also short-circuits the post-parse raw-body write so the MIME envelope isn't duplicated at `file_path` alongside the extracted file at `file_destination` [#280](https://github.com/littlebearapps/untether/issues/280)
810
- fix webhook rate limiter never returning 429 — `_process_webhook` awaited the downstream dispatch (Telegram outbox send, `http_forward` network call, etc.) before returning 202, which capped request throughput at the dispatch rate (~1/sec for private Telegram chats) and meant the `TokenBucketLimiter` never saw a real burst. Dispatch is now fire-and-forget with exception logging, so the rate limiter drains the bucket correctly and a burst of 80 requests against `rate_limit = 60` now yields 60 × 202 + 20 × 429 [#281](https://github.com/littlebearapps/untether/issues/281)
911
- **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](https://github.com/littlebearapps/untether/issues/192)
@@ -49,6 +51,39 @@
4951
- `on_failure = "abort"` (default) sends failure notification; `"run_with_error"` injects error into prompt
5052
- all fetched data prefixed with untrusted-data marker
5153

54+
- **hot-reload for trigger configuration** — editing `untether.toml` `[triggers]` applies changes immediately without restarting Untether or killing active runs [#269](https://github.com/littlebearapps/untether/issues/269) ([#285](https://github.com/littlebearapps/untether/pull/285))
55+
- new `TriggerManager` class holds cron and webhook config; scheduler reads `manager.crons` each tick; webhook server resolves routes per-request via `manager.webhook_for_path()`
56+
- supports add/remove/modify of crons and webhooks, auth/secret changes, action type, multipart/file settings, cron fetch, and timezones
57+
- `last_fired` dict preserved across swaps to prevent double-firing within the same minute
58+
- unauthenticated webhooks logged at `WARNING` on reload (previously only at startup)
59+
- 13 new tests in `test_trigger_manager.py`; 2038 existing tests still pass
60+
61+
- **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 [#286](https://github.com/littlebearapps/untether/issues/286)
62+
- `TelegramBridgeConfig` unfrozen (keeps `slots=True`) and gains an `update_from(settings)` method
63+
- `handle_reload()` now applies changes in-place and refreshes cached loop-state copies; restart-only keys (`bot_token`, `chat_id`, `session_mode`, `topics`, `message_overflow`) still warn with `restart_required=true`
64+
- `route_update()` reads `cfg.allowed_user_ids` live so allowlist changes take effect on the next message
65+
66+
- **`/at` command for one-shot delayed runs** — schedule a prompt to run between 60s and 24h in the future with `/at 30m Check the build`; accepts `Ns`/`Nm`/`Nh` suffixes [#288](https://github.com/littlebearapps/untether/issues/288)
67+
- pending delays tracked in-memory (lost on restart — acceptable for one-shot use)
68+
- `/cancel` drops pending `/at` timers before they fire
69+
- per-chat cap of 20 pending delays; graceful drain cancels pending scopes on shutdown
70+
- new module `telegram/at_scheduler.py`; command registered as `at` entry point
71+
72+
- **`run_once` cron flag**`[[triggers.crons]]` entries can set `run_once = true` to fire once then auto-disable; the cron stays in the TOML and re-activates on the next config reload or restart [#288](https://github.com/littlebearapps/untether/issues/288)
73+
74+
- **trigger visibility improvements (Tier 1)** — surface configured triggers in the Telegram UI [#271](https://github.com/littlebearapps/untether/issues/271)
75+
- `/ping` in a chat with active triggers appends `⏰ triggers: 1 cron (daily-review, 9:00 AM daily (Melbourne))`
76+
- trigger-initiated runs show provenance in the meta footer: `🏷 opus 4.6 · plan · ⏰ cron:daily-review`
77+
- new `describe_cron(schedule, timezone)` utility renders common cron patterns in plain English; falls back to the raw expression for complex schedules
78+
- `RunContext` gains `trigger_source` field; `ProgressTracker.note_event` merges engine meta over the dispatcher-seeded trigger so it survives
79+
- `TriggerManager` exposes `crons_for_chat()`, `webhooks_for_chat()`, `cron_ids()`, `webhook_ids()` helpers
80+
81+
- **faster, cleaner restarts (Tier 1)** — restart gap reduced from ~15-30s to ~5s with no lost messages [#287](https://github.com/littlebearapps/untether/issues/287)
82+
- persist last Telegram `update_id` to `last_update_id.json` and resume polling from the saved offset on startup; Telegram retains undelivered updates for 24h, so the polling gap no longer drops or re-processes messages
83+
- `Type=notify` systemd integration via stdlib `sd_notify` (`socket.AF_UNIX`, no dependency) — `READY=1` is sent after the first `getUpdates` succeeds, `STOPPING=1` at the start of drain
84+
- `RestartSec=2` in `contrib/untether.service` (was `10`) — faster restart after drain completes
85+
- `contrib/untether.service` also adds `NotifyAccess=main`; existing installs must copy the unit file and `systemctl --user daemon-reload`
86+
5287
## v0.35.0 (2026-03-31)
5388

5489
### fixes

CLAUDE.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ Untether adds interactive permission control, plan mode support, and several UX
4040
- **`/continue`** — 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)
4141
- **Timezone-aware cron triggers** — per-cron `timezone` or global `default_timezone` with IANA names (e.g. `Australia/Melbourne`); DST-aware via `zoneinfo`; invalid names rejected at config parse time
4242
- **Hot-reload trigger configuration** — editing `untether.toml` applies cron/webhook changes immediately without restart; `TriggerManager` holds mutable state that the cron scheduler and webhook server reference at runtime; `handle_reload()` re-parses `[triggers]` on config file change
43+
- **Hot-reload Telegram bridge settings**`voice_transcription`, file transfer, `allowed_user_ids`, timing, and `show_resume_line` settings reload without restart; `TelegramBridgeConfig` unfrozen (slots kept) with `update_from()` wired into `handle_reload()`; restart-only keys (`bot_token`, `chat_id`, `session_mode`, `topics`, `message_overflow`) still warn
44+
- **`/at` command** — one-shot delayed runs: `/at 30m <prompt>` schedules a prompt to run in 60s–24h; `/cancel` drops pending delays before firing; lost on restart (documented) with a per-chat cap of 20 pending delays; `telegram/at_scheduler.py` holds task-group + run_job refs
45+
- **`run_once` cron flag**`[[triggers.crons]]` entries can set `run_once = true` to fire once then auto-disable; cron stays in TOML and re-activates on config reload or restart
46+
- **Trigger visibility (Tier 1)**`/ping` shows per-chat trigger summary (`⏰ triggers: 1 cron (id, 9:00 AM daily (Melbourne))`); run footer shows `⏰ cron:<id>` / `⚡ webhook:<id>` for trigger-initiated runs; new `describe_cron()` utility renders common patterns in plain English
47+
- **Graceful restart improvements (Tier 1)** — persists Telegram `update_id` to `last_update_id.json` so restarts don't drop/duplicate messages; `Type=notify` systemd integration via stdlib `sd_notify` (`READY=1` + `STOPPING=1`); `RestartSec=2`
4348

4449
See `.claude/skills/claude-stream-json/` and `.claude/rules/control-channel.md` for implementation details.
4550

@@ -86,7 +91,12 @@ Telegram <-> TelegramPresenter <-> RunnerBridge <-> Runner (claude/codex/opencod
8691
| `commands.py` | Command result types |
8792
| `scripts/validate_release.py` | Release validation (changelog format, issue links, version match) |
8893
| `scripts/healthcheck.sh` | Post-deploy health check (systemd, version, logs, Bot API) |
89-
| `triggers/manager.py` | TriggerManager: mutable cron/webhook holder for hot-reload; atomic config swap on TOML change |
94+
| `triggers/manager.py` | TriggerManager: mutable cron/webhook holder for hot-reload; atomic config swap on TOML change; `crons_for_chat`, `webhooks_for_chat`, `remove_cron` helpers |
95+
| `triggers/describe.py` | `describe_cron(schedule, timezone)` utility for human-friendly cron rendering |
96+
| `telegram/at_scheduler.py` | `/at` command state: pending one-shot delays with cancel scopes, install/uninstall, cancel per chat |
97+
| `telegram/commands/at.py` | `/at` command backend — parses Ns/Nm/Nh, schedules delayed run |
98+
| `telegram/offset_persistence.py` | Persist Telegram `update_id` across restarts; `DebouncedOffsetWriter` |
99+
| `sdnotify.py` | Stdlib `sd_notify` client for `READY=1`/`STOPPING=1` systemd signals |
90100
| `triggers/server.py` | Webhook HTTP server (aiohttp); multipart parsing from cached body, fire-and-forget dispatch |
91101
| `triggers/dispatcher.py` | Routes webhooks/crons to `run_job()` or non-agent action handlers |
92102
| `triggers/cron.py` | Cron expression parser, timezone-aware scheduler loop |
@@ -205,7 +215,13 @@ Key test files:
205215
- `test_trigger_fetch.py` — 12 tests: HTTP GET/POST, file read, parse modes, failure handling, prompt building
206216
- `test_trigger_auth.py` — 12 tests: bearer token, HMAC-SHA256/SHA1, timing-safe comparison
207217
- `test_trigger_rate_limit.py` — 5 tests: token bucket fill/drain, per-key isolation, refill timing
208-
- `test_trigger_manager.py` — 13 tests: TriggerManager init/update/clear, webhook server hot-reload (add/remove/update routes, secret changes, health count), cron schedule swapping, timezone updates
218+
- `test_trigger_manager.py` — 23 tests: TriggerManager init/update/clear, webhook server hot-reload (add/remove/update routes, secret changes, health count), cron schedule swapping, timezone updates; rc4 helpers (crons_for_chat, webhooks_for_chat, cron_ids, webhook_ids, remove_cron, atomic iteration)
219+
- `test_describe_cron.py` — 31 tests: human-friendly cron rendering (daily, weekday ranges, weekday lists, single day, timezone suffix, fallback to raw, AM/PM boundaries)
220+
- `test_trigger_meta_line.py` — 6 tests: trigger source rendering in `format_meta_line()`, ordering relative to model/effort/permission
221+
- `test_bridge_config_reload.py` — 11 tests: TelegramBridgeConfig unfrozen (slots preserved), `update_from()` copies all 11 fields, files swap, chat_ids/voice_transcription_api_key edge cases, trigger_manager field default
222+
- `test_at_command.py` — 34 tests: `/at` parse (valid/invalid suffixes, bounds, case-insensitive), `_format_delay`, schedule/cancel, per-chat cap, scheduler install/uninstall
223+
- `test_offset_persistence.py` — 15 tests: Telegram update_id round-trip, corrupt JSON handling, atomic write, `DebouncedOffsetWriter` interval/max-pending semantics, explicit flush
224+
- `test_sdnotify.py` — 7 tests: NOTIFY_SOCKET handling (absent/empty/filesystem/abstract-namespace), send error swallowing, UTF-8 encoding
209225

210226
## Development
211227

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ The wizard offers three **workflow modes** — pick the one that fits:
9595
- 🔄 **Cross-environment resume** — start a session in your terminal, pick it up from Telegram with `/continue`; works with Claude Code, Codex, OpenCode, Pi, and Gemini ([guide](docs/how-to/cross-environment-resume.md))
9696
- 📎 **File transfer** — upload files to your repo with `/file put`, download with `/file get`; agents can also deliver files automatically by writing to `.untether-outbox/` during a run — sent as Telegram documents on completion
9797
- 🛡️ **Graceful recovery** — orphan progress messages cleaned up on restart; stall detection with CPU-aware diagnostics; auto-continue for Claude Code sessions that exit prematurely
98-
-**Scheduled tasks** — cron expressions with timezone support, webhook triggers, and hot-reload configuration (no restart required)
98+
-**Scheduled tasks** — cron expressions with timezone support, webhook triggers, one-shot delays (`/at 30m <prompt>`), `run_once` crons, and hot-reload configuration (no restart required). `/ping` shows per-chat trigger summary; trigger-initiated runs show provenance in the footer
9999
- 💬 **Forum topics** — map Telegram topics to projects and branches
100100
- 📤 **Session export**`/export` for markdown or JSON transcripts
101101
- 🗂️ **File browser**`/browse` to navigate project files with inline buttons
@@ -179,7 +179,8 @@ The wizard offers three **workflow modes** — pick the one that fits:
179179
| `/trigger` | Set group chat trigger mode |
180180
| `/stats` | Per-engine session statistics (today/week/all-time) |
181181
| `/auth` | Codex device re-authentication |
182-
| `/ping` | Health check / uptime |
182+
| `/at 30m <prompt>` | Schedule a one-shot delayed run (60s–24h; `/cancel` to drop) |
183+
| `/ping` | Health check / uptime (shows per-chat trigger summary if any) |
183184

184185
Prefix any message with `/<engine>` to pick an engine for that task, or `/<project>` to target a repo:
185186

contrib/untether.service

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,20 @@
66
# systemctl --user enable --now untether
77
#
88
# Key settings:
9+
# Type=notify — Untether sends READY=1 via sd_notify after the
10+
# first getUpdates succeeds, so systemd knows the
11+
# bot is actually healthy (not just "PID exists").
12+
# STOPPING=1 is sent during drain. See #287.
13+
# NotifyAccess=main — only the main process can send sd_notify messages
14+
# (defence in depth).
915
# KillMode=mixed — SIGTERM only the main process first (drain logic
1016
# waits for active runs); then SIGKILL all remaining
1117
# cgroup processes (orphaned MCP servers, containers)
1218
# TimeoutStopSec=150 — give the 120s drain timeout room to complete
1319
# before systemd sends SIGKILL
20+
# RestartSec=2 — resume quickly after drain completes; Telegram
21+
# update_id persistence (#287) means no lost
22+
# messages across the restart gap.
1423
# OOMScoreAdjust=-100 — lower than CLI/tmux processes (oom_score_adj=0);
1524
# prevents earlyoom/kernel OOM killer from picking
1625
# Untether's Claude subprocesses first under memory
@@ -29,10 +38,11 @@ After=network-online.target
2938
Wants=network-online.target
3039

3140
[Service]
32-
Type=simple
41+
Type=notify
42+
NotifyAccess=main
3343
ExecStart=%h/.local/bin/untether
3444
Restart=always
35-
RestartSec=10
45+
RestartSec=2
3646

3747
# Graceful shutdown: SIGTERM the main process first, then SIGKILL the rest.
3848
# - process: SIGTERM main only, but orphaned children (MCP servers,

0 commit comments

Comments
 (0)