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
62 changes: 62 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions cli/c3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
51 changes: 28 additions & 23 deletions cli/hub_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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})


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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": []}
Expand All @@ -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})
Expand All @@ -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"]),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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": "",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)}
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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})

Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion cli/hub_ui.html
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@

<body>
<div id="root"></div>
<script type="text/babel">
<script type="text/babel" data-presets="react">
/* __C3_HUB_SCRIPTS__ */
</script>
</body>
Expand Down
5 changes: 4 additions & 1 deletion cli/hub_ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
35 changes: 21 additions & 14 deletions cli/hub_ui/components/hub_credentials.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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)); }
};
Expand Down Expand Up @@ -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);
};
Expand Down Expand Up @@ -2012,7 +2018,7 @@ function CredsManager({ path, projectName, onChanged, bindSlash }) {
<CredDrawer entry={drawer} path={path} projectName={projectName}
initialReplace={replaceOnOpen}
onClose={() => { setDrawer(null); setReplaceOnOpen(false); }}
onChanged={() => { load(); if (onChanged) onChanged(); }} />
onChanged={refresh} />
)}
{confirm && <CredConfirm spec={confirm} onClose={() => setConfirm(null)} />}
</div>
Expand Down Expand Up @@ -2420,7 +2426,8 @@ function HubCredentials({ projects, onOpenDrill }) {
<CredAuditView path={null} initialName={auditName}
onOpenDrill={onOpenDrill} />
) : sub === 'global' ? (
<CredsManager path={null} onChanged={loadOverview} />
<CredsManager path={null} onChanged={loadOverview}
globalEntries={ov ? ((ov.global || {}).entries || []) : null} />
) : !ov ? (
<div style={{ color: T.textMuted, fontSize: 13 }}>Loading…</div>
) : (ov.projects || []).length === 0 ? (
Expand Down
Loading
Loading