From 5091afe319b1ff1202a33b7c72834c73c67df754 Mon Sep 17 00:00:00 2001
From: crazy-daddy <67276479+crazy-daddy@users.noreply.github.com>
Date: Sun, 18 Oct 2026 12:07:54 +0300
Subject: [PATCH 1/4] feat: extend extra session roots to all views, add
machine badge
Part 2 of 2 (stack). Depends on feature/extra-roots-core (part 1):
branched from it and needs its services/session_roots.py and the
sessions.source_root column. Merge part 1 first; until then this
branch's diff against main also shows part 1.
- remaining call sites read synced dirs via session_jsonl_path /
project_dirs_for / Project.session_dirs: sessions and projects routers
(slug cache, prefix lookup, continuation lookup, JSONL fallback),
session chain resolver, title cache, memory, cron fires, agent
analytics, history, session filter, backfill_titles script
- synced copies of one session count once; a partial file mid-sync is
skipped without hiding the rest of its root
- agent_analytics and backfill_titles use settings.projects_dir instead
of a hardcoded ~/.claude/projects
- SessionSummary.machine (from sessions.source_root) and a machine badge
on session cards and the conversation header
---
SETUP.md | 2 +-
api/db/queries.py | 4 +-
api/db/queries_shells_cron.py | 33 +++--
api/models/history.py | 15 ++-
api/routers/agent_analytics.py | 29 +++--
api/routers/memory.py | 53 +++++---
api/routers/projects.py | 123 ++++++++++++------
api/routers/sessions.py | 113 +++++++++++++---
api/schemas.py | 4 +
api/scripts/backfill_titles.py | 6 +-
api/services/session_filter.py | 5 +-
api/services/session_relationships.py | 41 +++++-
api/services/session_title_cache.py | 87 ++++++++-----
frontend/src/lib/api-types.ts | 2 +
.../lib/components/GlobalSessionCard.svelte | 23 +++-
.../src/lib/components/SessionCard.svelte | 28 +++-
.../conversation/ConversationHeader.svelte | 18 ++-
17 files changed, 430 insertions(+), 156 deletions(-)
diff --git a/SETUP.md b/SETUP.md
index cde96d35..d083f18b 100644
--- a/SETUP.md
+++ b/SETUP.md
@@ -855,7 +855,7 @@ Each project dir from another machine joins a local project by the first rule th
| 4 | Same encoded dir name (identical path on both machines) | Nothing |
| 5 | Exactly one local project with the same folder name (`"match_by_name": true`, off by default) | Nothing |
-Unmatched dirs show as their own project. Rule 5 is a guess: a remote `api` or `web` folder would silently join whichever local project shares the name, so turn it on only for a root whose projects you know.
+Unmatched dirs show as their own project. Sessions from other machines carry a badge with the root's name. Rule 5 is a guess: a remote `api` or `web` folder would silently join whichever local project shares the name, so turn it on only for a root whose projects you know.
`path_map` prefixes are case-insensitive for Windows paths (drive letter or UNC) and case-sensitive otherwise.
diff --git a/api/db/queries.py b/api/db/queries.py
index 3e0a1495..be2084b1 100644
--- a/api/db/queries.py
+++ b/api/db/queries.py
@@ -209,7 +209,7 @@ def query_all_sessions(
s.input_tokens, s.output_tokens, s.total_cost,
s.initial_prompt, s.git_branch, s.models_used,
s.session_titles, s.is_continuation_marker, s.was_compacted,
- s.subagent_count, s.session_source
+ s.subagent_count, s.session_source, s.source_root
FROM sessions s
{fts_join}
{where}
@@ -1145,7 +1145,7 @@ def query_project_sessions(
s.initial_prompt, s.git_branch, s.session_titles,
s.is_continuation_marker, s.was_compacted, s.input_tokens,
s.output_tokens, s.total_cost, s.compaction_count,
- s.session_source
+ s.session_source, s.source_root
FROM sessions s
{fts_join}
WHERE {where}
diff --git a/api/db/queries_shells_cron.py b/api/db/queries_shells_cron.py
index 2b984f0e..b90259f9 100644
--- a/api/db/queries_shells_cron.py
+++ b/api/db/queries_shells_cron.py
@@ -206,6 +206,25 @@ def get_shells_project_rollup(
# ============================================================================
+def _session_jsonl_for_cron(
+ claude_projects_dir: Path, encoded_name: str, session_uuid: str
+) -> Path:
+ """Session JSONL under `claude_projects_dir`; when missing and that dir is
+ the real Claude root, fall back to a synced copy from an extra root."""
+ jsonl_path = (
+ claude_projects_dir / _PROJECTS_SUBDIR / encoded_name / f"{session_uuid}.jsonl"
+ )
+ if jsonl_path.exists():
+ return jsonl_path
+ from config import settings
+
+ if claude_projects_dir / _PROJECTS_SUBDIR != settings.projects_dir:
+ return jsonl_path
+ from services.session_roots import session_jsonl_path
+
+ return session_jsonl_path(encoded_name, session_uuid)
+
+
def get_cron_for_session(
conn: sqlite3.Connection,
session_uuid: str,
@@ -244,11 +263,8 @@ def get_cron_for_session(
(session_uuid,),
).fetchone()
if row:
- jsonl_path = (
- claude_projects_dir
- / _PROJECTS_SUBDIR
- / row[0]
- / f"{session_uuid}.jsonl"
+ jsonl_path = _session_jsonl_for_cron(
+ claude_projects_dir, row[0], session_uuid
)
if jsonl_path.exists():
# Read JSONL once and infer fires for all jobs in a single pass.
@@ -341,11 +357,8 @@ def get_cron_global(
snap = get_latest_cron_state(conn, session_uuid)
enc = jobs[0].get("project_encoded_name")
if enc:
- jsonl_path = (
- claude_projects_dir
- / _PROJECTS_SUBDIR
- / enc
- / f"{session_uuid}.jsonl"
+ jsonl_path = _session_jsonl_for_cron(
+ claude_projects_dir, enc, session_uuid
)
fires_map = (
infer_cron_fires_bulk(jsonl_path, jobs)
diff --git a/api/models/history.py b/api/models/history.py
index e0d8ac46..c4489e76 100644
--- a/api/models/history.py
+++ b/api/models/history.py
@@ -123,14 +123,19 @@ def get_project_name(path: str) -> str:
def list_existing_session_ids(projects_dir: Path) -> set[str]:
"""Get set of all existing session UUIDs (sessions that have .jsonl files)."""
+ from config import settings
+
session_ids = set()
- if not projects_dir.exists():
- return session_ids
+ dirs = []
+ if projects_dir.exists():
+ dirs.extend(d for d in projects_dir.iterdir() if d.is_dir())
+ if projects_dir == settings.projects_dir:
+ from services.session_roots import get_remote_dirs
- for project_dir in projects_dir.iterdir():
- if not project_dir.is_dir():
- continue
+ dirs.extend(rd.path for rd in get_remote_dirs() if rd.path.is_dir())
+
+ for project_dir in dirs:
for jsonl_file in project_dir.glob("*.jsonl"):
# Skip agent files
if jsonl_file.name.startswith("agent-"):
diff --git a/api/routers/agent_analytics.py b/api/routers/agent_analytics.py
index d608bf31..6dd8d30c 100644
--- a/api/routers/agent_analytics.py
+++ b/api/routers/agent_analytics.py
@@ -15,6 +15,7 @@
from cachetools import TTLCache, cached
+from config import settings
from schemas import (
AgentCategory,
AgentInvocation,
@@ -75,21 +76,31 @@ def _get_all_project_agent_names() -> frozenset[str]:
Cached with 120s TTL to avoid repeated filesystem scans.
"""
agent_names: set[str] = set()
- projects_dir = Path.home() / ".claude" / "projects"
- if not projects_dir.exists():
- return frozenset()
+ projects_dir = settings.projects_dir
+ encoded_names: list[str] = []
# Each subdirectory in ~/.claude/projects/ is an encoded project path.
# Decode it to find the real project directory, then check .claude/agents/.
- for encoded_dir in projects_dir.iterdir():
- if not encoded_dir.is_dir() or encoded_dir.name.startswith("."):
- continue
- if not is_encoded_project_dir(encoded_dir.name):
- continue
+ if projects_dir.exists():
+ for encoded_dir in projects_dir.iterdir():
+ if not encoded_dir.is_dir() or encoded_dir.name.startswith("."):
+ continue
+ if not is_encoded_project_dir(encoded_dir.name):
+ continue
+ encoded_names.append(encoded_dir.name)
+
+ # Remote (synced) session roots, attributed to their canonical project
+ from services.session_roots import get_remote_dirs
+
+ for rd in get_remote_dirs():
+ if rd.path.is_dir():
+ encoded_names.append(rd.canonical)
+
+ for encoded_name in dict.fromkeys(encoded_names):
# Decode to real project path using Project model
from models.project import Project
- decoded_path = Project.decode_path(encoded_dir.name)
+ decoded_path = Project.decode_path(encoded_name)
project_dir = Path(decoded_path)
if not project_dir.is_dir():
continue
diff --git a/api/routers/memory.py b/api/routers/memory.py
index 23d538d8..9c80888a 100644
--- a/api/routers/memory.py
+++ b/api/routers/memory.py
@@ -41,6 +41,7 @@ def memory_index() -> dict:
projects: list[dict] = []
pdir = settings.projects_dir
+ candidates: list[tuple[str, Path]] = []
if pdir.is_dir():
for enc_dir in sorted(pdir.iterdir()):
if not enc_dir.is_dir():
@@ -48,26 +49,38 @@ def memory_index() -> dict:
mem_dir = enc_dir / "memory"
if not mem_dir.is_dir():
continue
-
- try:
- md_files = [f for f in mem_dir.iterdir() if f.is_file() and f.suffix == ".md"]
- except OSError:
- continue
- if not md_files:
- continue
-
- note_count = sum(1 for f in md_files if f.name != "MEMORY.md")
- has_index = any(f.name == "MEMORY.md" for f in md_files)
-
- projects.append(
- {
- "encoded": enc_dir.name,
- "label": _project_label(enc_dir.name),
- "path": _decoded_path(enc_dir.name),
- "note_count": note_count,
- "has_index": has_index,
- }
- )
+ candidates.append((enc_dir.name, mem_dir))
+
+ from services.session_roots import get_remote_dirs
+
+ seen = {enc for enc, _ in candidates}
+ for rd in get_remote_dirs():
+ mem_dir = rd.path / "memory"
+ if rd.canonical in seen or not mem_dir.is_dir():
+ continue
+ seen.add(rd.canonical)
+ candidates.append((rd.canonical, mem_dir))
+
+ for encoded, mem_dir in candidates:
+ try:
+ md_files = [f for f in mem_dir.iterdir() if f.is_file() and f.suffix == ".md"]
+ except OSError:
+ continue
+ if not md_files:
+ continue
+
+ note_count = sum(1 for f in md_files if f.name != "MEMORY.md")
+ has_index = any(f.name == "MEMORY.md" for f in md_files)
+
+ projects.append(
+ {
+ "encoded": encoded,
+ "label": _project_label(encoded),
+ "path": _decoded_path(encoded),
+ "note_count": note_count,
+ "has_index": has_index,
+ }
+ )
projects.sort(key=lambda p: (-p["note_count"], p["label"].lower()))
diff --git a/api/routers/projects.py b/api/routers/projects.py
index 00124756..c4f8c29f 100644
--- a/api/routers/projects.py
+++ b/api/routers/projects.py
@@ -234,16 +234,22 @@ def _build_slug_cache(project: Project) -> dict[str, str]:
"""
slug_map: dict[str, str] = {}
- for jsonl_path in project.project_dir.glob("*.jsonl"):
- stem = jsonl_path.stem
- if "-" not in stem:
- continue
- try:
- session = Session.from_path(jsonl_path)
- if session.message_count > 0 and session.slug:
- slug_map[session.slug] = session.uuid
- except Exception:
+ for session_dir in project.session_dirs:
+ if not session_dir.exists():
continue
+ dir_map: dict[str, str] = {}
+ for jsonl_path in session_dir.glob("*.jsonl"):
+ stem = jsonl_path.stem
+ if "-" not in stem:
+ continue
+ try:
+ session = Session.from_path(jsonl_path)
+ if session.message_count > 0 and session.slug:
+ dir_map[session.slug] = session.uuid
+ except Exception:
+ continue
+ for slug, session_uuid in dir_map.items():
+ slug_map.setdefault(slug, session_uuid)
return slug_map
@@ -257,10 +263,13 @@ def _get_slug_cache(project: Project) -> dict[str, str]:
encoded_name = project.encoded_name
# Check if cache exists and is fresh
- try:
- current_mtime = project.project_dir.stat().st_mtime
- except Exception:
- current_mtime = 0
+ # Newest mtime across local and synced dirs, so synced sessions invalidate too
+ current_mtime = 0
+ for d in project.session_dirs:
+ try:
+ current_mtime = max(current_mtime, d.stat().st_mtime)
+ except Exception:
+ continue
if encoded_name in _slug_cache:
cached_mtime = _slug_cache_mtime.get(encoded_name, 0)
@@ -282,6 +291,8 @@ def session_to_summary(
session_source: Optional[str] = None,
) -> SessionSummary:
"""Convert a Session to SessionSummary."""
+ from services.session_roots import machine_for_path
+
initial_prompt = get_initial_prompt(session, max_length=500)
return SessionSummary(
@@ -300,6 +311,7 @@ def session_to_summary(
chain_info=chain_info,
session_titles=list(session.session_titles or []),
session_source=session_source,
+ machine=machine_for_path(session.jsonl_path),
)
@@ -315,6 +327,8 @@ def session_index_to_summary(
This is ~10x faster than session_to_summary() as it uses pre-computed
metadata from sessions-index.json.
"""
+ from services.session_roots import machine_for_path
+
return SessionSummary(
uuid=entry.session_id,
slug=None, # Not in index, will need Session for this
@@ -333,6 +347,7 @@ def session_index_to_summary(
chain_info=chain_info,
session_titles=title_cache.get_titles(project_encoded_name, entry.session_id) or [],
session_source=session_source,
+ machine=machine_for_path(Path(entry.full_path)) if entry.full_path else None,
)
@@ -536,6 +551,7 @@ def get_project(
session_titles=titles,
chain_info=chain_info,
session_source=row.get("session_source"),
+ machine=row.get("source_root"),
)
)
@@ -680,34 +696,14 @@ def _lookup_session_in_project(
from schemas import SessionLookupResult
if is_uuid_like:
- # Try exact UUID match first
- exact_path = project.project_dir / f"{identifier}.jsonl"
- if exact_path.exists():
- try:
- session = Session.from_path(exact_path)
- if session.message_count > 0:
- return SessionLookupResult(
- uuid=session.uuid,
- slug=session.slug,
- project_encoded_name=encoded_name,
- project_path=project_path,
- message_count=session.message_count,
- start_time=session.start_time,
- end_time=session.end_time,
- initial_prompt=get_initial_prompt(session, max_length=500),
- matched_by="uuid",
- )
- except Exception:
- pass
+ session_dirs = [d for d in project.session_dirs if d.exists()]
- # Try UUID prefix match
- for jsonl_path in project.project_dir.glob("*.jsonl"):
- stem = jsonl_path.stem
- if "-" not in stem:
- continue
- if stem.startswith(identifier):
+ # Try exact UUID match first
+ for session_dir in session_dirs:
+ exact_path = session_dir / f"{identifier}.jsonl"
+ if exact_path.exists():
try:
- session = Session.from_path(jsonl_path)
+ session = Session.from_path(exact_path)
if session.message_count > 0:
return SessionLookupResult(
uuid=session.uuid,
@@ -718,10 +714,34 @@ def _lookup_session_in_project(
start_time=session.start_time,
end_time=session.end_time,
initial_prompt=get_initial_prompt(session, max_length=500),
- matched_by="uuid_prefix",
+ matched_by="uuid",
)
except Exception:
+ pass
+
+ # Try UUID prefix match
+ for session_dir in session_dirs:
+ for jsonl_path in session_dir.glob("*.jsonl"):
+ stem = jsonl_path.stem
+ if "-" not in stem:
continue
+ if stem.startswith(identifier):
+ try:
+ session = Session.from_path(jsonl_path)
+ if session.message_count > 0:
+ return SessionLookupResult(
+ uuid=session.uuid,
+ slug=session.slug,
+ project_encoded_name=encoded_name,
+ project_path=project_path,
+ message_count=session.message_count,
+ start_time=session.start_time,
+ end_time=session.end_time,
+ initial_prompt=get_initial_prompt(session, max_length=500),
+ matched_by="uuid_prefix",
+ )
+ except Exception:
+ continue
# Try sessions-index.json for UUID matching
index = project.load_sessions_index()
@@ -754,7 +774,14 @@ def _lookup_session_in_project(
slug_cache = _get_slug_cache(project)
if identifier in slug_cache:
session_uuid = slug_cache[identifier]
- jsonl_path = project.project_dir / f"{session_uuid}.jsonl"
+ jsonl_path = next(
+ (
+ d / f"{session_uuid}.jsonl"
+ for d in project.session_dirs
+ if (d / f"{session_uuid}.jsonl").exists()
+ ),
+ project.project_dir / f"{session_uuid}.jsonl",
+ )
if jsonl_path.exists():
try:
session = Session.from_path(jsonl_path)
@@ -1562,6 +1589,13 @@ async def get_project_memory(encoded_name: str, request: Request):
from config import settings
memory_dir = settings.projects_dir / encoded_name / "memory"
+ if not memory_dir.is_dir():
+ from services.session_roots import project_dirs_for
+
+ for d in project_dirs_for(encoded_name):
+ if (d / "memory").is_dir():
+ memory_dir = d / "memory"
+ break
memory_file = memory_dir / "MEMORY.md"
# Compute index entry.
@@ -1630,6 +1664,13 @@ async def get_project_memory_file(encoded_name: str, filename: str, request: Req
raise HTTPException(status_code=400, detail="Invalid memory filename format")
memory_dir = settings.projects_dir / encoded_name / "memory"
+ if not memory_dir.is_dir():
+ from services.session_roots import project_dirs_for
+
+ for d in project_dirs_for(encoded_name):
+ if (d / "memory").is_dir():
+ memory_dir = d / "memory"
+ break
candidate = memory_dir / filename
# Defense in depth: resolve and assert containment within memory_dir.
diff --git a/api/routers/sessions.py b/api/routers/sessions.py
index a2663c01..032d5c98 100644
--- a/api/routers/sessions.py
+++ b/api/routers/sessions.py
@@ -208,15 +208,14 @@ def _list_all_projects_with_sessions_optimized() -> tuple[
- project_options: List of ProjectFilterOption for filter dropdowns
"""
from models import Project
+ from services.session_roots import get_remote_dirs
projects_dir = settings.projects_dir
- if not projects_dir.exists():
- return [], []
all_sessions: list[SessionMetadata] = []
project_options: list[ProjectFilterOption] = []
- for encoded_dir in projects_dir.iterdir():
+ for encoded_dir in projects_dir.iterdir() if projects_dir.exists() else ():
if not encoded_dir.is_dir() or not is_encoded_project_dir(encoded_dir.name):
continue
try:
@@ -227,6 +226,7 @@ def _list_all_projects_with_sessions_optimized() -> tuple[
# Try to use session index first (Phase 1 optimization)
# But detect stale indexes by comparing to actual JSONL file count
index = project.load_sessions_index()
+ # The index describes only the local dir; synced dirs are added below
jsonl_count = get_cached_jsonl_count(project.project_dir)
index_is_fresh = (
index
@@ -258,10 +258,12 @@ def _list_all_projects_with_sessions_optimized() -> tuple[
if initial_prompt is None and entry.first_prompt == "No prompt":
# Index may be stale - try loading actual prompt from session
try:
- jsonl_path = project.project_dir / f"{entry.session_id}.jsonl"
- if jsonl_path.exists():
- session = Session.from_path(jsonl_path)
- initial_prompt = get_initial_prompt(session, max_length=500)
+ for session_dir in project.session_dirs:
+ jsonl_path = session_dir / f"{entry.session_id}.jsonl"
+ if jsonl_path.exists():
+ session = Session.from_path(jsonl_path)
+ initial_prompt = get_initial_prompt(session, max_length=500)
+ break
except Exception:
pass # Keep None on error
all_sessions.append(
@@ -319,6 +321,63 @@ def _list_all_projects_with_sessions_optimized() -> tuple[
except Exception:
continue
+ # Sessions synced from other machines, shown under their canonical project
+ remote_dirs = get_remote_dirs()
+ seen = {(m.encoded_name, m.uuid) for m in all_sessions} if remote_dirs else set()
+ options_by_name = {opt.encoded_name: opt for opt in project_options}
+ for rd in remote_dirs:
+ try:
+ valid_sessions = []
+ for jsonl_path in rd.path.glob("*.jsonl"):
+ if not _is_valid_session_filename(jsonl_path):
+ continue
+ if (rd.canonical, jsonl_path.stem) in seen:
+ continue
+ # A file mid-sync may be partial or locked: skip it, keep the rest
+ try:
+ session = Session.from_path(jsonl_path)
+ if session.message_count > 0:
+ valid_sessions.append(session)
+ except Exception:
+ continue
+ if not valid_sessions:
+ continue
+
+ option = options_by_name.get(rd.canonical)
+ if option is None:
+ remote_path = rd.remote_project_path or rd.canonical
+ option = ProjectFilterOption(
+ encoded_name=rd.canonical,
+ path=remote_path,
+ name=Path(remote_path).name,
+ slug=compute_project_slug(rd.canonical, remote_path),
+ display_name=Path(remote_path).name,
+ session_count=0,
+ )
+ project_options.append(option)
+ options_by_name[rd.canonical] = option
+ option.session_count += len(valid_sessions)
+
+ for session in valid_sessions:
+ seen.add((rd.canonical, session.uuid))
+ all_sessions.append(
+ SessionMetadata(
+ uuid=session.uuid,
+ encoded_name=rd.canonical,
+ project_path=option.path,
+ message_count=session.message_count,
+ start_time=session.start_time,
+ end_time=session.end_time,
+ slug=session.slug,
+ initial_prompt=get_initial_prompt(session),
+ git_branch=next(iter(session.get_git_branches()), None),
+ title=session.session_titles[0] if session.session_titles else None,
+ _session=session,
+ )
+ )
+ except Exception:
+ continue
+
# Enrich session metadata with cached titles and slugs
# This populates session_titles for efficient server-side search
# without N+1 JSONL loading in matches_metadata()
@@ -526,6 +585,7 @@ def _get_all_sessions_sqlite(
or title_cache.get_titles(row["project_encoded_name"], row["uuid"])
or [],
session_source=get_session_source(row["uuid"]),
+ machine=row.get("source_root"),
)
sessions_with_context.append(session_context)
@@ -656,12 +716,13 @@ def _get_all_sessions_jsonl(
models_used: list[str] = []
subagent_count = 0
- project_dir = settings.projects_dir / meta.encoded_name
+ from services.session_roots import machine_for_path, session_jsonl_path
+
+ jsonl_path = session_jsonl_path(meta.encoded_name, meta.uuid)
+ project_dir = jsonl_path.parent
subagent_count = _count_subagents_fast(project_dir, meta.uuid)
if meta._session is None:
- duration_seconds = active_duration_seconds(
- project_dir / f"{meta.uuid}.jsonl", meta.start_time, meta.end_time
- )
+ duration_seconds = active_duration_seconds(jsonl_path, meta.start_time, meta.end_time)
if meta._session is not None:
try:
@@ -693,6 +754,7 @@ def _get_all_sessions_jsonl(
git_branches=[meta.git_branch] if meta.git_branch else [],
session_titles=session_titles,
session_source=get_session_source(meta.uuid),
+ machine=machine_for_path(jsonl_path),
)
sessions_with_context.append(session_context)
@@ -847,16 +909,17 @@ def get_continuation_session(session_uuid: str) -> ContinuationSessionInfo:
source_slug = source_session.slug
source_end_time = source_session.end_time
- # Get the project directory
- projects_dir = settings.projects_dir
- project_dir = projects_dir / project_encoded_name
+ # Get the project directories (local first, then synced copies)
+ from services.session_roots import project_dirs_for
+
+ project_dirs = [d for d in project_dirs_for(project_encoded_name) if d.exists()]
- if not project_dir.exists():
+ if not project_dirs:
raise HTTPException(status_code=404, detail="Project not found")
# Search for continuation session with same slug
candidates = []
- for jsonl_path in project_dir.glob("*.jsonl"):
+ for jsonl_path in (p for d in project_dirs for p in d.glob("*.jsonl")):
if not _is_valid_session_filename(jsonl_path):
continue
# Skip the source session itself
@@ -1050,6 +1113,8 @@ def get_session(uuid: str, request: Request, fresh: bool = False):
except Exception:
pass # DB unavailable, frontend will still try /chain as fallback
+ from services.session_roots import machine_for_path
+
response_data = SessionDetail(
uuid=session.uuid,
slug=session.slug,
@@ -1063,6 +1128,7 @@ def get_session(uuid: str, request: Request, fresh: bool = False):
subagent_count=session.count_subagents(),
has_todos=session.has_todos,
session_source=get_session_source(session.uuid),
+ machine=machine_for_path(session.jsonl_path),
todo_count=len(todos),
initial_prompt=initial_prompt,
initial_prompt_images=initial_prompt_images,
@@ -1810,13 +1876,12 @@ def _find_session_files(uuid_prefix: str) -> list[tuple[Path, str]]:
Returns (jsonl_path, project_encoded_name) pairs. The prefix is validated
by the caller, so it is safe to embed in a glob pattern.
"""
+ from services.session_roots import get_remote_dirs
from utils import is_encoded_project_dir
projects_dir = settings.projects_dir
- if not projects_dir.exists():
- return []
matches: list[tuple[Path, str]] = []
- for encoded_dir in projects_dir.iterdir():
+ for encoded_dir in projects_dir.iterdir() if projects_dir.exists() else ():
if not encoded_dir.is_dir() or not is_encoded_project_dir(encoded_dir.name):
continue
exact = encoded_dir / f"{uuid_prefix}.jsonl"
@@ -1825,6 +1890,16 @@ def _find_session_files(uuid_prefix: str) -> list[tuple[Path, str]]:
for jsonl in encoded_dir.glob(f"{uuid_prefix}*.jsonl"):
if not jsonl.name.startswith("agent-"):
matches.append((jsonl, encoded_dir.name))
+ # Synced copies from other machines, attributed to their canonical project
+ seen = {(name, p.stem) for p, name in matches}
+ for rd in get_remote_dirs():
+ exact = rd.path / f"{uuid_prefix}.jsonl"
+ if exact.exists():
+ return [(exact, rd.canonical)]
+ for jsonl in rd.path.glob(f"{uuid_prefix}*.jsonl"):
+ if not jsonl.name.startswith("agent-") and (rd.canonical, jsonl.stem) not in seen:
+ seen.add((rd.canonical, jsonl.stem))
+ matches.append((jsonl, rd.canonical))
return matches
diff --git a/api/schemas.py b/api/schemas.py
index 87071c22..7ec9a9b7 100644
--- a/api/schemas.py
+++ b/api/schemas.py
@@ -207,6 +207,10 @@ class SessionSummary(BaseModel):
None,
description="Session origin: 'desktop' for Claude Desktop, None for CLI",
)
+ machine: Optional[str] = Field(
+ None,
+ description="Extra session root the session was synced from; None for this machine",
+ )
class CompactionSummary(BaseModel):
diff --git a/api/scripts/backfill_titles.py b/api/scripts/backfill_titles.py
index 078efc2f..4e9728bf 100755
--- a/api/scripts/backfill_titles.py
+++ b/api/scripts/backfill_titles.py
@@ -88,8 +88,10 @@ def main():
for uuid, encoded_name, initial_prompt in sessions:
try:
# Get JSONL path
- projects_dir = Path.home() / ".claude" / "projects"
- jsonl_path = projects_dir / encoded_name / f"{uuid}.jsonl"
+ # Local file, else a copy synced from another machine
+ from services.session_roots import session_jsonl_path
+
+ jsonl_path = session_jsonl_path(encoded_name, uuid)
if not jsonl_path.exists():
print(f"⚠ Skipping {uuid}: JSONL not found")
diff --git a/api/services/session_filter.py b/api/services/session_filter.py
index 10e1ae11..656aa69f 100644
--- a/api/services/session_filter.py
+++ b/api/services/session_filter.py
@@ -67,12 +67,11 @@ def get_session(self) -> "Session":
if self._session:
return self._session
# Import here to avoid circular imports
- from config import settings
from models import Session
+ from services.session_roots import session_jsonl_path
# Construct from path
- projects_dir = settings.projects_dir
- jsonl_path = projects_dir / self.encoded_name / f"{self.uuid}.jsonl"
+ jsonl_path = session_jsonl_path(self.encoded_name, self.uuid)
return Session.from_path(jsonl_path)
def get_git_branches(self) -> set:
diff --git a/api/services/session_relationships.py b/api/services/session_relationships.py
index 378d0324..6a6c35f2 100644
--- a/api/services/session_relationships.py
+++ b/api/services/session_relationships.py
@@ -42,6 +42,23 @@ def get_resolver(project_dir: Path) -> "SessionRelationshipResolver":
return resolver
+def _sibling_session_dirs(project_dir: Path) -> List[Path]:
+ """Return project_dir followed by other dirs holding sessions of the same project."""
+ from config import settings
+ from services.session_roots import get_remote_dirs, project_dirs_for
+
+ if project_dir.parent == settings.projects_dir:
+ canonical = project_dir.name
+ else:
+ # A synced dir maps to its project; any other dir (e.g. tests) stands alone
+ canonical = next((rd.canonical for rd in get_remote_dirs() if rd.path == project_dir), None)
+ if canonical is None:
+ return [project_dir]
+ return [project_dir] + [
+ d for d in project_dirs_for(canonical) if d != project_dir and d.is_dir()
+ ]
+
+
class SessionRelationshipResolver:
"""
Discovers and resolves relationships between sessions.
@@ -67,6 +84,7 @@ def __init__(self, project_dir: Path):
(e.g., ~/.claude/projects/-Users-me-repo/)
"""
self.project_dir = project_dir
+ self.session_dirs = _sibling_session_dirs(project_dir)
self._session_cache: Dict[str, Session] = {}
self._uuid_to_session_map: Optional[Dict[str, str]] = None
@@ -76,8 +94,11 @@ def __init__(self, project_dir: Path):
def _get_session(self, uuid: str) -> Optional[Session]:
"""Load session with caching."""
if uuid not in self._session_cache:
- jsonl_path = self.project_dir / f"{uuid}.jsonl"
- if jsonl_path.exists():
+ jsonl_path = next(
+ (p for p in (d / f"{uuid}.jsonl" for d in self.session_dirs) if p.exists()),
+ None,
+ )
+ if jsonl_path is not None:
try:
self._session_cache[uuid] = Session.from_path(jsonl_path)
except Exception as e:
@@ -87,6 +108,16 @@ def _get_session(self, uuid: str) -> Optional[Session]:
return None
return self._session_cache[uuid]
+ def _iter_session_files(self):
+ """Yield session JSONL paths across session_dirs; earlier dirs win on duplicates."""
+ seen: Set[str] = set()
+ for session_dir in self.session_dirs:
+ for jsonl_path in session_dir.glob("*.jsonl"):
+ if jsonl_path.name in seen:
+ continue
+ seen.add(jsonl_path.name)
+ yield jsonl_path
+
def _build_uuid_to_session_map(self) -> Dict[str, str]:
"""
Build a map of message UUIDs to session UUIDs.
@@ -101,7 +132,7 @@ def _build_uuid_to_session_map(self) -> Dict[str, str]:
# First pass: collect all leaf_uuid targets we need to resolve
target_uuids: Set[str] = set()
- for jsonl_path in self.project_dir.glob("*.jsonl"):
+ for jsonl_path in self._iter_session_files():
if jsonl_path.name.startswith("agent-"):
continue
try:
@@ -116,7 +147,7 @@ def _build_uuid_to_session_map(self) -> Dict[str, str]:
# Second pass: scan messages only to find target UUIDs
remaining = set(target_uuids)
- for jsonl_path in self.project_dir.glob("*.jsonl"):
+ for jsonl_path in self._iter_session_files():
if not remaining:
break
if jsonl_path.name.startswith("agent-"):
@@ -152,7 +183,7 @@ def _build_leaf_uuid_children_map(self) -> Dict[str, List[str]]:
self._leaf_uuid_children_map = {}
uuid_map = self._build_uuid_to_session_map()
- for jsonl_path in self.project_dir.glob("*.jsonl"):
+ for jsonl_path in self._iter_session_files():
if jsonl_path.name.startswith("agent-"):
continue
child_uuid = jsonl_path.stem
diff --git a/api/services/session_title_cache.py b/api/services/session_title_cache.py
index bb396023..e70cbb51 100644
--- a/api/services/session_title_cache.py
+++ b/api/services/session_title_cache.py
@@ -169,8 +169,9 @@ def set_title(self, encoded_name: str, uuid: str, title: str) -> None:
else:
# Create new entry with current mtime if file exists
mtime_ms = 0
- project_dir = settings.projects_dir / encoded_name
- jsonl_path = project_dir / f"{uuid}.jsonl"
+ from services.session_roots import session_jsonl_path
+
+ jsonl_path = session_jsonl_path(encoded_name, uuid)
if jsonl_path.exists():
try:
mtime_ns = jsonl_path.stat().st_mtime_ns
@@ -227,10 +228,22 @@ def _is_stale(self, encoded_name: str) -> bool:
# Fallback to JSONL file count
if current_count is None:
- project_dir = settings.projects_dir / encoded_name
- if not project_dir.is_dir():
+ from services.session_roots import project_dirs_for
+
+ project_dirs = [d for d in project_dirs_for(encoded_name) if d.is_dir()]
+ if not project_dirs:
return False
- current_count = get_cached_jsonl_count(project_dir)
+ if len(project_dirs) == 1:
+ current_count = get_cached_jsonl_count(project_dirs[0])
+ else:
+ current_count = len(
+ {
+ p.name
+ for d in project_dirs
+ for p in d.glob("*.jsonl")
+ if not p.name.startswith("agent-")
+ }
+ )
cached_count = len(cached)
threshold = max(1, int(cached_count * 0.1))
@@ -456,45 +469,49 @@ def _build_from_jsonl(self, encoded_name: str) -> Dict[str, TitleEntry]:
"""
Build title cache by scanning all session JSONL files (slow fallback).
"""
- project_dir = settings.projects_dir / encoded_name
- if not project_dir.is_dir():
- return {}
-
- # Load sessions-index.json for fallback titles
- index_path = project_dir / "sessions-index.json"
- index = SessionIndex.load(index_path)
- index_summaries: Dict[str, str] = {}
- if index:
- for entry in index.entries:
- if entry.summary:
- index_summaries[entry.session_id] = entry.summary
+ from services.session_roots import project_dirs_for
data: Dict[str, TitleEntry] = {}
- for jsonl_path in project_dir.glob("*.jsonl"):
- if jsonl_path.name.startswith("agent-"):
+ for project_dir in project_dirs_for(encoded_name):
+ if not project_dir.is_dir():
continue
- uuid = jsonl_path.stem
+ # Load sessions-index.json for fallback titles
+ index_path = project_dir / "sessions-index.json"
+ index = SessionIndex.load(index_path)
+ index_summaries: Dict[str, str] = {}
+ if index:
+ for entry in index.entries:
+ if entry.summary:
+ index_summaries[entry.session_id] = entry.summary
+
+ for jsonl_path in project_dir.glob("*.jsonl"):
+ if jsonl_path.name.startswith("agent-"):
+ continue
- try:
- mtime_ns = jsonl_path.stat().st_mtime_ns
- mtime_ms = mtime_ns // 1_000_000
- except OSError:
- mtime_ms = 0
+ uuid = jsonl_path.stem
+ if uuid in data:
+ continue
- try:
- titles, slug = self._extract_titles_lightweight(jsonl_path)
- except Exception as exc:
- logger.debug("Error scanning %s: %s", jsonl_path.name, exc)
- titles, slug = [], None
+ try:
+ mtime_ns = jsonl_path.stat().st_mtime_ns
+ mtime_ms = mtime_ns // 1_000_000
+ except OSError:
+ mtime_ms = 0
+
+ try:
+ titles, slug = self._extract_titles_lightweight(jsonl_path)
+ except Exception as exc:
+ logger.debug("Error scanning %s: %s", jsonl_path.name, exc)
+ titles, slug = [], None
- if not titles:
- fallback = index_summaries.get(uuid)
- if fallback:
- titles = [fallback]
+ if not titles:
+ fallback = index_summaries.get(uuid)
+ if fallback:
+ titles = [fallback]
- data[uuid] = TitleEntry(titles=titles, slug=slug, mtime=mtime_ms)
+ data[uuid] = TitleEntry(titles=titles, slug=slug, mtime=mtime_ms)
return data
diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts
index f578dbad..a617aedc 100644
--- a/frontend/src/lib/api-types.ts
+++ b/frontend/src/lib/api-types.ts
@@ -259,6 +259,8 @@ export interface SessionSummary {
chain_title?: string;
/** Session origin: 'desktop' for Claude Desktop sessions, undefined for CLI */
session_source?: 'desktop' | null;
+ /** Name of the other computer this session was synced from; null = this machine */
+ machine?: string | null;
}
/**
diff --git a/frontend/src/lib/components/GlobalSessionCard.svelte b/frontend/src/lib/components/GlobalSessionCard.svelte
index e0783416..3dde1583 100644
--- a/frontend/src/lib/components/GlobalSessionCard.svelte
+++ b/frontend/src/lib/components/GlobalSessionCard.svelte
@@ -8,7 +8,8 @@
GitBranch,
Folder,
Monitor,
- Bot
+ Bot,
+ Laptop
} from 'lucide-svelte';
import type { SessionWithContext, LiveSessionSummary } from '$lib/api-types';
import { projectHrefFromSession } from '$lib/utils/project-url';
@@ -215,7 +216,7 @@
{displayPrompt}
{/if}
- {#if session.session_source === 'desktop' || showSubagentBadge}
+ {#if session.session_source === 'desktop' || session.machine || showSubagentBadge}
{#if session.session_source === 'desktop'}
@@ -223,6 +224,15 @@
Desktop
{/if}
+ {#if session.machine}
+
+
+ {session.machine}
+
+ {/if}
{#if showSubagentBadge}
@@ -370,6 +380,15 @@
Desktop
{/if}
+ {#if session.machine}
+
+
+ {session.machine}
+
+ {/if}
- import { MessageSquare, Users, Clock, Sparkles, GitBranch, Monitor } from 'lucide-svelte';
+ import {
+ MessageSquare,
+ Users,
+ Clock,
+ Sparkles,
+ GitBranch,
+ Monitor,
+ Laptop
+ } from 'lucide-svelte';
import ResumeSessionButton from './ResumeSessionButton.svelte';
import type { SessionSummary, LiveSessionSummary } from '$lib/api-types';
import { statusConfig } from '$lib/live-session-config';
@@ -224,6 +232,15 @@
Desktop
{/if}
+ {#if session.machine}
+
+
+ {session.machine}
+
+ {/if}
{:else}
@@ -343,6 +360,15 @@
Desktop
{/if}
+ {#if session.machine}
+
+
+ {session.machine}
+
+ {/if}
+
+ {entity.machine}
+
+ {/if}
{/snippet}
{#snippet headerRight()}
From 647fb59c123e81c0820a40218076a35297505dd2 Mon Sep 17 00:00:00 2001
From: crazy-daddy <67276479+crazy-daddy@users.noreply.github.com>
Date: Wed, 21 Oct 2026 22:31:00 +0300
Subject: [PATCH 2/4] fix(api): address review on extra session roots
(coverage)
Should fix:
- Tests with the temp-dir fixture: browser dedupe of a session synced to
two dirs, an unreadable remote file skipped while the rest list, machine
set on browser and detail responses for a synced session and null for a
local one, session_jsonl_path / project_dirs_for local with no roots,
index-backed lookup, short-id prefix hits across dirs, local-first
relationship resolver.
- projects.py: the "SQLite returned 0 sessions" disk fallback uses
project.list_session_paths(), so a remote-only project gets it too.
- session_lookup.py: with roots configured, find_session_with_project asks
the index for project and source_root first (one stat) and only scans
when the index does not know the session or its file moved. A local copy
wins even when the index still says synced.
Nits:
- skills, tools and agents summaries carry machine: the six
query_sessions_by_* SELECTs include s.source_root, the JSONL fallback in
skills uses machine_for_path.
- session_relationships: local dir first when the start dir is a synced one.
- agent_analytics: agents dir from settings.claude_base; the remote
canonicals loop (dead: those projects' .claude/agents live on the other
machine) is gone.
- SessionCard compact layout puts Desktop and machine badges on one row,
matching GlobalSessionCard.
- One-line comments in backfill_titles.py and queries_shells_cron.py.
- _find_session_files keeps returning an exact remote hit alone, like the
local loop: an exact filename only exists for a full UUID, where prefix
hits are moot, and the short-id resolver would otherwise pick by mtime.
---
api/db/queries.py | 12 +-
api/db/queries_shells_cron.py | 3 +-
api/routers/agent_analytics.py | 14 +-
api/routers/agents.py | 1 +
api/routers/projects.py | 4 +-
api/routers/sessions.py | 1 +
api/routers/skills.py | 4 +
api/routers/tools.py | 1 +
api/scripts/backfill_titles.py | 3 +-
api/services/session_lookup.py | 46 +++++-
api/services/session_relationships.py | 8 +-
api/tests/test_session_roots.py | 145 ++++++++++++++++++
.../src/lib/components/SessionCard.svelte | 30 ++--
13 files changed, 225 insertions(+), 47 deletions(-)
diff --git a/api/db/queries.py b/api/db/queries.py
index be2084b1..12a1bfae 100644
--- a/api/db/queries.py
+++ b/api/db/queries.py
@@ -606,7 +606,7 @@ def query_sessions_by_skill(
s.uuid, s.slug, s.project_encoded_name, s.project_path,
s.message_count, s.start_time, s.end_time, s.duration_seconds,
s.models_used, s.subagent_count, s.initial_prompt,
- s.git_branch, s.session_titles
+ s.git_branch, s.session_titles, s.source_root
FROM sessions s
JOIN session_skills sk ON s.uuid = sk.session_uuid
WHERE sk.skill_name = :skill AND sk.invocation_source != 'text_detection'
@@ -2074,7 +2074,7 @@ def query_sessions_by_agent(
s.uuid, s.slug, s.project_encoded_name, s.project_path,
s.message_count, s.start_time, s.end_time, s.duration_seconds,
s.models_used, s.subagent_count, s.initial_prompt,
- s.git_branch, s.session_titles
+ s.git_branch, s.session_titles, s.source_root
FROM sessions s
JOIN (SELECT DISTINCT session_uuid FROM subagent_invocations WHERE subagent_type = :type) si
ON s.uuid = si.session_uuid
@@ -2848,7 +2848,7 @@ def query_sessions_by_mcp_server(
s.uuid, s.slug, s.project_encoded_name, s.project_path,
s.message_count, s.start_time, s.end_time, s.duration_seconds,
s.models_used, s.subagent_count, s.initial_prompt,
- s.git_branch, s.session_titles,
+ s.git_branch, s.session_titles, s.source_root,
agg.has_main, agg.has_sub, agg.agent_ids
FROM sessions s
JOIN aggregated_sessions agg ON s.uuid = agg.session_uuid
@@ -3333,7 +3333,7 @@ def query_sessions_by_mcp_tool(
s.uuid, s.slug, s.project_encoded_name, s.project_path,
s.message_count, s.start_time, s.end_time, s.duration_seconds,
s.models_used, s.subagent_count, s.initial_prompt,
- s.git_branch, s.session_titles,
+ s.git_branch, s.session_titles, s.source_root,
agg.has_main, agg.has_sub, agg.agent_ids
FROM sessions s
JOIN aggregated_sessions agg ON s.uuid = agg.session_uuid
@@ -3811,7 +3811,7 @@ def query_sessions_by_builtin_server(
s.uuid, s.slug, s.project_encoded_name, s.project_path,
s.message_count, s.start_time, s.end_time, s.duration_seconds,
s.models_used, s.subagent_count, s.initial_prompt,
- s.git_branch, s.session_titles,
+ s.git_branch, s.session_titles, s.source_root,
agg.has_main, agg.has_sub, agg.agent_ids
FROM sessions s
JOIN aggregated_sessions agg ON s.uuid = agg.session_uuid
@@ -3886,7 +3886,7 @@ def query_sessions_by_builtin_tool(
s.uuid, s.slug, s.project_encoded_name, s.project_path,
s.message_count, s.start_time, s.end_time, s.duration_seconds,
s.models_used, s.subagent_count, s.initial_prompt,
- s.git_branch, s.session_titles,
+ s.git_branch, s.session_titles, s.source_root,
agg.has_main, agg.has_sub, agg.agent_ids
FROM sessions s
JOIN aggregated_sessions agg ON s.uuid = agg.session_uuid
diff --git a/api/db/queries_shells_cron.py b/api/db/queries_shells_cron.py
index b90259f9..e2f4ee9a 100644
--- a/api/db/queries_shells_cron.py
+++ b/api/db/queries_shells_cron.py
@@ -209,8 +209,7 @@ def get_shells_project_rollup(
def _session_jsonl_for_cron(
claude_projects_dir: Path, encoded_name: str, session_uuid: str
) -> Path:
- """Session JSONL under `claude_projects_dir`; when missing and that dir is
- the real Claude root, fall back to a synced copy from an extra root."""
+ """Session JSONL under `claude_projects_dir`, else a synced copy if that is the real root."""
jsonl_path = (
claude_projects_dir / _PROJECTS_SUBDIR / encoded_name / f"{session_uuid}.jsonl"
)
diff --git a/api/routers/agent_analytics.py b/api/routers/agent_analytics.py
index 6dd8d30c..52d1e949 100644
--- a/api/routers/agent_analytics.py
+++ b/api/routers/agent_analytics.py
@@ -57,8 +57,8 @@ def _parse_iso(value: Optional[str]) -> Optional[datetime]:
def get_custom_agents_dir() -> Path:
- """Get the ~/.claude/agents directory."""
- return Path.home() / ".claude" / "agents"
+ """Get the agents dir under settings.claude_base (~/.claude/agents by default)."""
+ return settings.claude_base / "agents"
def _agent_file_exists(agents_dir: Path, name: str) -> bool:
@@ -89,14 +89,8 @@ def _get_all_project_agent_names() -> frozenset[str]:
continue
encoded_names.append(encoded_dir.name)
- # Remote (synced) session roots, attributed to their canonical project
- from services.session_roots import get_remote_dirs
-
- for rd in get_remote_dirs():
- if rd.path.is_dir():
- encoded_names.append(rd.canonical)
-
- for encoded_name in dict.fromkeys(encoded_names):
+ # Synced roots add nothing here: their projects' .claude/agents dirs live on the other machine
+ for encoded_name in encoded_names:
# Decode to real project path using Project model
from models.project import Project
diff --git a/api/routers/agents.py b/api/routers/agents.py
index dbce68cb..331e9c29 100644
--- a/api/routers/agents.py
+++ b/api/routers/agents.py
@@ -475,6 +475,7 @@ async def get_agent_sessions(
sessions.append(
SessionSummary(
uuid=row["uuid"],
+ machine=row.get("source_root"),
slug=row.get("slug"),
project_encoded_name=row["project_encoded_name"],
project_display_name=project_display_name,
diff --git a/api/routers/projects.py b/api/routers/projects.py
index c4f8c29f..518f7cc9 100644
--- a/api/routers/projects.py
+++ b/api/routers/projects.py
@@ -559,8 +559,8 @@ def get_project(
# Bug fix: If SQLite returns 0 sessions but files exist on disk, fall back to filesystem
if total_count == 0:
- # Check if any .jsonl session files exist on disk
- session_files = list(project.project_dir.glob("*.jsonl"))
+ # Check if any .jsonl session files exist on disk (local or synced dirs)
+ session_files = project.list_session_paths()
if session_files:
logger.info(
"SQLite reports 0 sessions but %d .jsonl files found on disk, falling back to filesystem",
diff --git a/api/routers/sessions.py b/api/routers/sessions.py
index 032d5c98..f9771709 100644
--- a/api/routers/sessions.py
+++ b/api/routers/sessions.py
@@ -1895,6 +1895,7 @@ def _find_session_files(uuid_prefix: str) -> list[tuple[Path, str]]:
for rd in get_remote_dirs():
exact = rd.path / f"{uuid_prefix}.jsonl"
if exact.exists():
+ # Same rule as the local loop: an exact id is unambiguous, prefix hits are moot
return [(exact, rd.canonical)]
for jsonl in rd.path.glob(f"{uuid_prefix}*.jsonl"):
if not jsonl.name.startswith("agent-") and (rd.canonical, jsonl.stem) not in seen:
diff --git a/api/routers/skills.py b/api/routers/skills.py
index 8734b7fc..38f4d88c 100644
--- a/api/routers/skills.py
+++ b/api/routers/skills.py
@@ -620,6 +620,7 @@ async def get_skill_detail(
sessions.append(
SessionSummary(
uuid=row["uuid"],
+ machine=row.get("source_root"),
slug=row.get("slug"),
project_encoded_name=project_encoded_name,
project_slug=project_encoded_name,
@@ -714,6 +715,7 @@ def get_skill_sessions(
sessions.append(
SessionWithContext(
uuid=row["uuid"],
+ machine=row.get("source_root"),
slug=row.get("slug"),
project_encoded_name=project_encoded_name,
project_path=project_path,
@@ -742,6 +744,7 @@ def get_skill_sessions(
from command_helpers import aggregate_by_name
from parallel import process_items_parallel
+ from services.session_roots import machine_for_path
from utils import get_initial_prompt, list_all_projects, normalize_timezone
def process_session_for_skill(session_tuple) -> SessionWithContext | None:
@@ -769,6 +772,7 @@ def process_session_for_skill(session_tuple) -> SessionWithContext | None:
initial_prompt=initial_prompt,
git_branches=list(session.get_git_branches()),
session_titles=list(session.session_titles or []),
+ machine=machine_for_path(session.jsonl_path),
)
except Exception:
return None
diff --git a/api/routers/tools.py b/api/routers/tools.py
index 6ff77468..9d308c4d 100644
--- a/api/routers/tools.py
+++ b/api/routers/tools.py
@@ -133,6 +133,7 @@ def _build_session_summary(session_data: dict) -> SessionSummary:
return SessionSummary(
uuid=session_data["uuid"],
+ machine=session_data.get("source_root"),
slug=session_data.get("slug"),
project_encoded_name=session_data.get("project_encoded_name"),
project_display_name=project_display_name,
diff --git a/api/scripts/backfill_titles.py b/api/scripts/backfill_titles.py
index 4e9728bf..7d26b60f 100755
--- a/api/scripts/backfill_titles.py
+++ b/api/scripts/backfill_titles.py
@@ -87,8 +87,7 @@ def main():
for uuid, encoded_name, initial_prompt in sessions:
try:
- # Get JSONL path
- # Local file, else a copy synced from another machine
+ # JSONL path: local file, else a copy synced from another machine
from services.session_roots import session_jsonl_path
jsonl_path = session_jsonl_path(encoded_name, uuid)
diff --git a/api/services/session_lookup.py b/api/services/session_lookup.py
index e31c50fd..f29cc9d7 100644
--- a/api/services/session_lookup.py
+++ b/api/services/session_lookup.py
@@ -6,6 +6,7 @@
service with consistent error handling and return types.
"""
+import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
@@ -14,6 +15,8 @@
from models import Agent, Session
from utils import is_encoded_project_dir
+logger = logging.getLogger(__name__)
+
@dataclass
class SessionLookupResult:
@@ -67,6 +70,14 @@ def find_session_with_project(uuid: str) -> Optional[SessionLookupResult]:
Returns:
SessionLookupResult with session and project info, or None if not found.
"""
+ from services.session_roots import find_any_remote_session, get_extra_roots
+
+ # The index knows the project and root: one stat instead of a scan over every dir
+ if get_extra_roots():
+ hit = _indexed_location(uuid)
+ if hit is not None:
+ return hit
+
projects_dir = settings.projects_dir
if projects_dir.exists():
for encoded_dir in projects_dir.iterdir():
@@ -79,8 +90,6 @@ def find_session_with_project(uuid: str) -> Optional[SessionLookupResult]:
)
# Synced from another machine: report the project it is shown under
- from services.session_roots import find_any_remote_session
-
remote = find_any_remote_session(uuid)
if remote:
rd, jsonl_path = remote
@@ -91,6 +100,35 @@ def find_session_with_project(uuid: str) -> Optional[SessionLookupResult]:
return None
+def _indexed_location(uuid: str) -> Optional[SessionLookupResult]:
+ """Resolve a session through the SQLite index's project and source_root; None if unknown."""
+ from db.connection import sqlite_read
+ from services.session_roots import find_remote_session_jsonl
+
+ try:
+ with sqlite_read() as conn:
+ if conn is None:
+ return None
+ row = conn.execute(
+ "SELECT project_encoded_name, source_root FROM sessions WHERE uuid = ?", (uuid,)
+ ).fetchone()
+ except Exception as e:
+ logger.debug("Index lookup for %s failed, scanning instead: %s", uuid, e)
+ return None
+ if row is None:
+ return None
+ encoded_name, source_root = row[0], row[1]
+ # A local copy wins even when the index still says synced (indexed before it appeared)
+ jsonl_path = settings.projects_dir / encoded_name / f"{uuid}.jsonl"
+ if source_root is not None and not jsonl_path.exists():
+ jsonl_path = find_remote_session_jsonl(encoded_name, uuid)
+ if jsonl_path is None or not jsonl_path.exists():
+ return None # index stale: fall back to the scan
+ return SessionLookupResult(
+ session=Session.from_path(jsonl_path), project_encoded_name=encoded_name
+ )
+
+
def find_session(uuid: str) -> Optional[Session]:
"""
Find a session by UUID across all projects.
@@ -157,9 +195,7 @@ def find_session_by_message_uuid(message_uuid: str) -> Optional[SessionLookupRes
return None
-def _find_session_jsonl(
- projects_dir: Path, encoded_name: str, session_uuid: str
-) -> Optional[Path]:
+def _find_session_jsonl(projects_dir: Path, encoded_name: str, session_uuid: str) -> Optional[Path]:
"""
Find a session JSONL file, searching the project dir and its worktree dirs.
diff --git a/api/services/session_relationships.py b/api/services/session_relationships.py
index 6a6c35f2..2b6f23db 100644
--- a/api/services/session_relationships.py
+++ b/api/services/session_relationships.py
@@ -43,7 +43,7 @@ def get_resolver(project_dir: Path) -> "SessionRelationshipResolver":
def _sibling_session_dirs(project_dir: Path) -> List[Path]:
- """Return project_dir followed by other dirs holding sessions of the same project."""
+ """All dirs holding sessions of project_dir's project, local dir first, project_dir included."""
from config import settings
from services.session_roots import get_remote_dirs, project_dirs_for
@@ -54,9 +54,9 @@ def _sibling_session_dirs(project_dir: Path) -> List[Path]:
canonical = next((rd.canonical for rd in get_remote_dirs() if rd.path == project_dir), None)
if canonical is None:
return [project_dir]
- return [project_dir] + [
- d for d in project_dirs_for(canonical) if d != project_dir and d.is_dir()
- ]
+ # Local first so the local copy wins duplicates, same as everywhere else
+ dirs = [d for d in project_dirs_for(canonical) if d == project_dir or d.is_dir()]
+ return dirs if project_dir in dirs else [project_dir] + dirs
class SessionRelationshipResolver:
diff --git a/api/tests/test_session_roots.py b/api/tests/test_session_roots.py
index 1fba825d..f83df35b 100644
--- a/api/tests/test_session_roots.py
+++ b/api/tests/test_session_roots.py
@@ -663,6 +663,151 @@ def test_no_roots_means_no_sidecar_write(tmp_path, monkeypatch, mem_db):
assert read_sidecar(local / "projects" / encoded)["git_identity"] == "o/repo"
+# =============================================================================
+# Views: browser, detail, lookup, relationships (coverage PR)
+# =============================================================================
+
+
+def _client():
+ pytest.importorskip("fastapi")
+ from fastapi.testclient import TestClient
+
+ from main import app
+
+ return TestClient(app, base_url="http://localhost", client=("127.0.0.1", 50000))
+
+
+@pytest.fixture
+def jsonl_only(monkeypatch):
+ """Force the JSONL code paths so tests never touch the developer's metadata.db."""
+ monkeypatch.setattr(settings, "use_sqlite", False)
+
+
+def test_browser_dedupes_and_tags_synced_sessions(machines, jsonl_only):
+ local, remote, _ = machines
+ _write_session(local / "-Users-me-repo", "l1", "/Users/me/repo")
+ _write_session(remote / "-Users-me-repo", "l1", "/Users/me/repo") # synced back
+ _write_session(remote / "-Users-me-repo", "r1", "/Users/me/repo")
+ (remote / "-Users-me-repo" / "r2.jsonl").write_text("{not json\n", encoding="utf-8") # mid-sync
+
+ body = _client().get("/sessions/all").json()
+ by_uuid = {s["uuid"]: s["machine"] for s in body["sessions"]}
+ assert by_uuid == {"l1": None, "r1": "laptop"}
+
+
+def test_detail_reports_machine(machines, jsonl_only):
+ local, remote, _ = machines
+ _write_session(local / "-Users-me-repo", "l1", "/Users/me/repo")
+ _write_session(remote / "-Users-me-repo", "r1", "/Users/me/repo")
+
+ client = _client()
+ assert client.get("/sessions/r1").json()["machine"] == "laptop"
+ assert client.get("/sessions/l1").json()["machine"] is None
+
+
+def test_no_roots_paths_are_local(tmp_path, monkeypatch):
+ local = tmp_path / ".claude"
+ (local / "projects").mkdir(parents=True)
+ monkeypatch.setattr(settings, "claude_base", local)
+ monkeypatch.setattr(settings, "extra_roots", [])
+ session_roots.clear_cache()
+
+ assert session_jsonl_path("-x", "u1") == local / "projects" / "-x" / "u1.jsonl"
+ assert session_roots.project_dirs_for("-x") == [local / "projects" / "-x"]
+
+
+def test_indexed_location_uses_source_root(machines, mem_db, monkeypatch):
+ from contextlib import contextmanager
+
+ import db.connection
+ from services import session_lookup
+
+ local, remote, _ = machines
+ _write_session(local / "-Users-me-repo", "l1", "/Users/me/repo")
+ r1 = _write_session(remote / "-home-me-solo", "r1", "/home/me/solo")
+ indexer.sync_all_projects(mem_db)
+
+ @contextmanager
+ def fake_read():
+ yield mem_db
+
+ monkeypatch.setattr(db.connection, "sqlite_read", fake_read)
+
+ hit = session_lookup._indexed_location("r1")
+ assert (hit.session.jsonl_path, hit.project_encoded_name) == (r1, "-home-me-solo")
+ assert session_lookup._indexed_location("l1").project_encoded_name == "-Users-me-repo"
+ r1.unlink() # index stale: caller falls back to the scan
+ assert session_lookup._indexed_location("r1") is None
+
+
+def test_short_id_prefix_hits_span_local_and_remote(machines):
+ from routers.sessions import _find_session_files
+
+ local, remote, _ = machines
+ _write_session(
+ local / "-Users-me-repo", "abcd1234-0000-0000-0000-000000000000", "/Users/me/repo"
+ )
+ _write_session(
+ remote / "-home-me-solo", "abcd5678-0000-0000-0000-000000000000", "/home/me/solo"
+ )
+ _write_session(
+ remote / "-Users-me-repo", "abcd1234-0000-0000-0000-000000000000", "/Users/me/repo"
+ )
+
+ found = sorted((p.stem[:8], name) for p, name in _find_session_files("abcd"))
+ assert found == [("abcd1234", "-Users-me-repo"), ("abcd5678", "-home-me-solo")]
+ # An exact id wins outright, as in the local loop
+ (exact,) = _find_session_files("abcd5678-0000-0000-0000-000000000000")
+ assert exact[1] == "-home-me-solo"
+
+
+def test_browser_skips_unreadable_remote_file(machines, jsonl_only):
+ local, remote, _ = machines
+ _write_session(local / "-Users-me-repo", "l1", "/Users/me/repo")
+ _write_session(remote / "-Users-me-repo", "r1", "/Users/me/repo")
+ # Opens fine, every read raises: the shape of a file a sync client holds locked
+ (remote / "-Users-me-repo" / "r2.jsonl").mkdir()
+
+ body = _client().get("/sessions/all").json()
+ assert sorted(s["uuid"] for s in body["sessions"]) == ["l1", "r1"]
+
+
+def test_find_session_with_project_uses_index_for_synced_session(machines, mem_db, monkeypatch):
+ from contextlib import contextmanager
+
+ import db.connection
+ from services import session_lookup
+
+ local, remote, _ = machines
+ _write_session(local / "-Users-me-repo", "l1", "/Users/me/repo")
+ r1 = _write_session(remote / "-home-me-solo", "r1", "/home/me/solo")
+ indexer.sync_all_projects(mem_db)
+
+ @contextmanager
+ def fake_read():
+ yield mem_db
+
+ monkeypatch.setattr(db.connection, "sqlite_read", fake_read)
+
+ hit = session_lookup.find_session_with_project("r1")
+ assert (hit.session.jsonl_path, hit.project_encoded_name) == (r1, "-home-me-solo")
+ # Local copy appears after indexing: it wins over the synced one the index points at
+ local_copy = _write_session(local / "-home-me-solo", "r1", "/home/me/solo")
+ assert session_lookup.find_session_with_project("r1").session.jsonl_path == local_copy
+
+
+def test_relationship_resolver_prefers_local_copy(machines):
+ from services.session_relationships import SessionRelationshipResolver
+
+ local, remote, _ = machines
+ _write_session(local / "-Users-me-repo", "s1", "/Users/me/repo")
+ _write_session(remote / "-Users-me-repo", "s1", "/Users/me/repo")
+
+ resolver = SessionRelationshipResolver(remote / "-Users-me-repo")
+ assert resolver.session_dirs[0] == local / "-Users-me-repo"
+ assert resolver._get_session("s1").jsonl_path == local / "-Users-me-repo" / "s1.jsonl"
+
+
def test_extra_roots_endpoints(machines):
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
diff --git a/frontend/src/lib/components/SessionCard.svelte b/frontend/src/lib/components/SessionCard.svelte
index 78b3c317..c482b103 100644
--- a/frontend/src/lib/components/SessionCard.svelte
+++ b/frontend/src/lib/components/SessionCard.svelte
@@ -223,22 +223,20 @@
{displayPrompt}
{/if}
- {#if session.session_source === 'desktop'}
-
-
- Desktop
-
- {/if}
- {#if session.machine}
-
-
-
{session.machine}
+ {#if session.session_source === 'desktop' || session.machine}
+
+ {#if session.session_source === 'desktop'}
+
+
+ Desktop
+
+ {/if}
+ {#if session.machine}
+
+
+ {session.machine}
+
+ {/if}
{/if}
From d45bd09e5b7b53c18b1c2216caef328e5b326d56 Mon Sep 17 00:00:00 2001
From: crazy-daddy <67276479+crazy-daddy@users.noreply.github.com>
Date: Wed, 21 Oct 2026 22:36:04 +0300
Subject: [PATCH 3/4] test(api): skip the short-id test where FastAPI is absent
The models-only CI job installs no web deps; the test imports
routers.sessions, so it skips there like the endpoint tests do.
---
api/tests/test_session_roots.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/api/tests/test_session_roots.py b/api/tests/test_session_roots.py
index f83df35b..65bbc233 100644
--- a/api/tests/test_session_roots.py
+++ b/api/tests/test_session_roots.py
@@ -741,6 +741,7 @@ def fake_read():
def test_short_id_prefix_hits_span_local_and_remote(machines):
+ pytest.importorskip("fastapi") # the models-only CI job has no web deps
from routers.sessions import _find_session_files
local, remote, _ = machines
From d20531b9a5c8880eb1d080c660ac9093921cf187 Mon Sep 17 00:00:00 2001
From: Jayant Devkar <55962509+JayantDevkar@users.noreply.github.com>
Date: Thu, 22 Oct 2026 07:05:13 -0700
Subject: [PATCH 4/4] fix(api,frontend): extra roots views: badge on
skill/command detail, 409 on resuming a synced session, honest remote-file
tests
The skill and command detail query now returns the session's root as machine, and the shared frontend mapper keeps it, so both detail pages show the badge. Resuming a session that exists only as a synced copy now returns 409 naming its machine instead of launching a local claude --resume that cannot find it. The sessions browser skips one unreadable synced file instead of dropping its whole project, and the continuation lookup parses each uuid once. The unreadable-file test now reaches that code with real UUID filenames and a chmod 0 file, and the index lookup test asserts that no scan runs.
Claude-Session: https://claude.ai/code/session_019dHRodCxwuwTvnKtUNrUU7
---
api/db/queries.py | 2 +-
api/routers/sessions.py | 26 ++++-
api/routers/skills.py | 2 +-
api/tests/test_session_roots.py | 102 ++++++++++++++++--
.../lib/components/GlobalSessionCard.svelte | 2 +-
.../src/lib/components/SessionCard.svelte | 2 +-
frontend/src/lib/utils.ts | 3 +-
7 files changed, 124 insertions(+), 15 deletions(-)
diff --git a/api/db/queries.py b/api/db/queries.py
index 12a1bfae..1846e2c3 100644
--- a/api/db/queries.py
+++ b/api/db/queries.py
@@ -782,7 +782,7 @@ def _query_item_detail(
s.uuid, s.slug, s.project_encoded_name, s.project_path,
s.message_count, s.start_time, s.end_time, s.duration_seconds,
s.models_used, s.subagent_count, s.initial_prompt,
- s.git_branch, s.session_titles,
+ s.git_branch, s.session_titles, s.source_root AS machine,
agg.has_main, agg.has_sub, agg.agent_ids,
ss.invocation_sources
FROM sessions s
diff --git a/api/routers/sessions.py b/api/routers/sessions.py
index f9771709..30b16deb 100644
--- a/api/routers/sessions.py
+++ b/api/routers/sessions.py
@@ -192,6 +192,14 @@ def _count_subagents_fast(project_dir: Path, session_uuid: str) -> int:
return len(list(subagents_dir.glob("agent-*.jsonl")))
+def _has_messages(session: Session) -> bool:
+ """False for an empty session or one whose file cannot be read (a synced copy mid-sync)."""
+ try:
+ return session.message_count > 0
+ except Exception:
+ return False
+
+
def _list_all_projects_with_sessions_optimized() -> tuple[
list[SessionMetadata], list[ProjectFilterOption]
]:
@@ -288,7 +296,7 @@ def _list_all_projects_with_sessions_optimized() -> tuple[
# Fallback: load sessions the traditional way
# (index missing, empty, or stale)
sessions = project.list_sessions()
- valid_sessions = [s for s in sessions if s.message_count > 0]
+ valid_sessions = [s for s in sessions if _has_messages(s)]
if valid_sessions:
project_options.append(
@@ -919,12 +927,14 @@ def get_continuation_session(session_uuid: str) -> ContinuationSessionInfo:
# Search for continuation session with same slug
candidates = []
+ seen: set[str] = set()
for jsonl_path in (p for d in project_dirs for p in d.glob("*.jsonl")):
if not _is_valid_session_filename(jsonl_path):
continue
- # Skip the source session itself
- if jsonl_path.stem == session_uuid:
+ # Skip the source session itself, and synced copies of a session already checked
+ if jsonl_path.stem == session_uuid or jsonl_path.stem in seen:
continue
+ seen.add(jsonl_path.stem)
try:
candidate = Session.from_path(jsonl_path)
@@ -2006,11 +2016,13 @@ def resume_session_in_terminal(uuid: str, request: Request) -> TerminalResumeRes
The shell command is built entirely server-side from the validated UUID
and Karma's own project path resolution; the request body carries nothing.
Returns 404 if the session is unknown, 409 if its project directory no
- longer exists, 403 for cross-origin browser requests.
+ longer exists or it was synced from another machine, 403 for cross-origin
+ browser requests.
"""
from models import Project
from models.live_session import load_live_session
from routers.live_sessions import _reject_cross_origin
+ from services.session_roots import machine_for_path
from services.terminal_focus import focus_terminal, pid_is_live_claude
from services.terminal_launch import launch_resume_in_terminal
@@ -2035,7 +2047,13 @@ def resume_session_in_terminal(uuid: str, request: Request) -> TerminalResumeRes
matches = _find_session_files(uuid)
if not matches:
raise HTTPException(status_code=404, detail=f"Session not found: {uuid}")
+ # An exact local match returns before any synced copy is considered
jsonl_path, encoded_name = matches[0]
+ machine = machine_for_path(jsonl_path)
+ if machine:
+ raise HTTPException(
+ status_code=409, detail=f"This session was synced from {machine}; resume it there."
+ )
# Per-session cwd first (worktrees), then the live record, then the
# project-level path recovered from other sessions.
diff --git a/api/routers/skills.py b/api/routers/skills.py
index 38f4d88c..a8b9c2a9 100644
--- a/api/routers/skills.py
+++ b/api/routers/skills.py
@@ -620,7 +620,7 @@ async def get_skill_detail(
sessions.append(
SessionSummary(
uuid=row["uuid"],
- machine=row.get("source_root"),
+ machine=row.get("machine"),
slug=row.get("slug"),
project_encoded_name=project_encoded_name,
project_slug=project_encoded_name,
diff --git a/api/tests/test_session_roots.py b/api/tests/test_session_roots.py
index 65bbc233..90c398d8 100644
--- a/api/tests/test_session_roots.py
+++ b/api/tests/test_session_roots.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import json
+import os
import sqlite3
import sys
from pathlib import Path
@@ -762,15 +763,24 @@ def test_short_id_prefix_hits_span_local_and_remote(machines):
assert exact[1] == "-home-me-solo"
-def test_browser_skips_unreadable_remote_file(machines, jsonl_only):
+@pytest.mark.skipif(
+ sys.platform == "win32" or getattr(os, "geteuid", lambda: -1)() == 0,
+ reason="needs file permissions that bind the current user",
+)
+def test_browser_skips_unreadable_synced_file(machines, jsonl_only):
local, remote, _ = machines
- _write_session(local / "-Users-me-repo", "l1", "/Users/me/repo")
- _write_session(remote / "-Users-me-repo", "r1", "/Users/me/repo")
- # Opens fine, every read raises: the shape of a file a sync client holds locked
- (remote / "-Users-me-repo" / "r2.jsonl").mkdir()
+ l1, merged_ok, merged_bad, solo_ok, solo_bad = (
+ f"0000000{i}-0000-0000-0000-000000000000" for i in range(5)
+ )
+ _write_session(local / "-Users-me-repo", l1, "/Users/me/repo")
+ _write_session(remote / "-Users-me-repo", merged_ok, "/Users/me/repo")
+ _write_session(remote / "-home-me-solo", solo_ok, "/home/me/solo")
+ # Unreadable, like a file a sync client holds locked: in a merged and in a remote-only project
+ _write_session(remote / "-Users-me-repo", merged_bad, "/Users/me/repo").chmod(0)
+ _write_session(remote / "-home-me-solo", solo_bad, "/home/me/solo").chmod(0)
body = _client().get("/sessions/all").json()
- assert sorted(s["uuid"] for s in body["sessions"]) == ["l1", "r1"]
+ assert sorted(s["uuid"] for s in body["sessions"]) == sorted([l1, merged_ok, solo_ok])
def test_find_session_with_project_uses_index_for_synced_session(machines, mem_db, monkeypatch):
@@ -789,12 +799,18 @@ def fake_read():
yield mem_db
monkeypatch.setattr(db.connection, "sqlite_read", fake_read)
+ get_remote_dirs() # warm the root mapping, which lists dirs itself
+ scans = []
+ monkeypatch.setattr(session_roots, "find_any_remote_session", lambda uuid: scans.append(uuid))
+ real_iterdir = Path.iterdir
+ monkeypatch.setattr(Path, "iterdir", lambda self: scans.append(self) or real_iterdir(self))
hit = session_lookup.find_session_with_project("r1")
assert (hit.session.jsonl_path, hit.project_encoded_name) == (r1, "-home-me-solo")
# Local copy appears after indexing: it wins over the synced one the index points at
local_copy = _write_session(local / "-home-me-solo", "r1", "/home/me/solo")
assert session_lookup.find_session_with_project("r1").session.jsonl_path == local_copy
+ assert scans == []
def test_relationship_resolver_prefers_local_copy(machines):
@@ -809,6 +825,80 @@ def test_relationship_resolver_prefers_local_copy(machines):
assert resolver._get_session("s1").jsonl_path == local / "-Users-me-repo" / "s1.jsonl"
+def test_skill_and_command_detail_report_machine(machines, monkeypatch):
+ from contextlib import contextmanager
+
+ import db.connection
+
+ local, remote, _ = machines
+ _write_session(local / "-Users-me-repo", "l1", "/Users/me/repo")
+ _write_session(remote / "-home-me-solo", "r1", "/home/me/solo")
+ # The endpoints read on the test client's thread
+ conn = sqlite3.connect(":memory:", check_same_thread=False)
+ conn.row_factory = sqlite3.Row
+ ensure_schema(conn)
+ indexer.sync_all_projects(conn)
+ for uuid in ("l1", "r1"):
+ conn.execute(
+ "INSERT INTO session_skills (session_uuid, skill_name) VALUES (?, 'sk')", (uuid,)
+ )
+ conn.execute(
+ "INSERT INTO session_commands (session_uuid, command_name) VALUES (?, 'cmd')", (uuid,)
+ )
+
+ @contextmanager
+ def fake_read():
+ yield conn
+
+ monkeypatch.setattr(db.connection, "sqlite_read", fake_read)
+ client = _client()
+ for url in ("/skills/sk/detail", "/commands/cmd/detail"):
+ sessions = client.get(url).json()["sessions"]
+ assert {s["uuid"]: s["machine"] for s in sessions} == {"l1": None, "r1": "laptop"}, url
+
+
+@pytest.fixture
+def resume_launcher(monkeypatch):
+ """Record resume launches instead of opening a terminal; no live session for any uuid."""
+ pytest.importorskip("fastapi")
+ from models import live_session
+ from services import terminal_launch
+
+ launched = []
+ monkeypatch.setattr(live_session, "load_live_session", lambda uuid: None)
+ monkeypatch.setattr(
+ terminal_launch,
+ "launch_resume_in_terminal",
+ lambda cwd, uuid, term_program=None: launched.append((cwd, uuid)) or {"launched": True},
+ )
+ return launched
+
+
+def test_resume_refuses_a_session_synced_from_another_machine(machines, tmp_path, resume_launcher):
+ _, remote, _ = machines
+ uuid = "abcd5678-0000-0000-0000-000000000000"
+ _write_session(remote / "-home-me-solo", uuid, str(tmp_path))
+
+ resp = _client().post(f"/sessions/{uuid}/resume-in-terminal")
+ assert resp.status_code == 409
+ assert "laptop" in resp.json()["detail"]
+ assert resume_launcher == []
+
+
+def test_resume_uses_the_local_copy_of_a_synced_session(machines, tmp_path, resume_launcher):
+ local, remote, _ = machines
+ uuid = "abcd1234-0000-0000-0000-000000000000"
+ here, there = tmp_path / "here", tmp_path / "there"
+ here.mkdir()
+ there.mkdir()
+ _write_session(remote / "-Users-me-repo", uuid, str(there))
+ _write_session(local / "-Users-me-repo", uuid, str(here))
+
+ resp = _client().post(f"/sessions/{uuid}/resume-in-terminal")
+ assert resp.status_code == 200, resp.text
+ assert resume_launcher == [(str(here), uuid)]
+
+
def test_extra_roots_endpoints(machines):
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
diff --git a/frontend/src/lib/components/GlobalSessionCard.svelte b/frontend/src/lib/components/GlobalSessionCard.svelte
index 3dde1583..fedd4ee3 100644
--- a/frontend/src/lib/components/GlobalSessionCard.svelte
+++ b/frontend/src/lib/components/GlobalSessionCard.svelte
@@ -386,7 +386,7 @@
title={`Synced from ${session.machine}`}
>
- {session.machine}
+ {session.machine}
{/if}
diff --git a/frontend/src/lib/components/SessionCard.svelte b/frontend/src/lib/components/SessionCard.svelte
index c482b103..5b8e0fc3 100644
--- a/frontend/src/lib/components/SessionCard.svelte
+++ b/frontend/src/lib/components/SessionCard.svelte
@@ -364,7 +364,7 @@
title="Synced from {session.machine}"
>
- {session.machine}
+ {session.machine}
{/if}
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts
index 60cdb0ba..7fcc8464 100644
--- a/frontend/src/lib/utils.ts
+++ b/frontend/src/lib/utils.ts
@@ -1555,6 +1555,7 @@ export function toSessionWithContext(s: McpSessionSummary | SessionSummary): Ses
session_titles: s.session_titles,
project_encoded_name: encoded,
project_path: encoded ?? '',
- project_name: displayName || getProjectNameFromEncoded(encoded ?? '')
+ project_name: displayName || getProjectNameFromEncoded(encoded ?? ''),
+ machine: 'machine' in s ? s.machine : undefined
};
}