mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
`python cli.py -w` runs cli.py as __main__, so it records the tree in __main__._active_worktree, not in a separately imported cli module. _launch_cwd only read sys.modules["cli"], so on a mounting docker backend a removed tree fell back to the checkout instead of being sent for the gateway to refuse. Read both modules, still without importing. Refs #143547
573 lines
30 KiB
Python
573 lines
30 KiB
Python
"""``HermesCLI._session_db`` backed by the shared gateway session (CALLMAP §3.5).
|
|
|
|
Main's classic CLI holds a ``SessionDB`` writer. Rewired, the gateway is the single writer:
|
|
|
|
- reads (resume lookup, recap history, titles, the /resume picker, safety checks) go to a
|
|
read-only ``state.db`` reader in this process, which the maintainer allows;
|
|
- every write is a gateway verb (``session.create`` / ``session.resume`` / ``session.mutate`` /
|
|
``session.detach``) or a documented no-op where the gateway already owns the work
|
|
(transcript flushes, token accounting, maintenance);
|
|
- anything not yet on the gateway raises ``GatewayClientError`` (a ``ValueError``), so main's
|
|
existing ``except`` paths print it instead of writing locally.
|
|
|
|
The session id is the gateway's: :meth:`open_session` creates or resumes the session before the
|
|
banner and adopts the id it returns.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import logging
|
|
import os
|
|
import re
|
|
import sys
|
|
import uuid
|
|
|
|
from hermes_cli.gateway_client import GatewayClientError
|
|
from hermes_cli.gateway_connection import RESUME_REFUSALS, GatewayConnection
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Read-only SessionDB methods main's CLI calls (served by the WAL reader).
|
|
_READS = frozenset({
|
|
"get_session", "get_session_title", "get_session_by_title", "resolve_resume_session_id",
|
|
"get_messages_as_conversation", "get_resume_conversations", "assert_resume_safe",
|
|
"is_kanban_owned_session", "list_sessions_rich", "search_sessions", "get_next_title_in_lineage",
|
|
"get_handoff_state", "get_meta", "get_compression_tip", "resolve_session_by_title", "get_messages",
|
|
"session_count", "sanitize_title", "session_yolo_enabled",
|
|
"get_session_model_config_value",
|
|
})
|
|
# Writes the gateway already performs itself (persistence, accounting, maintenance).
|
|
_GATEWAY_OWNED = frozenset({
|
|
"create_session", "reopen_session", "append_messages_batch", "flush_token_counts",
|
|
"update_session_model", "patch_session_model_config", "maybe_auto_archive",
|
|
"maybe_auto_prune_and_vacuum", "set_meta", "update_token_counts", "close",
|
|
})
|
|
# Mutations the owner refuses without the execution generation they were read at (hermes_state_runtime).
|
|
_GENERATION_FENCED = frozenset({"delete", "rewind", "reset", "branch", "model", "compress", "workspace"})
|
|
_COUNTED_REPAIRS = frozenset({"prune_empty_ghost_sessions", "finalize_orphaned_compression_sessions"})
|
|
# Launch options main's ``cli.main`` takes that are creation policy on the gateway session.
|
|
_LAUNCH_POLICY = ("model", "provider", "toolsets", "reasoning", "api_key", "base_url", "max_turns",
|
|
"ignore_rules", "checkpoints", "pass_session_id", "skills")
|
|
|
|
|
|
def _noop(*_a, **_k):
|
|
return None
|
|
|
|
|
|
def _zero(*_a, **_k):
|
|
return 0
|
|
|
|
|
|
class GatewaySessionStore:
|
|
"""Truthy until the viewer's gateway connection is final (main's ``if self._session_db:`` guards);
|
|
a gateway hang-up stays truthy, and the next call redials."""
|
|
|
|
current: dict | None = None # the opened session's snapshot; never ``__getattr__``'s refusal
|
|
|
|
def __init__(self, connection=None):
|
|
self.connection = connection or GatewayConnection.shared()
|
|
self.snapshots: dict[str, dict] = {}
|
|
self.current: dict | None = None
|
|
self._reader = None
|
|
self._create_parameters = None
|
|
|
|
def __bool__(self):
|
|
return not self.connection.final
|
|
|
|
def close_reader(self):
|
|
"""Release this viewer's SQLite handles before offline maintenance; keep its RPC store."""
|
|
if self._reader is not None:
|
|
self._reader.close()
|
|
self._reader = None
|
|
|
|
# -- reads -----------------------------------------------------------------------------
|
|
def _db(self):
|
|
"""The read-only reader, opened on first use. No state.db yet (the gateway creates it on its
|
|
first start, after the CLI's startup maintenance probe) is retried on the next read."""
|
|
if self._reader is None:
|
|
from hermes_constants import get_hermes_home
|
|
path = get_hermes_home() / "state.db"
|
|
if not path.exists():
|
|
return None
|
|
try:
|
|
from hermes_state import SessionDB
|
|
self._reader = SurfaceLabelReader(SessionDB(db_path=path, read_only=True))
|
|
except Exception as exc: # health: allow BLE001 -- reads find nothing; retried on the next read
|
|
logger.debug("read-only state.db unavailable: %s", exc)
|
|
return self._reader
|
|
|
|
def __getattr__(self, name):
|
|
if name.startswith("__"):
|
|
raise AttributeError(name)
|
|
if name in _READS:
|
|
if name in {"sanitize_title", "session_yolo_enabled"}:
|
|
from hermes_state import SessionDB
|
|
return getattr(SessionDB, name)
|
|
db = self._db()
|
|
if db is None:
|
|
return _noop
|
|
return getattr(db, name)
|
|
if name in _GATEWAY_OWNED:
|
|
return _noop
|
|
if name in _COUNTED_REPAIRS:
|
|
return _zero
|
|
|
|
def refused(*_a, **_k):
|
|
raise GatewayClientError(f"{name} is not on the gateway session yet; nothing was written")
|
|
return refused
|
|
|
|
# -- session lifecycle -----------------------------------------------------------------
|
|
def call(self, method, **params):
|
|
return self.connection.call(method, **params)
|
|
|
|
def _adopt(self, snapshot):
|
|
sid = snapshot["stored_session_id"]
|
|
self.snapshots[sid] = snapshot
|
|
self.current = snapshot
|
|
self.connection.state(sid).adopt_snapshot(snapshot)
|
|
return sid
|
|
|
|
def attach(self, session_id, *, typed=False):
|
|
"""``session.resume`` once per session id this viewer uses (idempotent), carrying this CLI's launch
|
|
key as the thin client does (``GatewayConnection.resume``, the reconnect's too). A session that does
|
|
not take the key (a keyed startup alias resuming a Desktop/TUI session) attaches as is. ``typed``
|
|
(only :meth:`open_session`'s startup resume, where the user typed ``--api-key`` for that session)
|
|
refuses it instead, as the thin client does."""
|
|
if session_id in self.snapshots:
|
|
return self.snapshots[session_id]
|
|
snapshot = self.connection.resume(self.call, session_id, typed=typed)
|
|
self._adopt(snapshot)
|
|
return snapshot
|
|
|
|
def open_session(self, cli, launch=None):
|
|
"""Create (or resume) the gateway session for this CLI and adopt its id. A resume target the
|
|
gateway does not know is left as is: main's own "Session not found" path reports it."""
|
|
key = self.connection.launch_api_key = (launch or {}).get("api_key") or None
|
|
self.connection.launch_api_key_typed = key is not None and key == getattr(cli, "_typed_api_key", None)
|
|
if getattr(cli, "_resumed", False):
|
|
try:
|
|
snapshot = self.attach(cli.session_id, typed=self.connection.launch_api_key_typed)
|
|
except GatewayClientError as exc:
|
|
if str(exc) in RESUME_REFUSALS:
|
|
return None
|
|
raise
|
|
# The CLI sends a coding-posture toolset pick and an env-folded ignore_rules too; only a typed
|
|
# -t / --ignore-rules is checked.
|
|
_check_resume_launch({**(launch or {}), "toolsets": getattr(cli, "_typed_toolsets", None),
|
|
"ignore_rules": getattr(cli, "_explicit_ignore_rules", None) or None}, snapshot)
|
|
return self._adopt(snapshot)
|
|
return self.create(launch)
|
|
|
|
def create(self, launch=None, title=None, *, record_cwd=True):
|
|
"""``session.create`` with the launch policy (and ``title``: the owner's resolve -> create ->
|
|
title step); adopts and returns the new session id. ``record_cwd=False`` (``/new``,
|
|
``--create-if-missing``) leaves the row's cwd empty, as main's explicit creates did."""
|
|
from agent.session_source import CLI_FAMILY_SOURCES, create_surface, session_source_for
|
|
params = _creation_policy(launch or {})
|
|
params["cwd"] = _launch_cwd()
|
|
# Main's row label: ``cli``, ``oneshot`` for a finite -q/-Q run, or an explicit --source.
|
|
params["source"] = create_surface(session_source_for("cli"))
|
|
# Main's row stamp (run_agent._launch_cwd_for_session): this process's cwd (``-w`` never
|
|
# chdirs, so the repo root), only for a CLI-family label on a local terminal backend.
|
|
local = (os.getenv("TERMINAL_ENV") or "local").strip().lower() in ("", "local")
|
|
params["row_cwd"] = os.getcwd() if record_cwd and local and params["source"] in CLI_FAMILY_SOURCES else ""
|
|
if title:
|
|
params["title"] = title
|
|
params.update(self.client_env())
|
|
snapshot = self.call("session.create", request_id=uuid.uuid4().hex, **params)
|
|
warning = (snapshot.get("info") or {}).get("config_warning")
|
|
if warning:
|
|
print(f"Warning: {warning}", file=sys.stderr)
|
|
return self._adopt(snapshot)
|
|
|
|
def client_env(self):
|
|
"""This shell's ``client_env`` when the gateway takes it (one ``runtime.describe`` per store)."""
|
|
from hermes_cli.gateway_chat import client_env_policy
|
|
if self._create_parameters is None:
|
|
described = self.call("runtime.describe")
|
|
contract = described.get("session_create") if isinstance(described, dict) else None
|
|
self._create_parameters = list((contract or {}).get("parameters") or [])
|
|
return client_env_policy(self._create_parameters)
|
|
|
|
def branch(self, session_id, title=None):
|
|
"""``session.mutate branch``: the gateway forks the whole active transcript into a child it
|
|
attaches this viewer to; returns the child id (main's /branch then switches to it)."""
|
|
snapshot = self.call("session.resume", session_id=session_id)
|
|
payload = {"title": title} if title else {}
|
|
receipt = self.call("session.mutate", session_id=session_id, request_id=uuid.uuid4().hex,
|
|
expected_revision=snapshot["revision"],
|
|
expected_generation=snapshot["execution_generation"],
|
|
operation="branch", payload=payload) or {}
|
|
child = receipt.get("branched_session_id")
|
|
if not child:
|
|
raise GatewayClientError("branch_failed")
|
|
self._adopt(self.attach(child))
|
|
return child
|
|
|
|
def _mutate(self, session_id, operation, payload, snapshot=None):
|
|
snapshot = snapshot or self.call("session.resume", session_id=session_id)
|
|
fence = {}
|
|
if operation in _GENERATION_FENCED:
|
|
fence["expected_generation"] = snapshot["execution_generation"]
|
|
return self.call("session.mutate", session_id=session_id, request_id=uuid.uuid4().hex,
|
|
expected_revision=snapshot["revision"], operation=operation, payload=payload, **fence)
|
|
|
|
def set_session_title(self, session_id, title):
|
|
try:
|
|
self._mutate(session_id, "rename", {"title": title})
|
|
except GatewayClientError as exc:
|
|
# The owner's SessionDB ValueError text never crosses the wire (bounded reason codes):
|
|
# print main's "already in use" copy from the reader when another row holds the title.
|
|
holder = self._title_holder(session_id, title)
|
|
if holder:
|
|
raise ValueError(f"Title '{title}' is already in use by session {holder}") from exc
|
|
raise
|
|
if session_id in self.snapshots:
|
|
self.connection.state(session_id).title = title
|
|
return True
|
|
|
|
def rewind_user_turn(self, session_id, user_ordinal, *, warm_history=None, require_retryable=False, **_kw):
|
|
"""Main's ``SessionDB.rewind_user_turn`` (``/undo N``, ``/retry``) as ``session.mutate rewind``.
|
|
The user turn is located on the gateway's snapshot transcript (its rows carry ``row_id``),
|
|
checked against the CLI's warm view exactly as main checks it, then rewound by that row id."""
|
|
from agent.context_compressor import history_before_user_originated_turn, retryable_user_text
|
|
from agent.message_content import flatten_message_text
|
|
from agent.session_persistence import _is_ephemeral_scaffolding
|
|
from hermes_state_rewind import (
|
|
_HISTORY_CHANGED, RewindOutcome, RewindTargetUnavailableError, _comparison_content, _user_indices)
|
|
snapshot = self.call("session.resume", session_id=session_id)
|
|
durable = [m for m in snapshot.get("messages") or [] if not m.get("archived")] # the live rows
|
|
durable_user = _user_indices(durable)
|
|
if user_ordinal < 0:
|
|
user_ordinal = max(len(durable_user) + user_ordinal, 0)
|
|
if user_ordinal >= len(durable_user):
|
|
raise RewindTargetUnavailableError("target user message is no longer in session history")
|
|
target = durable[durable_user[user_ordinal]]
|
|
durable_prefix, live_view = history_before_user_originated_turn(durable, durable_user[user_ordinal])
|
|
prefix = [{k: v for k, v in m.items() if k != "row_id"} for m in durable_prefix]
|
|
if warm_history is not None:
|
|
warm = [m for m in warm_history if not _is_ephemeral_scaffolding(m)]
|
|
warm_user = _user_indices(warm)
|
|
if len(warm_user) != len(durable_user):
|
|
raise RuntimeError(_HISTORY_CHANGED)
|
|
prefix, warm_live = history_before_user_originated_turn(warm, warm_user[user_ordinal])
|
|
if _comparison_content(live_view) != _comparison_content(warm_live):
|
|
raise RuntimeError(_HISTORY_CHANGED)
|
|
live_text = retryable_user_text(live_view.get("content")) if require_retryable else None
|
|
if not isinstance(target.get("row_id"), int):
|
|
raise RuntimeError("rewind target has no durable row identity")
|
|
receipt = self.call("session.mutate", session_id=session_id, request_id=uuid.uuid4().hex,
|
|
expected_revision=snapshot["revision"],
|
|
expected_generation=snapshot["execution_generation"],
|
|
operation="rewind", payload={"target_message_id": target["row_id"]}) or {}
|
|
return RewindOutcome(
|
|
prefix=prefix, live_view=live_view,
|
|
live_text=live_text if live_text is not None else flatten_message_text(live_view.get("content")),
|
|
rewound_count=int(receipt.get("rewound_count") or 0), turns_undone=len(durable_user) - user_ordinal)
|
|
|
|
def set_session_yolo(self, session_id, enabled):
|
|
"""Main's ``/yolo`` persist hook: the gateway session's own bypass (``config.set yolo``, persisted
|
|
on the route), since approvals run there. The CLI's in-process flag stays main's display state."""
|
|
self.call("config.set", session_id=session_id, key="yolo", value="1" if enabled else "0")
|
|
|
|
def _title_holder(self, session_id, title):
|
|
db = self._db()
|
|
if db is None or not title:
|
|
return None
|
|
with db._read_ctx() as conn:
|
|
row = conn.execute("SELECT id FROM sessions WHERE title = ? AND id != ?", (title, session_id)).fetchone()
|
|
return row[0] if row else None
|
|
|
|
def _leave(self, session_id):
|
|
"""Drop this viewer's subscription record and return its id. The connection's SessionState holds
|
|
the live id (a reconnect resubscribes under a new one); cleared, no later reconnect resumes it."""
|
|
state = self.connection.states.get(session_id)
|
|
subscription = state.subscription_id if state is not None else None
|
|
if state is not None:
|
|
state.subscription_id = None
|
|
return subscription
|
|
|
|
def delete_session(self, session_id, sessions_dir=None):
|
|
self.snapshots.pop(session_id, None)
|
|
snapshot = self.call("session.resume", session_id=session_id)
|
|
# The owner answers and subscribes under the id it resolved the read to (a compression
|
|
# segment maps to its logical owner), and reuses a subscription this viewer already holds.
|
|
resolved = snapshot.get("session_id") or session_id
|
|
state = self.connection.states.get(resolved)
|
|
held = state is not None and state.subscription_id is not None
|
|
try:
|
|
self._mutate(session_id, "delete", {}, snapshot)
|
|
except GatewayClientError:
|
|
# Hand back only the attachment the read made, with a plain detach: an end_reason would
|
|
# run the owner's viewer close (fencing the session's delegations, ending it at idle).
|
|
if not held and snapshot.get("subscription_id"):
|
|
try:
|
|
self.call("session.detach", session_id=resolved, subscription_id=snapshot["subscription_id"])
|
|
except GatewayClientError as exc:
|
|
logger.debug("session.detach after a refused delete failed: %s", exc)
|
|
raise
|
|
self._leave(session_id)
|
|
return True
|
|
|
|
def end_session(self, session_id, reason=None):
|
|
"""Main's ``end_session(sid, reason)``: detach this viewer with ``end_reason``; the owner ends
|
|
the idle local row with that reason once no viewer is left (``ended_at`` / ``end_reason``)."""
|
|
snapshot = self.snapshots.pop(session_id, None)
|
|
state = self.connection.states.get(session_id)
|
|
reachable = state is not None and state.subscription_id is not None and not self.connection.final
|
|
if reachable:
|
|
try:
|
|
# Hung up: redial first, so the detach names the subscription the reconnect resumed.
|
|
self.connection.start()
|
|
if state.gaps != state.attached_gaps: # a replay gap retired it: re-attach, so the detach lands
|
|
self.connection.reattach(session_id)
|
|
except GatewayClientError as exc:
|
|
logger.debug("redial for session.detach failed: %s", exc)
|
|
reachable = False
|
|
subscription = self._leave(session_id)
|
|
if subscription and reachable:
|
|
params = {"end_reason": reason} if reason else {}
|
|
try:
|
|
self.call("session.detach", session_id=session_id, subscription_id=subscription, **params)
|
|
except GatewayClientError as exc:
|
|
logger.debug("session.detach failed: %s", exc)
|
|
if self.current is snapshot:
|
|
self.current = None
|
|
|
|
def delete_session_if_empty(self, session_id, sessions_dir=None):
|
|
"""Main's quit-immediately prune, as a gateway delete: only a row with no messages, no title
|
|
and no children (checked on the read-only reader; the gateway deletes the lineage)."""
|
|
db = self._db()
|
|
if db is None:
|
|
return False
|
|
try:
|
|
with db._read_ctx() as conn:
|
|
empty = conn.execute(
|
|
"SELECT 1 FROM sessions WHERE id = ? AND title IS NULL"
|
|
" AND NOT EXISTS (SELECT 1 FROM messages WHERE messages.session_id = sessions.id)"
|
|
" AND NOT EXISTS (SELECT 1 FROM sessions child WHERE child.parent_session_id = sessions.id)",
|
|
(session_id,)).fetchone()
|
|
except Exception as exc: # health: allow BLE001 -- a failed probe keeps the row (never deletes on doubt)
|
|
logger.debug("empty-session probe failed: %s", exc)
|
|
return False
|
|
if not empty:
|
|
return False
|
|
try:
|
|
return self.delete_session(session_id)
|
|
except GatewayClientError as exc:
|
|
logger.debug("empty session prune refused: %s", exc)
|
|
return False
|
|
|
|
# -- banner --------------------------------------------------------------------------
|
|
def banner_inventory(self):
|
|
"""The current session snapshot's banner fields (``gateway/session_local_inventory``), as
|
|
``build_welcome_banner`` kwargs; None until a session is open."""
|
|
info = (self.current or {}).get("info") or {}
|
|
if "tools" not in info:
|
|
return None
|
|
grouped = info.get("tools") or {}
|
|
toolset_of = {name: toolset for toolset, names in grouped.items() for name in names or []}
|
|
availability = info.get("tool_availability") or {
|
|
"available_toolsets": [], "unavailable_toolsets": [], "lazy_tools": [], "disabled_tools": []}
|
|
for item in availability.get("unavailable_toolsets", []):
|
|
for name in item.get("tools", []):
|
|
toolset_of.setdefault(name, item.get("id", item.get("name", "")))
|
|
return {
|
|
"tools": [{"type": "function", "function": {"name": name}} for names in grouped.values()
|
|
for name in names or []],
|
|
"get_toolset_for_tool": toolset_of.get,
|
|
"availability": availability,
|
|
"skills_by_category": info.get("skills") or {},
|
|
"mcp_status": list(info.get("mcp_servers") or []),
|
|
"enabled_toolsets": info.get("enabled_toolsets"),
|
|
"context_length": info.get("context_length"),
|
|
"cwd": info.get("cwd"),
|
|
# Main's startup lines under the banner, computed in the gateway (``source: cli`` only).
|
|
"tool_availability_warnings": info.get("tool_availability_warnings"),
|
|
}
|
|
|
|
|
|
class SurfaceLabelReader:
|
|
"""A read-only ``SessionDB`` whose source-filtered listings read main's labels: ``source='cli'``
|
|
also matches a gateway row labelled ``local`` that the CLI created (``created_source='cli'``), so
|
|
main's /resume picker, /sessions, /history and bare ``-c`` find the sessions the CLI made. The
|
|
surface predicate runs in SQL, before LIMIT/OFFSET.
|
|
"""
|
|
|
|
def __init__(self, db):
|
|
self._wrapped = db
|
|
self._has_column = False
|
|
|
|
def __getattr__(self, name):
|
|
return getattr(self._wrapped, name)
|
|
|
|
def _has_created_source_column(self) -> bool:
|
|
"""Read-only opens don't migrate, so ``created_source`` may be missing. Only "present" is
|
|
cached: a lock or I/O error is not proof of absence, and another connection may add it."""
|
|
if not self._has_column:
|
|
rows = self._wrapped._read_all("PRAGMA table_info(sessions)")
|
|
self._has_column = any(row[1] == "created_source" for row in rows)
|
|
return self._has_column
|
|
|
|
def _expand(self, requested):
|
|
"""``(live source labels, created_source labels)`` or None for a raw source filter.
|
|
|
|
Groups come from session_policy.SURFACES and STORED_AS_LOCAL: gui and desktop are one
|
|
conversation (the in-process agent is desktop, the managed worker creates with gui), acp
|
|
is stored local too, and tool/oneshot are not cli aliases. Live labels include the other
|
|
name of a gui/desktop pair, so a row that has not been peer-refreshed (``source='gui'``)
|
|
still matches ``--source desktop``.
|
|
"""
|
|
from gateway.session_policy import SURFACE_GROUPS
|
|
wanted = {requested} if isinstance(requested, str) else set(requested or ())
|
|
created = set()
|
|
live = set(wanted)
|
|
for name, group in SURFACE_GROUPS.items():
|
|
if name in wanted:
|
|
live |= group
|
|
created |= group
|
|
if not created or not self._has_created_source_column():
|
|
return None
|
|
return sorted(live | {"local"}), sorted(created)
|
|
|
|
def _labelled(self, requested, limit, offset, call):
|
|
expanded = self._expand(requested)
|
|
# No column (and so no gateway ``local`` rows yet): naming it would raise, and the caller
|
|
# swallows that into an empty page.
|
|
if expanded is None:
|
|
return call(requested, limit, offset, None)
|
|
live, surfaces = expanded
|
|
return call(live, limit, offset, surfaces)
|
|
|
|
def search_sessions(self, source=None, limit=20, offset=0, **kw):
|
|
return self._labelled(
|
|
source, limit, offset,
|
|
lambda src, lim, off, surfaces: self._wrapped.search_sessions(
|
|
source=src, limit=lim, offset=off, local_created_sources=surfaces, **kw))
|
|
|
|
def list_sessions_rich(self, source=None, sources=None, limit=20, offset=0, **kw):
|
|
requested = sources if sources else ([source] if source else None)
|
|
if not requested:
|
|
return self._wrapped.list_sessions_rich(limit=limit, offset=offset, **kw)
|
|
return self._labelled(
|
|
requested, limit, offset,
|
|
lambda src, lim, off, surfaces: self._wrapped.list_sessions_rich(
|
|
sources=src, limit=lim, offset=off, local_created_sources=surfaces, **kw))
|
|
|
|
def session_count(self, source=None, sources=None, **kw):
|
|
expanded = self._expand(sources or source)
|
|
if expanded is None:
|
|
return self._wrapped.session_count(source=source, sources=sources, **kw)
|
|
live, surfaces = expanded
|
|
return self._wrapped.session_count(sources=live, local_created_sources=surfaces, **kw)
|
|
|
|
|
|
_CANONICAL_LOCAL_ID = re.compile(r"^local-([0-9a-f]{8})[0-9a-f]{56}$")
|
|
|
|
|
|
def session_label(session_id):
|
|
"""Main's 8-character banner label for a canonical durable id (``local-`` + 64 hex); every other
|
|
id is shown as is. Display only: RPCs, /resume hints and the exit summary keep the full id."""
|
|
match = _CANONICAL_LOCAL_ID.match(session_id or "")
|
|
return match.group(1) if match else session_id
|
|
|
|
|
|
def _creation_policy(launch):
|
|
"""The explicit launch flags as ``session.create`` policy (the thin client's own mapping);
|
|
unset flags leave the gateway to resolve the profile default, as main's CLI did."""
|
|
from hermes_cli.gateway_chat import _requested_policy, env_launch_flags
|
|
fields = {key: launch.get(key) for key in _LAUNCH_POLICY}
|
|
if isinstance(fields.get("toolsets"), (list, tuple)):
|
|
fields["toolsets"] = ",".join(str(item) for item in fields["toolsets"])
|
|
policy = _requested_policy(argparse.Namespace(**fields, source=None))
|
|
policy.pop("source", None)
|
|
policy.update(_classic_only_policy(launch))
|
|
policy.update(env_launch_flags())
|
|
return policy
|
|
|
|
|
|
def _launch_cwd():
|
|
"""``session.create``'s ``cwd``, a host folder the owner checks on its own host. TERMINAL_CWD is the
|
|
folder this CLI has selected (``-w``, ``/worktree new`` and ``/resume`` all move it). A local backend
|
|
sends it as is, as on main. A docker/ssh one sends it when it is a host directory or the recorded
|
|
``-w`` tree (a vanished tree is then refused as on main, not swapped for the checkout); otherwise
|
|
it is a path inside the sandbox, so the launch dir is sent and the backend keeps its own cwd
|
|
(``terminal_uses_host_cwd``). A tree no longer selected (a later /resume moved away) never decides.
|
|
``hermes -w`` records the tree in the imported ``cli`` module, ``python cli.py -w`` in ``__main__``."""
|
|
terminal_cwd = os.getenv("TERMINAL_CWD")
|
|
trees = (getattr(sys.modules.get(name), "_active_worktree", None) for name in ("cli", "__main__"))
|
|
recorded = {os.path.realpath(tree["path"]) for tree in trees if tree and tree.get("path")}
|
|
if terminal_cwd and os.path.realpath(terminal_cwd) in recorded:
|
|
return os.path.realpath(terminal_cwd)
|
|
local = (os.getenv("TERMINAL_ENV") or "local").strip().lower() in ("", "local")
|
|
return os.path.realpath(terminal_cwd if terminal_cwd and (local or os.path.isdir(terminal_cwd)) else os.getcwd())
|
|
|
|
|
|
def _classic_only_policy(launch):
|
|
"""Main's `--run-budget` and `-v`, which the thin client refuses."""
|
|
policy = {}
|
|
if launch.get("run_budget") is not None:
|
|
policy["run_budget"] = float(launch["run_budget"])
|
|
if launch.get("verbose"):
|
|
policy["verbose"] = True
|
|
return policy
|
|
|
|
|
|
# Flags a resume may only repeat (``toolsets`` arrives here only from an explicit ``-t``).
|
|
_RESUME_CHECKED = _LAUNCH_POLICY
|
|
|
|
|
|
def _check_resume_launch(launch, snapshot):
|
|
"""Explicit launch flags on ``--resume`` / ``-c`` against the session's frozen route, as the thin
|
|
client checks them: the same flags again are fine, a different value is refused, never dropped."""
|
|
from hermes_cli.gateway_chat import check_resume_policy, resume_policy_error
|
|
fields = {key: launch.get(key) for key in _RESUME_CHECKED}
|
|
if isinstance(fields.get("toolsets"), (list, tuple)):
|
|
fields["toolsets"] = ",".join(str(item) for item in fields["toolsets"])
|
|
check_resume_policy(argparse.Namespace(**fields), snapshot)
|
|
frozen = ((snapshot.get("info") or {}).get("launch_request") or {})
|
|
differs = [key for key, value in _classic_only_policy(launch).items() if frozen.get(key) != value]
|
|
if differs:
|
|
raise resume_policy_error(differs)
|
|
|
|
|
|
def move_cli_workspace(cli, wt_info):
|
|
"""``/worktree new``: the session's tools run in the gateway under the cwd frozen in its policy, so
|
|
move that first (``session.mutate workspace``, Desktop's Move to project; a refusal raises before
|
|
anything moved). The moved session resumes in the tree, so it is recorded as a ``--tui -w`` tree is:
|
|
quit removes it when clean and a resume recreates it (N8). Then this process's cwd and TERMINAL_CWD
|
|
follow, so a later /new creates its session from the tree; a chdir OSError leaves both where they
|
|
were (the gateway session has moved by then)."""
|
|
from hermes_cli.gateway_automation import gateway_connection
|
|
cwd = wt_info["path"]
|
|
if gateway_connection(cli) is not None:
|
|
cli._session_db._mutate(cli.session_id, "workspace", {"cwd": cwd})
|
|
from hermes_cli.tui_worktree_resume import record_tui_worktree
|
|
record_tui_worktree(wt_info)
|
|
os.chdir(cwd)
|
|
os.environ["TERMINAL_CWD"] = cwd
|
|
|
|
|
|
def session_store():
|
|
return GatewaySessionStore()
|
|
|
|
|
|
def create_titled_session(title):
|
|
"""``chat -c <title> --create-if-missing`` for the classic CLI: the gateway's ``session.create``
|
|
with ``title`` (it resolves-or-creates under one owner lock), so this process writes no row.
|
|
Main's ``_create_titled_session`` minted the id and wrote the row itself, with no cwd stamp
|
|
(``row_cwd`` ""). Returns the id, or None when the gateway refused (the caller prints main's
|
|
"could not be created" copy)."""
|
|
params = _creation_policy({})
|
|
params["cwd"] = _launch_cwd()
|
|
params["source"] = "cli" # main's titled row is `cli` (hermes_cli.main._create_titled_session)
|
|
params["row_cwd"] = ""
|
|
try:
|
|
store = session_store()
|
|
params.update(store.client_env())
|
|
snapshot = store.call("session.create", request_id=uuid.uuid4().hex, title=title, **params)
|
|
except GatewayClientError as exc:
|
|
logger.warning("Failed to create titled session %r on the gateway: %s", title, exc)
|
|
return None
|
|
return snapshot["stored_session_id"]
|