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 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
- """Identify which agent runtime sent this hook payload.
380
-
381
- Codex is detected by `turn_id`, which its wire schema marks required on
382
- every turn-scoped event C3 hooks (pre/post tool use, user prompt submit,
383
- stop) and documents as "Codex extension". Claude Code never sends it.
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
- if isinstance(data.get("tool_response"), dict):
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.118.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
- _AGENTS_MD_CONTENT = _C3_HOOKLESS_WORKFLOW + """
4301
+ from services.codex_integration import CODEX_WORKFLOW
4302
4302
 
4303
- ## IDE Configuration (Codex)
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
- """Add or replace a dotted TOML section (e.g. 'mcp_servers.c3') in-place.
4430
-
4431
- Reads the existing file, removes the old section if present, and appends
4432
- the new section at the end. Handles simple scalar and list values only.
4433
- """
4434
- content = toml_path.read_text(encoding="utf-8") if toml_path.exists() else ""
4435
- header = f"[{section}]"
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
- required_markers = [
4559
- "C3 Tools",
4560
- "MANDATORY",
4561
- "SEARCH FIRST",
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
- """Keep user-global Codex/Antigravity MCP configs pointing at C3.
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
- # Claude Code Hooks & Settings
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
- # Cleanup .mcp.json if it's NOT the target config but exists (to avoid confusion)
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 in {"codex", "antigravity"}:
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["ide"] = ide_name
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.supports_hooks and profile.settings_path:
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
- try:
5864
- _ensure_global_claude_md()
5865
- except Exception as e:
5866
- print(f"Warning: Could not update global CLAUDE.md: {e}")
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 in ("claude-code", "codex"):
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 dispatch(event: str, payload: dict, project_path: Path | None = None) -> dict | None:
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
- event = sys.argv[1].strip().lower() if len(sys.argv) > 1 else ""
470
- if event not in VALID_EVENTS:
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
- output = dispatch(event, payload)
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.