Merge remote-tracking branch 'origin/main' into bb/gui

# Conflicts:
#       apps/dashboard/package-lock.json
#       apps/dashboard/package.json
#       apps/dashboard/src/components/BottomPickSheet.tsx
#       apps/dashboard/src/hooks/useBelowBreakpoint.ts
#       gateway/platforms/telegram.py
#       hermes_cli/gateway.py
#       hermes_cli/web_server.py
#       nix/web.nix
#       scripts/install.ps1
#       tests/gateway/test_telegram_thread_fallback.py
#       tui_gateway/server.py
This commit is contained in:
emozilla
2026-05-20 01:35:02 -04:00
463 changed files with 36101 additions and 5318 deletions
+141 -34
View File
@@ -2363,9 +2363,15 @@ def _truncate_token(value: Optional[str], visible: int = 6) -> str:
OAuth access token. JWT prefixes (the part before the first dot) are
stripped first when present so the visible suffix is always part of
the signing region rather than a meaningless header chunk.
Returns the Entra-ID placeholder when handed a callable (Azure Foundry
bearer provider) — the callable is NEVER invoked here.
"""
if not value:
return ""
if callable(value) and not isinstance(value, str):
# Entra ID bearer provider — never reveal a minted token in the UI.
return "<entra-id-bearer>"
s = str(value)
if "." in s and s.count(".") >= 2:
# Looks like a JWT — show the trailing piece of the signature only.
@@ -3716,85 +3722,181 @@ class CronJobUpdate(BaseModel):
updates: dict
@app.get("/api/cron/jobs")
async def list_cron_jobs():
from cron.jobs import list_jobs
_CRON_PROFILE_LOCK = threading.RLock()
return list_jobs(include_disabled=True)
def _cron_profile_dicts() -> List[Dict[str, Any]]:
"""Return dashboard profile records, falling back to a directory scan."""
from hermes_cli import profiles as profiles_mod
try:
return [_profile_to_dict(p) for p in profiles_mod.list_profiles()]
except Exception:
_log.exception("Failed to list profiles for cron dashboard; falling back to directory scan")
return _fallback_profile_dicts(profiles_mod)
def _cron_profile_home(profile: Optional[str]) -> Tuple[str, Path]:
"""Resolve a profile query value to (profile_name, HERMES_HOME)."""
from hermes_cli import profiles as profiles_mod
raw = (profile or "default").strip() or "default"
try:
canon = profiles_mod.normalize_profile_name(raw)
profiles_mod.validate_profile_name(canon)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
if not profiles_mod.profile_exists(canon):
raise HTTPException(status_code=404, detail=f"Profile '{canon}' does not exist.")
return canon, profiles_mod.get_profile_dir(canon)
def _annotate_cron_job(job: Dict[str, Any], profile: str, home: Path) -> Dict[str, Any]:
annotated = dict(job)
annotated["profile"] = profile
annotated["profile_name"] = profile
annotated["hermes_home"] = str(home)
annotated["is_default_profile"] = profile == "default"
return annotated
def _call_cron_for_profile(profile: Optional[str], func_name: str, *args, **kwargs):
"""Run cron.jobs helpers against the selected profile's cron directory.
cron.jobs keeps CRON_DIR/JOBS_FILE/OUTPUT_DIR as module globals resolved
from the process HERMES_HOME at import time. The dashboard is a single
process that can inspect many profiles, so temporarily retarget those
globals while holding a lock and restore them immediately after the call.
"""
profile_name, home = _cron_profile_home(profile)
with _CRON_PROFILE_LOCK:
from cron import jobs as cron_jobs
old_cron_dir = cron_jobs.CRON_DIR
old_jobs_file = cron_jobs.JOBS_FILE
old_output_dir = cron_jobs.OUTPUT_DIR
cron_jobs.CRON_DIR = home / "cron"
cron_jobs.JOBS_FILE = cron_jobs.CRON_DIR / "jobs.json"
cron_jobs.OUTPUT_DIR = cron_jobs.CRON_DIR / "output"
try:
result = getattr(cron_jobs, func_name)(*args, **kwargs)
finally:
cron_jobs.CRON_DIR = old_cron_dir
cron_jobs.JOBS_FILE = old_jobs_file
cron_jobs.OUTPUT_DIR = old_output_dir
if isinstance(result, list):
return [_annotate_cron_job(j, profile_name, home) for j in result]
if isinstance(result, dict):
return _annotate_cron_job(result, profile_name, home)
return result
def _find_cron_job_profile(job_id: str) -> Optional[str]:
for profile in _cron_profile_dicts():
name = str(profile.get("name") or "")
if not name:
continue
jobs = _call_cron_for_profile(name, "list_jobs", True)
if any(j.get("id") == job_id or j.get("name") == job_id for j in jobs):
return name
return None
@app.get("/api/cron/jobs")
async def list_cron_jobs(profile: str = "all"):
requested = (profile or "all").strip()
if requested.lower() != "all":
return _call_cron_for_profile(requested, "list_jobs", True)
jobs: List[Dict[str, Any]] = []
for item in _cron_profile_dicts():
name = str(item.get("name") or "")
if not name:
continue
try:
jobs.extend(_call_cron_for_profile(name, "list_jobs", True))
except Exception:
_log.exception("Failed to list cron jobs for profile %s", name)
return jobs
@app.get("/api/cron/jobs/{job_id}")
async def get_cron_job(job_id: str):
from cron.jobs import get_job
job = get_job(job_id)
async def get_cron_job(job_id: str, profile: Optional[str] = None):
selected = profile or _find_cron_job_profile(job_id)
if not selected:
raise HTTPException(status_code=404, detail="Job not found")
job = _call_cron_for_profile(selected, "get_job", job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job
@app.post("/api/cron/jobs")
async def create_cron_job(body: CronJobCreate):
from cron.jobs import create_job
async def create_cron_job(body: CronJobCreate, profile: str = "default"):
try:
job = create_job(
return _call_cron_for_profile(
profile,
"create_job",
prompt=body.prompt,
schedule=body.schedule,
name=body.name,
deliver=body.deliver,
)
return job
except Exception as e:
_log.exception("POST /api/cron/jobs failed")
raise HTTPException(status_code=400, detail=str(e))
@app.put("/api/cron/jobs/{job_id}")
async def update_cron_job(job_id: str, body: CronJobUpdate):
from cron.jobs import update_job
job = update_job(job_id, body.updates)
async def update_cron_job(job_id: str, body: CronJobUpdate, profile: Optional[str] = None):
selected = profile or _find_cron_job_profile(job_id)
if not selected:
raise HTTPException(status_code=404, detail="Job not found")
job = _call_cron_for_profile(selected, "update_job", job_id, body.updates)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job
@app.post("/api/cron/jobs/{job_id}/pause")
async def pause_cron_job(job_id: str):
from cron.jobs import pause_job
job = pause_job(job_id)
async def pause_cron_job(job_id: str, profile: Optional[str] = None):
selected = profile or _find_cron_job_profile(job_id)
if not selected:
raise HTTPException(status_code=404, detail="Job not found")
job = _call_cron_for_profile(selected, "pause_job", job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job
@app.post("/api/cron/jobs/{job_id}/resume")
async def resume_cron_job(job_id: str):
from cron.jobs import resume_job
job = resume_job(job_id)
async def resume_cron_job(job_id: str, profile: Optional[str] = None):
selected = profile or _find_cron_job_profile(job_id)
if not selected:
raise HTTPException(status_code=404, detail="Job not found")
job = _call_cron_for_profile(selected, "resume_job", job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job
@app.post("/api/cron/jobs/{job_id}/trigger")
async def trigger_cron_job(job_id: str):
from cron.jobs import trigger_job
job = trigger_job(job_id)
async def trigger_cron_job(job_id: str, profile: Optional[str] = None):
selected = profile or _find_cron_job_profile(job_id)
if not selected:
raise HTTPException(status_code=404, detail="Job not found")
job = _call_cron_for_profile(selected, "trigger_job", job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job
@app.delete("/api/cron/jobs/{job_id}")
async def delete_cron_job(job_id: str):
from cron.jobs import remove_job
if not remove_job(job_id):
async def delete_cron_job(job_id: str, profile: Optional[str] = None):
selected = profile or _find_cron_job_profile(job_id)
if not selected:
raise HTTPException(status_code=404, detail="Job not found")
if not _call_cron_for_profile(selected, "remove_job", job_id):
raise HTTPException(status_code=404, detail="Job not found")
return {"ok": True}
@@ -4479,6 +4581,7 @@ def _resolve_chat_argv(
# build unchanged for native CLI usage; only disable mouse tracking for
# the dashboard PTY path.
env.setdefault("HERMES_TUI_DISABLE_MOUSE", "1")
env.setdefault("HERMES_TUI_INLINE", "1")
if resume:
latest_resume, _latest_path = _session_latest_descendant(resume)
@@ -5807,7 +5910,11 @@ async def serve_plugin_asset(plugin_name: str, file_path: str):
".woff": "font/woff",
}
media_type = content_types.get(suffix, "application/octet-stream")
return FileResponse(target, media_type=media_type)
return FileResponse(
target,
media_type=media_type,
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
)
def _mount_plugin_api_routes():