From c2bc165cd6e525e3a26027f542e76a6a5761ead1 Mon Sep 17 00:00:00 2001 From: drknowhow Date: Sat, 5 Sep 2026 16:47:59 -0400 Subject: [PATCH 1/5] feat(codex): native Codex hooks, thread-bound handoffs, additive installs alongside Claude Code (2.119.0) Codex and Claude Code now coexist in one repository. Installing one host no longer strips the other's config, every launcher passes an explicit --host, and Codex gets the same hook chain (PreToolUse, PostToolUse, UserPromptSubmit, Stop, SessionStart, PreCompact, SessionEnd) via .codex/hooks.json. - core/host.py resolves the process identity from --host, C3_HOST, the host session env, then the project preference; delegated children drop inherited thread ids. - core/mcp_toml.merge_toml_section merges [mcp_servers.c3] with tomlkit and keeps user keys, comments, subtables and an explicit enabled=false. - core/codex_patch + hook_dispatch audit apply_patch per target (add, update, delete, move) through the existing guard and edit ledger; malformed patches deny, failed patches are not logged. - cli/hook_codex_lifecycle keeps a thread-bound handoff under .c3/host_sessions/codex. - services/codex_transcript + conversation_store import bounded rollouts, take the latest cumulative token_count, and separate cached input. - delegate runs codex exec --json with the prompt on stdin, an explicit sandbox and approval_policy="never"; resume is bound to the persisted thread id. - c3 doctor --ide codex; --global-fallback is opt-in; .codex/hooks.json and AGENTS.override.md join the agent-config confirm tier. Implementation by Cod (Codex). Review, version bump and CHANGELOG by Yep. Local gates were ruff clean and 3707 passed / 6 skipped (the Docker act integration test was deselected because registry auth fails before the job runs, same as on CI). Claude-Session: https://claude.ai/code/session_0165dDtca3R3ggdkX4rAPJoP --- .github/copilot-instructions.md | 2 +- AGENTS.md | 2 +- CHANGELOG.md | 58 ++++ CLAUDE.md | 2 +- README.md | 6 + cli/_hook_utils.py | 28 +- cli/c3.py | 172 ++++-------- cli/commands/parser.py | 6 + cli/hook_codex_lifecycle.py | 60 ++++ cli/hook_dispatch.py | 65 ++++- cli/hook_edit_ledger.py | 6 +- cli/hook_session_stats.py | 26 ++ cli/mcp_proxy.py | 13 +- cli/mcp_server.py | 75 ++--- cli/tools/delegate.py | 168 +++++++----- cli/tools/status.py | 22 +- core/codex_patch.py | 46 ++++ core/host.py | 37 +++ core/ide.py | 14 +- core/mcp_toml.py | 39 +++ docs/codex-native.md | 120 ++++++++ pyproject.toml | 3 +- services/access_guard.py | 1 + services/artifact_defs.py | 1 + services/claude_md.py | 4 + services/codex_integration.py | 143 ++++++++++ services/codex_transcript.py | 78 ++++++ services/conversation_store.py | 38 +++ services/runtime.py | 2 + services/session_manager.py | 13 +- services/telemetry.py | 20 +- tests/test_codex_mcp_transport.py | 57 ++++ tests/test_codex_native.py | 395 +++++++++++++++++++++++++++ tests/test_install_mcp_entrypoint.py | 4 +- 34 files changed, 1428 insertions(+), 298 deletions(-) create mode 100644 cli/hook_codex_lifecycle.py create mode 100644 core/codex_patch.py create mode 100644 core/host.py create mode 100644 docs/codex-native.md create mode 100644 services/codex_integration.py create mode 100644 services/codex_transcript.py create mode 100644 tests/test_codex_mcp_transport.py create mode 100644 tests/test_codex_native.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index dc16ac7..e60d38e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -28,7 +28,7 @@ When falling back, state which c3_* tool was attempted and why it was insufficie 9.5. **LOCAL CI** (v2.79.0+): `c3_ci(action='inspect|run|rerun|status|failures|logs|runs')` — run THIS repository's real `.github/workflows/*.yml` on this machine instead of pushing to find out. C3 reads the existing workflow files; it does NOT define a second CI config. `inspect` shows the job DAG and which jobs are runnable on this host; `run` executes in `needs` order and SKIPS (never passes) a job whose dependency failed; `failures` returns `{file,line,message}` instead of raw logs; `rerun` retries only what failed. VERDICTS: `FULL_CI_PASS` means every job ran HERE and passed — the only one that means safe to push. `PARTIAL_PASS` means something did not run (targets another OS, uses an action C3 cannot execute, or you selected a subset) and is NOT a green light. Jobs targeting another OS are refused unless `allow_foreign=true`, which labels the result cross-OS and can never yield FULL_CI_PASS. ENGINES (v2.81.0+): `native` runs jobs matching this host; `act` runs Linux jobs in a real container (real `uses:` actions included) when act+Docker are installed — `c3_ci(action='doctor')` reports what is available. A container run DOES count toward FULL_CI_PASS; a cross-OS one never does. macOS jobs cannot run locally on any engine. Jobs that look like they publish or deploy are refused unless allow_side_effects=true, and C3 never passes secrets to act. See docs/agent-ci.md. 10. **BITBUCKET** (when configured, v2.30.0+): `c3_bitbucket(action='...')` — for self-hosted enterprise Bitbucket Data Center / Server: PRs, branches, builds, repo admin. Tokens live in the OS keyring (set up via `c3 bitbucket login`, or `login --global` for a home config reusable across projects; account resolution precedence is project → home). Read actions are safe in plan mode; write actions (`merge_pr`, `create_branch`, etc.) are auto-logged to the edit ledger. 10.5. **JIRA** (when configured, v2.56.0+): `c3_jira(action='...')` — Jira Cloud + Data Center: `search` (raw JQL), `my_issues`, `get_issue`, `list_transitions`, `get_create_metadata`, `search_users`, `list_link_types`, `list_boards`, `list_sprints`, `list_worklogs` reads; `create_issue` / `update_issue` / `comment` / `transition` / `assign` / `link_issues` / `unlink_issues` / `move_to_sprint` / `move_to_backlog` / `add_worklog` / `attach_file` / `delete_issue` (permanent; refuses subtask-bearing issues unless delete_subtasks=true) mutations auto-logged to the edit ledger (identifiers only, never bodies). createmeta reflects the issue type's field configuration, NOT the create screen — if create_issue rejects a listed field ('not on the appropriate screen'), create without it, then set it via `update_issue`. Epic membership: pass `parent=` to create_issue/update_issue ('none' clears) — C3 maps it per deployment (Cloud `parent` field vs Data Center Epic Link customfield); `link_issues(issue, link_type, target)` creates typed links reading ' ' (types via `list_link_types`). Sprints: `list_boards` → `list_sprints(board_id)` → `move_to_sprint(issue, sprint_id)`; `attach_file(issue, file_path)` uploads a local file as evidence. Tokens in the OS keyring (`c3 jira login`, or `login --global` for cross-project reuse; the jira config section resolves project → home wholesale from one file — never field-merged). Read actions are safe in plan mode. -10.6. **CREDENTIALS** (v2.58.0+): `c3_credentials(action='...')` — named secret vault (global ~/.c3 + per-project .c3; project shadows global). `list`/`describe`/`check` return names + metadata, never values. To USE a secret do NOT reveal it — pass `env_creds='NAME1,NAME2'` to c3_shell or write `{{cred:NAME}}` inside cmd; C3 decodes at the subprocess boundary so values never enter context, and echoed values are auto-redacted to `[cred:NAME]`. `reveal` works only on entries the user marked `agent_readable` (that flag cannot be raised on an existing entry by the agent). `set`/`delete` allowed; all mutations + reveals ledger-logged by name. `import_env` (v2.93.0+) bulk-imports a .env WITHOUT you seeing a value - the server reads the file, you get names/lengths/fingerprints/reasons; `**/.env*` stays a built-in deny for your own reads. It DEFAULTS TO dry_run=true (call it bare to preview, re-run dry_run=false once the user has seen the list), is project-scope only, refuses overwrite, and refuses a path outside the project. A dry run also DIFFS (v2.94.0+): a row already matching the vault reports `unchanged`, so re-running an import that would do nothing is visible before you propose it - importing to the global vault or replacing a stored secret stays a user action. Users manage entries via the Credentials UI tab or `c3 creds`. STRUCTURED kinds (v2.87.0+: `address`/`identity`/`card`; v2.90.0+: `login`) hold named fields (card: cardholder/number/expiry[/cvc/billing_zip]; login: site_id/canonical_origin/username/password[/totp_secret]) and are inject-only: address a FIELD - `env_creds='CARD.number'` (env `$CARD_NUMBER`) or `{{cred:CARD.number}}` - reveal is permanently disabled for them, they never auto-inject, and echoes redact to `[cred:NAME.field]`. Have the user enter card/identity/address/login data via UI/CLI so it never enters the chat. `login` is STORAGE ONLY: C3 has no browser surface and MUST NOT grow one. Do not write a script that reads a login password out of the environment and types it into a page — a check you author in a process that already holds the plaintext is not a control. `canonical_origin` (https-only, stored normalized) exists so a separate, privileged runner can bind the credential to exactly one origin; that runner does not live in this package. `usage` action (v2.88.0+) shows when/where/how often a credential was used (counts + recent events for the current project). +10.6. **CREDENTIALS** (v2.58.0+): `c3_credentials(action='...')` — named secret vault (global ~/.c3 + per-project .c3; project shadows global). `list`/`describe`/`check` return names + metadata, never values. To USE a secret do NOT reveal it — pass `env_creds='NAME1,NAME2'` to c3_shell or write `{{cred:NAME}}` inside cmd; C3 decodes at the subprocess boundary so values never enter context, and echoed values are auto-redacted to `[cred:NAME]`. `reveal` works only on entries the user marked `agent_readable` (that flag cannot be raised on an existing entry by the agent). `set`/`delete` allowed; all mutations + reveals ledger-logged by name. `import_env` (v2.93.0+) bulk-imports a .env WITHOUT you seeing a value - the server reads the file, you get names/lengths/fingerprints/reasons; `**/.env*` stays a built-in deny for your own reads. It DEFAULTS TO dry_run=true (call it bare to preview, re-run dry_run=false once the user has seen the list), is project-scope only, refuses overwrite, and refuses a path outside the project. A dry run also DIFFS (v2.94.0+): a row already matching the vault reports `unchanged`, so re-running an import that would do nothing is visible before you propose it - importing to the global vault or replacing a stored secret stays a user action. Users manage entries via the Credentials UI tab or `c3 creds`. STRUCTURED kinds (v2.87.0+: `address`/`identity`/`card`; v2.90.0+: `login`) hold named fields (card: cardholder/number/expiry[/cvc/billing_zip]; login: site_id/canonical_target/username[/password/private_key/passphrase/totp_secret], one of password/private_key required) and are inject-only: address a FIELD - `env_creds='CARD.number'` (env `$CARD_NUMBER`) or `{{cred:CARD.number}}` - reveal is permanently disabled for them, they never auto-inject, and echoes redact to `[cred:NAME.field]`. Have the user enter card/identity/address/login data via UI/CLI so it never enters the chat. `login` covers websites AND servers/databases/other non-web targets (v2.118.0+): `canonical_target` is scheme://host[:port] from an allowlist (https, ssh, sftp, rdp, smb, winrm, ldaps, imaps, smtps, postgres, mysql, mssql, mongodb, redis, …), cleartext schemes refused by name. It is STORAGE ONLY: C3 has no browser surface, opens no SSH session, and MUST NOT grow either. Do not write a script that reads a login password out of the environment and types it into a page or a session — a check you author in a process that already holds the plaintext is not a control. `canonical_target` (stored normalized) exists so a separate, privileged runner can bind the credential to exactly one destination; that runner does not live in this package. `canonical_origin` still reads back but ONLY for an https target, so a browser broker fails closed on a server entry. `usage` action (v2.88.0+) shows when/where/how often a credential was used (counts + recent events for the current project). 11. **CROSS-PROJECT** (v2.31.0+): `c3_project(action='list|scan|info|search|read|edit|shell|...', project='')` — discover and operate on OTHER c3-installed projects. `list`/`scan` need no project; reads (search/read/compress/status/memory/impact/edits/validate/filter) run freely; writes (`edit`, `shell`, memory add/update/delete) require `allow_write=true` and are logged to the target project's ledger. SUB-PROJECT HIERARCHY (project = the PARENT; strict tree, one parent, up to 8 levels): reads `subprojects` (direct children), `sub_tree` (whole hierarchy), `sub_inspect` (target=path — is there a C3 project there, what is in it, who already claims it, what it claims, which nested projects under it are NOT linked yet; mutates nothing); writes `sub_add` (initializes), `sub_link` (target=path to an EXISTING project ANYWHERE on disk, incl. another drive — a child need NOT live inside the parent), `sub_remove`, `sub_cascade` (walks the whole subtree). Nested children are excluded from the parent's index; externally linked ones were never in it. A link that would make a project its own ancestor is refused. 11.5. **MASKED PATHS** (v2.63.0+): some paths are exposed but policy-TRANSFORMED. `c3_read`/`c3_compress`/`c3_search` serve a deterministic view prefixed `[c3-mask:transformed] view=`. Treat that content as evidence of STRUCTURE, not of values: literals may be synthetic, rows may be withheld, bodies may be stripped — never copy them into other files and never draw conclusions about data volume or completeness from them. Masked paths are READ-ONLY: `c3_edit` refuses, and so do `c3_shell` content reads, git content commands, `c3_validate`, `c3_impact`, `c3_filter` and `c3_delegate` (tag `[c3-mask:unsupported]`) — that is a policy decision, not a transient error, so do not route around it via another tool or the shell. A `[c3-mask:limited]` search footer means absence is not evidence a path does not exist. Rules are human-only: report the block, do not try to change it. 11.6. **CONFIRM HOLDS** (v2.97.0+): a refusal tagged `[c3-access:confirm]` is a PAUSE, not a block — either the user set that path to "ask me first" (`user`/`global` scope) or it is the builtin agent-config tier (`builtin` scope, item 12). READ THE S8 TAIL, it says which case you are in. If it names a request id, C3 filed it for you: `c3_override(action='wait', request_id='...', timeout_s=180)` — the bare call waits only 60s, and "still pending" is NOT a denial, so wait again or do unaffected work. Retry the SAME call once, on the SAME surface, only after an approval. If the S8 says no request was filed, it names the surfaces that do file (`c3_read`, `c3_edit`, native tools) — use one of them rather than asking in chat. If it says a request COULD NOT be filed, obey the reason it gives: a denied-and-muted request means do not ask again, a rate limit means withdraw one or wait; ask in chat only when the reason carries no instruction. Never retry before a decision, never route around the hold via c3_shell or another tool, and never re-file — duplicates collapse into the pending request. diff --git a/AGENTS.md b/AGENTS.md index 6603a34..c32e974 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ When falling back, state which c3_* tool was attempted and why it was insufficie 9.5. **LOCAL CI** (v2.79.0+): `c3_ci(action='inspect|run|rerun|status|failures|logs|runs')` — run THIS repository's real `.github/workflows/*.yml` on this machine instead of pushing to find out. C3 reads the existing workflow files; it does NOT define a second CI config. `inspect` shows the job DAG and which jobs are runnable on this host; `run` executes in `needs` order and SKIPS (never passes) a job whose dependency failed; `failures` returns `{file,line,message}` instead of raw logs; `rerun` retries only what failed. VERDICTS: `FULL_CI_PASS` means every job ran HERE and passed — the only one that means safe to push. `PARTIAL_PASS` means something did not run (targets another OS, uses an action C3 cannot execute, or you selected a subset) and is NOT a green light. Jobs targeting another OS are refused unless `allow_foreign=true`, which labels the result cross-OS and can never yield FULL_CI_PASS. ENGINES (v2.81.0+): `native` runs jobs matching this host; `act` runs Linux jobs in a real container (real `uses:` actions included) when act+Docker are installed — `c3_ci(action='doctor')` reports what is available. A container run DOES count toward FULL_CI_PASS; a cross-OS one never does. macOS jobs cannot run locally on any engine. Jobs that look like they publish or deploy are refused unless allow_side_effects=true, and C3 never passes secrets to act. See docs/agent-ci.md. 10. **BITBUCKET** (when configured, v2.30.0+): `c3_bitbucket(action='...')` — for self-hosted enterprise Bitbucket Data Center / Server: PRs, branches, builds, repo admin. Tokens live in the OS keyring (set up via `c3 bitbucket login`, or `login --global` for a home config reusable across projects; account resolution precedence is project → home). Read actions are safe in plan mode; write actions (`merge_pr`, `create_branch`, etc.) are auto-logged to the edit ledger. 10.5. **JIRA** (when configured, v2.56.0+): `c3_jira(action='...')` — Jira Cloud + Data Center: `search` (raw JQL), `my_issues`, `get_issue`, `list_transitions`, `get_create_metadata`, `search_users`, `list_link_types`, `list_boards`, `list_sprints`, `list_worklogs` reads; `create_issue` / `update_issue` / `comment` / `transition` / `assign` / `link_issues` / `unlink_issues` / `move_to_sprint` / `move_to_backlog` / `add_worklog` / `attach_file` / `delete_issue` (permanent; refuses subtask-bearing issues unless delete_subtasks=true) mutations auto-logged to the edit ledger (identifiers only, never bodies). createmeta reflects the issue type's field configuration, NOT the create screen — if create_issue rejects a listed field ('not on the appropriate screen'), create without it, then set it via `update_issue`. Epic membership: pass `parent=` to create_issue/update_issue ('none' clears) — C3 maps it per deployment (Cloud `parent` field vs Data Center Epic Link customfield); `link_issues(issue, link_type, target)` creates typed links reading ' ' (types via `list_link_types`). Sprints: `list_boards` → `list_sprints(board_id)` → `move_to_sprint(issue, sprint_id)`; `attach_file(issue, file_path)` uploads a local file as evidence. Tokens in the OS keyring (`c3 jira login`, or `login --global` for cross-project reuse; the jira config section resolves project → home wholesale from one file — never field-merged). Read actions are safe in plan mode. -10.6. **CREDENTIALS** (v2.58.0+): `c3_credentials(action='...')` — named secret vault (global ~/.c3 + per-project .c3; project shadows global). `list`/`describe`/`check` return names + metadata, never values. To USE a secret do NOT reveal it — pass `env_creds='NAME1,NAME2'` to c3_shell or write `{{cred:NAME}}` inside cmd; C3 decodes at the subprocess boundary so values never enter context, and echoed values are auto-redacted to `[cred:NAME]`. `reveal` works only on entries the user marked `agent_readable` (that flag cannot be raised on an existing entry by the agent). `set`/`delete` allowed; all mutations + reveals ledger-logged by name. `import_env` (v2.93.0+) bulk-imports a .env WITHOUT you seeing a value - the server reads the file, you get names/lengths/fingerprints/reasons; `**/.env*` stays a built-in deny for your own reads. It DEFAULTS TO dry_run=true (call it bare to preview, re-run dry_run=false once the user has seen the list), is project-scope only, refuses overwrite, and refuses a path outside the project. A dry run also DIFFS (v2.94.0+): a row already matching the vault reports `unchanged`, so re-running an import that would do nothing is visible before you propose it - importing to the global vault or replacing a stored secret stays a user action. Users manage entries via the Credentials UI tab or `c3 creds`. STRUCTURED kinds (v2.87.0+: `address`/`identity`/`card`; v2.90.0+: `login`) hold named fields (card: cardholder/number/expiry[/cvc/billing_zip]; login: site_id/canonical_origin/username/password[/totp_secret]) and are inject-only: address a FIELD - `env_creds='CARD.number'` (env `$CARD_NUMBER`) or `{{cred:CARD.number}}` - reveal is permanently disabled for them, they never auto-inject, and echoes redact to `[cred:NAME.field]`. Have the user enter card/identity/address/login data via UI/CLI so it never enters the chat. `login` is STORAGE ONLY: C3 has no browser surface and MUST NOT grow one. Do not write a script that reads a login password out of the environment and types it into a page — a check you author in a process that already holds the plaintext is not a control. `canonical_origin` (https-only, stored normalized) exists so a separate, privileged runner can bind the credential to exactly one origin; that runner does not live in this package. `usage` action (v2.88.0+) shows when/where/how often a credential was used (counts + recent events for the current project). +10.6. **CREDENTIALS** (v2.58.0+): `c3_credentials(action='...')` — named secret vault (global ~/.c3 + per-project .c3; project shadows global). `list`/`describe`/`check` return names + metadata, never values. To USE a secret do NOT reveal it — pass `env_creds='NAME1,NAME2'` to c3_shell or write `{{cred:NAME}}` inside cmd; C3 decodes at the subprocess boundary so values never enter context, and echoed values are auto-redacted to `[cred:NAME]`. `reveal` works only on entries the user marked `agent_readable` (that flag cannot be raised on an existing entry by the agent). `set`/`delete` allowed; all mutations + reveals ledger-logged by name. `import_env` (v2.93.0+) bulk-imports a .env WITHOUT you seeing a value - the server reads the file, you get names/lengths/fingerprints/reasons; `**/.env*` stays a built-in deny for your own reads. It DEFAULTS TO dry_run=true (call it bare to preview, re-run dry_run=false once the user has seen the list), is project-scope only, refuses overwrite, and refuses a path outside the project. A dry run also DIFFS (v2.94.0+): a row already matching the vault reports `unchanged`, so re-running an import that would do nothing is visible before you propose it - importing to the global vault or replacing a stored secret stays a user action. Users manage entries via the Credentials UI tab or `c3 creds`. STRUCTURED kinds (v2.87.0+: `address`/`identity`/`card`; v2.90.0+: `login`) hold named fields (card: cardholder/number/expiry[/cvc/billing_zip]; login: site_id/canonical_target/username[/password/private_key/passphrase/totp_secret], one of password/private_key required) and are inject-only: address a FIELD - `env_creds='CARD.number'` (env `$CARD_NUMBER`) or `{{cred:CARD.number}}` - reveal is permanently disabled for them, they never auto-inject, and echoes redact to `[cred:NAME.field]`. Have the user enter card/identity/address/login data via UI/CLI so it never enters the chat. `login` covers websites AND servers/databases/other non-web targets (v2.118.0+): `canonical_target` is scheme://host[:port] from an allowlist (https, ssh, sftp, rdp, smb, winrm, ldaps, imaps, smtps, postgres, mysql, mssql, mongodb, redis, …), cleartext schemes refused by name. It is STORAGE ONLY: C3 has no browser surface, opens no SSH session, and MUST NOT grow either. Do not write a script that reads a login password out of the environment and types it into a page or a session — a check you author in a process that already holds the plaintext is not a control. `canonical_target` (stored normalized) exists so a separate, privileged runner can bind the credential to exactly one destination; that runner does not live in this package. `canonical_origin` still reads back but ONLY for an https target, so a browser broker fails closed on a server entry. `usage` action (v2.88.0+) shows when/where/how often a credential was used (counts + recent events for the current project). 11. **CROSS-PROJECT** (v2.31.0+): `c3_project(action='list|scan|info|search|read|edit|shell|...', project='')` — discover and operate on OTHER c3-installed projects. `list`/`scan` need no project; reads (search/read/compress/status/memory/impact/edits/validate/filter) run freely; writes (`edit`, `shell`, memory add/update/delete) require `allow_write=true` and are logged to the target project's ledger. SUB-PROJECT HIERARCHY (project = the PARENT; strict tree, one parent, up to 8 levels): reads `subprojects` (direct children), `sub_tree` (whole hierarchy), `sub_inspect` (target=path — is there a C3 project there, what is in it, who already claims it, what it claims, which nested projects under it are NOT linked yet; mutates nothing); writes `sub_add` (initializes), `sub_link` (target=path to an EXISTING project ANYWHERE on disk, incl. another drive — a child need NOT live inside the parent), `sub_remove`, `sub_cascade` (walks the whole subtree). Nested children are excluded from the parent's index; externally linked ones were never in it. A link that would make a project its own ancestor is refused. 11.5. **MASKED PATHS** (v2.63.0+): some paths are exposed but policy-TRANSFORMED. `c3_read`/`c3_compress`/`c3_search` serve a deterministic view prefixed `[c3-mask:transformed] view=`. Treat that content as evidence of STRUCTURE, not of values: literals may be synthetic, rows may be withheld, bodies may be stripped — never copy them into other files and never draw conclusions about data volume or completeness from them. Masked paths are READ-ONLY: `c3_edit` refuses, and so do `c3_shell` content reads, git content commands, `c3_validate`, `c3_impact`, `c3_filter` and `c3_delegate` (tag `[c3-mask:unsupported]`) — that is a policy decision, not a transient error, so do not route around it via another tool or the shell. A `[c3-mask:limited]` search footer means absence is not evidence a path does not exist. Rules are human-only: report the block, do not try to change it. 11.6. **CONFIRM HOLDS** (v2.97.0+): a refusal tagged `[c3-access:confirm]` is a PAUSE, not a block — either the user set that path to "ask me first" (`user`/`global` scope) or it is the builtin agent-config tier (`builtin` scope, item 12). READ THE S8 TAIL, it says which case you are in. If it names a request id, C3 filed it for you: `c3_override(action='wait', request_id='...', timeout_s=180)` — the bare call waits only 60s, and "still pending" is NOT a denial, so wait again or do unaffected work. Retry the SAME call once, on the SAME surface, only after an approval. If the S8 says no request was filed, it names the surfaces that do file (`c3_read`, `c3_edit`, native tools) — use one of them rather than asking in chat. If it says a request COULD NOT be filed, obey the reason it gives: a denied-and-muted request means do not ask again, a rate limit means withdraw one or wait; ask in chat only when the reason carries no instruction. Never retry before a decision, never route around the hold via c3_shell or another tool, and never re-file — duplicates collapse into the pending request. diff --git a/CHANGELOG.md b/CHANGELOG.md index e4d2e5b..97337f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,64 @@ All notable changes to Code Context Control (C3) are documented here. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.119.0] - 2026-09-05 + +### Added — native Codex integration: hooks, thread-bound handoffs, additive installs + +Codex and Claude Code now coexist in one repository. Installing one host no +longer strips the other's configuration, and every launcher says which host +it is instead of guessing from a parent's environment. + +- `c3 install-mcp --ide codex` installs the C3 hook dispatcher into + `.codex/hooks.json` (PreToolUse, PostToolUse, UserPromptSubmit, Stop, + SessionStart, PreCompact, SessionEnd), keeping unrelated handlers. It never + bypasses hook trust: review and trust the hooks in Codex, then restart. +- `[mcp_servers.c3]` in `.codex/config.toml` is merged with `tomlkit` + (`core/mcp_toml.merge_toml_section`): user keys, comments, subtables and an + explicit `enabled = false` survive a reinstall, and malformed TOML is an + error rather than an overwrite. New installs default to + `startup_timeout_sec = 30` and `tool_timeout_sec = 60`; existing values win. +- Every installed launcher passes `--host `. `core/host.py` resolves the + process identity from that flag, then `C3_HOST`, then the host session env, + then the project preference. Delegated children drop inherited + `CODEX_THREAD_ID` / `CLAUDE_CODE_SESSION_ID`, so a Claude child cannot + impersonate its Codex caller. `detect_host` no longer reads the parent's env. +- Codex `apply_patch` payloads are audited per target — adds, updates, deletes + and moves — through the existing pre-tool guard and edit ledger. A malformed + patch or a failed guard denies; a failed patch is not recorded as an edit. +- Thread-bound handoffs under `.c3/host_sessions/codex/`: the MCP server + checkpoints after tool responses, and SessionStart restores only the same + thread's snapshot for the same project, labelled as potentially stale. +- `c3 doctor --ide codex` reports CLI support, installed configuration and + hook activity observed for the current thread, with no model call and no + environment values in the output. +- Codex transcripts sync from `CODEX_HOME/sessions` rollout files after a + project match; reasoning records are skipped. Session stats take the latest + cumulative `token_count` and separate cached input from total input. +- Codex delegation runs `exec --json` with the prompt on stdin, an explicit + sandbox and `approval_policy="never"`; the removed `--full-auto` flag is + gone. Resume is bound to the thread id persisted for this project and + caller, never `resume --last`. + +### Changed + +- The machine-wide Codex fallback in `CODEX_HOME/config.toml` is opt-in via + `--global-fallback`. The Codex install path no longer touches Antigravity's + config or deletes a Claude `.mcp.json`. +- `.codex/hooks.json` and `AGENTS.override.md` join the agent-config confirm + tier and artifact history. +- Session ids carry a random suffix so two hosts starting in the same second + no longer collide. +- `c3_delegate` / `c3_agent` progress lines go through the MCP session + (`ctx.info`) instead of raw stdout writes. +- `c3_status(view='sessions')` shows one cumulative row per host session and + prints `n/a` where usage is unavailable instead of zero. +- New dependency: `tomlkit>=0.13.2`. + +Existing installations need one `c3 install-mcp --ide codex` (or `--ide +claude`) to receive the `--host` arguments and hooks. See +`docs/codex-native.md`. + ## [2.118.2] - 2026-09-05 ### Fixed — the WinError 5 race had four more copies, including the lock store diff --git a/CLAUDE.md b/CLAUDE.md index 876ae93..2b60546 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ When falling back, state which c3_* tool was attempted and why it was insufficie 9.5. **LOCAL CI** (v2.79.0+): `c3_ci(action='inspect|run|rerun|status|failures|logs|runs')` — run THIS repository's real `.github/workflows/*.yml` on this machine instead of pushing to find out. C3 reads the existing workflow files; it does NOT define a second CI config. `inspect` shows the job DAG and which jobs are runnable on this host; `run` executes in `needs` order and SKIPS (never passes) a job whose dependency failed; `failures` returns `{file,line,message}` instead of raw logs; `rerun` retries only what failed. VERDICTS: `FULL_CI_PASS` means every job ran HERE and passed — the only one that means safe to push. `PARTIAL_PASS` means something did not run (targets another OS, uses an action C3 cannot execute, or you selected a subset) and is NOT a green light. Jobs targeting another OS are refused unless `allow_foreign=true`, which labels the result cross-OS and can never yield FULL_CI_PASS. ENGINES (v2.81.0+): `native` runs jobs matching this host; `act` runs Linux jobs in a real container (real `uses:` actions included) when act+Docker are installed — `c3_ci(action='doctor')` reports what is available. A container run DOES count toward FULL_CI_PASS; a cross-OS one never does. macOS jobs cannot run locally on any engine. Jobs that look like they publish or deploy are refused unless allow_side_effects=true, and C3 never passes secrets to act. See docs/agent-ci.md. 10. **BITBUCKET** (when configured, v2.30.0+): `c3_bitbucket(action='...')` — for self-hosted enterprise Bitbucket Data Center / Server: PRs, branches, builds, repo admin. Tokens live in the OS keyring (set up via `c3 bitbucket login`, or `login --global` for a home config reusable across projects; account resolution precedence is project → home). Read actions are safe in plan mode; write actions (`merge_pr`, `create_branch`, etc.) are auto-logged to the edit ledger. 10.5. **JIRA** (when configured, v2.56.0+): `c3_jira(action='...')` — Jira Cloud + Data Center: `search` (raw JQL), `my_issues`, `get_issue`, `list_transitions`, `get_create_metadata`, `search_users`, `list_link_types`, `list_boards`, `list_sprints`, `list_worklogs` reads; `create_issue` / `update_issue` / `comment` / `transition` / `assign` / `link_issues` / `unlink_issues` / `move_to_sprint` / `move_to_backlog` / `add_worklog` / `attach_file` / `delete_issue` (permanent; refuses subtask-bearing issues unless delete_subtasks=true) mutations auto-logged to the edit ledger (identifiers only, never bodies). createmeta reflects the issue type's field configuration, NOT the create screen — if create_issue rejects a listed field ('not on the appropriate screen'), create without it, then set it via `update_issue`. Epic membership: pass `parent=` to create_issue/update_issue ('none' clears) — C3 maps it per deployment (Cloud `parent` field vs Data Center Epic Link customfield); `link_issues(issue, link_type, target)` creates typed links reading ' ' (types via `list_link_types`). Sprints: `list_boards` → `list_sprints(board_id)` → `move_to_sprint(issue, sprint_id)`; `attach_file(issue, file_path)` uploads a local file as evidence. Tokens in the OS keyring (`c3 jira login`, or `login --global` for cross-project reuse; the jira config section resolves project → home wholesale from one file — never field-merged). Read actions are safe in plan mode. -10.6. **CREDENTIALS** (v2.58.0+): `c3_credentials(action='...')` — named secret vault (global ~/.c3 + per-project .c3; project shadows global). `list`/`describe`/`check` return names + metadata, never values. To USE a secret do NOT reveal it — pass `env_creds='NAME1,NAME2'` to c3_shell or write `{{cred:NAME}}` inside cmd; C3 decodes at the subprocess boundary so values never enter context, and echoed values are auto-redacted to `[cred:NAME]`. `reveal` works only on entries the user marked `agent_readable` (that flag cannot be raised on an existing entry by the agent). `set`/`delete` allowed; all mutations + reveals ledger-logged by name. `import_env` (v2.93.0+) bulk-imports a .env WITHOUT you seeing a value - the server reads the file, you get names/lengths/fingerprints/reasons; `**/.env*` stays a built-in deny for your own reads. It DEFAULTS TO dry_run=true (call it bare to preview, re-run dry_run=false once the user has seen the list), is project-scope only, refuses overwrite, and refuses a path outside the project. A dry run also DIFFS (v2.94.0+): a row already matching the vault reports `unchanged`, so re-running an import that would do nothing is visible before you propose it - importing to the global vault or replacing a stored secret stays a user action. Users manage entries via the Credentials UI tab or `c3 creds`. STRUCTURED kinds (v2.87.0+: `address`/`identity`/`card`; v2.90.0+: `login`) hold named fields (card: cardholder/number/expiry[/cvc/billing_zip]; login: site_id/canonical_origin/username/password[/totp_secret]) and are inject-only: address a FIELD - `env_creds='CARD.number'` (env `$CARD_NUMBER`) or `{{cred:CARD.number}}` - reveal is permanently disabled for them, they never auto-inject, and echoes redact to `[cred:NAME.field]`. Have the user enter card/identity/address/login data via UI/CLI so it never enters the chat. `login` is STORAGE ONLY: C3 has no browser surface and MUST NOT grow one. Do not write a script that reads a login password out of the environment and types it into a page — a check you author in a process that already holds the plaintext is not a control. `canonical_origin` (https-only, stored normalized) exists so a separate, privileged runner can bind the credential to exactly one origin; that runner does not live in this package. `usage` action (v2.88.0+) shows when/where/how often a credential was used (counts + recent events for the current project). +10.6. **CREDENTIALS** (v2.58.0+): `c3_credentials(action='...')` — named secret vault (global ~/.c3 + per-project .c3; project shadows global). `list`/`describe`/`check` return names + metadata, never values. To USE a secret do NOT reveal it — pass `env_creds='NAME1,NAME2'` to c3_shell or write `{{cred:NAME}}` inside cmd; C3 decodes at the subprocess boundary so values never enter context, and echoed values are auto-redacted to `[cred:NAME]`. `reveal` works only on entries the user marked `agent_readable` (that flag cannot be raised on an existing entry by the agent). `set`/`delete` allowed; all mutations + reveals ledger-logged by name. `import_env` (v2.93.0+) bulk-imports a .env WITHOUT you seeing a value - the server reads the file, you get names/lengths/fingerprints/reasons; `**/.env*` stays a built-in deny for your own reads. It DEFAULTS TO dry_run=true (call it bare to preview, re-run dry_run=false once the user has seen the list), is project-scope only, refuses overwrite, and refuses a path outside the project. A dry run also DIFFS (v2.94.0+): a row already matching the vault reports `unchanged`, so re-running an import that would do nothing is visible before you propose it - importing to the global vault or replacing a stored secret stays a user action. Users manage entries via the Credentials UI tab or `c3 creds`. STRUCTURED kinds (v2.87.0+: `address`/`identity`/`card`; v2.90.0+: `login`) hold named fields (card: cardholder/number/expiry[/cvc/billing_zip]; login: site_id/canonical_target/username[/password/private_key/passphrase/totp_secret], one of password/private_key required) and are inject-only: address a FIELD - `env_creds='CARD.number'` (env `$CARD_NUMBER`) or `{{cred:CARD.number}}` - reveal is permanently disabled for them, they never auto-inject, and echoes redact to `[cred:NAME.field]`. Have the user enter card/identity/address/login data via UI/CLI so it never enters the chat. `login` covers websites AND servers/databases/other non-web targets (v2.118.0+): `canonical_target` is scheme://host[:port] from an allowlist (https, ssh, sftp, rdp, smb, winrm, ldaps, imaps, smtps, postgres, mysql, mssql, mongodb, redis, …), cleartext schemes refused by name. It is STORAGE ONLY: C3 has no browser surface, opens no SSH session, and MUST NOT grow either. Do not write a script that reads a login password out of the environment and types it into a page or a session — a check you author in a process that already holds the plaintext is not a control. `canonical_target` (stored normalized) exists so a separate, privileged runner can bind the credential to exactly one destination; that runner does not live in this package. `canonical_origin` still reads back but ONLY for an https target, so a browser broker fails closed on a server entry. `usage` action (v2.88.0+) shows when/where/how often a credential was used (counts + recent events for the current project). 11. **CROSS-PROJECT** (v2.31.0+): `c3_project(action='list|scan|info|search|read|edit|shell|...', project='')` — discover and operate on OTHER c3-installed projects. `list`/`scan` need no project; reads (search/read/compress/status/memory/impact/edits/validate/filter) run freely; writes (`edit`, `shell`, memory add/update/delete) require `allow_write=true` and are logged to the target project's ledger. SUB-PROJECT HIERARCHY (project = the PARENT; strict tree, one parent, up to 8 levels): reads `subprojects` (direct children), `sub_tree` (whole hierarchy), `sub_inspect` (target=path — is there a C3 project there, what is in it, who already claims it, what it claims, which nested projects under it are NOT linked yet; mutates nothing); writes `sub_add` (initializes), `sub_link` (target=path to an EXISTING project ANYWHERE on disk, incl. another drive — a child need NOT live inside the parent), `sub_remove`, `sub_cascade` (walks the whole subtree). Nested children are excluded from the parent's index; externally linked ones were never in it. A link that would make a project its own ancestor is refused. 11.5. **MASKED PATHS** (v2.63.0+): some paths are exposed but policy-TRANSFORMED. `c3_read`/`c3_compress`/`c3_search` serve a deterministic view prefixed `[c3-mask:transformed] view=`. Treat that content as evidence of STRUCTURE, not of values: literals may be synthetic, rows may be withheld, bodies may be stripped — never copy them into other files and never draw conclusions about data volume or completeness from them. Masked paths are READ-ONLY: `c3_edit` refuses, and so do `c3_shell` content reads, git content commands, `c3_validate`, `c3_impact`, `c3_filter` and `c3_delegate` (tag `[c3-mask:unsupported]`) — that is a policy decision, not a transient error, so do not route around it via another tool or the shell. A `[c3-mask:limited]` search footer means absence is not evidence a path does not exist. Rules are human-only: report the block, do not try to change it. 11.6. **CONFIRM HOLDS** (v2.97.0+): a refusal tagged `[c3-access:confirm]` is a PAUSE, not a block — either the user set that path to "ask me first" (`user`/`global` scope) or it is the builtin agent-config tier (`builtin` scope, item 12). READ THE S8 TAIL, it says which case you are in. If it names a request id, C3 filed it for you: `c3_override(action='wait', request_id='...', timeout_s=180)` — the bare call waits only 60s, and "still pending" is NOT a denial, so wait again or do unaffected work. Retry the SAME call once, on the SAME surface, only after an approval. If the S8 says no request was filed, it names the surfaces that do file (`c3_read`, `c3_edit`, native tools) — use one of them rather than asking in chat. If it says a request COULD NOT be filed, obey the reason it gives: a denied-and-muted request means do not ask again, a rate limit means withdraw one or wait; ask in chat only when the reason carries no instruction. Never retry before a decision, never route around the hold via c3_shell or another tool, and never re-file — duplicates collapse into the pending request. diff --git a/README.md b/README.md index 121f434..fc7b130 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,12 @@ c3 init /path/to/huge-repo --force --no-embed # skip the embedding index on Upgrade with `c3 upgrade` (or `pipx upgrade code-context-control`). MCP is wired through the `c3-mcp` entry point, so upgrading needs no per-project reconfiguration. +For native Codex hooks, thread continuity, and additive installation alongside +Claude Code, see the [Codex integration guide](docs/codex-native.md). Run +`c3 install-mcp --ide codex`, review hook trust in Codex, then check +`c3 doctor --ide codex`. Existing installations need this reinstall to receive +the new host arguments and hooks. + > **Upgrading from before v2.60.1 on Windows:** existing projects are *not* repaired by upgrading — re-run `c3 init` once per project to fix hook registration. Full notes: [Upgrading](https://github.com/drknowhow/code-context-control/blob/main/docs/upgrading.md) · [Contributing](https://github.com/drknowhow/code-context-control/blob/main/docs/upgrading.md#from-source-contributors) diff --git a/cli/_hook_utils.py b/cli/_hook_utils.py index 9f9117f..c87d700 100644 --- a/cli/_hook_utils.py +++ b/cli/_hook_utils.py @@ -375,30 +375,20 @@ def record_unlocked_files( HOST_CODEX = "codex" -def detect_host(data: dict) -> str: - """Identify which agent runtime sent this hook payload. - - Codex is detected by `turn_id`, which its wire schema marks required on - every turn-scoped event C3 hooks (pre/post tool use, user prompt submit, - stop) and documents as "Codex extension". Claude Code never sends it. - - Codex MUST be checked before Gemini: Codex declares `tool_response` as - free-form, so an MCP tool that returns an object would otherwise trip the - isinstance-dict test and be mistaken for Gemini. - - The CODEX_* env fallback covers a future Codex that drops `turn_id`. A - false positive there degrades gracefully — Claude Code also accepts - hookSpecificOutput.additionalContext — so guessing "codex" is the safe - direction to be wrong in. - """ +def detect_host(data: dict, explicit_host: str | None = None) -> str: + """Use the hook's explicit host or wire identity, never its parent's env.""" + named = explicit_host or (data.get("_c3_host") if isinstance(data, dict) else None) + aliases = {"claude-code": HOST_CLAUDE, "claude": HOST_CLAUDE, + "codex": HOST_CODEX, "gemini": HOST_GEMINI, "antigravity": HOST_GEMINI} + if named in aliases: + return aliases[named] if not isinstance(data, dict): return HOST_CLAUDE if data.get("turn_id"): return HOST_CODEX - if isinstance(data.get("tool_response"), dict): + response = data.get("tool_response") + if isinstance(response, dict) and ("llmContent" in response or "returnDisplay" in response): return HOST_GEMINI - if os.environ.get("CODEX_THREAD_ID") or os.environ.get("CODEX_MANAGED_BY_NPM"): - return HOST_CODEX return HOST_CLAUDE diff --git a/cli/c3.py b/cli/c3.py index 08d2ab2..d2afb3a 100644 --- a/cli/c3.py +++ b/cli/c3.py @@ -92,7 +92,7 @@ # Config CONFIG_DIR = ".c3" CONFIG_FILE = ".c3/config.json" -__version__ = "2.118.2" +__version__ = "2.119.0" def _compress_file_cli(compressor, path, mode="smart", **kw): @@ -4298,17 +4298,9 @@ def cmd_pipe(args): _COPILOT_INSTRUCTIONS_CONTENT = _VSCODE_SESSION_INIT + "\n\n" + _C3_HOOKLESS_WORKFLOW -_AGENTS_MD_CONTENT = _C3_HOOKLESS_WORKFLOW + """ +from services.codex_integration import CODEX_WORKFLOW -## IDE Configuration (Codex) -This project uses project-scoped MCP servers. Ensure your `.codex/config.toml` includes: -```toml -[mcp_servers.c3] -command = "c3-mcp" -args = ["--project", "."] -enabled = true -``` -""" +_AGENTS_MD_CONTENT = CODEX_WORKFLOW _TERSE_SKILL_CONTENT = """\ # /terse — Terse Output Mode @@ -4426,50 +4418,13 @@ def _toml_escape_str(value: str) -> str: def _upsert_toml_section(toml_path: Path, section: str, entries: dict) -> None: - """Add or replace a dotted TOML section (e.g. 'mcp_servers.c3') in-place. - - Reads the existing file, removes the old section if present, and appends - the new section at the end. Handles simple scalar and list values only. - """ - content = toml_path.read_text(encoding="utf-8") if toml_path.exists() else "" - header = f"[{section}]" - - # Strip existing section (header + its key=value lines). Also strip any - # dotted child subtables (e.g. "[mcp_servers.c3.env]" under - # "[mcp_servers.c3]") so they are not orphaned beneath the re-appended - # section, which would corrupt the file on re-run. - child_prefix = f"{header[:-1]}." # "[mcp_servers.c3]" -> "[mcp_servers.c3." - lines = content.splitlines() - new_lines: list[str] = [] - skip = False - for line in lines: - stripped = line.strip() - if stripped == header: - skip = True - continue - if skip and stripped.startswith("["): - if not stripped.startswith(child_prefix): - skip = False - if not skip: - new_lines.append(line) - - content = "\n".join(new_lines).rstrip() - - # Build new section - section_lines = [f"\n\n{header}"] - for k, v in entries.items(): - if isinstance(v, list): - # Escape each list item individually - items = ", ".join(f'"{_toml_escape_str(x)}"' for x in v) - section_lines.append(f'"{k}" = [{items}]') - elif isinstance(v, bool): - section_lines.append(f'"{k}" = {"true" if v else "false"}') - else: - section_lines.append(f'"{k}" = "{_toml_escape_str(v)}"') - section_lines.append("") - - toml_path.parent.mkdir(parents=True, exist_ok=True) - toml_path.write_text(content + "\n".join(section_lines), encoding="utf-8") + from core.mcp_toml import merge_toml_section + owned = dict(entries) + defaults = {} + for key in ("enabled", "startup_timeout_sec", "tool_timeout_sec"): + if key in owned: + defaults[key] = owned.pop(key) + merge_toml_section(toml_path, section, owned, defaults) 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, def _ensure_codex_agents_workflow(agents_md_path: Path, project_path: str = "") -> str: """Ensure AGENTS.md contains the mandatory C3 workflow for Codex sessions.""" - required_markers = [ - "C3 Tools", - "MANDATORY", - "SEARCH FIRST", - "Anti-patterns", - "c3_search", - "c3_read", - "c3_validate", - "c3_edit", - "c3_memory", - "c3_filter", - ] - return _ensure_instruction_workflow(agents_md_path, _AGENTS_MD_CONTENT, - required_markers, project_path) + from services.claude_md import write_c3_instruction_doc + existing = agents_md_path.read_text(encoding="utf-8") if agents_md_path.exists() else None + rendered = write_c3_instruction_doc(agents_md_path, _AGENTS_MD_CONTENT, project_path or None) + return "written" if existing is None else ("kept" if rendered == existing else "updated") def _ensure_vscode_instructions_workflow(instructions_path: Path, @@ -4654,6 +4599,7 @@ def _ensure_project_session_configs(target: Path, server_script: str, primary_pr else: mcp_command = "python" server_args = [server_script_posix, "--project", target.as_posix()] + server_args += ["--host", "codex"] if primary_profile != "codex": codex_path = target / ".codex" / "config.toml" @@ -4674,18 +4620,20 @@ def _ensure_project_session_configs(target: Path, server_script: str, primary_pr def _ensure_global_session_fallbacks(server_script: str, c3_mcp_exe: str | None = None, primary_profile: str | None = None) -> None: - """Keep user-global Codex/Antigravity MCP configs pointing at C3. + """Install an explicitly requested user-global Codex fallback. These fallback entries omit `--project` so the MCP server can resolve the active working directory dynamically when a session starts in a project that does not yet have a project-local Codex config file. """ + if primary_profile not in (None, "codex"): + return server_script_posix = Path(server_script).as_posix() # With the installed entry point, no script path is needed; --project stays # omitted so the server resolves the working directory at session start. - fallback_args = [] if c3_mcp_exe else [server_script_posix] + fallback_args = (["--host", "codex"] if c3_mcp_exe else [server_script_posix, "--host", "codex"]) - codex_path = Path.home() / ".codex" / "config.toml" + codex_path = Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex") / "config.toml" try: codex_state = "updated" if codex_path.exists() else "written" _upsert_toml_section( @@ -4701,27 +4649,6 @@ def _ensure_global_session_fallbacks(server_script: str, c3_mcp_exe: str | None except PermissionError: print(f"Warning: Could not update {codex_path} (global fallback skipped)") - # Antigravity shares the ~/.gemini home dir but reads its own MCP config. - # When Antigravity is the primary profile, the main install flow already - # wrote this file — here we only keep it fresh for codex installs - # on machines that have Antigravity (its config dir exists). - antigravity_path = Path.home() / ".gemini" / "antigravity" / "mcp_config.json" - if primary_profile != "antigravity" and antigravity_path.parent.is_dir(): - try: - ag_state = _upsert_json_mcp_server( - antigravity_path, - "mcpServers", - "c3", - { - "command": c3_mcp_exe or sys.executable, - "args": fallback_args, - }, - ) - print(f"{ag_state.capitalize()} {antigravity_path} (global fallback)") - except PermissionError: - print(f"Warning: Could not update {antigravity_path} (global fallback skipped)") - - # Every hook script C3 has ever registered in a settings file — the v2.42+ # dispatcher plus the pre-v2.42 per-hook scripts — so uninstall recognises an # 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): except Exception as e: print(f" Warning: Could not update {mcp_config_path}: {e}") - # Claude Code Hooks & Settings + # Per-host hooks and settings; remove only C3-owned handlers. if profile.supports_hooks and profile.settings_path: settings_path = target / profile.settings_path if settings_path.exists(): @@ -5475,9 +5402,9 @@ def cmd_install_mcp(args): # Keep the script path as a single arg; 'python' keeps the source fallback # portable across platforms. if c3_mcp_exe: - new_entry = {"command": c3_mcp_exe, "args": ["--project", "."]} + new_entry = {"command": c3_mcp_exe, "args": ["--project", ".", "--host", profile.name]} else: - new_entry = {"command": "python", "args": [server_script, "--project", "."]} + new_entry = {"command": "python", "args": [server_script, "--project", ".", "--host", profile.name]} if profile.needs_type_field: new_entry["type"] = "stdio" @@ -5489,24 +5416,7 @@ def cmd_install_mcp(args): mcp_config_path = target / profile.config_path mcp_config_path.parent.mkdir(parents=True, exist_ok=True) - # Cleanup .mcp.json if it's NOT the target config but exists (to avoid confusion) - if profile.config_path != ".mcp.json": - mcp_json_legacy = target / ".mcp.json" - if mcp_json_legacy.exists(): - try: - with open(mcp_json_legacy, 'r', encoding="utf-8") as f: - legacy_data = json.load(f) - if "mcpServers" in legacy_data and "c3" in legacy_data["mcpServers"]: - del legacy_data["mcpServers"]["c3"] - if not legacy_data["mcpServers"]: - mcp_json_legacy.unlink() - print(f" Removed obsolete {mcp_json_legacy}") - else: - with open(mcp_json_legacy, 'w', encoding="utf-8") as f: - json.dump(legacy_data, f, indent=2) - print(f" Removed C3 from obsolete {mcp_json_legacy}") - except Exception: - pass + # Integrations coexist: only the selected host owns this install. try: if profile.config_format == "toml": @@ -5518,6 +5428,9 @@ def cmd_install_mcp(args): if profile.name == "codex": # Codex supports explicit enable/disable per server. toml_entries["enabled"] = True + toml_entries["startup_timeout_sec"] = 30 + toml_entries["tool_timeout_sec"] = 60 + toml_entries["args"] += ["--host", "codex"] _upsert_toml_section( mcp_config_path, f"{profile.config_key}.c3", @@ -5548,8 +5461,7 @@ def cmd_install_mcp(args): # instead of letting it surface as anonymous out-of-band drift. from services.artifact_defs import note_pending_write note_pending_write(target, profile.config_path, "install_mcp") - if profile.name in {"codex", "antigravity"}: - _ensure_project_session_configs(target, server_script, primary_profile=profile.name, c3_mcp_exe=c3_mcp_exe) + if getattr(args, "global_fallback", False) and profile.name == "codex": _ensure_global_session_fallbacks(server_script, c3_mcp_exe=c3_mcp_exe, primary_profile=profile.name) # ── Persist IDE choice to .c3/config.json ── @@ -5559,13 +5471,18 @@ def cmd_install_mcp(args): c3_config = _safe_read_json(c3_config_path, ".c3/config.json") - c3_config["ide"] = ide_name + c3_config.setdefault("ide", ide_name) + c3_config["installed_ides"] = sorted(set(c3_config.get("installed_ides", [])) | {ide_name}) c3_config["mcp"] = {"mode": mcp_mode} with open(c3_config_path, 'w', encoding="utf-8") as f: json.dump(c3_config, f, indent=2) # ── Install hooks (Claude Code) ── - if profile.supports_hooks and profile.settings_path: + if profile.name == "codex": + from services.codex_integration import install_hooks + hook_state = install_hooks(target, sys.executable, cli_dir / "hook_dispatch.py") + print(f"Codex hooks installed; active: {hook_state['active']}. {hook_state['activation']}") + if profile.name == "claude-code" and profile.supports_hooks and profile.settings_path: settings_dir = target / Path(profile.settings_path).parent settings_dir.mkdir(parents=True, exist_ok=True) settings_path = target / profile.settings_path @@ -5851,7 +5768,7 @@ def _is_c3_prompt_hook(entry: dict) -> bool: print(f"Kept {agents_md_path} (C3 workflow present)") # Warn about a common conflict: global Codex config disables c3. - global_codex_cfg = Path.home() / ".codex" / "config.toml" + global_codex_cfg = Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex") / "config.toml" global_enabled = _toml_section_bool_value(global_codex_cfg, "mcp_servers.c3", "enabled") if global_enabled is False: print(f"Warning: {global_codex_cfg} has [mcp_servers.c3] enabled = false.") @@ -5860,10 +5777,11 @@ def _is_c3_prompt_hook(entry: dict) -> bool: _sync_project_instruction_docs(str(target), sm) # ── User-global C3 enforcement ────────────────────────────── - try: - _ensure_global_claude_md() - except Exception as e: - print(f"Warning: Could not update global CLAUDE.md: {e}") + if profile.name == "claude-code": + try: + _ensure_global_claude_md() + except Exception as e: + print(f"Warning: Could not update global CLAUDE.md: {e}") # ── Default Access Guard rules (first run only) ───────────── from services import access_guard @@ -5874,7 +5792,7 @@ def _is_c3_prompt_hook(entry: dict) -> bool: print(" (remove with: c3 access remove --kind deny --global)") # ── Install /terse skill for supported IDEs ────────────────── - if profile.name in ("claude-code", "codex"): + if profile.name == "claude-code": try: _ensure_terse_skill(profile.name) except Exception as e: @@ -9292,6 +9210,11 @@ def _launch_tui() -> None: pass +def cmd_doctor(args): + from services.codex_integration import diagnose + print(json.dumps(diagnose(Path(args.project_path).resolve()), indent=2)) + + def main(): # Force UTF-8 on the CLI streams so server-supplied text (PR titles, branch # names, diffs) and our own glyphs render cleanly on Windows cp1252 consoles @@ -9325,6 +9248,7 @@ def main(): return commands = { + "doctor": cmd_doctor, "init": cmd_init, "index": cmd_index, "compress": cmd_compress, diff --git a/cli/commands/parser.py b/cli/commands/parser.py index 005fd29..aa6f33a 100644 --- a/cli/commands/parser.py +++ b/cli/commands/parser.py @@ -17,6 +17,10 @@ def build_parser(version: str, parse_cli_ide_arg): ) subparsers = parser.add_subparsers(dest="command") + p_doctor = subparsers.add_parser("doctor", help="Inspect native Codex integration without a model call") + p_doctor.add_argument("project_path", nargs="?", default=".") + p_doctor.add_argument("--ide", choices=["codex"], default="codex") + p_init = subparsers.add_parser("init", help="Initialize C3 for a project") p_init.add_argument("project_path", nargs="?", default=".") 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): p_install_mcp.add_argument("targets", nargs="*", help="Optional project path and/or IDE shorthand (for example: `claude` or `. codex`)") 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)") p_install_mcp.add_argument("--mcp-mode", choices=["direct", "proxy"], default="direct", help="MCP entrypoint mode (default: direct)") + p_install_mcp.add_argument("--global-fallback", action="store_true", help="Also install the machine-wide Codex fallback") p_install_mcp.add_argument("--permissions", choices=["read-only", "c3-strict", "standard", "permissive"], default=None, help="Apply Claude Code permission tier (Claude Code only)") 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") @@ -144,6 +149,7 @@ def build_parser(version: str, parse_cli_ide_arg): p_mcp_install.add_argument("targets", nargs="*", help="Optional project path and/or IDE shorthand") 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)") p_mcp_install.add_argument("--mcp-mode", choices=["direct", "proxy"], default="direct", help="MCP entrypoint mode (default: direct)") + p_mcp_install.add_argument("--global-fallback", action="store_true", help="Also install the machine-wide Codex fallback") p_mcp_install.add_argument("--permissions", choices=["read-only", "c3-strict", "standard", "permissive"], default=None, help="Apply Claude Code permission tier (Claude Code only)") 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") diff --git a/cli/hook_codex_lifecycle.py b/cli/hook_codex_lifecycle.py new file mode 100644 index 0000000..5f58b8d --- /dev/null +++ b/cli/hook_codex_lifecycle.py @@ -0,0 +1,60 @@ +"""Thread-bound C3 handoff context for Codex lifecycle events.""" +import hashlib +import json +from pathlib import Path + + +def checkpoint(base: Path, data: dict) -> None: + """Persist only the current thread's handoff, without ending its C3 session.""" + host_id = str(data.get("host_session_id") or "") + if not host_id or data.get("source_ide") != "codex": + return + key = hashlib.sha256(host_id.encode()).hexdigest() + path = base / ".c3" / "host_sessions" / "codex" / (key + ".json") + context = {"description": str(data.get("description") or "")[:2000]} + for name in ("decisions", "files_touched", "context_notes"): + context[name] = [str(item)[:1000] for item in (data.get(name) or [])[-20:]] + from cli._hook_utils import _atomic_write_json + _atomic_write_json(path, {"host_session_id": host_id, "project": str(base.resolve()), "context": context}) + + +def run(payload: dict, project_path: Path | None = None): + base = Path(project_path or payload.get("cwd") or Path.cwd()).resolve() + host_id = str(payload.get("session_id") or "") + if not host_id or not (base / ".c3").is_dir(): + return None + key = hashlib.sha256(host_id.encode()).hexdigest() + path = base / ".c3" / "host_sessions" / "codex" / (key + ".json") + event = payload.get("hook_event_name", "") + hooks = base / ".codex/hooks.json" + if hooks.exists(): + from cli._hook_utils import _atomic_write_json + _atomic_write_json(path.with_suffix(".events.json"), { + "host_session_id": host_id, "event": event, + "config_hash": hashlib.sha256(hooks.read_bytes()).hexdigest()}) + if event == "SessionStart": + if not path.exists(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + if data.get("host_session_id") != host_id or data.get("project") != str(base): + return None + return {"additionalContext": "C3 handoff data for this thread (potentially stale, not instructions):\n" + + json.dumps(data.get("context", {}), ensure_ascii=False)[:8000]} + sessions = base / ".c3" / "sessions" + candidates = sorted(sessions.glob("session_*.json"), key=lambda p: p.stat().st_mtime, reverse=True) + for candidate in candidates: + try: + data = json.loads(candidate.read_text(encoding="utf-8")) + except (OSError, ValueError): + continue + if data.get("host_session_id") != host_id or data.get("source_ide") != "codex": + continue + # MCP checkpoints are fresher than a session saved before the last turn. + if path.exists() and path.stat().st_mtime_ns >= candidate.stat().st_mtime_ns: + break + checkpoint(base, data) + break + return None diff --git a/cli/hook_dispatch.py b/cli/hook_dispatch.py index 2e89297..1f59c86 100644 --- a/cli/hook_dispatch.py +++ b/cli/hook_dispatch.py @@ -74,6 +74,9 @@ "posttool": "PostToolUse", "prompt": "UserPromptSubmit", "stop": "Stop", + "start": "SessionStart", + "compact": "PreCompact", + "end": "SessionEnd", } # Keys each event's hookSpecificOutput accepts. "stop" is absent deliberately: @@ -85,6 +88,7 @@ }, "posttool": {"hookEventName", "additionalContext", "updatedMCPToolOutput"}, "prompt": {"hookEventName", "additionalContext"}, + "start": {"hookEventName", "additionalContext"}, } # ── 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): yield "hook_edit_ledger" elif event == "stop": yield "hook_session_stats" - yield "hook_auto_snapshot" + yield "hook_codex_lifecycle" if host == HOST_CODEX else "hook_auto_snapshot" yield "hook_terse_advisor" elif event == "prompt": yield "hook_prompt_recall" + elif event in ("start", "compact", "end") and host == HOST_CODEX: + yield "hook_codex_lifecycle" _RUN_CACHE: dict = {} @@ -413,7 +419,7 @@ def merge_outputs(outputs: list, warnings: list, is_gemini: bool = False, return result or None -def dispatch(event: str, payload: dict, project_path: Path | None = None) -> dict | None: +def _collect(event: str, payload: dict, project_path: Path | None = None): """Run all applicable sub-hooks in-process and merge their outputs.""" raw_tool = str(payload.get("tool_name", "")) norm_tool = normalize_tool_name(raw_tool) @@ -457,6 +463,39 @@ def dispatch(event: str, payload: dict, project_path: Path | None = None) -> dic if event == "pretool" and outputs and _is_deny(outputs[-1]): break + return outputs, warnings + + +def dispatch(event: str, payload: dict, project_path: Path | None = None) -> dict | None: + host = detect_host(payload) + payload = {**payload, "hook_event_name": payload.get("hook_event_name") or _CODEX_EVENT_NAMES.get(event, event)} + if payload.get("tool_name") == "apply_patch" and event in ("pretool", "posttool"): + from core.codex_patch import patch_targets + base = Path(project_path or payload.get("cwd") or Path.cwd()).resolve() + try: + targets = patch_targets((payload.get("tool_input") or {}).get("command"), base) + except Exception as exc: + out = _fail_closed_deny("apply_patch", str(exc)) if event == "pretool" else {"_text": "[c3:hook-error] Could not audit malformed apply_patch payload"} + return merge_outputs([out], [], event=event, host=host) + if event == "posttool" and _hook_utils.tool_response_failed(payload): + return None + if event == "posttool": + response, _ = _hook_utils.get_tool_output(payload) + if str(response).lstrip().lower().startswith(("failed", "error", "apply_patch verification failed")): + return None + outputs, warnings = [], [] + for target in targets: + translated = {**payload, "tool_name": "Write" if target.change_type == "created" else "Edit", + "_c3_host": host, "_c3_change_type": target.change_type, + "_c3_original_tool": "apply_patch", + "tool_input": {"file_path": target.path}} + found, notices = _collect(event, translated, base) + outputs.extend(found) + warnings.extend(notices) + if event == "pretool" and any(_is_deny(o) for o in found): + break + else: + outputs, warnings = _collect(event, payload, project_path) if event == "pretool": outputs = _settle_grants(outputs) return merge_outputs(outputs, warnings, event=event, host=host) @@ -466,8 +505,14 @@ def main() -> None: # Windows hook subprocesses get cp1252 pipes; sub-hook _text may carry # box-drawing chars / emoji, so print(text) below would UnicodeEncodeError. _hook_utils.ensure_utf8_stdio() - event = sys.argv[1].strip().lower() if len(sys.argv) > 1 else "" - if event not in VALID_EVENTS: + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("event", nargs="?", default="") + parser.add_argument("--host", choices=["codex", "claude", "claude-code", "gemini"]) + parser.add_argument("--project") + args = parser.parse_args() + event = args.event.strip().lower() + if event not in (*VALID_EVENTS, "start", "compact", "end"): log_hook_error( "hook_dispatch", ValueError(f"unknown or missing event arg: {event!r} (expected {VALID_EVENTS})"), @@ -477,18 +522,28 @@ def main() -> None: try: raw = sys.stdin.read() payload = json.loads(raw) if raw.strip() else {} + if not isinstance(payload, dict): + raise ValueError("Hook payload must be a JSON object") except Exception as exc: log_hook_error("hook_dispatch", exc) + if event == "pretool": + print("C3 could not validate the hook payload", file=sys.stderr) + raise SystemExit(2) return try: - output = dispatch(event, payload) + if args.host: + payload["_c3_host"] = args.host + output = dispatch(event, payload, Path(args.project).resolve() if args.project else None) except Exception as exc: # The dispatcher itself failing is critical — say so. Route the notice # through merge_outputs so even the panic path speaks the host's # dialect: a raw {"additionalContext": ...} is itself invalid on Codex, # which would turn one crash into two error reports. log_hook_error("hook_dispatch", exc) + if event == "pretool": + print("C3 could not complete the pre-tool checks", file=sys.stderr) + raise SystemExit(2) notice = { "additionalContext": ( f"[c3:hook-error] hook_dispatch: {type(exc).__name__}: {exc}; " diff --git a/cli/hook_edit_ledger.py b/cli/hook_edit_ledger.py index ae924f1..163bcc6 100644 --- a/cli/hook_edit_ledger.py +++ b/cli/hook_edit_ledger.py @@ -150,7 +150,7 @@ def run(payload: dict, project_path: Path | None = None) -> dict | None: return None # Filter to editable extensions - if Path(file_path).suffix.lower() not in EDITABLE_EXTS: + if payload.get("_c3_original_tool") != "apply_patch" and Path(file_path).suffix.lower() not in EDITABLE_EXTS: return None if project_path is None: @@ -177,7 +177,7 @@ def run(payload: dict, project_path: Path | None = None) -> dict | None: now = datetime.now(timezone.utc) tool_input = payload.get("tool_input", {}) or {} - change_type = _detect_change_type(tool_name, tool_input) + change_type = payload.get("_c3_change_type") or _detect_change_type(tool_name, tool_input) # Git info is enriched asynchronously by EditLedgerEnricherAgent. # 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: # server process (services/edit_ledger.py) write within the same second. "id": f"edit_{now.strftime('%Y%m%d_%H%M%S')}_{_next_seq(ledger_file, now):03d}_{uuid.uuid4().hex[:4]}", "timestamp": now.isoformat(), - "session_id": "", + "session_id": payload.get("session_id", ""), "file": rel, "change_type": change_type, "summary": change_type if tracking_level == "minimal" else _extract_summary(tool_name, tool_input), diff --git a/cli/hook_session_stats.py b/cli/hook_session_stats.py index 4ba5302..a4fc838 100644 --- a/cli/hook_session_stats.py +++ b/cli/hook_session_stats.py @@ -100,6 +100,32 @@ def read_transcript_usage(transcript_path) -> dict: def run(payload: dict, project_path: Path | None = None): """Core logic — importable by the dispatcher and tests. Returns None.""" + from cli._hook_utils import HOST_CODEX, detect_host + if detect_host(payload) == HOST_CODEX: + from services.codex_transcript import read_rollout + base = Path(project_path or payload.get("cwd") or Path.cwd()).resolve() + transcript = payload.get("transcript_path") + data = None + if transcript and payload.get("session_id"): + try: + data = read_rollout(Path(transcript), base, str(payload["session_id"]), max_turns=0) + except (OSError, ValueError): + pass + usage = (data or {}).get("usage") + row = {"ts": datetime.now(timezone.utc).isoformat(), "session_id": payload.get("session_id"), + "provider": "codex", "source": "transcript" if usage is not None else "none", + "usage_available": bool((data or {}).get("usage_available")), "usage": usage, + # Codex input includes cached input; C3's cross-provider fields + # count cache reads separately, as Claude's wire format does. + "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, + "input_tokens_including_cache": usage.get("input_tokens") if usage is not None else None, + "output_tokens": usage.get("output_tokens") if usage is not None else None, + "cache_read_tokens": usage.get("cached_input_tokens") if usage is not None else None, + "cache_creation_tokens": None, "cost_usd": None, "model": (data or {}).get("model", "")} + if (base / ".c3").is_dir(): + with (base / ".c3" / "session_stats.jsonl").open("a", encoding="utf-8") as stream: + stream.write(json.dumps(row) + "\n") + return None usage = payload.get("usage") or {} if isinstance(usage, dict) and any(usage.get(src) for src, _ in _USAGE_FIELDS): # A future Claude Code that does send usage wins over re-reading. diff --git a/cli/mcp_proxy.py b/cli/mcp_proxy.py index ddb665a..12de900 100644 --- a/cli/mcp_proxy.py +++ b/cli/mcp_proxy.py @@ -22,7 +22,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from core.config import load_proxy_config -from core.ide import get_profile, load_ide_config +from core.ide import get_profile from services.ollama_client import OllamaClient from services.proxy_state import ProxyState from services.tool_classifier import ToolClassifier @@ -31,7 +31,7 @@ class MCPProxy: """Bidirectional JSON-RPC proxy with optional tool filtering and context injection.""" - def __init__(self, project_path: str): + def __init__(self, project_path: str, host: str | None = None): self.project_path = project_path self.config = load_proxy_config(project_path) self.disabled = self.config.get("PROXY_DISABLE", False) @@ -40,7 +40,8 @@ def __init__(self, project_path: str): self.state_tracking_enabled = self.filtering_enabled or self.context_injection_enabled # Identify the IDE - self.ide_name = load_ide_config(project_path) + from core.host import resolve_host + self.ide_name = resolve_host(project_path, host).provider self.ide_profile = get_profile(self.ide_name) # Initialize Ollama client (optional, for SLM classification) @@ -100,7 +101,7 @@ async def run(self): self._process = await asyncio.create_subprocess_exec( python_exe, server_script, - "--project", self.project_path, + "--project", self.project_path, "--host", self.ide_name, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, @@ -418,9 +419,9 @@ def main(): parser = argparse.ArgumentParser(description="C3 MCP Proxy") parser.add_argument("--project", required=True, help="Project root path") + parser.add_argument("--host") args = parser.parse_args() - - proxy = MCPProxy(args.project) + proxy = MCPProxy(args.project, host=args.host) asyncio.run(proxy.run()) diff --git a/cli/mcp_server.py b/cli/mcp_server.py index cd0a064..e5719f6 100644 --- a/cli/mcp_server.py +++ b/cli/mcp_server.py @@ -23,7 +23,8 @@ from fastmcp import Context, FastMCP -from core.ide import get_profile, load_ide_config +from core.host import resolve_host +from core.ide import get_profile from services.auto_memory import AutoMemory from services.context_snapshot import ContextSnapshot from services.runtime import C3Runtime, build_runtime, start_runtime, stop_runtime @@ -68,7 +69,10 @@ def _get_project_path() -> str: PROJECT_PATH = _get_project_path() -_IDE_NAME = load_ide_config(PROJECT_PATH) +_host_parser = argparse.ArgumentParser(add_help=False) +_host_parser.add_argument("--host") +_HOST = resolve_host(PROJECT_PATH, _host_parser.parse_known_args()[0].host) +_IDE_NAME = _HOST.provider _IDE_PROFILE = get_profile(_IDE_NAME) @@ -114,7 +118,7 @@ async def lifespan(server): snapshots = services.snapshots or ContextSnapshot(project) services.snapshots = snapshots - if _IDE_PROFILE.supports_transcripts: + if _IDE_NAME == "claude-code": if not (Path(project) / ".c3" / "transcript_index" / "index.json").exists(): transcript_index.build_index() else: @@ -187,6 +191,8 @@ def _bg_doc_index(): threading.Thread(target=_bg_doc_index, daemon=True, name="c3-doc-index").start() + services.host_context = _HOST + services.session_mgr.host_context = _HOST started_session = services.session_mgr.start_session("MCP server session", source_system=_IDE_NAME) start_runtime(services) @@ -194,7 +200,7 @@ def _bg_doc_index(): services.convo_store = convo_store if _IDE_PROFILE.supports_transcripts: try: - convo_store.sync(source="claude") + convo_store.sync(source="codex" if _IDE_NAME == "codex" else "claude") if services.retrieval: services.retrieval.mark_sessions_dirty() except Exception: @@ -206,7 +212,7 @@ def _bg_doc_index(): def _bg_convo_sync(): while not _convo_sync_stop.wait(timeout=60): try: - convo_store.sync(source="claude") + convo_store.sync(source="codex" if _IDE_NAME == "codex" else "claude") if services.retrieval: services.retrieval.mark_sessions_dirty() except Exception: @@ -226,7 +232,7 @@ def _bg_convo_sync(): # Auto-restore latest snapshot if recent (< 30 min). # Deferred to background thread so first tool call isn't blocked. # Skipped in benchmark mode to prevent snapshot budget from carrying over between tasks. - if not os.environ.get("C3_BENCHMARK_MODE"): + if _IDE_NAME != "codex" and not os.environ.get("C3_BENCHMARK_MODE"): import threading as _restore_t def _bg_auto_restore(): @@ -318,7 +324,7 @@ def _bg_validation_sweep(): services.session_mgr.save_session() if _IDE_PROFILE.supports_transcripts: try: - convo_store.sync(source="claude", force=True) + convo_store.sync(source="codex" if _IDE_NAME == "codex" else "claude", force=True) if services.retrieval: services.retrieval.mark_sessions_dirty() except Exception: @@ -434,6 +440,14 @@ def _finalize_response(ctx: Context, tool_name: str, args: dict, except Exception: pass svc.session_mgr.track_response(tool_name, persisted_response, response_tokens=response_tokens) + if svc.ide_name == "codex": + from cli.hook_codex_lifecycle import checkpoint + try: + with _finalize_lock: + checkpoint(Path(svc.project_path), svc.session_mgr.current_session or {}) + except (OSError, ValueError, TypeError): + print("[c3:handoff] Could not persist Codex checkpoint", file=sys.stderr) + hybrid_cfg = svc.hybrid_config or {} @@ -658,23 +672,12 @@ async def c3_delegate(task: str, task_type: str = "ask", context: str = "", def finalize(name, args, resp, summ, **kw): return _finalize_response(ctx, name, args, resp, summ, **kw) - # Wire progress notifications (same direct-stdout approach as c3_agent) - import json as _json - import threading as _threading - _stdout_lock = _threading.Lock() - + loop = asyncio.get_running_loop() def _progress_cb(message: str): - try: - line = _json.dumps({ - "jsonrpc": "2.0", - "method": "notifications/message", - "params": {"level": "info", "data": message}, - }, separators=(",", ":")) + "\n" - with _stdout_lock: - sys.stdout.buffer.write(line.encode("utf-8")) - sys.stdout.buffer.flush() - except Exception: - pass + def schedule(): + task = loop.create_task(ctx.info(message)) + task.add_done_callback(lambda done: done.exception() if not done.cancelled() else None) + loop.call_soon_threadsafe(schedule) svc._agent_progress_cb = _progress_cb try: return await asyncio.to_thread(handle_delegate, task, task_type, context, @@ -696,29 +699,11 @@ async def c3_agent(workflow: str, scope: str = "", context: str = "", def finalize(name, args, resp, summ, **kw): return _finalize_response(ctx, name, args, resp, summ, **kw) - # Wire live progress notifications: _log_progress calls this from the worker thread. - # We write raw JSON-RPC notifications directly to stdout instead of going through - # the async transport (session.send_log_message / ctx.info). The async approach fails - # because asyncio.run_coroutine_threadsafe coroutines never complete in time — the - # event loop is technically free (awaiting to_thread) but the transport write stalls. - # Direct stdout writes are safe here because the event loop is idle during to_thread - # (no concurrent transport writes until the tool response is sent after to_thread returns). - import json as _json - import threading as _threading - _stdout_lock = _threading.Lock() - def _progress_cb(message: str): - try: - line = _json.dumps({ - "jsonrpc": "2.0", - "method": "notifications/message", - "params": {"level": "info", "data": message}, - }, separators=(",", ":")) + "\n" - with _stdout_lock: - sys.stdout.buffer.write(line.encode("utf-8")) - sys.stdout.buffer.flush() - except Exception: - pass + def schedule(): + task = loop.create_task(ctx.info(message)) + task.add_done_callback(lambda done: done.exception() if not done.cancelled() else None) + loop.call_soon_threadsafe(schedule) svc._agent_progress_cb = _progress_cb try: return await asyncio.to_thread(handle_agent, workflow, scope, context, svc, finalize) diff --git a/cli/tools/delegate.py b/cli/tools/delegate.py index d58f8ca..0119124 100644 --- a/cli/tools/delegate.py +++ b/cli/tools/delegate.py @@ -6,6 +6,7 @@ """ import hashlib +import json import logging import os import shutil @@ -53,7 +54,7 @@ def _kill_proc_tree(proc): pass -def _communicate_with_heartbeat(proc, timeout=45, idle_timeout=15): +def _communicate_with_heartbeat(proc, timeout=45, idle_timeout=15, stdin_text=None): """communicate() replacement with idle-activity watchdog. Monitors both stdout and stderr for activity. If neither stream produces @@ -82,6 +83,14 @@ def _read_stream(stream, parts, track_activity=False): t_err = threading.Thread(target=_read_stream, args=(proc.stderr, stderr_parts, True), daemon=True) t_out.start() t_err.start() + if stdin_text is not None: + def _feed(): + try: + proc.stdin.write(stdin_text) + proc.stdin.close() + except (OSError, ValueError): + pass + threading.Thread(target=_feed, daemon=True).start() deadline = time.time() + timeout status = "ok" @@ -112,7 +121,7 @@ def _popen_kwargs(): return kwargs -def _probe_cli_version(exe: str, timeout: int = 10): +def _probe_cli_version(exe: str, timeout: int = 10, args: list[str] | None = None): """Run `` --version`` without subprocess.run's timeout footgun. ``subprocess.run(cmd, capture_output=True, timeout=N)`` reads as safe and @@ -135,7 +144,7 @@ def _probe_cli_version(exe: str, timeout: int = 10): """ timed_out = False proc = subprocess.Popen( - harden_win_argv([exe, "--version"]), + harden_win_argv([exe, *(args if args is not None else ["--version"])]), stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", @@ -281,6 +290,7 @@ def _run_claude(task: str, context: str, cwd: str | None = None, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", cwd=cwd, + env=_child_host_env("claude-code"), **_popen_kwargs(), ) output, err, status = _communicate_with_heartbeat( @@ -621,79 +631,106 @@ def _codex_cmd(prompt: str, model: str, sandbox: str, reasoning: str) -> list: cmd += [ "--config", f"model_reasoning_effort={reasoning}", "--sandbox", sandbox, - "--full-auto", + "--config", 'approval_policy="never"', + "--json", "--skip-git-repo-check", prompt, ] return cmd -def _run_codex(task: str, context: str, model: str, sandbox: str, - reasoning: str = "high", timeout: int = 120, - idle_timeout: int = 20, - cwd: str | None = None) -> tuple[str, bool]: - """Run codex exec as a subprocess. Returns (output, success). +def _child_host_env(provider: str) -> dict: + env = os.environ.copy() + for name in ("CODEX_THREAD_ID", "CODEX_MANAGED_BY_NPM", "CLAUDE_CODE_SESSION_ID"): + env.pop(name, None) + env["C3_HOST"] = provider + return env - Uses heartbeat monitor: kills process if no stderr activity for idle_timeout - seconds (catches MCP startup hangs). Also enforces total timeout. - """ - prompt = f"{task}\n\nContext:\n{context}" if context else task - cmd = _codex_cmd(prompt, model, sandbox, reasoning) + +def _delegate_binding(cwd, origin_id=""): + from core.host import resolve_host + host = resolve_host(str(cwd or Path.cwd())) + project = str(Path(cwd or Path.cwd()).resolve()) + origin = origin_id or host.host_session_id or f"pid-{os.getpid()}" + key = hashlib.sha256((project + "\0" + origin).encode()).hexdigest() + return Path.home() / ".c3" / "delegate_sessions" / (key + ".json"), project, origin + + +def _codex_result(stdout: str) -> tuple[str, str, bool]: + thread_id, messages, completed, error = "", [], False, "" + for line in stdout.splitlines(): + try: + event = json.loads(line) + except ValueError: + continue + if not isinstance(event, dict): + continue + kind = event.get("type") + if kind == "thread.started": + import uuid + try: + thread_id = str(uuid.UUID(event.get("thread_id", ""))) + except (ValueError, TypeError, AttributeError): + pass + elif kind == "item.completed": + item = event.get("item") or {} + if isinstance(item, dict) and item.get("type") == "agent_message": + messages.append(str(item.get("text") or "")) + elif kind == "turn.completed": + completed = True + elif kind in ("turn.failed", "error"): + error = str(event.get("error") or event.get("message") or "Codex turn failed") + if error or not completed: + return "[codex:error] " + (error or "No completed turn in Codex event stream"), thread_id, False + return "\n\n".join(messages).strip(), thread_id, True + + +def _execute_codex(cmd, prompt, timeout, idle_timeout, cwd, origin_id="", resume_id=""): try: - proc = subprocess.Popen( - harden_win_argv(cmd), - stdout=subprocess.PIPE, stderr=subprocess.PIPE, - stdin=subprocess.DEVNULL, - text=True, encoding="utf-8", errors="replace", - cwd=cwd, - **_popen_kwargs(), - ) + proc = subprocess.Popen(harden_win_argv(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, + stdin=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", + cwd=cwd, env=_child_host_env("codex"), **_popen_kwargs()) stdout, stderr, status = _communicate_with_heartbeat( - proc, timeout=timeout, idle_timeout=idle_timeout, - ) - if status == "idle_timeout": - return (f"[codex:idle_timeout] No stderr activity for {idle_timeout}s " - f"(likely MCP startup hang)"), False - if status == "timeout": - return f"[codex:timeout] No response after {timeout}s", False - + proc, timeout=timeout, idle_timeout=idle_timeout, stdin_text=prompt) + if status != "ok": + return f"[codex:{status}] Delegation exceeded its execution budget", False if proc.returncode != 0: - err = stderr.strip() if stderr else f"exit code {proc.returncode}" - return f"[codex:error] {err}", False + return "[codex:error] " + (stderr.strip()[-4000:] or f"exit code {proc.returncode}"), False + answer, thread_id, ok = _codex_result(stdout) + if resume_id and thread_id and resume_id != thread_id: + return "[codex:error] Resumed thread identity did not match", False + if ok and (thread_id or resume_id): + path, project, origin = _delegate_binding(cwd, origin_id) + from cli._hook_utils import _atomic_write_json + _atomic_write_json(path, {"project": project, "origin": origin, "thread_id": thread_id or resume_id}) + return answer, ok + except Exception as exc: + return f"[codex:error] {exc}", False - return stdout.strip(), True - except FileNotFoundError: - return "[codex:error] codex CLI not found on PATH", False - except Exception as e: - return f"[codex:error] {e}", False + +def _run_codex(task: str, context: str, model: str, sandbox: str, + reasoning: str = "high", timeout: int = 120, + idle_timeout: int = 0, cwd: str | None = None, + origin_id: str = "") -> tuple[str, bool]: + prompt = f"{task}\n\nContext:\n{context}" if context else task + return _execute_codex(_codex_cmd("-", model, sandbox, reasoning), prompt, + timeout, idle_timeout, cwd, origin_id) def _run_codex_resume(follow_up: str, timeout: int = 120, - cwd: str | None = None) -> tuple[str, bool]: - """Resume last Codex session with a follow-up prompt.""" - cmd = ["codex", "exec", "--skip-git-repo-check", "resume", "--last"] + cwd: str | None = None, origin_id: str = "") -> tuple[str, bool]: + path, project, origin = _delegate_binding(cwd, origin_id) try: - proc = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, stderr=subprocess.PIPE, - stdin=subprocess.PIPE, - text=True, encoding="utf-8", errors="replace", - cwd=cwd, - **_popen_kwargs(), - ) - try: - stdout, stderr = proc.communicate(input=follow_up, timeout=timeout) - except subprocess.TimeoutExpired: - _kill_proc_tree(proc) - return f"[codex:timeout] Resume timed out after {timeout}s", False - - if proc.returncode != 0: - err = stderr.strip() if stderr else f"exit code {proc.returncode}" - return f"[codex:error] {err}", False - - return stdout.strip(), True - except Exception as e: - return f"[codex:error] {e}", False + import uuid + saved = json.loads(path.read_text(encoding="utf-8")) + if saved.get("project") != project or saved.get("origin") != origin: + raise ValueError("delegate binding mismatch") + thread_id = str(uuid.UUID(saved["thread_id"])) + except (OSError, ValueError, KeyError, TypeError): + return "[codex:error] No Codex delegate thread bound to this project and caller; start a delegation first", False + cmd = [_which("codex") or "codex", "exec", "resume", "--skip-git-repo-check", + "--config", 'approval_policy="never"', "--json", thread_id, "-"] + return _execute_codex(cmd, follow_up, timeout, 0, cwd, origin_id, resume_id=thread_id) # Delegate task definitions @@ -991,7 +1028,9 @@ def _handle_codex_delegate(task: str, task_type: str, context: str, enriched = enriched[:max_ctx * 4] # Cache check - ckey = hashlib.md5(f"codex|{task_type}|{model}|{enriched}|{task}".encode()).hexdigest() + from cli.tools._grants import session_id + origin = session_id(svc) + ckey = hashlib.md5(f"codex|{svc.project_path}|{origin}|{task_type}|{model}|{enriched}|{task}".encode()).hexdigest() if ckey in _delegate_cache: cached_resp, _ = _delegate_cache[ckey] return finalize("c3_delegate", {"task_type": task_type, "backend": "codex", "cached": True}, @@ -1004,7 +1043,7 @@ def _handle_codex_delegate(task: str, task_type: str, context: str, task=task, context=enriched, model=model, sandbox=sandbox, reasoning=reasoning, timeout=timeout, - cwd=str(svc.project_path), + cwd=str(svc.project_path), origin_id=origin, ) elapsed = round(time.monotonic() - t0, 1) @@ -1279,8 +1318,9 @@ def _check_claude(): "codex directly.", "blocked") timeout = int(dcfg.get("codex_timeout", 120)) + from cli.tools._grants import session_id output, ok = _run_codex_resume(task, timeout=timeout, - cwd=str(svc.project_path)) + cwd=str(svc.project_path), origin_id=session_id(svc)) return finalize("c3_delegate", {"task_type": "codex_resume"}, output, "ok" if ok else "error") diff --git a/cli/tools/status.py b/cli/tools/status.py index 0771c7e..88539bd 100644 --- a/cli/tools/status.py +++ b/cli/tools/status.py @@ -131,6 +131,11 @@ def _budget_view(svc, detailed, finalize): def _health_view(svc, finalize): parts = [] + if getattr(svc, "ide_name", "") == "codex": + from services.codex_integration import hook_state + current = getattr(svc.session_mgr, "current_session", None) or {} + state = hook_state(Path(svc.project_path), str(current.get("host_session_id") or "")) + parts.append(f"[host] codex hooks supported:{state['supported']} installed:{state['installed']} active:{state['active']}") ollama_ok = svc.ollama_client and svc.ollama_client.is_available() models = svc.ollama_client.list_models() if ollama_ok else [] parts.append(f"[ollama] {'up (' + str(len(models)) + ' models)' if ollama_ok else 'unavailable'}") @@ -257,23 +262,28 @@ def _sessions_view(svc, finalize): if not entries: return finalize("c3_status", {"view": "sessions"}, "[sessions] No sessions recorded yet.", "empty") - recent = entries[-20:] # last 20 sessions + latest = {} + for index, entry in enumerate(entries): + key = (entry.get("provider", "claude"), entry.get("session_id") or f"row-{index}") + latest[key] = entry + recent = list(latest.values())[-20:] # cumulative snapshots, one per host session total_cost = sum(e.get("cost_usd") or 0 for e in recent) total_in = sum(e.get("input_tokens") or 0 for e in recent) total_out = sum(e.get("output_tokens") or 0 for e in recent) total_cache_read = sum(e.get("cache_read_tokens") or 0 for e in recent) lines = [f"# Session Stats (last {len(recent)} of {len(entries)} sessions)"] - lines.append(f"Total cost: ${total_cost:.4f} | In: {total_in:,} Out: {total_out:,} Cache-read: {total_cache_read:,}") + cost_total = f"${total_cost:.4f}" if any(e.get("cost_usd") is not None for e in recent) else "unavailable" + lines.append(f"Total cost: {cost_total} | Known In: {total_in:,} Out: {total_out:,} Cache-read: {total_cache_read:,}") lines.append("") lines.append(f"{'Date':<22} {'Cost':>8} {'In':>8} {'Out':>6} {'Cache-rd':>9} Stop") lines.append("-" * 64) for e in reversed(recent): ts = (e.get("ts") or "")[:19].replace("T", " ") - cost = f"${e.get('cost_usd') or 0:.4f}" - inp = f"{e.get('input_tokens') or 0:,}" - out = f"{e.get('output_tokens') or 0:,}" - cr = f"{e.get('cache_read_tokens') or 0:,}" + cost = f"${e['cost_usd']:.4f}" if e.get("cost_usd") is not None else "n/a" + inp = f"{e['input_tokens']:,}" if e.get("input_tokens") is not None else "n/a" + out = f"{e['output_tokens']:,}" if e.get("output_tokens") is not None else "n/a" + cr = f"{e['cache_read_tokens']:,}" if e.get("cache_read_tokens") is not None else "n/a" reason = e.get("stop_reason") or "" lines.append(f"{ts:<22} {cost:>8} {inp:>8} {out:>6} {cr:>9} {reason}") diff --git a/core/codex_patch.py b/core/codex_patch.py new file mode 100644 index 0000000..9144389 --- /dev/null +++ b/core/codex_patch.py @@ -0,0 +1,46 @@ +"""Extract native apply_patch targets without evaluating shell or patch content.""" +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class PatchTarget: + path: str + change_type: str + + +def patch_targets(command: str, cwd: Path) -> list[PatchTarget]: + if not isinstance(command, str) or len(command) > 8 * 1024 * 1024: + raise ValueError("missing or oversized patch") + lines = command.strip().splitlines() + if len(lines) < 3 or lines[0] != "*** Begin Patch" or lines[-1] != "*** End Patch": + raise ValueError("expected a complete apply_patch envelope") + targets = {} + current = None + operation = None + moved = False + for line in lines[1:-1]: + if line.startswith(("*** Add File: ", "*** Update File: ", "*** Delete File: ")): + header, name = line.split(": ", 1) + if not name.strip() or "\x00" in name: + raise ValueError("empty or invalid patch path") + current = str((cwd / name).resolve()) + operation = {"*** Add File": "created", "*** Update File": "modified", + "*** Delete File": "deleted"}[header] + moved = False + targets[current] = PatchTarget(current, operation) + elif line.startswith("*** Move to: "): + name = line[len("*** Move to: "):] + if current is None or operation != "modified" or moved or not name.strip() or "\x00" in name: + raise ValueError("invalid move target") + destination = str((cwd / name).resolve()) + targets[current] = PatchTarget(current, "deleted") + targets[destination] = PatchTarget(destination, "created") + moved = True + elif line.startswith("***") and line != "*** End of File": + raise ValueError("unknown patch directive") + elif current is None or (line and line[0] not in " +-@" and line != "*** End of File"): + raise ValueError("invalid patch body") + if not targets: + raise ValueError("patch contains no target files") + return list(targets.values()) diff --git a/core/host.py b/core/host.py new file mode 100644 index 0000000..c5636af --- /dev/null +++ b/core/host.py @@ -0,0 +1,37 @@ +"""Per-process host identity, separate from a project's preferred editor. + +Installers pass --host explicitly. Environment discovery is a compatibility +fallback for older launch configurations; hook payloads use their own identity. +""" +import os +from dataclasses import dataclass + +from core.ide import get_profile, load_ide_config, normalize_ide_name + + +@dataclass(frozen=True) +class HostContext: + provider: str + host_session_id: str = "" + + @property + def capabilities(self): + return get_profile(self.provider) + + +def resolve_host(project_path: str = "", explicit_host: str | None = None, + environ=None) -> HostContext: + env = os.environ if environ is None else environ + provider = explicit_host or env.get("C3_HOST") + if not provider: + # A Claude child can inherit CODEX_THREAD_ID from its parent. + if env.get("CLAUDE_CODE_SESSION_ID"): + provider = "claude-code" + elif env.get("CODEX_THREAD_ID"): + provider = "codex" + else: + provider = load_ide_config(project_path) + provider = normalize_ide_name(provider) + session_env = {"claude-code": "CLAUDE_CODE_SESSION_ID", "codex": "CODEX_THREAD_ID"} + session = str(env.get(session_env.get(provider, ""), "") or "").strip() + return HostContext(provider, session) diff --git a/core/ide.py b/core/ide.py index 99f03e7..9b5a256 100644 --- a/core/ide.py +++ b/core/ide.py @@ -20,9 +20,9 @@ class IDEProfile: needs_type_field: bool # VS Code requires "type": "stdio" instructions_file: Optional[str] # "CLAUDE.md", ".github/copilot-instructions.md" instructions_line_limit: Optional[int] # 200 for Claude, None for others - supports_hooks: bool # Only Claude Code - supports_transcripts: bool # Only Claude Code (reads ~/.claude/) - supports_clear: bool # Only Claude Code has /clear + supports_hooks: bool # Host capability; activation requires trust + supports_transcripts: bool # A host-specific transcript adapter exists + supports_clear: bool # Host supports clearing session context settings_path: Optional[str] # ".claude/settings.local.json" or None config_format: str = "json" # "json" or "toml" config_path_global: bool = False # True = config_path resolved from home dir, not project @@ -79,10 +79,10 @@ class IDEProfile: needs_type_field=False, instructions_file="AGENTS.md", instructions_line_limit=None, - supports_hooks=False, - supports_transcripts=False, - supports_clear=False, - settings_path=None, + supports_hooks=True, + supports_transcripts=True, + supports_clear=True, + settings_path=".codex/hooks.json", config_format="toml", ), "antigravity": IDEProfile( diff --git a/core/mcp_toml.py b/core/mcp_toml.py index 6faa455..618051b 100644 --- a/core/mcp_toml.py +++ b/core/mcp_toml.py @@ -14,6 +14,45 @@ from pathlib import Path +def merge_toml_section(toml_path: Path, section: str, entries: dict, + defaults: dict | None = None) -> None: + """Update owned keys while preserving user settings, subtables and comments. + + Parse and serialize before replacing anything: malformed user TOML is an + error, never an invitation to silently replace the file. + """ + import os + import tempfile + + import tomlkit + + content = toml_path.read_text(encoding="utf-8") if toml_path.exists() else "" + document = tomlkit.parse(content) + table = document + for key in section.split("."): + if key not in table: + table[key] = tomlkit.table() + table = table[key] + if not hasattr(table, "items"): + raise ValueError(f"{section} must be a TOML table") + for key, value in (defaults or {}).items(): + if key not in table: + table[key] = value + for key, value in entries.items(): + table[key] = value + rendered = tomlkit.dumps(document) + tomlkit.parse(rendered) + toml_path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix=toml_path.name + ".", dir=toml_path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="") as stream: + stream.write(rendered) + os.replace(temporary, toml_path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + def _strip_toml_comment(raw: str) -> str: """Strip a trailing ``#`` comment, but only when the ``#`` is outside a quoted string. diff --git a/docs/codex-native.md b/docs/codex-native.md new file mode 100644 index 0000000..45ba336 --- /dev/null +++ b/docs/codex-native.md @@ -0,0 +1,120 @@ +# Native Codex integration + +C3 supports Codex and Claude Code in the same repository. Installing one keeps +the other's MCP configuration, hooks, and instruction document. Installation is +project-scoped by default; machine-wide fallback configuration is opt-in. + +## Install and activate + +```sh +c3 install-mcp --ide codex +c3 doctor --ide codex +``` + +The installer merges C3's launcher and arguments into `.codex/config.toml`, +preserving user options, comments, environment subtables, and an explicit +`enabled = false`. New installations default to a 30-second MCP startup timeout +and a 60-second tool timeout. Existing timeout choices are retained. It installs +C3 handlers in `.codex/hooks.json`, keeping unrelated handlers, and refreshes +only the managed C3 block in `AGENTS.md`. + +Review and trust the project configuration and hooks in Codex, then restart the +session. C3 never bypasses hook trust. `c3 doctor` distinguishes CLI support, +installed configuration, and hook activity observed for the current thread. +Installation alone is not evidence of activation. A changed hook configuration +invalidates the previous activity evidence. A CLI doctor run without a Codex +thread environment reports activity as unknown. + +Use `--global-fallback` explicitly when a home-level Codex MCP fallback is wanted. +It honors `CODEX_HOME`. To install Claude Code alongside Codex: + +```sh +c3 install-mcp --ide claude +``` + +No model call is made by `c3 doctor`; it inspects local configuration and CLI help. +It does not print configured environment values or prove that a client's existing +MCP connection is healthy. If that connection reports `Transport closed`, restart +the client and inspect its MCP error log. A successful fresh server process does +not establish why an older connection closed. + +## Host and thread boundaries + +Each installed MCP launcher passes an explicit `--host`. The process uses this +host for capabilities, transcript selection, and session attribution, even when +the repository's primary IDE preference names another host. Host-specific thread +IDs are read only for that provider. Delegated children discard inherited parent +thread IDs so a Claude child cannot impersonate its Codex caller. + +Codex handoffs are stored under `.c3/host_sessions/codex/`, keyed by a hash of the +thread ID. C3 checkpoints its current session context after tool responses and +uses lifecycle events to offer a bounded, explicitly stale handoff to the same +thread and project. Another thread's latest snapshot is never restored +automatically. These are C3 project notes, separate from Cod's personal memory. + +## Hooks and audited edits + +The Codex dispatcher handles PreToolUse, PostToolUse, UserPromptSubmit, +SessionStart, Stop, PreCompact, and SessionEnd. It accepts Codex's native Bash and +apply_patch payloads. Multi-file patches check every source and destination, +including moves and deletions, before consuming any one-shot access grant. +Malformed patches and failed pre-tool guard checks deny the operation. Failed +patches are not recorded as successful edits. + +Successful patches feed the existing edit ledger and artifact history. Codex +hook settings and `AGENTS.override.md` are included in agent-config protection. +These hooks cover supported client tool paths; they are not an OS sandbox. +Server-side C3 access, confirmation, masking, and credential rules still apply. + +## Delegation and conversation data + +Codex delegation uses `exec --json`, an explicit sandbox, and a prompt supplied +over stdin. It follows the user's configured model when no model was selected. +It does not use the removed `--full-auto` flag. Output and error streams are +drained concurrently; the total execution deadline bounds quiet reasoning turns. +The returned `thread.started` ID is persisted against the originating project +and caller. Resume requires that exact binding and never uses `resume --last`. +The existing explicit opt-in requirement for resume remains in place. + +Conversation sync imports bounded user and assistant messages from Codex rollout +files under `CODEX_HOME/sessions`, after matching project metadata. It skips +reasoning records and duplicate message event views. Rollout files are an adapter +format that may evolve; unrecognized records are ignored. + +Usage comes from the latest cumulative token-count event, not the sum of events. +Cached input is separated from total input to avoid double counting. Missing +usage is reported as unavailable; cost remains unavailable without a measured +price. Cumulative Stop snapshots are deduplicated by provider and session ID. + +## Verification + +The regression tests cover additive installs in either order, TOML preservation, +hook routing and denial, multi-file patch accounting, thread-bound handoffs and +resume, transcript isolation, usage accounting, and real stdio MCP initialization +and tool calls for both Codex and Claude. CLI argument compatibility can be +checked without calling a model. Interactive hook trust and model-backed +delegation still require verification in a normally trusted client session. + +Relevant upstream contracts: [Codex hooks](https://learn.chatgpt.com/docs/hooks), +[MCP configuration](https://learn.chatgpt.com/docs/extend/mcp?surface=cli), and +[configuration reference](https://learn.chatgpt.com/docs/config-file/config-reference). + +### Local validation, 2026-09-05 + +- Windows, Python 3.14, Codex CLI 0.153.4: 183 targeted regression tests pass, + including fresh stdio MCP calls for both hosts and execution of the Windows + hook command with spaces, quotes, percent signs, and ampersands in paths. +- Repository-wide ruff and diff whitespace checks pass. Both sdist and wheel + build successfully and pass `twine check`. +- The broad test run recorded 3,703 passes and five skips. Its two failures were + a new test assertion subsequently corrected and verified in the targeted run, + and the existing container integration test, whose image pull fails before + executing the test job. The real CI lint job fails at the same Docker registry + authentication step. An isolated anonymous Docker configuration did not clear + the container-test failure. No FULL_CI_PASS is claimed; macOS matrix cells also + need their own runner. +- This chat's existing C3 MCP transport remained closed, including validation + and session-log calls; native fallback checks and this document preserve the + verification record. Fresh MCP processes passed for Codex and Claude. This + checkout's live Codex configuration still needs reinstall and normal hook + trust/restart. Interactive trust and model-backed delegation were not tested. diff --git a/pyproject.toml b/pyproject.toml index e9f4bd2..194eac3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "code-context-control" -version = "2.118.2" +version = "2.119.0" description = "Local MCP code-intelligence for AI coding tools: surgical search/read/edit, agent-config version history, path-level access + masking guards, and a multi-project hub." readme = "README.md" requires-python = ">=3.10" @@ -32,6 +32,7 @@ classifiers = [ dependencies = [ "tiktoken>=0.7.0", "fastmcp>=2.0.0", + "tomlkit>=0.13.2", "watchdog>=4.0.0", "tree-sitter>=0.22.0", "tree-sitter-python>=0.21.0", diff --git a/services/access_guard.py b/services/access_guard.py index 78d679d..5f13ad4 100644 --- a/services/access_guard.py +++ b/services/access_guard.py @@ -113,6 +113,7 @@ # artifact table so the two cannot drift apart again. "**/.github/copilot-instructions.md", "**/.cursorrules", "**/.vscode/mcp.json", "**/.cursor/mcp.json", "**/.codex/config.toml", + "**/.codex/hooks.json", "**/AGENTS.override.md", "**/.gemini/settings.json", "**/.claude/plugins/**", ) diff --git a/services/artifact_defs.py b/services/artifact_defs.py index aab964b..e2c438e 100644 --- a/services/artifact_defs.py +++ b/services/artifact_defs.py @@ -96,6 +96,7 @@ def _add(rel: Optional[str], cls: str, provider: str) -> None: _add(profile.settings_path, "settings", profile.name) _add(profile.instructions_file, "instructions", profile.name) _add(".claude/settings.json", "settings", "claude-code") + _add("AGENTS.override.md", "instructions", "codex") # Legacy Gemini CLI artifacts (profile removed in v2.52): existing projects # still carry these files, so keep classifying and tracking them. MCP class # first so 'settings' merges into roles, matching the old dual-role entry. diff --git a/services/claude_md.py b/services/claude_md.py index e4ed6ee..6c8747b 100644 --- a/services/claude_md.py +++ b/services/claude_md.py @@ -366,6 +366,10 @@ def _build_c3_workflow(self, nano: bool = False) -> str: nano=True: ~250 tokens (vs ~800 full). Use for IDEs where instructions space is limited. Filters out hooks/snapshot/transcript lines for IDEs that don't support them. """ + if self.instructions_file == "AGENTS.md" and self.supports_hooks: + from services.codex_integration import CODEX_WORKFLOW + return CODEX_WORKFLOW + workflow = C3_NANO_WORKFLOW if nano else C3_COMPACT_WORKFLOW # Strip features unsupported by this IDE to reduce irrelevant instruction diff --git a/services/codex_integration.py b/services/codex_integration.py new file mode 100644 index 0000000..826634a --- /dev/null +++ b/services/codex_integration.py @@ -0,0 +1,143 @@ +"""Codex-specific installation. Never edits another host's configuration.""" +import json +import os +import subprocess +from pathlib import Path + +CODEX_WORKFLOW = """# C3 — Codex workflow + +Use C3 for repository intelligence and audited changes: +1. Recall relevant project facts with c3_memory; inspect .c3/MAP.md. +2. Find candidates with c3_search; map with c3_compress(mode='map'), then + read exact symbols or lines with c3_read. Check shared changes with c3_impact. +3. Edit with c3_edit, validate with c3_validate, and record decisions with + c3_session(action='log'). Preserve unrelated work. +4. Use c3_shell for commands and c3_shell_job for long work; outputs already + have a safety budget. Use c3_filter for other long logs. +5. Snapshot with c3_session before clearing or handing off unfinished work. + +If a C3 tool fails or its scope is insufficient, explain the limitation and +use a targeted native fallback. Never bypass an access denial or masked path. +A [c3-access:confirm] response is a hold: follow its S8 instructions, wait on +the existing request with c3_override, and retry only after approval. A pending +request is not a denial. Agent-config writes must go through c3_edit. + +Codex lifecycle hooks require a supported client, installed .codex/hooks.json, +and user trust in Codex. Installation does not establish that hooks are active. +Native apply_patch and Bash hooks cover supported local tool paths only; +C3's server-side guards remain authoritative for C3 calls. + +MCP configuration uses [mcp_servers.c3] in project-scoped .codex/config.toml. Project configuration +and hooks must be trusted by Codex. Use `c3 install-mcp --ide codex` to update; +add `--global-fallback` only when a machine-wide fallback is wanted. +Detailed C3 actions are documented in MCP tool descriptions and `c3 --help`. +""" + + +def probe_cli() -> dict: + """Inspect CLI flags at zero model cost; never infer health from PATH alone.""" + import shutil + + from cli.tools.delegate import _probe_cli_version + executable = shutil.which("codex") + if not executable: + return {"available": False, "hooks_supported": "unknown", "reason": "Codex CLI not found"} + try: + version = _probe_cli_version(executable, timeout=10) + help_result = _probe_cli_version(executable, timeout=10, args=["exec", "--help"]) + if version is None or help_result is None: + return {"available": False, "hooks_supported": "unknown", "reason": "CLI probe timed out"} + help_text, _, help_code = help_result + supported = help_code == 0 and all(flag in help_text for flag in ("--json", "--sandbox")) + return {"available": version[2] == 0 and supported, + "version": version[0][:120], "delegate_arguments_supported": supported, + "hooks_supported": True if "--dangerously-bypass-hook-trust" in help_text else "unknown"} + except (OSError, subprocess.TimeoutExpired) as exc: + return {"available": False, "hooks_supported": "unknown", "reason": type(exc).__name__} + + +def hook_state(target: Path, host_session_id: str = "") -> dict: + import hashlib + path = target / ".codex/hooks.json" + state = {"supported": True, "installed": False, "active": "unknown"} + if not path.exists(): + return state + try: + raw = path.read_bytes() + hooks = json.loads(raw).get("hooks", {}) + state["installed"] = all(any("hook_dispatch.py" in h.get("command", "") + for group in hooks.get(event, []) for h in group.get("hooks", [])) + for event in ("PreToolUse", "PostToolUse")) + if host_session_id: + key = hashlib.sha256(host_session_id.encode()).hexdigest() + observed = target / ".c3/host_sessions/codex" / (key + ".events.json") + data = json.loads(observed.read_text(encoding="utf-8")) if observed.exists() else {} + if data.get("config_hash") == hashlib.sha256(raw).hexdigest() and data.get("host_session_id") == host_session_id: + state["active"] = "observed_this_session" + state["last_event"] = data.get("event") + except (OSError, ValueError, TypeError, AttributeError): + state["error"] = "Unreadable or invalid hook configuration/state" + return state + + +def diagnose(target: Path) -> dict: + import tomlkit + + from core.host import resolve_host + result = {"project": str(target.resolve()), "cli": probe_cli(), + "hooks": hook_state(target, resolve_host(str(target), "codex").host_session_id)} + config_path = target / ".codex/config.toml" + try: + config = tomlkit.parse(config_path.read_text(encoding="utf-8")) + server = config.get("mcp_servers", {}).get("c3", {}) + result["mcp"] = {"configured": bool(server), "enabled": server.get("enabled", True), + "startup_timeout_sec": server.get("startup_timeout_sec", 10), + "tool_timeout_sec": server.get("tool_timeout_sec", 60), + "explicit_host": "--host" in server.get("args", []), + "connection": "not tested; restart the client if its transport is closed"} + except (OSError, ValueError, TypeError, AttributeError) as exc: + result["mcp"] = {"configured": False, "reason": type(exc).__name__} + result["activation"] = "Trust project configuration and review hooks in Codex; installation never bypasses trust." + return result + + +def install_hooks(target: Path, interpreter: str, dispatcher: Path) -> dict: + path = target / ".codex" / "hooks.json" + data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} + hooks = data.setdefault("hooks", {}) + # One dispatcher per event preserves ordering inside C3's guard chain. + events = {"PreToolUse": "pretool", "PostToolUse": "posttool", + "UserPromptSubmit": "prompt", "Stop": "stop", + "SessionStart": "start", "PreCompact": "compact", + "SessionEnd": "end"} + for event, route in events.items(): + groups = [] + for group in hooks.get(event, []): + kept = [h for h in group.get("hooks", []) + if h.get("statusMessage") != "C3 lifecycle" + and "hook_dispatch.py" not in h.get("command", "")] + if kept: + groups.append({**group, "hooks": kept}) + # Encode a PowerShell invocation so cmd.exe cannot expand metacharacters + # or %variables% in project/interpreter paths. stdin remains the payload. + argv = [interpreter, str(dispatcher), route, "--host", "codex", "--project", str(target)] + import base64 + import shlex + command = shlex.join(argv) + script = "& " + " ".join("'" + str(arg).replace("'", "''") + "'" for arg in argv) + "; exit $LASTEXITCODE" + encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii") + command_windows = "powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand " + encoded + groups.append({"matcher": ".*", "hooks": [{ + "type": "command", "command": command, + "commandWindows": command_windows, "timeout": 3 if event == "SessionEnd" else 30, + "statusMessage": "C3 lifecycle"}]}) + hooks[event] = groups + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(path.name + f".{os.getpid()}.tmp") + try: + temporary.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + return {"supported": True, "installed": True, "active": "unknown", + "activation": "Review and trust the hooks in Codex, then restart the session."} diff --git a/services/codex_transcript.py b/services/codex_transcript.py new file mode 100644 index 0000000..d9a9feb --- /dev/null +++ b/services/codex_transcript.py @@ -0,0 +1,78 @@ +"""Bounded, project-checked Codex rollout adapter. No private reasoning import.""" +import json +import os +from datetime import datetime +from pathlib import Path + + +def codex_home() -> Path: + return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex") + + +def _records(path: Path): + # Bound individual lines, not the entire transcript in memory. + with path.open(encoding="utf-8", errors="replace") as stream: + while True: + line = stream.readline(2 * 1024 * 1024 + 1) + if not line: + return + if len(line) > 2 * 1024 * 1024: + while line and not line.endswith("\n"): + line = stream.readline(2 * 1024 * 1024 + 1) + continue + try: + record = json.loads(line) + if isinstance(record, dict): + yield record + except ValueError: + continue + + +def read_rollout(path: Path, project: Path, session_id: str = "", + max_turns: int = 2000, max_text: int = 24000) -> dict | None: + """Require matching session metadata before accepting any content or usage.""" + records = _records(path) + metadata = next(records, None) + if not metadata or metadata.get("type") != "session_meta": + return None + meta = metadata.get("payload") or {} + if not isinstance(meta, dict) or not meta.get("cwd") or not meta.get("id"): + return None + if Path(meta["cwd"]).resolve() != project.resolve(): + return None + if session_id and meta["id"] != session_id: + return None + turns, usage, model = [], None, "" + for record in records: + payload = record.get("payload") or {} + if not isinstance(payload, dict): + continue + if record.get("type") == "turn_context": + model = str(payload.get("model") or model) + elif record.get("type") == "event_msg" and payload.get("type") == "token_count": + info = payload.get("info") or {} + total = info.get("total_token_usage") if isinstance(info, dict) else None + if isinstance(total, dict): + # Totals are cumulative; summing events double-counts the session. + keys = ("input_tokens", "cached_input_tokens", "output_tokens", "reasoning_output_tokens", "total_tokens") + usage = {k: max(0, v) for k in keys + if isinstance((v := total.get(k)), int) and not isinstance(v, bool)} + elif record.get("type") == "response_item" and payload.get("type") == "message": + role = payload.get("role") + if role not in ("user", "assistant") or payload.get("phase") == "analysis": + continue + content = payload.get("content") or [] + if not isinstance(content, list): + continue + text = "\n".join(str(p.get("text", "")) for p in content if isinstance(p, dict) + and p.get("type") in ("input_text", "output_text", "text"))[:max_text] + if text and len(turns) < max_turns: + try: + ts = datetime.fromisoformat(record.get("timestamp", "").replace("Z", "+00:00")).timestamp() + except (ValueError, TypeError): + ts = 0.0 + turns.append({"id": f"t{len(turns)+1:04d}", "ts": ts, "role": role, + "text": text, "source": "codex"}) + return {"session_id": str(meta["id"]), "turns": turns, "usage": usage, + "usage_available": usage is not None and all(k in usage for k in ("input_tokens", "output_tokens")), + "model": model} diff --git a/services/conversation_store.py b/services/conversation_store.py index 0446dc4..290bbdc 100644 --- a/services/conversation_store.py +++ b/services/conversation_store.py @@ -64,12 +64,14 @@ def sync(self, source: str = "all", force: bool = False) -> dict: Returns {synced, total, by_source, errors?}. """ selected = self._normalize_source(source) + from services.codex_transcript import codex_home claude_dir = self._find_transcript_dir() gemini_dir = self._find_gemini_transcript_dir() imports_root = self.store_dir / "imports" imports_available = imports_root.exists() and any(imports_root.rglob("*.jsonl")) availability = { + "codex": (codex_home() / "sessions").is_dir(), "claude": bool(claude_dir), "gemini": bool(gemini_dir), "imports": bool(imports_available), @@ -94,6 +96,12 @@ def sync(self, source: str = "all", force: bool = False) -> dict: elif selected == "gemini": warnings.append("Gemini transcript directory not found for this project") + if selected in ("all", "codex"): + if availability["codex"]: + providers.append(("codex", lambda: self._sync_codex(force=force))) + elif selected == "codex": + warnings.append("Codex transcripts unavailable") + if selected in ("all", "imports"): if availability["imports"]: providers.append(("imports", lambda: self._sync_imports(force=force))) @@ -252,6 +260,36 @@ def get_stats(self) -> dict: # ── Transcript Parsing ───────────────────────────────────────────────── + def _sync_codex(self, force: bool = False) -> int: + from services.codex_transcript import codex_home, read_rollout + existing = {s.get("source_file"): s for s in self._load_sessions() if s.get("source") == "codex"} + files = sorted((codex_home() / "sessions").rglob("rollout-*.jsonl"), + key=lambda p: p.stat().st_mtime, reverse=True) + synced = 0 + for path in files: + if synced >= self.MAX_TRANSCRIPT_FILES: + break + source_file = str(path) + mtime = path.stat().st_mtime_ns + previous = existing.get(source_file, {}) + if not force and previous.get("source_mtime") == mtime: + continue + try: + data = read_rollout(path, self.project_path, max_turns=self.MAX_TURNS_PER_SESSION, + max_text=self.MAX_TEXT_LEN) + except (OSError, ValueError): + continue + if not data or not data["turns"]: + continue + for turn in data["turns"]: + turn["tokens"] = count_tokens(turn["text"]) + # Hash untrusted transcript IDs before using them in a filename. + import hashlib + session_id = "codex_" + hashlib.sha256(data["session_id"].encode()).hexdigest()[:32] + self._upsert_synced_session(session_id, data["turns"], "codex", source_file, mtime) + synced += 1 + return synced + def _find_transcript_dir(self): """Locate Claude Code's project transcript directory.""" import re as _re diff --git a/services/runtime.py b/services/runtime.py index c339ca7..66af8ea 100644 --- a/services/runtime.py +++ b/services/runtime.py @@ -151,6 +151,8 @@ def build_runtime(project_path: str, ide_name: str | None = None) -> C3Runtime: ollama_client = OllamaClient(hybrid_config.get("ollama_base_url", "http://localhost:11434")) session_mgr = SessionManager(str(project), str(c3_dir / "sessions"), ollama_client=ollama_client) + from core.host import resolve_host + session_mgr.host_context = resolve_host(str(project), resolved_ide) vector_store = None if not hybrid_config.get("HYBRID_DISABLE_SLTM"): diff --git a/services/session_manager.py b/services/session_manager.py index 698e2e1..6925450 100644 --- a/services/session_manager.py +++ b/services/session_manager.py @@ -21,14 +21,15 @@ from services.telemetry import append_telemetry_record -def host_session_id() -> str: +def host_session_id(provider: str | None = None) -> str: """The host IDE's session id for this process, or ''. Read from the environment at session start rather than cached at import: a runtime built for another project inside the same MCP process (the c3_project proxy) must inherit the same host identity. """ - return str(os.environ.get(SessionManager.HOST_SESSION_ENV) or "").strip() + from core.host import resolve_host + return resolve_host(explicit_host=provider).host_session_id class SessionManager: @@ -138,15 +139,17 @@ def _git_state(self) -> dict: def start_session(self, description: str = "", source_system: Optional[str] = None) -> dict: """Start a new session.""" - session_id = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") - source_ide = self._detect_ide_name() + import uuid + session_id = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:12] + host = getattr(self, "host_context", None) + source_ide = host.provider if host else self._detect_ide_name() normalized_source = self._normalize_source_system(source_system) if normalized_source: source_ide = self._IDE_BY_SOURCE.get(normalized_source, source_ide) source_system_value = normalized_source or self._SOURCE_BY_IDE.get(source_ide, "manual") self.current_session = { "id": session_id, - "host_session_id": host_session_id(), + "host_session_id": host.host_session_id if host and host.provider == source_ide else host_session_id(source_ide), "started": datetime.now(timezone.utc).isoformat(), "description": description, "source_system": source_system_value, diff --git a/services/telemetry.py b/services/telemetry.py index 73ce41b..cb16a91 100644 --- a/services/telemetry.py +++ b/services/telemetry.py @@ -476,7 +476,7 @@ def aggregate_session_stats(project_path, days: int = 0, except (ValueError, TypeError): continue rows_seen += 1 - if not any(_as_int(rec.get(f)) for f in _SESSION_TOKEN_FIELDS): + if rec.get("usage_available") is not False and not any(_as_int(rec.get(f)) for f in _SESSION_TOKEN_FIELDS): all_zero += 1 ts = str(rec.get("ts") or "") if since is not None: @@ -486,24 +486,27 @@ def aggregate_session_stats(project_path, days: int = 0, sid = str(rec.get("session_id") or "") if not sid: continue - prev = latest.get(sid) + key = (str(rec.get("provider") or "claude"), sid) + prev = latest.get(key) if prev is None or ts >= str(prev.get("ts") or ""): - latest[sid] = rec + latest[key] = rec except OSError: pass sessions = [] totals = {f: 0 for f in _SESSION_TOKEN_FIELDS} - for sid, rec in latest.items(): + for (provider, sid), rec in latest.items(): row = {"session_id": sid, "ts": str(rec.get("ts") or ""), "model": str(rec.get("model") or ""), "assistant_messages": _as_int(rec.get("assistant_messages")), - "source": str(rec.get("source") or "")} + "source": str(rec.get("source") or ""), + "provider": provider, "usage_available": rec.get("usage_available", True)} for f in _SESSION_TOKEN_FIELDS: value = _as_int(rec.get(f)) or 0 - row[f] = value - totals[f] += value - row["total_tokens"] = sum(row[f] for f in _SESSION_TOKEN_FIELDS) + row[f] = value if row["usage_available"] else None + if row["usage_available"]: + totals[f] += value + row["total_tokens"] = sum(row[f] or 0 for f in _SESSION_TOKEN_FIELDS) if row["usage_available"] else None sessions.append(row) sessions.sort(key=lambda r: r["ts"], reverse=True) @@ -511,6 +514,7 @@ def aggregate_session_stats(project_path, days: int = 0, "days": days, "sessions": sessions[:limit], "session_count": len(sessions), + "unavailable_sessions": sum(not row["usage_available"] for row in sessions), "rows_seen": rows_seen, "all_zero_rows": all_zero, "totals": totals, diff --git a/tests/test_codex_mcp_transport.py b/tests/test_codex_mcp_transport.py new file mode 100644 index 0000000..7a5e50c --- /dev/null +++ b/tests/test_codex_mcp_transport.py @@ -0,0 +1,57 @@ +"""Real stdio handshake and tool call, without starting an AI client or model.""" +import asyncio +import json +import os +import sys +from datetime import timedelta +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("host", ["codex", "claude-code"]) +def test_stdio_host_session(tmp_path, host): + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client + from mcp.types import Implementation + + repo = Path(__file__).resolve().parents[1] + project = tmp_path / "project with spaces" + project.mkdir() + c3 = project / ".c3" + c3.mkdir() + # Deliberately disagree with the caller: project preference cannot identify + # the connecting client. Child configuration and all state are isolated. + (c3 / "config.json").write_text(json.dumps({"ide": "claude-code" if host == "codex" else "codex", + "hybrid": {"HYBRID_DISABLE_SLTM": True, "rag": {"enabled": False}}, + "delegate": {"codex_enabled": False, "gemini_enabled": False}})) + (project / "example.py").write_text("def answer():\n return 42\n") + home = tmp_path / "home" + home.mkdir() + env = dict(os.environ) + env.update({"HOME": str(home), "USERPROFILE": str(home), "CODEX_HOME": str(home / ".codex"), + "C3_BENCHMARK_MODE": "1", "C3_TELEMETRY_OPT_IN": "0", "SENTRY_DSN": "", + "CODEX_THREAD_ID": "synthetic-codex-thread", "CLAUDE_CODE_SESSION_ID": "synthetic-claude-thread"}) + params = StdioServerParameters(command=sys.executable, + args=[str(repo / "cli/mcp_server.py"), "--project", str(project), "--host", host], + cwd=str(project), env=env) + + async def exercise(): + with (tmp_path / "mcp-stderr.log").open("w", encoding="utf-8") as errlog: + async with stdio_client(params, errlog=errlog) as (read, write): + async with ClientSession(read, write, read_timeout_seconds=timedelta(seconds=30), + client_info=Implementation(name=host, version="test")) as client: + initialized = await client.initialize() + assert "C3" in initialized.serverInfo.name + tools = await client.list_tools() + assert "c3_read" in {t.name for t in tools.tools} + response = await client.call_tool("c3_read", {"file_path": "example.py", "lines": [1, 2]}) + assert not response.isError + assert "42" in "\n".join(getattr(c, "text", "") for c in response.content) + await client.call_tool("c3_session", {"action": "save", "summary": "Transport smoke test"}) + sessions = [json.loads(p.read_text(encoding="utf-8")) for p in (c3 / "sessions").glob("session_*.json")] + assert sessions + assert sessions[-1]["source_ide"] == host + assert sessions[-1]["host_session_id"] == ("synthetic-codex-thread" if host == "codex" else "synthetic-claude-thread") + + asyncio.run(asyncio.wait_for(exercise(), timeout=55)) diff --git a/tests/test_codex_native.py b/tests/test_codex_native.py new file mode 100644 index 0000000..9832682 --- /dev/null +++ b/tests/test_codex_native.py @@ -0,0 +1,395 @@ +"""Native host boundary regressions; synthetic data and isolated installations.""" +import json +import os +import subprocess +import sys +import uuid +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +import tomlkit + +from core.codex_patch import patch_targets +from core.host import resolve_host +from core.mcp_toml import merge_toml_section +from services.codex_integration import CODEX_WORKFLOW, install_hooks +from services.codex_transcript import read_rollout + + +@pytest.mark.parametrize("order", [("claude", "codex"), ("codex", "claude")]) +def test_install_coexists_and_preserves_settings(tmp_path, monkeypatch, order): + from cli.c3 import cmd_install_mcp + home = tmp_path / "home" + home.mkdir() + project = tmp_path / "project with spaces" + project.mkdir() + monkeypatch.setattr(Path, "home", lambda: home) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + monkeypatch.setattr("shutil.which", lambda name: None) + for host in (*order, *order): + cmd_install_mcp(SimpleNamespace(project_path=str(project), ide=host, mcp_mode="direct")) + assert "c3" in json.loads((project / ".mcp.json").read_text())["mcpServers"] + codex = tomlkit.parse((project / ".codex/config.toml").read_text())["mcp_servers"]["c3"] + assert "--host" in codex["args"] + assert codex["startup_timeout_sec"] == 30 + assert not (home / ".codex/config.toml").exists() + hooks = json.loads((project / ".codex/hooks.json").read_text())["hooks"] + assert all(len(groups) == 1 for groups in hooks.values()) + assert json.loads((project / ".claude/settings.local.json").read_text())["hooks"]["PreToolUse"] + assert json.loads((project / ".c3/config.json").read_text())["installed_ides"] == ["claude-code", "codex"] + + +def test_merge_keeps_user_toml_and_comments(tmp_path): + path = tmp_path / "config.toml" + path.write_text('''# user settings +[mcp_servers.c3] +command = "old" # launcher +args = ["old"] +enabled = false +startup_timeout_sec = 90 +disabled_tools = ["c3_shell"] +[mcp_servers.c3.env] +SAFE_TEST_VALUE = 'C:\\literal\\value' +[other] +anything = 42 +''') + merge_toml_section(path, "mcp_servers.c3", {"command": 'C:\\a "quoted"\\b.exe', "args": ["--host", "codex"]}, + {"enabled": True, "startup_timeout_sec": 30}) + text = path.read_text() + data = tomlkit.parse(text) + server = data["mcp_servers"]["c3"] + assert server["enabled"] is False + assert server["startup_timeout_sec"] == 90 + assert server["disabled_tools"] == ["c3_shell"] + assert server["env"]["SAFE_TEST_VALUE"] == 'C:\\literal\\value' + assert server["command"] == 'C:\\a "quoted"\\b.exe' + assert "# user settings" in text and "# launcher" in text + assert data["other"]["anything"] == 42 + + +def test_invalid_toml_does_not_get_overwritten(tmp_path): + path = tmp_path / "config.toml" + path.write_text("[broken\n") + with pytest.raises(Exception): + merge_toml_section(path, "mcp_servers.c3", {"command": "new"}) + assert path.read_text() == "[broken\n" + + +def test_hooks_preserve_other_handlers_and_do_not_claim_trust(tmp_path): + path = tmp_path / ".codex/hooks.json" + path.parent.mkdir() + path.write_text(json.dumps({"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [ + {"type": "command", "command": "user-hook"}]}]}})) + for _ in range(2): + state = install_hooks(tmp_path, sys.executable, Path("cli/hook_dispatch.py").resolve()) + assert state["active"] == "unknown" + handlers = json.loads(path.read_text())["hooks"]["PreToolUse"] + assert len(handlers) == 2 + assert handlers[0]["hooks"][0]["command"] == "user-hook" + assert "-EncodedCommand" in handlers[1]["hooks"][0]["commandWindows"] + assert "bypass" not in path.read_text() + assert len(CODEX_WORKFLOW.encode()) < 4096 + + +def test_host_identity_is_explicit_and_provider_scoped(): + env = {"CODEX_THREAD_ID": "codex-parent", "CLAUDE_CODE_SESSION_ID": "claude-child"} + assert resolve_host(environ=env).provider == "claude-code" + assert resolve_host(explicit_host="claude-code", environ=env).host_session_id == "claude-child" + assert resolve_host(explicit_host="codex", environ=env).host_session_id == "codex-parent" + assert resolve_host(explicit_host="codex", environ={}).host_session_id == "" + + +PATCH = """*** Begin Patch +*** Add File: new.py ++print('hello') +*** Update File: old.py +*** Move to: moved.py +@@ +-old ++new +*** Delete File: gone.py +*** End Patch""" + + +def test_patch_tracks_source_and_destination(tmp_path): + assert {(Path(t.path).name, t.change_type) for t in patch_targets(PATCH, tmp_path)} == { + ("new.py", "created"), ("old.py", "deleted"), ("moved.py", "created"), ("gone.py", "deleted")} + + +@pytest.mark.parametrize("command", [None, "echo hi", "*** Begin Patch\n*** End Patch", + "*** Begin Patch\n*** Move to: x\n*** End Patch", + "*** Begin Patch\n*** Add File: x\n*** Unknown: y\n*** End Patch"]) +def test_bad_patch_denies(tmp_path, command): + from cli.hook_dispatch import dispatch + result = dispatch("pretool", {"turn_id": "t", "tool_name": "apply_patch", + "tool_input": {"command": command}}, tmp_path) + assert result["hookSpecificOutput"]["permissionDecision"] == "deny" + + +def test_patch_grant_not_spent_if_any_target_denied(tmp_path, monkeypatch): + import cli.hook_dispatch as dispatcher + consumed, checked = [], [] + def guard(payload, project, defer_consume=False): + path = Path(payload["tool_input"]["file_path"]).name + checked.append(path) + if path == "moved.py": + return {"hookSpecificOutput": {"permissionDecision": "deny", "permissionDecisionReason": "held"}} + return {"_on_allow": lambda: consumed.append(path) or "granted"} + monkeypatch.setattr(dispatcher, "_RUN_CACHE", { + "hook_access_guard": (guard, ""), "hook_pretool_enforce": (lambda p, pp: None, "")}) + result = dispatcher.dispatch("pretool", {"turn_id": "t", "tool_name": "apply_patch", + "tool_input": {"command": PATCH}}, tmp_path) + assert result["hookSpecificOutput"]["permissionDecision"] == "deny" + assert checked == ["new.py", "old.py", "moved.py"] + assert consumed == [] + + +def test_patch_guard_import_failure_denies(tmp_path, monkeypatch): + import cli.hook_dispatch as dispatcher + monkeypatch.setattr(dispatcher, "_RUN_CACHE", {"hook_access_guard": (None, "broken")}) + out = dispatcher.dispatch("pretool", {"turn_id": "t", "tool_name": "apply_patch", + "tool_input": {"command": PATCH}}, tmp_path) + assert out["hookSpecificOutput"]["permissionDecision"] == "deny" + + +def test_patch_posts_every_target_to_ledger(tmp_path): + from cli.hook_dispatch import dispatch + (tmp_path / ".c3").mkdir() + with patch("cli.hook_edit_ledger.load_hybrid_config", return_value={"edit_ledger": {"tracking_level": "minimal"}}): + dispatch("posttool", {"turn_id": "t", "session_id": "codex-thread", "tool_name": "apply_patch", + "tool_input": {"command": PATCH}, "tool_response": "Success. Updated files"}, tmp_path) + entries = [json.loads(line) for line in (tmp_path / ".c3/edit_ledger.jsonl").read_text().splitlines()] + assert {e["file"]: e["change_type"] for e in entries} == { + "new.py": "created", "old.py": "deleted", "moved.py": "created", "gone.py": "deleted"} + assert {e["session_id"] for e in entries} == {"codex-thread"} + + +def rollout(path, project, thread="thread-a"): + records = [ + {"type": "session_meta", "payload": {"id": thread, "cwd": str(project)}}, + {"type": "response_item", "payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}]}}, + {"type": "response_item", "payload": {"type": "reasoning", "summary": [{"text": "private reasoning"}]}}, + {"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "answer"}]}}, + {"type": "event_msg", "payload": {"type": "agent_message", "message": "answer"}}, + *[{"type": "event_msg", "payload": {"type": "token_count", "info": {"total_token_usage": {"input_tokens": n, "output_tokens": n // 2}}}} for n in (100, 200, 200)], + ] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(json.dumps(r) for r in records) + "\n{partial", encoding="utf-8") + + +def test_rollout_deduplicates_wire_views_and_cumulative_usage(tmp_path): + path = tmp_path / "rollout.jsonl" + rollout(path, tmp_path) + data = read_rollout(path, tmp_path, "thread-a") + assert [t["text"] for t in data["turns"]] == ["hello", "answer"] + assert data["usage"] == {"input_tokens": 200, "output_tokens": 100} + assert data["usage_available"] is True + assert read_rollout(path, tmp_path / "other") is None + assert read_rollout(path, tmp_path, "other-thread") is None + + +def test_codex_sync_is_incremental_and_project_scoped(tmp_path, monkeypatch): + from services.conversation_store import ConversationStore + home = tmp_path / "codex" + monkeypatch.setenv("CODEX_HOME", str(home)) + project = tmp_path / "project" + project.mkdir() + rollout(home / "sessions/2026/rollout-a.jsonl", project) + rollout(home / "sessions/2026/rollout-b.jsonl", tmp_path / "other", "thread-b") + store = ConversationStore(str(project)) + assert store.sync("codex")["synced"] == 1 + assert store.sync("codex")["synced"] == 0 + assert store.sync("codex", force=True)["synced"] == 1 + assert len(store.list_sessions()) == 1 + assert store.list_sessions()[0]["source"] == "codex" + + +def test_codex_usage_unavailable_is_not_zero(tmp_path): + from cli.hook_session_stats import run + (tmp_path / ".c3").mkdir() + run({"turn_id": "t", "session_id": "s"}, tmp_path) + data = json.loads((tmp_path / ".c3/session_stats.jsonl").read_text()) + assert data["usage_available"] is False + assert data["input_tokens"] is None and data["usage"] is None + + +def test_delegate_args_validate_without_model_call(): + import shutil + + from cli.tools.delegate import _codex_cmd + from services.win_subprocess import harden_win_argv + if not shutil.which("codex"): + pytest.skip("Codex CLI unavailable") + cmd = _codex_cmd("-", "", "read-only", "high") + assert "--full-auto" not in cmd + result = subprocess.run(harden_win_argv(cmd[:-1] + ["--help"]), capture_output=True, text=True, timeout=15) + assert result.returncode == 0, result.stderr + + +def test_delegate_json_result_and_bound_resume(tmp_path, monkeypatch): + from cli.tools import delegate + thread = str(uuid.uuid4()) + events = [{"type": "thread.started", "thread_id": thread}, + {"type": "item.completed", "item": {"type": "agent_message", "text": "answer"}}, + {"type": "turn.completed"}] + assert delegate._codex_result("\n".join(map(json.dumps, events))) == ("answer", thread, True) + assert delegate._codex_result('{"type":"turn.failed","error":"bad"}')[2] is False + monkeypatch.setattr(Path, "home", lambda: tmp_path) + assert delegate._run_codex_resume("next", cwd=str(tmp_path), origin_id="caller")[1] is False + path, project, origin = delegate._delegate_binding(tmp_path, "caller") + path.parent.mkdir(parents=True) + path.write_text(json.dumps({"project": project, "origin": origin, "thread_id": thread})) + calls = [] + monkeypatch.setattr(delegate, "_execute_codex", lambda cmd, *a, **kw: calls.append(cmd) or ("ok", True)) + assert delegate._run_codex_resume("next", cwd=str(tmp_path), origin_id="caller") == ("ok", True) + assert thread in calls[0] and "--last" not in calls[0] + assert delegate._run_codex_resume("next", cwd=str(tmp_path), origin_id="other")[1] is False + + +def test_child_environment_does_not_impersonate_parent(monkeypatch): + from cli.tools.delegate import _child_host_env + monkeypatch.setenv("CODEX_THREAD_ID", "parent") + monkeypatch.setenv("CLAUDE_CODE_SESSION_ID", "other-parent") + env = _child_host_env("claude-code") + assert "CODEX_THREAD_ID" not in env and "CLAUDE_CODE_SESSION_ID" not in env + assert env["C3_HOST"] == "claude-code" + + +def test_failed_patch_is_not_logged(tmp_path): + from cli.hook_dispatch import dispatch + (tmp_path / ".c3").mkdir() + dispatch("posttool", {"turn_id": "t", "tool_name": "apply_patch", + "tool_input": {"command": PATCH}, "tool_response": "Failed to find expected lines"}, tmp_path) + assert not (tmp_path / ".c3/edit_ledger.jsonl").exists() + + +def test_bad_pretool_payload_fails_closed(tmp_path): + result = subprocess.run([sys.executable, "cli/hook_dispatch.py", "pretool", "--host", "codex", + "--project", str(tmp_path)], input="[]", capture_output=True, text=True, timeout=10) + assert result.returncode == 2 + + +def test_handoff_is_thread_bound_and_preserves_newer_checkpoint(tmp_path): + from cli.hook_codex_lifecycle import checkpoint, run + from services.codex_integration import hook_state + (tmp_path / ".c3/sessions").mkdir(parents=True) + install_hooks(tmp_path, sys.executable, Path("cli/hook_dispatch.py").resolve()) + saved = tmp_path / ".c3/sessions/session_old.json" + data = {"host_session_id": "thread", "source_ide": "codex", "description": "old"} + saved.write_text(json.dumps(data)) + os.utime(saved, (1, 1)) + checkpoint(tmp_path, {**data, "description": "new"}) + run({"session_id": "thread", "hook_event_name": "PreCompact"}, tmp_path) + assert "new" in run({"session_id": "thread", "hook_event_name": "SessionStart"}, tmp_path)["additionalContext"] + assert run({"session_id": "other", "hook_event_name": "SessionStart"}, tmp_path) is None + assert hook_state(tmp_path, "thread")["active"] == "observed_this_session" + hooks = tmp_path / ".codex/hooks.json" + hooks.write_text(hooks.read_text() + "\n") + assert hook_state(tmp_path, "thread")["active"] == "unknown" + + +def test_codex_cached_input_is_not_counted_twice(tmp_path): + from cli.hook_session_stats import run + from services.telemetry import aggregate_session_stats + (tmp_path / ".c3").mkdir() + path = tmp_path / "rollout.jsonl" + rollout(path, tmp_path) + with path.open("a") as stream: + stream.write('\n' + json.dumps({"type": "event_msg", "payload": {"type": "token_count", "info": { + "total_token_usage": {"input_tokens": 200, "cached_input_tokens": 80, "output_tokens": 100}}}})) + for _ in range(2): + run({"turn_id": "t", "session_id": "thread-a", "transcript_path": str(path)}, tmp_path) + run({"turn_id": "t", "session_id": "unknown"}, tmp_path) + report = aggregate_session_stats(tmp_path) + assert report["total_tokens"] == 300 + assert report["totals"]["input_tokens"] == 120 + assert report["unavailable_sessions"] == 1 + assert report["all_zero_rows"] == 0 + + +def test_workflow_refresh_preserves_user_sections(tmp_path): + from cli.c3 import _ensure_codex_agents_workflow + path = tmp_path / "AGENTS.md" + path.write_text("User instructions before.\n") + _ensure_codex_agents_workflow(path) + with path.open("a") as stream: + stream.write("\nUser instructions after.\n") + _ensure_codex_agents_workflow(path) + text = path.read_text(encoding="utf-8") + assert text.count("