3880 lines
168 KiB
Python
3880 lines
168 KiB
Python
"""Session / delegation / spawn-tree / billing / pet JSON-RPC handlers (moved verbatim from server.py).
|
|
|
|
Handler bodies are byte-identical to their pre-split server.py form; they
|
|
are rebound onto server.py's globals at install time — see method_ctx.py.
|
|
"""
|
|
|
|
from .method_ctx import HandlerRegistry
|
|
|
|
_registry = HandlerRegistry()
|
|
method = _registry.method
|
|
_profile_scoped = _registry.profile_scoped
|
|
|
|
|
|
@method("session.create")
|
|
def _(rid, params: dict) -> dict:
|
|
sid = uuid.uuid4().hex[:8]
|
|
key = _new_session_key()
|
|
cols = int(params.get("cols", 80))
|
|
history = _coerce_seed_history(params.get("messages"))
|
|
title = str(params.get("title") or "").strip()
|
|
# When set, this is a branch: the new chat copies an existing conversation's
|
|
# history and links back to it so list_sessions_rich keeps it visible and the
|
|
# sidebar can nest it under its parent. Mirrors the TUI /branch marker.
|
|
parent_session_id = str(params.get("parent_session_id") or "").strip() or None
|
|
# Did the client pick a workspace, or are we falling back to the gateway's
|
|
# launch directory? Only an explicit choice is persisted as the session's
|
|
# workspace (see _ensure_session_db_row); otherwise it lands in "No
|
|
# workspace" instead of whatever folder the desktop launched in.
|
|
raw_cwd = str(params.get("cwd") or "").strip()
|
|
try:
|
|
explicit_cwd = bool(raw_cwd) and os.path.isdir(os.path.abspath(os.path.expanduser(raw_cwd)))
|
|
except Exception:
|
|
explicit_cwd = False
|
|
resolved_cwd = _completion_cwd(params)
|
|
source = _resolve_session_source(str(params.get("source") or "").strip() or None)
|
|
_enable_gateway_prompts()
|
|
|
|
# ``profile`` (app-global remote mode): a new chat started under a non-launch
|
|
# profile must build its agent + persist against THAT profile's home/state.db,
|
|
# not the dashboard's launch profile. Stored on the session so _start_agent_build
|
|
# and each turn re-bind HERMES_HOME. None/own profile → launch (unchanged).
|
|
profile = (params.get("profile") or "").strip() or None
|
|
profile_home = _profile_home(profile)
|
|
|
|
# The desktop composer owns its model/effort/fast as plain UI state and ships
|
|
# it on every session.create. Honor each as a PER-SESSION override (built into
|
|
# the agent below) — never a global config write, so picking a model/effort
|
|
# for a new chat can't mutate the profile default. provider is optional
|
|
# (resolved at build).
|
|
create_model = str(params.get("model") or "").strip()
|
|
session_model_override = (
|
|
{"model": create_model, "provider": str(params.get("provider") or "").strip() or None}
|
|
if create_model
|
|
else None
|
|
)
|
|
create_reasoning_override = None
|
|
if effort := str(params.get("reasoning_effort") or "").strip():
|
|
try:
|
|
from hermes_constants import parse_reasoning_effort
|
|
|
|
create_reasoning_override = parse_reasoning_effort(effort)
|
|
except Exception:
|
|
create_reasoning_override = None
|
|
# Presence is part of the contract: omitted means inherit the profile,
|
|
# true pins priority, and false pins normal. Empty string is the internal
|
|
# explicit-normal sentinel because _make_agent uses None for inheritance.
|
|
create_service_tier_override = None
|
|
if "fast" in params:
|
|
create_service_tier_override = (
|
|
"priority" if is_truthy_value(params.get("fast")) else ""
|
|
)
|
|
|
|
ready = threading.Event()
|
|
now = time.time()
|
|
lease = None # claimed lazily on the first turn (_ensure_active_session_slot)
|
|
|
|
with _sessions_lock:
|
|
_sessions[sid] = {
|
|
"agent": None,
|
|
"agent_error": None,
|
|
"agent_ready": ready,
|
|
"attached_images": [],
|
|
"close_on_disconnect": is_truthy_value(params.get("close_on_disconnect", False)),
|
|
"active_session_lease": lease,
|
|
"cols": cols,
|
|
"created_at": now,
|
|
"edit_snapshots": {},
|
|
"explicit_cwd": explicit_cwd,
|
|
"history": history,
|
|
"history_lock": threading.Lock(),
|
|
"history_version": 0,
|
|
"image_counter": 0,
|
|
"cwd": resolved_cwd,
|
|
"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": title or None,
|
|
"pending_hidden": is_truthy_value(params.get("hidden", False)),
|
|
"room_plumbing": is_truthy_value(params.get("room_plumbing", False)),
|
|
"follow_profile_config": is_truthy_value(params.get("follow_profile_config", False)),
|
|
"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,
|
|
}
|
|
_register_session_cwd(_sessions[sid])
|
|
|
|
# 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.
|
|
if parent_session_id and history:
|
|
try:
|
|
with _session_db(_sessions[sid]) as db:
|
|
if db is not None:
|
|
parent_key = parent_session_id
|
|
current = db.get_session_title(parent_key) or "branch"
|
|
branch_title = (
|
|
db.get_next_title_in_lineage(current)
|
|
if hasattr(db, "get_next_title_in_lineage")
|
|
else f"{current} (branch)"
|
|
)
|
|
db.create_session(
|
|
key,
|
|
source=source,
|
|
model=_resolve_model(),
|
|
model_config={"_branched_from": parent_key},
|
|
parent_session_id=parent_key,
|
|
cwd=_sessions[sid]["cwd"],
|
|
profile_name=(
|
|
Path(profile_home).name if profile_home else None
|
|
),
|
|
)
|
|
# 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.
|
|
try:
|
|
db.append_messages_batch(
|
|
key,
|
|
[
|
|
{"role": m.get("role", "user"), "content": m.get("content")}
|
|
for m in history
|
|
],
|
|
chunk_rows=500,
|
|
)
|
|
db.set_session_title(key, branch_title)
|
|
except Exception as exc:
|
|
from hermes_state import is_disk_full_error
|
|
|
|
if is_disk_full_error(exc):
|
|
raise
|
|
try:
|
|
db.delete_session(key)
|
|
except Exception:
|
|
logger.debug(
|
|
"branch seed compensation delete failed for %s",
|
|
key,
|
|
exc_info=True,
|
|
)
|
|
raise
|
|
_sessions[sid]["pending_title"] = None
|
|
except Exception:
|
|
# Persistence is best-effort here: a failed write must not break
|
|
# session.create itself — the lazy first-prompt path remains as the
|
|
# fallback, exactly as for plain drafts.
|
|
logger.warning(
|
|
"seeded-branch persistence failed for %s; falling back to "
|
|
"lazy row creation",
|
|
key,
|
|
exc_info=True,
|
|
)
|
|
|
|
# Return the lightweight session immediately so Ink can paint the composer
|
|
# + skeleton panel, then build the real AIAgent just after this response is
|
|
# flushed. This keeps startup responsive while still hydrating tools/skills
|
|
# without requiring the user to submit a first prompt.
|
|
_schedule_agent_build(sid)
|
|
_schedule_session_cap_enforcement() # trim detached idle sessions over the cap
|
|
|
|
return _ok(
|
|
rid,
|
|
{
|
|
"session_id": sid,
|
|
"stored_session_id": key,
|
|
"message_count": len(history),
|
|
"messages": _history_to_messages(history),
|
|
"info": {
|
|
# Reflect the per-session model override (desktop composer pick)
|
|
# in the immediate response so the client doesn't briefly clobber
|
|
# its sticky pick with the global default before the deferred
|
|
# build's session.info lands.
|
|
"model": (
|
|
session_model_override.get("model")
|
|
if session_model_override
|
|
else _resolve_model()
|
|
),
|
|
**(
|
|
{"provider": session_model_override["provider"]}
|
|
if session_model_override and session_model_override.get("provider")
|
|
else {}
|
|
),
|
|
"tools": {},
|
|
"skills": {},
|
|
"cwd": _sessions[sid]["cwd"],
|
|
"branch": _git_branch_for_cwd(_sessions[sid]["cwd"]),
|
|
"project": _project_info_for_cwd(_sessions[sid]["cwd"]),
|
|
"lazy": True,
|
|
"desktop_contract": DESKTOP_BACKEND_CONTRACT,
|
|
"profile_name": _response_profile_name(profile),
|
|
},
|
|
},
|
|
)
|
|
|
|
|
|
@method("session.list")
|
|
def _(rid, params: dict) -> dict:
|
|
with _profile_db(params) as db:
|
|
if db is None:
|
|
return _db_unavailable_error(rid, code=5006)
|
|
try:
|
|
# Resume picker should surface human conversation sessions from every
|
|
# user-facing surface — CLI, TUI, all gateway platforms (including new
|
|
# ones not enumerated here), ACP adapter clients, webhook sessions,
|
|
# custom `HERMES_SESSION_SOURCE` values, and older installs with
|
|
# different source labels. We deny-list only the noisy internal
|
|
# sources (``tool`` sub-agent runs and ``kanban`` dispatcher
|
|
# workers) rather than allow-listing a fixed set of platform names
|
|
# that goes stale whenever a new platform is added or a user names
|
|
# their own source.
|
|
deny = frozenset({"kanban", "tool"})
|
|
|
|
# ``title``: EXACT-title registry lookup, not a listing. The core
|
|
# UNIQUE title index means at most one session per db carries a
|
|
# given exact title, so callers that treat a title as an identity
|
|
# key (Bot Mode's canonical "Bot Chat" — Profile → Named Session)
|
|
# get a window-free O(1) answer instead of scanning a recency
|
|
# window that a busy profile can push the row out of. Hidden rows
|
|
# resolve (canonical chats are born hidden); archived rows and
|
|
# deny-listed sources do not; compression lineages resolve to the
|
|
# live tip (``resolved_id``), mirroring profiles.list's
|
|
# canonical_session resolver. Older clients never send this param;
|
|
# newer clients falling back to older gateways just get the normal
|
|
# windowed listing back (the param is ignored) and scan it.
|
|
title_lookup = str(params.get("title") or "").strip()
|
|
if title_lookup:
|
|
row = db.get_session_by_title(title_lookup)
|
|
if row and row.get("archived"):
|
|
from tools.bot_mode_probe import BOT_CHAT_TITLE
|
|
|
|
if title_lookup == BOT_CHAT_TITLE:
|
|
# 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.
|
|
if db.unarchive_recoverable_session(row["id"]):
|
|
row = db.get_session(row["id"])
|
|
if (
|
|
not row
|
|
or row.get("archived")
|
|
or (row.get("source") or "").strip().lower() in deny
|
|
):
|
|
return _ok(rid, {"sessions": []})
|
|
try:
|
|
# A named-session registry lookup must resolve only a real
|
|
# compression continuation. The generic resume resolver
|
|
# retains a legacy unmarked-child fallback for historical
|
|
# sessions; using it here can redirect the canonical Bot
|
|
# Chat to an unrelated normal child.
|
|
tip = db.get_compression_tip(row["id"]) or row["id"]
|
|
except Exception:
|
|
tip = row["id"]
|
|
tip_row = (db.get_session(tip) or row) if tip != row["id"] else row
|
|
return _ok(
|
|
rid,
|
|
{
|
|
"sessions": [
|
|
{
|
|
"id": row["id"],
|
|
"resolved_id": tip,
|
|
"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 "",
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
limit = int(params.get("limit", 200) or 200)
|
|
# ``include_hidden``: surfaces that OWN hidden sessions (the Bots
|
|
# pane's per-profile browser, plugin session pickers) need to list
|
|
# them; the flag stays off for the resume picker and every other
|
|
# global caller so `hidden` keeps meaning "not in shared lists".
|
|
include_hidden = is_truthy_value(params.get("include_hidden", False))
|
|
# Over-fetch modestly so per-source filtering doesn't leave us
|
|
# short; the compression-tip projection in ``list_sessions_rich``
|
|
# can also merge rows.
|
|
fetch_limit = max(limit * 2, 200)
|
|
rows = [
|
|
s
|
|
for s in db.list_sessions_rich(
|
|
source=None,
|
|
limit=fetch_limit,
|
|
order_by_last_active=True,
|
|
compact_rows=True,
|
|
include_hidden=include_hidden,
|
|
)
|
|
if (s.get("source") or "").strip().lower() not in deny
|
|
][:limit]
|
|
return _ok(
|
|
rid,
|
|
{
|
|
"sessions": [
|
|
{
|
|
"id": s["id"],
|
|
"title": s.get("title") or "",
|
|
"preview": s.get("preview") or "",
|
|
"started_at": s.get("started_at") or 0,
|
|
"message_count": s.get("message_count") or 0,
|
|
"source": s.get("source") or "",
|
|
}
|
|
for s in rows
|
|
]
|
|
},
|
|
)
|
|
except Exception as e:
|
|
return _err(rid, 5006, str(e))
|
|
|
|
|
|
@method("session.most_recent")
|
|
def _(rid, params: dict) -> dict:
|
|
"""Return the most recent human-facing session id, or ``None``.
|
|
|
|
Mirrors ``session.list``'s deny-list behaviour (drops ``tool``
|
|
sub-agent rows and ``kanban`` worker rows). Used by TUI auto-resume when
|
|
``display.tui_auto_resume_recent`` is on; the field is also handy
|
|
for any CLI tooling that wants "latest session" without paginating
|
|
the full list.
|
|
|
|
Contract: a ``{"session_id": null}`` result means "no eligible
|
|
session found right now". Errors are also folded into that
|
|
null-result shape (and logged) so callers don't have to special-
|
|
case JSON-RPC error envelopes for what is a normal "no answer".
|
|
|
|
Honors ``params.profile`` so app-global remote mode lists from the
|
|
focused profile's ``state.db`` (mirrors ``session.resume``).
|
|
"""
|
|
with _profile_db(params) as db:
|
|
if db is None:
|
|
return _ok(rid, {"session_id": None})
|
|
try:
|
|
deny = frozenset({"kanban", "tool"})
|
|
# Over-fetch by a generous bounded amount so heavy sub-agent
|
|
# users (lots of recent ``tool`` rows) don't get a false
|
|
# "no eligible session" answer. ``session.list`` uses a
|
|
# similar over-fetch strategy.
|
|
rows = db.list_sessions_rich(
|
|
source=None, limit=200, order_by_last_active=True, compact_rows=True
|
|
)
|
|
for row in rows:
|
|
src = (row.get("source") or "").strip().lower()
|
|
if src in deny:
|
|
continue
|
|
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 "",
|
|
},
|
|
)
|
|
return _ok(rid, {"session_id": None})
|
|
except Exception:
|
|
logger.exception("session.most_recent failed")
|
|
return _ok(rid, {"session_id": None})
|
|
|
|
|
|
@method("project.facts")
|
|
def _(rid, params: dict) -> dict:
|
|
"""Structured project facts for a cwd — manifests, package manager, the
|
|
exact verify commands, and context files.
|
|
|
|
The same detection the coding-context posture (#43316) bakes into the system
|
|
prompt, exposed so UIs (the desktop verify surface) consume it instead of
|
|
re-sniffing. ``{"facts": null}`` means the cwd isn't a code workspace.
|
|
"""
|
|
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 coding verification evidence for a cwd/session.
|
|
|
|
Read-only consumer of the core ledger. It never runs checks and 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}})
|
|
|
|
|
|
@method("session.resume")
|
|
def _(rid, params: dict) -> dict:
|
|
target = params.get("session_id", "")
|
|
if not target:
|
|
return _err(rid, 4006, "session_id required")
|
|
try:
|
|
cols = int(params.get("cols", 80))
|
|
except (TypeError, ValueError):
|
|
cols = 80
|
|
# ``profile`` (app-global remote mode): resume a session that lives in another
|
|
# local profile's state.db. None/own profile → the launch profile (unchanged).
|
|
profile = (params.get("profile") or "").strip() or None
|
|
profile_home = _profile_home(profile)
|
|
defer_history = is_truthy_value(params.get("defer_history", False))
|
|
# Desktop hydrates persisted transcripts through the authenticated REST
|
|
# route in parallel. Suppress the duplicate WebSocket transcript only when
|
|
# the caller explicitly requests it; other clients keep upstream behavior.
|
|
omit_messages = is_truthy_value(params.get("omit_messages", False))
|
|
|
|
# In a profile scope this opens a DEDICATED handle we own until the agent
|
|
# takes it (see the ownership transfer at _init_session below); every path
|
|
# that returns before that transfer must close it. Otherwise reuse the
|
|
# shared launch db, which outlives the RPC and is never closed here.
|
|
owns_db = False
|
|
if profile_home is not None:
|
|
from hermes_state import get_shared_session_db
|
|
|
|
db = get_shared_session_db(profile_home / "state.db")
|
|
owns_db = True
|
|
else:
|
|
db = _get_db()
|
|
try:
|
|
if db is None:
|
|
return _db_unavailable_error(rid, code=5000)
|
|
|
|
found = db.get_session(target)
|
|
if not found:
|
|
found = db.get_session_by_title(target)
|
|
if found:
|
|
target = found["id"]
|
|
elif is_truthy_value(params.get("lazy", False)) and _child_run_active(target):
|
|
# Race: a watch window opened on a freshly-spawned subagent. The
|
|
# child relays `subagent.start` (which carries child_session_id and
|
|
# triggers the window) BEFORE its first run_conversation() flushes
|
|
# the DB row via _ensure_db_session, so db.get_session(target) is
|
|
# momentarily empty. On slower hosts (notably WSL2, where SQLite +
|
|
# process scheduling widen the gap) the window's resume consistently
|
|
# lands inside this window and used to hard-fail "session not found"
|
|
# — the frontend then 404'd on the REST messages fallback and the
|
|
# window spun forever. The child is provably live (_child_run_active),
|
|
# so proceed into the lazy branch with empty history; the live mirror
|
|
# streams the whole turn anyway and the row exists by upgrade time.
|
|
found = {}
|
|
else:
|
|
# LIVE lazy session: session.create intentionally persists no
|
|
# state.db row until the first prompt (no "Untitled" litter),
|
|
# so a resume by the stored key or pending title lands here for
|
|
# every never-messaged session. Bot Mode hits it on every fresh
|
|
# non-default bot — the canonical Bot Chat is created lazily on
|
|
# the profile, the open/send then resumes it, and this hard 404
|
|
# ("session not found") killed messaging for exactly the bots
|
|
# that had never spoken. Match the in-memory registry by stored
|
|
# key or pending title, scoped to the SAME profile home this
|
|
# resume targets, and hand the caller the live record.
|
|
# (Nested per method_ctx rebinding — module helpers are
|
|
# invisible from installed handlers.)
|
|
def _find_live_unpersisted(needle: str, home) -> str:
|
|
want_home = str(home) if home is not None else None
|
|
for live_sid, record in list(_sessions.items()):
|
|
if not isinstance(record, dict):
|
|
continue
|
|
if (record.get("profile_home") or None) != want_home:
|
|
continue
|
|
if (
|
|
str(record.get("session_key") or "") == needle
|
|
or (record.get("pending_title") or "") == needle
|
|
):
|
|
return live_sid
|
|
return ""
|
|
|
|
live_sid = _find_live_unpersisted(target, profile_home)
|
|
live = _sessions.get(live_sid) if live_sid else None
|
|
if live is not None:
|
|
if owns_db:
|
|
with contextlib.suppress(Exception):
|
|
from hermes_state import release_or_close
|
|
release_or_close(db)
|
|
live["last_active"] = time.time()
|
|
# This resume reattaches the live record. A lazy session
|
|
# (no state.db row yet — every fresh Bot Chat) that was
|
|
# sentinel-parked by a WS drop MUST be rebound here, or it
|
|
# keeps the drop sentinel and the armed orphan-reap Timer
|
|
# fires against a client that is attached right now — the
|
|
# unpersisted sibling of the storm-killer paths (#91276).
|
|
transport = current_transport()
|
|
if transport is not None:
|
|
with live.setdefault("history_lock", threading.Lock()):
|
|
live["transport"] = transport
|
|
live.setdefault("viewers", {})[transport] = time.time()
|
|
_cancel_ws_orphan_reap(live_sid)
|
|
history = live.get("history") or []
|
|
return _ok(
|
|
rid,
|
|
_attach_todo_state(
|
|
{
|
|
"session_id": live_sid,
|
|
"stored_session_id": str(live.get("session_key") or ""),
|
|
"message_count": len(history),
|
|
"messages": [] if omit_messages else _history_to_messages(history),
|
|
"info": {
|
|
"model": _resolve_model(),
|
|
"lazy": True,
|
|
"profile_name": profile or "",
|
|
},
|
|
},
|
|
live,
|
|
),
|
|
)
|
|
|
|
# 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.
|
|
if owns_db:
|
|
try:
|
|
default_db = _get_db()
|
|
# Exact-id match ONLY. Title lookup (get_session_by_title)
|
|
# has no archived filter, no ordering, and bot titles
|
|
# collide by design ("Bot Chat") — a title-matched donor
|
|
# could adopt and non-recoverably retire an UNRELATED
|
|
# default-profile conversation. The stranded-session
|
|
# repro always has the exact id (the desktop routes by
|
|
# id), so nothing real is lost.
|
|
donor_row = (
|
|
default_db.get_session(target)
|
|
if default_db is not None
|
|
else None
|
|
)
|
|
# Never re-adopt an already-retired donor: a second
|
|
# profile resuming the same id would otherwise clone
|
|
# the conversation into two "canonical" stores.
|
|
if donor_row and donor_row.get("archived"):
|
|
donor_row = None
|
|
if donor_row:
|
|
adoption = 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 []),
|
|
profile or "?",
|
|
)
|
|
found = db.get_session(donor_row["id"])
|
|
if found:
|
|
target = found["id"]
|
|
except Exception:
|
|
logger.exception(
|
|
"stranded-session adoption failed for %s", target
|
|
)
|
|
if not found:
|
|
return _err(rid, 4007, "session not found")
|
|
|
|
# Follow the compression-continuation chain to the live tip so a resume on
|
|
# a rotated-out parent id binds to the descendant that actually holds the
|
|
# post-compression turns. Auto-compression ends the session and forks a
|
|
# continuation child; without this, resuming the original id (the desktop's
|
|
# routed id when the chat was opened before it rotated) reloads the parent
|
|
# transcript and the response generated after compression is missing — the
|
|
# "I came back and the reply isn't there" bug on large sessions. Resolving
|
|
# here also re-anchors the fast path below so a still-live rotated session
|
|
# is reused (by its new key) instead of rebuilding a duplicate agent on the
|
|
# stale parent. Skipped for lazy watch windows, which intentionally attach
|
|
# to the exact child branch they were opened on. Bot Chat is a named
|
|
# registry row — stay on a proven compression edge so an unmarked
|
|
# side chat cannot steal the open (the title-lookup / profiles.list
|
|
# contract). Other sessions keep the legacy unmarked-child walker.
|
|
if found and not is_truthy_value(params.get("lazy", False)):
|
|
try:
|
|
from tools.bot_mode_probe import BOT_CHAT_TITLE
|
|
|
|
if (found.get("title") or "").strip() == BOT_CHAT_TITLE:
|
|
tip = db.get_compression_tip(target) or target
|
|
else:
|
|
tip = db.resolve_resume_session_id(target)
|
|
except Exception:
|
|
tip = target
|
|
if tip and tip != target:
|
|
target = tip
|
|
found = db.get_session(target) or found
|
|
|
|
# Todo snapshots are derived from each path's already-loaded history
|
|
# (see _todo_state_from_history) — no extra transcript read here.
|
|
|
|
# Every interactive resume path materializes the model history, even when
|
|
# omit_messages suppresses the response copy. Count what THIS path will
|
|
# actually load before any reopen/history read so a runaway transcript
|
|
# cannot exhaust the dashboard. Only the non-deferred, non-omitted
|
|
# resume reads the whole compression lineage (ancestors → tip) into
|
|
# memory; the deferred Desktop resume (display transcript paged over
|
|
# REST), the omit_messages resume, and the lazy watch resume all load
|
|
# the TIP segment only — guarding those against the full-lineage count
|
|
# rejected exactly the well-compressed conversations compaction is
|
|
# meant to produce (85 segments / ~29k lineage rows / ~700-row tip →
|
|
# 4130 and a Bot Chat stuck on "Waking up…"). The metadata fallback
|
|
# keeps lightweight test/adaptor DBs that predate the shared SessionDB
|
|
# guard compatible. The limit resolves from config
|
|
# (sessions.max_resume_messages, 0 disables).
|
|
from hermes_state import (
|
|
SessionResumeTooLargeError,
|
|
resolved_max_resume_messages,
|
|
)
|
|
|
|
eager_build = is_truthy_value(params.get("eager_build", False))
|
|
guard_tip_only = (
|
|
is_truthy_value(params.get("lazy", False))
|
|
or omit_messages
|
|
or (defer_history and not eager_build)
|
|
)
|
|
safety_check = getattr(db, "assert_resume_safe", None)
|
|
try:
|
|
if callable(safety_check):
|
|
if guard_tip_only:
|
|
safety_check(target, tip_only=True)
|
|
else:
|
|
safety_check(target)
|
|
else:
|
|
resume_limit = resolved_max_resume_messages()
|
|
stored_message_count = int(found.get("message_count") or 0)
|
|
if resume_limit and stored_message_count > resume_limit:
|
|
raise SessionResumeTooLargeError(stored_message_count, resume_limit)
|
|
except SessionResumeTooLargeError as exc:
|
|
return _err(rid, 4130, str(exc))
|
|
except Exception as exc:
|
|
# Fail OPEN: a transient guard failure (locked DB, schema skew on
|
|
# an adaptor store) must not turn the safety check into a new way
|
|
# to lose access to a session. Only a genuine over-limit blocks.
|
|
logger.warning(
|
|
"resume safety check failed for %s (proceeding without guard): %s",
|
|
target, exc,
|
|
)
|
|
|
|
profile_resume_cwd = str(found.get("cwd") or "").strip() or _profile_configured_cwd(
|
|
profile_home
|
|
)
|
|
|
|
def _reuse_live_payload(sid: str, session: dict) -> dict:
|
|
payload = _live_session_payload(
|
|
sid,
|
|
session,
|
|
cols=cols,
|
|
touch=True,
|
|
transport=current_transport() or _stdio_transport,
|
|
omit_messages=omit_messages,
|
|
)
|
|
payload["resumed"] = target
|
|
if defer_history:
|
|
payload["messages"] = []
|
|
payload["message_count"] = int(
|
|
session.get("resume_message_count") or payload["message_count"]
|
|
)
|
|
payload["hydrating"] = bool(session.get("resume_hydrating"))
|
|
# A lazy watch session never owns a run loop, so its payload's running
|
|
# flag is always False — overlay the child-run registry so a reconnecting
|
|
# watch window keeps its busy indicator while the child is still mid-run.
|
|
if session.get("agent") is None and _child_run_active(target):
|
|
payload["running"] = True
|
|
payload["status"] = "streaming"
|
|
return payload
|
|
|
|
def _reuse_live_response(sid: str, session: dict) -> dict:
|
|
# The helper owns the resume lock because slow-path claim races can
|
|
# discover a live winner and return it after releasing their own lock.
|
|
# Keeping the client-gone check and transport rebind in one critical
|
|
# section makes grace expiry atomic across every reuse path.
|
|
with _session_resume_lock:
|
|
if _sessions.get(sid) is not session:
|
|
return _err(rid, 4007, "session no longer live; retry resume")
|
|
if session.get("_client_gone_interrupt_requested"):
|
|
return _err(rid, 4009, "session disconnect interrupt settling")
|
|
# This resume reattaches the live record: cancel any pending
|
|
# ws-orphan reap timer armed while the client was detached
|
|
# (storm killer — _live_session_payload's rebind also cancels,
|
|
# but only when a transport is passed; cancel unconditionally
|
|
# here so the fast path can never race the reap Timer).
|
|
_cancel_ws_orphan_reap(sid)
|
|
return _ok(rid, _reuse_live_payload(sid, session))
|
|
|
|
# Fast path: if the session is already live IN THIS PROFILE, reuse it
|
|
# under the lock. Never another profile's runtime of the same stored id
|
|
# — that ran profile B's turn on profile A's agent/memory (#100029).
|
|
with _session_resume_lock:
|
|
live = _find_live_session_by_key(target, profile_home)
|
|
if live is not None:
|
|
return _reuse_live_response(*live)
|
|
|
|
# Lazy/watch resume: register the live session WITHOUT building an agent.
|
|
# Used by the desktop's subagent windows — the child runs inside the
|
|
# parent's turn, so its window only needs the stored history plus a
|
|
# transport for the child-mirror's live events. Skipping _make_agent here
|
|
# is what keeps the window cheap while the backend is busy running the
|
|
# delegation. A later prompt.submit upgrades it via _start_agent_build
|
|
# (resume_session_id keeps the upgrade on the stored conversation).
|
|
if is_truthy_value(params.get("lazy", False)):
|
|
sid = uuid.uuid4().hex[:8]
|
|
source = _resolve_session_source(str(params.get("source") or "").strip() or None)
|
|
lease = None # claimed lazily on the first turn (_ensure_active_session_slot)
|
|
try:
|
|
db.reopen_session(target)
|
|
# The child's OWN conversation only — include_ancestors would prepend
|
|
# the parent's transcript onto the subagent's branch.
|
|
# repair_alternation: this resume feeds LIVE REPLAY (the loaded
|
|
# history becomes the resumed session record's working conversation),
|
|
# so heal a durable ``user;user`` violation once here instead of
|
|
# re-firing the pre-request repair on every subsequent turn.
|
|
history = db.get_messages_as_conversation(
|
|
target, repair_alternation=True, include_row_ids=True
|
|
)
|
|
except Exception as e:
|
|
if lease is not None:
|
|
lease.release()
|
|
return _err(rid, 5000, f"resume failed: {e}")
|
|
cwd = profile_resume_cwd or _default_session_cwd()
|
|
record = _deferred_session_record(
|
|
target,
|
|
cols=cols,
|
|
cwd=cwd,
|
|
history=history,
|
|
lease=lease,
|
|
source=source,
|
|
close_on_disconnect=is_truthy_value(params.get("close_on_disconnect", False)),
|
|
profile_home=profile_home,
|
|
lazy=True,
|
|
todo_state=_todo_state_from_history(history),
|
|
explicit_cwd=bool(profile_resume_cwd),
|
|
)
|
|
if (live := _claim_or_reuse_live(sid, target, record, lease)) is not None:
|
|
return _reuse_live_response(*live)
|
|
# A delegated child mid-run emits no session events of its own — report
|
|
# its liveness from the relay registry so the window shows a busy turn.
|
|
child_running = _child_run_active(target)
|
|
# User-visible messages use the VERBATIM display projection (child-only,
|
|
# no ancestors — matching the repaired read above), so model-invisible
|
|
# rows persisted by #65919 (verification candidates collapsed by
|
|
# repair_message_sequence) survive in the watch window just as they do
|
|
# on the eager resume + REST paths. The repaired ``history`` above still
|
|
# feeds live replay. Fall back to it if the display read fails.
|
|
try:
|
|
display_history = db.get_messages_as_conversation(
|
|
target, repair_alternation=False, include_row_ids=True
|
|
)
|
|
except Exception:
|
|
logger.debug("child-watch display projection read failed", exc_info=True)
|
|
display_history = history
|
|
messages = [] if omit_messages else _history_to_messages(display_history)
|
|
return _ok(
|
|
rid,
|
|
_attach_todo_state(
|
|
{
|
|
"session_id": sid,
|
|
"resumed": target,
|
|
"message_count": len(display_history) if omit_messages else len(messages),
|
|
"messages": messages,
|
|
"messages_omitted": omit_messages,
|
|
"info": _lazy_resume_info(cwd, profile=profile),
|
|
"inflight": None,
|
|
"running": child_running,
|
|
"session_key": target,
|
|
"started_at": record["created_at"],
|
|
"status": "streaming" if child_running else "idle",
|
|
},
|
|
record,
|
|
),
|
|
)
|
|
|
|
# Desktop can ask for a bounded acknowledgement and hydrate the display
|
|
# transcript through the paginated REST endpoint. Register the runtime now,
|
|
# then load model history and initialize optional providers in background.
|
|
# Repeated requests reuse the record through the live fast path above.
|
|
#
|
|
# Precedence vs omit_messages: defer_history SUPERSEDES omit_messages.
|
|
# Desktop sends both flags on a cold resume; when defer_history is set the
|
|
# response never carries a transcript (messages is always []) and the ONE
|
|
# history read happens in the background hydration worker — the synchronous
|
|
# omit_messages read below (cold resume default) is skipped entirely, so
|
|
# the transcript is never loaded twice for one resume. omit_messages only
|
|
# governs the response shape of the non-deferred paths.
|
|
if defer_history and not is_truthy_value(params.get("eager_build", False)):
|
|
sid = uuid.uuid4().hex[:8]
|
|
source = _resolve_session_source(str(params.get("source") or "").strip() or None)
|
|
lease = None # claimed lazily on the first turn (_ensure_active_session_slot)
|
|
_enable_gateway_prompts()
|
|
overrides = _stored_session_runtime_overrides(found) or {}
|
|
model_override = overrides.get("model_override") or {}
|
|
cwd = profile_resume_cwd or _default_session_cwd()
|
|
record = _deferred_session_record(
|
|
target,
|
|
cols=cols,
|
|
cwd=cwd,
|
|
history=[],
|
|
lease=lease,
|
|
source=source,
|
|
close_on_disconnect=is_truthy_value(params.get("close_on_disconnect", False)),
|
|
profile_home=profile_home,
|
|
model_override=overrides.get("model_override"),
|
|
resume_runtime_overrides=overrides or None,
|
|
explicit_cwd=bool(profile_resume_cwd),
|
|
)
|
|
record["resume_history_ready"] = threading.Event()
|
|
record["resume_hydrating"] = True
|
|
record["resume_message_count"] = int(found.get("message_count") or 0)
|
|
if (live := _claim_or_reuse_live(sid, target, record, lease)) is not None:
|
|
return _reuse_live_response(*live)
|
|
|
|
_schedule_resume_hydration(sid, target, db, close_db=owns_db)
|
|
# The hydration worker now owns a profile-scoped handle and closes it
|
|
# after the transcript read. The shared launch DB is process-owned.
|
|
if owns_db:
|
|
owns_db = False
|
|
_schedule_session_cap_enforcement()
|
|
return _ok(
|
|
rid,
|
|
_attach_todo_state(
|
|
{
|
|
"session_id": sid,
|
|
"resumed": target,
|
|
"message_count": record["resume_message_count"],
|
|
"messages": [],
|
|
"hydrating": True,
|
|
"info": _lazy_resume_info(
|
|
cwd,
|
|
model=model_override.get("model") or "",
|
|
provider=overrides.get("provider_override") or "",
|
|
profile=profile,
|
|
),
|
|
"inflight": None,
|
|
"running": False,
|
|
"session_key": target,
|
|
"started_at": record["created_at"],
|
|
"status": "resuming",
|
|
},
|
|
record,
|
|
),
|
|
)
|
|
|
|
# Cold resume default: register the live session and read its stored
|
|
# transcript, but build the agent OFF the response path. _make_agent can
|
|
# block for seconds (MCP discovery, prompt/skill build, AIAgent
|
|
# construction), and every resume caller (desktop + Ink TUI) awaits this RPC
|
|
# before it paints — so building eagerly is the bulk of the multi-second
|
|
# "switching sessions is frozen" latency. Return the full display transcript
|
|
# immediately and pre-warm the agent on a short timer (the same deferred-
|
|
# build contract session.create uses); _sess() also builds on demand if the
|
|
# first prompt beats the timer. A caller that needs the agent built
|
|
# synchronously (e.g. tests of the build race) passes ``eager_build: true``
|
|
# to fall through to the eager path below. Distinct from the lazy/watch
|
|
# branch above: a normal resume restores the full ancestor history and the
|
|
# session's persisted runtime identity, and is a real (upgradable) session.
|
|
if not is_truthy_value(params.get("eager_build", False)):
|
|
sid = uuid.uuid4().hex[:8]
|
|
source = _resolve_session_source(str(params.get("source") or "").strip() or None)
|
|
lease = None # claimed lazily on the first turn (_ensure_active_session_slot)
|
|
# Interactive resume routes approvals/clarify through gateway prompts;
|
|
# the deferred build wires the remaining per-session callbacks.
|
|
_enable_gateway_prompts()
|
|
try:
|
|
db.reopen_session(target)
|
|
# One lineage SELECT feeds both projections (#67142-adjacent perf,
|
|
# from the desktop audit): the model-fed copy is alternation-repaired
|
|
# (raw_history → sanitize_replay_history → the resumed session's
|
|
# working conversation) and the display copy stays verbatim —
|
|
# inspection/export must show what is actually stored.
|
|
if omit_messages:
|
|
raw_history = db.get_messages_as_conversation(
|
|
target, repair_alternation=True, include_row_ids=True
|
|
)
|
|
display_history = []
|
|
else:
|
|
raw_history, display_history = db.get_resume_conversations(target)
|
|
except Exception as e:
|
|
if lease is not None:
|
|
lease.release()
|
|
return _err(rid, 5000, f"resume failed: {e}")
|
|
# Display keeps the full transcript; the model-fed history drops a
|
|
# dangling/interrupted tool-call tail so a session killed mid-loop does
|
|
# not replay the unanswered call forever (#29086).
|
|
prefix = [] if omit_messages else db.get_ancestor_display_prefix(target)
|
|
history = sanitize_replay_history(raw_history)
|
|
# Restore the model/provider/reasoning/tier this chat last used so the
|
|
# deferred build (and the info below) match the eager path — without them
|
|
# the build drops the provider ("No LLM provider configured").
|
|
overrides = _stored_session_runtime_overrides(found) or {}
|
|
model_override = overrides.get("model_override") or {}
|
|
cwd = profile_resume_cwd or _default_session_cwd()
|
|
record = _deferred_session_record(
|
|
target,
|
|
cols=cols,
|
|
cwd=cwd,
|
|
history=history,
|
|
lease=lease,
|
|
source=source,
|
|
close_on_disconnect=is_truthy_value(params.get("close_on_disconnect", False)),
|
|
display_history_prefix=prefix,
|
|
profile_home=profile_home,
|
|
model_override=overrides.get("model_override"),
|
|
resume_runtime_overrides=overrides or None,
|
|
todo_state=_todo_state_from_history(history),
|
|
explicit_cwd=bool(profile_resume_cwd),
|
|
)
|
|
if (live := _claim_or_reuse_live(sid, target, record, lease)) is not None:
|
|
return _reuse_live_response(*live)
|
|
|
|
_schedule_agent_build(sid)
|
|
_schedule_session_cap_enforcement() # trim detached idle sessions over the cap
|
|
auto_continue = _maybe_schedule_auto_continue(sid, record, target)
|
|
|
|
messages = [] if omit_messages else _history_to_messages(display_history)
|
|
payload = {
|
|
"session_id": sid,
|
|
"resumed": target,
|
|
"message_count": len(raw_history) if omit_messages else len(messages),
|
|
"messages": messages,
|
|
"messages_omitted": omit_messages,
|
|
"info": _lazy_resume_info(
|
|
cwd,
|
|
model=model_override.get("model") or "",
|
|
provider=overrides.get("provider_override") or "",
|
|
profile=profile,
|
|
),
|
|
"inflight": None,
|
|
"running": False,
|
|
"session_key": target,
|
|
"started_at": record["created_at"],
|
|
"status": "idle",
|
|
}
|
|
if auto_continue is not None:
|
|
payload["auto_continue"] = auto_continue
|
|
return _ok(rid, _attach_todo_state(payload, record))
|
|
|
|
# Build the agent OUTSIDE the lock — _make_agent can block for seconds
|
|
# (MCP discovery, prompt/skill build, AIAgent construction). Holding
|
|
# _session_resume_lock across it would stall session.close on the main
|
|
# dispatch thread (it's not a _LONG_HANDLER), blocking fast-path RPCs.
|
|
sid = uuid.uuid4().hex[:8]
|
|
source = _resolve_session_source(str(params.get("source") or "").strip() or None)
|
|
lease = None # claimed lazily on the first turn (_ensure_active_session_slot)
|
|
_enable_gateway_prompts()
|
|
home_token = (
|
|
set_hermes_home_override(str(profile_home)) if profile_home is not None else None
|
|
)
|
|
secret_token = (
|
|
set_secret_scope(build_profile_secret_scope(Path(str(profile_home))))
|
|
if profile_home is not None
|
|
else None
|
|
)
|
|
try:
|
|
db.reopen_session(target)
|
|
# One lineage SELECT feeds both projections (see the interactive resume
|
|
# above): the model-fed copy is alternation-repaired for LIVE REPLAY, the
|
|
# display copy stays verbatim.
|
|
if omit_messages:
|
|
raw_history = db.get_messages_as_conversation(
|
|
target, repair_alternation=True, include_row_ids=True
|
|
)
|
|
display_history = []
|
|
else:
|
|
raw_history, display_history = db.get_resume_conversations(target)
|
|
# The display transcript keeps every row so the user still sees their
|
|
# full history. The model-fed history is sanitized: a session whose
|
|
# last turn died mid-tool-loop persists a dangling assistant(tool_calls)
|
|
# (or interrupted assistant→tool) tail; replaying it makes the model
|
|
# re-issue the unanswered call forever — the permanent-"thinking" stuck
|
|
# session in #29086. The messaging gateway already strips this; this is
|
|
# the WebUI/TUI resume path picking up the same cleanup.
|
|
display_history_prefix = (
|
|
[] if omit_messages else db.get_ancestor_display_prefix(target)
|
|
)
|
|
history = sanitize_replay_history(raw_history)
|
|
messages = [] if omit_messages else _history_to_messages(display_history)
|
|
tokens = _set_session_context(target)
|
|
try:
|
|
# Pass the profile's db so the agent persists turns to the right
|
|
# state.db; home override is active here so config/skills/model
|
|
# resolve to the profile too. Runtime identity is restored from the
|
|
# stored session row so switching chats does not inherit whatever
|
|
# global model another chat last selected.
|
|
stored_runtime_overrides = _stored_session_runtime_overrides(found)
|
|
agent = _make_agent(
|
|
sid,
|
|
target,
|
|
session_id=target,
|
|
session_db=db,
|
|
platform_override=source,
|
|
context_cwd_is_launch_artifact=(
|
|
source in _LAUNCH_CWD_NOT_A_WORKSPACE
|
|
and not profile_resume_cwd
|
|
),
|
|
**stored_runtime_overrides,
|
|
)
|
|
finally:
|
|
_clear_session_context(tokens)
|
|
except Exception as e:
|
|
if lease is not None:
|
|
lease.release()
|
|
return _err(rid, 5000, f"resume failed: {e}")
|
|
finally:
|
|
if home_token is not None:
|
|
reset_hermes_home_override(home_token)
|
|
if secret_token is not None:
|
|
reset_secret_scope(secret_token)
|
|
|
|
# Double-checked locking: another concurrent resume may have created the
|
|
# live session while we were building. Re-check under the lock; if it won,
|
|
# discard our just-built agent and reuse theirs (no worker/poller wired yet).
|
|
with _session_resume_lock:
|
|
live = _find_live_session_by_key(target, profile_home)
|
|
if live is not None:
|
|
try:
|
|
if hasattr(agent, "close"):
|
|
agent.close()
|
|
except Exception:
|
|
pass
|
|
if lease is not None:
|
|
lease.release()
|
|
return _reuse_live_response(*live)
|
|
try:
|
|
init_home_token = (
|
|
set_hermes_home_override(str(profile_home))
|
|
if profile_home is not None
|
|
else None
|
|
)
|
|
init_secret_token = (
|
|
set_secret_scope(build_profile_secret_scope(Path(str(profile_home))))
|
|
if profile_home is not None
|
|
else None
|
|
)
|
|
try:
|
|
_init_session(
|
|
sid,
|
|
target,
|
|
agent,
|
|
history,
|
|
cols=cols,
|
|
cwd=profile_resume_cwd,
|
|
session_db=db,
|
|
source=source,
|
|
explicit_cwd=bool(profile_resume_cwd),
|
|
)
|
|
# Ownership TRANSFER — the registered session's agent now
|
|
# holds this handle for its whole life, and _init_session
|
|
# never closes a caller-supplied session_db (its
|
|
# _init_owns_db stays False). Closing it in the finally
|
|
# below would fault every later turn on this session with
|
|
# "Cannot operate on a closed database".
|
|
#
|
|
# Ownership moves ONTO the agent rather than just being
|
|
# dropped: AIAgent.close() (reached from _teardown_session
|
|
# on session.close and the orphaned-session reaper) closes
|
|
# a handle it owns, so the dedicated fds and the token
|
|
# writer are released at teardown instead of living as
|
|
# long as the gateway process.
|
|
#
|
|
# The drop is UNCONDITIONAL and the transfer is best-effort
|
|
# on top of it, deliberately. Past this line the session is
|
|
# registered and holding this handle, so the finally must
|
|
# not close it even if the transfer was refused — a refusal
|
|
# leaves the old leak, which is survivable; closing under a
|
|
# live session is the permanent "Cannot operate on a closed
|
|
# database" break this patch exists to avoid.
|
|
#
|
|
# The transfer itself is gated on owns_db: with no
|
|
# non-launch profile selected this path resolved db to the
|
|
# SHARED launch handle (_get_db()), and transferring it
|
|
# made session.close() tear down the process-wide
|
|
# database under every unrelated session (#91610).
|
|
if owns_db:
|
|
_transfer_db_to_agent(agent, db)
|
|
owns_db = False
|
|
finally:
|
|
if init_home_token is not None:
|
|
reset_hermes_home_override(init_home_token)
|
|
if init_secret_token is not None:
|
|
reset_secret_scope(init_secret_token)
|
|
if sid in _sessions:
|
|
if stored_runtime_overrides.get("model_override") is not None:
|
|
_sessions[sid]["model_override"] = stored_runtime_overrides[
|
|
"model_override"
|
|
]
|
|
_sessions[sid]["display_history_prefix"] = display_history_prefix
|
|
# Remember the profile home so each turn re-binds HERMES_HOME (the
|
|
# agent persists to its own db, but mid-turn home reads — memory,
|
|
# skills — must resolve to the resumed profile too).
|
|
if profile_home is not None:
|
|
_sessions[sid]["profile_home"] = str(profile_home)
|
|
_sessions[sid]["active_session_lease"] = lease
|
|
except Exception as e:
|
|
# _init_session registers _sessions[sid] BEFORE its first read
|
|
# through this handle. If it raised in between — "database is
|
|
# locked" is the realistic trigger — the half-built session is
|
|
# still registered while the finally below closes the handle it
|
|
# holds, and the live-session fast path above would then serve
|
|
# that dead session on every later resume of this id
|
|
# ("'NoneType' object has no attribute 'execute'", permanently).
|
|
# owns_db still True means ownership never transferred, so the
|
|
# registration is ours to undo.
|
|
if owns_db:
|
|
with _sessions_lock:
|
|
_sessions.pop(sid, None)
|
|
if lease is not None:
|
|
lease.release()
|
|
return _err(rid, 5000, f"resume failed: {e}")
|
|
session = _sessions.get(sid) or {}
|
|
finally:
|
|
# Every return that does NOT reach the transfer above abandons this
|
|
# handle — session-not-found, both "resume failed" paths, the live-session
|
|
# fast path (the hot one: reconnects re-resume live chats through it), the
|
|
# deferred cold/lazy returns, and the double-checked-locking discard.
|
|
# Dropping it merely relied on refcounting to release the sqlite fds; that
|
|
# stops being true the moment anything pins the instance — SessionDB pins
|
|
# ITSELF once its background token writer starts, via
|
|
# atexit.register(_drain_token_queue_at_exit) (hermes_state.py), which only
|
|
# close() unregisters. A pinned handle keeps its db/-wal/-shm fds and its
|
|
# writer thread for the life of the process.
|
|
if owns_db and db is not None:
|
|
with contextlib.suppress(Exception):
|
|
db.close()
|
|
auto_continue = (
|
|
_maybe_schedule_auto_continue(sid, session, target) if session else None
|
|
)
|
|
payload = {
|
|
"session_id": sid,
|
|
"resumed": target,
|
|
"message_count": len(raw_history) if omit_messages else len(messages),
|
|
"messages": messages,
|
|
"messages_omitted": omit_messages,
|
|
"info": _session_info(agent, session),
|
|
"inflight": None,
|
|
"running": False,
|
|
"session_key": target,
|
|
"started_at": float(session.get("created_at") or time.time()),
|
|
"status": "idle",
|
|
}
|
|
if auto_continue is not None:
|
|
payload["auto_continue"] = auto_continue
|
|
return _ok(rid, _attach_todo_state(payload, session))
|
|
|
|
|
|
@method("session.cwd.set")
|
|
def _(rid, params: dict) -> dict:
|
|
session, err = _sess_nowait(params, rid)
|
|
if err:
|
|
return err
|
|
if session.get("running"):
|
|
return _err(rid, 4009, "session busy")
|
|
raw = str(params.get("cwd", "") or "").strip()
|
|
if not raw:
|
|
return _err(rid, 4016, "cwd required")
|
|
try:
|
|
cwd = _set_session_cwd(session, raw)
|
|
except ValueError as e:
|
|
return _err(rid, 4017, str(e))
|
|
agent = session.get("agent")
|
|
info = _session_info(agent, session) if agent is not None else {
|
|
"cwd": cwd,
|
|
"branch": _git_branch_for_cwd(cwd),
|
|
"project": _project_info_for_cwd(cwd),
|
|
"lazy": True,
|
|
}
|
|
_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 into another folder/project.
|
|
|
|
Unlike ``session.cwd.set`` (which acts on a live runtime session by its UI
|
|
id), this targets a persisted row by ``session_key`` so the desktop can fix
|
|
a session that was created in the wrong directory — no live agent required.
|
|
The git branch/root columns are REPLACED (not merely enriched), because the
|
|
whole point of the move is to change which project claims the session; a
|
|
stale ``git_repo_root`` would keep it grouped under the project it left.
|
|
|
|
A live agent bound to the row follows through the runtime path too, so its
|
|
terminal/file tools re-anchor immediately. An explicit move wins even
|
|
mid-turn: refusing a running session made the desktop's "Move to project"
|
|
claim success in the UI while ``state.db`` kept the old cwd — two sources
|
|
of truth disagreeing (#86626). In-flight tool calls keep the cwd they were
|
|
launched with; the NEXT tool call uses the new workspace.
|
|
"""
|
|
target = str(params.get("session_key") or "").strip()
|
|
if not target:
|
|
return _err(rid, 4007, "session_key required")
|
|
raw = str(params.get("cwd", "") or "").strip()
|
|
if not raw:
|
|
return _err(rid, 4016, "cwd required")
|
|
from hermes_constants import translate_cwd_for_wsl_backend
|
|
|
|
resolved = os.path.abspath(os.path.expanduser(translate_cwd_for_wsl_backend(raw)))
|
|
if not os.path.isdir(resolved):
|
|
return _err(rid, 4017, f"working directory does not exist: {raw}")
|
|
|
|
# Snapshot under the lock — concurrent RPCs mutate _sessions (same pattern
|
|
# as _cwd_for_session_key).
|
|
live = None
|
|
live_sid = ""
|
|
with _sessions_lock:
|
|
for sid, sess in list(_sessions.items()):
|
|
if sess.get("session_key") == target:
|
|
live, live_sid = sess, sid
|
|
break
|
|
|
|
branch = _git_branch_for_cwd(resolved)
|
|
root = _git_common_repo_root_for_cwd(resolved)
|
|
with _profile_db(params) as db:
|
|
if db is None:
|
|
return _db_unavailable_error(rid, code=5007)
|
|
# A brand-new draft has no persisted row yet; the live re-home below
|
|
# still applies and the row inherits the cwd when it is first written.
|
|
row_exists = bool(db.get_session(target))
|
|
if not row_exists and live is None:
|
|
return _err(rid, 4007, "session not found")
|
|
if row_exists:
|
|
try:
|
|
db.update_session_cwd(
|
|
target, resolved, 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, resolved)
|
|
except ValueError as e:
|
|
return _err(rid, 4017, str(e))
|
|
agent = live.get("agent")
|
|
info = _session_info(agent, live) if agent is not None else {
|
|
"cwd": resolved,
|
|
"branch": branch,
|
|
"project": _project_info_for_cwd(resolved),
|
|
"lazy": True,
|
|
}
|
|
_emit("session.info", live_sid, info)
|
|
|
|
return _ok(rid, {"cwd": resolved, "branch": branch, "git_repo_root": root})
|
|
|
|
|
|
@method("session.active_list")
|
|
def _(rid, params: dict) -> dict:
|
|
"""Return live TUI sessions in this gateway process.
|
|
|
|
Unlike ``session.list`` this is not a historical DB browser: it reports only
|
|
sessions with in-memory agents/workers that the current TUI can switch to
|
|
without closing siblings.
|
|
"""
|
|
current = str(params.get("current_session_id") or "")
|
|
try:
|
|
with _sessions_lock:
|
|
snapshot = list(_sessions.items())
|
|
except Exception as e:
|
|
return _err(rid, 5036, f"could not enumerate active sessions: {e}")
|
|
|
|
# Liveness filter (#38950): a session whose teardown has begun (``_finalized``)
|
|
# is dead — its agent/worker are being released and it is no longer
|
|
# attachable — but it can briefly remain in ``_sessions`` until the reaper
|
|
# pops it (the WS grace-reap and idle reaper both set ``_finalized`` inside
|
|
# ``_teardown_session`` before the pop). Counting these inflated the footer's
|
|
# "N sessions" count, which only ever went up until a gateway restart. Drop
|
|
# them here so the count reflects genuinely attachable sessions. We do NOT
|
|
# filter on ``transport is _detached_ws_transport`` (the WS-detached drop
|
|
# sentinel): a detached session is still attachable via a quick reconnect /
|
|
# session.resume until the grace-reap finalizes it, and a standalone
|
|
# ``hermes --tui`` session legitimately rides the real stdio transport and
|
|
# must stay visible.
|
|
# Keep the natural creation/insertion order from ``_sessions``. The
|
|
# frontend marks the focused session with ``current``; it should not jump to
|
|
# the top just because the user switched to it.
|
|
rows = [
|
|
_session_live_item(sid, session, current)
|
|
for sid, session in snapshot
|
|
if not session.get("_finalized")
|
|
]
|
|
return _ok(rid, {"sessions": rows})
|
|
|
|
|
|
@method("session.activate")
|
|
def _(rid, params: dict) -> dict:
|
|
"""Attach the frontend to an already-live TUI session.
|
|
|
|
This intentionally does not close the previously focused session; it merely
|
|
returns enough state for Ink to redraw around another live session id.
|
|
"""
|
|
sid = str(params.get("session_id") or "")
|
|
session, err = _sess_nowait({"session_id": sid}, rid)
|
|
if err:
|
|
return err
|
|
assert session is not None
|
|
|
|
return _ok(
|
|
rid,
|
|
_live_session_payload(
|
|
sid,
|
|
session,
|
|
touch=True,
|
|
transport=current_transport() or _stdio_transport,
|
|
omit_messages=is_truthy_value(params.get("omit_messages", False)),
|
|
),
|
|
)
|
|
|
|
|
|
@method("session.delete")
|
|
def _(rid, params: dict) -> dict:
|
|
"""Delete a stored session and its on-disk transcript files.
|
|
|
|
Used by the TUI resume picker (``d`` key) so users can prune old
|
|
sessions without dropping to the CLI. Refuses to delete a session
|
|
that is currently active in this gateway process — those rows are
|
|
still being written to and removing them out from under the live
|
|
agent corrupts message ordering and trips FK constraints when the
|
|
next message append flushes.
|
|
|
|
Honors ``params.profile`` so app-global remote mode deletes from the
|
|
focused profile's ``state.db`` + sessions dir (mirrors ``session.resume``).
|
|
"""
|
|
target = params.get("session_id", "")
|
|
if not target:
|
|
return _err(rid, 4006, "session_id required")
|
|
# Block deletion of any session currently bound to a live TUI session
|
|
# in this process. The picker hides the active session anyway, but a
|
|
# racing caller could still target it. Snapshot via ``list(...)``
|
|
# because ``_sessions`` is mutated by concurrent RPCs on the thread
|
|
# pool — iterating the dict directly can raise ``RuntimeError:
|
|
# dictionary changed size during iteration``. If even the snapshot
|
|
# raises, fail closed (refuse the delete) rather than fail open.
|
|
try:
|
|
with _sessions_lock:
|
|
snapshot = list(_sessions.values())
|
|
except Exception as e:
|
|
return _err(rid, 5036, f"could not enumerate active sessions: {e}")
|
|
active = {s.get("session_key") for s in snapshot if s.get("session_key")}
|
|
if target in active:
|
|
return _err(rid, 4023, "cannot delete an active session")
|
|
profile = (params.get("profile") or "").strip() or None
|
|
profile_home = _profile_home(profile)
|
|
with _profile_db(params) as db:
|
|
if db is None:
|
|
return _db_unavailable_error(rid, code=5036)
|
|
if profile_home is not None:
|
|
sessions_dir = Path(profile_home) / "sessions"
|
|
else:
|
|
sessions_dir = get_hermes_home() / "sessions"
|
|
try:
|
|
deleted = db.delete_session(target, sessions_dir=sessions_dir)
|
|
except Exception as e:
|
|
return _err(rid, 5036, f"delete failed: {e}")
|
|
if not deleted:
|
|
return _err(rid, 4007, "session not found")
|
|
return _ok(rid, {"deleted": target})
|
|
|
|
|
|
@method("session.title")
|
|
def _(rid, params: dict) -> dict:
|
|
session, err = _sess_nowait(params, rid)
|
|
if err:
|
|
return err
|
|
with _session_db(session) as db:
|
|
if db is None:
|
|
return _db_unavailable_error(rid, code=5007)
|
|
key = session["session_key"]
|
|
if "title" not in params:
|
|
fallback = session.get("pending_title") or ""
|
|
try:
|
|
resolved_title = db.get_session_title(key) or ""
|
|
if fallback:
|
|
if db.set_session_title(key, fallback):
|
|
session["pending_title"] = None
|
|
resolved_title = fallback
|
|
else:
|
|
existing_row = db.get_session(key)
|
|
existing_title = ((existing_row or {}).get("title") or "").strip()
|
|
if existing_title == fallback:
|
|
session["pending_title"] = None
|
|
resolved_title = fallback
|
|
elif not resolved_title:
|
|
resolved_title = fallback
|
|
elif resolved_title:
|
|
session["pending_title"] = None
|
|
except Exception:
|
|
resolved_title = fallback
|
|
_emit_session_info_for_session(params.get("session_id", ""), session)
|
|
return _ok(
|
|
rid,
|
|
{
|
|
"title": resolved_title,
|
|
"session_key": key,
|
|
},
|
|
)
|
|
title = (params.get("title", "") or "").strip()
|
|
if not title:
|
|
return _err(rid, 4021, "title required")
|
|
try:
|
|
if db.set_session_title(key, title):
|
|
session["pending_title"] = None
|
|
_emit_session_info_for_session(params.get("session_id", ""), session)
|
|
return _ok(rid, {"pending": False, "title": title})
|
|
# rowcount == 0 can mean "same value" as well as "missing row".
|
|
existing_row = db.get_session(key)
|
|
if existing_row:
|
|
session["pending_title"] = None
|
|
_emit_session_info_for_session(params.get("session_id", ""), session)
|
|
return _ok(
|
|
rid,
|
|
{
|
|
"pending": False,
|
|
"title": (existing_row.get("title") or title),
|
|
},
|
|
)
|
|
# No row yet (the DB write is deferred to the first prompt so empty
|
|
# drafts don't litter the sidebar). An explicit /title is clear user
|
|
# intent, not an abandoned draft — so persist the row NOW and set the
|
|
# title, mirroring the messaging gateway's _handle_title_command. The
|
|
# old behavior only queued pending_title and relied on the post-turn
|
|
# apply block; if that turn never landed under this session_key the
|
|
# title was silently lost and the sidebar fell back to the message
|
|
# preview. Creating the row up front removes that race entirely. The
|
|
# min-messages sidebar filter keeps a titled 0-message row hidden, so
|
|
# a /title'd-but-never-used draft still doesn't clutter the list.
|
|
_ensure_session_db_row(session)
|
|
with _session_db(session) as scoped_db:
|
|
if scoped_db is not None and scoped_db.set_session_title(key, title):
|
|
session["pending_title"] = None
|
|
_emit_session_info_for_session(params.get("session_id", ""), session)
|
|
return _ok(rid, {"pending": False, "title": title})
|
|
# Row creation didn't take (DB unavailable, or a concurrent writer) —
|
|
# fall back to queuing so the post-turn apply block can still recover.
|
|
session["pending_title"] = title
|
|
_emit_session_info_for_session(params.get("session_id", ""), session)
|
|
return _ok(rid, {"pending": True, "title": title})
|
|
except ValueError as e:
|
|
return _err(rid, 4022, str(e))
|
|
except Exception as e:
|
|
return _err(rid, 5007, str(e))
|
|
|
|
|
|
@method("session.set_hidden")
|
|
def _(rid, params: dict) -> dict:
|
|
"""Set/clear the generic ``hidden`` flag on a session (and its lineage).
|
|
|
|
Mirrors the durable ``pinned``/``archived`` setters: a hidden session is
|
|
dropped from the default global Sessions list (``list_sessions_rich``
|
|
without ``include_hidden``) but stays fully resumable by the surface that
|
|
owns it — for plugins that manage their own sessions and don't want them
|
|
cluttering the shared recents list. Flips the whole compression chain as a
|
|
unit in the DB layer.
|
|
|
|
Resolution is two-tier: a LIVE runtime session id first (which also
|
|
covers the not-yet-persisted draft via the ``pending_hidden`` deferral),
|
|
then a durable stored id/key against the target profile's state.db —
|
|
plugins reconciling sessions they own (e.g. Bot Mode's hide sweep) hold
|
|
stored ids for chats that aren't live right now, and the live-only
|
|
lookup silently failed those with 4001.
|
|
"""
|
|
hidden = is_truthy_value(params.get("hidden", True))
|
|
session, err = _sess_nowait(params, rid)
|
|
if session is not None:
|
|
with _session_db(session) as db:
|
|
if db is None:
|
|
return _db_unavailable_error(rid, code=5007)
|
|
key = session["session_key"]
|
|
try:
|
|
changed = db.set_session_hidden(key, hidden)
|
|
if not changed:
|
|
# No row yet (write deferred to the first prompt): remember the
|
|
# intent so _ensure_session_db_row is born hidden, mirroring the
|
|
# pending_title deferral.
|
|
session["pending_hidden"] = hidden
|
|
return _ok(rid, {"hidden": hidden, "session_key": key})
|
|
except Exception as e:
|
|
return _err(rid, 5007, str(e))
|
|
# Durable fallback: a stored session id (or key) in the requested
|
|
# profile's db. ``resolve_session_id`` follows key/title aliases the
|
|
# same way the REST pin/archive path does.
|
|
target = str(params.get("session_id") or "").strip()
|
|
with _profile_db(params) as db:
|
|
if db is None:
|
|
return _db_unavailable_error(rid, code=5007)
|
|
try:
|
|
resolved = db.resolve_session_id(target) if hasattr(db, "resolve_session_id") else target
|
|
if not resolved:
|
|
return err
|
|
db.set_session_hidden(resolved, hidden)
|
|
return _ok(rid, {"hidden": hidden, "session_key": resolved})
|
|
except Exception as e:
|
|
return _err(rid, 5007, str(e))
|
|
|
|
|
|
@method("message.react")
|
|
def _(rid, params: dict) -> dict:
|
|
"""Set or clear one author's emoji reaction on a persisted message.
|
|
|
|
iOS Tapback semantics, enforced in the DB layer: one reaction per author
|
|
per message, re-sending the same emoji retracts it. ``emoji: null`` clears
|
|
unconditionally. ``row_id`` is the durable ``messages.id`` forwarded by
|
|
``_history_to_messages`` — the renderer's own message ids are ephemeral.
|
|
"""
|
|
session, err = _sess_nowait(params, rid)
|
|
if err:
|
|
return err
|
|
|
|
# A live message hasn't round-tripped through a resume, so the desktop has
|
|
# no durable row id for it yet. It can instead name the ROLE whose newest
|
|
# row it means — which is the message the user just reacted to.
|
|
newest_role = str(params.get("newest_role") or "").strip()
|
|
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")
|
|
|
|
emoji = params.get("emoji")
|
|
if emoji is not None:
|
|
emoji = str(emoji).strip()
|
|
if not emoji:
|
|
return _err(rid, 4024, "emoji must be a non-empty string or null")
|
|
|
|
author = str(params.get("author") or "user").strip()
|
|
if author 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")
|
|
def _(rid, params: dict) -> dict:
|
|
"""Run a single stateless LLM request outside any conversation.
|
|
|
|
Generic helper for small generative chores (e.g. a commit message from a
|
|
diff). Accepts either a named ``template`` + ``variables`` or an explicit
|
|
``instructions`` / ``input`` pair. When ``session_id`` resolves to a live
|
|
session the call inherits that agent's model; otherwise it uses the
|
|
configured auxiliary ``task`` backend. Never mutates session history, so
|
|
prompt caching is untouched.
|
|
"""
|
|
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 {}
|
|
task = (params.get("task") or "title_generation").strip() or "title_generation"
|
|
|
|
try:
|
|
max_tokens = int(params.get("max_tokens") or 1024)
|
|
except (TypeError, ValueError):
|
|
max_tokens = 1024
|
|
temperature = params.get("temperature")
|
|
if temperature is not None:
|
|
try:
|
|
temperature = float(temperature)
|
|
except (TypeError, ValueError):
|
|
temperature = None
|
|
|
|
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")
|
|
|
|
# Optional: inherit the live session's model (no error if absent).
|
|
session = _sessions.get(params.get("session_id") or "")
|
|
main_runtime = _main_runtime_from_agent(session.get("agent")) if session else None
|
|
|
|
try:
|
|
from agent.oneshot import run_oneshot
|
|
|
|
text = run_oneshot(
|
|
instructions=instructions,
|
|
user_input=user_input,
|
|
template=template,
|
|
variables=variables,
|
|
task=task,
|
|
max_tokens=max_tokens,
|
|
temperature=temperature if temperature is not None else 0.3,
|
|
main_runtime=main_runtime,
|
|
)
|
|
except KeyError as e:
|
|
return _err(rid, 4031, str(e))
|
|
except ValueError as e:
|
|
return _err(rid, 4032, str(e))
|
|
except Exception as e:
|
|
logger.warning("llm.oneshot failed: %s", e)
|
|
return _err(rid, 5030, f"one-shot generation failed: {e}")
|
|
|
|
return _ok(rid, {"text": text})
|
|
|
|
|
|
@method("handoff.request")
|
|
def _(rid, params: dict) -> dict:
|
|
"""Queue a handoff of this session to a messaging platform.
|
|
|
|
Desktop parity with the CLI ``/handoff`` command: we only write
|
|
``handoff_state='pending'`` onto the persisted session row. The actual
|
|
transfer is performed by the separate ``hermes gateway`` process, whose
|
|
``_handoff_watcher`` claims the row, re-binds the session to the platform's
|
|
home channel, and forges a synthetic turn. The desktop then polls
|
|
``handoff.state`` for the terminal result.
|
|
"""
|
|
session, err = _sess_nowait(params, rid)
|
|
if err:
|
|
return err
|
|
if session.get("running"):
|
|
return _err(
|
|
rid,
|
|
4009,
|
|
"session busy — wait for the current turn to finish, then retry the handoff",
|
|
)
|
|
|
|
platform_name = (params.get("platform", "") or "").strip().lower()
|
|
if not platform_name:
|
|
return _err(rid, 4023, "platform required")
|
|
|
|
# Validate against the live gateway config — an unconfigured platform or a
|
|
# missing home channel would leave the handoff pending forever, so reject
|
|
# up front with a clear, actionable message (mirrors cli.py).
|
|
try:
|
|
from gateway.config import Platform, load_gateway_config
|
|
except Exception as e: # pragma: no cover — gateway pkg always ships
|
|
return _err(rid, 5021, f"could not load gateway config: {e}")
|
|
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}")
|
|
pcfg = gw_config.platforms.get(platform)
|
|
if not pcfg or not pcfg.enabled:
|
|
return _err(
|
|
rid,
|
|
4025,
|
|
f"platform '{platform_name}' is not configured/enabled in the gateway",
|
|
)
|
|
home = gw_config.get_home_channel(platform)
|
|
if not home 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 DB row, so make sure one exists even
|
|
# for a brand-new empty chat (mirrors the CLI's set_session_title stub).
|
|
_ensure_session_db_row(session)
|
|
|
|
with _session_db(session) as db:
|
|
if db is None:
|
|
return _db_unavailable_error(rid, code=5007)
|
|
key = session["session_key"]
|
|
try:
|
|
if not db.get_session(key):
|
|
db.set_session_title(key, f"handoff-{key[:8]}")
|
|
ok = db.request_handoff(key, platform_name)
|
|
except Exception as e:
|
|
return _err(rid, 5007, str(e))
|
|
|
|
if not ok:
|
|
return _err(
|
|
rid,
|
|
4027,
|
|
"session is already in flight for handoff — wait for it to settle, then retry",
|
|
)
|
|
return _ok(
|
|
rid,
|
|
{
|
|
"queued": True,
|
|
"session_key": key,
|
|
"platform": platform_name,
|
|
"home_name": home.name,
|
|
},
|
|
)
|
|
|
|
|
|
@method("handoff.state")
|
|
def _(rid, params: dict) -> dict:
|
|
"""Poll the handoff state for a session.
|
|
|
|
Returns ``{state, platform, error}`` where ``state`` is one of
|
|
``pending|running|completed|failed`` (or empty when no handoff record
|
|
exists). Desktop polls this after ``handoff.request``.
|
|
"""
|
|
session, err = _sess_nowait(params, rid)
|
|
if err:
|
|
return err
|
|
with _session_db(session) as db:
|
|
if db is None:
|
|
return _db_unavailable_error(rid, code=5007)
|
|
record = db.get_handoff_state(session["session_key"])
|
|
|
|
record = record or {}
|
|
return _ok(
|
|
rid,
|
|
{
|
|
"state": record.get("state") or "",
|
|
"platform": record.get("platform") or "",
|
|
"error": record.get("error") or "",
|
|
},
|
|
)
|
|
|
|
|
|
@method("handoff.fail")
|
|
def _(rid, params: dict) -> dict:
|
|
"""Mark a not-yet-claimed handoff as failed so the user can retry.
|
|
|
|
Desktop calls this when its bounded poll times out. Only PENDING rows are
|
|
changed (compare-and-swap in ``fail_handoff``): once the gateway watcher
|
|
has claimed the row (``running``) it owns the terminal state — failing it
|
|
from the waiter races the in-flight dispatch, which later overwrites
|
|
``failed`` → ``completed`` after the user was already told it failed
|
|
(split-brain; the delivery actually happened). For a ``running`` row the
|
|
caller gets ``{"failed": False, "state": "running"}`` and should surface
|
|
"still transferring" instead.
|
|
"""
|
|
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: preserve prior behavior
|
|
# minus the running-row stomp (fail only when still pending).
|
|
record = db.get_handoff_state(key) or {}
|
|
failed = (record.get("state") or "") == "pending"
|
|
if failed:
|
|
db.fail_handoff(key, reason)
|
|
if failed:
|
|
return _ok(rid, {"failed": True, "state": "failed"})
|
|
record = db.get_handoff_state(key) or {}
|
|
|
|
return _ok(rid, {"failed": False, "state": record.get("state") or ""})
|
|
|
|
|
|
@method("session.usage")
|
|
def _(rid, params: dict) -> dict:
|
|
session, err = _sess_nowait(params, rid)
|
|
if err:
|
|
return err
|
|
agent = session.get("agent")
|
|
usage: dict = _session_usage_snapshot(session)
|
|
if agent is None and not usage:
|
|
usage = {"calls": 0, "input": 0, "output": 0, "total": 0}
|
|
# Nous credits block — agent-independent (a portal fetch), so it shows even
|
|
# with zero API calls or on a resumed session. The TUI /usage panel renders
|
|
# these lines regardless of `calls`. Fail-open: [] when not logged into Nous
|
|
# or on any portal hiccup.
|
|
try:
|
|
from agent.account_usage import nous_credits_lines
|
|
|
|
credits = nous_credits_lines()
|
|
if credits:
|
|
usage["credits_lines"] = credits
|
|
except Exception:
|
|
pass
|
|
return _ok(rid, usage)
|
|
|
|
|
|
@method("session.context_breakdown")
|
|
def _(rid, params: dict) -> dict:
|
|
session, err = _sess_nowait(params, rid)
|
|
if err:
|
|
return err
|
|
agent = session.get("agent")
|
|
if 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": usage.get("context_used", 0) or usage.get("total", 0) or 0,
|
|
"model": _metadata_mirror(session).get("model", ""),
|
|
},
|
|
)
|
|
with session["history_lock"]:
|
|
history = list(session.get("history", []))
|
|
try:
|
|
from agent.context_breakdown import compute_session_context_breakdown
|
|
|
|
payload = compute_session_context_breakdown(agent, history)
|
|
except Exception as exc:
|
|
return _err(rid, 5000, f"Could not compute context breakdown: {exc}")
|
|
return _ok(rid, payload)
|
|
|
|
|
|
@method("pet.info")
|
|
@_profile_scoped
|
|
def _(rid, params: dict) -> dict:
|
|
"""Return the active petdex pet for surfaces that render sprites.
|
|
|
|
Shared by the desktop (canvas) and the TUI (half-block). Carries the
|
|
spritesheet bytes (base64) plus the engine's frame geometry + state-row
|
|
taxonomy so the renderer is a thin, framework-native consumer. The
|
|
activity→state decision is mirrored from ``agent.pet.state`` client-side.
|
|
|
|
Agent-independent (reads config + disk), so it works on any session and
|
|
before the agent finishes building. Fail-open: returns ``enabled=False``
|
|
on any error rather than erroring the surface.
|
|
"""
|
|
try:
|
|
enabled, pet, scale = _pet_active_selection()
|
|
|
|
if not enabled or pet is None or not pet.exists:
|
|
return _ok(rid, {"enabled": False})
|
|
|
|
payload = {"enabled": True, **_pet_sprite_payload(pet, scale=scale)}
|
|
|
|
# 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).
|
|
known_revision = str(params.get("knownRevision", "") or "")
|
|
if known_revision and known_revision == payload.get("spritesheetRevision"):
|
|
payload.pop("spritesheetBase64", None)
|
|
payload["spritesheetUnchanged"] = True
|
|
|
|
return _ok(rid, payload)
|
|
except Exception as exc: # noqa: BLE001 - cosmetic, never break the surface
|
|
logger.debug("pet.info failed: %s", exc)
|
|
return _ok(rid, {"enabled": False})
|
|
|
|
|
|
@method("pet.info.meta")
|
|
@_profile_scoped
|
|
def _(rid, params: dict) -> dict:
|
|
"""Cheap active-pet metadata used to avoid full payload refreshes."""
|
|
try:
|
|
enabled, pet, scale = _pet_active_selection()
|
|
if not enabled or pet is None or not pet.exists:
|
|
return _ok(rid, {"enabled": False})
|
|
return _ok(
|
|
rid,
|
|
{
|
|
"enabled": True,
|
|
"slug": pet.slug,
|
|
"displayName": pet.display_name,
|
|
"scale": scale,
|
|
"spritesheetRevision": _pet_sheet_revision(pet.spritesheet),
|
|
},
|
|
)
|
|
except Exception as exc: # noqa: BLE001 - cosmetic, never break the surface
|
|
logger.debug("pet.info.meta failed: %s", exc)
|
|
return _ok(rid, {"enabled": False})
|
|
|
|
|
|
@method("pet.cells")
|
|
@_profile_scoped
|
|
def _(rid, params: dict) -> dict:
|
|
"""Return half-block cell frames for one pet state (TUI renderer).
|
|
|
|
The TUI can't draw a canvas, so the engine downsamples the spritesheet to
|
|
a grid of half-block cells and the Ink side paints them with native color
|
|
props. Each cell is ``[tr,tg,tb,ta, br,bg,bb,ba]`` (top + bottom pixel).
|
|
|
|
Params: ``state`` (idle/run/review/failed/wave/jump), ``cols`` (width).
|
|
Fail-open: ``enabled=False`` on any problem.
|
|
"""
|
|
try:
|
|
from agent.pet import constants, render, store
|
|
from agent.pet.render import PetRenderer
|
|
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
|
|
cfg = load_config()
|
|
display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {}
|
|
pet_cfg = display.get("pet", {}) if isinstance(display.get("pet"), dict) else {}
|
|
except Exception:
|
|
pet_cfg = {}
|
|
|
|
if not is_truthy_value(pet_cfg.get("enabled"), default=False):
|
|
return _ok(rid, {"enabled": 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))
|
|
|
|
# Graphics path: when the TUI is attached to a real TTY (``graphics``)
|
|
# and the terminal speaks the kitty protocol, return a Unicode-
|
|
# placeholder payload for a crisp image instead of half-blocks. Env
|
|
# detection (KITTY_WINDOW_ID / TERM / TERM_PROGRAM) is shared with the
|
|
# Ink process since it spawns us; the dashboard PTY (xterm.js) has no
|
|
# such env, so it falls through to half-blocks automatically. Only
|
|
# kitty is grid-safe in Ink — iTerm/sixel stay on the fallback.
|
|
if params.get("graphics"):
|
|
configured = str(pet_cfg.get("render_mode", "auto") or "auto").lower()
|
|
gmode = render.detect_terminal_graphics() if configured in ("", "auto") else configured
|
|
if gmode == "kitty":
|
|
image_id = render.kitty_image_id(pet.slug)
|
|
# kitty sizes from scaled pixels (_cell_box), so unicode_cols is moot here.
|
|
payload = PetRenderer(
|
|
str(pet.spritesheet), mode="kitty", scale=scale
|
|
).kitty_payload(state, image_id=image_id)
|
|
if payload:
|
|
kcount = len(payload["frames"]) or 1
|
|
return _ok(
|
|
rid,
|
|
{
|
|
"enabled": True,
|
|
"slug": pet.slug,
|
|
"displayName": pet.display_name,
|
|
"state": state,
|
|
"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, kcount),
|
|
"scale": scale,
|
|
},
|
|
)
|
|
|
|
renderer = PetRenderer(
|
|
str(pet.spritesheet),
|
|
mode="unicode",
|
|
scale=scale,
|
|
unicode_cols=cols,
|
|
)
|
|
count = renderer.frame_count(state) or 1
|
|
frames = []
|
|
for i in range(count):
|
|
grid = renderer.cells(state, i, cols=cols)
|
|
frames.append(
|
|
[[[*top, *bottom] for (top, bottom) in row] for row in grid]
|
|
)
|
|
|
|
return _ok(
|
|
rid,
|
|
{
|
|
"enabled": True,
|
|
"slug": pet.slug,
|
|
"displayName": pet.display_name,
|
|
"state": state,
|
|
"cols": cols,
|
|
"frameMs": constants.LOOP_MS / max(1, count),
|
|
"frames": frames,
|
|
"scale": scale,
|
|
},
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.debug("pet.cells failed: %s", exc)
|
|
return _ok(rid, {"enabled": False})
|
|
|
|
|
|
@method("pet.gallery")
|
|
@_profile_scoped
|
|
def _(rid, params: dict) -> dict:
|
|
"""List adoptable pets for the desktop appearance picker.
|
|
|
|
Returns the petdex gallery merged with local install state plus the
|
|
current config (active slug + enabled). Agent-independent. Fail-open:
|
|
returns whatever is installed locally if the gallery can't be reached, so
|
|
the picker still works offline.
|
|
|
|
Param ``localOnly`` (bool): skip the remote petdex manifest fetch and return
|
|
only locally-installed pets. The desktop loads this first so the user's own
|
|
pets render instantly instead of waiting on the (possibly slow) manifest.
|
|
"""
|
|
local_only = bool(params.get("localOnly"))
|
|
try:
|
|
from agent.pet import store
|
|
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
|
|
cfg = load_config()
|
|
display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {}
|
|
pet_cfg = display.get("pet", {}) if isinstance(display.get("pet"), dict) else {}
|
|
except Exception:
|
|
pet_cfg = {}
|
|
|
|
installed = {p.slug: p for p in store.installed_pets()}
|
|
|
|
gallery: list[dict] = []
|
|
seen: set[str] = set()
|
|
try:
|
|
from agent.pet.manifest import fetch_manifest, prefetch
|
|
|
|
# Local-only: skip the network entirely, but kick off a background
|
|
# warm so the follow-up full request usually hits a cached manifest.
|
|
if local_only:
|
|
prefetch()
|
|
|
|
for entry in [] if local_only else fetch_manifest():
|
|
seen.add(entry.slug)
|
|
gallery.append(
|
|
{
|
|
"slug": entry.slug,
|
|
"displayName": entry.display_name,
|
|
"installed": entry.slug in installed,
|
|
"spritesheetUrl": entry.spritesheet_url,
|
|
# petdex exposes no popularity metric; "curated" (its
|
|
# hand-picked/official set, identified by the asset path)
|
|
# is the closest signal, so the picker can surface it first.
|
|
"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)
|
|
|
|
# Always include locally-installed pets even if the gallery is unreachable.
|
|
for slug, pet in installed.items():
|
|
if slug not in seen:
|
|
gallery.append(
|
|
{
|
|
"slug": slug,
|
|
"displayName": pet.display_name,
|
|
"installed": True,
|
|
"spritesheetUrl": "",
|
|
"generated": pet.generated,
|
|
}
|
|
)
|
|
|
|
return _ok(
|
|
rid,
|
|
{
|
|
"enabled": is_truthy_value(pet_cfg.get("enabled"), default=False),
|
|
"active": str(pet_cfg.get("slug", "") or ""),
|
|
"pets": gallery,
|
|
},
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.debug("pet.gallery failed: %s", exc)
|
|
return _ok(rid, {"enabled": False, "active": "", "pets": []})
|
|
|
|
|
|
@method("pet.select")
|
|
@_profile_scoped
|
|
def _(rid, params: dict) -> dict:
|
|
"""Adopt a pet from the desktop picker: install (if needed) + activate.
|
|
|
|
Params: ``slug`` (required). Writes ``display.pet.*`` to config and returns
|
|
``{ok, slug, displayName}``. The surface re-pulls ``pet.info`` to render it.
|
|
"""
|
|
slug = str(params.get("slug") or "").strip()
|
|
if not slug:
|
|
return _err(rid, 4004, "missing slug")
|
|
try:
|
|
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})
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.debug("pet.select failed: %s", exc)
|
|
return _err(rid, 5031, f"pet.select failed: {exc}")
|
|
|
|
|
|
@method("pet.remove")
|
|
@_profile_scoped
|
|
def _(rid, params: dict) -> dict:
|
|
"""Uninstall a pet from the desktop picker (delete its on-disk directory).
|
|
|
|
Params: ``slug`` (required). If the removed pet was the active one, the
|
|
display is turned off so nothing tries to render a now-missing sprite.
|
|
Returns ``{ok, slug}`` where ``ok`` reflects whether a directory was deleted.
|
|
"""
|
|
slug = str(params.get("slug") or "").strip()
|
|
if not slug:
|
|
return _err(rid, 4004, "missing slug")
|
|
try:
|
|
from agent.pet import store
|
|
from hermes_cli.pets import _clear_active_if
|
|
|
|
removed = store.remove_pet(slug)
|
|
|
|
# If that was the active pet, stop surfaces pointing at a deleted sprite.
|
|
try:
|
|
_clear_active_if(slug)
|
|
except Exception as exc: # noqa: BLE001 - removal already succeeded
|
|
logger.debug("pet.remove config update failed: %s", exc)
|
|
|
|
return _ok(rid, {"ok": removed, "slug": slug})
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.debug("pet.remove failed: %s", exc)
|
|
return _err(rid, 5031, f"pet.remove failed: {exc}")
|
|
|
|
|
|
@method("pet.export")
|
|
@_profile_scoped
|
|
def _(rid, params: dict) -> dict:
|
|
"""Export an installed pet as a re-importable ``.zip`` (pet.json + sprite).
|
|
|
|
Params: ``slug`` (required). Returns ``{ok, filename, zipBase64}`` — the
|
|
client decodes the base64 and saves it. Heavy-ish (reads + zips files) but
|
|
small; runs inline.
|
|
"""
|
|
slug = str(params.get("slug") or "").strip()
|
|
if not slug:
|
|
return _err(rid, 4004, "missing slug")
|
|
try:
|
|
import base64
|
|
|
|
from agent.pet import store
|
|
|
|
filename, data = store.export_pet(slug)
|
|
return _ok(
|
|
rid,
|
|
{"ok": True, "filename": filename, "zipBase64": base64.standard_b64encode(data).decode("ascii")},
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.debug("pet.export failed: %s", exc)
|
|
return _err(rid, 5031, f"pet.export failed: {exc}")
|
|
|
|
|
|
@method("pet.rename")
|
|
@_profile_scoped
|
|
def _(rid, params: dict) -> dict:
|
|
"""Rename an installed pet's display name + realign its slug/dir.
|
|
|
|
Params: ``slug`` + ``name`` (both required). Lets the generate flow hatch
|
|
with a provisional name and apply the user's chosen name at adopt time.
|
|
Returns ``{ok, slug, displayName}`` with the (possibly new) slug.
|
|
"""
|
|
slug = str(params.get("slug") or "").strip()
|
|
name = str(params.get("name") or "").strip()
|
|
if not slug:
|
|
return _err(rid, 4004, "missing slug")
|
|
if not name:
|
|
return _err(rid, 4004, "missing name")
|
|
try:
|
|
from agent.pet import store
|
|
|
|
new_slug = store.rename_pet(slug, name)
|
|
if not new_slug:
|
|
return _err(rid, 5031, "pet.rename failed")
|
|
|
|
# The dir may have moved; if the renamed pet was active, follow the slug
|
|
# in config so surfaces don't point at the old (now-missing) directory.
|
|
if new_slug != slug:
|
|
try:
|
|
from hermes_cli.pets import _rename_active_if
|
|
|
|
_rename_active_if(slug, new_slug)
|
|
except Exception as exc: # noqa: BLE001 - rename already succeeded
|
|
logger.debug("pet.rename config update failed: %s", exc)
|
|
|
|
return _ok(rid, {"ok": True, "slug": new_slug, "displayName": name})
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.debug("pet.rename failed: %s", exc)
|
|
return _err(rid, 5031, f"pet.rename failed: {exc}")
|
|
|
|
|
|
@method("pet.thumb")
|
|
@_profile_scoped
|
|
def _(rid, params: dict) -> dict:
|
|
"""Return a small idle-frame PNG (data URI) for one pet — the picker preview.
|
|
|
|
Cropped + cached server-side so the renderer gets a same-origin data URL
|
|
instead of a CDN ``<img>`` (which the desktop CSP / R2 hotlink rules break).
|
|
Params: ``slug`` (required), ``url`` (optional petdex spritesheet URL used
|
|
only for not-yet-installed pets). Fail-open: ``{ok: false}`` with no error.
|
|
"""
|
|
slug = str(params.get("slug") or "").strip()
|
|
if not slug:
|
|
return _err(rid, 4004, "missing slug")
|
|
try:
|
|
import base64
|
|
|
|
from agent.pet import store
|
|
|
|
data = store.thumbnail_png(slug, source_url=str(params.get("url") or ""))
|
|
if not data:
|
|
return _ok(rid, {"ok": False, "slug": slug})
|
|
|
|
return _ok(
|
|
rid,
|
|
{
|
|
"ok": True,
|
|
"slug": slug,
|
|
"dataUri": "data:image/png;base64," + base64.standard_b64encode(data).decode("ascii"),
|
|
},
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.debug("pet.thumb failed: %s", exc)
|
|
return _ok(rid, {"ok": False, "slug": slug})
|
|
|
|
|
|
@method("pet.disable")
|
|
@_profile_scoped
|
|
def _(rid, params: dict) -> dict:
|
|
"""Turn the pet off from the desktop picker (``display.pet.enabled=false``)."""
|
|
try:
|
|
from hermes_cli.pets import _set_enabled
|
|
|
|
_set_enabled(False)
|
|
return _ok(rid, {"ok": True})
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.debug("pet.disable failed: %s", exc)
|
|
return _err(rid, 5031, f"pet.disable failed: {exc}")
|
|
|
|
|
|
@method("pet.scale")
|
|
@_profile_scoped
|
|
def _(rid, params: dict) -> dict:
|
|
"""Persist ``display.pet.scale`` from the desktop slider. Params: ``scale``.
|
|
|
|
Clamped to the engine bounds. The renderer updates its own ``$petInfo`` for
|
|
instant feedback; this just makes the change durable + visible to the other
|
|
terminal surfaces on their next read.
|
|
"""
|
|
try:
|
|
from hermes_cli.pets import set_pet_scale
|
|
|
|
scale, err = set_pet_scale(params.get("scale"))
|
|
if err:
|
|
return _err(rid, 4004, err)
|
|
return _ok(rid, {"ok": True, "scale": scale})
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.debug("pet.scale failed: %s", exc)
|
|
return _err(rid, 5031, f"pet.scale failed: {exc}")
|
|
|
|
|
|
@method("pet.cancel")
|
|
def _(rid, params: dict) -> dict:
|
|
"""Signal an in-flight ``pet.generate``/``pet.hatch`` (by token) to stop.
|
|
|
|
Best-effort + idempotent: cancelling an unknown/finished token is a no-op.
|
|
Stays off the worker pool so it lands while a heavy generation is occupying
|
|
it. Returns ``{ok: True}``.
|
|
"""
|
|
token = str(params.get("token") or "").strip()
|
|
if token:
|
|
_pet_cancel_request(token)
|
|
return _ok(rid, {"ok": True})
|
|
|
|
|
|
@method("pet.generate.status")
|
|
def _(rid, params: dict) -> dict:
|
|
"""Whether pet generation is possible right now.
|
|
|
|
True only when a reference-capable image backend (Nous Portal / OpenRouter /
|
|
OpenAI gpt-image) is configured — the desktop checks this on open so it can
|
|
offer setup instead of a dead prompt. Cheap (config + plugin discovery).
|
|
"""
|
|
try:
|
|
from agent.pet.generate.imagegen import (
|
|
GenerationError,
|
|
list_sprite_providers,
|
|
resolve_provider,
|
|
)
|
|
|
|
try:
|
|
resolve_provider(require_references=True)
|
|
available = 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)
|
|
providers = []
|
|
return _ok(rid, {"available": available, "providers": providers})
|
|
except Exception as exc: # noqa: BLE001 - never break the surface
|
|
logger.debug("pet.generate.status failed: %s", exc)
|
|
return _ok(rid, {"available": False, "providers": []})
|
|
|
|
|
|
@method("pet.generate")
|
|
def _(rid, params: dict) -> dict:
|
|
"""Generate candidate base looks for a new pet (the draft/variant step).
|
|
|
|
Params: ``prompt`` (required unless ``referenceImage`` is given), ``count``
|
|
(default 4), ``style`` (default ``auto``), ``referenceImage`` (optional data
|
|
URL — a user photo/reference every draft is grounded on, e.g. to make *their*
|
|
pet). Returns ``{ok, token, drafts:[{index, dataUri}]}`` — the token keys the
|
|
staged base images for a later ``pet.hatch``. Heavy (network): worker pool.
|
|
"""
|
|
prompt = str(params.get("prompt") or "").strip()
|
|
ref_raw = str(params.get("referenceImage") or "").strip()
|
|
if not prompt and not ref_raw:
|
|
return _err(rid, 4004, "missing prompt")
|
|
try:
|
|
count = max(1, min(4, int(params.get("count") or 4)))
|
|
except (TypeError, ValueError):
|
|
count = 4
|
|
style = str(params.get("style") or "auto").strip() or "auto"
|
|
|
|
try:
|
|
import shutil
|
|
import uuid
|
|
|
|
from agent.pet.generate import generate_base_drafts
|
|
from agent.pet.generate.imagegen import GenerationError, resolve_provider
|
|
|
|
root = _pet_gen_root()
|
|
_pet_gen_sweep(root)
|
|
|
|
# Token up front so each draft can be staged + streamed the moment it
|
|
# lands, instead of the user staring at a blank grid until all N finish.
|
|
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:
|
|
_pet_cancel_release(token)
|
|
return _err(rid, 4004, str(exc))
|
|
|
|
# Optional desktop picker override: resolve the chosen provider up front so
|
|
# a bad/uncredentialed pick fails fast instead of mid-fan-out.
|
|
provider_name = str(params.get("provider") or "").strip()
|
|
sprite = None
|
|
if provider_name:
|
|
try:
|
|
sprite = resolve_provider(require_references=bool(reference_images), prefer=provider_name)
|
|
except GenerationError as exc:
|
|
_pet_cancel_release(token)
|
|
return _err(rid, 5031, str(exc))
|
|
|
|
concept = prompt or "a pet based on the reference image"
|
|
out: list[dict] = []
|
|
|
|
# Hand the token to the client up front (token-only init event) so a Stop
|
|
# fired before the first draft lands can still target this run.
|
|
try:
|
|
_emit("pet.generate.progress", "", {"token": token, "count": count})
|
|
except Exception as exc: # noqa: BLE001 - streaming is best-effort
|
|
logger.debug("pet.generate init emit failed: %s", exc)
|
|
|
|
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})
|
|
# Stream this draft to the client so the grid fills in live. Best-
|
|
# effort: a transport hiccup must not abort the generation itself.
|
|
try:
|
|
_emit(
|
|
"pet.generate.progress",
|
|
"",
|
|
{"token": token, "index": index, "dataUri": data_uri, "count": count},
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.debug("pet.generate progress emit failed: %s", exc)
|
|
|
|
try:
|
|
generate_base_drafts(
|
|
concept,
|
|
n=count,
|
|
style=style,
|
|
reference_images=reference_images,
|
|
provider=sprite,
|
|
on_draft=_on_draft,
|
|
is_cancelled=lambda: _pet_is_cancelled(token),
|
|
)
|
|
except GenerationError as exc:
|
|
_pet_cancel_release(token)
|
|
return _err(rid, 5031, str(exc))
|
|
|
|
cancelled = _pet_is_cancelled(token)
|
|
_pet_cancel_release(token)
|
|
if cancelled:
|
|
return _err(rid, 5031, "generation cancelled")
|
|
if not out:
|
|
return _err(rid, 5031, "generation produced no usable drafts")
|
|
out.sort(key=lambda d: d["index"])
|
|
return _ok(rid, {"ok": True, "token": token, "drafts": out})
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.debug("pet.generate failed: %s", exc)
|
|
return _err(rid, 5031, f"pet.generate failed: {exc}")
|
|
|
|
|
|
@method("pet.hatch")
|
|
def _(rid, params: dict) -> dict:
|
|
"""Turn a chosen base draft into a full pet — installed but NOT yet active.
|
|
|
|
Generation is expensive and the result varies, so hatch produces a *preview*
|
|
the surface plays (all frames) before the user commits: the pet is written to
|
|
the store (so it can be rendered + later activated) but the active pet is left
|
|
untouched. Adopt with ``pet.select`` or throw it away with ``pet.remove``.
|
|
|
|
Params: ``token`` + ``index`` (from ``pet.generate``), ``name`` (required),
|
|
``description`` (optional), ``prompt`` (optional concept for row prompts),
|
|
``style`` (optional). Returns ``{ok, slug, displayName, warnings, pet}`` where
|
|
``pet`` is the renderer payload. Heavy (network + raster): worker pool.
|
|
"""
|
|
token = str(params.get("token") or "").strip()
|
|
# Hatch cancellation rides its own key, not the generation token: hatching a
|
|
# draft mid-generation means pet.generate is still releasing `token`, which
|
|
# would otherwise wipe the arm we set here. Falls back to `token` for clients
|
|
# that don't send one.
|
|
cancel_token = str(params.get("cancelToken") or "").strip() or token
|
|
index = params.get("index", 0)
|
|
name = str(params.get("name") or "").strip()
|
|
if not token:
|
|
return _err(rid, 4004, "missing token")
|
|
if not name:
|
|
return _err(rid, 4004, "missing name")
|
|
try:
|
|
index = int(index)
|
|
except (TypeError, ValueError):
|
|
index = 0
|
|
|
|
try:
|
|
from agent.pet import store
|
|
from agent.pet.generate import hatch_pet
|
|
from agent.pet.generate.imagegen import GenerationError, resolve_provider
|
|
|
|
base = _pet_gen_root() / token / f"draft-{index}.png"
|
|
if not base.is_file():
|
|
return _err(rid, 4004, "draft expired — generate again")
|
|
|
|
# Optional desktop picker override (rows always need reference grounding).
|
|
provider_name = str(params.get("provider") or "").strip()
|
|
sprite = None
|
|
if provider_name:
|
|
try:
|
|
sprite = resolve_provider(require_references=True, prefer=provider_name)
|
|
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 is encoded as "<state>:<done>:<total>" so the egg
|
|
# screen can show "Drawing <state>… (n/total)"; other phases
|
|
# (compose, save) pass through as-is. Best-effort streaming.
|
|
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}
|
|
try:
|
|
_emit("pet.hatch.progress", "", payload)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.debug("pet.hatch progress emit failed: %s", exc)
|
|
|
|
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(params.get("style") or "auto").strip() or "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)
|
|
payload = _pet_sprite_payload(pet, scale=_pet_config_scale()) if pet else {}
|
|
return _ok(
|
|
rid,
|
|
{
|
|
"ok": True,
|
|
"slug": result.slug,
|
|
"displayName": result.display_name,
|
|
"warnings": result.validation.get("warnings", []),
|
|
"pet": payload,
|
|
},
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.debug("pet.hatch failed: %s", exc)
|
|
return _err(rid, 5031, f"pet.hatch failed: {exc}")
|
|
|
|
|
|
@method("billing.state")
|
|
def _(rid, params: dict) -> dict:
|
|
"""GET /api/billing/state → serialized BillingState (Screen 1 + 5).
|
|
|
|
Fail-open like the other billing RPCs: a logged-out / unreachable portal yields
|
|
{ok:true, logged_in:false}. No scope required for this endpoint.
|
|
"""
|
|
try:
|
|
from agent.billing_view import build_billing_state
|
|
|
|
state = build_billing_state()
|
|
return _ok(rid, _serialize_billing_state(state))
|
|
except Exception:
|
|
return _ok(rid, {"ok": True, "logged_in": False, "error": "could not load billing state"})
|
|
|
|
|
|
@method("usage.bars")
|
|
def _(rid, params: dict) -> dict:
|
|
"""Shared dollar usage model (two-bar view) for /usage + /subscription.
|
|
|
|
Fail-open: logged-out / unreachable portal → {ok:true, available:false}.
|
|
No scope required (read-only).
|
|
"""
|
|
try:
|
|
from agent.billing_usage import build_usage_model
|
|
|
|
return _ok(rid, _serialize_usage_model(build_usage_model()))
|
|
except Exception:
|
|
return _ok(rid, {"ok": True, "available": False})
|
|
|
|
|
|
@method("subscription.state")
|
|
def _(rid, params: dict) -> dict:
|
|
"""GET /api/billing/subscription → serialized SubscriptionState.
|
|
|
|
Fail-open like billing.state: logged-out / unreachable portal →
|
|
{ok:true, logged_in:false}. No scope required (read-only).
|
|
"""
|
|
try:
|
|
from agent.subscription_view import build_subscription_state
|
|
|
|
state = build_subscription_state()
|
|
return _ok(rid, _serialize_subscription_state(state))
|
|
except Exception:
|
|
return _ok(rid, {"ok": True, "logged_in": False, "error": "could not load subscription state"})
|
|
|
|
|
|
@method("subscription.preview")
|
|
def _(rid, params: dict) -> dict:
|
|
"""POST /api/billing/subscription/preview → serialized quote or typed error.
|
|
|
|
params: {subscription_type_id: str}. Chargeless effect quote. Requires
|
|
billing:manage (live Stripe calls + amounts), so a 403 → insufficient_scope
|
|
drives the device step-up exactly like the mutations.
|
|
"""
|
|
from agent.subscription_view import subscription_change_preview_from_payload
|
|
from hermes_cli.nous_billing import BillingError, post_subscription_preview
|
|
|
|
tier_id = params.get("subscription_type_id")
|
|
if not tier_id:
|
|
return _ok(rid, {"ok": False, "error": "invalid_request", "message": "subscription_type_id is required"})
|
|
try:
|
|
preview = subscription_change_preview_from_payload(
|
|
post_subscription_preview(subscription_type_id=tier_id)
|
|
)
|
|
return _ok(rid, _serialize_subscription_preview(preview))
|
|
except BillingError as exc:
|
|
return _ok(rid, _serialize_billing_error(exc))
|
|
except Exception as exc:
|
|
return _ok(rid, {"ok": False, "error": "error", "message": str(exc)})
|
|
|
|
|
|
@method("subscription.change")
|
|
def _(rid, params: dict) -> dict:
|
|
"""PUT /api/billing/subscription/pending-change → {ok, message} or typed error.
|
|
|
|
params: {subscription_type_id?: str, cancel?: bool}. Schedules a downgrade /
|
|
same-price change OR a cancellation at period end (chargeless). Requires
|
|
billing:manage.
|
|
"""
|
|
from hermes_cli.nous_billing import BillingError, put_subscription_pending_change
|
|
|
|
cancel = bool(params.get("cancel"))
|
|
tier_id = params.get("subscription_type_id")
|
|
if not cancel and not tier_id:
|
|
return _ok(rid, {"ok": False, "error": "invalid_request", "message": "subscription_type_id or cancel is required"})
|
|
try:
|
|
result = put_subscription_pending_change(subscription_type_id=tier_id, cancel=cancel)
|
|
return _ok(rid, {"ok": True, "message": result.get("message"), "payload": result})
|
|
except BillingError as exc:
|
|
return _ok(rid, _serialize_billing_error(exc))
|
|
except Exception as exc:
|
|
return _ok(rid, {"ok": False, "error": "error", "message": str(exc)})
|
|
|
|
|
|
@method("subscription.resume")
|
|
def _(rid, params: dict) -> dict:
|
|
"""DELETE /api/billing/subscription/pending-change → {ok, message} or typed error.
|
|
|
|
Clears a scheduled downgrade or cancellation (resume / undo). Chargeless, but it
|
|
re-enables recurring spend → requires billing:manage and honors the kill-switch.
|
|
"""
|
|
from hermes_cli.nous_billing import BillingError, delete_subscription_pending_change
|
|
|
|
try:
|
|
result = delete_subscription_pending_change()
|
|
return _ok(rid, {"ok": True, "message": result.get("message"), "payload": result})
|
|
except BillingError as exc:
|
|
return _ok(rid, _serialize_billing_error(exc))
|
|
except Exception as exc:
|
|
return _ok(rid, {"ok": False, "error": "error", "message": str(exc)})
|
|
|
|
|
|
@method("subscription.upgrade")
|
|
def _(rid, params: dict) -> dict:
|
|
"""POST /api/billing/subscription/upgrade → {ok, status, ...} or typed error.
|
|
|
|
params: {subscription_type_id: str, idempotency_key?: str}. The single money
|
|
route: prorate + charge the card on the subscription + flip the plan. SCA /
|
|
decline come back as status requires_action / payment_failed with a recovery_url
|
|
to finish in the portal. The idempotency key is minted if absent and echoed so
|
|
the TUI reuses it on retry of the SAME upgrade. Requires billing:manage.
|
|
"""
|
|
from agent.billing_view import new_idempotency_key
|
|
from hermes_cli.nous_billing import BillingError, post_subscription_upgrade
|
|
|
|
tier_id = params.get("subscription_type_id")
|
|
if not tier_id:
|
|
return _ok(rid, {"ok": False, "error": "invalid_request", "message": "subscription_type_id is required"})
|
|
key = params.get("idempotency_key") or new_idempotency_key()
|
|
try:
|
|
result = post_subscription_upgrade(subscription_type_id=tier_id, idempotency_key=key)
|
|
return _ok(
|
|
rid,
|
|
{
|
|
"ok": True,
|
|
"status": result.get("status"),
|
|
"target_tier_name": result.get("targetTierName"),
|
|
"recovery_url": result.get("recoveryUrl"),
|
|
"reason": result.get("reason"),
|
|
"idempotency_key": key,
|
|
},
|
|
)
|
|
except BillingError as exc:
|
|
env = _serialize_billing_error(exc)
|
|
env["idempotency_key"] = key # so the TUI can reuse on retry
|
|
return _ok(rid, env)
|
|
except Exception as exc:
|
|
return _ok(rid, {"ok": False, "error": "error", "message": str(exc), "idempotency_key": key})
|
|
|
|
|
|
@method("billing.charge")
|
|
def _(rid, params: dict) -> dict:
|
|
"""POST /api/billing/charge → {ok, chargeId} or a typed error envelope.
|
|
|
|
params: {amount_usd: str|number, idempotency_key?: str}. If no key is
|
|
supplied, the server-side core mints a fresh one and returns it so the TUI can
|
|
reuse it on retry of the SAME purchase.
|
|
"""
|
|
from hermes_cli.nous_billing import BillingError, post_charge
|
|
from agent.billing_view import new_idempotency_key
|
|
|
|
amount = params.get("amount_usd")
|
|
if amount is None:
|
|
return _ok(rid, {"ok": False, "error": "invalid_request", "message": "amount_usd is required"})
|
|
key = params.get("idempotency_key") or new_idempotency_key()
|
|
try:
|
|
result = post_charge(amount_usd=amount, idempotency_key=key)
|
|
return _ok(rid, {"ok": True, "charge_id": result.get("chargeId"), "idempotency_key": key})
|
|
except BillingError as exc:
|
|
env = _serialize_billing_error(exc)
|
|
env["idempotency_key"] = key # so the TUI can reuse on retry
|
|
return _ok(rid, env)
|
|
except Exception as exc:
|
|
return _ok(rid, {"ok": False, "error": "error", "message": str(exc), "idempotency_key": key})
|
|
|
|
|
|
@method("billing.charge_status")
|
|
def _(rid, params: dict) -> dict:
|
|
"""GET /api/billing/charge/{id} → {ok, status, ...} or typed error.
|
|
|
|
The poll. Caller drives the 2s/5-min cadence; this is a single status read.
|
|
"""
|
|
from hermes_cli.nous_billing import BillingError, get_charge_status
|
|
|
|
charge_id = params.get("charge_id")
|
|
if not charge_id:
|
|
return _ok(rid, {"ok": False, "error": "invalid_charge_id", "message": "charge_id is required"})
|
|
try:
|
|
result = get_charge_status(charge_id)
|
|
return _ok(
|
|
rid,
|
|
{
|
|
"ok": True,
|
|
"status": result.get("status"),
|
|
"amount_usd": result.get("amountUsd"),
|
|
"settled_at": result.get("settledAt"),
|
|
"reason": result.get("reason"),
|
|
},
|
|
)
|
|
except BillingError as exc:
|
|
return _ok(rid, _serialize_billing_error(exc))
|
|
except Exception as exc:
|
|
return _ok(rid, {"ok": False, "error": "error", "message": str(exc)})
|
|
|
|
|
|
@method("billing.auto_reload")
|
|
def _(rid, params: dict) -> dict:
|
|
"""PATCH /api/billing/auto-top-up → {ok:true} or typed error (Screen 2).
|
|
|
|
params: {enabled: bool, threshold: number, top_up_amount: number}.
|
|
"""
|
|
from hermes_cli.nous_billing import BillingError, patch_auto_top_up
|
|
|
|
try:
|
|
enabled = bool(params.get("enabled"))
|
|
threshold = params.get("threshold")
|
|
top_up_amount = params.get("top_up_amount")
|
|
if threshold is None or top_up_amount is None:
|
|
return _ok(rid, {"ok": False, "error": "invalid_request", "message": "threshold and top_up_amount are required"})
|
|
patch_auto_top_up(enabled=enabled, threshold=threshold, top_up_amount=top_up_amount)
|
|
return _ok(rid, {"ok": True})
|
|
except BillingError as exc:
|
|
return _ok(rid, _serialize_billing_error(exc))
|
|
except Exception as exc:
|
|
return _ok(rid, {"ok": False, "error": "error", "message": str(exc)})
|
|
|
|
|
|
@method("billing.step_up")
|
|
def _(rid, params: dict) -> dict:
|
|
"""Run the lazy billing:manage step-up device flow → {ok, granted}.
|
|
|
|
Triggered by the TUI after a billing call returns error=insufficient_scope.
|
|
Returns granted:false when the server silently downscopes (non-admin / unticked).
|
|
|
|
Runs on the thread pool (in _LONG_HANDLERS): the device flow blocks for the
|
|
whole device-code lifetime (minutes), so it must not stall the main stdin loop.
|
|
The verification URL/code reach the TUI via an out-of-band ``billing.step_up.
|
|
verification`` event (a plain print would be dropped by the JSON-RPC stdout
|
|
pipe), and the browser is opened TUI-side via openExternalUrl — never with the
|
|
gateway's headless webbrowser.open (hence open_browser=False).
|
|
"""
|
|
sid = params.get("session_id") or ""
|
|
try:
|
|
from hermes_cli.auth import step_up_nous_billing_scope
|
|
from hermes_cli.nous_billing import BillingError
|
|
|
|
def _on_verification(url: str, code: str) -> None:
|
|
_emit(
|
|
"billing.step_up.verification",
|
|
sid,
|
|
{"verification_url": url, "user_code": code},
|
|
)
|
|
|
|
granted = step_up_nous_billing_scope(
|
|
open_browser=False, on_verification=_on_verification
|
|
)
|
|
return _ok(rid, {"ok": True, "granted": bool(granted)})
|
|
except BillingError as exc:
|
|
# Route typed billing errors (e.g. session_revoked when the token expires
|
|
# mid-device-flow) through the shared spine like the other write handlers,
|
|
# so the TUI maps them to the right copy instead of a generic failure.
|
|
env = _serialize_billing_error(exc)
|
|
env["granted"] = False
|
|
return _ok(rid, env)
|
|
except Exception as exc:
|
|
return _ok(rid, {"ok": False, "error": "error", "message": str(exc), "granted": False})
|
|
|
|
|
|
@method("session.status")
|
|
def _(rid, params: dict) -> dict:
|
|
session, err = _sess_nowait(params, rid)
|
|
if err:
|
|
return err
|
|
|
|
from hermes_constants import display_hermes_home
|
|
|
|
key = session.get("session_key") or params.get("session_id") or ""
|
|
agent = session.get("agent")
|
|
meta = {}
|
|
# Prefer the live session's bound profile db, else params.profile, else launch.
|
|
status_params = dict(params or {})
|
|
if not status_params.get("profile") and session.get("profile_home"):
|
|
# profile_home is a path; still allow _session_db via a synthetic session
|
|
pass
|
|
with _session_db(session) as db:
|
|
if db is None:
|
|
# Fall back to ~params.profile naming for not-yet-mapped sessions.
|
|
with _profile_db(params) as db2:
|
|
db = db2
|
|
if db and key:
|
|
try:
|
|
meta = db.get_session(key) or {}
|
|
except Exception:
|
|
meta = {}
|
|
db = None # prevent double-use
|
|
if db is not None and key:
|
|
try:
|
|
meta = db.get_session(key) or {}
|
|
except Exception:
|
|
meta = {}
|
|
|
|
def _dt(value, fallback: datetime | None = None) -> datetime:
|
|
if value:
|
|
try:
|
|
return datetime.fromtimestamp(float(value))
|
|
except Exception:
|
|
pass
|
|
return fallback or datetime.now()
|
|
|
|
created = _dt(meta.get("started_at"))
|
|
updated = created
|
|
for field in ("updated_at", "last_updated_at", "last_activity_at"):
|
|
if meta.get(field):
|
|
updated = _dt(meta.get(field), created)
|
|
break
|
|
|
|
mirror = _metadata_mirror(session)
|
|
usage = _session_usage_snapshot(session)
|
|
provider = getattr(agent, "provider", None) or mirror.get("provider") or "unknown"
|
|
model = getattr(agent, "model", None) or mirror.get("model") or "(unknown)"
|
|
project = _project_info_for_cwd(_display_session_cwd(session))
|
|
lines = [
|
|
"Hermes TUI Status",
|
|
"",
|
|
f"Session ID: {key}",
|
|
f"Path: {display_hermes_home()}",
|
|
]
|
|
if project:
|
|
lines.append(f"Project: {project['name']}")
|
|
title = (meta.get("title") or "").strip()
|
|
if title:
|
|
lines.append(f"Title: {title}")
|
|
lines.extend(
|
|
[
|
|
f"Model: {model} ({provider})",
|
|
f"Created: {created.strftime('%Y-%m-%d %H:%M')}",
|
|
f"Last Activity: {updated.strftime('%Y-%m-%d %H:%M')}",
|
|
f"Tokens: {int(usage.get('total') or 0):,}",
|
|
f"Agent Running: {'Yes' if session.get('running') else 'No'}",
|
|
]
|
|
)
|
|
return _ok(rid, {"output": "\n".join(lines)})
|
|
|
|
|
|
@method("session.history")
|
|
def _(rid, params: dict) -> dict:
|
|
session, err = _sess_nowait(params, rid)
|
|
if err:
|
|
return err
|
|
history = list(session.get("history", []))
|
|
if session.get("session_key"):
|
|
with _session_db(session) as db:
|
|
if db is not None:
|
|
try:
|
|
# include_row_ids: the durable row id is how clients address
|
|
# a specific persisted turn (reactions, and the Desktop's
|
|
# content-based truncation-target resolution — #87059). 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.
|
|
history = db.get_messages_as_conversation(
|
|
session["session_key"],
|
|
include_ancestors=True,
|
|
include_row_ids=True,
|
|
)
|
|
except Exception:
|
|
pass
|
|
return _ok(
|
|
rid,
|
|
{
|
|
"count": len(history),
|
|
"messages": _history_to_messages(history),
|
|
},
|
|
)
|
|
|
|
|
|
@method("session.undo")
|
|
def _(rid, params: dict) -> dict:
|
|
session, err = _sess(params, rid)
|
|
if err:
|
|
return err
|
|
# Reject during an in-flight turn. If we mutated history while
|
|
# the agent thread is running, prompt.submit's post-run history
|
|
# write would either clobber the undo (version matches) or
|
|
# silently drop the agent's output (version mismatch, see below).
|
|
# Neither is what the user wants — make them /interrupt first.
|
|
if session.get("running"):
|
|
return _err(
|
|
rid, 4009, "session busy — /interrupt the current turn before /undo"
|
|
)
|
|
removed = 0
|
|
with session["history_lock"]:
|
|
if session.get("running"):
|
|
return _err(
|
|
rid, 4009, "session busy — /interrupt the current turn before /undo"
|
|
)
|
|
history = _history_without_ephemeral_scaffolding(
|
|
session.get("history", [])
|
|
)
|
|
# Truncate from the last *real* user turn. Popping only trailing
|
|
# assistant/tool then one user left timeline markers
|
|
# (async_delegation_complete, model_switch, …) or compaction
|
|
# handoffs as the undo target — so session.undo removed
|
|
# bookkeeping instead of the last exchange (#80622).
|
|
# Match user_originated_turn_view / CLI turn counting.
|
|
from agent.context_compressor import user_originated_turn_view
|
|
|
|
user_indices = [
|
|
index
|
|
for index, message in enumerate(history)
|
|
if user_originated_turn_view(message) is not None
|
|
]
|
|
if user_indices:
|
|
try:
|
|
_installed, _live_view, rewound_count = (
|
|
_rewind_active_session_history(session, len(user_indices) - 1)
|
|
)
|
|
removed = rewound_count
|
|
except Exception as exc:
|
|
return _err(rid, 5008, f"undo: {exc}")
|
|
return _ok(rid, {"removed": removed})
|
|
|
|
|
|
@method("session.compress")
|
|
def _(rid, params: dict) -> dict:
|
|
session, err = _sess_nowait(params, rid)
|
|
if err:
|
|
return err
|
|
assert session is not None
|
|
if _session_uses_compute_host(session):
|
|
sid = str(params.get("session_id") or "")
|
|
focus_topic = str(params.get("focus_topic", "") or "").strip()
|
|
command = "/compress" + (f" {focus_topic}" if focus_topic else "")
|
|
_late_session = session
|
|
|
|
def _on_late_ack(late: dict, _sid=sid) -> None:
|
|
_adopt_late_compute_host_compress_ack(_sid, _late_session, late, route_name="session.compress")
|
|
|
|
try:
|
|
ack = _send_compute_host_control(
|
|
sid,
|
|
route_name="session.compress",
|
|
command=command,
|
|
wait=True,
|
|
# Follows compression.context_total_ceiling_seconds instead of
|
|
# a fixed 120s: the host legitimately runs that long (#97948).
|
|
timeout=_compute_host_compress_wait_seconds(),
|
|
on_late_ack=_on_late_ack,
|
|
)
|
|
except queue.Empty:
|
|
# The waiter gave up but the host is still compressing; the late
|
|
# ack handler adopts the rotated session and pushes session.info
|
|
# when it lands. Not an error — the old 5019 made Desktop/TUI
|
|
# report a timeout while compression later succeeded silently.
|
|
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 ack.get("type") in {"control.error", "error"}:
|
|
return _err(rid, 4009, str(ack.get("message") or "compute-host compress failed"))
|
|
_apply_compute_host_metadata_mirror(session, ack)
|
|
host_result = ack.get("result")
|
|
if isinstance(host_result, dict):
|
|
# The host owns the isolated session's agent/history, so preserve
|
|
# its structured compression result verbatim. In particular this
|
|
# carries `status: aborted` and `summary.aborted`; flattening the
|
|
# old text-only acknowledgement made Desktop show aborted work as a
|
|
# success toast.
|
|
return _ok(rid, {**host_result, "turn_isolation": True})
|
|
host_info = ack.get("session_info") if isinstance(ack.get("session_info"), dict) else {}
|
|
host_messages = _history_to_messages(ack.get("messages")) if isinstance(ack.get("messages"), list) else []
|
|
# `messages` is returned at top level for the desktop transcript
|
|
# replacement. Keep the host acknowledgement metadata, but do not send
|
|
# the same (potentially large) transcript a second time inside it.
|
|
host_ack = {key: value for key, value in ack.items() if key != "messages"}
|
|
return _ok(
|
|
rid,
|
|
{
|
|
"status": "compressed",
|
|
"turn_isolation": True,
|
|
"host_ack": host_ack,
|
|
"info": host_info,
|
|
"messages": host_messages,
|
|
"usage": host_info.get("usage") if isinstance(host_info.get("usage"), dict) else {},
|
|
},
|
|
)
|
|
session, err = _sess(params, rid)
|
|
if err:
|
|
return err
|
|
if session.get("running"):
|
|
return _err(
|
|
rid, 4009, "session busy — /interrupt the current turn before /compress"
|
|
)
|
|
from agent.conversation_compression import (
|
|
finalize_context_engine_compression_notification,
|
|
)
|
|
|
|
sid = params.get("session_id", "")
|
|
focus_topic = str(params.get("focus_topic", "") or "").strip()
|
|
try:
|
|
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
|
|
before_tokens = (
|
|
estimate_request_tokens_rough(
|
|
before_messages, system_prompt=_sys_prompt, tools=_tools
|
|
)
|
|
if before_count
|
|
else 0
|
|
)
|
|
|
|
if before_count >= 4:
|
|
focus_suffix = f', focus: "{focus_topic}"' if focus_topic else ""
|
|
_status_update(
|
|
sid,
|
|
"compressing",
|
|
f"⠋ compressing {before_count} messages "
|
|
f"(~{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_count = len(messages)
|
|
# Re-read system prompt + tools after compression — _compress_context
|
|
# may have rebuilt the system prompt (_cached_system_prompt=None).
|
|
_sys_prompt_after = (
|
|
getattr(_agent, "_cached_system_prompt", "") or _sys_prompt
|
|
)
|
|
_tools_after = getattr(_agent, "tools", None) or _tools
|
|
after_tokens = (
|
|
estimate_request_tokens_rough(
|
|
messages,
|
|
system_prompt=_sys_prompt_after,
|
|
tools=_tools_after,
|
|
)
|
|
if after_count
|
|
else 0
|
|
)
|
|
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": after_count,
|
|
"before_tokens": before_tokens,
|
|
"after_tokens": after_tokens,
|
|
"summary": summary,
|
|
"usage": usage,
|
|
"info": info,
|
|
# Keep this identical to session.resume / session.history:
|
|
# raw tool results can contain large or sensitive payloads
|
|
# that belong in persisted history, not the transcript
|
|
# replacement response.
|
|
"messages": _history_to_messages(messages),
|
|
},
|
|
)
|
|
finally:
|
|
# Always clear the pinned compressing status so the bar
|
|
# reverts to neutral whether compaction succeeded, was a
|
|
# no-op, or raised.
|
|
_status_update(sid, "ready")
|
|
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:
|
|
finalize_context_engine_compression_notification(
|
|
session["agent"],
|
|
committed=False,
|
|
)
|
|
return _err(rid, 5005, str(e))
|
|
|
|
|
|
@method("session.save")
|
|
def _(rid, params: dict) -> dict:
|
|
session, err = _sess(params, rid)
|
|
if err:
|
|
return err
|
|
|
|
if _session_uses_compute_host(session):
|
|
sid = str(params.get("session_id") or "")
|
|
try:
|
|
ack = _send_compute_host_control(
|
|
sid,
|
|
route_name="session.save",
|
|
wait=True,
|
|
)
|
|
except Exception as exc:
|
|
return _err(rid, 5011, f"compute-host session save failed: {exc}")
|
|
if ack.get("type") in {"control.error", "error"}:
|
|
return _err(rid, 5011, str(ack.get("message") or "compute-host session save failed"))
|
|
result = ack.get("result")
|
|
if not isinstance(result, dict):
|
|
return _err(rid, 5011, "compute-host session save returned an invalid response")
|
|
return _ok(rid, result)
|
|
|
|
agent = session["agent"]
|
|
# Mirror the classic CLI /save: snapshot under the Hermes profile home
|
|
# (~/.hermes/sessions/saved/) rather than the project/workspace CWD, and
|
|
# include the system prompt so the export matches the dashboard save.
|
|
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}")
|
|
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
path = saved_dir / f"hermes_conversation_{timestamp}.json"
|
|
|
|
with session["history_lock"]:
|
|
messages = list(session.get("history", []))
|
|
|
|
session_id = getattr(agent, "session_id", None) or session.get("session_key") or ""
|
|
# Prefer the agent's session_start datetime (matches the classic CLI export);
|
|
# fall back to the gateway session's created_at timestamp.
|
|
agent_start = getattr(agent, "session_start", None)
|
|
if isinstance(agent_start, datetime):
|
|
session_start = agent_start.isoformat()
|
|
else:
|
|
created_at = session.get("created_at")
|
|
session_start = (
|
|
datetime.fromtimestamp(created_at).isoformat()
|
|
if isinstance(created_at, (int, float))
|
|
else ""
|
|
)
|
|
|
|
try:
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
json.dump(
|
|
{
|
|
"model": getattr(agent, "model", ""),
|
|
"session_id": session_id,
|
|
"session_start": session_start,
|
|
"system_prompt": getattr(agent, "_cached_system_prompt", "") or "",
|
|
"messages": messages,
|
|
},
|
|
f,
|
|
indent=2,
|
|
ensure_ascii=False,
|
|
)
|
|
return _ok(rid, {"file": str(path)})
|
|
except Exception as e:
|
|
return _err(rid, 5011, str(e))
|
|
|
|
|
|
@method("session.close")
|
|
def _(rid, params: dict) -> dict:
|
|
sid = params.get("session_id", "")
|
|
# Serialize only the ownership claim against session.resume / the orphan
|
|
# reaper. Finalization may run arbitrary plugin/agent cleanup and must not
|
|
# keep every unrelated session.resume waiting behind it.
|
|
with _session_resume_lock:
|
|
session = _pop_session_by_id(sid)
|
|
closed = _teardown_popped_session(session, end_reason="tui_close")
|
|
return _ok(rid, {"closed": closed})
|
|
|
|
|
|
@method("session.branch")
|
|
def _(rid, params: dict) -> dict:
|
|
session, err = _sess(params, rid)
|
|
if err:
|
|
return err
|
|
# Branch must write into the parent's profile-scoped state.db (app-global
|
|
# remote mode). Using the launch handle would orphan branch rows + history.
|
|
with _session_db(session) as db:
|
|
if db is None:
|
|
return _db_unavailable_error(rid, code=5008)
|
|
old_key = session["session_key"]
|
|
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)
|
|
]
|
|
|
|
def _visible_branch_history(messages):
|
|
visible = []
|
|
for message in messages or []:
|
|
if not isinstance(message, dict) or message.get("role") not in {"user", "assistant"}:
|
|
continue
|
|
if not _coerce_message_text(message.get("content")).strip():
|
|
continue
|
|
# Keep the FULL row — the copy loop below preserves reasoning
|
|
# fields and timeline-marker tags (display_kind/display_metadata,
|
|
# #82756); a minimal role/content copy would silently drop them.
|
|
visible.append(dict(message))
|
|
return visible
|
|
|
|
# The live session history is the model projection. After compaction it
|
|
# may contain only a summary and the protected tail, while the persisted
|
|
# display projection still contains the complete visible transcript. A
|
|
# branch must snapshot the latter; otherwise the child permanently loses
|
|
# every turn archived before the fork.
|
|
history = None
|
|
get_resume_conversations = getattr(db, "get_resume_conversations", None)
|
|
if callable(get_resume_conversations):
|
|
try:
|
|
_, display_history = get_resume_conversations(old_key)
|
|
display_history = _reconcile_display_with_live(display_history, in_memory_history)
|
|
history = _visible_branch_history(display_history)
|
|
except Exception:
|
|
logger.debug("branch display projection read failed", exc_info=True)
|
|
if not history:
|
|
history = _visible_branch_history(in_memory_history)
|
|
if not history:
|
|
return _err(rid, 4008, "nothing to branch — send a message first")
|
|
count = params.get("count")
|
|
if isinstance(count, int) and count > 0:
|
|
history = history[:count]
|
|
new_key = _new_session_key()
|
|
new_sid = uuid.uuid4().hex[:8]
|
|
source = _session_source(session)
|
|
lease = None # claimed lazily on the first turn (_ensure_active_session_slot)
|
|
branch_name = params.get("name", "")
|
|
try:
|
|
if branch_name:
|
|
title = branch_name
|
|
else:
|
|
current = db.get_session_title(old_key) or "branch"
|
|
title = (
|
|
db.get_next_title_in_lineage(current)
|
|
if hasattr(db, "get_next_title_in_lineage")
|
|
else f"{current} (branch)"
|
|
)
|
|
db.create_session(
|
|
new_key,
|
|
source=source,
|
|
model=_resolve_model(),
|
|
# Stable _branched_from marker so list_sessions_rich() keeps the
|
|
# branch visible in /resume and /sessions. The TUI branch leaves
|
|
# the parent live (no end_reason='branched'), so the legacy
|
|
# end_reason heuristic never matches it — the marker is the only
|
|
# thing that surfaces TUI branches. See issue #20856.
|
|
model_config={"_branched_from": old_key},
|
|
parent_session_id=old_key,
|
|
cwd=_session_cwd(session),
|
|
# The branch stays on its parent's profile. Explicit stamp (not
|
|
# just the parent-backfill) so it holds even when the parent row
|
|
# predates the profile_name column. Launch-profile branches are
|
|
# stamped explicitly too — NULL rows drop out of profile-keyed
|
|
# sidebar matching and deep-link resolution (#99222).
|
|
profile_name=(
|
|
Path(session["profile_home"]).name
|
|
if session.get("profile_home")
|
|
else _current_profile_name()
|
|
),
|
|
)
|
|
# 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"),
|
|
"reasoning": msg.get("reasoning"),
|
|
"reasoning_content": msg.get("reasoning_content"),
|
|
"reasoning_details": msg.get("reasoning_details"),
|
|
"codex_reasoning_items": msg.get("codex_reasoning_items"),
|
|
"codex_message_items": msg.get("codex_message_items"),
|
|
# Timeline markers (model_switch, personality_switch,
|
|
# auto_continue, …) ride as role=user; dropping the tag
|
|
# here re-planted them as bare user turns after a
|
|
# restart, corrupting the truncate ordinal address
|
|
# space the same way #82756 did.
|
|
"display_kind": msg.get("display_kind"),
|
|
"display_metadata": msg.get("display_metadata"),
|
|
# Preserve the parent's original message timestamps —
|
|
# branch copies are history, not new activity (9d73006ad).
|
|
"timestamp": msg.get("timestamp"),
|
|
}
|
|
for msg in history
|
|
],
|
|
chunk_rows=500,
|
|
)
|
|
db.set_session_title(new_key, title)
|
|
except Exception as e:
|
|
if lease is not None:
|
|
lease.release()
|
|
return _err(rid, 5008, f"branch failed: {e}")
|
|
# Bound before the try so the ownership finally below can never see them
|
|
# unbound, whatever raises inside.
|
|
branch_db = None
|
|
branch_owns_db = False
|
|
try:
|
|
# Bind the branched AGENT to the parent's profile, mirroring
|
|
# session.create/resume: home override so config/skills/memory resolve
|
|
# to the profile during the build, and the profile's own state.db
|
|
# handle so the live agent's message flushes — and any later
|
|
# compression rotation — persist there. Writing only the row to the
|
|
# parent's db while the agent stayed on the launch handle would
|
|
# recreate the cross-profile split one turn later.
|
|
parent_home = session.get("profile_home")
|
|
if parent_home:
|
|
from hermes_state import SessionDB
|
|
|
|
# DEDICATED handle, same ownership rule as session.resume: ours
|
|
# until the branched agent takes it below. _make_agent raising, or
|
|
# _init_session raising, both leave here without that transfer.
|
|
from hermes_state import get_shared_session_db
|
|
branch_db = get_shared_session_db(Path(parent_home) / "state.db")
|
|
branch_owns_db = True
|
|
home_token = (
|
|
set_hermes_home_override(parent_home) if parent_home else None
|
|
)
|
|
# The home override alone only moves config/skills/memory; credentials
|
|
# resolve through get_secret(), which without a scope falls through to
|
|
# process os.environ — the LAUNCH profile's .env. Install the parent's
|
|
# secret scope for the build, exactly as session.create/resume do
|
|
# (#67605), so the branched agent authenticates as its own profile.
|
|
secret_token = (
|
|
set_secret_scope(build_profile_secret_scope(Path(parent_home)))
|
|
if parent_home
|
|
else None
|
|
)
|
|
try:
|
|
tokens = _set_session_context(new_key)
|
|
try:
|
|
agent = _make_agent(
|
|
new_sid,
|
|
new_key,
|
|
session_id=new_key,
|
|
session_db=branch_db,
|
|
platform_override=source,
|
|
context_cwd_is_launch_artifact=(
|
|
_context_cwd_is_launch_artifact(session)
|
|
),
|
|
)
|
|
finally:
|
|
_clear_session_context(tokens)
|
|
_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")),
|
|
)
|
|
# Ownership TRANSFER — the branched session's agent holds this
|
|
# handle for its whole life and closes it on teardown. Drop is
|
|
# unconditional for the same reason as session.resume: past
|
|
# _init_session the branched session is registered against this
|
|
# handle, so the finally must not close it.
|
|
_transfer_db_to_agent(agent, branch_db)
|
|
branch_owns_db = False
|
|
finally:
|
|
if secret_token is not None:
|
|
reset_secret_scope(secret_token)
|
|
if home_token is not None:
|
|
reset_hermes_home_override(home_token)
|
|
if new_sid in _sessions:
|
|
_sessions[new_sid]["active_session_lease"] = lease
|
|
except Exception as e:
|
|
if lease is not None:
|
|
lease.release()
|
|
return _err(rid, 5000, f"agent init failed on branch: {e}")
|
|
finally:
|
|
if branch_owns_db and branch_db is not None:
|
|
with contextlib.suppress(Exception):
|
|
from hermes_state import release_or_close
|
|
release_or_close(branch_db)
|
|
branched_session = _sessions.get(new_sid)
|
|
return _ok(
|
|
rid,
|
|
{
|
|
"session_id": new_sid,
|
|
"stored_session_id": new_key,
|
|
"title": title,
|
|
"parent": old_key,
|
|
"message_count": len(history),
|
|
"messages": _history_to_messages(history),
|
|
"info": _session_info(agent, branched_session),
|
|
},
|
|
)
|
|
|
|
|
|
@method("session.interrupt")
|
|
def _(rid, params: dict) -> dict:
|
|
# Keypress barge-in: stopping the turn also silences its streaming TTS
|
|
# (voice is process-global, so no per-session scoping is needed).
|
|
_tts_stream_stop()
|
|
session, err = _sess_nowait(params, rid)
|
|
if err:
|
|
return err
|
|
expected_hosted_task_id = str(
|
|
params.get("expected_hosted_task_id") or ""
|
|
).strip()
|
|
if expected_hosted_task_id:
|
|
with session["history_lock"]:
|
|
active_task = session.get("_hosted_room_task")
|
|
if (
|
|
not session.get("running")
|
|
or not isinstance(active_task, dict)
|
|
or active_task.get("task_id") != expected_hosted_task_id
|
|
):
|
|
return _ok(rid, {"status": "not_interrupted", "interrupted": False})
|
|
if _session_uses_compute_host(session):
|
|
sid = str(params.get("session_id") or "")
|
|
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(str(params.get("session_id") or ""), session)
|
|
# Retire the crash-recovery marker on a confirmed local Stop. Waiting for
|
|
# the run thread's finally leaves a window where a backend exit looks like
|
|
# a crash and session.resume auto-continues the turn the user just stopped.
|
|
# 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"})
|
|
|
|
|
|
@method("delegation.status")
|
|
def _(rid, params: dict) -> dict:
|
|
from tools.delegate_tool import (
|
|
is_spawn_paused,
|
|
list_active_subagents,
|
|
_get_max_concurrent_children,
|
|
_get_max_spawn_depth,
|
|
)
|
|
|
|
return _ok(
|
|
rid,
|
|
{
|
|
"active": list_active_subagents(),
|
|
"paused": is_spawn_paused(),
|
|
"max_spawn_depth": _get_max_spawn_depth(),
|
|
"max_concurrent_children": _get_max_concurrent_children(),
|
|
},
|
|
)
|
|
|
|
|
|
@method("delegation.pause")
|
|
def _(rid, params: dict) -> dict:
|
|
from tools.delegate_tool import set_spawn_paused
|
|
|
|
paused = bool(params.get("paused", True))
|
|
return _ok(rid, {"paused": set_spawn_paused(paused)})
|
|
|
|
|
|
@method("subagent.interrupt")
|
|
def _(rid, params: dict) -> dict:
|
|
from tools.delegate_tool import interrupt_subagent
|
|
|
|
subagent_id = str(params.get("subagent_id") or "").strip()
|
|
if not subagent_id:
|
|
return _err(rid, 4000, "subagent_id required")
|
|
ok = interrupt_subagent(subagent_id)
|
|
return _ok(rid, {"found": ok, "subagent_id": subagent_id})
|
|
|
|
|
|
@method("subagent.steer")
|
|
def _(rid, params: dict) -> dict:
|
|
"""Queue steering text into a live delegated child without stopping it.
|
|
|
|
The redirection-side mirror of subagent.interrupt: resolves the child in
|
|
the delegation registry and calls AIAgent.steer(), which appends the text
|
|
to the child's last tool result at its next iteration boundary — the
|
|
in-flight tool call is never cut. "queued" is not "delivered": a child
|
|
already past its final tool batch has no boundary left to drain into,
|
|
and that race surfaces as ``missed_steer`` on the parent's completion
|
|
entry instead of being silently dropped.
|
|
"""
|
|
from tools.delegate_tool import steer_subagent
|
|
|
|
subagent_id = str(params.get("subagent_id") or "").strip()
|
|
if not subagent_id:
|
|
return _err(rid, 4000, "subagent_id required")
|
|
text = (params.get("text") or "").strip()
|
|
if not text:
|
|
return _err(rid, 4002, "text is required")
|
|
_invoking_session, err = _sess_nowait(params, rid)
|
|
if err:
|
|
return err
|
|
invoking_session_id = str(params.get("session_id") or "").strip()
|
|
invoking_transport, invoking_session = _current_session_steer_authority(
|
|
invoking_session_id
|
|
)
|
|
queued = False
|
|
if invoking_transport is not None and invoking_session is not None:
|
|
queued = steer_subagent(
|
|
subagent_id,
|
|
text,
|
|
owner_session_id=invoking_session_id,
|
|
owner_transport=invoking_transport,
|
|
owner_session_record=invoking_session,
|
|
)
|
|
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(params.get("session_id") or "").strip()
|
|
subagents = params.get("subagents") or []
|
|
if not isinstance(subagents, list) or not subagents:
|
|
return _err(rid, 4000, "subagents list required")
|
|
|
|
from datetime import datetime
|
|
|
|
started_at = params.get("started_at")
|
|
finished_at = params.get("finished_at") or time.time()
|
|
label = str(params.get("label") or "")
|
|
ts = datetime.utcfromtimestamp(float(finished_at)).strftime("%Y%m%dT%H%M%S")
|
|
fname = f"{ts}.json"
|
|
d = _spawn_tree_session_dir(session_id or "default")
|
|
path = d / fname
|
|
try:
|
|
payload = {
|
|
"session_id": session_id,
|
|
"started_at": float(started_at) if started_at else None,
|
|
"finished_at": float(finished_at),
|
|
"label": label,
|
|
"subagents": subagents,
|
|
}
|
|
path.write_text(json.dumps(payload, 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),
|
|
"session_id": session_id,
|
|
"started_at": payload["started_at"],
|
|
"finished_at": payload["finished_at"],
|
|
"label": label,
|
|
"count": len(subagents),
|
|
},
|
|
)
|
|
|
|
return _ok(rid, {"path": str(path), "session_id": session_id})
|
|
|
|
|
|
@method("spawn_tree.list")
|
|
def _(rid, params: dict) -> dict:
|
|
session_id = str(params.get("session_id") or "").strip()
|
|
limit = int(params.get("limit") or 50)
|
|
cross_session = bool(params.get("cross_session"))
|
|
|
|
if cross_session:
|
|
root = _spawn_trees_root()
|
|
roots = [p for p in root.iterdir() if p.is_dir()]
|
|
else:
|
|
roots = [_spawn_tree_session_dir(session_id or "default")]
|
|
|
|
entries: list[dict] = []
|
|
for d in roots:
|
|
indexed = _read_spawn_tree_index(d)
|
|
if indexed:
|
|
# 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()
|
|
)
|
|
continue
|
|
|
|
# Fallback for legacy (pre-index) sessions: full scan. O(N) reads
|
|
# but only runs once per session until the next save writes the index.
|
|
for p in d.glob("*.json"):
|
|
if p.name == _SPAWN_TREE_INDEX:
|
|
continue
|
|
try:
|
|
stat = p.stat()
|
|
try:
|
|
raw = json.loads(p.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
raw = {}
|
|
subagents = raw.get("subagents") or []
|
|
entries.append(
|
|
{
|
|
"path": str(p),
|
|
"session_id": raw.get("session_id") or d.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,
|
|
}
|
|
)
|
|
except OSError:
|
|
continue
|
|
|
|
entries.sort(key=lambda e: e.get("finished_at") or 0, reverse=True)
|
|
return _ok(rid, {"entries": entries[:limit]})
|
|
|
|
|
|
@method("spawn_tree.load")
|
|
def _(rid, params: dict) -> dict:
|
|
from pathlib import Path
|
|
|
|
raw_path = str(params.get("path") or "").strip()
|
|
if not raw_path:
|
|
return _err(rid, 4000, "path required")
|
|
|
|
# Reject paths escaping the spawn-trees root.
|
|
root = _spawn_trees_root().resolve()
|
|
try:
|
|
resolved = Path(raw_path).resolve()
|
|
resolved.relative_to(root)
|
|
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}")
|
|
|
|
return _ok(rid, payload)
|
|
|
|
|
|
@method("session.steer")
|
|
def _(rid, params: dict) -> dict:
|
|
"""Inject a user message into the next tool result without interrupting.
|
|
|
|
Mirrors AIAgent.steer(). Safe to call while a turn is running — the text
|
|
lands on the last tool result of the next tool batch and the model sees
|
|
it on its next iteration. No interrupt, no new user turn, no role
|
|
alternation violation.
|
|
"""
|
|
text = (params.get("text") or "").strip()
|
|
if not text:
|
|
return _err(rid, 4002, "text is required")
|
|
session, err = _sess_nowait(params, rid)
|
|
if err:
|
|
return err
|
|
agent = session.get("agent")
|
|
if agent is None or not hasattr(agent, "steer"):
|
|
return _err(rid, 4010, "agent does not support steer")
|
|
try:
|
|
accepted = agent.steer(text)
|
|
except Exception as exc:
|
|
return _err(rid, 5000, f"steer failed: {exc}")
|
|
if accepted:
|
|
# Record the correction on the live turn exactly like session.redirect
|
|
# does. Without this, a resume/reconnect while the turn is running
|
|
# rebuilds the transcript from the inflight snapshot and the steered
|
|
# text has no user bubble — the "my message vanished on reload" loss.
|
|
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).
|
|
_drop_queued_duplicates_of_inflight_user(session)
|
|
session["last_active"] = time.time()
|
|
return _ok(rid, {"status": "queued" if accepted else "rejected", "text": text})
|
|
|
|
|
|
@method("session.redirect")
|
|
def _(rid, params: dict) -> dict:
|
|
"""Redirect the active model turn while preserving valid work/context."""
|
|
text = (params.get("text") or "").strip()
|
|
if not text:
|
|
return _err(rid, 4002, "text is required")
|
|
session, err = _sess_nowait(params, rid)
|
|
if err:
|
|
return err
|
|
agent = session.get("agent")
|
|
# Turn-build window: a fresh turn flips running=True and kicks off an async
|
|
# agent build, so session["agent"] is briefly None. That is not an
|
|
# unsupported runtime — queue the correction server-side so it reaches the
|
|
# model as the next turn, instead of a misleading 4010 the client silently
|
|
# swallows into a lost follow-up.
|
|
if 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})
|
|
if (
|
|
agent is None
|
|
or getattr(agent, "_supports_active_turn_redirect", False) is not True
|
|
or not hasattr(agent, "redirect")
|
|
):
|
|
return _err(rid, 4010, "agent does not support active-turn redirect")
|
|
try:
|
|
accepted = agent.redirect(text)
|
|
except Exception as exc:
|
|
return _err(rid, 5000, f"redirect failed: {exc}")
|
|
if accepted:
|
|
with session["history_lock"]:
|
|
_record_inflight_correction(session, text)
|
|
# #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": "redirected" if accepted else "rejected", "text": text},
|
|
)
|
|
|
|
|
|
@method("terminal.resize")
|
|
def _(rid, params: dict) -> dict:
|
|
session, err = _sess_nowait(params, rid)
|
|
if err:
|
|
return err
|
|
session["cols"] = int(params.get("cols", 80))
|
|
return _ok(rid, {"cols": session["cols"]})
|
|
|
|
|
|
@method("session.events.since")
|
|
def _(rid, params: dict) -> dict:
|
|
"""Replay recorded events for a session newer than the client's last-seen seq.
|
|
|
|
Reconnect contract (desktop / web clients): every event frame now carries
|
|
``params.seq``. After a WS reconnect the client calls this with its last
|
|
observed seq; this returns the buffered frames in order so no mid-stream
|
|
event is lost. Frames older than the ring window report ``truncated`` so
|
|
the client knows to refetch history instead of silently accepting a gap.
|
|
"""
|
|
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
|
|
|
|
frames = event_replay.events_since(sid, last_seen)
|
|
return _ok(rid, {
|
|
"events": frames,
|
|
"latest_seq": event_replay.latest_seq(sid),
|
|
"truncated": event_replay.is_truncated(sid, last_seen),
|
|
"count": len(frames),
|
|
# Restart detection: seq counters are in-process, so after a gateway
|
|
# restart a client's old high watermark would silently match nothing.
|
|
# Clients compare this against the epoch they learned at gateway.ready
|
|
# and reset watermarks on mismatch.
|
|
"epoch": event_replay.replay_epoch(),
|
|
})
|
|
|
|
|
|
@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:
|
|
"""Bind this module's handlers onto ``server``'s globals and registry."""
|
|
_registry.install(server)
|