code-context-control 2.28.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/__init__.py +1 -0
- cli/_hook_utils.py +99 -0
- cli/c3.py +6152 -0
- cli/commands/__init__.py +1 -0
- cli/commands/common.py +312 -0
- cli/commands/parser.py +286 -0
- cli/docs.html +3178 -0
- cli/edits.html +878 -0
- cli/hook_auto_snapshot.py +142 -0
- cli/hook_c3_signal.py +61 -0
- cli/hook_c3read.py +116 -0
- cli/hook_edit_ledger.py +213 -0
- cli/hook_edit_unlock.py +170 -0
- cli/hook_filter.py +130 -0
- cli/hook_ghost_files.py +238 -0
- cli/hook_pretool_enforce.py +334 -0
- cli/hook_read.py +200 -0
- cli/hook_session_stats.py +62 -0
- cli/hook_terse_advisor.py +190 -0
- cli/hub.html +3764 -0
- cli/hub_server.py +1619 -0
- cli/mcp_proxy.py +428 -0
- cli/mcp_server.py +660 -0
- cli/server.py +2985 -0
- cli/tools/__init__.py +4 -0
- cli/tools/_helpers.py +65 -0
- cli/tools/agent.py +1165 -0
- cli/tools/compress.py +215 -0
- cli/tools/delegate.py +1184 -0
- cli/tools/edit.py +313 -0
- cli/tools/edits.py +118 -0
- cli/tools/filter.py +285 -0
- cli/tools/impact.py +163 -0
- cli/tools/memory.py +469 -0
- cli/tools/read.py +224 -0
- cli/tools/search.py +337 -0
- cli/tools/session.py +95 -0
- cli/tools/shell.py +193 -0
- cli/tools/status.py +306 -0
- cli/tools/validate.py +310 -0
- cli/ui/api.js +36 -0
- cli/ui/app.js +207 -0
- cli/ui/components/chat.js +758 -0
- cli/ui/components/dashboard.js +689 -0
- cli/ui/components/edits.js +220 -0
- cli/ui/components/instructions.js +481 -0
- cli/ui/components/memory.js +626 -0
- cli/ui/components/sessions.js +606 -0
- cli/ui/components/settings.js +1404 -0
- cli/ui/components/sidebar.js +156 -0
- cli/ui/icons.js +51 -0
- cli/ui/shared.js +119 -0
- cli/ui/theme.js +22 -0
- cli/ui.html +168 -0
- cli/ui_legacy.html +6797 -0
- cli/ui_nano.html +503 -0
- code_context_control-2.28.0.dist-info/METADATA +248 -0
- code_context_control-2.28.0.dist-info/RECORD +150 -0
- code_context_control-2.28.0.dist-info/WHEEL +5 -0
- code_context_control-2.28.0.dist-info/entry_points.txt +4 -0
- code_context_control-2.28.0.dist-info/licenses/LICENSE +201 -0
- code_context_control-2.28.0.dist-info/top_level.txt +5 -0
- core/__init__.py +75 -0
- core/config.py +269 -0
- core/ide.py +188 -0
- oracle/__init__.py +1 -0
- oracle/config.py +75 -0
- oracle/oracle.html +3900 -0
- oracle/oracle_server.py +663 -0
- oracle/services/__init__.py +1 -0
- oracle/services/c3_bridge.py +210 -0
- oracle/services/chat_engine.py +1103 -0
- oracle/services/chat_store.py +155 -0
- oracle/services/cross_memory.py +154 -0
- oracle/services/federated_graph.py +463 -0
- oracle/services/health_checker.py +117 -0
- oracle/services/insight_engine.py +307 -0
- oracle/services/memory_reader.py +106 -0
- oracle/services/memory_writer.py +182 -0
- oracle/services/ollama_bridge.py +332 -0
- oracle/services/project_scanner.py +87 -0
- oracle/services/review_agent.py +206 -0
- services/__init__.py +1 -0
- services/activity_log.py +93 -0
- services/agent_base.py +124 -0
- services/agents.py +1529 -0
- services/auto_memory.py +407 -0
- services/bench/__init__.py +6 -0
- services/bench/external/__init__.py +29 -0
- services/bench/external/aider_polyglot.py +405 -0
- services/bench/external/swe_bench.py +485 -0
- services/benchmark_dashboard.py +596 -0
- services/claude_md.py +785 -0
- services/compressor.py +592 -0
- services/context_snapshot.py +356 -0
- services/conversation_store.py +870 -0
- services/doc_index.py +537 -0
- services/e2e_benchmark.py +2884 -0
- services/e2e_evaluator.py +396 -0
- services/e2e_tasks.py +743 -0
- services/edit_ledger.py +459 -0
- services/embedding_index.py +341 -0
- services/error_reporting.py +123 -0
- services/file_memory.py +734 -0
- services/hub_service.py +585 -0
- services/indexer.py +712 -0
- services/memory.py +318 -0
- services/memory_consolidator.py +538 -0
- services/memory_graph.py +382 -0
- services/memory_grounder.py +304 -0
- services/memory_scorer.py +246 -0
- services/metrics.py +86 -0
- services/notifications.py +209 -0
- services/ollama_client.py +201 -0
- services/output_filter.py +488 -0
- services/parser.py +1238 -0
- services/project_manager.py +579 -0
- services/protocol.py +306 -0
- services/proxy_state.py +152 -0
- services/retrieval_broker.py +129 -0
- services/router.py +414 -0
- services/runtime.py +326 -0
- services/session_benchmark.py +1945 -0
- services/session_manager.py +1026 -0
- services/session_preloader.py +251 -0
- services/text_index.py +90 -0
- services/tool_classifier.py +176 -0
- services/transcript_index.py +340 -0
- services/validation_cache.py +155 -0
- services/vector_store.py +299 -0
- services/version_tracker.py +271 -0
- services/watcher.py +192 -0
- tui/__init__.py +0 -0
- tui/backend.py +59 -0
- tui/main.py +145 -0
- tui/screens/__init__.py +1 -0
- tui/screens/benchmark_view.py +109 -0
- tui/screens/claudemd_view.py +46 -0
- tui/screens/compress_view.py +52 -0
- tui/screens/index_view.py +74 -0
- tui/screens/init_view.py +82 -0
- tui/screens/mcp_view.py +73 -0
- tui/screens/optimize_view.py +41 -0
- tui/screens/pipe_view.py +46 -0
- tui/screens/projects_view.py +355 -0
- tui/screens/search_view.py +55 -0
- tui/screens/session_view.py +143 -0
- tui/screens/stats.py +158 -0
- tui/screens/ui_view.py +54 -0
- tui/theme.tcss +335 -0
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
"""Global project registry and session manager for C3."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import socket
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from services.activity_log import ActivityLog
|
|
15
|
+
|
|
16
|
+
_GLOBAL_C3_DIR = Path.home() / ".c3"
|
|
17
|
+
_PROJECTS_FILE = _GLOBAL_C3_DIR / "projects.json"
|
|
18
|
+
_REGISTRY_FILE = _GLOBAL_C3_DIR / "registry.json"
|
|
19
|
+
_SESSION_ACTIVITY_GRACE_SECONDS = 20 * 60
|
|
20
|
+
_REGISTRY_STARTUP_GRACE = 30 # seconds: keep registry entry while server is still starting
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _pythonw() -> str:
|
|
24
|
+
"""Return pythonw.exe when available for hidden-background launches on Windows."""
|
|
25
|
+
candidate = Path(sys.executable).parent / "pythonw.exe"
|
|
26
|
+
return str(candidate) if candidate.exists() else sys.executable
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _coerce_epoch(value) -> float | None:
|
|
30
|
+
"""Convert a value to epoch-seconds (float).
|
|
31
|
+
|
|
32
|
+
Accepts epoch numbers, ISO-8601 strings, or None. Registry writes from
|
|
33
|
+
_register_session() store a float; older writers and test fixtures may
|
|
34
|
+
have stored an ISO string. Return None if we can't interpret it.
|
|
35
|
+
"""
|
|
36
|
+
if value is None:
|
|
37
|
+
return None
|
|
38
|
+
if isinstance(value, (int, float)):
|
|
39
|
+
return float(value)
|
|
40
|
+
if isinstance(value, str):
|
|
41
|
+
s = value.strip()
|
|
42
|
+
if not s:
|
|
43
|
+
return None
|
|
44
|
+
try:
|
|
45
|
+
return float(s) # numeric string
|
|
46
|
+
except ValueError:
|
|
47
|
+
pass
|
|
48
|
+
try:
|
|
49
|
+
# Handle trailing Z and offset-aware ISO
|
|
50
|
+
if s.endswith("Z"):
|
|
51
|
+
s = s[:-1] + "+00:00"
|
|
52
|
+
dt = datetime.fromisoformat(s)
|
|
53
|
+
if dt.tzinfo is None:
|
|
54
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
55
|
+
return dt.timestamp()
|
|
56
|
+
except ValueError:
|
|
57
|
+
return None
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ProjectManager:
|
|
62
|
+
"""Manages the global C3 project registry stored in ~/.c3/projects.json."""
|
|
63
|
+
|
|
64
|
+
def __init__(self):
|
|
65
|
+
_GLOBAL_C3_DIR.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
|
|
67
|
+
def _read_projects(self) -> list:
|
|
68
|
+
try:
|
|
69
|
+
if _PROJECTS_FILE.exists():
|
|
70
|
+
with open(_PROJECTS_FILE, encoding="utf-8") as f:
|
|
71
|
+
return json.load(f).get("projects", [])
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
return []
|
|
75
|
+
|
|
76
|
+
def _write_projects(self, projects: list):
|
|
77
|
+
_GLOBAL_C3_DIR.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
with open(_PROJECTS_FILE, "w", encoding="utf-8") as f:
|
|
79
|
+
json.dump({"projects": projects}, f, indent=2)
|
|
80
|
+
|
|
81
|
+
def _read_registry(self) -> list:
|
|
82
|
+
try:
|
|
83
|
+
if _REGISTRY_FILE.exists():
|
|
84
|
+
with open(_REGISTRY_FILE, encoding="utf-8") as f:
|
|
85
|
+
return json.load(f)
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
return []
|
|
89
|
+
|
|
90
|
+
def _port_alive(self, port: int) -> bool:
|
|
91
|
+
try:
|
|
92
|
+
with socket.create_connection(("127.0.0.1", port), timeout=0.3):
|
|
93
|
+
return True
|
|
94
|
+
except Exception:
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
def _verify_c3_session(self, port: int) -> bool:
|
|
98
|
+
"""Verify that a given port is actually a C3 session UI."""
|
|
99
|
+
try:
|
|
100
|
+
url = f"http://127.0.0.1:{port}/api/health"
|
|
101
|
+
with urllib.request.urlopen(url, timeout=0.8) as r:
|
|
102
|
+
data = json.loads(r.read().decode("utf-8"))
|
|
103
|
+
is_c3 = data.get("service") in {"c3-ui", "c3-hub"}
|
|
104
|
+
# Also accept if 'sources' key is present (for older versions or custom UIs)
|
|
105
|
+
return is_c3 or "sources" in data
|
|
106
|
+
except Exception:
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
def _parse_timestamp(self, value: str | None) -> datetime | None:
|
|
110
|
+
if not value:
|
|
111
|
+
return None
|
|
112
|
+
try:
|
|
113
|
+
normalized = str(value).strip().replace("Z", "+00:00")
|
|
114
|
+
dt = datetime.fromisoformat(normalized)
|
|
115
|
+
if dt.tzinfo is None:
|
|
116
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
117
|
+
return dt
|
|
118
|
+
except Exception:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
def _read_project_config(self, path: str) -> dict:
|
|
122
|
+
config_path = Path(path) / ".c3" / "config.json"
|
|
123
|
+
if config_path.exists():
|
|
124
|
+
try:
|
|
125
|
+
with open(config_path, encoding="utf-8") as f:
|
|
126
|
+
return json.load(f)
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
return {}
|
|
130
|
+
|
|
131
|
+
def _get_budget_info(self, path: str) -> dict | None:
|
|
132
|
+
"""Read the context budget from the project's .c3 directory."""
|
|
133
|
+
budget_path = Path(path) / ".c3" / "context_budget.json"
|
|
134
|
+
if budget_path.exists():
|
|
135
|
+
try:
|
|
136
|
+
with open(budget_path, encoding="utf-8") as f:
|
|
137
|
+
return json.load(f)
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
def _get_live_session_info(self, path: str) -> dict | None:
|
|
143
|
+
"""Return live session info inferred from the project's activity log."""
|
|
144
|
+
try:
|
|
145
|
+
activity = ActivityLog(path)
|
|
146
|
+
|
|
147
|
+
# Use the public API — get_recent's scan_factor=100 for typed queries
|
|
148
|
+
# handles sparse rare events (session_start can sit far behind many
|
|
149
|
+
# tool_call entries). Going through the API also lets tests stub it.
|
|
150
|
+
starts = activity.get_recent(limit=1, event_type="session_start")
|
|
151
|
+
saves = activity.get_recent(limit=1, event_type="session_save")
|
|
152
|
+
last_start = starts[0] if starts else None
|
|
153
|
+
last_save = saves[0] if saves else None
|
|
154
|
+
|
|
155
|
+
if last_start is None:
|
|
156
|
+
return None
|
|
157
|
+
session_id = last_start.get("session_id")
|
|
158
|
+
started = last_start.get("timestamp")
|
|
159
|
+
if not session_id or not started:
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
# If the most recent save belongs to this session, it has ended.
|
|
163
|
+
if last_save and last_save.get("session_id") == session_id:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
recent = activity.get_recent(limit=1, since=started)
|
|
167
|
+
last_activity = recent[0].get("timestamp") if recent else started
|
|
168
|
+
started_dt = self._parse_timestamp(started)
|
|
169
|
+
last_activity_dt = self._parse_timestamp(last_activity)
|
|
170
|
+
if not started_dt or not last_activity_dt:
|
|
171
|
+
return None
|
|
172
|
+
now = datetime.now(timezone.utc)
|
|
173
|
+
idle_seconds = max(0, int((now - last_activity_dt).total_seconds()))
|
|
174
|
+
if idle_seconds > _SESSION_ACTIVITY_GRACE_SECONDS:
|
|
175
|
+
return None
|
|
176
|
+
duration_seconds = max(0, int((now - started_dt).total_seconds()))
|
|
177
|
+
return {
|
|
178
|
+
"session_id": session_id,
|
|
179
|
+
"started_at": started,
|
|
180
|
+
"last_activity": last_activity,
|
|
181
|
+
"description": last_start.get("description", ""),
|
|
182
|
+
"duration_seconds": duration_seconds,
|
|
183
|
+
"idle_seconds": idle_seconds,
|
|
184
|
+
}
|
|
185
|
+
except Exception:
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
def _get_last_session_timestamp(
|
|
189
|
+
self, path: str, stored_value: str | None = None, live_session: dict | None = None
|
|
190
|
+
) -> str | None:
|
|
191
|
+
"""Return the most recent known session timestamp for a project."""
|
|
192
|
+
latest_dt = None
|
|
193
|
+
latest_raw = None
|
|
194
|
+
|
|
195
|
+
def remember(value: str | None):
|
|
196
|
+
nonlocal latest_dt, latest_raw
|
|
197
|
+
dt = self._parse_timestamp(value)
|
|
198
|
+
if dt and (latest_dt is None or dt > latest_dt):
|
|
199
|
+
latest_dt = dt
|
|
200
|
+
latest_raw = value
|
|
201
|
+
|
|
202
|
+
remember(stored_value)
|
|
203
|
+
if live_session:
|
|
204
|
+
remember(live_session.get("last_activity"))
|
|
205
|
+
remember(live_session.get("started_at"))
|
|
206
|
+
try:
|
|
207
|
+
activity = ActivityLog(path)
|
|
208
|
+
remember((activity.get_recent(limit=1, event_type="session_save") or [{}])[0].get("timestamp"))
|
|
209
|
+
remember((activity.get_recent(limit=1, event_type="session_start") or [{}])[0].get("timestamp"))
|
|
210
|
+
except Exception:
|
|
211
|
+
pass
|
|
212
|
+
return latest_raw
|
|
213
|
+
|
|
214
|
+
def add_project(self, path: str, name: str = None) -> dict:
|
|
215
|
+
path = str(Path(path).resolve())
|
|
216
|
+
projects = self._read_projects()
|
|
217
|
+
for p in projects:
|
|
218
|
+
if p["path"] == path:
|
|
219
|
+
return p
|
|
220
|
+
cfg = self._read_project_config(path)
|
|
221
|
+
entry = {
|
|
222
|
+
"name": name or Path(path).name,
|
|
223
|
+
"path": path,
|
|
224
|
+
"ide": cfg.get("ide", "unknown"),
|
|
225
|
+
"added_at": datetime.utcnow().isoformat() + "Z",
|
|
226
|
+
"last_session": None,
|
|
227
|
+
"tags": [],
|
|
228
|
+
"notes": "",
|
|
229
|
+
}
|
|
230
|
+
projects.append(entry)
|
|
231
|
+
self._write_projects(projects)
|
|
232
|
+
return entry
|
|
233
|
+
|
|
234
|
+
def remove_project(self, path: str) -> bool:
|
|
235
|
+
path = str(Path(path).resolve())
|
|
236
|
+
projects = self._read_projects()
|
|
237
|
+
filtered = [p for p in projects if p["path"] != path]
|
|
238
|
+
if len(filtered) < len(projects):
|
|
239
|
+
self._write_projects(filtered)
|
|
240
|
+
return True
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
def sweep_registry(self):
|
|
244
|
+
"""Remove stale registry entries (dead ports). Call on hub startup."""
|
|
245
|
+
registry = self._read_registry()
|
|
246
|
+
if not registry:
|
|
247
|
+
return
|
|
248
|
+
valid = [e for e in registry if e.get("port") and self._port_alive(e["port"])]
|
|
249
|
+
if len(valid) != len(registry):
|
|
250
|
+
try:
|
|
251
|
+
with open(_REGISTRY_FILE, "w", encoding="utf-8") as f:
|
|
252
|
+
json.dump(valid, f, indent=2)
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
|
|
256
|
+
def list_projects(self) -> list:
|
|
257
|
+
projects = self._read_projects()
|
|
258
|
+
registry = self._read_registry()
|
|
259
|
+
ui_active_by_path: dict = {}
|
|
260
|
+
valid_registry = []
|
|
261
|
+
registry_changed = False
|
|
262
|
+
|
|
263
|
+
for entry in registry:
|
|
264
|
+
port = entry.get("port")
|
|
265
|
+
proj_path = entry.get("project_path", "")
|
|
266
|
+
if port and self._port_alive(port):
|
|
267
|
+
if self._verify_c3_session(port):
|
|
268
|
+
ui_active_by_path[proj_path] = entry
|
|
269
|
+
valid_registry.append(entry)
|
|
270
|
+
else:
|
|
271
|
+
registry_changed = True
|
|
272
|
+
else:
|
|
273
|
+
# Grace period: keep entries registered recently — the server may still be
|
|
274
|
+
# starting up between _register_session() and app.run() binding the port.
|
|
275
|
+
# started_at is written as epoch float by _register_session() but older
|
|
276
|
+
# entries (or test fixtures) may store an ISO string; coerce defensively.
|
|
277
|
+
started_epoch = _coerce_epoch(entry.get("started_at"))
|
|
278
|
+
if started_epoch is not None and time.time() - started_epoch < _REGISTRY_STARTUP_GRACE:
|
|
279
|
+
valid_registry.append(entry) # keep it; don't mark ui_active yet
|
|
280
|
+
else:
|
|
281
|
+
registry_changed = True
|
|
282
|
+
|
|
283
|
+
if registry_changed:
|
|
284
|
+
try:
|
|
285
|
+
with open(_REGISTRY_FILE, "w", encoding="utf-8") as f:
|
|
286
|
+
json.dump(valid_registry, f, indent=2)
|
|
287
|
+
except Exception:
|
|
288
|
+
pass
|
|
289
|
+
|
|
290
|
+
result = []
|
|
291
|
+
for p in projects:
|
|
292
|
+
enriched = dict(p)
|
|
293
|
+
path_accessible = Path(p["path"]).is_dir()
|
|
294
|
+
enriched["accessible"] = path_accessible
|
|
295
|
+
ui_active = ui_active_by_path.get(p["path"])
|
|
296
|
+
live_session = self._get_live_session_info(p["path"]) if path_accessible else None
|
|
297
|
+
enriched["ui_active"] = ui_active is not None
|
|
298
|
+
enriched["session_active"] = live_session is not None
|
|
299
|
+
# A project is active if either the web UI is live or the activity log shows a
|
|
300
|
+
# currently running C3 session. The hub card should not fall back to "idle"
|
|
301
|
+
# just because the session has no live UI port.
|
|
302
|
+
enriched["active"] = enriched["ui_active"] or enriched["session_active"]
|
|
303
|
+
enriched["port"] = ui_active["port"] if ui_active else None
|
|
304
|
+
enriched["budget"] = self._get_budget_info(p["path"]) if enriched["active"] else None
|
|
305
|
+
enriched["started_at"] = (
|
|
306
|
+
ui_active["started_at"]
|
|
307
|
+
if ui_active
|
|
308
|
+
else (live_session["started_at"] if live_session else None)
|
|
309
|
+
)
|
|
310
|
+
enriched["live_session_id"] = (
|
|
311
|
+
live_session["session_id"] if live_session else None
|
|
312
|
+
)
|
|
313
|
+
enriched["last_activity"] = (
|
|
314
|
+
live_session["last_activity"] if live_session else None
|
|
315
|
+
)
|
|
316
|
+
enriched["session_description"] = (
|
|
317
|
+
live_session["description"] if live_session else ""
|
|
318
|
+
)
|
|
319
|
+
enriched["last_session"] = (
|
|
320
|
+
self._get_last_session_timestamp(p["path"], p.get("last_session"), live_session)
|
|
321
|
+
if path_accessible else p.get("last_session")
|
|
322
|
+
)
|
|
323
|
+
cfg = self._read_project_config(p["path"]) if path_accessible else {}
|
|
324
|
+
if cfg:
|
|
325
|
+
enriched["ide"] = cfg.get("ide", p.get("ide", "unknown"))
|
|
326
|
+
enriched["c3_version"] = cfg.get("version")
|
|
327
|
+
try:
|
|
328
|
+
from core.ide import PROFILES
|
|
329
|
+
prof = PROFILES.get(enriched["ide"])
|
|
330
|
+
if prof:
|
|
331
|
+
if prof.config_path_global:
|
|
332
|
+
mf = Path.home() / prof.config_path
|
|
333
|
+
else:
|
|
334
|
+
mf = Path(p["path"]) / prof.config_path
|
|
335
|
+
enriched["mcp_installed"] = mf.exists()
|
|
336
|
+
mcp_cfg = cfg.get("mcp", {})
|
|
337
|
+
enriched["mcp_mode"] = mcp_cfg.get("mode") if isinstance(mcp_cfg, dict) else None
|
|
338
|
+
except Exception:
|
|
339
|
+
pass
|
|
340
|
+
result.append(enriched)
|
|
341
|
+
return result
|
|
342
|
+
|
|
343
|
+
def get_active_sessions(self) -> list:
|
|
344
|
+
active = []
|
|
345
|
+
for e in self._read_registry():
|
|
346
|
+
port = e.get("port", 0)
|
|
347
|
+
if port and self._port_alive(port) and self._verify_c3_session(port):
|
|
348
|
+
active.append(e)
|
|
349
|
+
|
|
350
|
+
for project in self.list_projects():
|
|
351
|
+
if project.get("session_active") and not project.get("ui_active"):
|
|
352
|
+
active.append(
|
|
353
|
+
{
|
|
354
|
+
"project_path": project["path"],
|
|
355
|
+
"project_name": project.get("name", Path(project["path"]).name),
|
|
356
|
+
"port": None,
|
|
357
|
+
"started_at": project.get("started_at"),
|
|
358
|
+
"live_session_id": project.get("live_session_id"),
|
|
359
|
+
}
|
|
360
|
+
)
|
|
361
|
+
return active
|
|
362
|
+
|
|
363
|
+
def launch_session(self, path: str) -> dict:
|
|
364
|
+
"""Launch a C3 UI session for a project.
|
|
365
|
+
|
|
366
|
+
Returns dict with 'launched' bool and optional 'error' string.
|
|
367
|
+
"""
|
|
368
|
+
if not Path(path).is_dir():
|
|
369
|
+
return {"launched": False, "error": f"Project path not accessible: {path}"}
|
|
370
|
+
c3_py = Path(__file__).parent.parent / "cli" / "c3.py"
|
|
371
|
+
env = os.environ.copy()
|
|
372
|
+
env["PYTHONPATH"] = str(Path(__file__).parent.parent)
|
|
373
|
+
kwargs = {}
|
|
374
|
+
cmd = [sys.executable, str(c3_py), "ui", path, "--no-browser", "--silent"]
|
|
375
|
+
log_dir = Path(path) / ".c3"
|
|
376
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
377
|
+
log_file = log_dir / "ui.log"
|
|
378
|
+
if sys.platform == "win32":
|
|
379
|
+
cmd[0] = _pythonw()
|
|
380
|
+
kwargs["creationflags"] = (
|
|
381
|
+
subprocess.DETACHED_PROCESS
|
|
382
|
+
| subprocess.CREATE_NEW_PROCESS_GROUP
|
|
383
|
+
| subprocess.CREATE_NO_WINDOW
|
|
384
|
+
)
|
|
385
|
+
kwargs["close_fds"] = True
|
|
386
|
+
kwargs["stdout"] = open(log_file, "a", encoding="utf-8")
|
|
387
|
+
kwargs["stderr"] = subprocess.STDOUT
|
|
388
|
+
kwargs["stdin"] = subprocess.DEVNULL
|
|
389
|
+
else:
|
|
390
|
+
kwargs["start_new_session"] = True
|
|
391
|
+
kwargs["stdout"] = open(log_file, "a", encoding="utf-8")
|
|
392
|
+
kwargs["stderr"] = subprocess.STDOUT
|
|
393
|
+
try:
|
|
394
|
+
subprocess.Popen(cmd, cwd=path, env=env, **kwargs)
|
|
395
|
+
return {"launched": True}
|
|
396
|
+
except Exception as e:
|
|
397
|
+
return {"launched": False, "error": str(e)}
|
|
398
|
+
|
|
399
|
+
def stop_session(self, port: int) -> bool:
|
|
400
|
+
try:
|
|
401
|
+
kwargs = {}
|
|
402
|
+
if sys.platform == "win32":
|
|
403
|
+
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
|
404
|
+
if sys.platform == "win32":
|
|
405
|
+
result = subprocess.run(f"netstat -ano | findstr :{port}", shell=True, capture_output=True, text=True, **kwargs)
|
|
406
|
+
pids = set()
|
|
407
|
+
for line in result.stdout.strip().splitlines():
|
|
408
|
+
if f":{port}" in line and "LISTENING" in line:
|
|
409
|
+
parts = line.strip().split()
|
|
410
|
+
if len(parts) >= 5:
|
|
411
|
+
pids.add(parts[-1])
|
|
412
|
+
for pid in pids:
|
|
413
|
+
subprocess.run(f"taskkill /PID {pid} /F", shell=True, capture_output=True, **kwargs)
|
|
414
|
+
else:
|
|
415
|
+
subprocess.run(f"lsof -ti:{port} | xargs kill -9", shell=True, capture_output=True)
|
|
416
|
+
return True
|
|
417
|
+
except Exception:
|
|
418
|
+
return False
|
|
419
|
+
|
|
420
|
+
def end_mcp_session(self, path: str) -> bool:
|
|
421
|
+
"""End an MCP-only session by writing a session_save event to the activity log."""
|
|
422
|
+
live = self._get_live_session_info(path)
|
|
423
|
+
if not live:
|
|
424
|
+
return False
|
|
425
|
+
activity = ActivityLog(path)
|
|
426
|
+
activity.log("session_save", {"session_id": live["session_id"], "source": "hub"})
|
|
427
|
+
return True
|
|
428
|
+
|
|
429
|
+
def update_last_session(self, path: str):
|
|
430
|
+
path = str(Path(path).resolve())
|
|
431
|
+
projects = self._read_projects()
|
|
432
|
+
for p in projects:
|
|
433
|
+
if p["path"] == path:
|
|
434
|
+
p["last_session"] = datetime.utcnow().isoformat() + "Z"
|
|
435
|
+
break
|
|
436
|
+
self._write_projects(projects)
|
|
437
|
+
|
|
438
|
+
def get_project_details(self, path: str) -> dict:
|
|
439
|
+
path = str(Path(path).resolve())
|
|
440
|
+
cfg = self._read_project_config(path)
|
|
441
|
+
ide = cfg.get("ide", "unknown")
|
|
442
|
+
mcp_cfg = cfg.get("mcp", {})
|
|
443
|
+
mcp_mode = mcp_cfg.get("mode", "unknown") if isinstance(mcp_cfg, dict) else "unknown"
|
|
444
|
+
mcp_installed = False
|
|
445
|
+
mcp_config_path = None
|
|
446
|
+
mcp_servers: list = []
|
|
447
|
+
try:
|
|
448
|
+
from core.ide import PROFILES
|
|
449
|
+
profile = PROFILES.get(ide)
|
|
450
|
+
if profile:
|
|
451
|
+
if profile.config_path_global:
|
|
452
|
+
mcp_file = Path.home() / profile.config_path
|
|
453
|
+
else:
|
|
454
|
+
mcp_file = Path(path) / profile.config_path
|
|
455
|
+
if mcp_file.exists():
|
|
456
|
+
mcp_installed = True
|
|
457
|
+
mcp_config_path = str(mcp_file)
|
|
458
|
+
if profile.config_format == "json":
|
|
459
|
+
with open(mcp_file, encoding="utf-8") as f:
|
|
460
|
+
mcp_data = json.load(f)
|
|
461
|
+
servers = mcp_data.get(profile.config_key, {})
|
|
462
|
+
if isinstance(servers, dict):
|
|
463
|
+
for name, conf in servers.items():
|
|
464
|
+
mcp_servers.append({
|
|
465
|
+
"name": name,
|
|
466
|
+
"command": conf.get("command", ""),
|
|
467
|
+
"args": conf.get("args", []),
|
|
468
|
+
"type": conf.get("type", ""),
|
|
469
|
+
"env_keys": list((conf.get("env") or {}).keys()),
|
|
470
|
+
})
|
|
471
|
+
elif profile.config_format == "toml":
|
|
472
|
+
try:
|
|
473
|
+
import tomllib
|
|
474
|
+
except ImportError:
|
|
475
|
+
try:
|
|
476
|
+
import tomli as tomllib
|
|
477
|
+
except ImportError:
|
|
478
|
+
tomllib = None
|
|
479
|
+
if tomllib:
|
|
480
|
+
with open(mcp_file, "rb") as f:
|
|
481
|
+
toml_data = tomllib.load(f)
|
|
482
|
+
for name, conf in toml_data.get("mcp_servers", {}).items():
|
|
483
|
+
mcp_servers.append({
|
|
484
|
+
"name": name,
|
|
485
|
+
"command": conf.get("command", ""),
|
|
486
|
+
"args": conf.get("args", []),
|
|
487
|
+
"type": "",
|
|
488
|
+
"env_keys": [],
|
|
489
|
+
})
|
|
490
|
+
except Exception:
|
|
491
|
+
pass
|
|
492
|
+
return {
|
|
493
|
+
"path": path,
|
|
494
|
+
"c3_version": cfg.get("version"),
|
|
495
|
+
"ide": ide,
|
|
496
|
+
"mcp_mode": mcp_mode,
|
|
497
|
+
"mcp_installed": mcp_installed,
|
|
498
|
+
"mcp_config_path": mcp_config_path,
|
|
499
|
+
"mcp_servers": mcp_servers,
|
|
500
|
+
"initialized": bool(cfg),
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
def update_project(self, path: str, **fields) -> bool:
|
|
504
|
+
"""Update editable project fields: name, tags, notes."""
|
|
505
|
+
path = str(Path(path).resolve())
|
|
506
|
+
projects = self._read_projects()
|
|
507
|
+
allowed = {"name", "tags", "notes", "autostart_ui"}
|
|
508
|
+
for p in projects:
|
|
509
|
+
if p["path"] == path:
|
|
510
|
+
for k, v in fields.items():
|
|
511
|
+
if k in allowed:
|
|
512
|
+
p[k] = v
|
|
513
|
+
self._write_projects(projects)
|
|
514
|
+
return True
|
|
515
|
+
return False
|
|
516
|
+
|
|
517
|
+
def get_autostart_projects(self) -> list[str]:
|
|
518
|
+
"""Return paths of projects with autostart_ui=True that are accessible."""
|
|
519
|
+
return [
|
|
520
|
+
p["path"] for p in self._read_projects()
|
|
521
|
+
if p.get("autostart_ui") and Path(p["path"]).is_dir()
|
|
522
|
+
]
|
|
523
|
+
|
|
524
|
+
def rename_project(self, path: str, new_name: str) -> bool:
|
|
525
|
+
path = str(Path(path).resolve())
|
|
526
|
+
projects = self._read_projects()
|
|
527
|
+
for p in projects:
|
|
528
|
+
if p["path"] == path:
|
|
529
|
+
p["name"] = new_name
|
|
530
|
+
self._write_projects(projects)
|
|
531
|
+
return True
|
|
532
|
+
return False
|
|
533
|
+
|
|
534
|
+
def transfer_project(self, old_path: str, new_path: str) -> dict:
|
|
535
|
+
"""Transfer a project registration from old_path to new_path.
|
|
536
|
+
|
|
537
|
+
Validates the new location exists with a .c3/ directory and updates
|
|
538
|
+
the registry to point there. Does not move files — the user handles
|
|
539
|
+
the actual copy/move.
|
|
540
|
+
"""
|
|
541
|
+
old_path = str(Path(old_path).resolve())
|
|
542
|
+
new_path = str(Path(new_path).resolve())
|
|
543
|
+
|
|
544
|
+
if old_path == new_path:
|
|
545
|
+
return {"transferred": False, "error": "Paths are identical"}
|
|
546
|
+
|
|
547
|
+
if not Path(new_path).is_dir():
|
|
548
|
+
return {"transferred": False, "error": "New path does not exist"}
|
|
549
|
+
|
|
550
|
+
if not (Path(new_path) / ".c3").is_dir():
|
|
551
|
+
return {"transferred": False, "error": "New path has no .c3 directory"}
|
|
552
|
+
|
|
553
|
+
projects = self._read_projects()
|
|
554
|
+
|
|
555
|
+
entry = None
|
|
556
|
+
for p in projects:
|
|
557
|
+
if p["path"] == old_path:
|
|
558
|
+
entry = p
|
|
559
|
+
break
|
|
560
|
+
if entry is None:
|
|
561
|
+
return {"transferred": False, "error": "Project not registered"}
|
|
562
|
+
|
|
563
|
+
for p in projects:
|
|
564
|
+
if p["path"] == new_path:
|
|
565
|
+
return {"transferred": False, "error": "New path already registered"}
|
|
566
|
+
|
|
567
|
+
# Update entry
|
|
568
|
+
old_dir_name = Path(old_path).name
|
|
569
|
+
entry["path"] = new_path
|
|
570
|
+
if entry.get("name") == old_dir_name:
|
|
571
|
+
entry["name"] = Path(new_path).name
|
|
572
|
+
|
|
573
|
+
# Re-read config from new location
|
|
574
|
+
cfg = self._read_project_config(new_path)
|
|
575
|
+
if cfg.get("ide"):
|
|
576
|
+
entry["ide"] = cfg["ide"]
|
|
577
|
+
|
|
578
|
+
self._write_projects(projects)
|
|
579
|
+
return {"transferred": True, "old_path": old_path, "new_path": new_path}
|