Files
max-sixty__worktrunk/plugins/worktrunk/CLAUDE.md
Maximilian Roos cfd6f099bc fix(codex): clear activity marker on session end (#3660)
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_.
2026-07-29 20:57:26 -07:00

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.json source: "./plugins/worktrunk". Claude reads plugins/worktrunk/.claude-plugin/plugin.json — the same wrapper convention as the marketplace root, and the only manifest location claude plugin validate accepts (a bare plugin.json at the plugin root loads through an undocumented fallback but fails validation). The manifest deliberately carries no version field: 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-version warning 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-path hooks manifest override for plugin loads, so a renamed file silently loads nothing (#3417) and a hooks key 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; a skills manifest array only adds directories to that scan, so listing the defaults is redundant.

    $CLAUDE_PLUGIN_ROOT is the plugin root.

  • Codex (codex-cli 0.144.1): .agents/plugins/marketplace.json source object { "source": "local", "path": "./plugins/worktrunk" }. Codex reads plugins/worktrunk/.codex-plugin/plugin.json. Skills load by convention — with no skills manifest key, Codex scans <plugin-root>/skills/; an explicit "skills": "./skills/" names the same directory, so the manifest carries no skills key. The scanned tree is the real-file mirror ("Plugin skills are a generated mirror" below).

  • Gemini: gemini-extension.json at the repo root; ${extensionPath} is the repo root, so ${extensionPath}/skills/ is the repo-root skills/ directly and hooks/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 add copies 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_recursive in codex-rs/core-plugins/src/store.rs), and sessions load from that cache copy. A symlink anywhere in the tree — a top-level skills link or a nested one like reference/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_path in codex-rs/core-plugins/src/manifest.rs).
  • Codex's convention scan (default_skill_roots, the empty-skills branch of plugin_skill_roots in codex-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.json at the plugin root by convention (DEFAULT_HOOKS_CONFIG_FILE, the None branch of load_plugin_hooks) — which once surfaced Worktrunk's Claude events in a Codex session (#3362). The Codex manifest carries its own hooks inline, taking Codex's Some(Inline) branch (resolve_manifest_hooks in codex-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 shared hooks/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_ROOT native, CLAUDE_PLUGIN_ROOT as an OOTB-compat alias — codex-rs/hooks/src/engine/discovery.rs). The Codex file uses the native $PLUGIN_ROOT so 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.