diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5a501c5..d0cbbc0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,68 @@ All notable changes to Code Context Control (C3) are documented here.
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [2.146.0] - 2026-09-22
+
+### Changed — the Hub's cross-project pages stop re-reading every activity log
+
+The Credentials page took about a second to open on a fast desktop, longer on
+a work laptop, and every save waited on two more reloads. Almost none of that
+time went to credentials. Every cross-project Hub route called
+`ProjectManager.list_projects()` just to get names and paths, and that call
+reads each project's activity log to work out which sessions are live.
+`ActivityLog.find_last` JSON-parsed every line on its way back through the
+file, so for a type a project had never logged it parsed the whole file (up to
+the 5 MB rotation cap), about six times per project. Measured with 71
+registered projects:
+
+| Route | Before | After |
+|---|---|---|
+| `/api/hub/credentials/overview` | 925 ms | 108 ms |
+| `/api/hub/credentials/audit` | 1004 ms | 68 ms |
+| `/api/hub/sessions` | 1745 ms | 287 ms |
+| `/api/hub/tokens/overview` | 928 ms | 180 ms |
+| locks, enforcement, access and approval-policy overviews | 760–825 ms | 14–26 ms |
+| `/api/projects` (polled every 5 s) | 713 ms | 225 ms |
+| `/api/ci/inspect` (polled every 2–10 s) | 327 ms | 61 ms |
+
+- `ActivityLog.find_last` and `get_recent(event_type=...)` skip any chunk or
+ line that does not contain the type's JSON-encoded name before parsing it.
+ `get_recent` reads the tail it keeps from the end of the file instead of
+ reading the whole file forward.
+- `ProjectManager.list_registered()` returns the stored project rows with no
+ liveness sweep. The credentials, tokens, locks, enforcement, access,
+ approval-policy, sessions and global-search routes use it. `/api/projects`
+ keeps the full `list_projects()`.
+- `credential_store.value_presence()` checks whether each value in a vault
+ still decodes with one registry read for the whole vault. Before, 43 entries
+ cost 362 reads of `config.json` and 912 path resolutions. `is_resolvable`
+ delegates to it, and the Hub, the project UI, `c3_credentials list` and
+ `c3 creds list` use it. The Hub's backup banner reuses the listing's probe
+ instead of repeating it.
+- The Sessions view lists the Claude transcript root once per request instead
+ of once per lookup, and a transcript found in another project's folder (a
+ worktree session) goes through the per-file cache instead of being re-read
+ on every request. The listing is per request, not cached longer, because
+ Windows does not update a folder's mtime in its parent's listing when a file
+ is created inside it, so a kept listing could not see a new transcript.
+- The CI tab reuses an engine probe (`act --version`, `docker version`, image
+ inspect) for 30 s. Starting a run and `c3_ci doctor` still probe fresh.
+
+### Changed — Hub pages compile faster in the browser and polls stop stacking
+
+- The Hub and project UI shells tell babel-standalone to apply only the React
+ preset. Its default also ran `preset-env`, which compiled the 600 KB bundle
+ down to ES5 on every page load: about 1.9 s on a desktop, against 0.35 s for
+ JSX alone. Current browsers run the rest natively.
+- The Credentials page's global vault list takes its rows from the page's own
+ overview. Opening the page used to load that overview twice, and each save
+ reloaded it twice more.
+- `usePoll` skips a tick while the previous call is still running, so a route
+ slower than its poll interval no longer stacks requests on the Hub.
+- The project list polls every 30 s instead of 5 s when neither the Projects
+ view nor a project drill-in is open. The sidebar and top-bar counts can lag
+ by that much on other pages.
+
## [2.145.2] - 2026-09-22
### Fixed — a corrupt embedding store no longer kills the MCP server (#170)
diff --git a/cli/c3.py b/cli/c3.py
index 62310a1..196a754 100644
--- a/cli/c3.py
+++ b/cli/c3.py
@@ -91,7 +91,7 @@
# Config
CONFIG_DIR = ".c3"
CONFIG_FILE = ".c3/config.json"
-__version__ = "2.145.2"
+__version__ = "2.146.0"
# The PreToolUse matcher for native subagent calls (installer and hub migration).
AGENT_MATCHER = "Agent|Task"
@@ -6685,10 +6685,10 @@ def _creds_cmd_list(args, project_path: str) -> None:
"(add --global for all projects).")
return
print(f"{len(entries)} credential(s) — project scope shadows global:")
+ present = cred_store.presence_for(entries, project_path)
missing = 0
for name, entry in entries.items():
- gone = not cred_store.is_resolvable(
- name, project_path=project_path, scope=entry["scope"])
+ gone = not present[name]
missing += gone
print(" " + _creds_entry_line(name, entry, gone))
if missing:
diff --git a/cli/hub_server.py b/cli/hub_server.py
index 77b6a9c..d31bbb9 100644
--- a/cli/hub_server.py
+++ b/cli/hub_server.py
@@ -1804,7 +1804,7 @@ def api_projects_browse():
return jsonify({"error": str(e)}), 400
if not resolved.is_dir():
return jsonify({"error": f"Not a directory: {resolved}"}), 404
- registered = {os.path.normcase(p.get("path", "")) for p in _pm()._read_projects()}
+ registered = {os.path.normcase(p.get("path", "")) for p in _pm().list_registered()}
dirs = []
try:
for child in sorted(resolved.iterdir(), key=lambda c: c.name.lower()):
@@ -1913,7 +1913,7 @@ def api_search_global():
if wanted else None)
candidates, skipped = [], []
- for p in _pm().list_projects():
+ for p in _pm().list_registered():
ppath = p.get("path") or ""
if wanted_norm is not None and os.path.normcase(ppath) not in wanted_norm:
continue
@@ -2138,14 +2138,15 @@ def api_projects_credentials():
usage = cred_store.read_usage_state(str(resolved))
home = cred_store.global_base()
global_names = set(cred_store.list_entries(str(home))) if home else set()
+ listed = cred_store.list_entries(str(resolved))
+ present = cred_store.presence_for(listed, str(resolved))
entries = []
- for name, entry in cred_store.list_entries(str(resolved)).items():
+ for name, entry in listed.items():
entries.append(_cred_entry_public(
name, entry, usage=usage,
shadows_global=(entry.get("scope") == "project"
and name in global_names),
- value_ok=cred_store.is_resolvable(
- name, project_path=str(resolved), scope=entry["scope"])))
+ value_ok=present[name]))
return jsonify({"path": str(resolved), "entries": entries})
@@ -2360,7 +2361,7 @@ def api_hub_credentials_audit():
only_global = (request.args.get("scope") or "").strip().lower() == "global"
events, errors = [], []
- for p in ([] if only_global else _pm().list_projects()):
+ for p in ([] if only_global else _pm().list_registered()):
ppath = str(p.get("path") or "")
if not (Path(ppath) / ".c3").is_dir():
continue
@@ -2417,9 +2418,12 @@ def api_hub_credentials_overview():
from services import credential_store as cred_store
home = cred_store.global_base()
global_entries = cred_store.list_entries(str(home)) if home else {}
+ # realm -> {name: value decodes}, probed once per vault and shared with
+ # the backup banner, which asks the same question of the same entries.
+ present = {"global": cred_store.value_presence("global", str(home))} if home else {}
shadowed_in = {name: [] for name in global_entries}
projects_out = []
- for p in _pm().list_projects():
+ for p in _pm().list_registered():
ppath = str(p.get("path") or "")
row = {"name": p.get("name") or "", "path": ppath,
"initialized": True, "error": None, "entries": []}
@@ -2428,14 +2432,15 @@ def api_hub_credentials_overview():
row["initialized"] = False
else:
usage = cred_store.read_usage_state(ppath)
+ ok = cred_store.value_presence("project", ppath)
+ present[cred_store.realm("project", ppath)] = ok
for name, entry in cred_store.list_entries(ppath).items():
if entry.get("scope") != "project":
continue
row["entries"].append(_cred_entry_public(
name, entry, usage=usage,
shadows_global=name in global_entries,
- value_ok=cred_store.is_resolvable(
- name, project_path=ppath, scope="project")))
+ value_ok=ok.get(name, False)))
if name in shadowed_in:
shadowed_in[name].append(
{"name": row["name"], "path": ppath})
@@ -2446,21 +2451,21 @@ def api_hub_credentials_overview():
global_out = [
{**_cred_entry_public(
name, entry, usage=global_usage,
- value_ok=cred_store.is_resolvable(
- name, project_path=str(home), scope="global")),
+ value_ok=present.get("global", {}).get(name, False)),
"shadowed_in": shadowed_in.get(name, [])}
for name, entry in global_entries.items()
]
return jsonify({"global": {"entries": global_out}, "projects": projects_out,
"backup": _cred_backup_summary(
- [row["path"] for row in projects_out if row["initialized"]])})
+ [row["path"] for row in projects_out if row["initialized"]],
+ present)})
-def _cred_backup_summary(project_paths) -> dict:
+def _cred_backup_summary(project_paths, present) -> dict:
"""Backup state as counts for the Hub banner; a broken file reports its error."""
from services import credential_backup
try:
- st = credential_backup.status(project_paths)
+ st = credential_backup.status(project_paths, presence=present)
except credential_backup.BackupError as exc:
return {"enabled": True, "error": str(exc), "restorable": 0, "lost": 0}
return {"enabled": st["enabled"], "restorable": len(st["restorable"]),
@@ -2612,7 +2617,7 @@ def api_hub_tokens_overview():
days = _hub_tokens_days(request.args.get("days"))
rows, totals = [], {"tool_calls": 0, "tool_tokens": 0,
"session_tokens": 0, "saved": 0}
- for p in _pm().list_projects():
+ for p in _pm().list_registered():
ppath = str(p.get("path") or "")
row = {"name": p.get("name") or "", "path": ppath, "initialized": True,
"error": None, "tool_calls": 0, "tool_tokens": 0,
@@ -2662,7 +2667,7 @@ def api_hub_locks_overview():
from services import agent_locks as al
rows, total = [], 0
- for p in _pm().list_projects():
+ for p in _pm().list_registered():
ppath = str(p.get("path") or "")
row = {"name": p.get("name") or "", "path": ppath, "initialized": True,
"error": None, "enabled": True, "mode": "advisory",
@@ -2752,7 +2757,7 @@ def api_hub_enforcement_overview():
rows = []
totals = {"discipline": 0, "access": 0}
- for p in _pm().list_projects():
+ for p in _pm().list_registered():
ppath = str(p.get("path") or "")
row = {"name": p.get("name") or "", "path": ppath, "initialized": True,
"error": None, "mode": None, "scope": "", "set_by": "",
@@ -3428,7 +3433,7 @@ def api_hub_override_policy_overview():
from services import override_policy as opol
rows = []
- for p in _pm().list_projects():
+ for p in _pm().list_registered():
ppath = str(p.get("path") or "")
row = {"name": p.get("name") or Path(ppath).name, "path": ppath,
"initialized": True, "error": None, "policy": None,
@@ -3740,7 +3745,7 @@ def api_hub_access_overview():
empty = {"deny": [], "read_only": [], "confirm": [], "mask": 0,
"corrupt": False}
rows = []
- for p in _pm().list_projects():
+ for p in _pm().list_registered():
ppath = str(p.get("path") or "")
row = {"name": p.get("name") or Path(ppath).name, "path": ppath,
"initialized": True, "error": None, "rules": dict(empty)}
@@ -4268,7 +4273,7 @@ def api_pm_global():
from services.task_store import TaskStore
tasks, skipped, by_project = [], [], {}
- entries = _pm()._read_projects()
+ entries = _pm().list_registered()
for p in entries:
ppath = p.get("path") or ""
if not Path(ppath).is_dir():
@@ -4808,7 +4813,7 @@ def _session_project(raw: str):
if not raw:
return None
want = os.path.normcase(os.path.normpath(raw))
- for p in _pm().list_projects():
+ for p in _pm().list_registered():
path = str(p.get("path") or "")
if path and os.path.normcase(os.path.normpath(path)) == want:
return {"name": str(p.get("name") or Path(path).name), "path": path}
@@ -4847,7 +4852,7 @@ def one(p):
return row
with ThreadPoolExecutor(max_workers=8) as pool:
- rows = list(pool.map(one, _pm().list_projects()))
+ rows = list(pool.map(one, _pm().list_registered()))
rows.sort(key=lambda r: r.get("last_active") or "", reverse=True)
return jsonify({"projects": rows, "features": _SESSIONS_FEATURES})
@@ -4876,7 +4881,7 @@ def api_hub_sessions_list():
res["errors"] = []
else:
projects = [{"name": str(p.get("name") or ""), "path": str(p.get("path") or "")}
- for p in _pm().list_projects()]
+ for p in _pm().list_registered()]
res = sc.list_many(projects, stale=stale, q=q, limit=limit, before=before)
return jsonify(res)
except Exception as e:
diff --git a/cli/hub_ui.html b/cli/hub_ui.html
index 9819f90..b20f805 100644
--- a/cli/hub_ui.html
+++ b/cli/hub_ui.html
@@ -137,7 +137,7 @@
-
diff --git a/cli/hub_ui/app.js b/cli/hub_ui/app.js
index b7df9a2..5a869ad 100644
--- a/cli/hub_ui/app.js
+++ b/cli/hub_ui/app.js
@@ -50,7 +50,10 @@ function App() {
}, []);
useEffect(() => { loadConfig(); loadProjects(); }, []);
- usePoll(loadProjects, 5000);
+ // Live status matters on the project list and in a drill-in. Elsewhere it
+ // only refreshes the sidebar and top-bar counts, which may lag 30 s, and
+ // each poll reads every project's activity log.
+ usePoll(loadProjects, mainView === 'projects' || drill ? 5000 : 30000);
// Persisted preferences
const toggleTheme = () => {
diff --git a/cli/hub_ui/components/hub_credentials.js b/cli/hub_ui/components/hub_credentials.js
index b7e806a..8f70ad0 100644
--- a/cli/hub_ui/components/hub_credentials.js
+++ b/cli/hub_ui/components/hub_credentials.js
@@ -1381,7 +1381,10 @@ function CredSourcesStrip({ sources, onResync, busy }) {
// path=null → the global vault (~/.c3): scope locked to 'global'.
// path=string → that project's merged view (global entries + project shadows).
-function CredsManager({ path, projectName, onChanged, bindSlash }) {
+// Global vault (path null): rows arrive as `globalEntries` (null while the
+// page's overview loads) and `onChanged` refills them; loading the overview
+// here as well fetched the whole cross-project index twice.
+function CredsManager({ path, projectName, onChanged, bindSlash, globalEntries }) {
const isGlobal = !path;
const [entries, setEntries] = useState([]);
const [loading, setLoading] = useState(true);
@@ -1408,14 +1411,10 @@ function CredsManager({ path, projectName, onChanged, bindSlash }) {
const withPath = (obj) => (path ? Object.assign({ path }, obj) : obj);
const load = useCallback(async () => {
+ if (!path) return;
try {
- if (path) {
- const data = await api.get('/api/projects/credentials?path=' + encodeURIComponent(path));
- setEntries((data && data.entries) || []);
- } else {
- const data = await api.get('/api/hub/credentials/overview');
- setEntries((((data || {}).global) || {}).entries || []);
- }
+ const data = await credApi.listProject(path);
+ setEntries((data && data.entries) || []);
setError('');
} catch (e) { setError(String(e)); }
setLoading(false);
@@ -1426,6 +1425,14 @@ function CredsManager({ path, projectName, onChanged, bindSlash }) {
load();
}, [load]);
+ useEffect(() => {
+ if (path) return;
+ setEntries(globalEntries || []);
+ setLoading(globalEntries == null);
+ }, [path, globalEntries]);
+
+ const refresh = () => { load(); if (onChanged) onChanged(); };
+
// `/` focuses the filter, as it does on every other hub board — but only
// where nothing above already claims the key. The hub Credentials page owns
// `/` for its cross-project search, so the manager binds it solely when
@@ -1447,7 +1454,7 @@ function CredsManager({ path, projectName, onChanged, bindSlash }) {
return () => window.removeEventListener('keydown', onKey);
}, [bindSlash, drawer, menu, confirm]);
- const done = (msg) => { notify(msg); load(); if (onChanged) onChanged(); };
+ const done = (msg) => { notify(msg); refresh(); };
const saveForm = async () => {
if (!form || !form.name.trim()) return;
@@ -1507,8 +1514,7 @@ function CredsManager({ path, projectName, onChanged, bindSlash }) {
if (resp && resp.error) setError(resp.error);
else {
notify(`${field === 'inject' ? 'Auto-inject' : 'Agent access'} ${next ? 'enabled' : 'disabled'} for '${entry.name}'`);
- load();
- if (onChanged) onChanged();
+ refresh();
}
} catch (e) { setError(apiErr(e)); }
};
@@ -1619,7 +1625,7 @@ function CredsManager({ path, projectName, onChanged, bindSlash }) {
} else {
notify(`${label}: ${ok} ${ok === 1 ? 'entry' : 'entries'}`);
}
- if (action !== 'check') { exitSelect(); load(); if (onChanged) onChanged(); }
+ if (action !== 'check') { exitSelect(); refresh(); }
} catch (e) { notify(apiErr(e), 'err'); }
setBulkBusy(false);
};
@@ -2012,7 +2018,7 @@ function CredsManager({ path, projectName, onChanged, bindSlash }) {
{ setDrawer(null); setReplaceOnOpen(false); }}
- onChanged={() => { load(); if (onChanged) onChanged(); }} />
+ onChanged={refresh} />
)}
{confirm && setConfirm(null)} />}
@@ -2420,7 +2426,8 @@ function HubCredentials({ projects, onOpenDrill }) {
) : sub === 'global' ? (
-
+
) : !ov ? (
Loading…
) : (ov.projects || []).length === 0 ? (
diff --git a/cli/hub_ui/state.js b/cli/hub_ui/state.js
index 9826d4b..72527d3 100644
--- a/cli/hub_ui/state.js
+++ b/cli/hub_ui/state.js
@@ -24,11 +24,15 @@ const IDE_OPTIONS = [
const ideLabel = (ide) => IDE_LABELS[ide] || ide || 'unknown';
-// Repeating poll that pauses while the tab is hidden.
+// Repeating poll that pauses while the tab is hidden and skips a tick while
+// the previous call is still running, so a slow route never stacks requests.
const usePoll = (fn, ms) => {
React.useEffect(() => {
- const iv = setInterval(() => {
- if (!document.hidden) fn();
+ let running = false;
+ const iv = setInterval(async () => {
+ if (document.hidden || running) return;
+ running = true;
+ try { await fn(); } finally { running = false; }
}, ms);
return () => clearInterval(iv);
}, [fn, ms]);
diff --git a/cli/server.py b/cli/server.py
index 0afbc42..84bdd33 100644
--- a/cli/server.py
+++ b/cli/server.py
@@ -2764,12 +2764,12 @@ def api_credentials_list():
from services import credential_store as cred_store
pp = str(PROJECT_PATH)
usage = cred_store.read_usage_state(pp)
+ listed = cred_store.list_entries(pp)
+ present = cred_store.presence_for(listed, pp)
out = []
- for name, entry in cred_store.list_entries(pp).items():
+ for name, entry in listed.items():
out.append(cred_store.public_entry(
- name, entry, usage=usage,
- value_ok=cred_store.is_resolvable(
- name, project_path=pp, scope=entry["scope"])))
+ name, entry, usage=usage, value_ok=present[name]))
return jsonify({"entries": out})
diff --git a/cli/tools/credentials.py b/cli/tools/credentials.py
index f394aa2..3589d2f 100644
--- a/cli/tools/credentials.py
+++ b/cli/tools/credentials.py
@@ -98,8 +98,7 @@ def _act_list(project_path: str) -> str:
"The user manages them via the Credentials UI tab or `c3 creds set`."
)
usage = cs.read_usage_state(project_path)
- missing = {n for n, e in entries.items() if not cs.is_resolvable(
- n, project_path=project_path, scope=e["scope"])}
+ missing = {n for n, ok in cs.presence_for(entries, project_path).items() if not ok}
lines = [f"[creds] {len(entries)} entries (project scope shadows global):"]
lines += [_format_entry_line(n, e, usage, n in missing)
for n, e in entries.items()]
diff --git a/cli/ui.html b/cli/ui.html
index 1e1d20a..c2b4f01 100644
--- a/cli/ui.html
+++ b/cli/ui.html
@@ -160,7 +160,7 @@
-
diff --git a/pyproject.toml b/pyproject.toml
index afb8171..977dec3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "code-context-control"
-version = "2.145.2"
+version = "2.146.0"
description = "Local MCP code-intelligence for AI coding tools: surgical search/read/edit, agent-config version history, path-level access + masking guards, and a multi-project hub."
readme = "README.md"
requires-python = ">=3.10"
diff --git a/services/activity_log.py b/services/activity_log.py
index 99eaf11..ac7d053 100644
--- a/services/activity_log.py
+++ b/services/activity_log.py
@@ -1,6 +1,6 @@
"""ActivityLog — Append-only JSONL activity log for C3 events."""
import json
-from collections import Counter, deque
+from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
@@ -75,6 +75,9 @@ def find_last(self, event_type: str, limit: int = 1,
"""
if not self.log_file.exists():
return []
+ # A row of this type holds its JSON-encoded name, so a chunk or line
+ # without those bytes cannot match and is skipped unparsed.
+ needle = json.dumps(event_type).encode("utf-8")
events: list = []
try:
with open(self.log_file, "rb") as handle:
@@ -86,12 +89,19 @@ def find_last(self, event_type: str, limit: int = 1,
position -= step
handle.seek(position)
block = handle.read(step) + tail
- lines = block.split(b"\n")
- # The first element may be a partial line: keep it for the
- # next (earlier) chunk unless we are at the file start.
- tail = lines.pop(0) if position > 0 else b""
- for raw in reversed(lines):
- if not raw.strip():
+ # The first line may be partial: keep it for the next
+ # (earlier) chunk unless we are at the file start.
+ tail = b""
+ if position > 0:
+ cut = block.find(b"\n")
+ if cut < 0:
+ tail = block
+ continue
+ tail, block = block[:cut], block[cut + 1:]
+ if needle not in block:
+ continue
+ for raw in reversed(block.split(b"\n")):
+ if needle not in raw:
continue
try:
entry = json.loads(raw.decode("utf-8", "replace"))
@@ -119,15 +129,13 @@ def get_recent(self, limit: int = 100, event_type: str = None,
# far back in the log behind many tool_call entries. Use a larger scan
# window so they aren't missed.
scan_factor = 100 if event_type else 5
- tail = deque(maxlen=max(1, limit * scan_factor))
- with open(self.log_file, encoding="utf-8") as handle:
- for line in handle:
- if line.strip():
- tail.append(line)
- for line in reversed(tail):
+ needle = json.dumps(event_type).encode("utf-8") if event_type else b""
+ for raw in self._tail_lines(max(1, limit * scan_factor)):
+ if needle not in raw:
+ continue
try:
- entry = json.loads(line)
- except json.JSONDecodeError:
+ entry = json.loads(raw)
+ except ValueError:
continue
if event_type and entry.get("type") != event_type:
continue
@@ -141,6 +149,26 @@ def get_recent(self, limit: int = 100, event_type: str = None,
break
return events
+ def _tail_lines(self, count: int, chunk_bytes: int = 64 * 1024) -> list:
+ """The last ``count`` non-blank lines as bytes, newest first."""
+ lines: list = []
+ with open(self.log_file, "rb") as handle:
+ handle.seek(0, 2)
+ position = handle.tell()
+ tail = b""
+ while position > 0 and len(lines) < count:
+ step = min(chunk_bytes, position)
+ position -= step
+ handle.seek(position)
+ parts = (handle.read(step) + tail).split(b"\n")
+ tail = parts.pop(0) if position > 0 else b""
+ for raw in reversed(parts):
+ if raw.strip():
+ lines.append(raw)
+ if len(lines) >= count:
+ break
+ return lines
+
def get_stats(self) -> dict:
"""Counts by event type, total events, time range."""
if not self.log_file.exists():
diff --git a/services/ci_act.py b/services/ci_act.py
index b153bbb..6f13aab 100644
--- a/services/ci_act.py
+++ b/services/ci_act.py
@@ -88,12 +88,33 @@ def _probe(cmd: list, timeout: int = 30) -> tuple:
return 127, f"{type(exc).__name__}: {exc}"
-def availability() -> dict:
+#: How old an engine probe a display surface may reuse. The hub CI tab
+#: re-inspects every 2-10 s, and each fresh probe spawns act and docker.
+DISPLAY_PROBE_TTL_S = 30.0
+_display_probe: tuple = (0.0, None)
+
+
+def availability(max_age: float = 0.0) -> dict:
"""Can this engine run at all? Reports WHY not, never just False.
Both halves are checked because they fail independently and a user with
act installed but Docker stopped deserves to be told which one to fix.
+
+ ``max_age`` > 0 reuses a result at most that many seconds old from an
+ earlier call that also passed it; the default always probes, which is
+ what a run and ``doctor`` need.
"""
+ global _display_probe
+ if max_age <= 0:
+ return _probe_engine()
+ at, info = _display_probe
+ if info is None or time.monotonic() - at > max_age:
+ info = _probe_engine()
+ _display_probe = (time.monotonic(), info)
+ return dict(info)
+
+
+def _probe_engine() -> dict:
act = find_act()
info: dict = {"ok": False, "act": act, "act_version": "",
"docker": False, "docker_version": "", "reason": ""}
diff --git a/services/ci_workflow.py b/services/ci_workflow.py
index c5582de..9841949 100644
--- a/services/ci_workflow.py
+++ b/services/ci_workflow.py
@@ -800,8 +800,11 @@ def inspect_project(project_path, event: str = "", engine: str = "auto") -> dict
# Lazy: keeps parsing independent of the execution engines, and avoids
# paying for a `docker version` probe on every import.
from services import ci_act
- engines = ci_act.availability() if engine in ("auto", "act") else {
- "ok": False, "reason": "engine='native' — containers not considered"}
+ if engine in ("auto", "act"):
+ engines = ci_act.availability(max_age=ci_act.DISPLAY_PROBE_TTL_S)
+ else:
+ engines = {"ok": False,
+ "reason": "engine='native' — containers not considered"}
act_ok = bool(engines.get("ok"))
native, container, foreign, unsupported = [], [], [], []
diff --git a/services/credential_backup.py b/services/credential_backup.py
index bb03e47..785ee7c 100644
--- a/services/credential_backup.py
+++ b/services/credential_backup.py
@@ -310,20 +310,26 @@ def sync(project_paths=()) -> dict:
return out
-def status(project_paths=()) -> dict:
+def status(project_paths=(), presence=None) -> dict:
"""Counts only — never a value. ``not_backed_up`` lists registered entries
with a live value but no sealed copy; ``restorable`` lists entries whose
- value is gone but whose copy is here."""
+ value is gone but whose copy is here.
+
+ ``presence`` maps realm to a ``credential_store.value_presence`` result
+ the caller already holds; any other vault is probed here, once."""
data = _load()
records = data.get("records", {}) if data else {}
out: dict = {"enabled": bool(data), "path": str(backup_path() or ""),
"created": data.get("created", "") if data else "",
"backed_up": sum(len(r) for r in records.values()),
"restorable": [], "lost": [], "not_backed_up": []}
+ known = dict(presence or {})
for scope, pp, realm_s, name in _realm_entries(project_paths):
label = name if scope == "global" else f"{name} ({pp})"
has_copy = name in records.get(realm_s, {})
- if cs.is_resolvable(name, project_path=pp, scope=scope):
+ if realm_s not in known:
+ known[realm_s] = cs.value_presence(scope, pp)
+ if known[realm_s].get(name, False):
if not has_copy:
out["not_backed_up"].append(label)
elif has_copy:
diff --git a/services/credential_store.py b/services/credential_store.py
index 13fb872..1b3db00 100644
--- a/services/credential_store.py
+++ b/services/credential_store.py
@@ -388,12 +388,14 @@ def _file_get(scope: str, project_path: str, realm_s: str, name: str) -> Optiona
token = _load_sidecar(path).get(name)
if not token:
return None
- key = _master_key(realm_s, create=False)
- if key is None:
+ return _decrypt(_master_key(realm_s, create=False), token)
+
+
+def _decrypt(key: Optional[bytes], token) -> Optional[str]:
+ if not key or not token:
return None
try:
- fernet = _crypto_module()(key)
- return fernet.decrypt(token.encode("ascii")).decode("utf-8")
+ return _crypto_module()(key).decrypt(token.encode("ascii")).decode("utf-8")
except Exception:
return None
@@ -1136,15 +1138,75 @@ def is_resolvable(name: str, *, project_path: str = ".", scope: str = "") -> boo
gateway) can answer "is this credential still good?" without importing
get_value at all. That absence is what the source-grep invariant test
asserts, which is a stronger guarantee than reviewing call sites."""
- raw = _get_raw(name, project_path=project_path, scope=scope)
- if raw is None:
+ owning = _norm_scope(scope, project_path) if scope else _owning_scope(name, project_path)
+ if not owning:
return False
- if structured_type(name, project_path=project_path, scope=scope):
+ return value_presence(owning, project_path, names=[name]).get(name, False)
+
+
+def value_presence(scope: str, project_path: str = ".", names=None) -> dict:
+ """``{name: bool}`` for one scope's registered entries (or only ``names``):
+ whether each stored value can still be decoded. Never a value.
+
+ A structured entry counts only when its payload is a JSON object. The
+ registry, realm, sidecar and master key are read once for the batch, so a
+ listing costs one keyring read per entry plus the structured attestation
+ for values that are not JSON objects.
+ """
+ scope = _norm_scope(scope, project_path)
+ entries = _read_entries(scope, project_path)
+ if names is not None:
+ entries = {n: entries[n] for n in names if n in entries}
+ if not entries:
+ return {}
+ try:
+ keyring = _keyring_module()
+ except RuntimeError:
+ return dict.fromkeys(entries, False)
+ realm_s = realm(scope, project_path)
+ sidecar, key = None, None
+ out = {}
+ for name, entry in entries.items():
+ if entry.get("storage") == "file":
+ if sidecar is None:
+ path = _secrets_path(scope, project_path)
+ sidecar = _load_sidecar(path) if path is not None else {}
+ key = _master_key(realm_s, create=False) if sidecar else None
+ raw = _decrypt(key, sidecar.get(name))
+ else:
+ try:
+ raw = keyring.get_password(KEYRING_SERVICE, _account(realm_s, name))
+ except Exception:
+ raw = None
+ if raw is None:
+ out[name] = False
+ continue
try:
- return isinstance(json.loads(raw), dict)
+ is_object = isinstance(json.loads(raw), dict)
+ except (ValueError, RecursionError):
+ is_object = False
+ if is_object or entry.get("type") in STRUCTURED_TYPES:
+ out[name] = is_object
+ continue
+ try:
+ attested = keyring.get_password(
+ KEYRING_SERVICE, _struct_account(realm_s, name)) or ""
except Exception:
- return False
- return True
+ attested = ""
+ out[name] = attested not in STRUCTURED_TYPES
+ return out
+
+
+def presence_for(entries: dict, project_path: str = ".") -> dict:
+ """``{name: bool}`` for a :func:`list_entries` view, each name probed in
+ the realm that owns it."""
+ by_scope: dict = {}
+ for name, entry in entries.items():
+ by_scope.setdefault(entry["scope"], []).append(name)
+ out = dict.fromkeys(entries, False)
+ for scope, names in by_scope.items():
+ out.update(value_presence(scope, project_path, names=names))
+ return out
def _get_raw(name: str, *, project_path: str = ".", scope: str = "") -> Optional[str]:
diff --git a/services/project_manager.py b/services/project_manager.py
index 9d84102..0c7196a 100644
--- a/services/project_manager.py
+++ b/services/project_manager.py
@@ -533,6 +533,15 @@ def sweep_registry(self) -> dict:
return {"dropped": len(registry) - len(valid) - len(reaped),
"reaped": reaped}
+ def list_registered(self) -> list:
+ """The stored project rows (name, path, parent_path, ...) as registered.
+
+ No liveness, session or config enrichment, so it touches one file.
+ Callers that only iterate projects use this; ``list_projects`` reads
+ every project's activity log and probes session ports.
+ """
+ return self._read_projects()
+
def list_projects(self) -> list:
projects = self._read_projects()
registry = self._read_registry()
diff --git a/services/session_catalog.py b/services/session_catalog.py
index 6c23917..a42b3c8 100644
--- a/services/session_catalog.py
+++ b/services/session_catalog.py
@@ -112,7 +112,7 @@ def _bare(text: str) -> str:
return re.sub(r"[^a-z0-9]", "", text.lower())
-def claude_transcript_dir(project) -> Path | None:
+def claude_transcript_dir(project, index: _RootIndex | None = None) -> Path | None:
"""The transcript folder Claude Code uses for ``project``, or None.
Exact slug first (every non-alphanumeric becomes ``-``), then the same slug
@@ -131,21 +131,62 @@ def claude_transcript_dir(project) -> Path | None:
if name and cand.is_dir():
return cand
target = _bare(project_str)
- try:
- for d in root.iterdir():
- if d.is_dir() and _bare(d.name) == target:
- return d
- except OSError:
- return None
+ if index is None or index.root != root:
+ index = _RootIndex(root)
+ for d in index.dirs():
+ if _bare(d.name) == target:
+ return d
return None
-def _same_dir(a: str, b) -> bool:
+class _RootIndex:
+ """One listing of the transcript root and, on first use, of which folder
+ holds each transcript id — shared by every project one request builds.
+
+ Request-scoped on purpose: Windows does not refresh a folder's mtime in
+ its parent's listing when a file is created inside it, so a listing kept
+ across requests cannot tell that a new transcript appeared.
+ """
+
+ def __init__(self, root: Path):
+ self.root = root
+ self._dirs: list[Path] | None = None
+ self._owners: dict[str, Path] | None = None
+ self._lock = threading.Lock()
+
+ def dirs(self) -> list[Path]:
+ with self._lock:
+ if self._dirs is None:
+ try:
+ with os.scandir(self.root) as it:
+ self._dirs = [Path(e.path) for e in it if e.is_dir()]
+ except OSError:
+ self._dirs = []
+ return self._dirs
+
+ def owner(self, stem: str) -> Path | None:
+ dirs = self.dirs()
+ with self._lock:
+ if self._owners is None:
+ owners: dict[str, Path] = {}
+ for d in dirs:
+ try:
+ with os.scandir(d) as it:
+ for e in it:
+ if e.name.endswith(".jsonl") and e.is_file():
+ owners.setdefault(e.name[:-6], Path(e.path))
+ except OSError:
+ continue
+ self._owners = owners
+ return self._owners.get(stem)
+
+
+def _dir_key(path) -> str:
try:
- ra, rb = os.path.realpath(a), os.path.realpath(str(b))
- except Exception:
- ra, rb = str(a), str(b)
- return os.path.normcase(os.path.normpath(ra)) == os.path.normcase(os.path.normpath(rb))
+ real = os.path.realpath(str(path))
+ except (OSError, ValueError):
+ real = str(path)
+ return os.path.normcase(os.path.normpath(real))
# ── Reading one transcript ─────────────────────────────────────────────────
@@ -569,36 +610,39 @@ def _transcript_files(tdir: Path | None, limit: int | None = MAX_TRANSCRIPTS) ->
return files if limit is None else files[:limit]
-def _find_elsewhere(host_id: str) -> Path | None:
+def _find_elsewhere(host_id: str, index: _RootIndex) -> Path | None:
"""A Claude transcript for ``host_id`` in ANY project folder — a session
run from a worktree lives under the worktree's slug, not the project's."""
if not UUID_RE.match(host_id or ""):
return None
- root = claude_projects_root()
- try:
- for d in root.iterdir():
- cand = d / f"{host_id}.jsonl"
- if cand.is_file():
- return cand
- except OSError:
- return None
- return None
+ return index.owner(host_id)
+
+def _build(project, name: str = "", index: _RootIndex | None = None) -> tuple[list[dict], dict]:
+ """Every row for one project (unfiltered) plus a lookup context.
-def _build(project, name: str = "") -> tuple[list[dict], dict]:
- """Every row for one project (unfiltered) plus a lookup context."""
+ ``index``: a transcript-root listing shared across one request's builds.
+ """
project = Path(project)
name = name or project.name
+ root = claude_projects_root()
+ if index is None or index.root != root:
+ index = _RootIndex(root)
with _project_lock(project):
cache = _load_cache(project)
dirty: list = []
- tdir = claude_transcript_dir(project)
+ tdir = claude_transcript_dir(project, index)
metas = {}
+ own_dir = _dir_key(project)
+ cwd_keys: dict[str, str] = {}
for f in _transcript_files(tdir):
meta = _cached(cache["transcripts"], f, scan_transcript, dirty)
if not meta:
continue
- if meta.get("cwd") and not _same_dir(meta["cwd"], project):
+ cwd = meta.get("cwd")
+ if cwd and cwd not in cwd_keys:
+ cwd_keys[cwd] = _dir_key(cwd)
+ if cwd and cwd_keys[cwd] != own_dir:
continue # a folder that only looks like ours
metas[meta["id"]] = dict(meta, _path=str(f))
records = []
@@ -617,6 +661,7 @@ def _build(project, name: str = "") -> tuple[list[dict], dict]:
snaps.append(snap)
if dirty:
_save_cache(project, cache)
+ dirty.clear()
# C3 id → host id, from every place that states it.
c3_to_host: dict[str, str] = {}
@@ -669,14 +714,16 @@ def row_id_for(c3id: str) -> str:
continue
rec0 = recs[0]
if _provider(rec0["source_system"]) == "claude" and UUID_RE.match(rid):
- found = _find_elsewhere(rid)
- if found is not None:
- try:
- metas[rid] = dict(scan_transcript(found), _path=str(found), _elsewhere=True)
- continue
- except Exception:
- pass
+ found = _find_elsewhere(rid, index)
+ meta = _cached(cache["transcripts"], found, scan_transcript, dirty) \
+ if found is not None else None
+ if meta:
+ metas[rid] = dict(meta, _path=str(found), _elsewhere=True)
+ continue
metas[rid] = {"id": rid, "_c3_only": True, "provider": _provider(rec0["source_system"])}
+ if dirty:
+ with _project_lock(project):
+ _save_cache(project, cache)
marks = _fold_marks(_read_marks(project))
cleared, pred, succ = _lifecycle(project)
@@ -818,11 +865,12 @@ def one(p):
if not path or not (Path(path) / ".c3").is_dir():
return path, [], ""
try:
- rows, _ = _build(path, str(p.get("name") or ""))
+ rows, _ = _build(path, str(p.get("name") or ""), index)
return path, rows, ""
except Exception as exc: # pragma: no cover - defensive
return path, [], str(exc)
+ index = _RootIndex(claude_projects_root())
merged, errors = [], []
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
for path, rows, err in pool.map(one, projects):
diff --git a/tests/test_activity_log_find_last.py b/tests/test_activity_log_find_last.py
index 734fcb7..e5649cf 100644
--- a/tests/test_activity_log_find_last.py
+++ b/tests/test_activity_log_find_last.py
@@ -20,7 +20,7 @@
from services.activity_log import ActivityLog # noqa: E402
-class TestFindLast(unittest.TestCase):
+class _LogCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
@@ -38,6 +38,8 @@ def _buried(self, noise: int):
rows += [{"type": "tool_call", "timestamp": f"t{i}"} for i in range(noise)]
self._write(rows)
+
+class TestFindLast(_LogCase):
def test_finds_a_row_get_recent_cannot_see(self):
self._buried(5000)
self.assertEqual(self.activity.get_recent(limit=1, event_type="session_start"), [])
@@ -91,6 +93,32 @@ def test_matches_get_recent_when_both_can_see_the_row(self):
self.activity.find_last("session_start"),
self.activity.get_recent(limit=1, event_type="session_start"))
+ def test_a_row_that_only_mentions_the_type_is_not_a_match(self):
+ self._write([
+ {"type": "session_start", "session_id": "real", "timestamp": "t0"},
+ {"type": "tool_call", "note": "session_start", "timestamp": "t1"},
+ ])
+ self.assertEqual(
+ [r["session_id"] for r in self.activity.find_last("session_start")], ["real"])
+ self.assertEqual(
+ [r["timestamp"] for r in self.activity.get_recent(event_type="session_start")],
+ ["t0"])
+
+
+class TestGetRecentTail(_LogCase):
+ def test_untyped_reads_the_newest_rows_across_chunks(self):
+ self._write([{"type": "tool_call", "timestamp": f"t{i:05d}", "pad": "x" * 100}
+ for i in range(3000)])
+ self.assertEqual([r["timestamp"] for r in self.activity.get_recent(limit=3)],
+ ["t02999", "t02998", "t02997"])
+
+ def test_window_counts_every_line_not_only_matches(self):
+ rows = [{"type": "decision", "timestamp": "t0"}]
+ rows += [{"type": "tool_call", "timestamp": f"t{i}"} for i in range(1, 101)]
+ self._write(rows)
+ self.assertEqual(self.activity.get_recent(limit=1, event_type="decision"), [])
+ self.assertEqual(len(self.activity.get_recent(limit=2, event_type="decision")), 1)
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_ci_act.py b/tests/test_ci_act.py
index f3ef0b5..792e225 100644
--- a/tests/test_ci_act.py
+++ b/tests/test_ci_act.py
@@ -560,5 +560,32 @@ def test_runs_a_linux_job_and_reports_a_clean_failure(self):
self.assertEqual(job.failures[0]["file"], "app/thing.py")
+class TestDisplayProbeCache(unittest.TestCase):
+ def setUp(self):
+ self._saved = ci_act._display_probe
+ ci_act._display_probe = (0.0, None)
+
+ def tearDown(self):
+ ci_act._display_probe = self._saved
+
+ def test_max_age_reuses_a_recent_probe_and_the_default_always_probes(self):
+ with mock.patch.object(ci_act, "_probe_engine",
+ side_effect=lambda: {"ok": True}) as probe:
+ ci_act.availability(max_age=30)
+ ci_act.availability(max_age=30)
+ self.assertEqual(probe.call_count, 1)
+ ci_act.availability()
+ self.assertEqual(probe.call_count, 2)
+
+ def test_an_expired_probe_runs_again(self):
+ with mock.patch.object(ci_act, "_probe_engine",
+ side_effect=lambda: {"ok": True}) as probe:
+ ci_act.availability(max_age=30)
+ at, info = ci_act._display_probe
+ ci_act._display_probe = (at - 31, info)
+ ci_act.availability(max_age=30)
+ self.assertEqual(probe.call_count, 2)
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_cred_audit.py b/tests/test_cred_audit.py
index df6b4b5..6c2bd3d 100644
--- a/tests/test_cred_audit.py
+++ b/tests/test_cred_audit.py
@@ -170,7 +170,7 @@ def test_hub_audit_route_isolates_an_unreadable_project(self):
rows = [{"name": "p", "path": str(proj)}]
class _PM:
- def list_projects(self):
+ def list_registered(self):
return rows
with mock.patch.object(hub_server, "_pm", new=lambda: _PM()), \
@@ -212,7 +212,7 @@ def test_scope_global_reads_the_shared_vault_alone(self):
"scope": "global", "via": "hub"})
class _PM:
- def list_projects(self):
+ def list_registered(self):
return [{"name": "p", "path": str(proj)}]
with mock.patch.object(hub_server, "_pm", new=lambda: _PM()), \
diff --git a/tests/test_credential_store.py b/tests/test_credential_store.py
index e8f84c8..0fc4de4 100644
--- a/tests/test_credential_store.py
+++ b/tests/test_credential_store.py
@@ -700,6 +700,56 @@ def test_missing_type_entry_stays_plain(self):
self.assertEqual(
cs.structured_type("OLD", project_path=self.project), "")
+ def _retype(self, name: str, ctype: str) -> None:
+ cfg_path = Path(self.project) / ".c3" / "config.json"
+ cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
+ cfg["credentials"]["entries"][name]["type"] = ctype
+ cfg_path.write_text(json.dumps(cfg), encoding="utf-8")
+
+ def test_value_presence_answers_per_entry_kind(self):
+ realm_s = cs.realm("project", self.project)
+ cs.set_credential("TOK", "plaintok", project_path=self.project)
+ cs.set_credential("VISA", CARD, project_path=self.project, ctype="card")
+ big = json.dumps({"full_name": "D T", "ssn": "x" * 250, "dob": "y" * 250,
+ "phone": "z" * 250, "email": "e" * 250})
+ self.assertEqual(cs.set_credential("BIG", big, project_path=self.project,
+ ctype="identity")["storage"], "file")
+ cs.set_credential("GONE", "v", project_path=self.project)
+ del self._stub.store[(cs.KEYRING_SERVICE, cs._account(realm_s, "GONE"))]
+ cs.set_credential("FORGED", "plaintok", project_path=self.project)
+ self._retype("FORGED", "card")
+ cs.set_credential("DEMOTED", CARD, project_path=self.project, ctype="card")
+ self._retype("DEMOTED", "token")
+ self._stub.set_password(cs.KEYRING_SERVICE, cs._account(realm_s, "DEMOTED"), "flat")
+
+ self.assertEqual(cs.value_presence("project", self.project), {
+ "TOK": True, "VISA": True, "BIG": True, "GONE": False,
+ "FORGED": False, "DEMOTED": False})
+ self.assertEqual(cs.value_presence("project", self.project, names=["TOK", "NOPE"]),
+ {"TOK": True})
+
+ def test_value_presence_reads_the_registry_once(self):
+ for i in range(5):
+ cs.set_credential(f"T{i}", "plaintok", project_path=self.project)
+ reads = mock.Mock(wraps=cs._load_config)
+ lookups = mock.Mock(wraps=self._stub.get_password)
+ with mock.patch.object(cs, "_load_config", reads), \
+ mock.patch.object(self._stub, "get_password", lookups):
+ self.assertTrue(all(cs.value_presence("project", self.project).values()))
+ self.assertEqual(reads.call_count, 1)
+ self.assertLessEqual(lookups.call_count, 10)
+
+ def test_presence_for_answers_each_name_from_its_owning_realm(self):
+ cs.set_credential("SHARED", "g", scope="global", project_path=self.project)
+ cs.set_credential("LOCAL", "p", project_path=self.project)
+ cs.set_credential("BOTH", "g", scope="global", project_path=self.project)
+ cs.set_credential("BOTH", "p", project_path=self.project)
+ del self._stub.store[(cs.KEYRING_SERVICE, cs._account(
+ cs.realm("project", self.project), "BOTH"))]
+ listed = cs.list_entries(self.project)
+ self.assertEqual(cs.presence_for(listed, self.project),
+ {"SHARED": True, "LOCAL": True, "BOTH": False})
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_hub_access_routes.py b/tests/test_hub_access_routes.py
index 0d54628..548709a 100644
--- a/tests/test_hub_access_routes.py
+++ b/tests/test_hub_access_routes.py
@@ -37,7 +37,7 @@ class _StubPM:
def __init__(self, projects):
self._projects = projects
- def list_projects(self):
+ def list_registered(self):
return self._projects
diff --git a/tests/test_hub_credentials_routes_write.py b/tests/test_hub_credentials_routes_write.py
index ec3e518..307dd7a 100644
--- a/tests/test_hub_credentials_routes_write.py
+++ b/tests/test_hub_credentials_routes_write.py
@@ -63,7 +63,7 @@ class _StubPM:
def __init__(self, rows):
self.rows = rows
- def list_projects(self):
+ def list_registered(self):
return self.rows
diff --git a/tests/test_hub_enforcement_routes.py b/tests/test_hub_enforcement_routes.py
index d5f300f..1a92cc1 100644
--- a/tests/test_hub_enforcement_routes.py
+++ b/tests/test_hub_enforcement_routes.py
@@ -30,7 +30,7 @@ class _StubPM:
def __init__(self, projects):
self._projects = projects
- def list_projects(self):
+ def list_registered(self):
return self._projects
diff --git a/tests/test_hub_locks_routes.py b/tests/test_hub_locks_routes.py
index 85c0397..53c26e2 100644
--- a/tests/test_hub_locks_routes.py
+++ b/tests/test_hub_locks_routes.py
@@ -26,7 +26,7 @@ class _StubPM:
def __init__(self, projects):
self._projects = projects
- def list_projects(self):
+ def list_registered(self):
return self._projects
diff --git a/tests/test_hub_rules_routes.py b/tests/test_hub_rules_routes.py
index d4f08dd..9173823 100644
--- a/tests/test_hub_rules_routes.py
+++ b/tests/test_hub_rules_routes.py
@@ -41,7 +41,7 @@ class _StubPM:
def __init__(self, projects):
self._projects = projects
- def list_projects(self):
+ def list_registered(self):
return self._projects
diff --git a/tests/test_hub_sessions_routes.py b/tests/test_hub_sessions_routes.py
index 3f34995..692ef9a 100644
--- a/tests/test_hub_sessions_routes.py
+++ b/tests/test_hub_sessions_routes.py
@@ -35,7 +35,7 @@ class _StubPM:
def __init__(self, projects):
self._projects = projects
- def list_projects(self):
+ def list_registered(self):
return self._projects
diff --git a/tests/test_session_catalog.py b/tests/test_session_catalog.py
index b808523..6a97f9c 100644
--- a/tests/test_session_catalog.py
+++ b/tests/test_session_catalog.py
@@ -370,5 +370,35 @@ def test_unknown_traversal_and_foreign_ids_are_404(self):
self.assertNotIn("argv", spec)
+class TestRootIndex(_Base):
+ def test_a_worktree_transcript_created_after_a_listing_shows_on_the_next(self):
+ wt = self.proj / ".claude" / "worktrees" / "feat"
+ wt.mkdir(parents=True)
+ self.fx.tdir(wt)
+ self.fx.record(self.proj, "20260901_100000_aaaaaaaaaaaa", U1)
+ self.assertFalse(self.row(U1)["resume"]["can_launch"])
+ self.fx.transcript(wt, U1, cwd=wt)
+ self.assertTrue(self.row(U1)["resume"]["can_launch"])
+
+ def test_a_transcript_in_another_folder_is_read_once_across_listings(self):
+ wt = self.proj / ".claude" / "worktrees" / "feat"
+ wt.mkdir(parents=True)
+ self.fx.transcript(wt, U1, cwd=wt)
+ self.fx.record(self.proj, "20260901_100000_aaaaaaaaaaaa", U1)
+ self.assertTrue(self.row(U1)["resume"]["can_launch"])
+ with mock.patch.object(sc, "scan_transcript", wraps=sc.scan_transcript) as scan:
+ self.assertTrue(self.row(U1)["resume"]["can_launch"])
+ scan.assert_not_called()
+
+ def test_one_index_lists_the_folders_once(self):
+ self.fx.transcript(self.proj, U1)
+ index = sc._RootIndex(sc.claude_projects_root())
+ self.assertIsNotNone(index.owner(U1))
+ with mock.patch.object(sc.os, "scandir") as scandir:
+ self.assertIsNone(index.owner(U2))
+ self.assertTrue(index.dirs())
+ scandir.assert_not_called()
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_token_tracking.py b/tests/test_token_tracking.py
index 682ea03..903f4f3 100644
--- a/tests/test_token_tracking.py
+++ b/tests/test_token_tracking.py
@@ -287,7 +287,7 @@ def test_hub_overview_isolates_a_project_it_cannot_read(self):
{"name": "bare", "path": str(Path(tmp) / "bare")}]
class _PM:
- def list_projects(self):
+ def list_registered(self):
return rows
with mock.patch.object(hub_server, "_pm", new=lambda: _PM()):
@@ -307,7 +307,7 @@ def test_days_param_is_clamped_not_trusted(self):
with tempfile.TemporaryDirectory() as tmp:
class _PM:
- def list_projects(self):
+ def list_registered(self):
return []
with mock.patch.object(hub_server, "_pm", new=lambda: _PM()):