# Conflicts: # .gitignore # Dockerfile # agent/onboarding.py # apps/desktop/electron/main.ts # apps/desktop/electron/pool-stop.ts # apps/desktop/src/components/model-picker.test.tsx # apps/desktop/src/store/updates.ts # apps/desktop/vite.config.ts # datagen-config-examples/run_browser_tasks.sh # docs/rca-ssl-cacert-post-git-pull.md # gateway/run.py # hermes_cli/backup.py # hermes_cli/credential_lifecycle.py # hermes_cli/dashboard_procs.py # hermes_cli/doctor_state.py # hermes_cli/env_loader.py # hermes_cli/gateway_windows.py # hermes_cli/local_runtime/endpoint.py # hermes_cli/psutil_android.py # hermes_cli/update_cmd.py # hermes_cli/update_cmd_windows.py # hermes_cli/web_routers/local_models.py # hermes_cli/web_server_config.py # hermes_cli/web_server_cron.py # plugins/memory/hindsight/__init__.py # plugins/memory/holographic/__init__.py # plugins/memory/honcho/cli.py # plugins/memory/mem0/__init__.py # plugins/platforms/google_chat/oauth.py # plugins/platforms/photon/adapter.py # scripts/ci/list_os_marked_tests.py # scripts/run_tests.sh # tests/agent/test_compression_stall_fallback.py # tests/agent/test_create_openai_client_ssl_verify.py # tests/gateway/test_google_chat_oauth_dependencies.py # tests/hermes_cli/conftest.py # tests/hermes_cli/test_cli_init.py # tests/hermes_cli/test_gateway_migrate_multiplex.py # tests/hermes_cli/test_psutil_android_extract.py # tests/hermes_cli/test_relaunch.py # tests/hermes_cli/test_update_check.py # tests/hermes_cli/test_update_handoff_desktop_rebuild.py # tests/hermes_cli/test_worktree_gc.py # tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py # tests/scripts/desktop_update/test_desktop_update_windows_retry_policy.py # tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py # tests/scripts/install/test_install_autostash_conflict_recovery.py # tests/scripts/install/test_install_clone_throttle_fallback.py # tests/scripts/install/test_install_commit_pin_rollback.py # tests/scripts/install/test_install_diverged_update.py # tests/scripts/install/test_install_lockfile_churn.py # tests/scripts/install/test_install_macos_launcher.py # tests/scripts/install/test_install_no_initial_commit.py # tests/scripts/install/test_install_ps1_ascii_only.py # tests/scripts/install/test_install_ps1_browser_install.py # tests/scripts/install/test_install_ps1_managed_node_swap.py # tests/scripts/install/test_install_ps1_native_stderr_eap.py # tests/scripts/install/test_install_ps1_node_path_for_npm.py # tests/scripts/install/test_install_ps1_python_fallback_venv.py # tests/scripts/install/test_install_ps1_resolver_strictmode.py # tests/scripts/install/test_install_ps1_uv_install_fallback.py # tests/scripts/install/test_install_ps1_uv_powershell_host.py # tests/scripts/install/test_install_ps1_venv_process_tree.py # tests/scripts/install/test_install_ps1_venv_recreate_safety.py # tests/scripts/install/test_install_ps1_venv_rename_abort.py # tests/scripts/install/test_install_ps1_venv_transaction_boundary.py # tests/scripts/install/test_install_ps1_web_server_syntax_probe.py # tests/scripts/install/test_install_scripts_computer_use.py # tests/scripts/install/test_install_sh_acp_launcher.py # tests/scripts/install/test_install_sh_bootstrap_marker.py # tests/scripts/install/test_install_sh_browser_install.py # tests/scripts/install/test_install_sh_install_method_stamp.py # tests/scripts/install/test_install_sh_node_deps_failure.py # tests/scripts/install/test_install_sh_node_deps_workspaces.py # tests/scripts/install/test_install_sh_node_global_prefix.py # tests/scripts/install/test_install_sh_node_npm_check.py # tests/scripts/install/test_install_sh_node_prerelease.py # tests/scripts/install/test_install_sh_node_probe.py # tests/scripts/install/test_install_sh_node_tarball_without_xz.py # tests/scripts/install/test_install_sh_pythonpath_sanitization.py # tests/scripts/install/test_install_sh_reuse_supported_python.py # tests/scripts/install/test_install_sh_root_fhs_uv_python_path.py # tests/scripts/install/test_install_sh_setup_wizard_tty_probe.py # tests/scripts/install/test_install_sh_symlink_stomp.py # tests/scripts/install/test_install_sh_termux_network_prereqs.py # tests/scripts/install/test_install_sh_termux_python_bounds.py # tests/scripts/install/test_install_sh_uv_lock_config.py # tests/scripts/install/test_install_unmerged_index.py # tests/scripts/test_run_tests_parallel.py # tests/test_managed_runtime_resolution.py # tests/test_project_metadata.py # tests/tools/test_browser_use_cli.py # tests/tools/test_tts_pythonpath_fallback.py # tests/tui_gateway/test_hosted_room_driver_runtime.py # tests/tui_gateway/test_tui_gateway_server.py # tools/lazy_deps.py # tools/voice_mode.py # uv.lock # website/docs/developer-guide/macos-bundle-updates.md # website/docs/developer-guide/pm-audit-status.md # website/docs/developer-guide/shared-bundle-builds.md # website/docs/developer-guide/source-update-completion.md # website/docs/developer-guide/stable-releases.md
202 lines
7.9 KiB
Python
202 lines
7.9 KiB
Python
"""Subprocess lifecycle manager for the google_meet bot.
|
|
|
|
One active meeting at a time, recorded in ``$HERMES_HOME/workspace/meetings/.active.json``
|
|
(``pid, meeting_id, out_dir, url, started_at, session_id, log_path, mode``) so tool calls
|
|
across turns can find the bot. The bot is a detached subprocess reached via files only
|
|
(``<meeting-id>/status.json``, ``<meeting-id>/transcript.txt``), so the agent loop can't block.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import json
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional
|
|
|
|
from hermes_constants import get_hermes_home
|
|
|
|
from plugins.google_meet._jsonfile import read_json
|
|
from utils import atomic_json_write
|
|
|
|
|
|
def _root() -> Path:
|
|
return Path(get_hermes_home()) / "workspace" / "meetings"
|
|
|
|
|
|
def _active_file() -> Path:
|
|
return _root() / ".active.json"
|
|
|
|
|
|
def _read_active() -> Optional[Dict[str, Any]]:
|
|
p = _active_file()
|
|
if not p.is_file():
|
|
return None
|
|
try:
|
|
return json.loads(p.read_text(encoding="utf-8-sig"))
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _write_active(data: Dict[str, Any]) -> None:
|
|
atomic_json_write(_active_file(), data)
|
|
|
|
|
|
def _pid_alive(pid: int) -> bool:
|
|
# Not ``os.kill(pid, 0)``: on Windows that can kill the target (bpo-14484).
|
|
from gateway.status import _pid_exists
|
|
return bool(pid) and _pid_exists(pid)
|
|
|
|
|
|
def _kill(pid: int, sig) -> None:
|
|
with contextlib.suppress(ProcessLookupError):
|
|
os.kill(pid, sig)
|
|
|
|
|
|
_NO_ACTIVE = {"ok": False, "reason": "no active meeting"}
|
|
|
|
|
|
def start(url: str, *, out_dir: Optional[Path] = None, headed: bool = False,
|
|
auth_state: Optional[str] = None, guest_name: str = "Hermes Agent", duration: Optional[str] = None,
|
|
session_id: Optional[str] = None, mode: str = "transcribe", realtime_model: Optional[str] = None,
|
|
realtime_voice: Optional[str] = None, realtime_instructions: Optional[str] = None,
|
|
realtime_api_key: Optional[str] = None) -> Dict[str, Any]:
|
|
"""Spawn the meet_bot subprocess for *url*, stopping any running bot first (one active meeting)."""
|
|
from plugins.google_meet.meet_bot import _is_safe_meet_url, _meeting_id_from_url
|
|
if not _is_safe_meet_url(url):
|
|
return {"ok": False, "error": "refusing: only https://meet.google.com/ URLs are allowed. got: " + repr(url)}
|
|
if _pid_alive(int((_read_active() or {}).get("pid", 0))):
|
|
stop(reason="replaced by new meet_join")
|
|
meeting_id = _meeting_id_from_url(url)
|
|
out = out_dir or (_root() / meeting_id)
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
# Wipe stale files from a previous run of this meeting id.
|
|
for name in ("transcript.txt", "status.json"):
|
|
with contextlib.suppress(OSError):
|
|
(out / name).unlink()
|
|
env = {**os.environ, "HERMES_MEET_URL": url, "HERMES_MEET_OUT_DIR": str(out),
|
|
"HERMES_MEET_GUEST_NAME": guest_name}
|
|
for value, var in (
|
|
(headed and "1", "HERMES_MEET_HEADED"),
|
|
(auth_state, "HERMES_MEET_AUTH_STATE"),
|
|
(duration, "HERMES_MEET_DURATION"),
|
|
(mode, "HERMES_MEET_MODE"), # bot defaults to transcribe when unset (v1 behavior)
|
|
(realtime_model, "HERMES_MEET_REALTIME_MODEL"),
|
|
(realtime_voice, "HERMES_MEET_REALTIME_VOICE"),
|
|
(realtime_instructions, "HERMES_MEET_REALTIME_INSTRUCTIONS")):
|
|
if value:
|
|
env[var] = value
|
|
# Resolve the realtime key at SPAWN time in the parent, where the profile secret scope
|
|
# (a contextvar) is installed; the detached child inherits env, not scope.
|
|
if not realtime_api_key:
|
|
from agent.secret_scope import get_secret
|
|
realtime_api_key = get_secret("HERMES_MEET_REALTIME_KEY") or get_secret("OPENAI_API_KEY")
|
|
if realtime_api_key:
|
|
env["HERMES_MEET_REALTIME_KEY"] = realtime_api_key
|
|
log_path = out / "bot.log"
|
|
# Detach: stdout/stderr → log file, new session so parent signals don't propagate.
|
|
with open(log_path, "ab", buffering=0) as log_fh:
|
|
proc = subprocess.Popen([sys.executable, "-m", "plugins.google_meet.meet_bot"], stdin=subprocess.DEVNULL,
|
|
stdout=log_fh, stderr=subprocess.STDOUT, env=env, start_new_session=True,
|
|
close_fds=True)
|
|
record = {"pid": proc.pid, "meeting_id": meeting_id, "out_dir": str(out), "url": url,
|
|
"started_at": time.time(), "session_id": session_id, "log_path": str(log_path), "mode": mode}
|
|
_write_active(record)
|
|
return {"ok": True, **record}
|
|
|
|
|
|
def status() -> Dict[str, Any]:
|
|
"""Return the current meeting state, or ``{"ok": False, "reason": ...}``."""
|
|
active = _read_active()
|
|
if not active:
|
|
return dict(_NO_ACTIVE)
|
|
pid = int(active.get("pid", 0))
|
|
alive = _pid_alive(pid) if pid else False
|
|
|
|
status_path = Path(active.get("out_dir", "")) / "status.json"
|
|
bot_status: Dict[str, Any] = {}
|
|
if status_path.is_file():
|
|
try:
|
|
bot_status = json.loads(status_path.read_text(encoding="utf-8-sig"))
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"ok": True,
|
|
"alive": alive,
|
|
"pid": pid,
|
|
"meetingId": active.get("meeting_id"),
|
|
"url": active.get("url"),
|
|
"startedAt": active.get("started_at"),
|
|
"outDir": active.get("out_dir"),
|
|
**bot_status,
|
|
}
|
|
|
|
|
|
def transcript(last: Optional[int] = None) -> Dict[str, Any]:
|
|
"""Read the current transcript file (empty result if the bot hasn't written one yet)."""
|
|
active = _read_active()
|
|
if not active:
|
|
return dict(_NO_ACTIVE)
|
|
tp = Path(active.get("out_dir", "")) / "transcript.txt"
|
|
if not tp.is_file():
|
|
return {
|
|
"ok": True,
|
|
"meetingId": active.get("meeting_id"),
|
|
"lines": [],
|
|
"total": 0,
|
|
"path": str(tp),
|
|
}
|
|
text = tp.read_text(encoding="utf-8-sig", errors="replace")
|
|
all_lines = [ln for ln in text.splitlines() if ln.strip()]
|
|
return {"ok": True, "meetingId": active.get("meeting_id"),
|
|
"lines": all_lines[-last:] if last else all_lines, "total": len(all_lines), "path": str(tp)}
|
|
|
|
|
|
def enqueue_say(text: str) -> Dict[str, Any]:
|
|
"""Append a ``say`` request to ``<out_dir>/say_queue.jsonl``.
|
|
Refused when no meeting is active or the active bot is transcribe-only."""
|
|
text = (text or "").strip()
|
|
if not text:
|
|
return {"ok": False, "reason": "text is required"}
|
|
active = _read_active()
|
|
if not active:
|
|
return dict(_NO_ACTIVE)
|
|
if active.get("mode") != "realtime":
|
|
return {"ok": False, "reason": ("active meeting is in transcribe mode — pass mode='realtime' "
|
|
"to meet_join to enable agent speech")}
|
|
out_dir = Path(active.get("out_dir", ""))
|
|
if not out_dir.is_dir():
|
|
return {"ok": False, "reason": f"out_dir missing: {out_dir}"}
|
|
queue_path = out_dir / "say_queue.jsonl"
|
|
entry = {"id": uuid.uuid4().hex[:12], "text": text}
|
|
with queue_path.open("a", encoding="utf-8") as f:
|
|
f.write(json.dumps(entry) + "\n")
|
|
return {"ok": True, "meetingId": active.get("meeting_id"), "enqueued_id": entry["id"],
|
|
"queue_path": str(queue_path)}
|
|
|
|
|
|
def stop(*, reason: str = "requested") -> Dict[str, Any]:
|
|
"""SIGTERM the active bot (SIGKILL after 10s), then clear the active pointer."""
|
|
active = _read_active()
|
|
if not active:
|
|
return dict(_NO_ACTIVE)
|
|
pid = int(active.get("pid", 0))
|
|
out_dir = active.get("out_dir")
|
|
if _pid_alive(pid):
|
|
_kill(pid, signal.SIGTERM)
|
|
for _ in range(20):
|
|
if not _pid_alive(pid):
|
|
break
|
|
time.sleep(0.5)
|
|
else:
|
|
_kill(pid, signal.SIGKILL) # windows-footgun: ok — POSIX-only plugin (google_meet registers no-op on Windows; see __init__.py)
|
|
_active_file().unlink(missing_ok=True)
|
|
return {"ok": True, "reason": reason, "meetingId": active.get("meeting_id"),
|
|
"transcriptPath": str(Path(out_dir) / "transcript.txt") if out_dir else None}
|