code-context-control 2.118.2__py3-none-any.whl → 2.119.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 +9 -19
- cli/c3.py +48 -124
- cli/commands/parser.py +6 -0
- cli/hook_codex_lifecycle.py +60 -0
- cli/hook_dispatch.py +60 -5
- cli/hook_edit_ledger.py +3 -3
- cli/hook_session_stats.py +26 -0
- cli/mcp_proxy.py +7 -6
- cli/mcp_server.py +30 -45
- cli/tools/delegate.py +104 -64
- cli/tools/status.py +16 -6
- {code_context_control-2.118.2.dist-info → code_context_control-2.119.0.dist-info}/METADATA +8 -1
- {code_context_control-2.118.2.dist-info → code_context_control-2.119.0.dist-info}/RECORD +31 -26
- core/codex_patch.py +46 -0
- core/host.py +37 -0
- core/ide.py +7 -7
- core/mcp_toml.py +39 -0
- services/access_guard.py +1 -0
- services/artifact_defs.py +1 -0
- services/atomic_json.py +9 -4
- services/claude_md.py +4 -0
- services/codex_integration.py +143 -0
- services/codex_transcript.py +78 -0
- services/conversation_store.py +38 -0
- services/runtime.py +2 -0
- services/session_manager.py +8 -5
- services/telemetry.py +12 -8
- {code_context_control-2.118.2.dist-info → code_context_control-2.119.0.dist-info}/WHEEL +0 -0
- {code_context_control-2.118.2.dist-info → code_context_control-2.119.0.dist-info}/entry_points.txt +0 -0
- {code_context_control-2.118.2.dist-info → code_context_control-2.119.0.dist-info}/licenses/LICENSE +0 -0
- {code_context_control-2.118.2.dist-info → code_context_control-2.119.0.dist-info}/top_level.txt +0 -0
cli/_hook_utils.py
CHANGED
|
@@ -375,30 +375,20 @@ HOST_GEMINI = "gemini"
|
|
|
375
375
|
HOST_CODEX = "codex"
|
|
376
376
|
|
|
377
377
|
|
|
378
|
-
def detect_host(data: dict) -> str:
|
|
379
|
-
"""
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
Codex MUST be checked before Gemini: Codex declares `tool_response` as
|
|
386
|
-
free-form, so an MCP tool that returns an object would otherwise trip the
|
|
387
|
-
isinstance-dict test and be mistaken for Gemini.
|
|
388
|
-
|
|
389
|
-
The CODEX_* env fallback covers a future Codex that drops `turn_id`. A
|
|
390
|
-
false positive there degrades gracefully — Claude Code also accepts
|
|
391
|
-
hookSpecificOutput.additionalContext — so guessing "codex" is the safe
|
|
392
|
-
direction to be wrong in.
|
|
393
|
-
"""
|
|
378
|
+
def detect_host(data: dict, explicit_host: str | None = None) -> str:
|
|
379
|
+
"""Use the hook's explicit host or wire identity, never its parent's env."""
|
|
380
|
+
named = explicit_host or (data.get("_c3_host") if isinstance(data, dict) else None)
|
|
381
|
+
aliases = {"claude-code": HOST_CLAUDE, "claude": HOST_CLAUDE,
|
|
382
|
+
"codex": HOST_CODEX, "gemini": HOST_GEMINI, "antigravity": HOST_GEMINI}
|
|
383
|
+
if named in aliases:
|
|
384
|
+
return aliases[named]
|
|
394
385
|
if not isinstance(data, dict):
|
|
395
386
|
return HOST_CLAUDE
|
|
396
387
|
if data.get("turn_id"):
|
|
397
388
|
return HOST_CODEX
|
|
398
|
-
|
|
389
|
+
response = data.get("tool_response")
|
|
390
|
+
if isinstance(response, dict) and ("llmContent" in response or "returnDisplay" in response):
|
|
399
391
|
return HOST_GEMINI
|
|
400
|
-
if os.environ.get("CODEX_THREAD_ID") or os.environ.get("CODEX_MANAGED_BY_NPM"):
|
|
401
|
-
return HOST_CODEX
|
|
402
392
|
return HOST_CLAUDE
|
|
403
393
|
|
|
404
394
|
|
cli/c3.py
CHANGED
|
@@ -92,7 +92,7 @@ console = Console() if HAS_RICH else None
|
|
|
92
92
|
# Config
|
|
93
93
|
CONFIG_DIR = ".c3"
|
|
94
94
|
CONFIG_FILE = ".c3/config.json"
|
|
95
|
-
__version__ = "2.
|
|
95
|
+
__version__ = "2.119.0"
|
|
96
96
|
|
|
97
97
|
|
|
98
98
|
def _compress_file_cli(compressor, path, mode="smart", **kw):
|
|
@@ -4298,17 +4298,9 @@ _C3_HOOKLESS_WORKFLOW = _adapt_workflow_for_ide(
|
|
|
4298
4298
|
|
|
4299
4299
|
_COPILOT_INSTRUCTIONS_CONTENT = _VSCODE_SESSION_INIT + "\n\n" + _C3_HOOKLESS_WORKFLOW
|
|
4300
4300
|
|
|
4301
|
-
|
|
4301
|
+
from services.codex_integration import CODEX_WORKFLOW
|
|
4302
4302
|
|
|
4303
|
-
|
|
4304
|
-
This project uses project-scoped MCP servers. Ensure your `.codex/config.toml` includes:
|
|
4305
|
-
```toml
|
|
4306
|
-
[mcp_servers.c3]
|
|
4307
|
-
command = "c3-mcp"
|
|
4308
|
-
args = ["--project", "."]
|
|
4309
|
-
enabled = true
|
|
4310
|
-
```
|
|
4311
|
-
"""
|
|
4303
|
+
_AGENTS_MD_CONTENT = CODEX_WORKFLOW
|
|
4312
4304
|
|
|
4313
4305
|
_TERSE_SKILL_CONTENT = """\
|
|
4314
4306
|
# /terse — Terse Output Mode
|
|
@@ -4426,50 +4418,13 @@ def _toml_escape_str(value: str) -> str:
|
|
|
4426
4418
|
|
|
4427
4419
|
|
|
4428
4420
|
def _upsert_toml_section(toml_path: Path, section: str, entries: dict) -> None:
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
# Strip existing section (header + its key=value lines). Also strip any
|
|
4438
|
-
# dotted child subtables (e.g. "[mcp_servers.c3.env]" under
|
|
4439
|
-
# "[mcp_servers.c3]") so they are not orphaned beneath the re-appended
|
|
4440
|
-
# section, which would corrupt the file on re-run.
|
|
4441
|
-
child_prefix = f"{header[:-1]}." # "[mcp_servers.c3]" -> "[mcp_servers.c3."
|
|
4442
|
-
lines = content.splitlines()
|
|
4443
|
-
new_lines: list[str] = []
|
|
4444
|
-
skip = False
|
|
4445
|
-
for line in lines:
|
|
4446
|
-
stripped = line.strip()
|
|
4447
|
-
if stripped == header:
|
|
4448
|
-
skip = True
|
|
4449
|
-
continue
|
|
4450
|
-
if skip and stripped.startswith("["):
|
|
4451
|
-
if not stripped.startswith(child_prefix):
|
|
4452
|
-
skip = False
|
|
4453
|
-
if not skip:
|
|
4454
|
-
new_lines.append(line)
|
|
4455
|
-
|
|
4456
|
-
content = "\n".join(new_lines).rstrip()
|
|
4457
|
-
|
|
4458
|
-
# Build new section
|
|
4459
|
-
section_lines = [f"\n\n{header}"]
|
|
4460
|
-
for k, v in entries.items():
|
|
4461
|
-
if isinstance(v, list):
|
|
4462
|
-
# Escape each list item individually
|
|
4463
|
-
items = ", ".join(f'"{_toml_escape_str(x)}"' for x in v)
|
|
4464
|
-
section_lines.append(f'"{k}" = [{items}]')
|
|
4465
|
-
elif isinstance(v, bool):
|
|
4466
|
-
section_lines.append(f'"{k}" = {"true" if v else "false"}')
|
|
4467
|
-
else:
|
|
4468
|
-
section_lines.append(f'"{k}" = "{_toml_escape_str(v)}"')
|
|
4469
|
-
section_lines.append("")
|
|
4470
|
-
|
|
4471
|
-
toml_path.parent.mkdir(parents=True, exist_ok=True)
|
|
4472
|
-
toml_path.write_text(content + "\n".join(section_lines), encoding="utf-8")
|
|
4421
|
+
from core.mcp_toml import merge_toml_section
|
|
4422
|
+
owned = dict(entries)
|
|
4423
|
+
defaults = {}
|
|
4424
|
+
for key in ("enabled", "startup_timeout_sec", "tool_timeout_sec"):
|
|
4425
|
+
if key in owned:
|
|
4426
|
+
defaults[key] = owned.pop(key)
|
|
4427
|
+
merge_toml_section(toml_path, section, owned, defaults)
|
|
4473
4428
|
|
|
4474
4429
|
|
|
4475
4430
|
def _toml_section_bool_value(toml_path: Path, section: str, key: str) -> bool | None:
|
|
@@ -4555,20 +4510,10 @@ def _ensure_instruction_workflow(instructions_path: Path, template: str,
|
|
|
4555
4510
|
def _ensure_codex_agents_workflow(agents_md_path: Path,
|
|
4556
4511
|
project_path: str = "") -> str:
|
|
4557
4512
|
"""Ensure AGENTS.md contains the mandatory C3 workflow for Codex sessions."""
|
|
4558
|
-
|
|
4559
|
-
|
|
4560
|
-
|
|
4561
|
-
|
|
4562
|
-
"Anti-patterns",
|
|
4563
|
-
"c3_search",
|
|
4564
|
-
"c3_read",
|
|
4565
|
-
"c3_validate",
|
|
4566
|
-
"c3_edit",
|
|
4567
|
-
"c3_memory",
|
|
4568
|
-
"c3_filter",
|
|
4569
|
-
]
|
|
4570
|
-
return _ensure_instruction_workflow(agents_md_path, _AGENTS_MD_CONTENT,
|
|
4571
|
-
required_markers, project_path)
|
|
4513
|
+
from services.claude_md import write_c3_instruction_doc
|
|
4514
|
+
existing = agents_md_path.read_text(encoding="utf-8") if agents_md_path.exists() else None
|
|
4515
|
+
rendered = write_c3_instruction_doc(agents_md_path, _AGENTS_MD_CONTENT, project_path or None)
|
|
4516
|
+
return "written" if existing is None else ("kept" if rendered == existing else "updated")
|
|
4572
4517
|
|
|
4573
4518
|
|
|
4574
4519
|
def _ensure_vscode_instructions_workflow(instructions_path: Path,
|
|
@@ -4654,6 +4599,7 @@ def _ensure_project_session_configs(target: Path, server_script: str, primary_pr
|
|
|
4654
4599
|
else:
|
|
4655
4600
|
mcp_command = "python"
|
|
4656
4601
|
server_args = [server_script_posix, "--project", target.as_posix()]
|
|
4602
|
+
server_args += ["--host", "codex"]
|
|
4657
4603
|
|
|
4658
4604
|
if primary_profile != "codex":
|
|
4659
4605
|
codex_path = target / ".codex" / "config.toml"
|
|
@@ -4674,18 +4620,20 @@ def _ensure_project_session_configs(target: Path, server_script: str, primary_pr
|
|
|
4674
4620
|
|
|
4675
4621
|
def _ensure_global_session_fallbacks(server_script: str, c3_mcp_exe: str | None = None,
|
|
4676
4622
|
primary_profile: str | None = None) -> None:
|
|
4677
|
-
"""
|
|
4623
|
+
"""Install an explicitly requested user-global Codex fallback.
|
|
4678
4624
|
|
|
4679
4625
|
These fallback entries omit `--project` so the MCP server can resolve the
|
|
4680
4626
|
active working directory dynamically when a session starts in a project that
|
|
4681
4627
|
does not yet have a project-local Codex config file.
|
|
4682
4628
|
"""
|
|
4629
|
+
if primary_profile not in (None, "codex"):
|
|
4630
|
+
return
|
|
4683
4631
|
server_script_posix = Path(server_script).as_posix()
|
|
4684
4632
|
# With the installed entry point, no script path is needed; --project stays
|
|
4685
4633
|
# omitted so the server resolves the working directory at session start.
|
|
4686
|
-
fallback_args = [] if c3_mcp_exe else [server_script_posix]
|
|
4634
|
+
fallback_args = (["--host", "codex"] if c3_mcp_exe else [server_script_posix, "--host", "codex"])
|
|
4687
4635
|
|
|
4688
|
-
codex_path = Path.home() / ".codex" / "config.toml"
|
|
4636
|
+
codex_path = Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex") / "config.toml"
|
|
4689
4637
|
try:
|
|
4690
4638
|
codex_state = "updated" if codex_path.exists() else "written"
|
|
4691
4639
|
_upsert_toml_section(
|
|
@@ -4701,27 +4649,6 @@ def _ensure_global_session_fallbacks(server_script: str, c3_mcp_exe: str | None
|
|
|
4701
4649
|
except PermissionError:
|
|
4702
4650
|
print(f"Warning: Could not update {codex_path} (global fallback skipped)")
|
|
4703
4651
|
|
|
4704
|
-
# Antigravity shares the ~/.gemini home dir but reads its own MCP config.
|
|
4705
|
-
# When Antigravity is the primary profile, the main install flow already
|
|
4706
|
-
# wrote this file — here we only keep it fresh for codex installs
|
|
4707
|
-
# on machines that have Antigravity (its config dir exists).
|
|
4708
|
-
antigravity_path = Path.home() / ".gemini" / "antigravity" / "mcp_config.json"
|
|
4709
|
-
if primary_profile != "antigravity" and antigravity_path.parent.is_dir():
|
|
4710
|
-
try:
|
|
4711
|
-
ag_state = _upsert_json_mcp_server(
|
|
4712
|
-
antigravity_path,
|
|
4713
|
-
"mcpServers",
|
|
4714
|
-
"c3",
|
|
4715
|
-
{
|
|
4716
|
-
"command": c3_mcp_exe or sys.executable,
|
|
4717
|
-
"args": fallback_args,
|
|
4718
|
-
},
|
|
4719
|
-
)
|
|
4720
|
-
print(f"{ag_state.capitalize()} {antigravity_path} (global fallback)")
|
|
4721
|
-
except PermissionError:
|
|
4722
|
-
print(f"Warning: Could not update {antigravity_path} (global fallback skipped)")
|
|
4723
|
-
|
|
4724
|
-
|
|
4725
4652
|
# Every hook script C3 has ever registered in a settings file — the v2.42+
|
|
4726
4653
|
# dispatcher plus the pre-v2.42 per-hook scripts — so uninstall recognises an
|
|
4727
4654
|
# entry written by any C3 version. A user hook is anything not naming one.
|
|
@@ -4926,7 +4853,7 @@ def _uninstall_mcp_all(project_path: str, include_global: bool = True):
|
|
|
4926
4853
|
except Exception as e:
|
|
4927
4854
|
print(f" Warning: Could not update {mcp_config_path}: {e}")
|
|
4928
4855
|
|
|
4929
|
-
#
|
|
4856
|
+
# Per-host hooks and settings; remove only C3-owned handlers.
|
|
4930
4857
|
if profile.supports_hooks and profile.settings_path:
|
|
4931
4858
|
settings_path = target / profile.settings_path
|
|
4932
4859
|
if settings_path.exists():
|
|
@@ -5475,9 +5402,9 @@ def cmd_install_mcp(args):
|
|
|
5475
5402
|
# Keep the script path as a single arg; 'python' keeps the source fallback
|
|
5476
5403
|
# portable across platforms.
|
|
5477
5404
|
if c3_mcp_exe:
|
|
5478
|
-
new_entry = {"command": c3_mcp_exe, "args": ["--project", "."]}
|
|
5405
|
+
new_entry = {"command": c3_mcp_exe, "args": ["--project", ".", "--host", profile.name]}
|
|
5479
5406
|
else:
|
|
5480
|
-
new_entry = {"command": "python", "args": [server_script, "--project", "."]}
|
|
5407
|
+
new_entry = {"command": "python", "args": [server_script, "--project", ".", "--host", profile.name]}
|
|
5481
5408
|
if profile.needs_type_field:
|
|
5482
5409
|
new_entry["type"] = "stdio"
|
|
5483
5410
|
|
|
@@ -5489,24 +5416,7 @@ def cmd_install_mcp(args):
|
|
|
5489
5416
|
mcp_config_path = target / profile.config_path
|
|
5490
5417
|
mcp_config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
5491
5418
|
|
|
5492
|
-
#
|
|
5493
|
-
if profile.config_path != ".mcp.json":
|
|
5494
|
-
mcp_json_legacy = target / ".mcp.json"
|
|
5495
|
-
if mcp_json_legacy.exists():
|
|
5496
|
-
try:
|
|
5497
|
-
with open(mcp_json_legacy, 'r', encoding="utf-8") as f:
|
|
5498
|
-
legacy_data = json.load(f)
|
|
5499
|
-
if "mcpServers" in legacy_data and "c3" in legacy_data["mcpServers"]:
|
|
5500
|
-
del legacy_data["mcpServers"]["c3"]
|
|
5501
|
-
if not legacy_data["mcpServers"]:
|
|
5502
|
-
mcp_json_legacy.unlink()
|
|
5503
|
-
print(f" Removed obsolete {mcp_json_legacy}")
|
|
5504
|
-
else:
|
|
5505
|
-
with open(mcp_json_legacy, 'w', encoding="utf-8") as f:
|
|
5506
|
-
json.dump(legacy_data, f, indent=2)
|
|
5507
|
-
print(f" Removed C3 from obsolete {mcp_json_legacy}")
|
|
5508
|
-
except Exception:
|
|
5509
|
-
pass
|
|
5419
|
+
# Integrations coexist: only the selected host owns this install.
|
|
5510
5420
|
|
|
5511
5421
|
try:
|
|
5512
5422
|
if profile.config_format == "toml":
|
|
@@ -5518,6 +5428,9 @@ def cmd_install_mcp(args):
|
|
|
5518
5428
|
if profile.name == "codex":
|
|
5519
5429
|
# Codex supports explicit enable/disable per server.
|
|
5520
5430
|
toml_entries["enabled"] = True
|
|
5431
|
+
toml_entries["startup_timeout_sec"] = 30
|
|
5432
|
+
toml_entries["tool_timeout_sec"] = 60
|
|
5433
|
+
toml_entries["args"] += ["--host", "codex"]
|
|
5521
5434
|
_upsert_toml_section(
|
|
5522
5435
|
mcp_config_path,
|
|
5523
5436
|
f"{profile.config_key}.c3",
|
|
@@ -5548,8 +5461,7 @@ def cmd_install_mcp(args):
|
|
|
5548
5461
|
# instead of letting it surface as anonymous out-of-band drift.
|
|
5549
5462
|
from services.artifact_defs import note_pending_write
|
|
5550
5463
|
note_pending_write(target, profile.config_path, "install_mcp")
|
|
5551
|
-
if profile.name
|
|
5552
|
-
_ensure_project_session_configs(target, server_script, primary_profile=profile.name, c3_mcp_exe=c3_mcp_exe)
|
|
5464
|
+
if getattr(args, "global_fallback", False) and profile.name == "codex":
|
|
5553
5465
|
_ensure_global_session_fallbacks(server_script, c3_mcp_exe=c3_mcp_exe, primary_profile=profile.name)
|
|
5554
5466
|
|
|
5555
5467
|
# ── Persist IDE choice to .c3/config.json ──
|
|
@@ -5559,13 +5471,18 @@ def cmd_install_mcp(args):
|
|
|
5559
5471
|
|
|
5560
5472
|
c3_config = _safe_read_json(c3_config_path, ".c3/config.json")
|
|
5561
5473
|
|
|
5562
|
-
c3_config
|
|
5474
|
+
c3_config.setdefault("ide", ide_name)
|
|
5475
|
+
c3_config["installed_ides"] = sorted(set(c3_config.get("installed_ides", [])) | {ide_name})
|
|
5563
5476
|
c3_config["mcp"] = {"mode": mcp_mode}
|
|
5564
5477
|
with open(c3_config_path, 'w', encoding="utf-8") as f:
|
|
5565
5478
|
json.dump(c3_config, f, indent=2)
|
|
5566
5479
|
|
|
5567
5480
|
# ── Install hooks (Claude Code) ──
|
|
5568
|
-
if profile.
|
|
5481
|
+
if profile.name == "codex":
|
|
5482
|
+
from services.codex_integration import install_hooks
|
|
5483
|
+
hook_state = install_hooks(target, sys.executable, cli_dir / "hook_dispatch.py")
|
|
5484
|
+
print(f"Codex hooks installed; active: {hook_state['active']}. {hook_state['activation']}")
|
|
5485
|
+
if profile.name == "claude-code" and profile.supports_hooks and profile.settings_path:
|
|
5569
5486
|
settings_dir = target / Path(profile.settings_path).parent
|
|
5570
5487
|
settings_dir.mkdir(parents=True, exist_ok=True)
|
|
5571
5488
|
settings_path = target / profile.settings_path
|
|
@@ -5851,7 +5768,7 @@ def cmd_install_mcp(args):
|
|
|
5851
5768
|
print(f"Kept {agents_md_path} (C3 workflow present)")
|
|
5852
5769
|
|
|
5853
5770
|
# Warn about a common conflict: global Codex config disables c3.
|
|
5854
|
-
global_codex_cfg = Path.home() / ".codex" / "config.toml"
|
|
5771
|
+
global_codex_cfg = Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex") / "config.toml"
|
|
5855
5772
|
global_enabled = _toml_section_bool_value(global_codex_cfg, "mcp_servers.c3", "enabled")
|
|
5856
5773
|
if global_enabled is False:
|
|
5857
5774
|
print(f"Warning: {global_codex_cfg} has [mcp_servers.c3] enabled = false.")
|
|
@@ -5860,10 +5777,11 @@ def cmd_install_mcp(args):
|
|
|
5860
5777
|
_sync_project_instruction_docs(str(target), sm)
|
|
5861
5778
|
|
|
5862
5779
|
# ── User-global C3 enforcement ──────────────────────────────
|
|
5863
|
-
|
|
5864
|
-
|
|
5865
|
-
|
|
5866
|
-
|
|
5780
|
+
if profile.name == "claude-code":
|
|
5781
|
+
try:
|
|
5782
|
+
_ensure_global_claude_md()
|
|
5783
|
+
except Exception as e:
|
|
5784
|
+
print(f"Warning: Could not update global CLAUDE.md: {e}")
|
|
5867
5785
|
|
|
5868
5786
|
# ── Default Access Guard rules (first run only) ─────────────
|
|
5869
5787
|
from services import access_guard
|
|
@@ -5874,7 +5792,7 @@ def cmd_install_mcp(args):
|
|
|
5874
5792
|
print(" (remove with: c3 access remove <glob> --kind deny --global)")
|
|
5875
5793
|
|
|
5876
5794
|
# ── Install /terse skill for supported IDEs ──────────────────
|
|
5877
|
-
if profile.name
|
|
5795
|
+
if profile.name == "claude-code":
|
|
5878
5796
|
try:
|
|
5879
5797
|
_ensure_terse_skill(profile.name)
|
|
5880
5798
|
except Exception as e:
|
|
@@ -9292,6 +9210,11 @@ def _launch_tui() -> None:
|
|
|
9292
9210
|
pass
|
|
9293
9211
|
|
|
9294
9212
|
|
|
9213
|
+
def cmd_doctor(args):
|
|
9214
|
+
from services.codex_integration import diagnose
|
|
9215
|
+
print(json.dumps(diagnose(Path(args.project_path).resolve()), indent=2))
|
|
9216
|
+
|
|
9217
|
+
|
|
9295
9218
|
def main():
|
|
9296
9219
|
# Force UTF-8 on the CLI streams so server-supplied text (PR titles, branch
|
|
9297
9220
|
# names, diffs) and our own glyphs render cleanly on Windows cp1252 consoles
|
|
@@ -9325,6 +9248,7 @@ def main():
|
|
|
9325
9248
|
return
|
|
9326
9249
|
|
|
9327
9250
|
commands = {
|
|
9251
|
+
"doctor": cmd_doctor,
|
|
9328
9252
|
"init": cmd_init,
|
|
9329
9253
|
"index": cmd_index,
|
|
9330
9254
|
"compress": cmd_compress,
|
cli/commands/parser.py
CHANGED
|
@@ -17,6 +17,10 @@ def build_parser(version: str, parse_cli_ide_arg):
|
|
|
17
17
|
)
|
|
18
18
|
subparsers = parser.add_subparsers(dest="command")
|
|
19
19
|
|
|
20
|
+
p_doctor = subparsers.add_parser("doctor", help="Inspect native Codex integration without a model call")
|
|
21
|
+
p_doctor.add_argument("project_path", nargs="?", default=".")
|
|
22
|
+
p_doctor.add_argument("--ide", choices=["codex"], default="codex")
|
|
23
|
+
|
|
20
24
|
p_init = subparsers.add_parser("init", help="Initialize C3 for a project")
|
|
21
25
|
p_init.add_argument("project_path", nargs="?", default=".")
|
|
22
26
|
p_init.add_argument("--force", action="store_true", help="Skip prompts and apply update non-interactively")
|
|
@@ -137,6 +141,7 @@ def build_parser(version: str, parse_cli_ide_arg):
|
|
|
137
141
|
p_install_mcp.add_argument("targets", nargs="*", help="Optional project path and/or IDE shorthand (for example: `claude` or `. codex`)")
|
|
138
142
|
p_install_mcp.add_argument("--ide", default="auto", type=parse_cli_ide_arg, metavar="{auto,claude,vscode,cursor,codex,antigravity}", help="Target IDE (default: auto-detect)")
|
|
139
143
|
p_install_mcp.add_argument("--mcp-mode", choices=["direct", "proxy"], default="direct", help="MCP entrypoint mode (default: direct)")
|
|
144
|
+
p_install_mcp.add_argument("--global-fallback", action="store_true", help="Also install the machine-wide Codex fallback")
|
|
140
145
|
p_install_mcp.add_argument("--permissions", choices=["read-only", "c3-strict", "standard", "permissive"], default=None, help="Apply Claude Code permission tier (Claude Code only)")
|
|
141
146
|
p_install_mcp.add_argument("--include-mcp-wildcard", action="store_true", help="Add mcp__* wildcard so non-C3 MCP servers don't prompt per-call")
|
|
142
147
|
|
|
@@ -144,6 +149,7 @@ def build_parser(version: str, parse_cli_ide_arg):
|
|
|
144
149
|
p_mcp_install.add_argument("targets", nargs="*", help="Optional project path and/or IDE shorthand")
|
|
145
150
|
p_mcp_install.add_argument("--ide", default="auto", type=parse_cli_ide_arg, metavar="{auto,claude,vscode,cursor,codex,antigravity}", help="Target IDE (default: auto-detect)")
|
|
146
151
|
p_mcp_install.add_argument("--mcp-mode", choices=["direct", "proxy"], default="direct", help="MCP entrypoint mode (default: direct)")
|
|
152
|
+
p_mcp_install.add_argument("--global-fallback", action="store_true", help="Also install the machine-wide Codex fallback")
|
|
147
153
|
p_mcp_install.add_argument("--permissions", choices=["read-only", "c3-strict", "standard", "permissive"], default=None, help="Apply Claude Code permission tier (Claude Code only)")
|
|
148
154
|
p_mcp_install.add_argument("--include-mcp-wildcard", action="store_true", help="Add mcp__* wildcard so non-C3 MCP servers don't prompt per-call")
|
|
149
155
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Thread-bound C3 handoff context for Codex lifecycle events."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def checkpoint(base: Path, data: dict) -> None:
|
|
8
|
+
"""Persist only the current thread's handoff, without ending its C3 session."""
|
|
9
|
+
host_id = str(data.get("host_session_id") or "")
|
|
10
|
+
if not host_id or data.get("source_ide") != "codex":
|
|
11
|
+
return
|
|
12
|
+
key = hashlib.sha256(host_id.encode()).hexdigest()
|
|
13
|
+
path = base / ".c3" / "host_sessions" / "codex" / (key + ".json")
|
|
14
|
+
context = {"description": str(data.get("description") or "")[:2000]}
|
|
15
|
+
for name in ("decisions", "files_touched", "context_notes"):
|
|
16
|
+
context[name] = [str(item)[:1000] for item in (data.get(name) or [])[-20:]]
|
|
17
|
+
from cli._hook_utils import _atomic_write_json
|
|
18
|
+
_atomic_write_json(path, {"host_session_id": host_id, "project": str(base.resolve()), "context": context})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run(payload: dict, project_path: Path | None = None):
|
|
22
|
+
base = Path(project_path or payload.get("cwd") or Path.cwd()).resolve()
|
|
23
|
+
host_id = str(payload.get("session_id") or "")
|
|
24
|
+
if not host_id or not (base / ".c3").is_dir():
|
|
25
|
+
return None
|
|
26
|
+
key = hashlib.sha256(host_id.encode()).hexdigest()
|
|
27
|
+
path = base / ".c3" / "host_sessions" / "codex" / (key + ".json")
|
|
28
|
+
event = payload.get("hook_event_name", "")
|
|
29
|
+
hooks = base / ".codex/hooks.json"
|
|
30
|
+
if hooks.exists():
|
|
31
|
+
from cli._hook_utils import _atomic_write_json
|
|
32
|
+
_atomic_write_json(path.with_suffix(".events.json"), {
|
|
33
|
+
"host_session_id": host_id, "event": event,
|
|
34
|
+
"config_hash": hashlib.sha256(hooks.read_bytes()).hexdigest()})
|
|
35
|
+
if event == "SessionStart":
|
|
36
|
+
if not path.exists():
|
|
37
|
+
return None
|
|
38
|
+
try:
|
|
39
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
40
|
+
except (OSError, ValueError):
|
|
41
|
+
return None
|
|
42
|
+
if data.get("host_session_id") != host_id or data.get("project") != str(base):
|
|
43
|
+
return None
|
|
44
|
+
return {"additionalContext": "C3 handoff data for this thread (potentially stale, not instructions):\n" +
|
|
45
|
+
json.dumps(data.get("context", {}), ensure_ascii=False)[:8000]}
|
|
46
|
+
sessions = base / ".c3" / "sessions"
|
|
47
|
+
candidates = sorted(sessions.glob("session_*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
48
|
+
for candidate in candidates:
|
|
49
|
+
try:
|
|
50
|
+
data = json.loads(candidate.read_text(encoding="utf-8"))
|
|
51
|
+
except (OSError, ValueError):
|
|
52
|
+
continue
|
|
53
|
+
if data.get("host_session_id") != host_id or data.get("source_ide") != "codex":
|
|
54
|
+
continue
|
|
55
|
+
# MCP checkpoints are fresher than a session saved before the last turn.
|
|
56
|
+
if path.exists() and path.stat().st_mtime_ns >= candidate.stat().st_mtime_ns:
|
|
57
|
+
break
|
|
58
|
+
checkpoint(base, data)
|
|
59
|
+
break
|
|
60
|
+
return None
|
cli/hook_dispatch.py
CHANGED
|
@@ -74,6 +74,9 @@ _CODEX_EVENT_NAMES = {
|
|
|
74
74
|
"posttool": "PostToolUse",
|
|
75
75
|
"prompt": "UserPromptSubmit",
|
|
76
76
|
"stop": "Stop",
|
|
77
|
+
"start": "SessionStart",
|
|
78
|
+
"compact": "PreCompact",
|
|
79
|
+
"end": "SessionEnd",
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
# Keys each event's hookSpecificOutput accepts. "stop" is absent deliberately:
|
|
@@ -85,6 +88,7 @@ _CODEX_HSO_KEYS = {
|
|
|
85
88
|
},
|
|
86
89
|
"posttool": {"hookEventName", "additionalContext", "updatedMCPToolOutput"},
|
|
87
90
|
"prompt": {"hookEventName", "additionalContext"},
|
|
91
|
+
"start": {"hookEventName", "additionalContext"},
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
# ── Routing tables (parity with the pre-v2.42 per-matcher registration) ─────
|
|
@@ -159,10 +163,12 @@ def _routes(event: str, raw_tool: str, norm_tool: str, host: str = HOST_CLAUDE):
|
|
|
159
163
|
yield "hook_edit_ledger"
|
|
160
164
|
elif event == "stop":
|
|
161
165
|
yield "hook_session_stats"
|
|
162
|
-
yield "hook_auto_snapshot"
|
|
166
|
+
yield "hook_codex_lifecycle" if host == HOST_CODEX else "hook_auto_snapshot"
|
|
163
167
|
yield "hook_terse_advisor"
|
|
164
168
|
elif event == "prompt":
|
|
165
169
|
yield "hook_prompt_recall"
|
|
170
|
+
elif event in ("start", "compact", "end") and host == HOST_CODEX:
|
|
171
|
+
yield "hook_codex_lifecycle"
|
|
166
172
|
|
|
167
173
|
|
|
168
174
|
_RUN_CACHE: dict = {}
|
|
@@ -413,7 +419,7 @@ def merge_outputs(outputs: list, warnings: list, is_gemini: bool = False,
|
|
|
413
419
|
return result or None
|
|
414
420
|
|
|
415
421
|
|
|
416
|
-
def
|
|
422
|
+
def _collect(event: str, payload: dict, project_path: Path | None = None):
|
|
417
423
|
"""Run all applicable sub-hooks in-process and merge their outputs."""
|
|
418
424
|
raw_tool = str(payload.get("tool_name", ""))
|
|
419
425
|
norm_tool = normalize_tool_name(raw_tool)
|
|
@@ -457,6 +463,39 @@ def dispatch(event: str, payload: dict, project_path: Path | None = None) -> dic
|
|
|
457
463
|
if event == "pretool" and outputs and _is_deny(outputs[-1]):
|
|
458
464
|
break
|
|
459
465
|
|
|
466
|
+
return outputs, warnings
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def dispatch(event: str, payload: dict, project_path: Path | None = None) -> dict | None:
|
|
470
|
+
host = detect_host(payload)
|
|
471
|
+
payload = {**payload, "hook_event_name": payload.get("hook_event_name") or _CODEX_EVENT_NAMES.get(event, event)}
|
|
472
|
+
if payload.get("tool_name") == "apply_patch" and event in ("pretool", "posttool"):
|
|
473
|
+
from core.codex_patch import patch_targets
|
|
474
|
+
base = Path(project_path or payload.get("cwd") or Path.cwd()).resolve()
|
|
475
|
+
try:
|
|
476
|
+
targets = patch_targets((payload.get("tool_input") or {}).get("command"), base)
|
|
477
|
+
except Exception as exc:
|
|
478
|
+
out = _fail_closed_deny("apply_patch", str(exc)) if event == "pretool" else {"_text": "[c3:hook-error] Could not audit malformed apply_patch payload"}
|
|
479
|
+
return merge_outputs([out], [], event=event, host=host)
|
|
480
|
+
if event == "posttool" and _hook_utils.tool_response_failed(payload):
|
|
481
|
+
return None
|
|
482
|
+
if event == "posttool":
|
|
483
|
+
response, _ = _hook_utils.get_tool_output(payload)
|
|
484
|
+
if str(response).lstrip().lower().startswith(("failed", "error", "apply_patch verification failed")):
|
|
485
|
+
return None
|
|
486
|
+
outputs, warnings = [], []
|
|
487
|
+
for target in targets:
|
|
488
|
+
translated = {**payload, "tool_name": "Write" if target.change_type == "created" else "Edit",
|
|
489
|
+
"_c3_host": host, "_c3_change_type": target.change_type,
|
|
490
|
+
"_c3_original_tool": "apply_patch",
|
|
491
|
+
"tool_input": {"file_path": target.path}}
|
|
492
|
+
found, notices = _collect(event, translated, base)
|
|
493
|
+
outputs.extend(found)
|
|
494
|
+
warnings.extend(notices)
|
|
495
|
+
if event == "pretool" and any(_is_deny(o) for o in found):
|
|
496
|
+
break
|
|
497
|
+
else:
|
|
498
|
+
outputs, warnings = _collect(event, payload, project_path)
|
|
460
499
|
if event == "pretool":
|
|
461
500
|
outputs = _settle_grants(outputs)
|
|
462
501
|
return merge_outputs(outputs, warnings, event=event, host=host)
|
|
@@ -466,8 +505,14 @@ def main() -> None:
|
|
|
466
505
|
# Windows hook subprocesses get cp1252 pipes; sub-hook _text may carry
|
|
467
506
|
# box-drawing chars / emoji, so print(text) below would UnicodeEncodeError.
|
|
468
507
|
_hook_utils.ensure_utf8_stdio()
|
|
469
|
-
|
|
470
|
-
|
|
508
|
+
import argparse
|
|
509
|
+
parser = argparse.ArgumentParser()
|
|
510
|
+
parser.add_argument("event", nargs="?", default="")
|
|
511
|
+
parser.add_argument("--host", choices=["codex", "claude", "claude-code", "gemini"])
|
|
512
|
+
parser.add_argument("--project")
|
|
513
|
+
args = parser.parse_args()
|
|
514
|
+
event = args.event.strip().lower()
|
|
515
|
+
if event not in (*VALID_EVENTS, "start", "compact", "end"):
|
|
471
516
|
log_hook_error(
|
|
472
517
|
"hook_dispatch",
|
|
473
518
|
ValueError(f"unknown or missing event arg: {event!r} (expected {VALID_EVENTS})"),
|
|
@@ -477,18 +522,28 @@ def main() -> None:
|
|
|
477
522
|
try:
|
|
478
523
|
raw = sys.stdin.read()
|
|
479
524
|
payload = json.loads(raw) if raw.strip() else {}
|
|
525
|
+
if not isinstance(payload, dict):
|
|
526
|
+
raise ValueError("Hook payload must be a JSON object")
|
|
480
527
|
except Exception as exc:
|
|
481
528
|
log_hook_error("hook_dispatch", exc)
|
|
529
|
+
if event == "pretool":
|
|
530
|
+
print("C3 could not validate the hook payload", file=sys.stderr)
|
|
531
|
+
raise SystemExit(2)
|
|
482
532
|
return
|
|
483
533
|
|
|
484
534
|
try:
|
|
485
|
-
|
|
535
|
+
if args.host:
|
|
536
|
+
payload["_c3_host"] = args.host
|
|
537
|
+
output = dispatch(event, payload, Path(args.project).resolve() if args.project else None)
|
|
486
538
|
except Exception as exc:
|
|
487
539
|
# The dispatcher itself failing is critical — say so. Route the notice
|
|
488
540
|
# through merge_outputs so even the panic path speaks the host's
|
|
489
541
|
# dialect: a raw {"additionalContext": ...} is itself invalid on Codex,
|
|
490
542
|
# which would turn one crash into two error reports.
|
|
491
543
|
log_hook_error("hook_dispatch", exc)
|
|
544
|
+
if event == "pretool":
|
|
545
|
+
print("C3 could not complete the pre-tool checks", file=sys.stderr)
|
|
546
|
+
raise SystemExit(2)
|
|
492
547
|
notice = {
|
|
493
548
|
"additionalContext": (
|
|
494
549
|
f"[c3:hook-error] hook_dispatch: {type(exc).__name__}: {exc}; "
|
cli/hook_edit_ledger.py
CHANGED
|
@@ -150,7 +150,7 @@ def run(payload: dict, project_path: Path | None = None) -> dict | None:
|
|
|
150
150
|
return None
|
|
151
151
|
|
|
152
152
|
# Filter to editable extensions
|
|
153
|
-
if Path(file_path).suffix.lower() not in EDITABLE_EXTS:
|
|
153
|
+
if payload.get("_c3_original_tool") != "apply_patch" and Path(file_path).suffix.lower() not in EDITABLE_EXTS:
|
|
154
154
|
return None
|
|
155
155
|
|
|
156
156
|
if project_path is None:
|
|
@@ -177,7 +177,7 @@ def run(payload: dict, project_path: Path | None = None) -> dict | None:
|
|
|
177
177
|
|
|
178
178
|
now = datetime.now(timezone.utc)
|
|
179
179
|
tool_input = payload.get("tool_input", {}) or {}
|
|
180
|
-
change_type = _detect_change_type(tool_name, tool_input)
|
|
180
|
+
change_type = payload.get("_c3_change_type") or _detect_change_type(tool_name, tool_input)
|
|
181
181
|
|
|
182
182
|
# Git info is enriched asynchronously by EditLedgerEnricherAgent.
|
|
183
183
|
# Mark as pending so the enricher knows to process this entry.
|
|
@@ -188,7 +188,7 @@ def run(payload: dict, project_path: Path | None = None) -> dict | None:
|
|
|
188
188
|
# server process (services/edit_ledger.py) write within the same second.
|
|
189
189
|
"id": f"edit_{now.strftime('%Y%m%d_%H%M%S')}_{_next_seq(ledger_file, now):03d}_{uuid.uuid4().hex[:4]}",
|
|
190
190
|
"timestamp": now.isoformat(),
|
|
191
|
-
"session_id": "",
|
|
191
|
+
"session_id": payload.get("session_id", ""),
|
|
192
192
|
"file": rel,
|
|
193
193
|
"change_type": change_type,
|
|
194
194
|
"summary": change_type if tracking_level == "minimal" else _extract_summary(tool_name, tool_input),
|
cli/hook_session_stats.py
CHANGED
|
@@ -100,6 +100,32 @@ def read_transcript_usage(transcript_path) -> dict:
|
|
|
100
100
|
|
|
101
101
|
def run(payload: dict, project_path: Path | None = None):
|
|
102
102
|
"""Core logic — importable by the dispatcher and tests. Returns None."""
|
|
103
|
+
from cli._hook_utils import HOST_CODEX, detect_host
|
|
104
|
+
if detect_host(payload) == HOST_CODEX:
|
|
105
|
+
from services.codex_transcript import read_rollout
|
|
106
|
+
base = Path(project_path or payload.get("cwd") or Path.cwd()).resolve()
|
|
107
|
+
transcript = payload.get("transcript_path")
|
|
108
|
+
data = None
|
|
109
|
+
if transcript and payload.get("session_id"):
|
|
110
|
+
try:
|
|
111
|
+
data = read_rollout(Path(transcript), base, str(payload["session_id"]), max_turns=0)
|
|
112
|
+
except (OSError, ValueError):
|
|
113
|
+
pass
|
|
114
|
+
usage = (data or {}).get("usage")
|
|
115
|
+
row = {"ts": datetime.now(timezone.utc).isoformat(), "session_id": payload.get("session_id"),
|
|
116
|
+
"provider": "codex", "source": "transcript" if usage is not None else "none",
|
|
117
|
+
"usage_available": bool((data or {}).get("usage_available")), "usage": usage,
|
|
118
|
+
# Codex input includes cached input; C3's cross-provider fields
|
|
119
|
+
# count cache reads separately, as Claude's wire format does.
|
|
120
|
+
"input_tokens": max(0, usage["input_tokens"] - usage.get("cached_input_tokens", 0)) if usage is not None and "input_tokens" in usage else None,
|
|
121
|
+
"input_tokens_including_cache": usage.get("input_tokens") if usage is not None else None,
|
|
122
|
+
"output_tokens": usage.get("output_tokens") if usage is not None else None,
|
|
123
|
+
"cache_read_tokens": usage.get("cached_input_tokens") if usage is not None else None,
|
|
124
|
+
"cache_creation_tokens": None, "cost_usd": None, "model": (data or {}).get("model", "")}
|
|
125
|
+
if (base / ".c3").is_dir():
|
|
126
|
+
with (base / ".c3" / "session_stats.jsonl").open("a", encoding="utf-8") as stream:
|
|
127
|
+
stream.write(json.dumps(row) + "\n")
|
|
128
|
+
return None
|
|
103
129
|
usage = payload.get("usage") or {}
|
|
104
130
|
if isinstance(usage, dict) and any(usage.get(src) for src, _ in _USAGE_FIELDS):
|
|
105
131
|
# A future Claude Code that does send usage wins over re-reading.
|