Refactor entry-side deletion refusal to execute in-transaction via `_write_guards_reject(conn, sid)` (#123583), per maintainer review: - Underlying `delete_session` and `delete_sessions` now accept an opt-in kwarg `exclude_active_write_guards=True` running inside `_do` write transaction, eliminating the race condition where a turn acquires the lease between check and delete. - Raises `SessionActiveWriteGuardError` when refusing single delete, leaving the row untouched; `delete_sessions` atomically skips active rows. - Checks both active turn leases and compression locks via the existing reclaim-aware `_write_guards_reject` helper. - Covers all user-facing delete sinks: * Web `DELETE /api/sessions/{id}` -> 409 Conflict * Web `POST /api/sessions/bulk-delete` -> skips active rows * Web / CLI `prune` -> passes `exclude_active_write_guards=True` so lineage parents of active conversations are not pruned * API Server `DELETE /api/sessions/{id}` -> 409 session_active_turn * CLI `hermes sessions delete` & `export --delete-after-verified` -> exits 1 * CLI browse picker -> refuses active delete * TUI Gateway `session.delete` -> 4023 error - Conforms to rubric with 2 targeted invariant tests in `tests/hermes_state/test_delete_session_write_guards.py`. - Updates user guide and web dashboard docs for 409 / exit 1. (cherry picked from commit 2c037a7a79dc211b49bacc72e3140951ccf900cf)
2433 lines
130 KiB
Python
2433 lines
130 KiB
Python
"""Session / delegation / spawn-tree / billing / pet JSON-RPC handlers.
|
||
|
||
Bodies are rebound onto server.py's globals at install time (method_ctx.py), so they use server
|
||
helpers (``_sessions``, ``_ok``, ``_err``, ...) bare; module-level helpers are published onto
|
||
server.py the same way (tests monkeypatching ``server.X`` still intercept)."""
|
||
|
||
import contextlib
|
||
|
||
from .method_ctx import HandlerRegistry, bind_module
|
||
|
||
_registry = HandlerRegistry()
|
||
method = _registry.method
|
||
_profile_scoped = _registry.profile_scoped
|
||
|
||
|
||
# ── shared handler plumbing ──────────────────────────────────────────
|
||
def _session_arg(resolve):
|
||
"""Resolve ``params.session_id`` via ``resolve`` (a lambda — decoration precedes bind_module) → 3rd arg."""
|
||
def deco(fn):
|
||
def handler(rid, params: dict) -> dict:
|
||
session, err = resolve(params, rid)
|
||
return err or fn(rid, params, session)
|
||
return handler
|
||
return deco
|
||
|
||
|
||
_with_session = _session_arg(lambda params, rid: _sess_nowait(params, rid)) # no agent-build wait
|
||
_with_live_session = _session_arg(lambda params, rid: _sess(params, rid)) # waits for the agent build
|
||
|
||
|
||
def _session_method(name: str, *, live: bool = False):
|
||
"""``@method(name)`` over ``_with_live_session`` (waits for the agent build) or ``_with_session``."""
|
||
return lambda fn: method(name)((_with_live_session if live else _with_session)(fn))
|
||
|
||
|
||
def _with_db(code: int, *, session_scoped: bool):
|
||
"""Append a db arg — the session's db (after ``_with_session``) or ``_profile_db(params)``; ``code`` when None."""
|
||
def deco(fn):
|
||
def handler(rid, params: dict, *session) -> dict:
|
||
with (_session_db(session[0]) if session_scoped else _profile_db(params)) as db:
|
||
if db is None:
|
||
return _db_unavailable_error(rid, code=code)
|
||
return fn(rid, params, *session, db)
|
||
return _with_session(handler) if session_scoped else handler
|
||
return deco
|
||
|
||
|
||
def _str_param(params: dict, key: str, default: str = "") -> str:
|
||
"""``str(params[key]).strip()`` with ``default`` for missing / falsy values."""
|
||
return str(params.get(key) or "").strip() or default
|
||
|
||
|
||
def _flag(params: dict, name: str) -> bool:
|
||
return is_truthy_value(params.get(name, False))
|
||
|
||
|
||
def _int_param(params: dict, key: str, default: int) -> int:
|
||
"""``int(params[key])`` with ``default`` for missing / unparsable values."""
|
||
try:
|
||
return int(params.get(key, default))
|
||
except (TypeError, ValueError):
|
||
return default
|
||
|
||
|
||
def _new_runtime_ids(params: dict) -> tuple[str, str]:
|
||
"""Fresh runtime sid + resolved DB ``source`` for a session minted from ``params``."""
|
||
return uuid.uuid4().hex[:8], _resolve_session_source(_str_param(params, "source") or None)
|
||
|
||
|
||
def _profile_build_scope(profile_home):
|
||
"""Bind HERMES_HOME + secret + terminal scope for an agent build: the same composition a turn
|
||
binds (``_session_profile_runtime_scope``). Home alone leaves ``get_secret()`` on the LAUNCH
|
||
``.env``; home + secrets alone leaves ``_make_agent``'s terminal probing on the launch process's
|
||
ambient ``TERMINAL_*`` (a ``terminal.backend: docker`` secondary built a ``local`` agent)."""
|
||
return _session_profile_runtime_scope({"profile_home": str(profile_home) if profile_home else None})
|
||
|
||
|
||
def _make_agent_in_context(sid: str, key: str, **kwargs):
|
||
"""``_make_agent`` with the session context bound for the build and cleared after."""
|
||
tokens = _set_session_context(key, cwd=kwargs.get("cwd_override"))
|
||
try:
|
||
return _make_agent(sid, key, session_id=key, **kwargs)
|
||
finally:
|
||
_clear_session_context(tokens)
|
||
|
||
|
||
def _profile_session_db(profile_home):
|
||
"""``(db, owns)``: a DEDICATED handle on ``profile_home``'s state.db, else the shared launch db."""
|
||
if profile_home:
|
||
from hermes_state_registry import acquire
|
||
return acquire(Path(profile_home) / "state.db"), True
|
||
return _get_db(), False
|
||
|
||
|
||
def _release_db(db) -> None:
|
||
with contextlib.suppress(Exception):
|
||
from hermes_state_registry import release_or_close
|
||
release_or_close(db)
|
||
|
||
|
||
def _branch_title(db, parent_key: str) -> str:
|
||
"""Next title in the parent's lineage (mirrors the TUI /branch naming)."""
|
||
current = db.get_session_title(parent_key) or "branch"
|
||
if hasattr(db, "get_next_title_in_lineage"):
|
||
return db.get_next_title_in_lineage(current)
|
||
return f"{current} (branch)"
|
||
|
||
|
||
def _cwd_info(session: dict, cwd: str, branch=None) -> dict:
|
||
"""session.info after a cwd change: the full agent view, or the lazy shape."""
|
||
if (agent := session.get("agent")) is not None:
|
||
return _session_info(agent, session)
|
||
return {"cwd": cwd, "branch": git_probe.branch(cwd) if branch is None else branch,
|
||
"project": _project_info_for_cwd(cwd), "lazy": True,
|
||
"desktop_contract": DESKTOP_BACKEND_CONTRACT}
|
||
|
||
|
||
def _session_row_summary(row: dict, *, tip_row: dict | None = None, resolved_id=None) -> dict:
|
||
"""Compact session.list row; ``tip_row``/``resolved_id`` come from the compression tip."""
|
||
tip_row = tip_row or row
|
||
return {"id": row["id"], **({} if resolved_id is None else {"resolved_id": resolved_id}),
|
||
"title": row.get("title") or "", "preview": tip_row.get("preview") or "",
|
||
"started_at": row.get("started_at") or 0, "message_count": tip_row.get("message_count") or 0,
|
||
"source": row.get("source") or ""}
|
||
|
||
|
||
from hermes_state_sessions import INTERNAL_LISTING_SOURCES
|
||
|
||
# Hidden from human listings (kanban workers, tool integrations, one-shot runs); see INTERNAL_LISTING_SOURCES.
|
||
_LISTING_DENY_SOURCES = frozenset(INTERNAL_LISTING_SOURCES)
|
||
|
||
|
||
def _denied_source(row: dict) -> bool:
|
||
return (row.get("source") or "").strip().lower() in _LISTING_DENY_SOURCES
|
||
|
||
|
||
def _auto_resume_denied_source(row: dict) -> bool:
|
||
"""``_denied_source`` plus ``source='unknown'``: auto-resume must never land on a
|
||
token-accounting guard placeholder (#54320). The guard mints those rows when legacy
|
||
message rows lack a ``sessions`` row, and such a placeholder can outrank the session
|
||
the user actually opened. Human-facing listings keep showing them (they may be a
|
||
real session awaiting repair); only the pick-a-session-for-me paths skip them."""
|
||
source = (row.get("source") or "").strip().lower()
|
||
return source in _LISTING_DENY_SOURCES or source == "unknown"
|
||
|
||
|
||
def _listing_rows(db, limit: int, **kwargs) -> list:
|
||
"""Human-facing ``list_sessions_rich`` rows (most recent first), deny-list applied."""
|
||
rows = db.list_sessions_rich(source=None, limit=limit, order_by_last_active=True, compact_rows=True, **kwargs)
|
||
return [row for row in rows if not _denied_source(row)]
|
||
|
||
|
||
def _snapshot_sessions(rid):
|
||
"""``(list(_sessions.items()), None)`` under the lock, or ``(None, 5036 error)`` — fail CLOSED."""
|
||
try:
|
||
with _sessions_lock:
|
||
return list(_sessions.items()), None
|
||
except Exception as e:
|
||
return None, _err(rid, 5036, f"could not enumerate active sessions: {e}")
|
||
|
||
|
||
def _pet_display_cfg() -> dict:
|
||
"""``display.pet`` config block, ``{}`` when config is unreadable."""
|
||
try:
|
||
from hermes_cli.config import load_config
|
||
cfg = load_config()
|
||
display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {}
|
||
return display.get("pet", {}) if isinstance(display.get("pet"), dict) else {}
|
||
except Exception:
|
||
return {}
|
||
|
||
|
||
def _pet_emit(event: str, payload: dict, what: str) -> None:
|
||
"""Best-effort progress emit: a transport hiccup must never abort generation."""
|
||
try:
|
||
_emit(event, "", payload)
|
||
except Exception as exc: # noqa: BLE001
|
||
logger.debug("%s emit failed: %s", what, exc)
|
||
|
||
|
||
def _pet_gen_abort(rid, token: str, code: int, message: str) -> dict:
|
||
"""Release the cancel arm for ``token`` and return ``_err``."""
|
||
_pet_cancel_release(token)
|
||
return _err(rid, code, message)
|
||
|
||
|
||
def _pet_method(name: str, *, fail_open=None, slug: bool = False, scoped: bool = True):
|
||
"""``@method`` (+ ``@_profile_scoped`` unless ``scoped=False``) whose exceptions never break the surface: logged
|
||
at debug, then ``fail_open`` (payload or ``params -> payload``) or ``_err(5031)``. ``slug``: 3rd arg (4004)."""
|
||
def deco(fn):
|
||
def handler(rid, params: dict) -> dict:
|
||
try:
|
||
if slug and not (value := _str_param(params, "slug")):
|
||
return _err(rid, 4004, "missing slug")
|
||
return fn(rid, params, value) if slug else fn(rid, params)
|
||
except Exception as exc: # noqa: BLE001 - cosmetic surface
|
||
logger.debug("%s failed: %s", name, exc)
|
||
if fail_open is not None:
|
||
return _ok(rid, fail_open(params) if callable(fail_open) else dict(fail_open))
|
||
return _err(rid, 5031, f"{name} failed: {exc}")
|
||
return method(name)(_profile_scoped(handler) if scoped else handler)
|
||
return deco
|
||
|
||
|
||
def _active_pet():
|
||
"""``(pet, scale)`` when the pet display is enabled and the pet exists, else None."""
|
||
enabled, pet, scale = _pet_active_selection()
|
||
return None if not enabled or pet is None or not pet.exists else (pet, scale)
|
||
|
||
|
||
def _billing_call(rid, fn, extra: dict | None = None) -> dict:
|
||
"""Portal call → ok; BillingError → serialized envelope, else generic; ``extra`` rides both ERROR envelopes."""
|
||
from hermes_cli.nous_billing import BillingError
|
||
try:
|
||
return _ok(rid, fn())
|
||
except BillingError as exc:
|
||
return _ok(rid, {**_serialize_billing_error(exc), **(extra or {})})
|
||
except Exception as exc:
|
||
return _ok(rid, {"ok": False, "error": "error", "message": str(exc), **(extra or {})})
|
||
|
||
|
||
def _billing_invalid(rid, message: str, error: str = "invalid_request") -> dict:
|
||
return _ok(rid, {"ok": False, "error": error, "message": message})
|
||
|
||
|
||
def _billing_pick(result: dict, **fields) -> dict:
|
||
"""``{"ok": True, <snake>: result[<camel>], ...}`` in ``fields`` order."""
|
||
return {"ok": True, **{key: result.get(src) for key, src in fields.items()}}
|
||
|
||
|
||
def _billing_pending_change(result: dict) -> dict:
|
||
return {"ok": True, "message": result.get("message"), "payload": result}
|
||
|
||
|
||
# ── session.create / list / most_recent / facts ──────────────────────
|
||
def _persist_branch(db, new_key: str, parent_key: str, title: str, history: list, *, source, cwd, profile_name,
|
||
model: str, copy_fields=(), compensate: bool = False, title_source: str = "user",
|
||
user_id: str | None = None) -> None:
|
||
"""Branch child row + parent transcript (bounded-chunk transactions) + title. ``_branched_from`` keeps the
|
||
row visible in list_sessions_rich() (the live parent never matches the legacy end_reason='branched'
|
||
heuristic); NULL ``profile_name`` rows drop out of profile-keyed sidebar matching / deep links. ``compensate``
|
||
deletes a committed row whose transcript/title failed (a durable-but-empty row would defeat the INSERT OR
|
||
IGNORE first-prompt seed) — except on disk-full, where the delete cannot land. ``user_id`` is the creating
|
||
login: the child is a Desktop session too, and the row only records identity at insert."""
|
||
# The child sends the parent's exact system prompt: a row without one makes the branch's first
|
||
# turn rebuild (re-probing the workspace) and forfeits the warm cache the copied transcript buys.
|
||
parent_prompt = None
|
||
try:
|
||
parent_prompt = (db.get_session(parent_key) or {}).get("system_prompt")
|
||
except Exception:
|
||
logger.debug("branch: parent system prompt read failed for %s", parent_key, exc_info=True)
|
||
db.create_session(new_key, source=source, model=model, model_config={"_branched_from": parent_key},
|
||
parent_session_id=parent_key, cwd=cwd, profile_name=profile_name, user_id=user_id,
|
||
system_prompt=parent_prompt or None)
|
||
try:
|
||
# Compensation guard (#93959 review): if the transcript copy or title write fails AFTER the row
|
||
# committed, the durable-but-empty row would defeat the lazy first-prompt fallback
|
||
# (_ensure_session_db_row is INSERT OR IGNORE — the row exists, so the seed never lands and the
|
||
# renderer fail-latches on a "transcript-less" session again). Roll back just this child so the seed
|
||
# path can retry cleanly on first submit.
|
||
# Copy the whole parent history in bounded-chunk transactions — a branch seed can be hundreds of
|
||
# rows, and per-row transactions were the write-amplification pattern removed in #23254.
|
||
db.append_messages_batch(
|
||
new_key, [{"role": msg.get("role", "user"), "content": msg.get("content"),
|
||
**{field: msg.get(field) for field in copy_fields}} for msg in history], chunk_rows=500)
|
||
if title_source == "user":
|
||
db.set_session_title(new_key, title)
|
||
else:
|
||
db.set_auto_title(new_key, title, source=title_source)
|
||
except Exception as exc:
|
||
from hermes_state_errors import is_disk_full_error
|
||
if compensate and not is_disk_full_error(exc):
|
||
try:
|
||
db.delete_session(new_key)
|
||
except Exception:
|
||
logger.debug("branch seed compensation delete failed for %s", new_key, exc_info=True)
|
||
raise
|
||
|
||
|
||
def _seed_branch_row(record: dict, key: str, parent_session_id: str, history: list, source: str, profile_home):
|
||
"""Persist a seeded desktop branch child NOW (the one session.create exception to lazy rows): the
|
||
renderer's post-create resume re-fetches it via REST/defer_history, so an unpersisted child 404s and
|
||
the fail-latch spins forever. Best-effort — on failure the lazy first-prompt path is the fallback."""
|
||
try:
|
||
with _session_db(record) as db:
|
||
if db is None:
|
||
return
|
||
_persist_branch(db, key, parent_session_id, _branch_title(db, parent_session_id), history,
|
||
source=source, cwd=record["cwd"],
|
||
profile_name=profile_name_for_home(profile_home) or _current_profile_name(),
|
||
model=_session_default_model(record), compensate=True, title_source="derived", user_id=_session_auth_user_id(record))
|
||
record["pending_title"] = None
|
||
# The first submit's _persist_branch_seed is the fallback for a failed seed, not a second copy.
|
||
record["_branch_seed_persisted"] = True
|
||
except Exception:
|
||
logger.warning("seeded-branch persistence failed for %s; falling back to lazy row creation", key,
|
||
exc_info=True)
|
||
|
||
|
||
def _seed_row(record: dict) -> None:
|
||
"""Persist a parentless seeded session NOW, for the reason ``_seed_branch_row`` gives: seeded content is
|
||
intent, not an abandoned draft, and the renderer's post-create hydration reads the DB. The client's title
|
||
lands with the row so a restart before the first prompt keeps it. Best-effort — the first-prompt path is
|
||
the fallback, and it re-copies the WHOLE seed, so a partial copy is rolled back here (the compensation
|
||
``_persist_branch`` applies to branch children) rather than left to be duplicated."""
|
||
key = record.get("session_key")
|
||
try:
|
||
if _ensure_session_db_row(record) is False:
|
||
return
|
||
_persist_branch_seed(record)
|
||
except Exception:
|
||
logger.warning("seeded-session persistence failed for %s; falling back to lazy row creation", key, exc_info=True)
|
||
if not record.get("_branch_seed_persisted"):
|
||
with contextlib.suppress(Exception), _session_db(record) as db:
|
||
if db is not None:
|
||
db.delete_session(key)
|
||
return
|
||
try:
|
||
if title := record.get("pending_title"):
|
||
with _session_db(record) as db:
|
||
if db is not None and db.set_session_title(key, title):
|
||
record["pending_title"] = None
|
||
except Exception:
|
||
logger.debug("seeded-session title write failed for %s; pending_title stays queued", key, exc_info=True)
|
||
|
||
|
||
def _create_overrides(params: dict) -> tuple:
|
||
"""PER-SESSION (model, reasoning, service_tier) overrides from the composer — never a global config
|
||
write. ``fast`` presence is the contract: omitted inherits, true pins priority, false pins normal ("")."""
|
||
create_model = _str_param(params, "model")
|
||
model_override = None
|
||
if create_model:
|
||
model_override = {"model": create_model, "provider": _str_param(params, "provider") or None}
|
||
reasoning_override = None
|
||
if effort := _str_param(params, "reasoning_effort"):
|
||
with contextlib.suppress(Exception):
|
||
from hermes_constants import parse_reasoning_effort
|
||
reasoning_override = parse_reasoning_effort(effort)
|
||
service_tier_override = None
|
||
if "fast" in params:
|
||
service_tier_override = "priority" if is_truthy_value(params.get("fast")) else ""
|
||
return model_override, reasoning_override, service_tier_override
|
||
|
||
|
||
def _create_session(rid, params: dict, *, copy_parent_history: bool = False) -> dict:
|
||
"""``session.create``; ``copy_parent_history`` (``session.branch_stored``) reads the parent's
|
||
transcript server-side and omits it from the reply."""
|
||
# ``profile`` (app-global remote mode): stored so the build and every turn re-bind HERMES_HOME.
|
||
profile_home = _profile_home(profile := (params.get("profile") or "").strip() or None)
|
||
# Reject an incoherent model×provider pair BEFORE any state exists: minting it only defers the
|
||
# failure to the first turn's provider 404 (#96817). Custom/unknown providers stay permissive.
|
||
from .methods_session_model_guard import model_override_conflict
|
||
if conflict := model_override_conflict(params, _profile_build_scope(profile_home)):
|
||
return _err(rid, -32602, conflict.pop("message"), conflict)
|
||
(sid, source), key = _new_runtime_ids(params), _new_session_key()
|
||
history = _coerce_seed_history(params.get("messages"))
|
||
# Branch: links back so list_sessions_rich keeps it visible and the sidebar nests it.
|
||
parent_session_id = _str_param(params, "parent_session_id") or None
|
||
if copy_parent_history:
|
||
if not parent_session_id:
|
||
return _err(rid, 4008, "parent_session_id is required when copying parent history")
|
||
# Whole-session desktop branches must not serialize the parent's transcript
|
||
# through the renderer. Read the durable display projection here, where the
|
||
# owning state.db already lives, and keep the full copy server-side.
|
||
with _profile_db(params) as db:
|
||
if db is None:
|
||
return _db_unavailable_error(rid, code=5008)
|
||
try:
|
||
_, display_history = db.get_resume_conversations(parent_session_id)
|
||
except Exception as exc:
|
||
return _err(rid, 4008, f"nothing to branch — {exc}")
|
||
history = _visible_branch_history(display_history)
|
||
if not history:
|
||
return _err(rid, 4008, "nothing to branch — send a message first")
|
||
# Only an explicitly chosen existing workspace persists as cwd; the launch-dir fallback is "No workspace".
|
||
explicit_cwd = False
|
||
raw_cwd = _str_param(params, "cwd") # unguarded, as on BASE: only the path check is best-effort
|
||
# An ssh profile's cwd lives on the remote host, where the host isdir check cannot vouch for it.
|
||
remote_cwd = bool(raw_cwd) and _is_remote_cwd_shape(raw_cwd) and _cwd_is_remote(profile_home)
|
||
with contextlib.suppress(Exception):
|
||
explicit_cwd = bool(raw_cwd) and (remote_cwd or os.path.isdir(os.path.abspath(os.path.expanduser(raw_cwd))))
|
||
_enable_gateway_prompts()
|
||
session_model_override, create_reasoning_override, create_service_tier_override = _create_overrides(params)
|
||
now = time.time()
|
||
with _sessions_lock:
|
||
_sessions[sid] = {
|
||
"agent": None, "agent_error": None, "agent_ready": threading.Event(), "attached_images": [],
|
||
"close_on_disconnect": _flag(params, "close_on_disconnect"),
|
||
"active_session_lease": None, # claimed lazily on the first turn (_ensure_active_session_slot)
|
||
"cols": int(params.get("cols", 80)), "created_at": now, "edit_snapshots": {},
|
||
"explicit_cwd": explicit_cwd,
|
||
"history": history, "history_lock": threading.Lock(), "history_version": 0, "image_counter": 0,
|
||
"seeded": bool(history), # gates _persist_branch_seed: only create-time history is unpersisted
|
||
"cwd": _completion_cwd(params), "inflight_turn": None, "last_active": now,
|
||
"model_override": session_model_override,
|
||
"create_reasoning_override": create_reasoning_override,
|
||
"create_service_tier_override": create_service_tier_override,
|
||
"parent_session_id": parent_session_id, "pending_title": _str_param(params, "title") or None,
|
||
"pending_hidden": _flag(params, "hidden"), "room_plumbing": _flag(params, "room_plumbing"),
|
||
"follow_profile_config": _flag(params, "follow_profile_config"),
|
||
"profile_home": str(profile_home) if profile_home is not None else None,
|
||
"running": False, "session_key": key, "show_reasoning": _load_show_reasoning(), "source": source,
|
||
"slash_worker": None, "tool_progress_mode": _load_tool_progress_mode(), "tool_started_at": {},
|
||
"transport": current_transport() or _stdio_transport,
|
||
"auth_user_id": _transport_auth_user_id(current_transport())}
|
||
_register_session_cwd(_sessions[sid])
|
||
if session_model_override:
|
||
# A composer pick rides in as this override and beats model.default for the whole session;
|
||
# name both so agent.log alone explains which model a new chat runs, and why (#107410).
|
||
logger.info("session.create %s: model=%s provider=%s source=client override (profile default: %s)",
|
||
key, session_model_override["model"], session_model_override.get("provider") or "-",
|
||
_session_default_model(_sessions[sid]))
|
||
# No DB row here (drafts left "Untitled" litter): created on the first prompt — except seeded sessions.
|
||
# NOTE: we intentionally do NOT persist a DB row here. Every TUI/desktop launch (and every "New agent" /
|
||
# draft) opens a session here just to paint the composer, so eagerly creating a row left an "Untitled"
|
||
# empty session behind for every launch the user never typed into. The row is now created lazily on the
|
||
# first prompt (see _ensure_session_db_row + prompt.submit), and the AIAgent's own INSERT-OR-IGNORE
|
||
# persists it on the first turn too. EXCEPTION — seeded branch children (#93959): a desktop branch
|
||
# carries parent_session_id AND a seeded transcript, which is explicit user intent, not an abandoned
|
||
# draft. The row MUST exist immediately: the renderer's post-create resume re-fetches the child through
|
||
# REST + defer_history hydration, both of which read the DB — an unpersisted child 404s, the fail-latch
|
||
# then refuses to bind a "transcript-less" session, and the user sees an infinite spinner whose
|
||
# optimistic row vanishes on restart. Persisting up front also means a restart keeps the branch (both
|
||
# reports lost it) and the title lands in the parent's lineage instead of falling back to a
|
||
# message-preview name. Title mirrors the TUI /branch naming.
|
||
# The same holds for a seeded session WITHOUT a parent (a client opening a chat with its first turns
|
||
# already written): the transcript exists only in memory, so a restart before the first prompt lost it
|
||
# and the post-create resume 404'd. Persist it up front too; only empty drafts stay lazy.
|
||
if parent_session_id and history:
|
||
_seed_branch_row(_sessions[sid], key, parent_session_id, history, source, profile_home)
|
||
elif history:
|
||
_seed_row(_sessions[sid])
|
||
elif explicit_cwd and remote_cwd:
|
||
# A remote project session persists its row now: the per-profile gateway that runs the first turn mints
|
||
# the row itself (AIAgent INSERT-OR-IGNORE) with cwd=None, and the sidebar then drops it to Home. Local
|
||
# project drafts stay lazy — their cwd reaches the row on the first prompt (no "Untitled" litter).
|
||
_ensure_session_db_row(_sessions[sid])
|
||
# Return immediately so Ink can paint; the AIAgent builds right after the flush.
|
||
_schedule_agent_build(sid)
|
||
_schedule_session_cap_enforcement() # trim detached idle sessions over the cap
|
||
cwd = _sessions[sid]["cwd"]
|
||
override = session_model_override or {}
|
||
messages = _history_to_messages(history, profile_home=profile_home) # hidden seed rows are not on the wire; count what is (as resume does)
|
||
return _ok(rid, {
|
||
"session_id": sid, "stored_session_id": key, "message_count": len(messages),
|
||
**({"messages_omitted": True} if copy_parent_history else {"messages": messages}),
|
||
# Reflect the override now so the client doesn't clobber its sticky pick.
|
||
"info": {"model": override.get("model") if override else _session_default_model(_sessions[sid]),
|
||
**({"provider": override["provider"]} if override.get("provider") else {}),
|
||
"tools": {}, "skills": {}, "cwd": cwd, "branch": git_probe.branch(cwd),
|
||
"project": _project_info_for_cwd(cwd), "lazy": True, "desktop_contract": DESKTOP_BACKEND_CONTRACT,
|
||
"profile_name": _response_profile_name(profile)}})
|
||
|
||
|
||
@method("session.create")
|
||
def _(rid, params: dict) -> dict:
|
||
return _create_session(rid, params)
|
||
|
||
|
||
@method("session.branch_stored")
|
||
def _(rid, params: dict) -> dict:
|
||
"""Whole-session branch of a stored parent without routing its transcript through the client
|
||
(a distinct method so an older gateway answers "unknown method" instead of an empty branch)."""
|
||
return _create_session(rid, params, copy_parent_history=True)
|
||
|
||
|
||
def _unarchive_recoverable(db, session_id: str) -> bool:
|
||
"""``unarchive_recoverable_session`` that works on a read-only listing handle (foreign profile):
|
||
the rare write escalates to a short-lived registry writer instead of writing on the reader."""
|
||
if not getattr(db, "read_only", False):
|
||
return db.unarchive_recoverable_session(session_id)
|
||
from hermes_state_registry import acquire
|
||
try:
|
||
wdb = acquire(db.db_path)
|
||
except Exception:
|
||
logger.warning("Bot Chat unarchive skipped: writer unavailable for %s", db.db_path, exc_info=True)
|
||
return False
|
||
try:
|
||
return wdb.unarchive_recoverable_session(session_id)
|
||
finally:
|
||
with contextlib.suppress(Exception):
|
||
wdb.close()
|
||
|
||
|
||
def _session_list_by_title(rid, db, title_lookup: str) -> dict:
|
||
"""EXACT-title lookup (title as identity), window-free on purpose (a busy profile's windowed listing can
|
||
push the row out). Hidden rows resolve (canonical chats are born hidden); archived / deny-listed do not;
|
||
lineages resolve to the live tip (``resolved_id``)."""
|
||
row = db.get_session_by_title(title_lookup)
|
||
if row and row.get("archived"):
|
||
from tools.bot_mode_probe import BOT_CHAT_TITLE
|
||
# A Bot Chat archived by the ws-orphan reaper / agent_close is an accident (the desktop would mint
|
||
# replacements forever): resurrect recoverable reasons only. Re-fetch by ID — title is not UNIQUE.
|
||
if title_lookup == BOT_CHAT_TITLE and _unarchive_recoverable(db, row["id"]):
|
||
# The canonical Bot Chat is identity-scoped: an archive stamped by the ws-orphan reaper or older
|
||
# agent cleanup (ws_orphan_reap / agent_close) is an accident, not user intent, and hiding the
|
||
# row here makes the desktop mint transient replacements forever (#92687). Resurrect it — same
|
||
# recoverable-reason set as stale-route recovery. Deliberate archives (no/explicit end_reason)
|
||
# still hide. Re-fetch by ID: title has no DB-level UNIQUE, so a title re-query could grab a
|
||
# different (still-archived) duplicate row.
|
||
row = db.get_session(row["id"])
|
||
if not row or row.get("archived") or _denied_source(row):
|
||
return _ok(rid, {"sessions": []})
|
||
tip = row["id"]
|
||
with contextlib.suppress(Exception):
|
||
# Real compression continuation only: the resolver's unmarked-child fallback could redirect Bot Chat.
|
||
tip = db.get_compression_tip(row["id"]) or row["id"]
|
||
tip_row = (db.get_session(tip) or row) if tip != row["id"] else row
|
||
return _ok(rid, {"sessions": [_session_row_summary(row, tip_row=tip_row, resolved_id=tip)]})
|
||
|
||
|
||
@method("session.list")
|
||
@_with_db(5006, session_scoped=False)
|
||
def _(rid, params: dict, db) -> dict:
|
||
try:
|
||
if title_lookup := _str_param(params, "title"):
|
||
return _session_list_by_title(rid, db, title_lookup)
|
||
limit = int(params.get("limit", 200) or 200)
|
||
# Over-fetch: per-source filtering + tip merging must not leave us short. ``include_hidden`` is for
|
||
# surfaces that OWN hidden sessions (Bots pane, pickers).
|
||
from pathlib import Path
|
||
|
||
from hermes_cli.session_listing import show_subagent_sessions
|
||
|
||
# ``sessions.show_subagents`` (the store's own profile config) re-admits delegate runs (#97202).
|
||
# A store without a path has no profile config to read, so it keeps the default shape.
|
||
db_path = getattr(db, "db_path", None)
|
||
include_subagents = bool(db_path) and show_subagent_sessions(Path(db_path).parent)
|
||
rows = _listing_rows(db, max(limit * 2, 200), include_hidden=_flag(params, "include_hidden"),
|
||
include_subagents=include_subagents)[:limit]
|
||
return _ok(rid, {"sessions": [_session_row_summary(s) for s in rows]})
|
||
except Exception as e:
|
||
return _err(rid, 5006, str(e))
|
||
|
||
|
||
@method("session.most_recent")
|
||
def _(rid, params: dict) -> dict:
|
||
"""Most recent human-facing session, skipping auto-resume-denied rows (deny-list
|
||
plus ``source='unknown'`` guard placeholders, #54320); errors fold into ``session_id: null``."""
|
||
with _profile_db(params) as db:
|
||
try:
|
||
# Generous over-fetch: many denied rows must not yield a false "none".
|
||
rows = ([row for row in _listing_rows(db, 200)
|
||
if not _auto_resume_denied_source(row)]
|
||
if db is not None else [])
|
||
for row in rows[:1]:
|
||
return _ok(rid, {"session_id": row.get("id"), "title": row.get("title") or "",
|
||
"started_at": row.get("started_at") or 0, "source": row.get("source") or ""})
|
||
except Exception:
|
||
logger.exception("session.most_recent failed")
|
||
return _ok(rid, {"session_id": None})
|
||
|
||
|
||
@method("project.facts")
|
||
def _(rid, params: dict) -> dict:
|
||
"""The system prompt's coding-context detection for a cwd (UIs don't re-sniff); null = not code."""
|
||
try:
|
||
from agent.coding_context import project_facts_for
|
||
return _ok(rid, {"facts": project_facts_for(params.get("cwd"))})
|
||
except Exception:
|
||
logger.exception("project.facts failed")
|
||
return _ok(rid, {"facts": None})
|
||
|
||
|
||
@method("verification.status")
|
||
@_profile_scoped
|
||
def _(rid, params: dict) -> dict:
|
||
"""Best known verification evidence for a cwd/session. Read-only: never runs checks,
|
||
never upgrades targeted evidence into a repository-wide guarantee."""
|
||
try:
|
||
from agent.verification_evidence import verification_status
|
||
return _ok(rid, {"verification": verification_status(
|
||
session_id=params.get("session_id") or params.get("session_key"), cwd=params.get("cwd"))})
|
||
except Exception:
|
||
logger.exception("verification.status failed")
|
||
return _ok(rid, {"verification": {"status": "unknown", "evidence": None}})
|
||
|
||
|
||
# ── session.resume ───────────────────────────────────────────────────
|
||
class _Resume:
|
||
"""Per-call ``session.resume`` state. ``owns_db``: the DEDICATED profile handle is ours
|
||
to close (handler ``finally``) until handed to the hydration worker or the agent."""
|
||
|
||
def __init__(self, rid, params: dict, target: str) -> None:
|
||
self.rid, self.params, self.target = rid, params, target
|
||
self.db, self.owns_db, self.found, self.profile_resume_cwd = None, False, None, ""
|
||
self.cols = _int_param(params, "cols", 80)
|
||
# ``profile`` (app-global remote mode): resume from another local profile's state.db.
|
||
self.profile = (params.get("profile") or "").strip() or None
|
||
self.profile_home = _profile_home(self.profile)
|
||
self.lazy, self.defer_history = _flag(params, "lazy"), _flag(params, "defer_history")
|
||
# Desktop hydrates over REST; suppress the duplicate WS copy only when asked.
|
||
self.omit_messages, self.eager_build = _flag(params, "omit_messages"), _flag(params, "eager_build")
|
||
|
||
def mint(self, prompts: bool = True) -> tuple:
|
||
"""``(runtime sid, source, cwd)`` for the live record this resume registers (+ gateway prompts on)."""
|
||
ids = _new_runtime_ids(self.params)
|
||
if prompts:
|
||
_enable_gateway_prompts()
|
||
return *ids, self.profile_resume_cwd or _default_session_cwd()
|
||
|
||
def record(self, source: str, cwd: str, history: list, overrides: dict | None = None, **extra) -> dict:
|
||
"""``_deferred_session_record`` with this resume's common fields (lease claimed lazily on turn 1);
|
||
``overrides`` restores the stored model/provider/reasoning/tier so the deferred build matches eager."""
|
||
if overrides is not None:
|
||
extra.update(model_override=overrides.get("model_override"), resume_runtime_overrides=overrides or None)
|
||
model_config = _parse_model_config((self.found or {}).get("model_config"), quiet=True)
|
||
follows_profile = _row_follows_profile(self.found)
|
||
else:
|
||
model_config, follows_profile = {}, False
|
||
record = _deferred_session_record(
|
||
self.target, cols=self.cols, cwd=cwd, history=history, lease=None, source=source,
|
||
close_on_disconnect=_flag(self.params, "close_on_disconnect"),
|
||
profile_home=self.profile_home, explicit_cwd=bool(self.profile_resume_cwd), **extra)
|
||
if follows_profile:
|
||
record.update(
|
||
follow_profile_config=True,
|
||
composer_override_profile=(model_config.get("composer_override_profile")
|
||
if overrides and overrides.get("model_override") else None),
|
||
)
|
||
return record
|
||
|
||
def claim(self, sid: str, record: dict) -> dict | None:
|
||
"""Register ``record`` live under the resume lock, or reuse a concurrent winner's session."""
|
||
live = _claim_or_reuse_live(sid, self.target, record, None)
|
||
return None if live is None else _resume_reuse_live(self, *live)
|
||
|
||
def restore(self):
|
||
"""``(sanitized model history, display history, raw history)`` for a cold/eager resume."""
|
||
raw, display = self.read_history()
|
||
return canonicalize_replay_history(raw), display, raw
|
||
|
||
def info(self, cwd: str, overrides: dict) -> dict:
|
||
return _lazy_resume_info(cwd, model=(overrides.get("model_override") or {}).get("model") or "",
|
||
provider=overrides.get("provider_override") or "", profile=self.profile)
|
||
|
||
def child_history(self, repair: bool) -> list:
|
||
"""The child's OWN conversation (no ancestors), row ids included."""
|
||
return self.db.get_messages_as_conversation(self.target, repair_alternation=repair, include_row_ids=True)
|
||
|
||
def messages(self, display: list) -> list:
|
||
return [] if self.omit_messages else _history_to_messages(display, profile_home=self.profile_home)
|
||
|
||
def read_history(self) -> tuple:
|
||
"""One lineage SELECT, two projections: model-fed copy alternation-repaired (healed once
|
||
here instead of every turn's pre-request repair), display copy verbatim."""
|
||
self.db.reopen_session(self.target)
|
||
if self.omit_messages:
|
||
return self.child_history(repair=True), []
|
||
return self.db.get_resume_conversations(self.target)
|
||
|
||
def display_prefix(self) -> list:
|
||
"""Ancestor display rows (model-fed history drops a dangling tool-call tail — display keeps it)."""
|
||
return [] if self.omit_messages else self.db.get_ancestor_display_prefix(self.target)
|
||
|
||
|
||
def _find_live_unpersisted(needle: str, home) -> str:
|
||
"""Runtime sid of a live, not-yet-persisted session matched by stored key or pending title."""
|
||
want_home = str(home) if home is not None else None
|
||
return next((
|
||
live_sid for live_sid, record in list(_sessions.items())
|
||
if isinstance(record, dict) and (record.get("profile_home") or None) == want_home
|
||
and (str(record.get("session_key") or "") == needle or (record.get("pending_title") or "") == needle)), "")
|
||
|
||
|
||
def _resume_live_unpersisted(ctx: _Resume, live_sid: str, live: dict) -> dict:
|
||
"""Reattach a LIVE lazy session with no state.db row yet (every fresh Bot Chat; a 404 here killed messaging
|
||
for never-spoken bots). Attach the transport and cancel the armed orphan-reap Timer (a WS drop may have
|
||
sentinel-parked the record) or it fires against this client."""
|
||
if ctx.owns_db:
|
||
_release_db(ctx.db)
|
||
with _session_resume_lock:
|
||
if (refusal := _reattach_refusal(ctx.rid, live_sid, live)) is not None:
|
||
return refusal
|
||
live["last_active"] = time.time()
|
||
if (transport := current_transport()) is not None:
|
||
with live.setdefault("history_lock", threading.Lock()):
|
||
_rebind_live_transport(live_sid, live, transport)
|
||
else:
|
||
_cancel_ws_orphan_reap(live_sid)
|
||
messages = ctx.messages(live.get("history") or []) # count the wire, as every other resume path does
|
||
# The chat's own pick, not the profile default: a warm reattach that reported `_resolve_model()` flipped the
|
||
# Desktop picker on every reload while the session was still live, and back once it had been dropped.
|
||
model, provider = _live_session_identity(live)
|
||
return _ok(ctx.rid, _attach_todo_state({
|
||
"session_id": live_sid, "stored_session_id": str(live.get("session_key") or ""),
|
||
"message_count": len(messages), "messages": messages,
|
||
"info": {"model": model, "provider": provider, "lazy": True,
|
||
"desktop_contract": DESKTOP_BACKEND_CONTRACT,
|
||
"profile_name": profile_name_for_home(live.get("profile_home")) or _response_profile_name(ctx.profile)}}, live))
|
||
|
||
|
||
def _resume_adopt_stranded(ctx: _Resume) -> None:
|
||
"""Adopt a lineage stranded in the DEFAULT store (older builds ran a profile bot's turns on the focused
|
||
tile's backend; unadopted it 4001s forever). Exact-id ONLY — bot titles collide; never a retired donor."""
|
||
try:
|
||
# Stranded-session adoption (#93296 follow-up): before session RPCs routed by their TARGET session,
|
||
# a profile bot's turns executed on the focused tile's backend — usually default — so its canonical
|
||
# session accumulated in the DEFAULT profile's state.db. Now that routing is correct, this
|
||
# profile-scoped resume is the first place the fix and the stranded data collide: the id exists in
|
||
# the default store but not here, and without adoption the same chat 4001s forever (the fix made it
|
||
# unreachable instead of misrouted). Adopt the full lineage from the default store into this
|
||
# profile's db, then retry the lookup. Only profile-scoped resumes reach here (owns_db); unknown ids
|
||
# in the default store still 4007 exactly as before.
|
||
default_db = _get_db()
|
||
donor_row = default_db.get_session(ctx.target) if default_db is not None else None
|
||
if not donor_row or donor_row.get("archived"):
|
||
return
|
||
adoption = ctx.db.adopt_session_lineage_from(default_db, donor_row["id"])
|
||
if adoption.get("adopted"):
|
||
logger.info("adopted stranded session %s (lineage of %s segment(s)) from default store into profile %s",
|
||
donor_row["id"],
|
||
len(adoption.get("imported_ids") or []) + len(adoption.get("skipped_ids") or []),
|
||
ctx.profile or "?")
|
||
ctx.found = ctx.db.get_session(donor_row["id"])
|
||
if ctx.found:
|
||
ctx.target = ctx.found["id"]
|
||
except Exception:
|
||
logger.exception("stranded-session adoption failed for %s", ctx.target)
|
||
|
||
|
||
def _resume_locate(ctx: _Resume) -> dict | None:
|
||
"""Resolve ``ctx.target`` to a stored row (``ctx.found``); a dict is an early response."""
|
||
ctx.found = ctx.db.get_session(ctx.target)
|
||
if ctx.found:
|
||
return None
|
||
ctx.found = ctx.db.get_session_by_title(ctx.target)
|
||
if ctx.found:
|
||
ctx.target = ctx.found["id"]
|
||
return None
|
||
if ctx.lazy and _child_run_active(ctx.target):
|
||
# Fresh subagent watch window: `subagent.start` relays BEFORE the child's first DB flush. Proceed lazily
|
||
# with empty history — the live mirror streams the turn and the row exists by upgrade time.
|
||
ctx.found = {}
|
||
return None
|
||
live_sid = _find_live_unpersisted(ctx.target, ctx.profile_home)
|
||
if (live := _sessions.get(live_sid) if live_sid else None) is not None:
|
||
return _resume_live_unpersisted(ctx, live_sid, live)
|
||
if ctx.owns_db:
|
||
_resume_adopt_stranded(ctx)
|
||
return None if ctx.found else _err(ctx.rid, 4007, "session not found")
|
||
|
||
|
||
def _resume_follow_tip(ctx: _Resume) -> None:
|
||
"""Rebind a rotated-out parent id to its compression tip (resuming the original reloads the parent
|
||
transcript and loses the post-compression reply). Skipped for lazy watch windows (exact child); Bot Chat
|
||
follows proven compression edges only."""
|
||
if not ctx.found or ctx.lazy:
|
||
return
|
||
tip = ctx.target
|
||
with contextlib.suppress(Exception):
|
||
from tools.bot_mode_probe import BOT_CHAT_TITLE
|
||
if (ctx.found.get("title") or "").strip() == BOT_CHAT_TITLE:
|
||
tip = ctx.db.get_compression_tip(ctx.target) or ctx.target
|
||
else:
|
||
tip = ctx.db.resolve_resume_session_id(ctx.target)
|
||
if tip and tip != ctx.target:
|
||
ctx.target = tip
|
||
ctx.found = ctx.db.get_session(tip) or ctx.found
|
||
|
||
|
||
def _resume_guard(ctx: _Resume) -> dict | None:
|
||
"""Refuse a runaway transcript before any history read (sessions.max_resume_messages). Deferred /
|
||
omit_messages / lazy paths load the TIP segment only and are guarded tip-only (a lineage count rejected
|
||
exactly the well-compressed chats). Metadata fallback for lightweight adaptor DBs; fails OPEN on errors."""
|
||
from hermes_state import SessionResumeTooLargeError, resolved_max_resume_messages
|
||
tip_only = ctx.lazy or ctx.omit_messages or (ctx.defer_history and not ctx.eager_build)
|
||
try:
|
||
if callable(safety_check := getattr(ctx.db, "assert_resume_safe", None)):
|
||
safety_check(ctx.target, **({"tip_only": True} if tip_only else {}))
|
||
elif (limit := resolved_max_resume_messages()) and (n := int(ctx.found.get("message_count") or 0)) > limit:
|
||
raise SessionResumeTooLargeError(n, limit)
|
||
except SessionResumeTooLargeError as exc:
|
||
return _err(ctx.rid, 4130, str(exc))
|
||
except Exception as exc:
|
||
logger.warning("resume safety check failed for %s (proceeding without guard): %s", ctx.target, exc)
|
||
return None
|
||
|
||
|
||
def _resume_reuse_live(ctx: _Resume, sid: str, session: dict) -> dict:
|
||
"""Reattach an already-live session under the resume lock (held across the client-gone check,
|
||
transport attach and reap cancel so grace expiry is atomic). _live_session_payload ATTACHES this
|
||
caller alongside the client(s) already streaming instead of taking the slot from them."""
|
||
with _session_resume_lock:
|
||
return _resume_reuse_live_locked(ctx, sid, session)
|
||
|
||
|
||
def _resume_reuse_live_locked(ctx: _Resume, sid: str, session: dict) -> dict:
|
||
"""Reuse with _session_resume_lock already held (including the eager double-check)."""
|
||
if (refusal := _reattach_refusal(ctx.rid, sid, session)) is not None:
|
||
return refusal
|
||
_cancel_ws_orphan_reap(sid) # unconditionally: the fast path must never race the reap Timer
|
||
payload = _live_session_payload(sid, session, cols=ctx.cols, touch=True, omit_messages=ctx.omit_messages,
|
||
transport=current_transport() or _stdio_transport)
|
||
payload["resumed"] = ctx.target
|
||
if ctx.defer_history:
|
||
payload.update(messages=[], hydrating=bool(session.get("resume_hydrating")),
|
||
message_count=int(session.get("resume_message_count") or payload["message_count"]))
|
||
# A lazy watch session never owns a run loop — overlay the child-run registry.
|
||
if session.get("agent") is None and _child_run_active(ctx.target):
|
||
payload.update(running=True, status="streaming")
|
||
return _ok(ctx.rid, payload)
|
||
|
||
|
||
def _resume_response(
|
||
ctx: _Resume, sid: str, record: dict, *, info: dict, display: list = (), count_source: list | None = None,
|
||
messages: list | None = None, message_count: int | None = None, running: bool = False,
|
||
status: str = "idle", hydrating: bool | None = None, started_at=None, auto_continue=None,
|
||
) -> dict:
|
||
"""Common resume payload; omit_messages counts ``count_source`` (client still learns the stored size)."""
|
||
if messages is None:
|
||
messages = ctx.messages(display)
|
||
if message_count is None:
|
||
message_count = len(count_source) if ctx.omit_messages else len(messages)
|
||
payload = {"session_id": sid, "resumed": ctx.target, "message_count": message_count, "messages": messages,
|
||
**({"messages_omitted": ctx.omit_messages} if hydrating is None else {"hydrating": hydrating}),
|
||
"info": info, "inflight": None, "running": running, "session_key": ctx.target,
|
||
"started_at": record["created_at"] if started_at is None else started_at, "status": status}
|
||
if auto_continue is not None:
|
||
payload["auto_continue"] = auto_continue
|
||
return _ok(ctx.rid, _attach_todo_state(payload, record))
|
||
|
||
|
||
def _resume_lazy(ctx: _Resume) -> dict:
|
||
"""Lazy/watch resume (desktop subagent windows): a live session WITHOUT an agent — the child runs
|
||
inside the parent's turn, so the window needs stored history + a transport; prompt.submit upgrades it."""
|
||
sid, source, cwd = ctx.mint(prompts=False)
|
||
try:
|
||
ctx.db.reopen_session(ctx.target)
|
||
# repair_alternation heals a durable ``user;user`` once here.
|
||
history = ctx.child_history(repair=True)
|
||
except Exception as e:
|
||
return _err(ctx.rid, 5000, resume_failed_message(e))
|
||
record = ctx.record(source, cwd, history, lazy=True, todo_state=_todo_state_from_history(history))
|
||
if (reused := ctx.claim(sid, record)) is not None:
|
||
return reused
|
||
# A child mid-run emits no session events — liveness comes from the relay registry.
|
||
running = _child_run_active(ctx.target)
|
||
# Display uses the VERBATIM child-only projection so model-invisible rows survive; repaired ``history``
|
||
# still feeds live replay.
|
||
display = history
|
||
try:
|
||
display = ctx.child_history(repair=False)
|
||
except Exception:
|
||
logger.debug("child-watch display projection read failed", exc_info=True)
|
||
return _resume_response(ctx, sid, record, info=_lazy_resume_info(cwd, profile=ctx.profile), display=display,
|
||
count_source=display, running=running, status="streaming" if running else "idle")
|
||
|
||
|
||
def _resume_deferred(ctx: _Resume) -> dict:
|
||
"""Bounded ack; the transcript hydrates in the background (the ONE history read) and pages over REST."""
|
||
sid, source, cwd = ctx.mint()
|
||
with _profile_build_scope(ctx.profile_home):
|
||
overrides = _stored_session_runtime_overrides(ctx.found)
|
||
record = ctx.record(source, cwd, [], overrides,
|
||
todo_state=_todo_state_from_db(ctx.db, ctx.target))
|
||
record.update(resume_history_ready=threading.Event(), resume_hydrating=True,
|
||
resume_message_count=int(ctx.found.get("message_count") or 0))
|
||
if (reused := ctx.claim(sid, record)) is not None:
|
||
return reused
|
||
# Desktop owns the visible transcript through bounded REST pages, not this model-history restore.
|
||
_schedule_resume_hydration(
|
||
sid, ctx.target, ctx.db, close_db=ctx.owns_db,
|
||
model_history_only=source == "desktop" and ctx.omit_messages)
|
||
ctx.owns_db = False # the hydration worker now owns (and closes) the profile-scoped handle
|
||
_schedule_session_cap_enforcement()
|
||
return _resume_response(ctx, sid, record, info=ctx.info(cwd, overrides), messages=[],
|
||
message_count=record["resume_message_count"], status="resuming", hydrating=True)
|
||
|
||
|
||
def _resume_cold(ctx: _Resume) -> dict:
|
||
"""Default cold resume: transcript now, agent OFF the response path (_make_agent can block for seconds;
|
||
callers await this RPC before painting) — pre-warmed on a timer, _sess() builds on demand if the first
|
||
prompt beats it. Unlike lazy, restores full ancestor history + persisted runtime identity."""
|
||
sid, source, cwd = ctx.mint()
|
||
try:
|
||
history, display_history, raw_history = ctx.restore()
|
||
except Exception as e:
|
||
return _err(ctx.rid, 5000, resume_failed_message(e))
|
||
with _profile_build_scope(ctx.profile_home):
|
||
overrides = _stored_session_runtime_overrides(ctx.found)
|
||
record = ctx.record(source, cwd, history, overrides, display_history_prefix=ctx.display_prefix(),
|
||
todo_state=_todo_state_from_history(history))
|
||
if (reused := ctx.claim(sid, record)) is not None:
|
||
return reused
|
||
_schedule_agent_build(sid)
|
||
_schedule_session_cap_enforcement() # trim detached idle sessions over the cap
|
||
return _resume_response(ctx, sid, record, info=ctx.info(cwd, overrides), display=display_history,
|
||
count_source=raw_history,
|
||
auto_continue=_maybe_schedule_auto_continue(sid, record, ctx.target))
|
||
|
||
|
||
def _resume_eager(ctx: _Resume) -> dict:
|
||
"""Synchronous build OUTSIDE _session_resume_lock (it would stall session.close), then double-checked."""
|
||
sid, source, _cwd = ctx.mint()
|
||
with _profile_build_scope(ctx.profile_home):
|
||
try:
|
||
history, display_history, raw_history = ctx.restore()
|
||
display_history_prefix = ctx.display_prefix()
|
||
# Profile db so turns persist to the right state.db; stored runtime identity so switching chats does
|
||
# not inherit another chat's global model.
|
||
stored_runtime_overrides = _stored_session_runtime_overrides(ctx.found)
|
||
agent = _make_agent_in_context(
|
||
sid, ctx.target, session_db=ctx.db, platform_override=source,
|
||
cwd_override=ctx.profile_resume_cwd or None,
|
||
context_cwd_is_launch_artifact=(source in _LAUNCH_CWD_NOT_A_WORKSPACE and not ctx.profile_resume_cwd),
|
||
auth_user_id=_transport_auth_user_id(current_transport()), **stored_runtime_overrides)
|
||
except Exception as e:
|
||
return _err(ctx.rid, 5000, resume_failed_message(e))
|
||
with _session_resume_lock:
|
||
live = _find_live_session_by_key(ctx.target, ctx.profile_home)
|
||
if live is not None:
|
||
with contextlib.suppress(Exception):
|
||
agent.close()
|
||
return _resume_reuse_live_locked(ctx, *live)
|
||
try:
|
||
with _profile_build_scope(ctx.profile_home):
|
||
_init_session(sid, ctx.target, agent, history, cols=ctx.cols, cwd=ctx.profile_resume_cwd,
|
||
session_db=ctx.db, source=source, explicit_cwd=bool(ctx.profile_resume_cwd))
|
||
# Ownership TRANSFER: the agent holds the handle for life (AIAgent.close() releases it). The
|
||
# owns_db drop is UNCONDITIONAL — the session is registered against the handle, so the finally
|
||
# must not close it even if the transfer was refused (a leak beats "closed database" every
|
||
# turn). Gated on owns_db: the SHARED launch handle must never move onto one session.
|
||
if ctx.owns_db:
|
||
_transfer_db_to_agent(agent, ctx.db)
|
||
ctx.owns_db = False
|
||
if (session := _sessions.get(sid)) is not None:
|
||
if stored_runtime_overrides.get("model_override") is not None:
|
||
session["model_override"] = stored_runtime_overrides["model_override"]
|
||
model_config = _parse_model_config(ctx.found.get("model_config"), quiet=True)
|
||
if _row_follows_profile(ctx.found):
|
||
session["follow_profile_config"] = True
|
||
session["composer_override_profile"] = (
|
||
model_config.get("composer_override_profile")
|
||
if stored_runtime_overrides.get("model_override") else None)
|
||
# Each turn re-binds HERMES_HOME (mid-turn memory/skills reads); lease claimed lazily on turn 1.
|
||
if ctx.profile_home is not None:
|
||
session["profile_home"] = str(ctx.profile_home)
|
||
session.update(display_history_prefix=display_history_prefix, active_session_lease=None)
|
||
except Exception as e:
|
||
# _init_session registers _sessions[sid] BEFORE its first db read; left in place the fast path
|
||
# would serve that dead session forever.
|
||
if ctx.owns_db:
|
||
with _sessions_lock:
|
||
_sessions.pop(sid, None)
|
||
return _err(ctx.rid, 5000, resume_failed_message(e))
|
||
session = _sessions.get(sid) or {}
|
||
return _resume_response(
|
||
ctx, sid, session, info=_session_info(agent, session), display=display_history, count_source=raw_history,
|
||
started_at=float(session.get("created_at") or time.time()),
|
||
auto_continue=_maybe_schedule_auto_continue(sid, session, ctx.target) if session else None)
|
||
|
||
|
||
@method("session.resume")
|
||
def _(rid, params: dict) -> dict:
|
||
if not (target := params.get("session_id", "")):
|
||
return _err(rid, 4006, "session_id required")
|
||
ctx = _Resume(rid, params, target)
|
||
# Profile scope: a DEDICATED handle we own until the agent takes it; else the shared launch db.
|
||
ctx.db, ctx.owns_db = _profile_session_db(ctx.profile_home)
|
||
try:
|
||
if ctx.db is None:
|
||
return _db_unavailable_error(rid, code=5000)
|
||
if (resp := _resume_locate(ctx)) is not None:
|
||
return resp
|
||
_resume_follow_tip(ctx)
|
||
if (resp := _resume_guard(ctx)) is not None:
|
||
return resp
|
||
ctx.profile_resume_cwd = _str_param(ctx.found, "cwd") or _profile_workspace_cwd(ctx.profile_home)
|
||
# Fast path: reuse a session live IN THIS PROFILE (never another profile's runtime).
|
||
with _session_resume_lock:
|
||
live = _find_live_session_by_key(ctx.target, ctx.profile_home)
|
||
if live is not None:
|
||
return _resume_reuse_live(ctx, *live)
|
||
if ctx.lazy:
|
||
return _resume_lazy(ctx)
|
||
if ctx.eager_build:
|
||
return _resume_eager(ctx)
|
||
return _resume_deferred(ctx) if ctx.defer_history else _resume_cold(ctx)
|
||
finally:
|
||
# Refcounting alone does not release the sqlite fds: SessionDB pins ITSELF (atexit.register) once its
|
||
# background token writer starts; only close() unregisters.
|
||
if ctx.owns_db and ctx.db is not None:
|
||
with contextlib.suppress(Exception):
|
||
ctx.db.close()
|
||
|
||
|
||
# ── cwd / workspace / live-session bookkeeping ───────────────────────
|
||
@_session_method("session.cwd.set")
|
||
def _(rid, params: dict, session: dict) -> dict:
|
||
if session.get("running"):
|
||
return _err(rid, 4009, "session busy")
|
||
if not (raw := _str_param(params, "cwd")):
|
||
return _err(rid, 4016, "cwd required")
|
||
try:
|
||
cwd = _set_session_cwd(session, raw)
|
||
except ValueError as e:
|
||
return _err(rid, 4017, str(e))
|
||
info = _cwd_info(session, cwd)
|
||
_emit("session.info", params.get("session_id", ""), info)
|
||
return _ok(rid, info)
|
||
|
||
|
||
@method("session.workspace.move")
|
||
def _(rid, params: dict) -> dict:
|
||
"""Re-home a STORED session's workspace (by ``session_key``; no live agent required). git branch/root are
|
||
REPLACED (a stale ``git_repo_root`` kept the session under the project it left); a live agent follows even
|
||
mid-turn (refusing made the UI claim success while state.db kept the old cwd)."""
|
||
if not (target := _str_param(params, "session_key")):
|
||
return _err(rid, 4007, "session_key required")
|
||
if not (raw := _str_param(params, "cwd")):
|
||
return _err(rid, 4016, "cwd required")
|
||
from hermes_constants import translate_cwd_for_wsl_backend
|
||
# Snapshot under the lock — concurrent RPCs mutate _sessions.
|
||
with _sessions_lock:
|
||
live_sid, live = next(
|
||
((sid, sess) for sid, sess in list(_sessions.items()) if sess.get("session_key") == target), ("", None))
|
||
# The live session's profile decides (as _set_session_cwd below does); an ssh workspace is never host-validated.
|
||
home = live.get("profile_home") if live is not None else _profile_home(params.get("profile"))
|
||
try:
|
||
target_cwd = _workspace_cwd(home, translate_cwd_for_wsl_backend(raw))
|
||
except ValueError:
|
||
return _err(rid, 4017, f"working directory does not exist: {raw}")
|
||
branch, root = git_probe.branch(target_cwd), git_probe.common_repo_root(target_cwd)
|
||
with _profile_db(params, writer=True) as db:
|
||
if db is None:
|
||
return _db_unavailable_error(rid, code=5007)
|
||
# A draft has no row yet; the live re-home still applies (row inherits cwd on write).
|
||
if not db.get_session(target):
|
||
if live is None:
|
||
return _err(rid, 4007, "session not found")
|
||
else:
|
||
try:
|
||
db.update_session_cwd(target, target_cwd, branch, root, replace_git_meta=True)
|
||
except Exception as e:
|
||
return _err(rid, 5007, f"move failed: {e}")
|
||
if live is not None:
|
||
try:
|
||
_set_session_cwd(live, target_cwd)
|
||
except ValueError as e:
|
||
return _err(rid, 4017, str(e))
|
||
_emit("session.info", live_sid, _cwd_info(live, target_cwd, branch=branch))
|
||
return _ok(rid, {"cwd": target_cwd, "branch": branch, "git_repo_root": root})
|
||
|
||
|
||
@method("session.active_list")
|
||
def _(rid, params: dict) -> dict:
|
||
"""Live TUI sessions in this process (not a DB browser)."""
|
||
snapshot, err = _snapshot_sessions(rid)
|
||
if err:
|
||
return err
|
||
current = str(params.get("current_session_id") or "")
|
||
# ``_finalized`` sessions linger until the reaper pops them (they inflated the footer). Do NOT filter on
|
||
# the WS-detached sentinel: detached is attachable until grace-reap, and ``hermes --tui`` rides stdio.
|
||
# Keep insertion order (focused must not jump).
|
||
rows = [_session_live_item(sid, session, current) for sid, session in snapshot if not session.get("_finalized")]
|
||
return _ok(rid, {"sessions": rows})
|
||
|
||
|
||
@_session_method("session.activate")
|
||
def _(rid, params: dict, session: dict) -> dict:
|
||
"""Attach the frontend to a live TUI session without closing the previously focused one."""
|
||
sid = str(params.get("session_id") or "")
|
||
# Only the rebind is atomic with grace expiry; the payload (a DB history read unless
|
||
# ``omit_messages``) must not hold the process-wide resume lock.
|
||
with _session_resume_lock:
|
||
if (refusal := _reattach_refusal(rid, sid, session)) is not None:
|
||
return refusal
|
||
with session["history_lock"]:
|
||
_rebind_live_transport(sid, session, current_transport() or _stdio_transport)
|
||
return _ok(rid, _live_session_payload(
|
||
sid, session, touch=True, omit_messages=is_truthy_value(params.get("omit_messages", False))))
|
||
|
||
|
||
@method("session.delete")
|
||
def _(rid, params: dict) -> dict:
|
||
"""Delete a stored session + transcripts; refused while live here (FK trips on the agent's next flush)."""
|
||
if not (target := params.get("session_id", "")):
|
||
return _err(rid, 4006, "session_id required")
|
||
snapshot, err = _snapshot_sessions(rid)
|
||
if err:
|
||
return err
|
||
if any(s.get("session_key") == target for _sid, s in snapshot):
|
||
return _err(rid, 4023, "cannot delete an active session")
|
||
profile_home = _profile_home((params.get("profile") or "").strip() or None)
|
||
with _profile_db(params, writer=True) as db:
|
||
if db is None:
|
||
return _db_unavailable_error(rid, code=5036)
|
||
from hermes_state_errors import SessionActiveWriteGuardError
|
||
try:
|
||
home = Path(profile_home) if profile_home is not None else get_hermes_home()
|
||
deleted = db.delete_session(target, sessions_dir=home / "sessions", exclude_active_write_guards=True)
|
||
except SessionActiveWriteGuardError:
|
||
return _err(rid, 4023, "cannot delete an active session")
|
||
except Exception as e:
|
||
return _err(rid, 5036, f"delete failed: {e}")
|
||
return _ok(rid, {"deleted": target}) if deleted else _err(rid, 4007, "session not found")
|
||
|
||
|
||
def _title_read(session: dict, db, key: str) -> str:
|
||
"""``session.title`` without ``title``: read it, applying a queued pending_title if possible."""
|
||
fallback = session.get("pending_title") or ""
|
||
try:
|
||
resolved_title = db.get_session_title(key) or ""
|
||
if not fallback:
|
||
if resolved_title:
|
||
session["pending_title"] = None
|
||
elif (db.set_session_title(key, fallback)
|
||
or ((db.get_session(key) or {}).get("title") or "").strip() == fallback):
|
||
session["pending_title"] = None
|
||
resolved_title = fallback
|
||
elif not resolved_title:
|
||
resolved_title = fallback
|
||
except Exception:
|
||
resolved_title = fallback
|
||
return resolved_title
|
||
|
||
|
||
@method("session.title")
|
||
@_with_db(5007, session_scoped=True)
|
||
def _(rid, params: dict, session: dict, db) -> dict:
|
||
key = session["session_key"]
|
||
if "title" not in params:
|
||
result = {"title": _title_read(session, db, key), "session_key": key}
|
||
elif not (title := (params.get("title", "") or "").strip()):
|
||
return _err(rid, 4021, "title required")
|
||
else:
|
||
try:
|
||
if db.set_session_title(key, title):
|
||
pending, value = False, title
|
||
# rowcount == 0 can mean "same value" as well as "missing row".
|
||
elif existing_row := db.get_session(key):
|
||
pending, value = False, existing_row.get("title") or title
|
||
else:
|
||
# No row yet: an explicit /title is clear intent, so persist the row NOW (as the gateway's
|
||
# _handle_title_command); the min-messages sidebar filter hides a titled 0-message row. If
|
||
# row creation didn't take, queue so the post-turn apply block can recover.
|
||
_ensure_session_db_row(session)
|
||
with _session_db(session) as scoped_db:
|
||
pending, value = not (scoped_db is not None and scoped_db.set_session_title(key, title)), title
|
||
except ValueError as e:
|
||
return _err(rid, 4022, str(e))
|
||
except Exception as e:
|
||
return _err(rid, 5007, str(e))
|
||
session["pending_title"] = value if pending else None
|
||
result = {"pending": pending, "title": value}
|
||
_emit_session_info_for_session(params.get("session_id", ""), session)
|
||
return _ok(rid, result)
|
||
|
||
|
||
@method("session.set_hidden")
|
||
def _(rid, params: dict) -> dict:
|
||
"""Set/clear ``hidden`` (leaves the default list, stays resumable by its owner) on a session + lineage:
|
||
LIVE runtime id first (unpersisted drafts via ``pending_hidden``), then a stored id/key in the profile db."""
|
||
hidden = is_truthy_value(params.get("hidden", True))
|
||
# Quiet live lookup: a stored id that is not in memory is this method's expected second tier, not a
|
||
# rejection — _sess_nowait would log "session-scoped RPC rejected … not in memory" for a request that is
|
||
# then fulfilled from the profile db, burying the real stale-runtime-id signal under sweep noise.
|
||
session = _sessions.get(str(params.get("session_id") or ""))
|
||
with (_profile_db(params, writer=True) if session is None else _session_db(session)) as db:
|
||
if db is None:
|
||
return _db_unavailable_error(rid, code=5007)
|
||
try:
|
||
if session is not None:
|
||
key = session["session_key"]
|
||
if not db.set_session_hidden(key, hidden):
|
||
session["pending_hidden"] = hidden # no row yet: _ensure_session_db_row is born hidden
|
||
else:
|
||
# ``resolve_session_id`` follows key/title aliases like the REST pin/archive path.
|
||
target = _str_param(params, "session_id")
|
||
if not (key := db.resolve_session_id(target) if hasattr(db, "resolve_session_id") else target):
|
||
return _err(rid, 4001, "session not found")
|
||
db.set_session_hidden(key, hidden)
|
||
return _ok(rid, {"hidden": hidden, "session_key": key})
|
||
except Exception as e:
|
||
return _err(rid, 5007, str(e))
|
||
|
||
|
||
@_session_method("message.react")
|
||
def _(rid, params: dict, session: dict) -> dict:
|
||
"""Set/clear one author's emoji reaction (Tapback semantics: one per author, same emoji retracts, null
|
||
clears). ``row_id`` is ``messages.id``; a not-yet-persisted live message names ``newest_role`` instead."""
|
||
newest_role = _str_param(params, "newest_role")
|
||
row_id = params.get("row_id")
|
||
if row_id is None and newest_role not in {"user", "assistant"}:
|
||
return _err(rid, 4023, "row_id or newest_role required")
|
||
if (emoji := params.get("emoji")) is not None and not (emoji := str(emoji).strip()):
|
||
return _err(rid, 4024, "emoji must be a non-empty string or null")
|
||
if (author := str(params.get("author") or "user").strip()) not in {"user", "agent"}:
|
||
return _err(rid, 4025, "author must be 'user' or 'agent'")
|
||
with _session_db(session) as db:
|
||
if db is None:
|
||
return _db_unavailable_error(rid, code=5007)
|
||
try:
|
||
if row_id is None:
|
||
row_id = db.latest_message_row_id(session["session_key"], role=newest_role)
|
||
if row_id is None:
|
||
return _err(rid, 4040, "no message to react to yet")
|
||
reactions = db.set_message_reaction(session["session_key"], int(row_id), emoji, author=author)
|
||
except Exception as e:
|
||
return _err(rid, 5007, str(e))
|
||
if reactions is None:
|
||
return _err(rid, 4040, "message not found in this session")
|
||
return _ok(rid, {"row_id": int(row_id), "reactions": reactions})
|
||
|
||
|
||
@method("llm.oneshot")
|
||
@_profile_scoped
|
||
def _(rid, params: dict) -> dict:
|
||
"""Stateless one-shot LLM request; a live ``session_id`` lends its model, else the ``task`` backend.
|
||
Runs under the session's profile scope (else ``params.profile`` / the launch scope): the aux
|
||
task config and its API key otherwise resolved from the LAUNCH profile — a secondary's titles /
|
||
project ideas ran on, and billed, the default profile's auxiliary provider."""
|
||
template = (params.get("template") or "").strip() or None
|
||
instructions = params.get("instructions") or ""
|
||
user_input = params.get("input") or ""
|
||
variables = params.get("variables") if isinstance(params.get("variables"), dict) else {}
|
||
try:
|
||
temperature = float(params["temperature"]) if params.get("temperature") is not None else 0.3
|
||
except (TypeError, ValueError):
|
||
temperature = 0.3
|
||
if not template and not str(instructions).strip() and not str(user_input).strip():
|
||
return _err(rid, 4030, "llm.oneshot requires a template or instructions/input")
|
||
session = _sessions.get(params.get("session_id") or "")
|
||
try:
|
||
from agent.oneshot import run_oneshot
|
||
with (_session_profile_runtime_scope(session) if session else contextlib.nullcontext()):
|
||
return _ok(rid, {"text": run_oneshot(
|
||
instructions=instructions, user_input=user_input, template=template, variables=variables,
|
||
task=(params.get("task") or "title_generation").strip() or "title_generation",
|
||
max_tokens=_int_param(params, "max_tokens", 1024) or 1024, temperature=temperature,
|
||
main_runtime=_main_runtime_from_agent(session.get("agent")) if session else None)})
|
||
except (KeyError, ValueError) as e:
|
||
return _err(rid, 4031 if isinstance(e, KeyError) else 4032, str(e))
|
||
except Exception as e:
|
||
logger.warning("llm.oneshot failed: %s", e)
|
||
return _err(rid, 5030, f"one-shot generation failed: {e}")
|
||
|
||
|
||
# ── handoff ──────────────────────────────────────────────────────────
|
||
@_session_method("handoff.request")
|
||
def _(rid, params: dict, session: dict) -> dict:
|
||
"""Queue a handoff (desktop /handoff): only writes ``pending``; the gateway watcher claims and re-binds."""
|
||
if session.get("running"):
|
||
return _err(rid, 4009, "session busy — wait for the current turn to finish, then retry the handoff")
|
||
if not (platform_name := (params.get("platform", "") or "").strip().lower()):
|
||
return _err(rid, 4023, "platform required")
|
||
# Validate up front: an unconfigured platform / missing home channel pends forever.
|
||
from gateway.config import Platform, load_gateway_config
|
||
try:
|
||
platform = Platform(platform_name)
|
||
except (ValueError, KeyError):
|
||
return _err(rid, 4024, f"unknown platform '{platform_name}'")
|
||
try:
|
||
with _session_profile_runtime_scope(session):
|
||
gw_config = load_gateway_config()
|
||
except Exception as e:
|
||
return _err(rid, 5021, f"could not load gateway config: {e}")
|
||
if not getattr(gw_config.platforms.get(platform), "enabled", False):
|
||
return _err(rid, 4025, f"platform '{platform_name}' is not configured/enabled in the gateway")
|
||
if not (home := gw_config.get_home_channel(platform)) or not home.chat_id:
|
||
return _err(rid, 4026, f"no home channel configured for {platform_name} — set one with "
|
||
"/sethome on the destination chat first")
|
||
# The watcher transfers a persisted row, so make sure one exists for an empty chat.
|
||
_ensure_session_db_row(session)
|
||
key = session["session_key"]
|
||
with _session_db(session) as db:
|
||
if db is None:
|
||
return _db_unavailable_error(rid, code=5007)
|
||
try:
|
||
if not db.get_session(key):
|
||
db.set_session_title(key, f"handoff-{key[:8]}")
|
||
if not db.request_handoff(key, platform_name):
|
||
return _err(rid, 4027, "session is already in flight for handoff — wait for it to settle, then retry")
|
||
except Exception as e:
|
||
return _err(rid, 5007, str(e))
|
||
return _ok(rid, {"queued": True, "session_key": key, "platform": platform_name, "home_name": home.name})
|
||
|
||
|
||
@method("handoff.state")
|
||
@_with_db(5007, session_scoped=True)
|
||
def _(rid, params: dict, session: dict, db) -> dict:
|
||
"""Poll ``{state, platform, error}``; ``state`` is pending|running|completed|failed or empty."""
|
||
record = db.get_handoff_state(session["session_key"]) or {}
|
||
return _ok(rid, {field: record.get(field) or "" for field in ("state", "platform", "error")})
|
||
|
||
|
||
@method("handoff.fail")
|
||
def _(rid, params: dict) -> dict:
|
||
"""Fail a not-yet-claimed handoff (poll timeout); a claimed ``running`` row is the watcher's (CAS)."""
|
||
# Undecorated on purpose: tests rebind this handler's __code__ directly.
|
||
session, err = _sess_nowait(params, rid)
|
||
if err:
|
||
return err
|
||
reason = str(params.get("error") or "handoff failed").strip()[:500]
|
||
with _session_db(session) as db:
|
||
if db is None:
|
||
return _db_unavailable_error(rid, code=5007)
|
||
key = session["session_key"]
|
||
try:
|
||
failed = db.fail_handoff(key, reason, only_states=("pending",))
|
||
except TypeError:
|
||
# Older SessionDB without only_states: fail only when still pending.
|
||
if failed := ((db.get_handoff_state(key) or {}).get("state") or "") == "pending":
|
||
db.fail_handoff(key, reason)
|
||
state = "failed" if failed else (db.get_handoff_state(key) or {}).get("state") or ""
|
||
return _ok(rid, {"failed": bool(failed), "state": state})
|
||
|
||
|
||
# ── usage ────────────────────────────────────────────────────────────
|
||
@_session_method("session.usage")
|
||
def _(rid, params: dict, session: dict) -> dict:
|
||
usage: dict = _session_usage_snapshot(session)
|
||
if session.get("agent") is None and not usage:
|
||
usage = {"calls": 0, "input": 0, "output": 0, "total": 0}
|
||
# Nous credits are agent-independent (portal fetch); fail-open when absent.
|
||
with contextlib.suppress(Exception):
|
||
from agent.account_usage import nous_credits_lines
|
||
if credits := nous_credits_lines():
|
||
usage["credits_lines"] = credits
|
||
# Provider account limits (e.g. Codex quota windows) — the same block the CLI and gateway /usage
|
||
# render, so the Desktop usage feed is not the one surface that omits them. Fail-open.
|
||
with contextlib.suppress(Exception):
|
||
if account := _account_usage_lines(session):
|
||
usage["account_lines"] = account
|
||
return _ok(rid, usage)
|
||
|
||
|
||
def _account_usage_lines(session: dict) -> list[str]:
|
||
"""Rendered account-limit lines for the session's route: the live agent's provider/endpoint when
|
||
built, else the configured ``model.provider`` (on-disk credentials suffice, e.g. Codex OAuth)."""
|
||
from agent.account_usage import fetch_account_usage, render_account_usage_lines
|
||
agent = session.get("agent")
|
||
provider = getattr(agent, "provider", None) or _config_model_target()[1]
|
||
if not provider:
|
||
return []
|
||
snapshot = fetch_account_usage(
|
||
provider, base_url=getattr(agent, "base_url", None), api_key=getattr(agent, "api_key", None))
|
||
return render_account_usage_lines(snapshot)
|
||
|
||
|
||
@_session_method("session.context_breakdown")
|
||
def _(rid, params: dict, session: dict) -> dict:
|
||
if (agent := session.get("agent")) is None:
|
||
usage = _session_usage_snapshot(session) or _get_usage(None)
|
||
return _ok(rid, {
|
||
"categories": [], "context_max": usage.get("context_max", 0) or 0,
|
||
"context_percent": usage.get("context_percent", 0) or 0,
|
||
"context_used": usage.get("context_used", 0) or 0,
|
||
"estimated_total": 0,
|
||
"context_estimated": usage.get("context_estimated", False),
|
||
"context_source": usage.get("context_source", "provider_usage"),
|
||
"model": _metadata_mirror(session).get("model", "")})
|
||
with session["history_lock"]:
|
||
history = list(session.get("history", []))
|
||
# Bind the session context (on the RPC thread the session cwd is unset, so the prompt build inside
|
||
# would key its workspace pin on the backend's cwd and overwrite the session's pin) and the session's
|
||
# profile runtime scope: the build reaches the external memory provider's system_prompt_block(),
|
||
# whose get_secret read fails closed once this process multiplexes (#112927).
|
||
tokens = _set_session_context(session["session_key"])
|
||
try:
|
||
from agent.context_breakdown import compute_session_context_breakdown
|
||
from agent.context_file_sources import context_file_sources_for_agent
|
||
with _session_profile_runtime_scope(session):
|
||
payload = compute_session_context_breakdown(agent, history)
|
||
# Structured per-file rows so the Desktop popover can explain "why is my CLAUDE.md ignored?".
|
||
payload["context_files"] = context_file_sources_for_agent(agent)
|
||
return _ok(rid, payload)
|
||
except Exception as exc:
|
||
return _err(rid, 5000, f"Could not compute context breakdown: {exc}")
|
||
finally:
|
||
_clear_session_context(tokens)
|
||
|
||
|
||
# ── pet ──────────────────────────────────────────────────────────────
|
||
_PET_OFF = {"enabled": False}
|
||
|
||
|
||
@_pet_method("pet.info", fail_open=_PET_OFF)
|
||
def _(rid, params: dict) -> dict:
|
||
"""Active pet for sprite renderers: spritesheet (base64) + frame geometry + state-row taxonomy."""
|
||
if (active := _active_pet()) is None:
|
||
return _ok(rid, {"enabled": False})
|
||
pet, scale = active
|
||
payload = {"enabled": True, **_pet_sprite_payload(pet, scale=scale)}
|
||
# Send-once for the multi-MB sheet: same revision → metadata only.
|
||
if (known := str(params.get("knownRevision", "") or "")) and known == payload.get("spritesheetRevision"):
|
||
# Send-once semantics for the multi-MB spritesheet (#54730): a caller that already holds the sheet
|
||
# passes the revision it has, and an unchanged sheet comes back as metadata only
|
||
# (spritesheetUnchanged).
|
||
payload.pop("spritesheetBase64", None)
|
||
payload["spritesheetUnchanged"] = True
|
||
return _ok(rid, payload)
|
||
|
||
|
||
@_pet_method("pet.info.meta", fail_open=_PET_OFF)
|
||
def _(rid, params: dict) -> dict:
|
||
"""Cheap active-pet metadata used to avoid full payload refreshes."""
|
||
if (active := _active_pet()) is None:
|
||
return _ok(rid, {"enabled": False})
|
||
pet, scale = active
|
||
return _ok(rid, {"enabled": True, "slug": pet.slug, "displayName": pet.display_name, "scale": scale,
|
||
"spritesheetRevision": _pet_sheet_revision(pet.spritesheet)})
|
||
|
||
|
||
def _pet_kitty_cells(pet, pet_cfg: dict, state: str, scale: float) -> dict | None:
|
||
"""kitty payload for a TTY that speaks it (dashboard PTY falls through); only kitty is grid-safe in Ink."""
|
||
from agent.pet import constants, render
|
||
from agent.pet.render import PetRenderer
|
||
configured = str(pet_cfg.get("render_mode", "auto") or "auto").lower()
|
||
if (render.detect_terminal_graphics() if configured in ("", "auto") else configured) != "kitty":
|
||
return None
|
||
image_id = render.kitty_image_id(pet.slug)
|
||
# kitty sizes from scaled pixels, so unicode_cols is moot here.
|
||
payload = PetRenderer(str(pet.spritesheet), mode="kitty", scale=scale).kitty_payload(state, image_id=image_id)
|
||
if not payload:
|
||
return None
|
||
return {"graphics": "kitty", "imageId": image_id, "color": render.kitty_color_hex(image_id),
|
||
"cols": payload["cols"], "rows": payload["rows"], "placeholder": payload["placeholder"],
|
||
"frames": payload["frames"], "frameMs": constants.LOOP_MS / max(1, len(payload["frames"]) or 1),
|
||
"scale": scale}
|
||
|
||
|
||
@_pet_method("pet.cells", fail_open=_PET_OFF)
|
||
def _(rid, params: dict) -> dict:
|
||
"""Half-block cell frames (``[tr,tg,tb,ta, br,bg,bb,ba]``) for one pet ``state``; ``cols``, ``graphics``."""
|
||
from agent.pet import constants, store
|
||
from agent.pet.render import PetRenderer
|
||
pet_cfg = _pet_display_cfg()
|
||
pet = None
|
||
if is_truthy_value(pet_cfg.get("enabled"), default=False):
|
||
pet = store.resolve_active_pet(str(pet_cfg.get("slug", "") or ""))
|
||
if pet is None or not pet.exists:
|
||
return _ok(rid, {"enabled": False})
|
||
state = str(params.get("state") or constants.PetState.IDLE.value)
|
||
scale = float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE)
|
||
cols = int(params.get("cols") or 0) or constants.resolve_cols(scale, pet_cfg.get("unicode_cols", 0))
|
||
base = {"enabled": True, "slug": pet.slug, "displayName": pet.display_name, "state": state}
|
||
if params.get("graphics") and (kitty := _pet_kitty_cells(pet, pet_cfg, state, scale)):
|
||
return _ok(rid, {**base, **kitty})
|
||
renderer = PetRenderer(str(pet.spritesheet), mode="unicode", scale=scale, unicode_cols=cols)
|
||
count = renderer.frame_count(state) or 1
|
||
frames = [[[[*top, *bottom] for (top, bottom) in row] for row in renderer.cells(state, i, cols=cols)]
|
||
for i in range(count)]
|
||
return _ok(rid, {**base, "cols": cols, "frameMs": constants.LOOP_MS / max(1, count), "frames": frames,
|
||
"scale": scale})
|
||
|
||
|
||
@_pet_method("pet.gallery", fail_open={"enabled": False, "active": "", "pets": []})
|
||
def _(rid, params: dict) -> dict:
|
||
"""Petdex gallery + local install state (installed-only offline); ``localOnly`` skips the remote manifest."""
|
||
local_only = bool(params.get("localOnly"))
|
||
from agent.pet import store
|
||
pet_cfg = _pet_display_cfg()
|
||
installed = {p.slug: p for p in store.installed_pets()}
|
||
gallery: list[dict] = []
|
||
try:
|
||
from agent.pet.manifest import fetch_manifest, prefetch
|
||
# Local-only still warms the manifest cache in the background.
|
||
if local_only:
|
||
prefetch()
|
||
for entry in [] if local_only else fetch_manifest():
|
||
gallery.append({
|
||
"slug": entry.slug, "displayName": entry.display_name, "installed": entry.slug in installed,
|
||
"spritesheetUrl": entry.spritesheet_url,
|
||
# No popularity metric; petdex's hand-picked set (by asset path) is closest.
|
||
"curated": "/curated/" in entry.spritesheet_url,
|
||
"generated": entry.slug in installed and installed[entry.slug].generated})
|
||
except Exception as exc: # noqa: BLE001 - offline: fall back to installed
|
||
logger.debug("pet.gallery manifest fetch failed: %s", exc)
|
||
seen = {item["slug"] for item in gallery}
|
||
gallery.extend(
|
||
{"slug": slug, "displayName": pet.display_name, "installed": True, "spritesheetUrl": "",
|
||
"generated": pet.generated}
|
||
for slug, pet in installed.items() if slug not in seen)
|
||
return _ok(rid, {"enabled": is_truthy_value(pet_cfg.get("enabled"), default=False),
|
||
"active": str(pet_cfg.get("slug", "") or ""), "pets": gallery})
|
||
|
||
|
||
@_pet_method("pet.select", slug=True)
|
||
def _(rid, params: dict, slug: str) -> dict:
|
||
"""Adopt a pet: install (if needed) + activate; writes ``display.pet.*`` to config."""
|
||
from agent.pet import store
|
||
from agent.pet.manifest import ManifestError
|
||
from hermes_cli.pets import _set_active
|
||
try:
|
||
pet = store.install_pet(slug)
|
||
except (store.PetStoreError, ManifestError) as exc:
|
||
return _err(rid, 5031, f"could not adopt '{slug}': {exc}")
|
||
_set_active(slug)
|
||
return _ok(rid, {"ok": True, "slug": slug, "displayName": pet.display_name})
|
||
|
||
|
||
@_pet_method("pet.remove", slug=True)
|
||
def _(rid, params: dict, slug: str) -> dict:
|
||
"""Uninstall a pet (delete its directory); if it was active, turn the display off."""
|
||
from agent.pet import store
|
||
from hermes_cli.pets import _clear_active_if
|
||
removed = store.remove_pet(slug)
|
||
_pet_config_followup("pet.remove", _clear_active_if, slug)
|
||
return _ok(rid, {"ok": removed, "slug": slug})
|
||
|
||
|
||
def _pet_config_followup(what: str, fn, *args) -> None:
|
||
"""Best-effort ``hermes_cli.pets`` active-slug update after a store op that already succeeded."""
|
||
try:
|
||
fn(*args)
|
||
except Exception as exc: # noqa: BLE001
|
||
logger.debug("%s config update failed: %s", what, exc)
|
||
|
||
|
||
def _b64(data: bytes) -> str:
|
||
import base64
|
||
return base64.standard_b64encode(data).decode("ascii")
|
||
|
||
|
||
@_pet_method("pet.export", slug=True)
|
||
def _(rid, params: dict, slug: str) -> dict:
|
||
"""Export an installed pet as a re-importable ``.zip`` → ``{ok, filename, zipBase64}``."""
|
||
from agent.pet import store
|
||
filename, data = store.export_pet(slug)
|
||
return _ok(rid, {"ok": True, "filename": filename, "zipBase64": _b64(data)})
|
||
|
||
|
||
@_pet_method("pet.rename", slug=True)
|
||
def _(rid, params: dict, slug: str) -> dict:
|
||
"""Rename a pet's display name + realign its slug/dir; follows the active slug in config."""
|
||
if not (name := _str_param(params, "name")):
|
||
return _err(rid, 4004, "missing name")
|
||
from agent.pet import store
|
||
if not (new_slug := store.rename_pet(slug, name)):
|
||
return _err(rid, 5031, "pet.rename failed")
|
||
if new_slug != slug:
|
||
from hermes_cli.pets import _rename_active_if
|
||
_pet_config_followup("pet.rename", _rename_active_if, slug, new_slug)
|
||
return _ok(rid, {"ok": True, "slug": new_slug, "displayName": name})
|
||
|
||
|
||
@_pet_method("pet.thumb", slug=True, fail_open=lambda params: {"ok": False, "slug": _str_param(params, "slug")})
|
||
def _(rid, params: dict, slug: str) -> dict:
|
||
"""Idle-frame PNG data URI for the picker (desktop CSP breaks CDN ``<img>``); ``url``: not-yet-installed."""
|
||
from agent.pet import store
|
||
if not (data := store.thumbnail_png(slug, source_url=str(params.get("url") or ""))):
|
||
return _ok(rid, {"ok": False, "slug": slug})
|
||
return _ok(rid, {"ok": True, "slug": slug, "dataUri": "data:image/png;base64," + _b64(data)})
|
||
|
||
|
||
@_pet_method("pet.disable")
|
||
def _(rid, params: dict) -> dict:
|
||
"""``display.pet.enabled=false`` from the desktop picker."""
|
||
from hermes_cli.pets import _set_enabled
|
||
_set_enabled(False)
|
||
return _ok(rid, {"ok": True})
|
||
|
||
|
||
@_pet_method("pet.scale")
|
||
def _(rid, params: dict) -> dict:
|
||
"""Persist ``display.pet.scale`` (clamped to engine bounds) from the desktop slider."""
|
||
from hermes_cli.pets import set_pet_scale
|
||
scale, err = set_pet_scale(params.get("scale"))
|
||
return _err(rid, 4004, err) if err else _ok(rid, {"ok": True, "scale": scale})
|
||
|
||
|
||
@method("pet.cancel")
|
||
def _(rid, params: dict) -> dict:
|
||
"""Stop an in-flight generate/hatch by token (idempotent; off the pool so it lands mid-generation)."""
|
||
if token := _str_param(params, "token"):
|
||
_pet_cancel_request(token)
|
||
return _ok(rid, {"ok": True})
|
||
|
||
|
||
@_pet_method("pet.generate.status", scoped=False, fail_open={"available": False, "providers": []})
|
||
def _(rid, params: dict) -> dict:
|
||
"""Whether pet generation is possible: a reference-capable image backend is configured."""
|
||
from agent.pet.generate.imagegen import GenerationError, list_sprite_providers, resolve_provider
|
||
available, providers = True, []
|
||
try:
|
||
resolve_provider(require_references=True)
|
||
except GenerationError:
|
||
available = False
|
||
try:
|
||
providers = list_sprite_providers()
|
||
except Exception as exc: # noqa: BLE001 - picker is best-effort
|
||
logger.debug("pet provider list failed: %s", exc)
|
||
return _ok(rid, {"available": available, "providers": providers})
|
||
|
||
|
||
def _pet_pick_provider(params: dict, *, require_references: bool):
|
||
"""Picker-chosen ``params.provider`` resolved up front (a bad pick fails fast, not mid-fan-out)."""
|
||
from agent.pet.generate.imagegen import resolve_provider
|
||
name = _str_param(params, "provider")
|
||
return resolve_provider(require_references=require_references, prefer=name) if name else None
|
||
|
||
|
||
@_pet_method("pet.generate", scoped=False)
|
||
def _(rid, params: dict) -> dict:
|
||
"""Candidate base looks for a new pet (draft step; worker pool): ``prompt`` (or a ``referenceImage``
|
||
data URL), ``count`` (≤4), ``style``, ``provider`` → ``{ok, token, drafts:[{index, dataUri}]}``."""
|
||
prompt = _str_param(params, "prompt")
|
||
ref_raw = _str_param(params, "referenceImage")
|
||
if not prompt and not ref_raw:
|
||
return _err(rid, 4004, "missing prompt")
|
||
count = max(1, min(4, _int_param(params, "count", 4) or 4))
|
||
import shutil
|
||
from agent.pet.generate import generate_base_drafts
|
||
from agent.pet.generate.imagegen import GenerationError
|
||
root = _pet_gen_root()
|
||
_pet_gen_sweep(root)
|
||
# Token up front so each draft is staged + streamed the moment it lands.
|
||
token = uuid.uuid4().hex[:12]
|
||
_pet_cancel_arm(token)
|
||
stage = root / token
|
||
stage.mkdir(parents=True, exist_ok=True)
|
||
reference_images = None
|
||
if ref_raw:
|
||
try:
|
||
reference_images = _pet_reference_images_from_data_url(ref_raw, stage)
|
||
except ValueError as exc:
|
||
return _pet_gen_abort(rid, token, 4004, str(exc))
|
||
try:
|
||
sprite = _pet_pick_provider(params, require_references=bool(reference_images))
|
||
except GenerationError as exc:
|
||
return _pet_gen_abort(rid, token, 5031, str(exc))
|
||
out: list[dict] = []
|
||
# Token-only init event so a Stop fired before the first draft can target this run.
|
||
_pet_emit("pet.generate.progress", {"token": token, "count": count}, "pet.generate init")
|
||
|
||
def _on_draft(index: int, src) -> None:
|
||
dest = stage / f"draft-{index}.png"
|
||
try:
|
||
shutil.copyfile(src, dest)
|
||
data_uri = _pet_png_data_uri(dest)
|
||
except Exception as exc: # noqa: BLE001 - skip a bad draft, keep the rest
|
||
logger.debug("pet.generate draft %d failed: %s", index, exc)
|
||
return
|
||
out.append({"index": index, "dataUri": data_uri})
|
||
_pet_emit("pet.generate.progress", {"token": token, "index": index, "dataUri": data_uri, "count": count},
|
||
"pet.generate progress")
|
||
try:
|
||
generate_base_drafts(prompt or "a pet based on the reference image", n=count,
|
||
style=_str_param(params, "style", "auto"), reference_images=reference_images,
|
||
provider=sprite, on_draft=_on_draft, is_cancelled=lambda: _pet_is_cancelled(token))
|
||
except GenerationError as exc:
|
||
return _pet_gen_abort(rid, token, 5031, str(exc))
|
||
cancelled = _pet_is_cancelled(token)
|
||
_pet_cancel_release(token)
|
||
if cancelled or not out:
|
||
return _err(rid, 5031, "generation cancelled" if cancelled else "generation produced no usable drafts")
|
||
return _ok(rid, {"ok": True, "token": token, "drafts": sorted(out, key=lambda d: d["index"])})
|
||
|
||
|
||
@_pet_method("pet.hatch", scoped=False)
|
||
def _(rid, params: dict) -> dict:
|
||
"""Turn a base draft (``token`` + ``index``) into a full pet — installed but NOT active (``pet.select``
|
||
adopts, ``pet.remove`` discards) → ``{ok, slug, displayName, warnings, pet}``."""
|
||
token, name = _str_param(params, "token"), _str_param(params, "name")
|
||
if not token or not name:
|
||
return _err(rid, 4004, "missing token" if not token else "missing name")
|
||
# Own cancel key: pet.generate may still be releasing `token`. Falls back for old clients.
|
||
cancel_token = _str_param(params, "cancelToken") or token
|
||
from agent.pet import store
|
||
from agent.pet.generate import hatch_pet
|
||
from agent.pet.generate.imagegen import GenerationError
|
||
base = _pet_gen_root() / token / f"draft-{_int_param(params, 'index', 0)}.png"
|
||
if not base.is_file():
|
||
return _err(rid, 4004, "draft expired — generate again")
|
||
try:
|
||
sprite = _pet_pick_provider(params, require_references=True) # rows always need reference grounding
|
||
except GenerationError as exc:
|
||
return _err(rid, 5031, str(exc))
|
||
_pet_cancel_arm(cancel_token)
|
||
slug = store.unique_slug(name)
|
||
|
||
def _on_progress(event: str, detail: str) -> None:
|
||
# Row progress "<state>:<done>:<total>" → "Drawing <state>… (n/total)".
|
||
payload: dict = {"event": event, "detail": detail}
|
||
if event == "row" and detail.count(":") == 2:
|
||
state, done, total = detail.split(":")
|
||
payload = {"event": "row", "state": state, "done": done, "total": total}
|
||
_pet_emit("pet.hatch.progress", payload, "pet.hatch progress")
|
||
try:
|
||
result = hatch_pet(
|
||
base_image=base, slug=slug, display_name=name, description=str(params.get("description") or ""),
|
||
concept=str(params.get("prompt") or name), style=_str_param(params, "style", "auto"), provider=sprite,
|
||
on_progress=_on_progress, is_cancelled=lambda: _pet_is_cancelled(cancel_token))
|
||
except GenerationError as exc:
|
||
return _err(rid, 5031, str(exc))
|
||
finally:
|
||
_pet_cancel_release(cancel_token)
|
||
pet = store.load_pet(result.slug)
|
||
return _ok(rid, {"ok": True, "slug": result.slug, "displayName": result.display_name,
|
||
"warnings": result.validation.get("warnings", []),
|
||
"pet": _pet_sprite_payload(pet, scale=_pet_config_scale()) if pet else {}})
|
||
|
||
|
||
# ── billing / subscription ───────────────────────────────────────────
|
||
# All fail-open: a logged-out / unreachable portal yields an ``ok`` envelope with a typed
|
||
# ``error`` (not a JSON-RPC error) so the TUI maps it to copy. ``billing:manage`` routes
|
||
# return error=insufficient_scope on 403, which drives the ``billing.step_up`` device flow.
|
||
def _billing_view(name: str, module: str, builder: str, serializer: str, fallback: dict) -> None:
|
||
"""Read-only view RPC (no scope required): ``serializer(module.builder())``, ``fallback`` on any error.
|
||
The view module stays a lazy import (startup budget); the serializer is a server global."""
|
||
@method(name)
|
||
def _(rid, params: dict) -> dict:
|
||
try:
|
||
from importlib import import_module
|
||
return _ok(rid, globals()[serializer](getattr(import_module(module), builder)()))
|
||
except Exception:
|
||
return _ok(rid, dict(fallback))
|
||
|
||
|
||
@method("billing.state")
|
||
def _(rid, params: dict) -> dict:
|
||
"""Read-only billing view (no scope required); fail-open. The Nous free tier has no account to
|
||
bill, so its state is answered locally (``free_tier`` set, ``logged_in`` false) without a portal
|
||
round-trip that could only fail."""
|
||
try:
|
||
from agent.billing_view import BillingState, build_billing_state
|
||
from hermes_cli.anon_auth import guest_carries_inference
|
||
if guest_carries_inference():
|
||
return _ok(rid, _serialize_billing_state(BillingState(logged_in=False), free_tier=True))
|
||
return _ok(rid, _serialize_billing_state(build_billing_state()))
|
||
except Exception:
|
||
return _ok(rid, {"ok": True, "logged_in": False, "free_tier": False, "error": "could not load billing state"})
|
||
|
||
|
||
_billing_view("usage.bars", "agent.billing_usage", "build_usage_model", "_serialize_usage_model", # two-bar $ view
|
||
{"ok": True, "available": False})
|
||
_billing_view("subscription.state", "agent.subscription_view", "build_subscription_state",
|
||
"_serialize_subscription_state",
|
||
{"ok": True, "logged_in": False, "error": "could not load subscription state"})
|
||
|
||
|
||
@method("subscription.preview")
|
||
def _(rid, params: dict) -> dict:
|
||
"""POST /api/billing/subscription/preview → chargeless effect quote. billing:manage."""
|
||
from agent.subscription_view import subscription_change_preview_from_payload
|
||
from hermes_cli.nous_billing import post_subscription_preview
|
||
if not (tier_id := params.get("subscription_type_id")):
|
||
return _billing_invalid(rid, "subscription_type_id is required")
|
||
return _billing_call(rid, lambda: _serialize_subscription_preview(
|
||
subscription_change_preview_from_payload(post_subscription_preview(subscription_type_id=tier_id))))
|
||
|
||
|
||
def _billing_route(name: str, call, *, invalid=None, message: str = "", error: str = "invalid_request",
|
||
idempotent: bool = False):
|
||
"""Portal write route on ``hermes_cli.nous_billing`` (lazy; tests patch its functions): ``invalid(params)``
|
||
→ ``_billing_invalid(message, error)``; ``call(nb, params, key)`` performs the request. ``idempotent``
|
||
mints ``idempotency_key`` if absent and echoes it (also on error) so the TUI retries the SAME operation."""
|
||
@method(name)
|
||
def _(rid, params: dict) -> dict:
|
||
import hermes_cli.nous_billing as nb
|
||
if invalid is not None and invalid(params):
|
||
return _billing_invalid(rid, message, error=error)
|
||
key = extra = None
|
||
if idempotent:
|
||
from agent.billing_view import new_idempotency_key
|
||
key = params.get("idempotency_key") or new_idempotency_key()
|
||
extra = {"idempotency_key": key}
|
||
return _billing_call(rid, lambda: call(nb, params, key) | (extra or {}), extra=extra)
|
||
|
||
|
||
# PUT pending-change: schedule a downgrade / same-price change OR a period-end cancellation.
|
||
_billing_route("subscription.change", lambda nb, p, _k: _billing_pending_change(nb.put_subscription_pending_change(
|
||
subscription_type_id=p.get("subscription_type_id"), cancel=bool(p.get("cancel")))),
|
||
invalid=lambda p: not p.get("cancel") and not p.get("subscription_type_id"),
|
||
message="subscription_type_id or cancel is required")
|
||
# DELETE pending-change: clear a scheduled downgrade / cancellation (re-enables recurring spend).
|
||
_billing_route("subscription.resume",
|
||
lambda nb, p, _k: _billing_pending_change(nb.delete_subscription_pending_change()))
|
||
# The money route (prorate + charge + flip plan). SCA / decline → status requires_action / payment_failed +
|
||
# recovery_url.
|
||
_billing_route("subscription.upgrade", lambda nb, p, key: _billing_pick(
|
||
nb.post_subscription_upgrade(subscription_type_id=p.get("subscription_type_id"), idempotency_key=key),
|
||
status="status", target_tier_name="targetTierName", recovery_url="recoveryUrl", reason="reason"),
|
||
invalid=lambda p: not p.get("subscription_type_id"), message="subscription_type_id is required", idempotent=True)
|
||
# POST /api/billing/charge → {ok, charge_id, idempotency_key}.
|
||
_billing_route("billing.charge", lambda nb, p, key: _billing_pick(
|
||
nb.post_charge(amount_usd=p.get("amount_usd"), idempotency_key=key), charge_id="chargeId"),
|
||
invalid=lambda p: p.get("amount_usd") is None, message="amount_usd is required", idempotent=True)
|
||
# GET /api/billing/charge/{id} — a single status read; the caller drives the poll cadence.
|
||
_billing_route("billing.charge_status", lambda nb, p, _k: _billing_pick(
|
||
nb.get_charge_status(p.get("charge_id")), status="status", amount_usd="amountUsd", settled_at="settledAt",
|
||
reason="reason"), invalid=lambda p: not p.get("charge_id"), message="charge_id is required",
|
||
error="invalid_charge_id")
|
||
|
||
|
||
def _auto_reload(nb, p: dict, _key) -> dict:
|
||
"""PATCH /api/billing/auto-top-up. params: {enabled, threshold, top_up_amount}."""
|
||
nb.patch_auto_top_up(enabled=bool(p.get("enabled")), threshold=p.get("threshold"),
|
||
top_up_amount=p.get("top_up_amount"))
|
||
return {"ok": True}
|
||
|
||
|
||
_billing_route("billing.auto_reload", _auto_reload, message="threshold and top_up_amount are required",
|
||
invalid=lambda p: p.get("threshold") is None or p.get("top_up_amount") is None)
|
||
|
||
|
||
@method("billing.step_up")
|
||
def _(rid, params: dict) -> dict:
|
||
"""billing:manage step-up device flow → {ok, granted} (false when the server downscopes). Pooled (blocks
|
||
for minutes); URL/code reach the TUI via ``billing.step_up.verification`` (stdout is the RPC pipe) and the
|
||
browser opens TUI-side, never via the gateway's headless webbrowser.open."""
|
||
sid = params.get("session_id") or ""
|
||
|
||
def call():
|
||
from hermes_cli.auth import step_up_nous_billing_scope
|
||
granted = step_up_nous_billing_scope(
|
||
open_browser=False,
|
||
on_verification=lambda url, code: _emit(
|
||
"billing.step_up.verification", sid, {"verification_url": url, "user_code": code}))
|
||
return {"ok": True, "granted": bool(granted)}
|
||
return _billing_call(rid, call, extra={"granted": False})
|
||
|
||
|
||
# ── session status / history / undo / compress / save / close ────────
|
||
def _status_row(session: dict, params: dict, key: str) -> dict:
|
||
"""Stored row for ``key``: the live session's bound profile db first, else params.profile / launch."""
|
||
if not key:
|
||
return {}
|
||
with _session_db(session) as db:
|
||
if db is not None:
|
||
return _try_get_session(db, key)
|
||
with _profile_db(params) as db2:
|
||
return _try_get_session(db2, key) if db2 else {}
|
||
|
||
|
||
def _try_get_session(db, key: str) -> dict:
|
||
with contextlib.suppress(Exception):
|
||
return db.get_session(key) or {}
|
||
return {}
|
||
|
||
|
||
@_session_method("session.status")
|
||
def _(rid, params: dict, session: dict) -> dict:
|
||
from hermes_cli.status_report import build_status_fields, status_lines
|
||
key = session.get("session_key") or params.get("session_id") or ""
|
||
mirror = _metadata_mirror(session)
|
||
# Under turn isolation the compute host owns the live route: a stale in-process agent object
|
||
# must not outrank the host's mirrored model/provider. Before the first host frame fills the
|
||
# mirror, the in-process agent is still the only route we know (same order as _session_info).
|
||
live_agent = session.get("agent")
|
||
agent = None if session.get("_compute_host_active") else live_agent
|
||
fields = build_status_fields(
|
||
key, agent, _status_row(session, params, key),
|
||
model=mirror.get("model") or getattr(live_agent, "model", None),
|
||
provider=mirror.get("provider") or getattr(live_agent, "provider", None),
|
||
tokens=_session_usage_snapshot(session).get("total"), agent_running=bool(session.get("running")),
|
||
home=session.get("profile_home"),
|
||
)
|
||
project = _project_info_for_cwd(_display_session_cwd(session))
|
||
lines = [
|
||
"Hermes TUI Status", "", *status_lines(fields, "session_id", "path"),
|
||
*([f"Project: {project['name']}"] if project else []),
|
||
*status_lines(fields, "title", "model", "created", "last_activity", "tokens", "agent_running")]
|
||
return _ok(rid, {"output": "\n".join(lines)})
|
||
|
||
|
||
@_session_method("session.history")
|
||
def _(rid, params: dict, session: dict) -> dict:
|
||
history = list(session.get("history", []))
|
||
if session.get("session_key"):
|
||
with _session_db(session) as db:
|
||
if db is not None:
|
||
# include_row_ids: the durable row id is how clients address a persisted turn (reactions,
|
||
# truncation targets); _history_to_messages forwards it.
|
||
with contextlib.suppress(Exception):
|
||
# The projection in _history_to_messages only forwards row_id when the row carries a
|
||
# stamp, so an unstamped read here silently strips the one durable address clients can
|
||
# use. See #87059.
|
||
history = db.get_messages_as_conversation(
|
||
session["session_key"], include_ancestors=True, include_row_ids=True)
|
||
return _ok(rid, {"count": len(history), "messages": _history_to_messages(history, profile_home=session.get("profile_home"))})
|
||
|
||
|
||
@_session_method("session.undo", live=True)
|
||
def _(rid, params: dict, session: dict) -> dict:
|
||
# Under a running turn the post-run write would clobber the undo — stop the reply first.
|
||
busy = _err(rid, 4009, busy_message("undo"))
|
||
if session.get("running"):
|
||
return busy
|
||
removed = 0
|
||
with session["history_lock"]:
|
||
if session.get("running"):
|
||
return busy
|
||
history = _history_without_ephemeral_scaffolding(session.get("history", []))
|
||
# Truncate from the last *real* user turn (not a timeline marker / compaction handoff).
|
||
from agent.context_compressor import user_originated_turn_view
|
||
if user_turns := sum(1 for message in history if user_originated_turn_view(message) is not None):
|
||
try:
|
||
removed = _rewind_active_session_history(session, user_turns - 1)[2]
|
||
except Exception as exc:
|
||
return _err(rid, 5008, f"undo: {exc}")
|
||
return _ok(rid, {"removed": removed})
|
||
|
||
|
||
def _compute_host_ack_error(rid, ack: dict, code: int, default: str):
|
||
"""``_err`` for a ``control.error``/``error`` ack, else None."""
|
||
if ack.get("type") in {"control.error", "error"}:
|
||
return _err(rid, code, str(ack.get("message") or default))
|
||
return None
|
||
|
||
|
||
def _save_via_compute_host(rid, params: dict) -> dict:
|
||
"""``session.save`` for a turn-isolated session: the host owns the transcript file."""
|
||
try:
|
||
ack = _send_compute_host_control(str(params.get("session_id") or ""), route_name="session.save", wait=True)
|
||
except Exception as exc:
|
||
return _err(rid, 5011, f"compute-host session save failed: {exc}")
|
||
if (resp := _compute_host_ack_error(rid, ack, 5011, "compute-host session save failed")) is not None:
|
||
return resp
|
||
if not isinstance(result := ack.get("result"), dict):
|
||
return _err(rid, 5011, "compute-host session save returned an invalid response")
|
||
return _ok(rid, result)
|
||
|
||
|
||
def _compress_via_compute_host(rid, params: dict, session: dict) -> dict:
|
||
"""``session.compress`` for a turn-isolated session: forward ``/compress`` to the host."""
|
||
sid = str(params.get("session_id") or "")
|
||
focus_topic = _str_param(params, "focus_topic")
|
||
|
||
def _on_late_ack(late: dict, _sid=sid) -> None:
|
||
_adopt_late_compute_host_compress_ack(_sid, session, late, route_name="session.compress")
|
||
try:
|
||
ack = _send_compute_host_control(
|
||
sid, route_name="session.compress", command="/compress" + (f" {focus_topic}" if focus_topic else ""),
|
||
# compression.context_total_ceiling_seconds: the host legitimately runs that long.
|
||
wait=True, timeout=_compute_host_compress_wait_seconds(), on_late_ack=_on_late_ack)
|
||
except queue.Empty:
|
||
# Waiter gave up, host still compressing; the late-ack handler adopts the rotated session when it
|
||
# lands. Not an error (a 5019 here reported timeouts that later succeeded).
|
||
return _ok(rid, {"status": "pending", "turn_isolation": True,
|
||
"message": ("compression still running in the background; "
|
||
"the transcript will refresh when it finishes")})
|
||
except Exception as exc:
|
||
return _err(rid, 5019, f"compute-host compress failed: {exc}")
|
||
if (resp := _compute_host_ack_error(rid, ack, 4009, "compute-host compress failed")) is not None:
|
||
return resp
|
||
_apply_compute_host_metadata_mirror(session, ack)
|
||
if isinstance(host_result := ack.get("result"), dict):
|
||
# Host-owned result verbatim (carries `status: aborted` / `summary.aborted`).
|
||
return _ok(rid, {**host_result, "turn_isolation": True})
|
||
host_info = ack.get("session_info") if isinstance(ack.get("session_info"), dict) else {}
|
||
return _ok(rid, {
|
||
"status": "compressed", "turn_isolation": True,
|
||
# `messages` goes top-level for the transcript replacement; don't duplicate it in the ack.
|
||
"host_ack": {key: value for key, value in ack.items() if key != "messages"}, "info": host_info,
|
||
"messages": _history_to_messages(ack.get("messages"), profile_home=session.get("profile_home")) if isinstance(ack.get("messages"), list) else [],
|
||
"usage": host_info.get("usage") if isinstance(host_info.get("usage"), dict) else {}})
|
||
|
||
|
||
def _compress_live(rid, sid: str, session: dict, focus_topic: str) -> dict:
|
||
"""In-process ``session.compress``: status pinned "compressing", then the before/after summary + messages."""
|
||
from agent.conversation_compression import finalize_context_engine_compression_notification
|
||
from agent.manual_compression_feedback import summarize_manual_compression
|
||
from agent.model_metadata import estimate_request_tokens_rough
|
||
with session["history_lock"]:
|
||
before_messages = list(session.get("history", []))
|
||
history_version = int(session.get("history_version", 0))
|
||
before_count = len(before_messages)
|
||
_agent = session["agent"]
|
||
_sys_prompt = getattr(_agent, "_cached_system_prompt", "") or ""
|
||
_tools = getattr(_agent, "tools", None) or None
|
||
|
||
def _tokens(msgs) -> int:
|
||
# Re-reads prompt + tools each call: _compress_context may have rebuilt the system prompt.
|
||
sys_prompt = getattr(_agent, "_cached_system_prompt", "") or _sys_prompt
|
||
tools = getattr(_agent, "tools", None) or _tools
|
||
return estimate_request_tokens_rough(msgs, system_prompt=sys_prompt, tools=tools) if msgs else 0
|
||
before_tokens = _tokens(before_messages)
|
||
if before_count >= 4:
|
||
focus_suffix = f', focus: "{focus_topic}"' if focus_topic else ""
|
||
_status_update(sid, "compressing",
|
||
f"⠋ compressing {before_count} messages (~{before_tokens:,} tok){focus_suffix}…")
|
||
try:
|
||
removed, usage = _compress_session_history(
|
||
session, focus_topic, approx_tokens=before_tokens, before_messages=before_messages,
|
||
history_version=history_version)
|
||
with session["history_lock"]:
|
||
messages = list(session.get("history", []))
|
||
after_tokens = _tokens(messages)
|
||
agent = session["agent"]
|
||
_sync_session_key_after_compress(sid, session)
|
||
summary = summarize_manual_compression(before_messages, messages, before_tokens, after_tokens,
|
||
compression_state=getattr(agent, "context_compressor", None))
|
||
info = _session_info(agent, session)
|
||
_emit("session.info", sid, info)
|
||
finalize_context_engine_compression_notification(agent, committed=True)
|
||
return _ok(rid, {
|
||
"status": "aborted" if summary["aborted"] else "compressed", "removed": removed,
|
||
"before_messages": before_count, "after_messages": len(messages),
|
||
"before_tokens": before_tokens, "after_tokens": after_tokens, "summary": summary,
|
||
"usage": usage, "info": info, "messages": _history_to_messages(messages, profile_home=session.get("profile_home"))})
|
||
finally:
|
||
# Always clear the pinned compressing status (success, no-op, or raise).
|
||
_status_update(sid, "ready")
|
||
|
||
|
||
@method("session.compress")
|
||
@_profile_scoped
|
||
def _(rid, params: dict) -> dict:
|
||
session, err = _sess_nowait(params, rid)
|
||
if err:
|
||
return err
|
||
if _session_uses_compute_host(session):
|
||
return _compress_via_compute_host(rid, params, session)
|
||
session, err = _sess(params, rid)
|
||
if err:
|
||
return err
|
||
if session.get("running"):
|
||
return _err(rid, 4009, busy_message("compress"))
|
||
sid = params.get("session_id", "")
|
||
try:
|
||
return _compress_live(rid, sid, session, _str_param(params, "focus_topic"))
|
||
except CompressionLockHeld as e:
|
||
_status_update(sid, "ready")
|
||
from agent.manual_compression_feedback import describe_compression_lock_skip
|
||
return _ok(rid, {"compressed": False, "lock_held": True, "message": describe_compression_lock_skip(e.holder)})
|
||
except Exception as e:
|
||
from agent.conversation_compression import finalize_context_engine_compression_notification
|
||
finalize_context_engine_compression_notification(session["agent"], committed=False)
|
||
return _err(rid, 5005, str(e))
|
||
|
||
|
||
@_session_method("session.save", live=True)
|
||
def _(rid, params: dict, session: dict) -> dict:
|
||
if _session_uses_compute_host(session):
|
||
return _save_via_compute_host(rid, params)
|
||
agent = session["agent"]
|
||
# Classic CLI /save: under the profile home, with the system prompt (dashboard parity).
|
||
saved_dir = get_hermes_home() / "sessions" / "saved"
|
||
try:
|
||
saved_dir.mkdir(parents=True, exist_ok=True)
|
||
except Exception as e:
|
||
return _err(rid, 5011, f"failed to create save directory {saved_dir}: {e}")
|
||
path = saved_dir / f"hermes_conversation_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||
with session["history_lock"]:
|
||
messages = list(session.get("history", []))
|
||
# Prefer the agent's session_start (classic CLI export); else the gateway created_at.
|
||
started = getattr(agent, "session_start", None)
|
||
if not isinstance(started, datetime):
|
||
created_at = session.get("created_at")
|
||
started = datetime.fromtimestamp(created_at) if isinstance(created_at, (int, float)) else None
|
||
try:
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
json.dump({"model": getattr(agent, "model", ""),
|
||
"session_id": getattr(agent, "session_id", None) or session.get("session_key") or "",
|
||
"session_start": started.isoformat() if started else "",
|
||
"system_prompt": getattr(agent, "_cached_system_prompt", "") or "",
|
||
"messages": messages}, f, indent=2, ensure_ascii=False)
|
||
except Exception as e:
|
||
return _err(rid, 5011, str(e))
|
||
return _ok(rid, {"file": str(path)})
|
||
|
||
|
||
@method("session.close")
|
||
def _(rid, params: dict) -> dict:
|
||
with _session_resume_lock: # lock only the ownership claim; finalization must not block resumes
|
||
session = _pop_session_by_id(params.get("session_id", ""))
|
||
return _ok(rid, {"closed": _teardown_popped_session(session, end_reason="tui_close")})
|
||
|
||
|
||
# ── session.branch ───────────────────────────────────────────────────
|
||
def _visible_branch_history(messages) -> list:
|
||
"""user/assistant rows with visible text, as FULL copies (reasoning + timeline-marker tags survive)."""
|
||
return [dict(message) for message in messages or []
|
||
if isinstance(message, dict) and message.get("role") in {"user", "assistant"}
|
||
and _coerce_message_text(message.get("content")).strip()]
|
||
|
||
|
||
def _build_branch_agent(session: dict, new_sid: str, new_key: str, history: list, source: str):
|
||
"""Build + register the branched agent in the parent's profile; the DEDICATED db handle is ours until
|
||
``_transfer_db_to_agent`` (released here on failure)."""
|
||
parent_home = session.get("profile_home")
|
||
parent_user_id = _session_auth_user_id(session)
|
||
branch_db, branch_owns_db = _profile_session_db(parent_home) if parent_home else (None, False)
|
||
try:
|
||
with _profile_build_scope(parent_home):
|
||
agent = _make_agent_in_context(new_sid, new_key, session_db=branch_db, platform_override=source,
|
||
cwd_override=_session_cwd(session),
|
||
context_cwd_is_launch_artifact=_context_cwd_is_launch_artifact(session),
|
||
auth_user_id=parent_user_id)
|
||
_init_session(new_sid, new_key, agent, list(history), cols=session.get("cols", 80),
|
||
cwd=_session_cwd(session), session_db=branch_db, source=source, profile_home=parent_home,
|
||
explicit_cwd=bool(session.get("explicit_cwd")))
|
||
_transfer_db_to_agent(agent, branch_db)
|
||
branch_owns_db = False
|
||
if new_sid in _sessions:
|
||
_sessions[new_sid]["active_session_lease"] = None # claimed lazily on the first turn
|
||
_sessions[new_sid]["auth_user_id"] = parent_user_id
|
||
return agent
|
||
finally:
|
||
if branch_owns_db and branch_db is not None:
|
||
_release_db(branch_db)
|
||
|
||
|
||
_BRANCH_COPY_FIELDS = (
|
||
"reasoning", "reasoning_content", "reasoning_details", "codex_reasoning_items", "codex_message_items",
|
||
# Timeline markers ride as role=user; untagged they become bare user turns after a restart, corrupting
|
||
# the truncate ordinal address space.
|
||
"display_kind", "display_metadata",
|
||
# Branch copies are history, not new activity: keep the parent's timestamps.
|
||
"timestamp")
|
||
|
||
|
||
def _branch_source_history(db, session: dict, old_key: str) -> list:
|
||
"""Rows a branch copies: the persisted DISPLAY projection reconciled with live memory (live history is
|
||
the MODEL projection — post-compaction summary + tail — the child would lose every archived turn)."""
|
||
with session["history_lock"]:
|
||
in_memory_history = [
|
||
dict(msg) for msg in list(session.get("display_history_prefix") or []) + list(session.get("history", []))
|
||
if isinstance(msg, dict)]
|
||
history = None
|
||
if callable(get_resume_conversations := getattr(db, "get_resume_conversations", None)):
|
||
try:
|
||
_, display_history = get_resume_conversations(old_key)
|
||
history = _visible_branch_history(_reconcile_display_with_live(display_history, in_memory_history))
|
||
except Exception:
|
||
logger.debug("branch display projection read failed", exc_info=True)
|
||
return history or _visible_branch_history(in_memory_history)
|
||
|
||
|
||
def _branch_live(rid, params: dict, session: dict, *, omit_messages: bool = False) -> dict:
|
||
# Write into the parent's profile-scoped state.db; the launch handle would orphan rows.
|
||
with _session_db(session) as db:
|
||
if db is None:
|
||
return _db_unavailable_error(rid, code=5008)
|
||
old_key = session["session_key"]
|
||
history = _branch_source_history(db, session, old_key)
|
||
if not history:
|
||
return _err(rid, 4008, "nothing to branch — send a message first")
|
||
if isinstance(count := params.get("count"), int) and count > 0:
|
||
history = history[:count]
|
||
new_key, new_sid, source = _new_session_key(), uuid.uuid4().hex[:8], _session_source(session)
|
||
try:
|
||
title = params.get("name", "") or _branch_title(db, old_key)
|
||
home = session.get("profile_home")
|
||
_persist_branch(db, new_key, old_key, title, history, source=source, cwd=_session_cwd(session),
|
||
profile_name=profile_name_for_home(home) or _current_profile_name(),
|
||
model=_session_default_model(session), copy_fields=_BRANCH_COPY_FIELDS,
|
||
title_source="user" if params.get("name") else "derived",
|
||
user_id=_session_auth_user_id(session))
|
||
except Exception as e:
|
||
return _err(rid, 5008, f"branch failed: {e}")
|
||
try:
|
||
agent = _build_branch_agent(session, new_sid, new_key, history, source)
|
||
except Exception as e:
|
||
return _err(rid, 5000, f"agent init failed on branch: {e}")
|
||
response = {"session_id": new_sid, "stored_session_id": new_key, "title": title, "parent": old_key,
|
||
"message_count": len(history), "info": _session_info(agent, _sessions.get(new_sid))}
|
||
if omit_messages:
|
||
response["messages_omitted"] = True
|
||
else:
|
||
response["messages"] = _history_to_messages(history, profile_home=session.get("profile_home"))
|
||
return _ok(rid, response)
|
||
|
||
|
||
@_session_method("session.branch", live=True)
|
||
def _(rid, params: dict, session: dict) -> dict:
|
||
return _branch_live(rid, params, session)
|
||
|
||
|
||
@_session_method("session.branch_whole", live=True)
|
||
def _(rid, params: dict, session: dict) -> dict:
|
||
"""Whole-history ``session.branch`` that doesn't echo the copied transcript back."""
|
||
return _branch_live(rid, params, session, omit_messages=True)
|
||
|
||
|
||
def _resume_wake_after_interrupt() -> None:
|
||
"""Re-arm a wake lease held by the interrupt caller or a voice capture.
|
||
|
||
``_wake_resume_if_owner`` no-ops unless that object holds the lease, so an
|
||
in-progress capture owned by someone else is not stolen. Interrupt already
|
||
silenced TTS before it can return an error; this matches that cut. A
|
||
``not_interrupted`` hosted-task mismatch must not call it.
|
||
"""
|
||
with _voice_sid_lock:
|
||
voice_owner = _voice_wake_owner
|
||
seen = []
|
||
for owner in (_caller_transport(), voice_owner):
|
||
if owner is None or any(owner is item for item in seen):
|
||
continue
|
||
seen.append(owner)
|
||
_wake_resume_if_owner(owner)
|
||
|
||
|
||
# ── interrupt / steer / redirect ─────────────────────────────────────
|
||
@method("session.interrupt")
|
||
def _(rid, params: dict) -> dict:
|
||
_tts_stream_stop() # keypress barge-in also silences streaming TTS (voice is process-global)
|
||
resume_wake = True
|
||
try:
|
||
session, err = _sess_nowait(params, rid)
|
||
if err:
|
||
return err
|
||
if expected := _str_param(params, "expected_hosted_task_id"):
|
||
with session["history_lock"]:
|
||
task = session.get("_hosted_room_task")
|
||
if not (session.get("running") and isinstance(task, dict) and task.get("task_id") == expected):
|
||
resume_wake = False
|
||
return _ok(rid, {"status": "not_interrupted", "interrupted": False})
|
||
sid = str(params.get("session_id") or "")
|
||
if _session_uses_compute_host(session):
|
||
try:
|
||
_interrupt_session_turn(sid, session, request_id=f"interrupt-{rid}")
|
||
except Exception as exc:
|
||
return _err(rid, 5019, f"compute-host interrupt failed: {exc}")
|
||
return _ok(rid, {"status": "interrupted", "turn_isolation": True})
|
||
session, err = _sess(params, rid)
|
||
if err:
|
||
return err
|
||
_interrupt_session_turn(sid, session)
|
||
# Retire the crash-recovery marker NOW: until the run thread's finally, a backend exit looks like a crash
|
||
# and session.resume auto-continues the turn the user just stopped (the extra key covers compression
|
||
# rotating session_key mid-turn).
|
||
with session["history_lock"]:
|
||
active_marker_key = str(session.pop("_active_turn_marker_key", "") or "")
|
||
_retire_turn_marker(session, active_marker_key)
|
||
return _ok(rid, {"status": "interrupted"})
|
||
finally:
|
||
if resume_wake:
|
||
try:
|
||
_resume_wake_after_interrupt()
|
||
except Exception:
|
||
logger.debug("session.interrupt wake resume failed", exc_info=True)
|
||
|
||
|
||
def _apply_correction(rid, session: dict, verb: str, text: str, accepted_status: str) -> dict:
|
||
"""``agent.<verb>(text)``; on acceptance record it on the live turn (mid-turn resume rebuilds the bubble)
|
||
and purge queued self-copies so post-turn drain cannot re-fire the old prompt."""
|
||
try:
|
||
accepted = getattr(session["agent"], verb)(text)
|
||
except Exception as exc:
|
||
return _err(rid, 5000, f"{verb} failed: {exc}")
|
||
if accepted:
|
||
with session["history_lock"]:
|
||
_record_inflight_correction(session, text)
|
||
# #84417: steer does not cancel the live original, but a server queue self-copy of that original
|
||
# must still not re-fire after settle (same class as redirect).
|
||
# #84417: purge server-queue self-duplicates of the live original so post-turn drain cannot
|
||
# restart the pre-correction prompt.
|
||
_drop_queued_duplicates_of_inflight_user(session)
|
||
session["last_active"] = time.time()
|
||
return _ok(rid, {"status": accepted_status if accepted else "rejected", "text": text})
|
||
|
||
|
||
def _correction_method(name: str, verb: str, accepted_status: str, supported, unsupported: str):
|
||
"""steer/redirect RPC: ``params.text`` (4002, checked before the session) into a live session;
|
||
``supported(agent)`` gates 4010."""
|
||
@method(name)
|
||
def _(rid, params: dict) -> dict:
|
||
if not (text := (params.get("text") or "").strip()):
|
||
return _err(rid, 4002, "text is required")
|
||
session, err = _sess_nowait(params, rid)
|
||
if err:
|
||
return err
|
||
agent = session.get("agent")
|
||
# Redirect during the turn-build window (running=True, agent None): queue for the next turn instead of
|
||
# a misleading 4010 the client swallows into a lost follow-up.
|
||
if verb == "redirect" and agent is None and session.get("running"):
|
||
_enqueue_prompt(session, text, current_transport() or _stdio_transport)
|
||
session["last_active"] = time.time()
|
||
return _ok(rid, {"status": "queued", "text": text})
|
||
# Compression in flight: queue instead of steering/redirecting. A correction that
|
||
# reaches the provider mid-compression aborts the compression (explicit_interrupt)
|
||
# — the follow-up kills the turn that would answer it (#61042). Queued here, it
|
||
# drains when compression finishes (the Discord-gateway contract; mirrors the
|
||
# interrupt→queue demotion in gateway/run_busy.py for the channel busy path).
|
||
if _session_compression_in_flight(session):
|
||
_enqueue_prompt(session, text, current_transport() or _stdio_transport)
|
||
session["last_active"] = time.time()
|
||
return _ok(rid, {"status": "queued", "text": text})
|
||
if not supported(agent):
|
||
return _err(rid, 4010, unsupported)
|
||
# An idle agent accepts steer() but only the next turn drains it, spliced after an old tool
|
||
# row (#64578). 'rejected' makes the client queue it as a normal next prompt.
|
||
if verb == "steer" and not session.get("running"):
|
||
return _ok(rid, {"status": "rejected", "text": text})
|
||
return _apply_correction(rid, session, verb, text, accepted_status)
|
||
|
||
|
||
# Inject text into the next tool result without interrupting (AIAgent.steer(): no new user turn, no role
|
||
# alternation violation).
|
||
_correction_method("session.steer", "steer", "queued", lambda agent: hasattr(agent, "steer"),
|
||
"agent does not support steer")
|
||
# Redirect the active model turn while preserving valid work/context.
|
||
_correction_method("session.redirect", "redirect", "redirected",
|
||
lambda agent: getattr(agent, "_supports_active_turn_redirect", False) is True
|
||
and hasattr(agent, "redirect"), "agent does not support active-turn redirect")
|
||
|
||
|
||
# ── delegation / spawn trees ─────────────────────────────────────────
|
||
@method("delegation.status")
|
||
def _(rid, params: dict) -> dict:
|
||
from tools import delegate_tool as dt
|
||
return _ok(rid, {"active": dt.list_active_subagents(), "paused": dt.is_spawn_paused(),
|
||
"max_spawn_depth": dt._get_max_spawn_depth(),
|
||
"max_concurrent_children": dt._get_max_concurrent_children()})
|
||
|
||
|
||
@method("delegation.pause")
|
||
def _(rid, params: dict) -> dict:
|
||
from tools.delegate_tool import set_spawn_paused
|
||
return _ok(rid, {"paused": set_spawn_paused(bool(params.get("paused", True)))})
|
||
|
||
|
||
@method("subagent.steer")
|
||
def _(rid, params: dict) -> dict:
|
||
"""Queue steering text into a live delegated child (the in-flight tool call is never cut). "queued"
|
||
is not "delivered": a child past its final tool batch surfaces ``missed_steer`` on the parent entry."""
|
||
from tools.delegate_tool import steer_subagent
|
||
if not (subagent_id := _str_param(params, "subagent_id")):
|
||
return _err(rid, 4000, "subagent_id required")
|
||
if not (text := (params.get("text") or "").strip()):
|
||
return _err(rid, 4002, "text is required")
|
||
if (err := _sess_nowait(params, rid)[1]) is not None:
|
||
return err
|
||
owner_id = _str_param(params, "session_id")
|
||
transport, owner = _current_session_steer_authority(owner_id)
|
||
queued = transport is not None and owner is not None and steer_subagent(
|
||
subagent_id, text, owner_session_id=owner_id, owner_transport=transport, owner_session_record=owner)
|
||
return _ok(rid, {"status": "queued" if queued else "rejected", "subagent_id": subagent_id, "text": text})
|
||
|
||
|
||
@method("spawn_tree.save")
|
||
def _(rid, params: dict) -> dict:
|
||
session_id = _str_param(params, "session_id")
|
||
subagents = params.get("subagents") or []
|
||
if not isinstance(subagents, list) or not subagents:
|
||
return _err(rid, 4000, "subagents list required")
|
||
started_at, label = params.get("started_at"), str(params.get("label") or "")
|
||
finished_at = float(params.get("finished_at") or time.time())
|
||
d = _spawn_tree_session_dir(session_id or "default")
|
||
path = d / f"{datetime.utcfromtimestamp(finished_at).strftime('%Y%m%dT%H%M%S')}.json"
|
||
meta = {"session_id": session_id, "started_at": float(started_at) if started_at else None,
|
||
"finished_at": finished_at, "label": label}
|
||
try:
|
||
path.write_text(json.dumps({**meta, "subagents": subagents}, ensure_ascii=False), encoding="utf-8")
|
||
except OSError as exc:
|
||
return _err(rid, 5000, f"spawn_tree.save failed: {exc}")
|
||
_append_spawn_tree_index(d, {"path": str(path), **meta, "count": len(subagents)})
|
||
return _ok(rid, {"path": str(path), "session_id": session_id})
|
||
|
||
|
||
def _legacy_spawn_tree_entry(p, session_dir_name: str) -> dict | None:
|
||
"""Index-shaped entry for a pre-index snapshot file (None when unreadable)."""
|
||
try:
|
||
stat = p.stat()
|
||
except OSError:
|
||
return None
|
||
raw = {}
|
||
with contextlib.suppress(Exception):
|
||
raw = json.loads(p.read_text(encoding="utf-8"))
|
||
if not isinstance(raw, dict):
|
||
raw = {}
|
||
subagents = raw.get("subagents") or []
|
||
return {"path": str(p), "session_id": raw.get("session_id") or session_dir_name,
|
||
"finished_at": raw.get("finished_at") or stat.st_mtime, "started_at": raw.get("started_at"),
|
||
"label": raw.get("label") or "", "count": len(subagents) if isinstance(subagents, list) else 0}
|
||
|
||
|
||
@method("spawn_tree.list")
|
||
def _(rid, params: dict) -> dict:
|
||
session_id = _str_param(params, "session_id")
|
||
if bool(params.get("cross_session")):
|
||
roots = [p for p in _spawn_trees_root().iterdir() if p.is_dir()]
|
||
else:
|
||
roots = [_spawn_tree_session_dir(session_id or "default")]
|
||
entries: list[dict] = []
|
||
for d in roots:
|
||
if indexed := _read_spawn_tree_index(d):
|
||
# Skip index entries whose snapshot file was manually deleted.
|
||
entries.extend(e for e in indexed if (p := e.get("path")) and Path(p).exists())
|
||
else: # Legacy (pre-index) sessions: full scan, once per session until the next save.
|
||
entries.extend(
|
||
entry for p in d.glob("*.json")
|
||
if p.name != _SPAWN_TREE_INDEX and (entry := _legacy_spawn_tree_entry(p, d.name)) is not None)
|
||
entries.sort(key=lambda e: e.get("finished_at") or 0, reverse=True)
|
||
return _ok(rid, {"entries": entries[:int(params.get("limit") or 50)]})
|
||
|
||
|
||
@method("spawn_tree.load")
|
||
def _(rid, params: dict) -> dict:
|
||
if not (raw_path := _str_param(params, "path")):
|
||
return _err(rid, 4000, "path required")
|
||
try:
|
||
(resolved := Path(raw_path).resolve()).relative_to(_spawn_trees_root().resolve())
|
||
except (ValueError, OSError) as exc:
|
||
return _err(rid, 4030, f"path outside spawn-trees root: {exc}")
|
||
try:
|
||
payload = json.loads(resolved.read_text(encoding="utf-8"))
|
||
except (OSError, json.JSONDecodeError) as exc:
|
||
return _err(rid, 5000, f"spawn_tree.load failed: {exc}")
|
||
if not isinstance(payload, dict):
|
||
return _err(rid, 5000, "spawn_tree.load failed: snapshot is not a JSON object")
|
||
return _ok(rid, payload)
|
||
|
||
|
||
# ── terminal / event replay ──────────────────────────────────────────
|
||
@_session_method("terminal.resize")
|
||
def _(rid, params: dict, session: dict) -> dict:
|
||
session["cols"] = cols = int(params.get("cols", 80))
|
||
return _ok(rid, {"cols": cols})
|
||
|
||
|
||
@method("session.events.since")
|
||
def _(rid, params: dict) -> dict:
|
||
"""Replay events after ``last_seen`` (WS reconnect); ``truncated`` past the ring window → client refetches."""
|
||
sid = str(params.get("session_id") or "")
|
||
try:
|
||
last_seen = int(params.get("last_seen", 0))
|
||
except (TypeError, ValueError):
|
||
return _err(rid, -32602, "invalid params: last_seen must be an integer")
|
||
from tui_gateway import event_replay as er
|
||
frames = er.events_since(sid, last_seen)
|
||
# ``epoch``: in-process seq — clients reset watermarks when this differs from gateway.ready's.
|
||
# ``open_requests``: server→client requests still unanswered — the ring cannot carry "a question still
|
||
# waiting", so the reconnecting client re-delivers these to its request handlers.
|
||
return _ok(rid, {"events": frames, "latest_seq": er.latest_seq(sid), "truncated": er.is_truncated(sid, last_seen),
|
||
"count": len(frames), "epoch": er.replay_epoch(), "open_requests": _open_requests(sid)})
|
||
|
||
|
||
@method("session.events.stats")
|
||
def _(rid, params: dict) -> dict:
|
||
"""Replay-buffer telemetry (ops/debug)."""
|
||
from tui_gateway import event_replay
|
||
return _ok(rid, event_replay.replay_stats())
|
||
|
||
|
||
def register(server) -> None:
|
||
"""Publish this module's helpers onto ``server`` (rebound to its globals) and install handlers."""
|
||
bind_module(globals(), server, skip=("_",))
|