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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions docs/how-to/file-transfer.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,12 +139,12 @@ Files are sent in alphabetical order, one at a time, immediately after the agent

Outbox delivery reuses the same security rules as `/file get`:

- **Deny globs** β€” files matching `.git/**`, `.env`, `.envrc`, `**/*.pem`, `**/.ssh/**` (and any custom deny globs) are silently skipped
- **Size limit** β€” files larger than 50 MB are skipped
- **Deny globs** β€” files matching `.git/**`, `.env`, `.envrc`, `**/*.pem`, `**/.ssh/**` (and any custom deny globs) are surfaced to the user as a `πŸ“Ž Outbox skipped` notice rather than silently dropped (#524)
- **Size limit** β€” files larger than 50 MB are skipped (and surfaced via the same notice)
- **Path traversal** β€” symlinks pointing outside the project root are rejected
- **File count** β€” capped at `outbox_max_files` per run (default 10)
- **Auto-cleanup** β€” sent files are deleted after delivery by default, preventing sensitive data accumulation
- **Successful runs only** β€” outbox is not scanned on errored or cancelled runs
- **Failed/auto-continued runs** β€” actual file delivery is still gated on a successful run, but skipped items (directories, deny-globbed files, oversized files) are surfaced even when the run fails or auto-continues, so you always learn what the agent intended to send. Opt out via `outbox_notify_skipped = false`.

### Engine compatibility

Expand All @@ -164,8 +164,8 @@ All engines support outbox delivery β€” any agent that can write files to disk c

### Limitations

- **Flat scan only** β€” only files directly in `.untether-outbox/` are sent; subdirectories are skipped. Agents can zip nested structures if needed.
- **Successful runs only** β€” if the agent errors or is cancelled, the outbox is not scanned.
- **Flat scan only** β€” only files directly in `.untether-outbox/` are sent; subdirectories are surfaced as `πŸ“Ž Outbox skipped` (#524) but not delivered. Agents can zip nested structures if needed.
- **Failed runs deliver no files** β€” the actual file send is still gated on a successful run, but the skipped-items notice fires either way so the user always learns what the agent intended to ship.
- **No real-time delivery** β€” files are sent after the run completes, not during.

<!-- TODO: capture screenshot of outbox delivery in Telegram -->
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name = "untether"
authors = [{name = "Little Bear Apps", email = "hello@littlebearapps.com"}]
maintainers = [{name = "Little Bear Apps", email = "hello@littlebearapps.com"}]
version = "0.35.3rc19"
version = "0.35.3rc20"
keywords = ["telegram", "claude-code", "codex", "opencode", "pi", "gemini-cli", "amp", "ai-agents", "coding-assistant", "remote-control", "cli-bridge"]
description = "Run AI coding agents from your phone. Bridges Claude Code, Codex, OpenCode, Pi, Gemini CLI, and Amp to Telegram with interactive permissions, voice input, cost tracking, and live progress."
readme = {file = "README.md", content-type = "text/markdown"}
Expand Down
140 changes: 105 additions & 35 deletions src/untether/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,33 @@ def _rc_label(rc: int) -> str:
# the parser saw. recent_events still records them for diagnostics.
_CONTROL_CHANNEL_EVENT_TYPES = frozenset({"control_request", "control_response"})

# #526 rc20 follow-up: shared with runner_bridge.py for paced
# ``subprocess.approval_pending`` INFO emission. The user-side stall
# detector (bridge) and the watchdog-side liveness detector (here)
# both honour the same 30-min refire window so operators see at most
# one INFO per session per 30 min of an approval-waiting state.
_APPROVAL_PENDING_REFIRE_S = 1800.0


def _recent_event_is_control_request(stream: JsonlStreamState) -> bool:
"""True if the most recent JSONL event in the ring buffer is a
Claude ``control_request`` frame β€” i.e. the session is awaiting an
approval response on the control channel.

Used by ``_watchdog_loop`` to demote ``subprocess.liveness_stall``
WARN β†’ ``subprocess.approval_pending`` INFO, mirroring the bridge-side
behaviour added in rc19. The bridge-side predicate inspects the
inline-keyboard payload of the most recent action; the watchdog has
no access to bridge state, so it consults the JSONL event stream
directly. Both signals agree in the common case where Claude emitted
a ``control_request`` and we're waiting for the user to click a
button (or otherwise resolve the approval).
"""
if not stream.recent_events:
return False
_, label = stream.recent_events[-1]
return label == "control_request"


def _classify_jsonl_event(raw: Any) -> str:
"""Return "tool_result" | "assistant" | "other" for a decoded JSONL event.
Expand Down Expand Up @@ -1062,6 +1089,11 @@ async def _subprocess_watchdog(

liveness_warned = False
prev_diag = None
# #526 rc20 follow-up: pace ``subprocess.approval_pending`` INFO
# so the watchdog emits at most once per 30 min while the user
# deliberates. Tracked as a local rather than on the stream so
# the lifetime matches the watchdog loop (per-subprocess).
last_approval_pending_emit_at: float = 0.0

# Poll until the process is dead or the reader finishes.
while not reader_done.is_set():
Expand Down Expand Up @@ -1092,46 +1124,84 @@ async def _subprocess_watchdog(
):
idle = time.monotonic() - stream.last_stdout_at
if idle >= self._LIVENESS_TIMEOUT_SECONDS:
liveness_warned = True
stream.liveness_stalls += 1
diag = collect_proc_diag(pid)
cpu_active = is_cpu_active(prev_diag, diag)
recent = list(stream.recent_events)[-5:]
logger.warning(
"subprocess.liveness_stall",
pid=pid,
idle_seconds=round(idle, 1),
event_count=stream.event_count,
last_event_type=stream.last_event_type,
tcp_established=diag.tcp_established if diag else None,
rss_kb=diag.rss_kb if diag else None,
cpu_active=cpu_active,
recent_events=[(round(t, 1), lbl) for t, lbl in recent],
)
# Auto-kill: config enabled + zero TCP + CPU NOT active
if (
self._stall_auto_kill
and diag is not None
and diag.tcp_established == 0
and diag.alive
and cpu_active is not True
):
# #526 rc20 follow-up: when the most recent JSONL
# event is a ``control_request``, the subprocess
# is awaiting a user approval β€” emit a paced
# ``subprocess.approval_pending`` INFO instead of
# the ``subprocess.liveness_stall`` WARN. Skip the
# auto-kill branch entirely (approval-waiting is
# by definition not a hang). Without latching
# ``liveness_warned`` so a later genuine hang
# (post-approval) can still fire the WARN.
if _recent_event_is_control_request(stream):
now = time.monotonic()
if (
last_approval_pending_emit_at == 0.0
or now - last_approval_pending_emit_at
>= _APPROVAL_PENDING_REFIRE_S
):
last_approval_pending_emit_at = now
diag = collect_proc_diag(pid)
cpu_active = is_cpu_active(prev_diag, diag)
recent = list(stream.recent_events)[-5:]
logger.info(
"subprocess.approval_pending",
pid=pid,
idle_seconds=round(idle, 1),
event_count=stream.event_count,
last_event_type=stream.last_event_type,
cpu_active=cpu_active,
recent_events=[(round(t, 1), lbl) for t, lbl in recent],
approval_pending=True,
source="watchdog",
)
prev_diag = diag
else:
liveness_warned = True
stream.liveness_stalls += 1
diag = collect_proc_diag(pid)
cpu_active = is_cpu_active(prev_diag, diag)
recent = list(stream.recent_events)[-5:]
logger.warning(
"subprocess.liveness_kill",
"subprocess.liveness_stall",
pid=pid,
reason="zero_tcp_zero_cpu",
idle_seconds=round(idle, 1),
event_count=stream.event_count,
last_event_type=stream.last_event_type,
tcp_established=diag.tcp_established if diag else None,
rss_kb=diag.rss_kb if diag else None,
cpu_active=cpu_active,
recent_events=[(round(t, 1), lbl) for t, lbl in recent],
approval_pending=False,
)
try:
_os.killpg(pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError) as e:
logger.debug(
"subprocess.watchdog.suppressed",
# Auto-kill: config enabled + zero TCP + CPU NOT active
if (
self._stall_auto_kill
and diag is not None
and diag.tcp_established == 0
and diag.alive
and cpu_active is not True
):
logger.warning(
"subprocess.liveness_kill",
pid=pid,
error=str(e),
error_type=e.__class__.__name__,
context="liveness_kill",
reason="zero_tcp_zero_cpu",
)
prev_diag = diag
try:
_os.killpg(pid, signal.SIGKILL)
except (
ProcessLookupError,
PermissionError,
OSError,
) as e:
logger.debug(
"subprocess.watchdog.suppressed",
pid=pid,
error=str(e),
error_type=e.__class__.__name__,
context="liveness_kill",
)
prev_diag = diag

await anyio.sleep(self._WATCHDOG_POLL_SECONDS)
if stream.did_emit_completed or reader_done.is_set():
Expand Down
Loading
Loading