Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
18 changes: 9 additions & 9 deletions api/db/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
32 changes: 22 additions & 10 deletions api/db/queries_shells_cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 10 additions & 5 deletions api/models/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -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-"):
Expand Down
27 changes: 16 additions & 11 deletions api/routers/agent_analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from cachetools import TTLCache, cached

from config import settings
from schemas import (
AgentCategory,
AgentInvocation,
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions api/routers/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
53 changes: 33 additions & 20 deletions api/routers/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,33 +41,46 @@ 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():
continue
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()))

Expand Down
Loading
Loading