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..1846e2c3 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} @@ -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' @@ -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 @@ -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} @@ -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 2b984f0e..e2f4ee9a 100644 --- a/api/db/queries_shells_cron.py +++ b/api/db/queries_shells_cron.py @@ -206,6 +206,24 @@ 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`, else a synced copy if that is the real 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 +262,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 +356,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..52d1e949 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, @@ -56,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: @@ -75,21 +76,25 @@ 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) + + # 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 - 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/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/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..518f7cc9 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"), ) ) @@ -543,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", @@ -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..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] ]: @@ -208,15 +216,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 +234,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 +266,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( @@ -286,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( @@ -319,6 +329,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 +593,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 +724,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 +762,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,21 +917,24 @@ 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 - if not project_dir.exists(): + project_dirs = [d for d in project_dirs_for(project_encoded_name) if d.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"): + 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) @@ -1050,6 +1123,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 +1138,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 +1886,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 +1900,17 @@ 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(): + # 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: + seen.add((rd.canonical, jsonl.stem)) + matches.append((jsonl, rd.canonical)) return matches @@ -1930,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 @@ -1959,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 8734b7fc..a8b9c2a9 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("machine"), 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/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..7d26b60f 100755 --- a/api/scripts/backfill_titles.py +++ b/api/scripts/backfill_titles.py @@ -87,9 +87,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" + # 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) 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_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 378d0324..2b6f23db 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]: + """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 + + 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] + # 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: """ 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/api/tests/test_session_roots.py b/api/tests/test_session_roots.py index 1fba825d..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 @@ -663,6 +664,241 @@ 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): + pytest.importorskip("fastapi") # the models-only CI job has no web deps + 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" + + +@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 + 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"]) == sorted([l1, merged_ok, solo_ok]) + + +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) + 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): + 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_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/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..fedd4ee3 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'; @@ -215,13 +223,20 @@ {displayPrompt}

{/if} - {#if session.session_source === 'desktop'} -
- - Desktop + {#if session.session_source === 'desktop' || session.machine} +
+ {#if session.session_source === 'desktop'} +
+ + Desktop +
+ {/if} + {#if session.machine} +
+ + {session.machine} +
+ {/if}
{/if}
@@ -343,6 +358,15 @@ Desktop
{/if} + {#if session.machine} +
+ + {session.machine} +
+ {/if} + {/if}
{/snippet} {#snippet headerRight()} 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 }; }