Codex now exposes `SessionEnd`, but Worktrunk’s Codex plugin only returned the activity marker to idle at turn end. This adds a main-session exit hook that clears the marker, using Codex’s three-second maximum hook timeout so cleanup has the best chance to complete without delaying shutdown. The CLI help, plugin-layout guidance, user documentation, generated mirrors, metadata test, and help snapshot now describe and verify the complete Codex lifecycle. Tested with `cargo run -- hook pre-merge --yes` (4,564 tests passed; 1 skipped). > _This was written by Claude Code on behalf of max_.
11 KiB
Worktrunk Plugin Guidelines (Claude Code + Codex)
Directory Layout
This directory (plugins/worktrunk/) is the Claude Code + Codex payload. Each
tool hardcodes its loader path with no fallback, so the repo root carries one
pointer per tool: Claude's and Codex's both source → ./plugins/worktrunk,
while Gemini resolves its extension at the repo root itself; Gemini's hooks
call the canonical hooks/wt.sh below.
worktrunk/ ← repo root = marketplace root
├── .claude-plugin/marketplace.json ← Claude pointer (source → ./plugins/worktrunk)
├── .agents/plugins/marketplace.json← Codex pointer (source → ./plugins/worktrunk)
├── gemini-extension.json ← Gemini manifest (extensionPath = repo root)
├── hooks/hooks.json ← Gemini activity hooks (call the wt.sh below)
├── skills/ ← real dir; Gemini reads ${extensionPath}/skills directly
└── plugins/worktrunk/ ← plugin root (Claude + Codex resolve source here)
├── .claude-plugin/plugin.json ← Claude manifest (metadata only — NO `hooks`
│ or `skills` keys; components load by
│ convention, see below)
├── .codex-plugin/plugin.json ← Codex manifest (Codex's required wrapper)
├── hooks/hooks.json ← Claude activity + WorktreeCreate/Remove hooks,
│ discovered by convention at this exact path
│ (#3417; Codex is kept off it by its inline
│ manifest, #3362)
├── hooks/wt.sh ← canonical hook shim; Claude reaches it via
│ $CLAUDE_PLUGIN_ROOT, Codex via $PLUGIN_ROOT,
│ Gemini via
│ ${extensionPath}/plugins/worktrunk/hooks/wt.sh
├── skills/ ← generated real-file mirror of repo-root
│ skills/ (test_docs_are_in_sync; never
│ hand-edit) — real files because Codex's
│ installer drops symlinks, see below
├── CLAUDE.md / README.md
└── (Codex activity hooks live *inline* in .codex-plugin/plugin.json's
`hooks` key — see Known Limitations below)
Path resolution differs by tool, all verified end-to-end against the real CLIs:
-
Claude (claude-cli 2.1.207):
.claude-plugin/marketplace.jsonsource: "./plugins/worktrunk". Claude readsplugins/worktrunk/.claude-plugin/plugin.json— the same wrapper convention as the marketplace root, and the only manifest locationclaude plugin validateaccepts (a bareplugin.jsonat the plugin root loads through an undocumented fallback but fails validation). The manifest deliberately carries noversionfield: installs pin the marketplace git SHA, so a semver here would be a second version to maintain with nothing consuming it —claude plugin validate's missing-versionwarning is accepted. Components load by convention, and the manifest must not name them:- Hooks are discovered at
hooks/hooks.json. The loader does not honor the string-pathhooksmanifest override for plugin loads, so a renamed file silently loads nothing (#3417) and ahookskey pointing at the conventional path is dead config that can only mask a mislocated file. - Skills are auto-discovered by scanning
skills/for<dir>/SKILL.md; askillsmanifest array only adds directories to that scan, so listing the defaults is redundant.
$CLAUDE_PLUGIN_ROOTis the plugin root. - Hooks are discovered at
-
Codex (codex-cli 0.144.1):
.agents/plugins/marketplace.jsonsourceobject{ "source": "local", "path": "./plugins/worktrunk" }. Codex readsplugins/worktrunk/.codex-plugin/plugin.json. Skills load by convention — with noskillsmanifest key, Codex scans<plugin-root>/skills/; an explicit"skills": "./skills/"names the same directory, so the manifest carries noskillskey. The scanned tree is the real-file mirror ("Plugin skills are a generated mirror" below). -
Gemini:
gemini-extension.jsonat the repo root;${extensionPath}is the repo root, so${extensionPath}/skills/is the repo-rootskills/directly andhooks/hooks.json(repo root) calls the canonical shim at${extensionPath}/plugins/worktrunk/hooks/wt.sh. No symlink or copy.
All three tools pick up the whole skills/ set — Gemini reads the repo-root
directory, Claude and Codex ship the plugin mirror — so a new repo-root skill
ships everywhere once test_docs_are_in_sync regenerates the mirror, provided
its directory contains a SKILL.md (test_plugin_layout_is_consolidated
enforces that; a directory without one is silently ignored). Claude-only
skills reach the other tools too (accepted tradeoff — see Known Limitations
below).
Plugin skills are a generated mirror
plugins/worktrunk/skills/ is a real-file mirror of the authored repo-root
skills/, regenerated — symlinks dereferenced, stale files deleted — by the
sync_plugin_skills_mirror stage of test_docs_are_in_sync; never hand-edit
it. Repo-root skills/ stays the authored home: Gemini reads it directly and
the docs sync writes into it.
The mirror holds real files because of how the plugin ships, verified
end-to-end against codex-cli 0.144.1 (scratch marketplaces through
codex plugin marketplace add + codex plugin add, skill inventory read with
codex debug prompt-input):
codex plugin addcopies the plugin into$CODEX_HOME/plugins/cache/<marketplace>/<plugin>/<version>with a copier that handles only regular files and directories, silently skipping symlink entries (copy_dir_recursiveincodex-rs/core-plugins/src/store.rs), and sessions load from that cache copy. A symlink anywhere in the tree — a top-levelskillslink or a nested one likereference/README.md— ships no content. No manifest value can bridge it: manifest paths must stay within the plugin root (..and absolute paths are rejected,resolve_manifest_pathincodex-rs/core-plugins/src/manifest.rs).- Codex's convention scan (
default_skill_roots, the empty-skillsbranch ofplugin_skill_rootsincodex-rs/core-plugins/src/loader.rs) reads the mirror like any directory. - Claude's installer dereferences symlinks, so a symlinked
skills/worked for Claude; the mirror serves it identically. A symlink also materializes as a plain text file on Windows checkouts, which shipped no skills from a Windows clone to Claude or Codex.
test_plugin_layout_is_consolidated pins the no-symlinks invariant;
test_docs_are_in_sync pins content equality with repo-root skills/.
Known Limitations
Status persists after user interrupt (Claude)
The Claude hooks track activity via git config (worktrunk.state.{branch}.marker):
UserPromptSubmit→ 🤖 (working)Notification,PreToolUse(AskUserQuestion),PermissionRequest,Stop→ 💬 (waiting for input)SessionEnd→ clears status
The 💬 transitions overlap deliberately: Notification covers the documented permission/idle path, but on platforms where it doesn't fire (VS Code extension, Windows CLI) PermissionRequest and Stop still mark the wait; PreToolUse(AskUserQuestion) catches the built-in question picker, which fires no Notification on any platform (claude-code#13024). There is currently no transition back to 🤖 once a turn-end/permission marker is set except a fresh UserPromptSubmit, so 💬 can persist into resumed work after a permission grant (the original symptom in #2916).
Problem: If the user interrupts Claude Code (Escape/Ctrl+C), the 🤖 status persists because there's no UserInterrupt hook. The Stop hook explicitly does not fire on user interrupt.
Tracking: claude-code#9516
Codex activity hooks
Claude's hooks live in the standalone hooks/hooks.json its loader discovers by convention (see Directory Layout above); the Codex manifest carries hooks as an inline object, { "hooks": { … } }, embedding a Codex-tailored hooks file directly. The inline form is deliberate:
- Why inline for Codex, not a path or an absent key. Claude and Codex share one payload dir, and Codex also auto-discovers
hooks/hooks.jsonat the plugin root by convention (DEFAULT_HOOKS_CONFIG_FILE, theNonebranch ofload_plugin_hooks) — which once surfaced Worktrunk's Claude events in a Codex session (#3362). The Codex manifest carries its own hooks inline, taking Codex'sSome(Inline)branch (resolve_manifest_hooksincodex-rs/core-plugins/src/manifest.rs), which overrides convention discovery. The inline object is both the functional definition of the Codex-native events and the thing that keeps Codex off the sharedhooks/hooks.json, so the two toolchains coexist on one file: Claude discovers it, Codex ignores it. (Scoping via the filename instead —hooks/claude-hooks.json— breaks Claude's discovery, #3417; the inline override makes it unnecessary.) - Why
$PLUGIN_ROOT, not$CLAUDE_PLUGIN_ROOT. Codex exports both to hook commands (PLUGIN_ROOTnative,CLAUDE_PLUGIN_ROOTas an OOTB-compat alias —codex-rs/hooks/src/engine/discovery.rs). The Codex file uses the native$PLUGIN_ROOTso nothing Claude-branded appears in a Codex session.
The events (Codex's HookEventsToml vocabulary, verified against codex-rs/config/src/hook_config.rs):
UserPromptSubmit→ 🤖 (working)PermissionRequest,Stop→ 💬 (waiting for input)SessionEnd→ clears the marker
Stop fires at turn-end, so 🤖 returns to 💬 when a turn completes. SessionEnd clears the marker when the main thread ends.
Accepted tradeoff: shared skills/ exposes wt-switch-create
Codex's mirrored skills/ and Gemini's ${extensionPath}/skills/ both carry the entire skill set, including wt-switch-create, which depends on Claude session-cwd switching (EnterWorktree) that neither provides. Accepted: a tool loading a skill it can't act on is harmless, and a single authored skills/ keeps the worktrunk skill single-source across all three tools and the docs sync. Don't add per-tool skills subtrees to exclude it.