Files
hermes-agent/tui_gateway/methods_session.py
shali10 40523600b0 fix(sessions): refuse to delete a session row a live turn still owns (#123583)
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)
2026-09-27 20:49:04 +05:30

2433 lines
130 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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=("_",))