Skip to content

Add Claude Code and Codex plugins backed by one hook table - #14

Merged
jbwinters merged 13 commits into
mainfrom
feat/host-plugins-20261106
Nov 7, 2026
Merged

jbwinters merged 13 commits into
mainfrom
feat/host-plugins-20261106

Conversation

@jbwinters

@jbwinters jbwinters commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Add native Claude Code and Codex plugins with explicit, user-invoked setup and hooks that run the locally installed Crosshead runtime. Plugin hooks perform no automatic downloads. The setup skill requires consent, verifies the installer digest and connects the runtime in plugin mode.

Migration preserves unrelated settings and hooks, refuses symlinked managed files before writing, and removes global registrations only for a user-scope plugin. Duplicate suppression applies to the matching event and source, including fork sessions. Codex migration waits for a receipt from the current plugin version and the actual Codex home, so stale trust evidence cannot remove working hooks. Doctor explains inactive plugins and pending trust. Plugin 1.0.1 content is hash-pinned.

Published as v0.5.0-alpha.9 (source c3648e3). Validation: 470/470 tests; typecheck, boundary, build, generated-file and release smoke checks. Real Claude Code 2.1.319 and Codex 0.167.0 journeys pass in a bounded disposable Docker container and on macOS CI: fresh setup, no-runtime notices, one capture per event with both registrations, migration, uninstall and doctor. Containers and volumes return to their baseline.

Coverage limits: model APIs and installer downloads are local fixtures; setup commands are exercised directly. Interactive consent and /hooks UI, native compaction, hosted identity registration and the production installer are separate acceptance paths. Public installation passed after deployment: GitHub workflow 42337328354 completed fresh installs and alpha.6 upgrades on Linux/macOS, and the public marketplace/installer Docker journey passed all nine checks. Desktop/mobile checks passed for all four setup pages.

The fallback action now carries a classified failure (timeout, aborted,
HTTP status and error code, or an error name) so local logs distinguish a
client timeout from a server error or a recall deadline. Messages, URLs and
bodies are never copied.
Claude Code and Codex can now install Crosshead as a plugin from this
repository's marketplace (.claude-plugin/marketplace.json and
.agents/plugins/marketplace.json). Each plugin contains only its hooks, a
setup skill and a POSIX launcher that runs the installed runtime's existing
hook entrypoints; it never bundles, downloads or updates the runtime. Without
a configured runtime, SessionStart prints one setup line and every hook exits 0.

src/runtime/host-hook-spec.ts is the single source for hook events, matchers,
status messages and timeouts. The settings.json/hooks.json installer and the
generated plugin files (scripts/host-plugins.mjs, checked by
npm run plugins:check and a unit test) both render from it.

crosshead install claude-code|codex and quickstart take
--hook-mode auto|plugin|settings. auto detects an enabled plugin. In plugin
mode the installer writes no host hooks and idempotently removes the ones it
wrote before. The hook entrypoint skips the duplicate registration, so each
event is handled once even before migration. crosshead doctor reports the
active mode per host and flags duplicate or missing registrations.

quickstart reuses an already running daemon, so setting up a second host no
longer fails with "Detached daemon exited before becoming ready".

scripts/test-host-plugins.sh runs the real Claude Code and Codex CLIs in a
disposable container against local fixtures.
In plugin mode the installer intentionally writes no host hook files, so the
adapter automation checks no longer report them missing. They point to the
claude_code_hooks/codex_hooks checks, which verify the plugin.
@jbwinters

Copy link
Copy Markdown
Contributor Author

Independent review: changes required

The structure holds up. The plugins are thin, there is one hook table, the legacy layout is byte-identical, and the release tarball is unchanged. Typecheck, plugins:check, host-plugins.test.ts (18/18) and claude plugin validate --strict all pass. The problem is that the dedup rule can leave a workspace with no capture, or with two captures. Fixes for the items below are coming on this branch.

High

  1. The legacy-hook lookup checks the wrong files. claudeProjectSettingsCandidates (host-plugin-mode.ts:156) checks every ancestor's .claude/settings.json, but Claude Code reads the project settings only from the project directory.
    • No capture: legacy hooks are in W/.claude/settings.json and a session starts in W/sub. The legacy hooks don't load, and the plugin sees them in W and skips.
    • Two captures: the default install writes hooks to $HOME/.claude/settings.json, which is the user settings file. For a project outside $HOME, that file isn't an ancestor, so the plugin also runs.
    • Fix: check exactly the files Claude Code loads ($CLAUDE_PROJECT_DIR/.claude/settings{,.local}.json and the user settings), and use CLAUDE_PROJECT_DIR instead of the payload cwd. Doctor reuses this lookup, so it has the same bug.
  2. Empty hook arrays count as legacy Crosshead hooks. claudeSettingsFileHasLegacyHooks and codexHooksFileHasLegacyHooks compare the JSON before and after filtering. The filter also prunes empty arrays and matcher groups, so {"hooks":{"Stop":[]}} reads as "legacy present". The plugin then skips, nothing captures, and doctor reports a false duplicate. A null group in Codex hooks.json throws inside dispatch. Fix: count matching Crosshead commands directly.

Medium

  1. Hook mode is global, but plugin enablement can be per project. If the plugin is enabled only in one project's .codex/config.toml or Claude project settings, the global legacy hooks get deleted, and every other project ends up with no hooks. Codex also ignores project config for untrusted projects. Fix: choose plugin mode and migrate global hooks only when the plugin is enabled at user scope.
  2. Codex doesn't run plugin hooks until the user trusts them. Migration removes the legacy hooks before the user has trusted the plugin's, and doctor reports ok either way. Each plugin version bump requires trusting them again. Fix: keep the legacy hooks until a --via plugin invocation is observed, and make doctor check for one.
  3. Nothing technical stops the model from running setup. The setup notice is added to the model's context, and the skill isn't restricted to the user, so the model could run setup itself, ending in curl | bash. Fix: make the skill user-invoked only (disable-model-invocation: true and the Codex equivalent), and reword the context line.
  4. quickstart can reuse an outdated daemon. After an install, it can reuse a daemon still running the old release, and it ignores --port. Fix: compare the running release with the installed one and restart through the existing safe stop/start path.

Low

  1. The launcher misses non-default locations. It only looks for host config in the default paths, so --claude-home or --codex-home installs show "isn't set up". It also bypasses the runtime's check for both ~/.crosshead and ~/.onlybots being present, and treats an empty CROSSHEAD_HOME differently from node.
  2. Migration edge cases. It deletes a git-tracked project settings file. It loses Codex hooks.json keys other than hooks. It throws partway through when the settings file is a symlink. The TOML parser reads enabled = false#c as enabled and matches crosshead@ from any marketplace.
  3. Nits.
    • Nothing enforces a plugin version bump when content changes.
    • Codex policy.authentication: "ON_INSTALL".
    • The SessionStart matcher doesn't include clear.
    • The launcher doesn't work on Windows.
    • No tests cover subdirectories, workspaces outside $HOME, empty arrays, project-scope enablement or Codex trust.

… charged

The recorded failure code passes the same identifier check as the error name.
The fallback warning now follows the failure class: a server deadline that
reports nothing charged says so, a 4xx names the rejection, and only timeouts,
network failures and unconfirmed server errors keep the may-still-settle caution.
A lifecycle test asserts that the fallback action records details.failure.
The daemon's health response now reports its entry script. When quickstart
finds a healthy daemon running a different release than the installed one, it
restarts it on the installed release through the ownership-checked stop/start
path, and reports the mismatch when a safe restart is not possible (legacy
ownership, organizational worker). --port applies on start and restart; a
current daemon on another port is reported instead of silently kept.
- Dedup checks exactly the settings files the host loads: for Claude Code the
  user settings and the session's CLAUDE_PROJECT_DIR (plus the git-root local
  file), with no ancestor walk; for Codex the hooks.json of the CODEX_HOME it
  ran with. Doctor uses the same lookup.
- Legacy hooks are detected by counting Crosshead commands; empty arrays,
  empty groups and null groups never count and never throw.
- Plugin mode, and removal of installer hooks, require the plugin enabled at
  user scope. Project-only enablement keeps settings mode; Codex project
  config counts only for trusted projects. Doctor explains both.
- Codex keeps its installer hooks until a plugin invocation has been
  recorded, so untrusted plugin hooks never leave a session uncaptured; doctor
  points to /hooks trust when none has been seen.
- The setup skill is user-invoked only, and SessionStart notices are
  user-visible systemMessages with no model context.
- The launcher only locates the runtime home (empty variables unset, two-homes
  conflict reported); the runtime resolves --claude-home/--codex-home installs
  itself, and a capability marker in the hook wrappers identifies runtimes
  that support plugin dispatch.
- Migration never deletes a settings file, keeps unknown Codex hooks.json
  keys, refuses symlinked files before writing anything, and the TOML reader
  handles comments and matches only crosshead@crosshead.
- SessionStart also matches clear; plugin content is pinned per version.
@jbwinters

Copy link
Copy Markdown
Contributor Author

Review findings: status

Fixes are in b63a7a8 (quickstart daemon), e007b2a (everything else, with tests and docs) and 632b49a (e2e container uses --rm). Every finding except the ON_INSTALL nit is fixed.

# Finding Status
1 Legacy-hook lookup checks the wrong files Fixed (e007b2a). The lookup checks exactly the files Claude Code loads, per https://code.claude.com/docs/en/settings.md: user settings.json in the Claude config dir, and .claude/settings.json plus .claude/settings.local.json in the session's project directory. It also checks the git-root settings.local.json, where Claude Code 2.1.211+ keeps the local file. There is no ancestor walk. The project directory is CLAUDE_PROJECT_DIR when set, else the payload cwd. Doctor uses the same lookup and defaults to CLAUDE_PROJECT_DIR.
2 Empty hook arrays count as legacy hooks Fixed (e007b2a). Detection counts matching Crosshead commands. Empty arrays, empty groups, and null or non-object groups count as zero and don't throw. The filters tolerate the same shapes.
3 Hook mode is global, enablement can be per project Fixed (e007b2a). Plugin mode, and removal of installer hooks, happen only when the plugin is enabled at user scope: Claude user settings, or $CODEX_HOME/config.toml. With project-only enablement, the mode stays settings, the global hooks are kept, and the plugin defers to them; --hook-mode plugin then writes no new hooks. Codex project .codex/config.toml counts only when the user config trusts that project (trust_level = "trusted", per the Codex plugin docs). Doctor explains both cases.
4 Codex removes legacy hooks before the plugin hooks are trusted Fixed (e007b2a). A --via plugin invocation is recorded in state/host-plugin-invocations.json, even when it defers. Codex migration waits for that record. Until then the installer hooks capture and the plugin defers, so each event is captured exactly once. Doctor warns, and mentions /hooks trust, when plugin mode is configured but no plugin invocation has been seen.
5 Model could run setup Fixed (e007b2a). The Claude skill has disable-model-invocation: true. Codex gets skills/setup/agents/openai.yaml with policy.allow_implicit_invocation: false, the documented Codex mechanism; Codex has no SKILL.md field for this. SessionStart now emits only a systemMessage addressed to the user ("To set it up, run /crosshead:setup."), with no additionalContext. The e2e confirms neither host sends the notice or the skill name to the model.
6 quickstart reuses an outdated daemon and ignores --port Fixed (b63a7a8). The daemon's /health reports its entry script, which quickstart compares with the installed release. On a mismatch it restarts the daemon through the ownership-checked stop/start path. It reports the mismatch instead when a restart isn't safe (legacy ownership, organizational worker). --port applies on start and restart, and a current daemon on a different port is reported.
7 Launcher misses non-default locations Fixed (e007b2a). The launcher only locates the runtime home. The runtime resolves the adapter config itself: the host's CLAUDE_CONFIG_DIR/CODEX_HOME, else the directory recorded at install. An empty CROSSHEAD_HOME is treated as unset in both the launcher and node. Separate ~/.crosshead and ~/.onlybots installs now produce the runtime's "choose one" notice instead of a silent pick. Old runtimes are recognized by a capability marker in the hook wrappers.
8 Migration edge cases Fixed (e007b2a). Settings files are never deleted; an emptied file becomes {} (also on uninstall). Unknown top-level keys in Codex hooks.json are kept. Every file to change is checked before any write, and a symlinked settings file is refused with nothing changed. The TOML reader strips # comments outside strings (so enabled = false#c reads as false) and handles inline tables, dotted keys and multi-line values. Only crosshead@crosshead is recognized, in both hosts.
9a No enforced version bump Fixed (e007b2a). A test pins a SHA-256 of the generated plugins/ content for each CROSSHEAD_HOST_PLUGIN_VERSION. 1.0.0 is unreleased, so it stays 1.0.0 with the new digest pinned.
9b policy.authentication: "ON_INSTALL" Not changed. The Codex plugin docs say to always include policy.installation and policy.authentication on every entry, and ON_INSTALL is the only documented value. The plugin asks for no credentials; a comment in the generator says so.
9c SessionStart matcher lacks clear Fixed (e007b2a). The matcher is now startup|resume|clear|compact in both hosts and both modes, because /clear starts a fresh context like startup. This deliberately changes the installer-written layout, and the byte-for-byte tests were updated with that reason. fork is left out.
9d Launcher doesn't work on Windows Documented as unsupported in the launcher, docs/HOST-PLUGINS.md and the README.
9e Missing tests Added (e007b2a): subdirectory session, workspace outside $HOME, empty and null arrays, project-scope enablement (Claude and trusted Codex), untrusted Codex project config, the Codex not-yet-trusted window, symlinked settings (Claude and Codex), a stale daemon (real daemon restarted from a copied release), the launcher with non-default homes, empty CROSSHEAD_HOME and the two-homes conflict. When the new test file runs against bbb1e09, 30 of 34 tests fail. Most fail on behavior; a few fail only because they call new helpers (the stale-daemon test is one).

Verification

  • npm run typecheck, check:boundary, build and plugins:check pass, and vitest run passes 432/432.
  • scripts/test-host-plugins.sh passes 8/8 (Claude Code 2.1.319, Codex 0.167.0). The e2e container runs with 2 CPUs, 3 GiB RAM and no swap, and is removed afterwards.
  • GitHub CI on 632b49a: Test (Node 22), Test (Node 24), Claude Code and Codex plugins, docker-smoke, Reproducible release build all succeeded.

@jbwinters
jbwinters merged commit c3648e3 into main Nov 7, 2026
8 checks passed
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.

1 participant