diff --git a/CHANGELOG.md b/CHANGELOG.md
index c4bd759..3f7b821 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,24 @@ 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.157.1] - 2026-10-14
+
+### Fixed — the phone's session search and overview no longer leak hidden transcript text
+
+With `mobile_sessions_transcripts` off, the gateway blanked prompts in the
+rows it returned, but `GET /api/mobile/sessions?q=` still matched against the
+unblanked prompts and first-prompt titles, so a search told the phone which
+sessions contained a word. `/api/mobile/sessions/overview` also passed a
+first-prompt title through as `newest.title`, because
+`session_catalog.overview()` did not say where the title came from.
+
+- `session_catalog._matches` / `filter_rows` / `list_sessions` /
+ `list_many` take `transcripts=` (default true, so the Hub is unchanged);
+ false searches only the id, branch, note, stale reason and a title that
+ did not come from the first prompt.
+- `session_catalog.overview()` adds `title_source` to `newest`.
+- Found by the c3-mobile Sessions screen's builder reviewing the v7 routes.
+
## [2.157.0] - 2026-10-14
### Added — the fleet views on the mobile gateway (API version 7)
diff --git a/cli/c3.py b/cli/c3.py
index 69f0a60..83163b0 100644
--- a/cli/c3.py
+++ b/cli/c3.py
@@ -91,7 +91,7 @@
# Config
CONFIG_DIR = ".c3"
CONFIG_FILE = ".c3/config.json"
-__version__ = "2.157.0"
+__version__ = "2.157.1"
# The PreToolUse matcher for native subagent calls (installer and hub migration).
AGENT_MATCHER = "Agent|Task"
diff --git a/cli/guide/tools.html b/cli/guide/tools.html
index 296e6d8..d5e7756 100644
--- a/cli/guide/tools.html
+++ b/cli/guide/tools.html
@@ -1610,7 +1610,7 @@
Examples
c3_schedule
execute
- v2.157.0
+ v2.157.1
Scheduled tasks — a skill, prompt or command the Oracle fires on a cadence, autonomously or after a human approves each run
diff --git a/oracle/services/mobile_fleet.py b/oracle/services/mobile_fleet.py
index f925a36..2dfd9e1 100644
--- a/oracle/services/mobile_fleet.py
+++ b/oracle/services/mobile_fleet.py
@@ -455,6 +455,9 @@ def mobile_sessions_list():
Query: ``stale`` hide|only|likely|all (default hide), ``q``, ``limit``
(default 50, max 200), ``before`` (ISO cursor from ``next_before``).
+ Without ``mobile_sessions_transcripts``, ``q`` never matches prompt text
+ or a first-prompt title: a hit on hidden text would reveal it a word at
+ a time.
"""
gate = m._feature_or_404("sessions")
if gate:
@@ -473,11 +476,12 @@ def mobile_sessions_list():
if err:
return err
res = sc.list_sessions(proj["path"], name=proj["name"], stale=stale,
- q=q, limit=limit, before=before)
+ q=q, limit=limit, before=before,
+ transcripts=transcripts)
res["errors"] = []
else:
res = sc.list_many(_registered(), stale=stale, q=q, limit=limit,
- before=before)
+ before=before, transcripts=transcripts)
except Exception as exc:
return m._svc_error(exc)
res["sessions"] = [_session_wire(r, transcripts)
diff --git a/pyproject.toml b/pyproject.toml
index 59a1abe..9834009 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "code-context-control"
-version = "2.157.0"
+version = "2.157.1"
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/session_catalog.py b/services/session_catalog.py
index a42b3c8..ff36082 100644
--- a/services/session_catalog.py
+++ b/services/session_catalog.py
@@ -808,17 +808,28 @@ def _resume_view(rid: str, provider: str, meta: dict, live: bool, project) -> di
# ── Public API ─────────────────────────────────────────────────────────────
-def _matches(row: dict, q: str) -> bool:
+def _matches(row: dict, q: str, *, transcripts: bool = True) -> bool:
+ """Every word of ``q`` appears in the row.
+
+ ``transcripts=False`` searches only what a caller without transcript
+ access is shown: no prompts, and no title copied from the first prompt.
+ Matching on text the caller cannot see would still tell it which
+ sessions contain a word.
+ """
if not q:
return True
- hay = " ".join([row["id"], row["title"], row["first_prompt"], row["last_prompt"],
- row["branch"], ((row.get("note") or {}).get("summary") or ""),
- ((row.get("stale") or {}).get("reason") or "")]).lower()
+ parts = [row["id"], row["branch"], ((row.get("note") or {}).get("summary") or ""),
+ ((row.get("stale") or {}).get("reason") or "")]
+ if transcripts:
+ parts += [row["title"], row["first_prompt"], row["last_prompt"]]
+ elif row.get("title_source") != "first_prompt":
+ parts.append(row["title"])
+ hay = " ".join(parts).lower()
return all(word in hay for word in q.lower().split())
def filter_rows(rows: list[dict], *, stale: str = "hide", q: str = "",
- limit: int = 50, before: str = "") -> dict:
+ limit: int = 50, before: str = "", transcripts: bool = True) -> dict:
"""Apply the list filters to already-built rows (newest first)."""
stale = (stale or "hide").lower()
out = []
@@ -831,7 +842,7 @@ def filter_rows(rows: list[dict], *, stale: str = "hide", q: str = "",
continue
if before and row["last_active"] >= before:
continue
- if not _matches(row, q):
+ if not _matches(row, q, transcripts=transcripts):
continue
out.append(row)
limit = max(1, min(int(limit or 50), 500))
@@ -841,18 +852,20 @@ def filter_rows(rows: list[dict], *, stale: str = "hide", q: str = "",
def list_sessions(project, *, name: str = "", stale: str = "hide", q: str = "",
- limit: int = 50, before: str = "") -> dict:
+ limit: int = 50, before: str = "", transcripts: bool = True) -> dict:
"""``{"sessions": [row...], "next_before": iso|""}`` for one project.
stale: ``hide`` (default), ``only``, ``likely`` (unmarked rows with hints)
- or ``all``.
+ or ``all``. ``transcripts=False`` keeps ``q`` off the prompt text.
"""
rows, _ = _build(project, name)
- return filter_rows(rows, stale=stale, q=q, limit=limit, before=before)
+ return filter_rows(rows, stale=stale, q=q, limit=limit, before=before,
+ transcripts=transcripts)
def list_many(projects: list[dict], *, stale: str = "hide", q: str = "",
- limit: int = 50, before: str = "", workers: int = 8) -> dict:
+ limit: int = 50, before: str = "", workers: int = 8,
+ transcripts: bool = True) -> dict:
"""Rows from several projects (``[{name, path}]``) merged newest first.
Projects build in parallel; one unreadable project is reported under
@@ -878,7 +891,8 @@ def one(p):
if err:
errors.append({"path": path, "error": err})
merged.sort(key=lambda r: r["last_active"], reverse=True)
- result = filter_rows(merged, stale=stale, q=q, limit=limit, before=before)
+ result = filter_rows(merged, stale=stale, q=q, limit=limit, before=before,
+ transcripts=transcripts)
result["errors"] = errors
return result
@@ -1042,7 +1056,10 @@ def overview(project, *, name: str = "") -> dict:
if dirty:
_save_cache(project, cache)
if meta:
- newest = {"id": meta["id"], "title": meta.get("title") or "(untitled)"}
+ # title_source travels with the title so a surface that withholds
+ # transcript text can tell a first-prompt title from a summary.
+ newest = {"id": meta["id"], "title": meta.get("title") or "(untitled)",
+ "title_source": meta.get("title_source") or "none"}
return {"name": name or project.name, "path": str(project),
"counts": {"total": len(files), "live": len(live),
"stale": len(stale_ids & {f.stem for f in files}), "idle": idle},
diff --git a/tests/test_mobile_fleet.py b/tests/test_mobile_fleet.py
index 403029e..682b86d 100644
--- a/tests/test_mobile_fleet.py
+++ b/tests/test_mobile_fleet.py
@@ -426,6 +426,50 @@ def test_detail_drops_the_preview_by_default(self):
self.assertEqual(body["preview"], [])
self.assertNotIn("secret turn", json.dumps(body))
+ def test_search_never_matches_hidden_text(self):
+ rows = [_row(self.proj),
+ _row(self.proj, id="9" * 36, title="deploy the parser",
+ title_source="first_prompt", first_prompt="deploy the parser")]
+ with mock.patch.object(sc, "_build", return_value=(rows, {})):
+ # "first" is only in a prompt; "deploy" only in a first-prompt title.
+ for q in ("first", "last", "deploy"):
+ _, body = self.get("/api/mobile/sessions",
+ project=str(self.proj), q=q)
+ self.assertEqual(body["sessions"], [], q)
+ # Visible text still matches.
+ _, body = self.get("/api/mobile/sessions", project=str(self.proj),
+ q="parser main")
+ self.assertEqual(len(body["sessions"]), 1)
+ srv._cfg["mobile_sessions_transcripts"] = True
+ _, body = self.get("/api/mobile/sessions", project=str(self.proj),
+ q="first")
+ self.assertEqual(len(body["sessions"]), 1)
+
+ def test_search_across_projects_respects_the_switch_too(self):
+ rows = [_row(self.proj)]
+ with mock.patch.object(sc, "_build", return_value=(rows, {})):
+ _, body = self.get("/api/mobile/sessions", q="first")
+ self.assertEqual(body["sessions"], [])
+
+ def test_overview_withholds_a_real_first_prompt_title(self):
+ # Unmocked session_catalog.overview: newest must carry title_source
+ # for the withholding to fire at all.
+ tdir = Path(self._tmp.name) / "transcripts"
+ tdir.mkdir()
+ transcript = tdir / "abc.jsonl"
+ transcript.write_text("{}\n", encoding="utf-8")
+ meta = {"id": "abc", "title": "my secret first prompt",
+ "title_source": "first_prompt"}
+ with mock.patch.object(sc, "claude_transcript_dir", return_value=tdir), \
+ mock.patch.object(sc, "_transcript_files",
+ return_value=[transcript]), \
+ mock.patch.object(sc, "_cached", return_value=meta):
+ status, body = self.get("/api/mobile/sessions/overview")
+ self.assertEqual(status, 200, body)
+ self.assertNotIn("my secret first prompt", json.dumps(body))
+ newest = body["projects"][0]["newest"]
+ self.assertEqual(newest["title"], "(untitled)")
+
def test_unregistered_project_is_a_404(self):
status, _ = self.get("/api/mobile/sessions", project=str(self.ghost))
self.assertEqual(status, 404)