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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion 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.157.0"
__version__ = "2.157.1"

# The PreToolUse matcher for native subagent calls (installer and hub migration).
AGENT_MATCHER = "Agent|Task"
Expand Down
2 changes: 1 addition & 1 deletion cli/guide/tools.html
Original file line number Diff line number Diff line change
Expand Up @@ -1610,7 +1610,7 @@ <h4>Examples</h4>
<span class="tool-name">c3_schedule</span>
<div class="tag-row">
<span class="badge badge-purple">execute</span>
<span class="badge">v2.157.0</span>
<span class="badge">v2.157.1</span>
</div>
<span class="tool-tagline">Scheduled tasks — a skill, prompt or command the Oracle fires on a cadence, autonomously or after a human approves each run</span>
</div>
Expand Down
8 changes: 6 additions & 2 deletions oracle/services/mobile_fleet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
41 changes: 29 additions & 12 deletions services/session_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand All @@ -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))
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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},
Expand Down
44 changes: 44 additions & 0 deletions tests/test_mobile_fleet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading