code-context-control 2.145.2__py3-none-any.whl → 2.146.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cli/c3.py CHANGED
@@ -91,7 +91,7 @@ console = Console() if HAS_RICH else None
91
91
  # Config
92
92
  CONFIG_DIR = ".c3"
93
93
  CONFIG_FILE = ".c3/config.json"
94
- __version__ = "2.145.2"
94
+ __version__ = "2.146.0"
95
95
 
96
96
  # The PreToolUse matcher for native subagent calls (installer and hub migration).
97
97
  AGENT_MATCHER = "Agent|Task"
@@ -6685,10 +6685,10 @@ def _creds_cmd_list(args, project_path: str) -> None:
6685
6685
  "(add --global for all projects).")
6686
6686
  return
6687
6687
  print(f"{len(entries)} credential(s) — project scope shadows global:")
6688
+ present = cred_store.presence_for(entries, project_path)
6688
6689
  missing = 0
6689
6690
  for name, entry in entries.items():
6690
- gone = not cred_store.is_resolvable(
6691
- name, project_path=project_path, scope=entry["scope"])
6691
+ gone = not present[name]
6692
6692
  missing += gone
6693
6693
  print(" " + _creds_entry_line(name, entry, gone))
6694
6694
  if missing:
cli/hub_server.py CHANGED
@@ -1804,7 +1804,7 @@ def api_projects_browse():
1804
1804
  return jsonify({"error": str(e)}), 400
1805
1805
  if not resolved.is_dir():
1806
1806
  return jsonify({"error": f"Not a directory: {resolved}"}), 404
1807
- registered = {os.path.normcase(p.get("path", "")) for p in _pm()._read_projects()}
1807
+ registered = {os.path.normcase(p.get("path", "")) for p in _pm().list_registered()}
1808
1808
  dirs = []
1809
1809
  try:
1810
1810
  for child in sorted(resolved.iterdir(), key=lambda c: c.name.lower()):
@@ -1913,7 +1913,7 @@ def api_search_global():
1913
1913
  if wanted else None)
1914
1914
 
1915
1915
  candidates, skipped = [], []
1916
- for p in _pm().list_projects():
1916
+ for p in _pm().list_registered():
1917
1917
  ppath = p.get("path") or ""
1918
1918
  if wanted_norm is not None and os.path.normcase(ppath) not in wanted_norm:
1919
1919
  continue
@@ -2138,14 +2138,15 @@ def api_projects_credentials():
2138
2138
  usage = cred_store.read_usage_state(str(resolved))
2139
2139
  home = cred_store.global_base()
2140
2140
  global_names = set(cred_store.list_entries(str(home))) if home else set()
2141
+ listed = cred_store.list_entries(str(resolved))
2142
+ present = cred_store.presence_for(listed, str(resolved))
2141
2143
  entries = []
2142
- for name, entry in cred_store.list_entries(str(resolved)).items():
2144
+ for name, entry in listed.items():
2143
2145
  entries.append(_cred_entry_public(
2144
2146
  name, entry, usage=usage,
2145
2147
  shadows_global=(entry.get("scope") == "project"
2146
2148
  and name in global_names),
2147
- value_ok=cred_store.is_resolvable(
2148
- name, project_path=str(resolved), scope=entry["scope"])))
2149
+ value_ok=present[name]))
2149
2150
  return jsonify({"path": str(resolved), "entries": entries})
2150
2151
 
2151
2152
 
@@ -2360,7 +2361,7 @@ def api_hub_credentials_audit():
2360
2361
  only_global = (request.args.get("scope") or "").strip().lower() == "global"
2361
2362
 
2362
2363
  events, errors = [], []
2363
- for p in ([] if only_global else _pm().list_projects()):
2364
+ for p in ([] if only_global else _pm().list_registered()):
2364
2365
  ppath = str(p.get("path") or "")
2365
2366
  if not (Path(ppath) / ".c3").is_dir():
2366
2367
  continue
@@ -2417,9 +2418,12 @@ def api_hub_credentials_overview():
2417
2418
  from services import credential_store as cred_store
2418
2419
  home = cred_store.global_base()
2419
2420
  global_entries = cred_store.list_entries(str(home)) if home else {}
2421
+ # realm -> {name: value decodes}, probed once per vault and shared with
2422
+ # the backup banner, which asks the same question of the same entries.
2423
+ present = {"global": cred_store.value_presence("global", str(home))} if home else {}
2420
2424
  shadowed_in = {name: [] for name in global_entries}
2421
2425
  projects_out = []
2422
- for p in _pm().list_projects():
2426
+ for p in _pm().list_registered():
2423
2427
  ppath = str(p.get("path") or "")
2424
2428
  row = {"name": p.get("name") or "", "path": ppath,
2425
2429
  "initialized": True, "error": None, "entries": []}
@@ -2428,14 +2432,15 @@ def api_hub_credentials_overview():
2428
2432
  row["initialized"] = False
2429
2433
  else:
2430
2434
  usage = cred_store.read_usage_state(ppath)
2435
+ ok = cred_store.value_presence("project", ppath)
2436
+ present[cred_store.realm("project", ppath)] = ok
2431
2437
  for name, entry in cred_store.list_entries(ppath).items():
2432
2438
  if entry.get("scope") != "project":
2433
2439
  continue
2434
2440
  row["entries"].append(_cred_entry_public(
2435
2441
  name, entry, usage=usage,
2436
2442
  shadows_global=name in global_entries,
2437
- value_ok=cred_store.is_resolvable(
2438
- name, project_path=ppath, scope="project")))
2443
+ value_ok=ok.get(name, False)))
2439
2444
  if name in shadowed_in:
2440
2445
  shadowed_in[name].append(
2441
2446
  {"name": row["name"], "path": ppath})
@@ -2446,21 +2451,21 @@ def api_hub_credentials_overview():
2446
2451
  global_out = [
2447
2452
  {**_cred_entry_public(
2448
2453
  name, entry, usage=global_usage,
2449
- value_ok=cred_store.is_resolvable(
2450
- name, project_path=str(home), scope="global")),
2454
+ value_ok=present.get("global", {}).get(name, False)),
2451
2455
  "shadowed_in": shadowed_in.get(name, [])}
2452
2456
  for name, entry in global_entries.items()
2453
2457
  ]
2454
2458
  return jsonify({"global": {"entries": global_out}, "projects": projects_out,
2455
2459
  "backup": _cred_backup_summary(
2456
- [row["path"] for row in projects_out if row["initialized"]])})
2460
+ [row["path"] for row in projects_out if row["initialized"]],
2461
+ present)})
2457
2462
 
2458
2463
 
2459
- def _cred_backup_summary(project_paths) -> dict:
2464
+ def _cred_backup_summary(project_paths, present) -> dict:
2460
2465
  """Backup state as counts for the Hub banner; a broken file reports its error."""
2461
2466
  from services import credential_backup
2462
2467
  try:
2463
- st = credential_backup.status(project_paths)
2468
+ st = credential_backup.status(project_paths, presence=present)
2464
2469
  except credential_backup.BackupError as exc:
2465
2470
  return {"enabled": True, "error": str(exc), "restorable": 0, "lost": 0}
2466
2471
  return {"enabled": st["enabled"], "restorable": len(st["restorable"]),
@@ -2612,7 +2617,7 @@ def api_hub_tokens_overview():
2612
2617
  days = _hub_tokens_days(request.args.get("days"))
2613
2618
  rows, totals = [], {"tool_calls": 0, "tool_tokens": 0,
2614
2619
  "session_tokens": 0, "saved": 0}
2615
- for p in _pm().list_projects():
2620
+ for p in _pm().list_registered():
2616
2621
  ppath = str(p.get("path") or "")
2617
2622
  row = {"name": p.get("name") or "", "path": ppath, "initialized": True,
2618
2623
  "error": None, "tool_calls": 0, "tool_tokens": 0,
@@ -2662,7 +2667,7 @@ def api_hub_locks_overview():
2662
2667
  from services import agent_locks as al
2663
2668
 
2664
2669
  rows, total = [], 0
2665
- for p in _pm().list_projects():
2670
+ for p in _pm().list_registered():
2666
2671
  ppath = str(p.get("path") or "")
2667
2672
  row = {"name": p.get("name") or "", "path": ppath, "initialized": True,
2668
2673
  "error": None, "enabled": True, "mode": "advisory",
@@ -2752,7 +2757,7 @@ def api_hub_enforcement_overview():
2752
2757
 
2753
2758
  rows = []
2754
2759
  totals = {"discipline": 0, "access": 0}
2755
- for p in _pm().list_projects():
2760
+ for p in _pm().list_registered():
2756
2761
  ppath = str(p.get("path") or "")
2757
2762
  row = {"name": p.get("name") or "", "path": ppath, "initialized": True,
2758
2763
  "error": None, "mode": None, "scope": "", "set_by": "",
@@ -3428,7 +3433,7 @@ def api_hub_override_policy_overview():
3428
3433
  from services import override_policy as opol
3429
3434
 
3430
3435
  rows = []
3431
- for p in _pm().list_projects():
3436
+ for p in _pm().list_registered():
3432
3437
  ppath = str(p.get("path") or "")
3433
3438
  row = {"name": p.get("name") or Path(ppath).name, "path": ppath,
3434
3439
  "initialized": True, "error": None, "policy": None,
@@ -3740,7 +3745,7 @@ def api_hub_access_overview():
3740
3745
  empty = {"deny": [], "read_only": [], "confirm": [], "mask": 0,
3741
3746
  "corrupt": False}
3742
3747
  rows = []
3743
- for p in _pm().list_projects():
3748
+ for p in _pm().list_registered():
3744
3749
  ppath = str(p.get("path") or "")
3745
3750
  row = {"name": p.get("name") or Path(ppath).name, "path": ppath,
3746
3751
  "initialized": True, "error": None, "rules": dict(empty)}
@@ -4268,7 +4273,7 @@ def api_pm_global():
4268
4273
  from services.task_store import TaskStore
4269
4274
 
4270
4275
  tasks, skipped, by_project = [], [], {}
4271
- entries = _pm()._read_projects()
4276
+ entries = _pm().list_registered()
4272
4277
  for p in entries:
4273
4278
  ppath = p.get("path") or ""
4274
4279
  if not Path(ppath).is_dir():
@@ -4808,7 +4813,7 @@ def _session_project(raw: str):
4808
4813
  if not raw:
4809
4814
  return None
4810
4815
  want = os.path.normcase(os.path.normpath(raw))
4811
- for p in _pm().list_projects():
4816
+ for p in _pm().list_registered():
4812
4817
  path = str(p.get("path") or "")
4813
4818
  if path and os.path.normcase(os.path.normpath(path)) == want:
4814
4819
  return {"name": str(p.get("name") or Path(path).name), "path": path}
@@ -4847,7 +4852,7 @@ def api_hub_sessions_overview():
4847
4852
  return row
4848
4853
 
4849
4854
  with ThreadPoolExecutor(max_workers=8) as pool:
4850
- rows = list(pool.map(one, _pm().list_projects()))
4855
+ rows = list(pool.map(one, _pm().list_registered()))
4851
4856
  rows.sort(key=lambda r: r.get("last_active") or "", reverse=True)
4852
4857
  return jsonify({"projects": rows, "features": _SESSIONS_FEATURES})
4853
4858
 
@@ -4876,7 +4881,7 @@ def api_hub_sessions_list():
4876
4881
  res["errors"] = []
4877
4882
  else:
4878
4883
  projects = [{"name": str(p.get("name") or ""), "path": str(p.get("path") or "")}
4879
- for p in _pm().list_projects()]
4884
+ for p in _pm().list_registered()]
4880
4885
  res = sc.list_many(projects, stale=stale, q=q, limit=limit, before=before)
4881
4886
  return jsonify(res)
4882
4887
  except Exception as e:
cli/hub_ui/app.js CHANGED
@@ -50,7 +50,10 @@ function App() {
50
50
  }, []);
51
51
 
52
52
  useEffect(() => { loadConfig(); loadProjects(); }, []);
53
- usePoll(loadProjects, 5000);
53
+ // Live status matters on the project list and in a drill-in. Elsewhere it
54
+ // only refreshes the sidebar and top-bar counts, which may lag 30 s, and
55
+ // each poll reads every project's activity log.
56
+ usePoll(loadProjects, mainView === 'projects' || drill ? 5000 : 30000);
54
57
 
55
58
  // Persisted preferences
56
59
  const toggleTheme = () => {
@@ -1381,7 +1381,10 @@ function CredSourcesStrip({ sources, onResync, busy }) {
1381
1381
 
1382
1382
  // path=null → the global vault (~/.c3): scope locked to 'global'.
1383
1383
  // path=string → that project's merged view (global entries + project shadows).
1384
- function CredsManager({ path, projectName, onChanged, bindSlash }) {
1384
+ // Global vault (path null): rows arrive as `globalEntries` (null while the
1385
+ // page's overview loads) and `onChanged` refills them; loading the overview
1386
+ // here as well fetched the whole cross-project index twice.
1387
+ function CredsManager({ path, projectName, onChanged, bindSlash, globalEntries }) {
1385
1388
  const isGlobal = !path;
1386
1389
  const [entries, setEntries] = useState([]);
1387
1390
  const [loading, setLoading] = useState(true);
@@ -1408,14 +1411,10 @@ function CredsManager({ path, projectName, onChanged, bindSlash }) {
1408
1411
  const withPath = (obj) => (path ? Object.assign({ path }, obj) : obj);
1409
1412
 
1410
1413
  const load = useCallback(async () => {
1414
+ if (!path) return;
1411
1415
  try {
1412
- if (path) {
1413
- const data = await api.get('/api/projects/credentials?path=' + encodeURIComponent(path));
1414
- setEntries((data && data.entries) || []);
1415
- } else {
1416
- const data = await api.get('/api/hub/credentials/overview');
1417
- setEntries((((data || {}).global) || {}).entries || []);
1418
- }
1416
+ const data = await credApi.listProject(path);
1417
+ setEntries((data && data.entries) || []);
1419
1418
  setError('');
1420
1419
  } catch (e) { setError(String(e)); }
1421
1420
  setLoading(false);
@@ -1426,6 +1425,14 @@ function CredsManager({ path, projectName, onChanged, bindSlash }) {
1426
1425
  load();
1427
1426
  }, [load]);
1428
1427
 
1428
+ useEffect(() => {
1429
+ if (path) return;
1430
+ setEntries(globalEntries || []);
1431
+ setLoading(globalEntries == null);
1432
+ }, [path, globalEntries]);
1433
+
1434
+ const refresh = () => { load(); if (onChanged) onChanged(); };
1435
+
1429
1436
  // `/` focuses the filter, as it does on every other hub board — but only
1430
1437
  // where nothing above already claims the key. The hub Credentials page owns
1431
1438
  // `/` for its cross-project search, so the manager binds it solely when
@@ -1447,7 +1454,7 @@ function CredsManager({ path, projectName, onChanged, bindSlash }) {
1447
1454
  return () => window.removeEventListener('keydown', onKey);
1448
1455
  }, [bindSlash, drawer, menu, confirm]);
1449
1456
 
1450
- const done = (msg) => { notify(msg); load(); if (onChanged) onChanged(); };
1457
+ const done = (msg) => { notify(msg); refresh(); };
1451
1458
 
1452
1459
  const saveForm = async () => {
1453
1460
  if (!form || !form.name.trim()) return;
@@ -1507,8 +1514,7 @@ function CredsManager({ path, projectName, onChanged, bindSlash }) {
1507
1514
  if (resp && resp.error) setError(resp.error);
1508
1515
  else {
1509
1516
  notify(`${field === 'inject' ? 'Auto-inject' : 'Agent access'} ${next ? 'enabled' : 'disabled'} for '${entry.name}'`);
1510
- load();
1511
- if (onChanged) onChanged();
1517
+ refresh();
1512
1518
  }
1513
1519
  } catch (e) { setError(apiErr(e)); }
1514
1520
  };
@@ -1619,7 +1625,7 @@ function CredsManager({ path, projectName, onChanged, bindSlash }) {
1619
1625
  } else {
1620
1626
  notify(`${label}: ${ok} ${ok === 1 ? 'entry' : 'entries'}`);
1621
1627
  }
1622
- if (action !== 'check') { exitSelect(); load(); if (onChanged) onChanged(); }
1628
+ if (action !== 'check') { exitSelect(); refresh(); }
1623
1629
  } catch (e) { notify(apiErr(e), 'err'); }
1624
1630
  setBulkBusy(false);
1625
1631
  };
@@ -2012,7 +2018,7 @@ function CredsManager({ path, projectName, onChanged, bindSlash }) {
2012
2018
  <CredDrawer entry={drawer} path={path} projectName={projectName}
2013
2019
  initialReplace={replaceOnOpen}
2014
2020
  onClose={() => { setDrawer(null); setReplaceOnOpen(false); }}
2015
- onChanged={() => { load(); if (onChanged) onChanged(); }} />
2021
+ onChanged={refresh} />
2016
2022
  )}
2017
2023
  {confirm && <CredConfirm spec={confirm} onClose={() => setConfirm(null)} />}
2018
2024
  </div>
@@ -2420,7 +2426,8 @@ function HubCredentials({ projects, onOpenDrill }) {
2420
2426
  <CredAuditView path={null} initialName={auditName}
2421
2427
  onOpenDrill={onOpenDrill} />
2422
2428
  ) : sub === 'global' ? (
2423
- <CredsManager path={null} onChanged={loadOverview} />
2429
+ <CredsManager path={null} onChanged={loadOverview}
2430
+ globalEntries={ov ? ((ov.global || {}).entries || []) : null} />
2424
2431
  ) : !ov ? (
2425
2432
  <div style={{ color: T.textMuted, fontSize: 13 }}>Loading…</div>
2426
2433
  ) : (ov.projects || []).length === 0 ? (
cli/hub_ui/state.js CHANGED
@@ -24,11 +24,15 @@ const IDE_OPTIONS = [
24
24
 
25
25
  const ideLabel = (ide) => IDE_LABELS[ide] || ide || 'unknown';
26
26
 
27
- // Repeating poll that pauses while the tab is hidden.
27
+ // Repeating poll that pauses while the tab is hidden and skips a tick while
28
+ // the previous call is still running, so a slow route never stacks requests.
28
29
  const usePoll = (fn, ms) => {
29
30
  React.useEffect(() => {
30
- const iv = setInterval(() => {
31
- if (!document.hidden) fn();
31
+ let running = false;
32
+ const iv = setInterval(async () => {
33
+ if (document.hidden || running) return;
34
+ running = true;
35
+ try { await fn(); } finally { running = false; }
32
36
  }, ms);
33
37
  return () => clearInterval(iv);
34
38
  }, [fn, ms]);
cli/hub_ui.html CHANGED
@@ -137,7 +137,7 @@
137
137
 
138
138
  <body>
139
139
  <div id="root"></div>
140
- <script type="text/babel">
140
+ <script type="text/babel" data-presets="react">
141
141
  /* __C3_HUB_SCRIPTS__ */
142
142
  </script>
143
143
  </body>
cli/server.py CHANGED
@@ -2764,12 +2764,12 @@ def api_credentials_list():
2764
2764
  from services import credential_store as cred_store
2765
2765
  pp = str(PROJECT_PATH)
2766
2766
  usage = cred_store.read_usage_state(pp)
2767
+ listed = cred_store.list_entries(pp)
2768
+ present = cred_store.presence_for(listed, pp)
2767
2769
  out = []
2768
- for name, entry in cred_store.list_entries(pp).items():
2770
+ for name, entry in listed.items():
2769
2771
  out.append(cred_store.public_entry(
2770
- name, entry, usage=usage,
2771
- value_ok=cred_store.is_resolvable(
2772
- name, project_path=pp, scope=entry["scope"])))
2772
+ name, entry, usage=usage, value_ok=present[name]))
2773
2773
  return jsonify({"entries": out})
2774
2774
 
2775
2775
 
cli/tools/credentials.py CHANGED
@@ -98,8 +98,7 @@ def _act_list(project_path: str) -> str:
98
98
  "The user manages them via the Credentials UI tab or `c3 creds set`."
99
99
  )
100
100
  usage = cs.read_usage_state(project_path)
101
- missing = {n for n, e in entries.items() if not cs.is_resolvable(
102
- n, project_path=project_path, scope=e["scope"])}
101
+ missing = {n for n, ok in cs.presence_for(entries, project_path).items() if not ok}
103
102
  lines = [f"[creds] {len(entries)} entries (project scope shadows global):"]
104
103
  lines += [_format_entry_line(n, e, usage, n in missing)
105
104
  for n, e in entries.items()]
cli/ui.html CHANGED
@@ -160,7 +160,7 @@
160
160
 
161
161
  <body>
162
162
  <div id="root"></div>
163
- <script type="text/babel">
163
+ <script type="text/babel" data-presets="react">
164
164
  /* __C3_UI_SCRIPTS__ */
165
165
  </script>
166
166
  </body>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-context-control
3
- Version: 2.145.2
3
+ Version: 2.146.0
4
4
  Summary: 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.
5
5
  Author-email: Dimitri Tselenchuk <dtselenc@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -1,7 +1,7 @@
1
1
  cli/__init__.py,sha256=ec66drCZGNMRU4V6ov0zVhYZph1us12Vn8OvG_LJyRY,22
2
2
  cli/_hook_utils.py,sha256=RhfS2WS5VMSi2w2Jxcts1wg5WC-UFp89JOQvcO5Jjb8,23002
3
3
  cli/_shell_writes.py,sha256=XzUNlUDljmg-wZxK03xHnRe36WeQxe4tpO9HKVFwP5o,5090
4
- cli/c3.py,sha256=Thn44zRVOuNqg22oafbPECYSXzQCBYZycF377xu1zIE,431828
4
+ cli/c3.py,sha256=AashzO8-7Jfq2V61RXFh2z5KfF5Oux5INUY-2s6IQVk,431810
5
5
  cli/docs.html,sha256=pPxcP5ieY0DcfEDvegwIcRxbz1woLZoKLVSF-4JVy0Q,146784
6
6
  cli/edits.html,sha256=UjAhoCmBmQ89cklGvJqzC6eyNP2tc8H6T-e01DVkLvE,43418
7
7
  cli/hook_access_guard.py,sha256=Mjrrt3agYCWPqSjdzk_Sw4ooMrizO8Um-nQjgGfXck4,27896
@@ -23,13 +23,13 @@ cli/hook_session_end.py,sha256=toFEPDKRoutDuVOmuOgHmQ8CM0o7tF7yALhMxJz7VEM,3482
23
23
  cli/hook_session_open.py,sha256=Badeikv37D7b6inSHbVjAxtfdH4xiJKFT1O52fgreGs,7443
24
24
  cli/hook_session_stats.py,sha256=I-2NeLlV1NxeqKtTM2JH9haSpFU7r4qG0u8pfCJ80YU,8725
25
25
  cli/hook_terse_advisor.py,sha256=W_zaTfX2akgX-ifuUTsLu2H1atM8XmWt6jpPoeU7Pm0,6252
26
- cli/hub_server.py,sha256=pA8AH-Y2vZDwT8F5Fb6sYPURI-seI1fysU8RYM3-SIA,217816
27
- cli/hub_ui.html,sha256=nnGqWhWGCLfNisGUnoAlQFcBr6wPNsq-AEOR7uFtVkE,2942
26
+ cli/hub_server.py,sha256=of3Auy9HFPSa1gDqrRITOfv29wz6uqi62Afb0iFW7uE,218145
27
+ cli/hub_ui.html,sha256=v41R_JAH4Jwodpy3tbeBUel-80FbNmffBrey-QH5Q64,2963
28
28
  cli/mcp_proxy.py,sha256=r-Qhlie7gCtotex8yQUudCyBLRUK-IW6cNIW5RhBW0o,17648
29
29
  cli/mcp_server.py,sha256=5y_oaJvvGq6p1C4L-4MamudyAhpJYeIjDJcXSeB9dsM,73775
30
30
  cli/progress.py,sha256=qTkNy0howOD3hXraMo4ok3dYTpBW5yG_tenKfF4UDM0,1470
31
- cli/server.py,sha256=TBuQi03jfHMeTIr9O27zlFMLA6AGCPiU0vNb319tfhw,173203
32
- cli/ui.html,sha256=xcdt74nlFEXx-0Bx6-Okw-WSVZPAXL0iukxU0ytI6CA,5694
31
+ cli/server.py,sha256=dJOOXYU1zmN-UBuMmHhv8XlnknSyO8xj7mXWSDAGIBY,173188
32
+ cli/ui.html,sha256=uuPwIJNKDQH_fsaR3xNB2AKky9WzKfaXnKr_k3aTtxo,5715
33
33
  cli/ui_legacy.html,sha256=cI8tC6RKmE2NIJOcsu7CY-zT4VznjcbD6NTjxb_fvUY,378460
34
34
  cli/ui_nano.html,sha256=UAwQ6bbTOXAoGq191AZ7slhngR9edJSa3IhqpynveDg,27740
35
35
  cli/commands/__init__.py,sha256=0Z8MABNzwSFJGT4Xv9R5AJVR8XxraTsuVTz5b0bShmo,38
@@ -49,8 +49,8 @@ cli/guide/sessions.html,sha256=fBcbX61mvy2T5fX9Lkojb8Gw7n2RZ4Oo95EE_SC5Q6A,31707
49
49
  cli/guide/shared.css,sha256=Mmm7W6aYxrkDq2RPOcyXiQFYGR-yB3auwX1uIWi6C74,15967
50
50
  cli/guide/tools.html,sha256=8Kup2WXvZg2drI9vzG3ZxPKifN5VJuaC3ljd5oUbho8,133248
51
51
  cli/guide/workflow.html,sha256=T_5HW4YOPng02vrxBWknz4LYSx2vC9Iogdwb81YlWqA,78604
52
- cli/hub_ui/app.js,sha256=lA6HxB1XjB5VZXqw-wyDkDQnh5F2NC8GATnozzXpAt8,7115
53
- cli/hub_ui/state.js,sha256=QIRs_ooO8ODgdU0mwiwvIAz7IbiTs17fqLyolXbCeCI,7373
52
+ cli/hub_ui/app.js,sha256=AWzlFht1Fmn4559KfooKC03CvoG2I6AXbYUEZ2w8ZfU,7362
53
+ cli/hub_ui/state.js,sha256=OrIQ_kx_wUdyiyXlKmMKC8bbKzMPve1ZuFClRXOrDEw,7593
54
54
  cli/hub_ui/components/add_project.js,sha256=j5m0mX0gePcEYQW_0vodJkw5v8Vn2lQqPAI0miIGdv4,3182
55
55
  cli/hub_ui/components/config_editor.js,sha256=xew4RoxFt3j3r0UTkVc3-7y2Z1cOjx3dgRZMKSsCFhg,18211
56
56
  cli/hub_ui/components/drill_artifacts.js,sha256=cSD_Ph2HUoURI893i-JdfftsO9AKz_0TFH51s76B6x4,9468
@@ -63,7 +63,7 @@ cli/hub_ui/components/global_search.js,sha256=GPfq1EsZa73vXBNCVn4VhT3a7gYkaUblbc
63
63
  cli/hub_ui/components/hub_access.js,sha256=AF00OgOGak4ASPE2OvxOsBqjS_KKd5QKCZZ6Kp3zGXw,34339
64
64
  cli/hub_ui/components/hub_ci.js,sha256=KrdzALpgFTFcPEMN1R-OU0tyl8irsj91HECRlGsQLjQ,17873
65
65
  cli/hub_ui/components/hub_cred_audit.js,sha256=fUmAPTOa75gdxAsw6cNHPYenu7jDTljSbFX9GYJnW14,11662
66
- cli/hub_ui/components/hub_credentials.js,sha256=SH5_0fd-yKbqKkZzh9RV5KEGjMKDZqRc6DpSbOqX9KU,119449
66
+ cli/hub_ui/components/hub_credentials.js,sha256=xDqxDb0a0yaJQgTE-hEtx0PTOo4x9j__5GhdlxGd9mo,119643
67
67
  cli/hub_ui/components/hub_enforcement.js,sha256=NuHl49KvKUjrYtfV6FAvfWiaFGq020wEHjHCx9AYhf4,48839
68
68
  cli/hub_ui/components/hub_locks.js,sha256=v1yv7JH5anfC_kZo26I1Dyt1_ytamGOaBpCIWGziuVk,10321
69
69
  cli/hub_ui/components/hub_sessions.js,sha256=Uo-U3T9ZIqSgiBkfNIWB3K2c-J-WBJApUU2IPSi5BZk,21401
@@ -87,7 +87,7 @@ cli/tools/artifacts.py,sha256=SjazShDFIzOpAZW7uMv9itBktXnc6pqQKZeaen8GgzI,9379
87
87
  cli/tools/bitbucket.py,sha256=cni8Y1EoiNm9jcHEpzi2dgKFIT1-4ct0WOhpTs7ac3k,35812
88
88
  cli/tools/ci.py,sha256=1--Ak18ikHkO7q4HWlDCVac-U969eEFejkd-24T0xuY,20057
89
89
  cli/tools/compress.py,sha256=PcgxEw7KfofmJsTLfRf3mjWMxyNlqkcSNzMTlT27SW0,12534
90
- cli/tools/credentials.py,sha256=DKa6rTzEO_XTzeIVUBT8z9vCgjbeiXPR_TFg9_Y3dQQ,20078
90
+ cli/tools/credentials.py,sha256=NczO0KT3LTJlD3ZedXWRAtH_8YTPhya0Txja3ystdzE,20039
91
91
  cli/tools/delegate.py,sha256=TrHPXEyufQh0in3Ay7XcOG5wQwCYbnbz9r4naIuhbN8,126990
92
92
  cli/tools/edit.py,sha256=xnRRM2UUybvGvfSzXzIjWmt6_zNaCbkupiK1z-8K4jE,30127
93
93
  cli/tools/edit_verify.py,sha256=L1a6U9Z-pAOQz0Ur9SFTNfmCl0OP9_4VdkyxlXOyHDM,11910
@@ -131,7 +131,7 @@ cli/ui/components/sessions.js,sha256=FIKtil76B8tCkAmcFV7hlj6GQ_DCJK2jCzvEmdK7NBE
131
131
  cli/ui/components/settings.js,sha256=FXcn-UQE2mXC6HFsLeEas-wAUFKt0OJaLySroZQMhGA,84095
132
132
  cli/ui/components/sidebar.js,sha256=K2ym2kUgpbyG-EBA_wBIIiqQY8qTatsiBZwzseMWuIQ,9939
133
133
  cli/ui/components/tasks.js,sha256=vyKQ3uwoppMwvdEaHlhWXW4oWcAisx4NveqzMhsYqHo,38438
134
- code_context_control-2.145.2.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
134
+ code_context_control-2.146.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
135
135
  core/__init__.py,sha256=TSDCEcM4V7gcZVM3w2ykJaqEUch4Dkon-rivV17T73s,2501
136
136
  core/codex_patch.py,sha256=CxvT_rQIwPNTRJZj9EHJmx0ZdOeSLxjiixuLI3Z7lPc,2107
137
137
  core/config.py,sha256=wCjrZKXRywUbR6WRcO7optyOirAQ2DeRNdPCU0X5zIY,23762
@@ -190,7 +190,7 @@ oracle/ui/chat/toolbar.js,sha256=ZDexIIpGwsQ6XsY4Tyo082rJoofdD_JBTV97-mLQUVM,120
190
190
  services/__init__.py,sha256=3Kn4cZweLm7at8wFdBdZ-Zwo8hHcnVIsmY5f29nzi2Y,116
191
191
  services/access_guard.py,sha256=tj-rWVlEzLIviIEXMRAYCWRlcgLPUwJ1qqQfmIA5t7Q,79074
192
192
  services/access_telemetry.py,sha256=9pOCLS9LgDtM7Z4cCEyl7osxhj-m3wsPubcXolBKuV4,9568
193
- services/activity_log.py,sha256=n7iso1YmH_WLrNdpUViECv_zJEh2dtFejDMcYnJrP3Y,6752
193
+ services/activity_log.py,sha256=c_jTOsR-d1d5B3u67eg8mRA7LZhtP6H1-6leeGa0Go4,7990
194
194
  services/agent_base.py,sha256=a-gdSd_jtZtbjXo1WS8CnWCagXgKaGZd5ShcG6s0kT4,4809
195
195
  services/agent_downshift.py,sha256=hGFg-knqa6NUqmZUijyTDV82Fz88ieh1YjN_mG5tz8U,8444
196
196
  services/agent_locks.py,sha256=nRAPuRSoet7BG9oOsNyA5WhlNy-UMhpsq6z5ggIPmLA,22421
@@ -204,7 +204,7 @@ services/background_service.py,sha256=Hn-vjAUq_wPm0cUiwwzyr-D7Xxkygjuy7s3_9XuLaO
204
204
  services/benchmark_dashboard.py,sha256=iR-DnqnoKbqHMJ4d-ZkIvJBYfzwTa7r-jzO6j2BYDfQ,27711
205
205
  services/bitbucket_client.py,sha256=2RehoyVP4vc52zjQi3Gdlt2WRAUpFv8zVMn12xx1yHE,22338
206
206
  services/bitbucket_credentials.py,sha256=2qLA9pQMol4y95y4DJMNBsBBPUsJQCKbLFo2iiCnfvI,7364
207
- services/ci_act.py,sha256=SS0urrIScwthfWHB5qFCk6cTY5Wa9kV20M3v8xgCWAI,23358
207
+ services/ci_act.py,sha256=82i5aa1wgQvc8Xq_pQ9-k1_57LBQ0-mWSUT9xV-q7Tk,24070
208
208
  services/ci_cache.py,sha256=Too3XyeE-oeiqQm4IJ10giFvhIEr29YfDLUpZDl8SBU,13256
209
209
  services/ci_expr.py,sha256=BsCUoL4ZN1Q8KjYJ07xWsPynV04Q8dg-ryIIKNOYk70,15641
210
210
  services/ci_failures.py,sha256=32aJ_R5S-JE4fErhzs06ZQzhBVLawZ4brtXtdoJrnE0,8087
@@ -212,7 +212,7 @@ services/ci_github.py,sha256=2yXsVAiWMIvhxH7xsMJIO_3DxCvZbKJcSWt4zXyyWxE,6699
212
212
  services/ci_impact.py,sha256=9_CJv2sNza0_qW7cSsMnxhJy_AGflDONjL9jKQeoWmc,10966
213
213
  services/ci_intel.py,sha256=frr1jL-FXU_JNUoRW8TwnZo04fqUElAGGEsDEvgIeUs,7161
214
214
  services/ci_runner.py,sha256=S4xJakKb8ZNojtMKjmcqOkTL1NPEV8b_jThJEFR4mFs,51001
215
- services/ci_workflow.py,sha256=Y671yZhCfFyv_7fW5Vox6WfQ_VUzejwscLGDfK9pDf4,34920
215
+ services/ci_workflow.py,sha256=vqUysN2d7-SHDLP3l8E1Z8xPdkVezd-GuxxmvRRUiC8,34996
216
216
  services/circuit_breaker.py,sha256=ES6PpjL40gfDhtK88GeS6GFzsz5EOEEkZsB-DzX_tbw,3179
217
217
  services/claude_md.py,sha256=fhRyJ1ueDKVbhNI-6U3W6Py5NAXngaBzxO4mTxvqEOo,62495
218
218
  services/codex_integration.py,sha256=Zuyvb-nAMahZkNxl8oKA7bXs07xbQshG8HGY68ml2cg,7526
@@ -222,8 +222,8 @@ services/context_snapshot.py,sha256=2f_bxY3xX4ZC653ncYno8YSeSTFSavwBjrorytHA0Ns,
222
222
  services/conversation_store.py,sha256=RKA0nk09VAI8Ip935kSVlQdF-cQEzFLrgHbZiEJ7G1E,37518
223
223
  services/cred_audit.py,sha256=tdxnRzbB0c8nJ7dqA9HOJrcOeRWivEzq6yhwu6ksmZI,10240
224
224
  services/cred_telemetry.py,sha256=LwDd4x0Mu6YXeDUrgICHnX9o1Ym_4hoouRp98qx0zlI,10926
225
- services/credential_backup.py,sha256=f34AkUbVDm95jAbsBUkQs1bhRIzEC1TfHJ9axgl8HYU,12960
226
- services/credential_store.py,sha256=ttjE5Dwa9Rf32VIDR65UIG9tIui-U5eMFHlomLb9dBI,72126
225
+ services/credential_backup.py,sha256=TeOAwDAEG-BLnpO0E861m3AeFSbD8tQ_q3ydNYcMDds,13223
226
+ services/credential_store.py,sha256=S5WnwPmaCJdo3wIZlWh7BRuT057pHPf1cdlzZOWPOgc,74627
227
227
  services/delegate_agents.py,sha256=dIQAWepQgtDdV6s8WoCLvf7qAjlwMZHcVWWzsSO3qjE,4745
228
228
  services/delegate_hints.py,sha256=h65rOfY7PJDl5C8Z4b-L4vGcGri0h4J7canL0QWYQ28,8341
229
229
  services/delegate_write.py,sha256=6pKu6QTQ7JxRsOGoXPL_dMqDhbmEl5z-i42KI9Vpl2M,10164
@@ -274,7 +274,7 @@ services/override_policy.py,sha256=TehVPxfQ8LZWIOUzCnnioIdMUPZZ7JZa1ja77smpW2c,3
274
274
  services/override_requests.py,sha256=H9lSUbfOjkjE1muztaQ58InqqsqhtnKIT-pF2VzvWkg,31148
275
275
  services/override_wake.py,sha256=HjSOXQTDi0irgeMyrTqngL7xLcSOOIgYsVW9yxod4ps,10363
276
276
  services/parser.py,sha256=S_YoKLS3r1xCUVHApXb3BTEfoRAzFhuo1_8XsG0qxVE,63505
277
- services/project_manager.py,sha256=FkNCddjMU2qywuJ3RWK4fU3xg3SA-F2CteR2rix4iUY,47990
277
+ services/project_manager.py,sha256=ibgNOqmNQBjdr_dXkbcsUdsnOriroYKW87wMnGZCovk,48376
278
278
  services/project_runtime.py,sha256=yyftX48BFjq4B2KcR4KGszDAAa4xR6gn6j0SgTyro1s,11603
279
279
  services/protocol.py,sha256=CWZ0BWWd-jPui-11hZlvGgkNk18gMFhhLWbp-BP35Kk,12861
280
280
  services/proxy_state.py,sha256=u5rd0k6CrOsywZA8FpRu_hMLwhR0TAJhZjy5MdWbCGc,6107
@@ -288,7 +288,7 @@ services/router.py,sha256=Cz10nx2fKTbaGn14mSBePWIDrw5rdcs_1JFYXeik084,15626
288
288
  services/runtime.py,sha256=931eux-zdsXh0M6i6RmXEGAKiXLfa9aDra9V12Q5Z_Y,13767
289
289
  services/scanner.py,sha256=lH0aiDCdCRruKS-VTAD_LsF4zrhMLpMRhFlOZZlw4VM,9613
290
290
  services/session_benchmark.py,sha256=KUcWWC5X088p_ao6DgglUMwj7jCzdecPKxJ7XiGKob0,106255
291
- services/session_catalog.py,sha256=dPs2tp93G71iL-CxjnFZ4NmoX10DbSjOymikliJYZig,44700
291
+ services/session_catalog.py,sha256=H0TrxFQERg26B1Jgx_rwXHTO4lhXhAshj2oIvWHYqZg,46661
292
292
  services/session_live.py,sha256=JixvsUdmcKGsuZGgWj-iRFJZbQDfQTWoVeIy1-M90A0,5146
293
293
  services/session_manager.py,sha256=IBE71TORRlNuRBhswobD5KzXhfFAijy6KrnmaP1lnuc,57391
294
294
  services/session_preloader.py,sha256=DsTAXMKVtrX9yu1sEFojYDi9-jkSAj1Ylt9JTy57Dow,9883
@@ -333,8 +333,8 @@ tui/screens/search_view.py,sha256=MMHjVdlk3HZSuDBSvq8IGrqv_Mh5Us6YqXQ80bcWSMk,19
333
333
  tui/screens/session_view.py,sha256=eZ1eDwHTvPOck1wCCviixtOaCxIkBT_95ytNNNriGNA,5991
334
334
  tui/screens/stats.py,sha256=p81PjzdaIv7hllb8f45-rlVe4lJZwSdIMqu7e86_u5s,6223
335
335
  tui/screens/ui_view.py,sha256=1QJCgLh2YfgWIpvzRG1KOGXYEaOYX6ojN61Azjf2oX0,2125
336
- code_context_control-2.145.2.dist-info/METADATA,sha256=bHhi9gHWwyOe6v9kxLBSGBFgB_jEEtfWVQomsR91rss,28902
337
- code_context_control-2.145.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
338
- code_context_control-2.145.2.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
339
- code_context_control-2.145.2.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
340
- code_context_control-2.145.2.dist-info/RECORD,,
336
+ code_context_control-2.146.0.dist-info/METADATA,sha256=qMaA5mgjHH8SPwuUFvut1k-KVaWK3DuHawvK8axeKNs,28902
337
+ code_context_control-2.146.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
338
+ code_context_control-2.146.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
339
+ code_context_control-2.146.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
340
+ code_context_control-2.146.0.dist-info/RECORD,,
services/activity_log.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """ActivityLog — Append-only JSONL activity log for C3 events."""
2
2
  import json
3
- from collections import Counter, deque
3
+ from collections import Counter
4
4
  from datetime import datetime, timezone
5
5
  from pathlib import Path
6
6
 
@@ -75,6 +75,9 @@ class ActivityLog:
75
75
  """
76
76
  if not self.log_file.exists():
77
77
  return []
78
+ # A row of this type holds its JSON-encoded name, so a chunk or line
79
+ # without those bytes cannot match and is skipped unparsed.
80
+ needle = json.dumps(event_type).encode("utf-8")
78
81
  events: list = []
79
82
  try:
80
83
  with open(self.log_file, "rb") as handle:
@@ -86,12 +89,19 @@ class ActivityLog:
86
89
  position -= step
87
90
  handle.seek(position)
88
91
  block = handle.read(step) + tail
89
- lines = block.split(b"\n")
90
- # The first element may be a partial line: keep it for the
91
- # next (earlier) chunk unless we are at the file start.
92
- tail = lines.pop(0) if position > 0 else b""
93
- for raw in reversed(lines):
94
- if not raw.strip():
92
+ # The first line may be partial: keep it for the next
93
+ # (earlier) chunk unless we are at the file start.
94
+ tail = b""
95
+ if position > 0:
96
+ cut = block.find(b"\n")
97
+ if cut < 0:
98
+ tail = block
99
+ continue
100
+ tail, block = block[:cut], block[cut + 1:]
101
+ if needle not in block:
102
+ continue
103
+ for raw in reversed(block.split(b"\n")):
104
+ if needle not in raw:
95
105
  continue
96
106
  try:
97
107
  entry = json.loads(raw.decode("utf-8", "replace"))
@@ -119,15 +129,13 @@ class ActivityLog:
119
129
  # far back in the log behind many tool_call entries. Use a larger scan
120
130
  # window so they aren't missed.
121
131
  scan_factor = 100 if event_type else 5
122
- tail = deque(maxlen=max(1, limit * scan_factor))
123
- with open(self.log_file, encoding="utf-8") as handle:
124
- for line in handle:
125
- if line.strip():
126
- tail.append(line)
127
- for line in reversed(tail):
132
+ needle = json.dumps(event_type).encode("utf-8") if event_type else b""
133
+ for raw in self._tail_lines(max(1, limit * scan_factor)):
134
+ if needle not in raw:
135
+ continue
128
136
  try:
129
- entry = json.loads(line)
130
- except json.JSONDecodeError:
137
+ entry = json.loads(raw)
138
+ except ValueError:
131
139
  continue
132
140
  if event_type and entry.get("type") != event_type:
133
141
  continue
@@ -141,6 +149,26 @@ class ActivityLog:
141
149
  break
142
150
  return events
143
151
 
152
+ def _tail_lines(self, count: int, chunk_bytes: int = 64 * 1024) -> list:
153
+ """The last ``count`` non-blank lines as bytes, newest first."""
154
+ lines: list = []
155
+ with open(self.log_file, "rb") as handle:
156
+ handle.seek(0, 2)
157
+ position = handle.tell()
158
+ tail = b""
159
+ while position > 0 and len(lines) < count:
160
+ step = min(chunk_bytes, position)
161
+ position -= step
162
+ handle.seek(position)
163
+ parts = (handle.read(step) + tail).split(b"\n")
164
+ tail = parts.pop(0) if position > 0 else b""
165
+ for raw in reversed(parts):
166
+ if raw.strip():
167
+ lines.append(raw)
168
+ if len(lines) >= count:
169
+ break
170
+ return lines
171
+
144
172
  def get_stats(self) -> dict:
145
173
  """Counts by event type, total events, time range."""
146
174
  if not self.log_file.exists():
services/ci_act.py CHANGED
@@ -88,12 +88,33 @@ def _probe(cmd: list, timeout: int = 30) -> tuple:
88
88
  return 127, f"{type(exc).__name__}: {exc}"
89
89
 
90
90
 
91
- def availability() -> dict:
91
+ #: How old an engine probe a display surface may reuse. The hub CI tab
92
+ #: re-inspects every 2-10 s, and each fresh probe spawns act and docker.
93
+ DISPLAY_PROBE_TTL_S = 30.0
94
+ _display_probe: tuple = (0.0, None)
95
+
96
+
97
+ def availability(max_age: float = 0.0) -> dict:
92
98
  """Can this engine run at all? Reports WHY not, never just False.
93
99
 
94
100
  Both halves are checked because they fail independently and a user with
95
101
  act installed but Docker stopped deserves to be told which one to fix.
102
+
103
+ ``max_age`` > 0 reuses a result at most that many seconds old from an
104
+ earlier call that also passed it; the default always probes, which is
105
+ what a run and ``doctor`` need.
96
106
  """
107
+ global _display_probe
108
+ if max_age <= 0:
109
+ return _probe_engine()
110
+ at, info = _display_probe
111
+ if info is None or time.monotonic() - at > max_age:
112
+ info = _probe_engine()
113
+ _display_probe = (time.monotonic(), info)
114
+ return dict(info)
115
+
116
+
117
+ def _probe_engine() -> dict:
97
118
  act = find_act()
98
119
  info: dict = {"ok": False, "act": act, "act_version": "",
99
120
  "docker": False, "docker_version": "", "reason": ""}
services/ci_workflow.py CHANGED
@@ -800,8 +800,11 @@ def inspect_project(project_path, event: str = "", engine: str = "auto") -> dict
800
800
  # Lazy: keeps parsing independent of the execution engines, and avoids
801
801
  # paying for a `docker version` probe on every import.
802
802
  from services import ci_act
803
- engines = ci_act.availability() if engine in ("auto", "act") else {
804
- "ok": False, "reason": "engine='native' — containers not considered"}
803
+ if engine in ("auto", "act"):
804
+ engines = ci_act.availability(max_age=ci_act.DISPLAY_PROBE_TTL_S)
805
+ else:
806
+ engines = {"ok": False,
807
+ "reason": "engine='native' — containers not considered"}
805
808
  act_ok = bool(engines.get("ok"))
806
809
 
807
810
  native, container, foreign, unsupported = [], [], [], []
@@ -310,20 +310,26 @@ def sync(project_paths=()) -> dict:
310
310
  return out
311
311
 
312
312
 
313
- def status(project_paths=()) -> dict:
313
+ def status(project_paths=(), presence=None) -> dict:
314
314
  """Counts only — never a value. ``not_backed_up`` lists registered entries
315
315
  with a live value but no sealed copy; ``restorable`` lists entries whose
316
- value is gone but whose copy is here."""
316
+ value is gone but whose copy is here.
317
+
318
+ ``presence`` maps realm to a ``credential_store.value_presence`` result
319
+ the caller already holds; any other vault is probed here, once."""
317
320
  data = _load()
318
321
  records = data.get("records", {}) if data else {}
319
322
  out: dict = {"enabled": bool(data), "path": str(backup_path() or ""),
320
323
  "created": data.get("created", "") if data else "",
321
324
  "backed_up": sum(len(r) for r in records.values()),
322
325
  "restorable": [], "lost": [], "not_backed_up": []}
326
+ known = dict(presence or {})
323
327
  for scope, pp, realm_s, name in _realm_entries(project_paths):
324
328
  label = name if scope == "global" else f"{name} ({pp})"
325
329
  has_copy = name in records.get(realm_s, {})
326
- if cs.is_resolvable(name, project_path=pp, scope=scope):
330
+ if realm_s not in known:
331
+ known[realm_s] = cs.value_presence(scope, pp)
332
+ if known[realm_s].get(name, False):
327
333
  if not has_copy:
328
334
  out["not_backed_up"].append(label)
329
335
  elif has_copy:
@@ -388,12 +388,14 @@ def _file_get(scope: str, project_path: str, realm_s: str, name: str) -> Optiona
388
388
  token = _load_sidecar(path).get(name)
389
389
  if not token:
390
390
  return None
391
- key = _master_key(realm_s, create=False)
392
- if key is None:
391
+ return _decrypt(_master_key(realm_s, create=False), token)
392
+
393
+
394
+ def _decrypt(key: Optional[bytes], token) -> Optional[str]:
395
+ if not key or not token:
393
396
  return None
394
397
  try:
395
- fernet = _crypto_module()(key)
396
- return fernet.decrypt(token.encode("ascii")).decode("utf-8")
398
+ return _crypto_module()(key).decrypt(token.encode("ascii")).decode("utf-8")
397
399
  except Exception:
398
400
  return None
399
401
 
@@ -1136,15 +1138,75 @@ def is_resolvable(name: str, *, project_path: str = ".", scope: str = "") -> boo
1136
1138
  gateway) can answer "is this credential still good?" without importing
1137
1139
  get_value at all. That absence is what the source-grep invariant test
1138
1140
  asserts, which is a stronger guarantee than reviewing call sites."""
1139
- raw = _get_raw(name, project_path=project_path, scope=scope)
1140
- if raw is None:
1141
+ owning = _norm_scope(scope, project_path) if scope else _owning_scope(name, project_path)
1142
+ if not owning:
1141
1143
  return False
1142
- if structured_type(name, project_path=project_path, scope=scope):
1144
+ return value_presence(owning, project_path, names=[name]).get(name, False)
1145
+
1146
+
1147
+ def value_presence(scope: str, project_path: str = ".", names=None) -> dict:
1148
+ """``{name: bool}`` for one scope's registered entries (or only ``names``):
1149
+ whether each stored value can still be decoded. Never a value.
1150
+
1151
+ A structured entry counts only when its payload is a JSON object. The
1152
+ registry, realm, sidecar and master key are read once for the batch, so a
1153
+ listing costs one keyring read per entry plus the structured attestation
1154
+ for values that are not JSON objects.
1155
+ """
1156
+ scope = _norm_scope(scope, project_path)
1157
+ entries = _read_entries(scope, project_path)
1158
+ if names is not None:
1159
+ entries = {n: entries[n] for n in names if n in entries}
1160
+ if not entries:
1161
+ return {}
1162
+ try:
1163
+ keyring = _keyring_module()
1164
+ except RuntimeError:
1165
+ return dict.fromkeys(entries, False)
1166
+ realm_s = realm(scope, project_path)
1167
+ sidecar, key = None, None
1168
+ out = {}
1169
+ for name, entry in entries.items():
1170
+ if entry.get("storage") == "file":
1171
+ if sidecar is None:
1172
+ path = _secrets_path(scope, project_path)
1173
+ sidecar = _load_sidecar(path) if path is not None else {}
1174
+ key = _master_key(realm_s, create=False) if sidecar else None
1175
+ raw = _decrypt(key, sidecar.get(name))
1176
+ else:
1177
+ try:
1178
+ raw = keyring.get_password(KEYRING_SERVICE, _account(realm_s, name))
1179
+ except Exception:
1180
+ raw = None
1181
+ if raw is None:
1182
+ out[name] = False
1183
+ continue
1143
1184
  try:
1144
- return isinstance(json.loads(raw), dict)
1185
+ is_object = isinstance(json.loads(raw), dict)
1186
+ except (ValueError, RecursionError):
1187
+ is_object = False
1188
+ if is_object or entry.get("type") in STRUCTURED_TYPES:
1189
+ out[name] = is_object
1190
+ continue
1191
+ try:
1192
+ attested = keyring.get_password(
1193
+ KEYRING_SERVICE, _struct_account(realm_s, name)) or ""
1145
1194
  except Exception:
1146
- return False
1147
- return True
1195
+ attested = ""
1196
+ out[name] = attested not in STRUCTURED_TYPES
1197
+ return out
1198
+
1199
+
1200
+ def presence_for(entries: dict, project_path: str = ".") -> dict:
1201
+ """``{name: bool}`` for a :func:`list_entries` view, each name probed in
1202
+ the realm that owns it."""
1203
+ by_scope: dict = {}
1204
+ for name, entry in entries.items():
1205
+ by_scope.setdefault(entry["scope"], []).append(name)
1206
+ out = dict.fromkeys(entries, False)
1207
+ for scope, names in by_scope.items():
1208
+ out.update(value_presence(scope, project_path, names=names))
1209
+ return out
1148
1210
 
1149
1211
 
1150
1212
  def _get_raw(name: str, *, project_path: str = ".", scope: str = "") -> Optional[str]:
@@ -533,6 +533,15 @@ class ProjectManager:
533
533
  return {"dropped": len(registry) - len(valid) - len(reaped),
534
534
  "reaped": reaped}
535
535
 
536
+ def list_registered(self) -> list:
537
+ """The stored project rows (name, path, parent_path, ...) as registered.
538
+
539
+ No liveness, session or config enrichment, so it touches one file.
540
+ Callers that only iterate projects use this; ``list_projects`` reads
541
+ every project's activity log and probes session ports.
542
+ """
543
+ return self._read_projects()
544
+
536
545
  def list_projects(self) -> list:
537
546
  projects = self._read_projects()
538
547
  registry = self._read_registry()
@@ -112,7 +112,7 @@ def _bare(text: str) -> str:
112
112
  return re.sub(r"[^a-z0-9]", "", text.lower())
113
113
 
114
114
 
115
- def claude_transcript_dir(project) -> Path | None:
115
+ def claude_transcript_dir(project, index: _RootIndex | None = None) -> Path | None:
116
116
  """The transcript folder Claude Code uses for ``project``, or None.
117
117
 
118
118
  Exact slug first (every non-alphanumeric becomes ``-``), then the same slug
@@ -131,21 +131,62 @@ def claude_transcript_dir(project) -> Path | None:
131
131
  if name and cand.is_dir():
132
132
  return cand
133
133
  target = _bare(project_str)
134
- try:
135
- for d in root.iterdir():
136
- if d.is_dir() and _bare(d.name) == target:
137
- return d
138
- except OSError:
139
- return None
134
+ if index is None or index.root != root:
135
+ index = _RootIndex(root)
136
+ for d in index.dirs():
137
+ if _bare(d.name) == target:
138
+ return d
140
139
  return None
141
140
 
142
141
 
143
- def _same_dir(a: str, b) -> bool:
142
+ class _RootIndex:
143
+ """One listing of the transcript root and, on first use, of which folder
144
+ holds each transcript id — shared by every project one request builds.
145
+
146
+ Request-scoped on purpose: Windows does not refresh a folder's mtime in
147
+ its parent's listing when a file is created inside it, so a listing kept
148
+ across requests cannot tell that a new transcript appeared.
149
+ """
150
+
151
+ def __init__(self, root: Path):
152
+ self.root = root
153
+ self._dirs: list[Path] | None = None
154
+ self._owners: dict[str, Path] | None = None
155
+ self._lock = threading.Lock()
156
+
157
+ def dirs(self) -> list[Path]:
158
+ with self._lock:
159
+ if self._dirs is None:
160
+ try:
161
+ with os.scandir(self.root) as it:
162
+ self._dirs = [Path(e.path) for e in it if e.is_dir()]
163
+ except OSError:
164
+ self._dirs = []
165
+ return self._dirs
166
+
167
+ def owner(self, stem: str) -> Path | None:
168
+ dirs = self.dirs()
169
+ with self._lock:
170
+ if self._owners is None:
171
+ owners: dict[str, Path] = {}
172
+ for d in dirs:
173
+ try:
174
+ with os.scandir(d) as it:
175
+ for e in it:
176
+ if e.name.endswith(".jsonl") and e.is_file():
177
+ owners.setdefault(e.name[:-6], Path(e.path))
178
+ except OSError:
179
+ continue
180
+ self._owners = owners
181
+ return self._owners.get(stem)
182
+
183
+
184
+ def _dir_key(path) -> str:
144
185
  try:
145
- ra, rb = os.path.realpath(a), os.path.realpath(str(b))
146
- except Exception:
147
- ra, rb = str(a), str(b)
148
- return os.path.normcase(os.path.normpath(ra)) == os.path.normcase(os.path.normpath(rb))
186
+ real = os.path.realpath(str(path))
187
+ except (OSError, ValueError):
188
+ real = str(path)
189
+ return os.path.normcase(os.path.normpath(real))
149
190
 
150
191
 
151
192
  # ── Reading one transcript ─────────────────────────────────────────────────
@@ -569,36 +610,39 @@ def _transcript_files(tdir: Path | None, limit: int | None = MAX_TRANSCRIPTS) ->
569
610
  return files if limit is None else files[:limit]
570
611
 
571
612
 
572
- def _find_elsewhere(host_id: str) -> Path | None:
613
+ def _find_elsewhere(host_id: str, index: _RootIndex) -> Path | None:
573
614
  """A Claude transcript for ``host_id`` in ANY project folder — a session
574
615
  run from a worktree lives under the worktree's slug, not the project's."""
575
616
  if not UUID_RE.match(host_id or ""):
576
617
  return None
577
- root = claude_projects_root()
578
- try:
579
- for d in root.iterdir():
580
- cand = d / f"{host_id}.jsonl"
581
- if cand.is_file():
582
- return cand
583
- except OSError:
584
- return None
585
- return None
618
+ return index.owner(host_id)
619
+
586
620
 
621
+ def _build(project, name: str = "", index: _RootIndex | None = None) -> tuple[list[dict], dict]:
622
+ """Every row for one project (unfiltered) plus a lookup context.
587
623
 
588
- def _build(project, name: str = "") -> tuple[list[dict], dict]:
589
- """Every row for one project (unfiltered) plus a lookup context."""
624
+ ``index``: a transcript-root listing shared across one request's builds.
625
+ """
590
626
  project = Path(project)
591
627
  name = name or project.name
628
+ root = claude_projects_root()
629
+ if index is None or index.root != root:
630
+ index = _RootIndex(root)
592
631
  with _project_lock(project):
593
632
  cache = _load_cache(project)
594
633
  dirty: list = []
595
- tdir = claude_transcript_dir(project)
634
+ tdir = claude_transcript_dir(project, index)
596
635
  metas = {}
636
+ own_dir = _dir_key(project)
637
+ cwd_keys: dict[str, str] = {}
597
638
  for f in _transcript_files(tdir):
598
639
  meta = _cached(cache["transcripts"], f, scan_transcript, dirty)
599
640
  if not meta:
600
641
  continue
601
- if meta.get("cwd") and not _same_dir(meta["cwd"], project):
642
+ cwd = meta.get("cwd")
643
+ if cwd and cwd not in cwd_keys:
644
+ cwd_keys[cwd] = _dir_key(cwd)
645
+ if cwd and cwd_keys[cwd] != own_dir:
602
646
  continue # a folder that only looks like ours
603
647
  metas[meta["id"]] = dict(meta, _path=str(f))
604
648
  records = []
@@ -617,6 +661,7 @@ def _build(project, name: str = "") -> tuple[list[dict], dict]:
617
661
  snaps.append(snap)
618
662
  if dirty:
619
663
  _save_cache(project, cache)
664
+ dirty.clear()
620
665
 
621
666
  # C3 id → host id, from every place that states it.
622
667
  c3_to_host: dict[str, str] = {}
@@ -669,14 +714,16 @@ def _build(project, name: str = "") -> tuple[list[dict], dict]:
669
714
  continue
670
715
  rec0 = recs[0]
671
716
  if _provider(rec0["source_system"]) == "claude" and UUID_RE.match(rid):
672
- found = _find_elsewhere(rid)
673
- if found is not None:
674
- try:
675
- metas[rid] = dict(scan_transcript(found), _path=str(found), _elsewhere=True)
676
- continue
677
- except Exception:
678
- pass
717
+ found = _find_elsewhere(rid, index)
718
+ meta = _cached(cache["transcripts"], found, scan_transcript, dirty) \
719
+ if found is not None else None
720
+ if meta:
721
+ metas[rid] = dict(meta, _path=str(found), _elsewhere=True)
722
+ continue
679
723
  metas[rid] = {"id": rid, "_c3_only": True, "provider": _provider(rec0["source_system"])}
724
+ if dirty:
725
+ with _project_lock(project):
726
+ _save_cache(project, cache)
680
727
 
681
728
  marks = _fold_marks(_read_marks(project))
682
729
  cleared, pred, succ = _lifecycle(project)
@@ -818,11 +865,12 @@ def list_many(projects: list[dict], *, stale: str = "hide", q: str = "",
818
865
  if not path or not (Path(path) / ".c3").is_dir():
819
866
  return path, [], ""
820
867
  try:
821
- rows, _ = _build(path, str(p.get("name") or ""))
868
+ rows, _ = _build(path, str(p.get("name") or ""), index)
822
869
  return path, rows, ""
823
870
  except Exception as exc: # pragma: no cover - defensive
824
871
  return path, [], str(exc)
825
872
 
873
+ index = _RootIndex(claude_projects_root())
826
874
  merged, errors = [], []
827
875
  with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
828
876
  for path, rows, err in pool.map(one, projects):