Skip to content

feat(plugin): ship Codex-native activity hooks - #3364

Merged
max-sixty merged 2 commits into
mainfrom
feat/issue-3362-codex-native-hooks
Jul 7, 2026
Merged

feat(plugin): ship Codex-native activity hooks#3364
max-sixty merged 2 commits into
mainfrom
feat/issue-3362-codex-native-hooks

Conversation

@worktrunk-bot

Copy link
Copy Markdown
Collaborator

What this does

This is the "support" counterpart to #3363. Where #3363 suppresses Codex's auto-discovery of the shared Claude hooks/hooks.json (setting hooks: {} so nothing loads), this PR ships Codex-native activity hooks so wt list shows 🤖/💬 for Codex sessions the same way it does for Claude Code, OpenCode, and Gemini. Meeting users where they are: #3362 (comment).

Both PRs fix the original bug (Claude-branded events surfacing in Codex sessions). They differ only in direction — a maintainer picks one.

How

The Codex manifest (.codex-plugin/plugin.json) now carries an inline hooks object — a Codex-tailored hooks file embedded directly in the manifest, rather than a path or an absent key:

"hooks": {
  "hooks": {
    "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "bash \"$PLUGIN_ROOT/hooks/wt.sh\" config state marker set 🤖 || true" }] }],
    "PermissionRequest": [{ "matcher": "", "hooks": [{ "type": "command", "command": "bash \"$PLUGIN_ROOT/hooks/wt.sh\" config state marker set 💬 || true" }] }],
    "Stop": [{ "hooks": [{ "type": "command", "command": "bash \"$PLUGIN_ROOT/hooks/wt.sh\" config state marker set 💬 || true" }] }]
  }
}

Two deliberate choices, both verified against Codex's source (98d28aab):

  • Inline object, not a path or absent key. Claude and Codex share one payload dir. An absent hooks key makes Codex auto-discover hooks/hooks.json — the Claude file — and surface Claude events (the Codex plugin hooks should not rely on compatibility with Claude #3362 bug). An inline object takes Codex's Some(Inline) branch in resolve_manifest_hooks, which overrides discovery — so only these Codex-native events fire, and there's no second file to collide with the Claude one. (This is the same override mechanism fix(plugin): suppress Codex auto-discovery of Claude hooks #3363 uses with an empty inline object.)
  • $PLUGIN_ROOT, not $CLAUDE_PLUGIN_ROOT. Codex exports both to hook commands (discovery.rs#L230-L232PLUGIN_ROOT native, CLAUDE_PLUGIN_ROOT an OOTB-compat alias). Using the native var keeps everything Claude-branded out of a Codex session, directly addressing the "poor UX to see anything about Claude while using Codex" concern.

The event set is Codex's HookEventsToml vocabulary: UserPromptSubmit → 🤖 (working), PermissionRequest/Stop → 💬 (waiting). Codex gained a Stop turn-end event after codex-cli 0.130.0 (the version whose lack of a turn-end event motivated the original removal in #2786), so 🤖 correctly returns to 💬 when a turn completes — the transition the old "no turn-end event" limitation lacked.

Known limitation (documented, not a regression)

Codex's HookEventsToml has no SessionEnd/session-exit event, so there's no hook to clear the marker when a Codex session exits. The resting state after a normal exit is 💬 (set by the last Stop), which lingers until the next session or a manual wt config state marker clear. This is the same class of limitation already documented for Claude ("Status persists after user interrupt"). If Codex later adds a session-exit event, wiring a marker clear handler is a one-line addition.

Files

  • plugins/worktrunk/.codex-plugin/plugin.json — inline Codex-native hooks object
  • plugins/worktrunk/CLAUDE.md — rewrote the "Codex ships no hooks" limitation into "Codex activity hooks", documenting the inline/$PLUGIN_ROOT rationale and the no-SessionEnd tradeoff
  • src/commands/config/codex.rs — install now hints that markers appear in wt list
  • src/cli/config.rs — Codex plugin --help no longer claims markers are Claude-only
  • docs/content/claude-code.md (+ synced skill mirror) — capability table and prose updated
  • tests/integration_tests/config_show.rs — asserts the inline hooks object exists, defines exactly the Codex-native events, and uses $PLUGIN_ROOT (never the Claude alias)

Testing

test_codex_plugin_metadata_is_valid_json, the test_help snapshots, and test_docs_are_in_sync pass locally; cargo fmt/clippy clean.

The hooks' end-to-end behavior depends on Codex runtime and isn't verifiable from CI — a maintainer should confirm against a live Codex session before merge (marker appears on prompt, returns to 💬 at turn end, persists after exit as documented).

Closes #3362 (as an alternative to #3363).

Codex auto-discovers the shared Claude hooks/hooks.json when its manifest
omits a `hooks` key, surfacing Claude-branded events in Codex sessions
(#3362). This ships Codex-native activity hooks instead of suppressing them
(the counterpart to #3363): an inline `hooks` object in the Codex manifest
overrides discovery, and its commands use Codex's native $PLUGIN_ROOT rather
than the Claude-branded $CLAUDE_PLUGIN_ROOT alias.

Events (Codex's HookEventsToml vocabulary): UserPromptSubmit -> 🤖,
PermissionRequest/Stop -> 💬. Codex added a Stop turn-end event after
codex-cli 0.130.0, so 🤖 returns to 💬 at turn end. Codex has no session-exit
event, so the marker persists after a session ends (documented tradeoff,
same class as Claude's "persists after interrupt").

Closes #3362

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The new "Activity markers appear in wt list" hint used a plain string, so
the <underline> tag rendered literally. Wrap it in cformat! and regenerate
the codex-install snapshot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ofek

ofek commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Is it possible for Claude to have a dedicated plugin subdirectory like Codex or does it lack interoperability/support for conventions like the AGENTS.md situation?

@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

Good question — the two tools aren't symmetric here, and it turns out a dedicated subdir wouldn't buy what you might expect.

Can Claude take a dedicated manifest subdir like .codex-plugin/? In this layout, no. We verified end-to-end against claude-cli 2.1.x that with the marketplace source pointing at ./plugins/worktrunk, Claude wants plugin.json at the plugin root — adding a .claude-plugin/ wrapper inside that dir makes it fail with "Plugin not found". Codex is the opposite: it requires its manifest in the .codex-plugin/ wrapper. So they pull in opposite directions, and the repo carries a per-tool pointer at the root for each (.claude-plugin/marketplace.json, .agents/plugins/marketplace.json, gemini-extension.json).

But — and this is the key point — a manifest subdir wouldn't isolate the payload anyway. Codex's .codex-plugin/ only relocates the manifest; both tools still share the same plugins/worktrunk/ payload (hooks/, skills/, the wt.sh shim). That shared payload is exactly the source of #3362: Codex auto-discovers the Claude hooks/hooks.json by convention and surfaces Claude-branded events. So the collision is a payload-sharing problem, not a manifest-location one — which is why the fix here lives in the manifest's hooks key (an inline object that overrides Codex's auto-discovery) rather than a directory split. Splitting manifests into per-tool subdirs wouldn't have separated the hooks/skills that actually collide.

On the AGENTS.md interoperability angle: yes, it's the same class of situation. There's no shared cross-tool convention — each tool hardcodes its own loader/manifest path with no fallback, and Claude reads CLAUDE.md rather than AGENTS.md. That lack of a common convention is precisely why the repo has to keep three separate root pointers and single-source the shared skills//wt.sh behind them (see plugins/worktrunk/CLAUDE.md for the full layout rationale).

If any of those tools grows real cross-tool interop (a shared manifest convention, or Codex payload isolation), the layout could collapse to fewer pointers — but today the three-pointer + shared-payload split is the minimal thing that works against all three real CLIs.

@ofek

ofek commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Could the structure of this repo be improved at all to make the Claude plugin config less "prominent" and isolate what is possible?

@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

Good instinct, and it splits cleanly into "forced by the CLIs" vs. "actually improvable."

What's forced (can't isolate):

  • plugin.json at the plugin root. Claude hardcodes the manifest at the plugin root and rejects a .claude-plugin/ wrapper inside the source dir (verified against 2.1.x — you get "Plugin not found"). Codex is the mirror image: it requires its manifest under .codex-plugin/. So the visible asymmetry — Codex neatly tucked in a subdir, Claude bare at the root — is the two loaders pulling in opposite directions, not a repo choice. There's no arrangement that tucks both away.
  • CLAUDE.md. Claude reads CLAUDE.md, not AGENTS.md, with no fallback — so that filename is fixed too.
  • The shared payload (wt.sh, skills/). These are genuinely common to all three tools and single-sourced deliberately. Splitting them per-tool to make Claude "less prominent" would mean duplicating the shim and the skills, which trades a cosmetic win for real drift risk. That's the wrong direction.

What this PR actually unlocks (a real improvement):

Before this PR, plugins/worktrunk/hooks/hooks.json had to sit at that conventional path because it was the file everyone's auto-discovery would find — which is exactly the #3362 bug (Codex discovering the Claude file). Now that the Codex manifest carries inline hooks that override discovery, that hooks/hooks.json is consumed by Claude only (Gemini uses its own repo-root hooks/hooks.json).

Claude's hooks key is a path, not a convention lookup — "hooks": "./hooks/hooks.json". So post-#3364 you could rename/relocate it to something explicitly Claude-scoped (e.g. a claude-hooks.json, or a Claude-only subdir) and repoint that key. Two concrete wins:

  1. It stops a future tool's convention-based auto-discovery from grabbing the file the way Codex did — the collision class that caused Codex plugin hooks should not rely on compatibility with Claude #3362 disappears structurally rather than being overridden per-tool.
  2. It reads as "this file is Claude's," reducing the impression that the bare root config is a shared/default thing.

That's the one place isolation is genuinely available today, and it's a byproduct of the override this PR introduces. I'd keep it out of this PR (it's cleanly separable and this one's already scoped to the Codex hooks), but it's a good follow-up for a maintainer to take — the layout rationale lives in plugins/worktrunk/CLAUDE.md if anyone wants to pick it up.

The honest summary: the manifest prominence is fixed by the CLIs, but the hooks payload can now be Claude-scoped, and that's the meaningful isolation lever.

@max-sixty
max-sixty merged commit 512a94d into main Jul 7, 2026
36 checks passed
@max-sixty
max-sixty deleted the feat/issue-3362-codex-native-hooks branch July 7, 2026 06:05
@ofek

ofek commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

@worktrunk-bot Now that this has been merged, what follow-up items do you have in mind to implement?

@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

Three, in rough priority order — one actionable now, two gated on things outside this repo.

1. Claude-scope the shared hooks payload (actionable now, cleanly separable).
This is the isolation lever from the thread above. Before #3364, plugins/worktrunk/hooks/hooks.json had to sit at the conventional path because it was the file everyone's auto-discovery grabbed — the #3362 collision. Now that the Codex manifest carries inline hooks that override discovery, that file is consumed by Claude only (Gemini uses its own repo-root hooks/hooks.json with a BeforeAgent event; Codex no longer touches it). Claude's hooks key in plugin.json is a path ("./hooks/hooks.json"), not a convention lookup, so it can be renamed/relocated to something explicitly Claude-scoped and repointed. Two wins: a future tool's convention-based discovery can't grab it the way Codex did (the collision class disappears structurally instead of being overridden per-tool), and it stops reading as a shared/default config. I'd keep this its own small PR.

2. Confirm the Codex hooks against a live session, then close the loop (gated on a maintainer with Codex).
The end-to-end behavior isn't verifiable from CI — it depends on the Codex runtime. The pre-merge ask still stands post-merge: confirm the marker appears on prompt (🤖), returns to 💬 at turn end, and persists after exit as documented. If any of those don't match, that's a real bug to file rather than a follow-up feature.

3. Wire marker clear on Codex session-exit (gated on upstream).
The documented limitation: Codex's HookEventsToml has no SessionEnd/session-exit event, so the marker rests at 💬 after a normal exit until the next session or a manual wt config state marker clear. If Codex adds a session-exit event, wiring a marker clear handler into the inline manifest is a one-line addition. Nothing to do until upstream ships the event — worth a watch, not work.

The only one worth opening now is (1). Happy to take it if a maintainer wants it — otherwise the layout rationale in plugins/worktrunk/CLAUDE.md has the full context for whoever picks it up.

@ofek

ofek commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Go ahead and implement that now while I test this change myself tomorrow.

@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

Done — opened #3382.

It renames plugins/worktrunk/hooks/hooks.jsonhooks/claude-hooks.json and repoints plugin.json's hooks key, so the file a future tool's convention-based discovery would grab (hooks/hooks.json) is now empty at the plugin root — the #3362 collision class is gone structurally rather than overridden per-tool. wt.sh stays put (Codex/Gemini reference it there), and the inline Codex hooks stay as the functional definition of Codex's markers. Scoped to just item (1); the plugin-layout tests are updated and green. (2) and (3) stay out — gated on the Codex runtime and an upstream session-exit event.

Enjoy testing tomorrow — if the markers don't behave as documented (🤖 on prompt, back to 💬 at turn end, persisting after exit), that's a bug to file rather than a follow-up.

max-sixty added a commit that referenced this pull request Jul 8, 2026
…indow (#3386)

Five commits landed on `main` during #3385's CI wait, so they ship
inside v0.66.0's tree but weren't in its changelog (the step-12 drift
check in the release skill caught this). This adds the user-facing three
to the 0.66.0 section:

- **Codex activity markers**
([#3364](#3364), closes
[#3362](#3362), thanks
@ofek for reporting)
- **SCP-style SSH remotes with custom usernames**
([#3371](#3371), thanks
@fcoury-oai)
- **Windows app-alias Settings path**
([#3372](#3372), thanks
@ofek)

Excluded as internal: #3360 (trait docstrings), #3379 (argv[0] display
refactor). Every entry verified against the actual diffs.

The v0.66.0 tag will point at this PR's squash commit so the tagged tree
carries the complete changelog.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

> _This was written by Claude Code on behalf of Maximilian Roos_

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
max-sixty added a commit that referenced this pull request Jul 12, 2026
…n-only discovery (#3431)

## What this does

Moves the Claude plugin manifest to the canonical
`plugins/worktrunk/.claude-plugin/plugin.json` and strips it to metadata
only, dropping the `hooks` and `skills` keys so that both component
types load purely by Claude Code's convention discovery. Rewrites the
layout docs and inverts `test_plugin_layout_is_consolidated` to pin the
new invariants.

## Background

The plugin layout has been in flux:
[#3382](#3382) Claude-scoped
the hooks filename, which silently broke hook loading
([#3417](#3417)), and
[#3418](#3418) reverted it.
That revert restored working hooks but left two claims baked into the
layout, the docs, and the test that this PR found to be stale.

## Research method

Everything below was verified empirically against claude-cli 2.1.207
(the current release), not from docs alone:

- **Scratch marketplace probe**: a throwaway marketplace mirroring
worktrunk's exact shape (repo-root `.claude-plugin/marketplace.json`,
plugin in a `./plugins/<name>` subdir source, `skills` as a symlink
pointing outside the plugin dir, hooks at the conventional path) with
two plugin variants — manifest in `.claude-plugin/plugin.json` vs. bare
`plugin.json` at the plugin root — installed through the real `claude
plugin marketplace add` + `claude plugin install` flow.
- **Component inventory**: `claude plugin details` and `claude plugin
validate` on both variants.
- **Live sessions**: headless `claude -p` runs confirming skills appear
in-session and hook commands actually execute (marker files, and reading
the 🤖 activity marker from inside a live session).
- **Loader inspection**: the manifest resolution order read directly out
of the claude-code binary's bundled loader code.

## Findings

1. **The "no `.claude-plugin/` wrapper" claim no longer holds.** The
repo documented — and the test pinned — that Claude wants `plugin.json`
bare at the plugin root, and that a `.claude-plugin/` wrapper in a
subdir-source plugin fails with "Plugin not found". On 2.1.207 the
wrapper form installs, loads skills, and fires hooks end-to-end. The
loader tries `.claude-plugin/plugin.json` first with root `plugin.json`
only as a runtime fallback, several marketplace-resolution paths read
*only* the wrapper, and `claude plugin validate` rejects the root-level
form outright ("No manifest found in directory. Expected
.claude-plugin/marketplace.json or .claude-plugin/plugin.json").
2. **"Claude has no skill auto-discovery" is wrong.** Skills are
auto-discovered by scanning `skills/*/SKILL.md`; a `skills` manifest
array only *adds* directories to that scan (entries equal to the default
dir are filtered). An unlisted skill behind the out-of-plugin symlink
loaded fine — the installer dereferences the symlink into a real
directory in the install cache. Dropping the array also makes the old
test guard ("every skill must be listed or it's silently invisible")
structurally unnecessary: a new repo-root skill now ships to Claude,
Codex, and Gemini with no manifest change anywhere.
3. **The `hooks` manifest key was dead config.** #3417 established the
string-path override isn't honored for plugin loads; convention
discovery of `hooks/hooks.json` is the mechanism that works. A key
pointing at the conventional path adds nothing and could mask a future
mislocated file, so it's gone (one mechanism per guarantee).

## Changes

- `plugins/worktrunk/plugin.json` →
`plugins/worktrunk/.claude-plugin/plugin.json`; content reduced to
`name`/`description`/`author`.
- `plugins/worktrunk/CLAUDE.md`: layout diagram and Claude
path-resolution bullet rewritten to the convention-only model; the
refuted wrapper claim and the stale no-auto-discovery claim removed.
- Root `CLAUDE.md`: the Plugin Layout pointer updated (dropped the stale
"Codex no-hooks re-enablement" phrase — Codex hooks shipped inline in
#3364).
- `test_plugin_layout_is_consolidated`: now asserts the wrapper manifest
exists, a root-level `plugin.json` does not, the manifest carries no
`hooks`/`skills` keys, the plugin `skills` symlink resolves to the
repo-root `skills/` (unix only — Windows checkouts without
`core.symlinks` materialize it as a file), and every repo-root skill dir
carries a `SKILL.md` (the scan silently ignores a dir without one).

Codex and Gemini are untouched: Codex reads only its `.codex-plugin/`
wrapper, and its inline `hooks` object still overrides Codex's own
convention discovery, so the #3362 collision stays closed independent of
any of this.

## Verification

- `claude plugin validate plugins/worktrunk` passes (it failed on the
old layout). Remaining warnings are deliberate: no `version` field
(installs pin the git SHA as the version; a hand-maintained semver would
go stale) and the plugin-root `CLAUDE.md` (developer docs, not plugin
context).
- A live `claude --plugin-dir` session on this exact tree loads both
skills and the `UserPromptSubmit` hook sets the 🤖 marker, observed from
inside the session.
- The scratch-marketplace probe covered the same shape through the full
marketplace install flow, including the symlinked skills dir.
- Full pre-merge gate: 4389 tests passed plus lints, doctests, and
docs-sync.

## Risk

The one scenario this could regress is a Claude Code version old enough
to read *only* the root-level manifest. That couldn't be tested
directly; the mitigations are that the wrapper is the documented
convention, the loader prefers it today, and Claude Code self-updates
aggressively. The old layout's "verified end-to-end" claim most likely
dates from an earlier 2.1.x behavior or a misdiagnosed failure.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Codex plugin hooks should not rely on compatibility with Claude

3 participants