feat(gateway): build authoritative project tree

This commit is contained in:
Brooklyn Nicholson 2026-06-25 16:40:27 -05:00
parent e7811345c1
commit 4e023f5bc9
7 changed files with 2073 additions and 46 deletions

View file

@ -2977,6 +2977,7 @@ async def get_sessions(
order: str = "created",
source: str = None,
exclude_sources: str = None,
cwd_prefix: str = None,
profile: Optional[str] = None,
):
"""List sessions.
@ -3018,6 +3019,7 @@ async def get_sessions(
sessions = db.list_sessions_rich(
source=source or None,
exclude_sources=exclude_list or None,
cwd_prefix=(cwd_prefix or None),
limit=limit,
offset=offset,
min_message_count=min_message_count,
@ -3027,6 +3029,7 @@ async def get_sessions(
)
total = db.session_count(
source=source or None,
cwd_prefix=(cwd_prefix or None),
exclude_sources=exclude_list or None,
min_message_count=min_message_count,
include_archived=include_archived,

View file

@ -703,6 +703,19 @@ def test_load_enabled_toolsets_accepts_plugin_env_after_discovery(monkeypatch):
assert server._load_enabled_toolsets() == ["plugin_demo"]
def test_load_enabled_toolsets_folds_project_into_focus_posture(monkeypatch):
# Focus-mode coding posture returns before the config fallback, but it's
# still a GUI-only resolver — `project` must come along so the desktop keeps
# the project tools while sitting in a repo.
monkeypatch.delenv("HERMES_TUI_TOOLSETS", raising=False)
import agent.coding_context as cc
monkeypatch.setattr(cc, "coding_selection", lambda **_: ["coding", "figma"])
assert server._load_enabled_toolsets() == ["coding", "figma", "project"]
def test_load_enabled_toolsets_rejects_disabled_mcp_env(monkeypatch, capsys):
monkeypatch.setenv("HERMES_TUI_TOOLSETS", "mcp-off")
monkeypatch.setitem(
@ -722,10 +735,10 @@ def test_load_enabled_toolsets_rejects_disabled_mcp_env(monkeypatch, capsys):
config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}}
)
# Sorted: ["kanban", "memory"]. `kanban` is auto-recovered by
# _get_platform_tools because it's a non-configurable platform toolset
# whose tools live in hermes-cli's universe (see toolsets.py).
assert server._load_enabled_toolsets() == ["kanban", "memory"]
# Sorted: ["kanban", "memory", "project"]. `kanban` is auto-recovered by
# _get_platform_tools (a non-configurable platform toolset in hermes-cli's
# universe); `project` is GUI-only, folded in by _load_enabled_toolsets.
assert server._load_enabled_toolsets() == ["kanban", "memory", "project"]
err = capsys.readouterr().err
assert "ignoring disabled MCP servers" in err
assert "mcp-off" in err
@ -746,7 +759,7 @@ def test_load_enabled_toolsets_falls_back_when_tui_env_invalid(monkeypatch, caps
config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}}
)
assert server._load_enabled_toolsets() == ["kanban", "memory"]
assert server._load_enabled_toolsets() == ["kanban", "memory", "project"]
assert "using configured CLI toolsets" in capsys.readouterr().err
@ -1172,7 +1185,7 @@ def test_session_cwd_set_profile_session_updates_profile_db(monkeypatch, tmp_pat
captured = {}
class ProfileDB:
def update_session_cwd(self, session_id, cwd):
def update_session_cwd(self, session_id, cwd, git_branch=None, git_repo_root=None):
captured["profile_update"] = (session_id, cwd)
def close(self):
@ -2009,7 +2022,7 @@ def test_ensure_session_db_row_persists_explicit_cwd(monkeypatch, tmp_path):
created = []
class _FakeDB:
def create_session(self, key, source=None, model=None, model_config=None, cwd=None):
def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None):
created.append(
{"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd}
)
@ -2028,7 +2041,7 @@ def test_ensure_session_db_row_persists_session_source(monkeypatch):
created = []
class _FakeDB:
def create_session(self, key, source=None, model=None, model_config=None, cwd=None):
def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None):
created.append(
{"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd}
)
@ -2049,7 +2062,7 @@ def test_ensure_session_db_row_defaults_to_no_workspace(monkeypatch, tmp_path):
created = []
class _FakeDB:
def create_session(self, key, source=None, model=None, model_config=None, cwd=None):
def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None):
created.append(
{"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd}
)
@ -2076,7 +2089,7 @@ def test_ensure_session_db_row_persists_session_model_override(monkeypatch):
created = []
class _FakeDB:
def create_session(self, key, source=None, model=None, model_config=None, cwd=None):
def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None):
created.append(
{"key": key, "model": model, "model_config": model_config, "cwd": cwd}
)
@ -2108,7 +2121,7 @@ def test_ensure_session_db_row_no_override_uses_global(monkeypatch):
created = []
class _FakeDB:
def create_session(self, key, source=None, model=None, model_config=None, cwd=None):
def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None):
created.append({"model": model, "model_config": model_config})
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())

View file

@ -0,0 +1,352 @@
"""Invariants for the authoritative project-tree builder (tui_gateway.project_tree).
These assert structural contracts (worktree folding, kanban collapse, lane id
scheme, membership union) rather than snapshots, so routine data changes don't
break them.
"""
from __future__ import annotations
from tui_gateway import project_tree as pt
_SID = 0
def _session(cwd, *, branch="", repo_root="", **over):
global _SID
_SID += 1
row = {
"id": f"s{_SID}",
"cwd": cwd,
"git_branch": branch,
"git_repo_root": repo_root,
"started_at": 1000,
"last_active": 1000,
"title": None,
"preview": None,
"source": "cli",
}
row.update(over)
return row
def _project(pid, name, folders, **over):
row = {
"id": pid,
"name": name,
"primary_path": folders[0] if folders else None,
"archived": False,
"folders": [{"path": p, "is_primary": i == 0} for i, p in enumerate(folders)],
}
row.update(over)
return row
def _resolver(mapping):
"""Build a resolve() from {cwd: (repo_root, worktree_root)}."""
def resolve(cwd):
hit = mapping.get(cwd)
if not hit:
return None
return {"repo_root": hit[0], "worktree_root": hit[1]}
return resolve
def _lane_ids(project):
return [g["id"] for repo in project["repos"] for g in repo["groups"]]
# ---------------------------------------------------------------------------
def test_main_checkout_groups_by_recorded_branch_with_stable_lane_ids():
resolve = _resolver({"/repo": ("/repo", "/repo")})
sessions = [
_session("/repo", branch="main"),
_session("/repo", branch="feature"),
]
tree = pt.build_tree([], sessions, [], resolve, hydrate=True)
project = next(p for p in tree["projects"] if p["id"] == "/repo")
assert project["isAuto"] is True
assert _lane_ids(project) == ["/repo::branch::main", "/repo::branch::feature"]
# Trunk sorts ahead of the feature branch; both live in the main checkout.
assert [g["label"] for repo in project["repos"] for g in repo["groups"]] == ["main", "feature"]
assert all(g["isMain"] for repo in project["repos"] for g in repo["groups"])
def test_linked_worktrees_fold_under_their_common_repo_root():
# The linked worktree's own toplevel is /elsewhere/wt, but its COMMON root is
# /repo, so it must group under /repo (not as a separate project).
resolve = _resolver(
{
"/repo": ("/repo", "/repo"),
"/elsewhere/wt": ("/repo", "/elsewhere/wt"),
}
)
sessions = [
_session("/repo", branch="main"),
_session("/elsewhere/wt", branch="feature"),
]
tree = pt.build_tree([], sessions, [], resolve, hydrate=True)
assert [p["id"] for p in tree["projects"]] == ["/repo"]
project = tree["projects"][0]
assert project["repos"][0]["id"] == "/repo"
lane_ids = _lane_ids(project)
assert "/repo::branch::main" in lane_ids
# Linked worktree lane is keyed by the worktree path and is not main.
linked = next(g for repo in project["repos"] for g in repo["groups"] if not g["isMain"])
assert linked["id"] == "/elsewhere/wt"
assert linked["path"] == "/elsewhere/wt"
def test_kanban_task_worktrees_collapse_into_one_bucket():
resolve = _resolver(
{
"/repo": ("/repo", "/repo"),
"/repo/.worktrees/t_aaaaaaaa": ("/repo", "/repo/.worktrees/t_aaaaaaaa"),
"/repo/.worktrees/t_bbbbbbbb": ("/repo", "/repo/.worktrees/t_bbbbbbbb"),
}
)
sessions = [
_session("/repo", branch="main"),
_session("/repo/.worktrees/t_aaaaaaaa"),
_session("/repo/.worktrees/t_bbbbbbbb"),
]
tree = pt.build_tree([], sessions, [], resolve, hydrate=True)
project = tree["projects"][0]
kanban = [g for repo in project["repos"] for g in repo["groups"] if g.get("isKanban")]
assert len(kanban) == 1
assert kanban[0]["id"] == "/repo::kanban"
assert kanban[0]["path"] == "/repo/.worktrees"
assert len(kanban[0]["sessions"]) == 2
# The bucket sorts below the real main branch.
assert _lane_ids(project)[-1] == "/repo::kanban"
def test_user_worktree_under_dotworktrees_is_its_own_lane_not_kanban():
# A user "New worktree" lives at <repo>/.worktrees/<slug> (no t_ id), so it
# must NOT collapse into the kanban bucket — it gets its own linked lane.
resolve = _resolver(
{
"/repo": ("/repo", "/repo"),
"/repo/.worktrees/test-gui-stuff": ("/repo", "/repo/.worktrees/test-gui-stuff"),
}
)
sessions = [
_session("/repo", branch="main"),
_session("/repo/.worktrees/test-gui-stuff", branch="hermes/test-gui-stuff"),
]
tree = pt.build_tree([], sessions, [], resolve, hydrate=True)
project = tree["projects"][0]
lanes = {g["id"]: g for repo in project["repos"] for g in repo["groups"]}
assert "/repo/.worktrees/test-gui-stuff" in lanes
assert not lanes["/repo/.worktrees/test-gui-stuff"].get("isKanban")
assert "/repo::kanban" not in lanes
def test_unrecorded_and_recorded_main_share_one_lane():
# Empty git_branch (historical sessions) folds into the same trunk lane as
# sessions that recorded branch "main" — no duplicate "main".
resolve = _resolver({"/repo": ("/repo", "/repo")})
sessions = [_session("/repo", branch=""), _session("/repo", branch="main")]
tree = pt.build_tree([], sessions, [], resolve, hydrate=True)
project = tree["projects"][0]
main_lanes = [g for repo in project["repos"] for g in repo["groups"] if g["label"] == "main"]
assert len(main_lanes) == 1
assert main_lanes[0]["id"] == "/repo::branch::main"
assert len(main_lanes[0]["sessions"]) == 2
def test_persisted_repo_root_used_when_no_live_probe():
# No resolver (remote backend): fall back to the persisted git_repo_root and
# split the main checkout by the session's recorded branch.
sessions = [_session("/repo/src", branch="main", repo_root="/repo")]
tree = pt.build_tree([], sessions, [], resolve=None, hydrate=True)
project = next(p for p in tree["projects"] if p["id"] == "/repo")
assert _lane_ids(project) == ["/repo::branch::main"]
def test_explicit_project_claims_sessions_and_beats_auto():
project = _project("p_app", "App", ["/www/app"])
resolve = _resolver(
{
"/www/app": ("/www/app", "/www/app"),
"/www/other": ("/www/other", "/www/other"),
}
)
sessions = [
_session("/www/app", branch="main"),
_session("/www/other", branch="main"),
]
tree = pt.build_tree([project], sessions, [], resolve, hydrate=True)
explicit = next(p for p in tree["projects"] if p["id"] == "p_app")
assert explicit["isAuto"] is False
assert explicit["sessionCount"] == 1
# The unowned /www/other session becomes its own auto project.
assert any(p["id"] == "/www/other" and p["isAuto"] for p in tree["projects"])
def test_scoped_session_ids_is_union_of_placed_sessions():
project = _project("p_app", "App", ["/www/app"])
resolve = _resolver(
{
"/www/app": ("/www/app", "/www/app"),
"/www/repo": ("/www/repo", "/www/repo"),
}
)
owned = _session("/www/app", branch="main")
auto = _session("/www/repo", branch="main")
homeless = _session(None) # no cwd -> belongs to no project
tree = pt.build_tree([project], [owned, auto, homeless], [], resolve, hydrate=True)
assert set(tree["scoped_session_ids"]) == {owned["id"], auto["id"]}
assert homeless["id"] not in tree["scoped_session_ids"]
def test_overview_drops_session_rows_but_keeps_counts_and_previews():
resolve = _resolver({"/repo": ("/repo", "/repo")})
sessions = [_session("/repo", branch="main") for _ in range(4)]
tree = pt.build_tree([], sessions, [], resolve, preview_limit=3, hydrate=False)
project = tree["projects"][0]
assert project["sessionCount"] == 4
assert len(project["previewSessions"]) == 3
# Lanes carry structure + counts but no rows in overview mode.
assert all(g["sessions"] == [] for repo in project["repos"] for g in repo["groups"])
assert project["repos"][0]["sessionCount"] == 4
def test_discovered_repo_with_no_sessions_becomes_zero_session_project():
discovered = [{"root": "/www/fresh", "label": "fresh", "sessions": 0, "last_active": 5}]
tree = pt.build_tree([], [], discovered, resolve=None, hydrate=False)
fresh = next(p for p in tree["projects"] if p["id"] == "/www/fresh")
assert fresh["isAuto"] is True
assert fresh["sessionCount"] == 0
assert fresh["repos"][0]["groups"] == []
def test_explicit_project_with_no_sessions_seeds_its_folders_as_repos():
# A brand-new (or unloaded) project must still expose its declared folders as
# repos so the entered view renders and the desktop's optimistic overlay has a
# lane to place a freshly-created session into (otherwise it only shows after a
# full tree refresh).
project = _project("p_new", "New", ["/work/blank"])
tree = pt.build_tree([project], [], [], resolve=None, hydrate=True)
node = next(p for p in tree["projects"] if p["id"] == "p_new")
assert node["sessionCount"] == 0
assert [r["path"] for r in node["repos"]] == ["/work/blank"]
assert node["repos"][0]["groups"] == []
def test_seeded_folder_repo_does_not_duplicate_a_session_derived_repo():
# When a folder already has sessions (same git root), seeding must not add a
# second repo for the same path.
project = _project("p_app", "App", ["/www/app"])
resolve = _resolver({"/www/app": ("/www/app", "/www/app")})
sessions = [_session("/www/app", branch="main")]
tree = pt.build_tree([project], sessions, [], resolve, hydrate=True)
node = next(p for p in tree["projects"] if p["id"] == "p_app")
assert [r["path"] for r in node["repos"]] == ["/www/app"]
def test_discovered_repo_owned_by_explicit_project_is_not_duplicated():
project = _project("p_app", "App", ["/www/app"])
discovered = [{"root": "/www/app", "label": "app", "sessions": 2, "last_active": 1}]
tree = pt.build_tree([project], [], discovered, resolve=None, hydrate=False)
assert [p["id"] for p in tree["projects"] if p["path"] == "/www/app"] == ["p_app"]
def test_nested_project_folders_pick_the_deepest_match():
# The folder index must resolve a session to its most-specific (deepest)
# project folder, not just any ancestor.
outer = _project("p_outer", "Outer", ["/work"])
inner = _project("p_inner", "Inner", ["/work/app"])
resolve = _resolver(
{
"/work/app": ("/work/app", "/work/app"),
"/work/other": ("/work/other", "/work/other"),
}
)
tree = pt.build_tree(
[outer, inner],
[_session("/work/app", branch="main"), _session("/work/other", branch="main")],
[],
resolve,
hydrate=True,
)
by_id = {p["id"]: p for p in tree["projects"]}
assert by_id["p_inner"]["sessionCount"] == 1 # /work/app → deepest folder wins
assert by_id["p_outer"]["sessionCount"] == 1 # /work/other → only the outer project
def test_junk_root_never_becomes_an_auto_project():
# A session whose git root is HERMES_HOME (config/state) must not spawn a
# phantom project; it falls through to flat Recents (unscoped). A real repo
# alongside it still groups normally.
resolve = _resolver(
{
"/home/me/.hermes": ("/home/me/.hermes", "/home/me/.hermes"),
"/www/app": ("/www/app", "/www/app"),
}
)
junk = _session("/home/me/.hermes", branch="main")
real = _session("/www/app", branch="main")
is_junk = lambda root: root == "/home/me/.hermes"
tree = pt.build_tree([], [junk, real], [], resolve, hydrate=True, is_junk_root=is_junk)
ids = {p["id"] for p in tree["projects"]}
assert ids == {"/www/app"}
assert junk["id"] not in tree["scoped_session_ids"]
assert real["id"] in tree["scoped_session_ids"]
def test_junk_root_is_dropped_from_the_discovered_tier():
discovered = [{"root": "/home/me/.hermes", "label": ".hermes", "sessions": 0, "last_active": 9}]
tree = pt.build_tree([], [], discovered, resolve=None, is_junk_root=lambda r: r == "/home/me/.hermes")
assert tree["projects"] == []
def test_colliding_repo_basenames_disambiguate_labels():
resolve = _resolver(
{
"/x/proj": ("/x/proj", "/x/proj"),
"/y/proj": ("/y/proj", "/y/proj"),
}
)
sessions = [_session("/x/proj", branch="main"), _session("/y/proj", branch="main")]
tree = pt.build_tree([], sessions, [], resolve, hydrate=True)
labels = sorted(p["label"] for p in tree["projects"])
assert labels == ["x/proj", "y/proj"]

View file

@ -0,0 +1,237 @@
"""Tests for the projects.* JSON-RPC methods on the tui_gateway server."""
from __future__ import annotations
import os
import subprocess
import pytest
import tui_gateway.server as server
def _call(method, params=None):
handler = server._methods[method]
resp = handler(1, params or {})
assert "error" not in resp, resp.get("error")
return resp["result"]
def test_methods_registered():
for m in (
"projects.list",
"projects.create",
"projects.get",
"projects.update",
"projects.add_folder",
"projects.remove_folder",
"projects.set_primary",
"projects.archive",
"projects.set_active",
"projects.for_cwd",
):
assert m in server._methods
def test_for_cwd_is_a_long_handler():
# git-probe handler must run off the dispatch thread.
assert "projects.for_cwd" in server._LONG_HANDLERS
def test_repo_root_cache_does_not_freeze_a_not_yet_repo(monkeypatch):
# We `git init` a new project's folder on first worktree; the cache must not
# have frozen the pre-init "" result, or the main lane mislabels by basename.
# Negative results are TTL-cached; TTL=0 here makes them expire immediately so
# this verifies the "never permanently frozen" contract directly.
from tui_gateway import git_probe
monkeypatch.setattr(git_probe, "_NEG_TTL", 0)
cwd = "/tmp/baby pics"
git_probe.invalidate()
state = {"root": ""} # flips once the folder becomes a repo
monkeypatch.setattr(git_probe, "run_git", lambda c, *a: state["root"] if c == cwd else "")
assert git_probe.repo_root(cwd) == "" # pre-init: not a repo (expires at once)
state["root"] = cwd # `git init` happened
assert git_probe.repo_root(cwd) == cwd # re-probed, not frozen
assert git_probe.repo_root(cwd) == cwd # now cached
def test_negative_results_are_ttl_cached_then_re_probed(monkeypatch):
# A non-repo cwd is re-derived on every session in a project-tree build, so a
# "not a repo" answer must be cached briefly to avoid re-spawning git dozens
# of times — but only until the TTL elapses, so a folder that later becomes a
# repo is still picked up.
from tui_gateway import git_probe
git_probe.invalidate()
calls = {"n": 0}
def probe(_cwd, *_a):
calls["n"] += 1
return "" # never a repo
monkeypatch.setattr(git_probe, "run_git", probe)
monkeypatch.setattr(git_probe, "_NEG_TTL", 1000) # effectively no expiry here
cwd = "/not/a/repo"
assert git_probe.repo_root(cwd) == ""
for _ in range(10):
assert git_probe.repo_root(cwd) == ""
assert calls["n"] == 1 # cached: probed once, not 11 times
# Once the TTL lapses, the next lookup re-probes (a `git init` may have run).
monkeypatch.setattr(git_probe, "_NEG_TTL", 0)
git_probe._cache._neg[cwd] = 0.0 # force-expire the cached negative
assert git_probe.repo_root(cwd) == ""
assert calls["n"] == 2
def test_repo_root_cache_is_single_flight(monkeypatch):
# Concurrent identical probes share one git invocation (gateway long handlers
# run on worker threads).
import threading
from tui_gateway import git_probe
git_probe.invalidate()
calls = {"n": 0}
started = threading.Event()
def slow(_cwd, *_a):
calls["n"] += 1
started.set()
time = __import__("time")
time.sleep(0.05)
return "/repo"
monkeypatch.setattr(git_probe, "run_git", slow)
out: list[str] = []
threads = [threading.Thread(target=lambda: out.append(git_probe.repo_root("/repo/x"))) for _ in range(6)]
for t in threads:
t.start()
for t in threads:
t.join()
assert out == ["/repo"] * 6
assert calls["n"] == 1
def test_warm_roots_probes_in_parallel_and_fills_the_cache(monkeypatch):
# Cold first paint must not serialize one git subprocess per cwd.
import threading
import time
from tui_gateway import git_probe
git_probe.invalidate()
lock = threading.Lock()
live = {"now": 0, "peak": 0, "calls": 0}
def slow(cwd, *_a):
with lock:
live["now"] += 1
live["calls"] += 1
live["peak"] = max(live["peak"], live["now"])
time.sleep(0.02)
with lock:
live["now"] -= 1
return cwd # show-toplevel → cwd is its own root
monkeypatch.setattr(git_probe, "run_git", slow)
cwds = [f"/repo{i}" for i in range(8)]
git_probe.warm_roots(cwds, max_workers=8)
assert live["peak"] > 1 # ran concurrently, not serialized
# Cache is warm: resolving again triggers no further probes.
before = live["calls"]
assert git_probe.repo_root("/repo0") == "/repo0"
assert live["calls"] == before
def test_create_list_roundtrip(tmp_path):
created = _call("projects.create", {"name": "Demo", "folders": [str(tmp_path)], "use": True})
assert created["project"]["slug"] == "demo"
listing = _call("projects.list")
assert [p["slug"] for p in listing["projects"]] == ["demo"]
assert listing["active_id"] == created["project"]["id"]
def test_add_folder_and_for_cwd(tmp_path):
folder = tmp_path / "repo"
folder.mkdir()
pid = _call("projects.create", {"name": "Repo", "folders": [str(folder)]})["project"]["id"]
nested = folder / "src"
nested.mkdir()
resolved = _call("projects.for_cwd", {"cwd": str(nested)})
assert resolved["project"]["id"] == pid
# branch key is present (empty string when not a git repo).
assert "branch" in resolved
def test_update_and_archive(tmp_path):
pid = _call("projects.create", {"name": "Orig", "folders": [str(tmp_path)]})["project"]["id"]
updated = _call("projects.update", {"id": pid, "name": "Renamed"})
assert updated["project"]["name"] == "Renamed"
payload = _call("projects.archive", {"id": pid})
assert all(p["id"] != pid or p["archived"] for p in payload["projects"])
def test_get_unknown_returns_error():
resp = server._methods["projects.get"](1, {"id": "nope"})
assert "error" in resp
def test_delete_removes_project(tmp_path):
pid = _call("projects.create", {"name": "Doomed", "folders": [str(tmp_path)]})["project"]["id"]
payload = _call("projects.delete", {"id": pid})
assert all(p["id"] != pid for p in payload["projects"])
assert "projects.delete" in server._methods
def test_discover_repos_is_registered_long_handler():
assert "projects.discover_repos" in server._methods
assert "projects.discover_repos" in server._LONG_HANDLERS
assert "projects.record_repos" in server._methods
assert "projects.record_repos" in server._LONG_HANDLERS
def test_record_repos_persists_and_shows_zero_session_repo(tmp_path):
repo = tmp_path / "fresh-repo"
repo.mkdir()
# Repo-first: a scanned repo with no hermes sessions still surfaces.
_call("projects.record_repos", {"repos": [{"root": str(repo), "label": "fresh-repo"}]})
by_label = {r["label"]: r for r in _call("projects.discover_repos")["repos"]}
assert "fresh-repo" in by_label
assert by_label["fresh-repo"]["sessions"] == 0
def test_discover_repos_from_full_history(tmp_path):
repo = tmp_path / "myrepo"
(repo / "src").mkdir(parents=True)
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
plain = tmp_path / "plain"
plain.mkdir()
db = server._get_db()
db.create_session("s1", "cli", cwd=str(repo))
db.create_session("s2", "cli", cwd=str(repo / "src"))
db.create_session("s3", "cli", cwd=str(plain)) # not a git repo → excluded
repos = _call("projects.discover_repos")["repos"]
by_label = {r["label"]: r for r in repos}
assert "myrepo" in by_label
assert by_label["myrepo"]["sessions"] == 2 # both repo cwds aggregate
assert "plain" not in by_label # non-git dir never promoted
# The probe is persisted back onto the session rows (membership at the source).
assert os.path.realpath(db.get_session("s1")["git_repo_root"]) == os.path.realpath(str(repo))

187
tui_gateway/git_probe.py Normal file
View file

@ -0,0 +1,187 @@
"""Git working-tree probing for the gateway: run git, resolve repo roots, fold
linked worktrees under their common root.
Probing runs where the gateway runs, so it resolves repos for both local and
remote backends (unlike the desktop's electron probe, which only sees the local
fs). Resolved roots are cached with a thread-safe, single-flight cache: the
gateway's long handlers run on worker threads, so concurrent identical probes
(e.g. two overlapping project-tree builds) share one `git` invocation instead of
racing an unguarded dict.
Positive results are cached for the process lifetime; negative results (a cwd
that isn't a git repo, or a deleted/nonexistent dir) are cached only for a short
TTL (`_NEG_TTL`). Caching negatives matters a lot for the desktop Projects tree:
``project_tree.build_tree`` resolves a cwd once *per session* (not per distinct
cwd), so a power user with hundreds of sessions in non-git/deleted dirs would
otherwise re-spawn ``git`` hundreds of times on *every* sidebar open the cause
of the multi-second "Projects" load. The TTL keeps a not-yet-repo cwd
re-probable (we `git init` a new project's folder on its first worktree, and a
frozen "" would mislabel its main lane by the dir basename) it just stops the
same "not a repo" answer from being re-derived dozens of times within one build
and across rapid re-opens. `invalidate()` drops everything after a known
mutation.
"""
from __future__ import annotations
import os
import subprocess
import threading
import time
from collections.abc import Iterable
from concurrent.futures import ThreadPoolExecutor
_GIT_TIMEOUT = 1.5
_WARM_WORKERS = 8
# How long a "not a git repo" answer stays cached before it's re-probed. Short
# enough that a freshly `git init`-ed / newly-created folder shows correctly
# within a few seconds; long enough to collapse the hundreds of redundant probes
# a single project-tree build (and rapid re-opens) would otherwise fire.
_NEG_TTL = 30.0
def run_git(cwd: str, *args: str) -> str:
"""``git -C <cwd> <args>`` → stripped stdout, or ``""`` on any failure."""
if not cwd:
return ""
try:
result = subprocess.run(
["git", "-C", cwd, *args],
capture_output=True,
text=True,
timeout=_GIT_TIMEOUT,
check=False,
stdin=subprocess.DEVNULL,
)
return result.stdout.strip() if result.returncode == 0 else ""
except Exception:
return ""
def branch(cwd: str) -> str:
return run_git(cwd, "branch", "--show-current") or run_git(cwd, "rev-parse", "--short", "HEAD")
class _RootCache:
"""Thread-safe, single-flight cache of git-root probes. Positive results are
cached for the process lifetime; negative ("not a repo") results are cached
only for ``_NEG_TTL`` seconds so a not-yet-repo cwd stays re-probable.
Followers wait on the leader's probe instead of duplicating it."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._roots: dict[str, str] = {}
self._neg: dict[str, float] = {} # key -> monotonic expiry
self._inflight: dict[str, threading.Event] = {}
def invalidate(self) -> None:
with self._lock:
self._roots.clear()
self._neg.clear()
self._inflight.clear()
def resolve(self, key: str, probe) -> str:
while True:
with self._lock:
hit = self._roots.get(key)
if hit:
return hit
expiry = self._neg.get(key)
if expiry is not None:
if expiry > time.monotonic():
# Recently probed as "not a repo" — trust it briefly
# instead of re-spawning git for the same dead/non-repo
# cwd on every session in the tree build.
return ""
# TTL elapsed: drop it and re-probe (it may be a repo now).
del self._neg[key]
gate = self._inflight.get(key)
if gate is None:
gate = threading.Event()
self._inflight[key] = gate
leader = True
else:
leader = False
if not leader:
# Another thread is probing this key — wait, then re-read.
gate.wait(timeout=_GIT_TIMEOUT + 0.5)
continue
value = ""
try:
value = probe()
finally:
with self._lock:
if value:
self._roots[key] = value
else:
self._neg[key] = time.monotonic() + _NEG_TTL
self._inflight.pop(key, None)
gate.set()
return value
_cache = _RootCache()
def invalidate() -> None:
"""Drop cached roots after a known mutation (e.g. a worktree was added)."""
_cache.invalidate()
def repo_root(cwd: str) -> str:
"""Top-level git repo root for ``cwd`` (``""`` when not a repo)."""
if not cwd:
return ""
return _cache.resolve(cwd, lambda: run_git(cwd, "rev-parse", "--show-toplevel"))
def common_repo_root(cwd: str) -> str:
"""The MAIN (common) repo root for ``cwd``, folding linked worktrees.
``--show-toplevel`` returns a linked worktree's OWN root, so grouping by it
splits every worktree into a separate "repo". The common ``.git`` dir
(``--git-common-dir``) is shared by a repo and all its worktrees, so its
parent is the one true repo root; fall back to the toplevel root otherwise.
"""
if not cwd:
return ""
def _probe() -> str:
gitdir = run_git(cwd, "rev-parse", "--path-format=absolute", "--git-common-dir")
if gitdir:
gitdir = os.path.realpath(gitdir)
if os.path.basename(gitdir) == ".git":
return os.path.dirname(gitdir)
return repo_root(cwd)
return _cache.resolve(f"common:{cwd}", _probe)
def resolve(cwd: str) -> dict | None:
"""Inject-able resolver for ``project_tree.build_tree``.
Returns ``{"repo_root": <common root>, "worktree_root": <this checkout>}``
or ``None`` when ``cwd`` is not in a git repo. ``build_tree`` treats
``worktree_root == repo_root`` as the main checkout.
"""
worktree_root = repo_root(cwd)
if not worktree_root:
return None
return {"repo_root": common_repo_root(cwd) or worktree_root, "worktree_root": worktree_root}
def warm_roots(cwds: Iterable[str], max_workers: int = _WARM_WORKERS) -> None:
"""Pre-resolve many cwds' roots in parallel (bounded) so a cold first paint
doesn't serialize one git subprocess per session cwd. Single-flight dedupes
overlap; results land in the shared cache for the sequential consumers."""
pending = sorted({(cwd or "").strip() for cwd in cwds} - {""})
if not pending:
return
if len(pending) == 1:
resolve(pending[0])
return
with ThreadPoolExecutor(max_workers=min(max_workers, len(pending))) as pool:
list(pool.map(resolve, pending))

558
tui_gateway/project_tree.py Normal file
View file

@ -0,0 +1,558 @@
"""Authoritative project -> repo -> lane -> session tree builder.
This is the single source of truth for how the desktop sidebar groups sessions
into projects, repos, and lanes. It is pure (all git resolution is injected via
``resolve``) so it can be unit-tested with fixtures and reused by the gateway's
``projects.tree`` / ``projects.project_sessions`` RPCs.
It deliberately mirrors the desktop's former client-side grouping (the old
``workspace-groups.ts``) so the emitted ids and lane keys stay byte-compatible
with the renderer's persisted state (pins, manual ordering, dismissal), which
all key off these exact strings:
- explicit project id .......... ``p_<hex>`` (from projects.db)
- auto/discovered project id ... the repo root path
- repo node id ................. the repo root path
- main branch lane id .......... ``<repoRoot>::branch::<branch>`` (or ``::branch::``)
- kanban bucket lane id ........ ``<repoRoot>::kanban``
- linked worktree lane id ...... the worktree path
The one correctness upgrade over the client version: linked worktrees are folded
under their MAIN repo via a git common-dir probe (injected as ``resolve``),
instead of being treated as separate repos (``git rev-parse --show-toplevel``
returns the worktree's own root, which is why the client double-counted them).
"""
from __future__ import annotations
import re
from typing import Any, Callable, Optional
# A cwd -> git identity resolver. Returns ``{"repo_root", "worktree_root"}`` where
# ``repo_root`` is the COMMON (main) repo root shared across worktrees and
# ``worktree_root`` is this cwd's own checkout root. Returns ``None`` when the
# cwd is not in a git repo (or cannot be probed, e.g. a remote backend).
Resolve = Callable[[str], Optional[dict]]
# Only KANBAN-TASK worktrees (`<repo>/.worktrees/t_<hex>`, the `t_…` id kanban_db
# mints) collapse into one lane; user-named "New worktree" dirs under
# `.worktrees/` stay as their own lanes.
_KANBAN_DIR_RE = re.compile(r"^(.*[/\\]\.worktrees)[/\\]t_[0-9a-f]+[/\\]?$")
_TRUNK_BRANCHES = {"main", "master", "trunk", "develop"}
DEFAULT_BRANCH_LABEL = "main"
def _branch_lane_id(repo_root: str, branch: str = "") -> str:
"""The one definition of a main-checkout lane id (must match the desktop)."""
return f"{repo_root}::branch::{(branch or '').strip()}"
def _kanban_lane_id(repo_root: str) -> str:
return f"{repo_root}::kanban"
# ---------------------------------------------------------------------------
# Path helpers (match the TS segment logic so labels/ids line up)
# ---------------------------------------------------------------------------
def _segments(path: str) -> list[str]:
return [s for s in re.split(r"[/\\]", (path or "").rstrip("/\\")) if s]
def base_name(path: str) -> str:
segs = _segments(path)
return segs[-1] if segs else ""
def kanban_worktree_dir(path: str) -> Optional[str]:
"""The ``<repo>/.worktrees`` dir for a ``.../.worktrees/<task>`` path, else None."""
m = _KANBAN_DIR_RE.match(path or "")
return m.group(1) if m else None
def _is_path_under(folder: str, target: str) -> bool:
"""True when ``target`` equals ``folder`` or is nested under it (segment-wise)."""
f = _segments(folder)
t = _segments(target)
if not f or len(f) > len(t):
return False
return all(f[i] == t[i] for i in range(len(f)))
def _with_base_name(path: str, name: str) -> str:
stripped = re.sub(r"[/\\]+$", "", path)
return re.sub(r"[^/\\]+$", name, stripped)
# ---------------------------------------------------------------------------
# Lane placement
# ---------------------------------------------------------------------------
def _placement(
repo_root: str,
lane_key: str,
lane_label: str,
lane_path: str,
is_main: bool,
is_kanban: bool,
) -> dict:
return {
"repo_key": repo_root,
"repo_label": base_name(repo_root) or repo_root,
"repo_path": repo_root,
"lane_key": lane_key,
"lane_label": lane_label,
"lane_path": lane_path,
"is_main": is_main,
"is_kanban": is_kanban,
}
def _place_by_heuristic(path: str) -> Optional[dict]:
"""Path-only fallback when there is no git probe and no persisted root."""
base = base_name(path)
if not base:
return None
kanban_dir = kanban_worktree_dir(path)
if kanban_dir:
repo_path = re.sub(r"[/\\]+$", "", _with_base_name(kanban_dir, ""))
return _placement(repo_path, _kanban_lane_id(repo_path), "kanban", kanban_dir, False, True)
m = re.match(r"^(.+)-wt-(.+)$", base)
if m:
repo_path = _with_base_name(path, m.group(1))
return _placement(repo_path, path, m.group(2), path, False, False)
return _placement(path, path, base, path, True, False)
def _place(cwd: str, branch: str, resolve: Optional[Resolve], persisted_root: str) -> Optional[dict]:
info = resolve(cwd) if resolve else None
if info and info.get("repo_root") and info.get("worktree_root"):
repo_root = info["repo_root"]
worktree_root = info["worktree_root"]
is_main = worktree_root == repo_root or bool(info.get("is_main"))
if is_main:
# Unrecorded branch folds into the one trunk lane, so a repo never
# shows two "main" lanes (recorded "main" + the empty-branch bucket).
b = (branch or "").strip() or DEFAULT_BRANCH_LABEL
return _placement(repo_root, _branch_lane_id(repo_root, b), b, repo_root, True, False)
kanban_dir = kanban_worktree_dir(worktree_root)
if kanban_dir:
return _placement(repo_root, _kanban_lane_id(repo_root), "kanban", kanban_dir, False, True)
label = base_name(worktree_root) or worktree_root
return _placement(repo_root, worktree_root, label, worktree_root, False, False)
# No live probe: trust the backend-persisted root (group by it, split main by
# the session's recorded branch). Kanban tasks still collapse by path shape.
if persisted_root:
kanban_dir = kanban_worktree_dir(cwd)
if kanban_dir:
return _placement(persisted_root, _kanban_lane_id(persisted_root), "kanban", kanban_dir, False, True)
b = (branch or "").strip() or DEFAULT_BRANCH_LABEL
return _placement(persisted_root, _branch_lane_id(persisted_root, b), b, persisted_root, True, False)
return _place_by_heuristic(cwd)
def _session_repo_root(session: dict, resolve: Optional[Resolve]) -> str:
"""The COMMON repo root a session belongs to (folds linked worktrees)."""
cwd = (session.get("cwd") or "").strip()
if cwd and resolve:
info = resolve(cwd)
if info and info.get("repo_root"):
return info["repo_root"]
return (session.get("git_repo_root") or "").strip()
# ---------------------------------------------------------------------------
# Ordering + label disambiguation (parity with the old client tree)
# ---------------------------------------------------------------------------
def _lane_sort_key(group: dict) -> tuple:
# Trunk pins to the top; the kanban aggregate sinks to the bottom; the rest
# (branches + linked worktrees) sort by most-recent activity, then label.
is_trunk = bool(group.get("isMain")) and group["label"].lower() in _TRUNK_BRANCHES
is_kanban = bool(group.get("isKanban"))
activity = max((_session_time(s) for s in group.get("sessions") or []), default=0.0)
return (
0 if is_trunk else 1,
1 if is_kanban else 0,
-activity,
group["label"].lower(),
)
def _sort_lanes(groups: list[dict]) -> list[dict]:
return sorted(groups, key=_lane_sort_key)
def _disambiguate_labels(items: list[dict]) -> None:
"""Grow colliding basenames into path-prefixed labels (in place)."""
by_label: dict[str, list[dict]] = {}
for item in items:
by_label.setdefault(item["label"], []).append(item)
for bucket in by_label.values():
pathed = [g for g in bucket if g.get("path")]
if len(pathed) < 2:
continue
parents = {id(g): _segments(g["path"])[:-1] for g in pathed}
max_depth = max(len(p) for p in parents.values())
depth = 1
while depth <= max_depth:
counts: dict[str, int] = {}
for g in pathed:
segs = parents[id(g)]
prefix = "/".join(segs[-depth:]) if depth else ""
base = base_name(g["path"]) or g["path"]
g["label"] = f"{prefix}/{base}" if prefix else base
counts[g["label"]] = counts.get(g["label"], 0) + 1
if all(c == 1 for c in counts.values()):
break
depth += 1
# ---------------------------------------------------------------------------
# Repo subtree assembly
# ---------------------------------------------------------------------------
def _session_time(session: dict) -> float:
return float(session.get("last_active") or session.get("started_at") or 0)
def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool) -> list[dict]:
"""Build the ``repo -> lane -> sessions`` subtree for a set of sessions."""
lanes: dict[str, dict] = {} # lane_key -> {group, repo_key, repo_label, repo_path}
for session in sessions:
cwd = (session.get("cwd") or "").strip()
if not cwd:
continue
placement = _place(
cwd,
(session.get("git_branch") or "").strip(),
resolve,
(session.get("git_repo_root") or "").strip(),
)
if not placement:
continue
entry = lanes.get(placement["lane_key"])
if entry is None:
entry = {
"group": {
"id": placement["lane_key"],
"label": placement["lane_label"],
"path": placement["lane_path"],
"isMain": placement["is_main"],
"isKanban": placement["is_kanban"],
"sessions": [],
},
"repo_key": placement["repo_key"],
"repo_label": placement["repo_label"],
"repo_path": placement["repo_path"],
}
lanes[placement["lane_key"]] = entry
entry["group"]["sessions"].append(session)
repos: dict[str, dict] = {}
for entry in lanes.values():
group = entry["group"]
group["sessions"].sort(key=_session_time, reverse=True)
count = len(group["sessions"])
if not hydrate:
group["sessions"] = []
repo = repos.get(entry["repo_key"])
if repo is None:
repo = {
"id": entry["repo_key"],
"label": entry["repo_label"],
"path": entry["repo_path"],
"groups": [],
"sessionCount": 0,
}
repos[entry["repo_key"]] = repo
repo["groups"].append(group)
repo["sessionCount"] += count
repo_list = list(repos.values())
for repo in repo_list:
repo["groups"] = _sort_lanes(repo["groups"])
_disambiguate_labels(repo["groups"])
_disambiguate_labels(repo_list)
return repo_list
def _seed_folder_repos(
repos: list[dict], folders: list[dict], resolve: Optional[Resolve]
) -> list[dict]:
"""Ensure every declared project folder shows as a repo, even with 0 sessions.
A brand-new project (or any project whose sessions haven't loaded yet) has an
empty session-derived ``repos`` list. That breaks two things on the desktop:
the entered-project view renders blank (it early-returns on no repos), and the
optimistic live-session overlay has no lane to drop a freshly-created session
into so a new session in the project only appears after a full tree refresh.
Seeding each folder as an empty repo fixes both: the overlay matches a new
session's cwd under the folder root, and the drill-in renders a real (if
empty) project body. Folders already covered by a session-derived repo (same
git root) are left untouched.
"""
seen = {r["id"] for r in repos} | {r["path"] for r in repos if r.get("path")}
seeded = list(repos)
for folder in folders or []:
raw = (folder.get("path") or "").strip()
if not raw:
continue
info = resolve(raw) if resolve else None
root = (info or {}).get("repo_root") or re.sub(r"[/\\]+$", "", raw)
if not root or root in seen:
continue
seeded.append({"id": root, "label": base_name(root) or root, "path": root, "groups": [], "sessionCount": 0})
seen.add(root)
if len(seeded) != len(repos):
_disambiguate_labels(seeded)
return seeded
# ---------------------------------------------------------------------------
# Explicit-project ownership
# ---------------------------------------------------------------------------
class _FolderIndex:
"""Maps a normalized folder path → (owning project, depth), so a session is
matched to its project by walking its cwd's ancestors (O(path depth) dict
lookups) instead of scanning every project × folder per session the
difference between O(sessions × projects) and O(sessions) at power-user scale.
"""
def __init__(self, projects: list[dict]) -> None:
self._by_path: dict[str, tuple[dict, int]] = {}
for project in projects:
for folder in project.get("folders") or []:
segs = _segments(folder.get("path") or "")
if not segs:
continue
key = "/".join(segs)
depth = len(segs)
# Deepest folder wins; ties keep the first project (scan order).
existing = self._by_path.get(key)
if existing is None or depth > existing[1]:
self._by_path[key] = (project, depth)
def match(self, target: str) -> tuple[Optional[dict], int]:
"""Owning project for ``target`` by longest ancestor folder, + its depth."""
segs = _segments(target or "")
# Longest prefix first → deepest (most specific) folder wins.
for end in range(len(segs), 0, -1):
hit = self._by_path.get("/".join(segs[:end]))
if hit:
return hit
return None, -1
def _project_for_path(index: _FolderIndex, target: str) -> Optional[dict]:
return index.match(target)[0]
def _project_for_session(session: dict, index: _FolderIndex, resolve: Optional[Resolve]) -> Optional[dict]:
cwd = (session.get("cwd") or "").strip()
if not cwd:
return None
repo_root = _session_repo_root(session, resolve)
candidates = [cwd, repo_root] if repo_root and repo_root != cwd else [cwd]
best: Optional[dict] = None
best_len = -1
for target in candidates:
match, length = index.match(target)
if match and length > best_len:
best_len = length
best = match
return best
# ---------------------------------------------------------------------------
# Public builder
# ---------------------------------------------------------------------------
def _project_node(
*,
pid: str,
label: str,
path: Optional[str],
repos: list[dict],
session_count: int,
last_active: float,
preview_sessions: list[dict],
color: Any = None,
icon: Any = None,
is_auto: bool = False,
) -> dict:
return {
"id": pid,
"label": label,
"path": path,
"color": color,
"icon": icon,
"isAuto": is_auto,
"sessionCount": session_count,
"lastActive": last_active,
"repos": repos,
"previewSessions": preview_sessions,
}
def build_tree(
projects: list[dict],
sessions: list[dict],
discovered_repos: list[dict],
resolve: Optional[Resolve] = None,
*,
preview_limit: int = 3,
hydrate: bool = False,
is_junk_root: Optional[Callable[[str], bool]] = None,
) -> dict:
"""Build the authoritative project tree.
``projects`` are ``projects_db.Project.to_dict()`` shapes (non-archived).
``sessions`` are projected session-row dicts (must carry ``id``, ``cwd``,
``git_branch``, ``git_repo_root``, ``started_at``, ``last_active``).
``discovered_repos`` are ``{"root", "label", "sessions", "last_active"}``.
``is_junk_root`` flags roots that must never become an AUTO project (the
bare home dir, the HERMES_HOME subtree) their sessions fall through to the
flat Recents list. User-created projects are honored regardless.
Returns ``{"projects": [...], "scoped_session_ids": [...]}``. When
``hydrate`` is False (overview), lane ``sessions`` arrays are emptied but
every count is preserved and each project carries up to ``preview_limit``
``previewSessions``. When True (drill-in), lanes carry full session rows.
"""
active_projects = [p for p in projects if not p.get("archived")]
_junk = is_junk_root or (lambda _root: False)
folder_index = _FolderIndex(active_projects)
by_project: dict[str, list[dict]] = {}
unowned: list[dict] = []
for session in sessions:
owner = _project_for_session(session, folder_index, resolve)
if owner:
by_project.setdefault(owner["id"], []).append(session)
else:
unowned.append(session)
scoped_ids: list[str] = []
def _previews(project_sessions: list[dict]) -> list[dict]:
if preview_limit <= 0:
return []
ordered = sorted(project_sessions, key=_session_time, reverse=True)
return ordered[:preview_limit]
def _last_active(project_sessions: list[dict]) -> float:
return max((_session_time(s) for s in project_sessions), default=0.0)
result: list[dict] = []
# Tier 1: explicit, user-created projects (always shown, even with 0 sessions).
for project in active_projects:
psessions = by_project.get(project["id"], [])
scoped_ids.extend(s["id"] for s in psessions if s.get("id"))
repos = _seed_folder_repos(
_build_repos(psessions, resolve, hydrate), project.get("folders") or [], resolve
)
result.append(
_project_node(
pid=project["id"],
label=project.get("name") or project["id"],
path=project.get("primary_path"),
color=project.get("color"),
icon=project.get("icon"),
repos=repos,
session_count=len(psessions),
last_active=_last_active(psessions),
preview_sessions=_previews(psessions),
)
)
# Tier 2: auto projects from leftover sessions, one per common git repo root.
by_repo: dict[str, list[dict]] = {}
for session in unowned:
root = _session_repo_root(session, resolve)
if root:
by_repo.setdefault(root, []).append(session)
seen: set[str] = set()
for repo_root, repo_sessions in by_repo.items():
# The home dir / HERMES_HOME subtree is config + state, never a project;
# its sessions stay loose in Recents (not scoped to a phantom project).
if _junk(repo_root):
continue
repos = _build_repos(repo_sessions, resolve, hydrate)
repo_node = next((r for r in repos if r["id"] == repo_root or r["path"] == repo_root), None)
if repo_node is None:
continue
seen.add(repo_root)
scoped_ids.extend(s["id"] for s in repo_sessions if s.get("id"))
result.append(
_project_node(
pid=repo_root,
label=base_name(repo_root) or repo_root,
path=repo_root,
repos=repos,
session_count=repo_node["sessionCount"],
last_active=_last_active(repo_sessions),
preview_sessions=_previews(repo_sessions),
is_auto=True,
)
)
# Tier 3: repos discovered from full history / disk scan with no loaded
# sessions, folded to their common root and not owned by an explicit project.
for repo in discovered_repos or []:
raw_root = (repo.get("root") or "").strip()
if not raw_root:
continue
info = resolve(raw_root) if resolve else None
root = (info or {}).get("repo_root") or raw_root
if root in seen or _junk(root) or _project_for_path(folder_index, root):
continue
seen.add(root)
label = repo.get("label") or base_name(root) or root
result.append(
_project_node(
pid=root,
label=label,
path=root,
repos=[{"id": root, "label": label, "path": root, "groups": [], "sessionCount": 0}],
session_count=int(repo.get("sessions") or 0),
last_active=float(repo.get("last_active") or 0),
preview_sessions=[],
is_auto=True,
)
)
# Auto projects are labelled by repo basename, which can collide (two "app"
# repos in different parents). Grow path prefixes so each is distinct.
# Explicit projects keep their user-chosen names untouched.
_disambiguate_labels([p for p in result if p.get("isAuto")])
return {"projects": result, "scoped_session_ids": scoped_ids}

View file

@ -25,6 +25,7 @@ from hermes_constants import (
)
from hermes_cli.env_loader import load_hermes_dotenv
from utils import is_truthy_value
from tui_gateway import git_probe
from tui_gateway.transport import (
StdioTransport,
Transport,
@ -191,6 +192,11 @@ _LONG_HANDLERS = frozenset(
"pet.select",
"pet.thumb",
"plugins.manage",
"projects.discover_repos",
"projects.record_repos",
"projects.for_cwd",
"projects.tree",
"projects.project_sessions",
"session.branch",
"session.compress",
"session.resume",
@ -1363,31 +1369,14 @@ def _terminal_task_cwd(session: dict | None) -> str:
return _session_cwd(session)
def _git_branch_for_cwd(cwd: str) -> str:
try:
result = subprocess.run(
["git", "-C", cwd, "branch", "--show-current"],
capture_output=True,
text=True,
timeout=1.5,
check=False,
stdin=subprocess.DEVNULL,
)
if result.returncode == 0:
branch = result.stdout.strip()
if branch:
return branch
head = subprocess.run(
["git", "-C", cwd, "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
timeout=1.5,
check=False,
stdin=subprocess.DEVNULL,
)
return head.stdout.strip() if head.returncode == 0 else ""
except Exception:
return ""
# Git working-tree probing (run git, resolve roots, fold worktrees) lives in a
# focused, single-flight-cached module; these stay as the in-server names every
# call site already uses.
_git = git_probe.run_git
_git_branch_for_cwd = git_probe.branch
_git_repo_root_for_cwd = git_probe.repo_root
_git_common_repo_root_for_cwd = git_probe.common_repo_root
_resolve_cwd_git = git_probe.resolve
def _session_cwd(session: dict | None) -> str:
@ -1396,6 +1385,75 @@ def _session_cwd(session: dict | None) -> str:
return _completion_cwd()
def _heal_dead_cwd(cwd: str) -> str:
"""Resolve a session cwd that points at a now-deleted directory.
A session anchored to a linked worktree (``<repo>/.worktrees/<name>``) keeps
that path after the worktree is removed (branch merged, `git worktree
remove`, etc). The literal dir is gone, so a probe of it returns nothing and
the composer shows no branch while the sidebar still folds the path up to
the repo's main lane. Heal the mismatch: walk up to the first existing
ancestor, then resolve its common git root, so a dead-worktree cwd collapses
to the live repo root (and its real current branch).
Only meaningful for local backends; a remote/SSH cwd may legitimately not
exist on the host, so callers must skip healing there.
"""
raw = (cwd or "").strip()
if not raw or os.path.isdir(raw):
return raw
probe = raw
# Climb to the first ancestor that still exists on disk.
for _ in range(64):
parent = os.path.dirname(probe)
if not parent or parent == probe:
break
probe = parent
if os.path.isdir(probe):
break
if not os.path.isdir(probe):
return raw
try:
root = _git_common_repo_root_for_cwd(probe) or _git_repo_root_for_cwd(probe)
except Exception:
root = ""
return root or probe
def _is_local_terminal_backend() -> bool:
backend = (os.environ.get("TERMINAL_ENV") or "").strip().lower()
return not backend or backend == "local"
def _display_session_cwd(session: dict | None) -> str:
"""Session cwd for display/probe surfaces, healed past deleted worktrees.
Persists the healed value back to the session row (best-effort, local only)
so the next load is already coherent and the sidebar lane stops showing a
session pinned to a vanished path.
"""
cwd = _session_cwd(session)
if not _is_local_terminal_backend():
return cwd
healed = _heal_dead_cwd(cwd)
if healed and healed != cwd and session is not None:
session["cwd"] = healed
try:
with _session_db(session) as db:
if db is not None:
db.update_session_cwd(session.get("session_key", ""), healed)
except Exception:
logger.debug("failed to persist healed session cwd", exc_info=True)
_persist_session_git_meta(session, healed)
return healed
def _session_source(session: dict | None) -> str:
if session:
source = str(session.get("source") or "").strip()
@ -1500,12 +1558,19 @@ def _ensure_session_db_row(session: dict) -> None:
model_config["reasoning_config"] = reasoning
if tier := session.get("create_service_tier_override"):
model_config["service_tier"] = tier
# Branch lineage: stamp the same ``_branched_from`` marker the TUI /branch
# uses so list_sessions_rich keeps the branch listed and the desktop sidebar
# can nest it under its parent.
parent_session_id = session.get("parent_session_id") or None
if parent_session_id:
model_config["_branched_from"] = parent_session_id
try:
db.create_session(
key,
source=_session_source(session),
model=row_model,
model_config=model_config or None,
parent_session_id=parent_session_id,
cwd=_session_cwd(session) if session.get("explicit_cwd") else None,
)
except Exception:
@ -1518,6 +1583,35 @@ def _ensure_session_db_row(session: dict) -> None:
pass
def _persist_branch_seed(session: dict) -> None:
"""First-turn persist of a branch's copied transcript.
A branch is a draft until its first submit: the parent's messages live only
in ``session["history"]`` (they ride into the agent as ``conversation_history``,
which ``_flush_messages_to_session_db`` skips by identity). Without this the
branch row would resume missing its pre-branch context. Runs once; the row +
parent link are written by ``_ensure_session_db_row`` just before this.
"""
if not session.get("parent_session_id") or session.get("_branch_seed_persisted"):
return
key = session.get("session_key")
if not key:
return
with session["history_lock"]:
seed = [dict(msg) for msg in (session.get("history") or [])]
if not seed:
return
with _session_db(session) as db:
if db is None:
return
try:
for msg in seed:
db.append_message(session_id=key, role=msg.get("role", "user"), content=msg.get("content"))
session["_branch_seed_persisted"] = True
except Exception:
logger.debug("branch seed persist failed", exc_info=True)
@contextlib.contextmanager
def _session_db(session: dict):
"""Yield the SessionDB that owns this session's row (profile-aware).
@ -1546,6 +1640,41 @@ def _session_db(session: dict):
db.close()
def _persist_session_git_meta(session: dict, cwd: str) -> None:
"""Resolve + persist a session's git branch / repo root WITHOUT blocking.
Branch and root come from ``git`` subprocess probes; running them inline on
the session-init / cwd-set path would stall startup whenever ``cwd`` is slow
or on an unreachable mount. Run them on a short-lived daemon thread instead
and persist via the same profile-aware db the caller writes ``cwd`` to.
Best-effort: ``cwd`` itself is persisted synchronously by the caller, so a
probe failure just leaves these enrichment columns unset (the project tree
falls back to its live resolver / lazy backfill). Daemon, so a mid-flight
probe never delays gateway shutdown.
"""
session_key = session.get("session_key", "")
if not session_key or not cwd:
return
# Snapshot the routing fields now; the live session dict may be gone by the
# time the thread runs. `_session_db` reopens the profile-correct db inside.
db_session = {"session_key": session_key, "profile_home": session.get("profile_home")}
def _run() -> None:
try:
branch = _git_branch_for_cwd(cwd)
root = _git_common_repo_root_for_cwd(cwd)
if not (branch or root):
return
with _session_db(db_session) as db:
if db is not None:
db.update_session_cwd(session_key, cwd, branch, root)
except Exception:
logger.debug("failed to persist session git metadata", exc_info=True)
threading.Thread(target=_run, name="git-meta", daemon=True).start()
def _set_session_cwd(session: dict, cwd: str) -> str:
resolved = os.path.abspath(os.path.expanduser(str(cwd)))
if not os.path.isdir(resolved):
@ -1561,6 +1690,8 @@ def _set_session_cwd(session: dict, cwd: str) -> str:
db.update_session_cwd(session.get("session_key", ""), resolved)
except Exception:
logger.debug("failed to persist session cwd", exc_info=True)
# Branch/repo-root probes are git subprocesses — capture them off the hot path.
_persist_session_git_meta(session, resolved)
try:
from tools.terminal_tool import cleanup_vm
@ -2240,7 +2371,11 @@ def _load_enabled_toolsets() -> list[str] | None:
selection = coding_selection(platform="tui")
if selection is not None:
return selection
# Fold in `project` here too: this is a GUI-only resolver, and
# the focus-mode coding posture returns before the fallback path
# that normally adds it — without this the desktop loses the
# project tools exactly when sitting in a repo (see below).
return sorted({*selection, "project"})
except Exception:
pass
@ -2346,12 +2481,18 @@ def _load_enabled_toolsets() -> list[str] | None:
# list without baking in implicit MCP defaults. Using the wrong
# variant at agent creation time makes MCP tools silently missing
# from the TUI. See PR #3252 for the original design split.
enabled = sorted(
_get_platform_tools(cfg, "cli", include_default_mcp_servers=True)
)
enabled = _get_platform_tools(cfg, "cli", include_default_mcp_servers=True)
if fallback_notice is not None:
print(fallback_notice, file=sys.stderr, flush=True)
return enabled or None
if not enabled:
return None
# The desktop Project tools are off _HERMES_CORE_TOOLS (every other
# platform would carry their schema for nothing), so the platform
# recovery above — which keys off hermes-cli's tool universe — can't
# surface them. This resolver runs ONLY in the desktop/TUI gateway, so
# folding in the `project` toolset here is the gate that exposes them on
# exactly the surface that can follow a project move.
return sorted(enabled | {"project"})
except Exception:
if fallback_notice is not None:
print(
@ -2914,7 +3055,7 @@ def _session_info(agent, session: dict | None = None) -> dict:
if candidate.get("agent") is agent:
session = candidate
break
cwd = _session_cwd(session)
cwd = _display_session_cwd(session)
session_key = str(
(session or {}).get("session_key") or getattr(agent, "session_id", "") or ""
)
@ -3466,11 +3607,67 @@ def _agent_cbs(sid: str) -> dict:
}
def _apply_project_workspace(task_id: str, path: str, _name: str = "") -> None:
"""Intentional workspace move from the project_* tools: re-anchor the live
session's cwd to the chosen project's folder and push session.info so the
desktop follows (refresh tree + scope into the project). This is the ONLY
auto-cwd path driven by an explicit tool call, never a terminal `cd`."""
if not path:
return
# The tool's task_id is the durable session_key, but _sessions is keyed by a
# short sid uuid (and the desktop routes events by that sid). Resolve it.
key = str(task_id or "")
sid = ""
session = None
with _sessions_lock:
if key in _sessions:
sid, session = key, _sessions[key]
else:
for cand_sid, cand in _sessions.items():
if cand.get("session_key") == key or getattr(cand.get("agent"), "session_id", None) == key:
sid, session = cand_sid, cand
break
if session is None:
return
resolved = os.path.abspath(os.path.expanduser(str(path)))
if not os.path.isdir(resolved):
return
session["cwd"] = resolved
session["explicit_cwd"] = True
_register_session_cwd(session)
with _session_db(session) as db:
if db is not None:
try:
db.update_session_cwd(session.get("session_key", ""), resolved)
except Exception:
logger.debug("failed to persist project workspace cwd", exc_info=True)
_persist_session_git_meta(session, resolved)
try:
agent = session.get("agent")
info = (
_session_info(agent, session)
if agent is not None
else {"cwd": resolved, "branch": _git_branch_for_cwd(resolved), "lazy": True}
)
_emit("session.info", sid, info)
except Exception:
logger.debug("failed to emit session.info after project workspace move", exc_info=True)
def _wire_callbacks(sid: str):
from tools.terminal_tool import set_sudo_password_callback
from tools.skills_tool import set_secret_capture_callback
from tools.project_tools import set_project_workspace_callback
set_sudo_password_callback(lambda: _block("sudo.request", sid, {}, timeout=120))
set_project_workspace_callback(_apply_project_workspace)
def secret_cb(env_var, prompt, metadata=None):
pl = {"prompt": prompt, "env_var": env_var}
@ -4119,7 +4316,10 @@ def _init_session(
_sessions[sid]["cwd"] = row["cwd"]
else:
try:
db.update_session_cwd(key, _sessions[sid]["cwd"])
_cwd = _sessions[sid]["cwd"]
db.update_session_cwd(key, _cwd)
# git branch/root probes run off the hot path (see _set_session_cwd).
_persist_session_git_meta(_sessions[sid], _cwd)
except Exception:
logger.debug("failed to persist resumed session cwd", exc_info=True)
_register_session_cwd(_sessions[sid])
@ -4592,6 +4792,10 @@ def _(rid, params: dict) -> dict:
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
@ -4663,6 +4867,7 @@ def _(rid, params: dict) -> dict:
"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,
"profile_home": str(profile_home) if profile_home is not None else None,
"running": False,
@ -4675,6 +4880,7 @@ def _(rid, params: dict) -> dict:
"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
@ -7469,8 +7675,28 @@ def _(rid, params: dict) -> dict:
session, err = _sess(params, rid)
if err:
return err
if hasattr(session["agent"], "interrupt"):
# Safety net: if the turn's run thread is already gone but `running` stayed
# stuck (a crash/desync that skipped the run loop's `finally`), force-clear it
# so the session can't be permanently bricked at 4009 "session busy" — every
# send/restore/resume would otherwise reject until a full backend restart.
# A genuinely live turn is left alone: its cooperative interrupt + `finally`
# release `running` the normal way; clearing it here would let a second turn
# race the first on the same session.
run_thread = session.get("_run_thread")
run_thread_alive = run_thread is not None and run_thread.is_alive()
should_interrupt = bool(session.get("running")) and run_thread_alive
if should_interrupt and hasattr(session["agent"], "interrupt"):
session["agent"].interrupt()
if not run_thread_alive:
with session["history_lock"]:
if session.get("running"):
session["running"] = False
_clear_inflight_turn(session)
# Stop = stop the TURN (cooperative interrupt above also kills the in-flight
# foreground subprocess). Background processes the agent started (dev servers,
# watchers) are intentionally left running — kill those individually with the
# "x" on the task row (process.kill). Don't reap them here.
# Scope the pending-prompt release to THIS session. A global
# _clear_pending() would collaterally cancel clarify/sudo/secret
# prompts on unrelated sessions sharing the same tui_gateway
@ -7796,6 +8022,9 @@ def _(rid, params: dict) -> dict:
# Persist the DB row lazily, now that the user has actually sent a message.
_ensure_session_db_row(session)
# A branch becomes real here: copy its parent's transcript into the row so it
# resumes with full context (the agent won't persist the seed itself).
_persist_branch_seed(session)
_start_agent_build(sid, session)
def run_after_agent_ready() -> None:
@ -7816,7 +8045,11 @@ def _(rid, params: dict) -> dict:
return
_run_prompt_submit(rid, sid, session, text)
threading.Thread(target=run_after_agent_ready, daemon=True).start()
run_thread = threading.Thread(target=run_after_agent_ready, daemon=True)
# Keep a handle so session.interrupt can tell a live turn from a stuck
# `running` flag (a turn that died without clearing it) and recover the latter.
session["_run_thread"] = run_thread
run_thread.start()
return _ok(rid, {"status": "streaming"})
@ -8025,6 +8258,11 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
if not isinstance(session.get("inflight_turn"), dict):
_start_inflight_turn(session, text)
agent = session["agent"]
if hasattr(agent, "clear_interrupt"):
try:
agent.clear_interrupt()
except Exception:
pass
_emit("message.start", sid)
def run():
@ -8329,12 +8567,19 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
try:
from agent.title_generator import maybe_auto_title
_title_key = session.get("session_key") or sid
maybe_auto_title(
_get_db(),
session.get("session_key") or sid,
_title_key,
text,
raw,
session.get("history", []),
# Push the generated title live so the sidebar renames
# without waiting for the next list refresh (the titler
# runs async, after this turn's refresh already fired).
title_callback=lambda t, _k=_title_key: _emit(
"session.title", sid, {"session_id": _k, "title": t}
),
)
except Exception:
pass
@ -9803,6 +10048,438 @@ def _(rid, params: dict) -> dict:
return _err(rid, 4002, f"unknown config key: {key}")
# ---------------------------------------------------------------------------
# Projects — first-class, per-profile, multi-folder workspaces
# ---------------------------------------------------------------------------
# JSON-RPC error codes for the projects surface.
_E_PROJECTS = 5061 # generic failure
_E_NO_PROJECT = 5062 # id resolved to nothing
_E_PROJECT_ARG = 5063 # invalid argument (e.g. bad name/slug)
class _NoProject(Exception):
"""Raised inside a projects handler when ``params['id']`` resolves to None."""
def _projects_payload(conn) -> dict:
from hermes_cli import projects_db as pdb
return {
"projects": [p.to_dict() for p in pdb.list_projects(conn, include_archived=True)],
"active_id": pdb.get_active_id(conn),
}
def _projects_method(name: str):
"""Register a projects RPC, injecting (pdb, conn) and unifying error mapping.
Every project CRUD handler opened the per-profile DB, mapped a missing id to
5062, bad args to 5063, and everything else to 5061. This collapses that
boilerplate so each handler is just its one meaningful operation.
"""
def decorator(fn):
@method(name)
def handler(rid, params: dict) -> dict:
try:
from hermes_cli import projects_db as pdb
with pdb.connect_closing() as conn:
return fn(rid, params, pdb, conn)
except _NoProject:
return _err(rid, _E_NO_PROJECT, "no such project")
except ValueError as e:
return _err(rid, _E_PROJECT_ARG, str(e))
except Exception as e:
return _err(rid, _E_PROJECTS, str(e))
return handler
return decorator
def _require_project(pdb, conn, params: dict):
"""The project named by ``params['id']`` (or raise ``_NoProject``)."""
proj = pdb.get_project(conn, str(params.get("id") or ""))
if proj is None:
raise _NoProject
return proj
@_projects_method("projects.list")
def _(rid, params, pdb, conn) -> dict:
return _ok(rid, _projects_payload(conn))
@_projects_method("projects.get")
def _(rid, params, pdb, conn) -> dict:
return _ok(rid, {"project": _require_project(pdb, conn, params).to_dict()})
@_projects_method("projects.create")
def _(rid, params, pdb, conn) -> dict:
pid = pdb.create_project(
conn,
name=str(params.get("name") or ""),
slug=params.get("slug"),
folders=params.get("folders") or [],
primary_path=params.get("primary_path"),
description=params.get("description"),
icon=params.get("icon"),
color=params.get("color"),
board_slug=params.get("board_slug"),
)
if params.get("use"):
pdb.set_active(conn, pid)
proj = pdb.get_project(conn, pid)
return _ok(rid, {"project": proj.to_dict() if proj else None})
@_projects_method("projects.update")
def _(rid, params, pdb, conn) -> dict:
proj = _require_project(pdb, conn, params)
pdb.update_project(
conn,
proj.id,
name=params.get("name"),
description=params.get("description"),
icon=params.get("icon"),
color=params.get("color"),
board_slug=params.get("board_slug"),
)
return _ok(rid, {"project": pdb.get_project(conn, proj.id).to_dict()})
@_projects_method("projects.add_folder")
def _(rid, params, pdb, conn) -> dict:
proj = _require_project(pdb, conn, params)
pdb.add_folder(
conn,
proj.id,
str(params.get("path") or ""),
label=params.get("label"),
is_primary=bool(params.get("is_primary")),
)
return _ok(rid, {"project": pdb.get_project(conn, proj.id).to_dict()})
@_projects_method("projects.remove_folder")
def _(rid, params, pdb, conn) -> dict:
proj = _require_project(pdb, conn, params)
pdb.remove_folder(conn, proj.id, str(params.get("path") or ""))
return _ok(rid, {"project": pdb.get_project(conn, proj.id).to_dict()})
@_projects_method("projects.set_primary")
def _(rid, params, pdb, conn) -> dict:
proj = _require_project(pdb, conn, params)
pdb.set_primary(conn, proj.id, str(params.get("path") or ""))
return _ok(rid, {"project": pdb.get_project(conn, proj.id).to_dict()})
@_projects_method("projects.archive")
def _(rid, params, pdb, conn) -> dict:
proj = _require_project(pdb, conn, params)
(pdb.restore_project if params.get("restore") else pdb.archive_project)(conn, proj.id)
return _ok(rid, _projects_payload(conn))
@_projects_method("projects.delete")
def _(rid, params, pdb, conn) -> dict:
proj = _require_project(pdb, conn, params)
pdb.delete_project(conn, proj.id)
return _ok(rid, _projects_payload(conn))
@_projects_method("projects.set_active")
def _(rid, params, pdb, conn) -> dict:
pdb.set_active(conn, _require_project(pdb, conn, params).id if params.get("id") else None)
return _ok(rid, {"active_id": pdb.get_active_id(conn)})
@_projects_method("projects.for_cwd")
def _(rid, params, pdb, conn) -> dict:
cwd = _completion_cwd({"cwd": str(params.get("cwd") or "").strip()} if params.get("cwd") else {})
proj = pdb.project_for_path(conn, cwd)
return _ok(rid, {"project": proj.to_dict() if proj else None, "cwd": cwd, "branch": _git_branch_for_cwd(cwd)})
def _is_repo_junk(root: str) -> bool:
"""A git root we never auto-surface as a project: the bare home dir or
anything under HERMES_HOME (~/.hermes by default) config/sessions/skills,
not a workspace. User-created projects pointing there are still honored."""
if not root:
return True
from hermes_constants import get_hermes_home
real = os.path.realpath(root)
home = os.path.realpath(os.path.expanduser("~"))
hermes_home = os.path.realpath(str(get_hermes_home()))
return real == home or real == hermes_home or real.startswith(hermes_home + os.sep)
def _discover_repos_payload(db, *, conn=None, backfill: bool = True) -> list[dict]:
"""Merge filesystem-scanned repos (cached) with session-derived repo roots.
Repo-first: the disk scan (persisted by `projects.record_repos`) surfaces
repos even with zero hermes sessions. Session-derived roots cover repos
outside the scan roots. Both are junk-filtered (hermes home subtree + bare
home) and carry their session totals for the overview.
``conn`` reuses an already-open projects.db connection (the tree path holds
one); ``backfill`` persists resolved roots back onto session rows kept off
the per-turn tree path (grouping uses the live git resolver regardless) and
done only on the explicit discover/record refresh.
"""
_is_junk = _is_repo_junk
repos: dict[str, dict] = {}
def _agg(root: str) -> dict:
return repos.setdefault(root, {"root": root, "label": "", "sessions": 0, "last_active": 0.0})
# Session-derived roots (common repo root, folding worktrees; cached) +
# backfill the column so persisted git_repo_root matches the tree grouping.
cwd_rows = list(db.distinct_session_cwds())
# Warm the per-cwd git probes in parallel so a cold first paint doesn't
# serialize one subprocess per distinct cwd before this loop reads the cache.
git_probe.warm_roots(str(r.get("cwd") or "") for r in cwd_rows)
cwd_to_root: dict[str, str] = {}
for row in cwd_rows:
cwd = str(row.get("cwd") or "")
root = _git_common_repo_root_for_cwd(cwd)
if not root:
continue
cwd_to_root[cwd] = root
if _is_junk(root):
continue
agg = _agg(root)
agg["sessions"] += int(row.get("sessions") or 0)
agg["last_active"] = max(agg["last_active"], float(row.get("last_active") or 0))
if backfill:
try:
db.backfill_repo_roots(cwd_to_root)
except Exception:
logger.debug("failed to backfill repo roots", exc_info=True)
# Filesystem-scanned roots from the cache (may have zero sessions). Reuse the
# caller's projects.db connection when given, else open a short-lived one.
try:
from hermes_cli import projects_db as pdb
def _read(c) -> None:
for entry in pdb.list_discovered_repos(c):
root = str(entry.get("root") or "")
if not root or _is_junk(root):
continue
agg = _agg(root)
if entry.get("label"):
agg["label"] = entry["label"]
agg["last_active"] = max(agg["last_active"], float(entry.get("last_seen") or 0))
if conn is not None:
_read(conn)
else:
with pdb.connect_closing() as own:
_read(own)
except Exception:
logger.debug("failed to read discovered repo cache", exc_info=True)
out = sorted(repos.values(), key=lambda r: r["last_active"], reverse=True)
for r in out:
r["label"] = r["label"] or os.path.basename(r["root"].rstrip("/\\")) or r["root"]
return out
@method("projects.discover_repos")
def _(rid, params: dict) -> dict:
"""Repos for the desktop overview: scanned-from-disk (cached) session-derived."""
try:
db = _get_db()
if db is None:
return _ok(rid, {"repos": []})
return _ok(rid, {"repos": _discover_repos_payload(db)})
except Exception as e:
return _err(rid, 5061, str(e))
@method("projects.record_repos")
def _(rid, params: dict) -> dict:
"""Persist git repo roots found by the client's filesystem scan, then return
the merged repo list. The native crawl runs on the desktop (local fs); this
caches the result so later reads are instant instead of re-walking disk."""
try:
from hermes_cli import projects_db as pdb
pairs: list[tuple[str, str | None]] = []
for item in params.get("repos") or []:
if isinstance(item, str):
pairs.append((item, None))
elif isinstance(item, dict) and item.get("root"):
pairs.append((str(item["root"]), item.get("label")))
with pdb.connect_closing() as conn:
pdb.record_discovered_repos(conn, pairs, replace=True)
db = _get_db()
return _ok(rid, {"repos": _discover_repos_payload(db) if db is not None else []})
except Exception as e:
return _err(rid, 5061, str(e))
# Sources excluded from the project tree: cron runs and tool/subagent children
# are not user conversations. Subagent/compression children are already dropped
# by list_sessions_rich(include_children=False); cron has its own section.
_PROJECT_TREE_EXCLUDED_SOURCES = ["cron"]
def _project_tree_row(r: dict) -> dict:
"""Project a SessionDB row to the minimal shape the sidebar renders.
Keeps the fields the grouping needs (cwd / git_branch / git_repo_root) plus
everything ``SidebarSessionRow`` reads, and drops the heavy columns
(system_prompt, model_config, ...) so the tree payload stays lean.
"""
return {
"id": r.get("id"),
"_lineage_root_id": r.get("_lineage_root_id"),
# The sidebar nests branch/fork sessions under their parent
# (flattenSessionsWithBranches keys on this); without it, lane rows can't
# draw the └─ connector the flat Recents list shows.
"parent_session_id": r.get("parent_session_id"),
"title": r.get("title"),
"preview": r.get("preview"),
"started_at": r.get("started_at") or 0,
"ended_at": r.get("ended_at"),
"last_active": r.get("last_active") or r.get("started_at") or 0,
"source": r.get("source"),
"archived": bool(r.get("archived")),
"message_count": r.get("message_count") or 0,
"tool_call_count": r.get("tool_call_count") or 0,
"input_tokens": r.get("input_tokens") or 0,
"output_tokens": r.get("output_tokens") or 0,
"model": r.get("model"),
"is_active": False,
"cwd": r.get("cwd"),
"git_branch": r.get("git_branch"),
"git_repo_root": r.get("git_repo_root"),
}
def _project_tree_inputs(
db, session_limit: int, *, include_discovered: bool
) -> tuple[list[dict], list[dict], list[dict], str | None]:
"""Gather (sessions, projects, discovered_repos, active_id) for build_tree.
``include_discovered`` is the zero-session-repo overview tier; the entered
view (drill-in) skips it entirely it only needs the project it's showing,
which already has sessions avoiding the distinct-cwd scan + git probes on
that per-turn path. One projects.db connection serves both reads.
"""
rows = db.list_sessions_rich(
limit=session_limit,
offset=0,
order_by_last_active=True,
min_message_count=1,
include_children=False,
exclude_sources=_PROJECT_TREE_EXCLUDED_SOURCES,
include_archived=False,
)
sessions = [_project_tree_row(r) for r in rows]
# Parallel-warm the git cache so build_tree's resolver reads it instead of
# cold-probing each cwd in sequence (matters on the drill-in path, which
# skips the discovery warm-up below).
git_probe.warm_roots(s["cwd"] for s in sessions if s.get("cwd"))
from hermes_cli import projects_db as pdb
with pdb.connect_closing() as conn:
projects = [p.to_dict() for p in pdb.list_projects(conn)]
active_id = pdb.get_active_id(conn)
# backfill stays off the hot tree path — grouping uses the live resolver.
discovered = _discover_repos_payload(db, conn=conn, backfill=False) if include_discovered else []
return sessions, projects, discovered, active_id
def _build_project_tree(
db, *, preview_limit: int, hydrate: bool, session_limit: int, include_discovered: bool
) -> tuple[dict, str | None]:
"""Gather inputs and run the one authoritative builder. Returns (tree, active_id)."""
from tui_gateway import project_tree
sessions, projects, discovered, active_id = _project_tree_inputs(
db, session_limit, include_discovered=include_discovered
)
tree = project_tree.build_tree(
projects,
sessions,
discovered,
_resolve_cwd_git,
preview_limit=preview_limit,
hydrate=hydrate,
is_junk_root=_is_repo_junk,
)
return tree, active_id
@method("projects.tree")
def _(rid, params: dict) -> dict:
"""Authoritative project overview: project -> repo -> lane structure with
counts + a few preview sessions per project, plus the flat set of session
ids claimed by any project (so the desktop excludes them from flat Recents).
Lanes carry no session rows here; drill-in uses ``projects.project_sessions``.
"""
try:
db = _get_db()
if db is None:
return _ok(rid, {"projects": [], "active_id": None, "scoped_session_ids": []})
tree, active_id = _build_project_tree(
db,
preview_limit=int(params.get("preview_limit") or 3),
hydrate=False,
session_limit=int(params.get("session_limit") or 2000),
include_discovered=True,
)
return _ok(
rid,
{"projects": tree["projects"], "active_id": active_id, "scoped_session_ids": tree["scoped_session_ids"]},
)
except Exception as e:
return _err(rid, 5061, str(e))
@method("projects.project_sessions")
def _(rid, params: dict) -> dict:
"""Fully hydrated lanes (repo -> lane -> session rows) for one project,
built from the same authoritative grouping as ``projects.tree`` so ids and
membership match exactly. Used when the user enters a project."""
try:
project_id = str(params.get("project_id") or "")
if not project_id:
return _err(rid, 5063, "project_id required")
db = _get_db()
if db is None:
return _ok(rid, {"project": None})
# Drill-in only needs the entered project (which has sessions), so skip
# the zero-session discovery tier entirely.
tree, _active = _build_project_tree(
db, preview_limit=0, hydrate=True, session_limit=int(params.get("session_limit") or 5000),
include_discovered=False,
)
proj = next((p for p in tree["projects"] if p["id"] == project_id), None)
return _ok(rid, {"project": proj})
except Exception as e:
return _err(rid, 5061, str(e))
@method("config.get")
def _(rid, params: dict) -> dict:
key = params.get("key", "")