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 clauderegisters thecailmdaley/feltmarketplace and installs the plugin through Claude's native CLI;felt setup codexdoes the same through Codex's native marketplace and plugin commands. Neither installer hand-writes harness configuration.felt setup piinstalls the same skills plus the pi extension through pi's package manager.felt updaterefreshes pi only when the Felt package is already registered, so an update never opts a new harness into the integration.- The plugin bundles the
feltandshuttleskills, 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
PostToolUseandUserPromptSubmithooks, 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 updatereplaces both Go CLIs as a pair, then refreshes each installed integration; the Homebrew formula'spost_installdoes the same onbrew 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.