Skip to content

Plugin integration and releasing

felt ships one shared plugin payload (claude-plugin/) for Claude Code and Codex, plus a native package for pi. The same hook scripts and skills directory work for Claude and Codex; only the manifest at the plugin root differs (.claude-plugin/ and .codex-plugin/ siblings, same content). A single marketplace manifest at .claude-plugin/marketplace.json registers the shared plugin for both.

  • felt setup claude registers the cailmdaley/felt marketplace and installs the plugin through Claude's native CLI; felt setup codex does the same through Codex's native marketplace and plugin commands. Neither installer hand-writes harness configuration.
  • felt setup pi installs the same skills plus the pi extension through pi's package manager. felt update refreshes pi only when the Felt package is already registered, so an update never opts a new harness into the integration.
  • The plugin bundles the felt and shuttle skills, Felt hooks for SessionStart (active and recently touched fibers) and PreToolUse (internal/feltcli/hook.go), and Shuttle hooks for event and commit records. Updating the binaries updates binary-owned hook behavior; refresh the plugin when its skills or adapter scripts change.
  • Handoff nudges run in felt's Pi extension and the shared Claude Code/Codex PostToolUse and UserPromptSubmit hooks, independent of a store or running daemon. Refresh the package/plugin after changing this integration, not just the binaries.
  • Binaries and plugin update in lockstep. felt update replaces both Go CLIs as a pair, then refreshes each installed integration; the Homebrew formula's post_install does the same on brew upgrade felt.

Context handoff nudges

The first nudge fires at min(window × 75%, 500000) context tokens; the firmer nudge fires at min(window × 88%, 750000). Each level fires once per session, even after resuming or compacting. If a turn crosses both thresholds, Pi sends the first nudge and escalates on the next turn if work continues. The integration advises a durable handoff; it neither terminates the session nor changes interactive Pi's compaction settings. Confer can still veto automatic compaction for its workers, but felt owns their nudges.

Environment variable Default
SHUTTLE_HANDOFF_PCT 75
SHUTTLE_HANDOFF_TOKENS 500000
SHUTTLE_HANDOFF_HARD_PCT 88
SHUTTLE_HANDOFF_HARD_TOKENS 750000

Percentages must be positive and at most 100; token limits must be positive. Hard settings must be at least their corresponding first-nudge settings. Invalid configuration disables nudges without failing tools. Pi uses getContextUsage() and records shuttle-handoff entries in the session; it also honors existing confer-handoff entries.

The Claude/Codex shim needs Node on the hook's PATH (or a standard Homebrew/system install); a missing runtime fails open. It reads at most the last 256 KiB of the transcript and skips malformed, partial, or oversized records. If that suffix contains no usage record, it emits nothing and tries again at the next hook. Claude context is the latest assistant's input, cache-read, cache-creation, and output tokens combined. An explicit context_window field, SHUTTLE_HANDOFF_CONTEXT_WINDOW override, or [1m] model selector supplies the window; otherwise only the absolute limit applies. No window is guessed from a Claude alias. The hook stores private atomic per-level claims under ~/.shuttle/handoff/, keyed by a hash of harness and session ID; SHUTTLE_HANDOFF_STATE_DIR overrides this location. Resumes retain these files; delete a session's claims only to deliberately re-enable its warnings.

Codex supports these hooks and additionalContext. Its rollout event_msg/token_count records provide info.last_token_usage.total_tokens and info.model_context_window; cumulative billing totals are never used. The transcript format isn't a stable Codex hook API, so unsupported formats fail open rather than estimating context. Codex users must trust plugin hooks in their interactive session.

Refresh a source build on the current machine with felt setup claude --source <clean-main-checkout> and felt setup codex --source <clean-main-checkout> for installed harnesses, then check felt setup receipt --json. These commands promote ~/.felt/plugin-runtime/current transactionally; they don't restart or deploy the daemon. For an installed Pi package, run pi update git:github.com/cailmdaley/felt, then /reload or start a new session.

Plugin promotion

Every Claude/Codex setup source enters the same transaction. Remote GitHub refs are first acquired into a disposable checkout; local --source paths enter directly. Setup validates and copies only the complete marketplace payload into ~/.felt/plugin-runtime/, then promotes it under a cross-process lock with a crash journal. Both manifests must describe the same version; the two skills, hook manifest, executable hook files, and the installed Shuttle CLI contract must all validate before the native CLI sees the candidate. Native harness CLIs receive only the stable promoted current path and remain the sole writers of their caches and configuration. If native installation reports failure, setup restores both the last known-good staged generation and the harness's previous marketplace/plugin state. A zero exit status alone never commits a promotion: before the journal records committed and previous is discarded, setup reads the cache path each native CLI reports as loaded (plugin list --json), recomputes its payload digest, and requires it to carry the promoted generation marker. A failed verify first gets one forced reinstall (uninstall+install / remove+add), because plugin update on an unchanged manifest version legitimately keeps the old versioned cache; a cache that still cannot prove the promoted generation after that is a rejected candidate — the filesystem rolls back and the prior native state is restored. The journal also records native activation intent: after an interruption, the next setup restores current first, reinstalls each affected harness from that path, verifies the restored state, and retains the journal until reconciliation succeeds.

Every promoted plugin carries .felt-generation.json inside the payload the harness copies. It binds the canonical local or GitHub source, requested ref, resolved commit, plugin version, both Go CLI build identities, and a deterministic payload digest. A same-version payload change is therefore a different generation. The receipt recomputes the digest in current and the loaded harness cache and reports pending journals, missing markers, or identity disagreement as unhealthy with a setup command to repair it. It also checks that the generation records the installed felt and shuttle builds. Marker and journal writes are fsynced and renamed with a parent-directory sync, so the recovery guarantees hold across power loss, not only process death.

Use felt setup validate --source <checkout> as the non-mutating candidate gate. Use felt setup receipt --json to report the bundle the harness CLIs load, the felt binary, and Felt-owned hook state. Run shuttle doctor for the host, listener, and live daemon contract; incidental cache directories are not authoritative evidence.

CI is a release gate as well as a pull-request check. The UI job runs npm test, which executes the board suite twice under the pinned America/Los_Angeles and Europe/Paris timezones, and then runs the production bundle build. A green Go and daemon suite without this UI test is not a release-ready result. CI also runs govulncheck for reachable Go vulnerabilities, mix_audit for Elixir dependency advisories, and npm audit --omit=dev for shipped UI dependencies. Resolve findings before cutting a stable tag. The toolchain directive in go.mod pins the compiler used by source builds, CI, and release packaging; the go directive remains the language minimum. Keep the toolchain on a supported security patch release.

Release: scripts/release.sh <version> first requires the main checkout to be cleanly aligned with origin/main and checks that the new tag is absent both locally and remotely. It then bumps claude-plugin/.claude-plugin/plugin.json and .codex-plugin/plugin.json in sync with the binary tag, commits that bump, and creates the annotated tag. The script prints the explicit git push origin main v<version> command; it does not push for you.

Pushing the tag triggers the GoReleaser workflow (darwin/linux × amd64/arm64; it updates the Homebrew formula for final public releases). The workflow puts both Go CLIs in felt_<os>_<arch>.tar.gz and pins GoReleaser to the version used for the published 1.1.0-rc.3 assets. The Mix daemon release uses shuttled_<os>_<arch>.tar.gz. Before packaging, GoReleaser runs the complete candidate validator, requires the two plugin manifests to agree, and on a real tag refuses a manifest version that does not match it. Local snapshots skip only the tag comparison, which keeps development packaging usable while retaining the agreement check. GoReleaser creates a draft release and defers the Homebrew tap update; the daemon matrix builds, gates and boot-tests every native artifact, attaches all four daemon tarballs, and the final job verifies the complete eight-archive set before making the release public.

The Linux daemon legs run inside an almalinux:8 container, not on the bare Ubuntu runner: a Mix release bundles the native ERTS that built it, and that ERTS inherits the glibc/libstdc++ symbol floor of the build host. Built on ubuntu-latest it needs GLIBC_2.38, which no HPC login node has (the fleet floor is 2.28 — RHEL/Rocky/Alma 8). EL8 is the manylinux_2_28 baseline, so an artifact built there runs everywhere newer. There is no prebuilt OTP for EL8, so the leg builds OTP from source into a cached /opt/otp, keyed on the exact patch pinned in the workflow's EL8_OTP_VERSION (same major as ci.yml).

The invariant is declared and gated, not assumed: scripts/check-glibc-floor.sh runs objdump -T over every ELF file in the assembled release and fails the job if any binary needs more than GLIBC_2.28 / GLIBCXX_3.4.25 / GCC_7.0.0. The boot test cannot catch this class of regression — it runs on the host that produced the binaries, where the symbols are present by construction — which is why a base-image bump or a leg quietly losing its container: fails at the gate instead of on someone's cluster. Run the script locally against an untarred release to audit a published artifact (macOS: brew install binutils).

GoReleaser's formula is preserved as an Actions artifact alongside those exact archives; for a final release, that job pushes the preserved file to the tap through the GitHub Contents API only after publication. A failed native platform therefore leaves a draft for repair instead of exposing a stable CLI release that cannot satisfy SHUTTLE_DAEMON=1 installs; a tap update cannot point at draft assets either. Rerunning the same tag reuses that draft and replaces its artifacts.

The tap currently publishes a Homebrew Formula, so keep using brew install cailmdaley/tap/felt. GoReleaser reports the legacy brews publisher as deprecated in current v2 releases; moving to homebrew_casks would require a coordinated tap and documentation migration and is tracked as a packaging follow-up rather than being mixed into a stable release cut.

For an end-to-end binary consumer check, use an explicit published tag in a fresh home directory and inspect both installed versions before starting the daemon:

PROBE_ROOT="$(mktemp -d)"
HOME="$PROBE_ROOT/felt-home" PATH=/usr/bin:/bin \
  FELT_INSTALL_DIR="$PROBE_ROOT/cli" \
  FELT_VERSION=2.0.0 SHUTTLE_DAEMON=1 \
  SHUTTLE_HOME="$PROBE_ROOT/shuttle" sh ./install.sh
"$PROBE_ROOT/cli/felt" --version
SHUTTLE_RELEASE="$PROBE_ROOT/shuttle" "$PROBE_ROOT/cli/shuttle" version

The installer verifies both Go CLI versions and the daemon release before replacing an existing installation. With no daemon listener, shuttle version uses the release directory and runs its shuttled version command, which starts the bundled BEAM and fails on a host whose glibc is older than the build machine's. The native release matrix boots each assembled daemon artifact before upload, while the Linux container acceptance test builds from a clean image and polls /api/v1/version until its contract is healthy.

Release candidates: scripts/release.sh 1.1.0-rc.1 — any X.Y.Z-<suffix> version cuts a prerelease. Three things then keep it away from everyone who didn't ask for it, and all three key off the - in the tag: goreleaser marks the GitHub release prerelease: auto; install.sh and felt update resolve through the releases/latest API, which skips prereleases; and the Homebrew tap's skip_upload is true for any prerelease, so brew upgrade felt never sees it. The only way in is pinning FELT_VERSION (see Release candidates). The daemon tarballs attach to the RC release the same way, stamped with the RC version.