Skip to content

chore: release v0.35.1#309

Merged
Nathan Schram (nathanschram) merged 48 commits intomasterfrom
dev
Apr 15, 2026
Merged

chore: release v0.35.1#309
Nathan Schram (nathanschram) merged 48 commits intomasterfrom
dev

Conversation

@nathanschram
Copy link
Copy Markdown
Member

@nathanschram Nathan Schram (nathanschram) commented Apr 15, 2026

Summary

Promotes v0.35.1 from dev → master for PyPI publish. Aggregates all v0.35.1rc1 → rc6 staging work plus final release polish.

Headline changes (see full changelog for entries with issue links):

Trigger enhancements (7 major features)

Reliability & restarts

Security hardening (6 issues)

Bug fixes

Docs & observability

Staging record

  • rc1–rc6 staged to TestPyPI across 2026-04-01 → 2026-04-15
  • rc5 ran on @hetz_lba1_bot since 2026-04-14 without issues
  • rc6 ran on @hetz_lba1_bot since earlier today, added doc audit + healthcheck fix + uninstall guides

Pre-release validation (all passing)

  • `python3 scripts/validate_release.py` — 3 passed, 0 failed
  • `uv run ruff format --check src/ tests/` — 260 files clean
  • `uv run ruff check src/` — all checks passed
  • `uv lock --check` — lockfile in sync
  • `uv run pytest` — 2164 passed, 1 skipped, 81.65% coverage
  • `uv build` — wheel + sdist built
  • All 19 CI checks green on dev (format, ruff, ty, pytest 3.12/3.13/3.14, build, lockfile, docs, pip-audit, bandit, CodeQL, install-test, release-validation, Socket Security, CodeRabbit)

Nathan must do (release guard)

  • Review and approve this PR
  • Squash-merge to master (Claude Code is blocked from master merges by release guard)
  • Tag: `git tag v0.35.1 && git push origin master --tags`
  • Approve the `pypi` environment in the release workflow (required reviewer gate)

On tag push, `release.yml` will:

  1. Validate tag matches `pyproject.toml` version (`0.35.1`)
  2. Run full pytest suite on Python 3.12/3.13/3.14
  3. Build wheel + sdist, validate with `twine check` and `check-wheel-contents`
  4. Publish to PyPI via trusted publishing (OIDC) — pauses at `pypi` environment for manual approval
  5. Create GitHub Release with auto-generated notes and dist artifacts

Post-release verification (after PyPI publishes)

```bash
pip index versions untether # should show 0.35.1
gh release view v0.35.1
pipx upgrade untether && untether --version # should print 0.35.1
scripts/healthcheck.sh --version 0.35.1 # post-deploy health check
```

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • /at one-shot delayed runs with /cancel, per-chat caps, and trigger provenance shown in run footers and /ping.
    • Timezone-aware cron scheduling, run_once crons, cron data-fetch, and non-agent webhook actions (file write, HTTP forward, notify).
    • Hot-reload for trigger and Telegram bridge settings; persisted Telegram polling offset and systemd readiness/stop notifications.
  • Bug Fixes

    • Improved multipart webhook handling, rate-limiting behavior, stall-warning accuracy, and JSONL stdout safety; faster, safer restarts.
  • Documentation

    • New update/uninstall guides, expanded trigger, cron, SSRF, and group-button authorization docs.

…#158, #159)

* ci: add CODEOWNERS, update action SHA pins, add permission comments

- Create .github/CODEOWNERS requiring @littlebearapps/core review
- Pin setup-uv to v7.4.0 (6ee6290f), download-artifact to v8.0.1 (3e5f45b2)
- Add precise version comments on all action SHAs (codeql v3.32.6,
  pypi-publish v1.13.0, action-gh-release v2.5.0, fetch-metadata v2.5.0)
- Document write permissions with why-comments (OIDC, releases, auto-merge)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: add release guard hooks and document protection in CLAUDE.md

Defence-in-depth hooks prevent Claude Code from pushing to master,
merging PRs, creating tags, or triggering releases. Feature branch
pushes and PR creation remain allowed.

- release-guard.sh: Bash hook blocking master push, tags, releases, PR merge
- release-guard-protect.sh: Edit/Write hook protecting guard files and hooks.json
- release-guard-mcp.sh: GitHub MCP hook blocking merge and master writes
- hooks.json: register all three hooks
- CLAUDE.md: document release guard, update workflow roles, CI pipeline notes

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: clarify /config default labels and remove redundant "Works with" lines

Default labels now explain what "default" means for each setting:
- Diff preview: "default (off)" — matches actual behaviour (was "default (on)")
- Model/Reasoning: "default (engine decides)"
- API cost: "default (on)", Subscription usage: "default (off)"
- Plan mode home hint: "agent decides"
- Diff preview home hint: "buttons only"

Added info lines to plan mode and reasoning sub-pages explaining
the default behaviour in more detail.

Removed all 9 "Works with: ..." lines from sub-pages — they're
redundant because engine visibility guards already hide settings
from unsupported engines.

Fixes #119

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: suppress redundant cost footer on error runs

When a run fails (e.g. subscription limit hit), the diagnostic context
line from _extract_error() already shows cost, turns, and API time.
The 💰 cost footer was duplicating this same data in a different format.

Now the cost footer only appears on successful runs where it's the sole
source of cost information. Error runs still show cost in the diagnostic
line, and budget alerts still fire regardless.

Also adds usage field to mock Return dataclass (matching ErrorReturn)
so tests can verify cost footer behaviour on success runs.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: suppress stall notifications when CPU-active + heartbeat re-render

When cpu_active=True (extended thinking, background agents), suppress
Telegram stall warning notifications and instead trigger a heartbeat
re-render so the elapsed time counter keeps ticking. Notifications
still fire when cpu_active=False or None (no baseline).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* chore: staging 0.34.5rc2

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: CI release-validation tomllib bytes/str mismatch

tomllib.loads() expects str but was receiving bytes from
sys.stdin.buffer.read() and open(...,'rb').read(). First
triggered when PR #122 changed the version (rc1 → rc2).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* docs: integrate screenshots into docs with correct JPG references

- Add 44 screenshots to docs/assets/screenshots/
- Fix all image refs from .png to .jpg across 25 doc files
- README uses absolute raw.githubusercontent.com URLs for PyPI rendering
- Fix 5 filename mismatches (session-auto-resume→chat-auto-resume, etc.)
- Comment out 11 missing screenshots with TODO markers
- Add CAPTURES.md checklist tracking capture status

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: convert markdown images to HTML img tags for GitHub compatibility

Switch from MkDocs `![alt](src){ loading=lazy }` syntax to HTML `<img>`
tags with width="360" and loading="lazy". Fixes two GitHub rendering
issues: `{ loading=lazy }` appearing as visible text, and oversized
images with no width constraint.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: fix 3 screenshot mismatches and replace 3 screenshots

- first-run.md: rewrite resume line text to match footer screenshot
- interactive-control.md: update planmode show admonition to match screenshot (auto not on)
- switch-engines.md: swap engine-footer.jpg for multi-engine-switch.jpg
- Replace startup-message.jpg with clean v0.34.4 capture (was rc/6-projects)
- Replace cooldown-auto-deny.jpg with post-outline approve/deny buttons
- Replace file-put.jpg with photo save confirmation

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: add iOS caption limitation note to file transfer guide

Telegram iOS doesn't show a caption field when sending documents via the
File picker, so /file put <path> captions aren't easily accessible.
Added a note with workarounds (use Desktop, send as photo, or let
auto-save handle it). Updated screenshot alt text to match actual
screenshot content.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: temp swap README image URLs to feature branch for preview

Will revert to master before merging.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: lay out all 3 README screenshots in a single row

Reduce from 360px to 270px each and combine into one <p> block
so all three hero screenshots sit side by side.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: swap 3rd hero screenshot for config-menu for visual variety

Replace plan-outline-approve (too similar to approval-diff-preview)
with config-menu showing the /config settings grid. The three hero
images now tell: voice input → approve changes → configure everything.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: add captions under README hero screenshots

Small <sub> captions: "Send tasks by voice (Whisper transcription)",
"Approve changes remotely", "Configure from Telegram".

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: use table layout for README hero screenshots with captions

Fixes stacking issue — <br> in a <p> broke inline flow. A table
keeps images side by side with captions underneath each one.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: replace table layout with single hero collage image

Composite image scales proportionally on mobile instead of requiring
horizontal scroll. Captions baked into the image via ImageMagick.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: swap middle hero screenshot for full 3-button approval view

Replace approval-diff-preview with approval-buttons-howto showing
Approve / Deny / Pause & Outline Plan — more visually impressive.
Caption now reads "Approve changes remotely (Claude Code)".
Added footnote linking to engine compatibility table.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: swap config-menu for parallel-projects in hero collage

Third hero screenshot now shows 10+ projects running simultaneously
across different repos — much more compelling than a settings menu.
New caption: "Run agents across projects in parallel".

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: revert README image URL to master for merge

Swap hero-collage URL back from feature/github-hardening to master.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: staging 0.34.5rc3

- fix: preserve all EngineOverrides fields when setting model/planmode/reasoning
  (was silently wiping ask_questions, diff_preview, show_api_cost, etc.)
- fix: /config home page resolves "default" to effective values
- feat: file upload auto-deduplication (append _1, _2 instead of requiring --force)
- feat: media groups without captions now auto-save instead of showing usage text
- feat: resume line visual separation (blank line + ↩️ prefix)
- fix: claude auto-approve echoes updatedInput in control response

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: expand permission policies for Codex CLI and Gemini CLI in /config

Codex gets a new "Approval policy" page (full auto / safe) that passes
--ask-for-approval untrusted when safe mode is selected. Gemini's approval
mode expands from 2 to 3 tiers (read-only / edit files / full access) with
--approval-mode auto_edit for the middle tier. Both engines now show an
"Agent controls" section on the /config home page. Engine-specific model
default hints replace the generic "from CLI settings" text.

Also adds staging.sh helper, context-guard-stop hook, and docs updates.

Closes #131

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: staging 0.34.5rc4

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: /config UX overhaul, resume line toggle, cost budget settings, model metadata

/config UX cleanup:
- Convert all binary toggles from 3-column (on/off/clear) to 2-column
  (toggle + clear) for better mobile tap targets
- Merge Engine + Model into combined "Engine & model" page
- Reorganise home page to max 2 buttons per row across all engines
- Split plan mode 3-option rows (off/on/auto) into 2+1 layout
- Add _toggle_row() helper for consistent toggle button rendering

New features:
- #128: Resume line /config toggle — per-chat show_resume_line override
  via EngineOverrides with On/Off/Clear buttons, wired into executor
- #129: Cost budget /config settings — per-chat budget_enabled and
  budget_auto_cancel overrides on the Cost & Usage page, wired into
  _check_cost_budget() in runner_bridge.py

Model metadata improvements:
- Show Claude Code [1m] context window suffix: "opus 4.6 (1M)"
- Strip Gemini CLI "auto-" prefix: "auto-gemini-3" → "gemini-3"
- Future-proof: unknown suffixes default to .upper() (e.g. [500k] → 500K)

Bug fixes:
- #124: Standalone override commands (/planmode, /model, /reasoning) now
  preserve all EngineOverrides fields including new ones
- Error handling: control_response.write_failed catch-all in claude.py,
  ask_question.extraction_failed warning, model.override.failed logging

Hardening:
- Plan outline sent as separate ephemeral message (avoids 4096 char truncation)
- Added show_resume_line, budget_enabled, budget_auto_cancel to
  EngineOverrides, EngineRunOptions, normalize/merge, and all constructors

Tests: 1610 passed, 80.56% coverage, ruff clean.
Integration tested on @untether_dev_bot across all 6 engine chats.

Closes #128, closes #129, fixes #124

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: trigger CI for PR #132

* fix: address 11 CodeRabbit review comments on PR #132

Bug fixes:
- claude.py: fix UnboundLocalError when factory.resume is falsy in
  ask_question.extraction_failed logging path
- ask_question.py: reject malformed option callbacks instead of
  silently falling back to option 0
- files.py: raise FileExistsError when deduplicate_target exhausts
  999 suffixes instead of returning the original (overwrite risk)
- config.py: disambiguate Codex "Full auto" (fa) vs Gemini "Full
  access" (ya) callback IDs and toast labels

Hardening:
- codex.py: add --ask-for-approval to _EXEC_ONLY_FLAGS guard
- model.py: add try/except to clear path (matching set path)
- reasoning.py: add try/except to clear path (matching set path)
- loop.py: notify user when media group upload fails instead of
  silently dropping
- export.py: log session count instead of identifiers at info level
- config.py: resolve resume-line default from config instead of
  hardcoding True
- staging.sh: pin PyPI index in rollback/reset with --pip-args

Skipped (not applicable):
- CHANGELOG.md: RC versions don't get changelog entries per release
  discipline
- docs/tutorials TODO screenshot: pre-existing, not introduced by PR
- .claude/hooks/context-guard-stop.sh: ContextDocs plugin hook, not
  Untether source

Tests: 1611 passed, 80.48% coverage, ruff clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: replace bare pass with debug log to satisfy bandit B110

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: setup wizard security + UX improvements

- Auto-set allowed_user_ids from captured Telegram user ID during
  onboarding (security: restricts bot to the setup user's account)
- Add "next steps" panel after wizard completion with pointers to
  /config, voice notes, projects, and account lock confirmation
- Update install.md: Python 3.12+ (not just 3.14), dynamic version
  string, /config mention for post-setup changes
- Update first-run.md: /config → Engine & model for default engine

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: plan outline UX — markdown rendering, buttons, cleanup (#139, #140, #141)

- Render outline messages as formatted text via render_markdown() +
  split_markdown_body() instead of raw markdown (#139)
- Add approve/deny buttons to last outline message so users don't
  have to scroll up past long outlines (#140)
- Delete outline messages on approve/deny via module-level
  _OUTLINE_REGISTRY callable from callback handler; suppress stale
  keyboard on progress message (#141)
- 8 new tests for outline rendering, keyboard placement, and cleanup
- Bump version to 0.35.0rc5

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: /continue command — cross-environment resume for all engines (#135)

New `/continue` command resumes the most recent CLI session in the
project directory from Telegram. Enables starting a session in your
terminal and picking it up from your phone.

Engine support: Claude (--continue), Codex (resume --last), OpenCode
(--continue), Pi (--continue), Gemini (--resume latest). AMP not
supported (requires explicit thread ID).

Includes ResumeToken.is_continue flag, build_args for all 6 runners,
reserved command registration, resume emoji prefix stripping for
reply-to-continue, docs (how-to guide, README, commands ref, routing
explanation, conversation modes tutorial), and 99 new test assertions.

Integration tested against @untether_dev_bot — all 5 supported engines
passed secret-recall verification via Telegram MCP.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: agent outbox file delivery + fix cross-chat ask stealing (#143, #144)

Outbox delivery (#143): agents write files to .untether-outbox/ during a
run; Untether sends them as Telegram documents on completion with 📎
captions. Config: outbox_enabled, outbox_dir, outbox_max_files,
outbox_cleanup. Deny-glob security, size limits, auto-cleanup.
Preamble updated for all 6 engines. Integration tested across
Claude, Codex, OpenCode, Pi, and Gemini.

AskUserQuestion fix (#144): _PENDING_ASK_REQUESTS and
_ASK_QUESTION_FLOWS were global dicts with no chat_id scoping — a
pending ask in one chat would steal the next message from any other
chat. Added channel_id contextvar and scoped all ask lookups by it.
Session cleanup now also clears stale pending asks.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: v0.35.0 changelog completion + fix #123 updatedInput

- Complete v0.35.0 changelog: add missing entries for /continue (#135),
  /config UX overhaul (#132), resume line toggle (#128), cost budget
  (#129), model metadata, resume line formatting (#127), override
  preservation (#124), and updatedInput fix (#123)
- Fix #123: register input for system-level auto-approved control
  requests so updatedInput is included in the response
- Add parameterised test for all 5 auto-approve types input registration
- Remove unused OutboxResult import (ruff fix)

Issues closed: #115, #118, #123, #124, #126, #127, #134

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: staging 0.35.0rc6

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: rc6 integration test fixes (#145, #146, #147, #148, #149)

- Reduce Telegram API timeout from 120s to 30s (#145)
- OpenCode error runs show error text instead of empty body (#146)
- Pi /continue captures session ID via allow_id_promotion (#147)
- Post-outline approval uses skip_reply to avoid "not found" (#148)
- Orphan progress message cleanup on restart (#149)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: post-outline notification reply + OpenCode empty body (#148, #150)

- #148: skip_reply callback results now bypass the executor's default
  reply_to fallback, sending directly via the transport with no
  reply_to_message_id. Previously, the executor treated reply_to=None
  as "use default" which pointed to the (deleted) outline message.

- #150: OpenCode normal completion with no Text events now falls back
  to last_tool_error. Added state.last_tool_error field populated on
  ToolUse error status. Covers both translate() and stream_end_events().

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: suppress post-outline notification to avoid "message not found" (#148)

After outline approval/denial, the progress loop's _send_notify was
firing for the next tool approval, but the notification's reply_to
anchor could reference deleted state. Added _outline_just_resolved
flag to skip one notification cycle after outline cleanup.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: note OpenCode lacks auto-compaction — long sessions degrade (#150)

Added known limitation to OpenCode runner docs and integration testing
playbook. OpenCode sessions accumulate unbounded context (no compaction
events unlike Pi). Workaround: use /new before isolated tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: skip_reply on regular approve path when outline was deleted (#148)

The "Approve Plan" button on outline messages uses the real
ExitPlanMode request_id, routing through the regular approve path
(not the da: synthetic path). When outline messages exist, set
skip_reply=True on the CommandResult to avoid replying to the
just-deleted outline message.

Also added reply_to_message_id and text_preview to transport.send.failed
warning for easier debugging.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: update changelog for rc6 integration test fixes (#145-#150)

Updated fix descriptions for #146/#150 (OpenCode last_tool_error
fallback) and #148 (regular approve path skip_reply). Added docs
section for OpenCode compaction limitation. Updated test counts.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* style: fix formatting after merge resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address CodeRabbit review comments on PR #151

- bridge.py: replace text_preview with text_len in send failure warning
  to avoid logging raw message content (security)
- runner_bridge.py: move unregister_progress() after send_result_message()
  to avoid orphan window between ephemeral cleanup and final message send
- cross-environment-resume.md: add language spec to code block

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: resolve /config "default" labels to effective on/off values (#152)

Sub-pages showed "Current: default" or "default (on/off)" while buttons
already showed the resolved value. Now all boolean-toggle settings show
the effective on/off value in both text and buttons.

Affected: verbose, ask mode, diff preview, API cost, subscription usage,
budget enabled/auto-cancel, resume line. Home page cost & resume labels
also resolved.

Plan mode, model, and reasoning keep "default" since they depend on CLI
settings and aren't simple on/off booleans.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: update changelog for rc7 config default labels fix (#152)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: update documentation for v0.35.0

- fix missing nav entries in zensical.toml (cross-env resume, Gemini/Amp runners)
- rewrite inline-settings.md for /config UX overhaul (2-column toggles, budget/resume toggles)
- update plan-mode.md with outline rendering, buttons-on-last-chunk, ephemeral cleanup
- update interactive-control tutorial with outline UX improvements
- add orphan progress cleanup section to operations.md
- add engine-specific approval policies to interactive-approval.md
- add per-chat budget overrides to cost-budgets.md
- update module-map.md with Gemini/Amp and new modules (outbox, progress persistence, proc_diag)
- update architecture.md mermaid diagrams with all 6 engines
- bump specification.md to v0.35.0, add progress persistence and outbox sections
- add v0.35.0 screenshot entries to CAPTURES.md

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: broaden frozen ring buffer stall escalation beyond MCP tools (#155)

Frozen ring buffer escalation was gated on `mcp_server is not None`,
so general stalls with cpu_active=True and no MCP tool running were
silently suppressed indefinitely. Broadened to fire for all stalls
after 3+ checks with no new JSONL events regardless of tool type.

New notification: "CPU active, no new events" for non-MCP frozen stalls.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: tool approval buttons no longer suppressed after outline approval (#156)

After "Approve Plan" on an outline, the stale discuss_approve action
remained in ProgressTracker with completed=False. The renderer picked
up its stale "Approve Plan"/"Deny" buttons first, then the suppression
logic at line 994 stripped ALL buttons — including new Write/Edit/Bash
approval buttons. Claude blocked indefinitely waiting for approval.

Fix: after suppressing stale buttons, complete the discuss_approve
action(s) in the tracker, reset _outline_sent, and trigger a re-render
so subsequent tool requests get their own Approve/Deny buttons.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: add workflow mode indicator to startup message, fix startup crash on topics validation failure (#158, #159)

Features:
- Startup message now shows mode: assistant/workspace/handoff
- Derived from session_mode + topics.enabled config values
- _resolve_mode_label() helper in backend.py

Bug fixes:
- Fix UnboundLocalError crash when topics validation fails on startup (#158)
  - Moved import signal and shutdown imports before try block in loop.py
- Downgrade can_manage_topics check from fatal error to warning (#159)
  - Bot can now start without manage_topics admin right
  - Existing topics work fine; only topic creation/editing affected

Tests:
- 17 new unit tests for stateless/handoff mode (test_stateless_mode.py)
  - _should_show_resume_line, _chat_session_key, ResumeResolver, ResumeLineProxy
  - Integration-level: stateless shows resume lines, no auto-resume, chat hides lines
- 3 new tests for mode indicator in startup message (test_telegram_backend.py)

Docs:
- New docs/reference/modes.md — comprehensive reference for all 3 workflow modes
- Updated docs/reference/index.md and zensical.toml nav with modes page

* docs: comprehensive three-mode coverage across all documentation

New:
- docs/how-to/choose-a-mode.md — decision tree, mode comparison, mermaid
  sequence diagrams, configuration examples, switching guide, workspace
  prerequisites

Updated:
- README.md — improved three-mode description in features list
- docs/tutorials/install.md — added mode selection step (section 10)
- docs/tutorials/first-run.md — added 'What mode am I in?' tip
- docs/reference/config.md — cross-linked session_mode/show_resume_line to modes.md
- docs/reference/transports/telegram.md — added mode requirement callouts
  for forum topics and chat sessions sections
- docs/how-to/chat-sessions.md — added session persistence explanation
  (state files, auto-resume mechanics, handoff note)
- docs/how-to/topics.md — expanded prerequisites checklist with group
  privacy, can_manage_topics, and re-add steps
- docs/how-to/cross-environment-resume.md — added handoff mode terminal
  workflow with mermaid sequence diagram
- docs/how-to/index.md — added 'Getting started' section with choose-a-mode
- zensical.toml — added choose-a-mode to nav

* docs: add three-mode summary table to README Quick Start section

* feat: migrate to dev branch workflow — dev→TestPyPI, master→PyPI

Branch model:
- feature/* → PR → dev (TestPyPI auto-publish) → PR → master (PyPI)
- master always matches latest PyPI release
- dev is the integration/staging branch

CI changes:
- ci.yml: TestPyPI publish triggers on dev push (was master)
- ci.yml, codeql.yml: CI runs on both master and dev pushes
- dependabot.yml: PRs target dev branch

Hook changes:
- release-guard.sh: updated messages to mention dev branch
- release-guard-mcp.sh: updated messages to mention dev branch
- Both hooks already allow dev pushes (only block master/main)

Documentation:
- CLAUDE.md: updated 3-phase workflow, CI table, release guard docs
- dev-workflow.md: added branch model section
- release-discipline.md: added dev branch staging notes

* ci: retrigger CI for PR #160

* feat: allow Claude Code to merge PRs targeting dev branch only

Release guard hooks now check the PR's base branch:
- dev → allowed (TestPyPI/staging)
- master/main → blocked (PyPI releases remain Nathan-only)

Both Bash hook (gh pr merge) and MCP hook (merge_pull_request)
updated with base branch checking via gh pr view.

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
* ci: add CODEOWNERS, update action SHA pins, add permission comments

- Create .github/CODEOWNERS requiring @littlebearapps/core review
- Pin setup-uv to v7.4.0 (6ee6290f), download-artifact to v8.0.1 (3e5f45b2)
- Add precise version comments on all action SHAs (codeql v3.32.6,
  pypi-publish v1.13.0, action-gh-release v2.5.0, fetch-metadata v2.5.0)
- Document write permissions with why-comments (OIDC, releases, auto-merge)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: add release guard hooks and document protection in CLAUDE.md

Defence-in-depth hooks prevent Claude Code from pushing to master,
merging PRs, creating tags, or triggering releases. Feature branch
pushes and PR creation remain allowed.

- release-guard.sh: Bash hook blocking master push, tags, releases, PR merge
- release-guard-protect.sh: Edit/Write hook protecting guard files and hooks.json
- release-guard-mcp.sh: GitHub MCP hook blocking merge and master writes
- hooks.json: register all three hooks
- CLAUDE.md: document release guard, update workflow roles, CI pipeline notes

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: clarify /config default labels and remove redundant "Works with" lines

Default labels now explain what "default" means for each setting:
- Diff preview: "default (off)" — matches actual behaviour (was "default (on)")
- Model/Reasoning: "default (engine decides)"
- API cost: "default (on)", Subscription usage: "default (off)"
- Plan mode home hint: "agent decides"
- Diff preview home hint: "buttons only"

Added info lines to plan mode and reasoning sub-pages explaining
the default behaviour in more detail.

Removed all 9 "Works with: ..." lines from sub-pages — they're
redundant because engine visibility guards already hide settings
from unsupported engines.

Fixes #119

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: suppress redundant cost footer on error runs

When a run fails (e.g. subscription limit hit), the diagnostic context
line from _extract_error() already shows cost, turns, and API time.
The 💰 cost footer was duplicating this same data in a different format.

Now the cost footer only appears on successful runs where it's the sole
source of cost information. Error runs still show cost in the diagnostic
line, and budget alerts still fire regardless.

Also adds usage field to mock Return dataclass (matching ErrorReturn)
so tests can verify cost footer behaviour on success runs.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: suppress stall notifications when CPU-active + heartbeat re-render

When cpu_active=True (extended thinking, background agents), suppress
Telegram stall warning notifications and instead trigger a heartbeat
re-render so the elapsed time counter keeps ticking. Notifications
still fire when cpu_active=False or None (no baseline).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* chore: staging 0.34.5rc2

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: CI release-validation tomllib bytes/str mismatch

tomllib.loads() expects str but was receiving bytes from
sys.stdin.buffer.read() and open(...,'rb').read(). First
triggered when PR #122 changed the version (rc1 → rc2).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* docs: integrate screenshots into docs with correct JPG references

- Add 44 screenshots to docs/assets/screenshots/
- Fix all image refs from .png to .jpg across 25 doc files
- README uses absolute raw.githubusercontent.com URLs for PyPI rendering
- Fix 5 filename mismatches (session-auto-resume→chat-auto-resume, etc.)
- Comment out 11 missing screenshots with TODO markers
- Add CAPTURES.md checklist tracking capture status

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: convert markdown images to HTML img tags for GitHub compatibility

Switch from MkDocs `![alt](src){ loading=lazy }` syntax to HTML `<img>`
tags with width="360" and loading="lazy". Fixes two GitHub rendering
issues: `{ loading=lazy }` appearing as visible text, and oversized
images with no width constraint.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: fix 3 screenshot mismatches and replace 3 screenshots

- first-run.md: rewrite resume line text to match footer screenshot
- interactive-control.md: update planmode show admonition to match screenshot (auto not on)
- switch-engines.md: swap engine-footer.jpg for multi-engine-switch.jpg
- Replace startup-message.jpg with clean v0.34.4 capture (was rc/6-projects)
- Replace cooldown-auto-deny.jpg with post-outline approve/deny buttons
- Replace file-put.jpg with photo save confirmation

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: add iOS caption limitation note to file transfer guide

Telegram iOS doesn't show a caption field when sending documents via the
File picker, so /file put <path> captions aren't easily accessible.
Added a note with workarounds (use Desktop, send as photo, or let
auto-save handle it). Updated screenshot alt text to match actual
screenshot content.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: temp swap README image URLs to feature branch for preview

Will revert to master before merging.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: lay out all 3 README screenshots in a single row

Reduce from 360px to 270px each and combine into one <p> block
so all three hero screenshots sit side by side.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: swap 3rd hero screenshot for config-menu for visual variety

Replace plan-outline-approve (too similar to approval-diff-preview)
with config-menu showing the /config settings grid. The three hero
images now tell: voice input → approve changes → configure everything.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: add captions under README hero screenshots

Small <sub> captions: "Send tasks by voice (Whisper transcription)",
"Approve changes remotely", "Configure from Telegram".

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: use table layout for README hero screenshots with captions

Fixes stacking issue — <br> in a <p> broke inline flow. A table
keeps images side by side with captions underneath each one.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: replace table layout with single hero collage image

Composite image scales proportionally on mobile instead of requiring
horizontal scroll. Captions baked into the image via ImageMagick.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: swap middle hero screenshot for full 3-button approval view

Replace approval-diff-preview with approval-buttons-howto showing
Approve / Deny / Pause & Outline Plan — more visually impressive.
Caption now reads "Approve changes remotely (Claude Code)".
Added footnote linking to engine compatibility table.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: swap config-menu for parallel-projects in hero collage

Third hero screenshot now shows 10+ projects running simultaneously
across different repos — much more compelling than a settings menu.
New caption: "Run agents across projects in parallel".

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: revert README image URL to master for merge

Swap hero-collage URL back from feature/github-hardening to master.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: staging 0.34.5rc3

- fix: preserve all EngineOverrides fields when setting model/planmode/reasoning
  (was silently wiping ask_questions, diff_preview, show_api_cost, etc.)
- fix: /config home page resolves "default" to effective values
- feat: file upload auto-deduplication (append _1, _2 instead of requiring --force)
- feat: media groups without captions now auto-save instead of showing usage text
- feat: resume line visual separation (blank line + ↩️ prefix)
- fix: claude auto-approve echoes updatedInput in control response

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: expand permission policies for Codex CLI and Gemini CLI in /config

Codex gets a new "Approval policy" page (full auto / safe) that passes
--ask-for-approval untrusted when safe mode is selected. Gemini's approval
mode expands from 2 to 3 tiers (read-only / edit files / full access) with
--approval-mode auto_edit for the middle tier. Both engines now show an
"Agent controls" section on the /config home page. Engine-specific model
default hints replace the generic "from CLI settings" text.

Also adds staging.sh helper, context-guard-stop hook, and docs updates.

Closes #131

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: staging 0.34.5rc4

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: /config UX overhaul, resume line toggle, cost budget settings, model metadata

/config UX cleanup:
- Convert all binary toggles from 3-column (on/off/clear) to 2-column
  (toggle + clear) for better mobile tap targets
- Merge Engine + Model into combined "Engine & model" page
- Reorganise home page to max 2 buttons per row across all engines
- Split plan mode 3-option rows (off/on/auto) into 2+1 layout
- Add _toggle_row() helper for consistent toggle button rendering

New features:
- #128: Resume line /config toggle — per-chat show_resume_line override
  via EngineOverrides with On/Off/Clear buttons, wired into executor
- #129: Cost budget /config settings — per-chat budget_enabled and
  budget_auto_cancel overrides on the Cost & Usage page, wired into
  _check_cost_budget() in runner_bridge.py

Model metadata improvements:
- Show Claude Code [1m] context window suffix: "opus 4.6 (1M)"
- Strip Gemini CLI "auto-" prefix: "auto-gemini-3" → "gemini-3"
- Future-proof: unknown suffixes default to .upper() (e.g. [500k] → 500K)

Bug fixes:
- #124: Standalone override commands (/planmode, /model, /reasoning) now
  preserve all EngineOverrides fields including new ones
- Error handling: control_response.write_failed catch-all in claude.py,
  ask_question.extraction_failed warning, model.override.failed logging

Hardening:
- Plan outline sent as separate ephemeral message (avoids 4096 char truncation)
- Added show_resume_line, budget_enabled, budget_auto_cancel to
  EngineOverrides, EngineRunOptions, normalize/merge, and all constructors

Tests: 1610 passed, 80.56% coverage, ruff clean.
Integration tested on @untether_dev_bot across all 6 engine chats.

Closes #128, closes #129, fixes #124

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: trigger CI for PR #132

* fix: address 11 CodeRabbit review comments on PR #132

Bug fixes:
- claude.py: fix UnboundLocalError when factory.resume is falsy in
  ask_question.extraction_failed logging path
- ask_question.py: reject malformed option callbacks instead of
  silently falling back to option 0
- files.py: raise FileExistsError when deduplicate_target exhausts
  999 suffixes instead of returning the original (overwrite risk)
- config.py: disambiguate Codex "Full auto" (fa) vs Gemini "Full
  access" (ya) callback IDs and toast labels

Hardening:
- codex.py: add --ask-for-approval to _EXEC_ONLY_FLAGS guard
- model.py: add try/except to clear path (matching set path)
- reasoning.py: add try/except to clear path (matching set path)
- loop.py: notify user when media group upload fails instead of
  silently dropping
- export.py: log session count instead of identifiers at info level
- config.py: resolve resume-line default from config instead of
  hardcoding True
- staging.sh: pin PyPI index in rollback/reset with --pip-args

Skipped (not applicable):
- CHANGELOG.md: RC versions don't get changelog entries per release
  discipline
- docs/tutorials TODO screenshot: pre-existing, not introduced by PR
- .claude/hooks/context-guard-stop.sh: ContextDocs plugin hook, not
  Untether source

Tests: 1611 passed, 80.48% coverage, ruff clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: replace bare pass with debug log to satisfy bandit B110

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: setup wizard security + UX improvements

- Auto-set allowed_user_ids from captured Telegram user ID during
  onboarding (security: restricts bot to the setup user's account)
- Add "next steps" panel after wizard completion with pointers to
  /config, voice notes, projects, and account lock confirmation
- Update install.md: Python 3.12+ (not just 3.14), dynamic version
  string, /config mention for post-setup changes
- Update first-run.md: /config → Engine & model for default engine

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: plan outline UX — markdown rendering, buttons, cleanup (#139, #140, #141)

- Render outline messages as formatted text via render_markdown() +
  split_markdown_body() instead of raw markdown (#139)
- Add approve/deny buttons to last outline message so users don't
  have to scroll up past long outlines (#140)
- Delete outline messages on approve/deny via module-level
  _OUTLINE_REGISTRY callable from callback handler; suppress stale
  keyboard on progress message (#141)
- 8 new tests for outline rendering, keyboard placement, and cleanup
- Bump version to 0.35.0rc5

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: /continue command — cross-environment resume for all engines (#135)

New `/continue` command resumes the most recent CLI session in the
project directory from Telegram. Enables starting a session in your
terminal and picking it up from your phone.

Engine support: Claude (--continue), Codex (resume --last), OpenCode
(--continue), Pi (--continue), Gemini (--resume latest). AMP not
supported (requires explicit thread ID).

Includes ResumeToken.is_continue flag, build_args for all 6 runners,
reserved command registration, resume emoji prefix stripping for
reply-to-continue, docs (how-to guide, README, commands ref, routing
explanation, conversation modes tutorial), and 99 new test assertions.

Integration tested against @untether_dev_bot — all 5 supported engines
passed secret-recall verification via Telegram MCP.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: agent outbox file delivery + fix cross-chat ask stealing (#143, #144)

Outbox delivery (#143): agents write files to .untether-outbox/ during a
run; Untether sends them as Telegram documents on completion with 📎
captions. Config: outbox_enabled, outbox_dir, outbox_max_files,
outbox_cleanup. Deny-glob security, size limits, auto-cleanup.
Preamble updated for all 6 engines. Integration tested across
Claude, Codex, OpenCode, Pi, and Gemini.

AskUserQuestion fix (#144): _PENDING_ASK_REQUESTS and
_ASK_QUESTION_FLOWS were global dicts with no chat_id scoping — a
pending ask in one chat would steal the next message from any other
chat. Added channel_id contextvar and scoped all ask lookups by it.
Session cleanup now also clears stale pending asks.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: v0.35.0 changelog completion + fix #123 updatedInput

- Complete v0.35.0 changelog: add missing entries for /continue (#135),
  /config UX overhaul (#132), resume line toggle (#128), cost budget
  (#129), model metadata, resume line formatting (#127), override
  preservation (#124), and updatedInput fix (#123)
- Fix #123: register input for system-level auto-approved control
  requests so updatedInput is included in the response
- Add parameterised test for all 5 auto-approve types input registration
- Remove unused OutboxResult import (ruff fix)

Issues closed: #115, #118, #123, #124, #126, #127, #134

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: staging 0.35.0rc6

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: rc6 integration test fixes (#145, #146, #147, #148, #149)

- Reduce Telegram API timeout from 120s to 30s (#145)
- OpenCode error runs show error text instead of empty body (#146)
- Pi /continue captures session ID via allow_id_promotion (#147)
- Post-outline approval uses skip_reply to avoid "not found" (#148)
- Orphan progress message cleanup on restart (#149)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: post-outline notification reply + OpenCode empty body (#148, #150)

- #148: skip_reply callback results now bypass the executor's default
  reply_to fallback, sending directly via the transport with no
  reply_to_message_id. Previously, the executor treated reply_to=None
  as "use default" which pointed to the (deleted) outline message.

- #150: OpenCode normal completion with no Text events now falls back
  to last_tool_error. Added state.last_tool_error field populated on
  ToolUse error status. Covers both translate() and stream_end_events().

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: suppress post-outline notification to avoid "message not found" (#148)

After outline approval/denial, the progress loop's _send_notify was
firing for the next tool approval, but the notification's reply_to
anchor could reference deleted state. Added _outline_just_resolved
flag to skip one notification cycle after outline cleanup.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: note OpenCode lacks auto-compaction — long sessions degrade (#150)

Added known limitation to OpenCode runner docs and integration testing
playbook. OpenCode sessions accumulate unbounded context (no compaction
events unlike Pi). Workaround: use /new before isolated tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: skip_reply on regular approve path when outline was deleted (#148)

The "Approve Plan" button on outline messages uses the real
ExitPlanMode request_id, routing through the regular approve path
(not the da: synthetic path). When outline messages exist, set
skip_reply=True on the CommandResult to avoid replying to the
just-deleted outline message.

Also added reply_to_message_id and text_preview to transport.send.failed
warning for easier debugging.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: update changelog for rc6 integration test fixes (#145-#150)

Updated fix descriptions for #146/#150 (OpenCode last_tool_error
fallback) and #148 (regular approve path skip_reply). Added docs
section for OpenCode compaction limitation. Updated test counts.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* style: fix formatting after merge resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address CodeRabbit review comments on PR #151

- bridge.py: replace text_preview with text_len in send failure warning
  to avoid logging raw message content (security)
- runner_bridge.py: move unregister_progress() after send_result_message()
  to avoid orphan window between ephemeral cleanup and final message send
- cross-environment-resume.md: add language spec to code block

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: resolve /config "default" labels to effective on/off values (#152)

Sub-pages showed "Current: default" or "default (on/off)" while buttons
already showed the resolved value. Now all boolean-toggle settings show
the effective on/off value in both text and buttons.

Affected: verbose, ask mode, diff preview, API cost, subscription usage,
budget enabled/auto-cancel, resume line. Home page cost & resume labels
also resolved.

Plan mode, model, and reasoning keep "default" since they depend on CLI
settings and aren't simple on/off booleans.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: update changelog for rc7 config default labels fix (#152)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: update documentation for v0.35.0

- fix missing nav entries in zensical.toml (cross-env resume, Gemini/Amp runners)
- rewrite inline-settings.md for /config UX overhaul (2-column toggles, budget/resume toggles)
- update plan-mode.md with outline rendering, buttons-on-last-chunk, ephemeral cleanup
- update interactive-control tutorial with outline UX improvements
- add orphan progress cleanup section to operations.md
- add engine-specific approval policies to interactive-approval.md
- add per-chat budget overrides to cost-budgets.md
- update module-map.md with Gemini/Amp and new modules (outbox, progress persistence, proc_diag)
- update architecture.md mermaid diagrams with all 6 engines
- bump specification.md to v0.35.0, add progress persistence and outbox sections
- add v0.35.0 screenshot entries to CAPTURES.md

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: broaden frozen ring buffer stall escalation beyond MCP tools (#155)

Frozen ring buffer escalation was gated on `mcp_server is not None`,
so general stalls with cpu_active=True and no MCP tool running were
silently suppressed indefinitely. Broadened to fire for all stalls
after 3+ checks with no new JSONL events regardless of tool type.

New notification: "CPU active, no new events" for non-MCP frozen stalls.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: tool approval buttons no longer suppressed after outline approval (#156)

After "Approve Plan" on an outline, the stale discuss_approve action
remained in ProgressTracker with completed=False. The renderer picked
up its stale "Approve Plan"/"Deny" buttons first, then the suppression
logic at line 994 stripped ALL buttons — including new Write/Edit/Bash
approval buttons. Claude blocked indefinitely waiting for approval.

Fix: after suppressing stale buttons, complete the discuss_approve
action(s) in the tracker, reset _outline_sent, and trigger a re-render
so subsequent tool requests get their own Approve/Deny buttons.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: add workflow mode indicator to startup message, fix startup crash on topics validation failure (#158, #159)

Features:
- Startup message now shows mode: assistant/workspace/handoff
- Derived from session_mode + topics.enabled config values
- _resolve_mode_label() helper in backend.py

Bug fixes:
- Fix UnboundLocalError crash when topics validation fails on startup (#158)
  - Moved import signal and shutdown imports before try block in loop.py
- Downgrade can_manage_topics check from fatal error to warning (#159)
  - Bot can now start without manage_topics admin right
  - Existing topics work fine; only topic creation/editing affected

Tests:
- 17 new unit tests for stateless/handoff mode (test_stateless_mode.py)
  - _should_show_resume_line, _chat_session_key, ResumeResolver, ResumeLineProxy
  - Integration-level: stateless shows resume lines, no auto-resume, chat hides lines
- 3 new tests for mode indicator in startup message (test_telegram_backend.py)

Docs:
- New docs/reference/modes.md — comprehensive reference for all 3 workflow modes
- Updated docs/reference/index.md and zensical.toml nav with modes page

* docs: comprehensive three-mode coverage across all documentation

New:
- docs/how-to/choose-a-mode.md — decision tree, mode comparison, mermaid
  sequence diagrams, configuration examples, switching guide, workspace
  prerequisites

Updated:
- README.md — improved three-mode description in features list
- docs/tutorials/install.md — added mode selection step (section 10)
- docs/tutorials/first-run.md — added 'What mode am I in?' tip
- docs/reference/config.md — cross-linked session_mode/show_resume_line to modes.md
- docs/reference/transports/telegram.md — added mode requirement callouts
  for forum topics and chat sessions sections
- docs/how-to/chat-sessions.md — added session persistence explanation
  (state files, auto-resume mechanics, handoff note)
- docs/how-to/topics.md — expanded prerequisites checklist with group
  privacy, can_manage_topics, and re-add steps
- docs/how-to/cross-environment-resume.md — added handoff mode terminal
  workflow with mermaid sequence diagram
- docs/how-to/index.md — added 'Getting started' section with choose-a-mode
- zensical.toml — added choose-a-mode to nav

* docs: add three-mode summary table to README Quick Start section

* feat: migrate to dev branch workflow — dev→TestPyPI, master→PyPI

Branch model:
- feature/* → PR → dev (TestPyPI auto-publish) → PR → master (PyPI)
- master always matches latest PyPI release
- dev is the integration/staging branch

CI changes:
- ci.yml: TestPyPI publish triggers on dev push (was master)
- ci.yml, codeql.yml: CI runs on both master and dev pushes
- dependabot.yml: PRs target dev branch

Hook changes:
- release-guard.sh: updated messages to mention dev branch
- release-guard-mcp.sh: updated messages to mention dev branch
- Both hooks already allow dev pushes (only block master/main)

Documentation:
- CLAUDE.md: updated 3-phase workflow, CI table, release guard docs
- dev-workflow.md: added branch model section
- release-discipline.md: added dev branch staging notes

* ci: retrigger CI for PR #160

* feat: allow Claude Code to merge PRs targeting dev branch only

Release guard hooks now check the PR's base branch:
- dev → allowed (TestPyPI/staging)
- master/main → blocked (PyPI releases remain Nathan-only)

Both Bash hook (gh pr merge) and MCP hook (merge_pull_request)
updated with base branch checking via gh pr view.

* docs: add workflow mode indicator and modes.md to CLAUDE.md

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
….0rc8

* ci: add CODEOWNERS, update action SHA pins, add permission comments

- Create .github/CODEOWNERS requiring @littlebearapps/core review
- Pin setup-uv to v7.4.0 (6ee6290f), download-artifact to v8.0.1 (3e5f45b2)
- Add precise version comments on all action SHAs (codeql v3.32.6,
  pypi-publish v1.13.0, action-gh-release v2.5.0, fetch-metadata v2.5.0)
- Document write permissions with why-comments (OIDC, releases, auto-merge)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: add release guard hooks and document protection in CLAUDE.md

Defence-in-depth hooks prevent Claude Code from pushing to master,
merging PRs, creating tags, or triggering releases. Feature branch
pushes and PR creation remain allowed.

- release-guard.sh: Bash hook blocking master push, tags, releases, PR merge
- release-guard-protect.sh: Edit/Write hook protecting guard files and hooks.json
- release-guard-mcp.sh: GitHub MCP hook blocking merge and master writes
- hooks.json: register all three hooks
- CLAUDE.md: document release guard, update workflow roles, CI pipeline notes

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: clarify /config default labels and remove redundant "Works with" lines

Default labels now explain what "default" means for each setting:
- Diff preview: "default (off)" — matches actual behaviour (was "default (on)")
- Model/Reasoning: "default (engine decides)"
- API cost: "default (on)", Subscription usage: "default (off)"
- Plan mode home hint: "agent decides"
- Diff preview home hint: "buttons only"

Added info lines to plan mode and reasoning sub-pages explaining
the default behaviour in more detail.

Removed all 9 "Works with: ..." lines from sub-pages — they're
redundant because engine visibility guards already hide settings
from unsupported engines.

Fixes #119

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: suppress redundant cost footer on error runs

When a run fails (e.g. subscription limit hit), the diagnostic context
line from _extract_error() already shows cost, turns, and API time.
The 💰 cost footer was duplicating this same data in a different format.

Now the cost footer only appears on successful runs where it's the sole
source of cost information. Error runs still show cost in the diagnostic
line, and budget alerts still fire regardless.

Also adds usage field to mock Return dataclass (matching ErrorReturn)
so tests can verify cost footer behaviour on success runs.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: suppress stall notifications when CPU-active + heartbeat re-render

When cpu_active=True (extended thinking, background agents), suppress
Telegram stall warning notifications and instead trigger a heartbeat
re-render so the elapsed time counter keeps ticking. Notifications
still fire when cpu_active=False or None (no baseline).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* chore: staging 0.34.5rc2

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: CI release-validation tomllib bytes/str mismatch

tomllib.loads() expects str but was receiving bytes from
sys.stdin.buffer.read() and open(...,'rb').read(). First
triggered when PR #122 changed the version (rc1 → rc2).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* docs: integrate screenshots into docs with correct JPG references

- Add 44 screenshots to docs/assets/screenshots/
- Fix all image refs from .png to .jpg across 25 doc files
- README uses absolute raw.githubusercontent.com URLs for PyPI rendering
- Fix 5 filename mismatches (session-auto-resume→chat-auto-resume, etc.)
- Comment out 11 missing screenshots with TODO markers
- Add CAPTURES.md checklist tracking capture status

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: convert markdown images to HTML img tags for GitHub compatibility

Switch from MkDocs `![alt](src){ loading=lazy }` syntax to HTML `<img>`
tags with width="360" and loading="lazy". Fixes two GitHub rendering
issues: `{ loading=lazy }` appearing as visible text, and oversized
images with no width constraint.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: fix 3 screenshot mismatches and replace 3 screenshots

- first-run.md: rewrite resume line text to match footer screenshot
- interactive-control.md: update planmode show admonition to match screenshot (auto not on)
- switch-engines.md: swap engine-footer.jpg for multi-engine-switch.jpg
- Replace startup-message.jpg with clean v0.34.4 capture (was rc/6-projects)
- Replace cooldown-auto-deny.jpg with post-outline approve/deny buttons
- Replace file-put.jpg with photo save confirmation

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: add iOS caption limitation note to file transfer guide

Telegram iOS doesn't show a caption field when sending documents via the
File picker, so /file put <path> captions aren't easily accessible.
Added a note with workarounds (use Desktop, send as photo, or let
auto-save handle it). Updated screenshot alt text to match actual
screenshot content.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: temp swap README image URLs to feature branch for preview

Will revert to master before merging.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: lay out all 3 README screenshots in a single row

Reduce from 360px to 270px each and combine into one <p> block
so all three hero screenshots sit side by side.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: swap 3rd hero screenshot for config-menu for visual variety

Replace plan-outline-approve (too similar to approval-diff-preview)
with config-menu showing the /config settings grid. The three hero
images now tell: voice input → approve changes → configure everything.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: add captions under README hero screenshots

Small <sub> captions: "Send tasks by voice (Whisper transcription)",
"Approve changes remotely", "Configure from Telegram".

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: use table layout for README hero screenshots with captions

Fixes stacking issue — <br> in a <p> broke inline flow. A table
keeps images side by side with captions underneath each one.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: replace table layout with single hero collage image

Composite image scales proportionally on mobile instead of requiring
horizontal scroll. Captions baked into the image via ImageMagick.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: swap middle hero screenshot for full 3-button approval view

Replace approval-diff-preview with approval-buttons-howto showing
Approve / Deny / Pause & Outline Plan — more visually impressive.
Caption now reads "Approve changes remotely (Claude Code)".
Added footnote linking to engine compatibility table.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: swap config-menu for parallel-projects in hero collage

Third hero screenshot now shows 10+ projects running simultaneously
across different repos — much more compelling than a settings menu.
New caption: "Run agents across projects in parallel".

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: revert README image URL to master for merge

Swap hero-collage URL back from feature/github-hardening to master.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: staging 0.34.5rc3

- fix: preserve all EngineOverrides fields when setting model/planmode/reasoning
  (was silently wiping ask_questions, diff_preview, show_api_cost, etc.)
- fix: /config home page resolves "default" to effective values
- feat: file upload auto-deduplication (append _1, _2 instead of requiring --force)
- feat: media groups without captions now auto-save instead of showing usage text
- feat: resume line visual separation (blank line + ↩️ prefix)
- fix: claude auto-approve echoes updatedInput in control response

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: expand permission policies for Codex CLI and Gemini CLI in /config

Codex gets a new "Approval policy" page (full auto / safe) that passes
--ask-for-approval untrusted when safe mode is selected. Gemini's approval
mode expands from 2 to 3 tiers (read-only / edit files / full access) with
--approval-mode auto_edit for the middle tier. Both engines now show an
"Agent controls" section on the /config home page. Engine-specific model
default hints replace the generic "from CLI settings" text.

Also adds staging.sh helper, context-guard-stop hook, and docs updates.

Closes #131

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: staging 0.34.5rc4

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: /config UX overhaul, resume line toggle, cost budget settings, model metadata

/config UX cleanup:
- Convert all binary toggles from 3-column (on/off/clear) to 2-column
  (toggle + clear) for better mobile tap targets
- Merge Engine + Model into combined "Engine & model" page
- Reorganise home page to max 2 buttons per row across all engines
- Split plan mode 3-option rows (off/on/auto) into 2+1 layout
- Add _toggle_row() helper for consistent toggle button rendering

New features:
- #128: Resume line /config toggle — per-chat show_resume_line override
  via EngineOverrides with On/Off/Clear buttons, wired into executor
- #129: Cost budget /config settings — per-chat budget_enabled and
  budget_auto_cancel overrides on the Cost & Usage page, wired into
  _check_cost_budget() in runner_bridge.py

Model metadata improvements:
- Show Claude Code [1m] context window suffix: "opus 4.6 (1M)"
- Strip Gemini CLI "auto-" prefix: "auto-gemini-3" → "gemini-3"
- Future-proof: unknown suffixes default to .upper() (e.g. [500k] → 500K)

Bug fixes:
- #124: Standalone override commands (/planmode, /model, /reasoning) now
  preserve all EngineOverrides fields including new ones
- Error handling: control_response.write_failed catch-all in claude.py,
  ask_question.extraction_failed warning, model.override.failed logging

Hardening:
- Plan outline sent as separate ephemeral message (avoids 4096 char truncation)
- Added show_resume_line, budget_enabled, budget_auto_cancel to
  EngineOverrides, EngineRunOptions, normalize/merge, and all constructors

Tests: 1610 passed, 80.56% coverage, ruff clean.
Integration tested on @untether_dev_bot across all 6 engine chats.

Closes #128, closes #129, fixes #124

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: trigger CI for PR #132

* fix: address 11 CodeRabbit review comments on PR #132

Bug fixes:
- claude.py: fix UnboundLocalError when factory.resume is falsy in
  ask_question.extraction_failed logging path
- ask_question.py: reject malformed option callbacks instead of
  silently falling back to option 0
- files.py: raise FileExistsError when deduplicate_target exhausts
  999 suffixes instead of returning the original (overwrite risk)
- config.py: disambiguate Codex "Full auto" (fa) vs Gemini "Full
  access" (ya) callback IDs and toast labels

Hardening:
- codex.py: add --ask-for-approval to _EXEC_ONLY_FLAGS guard
- model.py: add try/except to clear path (matching set path)
- reasoning.py: add try/except to clear path (matching set path)
- loop.py: notify user when media group upload fails instead of
  silently dropping
- export.py: log session count instead of identifiers at info level
- config.py: resolve resume-line default from config instead of
  hardcoding True
- staging.sh: pin PyPI index in rollback/reset with --pip-args

Skipped (not applicable):
- CHANGELOG.md: RC versions don't get changelog entries per release
  discipline
- docs/tutorials TODO screenshot: pre-existing, not introduced by PR
- .claude/hooks/context-guard-stop.sh: ContextDocs plugin hook, not
  Untether source

Tests: 1611 passed, 80.48% coverage, ruff clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: replace bare pass with debug log to satisfy bandit B110

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: setup wizard security + UX improvements

- Auto-set allowed_user_ids from captured Telegram user ID during
  onboarding (security: restricts bot to the setup user's account)
- Add "next steps" panel after wizard completion with pointers to
  /config, voice notes, projects, and account lock confirmation
- Update install.md: Python 3.12+ (not just 3.14), dynamic version
  string, /config mention for post-setup changes
- Update first-run.md: /config → Engine & model for default engine

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: plan outline UX — markdown rendering, buttons, cleanup (#139, #140, #141)

- Render outline messages as formatted text via render_markdown() +
  split_markdown_body() instead of raw markdown (#139)
- Add approve/deny buttons to last outline message so users don't
  have to scroll up past long outlines (#140)
- Delete outline messages on approve/deny via module-level
  _OUTLINE_REGISTRY callable from callback handler; suppress stale
  keyboard on progress message (#141)
- 8 new tests for outline rendering, keyboard placement, and cleanup
- Bump version to 0.35.0rc5

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: /continue command — cross-environment resume for all engines (#135)

New `/continue` command resumes the most recent CLI session in the
project directory from Telegram. Enables starting a session in your
terminal and picking it up from your phone.

Engine support: Claude (--continue), Codex (resume --last), OpenCode
(--continue), Pi (--continue), Gemini (--resume latest). AMP not
supported (requires explicit thread ID).

Includes ResumeToken.is_continue flag, build_args for all 6 runners,
reserved command registration, resume emoji prefix stripping for
reply-to-continue, docs (how-to guide, README, commands ref, routing
explanation, conversation modes tutorial), and 99 new test assertions.

Integration tested against @untether_dev_bot — all 5 supported engines
passed secret-recall verification via Telegram MCP.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: agent outbox file delivery + fix cross-chat ask stealing (#143, #144)

Outbox delivery (#143): agents write files to .untether-outbox/ during a
run; Untether sends them as Telegram documents on completion with 📎
captions. Config: outbox_enabled, outbox_dir, outbox_max_files,
outbox_cleanup. Deny-glob security, size limits, auto-cleanup.
Preamble updated for all 6 engines. Integration tested across
Claude, Codex, OpenCode, Pi, and Gemini.

AskUserQuestion fix (#144): _PENDING_ASK_REQUESTS and
_ASK_QUESTION_FLOWS were global dicts with no chat_id scoping — a
pending ask in one chat would steal the next message from any other
chat. Added channel_id contextvar and scoped all ask lookups by it.
Session cleanup now also clears stale pending asks.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: v0.35.0 changelog completion + fix #123 updatedInput

- Complete v0.35.0 changelog: add missing entries for /continue (#135),
  /config UX overhaul (#132), resume line toggle (#128), cost budget
  (#129), model metadata, resume line formatting (#127), override
  preservation (#124), and updatedInput fix (#123)
- Fix #123: register input for system-level auto-approved control
  requests so updatedInput is included in the response
- Add parameterised test for all 5 auto-approve types input registration
- Remove unused OutboxResult import (ruff fix)

Issues closed: #115, #118, #123, #124, #126, #127, #134

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: staging 0.35.0rc6

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: rc6 integration test fixes (#145, #146, #147, #148, #149)

- Reduce Telegram API timeout from 120s to 30s (#145)
- OpenCode error runs show error text instead of empty body (#146)
- Pi /continue captures session ID via allow_id_promotion (#147)
- Post-outline approval uses skip_reply to avoid "not found" (#148)
- Orphan progress message cleanup on restart (#149)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: post-outline notification reply + OpenCode empty body (#148, #150)

- #148: skip_reply callback results now bypass the executor's default
  reply_to fallback, sending directly via the transport with no
  reply_to_message_id. Previously, the executor treated reply_to=None
  as "use default" which pointed to the (deleted) outline message.

- #150: OpenCode normal completion with no Text events now falls back
  to last_tool_error. Added state.last_tool_error field populated on
  ToolUse error status. Covers both translate() and stream_end_events().

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: suppress post-outline notification to avoid "message not found" (#148)

After outline approval/denial, the progress loop's _send_notify was
firing for the next tool approval, but the notification's reply_to
anchor could reference deleted state. Added _outline_just_resolved
flag to skip one notification cycle after outline cleanup.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: note OpenCode lacks auto-compaction — long sessions degrade (#150)

Added known limitation to OpenCode runner docs and integration testing
playbook. OpenCode sessions accumulate unbounded context (no compaction
events unlike Pi). Workaround: use /new before isolated tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: skip_reply on regular approve path when outline was deleted (#148)

The "Approve Plan" button on outline messages uses the real
ExitPlanMode request_id, routing through the regular approve path
(not the da: synthetic path). When outline messages exist, set
skip_reply=True on the CommandResult to avoid replying to the
just-deleted outline message.

Also added reply_to_message_id and text_preview to transport.send.failed
warning for easier debugging.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: update changelog for rc6 integration test fixes (#145-#150)

Updated fix descriptions for #146/#150 (OpenCode last_tool_error
fallback) and #148 (regular approve path skip_reply). Added docs
section for OpenCode compaction limitation. Updated test counts.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* style: fix formatting after merge resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address CodeRabbit review comments on PR #151

- bridge.py: replace text_preview with text_len in send failure warning
  to avoid logging raw message content (security)
- runner_bridge.py: move unregister_progress() after send_result_message()
  to avoid orphan window between ephemeral cleanup and final message send
- cross-environment-resume.md: add language spec to code block

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: resolve /config "default" labels to effective on/off values (#152)

Sub-pages showed "Current: default" or "default (on/off)" while buttons
already showed the resolved value. Now all boolean-toggle settings show
the effective on/off value in both text and buttons.

Affected: verbose, ask mode, diff preview, API cost, subscription usage,
budget enabled/auto-cancel, resume line. Home page cost & resume labels
also resolved.

Plan mode, model, and reasoning keep "default" since they depend on CLI
settings and aren't simple on/off booleans.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: update changelog for rc7 config default labels fix (#152)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: update documentation for v0.35.0

- fix missing nav entries in zensical.toml (cross-env resume, Gemini/Amp runners)
- rewrite inline-settings.md for /config UX overhaul (2-column toggles, budget/resume toggles)
- update plan-mode.md with outline rendering, buttons-on-last-chunk, ephemeral cleanup
- update interactive-control tutorial with outline UX improvements
- add orphan progress cleanup section to operations.md
- add engine-specific approval policies to interactive-approval.md
- add per-chat budget overrides to cost-budgets.md
- update module-map.md with Gemini/Amp and new modules (outbox, progress persistence, proc_diag)
- update architecture.md mermaid diagrams with all 6 engines
- bump specification.md to v0.35.0, add progress persistence and outbox sections
- add v0.35.0 screenshot entries to CAPTURES.md

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: broaden frozen ring buffer stall escalation beyond MCP tools (#155)

Frozen ring buffer escalation was gated on `mcp_server is not None`,
so general stalls with cpu_active=True and no MCP tool running were
silently suppressed indefinitely. Broadened to fire for all stalls
after 3+ checks with no new JSONL events regardless of tool type.

New notification: "CPU active, no new events" for non-MCP frozen stalls.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: tool approval buttons no longer suppressed after outline approval (#156)

After "Approve Plan" on an outline, the stale discuss_approve action
remained in ProgressTracker with completed=False. The renderer picked
up its stale "Approve Plan"/"Deny" buttons first, then the suppression
logic at line 994 stripped ALL buttons — including new Write/Edit/Bash
approval buttons. Claude blocked indefinitely waiting for approval.

Fix: after suppressing stale buttons, complete the discuss_approve
action(s) in the tracker, reset _outline_sent, and trigger a re-render
so subsequent tool requests get their own Approve/Deny buttons.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: add workflow mode indicator to startup message, fix startup crash on topics validation failure (#158, #159)

Features:
- Startup message now shows mode: assistant/workspace/handoff
- Derived from session_mode + topics.enabled config values
- _resolve_mode_label() helper in backend.py

Bug fixes:
- Fix UnboundLocalError crash when topics validation fails on startup (#158)
  - Moved import signal and shutdown imports before try block in loop.py
- Downgrade can_manage_topics check from fatal error to warning (#159)
  - Bot can now start without manage_topics admin right
  - Existing topics work fine; only topic creation/editing affected

Tests:
- 17 new unit tests for stateless/handoff mode (test_stateless_mode.py)
  - _should_show_resume_line, _chat_session_key, ResumeResolver, ResumeLineProxy
  - Integration-level: stateless shows resume lines, no auto-resume, chat hides lines
- 3 new tests for mode indicator in startup message (test_telegram_backend.py)

Docs:
- New docs/reference/modes.md — comprehensive reference for all 3 workflow modes
- Updated docs/reference/index.md and zensical.toml nav with modes page

* docs: comprehensive three-mode coverage across all documentation

New:
- docs/how-to/choose-a-mode.md — decision tree, mode comparison, mermaid
  sequence diagrams, configuration examples, switching guide, workspace
  prerequisites

Updated:
- README.md — improved three-mode description in features list
- docs/tutorials/install.md — added mode selection step (section 10)
- docs/tutorials/first-run.md — added 'What mode am I in?' tip
- docs/reference/config.md — cross-linked session_mode/show_resume_line to modes.md
- docs/reference/transports/telegram.md — added mode requirement callouts
  for forum topics and chat sessions sections
- docs/how-to/chat-sessions.md — added session persistence explanation
  (state files, auto-resume mechanics, handoff note)
- docs/how-to/topics.md — expanded prerequisites checklist with group
  privacy, can_manage_topics, and re-add steps
- docs/how-to/cross-environment-resume.md — added handoff mode terminal
  workflow with mermaid sequence diagram
- docs/how-to/index.md — added 'Getting started' section with choose-a-mode
- zensical.toml — added choose-a-mode to nav

* docs: add three-mode summary table to README Quick Start section

* feat: migrate to dev branch workflow — dev→TestPyPI, master→PyPI

Branch model:
- feature/* → PR → dev (TestPyPI auto-publish) → PR → master (PyPI)
- master always matches latest PyPI release
- dev is the integration/staging branch

CI changes:
- ci.yml: TestPyPI publish triggers on dev push (was master)
- ci.yml, codeql.yml: CI runs on both master and dev pushes
- dependabot.yml: PRs target dev branch

Hook changes:
- release-guard.sh: updated messages to mention dev branch
- release-guard-mcp.sh: updated messages to mention dev branch
- Both hooks already allow dev pushes (only block master/main)

Documentation:
- CLAUDE.md: updated 3-phase workflow, CI table, release guard docs
- dev-workflow.md: added branch model section
- release-discipline.md: added dev branch staging notes

* ci: retrigger CI for PR #160

* feat: allow Claude Code to merge PRs targeting dev branch only

Release guard hooks now check the PR's base branch:
- dev → allowed (TestPyPI/staging)
- master/main → blocked (PyPI releases remain Nathan-only)

Both Bash hook (gh pr merge) and MCP hook (merge_pull_request)
updated with base branch checking via gh pr view.

* docs: add workflow mode indicator and modes.md to CLAUDE.md

* fix: dual outline buttons (#163), entity URL sanitisation (#157), changelog migration

- Strip approval buttons from progress message when outline is visible —
  only outline message shows Approve/Deny/Cancel (#163)
- Reset outline state via source_has_approval tracking so future
  ExitPlanMode requests work correctly (#163)
- Sanitise text_link entities with invalid URLs (localhost, loopback,
  file paths, bare hostnames) by converting to code entities — prevents
  silent 400 errors that drop the entire final message (#157)
- Merge v0.34.5 changelog into v0.35.0 — v0.34.5 was never released
  (latest PyPI is v0.34.4), all rc1-rc7 work is v0.35.0

17 new tests (2 for #163, 15 for #157).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: staging 0.35.0rc8

fix: restore frozen ring buffer stall escalation (#155)

The #163 fix (6f43e5b) accidentally removed all frozen ring buffer
code from runner_bridge.py. Restored from 8fcad32:

- _frozen_ring_count tracking and ring buffer snapshot comparison
- frozen_escalate gating (fires notification after 3+ frozen checks
  despite cpu_active=True)
- _has_running_mcp_tool() for MCP server name extraction
- _STALL_THRESHOLD_MCP_TOOL (15 min, configurable via watchdog)
- MCP-aware notification text ("MCP tool may be hung", "CPU active,
  no new events", "MCP tool running")
- 8 new tests + 2 updated existing tests
- mcp_tool_timeout watchdog setting

docs: integration testing S1 MCP threshold, tutorials index,
glossary, outbox screenshot, CAPTURES checklist

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: CI lint — unused import in test, bandit nosec for loopback blocklist

- Remove unused ActionEvent import in test_has_running_mcp_tool_returns_server_name
- Add # nosec B104 to _LOOPBACK_HOSTS — it's a URL blocklist, not a bind address

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
* ci: add CODEOWNERS, update action SHA pins, add permission comments

- Create .github/CODEOWNERS requiring @littlebearapps/core review
- Pin setup-uv to v7.4.0 (6ee6290f), download-artifact to v8.0.1 (3e5f45b2)
- Add precise version comments on all action SHAs (codeql v3.32.6,
  pypi-publish v1.13.0, action-gh-release v2.5.0, fetch-metadata v2.5.0)
- Document write permissions with why-comments (OIDC, releases, auto-merge)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: add release guard hooks and document protection in CLAUDE.md

Defence-in-depth hooks prevent Claude Code from pushing to master,
merging PRs, creating tags, or triggering releases. Feature branch
pushes and PR creation remain allowed.

- release-guard.sh: Bash hook blocking master push, tags, releases, PR merge
- release-guard-protect.sh: Edit/Write hook protecting guard files and hooks.json
- release-guard-mcp.sh: GitHub MCP hook blocking merge and master writes
- hooks.json: register all three hooks
- CLAUDE.md: document release guard, update workflow roles, CI pipeline notes

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: clarify /config default labels and remove redundant "Works with" lines

Default labels now explain what "default" means for each setting:
- Diff preview: "default (off)" — matches actual behaviour (was "default (on)")
- Model/Reasoning: "default (engine decides)"
- API cost: "default (on)", Subscription usage: "default (off)"
- Plan mode home hint: "agent decides"
- Diff preview home hint: "buttons only"

Added info lines to plan mode and reasoning sub-pages explaining
the default behaviour in more detail.

Removed all 9 "Works with: ..." lines from sub-pages — they're
redundant because engine visibility guards already hide settings
from unsupported engines.

Fixes #119

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: suppress redundant cost footer on error runs

When a run fails (e.g. subscription limit hit), the diagnostic context
line from _extract_error() already shows cost, turns, and API time.
The 💰 cost footer was duplicating this same data in a different format.

Now the cost footer only appears on successful runs where it's the sole
source of cost information. Error runs still show cost in the diagnostic
line, and budget alerts still fire regardless.

Also adds usage field to mock Return dataclass (matching ErrorReturn)
so tests can verify cost footer behaviour on success runs.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: suppress stall notifications when CPU-active + heartbeat re-render

When cpu_active=True (extended thinking, background agents), suppress
Telegram stall warning notifications and instead trigger a heartbeat
re-render so the elapsed time counter keeps ticking. Notifications
still fire when cpu_active=False or None (no baseline).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* chore: staging 0.34.5rc2

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: CI release-validation tomllib bytes/str mismatch

tomllib.loads() expects str but was receiving bytes from
sys.stdin.buffer.read() and open(...,'rb').read(). First
triggered when PR #122 changed the version (rc1 → rc2).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* docs: integrate screenshots into docs with correct JPG references

- Add 44 screenshots to docs/assets/screenshots/
- Fix all image refs from .png to .jpg across 25 doc files
- README uses absolute raw.githubusercontent.com URLs for PyPI rendering
- Fix 5 filename mismatches (session-auto-resume→chat-auto-resume, etc.)
- Comment out 11 missing screenshots with TODO markers
- Add CAPTURES.md checklist tracking capture status

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: convert markdown images to HTML img tags for GitHub compatibility

Switch from MkDocs `![alt](src){ loading=lazy }` syntax to HTML `<img>`
tags with width="360" and loading="lazy". Fixes two GitHub rendering
issues: `{ loading=lazy }` appearing as visible text, and oversized
images with no width constraint.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: fix 3 screenshot mismatches and replace 3 screenshots

- first-run.md: rewrite resume line text to match footer screenshot
- interactive-control.md: update planmode show admonition to match screenshot (auto not on)
- switch-engines.md: swap engine-footer.jpg for multi-engine-switch.jpg
- Replace startup-message.jpg with clean v0.34.4 capture (was rc/6-projects)
- Replace cooldown-auto-deny.jpg with post-outline approve/deny buttons
- Replace file-put.jpg with photo save confirmation

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: add iOS caption limitation note to file transfer guide

Telegram iOS doesn't show a caption field when sending documents via the
File picker, so /file put <path> captions aren't easily accessible.
Added a note with workarounds (use Desktop, send as photo, or let
auto-save handle it). Updated screenshot alt text to match actual
screenshot content.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: temp swap README image URLs to feature branch for preview

Will revert to master before merging.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: lay out all 3 README screenshots in a single row

Reduce from 360px to 270px each and combine into one <p> block
so all three hero screenshots sit side by side.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: swap 3rd hero screenshot for config-menu for visual variety

Replace plan-outline-approve (too similar to approval-diff-preview)
with config-menu showing the /config settings grid. The three hero
images now tell: voice input → approve changes → configure everything.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: add captions under README hero screenshots

Small <sub> captions: "Send tasks by voice (Whisper transcription)",
"Approve changes remotely", "Configure from Telegram".

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: use table layout for README hero screenshots with captions

Fixes stacking issue — <br> in a <p> broke inline flow. A table
keeps images side by side with captions underneath each one.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: replace table layout with single hero collage image

Composite image scales proportionally on mobile instead of requiring
horizontal scroll. Captions baked into the image via ImageMagick.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: swap middle hero screenshot for full 3-button approval view

Replace approval-diff-preview with approval-buttons-howto showing
Approve / Deny / Pause & Outline Plan — more visually impressive.
Caption now reads "Approve changes remotely (Claude Code)".
Added footnote linking to engine compatibility table.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: swap config-menu for parallel-projects in hero collage

Third hero screenshot now shows 10+ projects running simultaneously
across different repos — much more compelling than a settings menu.
New caption: "Run agents across projects in parallel".

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: revert README image URL to master for merge

Swap hero-collage URL back from feature/github-hardening to master.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: staging 0.34.5rc3

- fix: preserve all EngineOverrides fields when setting model/planmode/reasoning
  (was silently wiping ask_questions, diff_preview, show_api_cost, etc.)
- fix: /config home page resolves "default" to effective values
- feat: file upload auto-deduplication (append _1, _2 instead of requiring --force)
- feat: media groups without captions now auto-save instead of showing usage text
- feat: resume line visual separation (blank line + ↩️ prefix)
- fix: claude auto-approve echoes updatedInput in control response

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: expand permission policies for Codex CLI and Gemini CLI in /config

Codex gets a new "Approval policy" page (full auto / safe) that passes
--ask-for-approval untrusted when safe mode is selected. Gemini's approval
mode expands from 2 to 3 tiers (read-only / edit files / full access) with
--approval-mode auto_edit for the middle tier. Both engines now show an
"Agent controls" section on the /config home page. Engine-specific model
default hints replace the generic "from CLI settings" text.

Also adds staging.sh helper, context-guard-stop hook, and docs updates.

Closes #131

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: staging 0.34.5rc4

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: /config UX overhaul, resume line toggle, cost budget settings, model metadata

/config UX cleanup:
- Convert all binary toggles from 3-column (on/off/clear) to 2-column
  (toggle + clear) for better mobile tap targets
- Merge Engine + Model into combined "Engine & model" page
- Reorganise home page to max 2 buttons per row across all engines
- Split plan mode 3-option rows (off/on/auto) into 2+1 layout
- Add _toggle_row() helper for consistent toggle button rendering

New features:
- #128: Resume line /config toggle — per-chat show_resume_line override
  via EngineOverrides with On/Off/Clear buttons, wired into executor
- #129: Cost budget /config settings — per-chat budget_enabled and
  budget_auto_cancel overrides on the Cost & Usage page, wired into
  _check_cost_budget() in runner_bridge.py

Model metadata improvements:
- Show Claude Code [1m] context window suffix: "opus 4.6 (1M)"
- Strip Gemini CLI "auto-" prefix: "auto-gemini-3" → "gemini-3"
- Future-proof: unknown suffixes default to .upper() (e.g. [500k] → 500K)

Bug fixes:
- #124: Standalone override commands (/planmode, /model, /reasoning) now
  preserve all EngineOverrides fields including new ones
- Error handling: control_response.write_failed catch-all in claude.py,
  ask_question.extraction_failed warning, model.override.failed logging

Hardening:
- Plan outline sent as separate ephemeral message (avoids 4096 char truncation)
- Added show_resume_line, budget_enabled, budget_auto_cancel to
  EngineOverrides, EngineRunOptions, normalize/merge, and all constructors

Tests: 1610 passed, 80.56% coverage, ruff clean.
Integration tested on @untether_dev_bot across all 6 engine chats.

Closes #128, closes #129, fixes #124

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: trigger CI for PR #132

* fix: address 11 CodeRabbit review comments on PR #132

Bug fixes:
- claude.py: fix UnboundLocalError when factory.resume is falsy in
  ask_question.extraction_failed logging path
- ask_question.py: reject malformed option callbacks instead of
  silently falling back to option 0
- files.py: raise FileExistsError when deduplicate_target exhausts
  999 suffixes instead of returning the original (overwrite risk)
- config.py: disambiguate Codex "Full auto" (fa) vs Gemini "Full
  access" (ya) callback IDs and toast labels

Hardening:
- codex.py: add --ask-for-approval to _EXEC_ONLY_FLAGS guard
- model.py: add try/except to clear path (matching set path)
- reasoning.py: add try/except to clear path (matching set path)
- loop.py: notify user when media group upload fails instead of
  silently dropping
- export.py: log session count instead of identifiers at info level
- config.py: resolve resume-line default from config instead of
  hardcoding True
- staging.sh: pin PyPI index in rollback/reset with --pip-args

Skipped (not applicable):
- CHANGELOG.md: RC versions don't get changelog entries per release
  discipline
- docs/tutorials TODO screenshot: pre-existing, not introduced by PR
- .claude/hooks/context-guard-stop.sh: ContextDocs plugin hook, not
  Untether source

Tests: 1611 passed, 80.48% coverage, ruff clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: replace bare pass with debug log to satisfy bandit B110

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: setup wizard security + UX improvements

- Auto-set allowed_user_ids from captured Telegram user ID during
  onboarding (security: restricts bot to the setup user's account)
- Add "next steps" panel after wizard completion with pointers to
  /config, voice notes, projects, and account lock confirmation
- Update install.md: Python 3.12+ (not just 3.14), dynamic version
  string, /config mention for post-setup changes
- Update first-run.md: /config → Engine & model for default engine

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: plan outline UX — markdown rendering, buttons, cleanup (#139, #140, #141)

- Render outline messages as formatted text via render_markdown() +
  split_markdown_body() instead of raw markdown (#139)
- Add approve/deny buttons to last outline message so users don't
  have to scroll up past long outlines (#140)
- Delete outline messages on approve/deny via module-level
  _OUTLINE_REGISTRY callable from callback handler; suppress stale
  keyboard on progress message (#141)
- 8 new tests for outline rendering, keyboard placement, and cleanup
- Bump version to 0.35.0rc5

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: /continue command — cross-environment resume for all engines (#135)

New `/continue` command resumes the most recent CLI session in the
project directory from Telegram. Enables starting a session in your
terminal and picking it up from your phone.

Engine support: Claude (--continue), Codex (resume --last), OpenCode
(--continue), Pi (--continue), Gemini (--resume latest). AMP not
supported (requires explicit thread ID).

Includes ResumeToken.is_continue flag, build_args for all 6 runners,
reserved command registration, resume emoji prefix stripping for
reply-to-continue, docs (how-to guide, README, commands ref, routing
explanation, conversation modes tutorial), and 99 new test assertions.

Integration tested against @untether_dev_bot — all 5 supported engines
passed secret-recall verification via Telegram MCP.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: agent outbox file delivery + fix cross-chat ask stealing (#143, #144)

Outbox delivery (#143): agents write files to .untether-outbox/ during a
run; Untether sends them as Telegram documents on completion with 📎
captions. Config: outbox_enabled, outbox_dir, outbox_max_files,
outbox_cleanup. Deny-glob security, size limits, auto-cleanup.
Preamble updated for all 6 engines. Integration tested across
Claude, Codex, OpenCode, Pi, and Gemini.

AskUserQuestion fix (#144): _PENDING_ASK_REQUESTS and
_ASK_QUESTION_FLOWS were global dicts with no chat_id scoping — a
pending ask in one chat would steal the next message from any other
chat. Added channel_id contextvar and scoped all ask lookups by it.
Session cleanup now also clears stale pending asks.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: v0.35.0 changelog completion + fix #123 updatedInput

- Complete v0.35.0 changelog: add missing entries for /continue (#135),
  /config UX overhaul (#132), resume line toggle (#128), cost budget
  (#129), model metadata, resume line formatting (#127), override
  preservation (#124), and updatedInput fix (#123)
- Fix #123: register input for system-level auto-approved control
  requests so updatedInput is included in the response
- Add parameterised test for all 5 auto-approve types input registration
- Remove unused OutboxResult import (ruff fix)

Issues closed: #115, #118, #123, #124, #126, #127, #134

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: staging 0.35.0rc6

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: rc6 integration test fixes (#145, #146, #147, #148, #149)

- Reduce Telegram API timeout from 120s to 30s (#145)
- OpenCode error runs show error text instead of empty body (#146)
- Pi /continue captures session ID via allow_id_promotion (#147)
- Post-outline approval uses skip_reply to avoid "not found" (#148)
- Orphan progress message cleanup on restart (#149)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: post-outline notification reply + OpenCode empty body (#148, #150)

- #148: skip_reply callback results now bypass the executor's default
  reply_to fallback, sending directly via the transport with no
  reply_to_message_id. Previously, the executor treated reply_to=None
  as "use default" which pointed to the (deleted) outline message.

- #150: OpenCode normal completion with no Text events now falls back
  to last_tool_error. Added state.last_tool_error field populated on
  ToolUse error status. Covers both translate() and stream_end_events().

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: suppress post-outline notification to avoid "message not found" (#148)

After outline approval/denial, the progress loop's _send_notify was
firing for the next tool approval, but the notification's reply_to
anchor could reference deleted state. Added _outline_just_resolved
flag to skip one notification cycle after outline cleanup.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: note OpenCode lacks auto-compaction — long sessions degrade (#150)

Added known limitation to OpenCode runner docs and integration testing
playbook. OpenCode sessions accumulate unbounded context (no compaction
events unlike Pi). Workaround: use /new before isolated tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: skip_reply on regular approve path when outline was deleted (#148)

The "Approve Plan" button on outline messages uses the real
ExitPlanMode request_id, routing through the regular approve path
(not the da: synthetic path). When outline messages exist, set
skip_reply=True on the CommandResult to avoid replying to the
just-deleted outline message.

Also added reply_to_message_id and text_preview to transport.send.failed
warning for easier debugging.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: update changelog for rc6 integration test fixes (#145-#150)

Updated fix descriptions for #146/#150 (OpenCode last_tool_error
fallback) and #148 (regular approve path skip_reply). Added docs
section for OpenCode compaction limitation. Updated test counts.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* style: fix formatting after merge resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address CodeRabbit review comments on PR #151

- bridge.py: replace text_preview with text_len in send failure warning
  to avoid logging raw message content (security)
- runner_bridge.py: move unregister_progress() after send_result_message()
  to avoid orphan window between ephemeral cleanup and final message send
- cross-environment-resume.md: add language spec to code block

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: resolve /config "default" labels to effective on/off values (#152)

Sub-pages showed "Current: default" or "default (on/off)" while buttons
already showed the resolved value. Now all boolean-toggle settings show
the effective on/off value in both text and buttons.

Affected: verbose, ask mode, diff preview, API cost, subscription usage,
budget enabled/auto-cancel, resume line. Home page cost & resume labels
also resolved.

Plan mode, model, and reasoning keep "default" since they depend on CLI
settings and aren't simple on/off booleans.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: update changelog for rc7 config default labels fix (#152)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: update documentation for v0.35.0

- fix missing nav entries in zensical.toml (cross-env resume, Gemini/Amp runners)
- rewrite inline-settings.md for /config UX overhaul (2-column toggles, budget/resume toggles)
- update plan-mode.md with outline rendering, buttons-on-last-chunk, ephemeral cleanup
- update interactive-control tutorial with outline UX improvements
- add orphan progress cleanup section to operations.md
- add engine-specific approval policies to interactive-approval.md
- add per-chat budget overrides to cost-budgets.md
- update module-map.md with Gemini/Amp and new modules (outbox, progress persistence, proc_diag)
- update architecture.md mermaid diagrams with all 6 engines
- bump specification.md to v0.35.0, add progress persistence and outbox sections
- add v0.35.0 screenshot entries to CAPTURES.md

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: broaden frozen ring buffer stall escalation beyond MCP tools (#155)

Frozen ring buffer escalation was gated on `mcp_server is not None`,
so general stalls with cpu_active=True and no MCP tool running were
silently suppressed indefinitely. Broadened to fire for all stalls
after 3+ checks with no new JSONL events regardless of tool type.

New notification: "CPU active, no new events" for non-MCP frozen stalls.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: tool approval buttons no longer suppressed after outline approval (#156)

After "Approve Plan" on an outline, the stale discuss_approve action
remained in ProgressTracker with completed=False. The renderer picked
up its stale "Approve Plan"/"Deny" buttons first, then the suppression
logic at line 994 stripped ALL buttons — including new Write/Edit/Bash
approval buttons. Claude blocked indefinitely waiting for approval.

Fix: after suppressing stale buttons, complete the discuss_approve
action(s) in the tracker, reset _outline_sent, and trigger a re-render
so subsequent tool requests get their own Approve/Deny buttons.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: add workflow mode indicator to startup message, fix startup crash on topics validation failure (#158, #159)

Features:
- Startup message now shows mode: assistant/workspace/handoff
- Derived from session_mode + topics.enabled config values
- _resolve_mode_label() helper in backend.py

Bug fixes:
- Fix UnboundLocalError crash when topics validation fails on startup (#158)
  - Moved import signal and shutdown imports before try block in loop.py
- Downgrade can_manage_topics check from fatal error to warning (#159)
  - Bot can now start without manage_topics admin right
  - Existing topics work fine; only topic creation/editing affected

Tests:
- 17 new unit tests for stateless/handoff mode (test_stateless_mode.py)
  - _should_show_resume_line, _chat_session_key, ResumeResolver, ResumeLineProxy
  - Integration-level: stateless shows resume lines, no auto-resume, chat hides lines
- 3 new tests for mode indicator in startup message (test_telegram_backend.py)

Docs:
- New docs/reference/modes.md — comprehensive reference for all 3 workflow modes
- Updated docs/reference/index.md and zensical.toml nav with modes page

* docs: comprehensive three-mode coverage across all documentation

New:
- docs/how-to/choose-a-mode.md — decision tree, mode comparison, mermaid
  sequence diagrams, configuration examples, switching guide, workspace
  prerequisites

Updated:
- README.md — improved three-mode description in features list
- docs/tutorials/install.md — added mode selection step (section 10)
- docs/tutorials/first-run.md — added 'What mode am I in?' tip
- docs/reference/config.md — cross-linked session_mode/show_resume_line to modes.md
- docs/reference/transports/telegram.md — added mode requirement callouts
  for forum topics and chat sessions sections
- docs/how-to/chat-sessions.md — added session persistence explanation
  (state files, auto-resume mechanics, handoff note)
- docs/how-to/topics.md — expanded prerequisites checklist with group
  privacy, can_manage_topics, and re-add steps
- docs/how-to/cross-environment-resume.md — added handoff mode terminal
  workflow with mermaid sequence diagram
- docs/how-to/index.md — added 'Getting started' section with choose-a-mode
- zensical.toml — added choose-a-mode to nav

* docs: add three-mode summary table to README Quick Start section

* feat: migrate to dev branch workflow — dev→TestPyPI, master→PyPI

Branch model:
- feature/* → PR → dev (TestPyPI auto-publish) → PR → master (PyPI)
- master always matches latest PyPI release
- dev is the integration/staging branch

CI changes:
- ci.yml: TestPyPI publish triggers on dev push (was master)
- ci.yml, codeql.yml: CI runs on both master and dev pushes
- dependabot.yml: PRs target dev branch

Hook changes:
- release-guard.sh: updated messages to mention dev branch
- release-guard-mcp.sh: updated messages to mention dev branch
- Both hooks already allow dev pushes (only block master/main)

Documentation:
- CLAUDE.md: updated 3-phase workflow, CI table, release guard docs
- dev-workflow.md: added branch model section
- release-discipline.md: added dev branch staging notes

* ci: retrigger CI for PR #160

* feat: allow Claude Code to merge PRs targeting dev branch only

Release guard hooks now check the PR's base branch:
- dev → allowed (TestPyPI/staging)
- master/main → blocked (PyPI releases remain Nathan-only)

Both Bash hook (gh pr merge) and MCP hook (merge_pull_request)
updated with base branch checking via gh pr view.

* docs: add workflow mode indicator and modes.md to CLAUDE.md

* fix: dual outline buttons (#163), entity URL sanitisation (#157), changelog migration

- Strip approval buttons from progress message when outline is visible —
  only outline message shows Approve/Deny/Cancel (#163)
- Reset outline state via source_has_approval tracking so future
  ExitPlanMode requests work correctly (#163)
- Sanitise text_link entities with invalid URLs (localhost, loopback,
  file paths, bare hostnames) by converting to code entities — prevents
  silent 400 errors that drop the entire final message (#157)
- Merge v0.34.5 changelog into v0.35.0 — v0.34.5 was never released
  (latest PyPI is v0.34.4), all rc1-rc7 work is v0.35.0

17 new tests (2 for #163, 15 for #157).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: staging 0.35.0rc8

fix: restore frozen ring buffer stall escalation (#155)

The #163 fix (6f43e5b) accidentally removed all frozen ring buffer
code from runner_bridge.py. Restored from 8fcad32:

- _frozen_ring_count tracking and ring buffer snapshot comparison
- frozen_escalate gating (fires notification after 3+ frozen checks
  despite cpu_active=True)
- _has_running_mcp_tool() for MCP server name extraction
- _STALL_THRESHOLD_MCP_TOOL (15 min, configurable via watchdog)
- MCP-aware notification text ("MCP tool may be hung", "CPU active,
  no new events", "MCP tool running")
- 8 new tests + 2 updated existing tests
- mcp_tool_timeout watchdog setting

docs: integration testing S1 MCP threshold, tutorials index,
glossary, outbox screenshot, CAPTURES checklist

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: CI lint — unused import in test, bandit nosec for loopback blocklist

- Remove unused ActionEvent import in test_has_running_mcp_tool_returns_server_name
- Add # nosec B104 to _LOOPBACK_HOSTS — it's a URL blocklist, not a bind address

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: update CLAUDE.md test counts for v0.35.0rc8

Total: 1578 → 1743 tests
Per-file: test_exec_bridge 109→112, test_claude_control 82→89,
test_callback_dispatch 25→26, test_ask_user_question 25→29,
test_meta_line 43→54, test_preamble 5→6, test_config_command
195→218, test_build_args 33→39

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
…ll (#183, #184, #167, #168)

* fix: prevent Codex/OpenCode headless hangs (#184, #183)

Codex (#184): always pass --ask-for-approval in headless mode.
Default to "never" (auto-approve all) so Codex never blocks on
terminal input. Safe permission mode still uses "untrusted".

OpenCode (#183): surface unsupported JSONL event types as visible
Telegram warnings instead of silently dropping them. When msgspec
DecodeError occurs, _extract_event_type() tries to parse the raw
JSON for the type field. If extractable, a warning ActionEvent is
emitted (visible in Telegram) instead of returning [].

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: auto-continue for Claude bug #34142, sleeping-process stall fix (#167, #168)

Auto-continue (#167): detect when Claude Code exits after receiving
tool results without processing them (last_event_type=user) and
auto-resume the session. Configurable via [auto_continue] with
enabled (default true) and max_retries (default 1).

Sleeping-process stall (#168): CPU-active suppression now checks
process_state; when main process is sleeping (state=S) but children
are CPU-active (hung Bash tool), notifications fire. Stall message
shows tool name ("Bash tool may be stuck") instead of generic text.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: CI lint — explicit super() for @DataClass(slots=True) compat

Zero-argument super() breaks in @DataClass(slots=True) on Python
<3.14 because the __class__ cell references the pre-slot class.
Use explicit JsonlSubprocessRunner.decode_error_events(self, ...)
instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: resolve 9 new ty warnings — typed test helpers, isinstance narrowing

- TestShouldAutoContinue._call: replace mixed-type dict with typed
  keyword args to satisfy ty's union narrowing
- TestDecodeErrorEvents: add isinstance(ActionEvent) checks before
  accessing .message and .action attributes on union type

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* ci: make ty check informational (continue-on-error)

ty has 55 pre-existing warnings across the codebase. These are not
regressions — the same warnings exist on dev and master. Making ty
non-blocking so it doesn't prevent PR merges while still reporting
warnings for visibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- test count: 1743 → 1765
- test_exec_bridge: 112 → 124 (auto-continue, sleeping-process stall)
- test_build_args: 39 → 40 (Codex default approval)
- add auto-continue feature to features list
- note sleeping-process awareness in stall diagnostics
- clarify ty is informational (continue-on-error) in CI table

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…189)

* feat: emoji buttons + edit-in-place for outline approval (#186)

Add emoji prefixes to ExitPlanMode and post-outline buttons (✅/❌/📋).
Post-outline approve/deny now edits the "Asked Claude Code to outline
the plan" message in-place instead of creating a second message.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: redesign startup message layout (#187)

Split engine info into separate lines, add italic subheadings,
rename "projects" to "directories", add bug report link.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: add 🧹 emoji to /new session clear messages

Part of startup message UX improvements (#187).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: make stall warnings succinct and accurate for long-running tools (#188)

Truncate Last: to 80 chars, recognise command: prefix for Bash tools,
use reassuring "still running" when CPU active, drop PID diagnostics
from Telegram messages, only say "may be stuck" when genuinely stuck.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: frozen ring escalation shows reassuring message for long Bash commands (#188)

When a known tool is running (main sleeping, CPU active on children),
frozen ring escalation now shows "Bash command still running" instead
of alarming "No progress" message. Found via wpnav staging session
where benchmark scripts ran for 60+ min with false warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Security audit fixes (4 HIGH severity):
- sanitise bot token in log URLs (#190)
- cap JSONL line buffer at 10MB to prevent OOM (#191)
- fix tag name injection in notify-website CI workflow (#193)
- add -- separator before user prompts in gemini/amp runners (#194)

Also includes: tool-active stall repeat suppression, CLAUDE.md doc
updates, configurable watchdog timeouts, and 4 new tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* feat: add "Let's discuss" button to post-outline plan approval

Add a 💬 Let's discuss button to the post-outline plan approval
buttons (between Approve Plan/Deny and cancel). When clicked, it
tells Claude Code to ask the user what they'd like to discuss about
the plan before deciding to approve or deny.

Implementation:
- New `chat` action in claude_control.py with `_CHAT_DENY_MESSAGE`
- Handles both da: prefix (synthetic) and hold-open (real request_id) paths
- Clears cooldown and outline_pending state on both paths
- Early toast: "Let's discuss..."
- Post-outline keyboard now has 2 rows: [Approve Plan | Deny], [Let's discuss]

Tests: 5 new tests, 5 updated for new button layout (1773 pass, 81% coverage)
Docs: updated 9 files across how-to, tutorial, reference, and rules

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: remove unused _CHAT_DENY_MESSAGE import in test

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
…tion (#220, #221)

- Read OpenCode's ~/.config/opencode/opencode.json at runner construction
  to populate the model footer even when no untether.toml override is set.
  Previously the footer showed no model for the default config. (#221)

- Update _ENGINE_MODEL_HINTS for opencode to show "provider/model
  (e.g. openai/gpt-4o)" instead of the unhelpful "from provider config",
  guiding users to use the required provider-prefixed format. (#220)

- Gate /planmode to Claude-only; gate /usage to subscription-supported
  engines; add _resolve_engine helper for command-level engine checks.

- Deduplicate repeated StartedEvent headers in /export markdown output
  for resumed sessions.

- Fix Gemini CLI prompt injection: use --prompt=<value> instead of
  -p <value> to prevent prompts starting with - being parsed as flags.

- Ensure Codex runner always includes model in meta dict.

- Add 8 tests for _read_opencode_default_model and build_runner fallback,
  plus engine gate and export dedup tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: /new command now cancels running processes before clearing sessions (#222)

Previously /new only cleared stored resume tokens, leaving old Claude/Codex/OpenCode
processes running (~400 MB each). This leaked processes and worsened memory pressure,
contributing to earlyoom kills (rc=143).

Now /new cancels all running tasks for the chat before clearing sessions. Reply
message shows "cancelled run and cleared" when a process was killed.

- Add _cancel_chat_tasks() helper to topics.py
- Add running_tasks param to both /new handlers
- Pass running_tasks through all 3 call sites in loop.py
- Add running_tasks field to TelegramCommandContext
- 10 new tests covering cancellation behaviour
- Update 7 docs to reflect new /new behaviour
- Bump version to 0.35.0rc12

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: remove unused AsyncMock import (pre-existing ruff F401)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
…222) (#226)

When earlyoom killed Claude sessions (rc=143/SIGTERM), auto-continue detected
last_event_type=user and immediately respawned all 4 killed sessions (~5 GB of
new processes) into the same memory pressure. This caused a death spiral where
sessions were killed and respawned repeatedly.

Fix: _should_auto_continue now checks proc_returncode — signal deaths (rc>128
or rc<0) are excluded. The upstream bug #34142/#30333 exits with rc=0, so
auto-continue still works for its intended purpose.

- Add _is_signal_death() helper to runner_bridge.py
- Add proc_returncode field to JsonlStreamState
- Store returncode on stream state after process exit
- Pass proc_returncode through to _should_auto_continue
- 12 new tests for signal death detection and auto-continue gating

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Auto-continue feature description: add signal death suppression note
- runner_bridge.py key file: add auto-continue mention
- test_exec_bridge.py: 128 → 140 tests (signal death tests)
- runner-development.md: add stream state tracking section (proc_returncode)
- telegram-transport.md: add /new cancellation section

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…229) (#230)

* fix: improve error_during_execution hint for session archival (#228)

Update error hint text from "corrupted during a restart" to "archived or
expired" — better reflects the actual cause when Claude Code auto-archives
a session between resume runs.

Related: anthropics/claude-code#39178

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: prevent duplicate control response for already-handled requests (#229)

When a user clicks Approve/Deny on a control request via Telegram,
send_claude_control_response() marks it in _HANDLED_REQUESTS but can't
access state.pending_control_requests. The 5-minute expiry check then
sends a duplicate DENY for the same request_id, causing Claude Code to
receive conflicting approve+deny responses and stall.

Add reconciliation in translate() that checks _HANDLED_REQUESTS against
pending_control_requests before the expiry loop:
- Removes already-handled requests from pending (prevents spurious deny)
- Emits action_completed to clear stale inline keyboards
- Adds belt-and-suspenders guard on the expiry list comprehension

The upstream Claude Code freeze is tracked in anthropics/claude-code#39666.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: Gemini JSONL parsing, ask mode toggle, diff preview buttons, doc chat IDs (#231, #232, #233, #238)

- Strip non-JSON prefixes from JSONL stdout lines in decode_jsonl() — fixes
  Gemini CLI "MCP issues detected" warning corrupting the first event (#231)
- Change ask mode toggle default from False to True to match display default,
  fixing inverted button state in /config (#232)
- Only strip approval buttons from progress when current action is a
  DiscussApproval (outline flow), not for regular tool approvals — fixes
  diff preview buttons disappearing after plan outline (#233)
- Update integration test chat IDs from stale ut-dev: to ut-dev-hf: (#238)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: backfill changelog entries, update docs and rules for v0.35.0

- Backfill changelog entries for fixes #32, #33, #59, #60, #62, #115,
  #134, #152, #166 and changes #36, #38
- Update contrib/untether.service KillMode from process to mixed (#166)
- Update CLAUDE.md test counts (1818 tests)
- Update docs (config, operations, troubleshooting, first-run)
- Sync .claude/rules with current conventions

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: release v0.35.0

- Bump version from 0.35.0rc14 to 0.35.0
- Set changelog date to 2026-03-29
- Remove non-standard ### ci changelog section (internal repo infra)
- Sync lockfile

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: bump requests 2.33.0 (CVE-2026-25645), ignore unfixed pygments CVE

- Bump requests 2.32.5 -> 2.33.0 to fix CVE-2026-25645
- Ignore CVE-2026-4539 in pip-audit (pygments 2.19.2, no fix available)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
- Fix _PENDING_ASK_REQUESTS type: dict[str, tuple[int, str]] not dict[str, str]
- Fix auto-approve docs: _AUTO_APPROVE_TYPES + _TOOLS_REQUIRING_APPROVAL
  (not the non-existent _AUTO_APPROVE_TOOLS)
- Update screenshot count 47 -> 48
- Add progress persistence to features list

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
`/new` was only handled in `_dispatch_builtin_command()` when
`topics.enabled=true`. With topics disabled (assistant mode), the
command fell through to prompt dispatch and triggered an engine run.

- Move `/new` out of the `topics.enabled` gate, mirroring `/ctx`
  pattern: topic → `handle_new_command`, chat session →
  `handle_chat_new_command`, stateless → cancel + reply
- Add `chat_session_store` and `chat_session_key` to
  `TelegramCommandContext` dataclass
- Remove unreachable early routing for `/new` at lines 1871-1910
  (now handled by `_dispatch_builtin_command`)

Verified: all 6 engine forum topic chats return "no stored sessions"
instead of triggering runs. Project supergroup chats unaffected.

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
)

* feat: v0.35.0 UX polish — error formatting, resume line, help links, error hints expansion (#244, #245, #246)

Error messages: hints shown above raw error in code blocks, 67 error patterns
(was 32) covering model, context, safety, auth, CLI, SSL, AMP/Gemini-specific.
Resume line moved below cost/subscription footer for cleaner visual hierarchy.
Startup message and /config menu now include help guide and bug report links.
README restructured with consolidated Help Guides section. AMP -x flag fix.
New docs/reference/errors.md central error reference with cross-links from
all 6 engine guides and troubleshooting doc.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: startup message help/bug links point to README Help Guides section

Updated URLs from old littlebearapps.com docs site to the restructured
README.md anchors (#-help-guides, #-contributing) — now consistent with
the /config menu links added earlier.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: ruff format fix in backend.py

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: Gemini runner defaults to yolo approval mode in headless mode (#244, #248)

Gemini CLI's default (read-only) mode disables write tools entirely,
causing 8-18 min stalls as the agent cascades through sub-agents.
Default to --approval-mode yolo since headless mode has no interactive
approval path, matching the existing Codex pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
* feat: logging audit — fill gaps in structlog coverage (#254)

- Elevate settings loader failures from DEBUG to WARNING (footer,
  watchdog, auto-continue, preamble) so config regressions are visible
  in production logs and to the issue watcher
- Add access control logging (message.dropped, callback.dropped) in
  parsing.py for unrecognised chat IDs
- Add handle.engine_resolved info log in executor.py after successful
  runner + CWD resolution
- Elevate outline cleanup failures from DEBUG to WARNING
- Add credential redaction for OpenAI (sk-...) and GitHub (ghp_, ghs_,
  gho_, github_pat_) API key patterns in logging.py
- Add file_transfer.saved and file_transfer.sent info logs
- Bind session_id in structlog context vars when StartedEvent arrives
- Add media_group.flush.ok, cost_budget.check, cancel.ambiguous,
  cancel.nothing_running debug logs
- Update troubleshooting docs with key log events table and redaction note

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: expand ruff lint rules from 7 to 18, auto-fix imports (#255)

Add ASYNC, LOG, I (isort), PT, RET, RUF (full), FURB, PIE, FLY, FA,
ISC rule sets to ruff configuration. Tailored for Untether's
async/structlog/pytest-heavy codebase.

Auto-fixed:
- 42 import sorts across ~35 files via isort (I)
- 73 stale noqa directives cleaned by RUF100
- 3 useless if-else conditions simplified in config.py (RUF034)
- 9 unused unpacked variables prefixed with _ (RUF059)
- 1 endswith call merged to tuple in render.py (PIE810)
- __all__ sorted in api.py (RUF022)

Per-file ignores for tests: ASYNC109/110/251, PT006/012, RUF059, S110
Global ignores: FLY002, RET504/505, RUF001/005/009, PT018

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: staging 0.35.0rc16

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Upstream Claude Code bug — Bash tool children use their own session
group, unreachable by Untether's process group cleanup. No TTY means
no SIGHUP cascade in headless mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- #245: AMP CLI -x flag regression (double-dash separator broke execute mode)
- #246: expanded error hints coverage (model, auth, safety, CLI, SSL categories)
- #248: add issue reference alongside #244 for Gemini yolo default fix

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Bump version from 0.35.0rc16 to 0.35.0
- Set changelog date to 2026-03-31
- Sync lockfile

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Implements hot-reload for trigger configuration (crons and webhooks) via new TriggerManager class. Editing untether.toml applies changes immediately without restarting Untether or killing active runs.

- New TriggerManager class in triggers/manager.py holds cron/webhook config
- Cron scheduler reads manager.crons each tick; last_fired preserved across swaps
- Webhook server resolves routes per-request via manager.webhook_for_path()
- Config watcher handle_reload() re-reads raw TOML [triggers] section
- 13 new tests in test_trigger_manager.py; 2038 existing tests still pass

Closes #269.

Co-authored-by: Nathan Schram <[email protected]>
…t Tier 1 (#271, #286, #287, #288)

* fix: stop Untether being the preferred OOM victim (#275)

systemd user services inherit OOMScoreAdjust=200 + OOMPolicy=stop
defaults, which made Untether's engine subprocesses preferred
earlyoom/kernel OOM killer targets ahead of CLI claude
(oom_score_adj=0) and orphaned grandchildren actually consuming the
RAM. When lba-1 ran low on RAM, live Telegram chats died with rc=143
(SIGTERM) while the processes actually eating the RAM survived.

Updates contrib/untether.service with:

- OOMScoreAdjust=-100 — documents intent; kernel clamps to the parent
  baseline for unprivileged users (typically 100), but takes effect
  if the parent [email protected] is ever overridden lower
- OOMPolicy=continue — a single OOM-killed child no longer tears
  down the whole unit cgroup; previously every live chat died at once

Also updates docs/reference/dev-instance.md with a new OOM section
covering the asymmetry, the clamping caveat, and the optional
sudo systemctl edit [email protected] override for operators who
want Untether's children to live longer than CLI processes.

Existing installs need to copy the unit file and
`systemctl --user daemon-reload`; staging picks up the change on
the next `scripts/staging.sh install` cycle.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* 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]>

* fix: prevent /at timers from firing after /cancel (CancelScope race)

anyio.CancelScope.__exit__ swallows the Cancelled exception when the
scope itself caused the cancellation. The fire/dispatch code outside
the scope continued regardless. Added cancelled_caught check after
the scope exits to prevent stale timers from dispatching.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: add v0.35.1rc4 integration test plan

52-test plan covering all rc4 features: /at command, run_once,
hot-reload (triggers + bridge config), trigger visibility,
graceful restart Tier 1, plus standard Tier 1/6/7 regression.

Includes correct dev bot chat IDs (Bot API + Telethon MCP mapping),
pre-test trigger config, results template, and known caveats.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: update integration test chat IDs to current dev bot

The old ut-dev-hf: chat IDs (5171122044 etc.) belong to a different
bot (ID 8485467124). Updated both docs to the current @untether_dev_bot
chats with both Telethon and Bot API ID forms. Added note about
Telegram MCP PeerUser fallback for channel/supergroup IDs.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: comprehensive v0.35.1 documentation updates

HIGH priority:
- config.md: add run_once to cron table, fix watch_config description
  to list hot-reloadable vs restart-only settings
- operations.md: fix hot-reload section (transport settings ARE now
  partially hot-reloadable), add /ping trigger format, update_id
  persistence, systemd section with Type=notify/OOM notes
- schedule-tasks.md: add /at command section with examples, run_once
  mention

MEDIUM priority:
- triggers.md: remove duplicate hot-reload section, keep authoritative
  version with watch_config requirement and last_fired note
- CLAUDE.md: add diff_preview plan bypass (#283) to features list
- troubleshooting.md: add entries for config hot-reload issues and
  /at delay not firing

LOW priority:
- security.md: document untrusted-payload prefix for webhooks/cron
- voice-notes.md: note that voice settings hot-reload
- specification.md: bump version to v0.35.1
- tutorials: update version numbers from 0.35.0 to 0.35.1

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
* fix: add 38 missing structlog calls across 13 files (logging audit)

Comprehensive logging audit found gaps in security-critical paths
(auth, rate limiting, SSRF), runner lifecycle (codex peer parity),
state mutations (topic_state), and CLI error paths. Adds structured
log statements at appropriate levels without over-logging.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: comprehensive v0.35.1 documentation updates

- Expand docs/reference/changelog.md with full v0.35.1 entry (security,
  fixes, changes) instead of a stub pointing to GitHub
- Add #190 (token redaction) and #191 (line buffer cap) to CHANGELOG.md
- Add logging audit (#299) to CHANGELOG.md and docs changelog
- Update CLAUDE.md test count from 2038 to 2165

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: staging 0.35.1rc5

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: bump pytest 9.0.2 → 9.0.3 (CVE-2025-71176)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
…guides (#304)

* docs: fill 8 documentation gaps found in v0.35.1 audit

Comprehensive audit of all 121 doc files against the v0.35.1 changelog
(23 issues) found the major features well-documented but 8 specific gaps:

Security:
- group-chat.md: document callback sender validation in groups (#192)
- security.md: cross-reference button validation from allowed_user_ids
- security.md: fix misleading SSRF allowlist claim (no TOML setting exists)
- security.md: add bot token auto-redaction tip (#190)

UX:
- plan-mode.md: document auto-approval after plan approval (#283)
- interactive-approval.md: note about plan bypass in diff preview section
- commands-and-directives.md: /ping description now mentions trigger summary

Completeness:
- runners/amp/runner.md: add sanitize_prompt() note (#194)
- troubleshooting.md: document 10 MB engine output line cap (#191)
- glossary.md: add delayed run, webhook action, hot-reload entries

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: healthcheck.sh exits prematurely under set -e (#302)

Two bugs in scripts/healthcheck.sh made post-deploy health checks unusable:

1. pass()/fail() used `((var++))` which returns the pre-increment value.
   On first call (var=0) that trips `set -e`, so only the first check ever
   ran and the script always exited 1. Switched to explicit assignment.

2. The error-log count piped journalctl through `grep -c .`, which counts
   journalctl's `-- No entries --` meta line as a match, producing a
   false-positive "1 ERROR-level log entries in last 60s" on clean
   systems. Now filters meta lines with `grep -vc '^-- '`.

Verified with `scripts/healthcheck.sh --version 0.35.1rc5` on staging:
all 5 checks run, exit 0.

Closes #302

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: add update/uninstall guides, README transparency section

- New how-to guides: docs/how-to/update.md and docs/how-to/uninstall.md
- README: "What Untether accesses" section with network, filesystem,
  process, and credential disclosures
- README: update/uninstall one-liner in Quick Start section
- README: update/uninstall links in How-To Guides section
- install.md: cross-links to update and uninstall guides
- zensical.toml: nav entries for new pages
- how-to/index.md: entries in Getting Started section

Addresses gap found during hesreallyhim/awesome-claude-code evaluation:
the repo had zero uninstallation documentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: staging 0.35.1rc6 — doc audit gaps, healthcheck fix, update/uninstall guides

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Finalises v0.35.1 for PyPI publish. Adds two final changelog entries:
- #302: healthcheck.sh set -e + journalctl meta line fix
- #305: update/uninstall guides + README transparency section
- #306: comprehensive v0.35.1 documentation audit (8 gap fills)

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 15, 2026

📝 Walkthrough

Walkthrough

Implemented a trigger subsystem (cron/webhook) with timezone-aware scheduling, hot-reloadable trigger/Telegram transport settings, one‑shot /at delayed runs, multipart webhook handling, SSRF protections, run provenance propagation into run metadata/footers, tree-CPU-aware stall detection/suppression, persisted Telegram offset and systemd READY/STOPPING notifications, plus extensive docs and tests.

Changes

Cohort / File(s) Summary
Trigger system core
src/untether/triggers/manager.py, src/untether/triggers/dispatcher.py, src/untether/triggers/cron.py, src/untether/triggers/server.py
New mutable TriggerManager for hot-reload; dispatchers always set RunContext.trigger_source; cron scheduler timezone resolution, run_once behavior, and fetch pre-step; webhook server parses multipart, returns 202 and schedules background dispatch.
Trigger actions & fetch
src/untether/triggers/actions.py, src/untether/triggers/fetch.py, src/untether/triggers/ssrf.py
New non-agent webhook actions (file_write, http_forward, notify_only); cron fetch implementations (http/file) with parsing and prompt injection; SSRF validation with DNS checks and blocked-network rules.
Trigger schema & templating
src/untether/triggers/settings.py, src/untether/triggers/templating.py, src/untether/triggers/describe.py, src/untether/triggers/__init__.py, src/untether/triggers/auth.py
Schema additions: CronFetchConfig, per-cron timezone, run_once, optional prompt_template, multipart fields, action-driven webhook fields; render_template_fields and describe_cron utilities; structured auth logging.
Telegram bridge & transport
src/untether/telegram/bridge.py, src/untether/telegram/loop.py, src/untether/telegram/offset_persistence.py, src/untether/telegram/parsing.py, src/untether/telegram/offset_persistence.py
TelegramBridgeConfig made mutable with update_from() and trigger_manager field; poll loop resumes from persisted update_id, installs /at scheduler, hot-reloads transport and triggers, sends sd_notify READY/STOPPING, and flushes offset via DebouncedOffsetWriter.
Commands & /at scheduler
src/untether/telegram/commands/at.py, src/untether/telegram/commands/cancel.py, src/untether/telegram/commands/ping.py, src/untether/telegram/commands/config.py, src/untether/telegram/at_scheduler.py
New /at command and at_scheduler module (install/uninstall, schedule/cancel, per-chat cap, in-memory only); /cancel cancels pending /at; /ping shows trigger summaries; config UI updated for on/off + engine-specific reasoning.
Run provenance & progress
src/untether/context.py, src/untether/runner_bridge.py, src/untether/progress.py, src/untether/markdown.py
Added RunContext.trigger_source; runner_bridge seeds progress_tracker.meta["trigger"] from context; ProgressTracker.note_event now merges StartedEvent.meta (not replace); markdown footer includes trigger provenance when present.
Stall detection & proc diagnostics
src/untether/utils/proc_diag.py, src/untether/runner_bridge.py
ProcessDiag now aggregates tree CPU ticks; is_tree_cpu_active helper; stall monitor considers active children with a higher threshold, suppresses repeated warnings when children are busy, and exposes separate total vs per-warning counts.
Prompt sanitization & runners
src/untether/runner.py, src/untether/runners/*.py
Added JsonlSubprocessRunner.sanitize_prompt to prefix leading-space for flag-like prompts; runners updated to use sanitize_prompt; added config validation warnings (logger.warning) before raising ConfigError in several runners.
Systemd notify & service
src/untether/sdnotify.py, contrib/untether.service
New minimal sd_notify client; service unit switched to Type=notify, NotifyAccess=main, faster RestartSec, and OOMPolicy/OOMScoreAdjust changes.
Settings & CLI wiring
src/untether/settings.py, src/untether/commands.py, src/untether/cli/run.py
WatchdogSettings gains subagent_timeout; CommandContext gains trigger_manager and default_chat_id; CLI run errors now log structured messages on lock/config failures.
Telegram helpers & persistence
src/untether/telegram/client_api.py, src/untether/telegram/topic_state.py, src/untether/telegram/engine_overrides.py
Added logs for message deletion and topic-state persistence; engine overrides add max reasoning level and helpers to surface engine default effort.
Offset persistence & debounce
src/untether/telegram/offset_persistence.py, src/untether/telegram/parsing.py
New offset file last_update_id.json, load/save APIs, DebouncedOffsetWriter for coalesced writes; parsing.poll_incoming accepts on_offset_advanced callback to feed the writer.
Tests & docs
tests/** (many new/updated), docs/**, CHANGELOG.md, pyproject.toml, README etc.
Extensive unit/integration tests for triggers, SSRF, fetch, actions, at_scheduler, offset persistence, sd_notify, stall behavior, and UI changes. Documentation and changelog updated for v0.35.1; entry point untether.command_backends.at added and version bumped.
Misc / Ops
scripts/healthcheck.sh, .github/workflows/*, zensical.toml
Bugfix to shell arithmetic to avoid set -e breakage; CI workflow action pin updates; website notify payload construction changed; docs nav updated with Update/Uninstall pages.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Telegram
    participant TelegramLoop
    participant TriggerManager
    participant CronScheduler
    participant TriggerDispatcher
    participant FetchModule
    participant SSRFValidator
    participant RunJob

    User->>Telegram: Edit untether.toml (crons/webhooks)
    Telegram->>TelegramLoop: Config change detected (watch_config=true)
    TelegramLoop->>TriggerManager: update(new_settings)
    TriggerManager->>TriggerManager: Replace _crons and _webhooks lists
    TriggerManager-->>TelegramLoop: Updated (info log with counts)
    
    Note over CronScheduler: Tick loop running continuously
    CronScheduler->>TriggerManager: Read current crons and default_timezone
    CronScheduler->>CronScheduler: _resolve_now() - convert UTC to local tz
    CronScheduler->>CronScheduler: Check if minute/hour matches schedule
    
    alt Cron matches
        CronScheduler->>FetchModule: fetch configured data (if present)
        alt Fetch succeeds
            FetchModule-->>CronScheduler: fetched_data + rendered_prompt
        else Fetch fails
            FetchModule-->>CronScheduler: None (abort dispatch)
        end
        
        CronScheduler->>TriggerDispatcher: dispatch_cron(cron_config)
        TriggerDispatcher->>SSRFValidator: validate_url_with_dns() if fetch used
        alt SSRF blocked
            SSRFValidator-->>TriggerDispatcher: SSRFError
            TriggerDispatcher->>Telegram: Send failure notification
        else SSRF passed
            TriggerDispatcher->>RunJob: run_job(prompt, trigger_source="cron:id")
        end
        
        alt run_once enabled
            CronScheduler->>TriggerManager: remove_cron(id)
        end
    end
Loading
sequenceDiagram
    participant Client as External Client
    participant WebhookServer
    participant TriggerManager
    participant RateLimiter
    participant Dispatcher
    participant Actions as Action Executor
    participant SSRF as SSRF Validator
    participant HTTPClient
    participant Telegram

    Client->>WebhookServer: POST /webhook/path with auth header
    WebhookServer->>TriggerManager: webhook_for_path(path)
    WebhookServer->>WebhookServer: Verify auth (bearer/HMAC)
    
    alt Auth fails
        WebhookServer-->>Client: 401 Unauthorized
    else Auth passes
        WebhookServer->>RateLimiter: allow(webhook_id)
        
        alt Rate limited
            WebhookServer-->>Client: 429 Too Many Requests
        else Rate OK
            WebhookServer->>WebhookServer: Parse multipart/JSON payload
            WebhookServer->>WebhookServer: Save multipart files if present
            WebhookServer-->>Client: 202 Accepted (fire-and-forget)
            
            par Async dispatch (background)
                WebhookServer->>Dispatcher: Schedule dispatch task
                alt webhook.action == "agent_run"
                    Dispatcher->>Dispatcher: Render prompt_template
                    Dispatcher->>Dispatcher: Create RunContext(trigger_source)
                else action == "file_write"
                    Dispatcher->>Actions: execute_file_write()
                    Actions->>Actions: Validate path, deny-globs, size
                    Actions-->>Dispatcher: success + message
                else action == "http_forward"
                    Dispatcher->>Actions: execute_http_forward()
                    Actions->>SSRF: validate_url_with_dns(forward_url)
                    SSRF-->>Actions: Validated (or blocked)
                    Actions->>HTTPClient: POST raw body to forward_url
                    HTTPClient-->>Actions: response
                    Actions-->>Dispatcher: success + status
                else action == "notify_only"
                    Dispatcher->>Actions: execute_notify_message()
                    Actions-->>Dispatcher: rendered_message
                end
                Dispatcher->>Telegram: Send notification if notify_on_success/failure
            end
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related issues

Possibly related PRs

Poem

🐰 Hopping through crons, webhooks, and time,

I stitch triggers and safety into a rhyme.
/at waits a moment, then springs into run,
sd_notify whispers when startup is done.
Docs and tests bundled — now the festival's begun! 🥕

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev

Master's v0.35.0 squash-merge (commit e5bff3c) introduced content that was
never back-merged into dev — primarily the `applies_to:` frontmatter on
.claude/rules/*.md files added by the v0.35.0 release commit. This caused
genuine merge conflicts when attempting the v0.35.1 dev → master PR.

Merge strategy:
- `-X ours` (dev's version wins on overlapping lines) since dev has v0.35.1
  content the release is about
- Master's additive `applies_to:` frontmatter preserved on rules files
  (non-conflicting addition)
- Fixed one duplicate block in `src/untether/telegram/commands/config.py`
  that `-X ours` combined incorrectly

Verification: 2164 tests pass, ruff format/lint clean, 81.65% coverage.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
chore: sync master into dev to unblock v0.35.1 release
@socket-security
Copy link
Copy Markdown

socket-security bot commented Apr 15, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatedaiohttp@​3.13.3 ⏵ 3.13.597 +1100 +10100100100

View full report

@nathanschram
Copy link
Copy Markdown
Member Author

Unblocked by merging #310 (sync master → dev). The applies_to: frontmatter from master's v0.35.0 release commit had never back-merged to dev; -X ours merge preserved both master's additions and dev's v0.35.1 content. All pre-sync validation re-passing on the updated PR.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
docs/reference/specification.md (1)

1-8: ⚠️ Potential issue | 🟡 Minor

Synchronise version reference in the Scope section.

Line 1 says v0.35.1, but Line 7 still says Untether v0.35.0 specifies:. This leaves the normative version ambiguous.

🛠️ Proposed fix
- Untether v0.35.0 specifies:
+ Untether v0.35.1 specifies:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/reference/specification.md` around lines 1 - 8, The header shows
"Untether Specification v0.35.1" but the Scope text still reads "Untether
v0.35.0 specifies:"; update the Scope sentence to match the document version by
changing "Untether v0.35.0 specifies:" to "Untether v0.35.1 specifies:" (locate
the string "Untether v0.35.0 specifies:" in the specification.md and make the
version consistent with the header).
tests/test_claude_control.py (1)

79-89: ⚠️ Potential issue | 🟡 Minor

Reset the runner registries before each test as well.

This fixture only clears after yield, so the first test in this module can still inherit dirty global state from an earlier module. With these registries living at module scope, that makes the new _PLAN_EXIT_APPROVED coverage order-dependent. Run the same cleanup before yield too.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_claude_control.py` around lines 79 - 89, The fixture
_clear_registries currently only clears globals after the test (after yield),
which allows earlier state to leak into the first test; modify the
_clear_registries fixture to perform the same cleanup both before yield and
after yield by calling _ACTIVE_RUNNERS.clear(), _SESSION_STDIN.clear(),
_REQUEST_TO_SESSION.clear(), _REQUEST_TO_INPUT.clear(),
_HANDLED_REQUESTS.clear(), _DISCUSS_COOLDOWN.clear(),
_PLAN_EXIT_APPROVED.clear(), and re-importing _DISCUSS_FEEDBACK_REFS from
untether.telegram.commands.claude_control before the yield so the registries are
reset for each test start as well as test teardown.
src/untether/telegram/commands/config.py (2)

1859-1866: ⚠️ Potential issue | 🟡 Minor

Add a toast for the new rs:max action.

answer_early=True makes this table the confirmation path for button presses, but rs:max currently falls through to None. Selecting Max will clear the spinner without any feedback.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/untether/telegram/commands/config.py` around lines 1859 - 1866, The rs
mapping currently lacks an entry for the 'rs:max' action so pressing the Max
button (with answer_early=True) falls through and gives no toast; add a "max":
"Reasoning: max" (or equivalent user-facing string) to the "rs" dict so the
'rs:max' action produces the same confirmation/toast as the other rs actions and
ensure the key matches the action name 'rs:max' used by the button handler.

1088-1113: ⚠️ Potential issue | 🟠 Major

Guard reasoning actions against engine-supported levels.

This branch accepts any _RS_ACTIONS value, so config:rs:max can still be persisted for Codex even though allowed_reasoning_levels("codex") excludes it. That leaves chat prefs holding a value the downstream engine-specific arg builder may not understand.

Suggested fix
     if action in _RS_ACTIONS:
         level = _RS_ACTIONS[action]
+        if level not in allowed_reasoning_levels(current_engine):
+            logger.warning(
+                "config.reasoning.unsupported_level",
+                chat_id=chat_id,
+                engine=current_engine,
+                action=action,
+                level=level,
+            )
+            await _page_reasoning(ctx)
+            return
         current = await prefs.get_engine_override(chat_id, current_engine)
         updated = EngineOverrides(
             model=current.model if current else None,
             reasoning=level,
Based on learnings: Build CLI arguments for all 6 engines with model/reasoning/permission flags; engine-specific flag names and defaults.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/untether/telegram/commands/config.py` around lines 1088 - 1113, The code
stores any value from _RS_ACTIONS into EngineOverrides without checking whether
the chosen reasoning level is valid for the engine; update the logic in the
branch handling action in _RS_ACTIONS to validate level against
allowed_reasoning_levels(current_engine) (or similar engine validation function)
before persisting: fetch allowed = allowed_reasoning_levels(current_engine) (or
compute allowed set), if level not in allowed then map it to a safe default
(e.g., the highest supported level or None) or return an error message to user,
and only then call prefs.set_engine_override(chat_id, current_engine, updated)
with the validated level; use the same symbols shown (prefs.get_engine_override,
EngineOverrides, prefs.set_engine_override, _RS_ACTIONS) so reviewers can locate
and update the code path.
🟡 Minor comments (12)
src/untether/triggers/ssrf.py-190-222 (1)

190-222: ⚠️ Potential issue | 🟡 Minor

DNS rebinding TOCTOU consideration.

The validation approach has an inherent TOCTOU (time-of-check to time-of-use) gap: DNS is resolved during validation, but the actual HTTP client will resolve DNS again when making the request. A malicious DNS server could return a public IP during validation and a private IP during the actual request (DNS rebinding attack).

This is a known limitation that's difficult to fully mitigate without using the resolved IP directly for the connection. Consider either:

  1. Documenting this limitation clearly
  2. Using the validated IP addresses directly when making the request (if httpx/aiohttp supports connecting to a specific IP)

For most use cases, the current protection is reasonable since it blocks obvious SSRF attempts. DNS rebinding requires a sophisticated attacker controlling DNS.

httpx connect to specific IP address instead of hostname
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/untether/triggers/ssrf.py` around lines 190 - 222, The current
validate_url_with_dns suffers from DNS rebinding TOCTOU because it only
validates DNS at check time; change it to return (or expose) a validated numeric
IP to be used for the actual connection so the HTTP client can connect to that
IP instead of resolving the hostname again: update resolve_and_validate (or add
a new resolve_and_select function) to return the chosen allowed
ipaddress.IPv4Address/IPv6Address, update validate_url_with_dns to call that and
return both the validated_url and the resolved_ip (or a small struct), and
modify callers to make httpx connect to "http(s)://<resolved_ip>:port/..." while
setting the original hostname in the Host header (or using httpx transport that
binds to the IP) so name-based TLS/Host checks still work; reference
resolve_and_validate, validate_url_with_dns, validate_url and ensure callers
preserve the original hostname in request headers when connecting to the numeric
IP.
docs/reference/runners/claude/runner.md-182-182 (1)

182-182: ⚠️ Potential issue | 🟡 Minor

Clarify that --effort max is Opus 4.6 only.

Verification confirms max is a valid value for the --effort flag, but upstream documentation indicates it is restricted to Opus 4.6. Update line 182 to note this model requirement.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/reference/runners/claude/runner.md` at line 182, Update the docs bullet
that mentions the `--effort <level>` flag to clarify that the `max` value is
only supported on Opus 4.6: change the line "* add `--effort <level>` if a
reasoning override is set (low/medium/high/max)." to explicitly note "`--effort
max` (Opus 4.6 only)" and ensure the list of valid values remains
"low/medium/high" for other models while documenting that "max" requires Opus
4.6.
docs/how-to/update.md-17-27 (1)

17-27: ⚠️ Potential issue | 🟡 Minor

These standalone shell blocks still trip MD046.

The fenced blocks at Line 19 and Line 25 do not match the repo's configured code-block style, so this new page ships with known markdownlint warnings. Please convert them to the expected indented form or align the file with whatever style the docs lint is enforcing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/how-to/update.md` around lines 17 - 27, The markdown uses fenced code
blocks for the commands ("untether --version" and "systemctl --user restart
untether") which violates the repo's MD046 rule; replace those fenced blocks
with the repository's preferred code-block style (e.g., convert to indented code
blocks or whatever the docs lint expects) so the commands are formatted without
backticks and the markdownlint warning is resolved.
docs/how-to/troubleshooting.md-247-254 (1)

247-254: ⚠️ Potential issue | 🟡 Minor

/cancel is described as read-only here, but the implementation deletes the timers.

handle_cancel() now calls cancel_pending_for_chat(chat_id) on a bare /cancel, so following this advice will actually remove the pending /at jobs. Please either document it as a destructive cancel action or point readers at a non-destructive inspection command.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/how-to/troubleshooting.md` around lines 247 - 254, The docs incorrectly
state `/cancel` is read-only while the implementation in handle_cancel() calls
cancel_pending_for_chat(chat_id) and deletes timers; update the troubleshooting
text to reflect that `/cancel` is destructive (it removes pending /at jobs) or
add a note pointing to a non-destructive inspection command (e.g., a specific
"list_pending" or "inspect_pending" endpoint/command) — mention the exact
function names handle_cancel() and cancel_pending_for_chat(chat_id) so
maintainers can find the behavior in code and either change the docs to warn
users or update the docs to reference the non-destructive inspection command.
docs/reference/config.md-455-459 (1)

455-459: ⚠️ Potential issue | 🟡 Minor

Clarify restart-only vs hot-reload semantics for trigger settings.

This note can conflict with other docs that describe trigger controls as hot-reloadable. Please align the exact behaviour for enabled/rate-limit/server settings in one canonical way to avoid operator mistakes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/reference/config.md` around lines 455 - 459, The tip text is ambiguous
about which trigger settings hot-reload applies to; update the copy so it
consistently states that when watch_config = true only
webhook/cron/schedule/timezone changes are hot-reloaded and that server settings
(host, port, rate_limit) and the enabled toggle require a restart; also align
and cross-reference the Triggers reference — Hot-reload section
(triggers/triggers.md) so both places use the exact same canonical phrasing for
watch_config, enabled, host, port, and rate_limit semantics to avoid
contradictions.
docs/how-to/operations.md-167-175 (1)

167-175: ⚠️ Potential issue | 🟡 Minor

Hot-reload list likely overstates triggers.enabled behaviour.

Line 167 lists triggers.enabled as immediately hot-reloadable, but other docs in this PR describe the trigger enabled toggle as restart-only. Please align this to one source of truth.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/how-to/operations.md` around lines 167 - 175, The docs currently list
`triggers.enabled` as hot-reloadable but other docs say it is restart-only;
update this section so `triggers.enabled` is listed under the **Restart-only**
group (remove it from the hot-reload list), and ensure any related mention
(e.g., "Trigger system" line and its bullets) no longer claim immediate
hot-reload for `triggers.enabled`; edit the entries referencing
`triggers.enabled` to match the restart-only behavior described elsewhere.
docs/how-to/schedule-tasks.md-9-13 (1)

9-13: ⚠️ Potential issue | 🟡 Minor

Add a language tag to the fenced code block (MD040).

This will keep docs lint clean and consistent with markdownlint expectations.

Suggested fix
-```
+```text
 /at 30m Check the build
 /at 2h Review the PR feedback
 /at 60s Say hello
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against the current code and only fix it if needed.

In @docs/how-to/schedule-tasks.md around lines 9 - 13, The fenced code block in
docs/how-to/schedule-tasks.md is missing a language tag (MD040); update the
opening fence from to a tagged fence such astext for the shown block
containing "/at 30m Check the build", "/at 2h Review the PR feedback", and "/at
60s Say hello" so markdownlint stops flagging it and the docs remain consistent.


</details>

</blockquote></details>
<details>
<summary>docs/reference/changelog.md-38-38 (1)</summary><blockquote>

`38-38`: _⚠️ Potential issue_ | _🟡 Minor_

**Don't point release docs at the moving `dev` branch.**

After this release is merged to `master`, this link will show whatever `dev` contains later, not the `v0.35.1` notes users are reading now. A relative link or tag-specific link keeps the release docs stable.


<details>
<summary>🔗 Suggested fix</summary>

```diff
-See [CHANGELOG.md](https://github.com/littlebearapps/untether/blob/dev/CHANGELOG.md#v0351-2026-04-14) for full implementation details.
+See [CHANGELOG.md](../../CHANGELOG.md#v0351-2026-04-14) for full implementation details.
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@docs/reference/changelog.md` at line 38, The link in the line that reads "See
[CHANGELOG.md](https://github.com/littlebearapps/untether/blob/dev/CHANGELOG.md#v0351-2026-04-14)"
points at the moving dev branch; update that anchor to a stable target by
replacing the URL with either a relative/stable repo link (e.g.,
../CHANGELOG.md#v0.35.1 or /CHANGELOG.md#v0.35.1) or a tag-specific GitHub blob
URL for v0.35.1 so the "See [CHANGELOG.md](...)" reference always resolves to
the v0.35.1 notes after merge.
```

</details>

</blockquote></details>
<details>
<summary>docs/how-to/webhooks-and-cron.md-137-141 (1)</summary><blockquote>

`137-141`: _⚠️ Potential issue_ | _🟡 Minor_

**These new code blocks are tripping markdownlint.**

The added fences are currently hitting MD040/MD046: the command/output examples at Lines 289, 301, and 308 need a language, and the other new fenced blocks should match the repo's configured code-block style.




Also applies to: 255-261, 275-281, 289-310

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@docs/how-to/webhooks-and-cron.md` around lines 137 - 141, The new fenced code
blocks in docs/how-to/webhooks-and-cron.md are missing language tags and
inconsistent with the repo's code-block style; update the fences so the toml
snippet containing "[triggers]" uses a "toml" tag, mark command examples with
"bash" (or "sh") and mark command output examples with a neutral tag like "text"
(or "console") to satisfy MD040/MD046, and make all other new fences (the
earlier blocks and the command/output examples) use the same consistent tagging
convention throughout the file.
```

</details>

</blockquote></details>
<details>
<summary>src/untether/telegram/offset_persistence.py-56-58 (1)</summary><blockquote>

`56-58`: _⚠️ Potential issue_ | _🟡 Minor_

**Reject booleans for `last_update_id`.**

`bool` is an `int` in Python, so `{"last_update_id": true}` is accepted here as `1`. That turns a malformed state file into a bogus offset.



<details>
<summary>Possible fix</summary>

```diff
-    if isinstance(raw, int) and raw >= 0:
+    if isinstance(raw, int) and not isinstance(raw, bool) and raw >= 0:
         return raw
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@src/untether/telegram/offset_persistence.py` around lines 56 - 58, The code
treating raw = data.get("last_update_id") accepts booleans because bool is a
subclass of int; update the validation in offset_persistence so that booleans
are explicitly rejected (e.g., check type(raw) is int or add "and not
isinstance(raw, bool)") before returning the value and still ensure raw >= 0;
specifically modify the conditional around raw (in the function handling
last_update_id) to exclude bools while preserving the non-negative integer
check.
```

</details>

</blockquote></details>
<details>
<summary>CHANGELOG.md-3-105 (1)</summary><blockquote>

`3-105`: _⚠️ Potential issue_ | _🟡 Minor_

**Add the missing `### tests` subsection for `v0.35.1`.**

This release entry skips the tests section even though the release clearly includes new coverage and validation work. Please keep the release notes aligned with the repo's changelog structure.



Based on learnings, "Update `CHANGELOG.md` with every version bump using format `## vX.Y.Z (YYYY-MM-DD)` with subsections for fixes, changes, breaking, docs, and tests".

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@CHANGELOG.md` around lines 3 - 105, Add a missing "### tests" subsection
under the existing "## v0.35.1 (2026-04-15)" header in CHANGELOG.md that lists
the test work included in this release (e.g. "13 new tests in
test_trigger_manager.py; existing 2038 tests still pass" and any other test
coverage/validation notes referenced in the release), keeping the same changelog
style and placement alongside the fixes/changes/docs subsections so the entry
includes fixes, changes, docs, and tests.
```

</details>

</blockquote></details>
<details>
<summary>src/untether/triggers/server.py-46-54 (1)</summary><blockquote>

`46-54`: _⚠️ Potential issue_ | _🟡 Minor_

**Use `asyncio.get_running_loop()` instead of `asyncio.get_event_loop()` and avoid direct StreamReader instantiation.**

The `asyncio.get_event_loop()` call is deprecated in Python 3.10+. Additionally, `streams.StreamReader` should not be instantiated directly; it's an internal implementation detail of aiohttp. Consider refactoring this logic to use aiohttp's public API or an alternative approach that aligns with Python 3.12+ best practices and the anyio async pattern guideline.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@src/untether/triggers/server.py` around lines 46 - 54, Replace the deprecated
asyncio.get_event_loop() call with asyncio.get_running_loop() and stop
instantiating streams.StreamReader directly; instead create an async-compatible
payload from raw_body (e.g., using aiohttp.payload.BytesPayload or another
public aiohttp payload helper) and construct the MultipartReader via aiohttp's
public API (e.g., MultipartReader.from_payload or other public factory) using
the Content-Type header built from content_type; adjust the code paths
referencing raw_body, content_type, and MultipartReader to consume the payload
rather than a directly instantiated StreamReader.
```

</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (12)</summary><blockquote>

<details>
<summary>tests/test_describe_cron.py (1)</summary><blockquote>

`73-85`: **Add fallback regression cases for unsupported `dom`/`mon` and invalid DOW.**

Please add explicit cases for `*/N` in day/month fields and out-of-range DOW values so fallback behaviour stays locked in.


<details>
<summary>Suggested test additions</summary>

```diff
         [
             "*/15 * * * *",  # stepped minutes
             "0 */4 * * *",  # stepped hours
+            "0 9 */2 * *",  # stepped day-of-month
+            "0 9 * */2 *",  # stepped month
             "0 9 1 * *",  # day-of-month
             "0 9 * 6 *",  # specific month
+            "0 9 * * 8",  # invalid day-of-week
+            "0 9 * * 1-5/2",  # stepped day-of-week
             "invalid",  # totally wrong
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@tests/test_describe_cron.py` around lines 73 - 85, Update the pytest
parameter list in tests/test_describe_cron.py (the pytest.mark.parametrize block
for "schedule") to include explicit fallback regression cases: add a
day-of-month stepped case like "0 9 */2 * *", a month-stepped case like "0 9 *
*/3 *", an out-of-range numeric DOW like "0 9 * * 8", and an invalid DOW token
like "0 9 * * MON" so the test covers unsupported dom/mon "*/N" and invalid DOW
fallback behavior.
```

</details>

</blockquote></details>
<details>
<summary>src/untether/triggers/settings.py (1)</summary><blockquote>

`143-153`: **Consider extracting shared timezone validation.**

Both `_validate_timezone` and `_validate_default_timezone` perform identical `ZoneInfo` validation. You could extract a module-level helper to reduce duplication:

<details>
<summary>♻️ Optional refactor</summary>

```diff
+def _check_iana_timezone(tz: str) -> None:
+    """Raise ValueError if tz is not a valid IANA timezone."""
+    try:
+        ZoneInfo(tz)
+    except (ZoneInfoNotFoundError, KeyError):
+        raise ValueError(
+            f"unknown timezone {tz!r}; use IANA names like 'Australia/Melbourne'"
+        ) from None
+
+
 class CronConfig(BaseModel):
     ...
     `@field_validator`("timezone")
     `@classmethod`
     def _validate_timezone(cls, v: str | None) -> str | None:
         if v is not None:
-            try:
-                ZoneInfo(v)
-            except (ZoneInfoNotFoundError, KeyError):
-                raise ValueError(
-                    f"unknown timezone {v!r}; use IANA names like 'Australia/Melbourne'"
-                ) from None
+            _check_iana_timezone(v)
         return v
```
</details>




Also applies to: 171-181

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@src/untether/triggers/settings.py` around lines 143 - 153, Both
_validate_timezone and _validate_default_timezone duplicate IANA timezone
validation; extract that logic into a module-level helper (e.g.,
validate_iana_timezone(value: str | None) -> str | None) and have both class
validators call it. Update the validators _validate_timezone and
_validate_default_timezone to delegate to the new helper and preserve the same
error type/message (raise ValueError with the existing message) and return
behavior so behavior remains identical.
```

</details>

</blockquote></details>
<details>
<summary>src/untether/triggers/manager.py (1)</summary><blockquote>

`80-82`: **Avoid returning mutable internal state from `crons`.**

Line 82 returns the internal list directly, so external callers can mutate manager state and bypass `update()`/`remove_cron()` invariants. Prefer returning a snapshot.



<details>
<summary>Proposed fix</summary>

```diff
     `@property`
     def crons(self) -> list[CronConfig]:
         """Current cron list — the scheduler iterates this each tick."""
-        return self._crons
+        return list(self._crons)
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@src/untether/triggers/manager.py` around lines 80 - 82, The crons() method
exposes internal mutable state by returning self._crons directly; change it to
return an immutable snapshot (e.g., a shallow copy or tuple) so callers cannot
mutate the manager's internal list and bypass invariants enforced by update()
and remove_cron(); update the impl of crons() to return list(self._crons) or
tuple(self._crons) and ensure any callers expecting mutability are adjusted to
work with the snapshot.
```

</details>

</blockquote></details>
<details>
<summary>.github/workflows/notify-website.yml (1)</summary><blockquote>

`20-24`: **Make the dispatch step fail on HTTP errors.**

Right now this can pass even when GitHub returns 4xx/5xx, which hides failed website notifications.


<details>
<summary>Suggested hardening</summary>

```diff
-          curl -s -X POST \
+          curl --fail-with-body --show-error --silent -X POST \
             -H "Accept: application/vnd.github+json" \
+            -H "Content-Type: application/json" \
             -H "Authorization: Bearer ${{ secrets.WEBSITE_DISPATCH_TOKEN }}" \
             https://api.github.com/repos/littlebearapps/littlebearapps.com/dispatches \
             -d "$(jq -n --arg tag "$TAG_NAME" '{"event_type":"release-published","client_payload":{"repo":"untether","tag":$tag}}')"
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/notify-website.yml around lines 20 - 24, The curl step
that posts to the repository dispatch currently doesn't fail on HTTP errors;
update the curl invocation (the curl -s -X POST call that uses $TAG_NAME and
WEBSITE_DISPATCH_TOKEN) to fail on non-2xx responses by adding the --fail (or
-f) flag and keep useful output with -S, or capture the HTTP status and exit
non-zero when it indicates an error; ensure the workflow exits with a non-zero
code on 4xx/5xx so the dispatch step correctly fails the job.
```

</details>

</blockquote></details>
<details>
<summary>tests/test_trigger_dispatcher.py (1)</summary><blockquote>

`265-267`: **Prefer condition-based waiting over fixed 10ms sleeps in async tests.**

These sleeps can intermittently race in slower CI runners.


<details>
<summary>Deterministic wait pattern</summary>

```diff
+async def _wait_for_calls(capture: RunJobCapture, *, min_calls: int = 1) -> None:
+    with anyio.fail_after(1):
+        while len(capture.calls) < min_calls:
+            await anyio.sleep(0)
+
 ...
-        await anyio.sleep(0.01)
+        await _wait_for_calls(run_job)
         tg.cancel_scope.cancel()

 ...
-        await anyio.sleep(0.01)
+        await _wait_for_calls(run_job)
         tg.cancel_scope.cancel()
```
</details>


Also applies to: 291-293

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@tests/test_trigger_dispatcher.py` around lines 265 - 267, The test uses a
fixed await anyio.sleep(0.01) after calling
dispatcher.dispatch_webhook(_make_webhook(), "Plain"), which can race on slow
CI; replace the fixed sleep with a deterministic condition-based wait such as
awaiting an anyio.Event or polling a predicate with timeout that signals when
the webhook handler has completed (e.g., set an Event inside the handler or
check a shared flag updated by dispatch_webhook), then await that event or
predicate before calling tg.cancel_scope.cancel(); apply the same pattern for
the similar block around lines that call dispatch_webhook at 291-293.
```

</details>

</blockquote></details>
<details>
<summary>docs/tutorials/install.md (1)</summary><blockquote>

`441-441`: **Optional wording polish for repeated sentence openings.**

Three consecutive sentences start with “To”, which reads a bit repetitive.

<details>
<summary>✍️ Suggested wording tweak</summary>

```diff
-To uninstall completely (CLI, config, and state), see the [uninstall guide](../how-to/uninstall.md). To learn more about updates, see [Update Untether](../how-to/update.md).
+For complete removal (CLI, config, and state), see the [uninstall guide](../how-to/uninstall.md). For update details, see [Update Untether](../how-to/update.md).
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@docs/tutorials/install.md` at line 441, The three consecutive sentences
starting with "To" are repetitive; update the sentence that begins "To uninstall
completely (CLI, config, and state), see the [uninstall guide]..." and/or the
sentence "To learn more about updates, see [Update Untether]..." so the openings
vary—e.g., change the uninstall sentence to "For a complete uninstall (CLI,
config, and state), see the [uninstall guide]" and the updates sentence to
"Learn more about updates in [Update Untether]"—make sure the revised lines
preserve the links and meaning.
```

</details>

</blockquote></details>
<details>
<summary>tests/test_trigger_templating.py (1)</summary><blockquote>

`67-86`: **Add `-> None` to the new test methods.**

The four added tests are unannotated, which drifts from the repo’s Python typing baseline.  


As per coding guidelines, "Add type hints on all public functions in Python".

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@tests/test_trigger_templating.py` around lines 67 - 86, Add explicit return
type annotations -> None to the four test methods in TestRenderTemplateFields:
test_substitution, test_no_untrusted_prefix, test_nested_path, and
test_missing_field_renders_empty so they conform to the repo typing baseline;
open the class TestRenderTemplateFields and update each method signature to
include "-> None" while leaving the body and assertions unchanged (these tests
call render_template_fields and reference _UNTRUSTED_PREFIX but only the method
signatures need the type hint).
```

</details>

</blockquote></details>
<details>
<summary>tests/test_callback_dispatch.py (1)</summary><blockquote>

`484-486`: **Minor: Inline imports of `patch` are redundant.**

Consider importing `patch` at module level alongside the existing `AsyncMock` import from `unittest.mock`. This is a minor style nit — not blocking.



<details>
<summary>♻️ Suggested refactor</summary>

```diff
 from unittest.mock import AsyncMock
+from unittest.mock import patch

 import pytest
```

Then remove the inline `from unittest.mock import patch` statements inside each test function.
</details>


Also applies to: 527-529, 557-559

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@tests/test_callback_dispatch.py` around lines 484 - 486, Move the inline
"from unittest.mock import patch" imports to the module-level imports alongside
AsyncMock (i.e., add patch to the existing unittest.mock import) and remove the
redundant inline import lines inside test functions; specifically update tests
that use with patch("untether.telegram.commands.dispatch.get_command",
return_value=backend): and other occurrences (the similar blocks at the other
noted locations) to rely on the module-level patch import instead.
```

</details>

</blockquote></details>
<details>
<summary>tests/test_trigger_server.py (1)</summary><blockquote>

`418-463`: **Path traversal test could be more explicit.**

The sanitization test correctly verifies no escape above `tmp_path` (line 462), but the primary assertion on line 460 is weak — it only checks *something* was written, not *what* the sanitized filename became.



<details>
<summary>♻️ Suggested improvement</summary>

Consider verifying the actual sanitized filename. Based on common sanitization patterns, the filename might become something like `passwd` or `upload.bin`:

```diff
-    # Must land inside the expected directory, not escape.
-    assert (dest / "upload.bin").exists() or any(dest.glob("*"))
+    # Must land inside the expected directory with sanitized name, not escape.
+    written_files = list(dest.glob("*"))
+    assert len(written_files) == 1, f"expected exactly one file, got {written_files}"
+    assert written_files[0].read_bytes() == b"evil"
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@tests/test_trigger_server.py` around lines 418 - 463, The test
test_multipart_unsafe_filename_sanitised currently only asserts that "something"
was written to dest which is weak; update the assertions to explicitly verify
the sanitized filename and that it did not retain traversal components. In the
test (using variables dest, tmp_path, file_destination template
"{{file.filename}}", and the POST to "/hooks/mp"), enumerate files in dest and
assert there is exactly one uploaded file whose name contains no path separators
(no ".." or "/" or "\"), and assert its basename equals the expected sanitized
name (e.g., "passwd" or "upload.bin" depending on your sanitizer) or otherwise
matches the sanitizer's deterministic output; keep the existing check ensuring
no file was written outside tmp_path.
```

</details>

</blockquote></details>
<details>
<summary>CLAUDE.md (1)</summary><blockquote>

`205-226`: **Consider avoiding exact per-file test counts in this long-lived doc.**

These numbers drift quickly and create recurring doc churn. Prefer qualitative scope descriptions (or generate counts automatically in CI).

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@CLAUDE.md` around lines 205 - 226, The CLAUDE.md listing includes exact
per-file test counts (e.g., "test_exec_runner.py — 22 tests") which will drift;
update the entries that reference individual test files (like
test_exec_runner.py, test_build_args.py, test_trigger_ssrf.py, etc.) to remove
hard-coded numbers and instead use qualitative scopes or summaries (e.g., "tests
covering event tracking, recent_events ring buffer, PID in StartedEvent meta")
or replace the whole block with a generated summary inserted by CI;
alternatively add a note that counts are auto-generated and switch to a script
in CI to inject current counts into the doc instead of maintaining numeric
literals manually.
```

</details>

</blockquote></details>
<details>
<summary>tests/test_exec_bridge.py (1)</summary><blockquote>

`3953-3978`: **Use the shared event factories in these new tests.**

These cases reintroduce hand-built `Action`/`ActionEvent` payloads, which makes the tests drift-prone when the event contract changes. Reusing the existing factories keeps the fixtures aligned with production events.


As per coding guidelines, "Use EventFactory for event construction, never construct dataclasses directly."


Also applies to: 4067-4075

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@tests/test_exec_bridge.py` around lines 3953 - 3978, Replace the
hand-constructed Action and ActionEvent instances with the shared event factory:
instead of calling Action(id="a1", kind="tool", title="Read") and
ActionEvent(...), use the project's EventFactory (e.g.,
EventFactory.action_event(...) or EventFactory.create_action_event(...)) to
build the same started-phase events and pass them to edits.on_event; do the same
for the second event (a2/Grep) and the other occurrence around the 4067-4075
region so tests use the centralized factory rather than direct dataclass
construction.
```

</details>

</blockquote></details>
<details>
<summary>tests/test_trigger_manager.py (1)</summary><blockquote>

`71-85`: **Consider adding type annotations to list fields.**

The `tasks` and `calls` fields lack element type annotations, which reduces IDE support and type checker effectiveness.


<details>
<summary>🔧 Suggested type annotations</summary>

```diff
 `@dataclass`
 class FakeTaskGroup:
-    tasks: list = field(default_factory=list)
+    tasks: list[tuple[Any, tuple[Any, ...]]] = field(default_factory=list)

     def start_soon(self, fn, *args):
         self.tasks.append((fn, args))


 `@dataclass`
 class RunJobCapture:
-    calls: list = field(default_factory=list)
+    calls: list[tuple[Any, ...]] = field(default_factory=list)

     async def __call__(self, *args, **kwargs):
         self.calls.append(args)
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@tests/test_trigger_manager.py` around lines 71 - 85, The dataclass list
fields lack element type annotations which harms type checking; update
FakeTaskGroup.tasks to a typed list e.g. List[Tuple[Callable[..., Any],
Tuple[Any, ...]]] and update RunJobCapture.calls to List[Tuple[Any, ...]] (or a
more specific tuple type if known), add the necessary imports (from typing
import List, Tuple, Callable, Any) and keep the existing start_soon and async
__call__ behavior intact so type checkers and IDEs can infer element types for
FakeTaskGroup.start_soon and RunJobCapture.__call__.
```

</details>

</blockquote></details>

</blockquote></details>

---

<details>
<summary>ℹ️ Review info</summary>

<details>
<summary>⚙️ Run configuration</summary>

**Configuration used**: defaults

**Review profile**: CHILL

**Plan**: Pro

**Run ID**: `68e1a01f-8ea5-4c51-8fc1-1e36f17fbcc6`

</details>

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between e5bff3c93e3335f72ecf7b72dfa566aa2b992a18 and 909fbf586aa2c9fdeb80fe8ab0f048988d9c969d.

</details>

<details>
<summary>⛔ Files ignored due to path filters (1)</summary>

* `uv.lock` is excluded by `!**/*.lock`

</details>

<details>
<summary>📒 Files selected for processing (110)</summary>

* `.claude/rules/runner-development.md`
* `.claude/rules/telegram-transport.md`
* `.claude/rules/testing-conventions.md`
* `.claude/skills/untether-architecture/SKILL.md`
* `.github/workflows/ci.yml`
* `.github/workflows/notify-website.yml`
* `.github/workflows/release.yml`
* `CHANGELOG.md`
* `CLAUDE.md`
* `README.md`
* `contrib/untether.service`
* `docs/explanation/architecture.md`
* `docs/how-to/group-chat.md`
* `docs/how-to/index.md`
* `docs/how-to/inline-settings.md`
* `docs/how-to/interactive-approval.md`
* `docs/how-to/model-reasoning.md`
* `docs/how-to/operations.md`
* `docs/how-to/plan-mode.md`
* `docs/how-to/schedule-tasks.md`
* `docs/how-to/security.md`
* `docs/how-to/troubleshooting.md`
* `docs/how-to/uninstall.md`
* `docs/how-to/update.md`
* `docs/how-to/voice-notes.md`
* `docs/how-to/webhooks-and-cron.md`
* `docs/reference/changelog.md`
* `docs/reference/commands-and-directives.md`
* `docs/reference/config.md`
* `docs/reference/dev-instance.md`
* `docs/reference/glossary.md`
* `docs/reference/integration-testing.md`
* `docs/reference/runners/amp/runner.md`
* `docs/reference/runners/claude/runner.md`
* `docs/reference/runners/gemini/runner.md`
* `docs/reference/specification.md`
* `docs/reference/triggers/triggers.md`
* `docs/tutorials/first-run.md`
* `docs/tutorials/install.md`
* `incoming/v0.35.1rc4-integration-test-plan.md`
* `pyproject.toml`
* `scripts/healthcheck.sh`
* `src/untether/cli/run.py`
* `src/untether/commands.py`
* `src/untether/context.py`
* `src/untether/markdown.py`
* `src/untether/progress.py`
* `src/untether/runner.py`
* `src/untether/runner_bridge.py`
* `src/untether/runners/amp.py`
* `src/untether/runners/claude.py`
* `src/untether/runners/codex.py`
* `src/untether/runners/gemini.py`
* `src/untether/runners/opencode.py`
* `src/untether/runners/pi.py`
* `src/untether/sdnotify.py`
* `src/untether/settings.py`
* `src/untether/telegram/at_scheduler.py`
* `src/untether/telegram/bridge.py`
* `src/untether/telegram/client_api.py`
* `src/untether/telegram/commands/at.py`
* `src/untether/telegram/commands/cancel.py`
* `src/untether/telegram/commands/config.py`
* `src/untether/telegram/commands/dispatch.py`
* `src/untether/telegram/commands/ping.py`
* `src/untether/telegram/commands/threads.py`
* `src/untether/telegram/engine_overrides.py`
* `src/untether/telegram/loop.py`
* `src/untether/telegram/offset_persistence.py`
* `src/untether/telegram/parsing.py`
* `src/untether/telegram/topic_state.py`
* `src/untether/triggers/__init__.py`
* `src/untether/triggers/actions.py`
* `src/untether/triggers/auth.py`
* `src/untether/triggers/cron.py`
* `src/untether/triggers/describe.py`
* `src/untether/triggers/dispatcher.py`
* `src/untether/triggers/fetch.py`
* `src/untether/triggers/manager.py`
* `src/untether/triggers/rate_limit.py`
* `src/untether/triggers/server.py`
* `src/untether/triggers/settings.py`
* `src/untether/triggers/ssrf.py`
* `src/untether/triggers/templating.py`
* `src/untether/utils/proc_diag.py`
* `tests/test_at_command.py`
* `tests/test_bridge_config_reload.py`
* `tests/test_build_args.py`
* `tests/test_callback_dispatch.py`
* `tests/test_claude_control.py`
* `tests/test_config_command.py`
* `tests/test_describe_cron.py`
* `tests/test_exec_bridge.py`
* `tests/test_offset_persistence.py`
* `tests/test_ping_command.py`
* `tests/test_proc_diag.py`
* `tests/test_sdnotify.py`
* `tests/test_telegram_bridge.py`
* `tests/test_telegram_engine_overrides.py`
* `tests/test_trigger_actions.py`
* `tests/test_trigger_cron.py`
* `tests/test_trigger_dispatcher.py`
* `tests/test_trigger_fetch.py`
* `tests/test_trigger_manager.py`
* `tests/test_trigger_meta_line.py`
* `tests/test_trigger_server.py`
* `tests/test_trigger_settings.py`
* `tests/test_trigger_ssrf.py`
* `tests/test_trigger_templating.py`
* `zensical.toml`

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->

Comment thread docs/reference/integration-testing.md Outdated
Comment thread pyproject.toml
authors = [{name = "Little Bear Apps", email = "[email protected]"}]
maintainers = [{name = "Little Bear Apps", email = "[email protected]"}]
version = "0.35.0"
version = "0.35.1"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Version bump should be minor, not patch, for a new command feature.

Line 5 sets 0.35.1, but Line 81 introduces a new /at command backend. That is a feature addition and should use a minor bump.

🔧 Suggested fix
-version = "0.35.1"
+version = "0.36.0"

As per coding guidelines, "Version bumps must match semantic versioning: patch for bug fixes/schema additions, minor for features/new commands."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
version = "0.35.1"
version = "0.36.0"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyproject.toml` at line 5, Change the package version from the patch bump to
a minor bump to reflect the new feature: update the version value currently set
to "0.35.1" in pyproject.toml to "0.36.0" because you added the new /at command
backend; ensure the version string in pyproject.toml (the line containing
version = "...") is updated and consistent with any release metadata or
CI/version checks that read the same value.

Comment thread README.md
Comment thread src/untether/runner_bridge.py
Comment thread src/untether/runners/claude.py
Comment on lines +98 to +120
# Snapshot the cron list for this tick — safe even if update()
# replaces manager._crons mid-iteration (new list, old ref valid).
crons = manager.crons
default_timezone = manager.default_timezone
for cron in crons:
try:
matched = cron_matches(cron.schedule, now)
local_now = _resolve_now(utc_now, cron.timezone, default_timezone)
matched = cron_matches(cron.schedule, local_now)
except Exception:
logger.exception("triggers.cron.match_failed", cron_id=cron.id)
continue
if matched:
key = (now.hour, now.minute)
key = (local_now.hour, local_now.minute)
if last_fired.get(cron.id) == key:
continue # already fired this minute
last_fired[cron.id] = key
logger.info("triggers.cron.firing", cron_id=cron.id)
await dispatcher.dispatch_cron(cron)
# #288: one-shot crons are removed from the active list
# after firing; they stay in the TOML and re-activate on
# the next config reload or restart.
if cron.run_once:
manager.remove_cron(cron.id)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Take a real snapshot before mutating manager.crons.

crons = manager.crons is only an alias. If remove_cron() mutates the backing list in place, removing a run_once entry during iteration can skip the next matching cron in the same tick. Snapshot with list(...) and defer removals until after the loop.

♻️ Suggested fix
-        crons = manager.crons
+        crons = list(manager.crons)
         default_timezone = manager.default_timezone
+        remove_after_tick: list[str] = []
         for cron in crons:
@@
-                if cron.run_once:
-                    manager.remove_cron(cron.id)
+                if cron.run_once:
+                    remove_after_tick.append(cron.id)
+
+        for cron_id in remove_after_tick:
+            manager.remove_cron(cron_id)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/untether/triggers/cron.py` around lines 98 - 120, The code currently
iterates over manager.crons by reference which can be mutated by
manager.remove_cron(cron.id) and skip entries; fix by first taking a real
snapshot (e.g., crons = list(manager.crons)) and, instead of calling
manager.remove_cron(...) inside the loop, collect cron ids with cron.run_once
into a to_remove set/list and after the for loop call manager.remove_cron(id)
for each id; keep existing logic around _resolve_now, cron_matches, last_fired,
logger, and dispatcher.dispatch_cron unaffected.

Comment thread src/untether/triggers/describe.py Outdated
Comment thread src/untether/triggers/describe.py Outdated
Comment on lines +46 to +48
self._crons = list(settings.crons)
self._webhooks_by_path = {wh.path: wh for wh in settings.webhooks}
self._default_timezone = settings.default_timezone
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate duplicate cron IDs and webhook paths before replacing state.

Line 47 silently overwrites earlier webhooks sharing the same path, and Line 135 only removes the first matching cron ID. That creates ambiguous routing/removal semantics and can mask config mistakes.

Proposed fix
     def update(self, settings: TriggersSettings) -> None:
         """Replace cron and webhook configuration.
@@
-        self._crons = list(settings.crons)
-        self._webhooks_by_path = {wh.path: wh for wh in settings.webhooks}
+        cron_ids = [c.id for c in settings.crons]
+        if len(cron_ids) != len(set(cron_ids)):
+            raise ValueError("Duplicate cron ids are not allowed")
+
+        webhook_paths = [wh.path for wh in settings.webhooks]
+        if len(webhook_paths) != len(set(webhook_paths)):
+            raise ValueError("Duplicate webhook paths are not allowed")
+
+        self._crons = list(settings.crons)
+        self._webhooks_by_path = {wh.path: wh for wh in settings.webhooks}

Run this to confirm whether upstream settings validation already enforces uniqueness:

#!/bin/bash
set -euo pipefail

fd -a "settings.py" src/untether/triggers
rg -n -C3 --type=py 'class (TriggersSettings|CronConfig|WebhookConfig)|validator|validate|webhooks|crons|path|id' src/untether/triggers/settings.py

Also applies to: 135-137

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/untether/triggers/manager.py` around lines 46 - 48, Detect and reject
duplicate webhook paths and cron IDs before replacing internal state: when
assigning self._webhooks_by_path from settings.webhooks and self._crons from
settings.crons, validate that no two WebhookConfig items share the same path and
no two CronConfig items share the same id; if duplicates exist, raise a clear
ValueError (or propagate a ValidationError) rather than silently overwriting.
Also update the cron removal logic (the method that currently removes only the
first matching id around the remove logic at/near where self._crons is mutated)
to remove all crons with the given id (or explicitly enforce uniqueness earlier
and assert exactly one removal) so semantics are unambiguous. Ensure the checks
reference settings.webhooks, settings.crons, self._webhooks_by_path and
self._crons so reviewers can find the changes.

Comment thread src/untether/triggers/rate_limit.py Outdated
Nathan Schram (nathanschram) added a commit that referenced this pull request Apr 15, 2026
…rs, runner, telegram, docs

CodeRabbit flagged 1 critical + 15 major + 4 outside-diff issues during
review of the v0.35.1 release PR. After triage, 15 are real bugs worth
fixing before release; 3 are false positives and 2 are deferred.

P0 — Critical functional regressions in v0.35.1 features:
- triggers/cron.py: daily/weekly crons stop after first day. last_fired
  was keyed by (hour, minute) only, so tomorrow's 09:00 looked identical
  to today's and got suppressed forever. Now keyed by full date+time. (#11)
- runner_bridge.py: tree_active subagent runs were force-killed at
  STALL_MAX_WARNINGS because the auto-cancel exemption only checked
  cpu_active. Now exempts tree_active when active children present. (#4)
- triggers/actions.py: append_timestamp on_conflict used second-resolution
  names so two requests in the same second clobbered each other (the
  feature is meant to PREVENT that). Now uses time.time_ns() with a
  collision probe loop. (#9)

P1 — Functional bugs in supporting features:
- runners/claude.py: _PLAN_EXIT_APPROVED was only set in interactive
  approval. Auto-approve drain (auto permission mode + post-discuss
  approval) skipped the bookkeeping, defeating #283 in those flows. (#5)
- telegram/parsing.py: offset was persisted before yield, so a crash
  between yield and consumer could record an unprocessed update as done.
  Now persists after yield. (#8)
- triggers/rate_limit.py: per-hit warning logs flooded structured output
  and fed the issue watcher. Dropped to debug. (#16)
- telegram/commands/config.py: reasoning levels weren't validated against
  engine support — manual callback_data could persist e.g. `max` on
  Codex. Defensive validation added. (#20)
- telegram/bridge.py: docstring claimed chat_ids was hot-reloadable but
  it's never sourced from settings. Corrected docstring (#6).

P2 — Doc/display quality:
- triggers/describe.py: invalid DOW values were normalised via % 7 (8 →
  Mon); stepped patterns like */2 in dom/mon were rendered as "daily"
  due to substring check. Both now fall back to raw schedule. (#13, #14)
- config.py: rs:max action had no toast entry. Added. (#19)
- README.md: access matrix overstated isolation, omitted /file put,
  outbox, and webhook file_write/http_forward actions. Now accurate. (#3)
- docs/reference/integration-testing.md: bot ID was reused as DM chat
  ID. Clarified that DM target is the user's chat ID. (#1)
- docs/reference/specification.md: header said v0.35.1 but body said
  "Untether v0.35.0 specifies:". Synced to v0.35.1. (#17)
- tests/test_claude_control.py: cleanup fixture only ran post-yield.
  Now runs pre- and post-yield. (#18)

Skipped (false positives or out of scope):
- #2 (version bump suggestion): user decision, not auto-applicable.
- #7 (restart-only keys hot-reloaded): false positive — update_from is
  selective and only touches hot fields.
- #12 (cron snapshot): false positive — remove_cron replaces the list,
  doesn't mutate in place.

Deferred to follow-up issues:
- #10 (Content-Type override on http_forward raw bodies)
- #15 (duplicate cron ID/webhook path validation)

Test additions:
- test_trigger_cron.py: regression test for daily cron firing on
  consecutive days (covers #11)
- test_describe_cron.py: 6 regression tests for #13/#14 fallback paths
- test_config_command.py: split per-engine reasoning level test +
  regression test for codex/rs:max rejection (covers #20)
- test_claude_control.py: clarified pre/post-yield cleanup (covers #18)

Verified: 2172 tests pass (was 2164, +8 new), 81.51% coverage,
ruff format/lint clean, build successful.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…rs, runner, telegram, docs (#311)

CodeRabbit flagged 1 critical + 15 major + 4 outside-diff issues during
review of the v0.35.1 release PR. After triage, 15 are real bugs worth
fixing before release; 3 are false positives and 2 are deferred.

P0 — Critical functional regressions in v0.35.1 features:
- triggers/cron.py: daily/weekly crons stop after first day. last_fired
  was keyed by (hour, minute) only, so tomorrow's 09:00 looked identical
  to today's and got suppressed forever. Now keyed by full date+time. (#11)
- runner_bridge.py: tree_active subagent runs were force-killed at
  STALL_MAX_WARNINGS because the auto-cancel exemption only checked
  cpu_active. Now exempts tree_active when active children present. (#4)
- triggers/actions.py: append_timestamp on_conflict used second-resolution
  names so two requests in the same second clobbered each other (the
  feature is meant to PREVENT that). Now uses time.time_ns() with a
  collision probe loop. (#9)

P1 — Functional bugs in supporting features:
- runners/claude.py: _PLAN_EXIT_APPROVED was only set in interactive
  approval. Auto-approve drain (auto permission mode + post-discuss
  approval) skipped the bookkeeping, defeating #283 in those flows. (#5)
- telegram/parsing.py: offset was persisted before yield, so a crash
  between yield and consumer could record an unprocessed update as done.
  Now persists after yield. (#8)
- triggers/rate_limit.py: per-hit warning logs flooded structured output
  and fed the issue watcher. Dropped to debug. (#16)
- telegram/commands/config.py: reasoning levels weren't validated against
  engine support — manual callback_data could persist e.g. `max` on
  Codex. Defensive validation added. (#20)
- telegram/bridge.py: docstring claimed chat_ids was hot-reloadable but
  it's never sourced from settings. Corrected docstring (#6).

P2 — Doc/display quality:
- triggers/describe.py: invalid DOW values were normalised via % 7 (8 →
  Mon); stepped patterns like */2 in dom/mon were rendered as "daily"
  due to substring check. Both now fall back to raw schedule. (#13, #14)
- config.py: rs:max action had no toast entry. Added. (#19)
- README.md: access matrix overstated isolation, omitted /file put,
  outbox, and webhook file_write/http_forward actions. Now accurate. (#3)
- docs/reference/integration-testing.md: bot ID was reused as DM chat
  ID. Clarified that DM target is the user's chat ID. (#1)
- docs/reference/specification.md: header said v0.35.1 but body said
  "Untether v0.35.0 specifies:". Synced to v0.35.1. (#17)
- tests/test_claude_control.py: cleanup fixture only ran post-yield.
  Now runs pre- and post-yield. (#18)

Skipped (false positives or out of scope):
- #2 (version bump suggestion): user decision, not auto-applicable.
- #7 (restart-only keys hot-reloaded): false positive — update_from is
  selective and only touches hot fields.
- #12 (cron snapshot): false positive — remove_cron replaces the list,
  doesn't mutate in place.

Deferred to follow-up issues:
- #10 (Content-Type override on http_forward raw bodies)
- #15 (duplicate cron ID/webhook path validation)

Test additions:
- test_trigger_cron.py: regression test for daily cron firing on
  consecutive days (covers #11)
- test_describe_cron.py: 6 regression tests for #13/#14 fallback paths
- test_config_command.py: split per-engine reasoning level test +
  regression test for codex/rs:max rejection (covers #20)
- test_claude_control.py: clarified pre/post-yield cleanup (covers #18)

Verified: 2172 tests pass (was 2164, +8 new), 81.51% coverage,
ruff format/lint clean, build successful.

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
@nathanschram
Copy link
Copy Markdown
Member Author

CodeRabbit (@coderabbitai) review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 15, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@nathanschram
Copy link
Copy Markdown
Member Author

CodeRabbit (@coderabbitai) full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 15, 2026

✅ Actions performed

Full review triggered.

@nathanschram
Copy link
Copy Markdown
Member Author

CodeRabbit findings status — all addressed

CodeRabbit posted 16 inline comments + 4 outside-diff comments on this PR at 05:00 UTC. They have been triaged and addressed in commit b979fec (originally PR #311, squash-merged into dev at 05:39 UTC and now part of this PR's diff).

Fixed (15)

P0 — Critical functional regressions:

P1 — Functional bugs:

P2 — Doc/display:

Skipped — false positives (verified against actual code, 3)

Deferred to follow-up issues (2)

Why no fresh CodeRabbit review on this PR

I tried @coderabbitai review (rate limited) and @coderabbitai full review (acknowledged at 05:57 UTC but no new review posted in 15+ min). CodeRabbit's incremental review system doesn't re-review files it has already commented on, even with full review. The 16 inline comments on this PR are from the 05:00 review against pre-fix commits and remain visible in the GitHub UI — please cross-reference each against the table above when reviewing.

Verification

Ready for your review.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 15, 2026

Tip

For best results, initiate chat on the files or code changes.

@nathanschram: Thank you for the thorough triage. I've reviewed your status report against the code context and can confirm the following:

False positive validations — confirmed ✅

  • Crash on macOS: FileNotFoundError for Claude OAuth credentials #7 (bridge.py restart-only keys): The AI summary of bridge.py confirms update_from() mutates only the hot-reloadable fields (show_resume_line, voice transcription fields, coalescing/debounce floats, allowed_user_ids, files). Architectural fields (bot_token, chat_id, session_mode, topics, message_overflow) are correctly excluded. False positive confirmed.
  • test_doctor_voice_checks fails due to env var leak from pydantic_settings #12 (manager.py iteration safety): The AI summary explicitly states remove_cron() "removes a cron by ID by replacing _crons with a new list." No in-place mutation — the same atomic replacement strategy used in update(). False positive confirmed.

Fixes — all P0/P1 issues acknowledged ✅

The three P0 fixes are particularly important: the last_fired date-keying fix for daily/weekly crons, the tree_active exemption in the stall monitor, and the time.time_ns() collision probe for append_timestamp. These are the right fixes with regression tests to prevent regressions.

Deferred issues ✅

#312 (Content-Type passthrough for http_forward) and #313 (duplicate cron ID/webhook path validation) are appropriately scoped as follow-up work — neither is a blocker for this release.

I'll resolve my open comments and approve the PR.

[resolve]
[approve]


🧠 Learnings used
Learnt from: CR
Repo: littlebearapps/untether PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-31T06:38:04.537Z
Learning: Never skip staging for minor/major releases; always dogfood TestPyPI rc on `hetz_lba1_bot` for 1+ week before PyPI release

Learnt from: CR
Repo: littlebearapps/untether PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-31T06:38:04.537Z
Learning: Applies to .github/workflows/*.yml : Use PyPI trusted publishing with OIDC for automated release deploys; require Nathan's approval in GitHub Actions UI before PyPI publish

Learnt from: CR
Repo: littlebearapps/untether PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-31T06:38:04.537Z
Learning: Auto-approve dependency updates via dependabot for patch/minor Python deps; flag major bumps for manual review

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 20

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/untether/telegram/commands/config.py (1)

1527-1563: ⚠️ Potential issue | 🟡 Minor

Validate cu:*_* callbacks before indexing the action map.

This branch accepts any callback containing _, then does {"on": ..., "off": ..., "clr": ...}[act]. A malformed callback such as config:cu:ac_bad will throw KeyError instead of safely returning to the page.

Possible fix
     if action and "_" in action:
         prefix, act = action.split("_", 1)
+        valid_acts = {"on": True, "off": False, "clr": None}
+        if act not in valid_acts:
+            logger.warning(
+                "config.cost_usage.invalid_action",
+                chat_id=chat_id,
+                engine=current_engine,
+                action=action,
+            )
+            await _page_cost_usage(ctx)
+            return
         current = await prefs.get_engine_override(chat_id, current_engine)
         ac_val = current.show_api_cost if current else None
         su_val = current.show_subscription_usage if current else None
         bg_val = current.budget_enabled if current else None
         bc_val = current.budget_auto_cancel if current else None
 
         if prefix == "ac" and has_api_cost:
-            ac_val = {"on": True, "off": False, "clr": None}[act]
+            ac_val = valid_acts[act]
             logger.info("config.api_cost.set", chat_id=chat_id, value=ac_val)
         elif prefix == "su" and has_sub_usage:
-            su_val = {"on": True, "off": False, "clr": None}[act]
+            su_val = valid_acts[act]
             logger.info("config.subscription_usage.set", chat_id=chat_id, value=su_val)
         elif prefix == "bg":
-            bg_val = {"on": True, "off": False, "clr": None}[act]
+            bg_val = valid_acts[act]
             logger.info("config.budget_enabled.set", chat_id=chat_id, value=bg_val)
         elif prefix == "bc":
-            bc_val = {"on": True, "off": False, "clr": None}[act]
+            bc_val = valid_acts[act]
             logger.info("config.budget_auto_cancel.set", chat_id=chat_id, value=bc_val)
+        else:
+            logger.warning(
+                "config.cost_usage.invalid_prefix",
+                chat_id=chat_id,
+                engine=current_engine,
+                action=action,
+            )
+            await _page_cost_usage(ctx)
+            return
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/untether/telegram/commands/config.py` around lines 1527 - 1563, The
branch that handles callbacks with an underscore splits action into prefix and
act and blindly indexes {"on","off","clr"}[act], which raises KeyError for
malformed acts (e.g., "ac_bad"); validate act against the allowed set before
indexing and bail out (or return to the page) on invalid values. Update the
block where action is split (the variables prefix and act) to check act in
("on","off","clr") and only map to booleans when valid; if invalid, log a
warning (using logger) and call the same safe exit path (await
_page_cost_usage(ctx) and return) instead of proceeding to build EngineOverrides
and calling prefs.set_engine_override. Ensure you reference the current
variables (action, prefix, act), the mapping uses only validated keys, and keep
the subsequent use of EngineOverrides and prefs.set_engine_override unchanged
when act is valid.
♻️ Duplicate comments (1)
pyproject.toml (1)

5-5: ⚠️ Potential issue | 🟠 Major

Use a minor bump for this feature release.

Line 5 uses a patch bump, but Line 81 adds a new command backend (at), which is a feature-level change and should be released as a minor version.

🔧 Proposed fix
-version = "0.35.1"
+version = "0.36.0"
As per coding guidelines, "Version bumping must follow semantic versioning: patch for bug fixes/schema additions/dependency updates, minor for new features/commands/engines/config, major for breaking changes".

Also applies to: 81-81

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyproject.toml` at line 5, The version in pyproject.toml is currently a patch
bump ("0.35.1") but a new command backend "at" was added (feature-level change),
so update the version to a minor bump (e.g., "0.36.0") in pyproject.toml; locate
the version key in the file (version = "...") and replace the patch-level value
with the next minor version to reflect the new "at" command addition.
🧹 Nitpick comments (10)
.claude/skills/untether-architecture/SKILL.md (1)

283-283: Clarify SHA1 as legacy and prefer SHA256 in webhook auth docs.

Given this is an architecture/security-facing doc, add a short note that HMAC-SHA256 is preferred and SHA1 is legacy/compat-only (if still supported).

As per coding guidelines "Maintain cross-file consistency, path verification, version accuracy, and command accuracy in AI context files".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.claude/skills/untether-architecture/SKILL.md at line 283, Update the
webhook auth documentation near run_webhook_server() and the
[[triggers.webhooks]] section to mark SHA1 as legacy/compat-only and state
HMAC-SHA256 as the preferred algorithm; specifically, add a concise note that
HMAC-SHA256 should be used for new integrations, that SHA1 support (if present)
exists only for backward compatibility, and recommend migrating SHA1 users to
SHA256 while keeping wording consistent with other docs and path/version
mentions in this file.
src/untether/context.py (1)

10-13: Consider replacing free-form provenance with structured fields.

trigger_source: str | None (e.g. "cron:daily-review") is easy to misuse and pushes string parsing downstream. Consider a typed split like trigger_kind + trigger_id for safer handling.

♻️ Proposed refactor
 `@dataclass`(frozen=True, slots=True)
 class RunContext:
     project: str | None = None
     branch: str | None = None
-    # rc4 (`#271`): trigger_source is set when a run is initiated by a cron
-    # or webhook (e.g. "cron:daily-review", "webhook:github-push") so the
-    # Telegram meta footer can show the provenance.
-    trigger_source: str | None = None
+    # Trigger provenance for footer/telemetry.
+    trigger_kind: str | None = None   # e.g. "cron" | "webhook"
+    trigger_id: str | None = None     # e.g. "daily-review"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/untether/context.py` around lines 10 - 13, The current free-form field
trigger_source on the Context model should be replaced with structured fields to
avoid downstream string parsing; add two fields (e.g., trigger_kind:
Literal['cron','webhook','manual',...] | str | None and trigger_id: str | None)
in place of trigger_source, update the Context dataclass/constructor (and any
factory/parse methods) to accept and populate these two fields, and change all
usages (serialization, logging, Telegram meta footer generation, tests) that
read trigger_source to use trigger_kind and trigger_id (or compose them only
where a combined string is explicitly needed). Ensure backward-compatible
parsing of existing "kind:id" style strings where necessary in any
deserialization code.
docs/tutorials/install.md (1)

441-441: Optional: smooth out repetitive sentence starts.

Line 441 begins consecutive sentences with “To …”; minor rewording would read cleaner.

✏️ Suggested wording
-To uninstall completely (CLI, config, and state), see the [uninstall guide](../how-to/uninstall.md). To learn more about updates, see [Update Untether](../how-to/update.md).
+For complete removal (CLI, config, and state), see the [uninstall guide](../how-to/uninstall.md). For update details, see [Update Untether](../how-to/update.md).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/tutorials/install.md` at line 441, The two consecutive sentences
starting with "To ..." in the paragraph ("To uninstall completely (CLI, config,
and state), see the [uninstall guide](../how-to/uninstall.md). To learn more
about updates, see [Update Untether](../how-to/update.md).") are repetitive;
reword the second sentence to avoid repeating "To" (for example start with
"Learn more about updates in [Update Untether](...)" or "For update information,
see [Update Untether](...)") so the flow reads smoother while keeping the same
links and meaning.
.claude/rules/runner-development.md (1)

37-40: Scope mismatch between rule content and applies_to metadata.

Line 39 defines requirements for src/untether/runner_bridge.py and src/untether/progress.py, but the file scope (Line 2) excludes both paths. Please either expand applies_to or move these requirements to a rule file that targets those modules.

Suggested metadata fix
-applies_to: "src/untether/runners/**,src/untether/runner.py"
+applies_to: "src/untether/runners/**,src/untether/runner.py,src/untether/runner_bridge.py,src/untether/progress.py"

As per coding guidelines, AI context files must maintain path verification and cross-file consistency ({CLAUDE.md,AGENTS.md,.claude/**/*.md}: Maintain cross-file consistency, path verification, version accuracy, and command accuracy in AI context files).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.claude/rules/runner-development.md around lines 37 - 40, The rule's content
references src/untether/runner_bridge.py and src/untether/progress.py (symbols:
RunContext.trigger_source, runner_bridge.handle_message,
ProgressTracker.note_event, StartedEvent.meta) but the rule's applies_to scope
excludes those paths; fix by either expanding the rule's applies_to metadata to
include src/untether/** (or specifically src/untether/runner_bridge.py and
src/untether/progress.py) so the rule applies to those files, or move the
paragraphs about RunContext.trigger_source, runner_bridge.handle_message,
ProgressTracker.note_event, and StartedEvent.meta into a separate rule file
whose applies_to includes those module paths to restore cross-file consistency.
src/untether/utils/proc_diag.py (1)

167-169: Minor: descendants includes direct children redundantly.

_find_descendants(pid) already includes direct children (via _find_children internally), and children is collected separately on line 167. This isn't a bug (children is used for child_pids field, descendants for CPU aggregation), but the naming could cause confusion during future maintenance.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/untether/utils/proc_diag.py` around lines 167 - 169, The code collects
both direct children via _find_children(pid) into children and all descendant
PIDs via _find_descendants(pid) into descendants which is confusing because
_find_descendants already includes direct children; rename the variable
descendants to all_descendants (or similar) and update its usages (e.g., passed
to _collect_tree_cpu) and related comment/field naming (child_pids remains
as-is) so the intent is explicit and future maintainers don't mistake redundancy
between children and descendants.
tests/test_trigger_server.py (1)

247-272: Use anyio primitives in these new async tests.

These tests use asyncio.sleep(), asyncio.gather(), and asyncio.Event() (lines 272, 534, 558–559), making them asyncio-specific despite being marked for pytest.mark.anyio. Replace with anyio equivalents so they remain backend-agnostic.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_trigger_server.py` around lines 247 - 272, Replace
asyncio-specific primitives with anyio equivalents in the async tests: remove
asyncio imports and use anyio.sleep instead of asyncio.sleep (e.g., in
test_dispatch_errors_dont_fail_http_response), use anyio.Event in place of
asyncio.Event, and replace asyncio.gather usage with an anyio.create_task_group
with start_soon (or anyio.gather if available in your anyio version) to remain
backend-agnostic; update imports and references for ExplodingDispatcher tests
and any other tests that used asyncio.*.
src/untether/triggers/actions.py (2)

268-285: Consider moving anyio import to module level.

The import anyio statements inside the retry loop (lines 269, 283) work but are inefficient. Moving to the top-level imports would be cleaner.

♻️ Suggested refactor
 import tempfile
 import time
 from pathlib import Path, PurePosixPath
 from typing import Any

+import anyio
 import httpx

Then remove the inline imports on lines 269 and 283.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/untether/triggers/actions.py` around lines 268 - 285, Move the inline
imports of anyio out of the retry loop by adding "import anyio" to the
module-level imports in src/untether/triggers/actions.py and remove the two
inline "import anyio" statements inside the http_forward retry logic (the block
handling exceptions for httpx.ConnectError and httpx.TimeoutException and the
earlier sleep call). This keeps the retry/backoff calls (await anyio.sleep(...))
unchanged but avoids repeated imports; update any linter imports ordering if
needed.

51-65: Comment is misleading about symlink protection.

Lines 60-61 state the check prevents "escaping via symlinks," but the actual check on line 62 only examines raw_path for .. segments. This doesn't prevent symlink-based directory escapes where resolved points outside the intended directory.

Consider clarifying the comment or adding actual symlink validation if this is a security requirement.

📝 Suggested comment fix
-    # Block traversal via symlinks: the resolved path must start with
-    # the expanded parent to prevent escaping.
+    # Block explicit traversal sequences in the raw path.
+    # Note: This does not prevent symlink-based escapes.
     if ".." in Path(raw_path).parts:
         return None
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/untether/triggers/actions.py` around lines 51 - 65, The docstring/comment
for _resolve_file_path is misleading: it claims to block "traversal via
symlinks" but the current check only inspects raw_path.parts for ".."; update
either the comment or implement real symlink validation. Fix option A: change
the comment/docstring to remove the symlink claim and accurately state that only
literal ".." segments in raw_path are rejected. Fix option B (preferred if
security requires it): after computing expanded = Path(raw_path).expanduser()
and resolved = expanded.resolve(strict=False), ensure resolved is inside the
intended base (e.g., require resolved.is_relative_to(expanded.parent) or compare
resolved.resolve().parts prefix against expanded.parent.resolve().parts) and
return None if resolved falls outside; keep the existing ".." check on raw_path
as well. Use the function name _resolve_file_path and variables raw_path,
expanded, and resolved to locate where to change.
src/untether/triggers/settings.py (1)

171-181: Consider extracting timezone validation to reduce duplication.

The timezone validation logic is duplicated between CronConfig._validate_timezone (lines 143-153) and TriggersSettings._validate_default_timezone (lines 171-181).

♻️ Optional refactor
def _validate_iana_timezone(v: str | None, field_name: str = "timezone") -> str | None:
    """Validate an IANA timezone string."""
    if v is not None:
        try:
            ZoneInfo(v)
        except (ZoneInfoNotFoundError, KeyError):
            raise ValueError(
                f"unknown {field_name} {v!r}; use IANA names like 'Australia/Melbourne'"
            ) from None
    return v

Then use @field_validator with _validate_iana_timezone(v) in both models.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/untether/triggers/settings.py` around lines 171 - 181, Extract the
duplicated IANA timezone validation into a shared helper (e.g.,
_validate_iana_timezone) and call it from both CronConfig._validate_timezone and
TriggersSettings._validate_default_timezone; implement
_validate_iana_timezone(v: str | None, field_name: str = "timezone") to try
ZoneInfo(v) and raise the same ValueError message on
ZoneInfoNotFoundError/KeyError (including the field_name in the message), then
have each `@field_validator` call that helper so the validation logic is
centralized.
tests/test_trigger_actions.py (1)

47-50: Broaden the tilde expansion assertion for cross-platform compatibility.

The assertion on line 50 only checks for /home or /root, but macOS uses /Users/ and some systems may have other home directory locations.

♻️ Suggested fix
     def test_tilde_expansion(self) -> None:
         result = _resolve_file_path("~/data/output.json")
         assert result is not None
-        assert str(result).startswith("/home") or str(result).startswith("/root")
+        # Verify tilde was expanded to an absolute path (varies by OS)
+        assert result.is_absolute()
+        assert "~" not in str(result)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_trigger_actions.py` around lines 47 - 50, The test
test_tilde_expansion is too narrowly asserting that the expanded path starts
with "/home" or "/root"; update it to assert against the actual current user's
home directory by using os.path.expanduser("~") or pathlib.Path.home(): call
_resolve_file_path("~/data/output.json"), ensure the result is not None and that
str(result).startswith(str(Path.home())) (or compare resolved absolute paths) so
the test passes on macOS and other platforms; reference the test function
test_tilde_expansion and helper _resolve_file_path when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.claude/skills/untether-architecture/SKILL.md:
- Line 287: The doc line omits the non-agent webhook path: update the sentence
to include TriggerDispatcher.dispatch_action() alongside dispatch_cron() and
dispatch_webhook(), stating that non-agent webhook actions also flow into
dispatch_action() → send the Telegram notification (⏰/⚡) → call run_job() with
the prompt, threading under the notification; ensure you reference
TriggerDispatcher.dispatch_action, dispatch_webhook, dispatch_cron and run_job
so cross-file/path verification and version/command accuracy are preserved.

In `@CHANGELOG.md`:
- Around line 3-105: Under the "## v0.35.1 (2026-04-15)" entry add the missing
release-note subsections "### breaking" and "### tests" (keep the existing
"fixes", "changes", and "docs" blocks intact); if there are no breaking changes
include an explicit empty "### breaking" header with a one-line "No breaking
changes." statement, and add a short "### tests" bullet summarising new/updated
test coverage (e.g. mention "13 new tests in test_trigger_manager.py" and
overall test audit) so the changelog conforms to the required
fixes/changes/breaking/docs/tests structure.

In `@docs/how-to/schedule-tasks.md`:
- Around line 9-13: The fenced code block that currently starts with ``` and
contains the lines "/at 30m Check the build", "/at 2h Review the PR feedback",
"/at 60s Say hello" is missing a language tag; update the opening fence to
include a language (for example ```text) so the block is explicitly labeled and
markdownlint MD040 is satisfied.

In `@docs/how-to/troubleshooting.md`:
- Around line 252-253: Update the misleading `/cancel` description to clearly
state that invoking `/cancel` will cancel pending `/at` jobs in the chat (not
just inspect them) and warn users to avoid accidental destructive use;
specifically edit the line that mentions "Use `/cancel` to see how many pending
delays exist" to something like "Use `/cancel` to cancel all pending `/at` jobs
in this chat (this will remove scheduled jobs) — use with caution; to only view
pending jobs use `/list`" and ensure `/cancel` and `/at` are referenced so
readers understand the destructive effect.

In `@docs/how-to/update.md`:
- Around line 19-27: Replace the fenced shell code blocks with indented code
blocks to satisfy markdownlint MD046: remove the ```sh fences and indent the
command lines for both instances (the line containing "untether --version" and
the line containing "systemctl --user restart untether") by four spaces each,
keeping surrounding text and blank lines intact so the commands render as
top-level indented code blocks.

In `@docs/how-to/webhooks-and-cron.md`:
- Around line 180-188: The example places prompt_template inside the
[triggers.crons.fetch] subtable so it becomes fetch.prompt_template; remove the
prompt_template line from inside the [triggers.crons.fetch] block and re-add it
after that block under the parent table (e.g., as prompt_template under
[triggers.crons]) so the key is triggers.crons.prompt_template rather than
triggers.crons.fetch.prompt_template; update the snippet accordingly referencing
the existing triggers.crons.fetch table and the prompt_template key.

In `@docs/reference/changelog.md`:
- Line 38: Update the hard-coded GitHub URL in docs/reference/changelog.md to a
stable relative or default-branch link: replace the current
"https://github.com/littlebearapps/untether/blob/dev/CHANGELOG.md#v0351-2026-04-14"
with a repository-relative link like "CHANGELOG.md#v0351-2026-04-14" (or use the
default branch explicitly, e.g., "/blob/main/CHANGELOG.md#v0351-2026-04-14") so
the release reference in the changelog remains stable; modify the link text on
the single line containing that URL.

In `@docs/reference/integration-testing.md`:
- Around line 212-223: The markdown table has a 3-column header but rows R1–R10
contain extra unescaped pipe characters in the "Expected" column causing MD056;
edit the table rows (R1, R2, ..., R10) in the integration-testing table so each
row has exactly three cells by either escaping any literal "|" in the Expected
text (replace with "\|") or replace those pipe separators with semicolons or
combine into a single sentence, ensuring the header columns "#", "Scenario", and
"Expected" remain aligned.

In `@docs/reference/runners/claude/runner.md`:
- Line 182: Update the docs line that describes the `--effort <level>` flag to
note that the `max` effort level is only supported by the Claude Opus 4.6 model;
edit the bullet "add `--effort <level>` if a reasoning override is set
(low/medium/high/max)" (reference the `--effort` mention in runner.md) to
include a parenthetical or sentence like "(note: `max` is supported only with
Claude Opus 4.6)" so readers are not misled about availability on earlier
models.

In `@docs/reference/triggers/triggers.md`:
- Around line 432-440: The prompt_template is placed inside the nested trigger
table [triggers.crons.fetch], making it appear as a field of the HTTP fetch
trigger; move prompt_template out of [triggers.crons.fetch] into the parent cron
table for that cron (so prompt_template is a property of the cron, not of the
fetch trigger). Locate the prompt_template line and relocate it from the
[triggers.crons.fetch] block to the enclosing cron table for the "fetch" cron so
documentation shows prompt_template as a cron-level field.

In `@src/untether/runner_bridge.py`:
- Around line 1183-1194: The function _has_active_children currently treats high
diag.tcp_total as equivalent to having child processes; change it so it only
returns True when diag.child_pids is non-empty (i.e., preserve the child PID
check: if diag.child_pids: return True) and remove the tcp_total >
self._TCP_ACTIVE_THRESHOLD condition from this method; if you want to keep the
TCP heuristic, create a separate method (e.g., _has_elevated_tcp or
_tcp_activity_reason) that checks diag.tcp_total against _TCP_ACTIVE_THRESHOLD
and use that to generate a distinct reason/message instead of routing TCP
activity through the active-children branch.

In `@src/untether/telegram/at_scheduler.py`:
- Around line 146-149: The code currently inserts entry into _PENDING before
scheduling the task; if _TASK_GROUP.start_soon(_run_delayed, token) raises the
entry remains and counts against the per-chat cap. Change the order and/or add
exception handling around _TASK_GROUP.start_soon: either schedule the task first
and only set _PENDING[token] = entry after start_soon succeeds, or wrap
start_soon in try/except and on failure remove _PENDING[token] (and re-raise the
exception). Refer to symbols _PENDING, _TASK_GROUP.start_soon, _run_delayed,
token and entry when applying the fix so the orphaned entry is always cleaned up
if scheduling fails.

In `@src/untether/telegram/commands/at.py`:
- Around line 42-44: The helper _format_delay currently drops seconds for
durations >=1 hour; update _format_delay to compute hours, minutes, and seconds
(use divmod twice: hours, remainder = divmod(delay_s, 3600); minutes, seconds =
divmod(remainder, 60)) and return a string that includes seconds when non-zero
(e.g., "1h 1m 5s" or "1h 0m 5s" depending on desired precision) while keeping
the existing behavior for shorter durations—ensure the function name
_format_delay is modified accordingly and all callsites still receive a string.

In `@src/untether/telegram/commands/cancel.py`:
- Around line 117-121: The current guard in cancel handling (in
src/untether/telegram/commands/cancel.py around the condition referencing
cfg.allowed_user_ids and query.sender_id) treats a missing sender_id as allowed;
change the logic so that when cfg.allowed_user_ids is set, a None sender_id is
treated as unauthorized. Concretely, update the if condition used in the
cancellation checks (the block using cfg.allowed_user_ids and query.sender_id)
to deny when query.sender_id is None OR query.sender_id not in
cfg.allowed_user_ids; apply the same change to the similar guard at the other
location mentioned (the 127-131 block) so both places consistently implement
deny-by-default for missing sender IDs.

In `@src/untether/telegram/engine_overrides.py`:
- Around line 234-242: In the engine == "claude" branch, harden reading and
returning the user-controlled effortLevel: catch UnicodeDecodeError from
settings_path.read_text() in addition to the existing exceptions, and only
return the value if it's a str and present in
allowed_reasoning_levels("claude"); otherwise return None. Locate the
settings_path / read_text() call and the variable level in that block and
replace the current isinstance(level, str) && non-empty check with membership
validation against allowed_reasoning_levels("claude") while keeping json.loads()
error handling.

In `@src/untether/triggers/fetch.py`:
- Around line 141-160: The symlink guard is ineffective because
resolve(strict=False) is called before checking path.is_symlink(), so symlinks
are collapsed and bypass the check; change the order so you construct a raw Path
from fetch.file_path and call is_symlink() on that raw path before calling
expanduser()/resolve(), returning the same rejection message if true; keep the
existing deny-glob check using _deny_reason and subsequent resolved path logic
unchanged, but ensure you reference Path(fetch.file_path) for the traversal,
symlink and deny checks and only call expanduser().resolve() after those checks
pass.
- Around line 92-118: The current code buffers resp.content and enforces
max_bytes only after reading everything; change the HTTP call to use
httpx.AsyncClient.stream(...) (or client.request(..., stream=True)) and iterate
the response body in chunks (e.g., aiter_bytes) to accumulate up to max_bytes
while aborting the stream and returning the "too large" warning if the
accumulated size exceeds max_bytes; also pre-check Content-Length header on
resp.headers to short-circuit if it declares a size > max_bytes, preserve
existing behavior for status-code errors (the logger call using rendered_url and
resp.status_code stays the same), and still return the full bytes only if the
streamed total remains <= max_bytes; keep usage of rendered_url,
rendered_headers, method, fetch.body, render_template_fields, timeout, logger,
and max_bytes to locate where to change.

In `@src/untether/triggers/server.py`:
- Around line 94-121: Currently the code renders webhook.file_destination inside
the multipart loop as soon as a file part is encountered, which can miss form
fields that appear later; change the logic in the async reader loop that handles
part (where raw_name/safe_name, file_data, form_fields["file"] are set) to not
call render_template_fields there, but instead collect file metadata and bytes
(e.g., keep safe_name and file_data in a temp structure or keep
form_fields["file"] populated) and finish parsing all parts; after the async for
part in reader loop completes, if webhook.file_destination is set then call
render_template_fields(dest_template, template_ctx) using the fully-populated
form_fields and then perform the final destination path build and file save.
Ensure you update references to render_template_fields,
webhook.file_destination, form_fields, and the file write logic so rendering is
deferred until after parsing completes.
- Around line 203-206: Replace the ad-hoc asyncio.create_task usage and
_dispatch_tasks bookkeeping in the webhook dispatch logic with an anyio
TaskGroup: create an anyio.create_task_group() (or self._task_group managed on
startup/shutdown) and start webhook handler coroutines within that group instead
of asyncio.create_task; remove the done-callback and the _dispatch_tasks set,
and ensure the group's cancel/finish is invoked during runner.cleanup() (or
server shutdown) so in-flight dispatch handlers are cancelled and awaited
automatically. Target the code that spawns webhook handler coroutines (the
locations currently calling asyncio.create_task to run the dispatch/webhook
handler) and the shutdown path that calls runner.cleanup() to wire the task
group lifecycle.

In `@tests/test_trigger_meta_line.py`:
- Around line 9-26: Update the tests to pass the raw trigger source strings
(e.g., "cron:daily-review" and "webhook:github-push") into format_meta_line
instead of already-prefixed strings with icons; change assertions in
test_trigger_only, test_trigger_with_model and test_trigger_webhook to verify
that format_meta_line prepends the correct icon/prefix to the raw source (e.g.,
resulting string contains the expected icon + space + the raw source), and in
test_trigger_with_model ensure the computed part (the full "icon + space +
trigger" fragment) appears as the last element when splitting by " · " and that
the model name (e.g., "opus") appears earlier.

---

Outside diff comments:
In `@src/untether/telegram/commands/config.py`:
- Around line 1527-1563: The branch that handles callbacks with an underscore
splits action into prefix and act and blindly indexes {"on","off","clr"}[act],
which raises KeyError for malformed acts (e.g., "ac_bad"); validate act against
the allowed set before indexing and bail out (or return to the page) on invalid
values. Update the block where action is split (the variables prefix and act) to
check act in ("on","off","clr") and only map to booleans when valid; if invalid,
log a warning (using logger) and call the same safe exit path (await
_page_cost_usage(ctx) and return) instead of proceeding to build EngineOverrides
and calling prefs.set_engine_override. Ensure you reference the current
variables (action, prefix, act), the mapping uses only validated keys, and keep
the subsequent use of EngineOverrides and prefs.set_engine_override unchanged
when act is valid.

---

Duplicate comments:
In `@pyproject.toml`:
- Line 5: The version in pyproject.toml is currently a patch bump ("0.35.1") but
a new command backend "at" was added (feature-level change), so update the
version to a minor bump (e.g., "0.36.0") in pyproject.toml; locate the version
key in the file (version = "...") and replace the patch-level value with the
next minor version to reflect the new "at" command addition.

---

Nitpick comments:
In @.claude/rules/runner-development.md:
- Around line 37-40: The rule's content references src/untether/runner_bridge.py
and src/untether/progress.py (symbols: RunContext.trigger_source,
runner_bridge.handle_message, ProgressTracker.note_event, StartedEvent.meta) but
the rule's applies_to scope excludes those paths; fix by either expanding the
rule's applies_to metadata to include src/untether/** (or specifically
src/untether/runner_bridge.py and src/untether/progress.py) so the rule applies
to those files, or move the paragraphs about RunContext.trigger_source,
runner_bridge.handle_message, ProgressTracker.note_event, and StartedEvent.meta
into a separate rule file whose applies_to includes those module paths to
restore cross-file consistency.

In @.claude/skills/untether-architecture/SKILL.md:
- Line 283: Update the webhook auth documentation near run_webhook_server() and
the [[triggers.webhooks]] section to mark SHA1 as legacy/compat-only and state
HMAC-SHA256 as the preferred algorithm; specifically, add a concise note that
HMAC-SHA256 should be used for new integrations, that SHA1 support (if present)
exists only for backward compatibility, and recommend migrating SHA1 users to
SHA256 while keeping wording consistent with other docs and path/version
mentions in this file.

In `@docs/tutorials/install.md`:
- Line 441: The two consecutive sentences starting with "To ..." in the
paragraph ("To uninstall completely (CLI, config, and state), see the [uninstall
guide](../how-to/uninstall.md). To learn more about updates, see [Update
Untether](../how-to/update.md).") are repetitive; reword the second sentence to
avoid repeating "To" (for example start with "Learn more about updates in
[Update Untether](...)" or "For update information, see [Update Untether](...)")
so the flow reads smoother while keeping the same links and meaning.

In `@src/untether/context.py`:
- Around line 10-13: The current free-form field trigger_source on the Context
model should be replaced with structured fields to avoid downstream string
parsing; add two fields (e.g., trigger_kind:
Literal['cron','webhook','manual',...] | str | None and trigger_id: str | None)
in place of trigger_source, update the Context dataclass/constructor (and any
factory/parse methods) to accept and populate these two fields, and change all
usages (serialization, logging, Telegram meta footer generation, tests) that
read trigger_source to use trigger_kind and trigger_id (or compose them only
where a combined string is explicitly needed). Ensure backward-compatible
parsing of existing "kind:id" style strings where necessary in any
deserialization code.

In `@src/untether/triggers/actions.py`:
- Around line 268-285: Move the inline imports of anyio out of the retry loop by
adding "import anyio" to the module-level imports in
src/untether/triggers/actions.py and remove the two inline "import anyio"
statements inside the http_forward retry logic (the block handling exceptions
for httpx.ConnectError and httpx.TimeoutException and the earlier sleep call).
This keeps the retry/backoff calls (await anyio.sleep(...)) unchanged but avoids
repeated imports; update any linter imports ordering if needed.
- Around line 51-65: The docstring/comment for _resolve_file_path is misleading:
it claims to block "traversal via symlinks" but the current check only inspects
raw_path.parts for ".."; update either the comment or implement real symlink
validation. Fix option A: change the comment/docstring to remove the symlink
claim and accurately state that only literal ".." segments in raw_path are
rejected. Fix option B (preferred if security requires it): after computing
expanded = Path(raw_path).expanduser() and resolved =
expanded.resolve(strict=False), ensure resolved is inside the intended base
(e.g., require resolved.is_relative_to(expanded.parent) or compare
resolved.resolve().parts prefix against expanded.parent.resolve().parts) and
return None if resolved falls outside; keep the existing ".." check on raw_path
as well. Use the function name _resolve_file_path and variables raw_path,
expanded, and resolved to locate where to change.

In `@src/untether/triggers/settings.py`:
- Around line 171-181: Extract the duplicated IANA timezone validation into a
shared helper (e.g., _validate_iana_timezone) and call it from both
CronConfig._validate_timezone and TriggersSettings._validate_default_timezone;
implement _validate_iana_timezone(v: str | None, field_name: str = "timezone")
to try ZoneInfo(v) and raise the same ValueError message on
ZoneInfoNotFoundError/KeyError (including the field_name in the message), then
have each `@field_validator` call that helper so the validation logic is
centralized.

In `@src/untether/utils/proc_diag.py`:
- Around line 167-169: The code collects both direct children via
_find_children(pid) into children and all descendant PIDs via
_find_descendants(pid) into descendants which is confusing because
_find_descendants already includes direct children; rename the variable
descendants to all_descendants (or similar) and update its usages (e.g., passed
to _collect_tree_cpu) and related comment/field naming (child_pids remains
as-is) so the intent is explicit and future maintainers don't mistake redundancy
between children and descendants.

In `@tests/test_trigger_actions.py`:
- Around line 47-50: The test test_tilde_expansion is too narrowly asserting
that the expanded path starts with "/home" or "/root"; update it to assert
against the actual current user's home directory by using
os.path.expanduser("~") or pathlib.Path.home(): call
_resolve_file_path("~/data/output.json"), ensure the result is not None and that
str(result).startswith(str(Path.home())) (or compare resolved absolute paths) so
the test passes on macOS and other platforms; reference the test function
test_tilde_expansion and helper _resolve_file_path when making the change.

In `@tests/test_trigger_server.py`:
- Around line 247-272: Replace asyncio-specific primitives with anyio
equivalents in the async tests: remove asyncio imports and use anyio.sleep
instead of asyncio.sleep (e.g., in
test_dispatch_errors_dont_fail_http_response), use anyio.Event in place of
asyncio.Event, and replace asyncio.gather usage with an anyio.create_task_group
with start_soon (or anyio.gather if available in your anyio version) to remain
backend-agnostic; update imports and references for ExplodingDispatcher tests
and any other tests that used asyncio.*.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a491f96f-5c23-4679-91ae-5e9db662f354

📥 Commits

Reviewing files that changed from the base of the PR and between e5bff3c and b979fec.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (110)
  • .claude/rules/runner-development.md
  • .claude/rules/telegram-transport.md
  • .claude/rules/testing-conventions.md
  • .claude/skills/untether-architecture/SKILL.md
  • .github/workflows/ci.yml
  • .github/workflows/notify-website.yml
  • .github/workflows/release.yml
  • CHANGELOG.md
  • CLAUDE.md
  • README.md
  • contrib/untether.service
  • docs/explanation/architecture.md
  • docs/how-to/group-chat.md
  • docs/how-to/index.md
  • docs/how-to/inline-settings.md
  • docs/how-to/interactive-approval.md
  • docs/how-to/model-reasoning.md
  • docs/how-to/operations.md
  • docs/how-to/plan-mode.md
  • docs/how-to/schedule-tasks.md
  • docs/how-to/security.md
  • docs/how-to/troubleshooting.md
  • docs/how-to/uninstall.md
  • docs/how-to/update.md
  • docs/how-to/voice-notes.md
  • docs/how-to/webhooks-and-cron.md
  • docs/reference/changelog.md
  • docs/reference/commands-and-directives.md
  • docs/reference/config.md
  • docs/reference/dev-instance.md
  • docs/reference/glossary.md
  • docs/reference/integration-testing.md
  • docs/reference/runners/amp/runner.md
  • docs/reference/runners/claude/runner.md
  • docs/reference/runners/gemini/runner.md
  • docs/reference/specification.md
  • docs/reference/triggers/triggers.md
  • docs/tutorials/first-run.md
  • docs/tutorials/install.md
  • incoming/v0.35.1rc4-integration-test-plan.md
  • pyproject.toml
  • scripts/healthcheck.sh
  • src/untether/cli/run.py
  • src/untether/commands.py
  • src/untether/context.py
  • src/untether/markdown.py
  • src/untether/progress.py
  • src/untether/runner.py
  • src/untether/runner_bridge.py
  • src/untether/runners/amp.py
  • src/untether/runners/claude.py
  • src/untether/runners/codex.py
  • src/untether/runners/gemini.py
  • src/untether/runners/opencode.py
  • src/untether/runners/pi.py
  • src/untether/sdnotify.py
  • src/untether/settings.py
  • src/untether/telegram/at_scheduler.py
  • src/untether/telegram/bridge.py
  • src/untether/telegram/client_api.py
  • src/untether/telegram/commands/at.py
  • src/untether/telegram/commands/cancel.py
  • src/untether/telegram/commands/config.py
  • src/untether/telegram/commands/dispatch.py
  • src/untether/telegram/commands/ping.py
  • src/untether/telegram/commands/threads.py
  • src/untether/telegram/engine_overrides.py
  • src/untether/telegram/loop.py
  • src/untether/telegram/offset_persistence.py
  • src/untether/telegram/parsing.py
  • src/untether/telegram/topic_state.py
  • src/untether/triggers/__init__.py
  • src/untether/triggers/actions.py
  • src/untether/triggers/auth.py
  • src/untether/triggers/cron.py
  • src/untether/triggers/describe.py
  • src/untether/triggers/dispatcher.py
  • src/untether/triggers/fetch.py
  • src/untether/triggers/manager.py
  • src/untether/triggers/rate_limit.py
  • src/untether/triggers/server.py
  • src/untether/triggers/settings.py
  • src/untether/triggers/ssrf.py
  • src/untether/triggers/templating.py
  • src/untether/utils/proc_diag.py
  • tests/test_at_command.py
  • tests/test_bridge_config_reload.py
  • tests/test_build_args.py
  • tests/test_callback_dispatch.py
  • tests/test_claude_control.py
  • tests/test_config_command.py
  • tests/test_describe_cron.py
  • tests/test_exec_bridge.py
  • tests/test_offset_persistence.py
  • tests/test_ping_command.py
  • tests/test_proc_diag.py
  • tests/test_sdnotify.py
  • tests/test_telegram_bridge.py
  • tests/test_telegram_engine_overrides.py
  • tests/test_trigger_actions.py
  • tests/test_trigger_cron.py
  • tests/test_trigger_dispatcher.py
  • tests/test_trigger_fetch.py
  • tests/test_trigger_manager.py
  • tests/test_trigger_meta_line.py
  • tests/test_trigger_server.py
  • tests/test_trigger_settings.py
  • tests/test_trigger_ssrf.py
  • tests/test_trigger_templating.py
  • zensical.toml


### Dispatch

Both crons and webhooks feed into `TriggerDispatcher.dispatch_cron()`/`dispatch_webhook()` → sends a notification message to Telegram (`⏰`/`⚡`) → calls `run_job()` with the prompt, threading under the notification.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Dispatch flow docs are incomplete for webhook actions.

This line documents only dispatch_cron()/dispatch_webhook(), but the release scope also includes non-agent webhook actions (dispatch_action()). Please include that path to keep the architecture doc accurate.

As per coding guidelines "Maintain cross-file consistency, path verification, version accuracy, and command accuracy in AI context files".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.claude/skills/untether-architecture/SKILL.md at line 287, The doc line
omits the non-agent webhook path: update the sentence to include
TriggerDispatcher.dispatch_action() alongside dispatch_cron() and
dispatch_webhook(), stating that non-agent webhook actions also flow into
dispatch_action() → send the Telegram notification (⏰/⚡) → call run_job() with
the prompt, threading under the notification; ensure you reference
TriggerDispatcher.dispatch_action, dispatch_webhook, dispatch_cron and run_job
so cross-file/path verification and version/command accuracy are preserved.

Comment thread CHANGELOG.md
Comment on lines +3 to +105
## v0.35.1 (2026-04-15)

### fixes

- diff preview approval gate no longer blocks edits after a plan is approved — the `_discuss_approved` flag now short-circuits diff preview as well as `ExitPlanMode`, so once the user approves a plan outline the next `Edit`/`Write` runs without a second approval prompt [#283](https://github.com/littlebearapps/untether/issues/283)
- `scripts/healthcheck.sh` exits prematurely under `set -e` — `pass()`/`fail()` used `((var++))` which returns the pre-increment value, tripping `set -e` on the first call so only the first check ever ran and the script always exited 1. Also, the error-log count piped journalctl through `grep -c .`, which counted `-- No entries --` meta lines as matches, producing false-positive log-error counts on clean systems. Now uses explicit `var=$((var+1))` assignment and filters meta lines with `grep -vc '^-- '` [#302](https://github.com/littlebearapps/untether/issues/302)

- fix multipart webhooks returning HTTP 500 — `_process_webhook` pre-read the request body for size/auth/rate-limit checks, leaving the stream empty when `_parse_multipart` called `request.multipart()`. Now the multipart reader is constructed from the cached raw body, so multipart uploads work end-to-end; also short-circuits the post-parse raw-body write so the MIME envelope isn't duplicated at `file_path` alongside the extracted file at `file_destination` [#280](https://github.com/littlebearapps/untether/issues/280)
- fix webhook rate limiter never returning 429 — `_process_webhook` awaited the downstream dispatch (Telegram outbox send, `http_forward` network call, etc.) before returning 202, which capped request throughput at the dispatch rate (~1/sec for private Telegram chats) and meant the `TokenBucketLimiter` never saw a real burst. Dispatch is now fire-and-forget with exception logging, so the rate limiter drains the bucket correctly and a burst of 80 requests against `rate_limit = 60` now yields 60 × 202 + 20 × 429 [#281](https://github.com/littlebearapps/untether/issues/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](https://github.com/littlebearapps/untether/issues/192)
- also validate sender on cancel button callback — the cancel handler was routed directly, bypassing the dispatch validation
- **security:** escape release tag name in notify-website CI workflow — use `jq` for proper JSON encoding instead of direct interpolation, preventing JSON injection from crafted tag names [#193](https://github.com/littlebearapps/untether/issues/193)
- **security:** sanitise flag-like prompts in Gemini and AMP runners — prompts starting with `-` are space-prefixed to prevent CLI flag injection; moved `sanitize_prompt()` to base runner class for all engines [#194](https://github.com/littlebearapps/untether/issues/194)
- **security:** redact bot token from structured log URLs — `_redact_event_dict` now strips bot tokens embedded in Telegram API endpoint strings, preventing credential leakage to log files and aggregation systems [#190](https://github.com/littlebearapps/untether/issues/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](https://github.com/littlebearapps/untether/issues/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](https://github.com/littlebearapps/untether/issues/264)
- `/ping` uptime now resets on service restart — previously the module-level start time was cached across `/restart` commands; now `reset_uptime()` is called on each service start [#234](https://github.com/littlebearapps/untether/issues/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](https://github.com/littlebearapps/untether/issues/299)
- **systemd:** stop Untether being the preferred OOM victim — systemd user services inherit `OOMScoreAdjust=200` and `OOMPolicy=stop` defaults, which made Untether's engine subprocesses preferred earlyoom/kernel OOM killer targets ahead of CLI `claude` (`oom_score_adj=0`) and orphaned grandchildren actually consuming the RAM. `contrib/untether.service` now sets `OOMScoreAdjust=-100` (documents intent; the kernel clamps to the parent baseline for unprivileged users, typically 100) and `OOMPolicy=continue` (a single OOM-killed child no longer tears down the whole unit cgroup, which previously broke every live chat at once). Docs in `docs/reference/dev-instance.md` updated. Existing installs need to copy the unit file and `systemctl --user daemon-reload`; staging picks up the change on the next `scripts/staging.sh install` cycle [#275](https://github.com/littlebearapps/untether/issues/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](https://github.com/littlebearapps/untether/issues/270)
- per-cron `timezone` field with IANA timezone names (e.g. `"Australia/Melbourne"`)
- global `default_timezone` in `[triggers]` — per-cron `timezone` overrides it
- DST-aware via Python's `zoneinfo` module (zero new dependencies)
- invalid timezone names rejected at config parse time with clear error messages

- **SSRF protection for trigger outbound requests** — shared utility at `triggers/ssrf.py` blocks private/reserved IP ranges, validates URL schemes, and checks DNS resolution to prevent server-side request forgery in upcoming webhook forwarding and cron data-fetch features [#276](https://github.com/littlebearapps/untether/issues/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](https://github.com/littlebearapps/untether/issues/277)
- `action = "file_write"` — write POST body to disk with atomic writes, path traversal protection, deny-glob enforcement, and on-conflict handling
- `action = "http_forward"` — forward payload to another URL with SSRF protection, exponential backoff on 5xx, and header template rendering
- `action = "notify_only"` — send a templated Telegram message with no agent run
- `notify_on_success` / `notify_on_failure` flags for Telegram visibility on all action types
- default `action = "agent_run"` preserves full backward compatibility

- **multipart form data support for webhooks** — webhooks can now accept `multipart/form-data` POSTs with file uploads [#278](https://github.com/littlebearapps/untether/issues/278)
- file parts saved with sanitised filenames, atomic writes, deny-glob and path traversal protection
- configurable `file_destination` with template variables, `max_file_size_bytes` (default 50 MB)
- form fields available as template variables alongside file metadata

- **data-fetch cron triggers** — cron triggers can now pull data from external sources before rendering the prompt [#279](https://github.com/littlebearapps/untether/issues/279)
- `fetch.type = "http_get"` / `"http_post"` — fetch URL with SSRF protection, configurable timeout and headers
- `fetch.type = "file_read"` — read local file with path traversal protection and deny-globs
- `fetch.parse_as` — parse response as `json`, `text`, or `lines`
- fetched data injected into `prompt_template` via `store_as` variable (default `fetch_result`)
- `on_failure = "abort"` (default) sends failure notification; `"run_with_error"` injects error into prompt
- all fetched data prefixed with untrusted-data marker

- **hot-reload for trigger configuration** — editing `untether.toml` `[triggers]` applies changes immediately without restarting Untether or killing active runs [#269](https://github.com/littlebearapps/untether/issues/269) ([#285](https://github.com/littlebearapps/untether/pull/285))
- new `TriggerManager` class holds cron and webhook config; scheduler reads `manager.crons` each tick; webhook server resolves routes per-request via `manager.webhook_for_path()`
- supports add/remove/modify of crons and webhooks, auth/secret changes, action type, multipart/file settings, cron fetch, and timezones
- `last_fired` dict preserved across swaps to prevent double-firing within the same minute
- unauthenticated webhooks logged at `WARNING` on reload (previously only at startup)
- 13 new tests in `test_trigger_manager.py`; 2038 existing tests still pass

- **hot-reload for Telegram bridge 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)
- `TelegramBridgeConfig` unfrozen (keeps `slots=True`) and gains an `update_from(settings)` method
- `handle_reload()` now applies changes in-place and refreshes cached loop-state copies; restart-only keys (`bot_token`, `chat_id`, `session_mode`, `topics`, `message_overflow`) still warn with `restart_required=true`
- `route_update()` reads `cfg.allowed_user_ids` live so allowlist changes take effect on the next message

- **`/at` command for one-shot delayed runs** — schedule a prompt to run between 60s and 24h in the future with `/at 30m Check the build`; accepts `Ns`/`Nm`/`Nh` suffixes [#288](https://github.com/littlebearapps/untether/issues/288)
- pending delays tracked in-memory (lost on restart — acceptable for one-shot use)
- `/cancel` drops pending `/at` timers before they fire
- per-chat cap of 20 pending delays; graceful drain cancels pending scopes on shutdown
- new module `telegram/at_scheduler.py`; command registered as `at` entry point

- **`run_once` cron flag** — `[[triggers.crons]]` entries can set `run_once = true` to fire once then auto-disable; the cron stays in the TOML and re-activates on the next config reload or restart [#288](https://github.com/littlebearapps/untether/issues/288)

- **trigger visibility improvements (Tier 1)** — surface configured triggers in the Telegram UI [#271](https://github.com/littlebearapps/untether/issues/271)
- `/ping` in a chat with active triggers appends `⏰ triggers: 1 cron (daily-review, 9:00 AM daily (Melbourne))`
- trigger-initiated runs show provenance in the meta footer: `🏷 opus 4.6 · plan · ⏰ cron:daily-review`
- new `describe_cron(schedule, timezone)` utility renders common cron patterns in plain English; falls back to the raw expression for complex schedules
- `RunContext` gains `trigger_source` field; `ProgressTracker.note_event` merges engine meta over the dispatcher-seeded trigger so it survives
- `TriggerManager` exposes `crons_for_chat()`, `webhooks_for_chat()`, `cron_ids()`, `webhook_ids()` helpers

- **faster, cleaner restarts (Tier 1)** — restart gap reduced from ~15-30s to ~5s with no lost messages [#287](https://github.com/littlebearapps/untether/issues/287)
- persist last Telegram `update_id` to `last_update_id.json` and resume polling from the saved offset on startup; Telegram retains undelivered updates for 24h, so the polling gap no longer drops or re-processes messages
- `Type=notify` systemd integration via stdlib `sd_notify` (`socket.AF_UNIX`, no dependency) — `READY=1` is sent after the first `getUpdates` succeeds, `STOPPING=1` at the start of drain
- `RestartSec=2` in `contrib/untether.service` (was `10`) — faster restart after drain completes
- `contrib/untether.service` also adds `NotifyAccess=main`; existing installs must copy the unit file and `systemctl --user daemon-reload`

### docs

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add the standard ### tests / ### breaking subsections for v0.35.1.

This entry currently only has fixes/changes/docs, so it doesn't match the repository release-note template and it omits the new test coverage shipped in this release. If there were no breaking changes, add an explicit empty ### breaking section to keep the structure consistent.

As per coding guidelines, CHANGELOG.md must use format ## vX.Y.Z (YYYY-MM-DD) with subsections for fixes, changes, breaking, docs, and tests.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CHANGELOG.md` around lines 3 - 105, Under the "## v0.35.1 (2026-04-15)" entry
add the missing release-note subsections "### breaking" and "### tests" (keep
the existing "fixes", "changes", and "docs" blocks intact); if there are no
breaking changes include an explicit empty "### breaking" header with a one-line
"No breaking changes." statement, and add a short "### tests" bullet summarising
new/updated test coverage (e.g. mention "13 new tests in
test_trigger_manager.py" and overall test audit) so the changelog conforms to
the required fixes/changes/breaking/docs/tests structure.

Comment on lines +9 to +13
```
/at 30m Check the build
/at 2h Review the PR feedback
/at 60s Say hello
```
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add a language tag to the fenced command block.

Line 9 opens a fenced block without a language, which trips markdownlint (MD040).

🛠️ Proposed fix
-```
+```text
 /at 30m Check the build
 /at 2h Review the PR feedback
 /at 60s Say hello
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against the current code and only fix it if needed.

In @docs/how-to/schedule-tasks.md around lines 9 - 13, The fenced code block
that currently starts with and contains the lines "/at 30m Check the build", "/at 2h Review the PR feedback", "/at 60s Say hello" is missing a language tag; update the opening fence to include a language (for exampletext) so the
block is explicitly labeled and markdownlint MD040 is satisfied.


</details>

<!-- fingerprinting:phantom:poseidon:hawk -->

<!-- This is an auto-generated comment by CodeRabbit -->

Comment on lines +252 to +253
- Use `/cancel` to see how many pending delays exist. If it says "nothing running", there are no pending delays.
- Minimum duration: 60 seconds. Maximum: 24 hours. Values outside this range are rejected.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

/cancel description is misleading for pending /at runs.

This reads like inspection-only behaviour, but /cancel actually cancels pending /at jobs in that chat. Please reword to avoid accidental destructive use.

📝 Suggested wording
-- Use `/cancel` to see how many pending delays exist. If it says "nothing running", there are no pending delays.
+- Use `/cancel` to cancel pending delays in this chat; the reply includes how many were cancelled. If it says "nothing running", there were no pending delays.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- Use `/cancel` to see how many pending delays exist. If it says "nothing running", there are no pending delays.
- Minimum duration: 60 seconds. Maximum: 24 hours. Values outside this range are rejected.
- Use `/cancel` to cancel pending delays in this chat; the reply includes how many were cancelled. If it says "nothing running", there were no pending delays.
- Minimum duration: 60 seconds. Maximum: 24 hours. Values outside this range are rejected.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/how-to/troubleshooting.md` around lines 252 - 253, Update the misleading
`/cancel` description to clearly state that invoking `/cancel` will cancel
pending `/at` jobs in the chat (not just inspect them) and warn users to avoid
accidental destructive use; specifically edit the line that mentions "Use
`/cancel` to see how many pending delays exist" to something like "Use `/cancel`
to cancel all pending `/at` jobs in this chat (this will remove scheduled jobs)
— use with caution; to only view pending jobs use `/list`" and ensure `/cancel`
and `/at` are referenced so readers understand the destructive effect.

Comment thread docs/how-to/update.md
Comment on lines +19 to +27
```sh
untether --version
```

After upgrading, restart the service if running as a systemd unit:

```sh
systemctl --user restart untether
```
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Align top-level shell snippets with markdownlint MD046.

These two blocks are fenced, but the configured style expects indented code blocks.

Proposed markdownlint-compliant change
 Check your current version:
 
-```sh
-untether --version
-```
+    untether --version
 
 After upgrading, restart the service if running as a systemd unit:
 
-```sh
-systemctl --user restart untether
-```
+    systemctl --user restart untether
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```sh
untether --version
```
After upgrading, restart the service if running as a systemd unit:
```sh
systemctl --user restart untether
```
untether --version
After upgrading, restart the service if running as a systemd unit:
systemctl --user restart untether
🧰 Tools
🪛 markdownlint-cli2 (0.22.0)

[warning] 19-19: Code block style
Expected: indented; Actual: fenced

(MD046, code-block-style)


[warning] 25-25: Code block style
Expected: indented; Actual: fenced

(MD046, code-block-style)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/how-to/update.md` around lines 19 - 27, Replace the fenced shell code
blocks with indented code blocks to satisfy markdownlint MD046: remove the ```sh
fences and indent the command lines for both instances (the line containing
"untether --version" and the line containing "systemctl --user restart
untether") by four spaces each, keeping surrounding text and blank lines intact
so the commands render as top-level indented code blocks.

Comment on lines +92 to +118
try:
async with httpx.AsyncClient(timeout=timeout) as client:
kwargs: dict[str, Any] = {
"headers": rendered_headers,
"follow_redirects": False,
}
if method == "POST" and fetch.body:
kwargs["content"] = render_template_fields(
fetch.body, env_payload
).encode()

resp = await client.request(method, rendered_url, **kwargs)

if resp.status_code >= 400:
msg = f"fetch failed: HTTP {resp.status_code}"
logger.warning(
"triggers.fetch.http_error",
url=rendered_url,
status=resp.status_code,
)
return False, msg, None

body = resp.content
if len(body) > max_bytes:
msg = f"fetch response too large ({len(body)} bytes, max {max_bytes})"
logger.warning("triggers.fetch.too_large", size=len(body))
return False, msg, None
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/untether/triggers/fetch.py

Repository: littlebearapps/untether

Length of output: 9126


🏁 Script executed:

# Check if httpx is used elsewhere in the codebase
rg "httpx" --type py -A 3 -B 3

Repository: littlebearapps/untether

Length of output: 33635


🏁 Script executed:

# Check if streaming patterns are already used in the codebase
rg "\.stream\(" --type py -A 5

Repository: littlebearapps/untether

Length of output: 49


🏁 Script executed:

# Check httpx documentation/usage patterns for stream vs request
rg "aiter_bytes|aiter_raw" --type py

Repository: littlebearapps/untether

Length of output: 49


🏁 Script executed:

# Verify the current exception handling includes the HTTP section
sed -n '120,124p' src/untether/triggers/fetch.py

Repository: littlebearapps/untether

Length of output: 281


🏁 Script executed:

# Check if there are any tests that validate max_bytes behavior
rg "max_bytes|too_large" --type py -B 3 -A 3

Repository: littlebearapps/untether

Length of output: 19856


Enforce max_bytes while streaming the HTTP response, not after buffering.

resp = await client.request(...); body = resp.content buffers the entire response into memory before checking its size. A misconfigured or malicious endpoint can force arbitrarily large allocations, making the limit ineffective as a memory guard. The file-read function correctly pre-checks size via stat(), but HTTP should stream and enforce the limit incrementally.

Suggested fix
    try:
        async with httpx.AsyncClient(timeout=timeout) as client:
            kwargs: dict[str, Any] = {
                "headers": rendered_headers,
                "follow_redirects": False,
            }
            if method == "POST" and fetch.body:
                kwargs["content"] = render_template_fields(
                    fetch.body, env_payload
                ).encode()

            async with client.stream(method, rendered_url, **kwargs) as resp:
                if resp.status_code >= 400:
                    msg = f"fetch failed: HTTP {resp.status_code}"
                    logger.warning(
                        "triggers.fetch.http_error",
                        url=rendered_url,
                        status=resp.status_code,
                    )
                    return False, msg, None

                chunks: list[bytes] = []
                total = 0
                async for chunk in resp.aiter_bytes():
                    total += len(chunk)
                    if total > max_bytes:
                        msg = (
                            f"fetch response too large ({total} bytes, max {max_bytes})"
                        )
                        logger.warning("triggers.fetch.too_large", size=total)
                        return False, msg, None
                    chunks.append(chunk)
                body = b"".join(chunks)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/untether/triggers/fetch.py` around lines 92 - 118, The current code
buffers resp.content and enforces max_bytes only after reading everything;
change the HTTP call to use httpx.AsyncClient.stream(...) (or
client.request(..., stream=True)) and iterate the response body in chunks (e.g.,
aiter_bytes) to accumulate up to max_bytes while aborting the stream and
returning the "too large" warning if the accumulated size exceeds max_bytes;
also pre-check Content-Length header on resp.headers to short-circuit if it
declares a size > max_bytes, preserve existing behavior for status-code errors
(the logger call using rendered_url and resp.status_code stays the same), and
still return the full bytes only if the streamed total remains <= max_bytes;
keep usage of rendered_url, rendered_headers, method, fetch.body,
render_template_fields, timeout, logger, and max_bytes to locate where to
change.

Comment on lines +141 to +160
path = Path(fetch.file_path).expanduser().resolve(strict=False)

# Path traversal check.
if ".." in Path(fetch.file_path).parts:
msg = f"fetch file_read rejected: path traversal in {fetch.file_path!r}"
logger.warning("triggers.fetch.path_rejected", path=fetch.file_path)
return False, msg, None

# Deny-glob check.
reason = _deny_reason(path)
if reason is not None:
msg = f"fetch file_read rejected: path matches deny glob {reason!r}"
logger.warning("triggers.fetch.denied", path=str(path), deny_glob=reason)
return False, msg, None

# Symlink check.
if path.is_symlink():
msg = f"fetch file_read rejected: {path} is a symlink"
logger.warning("triggers.fetch.symlink", path=str(path))
return False, msg, None
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/untether/triggers/fetch.py | sed -n '130,170p'

Repository: littlebearapps/untether

Length of output: 1742


🏁 Script executed:

cat -n src/untether/triggers/fetch.py | sed -n '135,170p'

Repository: littlebearapps/untether

Length of output: 1592


🏁 Script executed:

python3 << 'EOF'
# Let's trace through the logic to understand the actual issue
from pathlib import Path

# The current code order is:
# 1. path = Path(fetch.file_path).expanduser().resolve(strict=False)
# 2. Check ".." in path.parts
# 3. Check deny_reason(path)
# 4. Check path.is_symlink()

# After resolve(), symlinks to files are already collapsed
# So when we check is_symlink() later, it returns False for resolved paths

# BUT: The suggest fix checks BEFORE resolve()
# raw_path.is_symlink() would catch the final component
# But raw_path.parents that are symlinks also need checking

print("Current issue: is_symlink() check happens AFTER resolve()")
print("resolve() collapses symlinks to their targets")
print("So is_symlink() will always return False for symlinked files")
print()
print("Proposed fix: Check symlinks on raw_path (before resolve)")
print("This needs to check both the path itself AND parent directories")
EOF

Repository: littlebearapps/untether

Length of output: 354


🏁 Script executed:

python3 << 'EOF'
# Let's also check what the function signature and return type are
with open('src/untether/triggers/fetch.py', 'r') as f:
    lines = f.readlines()
    # Find the function containing line 141-160
    for i in range(140, 0, -1):
        if 'def ' in lines[i]:
            # Print from function definition to line 165
            for j, line in enumerate(lines[i:165], start=i+1):
                print(f"{j:3d} {line}", end='')
            break
EOF

Repository: littlebearapps/untether

Length of output: 1331


🏁 Script executed:

head -10 src/untether/triggers/fetch.py

Repository: littlebearapps/untether

Length of output: 333


🏁 Script executed:

grep -n "path = Path" src/untether/triggers/fetch.py

Repository: littlebearapps/untether

Length of output: 138


Check for symlinks before calling resolve(), which already collapses them.

resolve(strict=False) resolves symlinks to their targets before path.is_symlink() is called, making the symlink guard ineffective. A symlinked fetch target can bypass rejection and read from its resolved location instead. Move the symlink check to the raw path before resolving:

Suggested fix
-    path = Path(fetch.file_path).expanduser().resolve(strict=False)
+    raw_path = Path(fetch.file_path).expanduser()
 
     # Path traversal check.
     if ".." in Path(fetch.file_path).parts:
         msg = f"fetch file_read rejected: path traversal in {fetch.file_path!r}"
         logger.warning("triggers.fetch.path_rejected", path=fetch.file_path)
         return False, msg, None
+
+    if raw_path.is_symlink() or any(parent.is_symlink() for parent in raw_path.parents):
+        msg = f"fetch file_read rejected: {raw_path} uses a symlinked path"
+        logger.warning("triggers.fetch.symlink", path=str(raw_path))
+        return False, msg, None
+
+    path = raw_path.resolve(strict=False)
 
     # Deny-glob check.
     reason = _deny_reason(path)
     if reason is not None:
         msg = f"fetch file_read rejected: path matches deny glob {reason!r}"
         logger.warning("triggers.fetch.denied", path=str(path), deny_glob=reason)
         return False, msg, None
-
-    # Symlink check.
-    if path.is_symlink():
-        msg = f"fetch file_read rejected: {path} is a symlink"
-        logger.warning("triggers.fetch.symlink", path=str(path))
-        return False, msg, None
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/untether/triggers/fetch.py` around lines 141 - 160, The symlink guard is
ineffective because resolve(strict=False) is called before checking
path.is_symlink(), so symlinks are collapsed and bypass the check; change the
order so you construct a raw Path from fetch.file_path and call is_symlink() on
that raw path before calling expanduser()/resolve(), returning the same
rejection message if true; keep the existing deny-glob check using _deny_reason
and subsequent resolved path logic unchanged, but ensure you reference
Path(fetch.file_path) for the traversal, symlink and deny checks and only call
expanduser().resolve() after those checks pass.

Comment on lines +94 to +121
async for part in reader:
if part.filename:
# File part — sanitise filename and save.
raw_name = part.filename or "upload.bin"
safe_name = raw_name.replace("/", "_").replace("\\", "_")
if not _SAFE_FILENAME_RE.match(safe_name):
safe_name = "upload.bin"

# Read file content with size limit.
max_file = webhook.max_file_size_bytes
chunks: list[bytes] = []
total = 0
while True:
chunk = await part.read_chunk(8192)
if not chunk:
break
total += len(chunk)
if total > max_file:
raise _MultipartError(413, "file too large")
chunks.append(chunk)
file_data = b"".join(chunks)

# Build destination path.
form_fields["file"] = {"filename": safe_name}
if webhook.file_destination:
dest_template = webhook.file_destination
template_ctx = {**form_fields, "file": {"filename": safe_name}}
dest_str = render_template_fields(dest_template, template_ctx)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Defer file_destination rendering until all form fields are parsed.

The destination template is rendered as soon as the file part is encountered. If the template references a form field that appears later in the multipart body, that value is still missing and the file is written to the wrong path. This makes upload behaviour depend on part order, even though the feature exposes form fields as template variables.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/untether/triggers/server.py` around lines 94 - 121, Currently the code
renders webhook.file_destination inside the multipart loop as soon as a file
part is encountered, which can miss form fields that appear later; change the
logic in the async reader loop that handles part (where raw_name/safe_name,
file_data, form_fields["file"] are set) to not call render_template_fields
there, but instead collect file metadata and bytes (e.g., keep safe_name and
file_data in a temp structure or keep form_fields["file"] populated) and finish
parsing all parts; after the async for part in reader loop completes, if
webhook.file_destination is set then call render_template_fields(dest_template,
template_ctx) using the fully-populated form_fields and then perform the final
destination path build and file save. Ensure you update references to
render_template_fields, webhook.file_destination, form_fields, and the file
write logic so rendering is deferred until after parsing completes.

Comment on lines +203 to +206
# Strong references to in-flight dispatch tasks (#281). Without this,
# asyncio can garbage-collect the task mid-flight and the dispatch is
# silently dropped. Tasks remove themselves on completion.
_dispatch_tasks: set[asyncio.Task[None]] = set()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check the file size first
wc -l src/untether/triggers/server.py

Repository: littlebearapps/untether

Length of output: 102


🏁 Script executed:

# Read the specific sections mentioned in the review
echo "=== Lines 203-206 ===" && sed -n '200,210p' src/untether/triggers/server.py && \
echo -e "\n=== Lines 314-347 ===" && sed -n '314,350p' src/untether/triggers/server.py

Repository: littlebearapps/untether

Length of output: 2122


🏁 Script executed:

# Get more context around those lines to understand the implementation
echo "=== Context around lines 203-206 ===" && sed -n '190,220p' src/untether/triggers/server.py && \
echo -e "\n=== Context around lines 314-347 ===" && sed -n '305,360p' src/untether/triggers/server.py

Repository: littlebearapps/untether

Length of output: 3403


🏁 Script executed:

# Check if runner.cleanup() exists and how it handles cleanup
echo "=== Looking for cleanup method ===" && rg -n "def cleanup|async def cleanup" src/untether/triggers/server.py -A 20

Repository: littlebearapps/untether

Length of output: 101


🏁 Script executed:

# Check imports to see if anyio or asyncio is being used
echo "=== Top of file (imports) ===" && head -50 src/untether/triggers/server.py

Repository: littlebearapps/untether

Length of output: 1641


🏁 Script executed:

# Search for anyio task group usage in the file
echo "=== Looking for anyio task group usage ===" && rg -n "create_task_group|TaskGroup|anyio" src/untether/triggers/server.py -B 2 -A 2

Repository: littlebearapps/untether

Length of output: 508


🏁 Script executed:

# Let me get the full file to understand the structure better
cat -n src/untether/triggers/server.py | head -100

Repository: littlebearapps/untether

Length of output: 3832


🏁 Script executed:

# Get the specific lines and full context for analysis
sed -n '1,50p' src/untether/triggers/server.py && \
echo "=== KEY SECTION: Lines 200-230 ===" && \
sed -n '200,230p' src/untether/triggers/server.py && \
echo "=== KEY SECTION: Lines 310-360 ===" && \
sed -n '310,360p' src/untether/triggers/server.py

Repository: littlebearapps/untether

Length of output: 4833


🏁 Script executed:

# Let's check the entire file efficiently
python3 <<'PYTHON_EOF'
with open('src/untether/triggers/server.py', 'r') as f:
    lines = f.readlines()

# Print lines 1-50 (imports and class start)
print("=== LINES 1-50 (Imports & Class Start) ===")
for i in range(0, min(50, len(lines))):
    print(f"{i+1:3d}: {lines[i]}", end='')

# Print lines 200-230 (dispatch_tasks declaration)
print("\n\n=== LINES 200-230 (Dispatch Tasks Declaration) ===")
for i in range(199, min(230, len(lines))):
    print(f"{i+1:3d}: {lines[i]}", end='')

# Print lines 310-360 (task creation & cleanup)
print("\n\n=== LINES 310-360 (Task Creation) ===")
for i in range(309, min(360, len(lines))):
    print(f"{i+1:3d}: {lines[i]}", end='')

# Search for cleanup method
print("\n\n=== CLEANUP METHOD ===")
for i, line in enumerate(lines):
    if 'def cleanup' in line or 'async def cleanup' in line:
        # Print context around cleanup
        start = max(0, i - 2)
        end = min(len(lines), i + 30)
        for j in range(start, end):
            print(f"{j+1:3d}: {lines[j]}", end='')
        break
PYTHON_EOF

Repository: littlebearapps/untether

Length of output: 5584


🏁 Script executed:

# Check if there's any anyio task group usage and how asyncio.create_task is used
python3 <<'PYTHON_EOF'
import re

with open('src/untether/triggers/server.py', 'r') as f:
    content = f.read()
    lines = content.split('\n')

# Check imports
print("=== IMPORTS ===")
for i, line in enumerate(lines[:60], 1):
    if 'import' in line.lower():
        print(f"{i:3d}: {line}")

# Search for asyncio.create_task usage
print("\n=== ASYNCIO.CREATE_TASK USAGE ===")
for i, line in enumerate(lines, 1):
    if 'create_task' in line.lower():
        start = max(0, i-3)
        end = min(len(lines), i+2)
        for j in range(start, end):
            marker = ">>>" if j == i-1 else "   "
            print(f"{marker} {j+1:3d}: {lines[j]}")
        print()

# Search for cleanup or cancel logic
print("\n=== CLEANUP/CANCEL PATTERNS ===")
for i, line in enumerate(lines, 1):
    if 'cleanup' in line.lower() or ('cancel' in line.lower() and i > 200):
        start = max(0, i-2)
        end = min(len(lines), i+8)
        for j in range(start, end):
            marker = ">>>" if j == i-1 else "   "
            print(f"{marker} {j+1:3d}: {lines[j]}")
        print()
PYTHON_EOF

Repository: littlebearapps/untether

Length of output: 1869


🏁 Script executed:

# Direct view of the file with line numbers - let's focus on key sections
cat -n src/untether/triggers/server.py | sed -n '1,60p' && echo "..." && \
cat -n src/untether/triggers/server.py | sed -n '195,225p' && echo "..." && \
cat -n src/untether/triggers/server.py | sed -n '305,360p'

Repository: littlebearapps/untether

Length of output: 6259


🏁 Script executed:

wc -l src/untether/triggers/server.py && \
echo "=== FULL FILE ===" && \
cat src/untether/triggers/server.py

Repository: littlebearapps/untether

Length of output: 13777


🏁 Script executed:

# Search for all create_task or dispatch_tasks references
rg -n "create_task|dispatch_tasks" src/untether/triggers/server.py -B 3 -A 3

Repository: littlebearapps/untether

Length of output: 1202


🏁 Script executed:

# Let's also check the runner class and its lifecycle
rg -n "class.*Server|def cleanup|async def cleanup|__aexit__|runner" src/untether/triggers/server.py -B 1 -A 1

Repository: littlebearapps/untether

Length of output: 315


🏁 Script executed:

# Check the exact file content one more time with proper formatting
python3 <<'PYTHON_EOF'
with open('src/untether/triggers/server.py', 'r') as f:
    lines = f.readlines()

print(f"Total lines: {len(lines)}\n")

# Print entire file with line numbers for analysis
for i, line in enumerate(lines, 1):
    print(f"{i:3d}: {line}", end='')
PYTHON_EOF

Repository: littlebearapps/untether

Length of output: 15646


🏁 Script executed:

# Get lines 314-347 specifically
sed -n '314,347p' src/untether/triggers/server.py | cat -n

Repository: littlebearapps/untether

Length of output: 1730


🏁 Script executed:

# Get full context including cleanup methods
python3 <<'PYTHON_EOF'
with open('src/untether/triggers/server.py', 'r') as f:
    content = f.read()

# Extract and print the exact lines
lines = content.split('\n')

# Show lines around the key areas
sections = [
    (0, 30, "IMPORTS & START"),
    (200, 210, "DISPATCH_TASKS DECLARATION"),
    (310, 350, "DISPATCH_WEBHOOK & TASK CREATION"),
]

for start, end, label in sections:
    print(f"\n{'='*60}")
    print(f"{label} (LINES {start+1}-{end+1})")
    print('='*60)
    for i in range(start, min(end, len(lines))):
        print(f"{i+1:3d}: {lines[i]}")

# Search for cleanup
print(f"\n{'='*60}")
print("CLEANUP METHODS")
print('='*60)
for i, line in enumerate(lines):
    if 'cleanup' in line.lower() or '__aexit__' in line:
        start = max(0, i - 2)
        end = min(len(lines), i + 25)
        for j in range(start, end):
            print(f"{j+1:3d}: {lines[j]}")
        print()
PYTHON_EOF

Repository: littlebearapps/untether

Length of output: 4164


🏁 Script executed:

# Comprehensive analysis of the code
python3 <<'PYTHON_EOF'
import re

with open('src/untether/triggers/server.py', 'r') as f:
    content = f.read()
    lines = content.split('\n')

# 1. Check imports
print("IMPORT ANALYSIS:")
print("-" * 60)
asyncio_imported = False
anyio_imported = False
for i, line in enumerate(lines[:50], 1):
    if 'import asyncio' in line:
        asyncio_imported = True
        print(f"Line {i}: {line} ✓ asyncio imported")
    if 'import anyio' in line:
        anyio_imported = True
        print(f"Line {i}: {line} ✓ anyio imported")

# 2. Check _dispatch_tasks declaration
print("\n_DISPATCH_TASKS DECLARATION:")
print("-" * 60)
for i, line in enumerate(lines[200:210], 201):
    if '_dispatch_tasks' in line:
        print(f"Line {i}: {line} ✓ Found")
        if 'asyncio.Task' in line:
            print("  → Uses asyncio.Task")

# 3. Check task creation in dispatch_webhook
print("\nTASK CREATION IN DISPATCH_WEBHOOK:")
print("-" * 60)
in_dispatch_webhook = False
for i, line in enumerate(lines, 1):
    if 'def dispatch_webhook' in line or 'async def dispatch_webhook' in line:
        in_dispatch_webhook = True
        start_idx = i - 1
    if in_dispatch_webhook and 'create_task' in line.lower():
        print(f"Line {i}: {line}")
        if 'asyncio.create_task' in line:
            print("  → Uses asyncio.create_task()")
        elif 'anyio' in line:
            print("  → Uses anyio")
        # Get context
        for j in range(max(0, i-5), min(len(lines), i+5)):
            if j != i-1:
                print(f"  {j+1:3d}: {lines[j]}")
        break

# 4. Check cleanup handling
print("\nCLEANUP HANDLING:")
print("-" * 60)
for i, line in enumerate(lines, 1):
    if '__aexit__' in line or 'async def cleanup' in line or 'def cleanup' in line:
        print(f"Found cleanup at line {i}: {line}")
        # Show context
        for j in range(max(0, i-2), min(len(lines), i+20)):
            print(f"  {j+1:3d}: {lines[j]}")
        if '_dispatch_tasks' in '\n'.join(lines[i:i+20]):
            print("  → _dispatch_tasks mentioned in cleanup ✓")
        else:
            print("  → _dispatch_tasks NOT mentioned in cleanup ✗")
        break

# 5. Task self-removal pattern
print("\nTASK SELF-REMOVAL PATTERN:")
print("-" * 60)
for i, line in enumerate(lines, 1):
    if 'remove' in line and '_dispatch_tasks' in line:
        for j in range(max(0, i-3), min(len(lines), i+3)):
            print(f"  {j+1:3d}: {lines[j]}")
        print()

PYTHON_EOF

Repository: littlebearapps/untether

Length of output: 674


Replace raw asyncio.create_task() with anyio task group management.

The code uses asyncio.create_task() (lines 329, 344) to spawn webhook handlers, bypassing anyio's structured concurrency. These tasks are tracked only in _dispatch_tasks with a done-callback; runner.cleanup() won't cancel or wait for them on shutdown, risking in-flight webhooks outliving the server and racing against transport teardown. Create dispatch tasks within an anyio task group to ensure they're properly cancelled on cleanup, per the coding guideline requiring anyio for async operations.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/untether/triggers/server.py` around lines 203 - 206, Replace the ad-hoc
asyncio.create_task usage and _dispatch_tasks bookkeeping in the webhook
dispatch logic with an anyio TaskGroup: create an anyio.create_task_group() (or
self._task_group managed on startup/shutdown) and start webhook handler
coroutines within that group instead of asyncio.create_task; remove the
done-callback and the _dispatch_tasks set, and ensure the group's cancel/finish
is invoked during runner.cleanup() (or server shutdown) so in-flight dispatch
handlers are cancelled and awaited automatically. Target the code that spawns
webhook handler coroutines (the locations currently calling asyncio.create_task
to run the dispatch/webhook handler) and the shutdown path that calls
runner.cleanup() to wire the task group lifecycle.

Comment on lines +9 to +26
def test_trigger_only(self):
out = format_meta_line({"trigger": "\u23f0 cron:daily-review"})
assert out == "\u23f0 cron:daily-review"

def test_trigger_with_model(self):
out = format_meta_line(
{"trigger": "\u23f0 cron:daily-review", "model": "claude-opus-4-6"}
)
assert out is not None
assert "\u23f0 cron:daily-review" in out
assert "opus" in out.lower()
# Model must come before trigger in the part order.
parts = out.split(" \u00b7 ")
assert parts.index("\u23f0 cron:daily-review") == len(parts) - 1

def test_trigger_webhook(self):
out = format_meta_line({"trigger": "\u26a1 webhook:github-push"})
assert out == "\u26a1 webhook:github-push"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Test the raw trigger source shape here, not a preformatted footer fragment.

format_meta_line() is fed cron:<id> / webhook:<id> metadata in production, but these cases pass strings that already include / . That means a regression in the icon/prefix mapping can still leave this file green.

💡 Suggested test update
-        out = format_meta_line({"trigger": "\u23f0 cron:daily-review"})
+        out = format_meta_line({"trigger": "cron:daily-review"})
         assert out == "\u23f0 cron:daily-review"
@@
-            {"trigger": "\u23f0 cron:daily-review", "model": "claude-opus-4-6"}
+            {"trigger": "cron:daily-review", "model": "claude-opus-4-6"}
@@
-        out = format_meta_line({"trigger": "\u26a1 webhook:github-push"})
+        out = format_meta_line({"trigger": "webhook:github-push"})
         assert out == "\u26a1 webhook:github-push"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_trigger_only(self):
out = format_meta_line({"trigger": "\u23f0 cron:daily-review"})
assert out == "\u23f0 cron:daily-review"
def test_trigger_with_model(self):
out = format_meta_line(
{"trigger": "\u23f0 cron:daily-review", "model": "claude-opus-4-6"}
)
assert out is not None
assert "\u23f0 cron:daily-review" in out
assert "opus" in out.lower()
# Model must come before trigger in the part order.
parts = out.split(" \u00b7 ")
assert parts.index("\u23f0 cron:daily-review") == len(parts) - 1
def test_trigger_webhook(self):
out = format_meta_line({"trigger": "\u26a1 webhook:github-push"})
assert out == "\u26a1 webhook:github-push"
def test_trigger_only(self):
out = format_meta_line({"trigger": "cron:daily-review"})
assert out == "\u23f0 cron:daily-review"
def test_trigger_with_model(self):
out = format_meta_line(
{"trigger": "cron:daily-review", "model": "claude-opus-4-6"}
)
assert out is not None
assert "\u23f0 cron:daily-review" in out
assert "opus" in out.lower()
# Model must come before trigger in the part order.
parts = out.split(" \u00b7 ")
assert parts.index("\u23f0 cron:daily-review") == len(parts) - 1
def test_trigger_webhook(self):
out = format_meta_line({"trigger": "webhook:github-push"})
assert out == "\u26a1 webhook:github-push"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_trigger_meta_line.py` around lines 9 - 26, Update the tests to
pass the raw trigger source strings (e.g., "cron:daily-review" and
"webhook:github-push") into format_meta_line instead of already-prefixed strings
with icons; change assertions in test_trigger_only, test_trigger_with_model and
test_trigger_webhook to verify that format_meta_line prepends the correct
icon/prefix to the raw source (e.g., resulting string contains the expected icon
+ space + the raw source), and in test_trigger_with_model ensure the computed
part (the full "icon + space + trigger" fragment) appears as the last element
when splitting by " · " and that the model name (e.g., "opus") appears earlier.

@nathanschram Nathan Schram (nathanschram) merged commit 02b9352 into master Apr 15, 2026
37 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant