code-context-control 2.153.0__py3-none-any.whl → 2.155.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.153.0"
94
+ __version__ = "2.155.0"
95
95
 
96
96
  # The PreToolUse matcher for native subagent calls (installer and hub migration).
97
97
  AGENT_MATCHER = "Agent|Task"
@@ -1288,6 +1288,14 @@ def cmd_init(args):
1288
1288
  except Exception:
1289
1289
  pass
1290
1290
 
1291
+ swept = _prune_project_quarantines(project_path)
1292
+ if swept["pruned"]:
1293
+ print(f" Fixed : freed {_format_bytes(swept['bytes'])} from "
1294
+ f"{len(swept['pruned'])} old embedding quarantine dir(s)")
1295
+ for name in swept["failed"]:
1296
+ print(f" [warn] {name} could not be fully removed (in use?); "
1297
+ "run 'c3 prune-quarantine' later")
1298
+
1291
1299
  # Delegate backends
1292
1300
  try:
1293
1301
  from services.ollama_client import OllamaClient as _OC
@@ -1463,6 +1471,59 @@ def cmd_index(args):
1463
1471
  return common_cmd_index(args, _command_deps())
1464
1472
 
1465
1473
 
1474
+ def _format_bytes(n: float) -> str:
1475
+ for unit in ("B", "KB", "MB", "GB"):
1476
+ if n < 1024:
1477
+ return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
1478
+ n /= 1024
1479
+ return f"{n:.1f} TB"
1480
+
1481
+
1482
+ def _prune_project_quarantines(project_path: str, keep=None, dry_run: bool = False) -> dict:
1483
+ """Prune one project's ``.c3/embeddings/quarantine_corrupt_*`` dirs; returns the sweep result."""
1484
+ from cli.progress import ProgressLine
1485
+ from services.embedding_index import prune_quarantines
1486
+
1487
+ prog = ProgressLine()
1488
+ try:
1489
+ return prune_quarantines(
1490
+ Path(project_path) / CONFIG_DIR / "embeddings", keep=keep, dry_run=dry_run,
1491
+ on_progress=lambda done, total: prog.update(
1492
+ f" removing quarantine copies {done}/{total}"))
1493
+ finally:
1494
+ prog.done()
1495
+
1496
+
1497
+ def cmd_prune_quarantine(args):
1498
+ """Delete old corrupt-embedding-store quarantine copies, keeping the newest few."""
1499
+ if args.all:
1500
+ from services.project_manager import ProjectManager
1501
+ projects = [p["path"] for p in ProjectManager().list_registered()]
1502
+ else:
1503
+ projects = [str(Path(args.project_path or ".").resolve())]
1504
+
1505
+ verb = "would remove" if args.dry_run else "removed"
1506
+ pruned = failed = freed = 0
1507
+ for project in projects:
1508
+ res = _prune_project_quarantines(project, keep=args.keep, dry_run=args.dry_run)
1509
+ if not (res["pruned"] or res["failed"]):
1510
+ continue
1511
+ pruned += len(res["pruned"])
1512
+ failed += len(res["failed"])
1513
+ freed += res["bytes"]
1514
+ print(f"{project}: {verb} {len(res['pruned'])} quarantine dir(s), "
1515
+ f"{_format_bytes(res['bytes'])}; kept {len(res['kept'])}")
1516
+ for name in res["failed"]:
1517
+ print(f" [warn] {name} could not be fully removed (in use?); run again later")
1518
+
1519
+ if not (pruned or failed):
1520
+ print(f"Nothing to prune in {len(projects)} project(s).")
1521
+ return
1522
+ print(f"\n{verb.capitalize()} {pruned} quarantine dir(s), {_format_bytes(freed)}.")
1523
+ if failed:
1524
+ sys.exit(1)
1525
+
1526
+
1466
1527
  def cmd_compress(args):
1467
1528
  """Compress a file and show results."""
1468
1529
  return common_cmd_compress(args, _command_deps())
@@ -9603,6 +9664,7 @@ def main():
9603
9664
  "enforce": cmd_enforce,
9604
9665
  "override": cmd_override,
9605
9666
  "locks": cmd_locks,
9667
+ "prune-quarantine": cmd_prune_quarantine,
9606
9668
  "ci": cmd_ci,
9607
9669
  "oracle": cmd_oracle,
9608
9670
  "upgrade": cmd_upgrade,
cli/commands/parser.py CHANGED
@@ -37,6 +37,17 @@ def build_parser(version: str, parse_cli_ide_arg):
37
37
  p_upgrade.add_argument("--check", action="store_true",
38
38
  help="Only report whether a newer version exists; don't install")
39
39
 
40
+ p_prune = subparsers.add_parser(
41
+ "prune-quarantine",
42
+ help="Delete old corrupt-embedding-store quarantine copies (frees disk)")
43
+ p_prune.add_argument("project_path", nargs="?", default=".")
44
+ p_prune.add_argument("--all", action="store_true",
45
+ help="Every project registered with the hub, not just this one")
46
+ p_prune.add_argument("--keep", type=int, default=None,
47
+ help="Newest quarantine dirs to keep per project (default 2)")
48
+ p_prune.add_argument("--dry-run", action="store_true",
49
+ help="Report what would be removed without deleting")
50
+
40
51
  p_index = subparsers.add_parser("index", help="Rebuild code index")
41
52
  p_index.add_argument("--max-files", type=int, default=None,
42
53
  help="Cap files indexed this run. Unset reads "
cli/guide/index.html CHANGED
@@ -663,6 +663,10 @@
663
663
  <code>c3 map {status|ensure|refresh}</code>
664
664
  <span>Inspect or rebuild the live repo map at .c3/MAP.md</span>
665
665
  </div>
666
+ <div class="quick-item">
667
+ <code>c3 prune-quarantine [--all] [--dry-run]</code>
668
+ <span>Free disk held by old corrupt-embedding-store copies in .c3/embeddings (<code>c3 init</code> runs it too)</span>
669
+ </div>
666
670
  <div class="quick-item">
667
671
  <code>c3 hub</code>
668
672
  <span>Launch the Project Hub web dashboard (localhost:3330)</span>
cli/hook_session_open.py CHANGED
@@ -30,6 +30,12 @@ the hub, and it skips a project that already has a live UI port, so a
30
30
  ``C3_NO_UI_AUTOSTART=1`` or
31
31
  ``.c3/config.json → {"ui": {"autostart_on_session": false}}``.
32
32
 
33
+ A ``clear`` (or an in-session ``resume``) keeps the MCP server it already
34
+ has, under a host session id that just ended. This hook finds that server
35
+ by the host pid both of them share and hands it the new id
36
+ (``services.host_handoff``, v2.155.0); the server then writes the
37
+ ``C3 connected <new id>`` row the desktop sentinel is waiting for.
38
+
33
39
  ``source: "compact"`` writes NOTHING: a compaction is the same session and
34
40
  the same MCP process, and a "Session started" toast on every compaction
35
41
  would teach the user to ignore the real ones.
@@ -48,6 +54,8 @@ from __future__ import annotations
48
54
  import json
49
55
  import os
50
56
  import sys
57
+ import time
58
+ import uuid
51
59
  from pathlib import Path
52
60
 
53
61
  sys.path.insert(0, str(Path(__file__).parent.parent))
@@ -56,6 +64,11 @@ from cli._hook_utils import detect_host, find_project, log_hook_error # noqa: E
56
64
  from cli.hook_session_end import short_id # noqa: E402
57
65
 
58
66
  _SILENT_SOURCES = {"compact"}
67
+ # How long one session's UI launch holds the project against the next. A
68
+ # server lands in the registry only after it binds its port, a few seconds
69
+ # after Popen; this covers that gap with room to spare. After it, the live
70
+ # registry entry (``_ui_running``) is the guard again.
71
+ _LAUNCH_GRACE_S = 90.0
59
72
 
60
73
 
61
74
  def _ui_autostart_enabled(project: Path) -> bool:
@@ -96,7 +109,10 @@ def _ui_running(pm, project: Path) -> bool:
96
109
  """True when a live UI server is already registered for this project.
97
110
 
98
111
  This is what keeps `resume` / `clear` — both real SessionStart events —
99
- from stacking a second server on every reconnect.
112
+ from stacking a second server on every reconnect. A registered server
113
+ whose port is quiet but whose pid is running is still "running": it is
114
+ starting up, or busy, and a second one would only be the leak this
115
+ check exists to prevent (2.155.0).
100
116
  """
101
117
  target = os.path.normcase(str(project))
102
118
  for entry in pm._read_registry():
@@ -107,8 +123,72 @@ def _ui_running(pm, project: Path) -> bool:
107
123
  same = os.path.normcase(str(Path(raw).resolve())) == target
108
124
  except Exception:
109
125
  same = os.path.normcase(raw) == target
110
- if same and entry.get("port") and pm._port_alive(entry["port"]):
126
+ if not same or not entry.get("port"):
127
+ continue
128
+ if pm._port_alive(entry["port"]):
129
+ return True
130
+ if entry.get("pid") and pm._pid_alive(entry.get("pid")):
131
+ return True
132
+ return False
133
+
134
+
135
+ def _launch_lock(project: Path) -> Path:
136
+ return project / ".c3" / "ui_launch.lock"
137
+
138
+
139
+ def _claim_launch(project: Path) -> bool:
140
+ """Atomically claim the right to launch this project's UI server.
141
+
142
+ ``_ui_running`` only sees a server once it has registered its port, a
143
+ few seconds after launch. Sessions that open inside that window (an
144
+ agent spawning several at once, or a resume racing a startup) each saw
145
+ no live UI and each launched one, which is how a project ended up with
146
+ two servers. The first claimant creates ``.c3/ui_launch.lock`` with
147
+ O_EXCL; anyone else inside ``_LAUNCH_GRACE_S`` stands down.
148
+
149
+ A lock older than the grace window is stale (the launch died, or the
150
+ server since exited). It is taken over by renaming it to a unique name,
151
+ which only one contender can do, then the create is retried. If the
152
+ lock cannot be handled at all the answer is True, which is the
153
+ behaviour from before this lock existed: a missing UI is worse than a
154
+ rare duplicate.
155
+ """
156
+ lock = _launch_lock(project)
157
+ try:
158
+ lock.parent.mkdir(parents=True, exist_ok=True)
159
+ except Exception:
160
+ return True
161
+ for _ in range(3):
162
+ try:
163
+ fd = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
164
+ except FileExistsError:
165
+ try:
166
+ age = time.time() - lock.stat().st_mtime
167
+ except FileNotFoundError:
168
+ continue # released between our create and stat; retry
169
+ except Exception:
170
+ return True
171
+ if age < _LAUNCH_GRACE_S:
172
+ return False
173
+ stale = lock.with_name(f"ui_launch.stale-{uuid.uuid4().hex}")
174
+ try:
175
+ os.rename(str(lock), str(stale))
176
+ except FileNotFoundError:
177
+ continue # another contender took it over first
178
+ except Exception:
179
+ return False
180
+ try:
181
+ stale.unlink()
182
+ except Exception:
183
+ pass
184
+ continue
185
+ except Exception:
111
186
  return True
187
+ try:
188
+ os.write(fd, f"{os.getpid()} {time.time():.0f}\n".encode("ascii"))
189
+ finally:
190
+ os.close(fd)
191
+ return True
112
192
  return False
113
193
 
114
194
 
@@ -136,6 +216,8 @@ def ensure_ui(project: Path, host_session_id: str = "") -> str:
136
216
  return "unregistered"
137
217
  if _ui_running(pm, project):
138
218
  return "already-running"
219
+ if not _claim_launch(project):
220
+ return "launch-pending"
139
221
  # launch_session copies os.environ into the detached child, which is
140
222
  # where cli/server._register_session reads the ownership from.
141
223
  os.environ["C3_UI_OWNER"] = "session"
@@ -150,6 +232,22 @@ def ensure_ui(project: Path, host_session_id: str = "") -> str:
150
232
  return "error"
151
233
 
152
234
 
235
+ def hand_off_runtime(project: Path, host: str, host_sid: str, start_source: str) -> list:
236
+ """Point the MCP server this host already runs at its new session id.
237
+
238
+ Returns the C3 session ids handed off (usually one, often none: a
239
+ ``startup`` has no server yet). Never raises.
240
+ """
241
+ if not host_sid:
242
+ return []
243
+ try:
244
+ from services.host_handoff import hand_off
245
+ return hand_off(project, host, host_sid, start_source)
246
+ except Exception as exc:
247
+ log_hook_error("hook_session_open", exc)
248
+ return []
249
+
250
+
153
251
  def run(payload: dict, project_path: Path | None = None):
154
252
  """Core logic — importable by the dispatcher and tests. Returns None."""
155
253
  start_source = str(payload.get("source") or "").strip().lower()
@@ -178,6 +276,7 @@ def run(payload: dict, project_path: Path | None = None):
178
276
  kind="session", ref_id=host_sid)
179
277
  except Exception:
180
278
  pass
279
+ hand_off_runtime(project, host, host_sid, start_source)
181
280
  ensure_ui(project, host_sid)
182
281
  return None
183
282
 
cli/mcp_server.py CHANGED
@@ -165,6 +165,93 @@ def _link_host_session(svc, host_sid: str) -> None:
165
165
  pass
166
166
 
167
167
 
168
+ # ── Host handoff (2.155.0): follow a /clear to its new host session id ─────
169
+ # The pid of the IDE / agent CLI above this process (services.host_handoff).
170
+ # Written into every heartbeat; the SessionStart hook matches on it.
171
+ _HOST_PID = 0
172
+ # How often the heartbeat thread looks for a handoff file. The desktop
173
+ # sentinel gives a new session 30 s to produce a `C3 connected` row.
174
+ _HANDOFF_POLL_S = 5
175
+
176
+
177
+ def _rebind_host_session(svc, host_sid: str, start_source: str = "") -> bool:
178
+ """Adopt ``host_sid`` as this server's host session id. Returns True when
179
+ something changed.
180
+
181
+ Everything that was derived from the old id is redone: the session
182
+ record (what ``_grants.session_id`` and the access guard read), the
183
+ host env var (what a c3_project proxy runtime built later would read),
184
+ the host↔C3 link file, the heartbeat — and the ``C3 connected <id>``
185
+ notification, which is what C3 Desk's "did not connect" sentinel waits
186
+ for after the hook's ``Session started <id>``.
187
+ """
188
+ host_sid = str(host_sid or "").strip()
189
+ session = getattr(getattr(svc, "session_mgr", None), "current_session", None)
190
+ if not host_sid or not isinstance(session, dict):
191
+ return False
192
+ previous = str(session.get("host_session_id") or "")
193
+ if previous == host_sid:
194
+ return False
195
+ session["host_session_id"] = host_sid
196
+ ide = getattr(svc, "ide_name", "") or _IDE_NAME
197
+ try:
198
+ from core.host import session_env_var
199
+ var = session_env_var(ide)
200
+ if var:
201
+ os.environ[var] = host_sid
202
+ except Exception:
203
+ pass
204
+ try:
205
+ import dataclasses
206
+ base = getattr(svc, "host_context", None) or _HOST
207
+ ctx = dataclasses.replace(base, host_session_id=host_sid)
208
+ svc.host_context = ctx
209
+ if getattr(svc, "session_mgr", None) is not None:
210
+ svc.session_mgr.host_context = ctx
211
+ except Exception:
212
+ pass
213
+ with _host_sid_lock:
214
+ _host_sid_cache["value"] = host_sid
215
+ _host_sid_cache["read_at"] = time.monotonic()
216
+ _host_sid_cache["linked"] = ""
217
+ _link_host_session(svc, host_sid)
218
+ try:
219
+ svc.activity_log.log("session_rebind", {
220
+ "session_id": str(session.get("id") or ""),
221
+ "host_session_id": host_sid,
222
+ "previous_host_session_id": previous,
223
+ "start_source": str(start_source or ""),
224
+ })
225
+ except Exception:
226
+ pass
227
+ try:
228
+ from services import session_live
229
+ session_live.beat(svc.project_path, str(session.get("id") or ""),
230
+ host_session_id=host_sid, ide=ide, host_pid=_HOST_PID)
231
+ except Exception:
232
+ pass
233
+ _notify_mcp_ready(svc)
234
+ return True
235
+
236
+
237
+ def _check_handoff(svc) -> bool:
238
+ """Consume a pending handoff file for this session, if any. Never raises."""
239
+ try:
240
+ session = getattr(getattr(svc, "session_mgr", None), "current_session", None) or {}
241
+ c3_id = str(session.get("id") or "")
242
+ if not c3_id:
243
+ return False
244
+ from services import host_handoff
245
+ data = host_handoff.read_handoff(svc.project_path, c3_id)
246
+ if not data:
247
+ return False
248
+ host_handoff.clear_handoff(svc.project_path, c3_id)
249
+ return _rebind_host_session(svc, str(data.get("host_session_id") or ""),
250
+ str(data.get("start_source") or ""))
251
+ except Exception:
252
+ return False
253
+
254
+
168
255
  def _notify_mcp_ready(svc) -> None:
169
256
  """``kind="mcp"`` / ``C3 connected`` — the runtime is ready to serve (D0b).
170
257
 
@@ -341,19 +428,36 @@ async def lifespan(server):
341
428
  if _live_session_id:
342
429
  from services import session_live
343
430
 
431
+ global _HOST_PID
432
+ try:
433
+ from services.host_handoff import host_process_id
434
+ _HOST_PID = int(host_process_id() or 0)
435
+ except Exception:
436
+ _HOST_PID = 0
437
+
344
438
  def _do_beat():
345
439
  session_live.beat(services.project_path, _live_session_id,
346
440
  host_session_id=_host_session_id(services) or "",
347
- ide=_IDE_NAME)
441
+ ide=_IDE_NAME, host_pid=_HOST_PID)
348
442
 
349
443
  _do_beat()
350
444
 
351
445
  def _bg_heartbeat():
352
- while not _heartbeat_stop.wait(timeout=session_live.HEARTBEAT_INTERVAL_S):
446
+ # Wakes every _HANDOFF_POLL_S to look for a handoff (a /clear
447
+ # moved this server to a new host session id), and beats every
448
+ # HEARTBEAT_INTERVAL_S as before.
449
+ last_beat = time.monotonic()
450
+ while not _heartbeat_stop.wait(timeout=_HANDOFF_POLL_S):
353
451
  try:
354
- _do_beat()
452
+ _check_handoff(services)
355
453
  except Exception:
356
454
  pass
455
+ if time.monotonic() - last_beat >= session_live.HEARTBEAT_INTERVAL_S:
456
+ last_beat = time.monotonic()
457
+ try:
458
+ _do_beat()
459
+ except Exception:
460
+ pass
357
461
 
358
462
  threading.Thread(target=_bg_heartbeat, daemon=True,
359
463
  name="c3-session-heartbeat").start()
@@ -423,6 +527,9 @@ async def lifespan(server):
423
527
  # notification within 30 s, so this must be the LAST thing before the
424
528
  # handshake proceeds and must never be skipped on a partial init.
425
529
  _notify_mcp_ready(services)
530
+ # A /clear that landed while this process was still starting left its
531
+ # handoff waiting; take it now rather than at the first poll.
532
+ _check_handoff(services)
426
533
 
427
534
  try:
428
535
  yield services
@@ -517,6 +624,10 @@ def _ensure_repo_map_once(rt) -> None:
517
624
  def _svc(ctx: Context) -> C3Runtime:
518
625
  rt = ctx.request_context.lifespan_context
519
626
  _ensure_repo_map_once(rt)
627
+ # A tool call right after a /clear must already run under the new host
628
+ # id (grants and leases are keyed by it): one stat per call, before the
629
+ # access guard binds the session.
630
+ _check_handoff(rt)
520
631
  access_guard.bind_session(_grants.session_id(rt))
521
632
  return rt
522
633
 
cli/server.py CHANGED
@@ -63,19 +63,8 @@ _HUB_CONFIG_FILE = _GLOBAL_C3_DIR / "hub_config.json"
63
63
 
64
64
 
65
65
  def _registry_read() -> list:
66
- try:
67
- if _REGISTRY_FILE.exists():
68
- with open(_REGISTRY_FILE, encoding="utf-8") as f:
69
- return json.load(f)
70
- except Exception:
71
- pass
72
- return []
73
-
74
-
75
- def _registry_write(entries: list):
76
- _GLOBAL_C3_DIR.mkdir(parents=True, exist_ok=True)
77
- with open(_REGISTRY_FILE, "w", encoding="utf-8") as f:
78
- json.dump(entries, f, indent=2)
66
+ from services import ui_registry
67
+ return ui_registry.read(_REGISTRY_FILE)
79
68
 
80
69
 
81
70
  def _read_hub_config() -> dict:
@@ -103,32 +92,54 @@ def _port_alive(port: int) -> bool:
103
92
  return False
104
93
 
105
94
 
106
- def _register_session(port: int, project_path: str, project_name: str):
95
+ # Seconds between a running server's re-assertions of its own registry entry
96
+ # (services.ui_registry.ensure). A lost entry — an older hub rewriting the
97
+ # file from a stale snapshot, a crash mid-write — comes back within one tick
98
+ # instead of leaving a server nothing can find, reap or reuse.
99
+ _REGISTRY_REASSERT_S = 60
100
+
101
+
102
+ def _registry_entry(port: int, project_path: str, project_name: str) -> dict:
103
+ # owner says who asked for this server: "session" when an IDE session
104
+ # start launched it (cli/hook_session_open), "user" for `c3 ui`, the
105
+ # hub's Open UI button and the TUI. Only session-owned servers are
106
+ # ever reaped automatically (ProjectManager.sweep_registry).
107
+ return {
108
+ "port": port,
109
+ "project_path": project_path,
110
+ "project_name": project_name,
111
+ "started_at": time.time(),
112
+ "pid": os.getpid(),
113
+ "owner": (os.environ.get("C3_UI_OWNER") or "user").strip().lower(),
114
+ "owner_session": (os.environ.get("C3_UI_OWNER_SESSION") or "").strip(),
115
+ }
116
+
117
+
118
+ def _register_session(port: int, project_path: str, project_name: str) -> dict:
119
+ """Upsert this server's entry under the registry lock (2.155.0).
120
+
121
+ This used to drop every OTHER entry whose port missed a 0.3 s connect —
122
+ a sibling server busy re-indexing was deregistered by the next one to
123
+ start, and lived on as an orphan. Pruning is the hub sweep's job now,
124
+ and it only drops an entry whose pid is gone as well.
125
+ """
126
+ from services import ui_registry
127
+ entry = _registry_entry(port, project_path, project_name)
107
128
  with _REGISTRY_LOCK:
108
- entries = _registry_read()
109
- # Remove our own port and any stale (dead) entries
110
- entries = [e for e in entries if e.get("port") != port and _port_alive(e.get("port", 0))]
111
- # owner says who asked for this server: "session" when an IDE session
112
- # start launched it (cli/hook_session_open), "user" for `c3 ui`, the
113
- # hub's Open UI button and the TUI. Only session-owned servers are
114
- # ever reaped automatically (ProjectManager.sweep_registry).
115
- entries.append({
116
- "port": port,
117
- "project_path": project_path,
118
- "project_name": project_name,
119
- "started_at": time.time(),
120
- "pid": os.getpid(),
121
- "owner": (os.environ.get("C3_UI_OWNER") or "user").strip().lower(),
122
- "owner_session": (os.environ.get("C3_UI_OWNER_SESSION") or "").strip(),
123
- })
124
- _registry_write(entries)
129
+ ui_registry.upsert(entry, _REGISTRY_FILE)
130
+ return entry
131
+
132
+
133
+ def _reassert_registration(entry: dict) -> bool:
134
+ from services import ui_registry
135
+ with _REGISTRY_LOCK:
136
+ return ui_registry.ensure(entry, _REGISTRY_FILE)
125
137
 
126
138
 
127
139
  def _unregister_session(port: int):
140
+ from services import ui_registry
128
141
  with _REGISTRY_LOCK:
129
- entries = _registry_read()
130
- entries = [e for e in entries if e.get("port") != port]
131
- _registry_write(entries)
142
+ ui_registry.remove_ports([port], _REGISTRY_FILE)
132
143
 
133
144
 
134
145
  def init_services(project_path: str):
@@ -4536,8 +4547,19 @@ def run_server(
4536
4547
  # Fallback chain: custom meta name → folder name → parent/folder for disambiguation
4537
4548
  _folder = _resolved.name or _resolved.parent.name or "project"
4538
4549
  _proj_name = (_meta.get("name") or "").strip() or _folder
4539
- _register_session(port, str(_resolved), _proj_name)
4550
+ _entry = _register_session(port, str(_resolved), _proj_name)
4540
4551
  atexit.register(_unregister_session, port)
4552
+
4553
+ def _bg_reassert():
4554
+ while True:
4555
+ time.sleep(_REGISTRY_REASSERT_S)
4556
+ try:
4557
+ _reassert_registration(_entry)
4558
+ except Exception:
4559
+ pass
4560
+
4561
+ threading.Thread(target=_bg_reassert, daemon=True,
4562
+ name="c3-ui-registry-reassert").start()
4541
4563
  except Exception:
4542
4564
  pass
4543
4565
 
cli/tools/_grants.py CHANGED
@@ -51,6 +51,24 @@ def session_id(svc) -> str:
51
51
  return str(session.get("id", "") or "") or f"pid-{os.getpid()}"
52
52
 
53
53
 
54
+ def agent_id(svc) -> str:
55
+ """One caller label for explicit locks and implicit edit leases.
56
+
57
+ Runtime host identity belongs to the caller, including c3_project proxies;
58
+ the target project's preferred editor must not override it.
59
+ """
60
+ from core.host import resolve_host
61
+ from services.agent_locks import agent_id_for
62
+
63
+ provider = getattr(svc, "ide_name", "")
64
+ if not isinstance(provider, str) or not provider.strip():
65
+ session = getattr(getattr(svc, "session_mgr", None), "current_session", None) or {}
66
+ provider = session.get("source_ide", "")
67
+ if not isinstance(provider, str) or not provider.strip():
68
+ provider = resolve_host(str(getattr(svc, "project_path", "") or "")).provider
69
+ return agent_id_for(session_id(svc), provider=provider)
70
+
71
+
54
72
  def allow(svc, denial, *, tool: str, op: str, path,
55
73
  peek: bool = False) -> str | None:
56
74
  """The `[c3-override:granted]` line when a live grant permits this exact
cli/tools/edit.py CHANGED
@@ -566,7 +566,8 @@ def _write_gate(svc, path: Path, rel: str, file_path: str, op: str,
566
566
  "holder": holder.get("agent_id", ""),
567
567
  "held_for_s": held_for},
568
568
  agent_locks.refusal(holder, rel), "lock held")
569
- agent_locks.lease(str(path), svc.project_path, session_id, intent=intent)
569
+ agent_locks.lease(str(path), svc.project_path, session_id, intent=intent,
570
+ agent_id=_grants.agent_id(svc))
570
571
  return None
571
572
 
572
573
 
cli/tools/locks.py CHANGED
@@ -40,7 +40,7 @@ def handle_locks(action: str, paths: str, intent: str, ttl_s: int,
40
40
  """Route c3_locks actions."""
41
41
  project = str(svc.project_path)
42
42
  session = _session_id(svc)
43
- agent = al.agent_id_for(session)
43
+ agent = _grants.agent_id(svc)
44
44
  args = {"action": action}
45
45
 
46
46
  cfg = al.config(project)
cli/tools/project.py CHANGED
@@ -54,6 +54,19 @@ def _runtime_for(path: str):
54
54
  return shared_cache().get(path)
55
55
 
56
56
 
57
+ class _CallerLabelRuntime:
58
+ """Use the caller's provider label without changing target session ownership."""
59
+
60
+ def __init__(self, target, caller):
61
+ from cli.tools import _grants
62
+
63
+ self._target = target
64
+ self.ide_name = _grants.agent_id(caller).split(":", 1)[0]
65
+
66
+ def __getattr__(self, name):
67
+ return getattr(self._target, name)
68
+
69
+
57
70
  def _is_registered(path: str) -> bool:
58
71
  """True when *path* is in the cross-project registry. Fails closed."""
59
72
  try:
@@ -524,6 +537,11 @@ def handle_project(action, svc, finalize, *, project="", query="", file_path="",
524
537
  return done(
525
538
  f"[c3_project:error] Could not load '{resolved['name']}': {e}", "error")
526
539
 
540
+ # Label edit leases with the requesting host, without mutating the cached
541
+ # runtime or borrowing the caller's session/grant identity in the target.
542
+ if action == "edit":
543
+ fsvc = _CallerLabelRuntime(fsvc, svc)
544
+
527
545
  banner = f"[c3_project:{resolved['name']}] {action}\n"
528
546
  try:
529
547
  body = _proxy(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-context-control
3
- Version: 2.153.0
3
+ Version: 2.155.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