code-context-control 2.127.0__py3-none-any.whl → 2.129.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/_hook_utils.py +22 -0
- cli/c3.py +5 -6
- cli/hook_session_open.py +103 -1
- cli/hub_server.py +195 -49
- cli/hub_ui/components/project_card.js +6 -1
- cli/mcp_server.py +40 -1
- cli/server.py +7 -0
- cli/tools/_helpers.py +34 -0
- cli/tools/ci.py +9 -0
- cli/tools/compress.py +50 -18
- cli/tools/read.py +88 -24
- {code_context_control-2.127.0.dist-info → code_context_control-2.129.0.dist-info}/METADATA +3 -1
- {code_context_control-2.127.0.dist-info → code_context_control-2.129.0.dist-info}/RECORD +24 -23
- services/activity_log.py +48 -0
- services/ci_act.py +60 -0
- services/ci_runner.py +11 -0
- services/dir_map.py +42 -12
- services/file_memory.py +49 -10
- services/project_manager.py +263 -25
- services/session_live.py +139 -0
- {code_context_control-2.127.0.dist-info → code_context_control-2.129.0.dist-info}/WHEEL +0 -0
- {code_context_control-2.127.0.dist-info → code_context_control-2.129.0.dist-info}/entry_points.txt +0 -0
- {code_context_control-2.127.0.dist-info → code_context_control-2.129.0.dist-info}/licenses/LICENSE +0 -0
- {code_context_control-2.127.0.dist-info → code_context_control-2.129.0.dist-info}/top_level.txt +0 -0
cli/_hook_utils.py
CHANGED
|
@@ -8,6 +8,7 @@ All hook reads/writes of enforcement state MUST go through this module.
|
|
|
8
8
|
import json
|
|
9
9
|
import os
|
|
10
10
|
import re
|
|
11
|
+
import shlex
|
|
11
12
|
import sys
|
|
12
13
|
import traceback
|
|
13
14
|
from datetime import datetime, timezone
|
|
@@ -34,6 +35,27 @@ LEGACY_UNLOCK_FILE = ".c3/unlocked_files.json"
|
|
|
34
35
|
STATE_WARNINGS: list = []
|
|
35
36
|
|
|
36
37
|
|
|
38
|
+
def hook_command_arg(raw) -> str:
|
|
39
|
+
"""Quote one argv element for a hook ``command`` string in settings.
|
|
40
|
+
|
|
41
|
+
Windows: a DOUBLE-quoted forward-slash path. Claude Code runs hooks through
|
|
42
|
+
Git Bash, where ``shlex.quote`` produces SINGLE quotes around a backslash
|
|
43
|
+
path — cmd.exe does not treat single quotes as quoting, so any path with a
|
|
44
|
+
space or parentheses (``Claude Code Companion (C3)``) breaks. A
|
|
45
|
+
double-quoted forward-slash path is parsed correctly by bash AND cmd.
|
|
46
|
+
Verified 2026-07-26 under Git Bash on Windows 11; the installer in
|
|
47
|
+
cli/c3.py carries the full investigation, including why no ``cmd.exe /c``
|
|
48
|
+
wrapper belongs here.
|
|
49
|
+
|
|
50
|
+
Anything that writes a hook command — the installer and the hub's
|
|
51
|
+
migration — must go through this, or the entry it writes is dead on
|
|
52
|
+
Windows.
|
|
53
|
+
"""
|
|
54
|
+
if sys.platform == "win32":
|
|
55
|
+
return '"' + str(raw).replace("\\", "/") + '"'
|
|
56
|
+
return shlex.quote(str(raw))
|
|
57
|
+
|
|
58
|
+
|
|
37
59
|
def ensure_utf8_stdio() -> None:
|
|
38
60
|
"""Force UTF-8 on the hook process's stdio pipes.
|
|
39
61
|
|
cli/c3.py
CHANGED
|
@@ -33,7 +33,6 @@ import json
|
|
|
33
33
|
import logging
|
|
34
34
|
import os
|
|
35
35
|
import re
|
|
36
|
-
import shlex
|
|
37
36
|
import subprocess
|
|
38
37
|
import sys
|
|
39
38
|
import tempfile
|
|
@@ -92,7 +91,7 @@ console = Console() if HAS_RICH else None
|
|
|
92
91
|
# Config
|
|
93
92
|
CONFIG_DIR = ".c3"
|
|
94
93
|
CONFIG_FILE = ".c3/config.json"
|
|
95
|
-
__version__ = "2.
|
|
94
|
+
__version__ = "2.129.0"
|
|
96
95
|
|
|
97
96
|
|
|
98
97
|
def _compress_file_cli(compressor, path, mode="smart", **kw):
|
|
@@ -5542,10 +5541,10 @@ def cmd_install_mcp(args):
|
|
|
5542
5541
|
# the wrapper was never needed. A double-quoted forward-slash path is parsed
|
|
5543
5542
|
# correctly by bash AND cmd, including paths containing spaces or parentheses
|
|
5544
5543
|
# (e.g. "Claude Code Companion (C3)"), which is why the wrapper was added.
|
|
5545
|
-
|
|
5546
|
-
|
|
5547
|
-
|
|
5548
|
-
|
|
5544
|
+
# Lives in cli/_hook_utils so the hub's hook migration quotes commands
|
|
5545
|
+
# the same way — it used shlex.quote and wrote entries that Windows
|
|
5546
|
+
# could not run.
|
|
5547
|
+
from cli._hook_utils import hook_command_arg as _hook_arg
|
|
5549
5548
|
|
|
5550
5549
|
# v2.42: single dispatcher script per hook event instead of N separate
|
|
5551
5550
|
# per-hook commands. One interpreter spawn per event; the dispatcher
|
cli/hook_session_open.py
CHANGED
|
@@ -22,6 +22,14 @@ Records:
|
|
|
22
22
|
- a ``session`` notification (``kind="session"``, ``ref_id=host id``,
|
|
23
23
|
``info``, title ``Session started <short id>``).
|
|
24
24
|
|
|
25
|
+
It also brings up the project's UI server when one is not already running
|
|
26
|
+
(``ensure_ui`` below, v2.128.0) — the only place in C3 where "a session
|
|
27
|
+
started" causes "the UI is up". It needs the project to be registered with
|
|
28
|
+
the hub, and it skips a project that already has a live UI port, so a
|
|
29
|
+
``resume`` or ``clear`` never stacks a second server. Opt out with
|
|
30
|
+
``C3_NO_UI_AUTOSTART=1`` or
|
|
31
|
+
``.c3/config.json → {"ui": {"autostart_on_session": false}}``.
|
|
32
|
+
|
|
25
33
|
``source: "compact"`` writes NOTHING: a compaction is the same session and
|
|
26
34
|
the same MCP process, and a "Session started" toast on every compaction
|
|
27
35
|
would teach the user to ignore the real ones.
|
|
@@ -37,6 +45,8 @@ this hook has nothing to tell the model.
|
|
|
37
45
|
"""
|
|
38
46
|
from __future__ import annotations
|
|
39
47
|
|
|
48
|
+
import json
|
|
49
|
+
import os
|
|
40
50
|
import sys
|
|
41
51
|
from pathlib import Path
|
|
42
52
|
|
|
@@ -48,6 +58,98 @@ from cli.hook_session_end import short_id # noqa: E402
|
|
|
48
58
|
_SILENT_SOURCES = {"compact"}
|
|
49
59
|
|
|
50
60
|
|
|
61
|
+
def _ui_autostart_enabled(project: Path) -> bool:
|
|
62
|
+
"""Opt-outs, in the order a user reaches for them."""
|
|
63
|
+
if os.environ.get("C3_NO_UI_AUTOSTART") or os.environ.get("C3_BENCHMARK_MODE"):
|
|
64
|
+
return False
|
|
65
|
+
try:
|
|
66
|
+
cfg = json.loads((project / ".c3" / "config.json").read_text(encoding="utf-8"))
|
|
67
|
+
except Exception:
|
|
68
|
+
return True # no readable config is not a reason to withhold the UI
|
|
69
|
+
ui = cfg.get("ui")
|
|
70
|
+
if isinstance(ui, dict) and "autostart_on_session" in ui:
|
|
71
|
+
return bool(ui.get("autostart_on_session"))
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_registered(pm, project: Path) -> bool:
|
|
76
|
+
"""True when the hub knows this project.
|
|
77
|
+
|
|
78
|
+
The UI server is a hub-level surface: a directory the user never
|
|
79
|
+
registered gets nothing automatic (``c3 ui`` still works). This is also
|
|
80
|
+
what keeps a test fixture or a scratch clone from growing a web server.
|
|
81
|
+
"""
|
|
82
|
+
target = os.path.normcase(str(project))
|
|
83
|
+
for entry in pm._read_projects():
|
|
84
|
+
raw = str(entry.get("path") or "")
|
|
85
|
+
if raw and os.path.normcase(raw) == target:
|
|
86
|
+
return True
|
|
87
|
+
try:
|
|
88
|
+
if raw and os.path.normcase(str(Path(raw).resolve())) == target:
|
|
89
|
+
return True
|
|
90
|
+
except Exception:
|
|
91
|
+
continue
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _ui_running(pm, project: Path) -> bool:
|
|
96
|
+
"""True when a live UI server is already registered for this project.
|
|
97
|
+
|
|
98
|
+
This is what keeps `resume` / `clear` — both real SessionStart events —
|
|
99
|
+
from stacking a second server on every reconnect.
|
|
100
|
+
"""
|
|
101
|
+
target = os.path.normcase(str(project))
|
|
102
|
+
for entry in pm._read_registry():
|
|
103
|
+
raw = str(entry.get("project_path") or "")
|
|
104
|
+
if not raw:
|
|
105
|
+
continue
|
|
106
|
+
try:
|
|
107
|
+
same = os.path.normcase(str(Path(raw).resolve())) == target
|
|
108
|
+
except Exception:
|
|
109
|
+
same = os.path.normcase(raw) == target
|
|
110
|
+
if same and entry.get("port") and pm._port_alive(entry["port"]):
|
|
111
|
+
return True
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def ensure_ui(project: Path, host_session_id: str = "") -> str:
|
|
116
|
+
"""Bring up this project's UI server for the session that just opened.
|
|
117
|
+
|
|
118
|
+
Nothing else in C3 connected "a session started" to "the UI is running":
|
|
119
|
+
the hub's sweep only chases projects flagged `autostart_ui`, and before
|
|
120
|
+
2.128.0 it ran once at hub startup — so a UI was only ever there because
|
|
121
|
+
an earlier session had left one behind.
|
|
122
|
+
|
|
123
|
+
Returns a short reason string (tests and diagnostics read it). Never
|
|
124
|
+
raises and never prints — SessionStart stdout becomes model context.
|
|
125
|
+
"""
|
|
126
|
+
try:
|
|
127
|
+
project = Path(project).resolve()
|
|
128
|
+
except Exception:
|
|
129
|
+
project = Path(project)
|
|
130
|
+
if not _ui_autostart_enabled(project):
|
|
131
|
+
return "disabled"
|
|
132
|
+
try:
|
|
133
|
+
from services.project_manager import ProjectManager
|
|
134
|
+
pm = ProjectManager()
|
|
135
|
+
if not _is_registered(pm, project):
|
|
136
|
+
return "unregistered"
|
|
137
|
+
if _ui_running(pm, project):
|
|
138
|
+
return "already-running"
|
|
139
|
+
# launch_session copies os.environ into the detached child, which is
|
|
140
|
+
# where cli/server._register_session reads the ownership from.
|
|
141
|
+
os.environ["C3_UI_OWNER"] = "session"
|
|
142
|
+
if host_session_id:
|
|
143
|
+
os.environ["C3_UI_OWNER_SESSION"] = host_session_id
|
|
144
|
+
result = pm.launch_session(str(project))
|
|
145
|
+
if result.get("launched"):
|
|
146
|
+
return "launched"
|
|
147
|
+
return f"failed: {result.get('error', '')}".strip()
|
|
148
|
+
except Exception as exc:
|
|
149
|
+
log_hook_error("hook_session_open", exc)
|
|
150
|
+
return "error"
|
|
151
|
+
|
|
152
|
+
|
|
51
153
|
def run(payload: dict, project_path: Path | None = None):
|
|
52
154
|
"""Core logic — importable by the dispatcher and tests. Returns None."""
|
|
53
155
|
start_source = str(payload.get("source") or "").strip().lower()
|
|
@@ -76,11 +178,11 @@ def run(payload: dict, project_path: Path | None = None):
|
|
|
76
178
|
kind="session", ref_id=host_sid)
|
|
77
179
|
except Exception:
|
|
78
180
|
pass
|
|
181
|
+
ensure_ui(project, host_sid)
|
|
79
182
|
return None
|
|
80
183
|
|
|
81
184
|
|
|
82
185
|
def main() -> None:
|
|
83
|
-
import json
|
|
84
186
|
try:
|
|
85
187
|
data = json.load(sys.stdin)
|
|
86
188
|
except Exception as exc:
|
cli/hub_server.py
CHANGED
|
@@ -29,6 +29,7 @@ from flask import Flask, jsonify, redirect, request, send_from_directory
|
|
|
29
29
|
|
|
30
30
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
31
31
|
|
|
32
|
+
from cli._hook_utils import hook_command_arg
|
|
32
33
|
from core.ide import PROFILES, detect_ide, get_profile, load_ide_config, normalize_ide_name
|
|
33
34
|
from services import access_guard
|
|
34
35
|
from services.activity_log import ActivityLog
|
|
@@ -71,8 +72,14 @@ _HUB_CONFIG_DEFAULTS = {
|
|
|
71
72
|
"projects_view": "list",
|
|
72
73
|
"main_view": "projects",
|
|
73
74
|
"oracle_url": "",
|
|
75
|
+
# Minutes of project silence, with no session heartbeat left, before a UI
|
|
76
|
+
# server that an IDE session launched is stopped. 0 = never reap.
|
|
77
|
+
"ui_reap_minutes": 30,
|
|
74
78
|
}
|
|
75
79
|
|
|
80
|
+
# How often the hub reaps orphaned UI servers and chases autostart projects.
|
|
81
|
+
_UI_SWEEP_INTERVAL_S = 60
|
|
82
|
+
|
|
76
83
|
|
|
77
84
|
def _read_hub_config() -> dict:
|
|
78
85
|
cfg = dict(_HUB_CONFIG_DEFAULTS)
|
|
@@ -4373,28 +4380,168 @@ def method_not_allowed(e):
|
|
|
4373
4380
|
|
|
4374
4381
|
# ─── Hook migration ──────────────────────────────────────────────────────────
|
|
4375
4382
|
|
|
4376
|
-
def
|
|
4377
|
-
|
|
4383
|
+
def _has_matcher(value: str):
|
|
4384
|
+
return lambda entries: any(e.get("matcher") == value for e in entries)
|
|
4385
|
+
|
|
4386
|
+
|
|
4387
|
+
def _has_command(fragment: str):
|
|
4388
|
+
return lambda entries: any(
|
|
4389
|
+
fragment in (hk.get("command") or "")
|
|
4390
|
+
for e in entries
|
|
4391
|
+
for hk in e.get("hooks", [])
|
|
4392
|
+
)
|
|
4393
|
+
|
|
4394
|
+
|
|
4395
|
+
def c3_hook_migrations(cli_dir: Path | None = None) -> list:
|
|
4396
|
+
"""C3's own hook entries, per settings file and hook event.
|
|
4397
|
+
|
|
4398
|
+
Adding a hook to the installer (``cli/c3.py``) does NOT reach a project
|
|
4399
|
+
that is already installed. The v2.126.0 SessionStart / SessionEnd entries
|
|
4400
|
+
landed in the installer only, so every existing project kept writing no
|
|
4401
|
+
``session_open`` / ``session_end`` rows at all — 60 projects on the
|
|
4402
|
+
machine this was found on, including C3's own. This table is how they
|
|
4403
|
+
catch up: the hub applies it at startup, additively and idempotently.
|
|
4378
4404
|
|
|
4379
|
-
|
|
4380
|
-
|
|
4405
|
+
``present`` decides "C3's entry for this event is already here", so a
|
|
4406
|
+
re-run is a no-op and a user's own entries for the same event survive.
|
|
4381
4407
|
"""
|
|
4382
|
-
cli_dir = Path(__file__).parent
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4408
|
+
cli_dir = cli_dir or Path(__file__).parent
|
|
4409
|
+
# hook_command_arg, not shlex.quote: on Windows the latter writes a
|
|
4410
|
+
# single-quoted backslash path that cmd.exe does not unquote, so every
|
|
4411
|
+
# command this table produced for a path with a space — every path under
|
|
4412
|
+
# "1. Projects", say — was dead on arrival. The installer has used the
|
|
4413
|
+
# double-quoted forward-slash form since 2026-07-26; this now matches it.
|
|
4414
|
+
dispatch = (
|
|
4415
|
+
f"{hook_command_arg(sys.executable)} "
|
|
4416
|
+
f"{hook_command_arg(str(cli_dir / 'hook_dispatch.py'))}"
|
|
4386
4417
|
)
|
|
4387
|
-
|
|
4418
|
+
c3read_entry = {
|
|
4388
4419
|
"matcher": "mcp__c3__c3_read",
|
|
4389
|
-
"hooks": [{
|
|
4420
|
+
"hooks": [{
|
|
4421
|
+
"type": "command",
|
|
4422
|
+
"command": (f"{hook_command_arg(sys.executable)} "
|
|
4423
|
+
f"{hook_command_arg(str(cli_dir / 'hook_c3read.py'))}"),
|
|
4424
|
+
}],
|
|
4390
4425
|
}
|
|
4426
|
+
c3read_present = _has_matcher("mcp__c3__c3_read")
|
|
4427
|
+
dispatch_present = _has_command("hook_dispatch.py")
|
|
4428
|
+
|
|
4429
|
+
def _lifecycle(event: str, arg: str) -> dict:
|
|
4430
|
+
return {
|
|
4431
|
+
"settings": ".claude/settings.local.json",
|
|
4432
|
+
"event": event,
|
|
4433
|
+
"present": dispatch_present,
|
|
4434
|
+
"entry": {"matcher": "",
|
|
4435
|
+
"hooks": [{"type": "command",
|
|
4436
|
+
"command": f"{dispatch} {arg}"}]},
|
|
4437
|
+
}
|
|
4391
4438
|
|
|
4392
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
4439
|
+
return [
|
|
4440
|
+
{"settings": ".claude/settings.local.json", "event": "PostToolUse",
|
|
4441
|
+
"present": c3read_present, "entry": c3read_entry},
|
|
4442
|
+
{"settings": ".gemini/settings.json", "event": "AfterTool",
|
|
4443
|
+
"present": c3read_present, "entry": c3read_entry},
|
|
4444
|
+
# Claude only — Gemini has no session-lifecycle event to hang these on.
|
|
4445
|
+
_lifecycle("SessionStart", "start"),
|
|
4446
|
+
_lifecycle("SessionEnd", "end"),
|
|
4396
4447
|
]
|
|
4397
4448
|
|
|
4449
|
+
|
|
4450
|
+
def _command_cli_dir(command: str) -> Path | None:
|
|
4451
|
+
"""The C3 ``cli/`` directory a hook command points at, if any."""
|
|
4452
|
+
try:
|
|
4453
|
+
tokens = shlex.split(str(command or ""), posix=False)
|
|
4454
|
+
except ValueError:
|
|
4455
|
+
tokens = str(command or "").split()
|
|
4456
|
+
for token in tokens:
|
|
4457
|
+
token = token.strip("'\"")
|
|
4458
|
+
if not token.endswith(".py"):
|
|
4459
|
+
continue
|
|
4460
|
+
candidate = Path(token)
|
|
4461
|
+
if not candidate.name.startswith("hook_"):
|
|
4462
|
+
continue
|
|
4463
|
+
parent = candidate.parent
|
|
4464
|
+
if (parent / "hook_dispatch.py").is_file():
|
|
4465
|
+
return parent
|
|
4466
|
+
return None
|
|
4467
|
+
|
|
4468
|
+
|
|
4469
|
+
def detect_project_cli_dir(path: str) -> Path | None:
|
|
4470
|
+
"""The ``cli/`` directory this project's existing C3 hooks already use.
|
|
4471
|
+
|
|
4472
|
+
The hub runs from wherever C3 is installed, so building commands from its
|
|
4473
|
+
own location writes install paths into a project that was installed from a
|
|
4474
|
+
checkout — C3's own repo above all, where the working tree IS the runtime.
|
|
4475
|
+
A hook edited there would then change nothing until the next install. Match
|
|
4476
|
+
what the project already points at; the caller falls back to ours.
|
|
4477
|
+
"""
|
|
4478
|
+
for rel_settings in (".claude/settings.local.json", ".gemini/settings.json"):
|
|
4479
|
+
settings_path = Path(path) / rel_settings
|
|
4480
|
+
if not settings_path.exists():
|
|
4481
|
+
continue
|
|
4482
|
+
try:
|
|
4483
|
+
with open(settings_path, encoding="utf-8") as f:
|
|
4484
|
+
hooks = (json.load(f).get("hooks") or {})
|
|
4485
|
+
except Exception:
|
|
4486
|
+
continue
|
|
4487
|
+
for entries in hooks.values():
|
|
4488
|
+
for entry in entries or []:
|
|
4489
|
+
for hook in (entry or {}).get("hooks", []):
|
|
4490
|
+
found = _command_cli_dir((hook or {}).get("command") or "")
|
|
4491
|
+
if found is not None:
|
|
4492
|
+
return found
|
|
4493
|
+
return None
|
|
4494
|
+
|
|
4495
|
+
|
|
4496
|
+
def migrate_hooks_for_project(path: str, migrations: list | None = None) -> int:
|
|
4497
|
+
"""Apply the hook table to one project. Returns settings files changed.
|
|
4498
|
+
|
|
4499
|
+
Never creates a settings file that is not already there: a project with no
|
|
4500
|
+
``.claude/settings.local.json`` has not been installed for Claude, and the
|
|
4501
|
+
hub is not the place to decide that it should be.
|
|
4502
|
+
"""
|
|
4503
|
+
if migrations is None:
|
|
4504
|
+
migrations = c3_hook_migrations(detect_project_cli_dir(path))
|
|
4505
|
+
by_file: dict = {}
|
|
4506
|
+
for migration in migrations:
|
|
4507
|
+
by_file.setdefault(migration["settings"], []).append(migration)
|
|
4508
|
+
|
|
4509
|
+
changed_files = 0
|
|
4510
|
+
for rel_settings, items in by_file.items():
|
|
4511
|
+
settings_path = Path(path) / rel_settings
|
|
4512
|
+
if not settings_path.exists():
|
|
4513
|
+
continue
|
|
4514
|
+
try:
|
|
4515
|
+
with open(settings_path, encoding="utf-8") as f:
|
|
4516
|
+
settings = json.load(f)
|
|
4517
|
+
except Exception:
|
|
4518
|
+
continue
|
|
4519
|
+
changed = False
|
|
4520
|
+
for migration in items:
|
|
4521
|
+
event = migration["event"]
|
|
4522
|
+
existing = list(settings.get("hooks", {}).get(event, []))
|
|
4523
|
+
if migration["present"](existing):
|
|
4524
|
+
continue
|
|
4525
|
+
existing.append(migration["entry"])
|
|
4526
|
+
settings.setdefault("hooks", {})[event] = existing
|
|
4527
|
+
changed = True
|
|
4528
|
+
if not changed:
|
|
4529
|
+
continue
|
|
4530
|
+
try:
|
|
4531
|
+
with open(settings_path, "w", encoding="utf-8") as f:
|
|
4532
|
+
json.dump(settings, f, indent=2)
|
|
4533
|
+
changed_files += 1
|
|
4534
|
+
except Exception:
|
|
4535
|
+
pass
|
|
4536
|
+
return changed_files
|
|
4537
|
+
|
|
4538
|
+
|
|
4539
|
+
def _migrate_project_hooks():
|
|
4540
|
+
"""Idempotently add missing C3 hooks to every registered project.
|
|
4541
|
+
|
|
4542
|
+
Runs at hub startup so existing projects pick up hook changes without a
|
|
4543
|
+
manual 'c3 install-mcp' per project.
|
|
4544
|
+
"""
|
|
4398
4545
|
try:
|
|
4399
4546
|
projects = _pm().list_projects()
|
|
4400
4547
|
except Exception:
|
|
@@ -4405,32 +4552,13 @@ def _migrate_project_hooks():
|
|
|
4405
4552
|
path = p.get("path", "")
|
|
4406
4553
|
if not path:
|
|
4407
4554
|
continue
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
continue
|
|
4412
|
-
try:
|
|
4413
|
-
with open(settings_path, encoding="utf-8") as f:
|
|
4414
|
-
settings = json.load(f)
|
|
4415
|
-
except Exception:
|
|
4416
|
-
continue
|
|
4417
|
-
|
|
4418
|
-
existing = settings.get("hooks", {}).get(hook_event, [])
|
|
4419
|
-
if any(h.get("matcher") == "mcp__c3__c3_read" for h in existing):
|
|
4420
|
-
continue # already present — skip
|
|
4421
|
-
|
|
4422
|
-
existing.append(new_hook)
|
|
4423
|
-
settings.setdefault("hooks", {})[hook_event] = existing
|
|
4424
|
-
try:
|
|
4425
|
-
with open(settings_path, "w", encoding="utf-8") as f:
|
|
4426
|
-
json.dump(settings, f, indent=2)
|
|
4427
|
-
updated += 1
|
|
4428
|
-
except Exception:
|
|
4429
|
-
pass
|
|
4555
|
+
# No shared table: each project's entries are built against the cli_dir
|
|
4556
|
+
# that project's existing C3 hooks already point at.
|
|
4557
|
+
updated += migrate_hooks_for_project(path)
|
|
4430
4558
|
|
|
4431
4559
|
if updated:
|
|
4432
4560
|
logging.getLogger(__name__).info(
|
|
4433
|
-
"[c3] Migrated
|
|
4561
|
+
"[c3] Migrated C3 hooks into %d project settings file(s)", updated
|
|
4434
4562
|
)
|
|
4435
4563
|
|
|
4436
4564
|
|
|
@@ -4478,21 +4606,39 @@ def run_hub(
|
|
|
4478
4606
|
pass
|
|
4479
4607
|
_migrate_project_hooks()
|
|
4480
4608
|
|
|
4481
|
-
#
|
|
4482
|
-
|
|
4609
|
+
# Periodic UI sweep. Before 2.128 this ran ONCE, two seconds after
|
|
4610
|
+
# startup, over autostart_ui projects — so a project whose session started
|
|
4611
|
+
# after the hub (the normal case), or whose UI died mid-session, never got
|
|
4612
|
+
# one. Now it also reaps the session-owned servers whose session is gone.
|
|
4613
|
+
def _ui_sweep_loop():
|
|
4483
4614
|
import time
|
|
4484
4615
|
time.sleep(2) # Give Flask a moment to bind
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
|
|
4491
|
-
|
|
4492
|
-
|
|
4493
|
-
|
|
4616
|
+
first_pass = True
|
|
4617
|
+
while True:
|
|
4618
|
+
try:
|
|
4619
|
+
pm = _pm()
|
|
4620
|
+
pm.sweep_registry()
|
|
4621
|
+
wanted = set(pm.get_autostart_projects())
|
|
4622
|
+
if wanted:
|
|
4623
|
+
for proj in pm.list_projects():
|
|
4624
|
+
if proj.get("path") not in wanted or proj.get("ui_active"):
|
|
4625
|
+
continue
|
|
4626
|
+
# First pass keeps the old contract ("autostart launches
|
|
4627
|
+
# the UI when the hub starts"); later passes only chase
|
|
4628
|
+
# projects that actually have a live session.
|
|
4629
|
+
if not (first_pass or proj.get("session_active")):
|
|
4630
|
+
continue
|
|
4631
|
+
try:
|
|
4632
|
+
pm.launch_session(proj["path"])
|
|
4633
|
+
except Exception:
|
|
4634
|
+
pass
|
|
4635
|
+
except Exception:
|
|
4636
|
+
pass
|
|
4637
|
+
first_pass = False
|
|
4638
|
+
time.sleep(_UI_SWEEP_INTERVAL_S)
|
|
4494
4639
|
|
|
4495
|
-
threading.Thread(target=
|
|
4640
|
+
threading.Thread(target=_ui_sweep_loop, daemon=True,
|
|
4641
|
+
name="c3-hub-ui-sweep").start()
|
|
4496
4642
|
|
|
4497
4643
|
bind_host = str(cfg.get("host", "127.0.0.1") or "127.0.0.1").strip()
|
|
4498
4644
|
if bind_host not in ("127.0.0.1", "localhost", "::1") and not quiet:
|
|
@@ -493,7 +493,12 @@ function ProjectCard({ p, isChild, rollup, expanded, onToggleExpand, onChanged,
|
|
|
493
493
|
} catch (err) { notify('IDE: ' + err.message, 'err'); }
|
|
494
494
|
};
|
|
495
495
|
|
|
496
|
-
|
|
496
|
+
// session_count > 1 = two IDEs / a repo and its worktrees on one project.
|
|
497
|
+
const sessionCount = p.session_count || (p.session_active ? 1 : 0);
|
|
498
|
+
const mcpLabel = sessionCount > 1 ? `MCP×${sessionCount}` : 'MCP';
|
|
499
|
+
const portLabel = p.port
|
|
500
|
+
? (sessionCount > 1 ? `:${p.port} · ${mcpLabel}` : `:${p.port}`)
|
|
501
|
+
: (p.session_active ? mcpLabel : '—');
|
|
497
502
|
const metaText = `${ideLabel(p.ide)} · ${portLabel} · ${p.last_session ? timeAgo(p.last_session) : 'never'}`;
|
|
498
503
|
|
|
499
504
|
const identity = (
|
cli/mcp_server.py
CHANGED
|
@@ -321,6 +321,33 @@ async def lifespan(server):
|
|
|
321
321
|
**({"host_session_id": _host_sid} if _host_sid else {}),
|
|
322
322
|
})
|
|
323
323
|
|
|
324
|
+
# Liveness heartbeat: THIS process is the session, so say so in a file a
|
|
325
|
+
# reader can trust. Without it the hub has to infer liveness from tool
|
|
326
|
+
# activity, which calls a quiet-but-open session dead after 20 min and a
|
|
327
|
+
# closed one live for 20 min. Refreshed on a 60 s daemon thread (same
|
|
328
|
+
# shape as _bg_convo_sync) and removed in the finally block below.
|
|
329
|
+
_heartbeat_stop = threading.Event()
|
|
330
|
+
_live_session_id = str((services.session_mgr.current_session or {}).get("id") or "")
|
|
331
|
+
if _live_session_id:
|
|
332
|
+
from services import session_live
|
|
333
|
+
|
|
334
|
+
def _do_beat():
|
|
335
|
+
session_live.beat(services.project_path, _live_session_id,
|
|
336
|
+
host_session_id=_host_session_id(services) or "",
|
|
337
|
+
ide=_IDE_NAME)
|
|
338
|
+
|
|
339
|
+
_do_beat()
|
|
340
|
+
|
|
341
|
+
def _bg_heartbeat():
|
|
342
|
+
while not _heartbeat_stop.wait(timeout=session_live.HEARTBEAT_INTERVAL_S):
|
|
343
|
+
try:
|
|
344
|
+
_do_beat()
|
|
345
|
+
except Exception:
|
|
346
|
+
pass
|
|
347
|
+
|
|
348
|
+
threading.Thread(target=_bg_heartbeat, daemon=True,
|
|
349
|
+
name="c3-session-heartbeat").start()
|
|
350
|
+
|
|
324
351
|
# Auto-restore latest snapshot if recent (< 30 min).
|
|
325
352
|
# Deferred to background thread so first tool call isn't blocked.
|
|
326
353
|
# Skipped in benchmark mode to prevent snapshot budget from carrying over between tasks.
|
|
@@ -391,6 +418,15 @@ async def lifespan(server):
|
|
|
391
418
|
yield services
|
|
392
419
|
finally:
|
|
393
420
|
_convo_sync_stop.set()
|
|
421
|
+
# Stop claiming to be alive before anything slow runs: a reader that
|
|
422
|
+
# polls during teardown should already see this session as gone.
|
|
423
|
+
_heartbeat_stop.set()
|
|
424
|
+
if _live_session_id:
|
|
425
|
+
try:
|
|
426
|
+
from services import session_live as _sl
|
|
427
|
+
_sl.clear(services.project_path, _live_session_id)
|
|
428
|
+
except Exception:
|
|
429
|
+
pass
|
|
394
430
|
# Auto-memory: extract remaining learnings and generate session summary.
|
|
395
431
|
if hasattr(services, "auto_memory"):
|
|
396
432
|
try:
|
|
@@ -698,7 +734,10 @@ async def c3_read(file_path: str, symbols: Any = None, lines: Any = None,
|
|
|
698
734
|
"""READ source, or MAP a file when called with only file_path (read-only, plan-mode safe).
|
|
699
735
|
file_path: single or comma-separated. symbols: names as the map prints them (Class.method ok).
|
|
700
736
|
lines: int, [start,end], or list of ranges. No symbols/lines = one line per symbol with
|
|
701
|
-
signature and [La-Lb] range at ~10% of the file's tokens; then read the symbols you need.
|
|
737
|
+
signature and [La-Lb] range at ~10% of the file's tokens; then read the symbols you need.
|
|
738
|
+
Paths OUTSIDE the project root are read too (sibling repo, extra working dir, installed
|
|
739
|
+
package source): tagged [c3-read:external], served from a transient record, never added
|
|
740
|
+
to this project's index or related facts. Access Guard rules still apply there."""
|
|
702
741
|
path_err = validate_file_path(file_path)
|
|
703
742
|
if path_err:
|
|
704
743
|
return f"[c3_read:error] {path_err}"
|
cli/server.py
CHANGED
|
@@ -108,11 +108,18 @@ def _register_session(port: int, project_path: str, project_name: str):
|
|
|
108
108
|
entries = _registry_read()
|
|
109
109
|
# Remove our own port and any stale (dead) entries
|
|
110
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).
|
|
111
115
|
entries.append({
|
|
112
116
|
"port": port,
|
|
113
117
|
"project_path": project_path,
|
|
114
118
|
"project_name": project_name,
|
|
115
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(),
|
|
116
123
|
})
|
|
117
124
|
_registry_write(entries)
|
|
118
125
|
|
cli/tools/_helpers.py
CHANGED
|
@@ -147,6 +147,40 @@ _HEREDOC_MARKERS = {
|
|
|
147
147
|
_GHOST_NAMES = _PYTHON_TYPE_NAMES | _HEREDOC_MARKERS
|
|
148
148
|
|
|
149
149
|
|
|
150
|
+
EXTERNAL_TAG = "[c3-read:external]"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def external_banner(path: str) -> str:
|
|
154
|
+
"""The one line every outside-the-root response opens with.
|
|
155
|
+
|
|
156
|
+
One definition so c3_read and c3_compress cannot drift apart on it.
|
|
157
|
+
"""
|
|
158
|
+
return (f"{EXTERNAL_TAG} {path} — outside the project root; served but "
|
|
159
|
+
f"not indexed, and absent from this project's search, map cache "
|
|
160
|
+
f"and related facts.\n")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def project_key(full, project_path) -> tuple[str, bool]:
|
|
164
|
+
"""``(key, external)`` for an already-resolved-or-not path.
|
|
165
|
+
|
|
166
|
+
Inside the root the key is the project-relative posix path — how
|
|
167
|
+
file_memory, the edit ledger and related facts all address a file.
|
|
168
|
+
Outside it there is no such key, so the absolute posix path stands in and
|
|
169
|
+
``external`` tells the caller to serve from a transient record rather than
|
|
170
|
+
the index (docs/file-map.md § Outside the root).
|
|
171
|
+
|
|
172
|
+
This used to be a bare ``relative_to`` whose ValueError escaped as a tool
|
|
173
|
+
crash. It is not a containment check and must not become one: Access Guard
|
|
174
|
+
already rules on the absolute canonical path, and it runs before this.
|
|
175
|
+
"""
|
|
176
|
+
resolved = Path(full).resolve()
|
|
177
|
+
try:
|
|
178
|
+
rel = resolved.relative_to(Path(project_path).resolve())
|
|
179
|
+
return str(rel).replace("\\", "/"), False
|
|
180
|
+
except ValueError:
|
|
181
|
+
return resolved.as_posix(), True
|
|
182
|
+
|
|
183
|
+
|
|
150
184
|
def validate_file_path(file_path: str) -> str | None:
|
|
151
185
|
"""Return an error message if file_path looks like a ghost-file path, else None.
|
|
152
186
|
|
cli/tools/ci.py
CHANGED
|
@@ -385,6 +385,15 @@ def handle_ci(action: str, job: str, run_id: str, allow_foreign: bool,
|
|
|
385
385
|
f" runs Linux jobs in a container "
|
|
386
386
|
f"({ci_act.DEFAULT_IMAGE}), real `uses:` actions included",
|
|
387
387
|
]
|
|
388
|
+
if state.get("warning"):
|
|
389
|
+
lines.append(f" WARNING: {state['warning']}")
|
|
390
|
+
# An image that is present locally is still not proof act can pull
|
|
391
|
+
# it: act sends the stored registry credential where the CLI sends
|
|
392
|
+
# none, so an expired token fails a public pull (issue #173). Say
|
|
393
|
+
# so rather than let `available` read as a guarantee.
|
|
394
|
+
lines.append(" a runner failure before any step runs is "
|
|
395
|
+
"reported as a container-setup failure, not as 0 "
|
|
396
|
+
"parsed failures")
|
|
388
397
|
else:
|
|
389
398
|
lines += [" act UNAVAILABLE", f" {state['reason']}"]
|
|
390
399
|
lines += [
|