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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,35 @@ 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.158.2] - 2026-10-21

### Fixed — approval requests that never reached the desk or the phone

Clients learn of an override request from its line in
`.c3/notifications.jsonl`, and two things kept that line from existing.
`NotificationStore.add` deduplicated on agent and title, and a request's
title names only the tool, the operation and the file name. Once one request
on a file had been read, the next request on that file inside the cooldown
(5 minutes at the default `critical` severity) wrote nothing, so the desk and
the phone never showed it. A notification with a `ref_id` now matches only
entries with the same `ref_id`, in the cooldown, the duplicate collapse and
the retro-cleanup; decision lines carry the request id as `ref_id` for the
same reason. Second, every process that rewrote the file (the auto-ack on
each tool response, acks from the gateway, the collapse) did so without a
cross-process lock and by truncating it first, so a line appended in between,
or read as a truncated file, was lost. On one machine, 36 of the 63 requests
whose project still existed had no notification line. Rewrites now hold a lock on
`notifications.jsonl.lock` and publish by atomic replace; the Hub's "clear"
route goes through the store.

A third gap was on the gateway: a session in a worktree nested inside a
registered project (`.claude/worktrees/<name>`) files requests under the
worktree path, which is not registered, so `/api/mobile/overrides` dropped
the row and `GET`, `decide` and `mute` answered 404. A row inside a served
project is now served by that project. Its notification still lands in the
worktree's `.c3/`, outside the feed, so clients see it on their next
`/overrides` read (C3 Desk 0.15.1 re-reads every 30 s).

## [2.158.1] - 2026-10-21

### Fixed — FileMemory and BranchWatch stop re-reading every file map
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.158.1"
__version__ = "2.158.2"

# The PreToolUse matcher for native subagent calls (installer and hub migration).
AGENT_MATCHER = "Agent|Task"
Expand Down
23 changes: 4 additions & 19 deletions cli/hub_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1072,25 +1072,10 @@ def api_project_notifications_clear():
if not nf.exists():
return jsonify({"cleared": 0})
try:
entries = []
for line in nf.read_text(encoding="utf-8").strip().splitlines():
if not line.strip():
continue
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
continue
count = 0
for e in entries:
if not e.get("acknowledged"):
e["acknowledged"] = True
count += 1
if count:
nf.write_text(
"\n".join(json.dumps(e, ensure_ascii=False) for e in entries) + "\n",
encoding="utf-8",
)
return jsonify({"cleared": count})
# Through the store: its rewrite holds the cross-process lock, so a
# notification another process appends meanwhile is kept.
from services.notifications import NotificationStore
return jsonify({"cleared": NotificationStore(path).acknowledge_all()})
except Exception as e:
return jsonify({"error": str(e)}), 500

Expand Down
7 changes: 6 additions & 1 deletion docs/override-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -679,7 +679,12 @@ withheld for that reason: it is a canonical local-filesystem identity used for
grant matching, and the client already has the human-readable `path`. The
cross-project listing additionally drops rows whose project the gateway's
scanner does not serve; the store is one file for the whole machine, and a
token's reach must not exceed the projects it can enumerate.
token's reach must not exceed the projects it can enumerate. A row filed from
a directory inside a served project, such as a session in a worktree under
`.claude/worktrees/`, is served by that project (since 2.158.2): the listing,
`GET /overrides/<id>`, `decide` and `mute` all accept it. Its notification
lands in the worktree's own `.c3/`, which the feed does not scan, so a client
learns of it from its next `/overrides` read rather than from the feed.

See the deviation list at the top of this document for the four places the
implementation had to decide something §8 left open.
Expand Down
42 changes: 37 additions & 5 deletions oracle/services/mobile_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,38 @@ def _resolve_registered(raw: str) -> Path | None:
return None


def _serving_project(raw: str) -> Path | None:
"""The registered project that serves an override row's ``project_path``:
that project itself, or the deepest registered project containing it.

A session in a worktree nested inside a registered project (Claude Code's
``.claude/worktrees/<name>``) files its requests under the worktree path,
which is never registered itself. Refusing those rows left the desk and
the phone unable to show or answer a hold the agent was blocked on.
"""
raw = (raw or "").strip()
if not raw or _scanner is None:
return None
try:
target = os.path.normcase(str(Path(raw).resolve()))
except (OSError, ValueError):
return None
best: Path | None = None
best_len = -1
for proj in _scanner.discover():
if not proj.get("has_c3"):
continue
try:
candidate = Path(proj["path"]).resolve()
except (OSError, ValueError):
continue
key = os.path.normcase(str(candidate))
inside = target == key or target.startswith(key.rstrip("\\/") + os.sep)
if inside and len(key) > best_len:
best, best_len = candidate, len(key)
return best


def _project_or_404(raw: str):
"""Returns (Path, None) or (None, error_response)."""
if not (raw or "").strip():
Expand Down Expand Up @@ -2453,7 +2485,7 @@ def mobile_overrides_list():
# does not serve — the store is one file across every project on the
# machine, including ones the scanner has since dropped.
rows = [r for r in rows
if _resolve_registered(str(r.get("project_path") or "")) is not None]
if _serving_project(str(r.get("project_path") or "")) is not None]
channels = _override_channels(rows)
cards = []
for r in rows:
Expand Down Expand Up @@ -2739,7 +2771,7 @@ def mobile_overrides_costs():
# whole machine, and a cross-project view must not name a project
# this token cannot otherwise see.
rules = [r for r in rules
if _resolve_registered(str(r.get("project_path") or "")) is not None]
if _serving_project(str(r.get("project_path") or "")) is not None]
return jsonify({"days": days, "rules": rules, "count": len(rules),
"project": project_path})

Expand All @@ -2754,7 +2786,7 @@ def mobile_override_get(request_id):
row = orq.get(str(request_id))
if row is None:
return jsonify({"error": "unknown request"}), 404
if _resolve_registered(str(row.get("project_path") or "")) is None:
if _serving_project(str(row.get("project_path") or "")) is None:
# Same refusal as an unknown id: whether a request exists for a project
# this gateway does not serve is not this token's business.
return jsonify({"error": "unknown request"}), 404
Expand Down Expand Up @@ -2823,7 +2855,7 @@ def mobile_override_decide(request_id):
f"{', '.join(orq.MODES)}"}), 400

row = orq.get(str(request_id))
if row is None or _resolve_registered(str(row.get("project_path") or "")) is None:
if row is None or _serving_project(str(row.get("project_path") or "")) is None:
return jsonify({"error": "unknown request"}), 404
if row.get("status") != orq.STATUS_PENDING:
# §12.7: it lapsed while the phone was showing it. 409, and the card
Expand Down Expand Up @@ -2961,7 +2993,7 @@ def mobile_override_mute(request_id):
from services import override_requests as orq
data = request.get_json(silent=True) or {}
row = orq.get(str(request_id))
if row is None or _resolve_registered(str(row.get("project_path") or "")) is None:
if row is None or _serving_project(str(row.get("project_path") or "")) is None:
return jsonify({"error": "unknown request"}), 404
if row.get("status") != orq.STATUS_PENDING:
return jsonify({"error": f"request is {row.get('status')}, not pending",
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.158.1"
version = "2.158.2"
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
Loading
Loading