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
81 changes: 51 additions & 30 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1171,6 +1171,45 @@ def _resolve_attachment_path(raw_path: str) -> Path | None:
return resolved


def _format_process_notification(evt: dict) -> "str | None":
"""Format a process notification event into a [SYSTEM: ...] message.

Handles both completion events (notify_on_complete) and watch pattern
match events from the unified completion_queue.
"""
evt_type = evt.get("type", "completion")
_sid = evt.get("session_id", "unknown")
_cmd = evt.get("command", "unknown")

if evt_type == "watch_disabled":
return f"[SYSTEM: {evt.get('message', '')}]"

if evt_type == "watch_match":
_pat = evt.get("pattern", "?")
_out = evt.get("output", "")
_sup = evt.get("suppressed", 0)
text = (
f"[SYSTEM: Background process {_sid} matched "
f"watch pattern \"{_pat}\".\n"
f"Command: {_cmd}\n"
f"Matched output:\n{_out}"
)
if _sup:
text += f"\n({_sup} earlier matches were suppressed by rate limit)"
text += "]"
return text

# Default: completion event
_exit = evt.get("exit_code", "?")
_out = evt.get("output", "")
return (
f"[SYSTEM: Background process {_sid} completed "
f"(exit code {_exit}).\n"
f"Command: {_cmd}\n"
f"Output:\n{_out}]"
)


def _detect_file_drop(user_input: str) -> "dict | None":
"""Detect if *user_input* starts with a real local file path.

Expand Down Expand Up @@ -8870,23 +8909,15 @@ def process_loop():
# Periodic config watcher — auto-reload MCP on mcp_servers change
if not self._agent_running:
self._check_config_mcp_changes()
# Check for background process completion notifications
# while the agent is idle (user hasn't typed anything yet).
# Check for background process notifications (completions
# and watch pattern matches) while agent is idle.
try:
from tools.process_registry import process_registry
if not process_registry.completion_queue.empty():
completion = process_registry.completion_queue.get_nowait()
_exit = completion.get("exit_code", "?")
_cmd = completion.get("command", "unknown")
_sid = completion.get("session_id", "unknown")
_out = completion.get("output", "")
_synth = (
f"[SYSTEM: Background process {_sid} completed "
f"(exit code {_exit}).\n"
f"Command: {_cmd}\n"
f"Output:\n{_out}]"
)
self._pending_input.put(_synth)
evt = process_registry.completion_queue.get_nowait()
_synth = _format_process_notification(evt)
if _synth:
self._pending_input.put(_synth)
except Exception:
pass
continue
Expand Down Expand Up @@ -9004,25 +9035,15 @@ def _restart_recording():
_cprint(f"{_DIM}Voice auto-restart failed: {e}{_RST}")
threading.Thread(target=_restart_recording, daemon=True).start()

# Drain process completion notifications — any background
# process that finished with notify_on_complete while the
# agent was running (or before) gets auto-injected as a
# new user message so the agent can react to it.
# Drain process notifications (completions + watch matches)
# that arrived while the agent was running.
try:
from tools.process_registry import process_registry
while not process_registry.completion_queue.empty():
completion = process_registry.completion_queue.get_nowait()
_exit = completion.get("exit_code", "?")
_cmd = completion.get("command", "unknown")
_sid = completion.get("session_id", "unknown")
_out = completion.get("output", "")
_synth = (
f"[SYSTEM: Background process {_sid} completed "
f"(exit code {_exit}).\n"
f"Command: {_cmd}\n"
f"Output:\n{_out}]"
)
self._pending_input.put(_synth)
evt = process_registry.completion_queue.get_nowait()
_synth = _format_process_notification(evt)
if _synth:
self._pending_input.put(_synth)
except Exception:
pass # Non-fatal — don't break the main loop

Expand Down
80 changes: 80 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,33 @@ def _resolve_hermes_bin() -> Optional[list[str]]:
return None


def _format_gateway_process_notification(evt: dict) -> "str | None":
"""Format a watch pattern event from completion_queue into a [SYSTEM:] message."""
evt_type = evt.get("type", "completion")
_sid = evt.get("session_id", "unknown")
_cmd = evt.get("command", "unknown")

if evt_type == "watch_disabled":
return f"[SYSTEM: {evt.get('message', '')}]"

if evt_type == "watch_match":
_pat = evt.get("pattern", "?")
_out = evt.get("output", "")
_sup = evt.get("suppressed", 0)
text = (
f"[SYSTEM: Background process {_sid} matched "
f"watch pattern \"{_pat}\".\n"
f"Command: {_cmd}\n"
f"Matched output:\n{_out}"
)
if _sup:
text += f"\n({_sup} earlier matches were suppressed by rate limit)"
text += "]"
return text

return None


class GatewayRunner:
"""
Main gateway controller.
Expand Down Expand Up @@ -3430,6 +3457,29 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):
except Exception as e:
logger.error("Process watcher setup error: %s", e)

# Drain watch pattern notifications that arrived during the agent run.
# Watch events and completions share the same queue; completions are
# already handled by the per-process watcher task above, so we only
# inject watch-type events here.
try:
from tools.process_registry import process_registry as _pr
_watch_events = []
while not _pr.completion_queue.empty():
evt = _pr.completion_queue.get_nowait()
evt_type = evt.get("type", "completion")
if evt_type in ("watch_match", "watch_disabled"):
_watch_events.append(evt)
# else: completion events are handled by the watcher task
for evt in _watch_events:
synth_text = _format_gateway_process_notification(evt)
if synth_text:
try:
await self._inject_watch_notification(synth_text, event)
except Exception as e2:
logger.error("Watch notification injection error: %s", e2)
except Exception as e:
logger.debug("Watch queue drain error: %s", e)

# NOTE: Dangerous command approvals are now handled inline by the
# blocking gateway approval mechanism in tools/approval.py. The agent
# thread blocks until the user responds with /approve or /deny, so by
Expand Down Expand Up @@ -6708,6 +6758,36 @@ async def _enrich_message_with_transcription(
return prefix
return user_text

async def _inject_watch_notification(self, synth_text: str, original_event) -> None:
"""Inject a watch-pattern notification as a synthetic message event.

Uses the source from the original user event to route the notification
back to the correct chat/adapter.
"""
source = getattr(original_event, "source", None)
if not source:
return
platform_name = source.platform.value if hasattr(source.platform, "value") else str(source.platform)
adapter = None
for p, a in self.adapters.items():
if p.value == platform_name:
adapter = a
break
if not adapter:
return
try:
from gateway.platforms.base import MessageEvent, MessageType
synth_event = MessageEvent(
text=synth_text,
message_type=MessageType.TEXT,
source=source,
internal=True,
)
logger.info("Watch pattern notification — injecting for %s", platform_name)
await adapter.handle_message(synth_event)
except Exception as e:
logger.error("Watch notification injection error: %s", e)

async def _run_process_watcher(self, watcher: dict) -> None:
"""
Periodically check a background process and push updates to the user.
Expand Down
Loading
Loading