Repository navigation
v2.4.8
The resume release. A stopped or crashed laptop client no longer unpairs the phone, and neither does Claude Code ending, restarting, resuming or sitting idle: each phone's relay login is stored outside the repo and resumed, by default with no age limit, and only an explicit disconnect, a revoke from the phone or a login the relay refuses unpairs. The push gate now grades each push against the repo being pushed, with that repo's own checks, and arms for more ways of typing a push. The SessionStart pair window sends your gh token only for a repo you ran hmd app connect in, and only when pairing needs it. A disk-space floor (10% free by default) stops agent spawns and test runs on a full disk. Users must re-pair phones once after upgrading.
Added
- A repo can declare its own checks, and the push gate passes it on a record of them —
.heimdall/repo-checks.json({"schema":1,"checks":[{"name","cmd"}]}, committed) plusbin/heimdall-repo-checks run|status [--repo PATH]:runexecutes each check from the repo root in order and writes.heimdall/receipts/repo-checks.jsonatomically (head_sha,tree_sha,decl_sha256,dirty,exit_code,finished_at, per-checkexit_code/secs);statussays whether it is green and current. The gate recomputes the commit, its tree and the committed declaration's hash from git objects and also requires a clean tree at run time, a green exit and exactly the declared checks; a stale, red, dirty, missing, mismatched, malformed or invalid record blocks and names the runner. A receipt attests ONE commit: the hook hands the commits the push sends as sources tostatus --pushes(each refspec's source resolved by git, HEAD for a bare push) and every one must be the receipt'shead_sha;--all,--mirror,--tags, a wildcard refspec, an option it cannot read and push configuration that picks the refs (push.default=matching, anyremote.<name>.push) cannot be tied to one receipt and block. The receipt's fields, types and formats are defined ONCE, inbin/lib/hmd_repo_receipt.py, whichrunbuilds the receipt with andstatusverifies it against; a test walks that definition (a receiptrunwrites passes, every field mutated alone fails). Likelast-sweep.jsonit is a cooperating-agent control, not a security boundary against someone who can write the file. Test:test/heimdall-push-gate-per-repo.test.sh. - A disk-space floor: agent spawns and test runs stop below 10% free —
HMD_MIN_FREE_PCT(a whole percent, default 10,0turns it off; a value that is not a whole number from 0 to 100, such as7%, is the default and says so on stderr, never a silent off) is checked against the free percent (df -Pk, Available ÷ size) of the volume holdingTMPDIRand of the one holding the repo;bin/lib/hmd_disk_floor.shis the one definition. It is enforced in three places: the Agent hook (bin/heimdall-precheck-agent) denies a spawn; the Bash hook (bin/heimdall-precheck-bash) denies a command that RUNS a*.test.shortest/run-all.sh, while text that only mentions one is not a run; andtest/run-all.shrefuses to start (exit 5) beside its absoluteHMD_TEST_MIN_FREE_GBfloor (default 5; whichever is stricter refuses), then asks again before every suite and every solo re-run. Once a floor is crossed mid-sweep nothing more is launched, the suites not yet started areREFUSED(a table row,suites_refusedanddisk_floorin the receipt, and innon_green_suites) and the run is red (exit 1), so a sweep cut short never reads as one that passed. A hook reads its own environment, so set the knob where Claude Code runs: an inlineHMD_MIN_FREE_PCT=5 bash test/x.test.shreaches the test, not the hook that has already said no. Adfthat cannot answer warns and allows (a resource guard, not a security gate).bin/heimdall-disk-floor checkshows where you stand. Test:test/disk-floor.test.sh.
Changed
hmd app: a stopped laptop relay client no longer unpairs the phone. Once a phone is bound, the client's relay session login (bearer
token, hmd key, seq marks) is stored outside the repo -- the macOS Keychain (written oversecurity -istdin, never argv), else a 0600
file under$HEIMDALL_HOME(bin/lib/hmd_session_login.py;HMD_SESSION_LOGIN_STORE=file|keychain|autopicks which, default auto: the Keychain on macOS whensecurityis on PATH,
else the file;test/run-all.shdefaults it tofile, so a sweep never touches the operator's Keychain). A TERM, Ctrl-C, KILL or crash revokes nothing;hmd app connectand its
respawns start the client with--resume, which reattaches to the same relay session without pairing again, however long ago the old
client was last alive: there is no age limit unless the operator setsHMD_SESSION_RESUME_H(hours, per phone;0= never resume a stopped client's
login), and the relay's own 30-day device-token lifetime is the one clock left. Onlyhmd app disconnect,hmd app disconnect --device, a revoke from the phone or a refused login unpair:
disconnect revokes at the relay through the stored login, also for a crashed client (it used to say "could NOT be revoked"). SIGUSR1 is
no longer a revoke trigger. No relay change, no relay deploy needed.hmd app disconnect --forget-local: the explicit way out of a stored login whose relay revoke can never be confirmed (stuckrevoking, a relay that stays
down or refuses it). Without the flag nothing changes -- an unconfirmed revoke still fails loudly and keeps the login -- and no other flag implies it. With it
(all phones, or one with--device) the revoke is tried first; a login still unconfirmed is dropped here: the relay session id, a warning that the phone can
keep connecting until that session expires and when (absolute UTC, the stored session expiry: the bind plus the relay's 30-day device-token lifetime) are
printed, the stored login (Keychain item and file) is deleted under the store lock, and asession_login_forgottenevent (relay-events.jsonl) and a line in
$HEIMDALL_HOME/logs/app.logrecord session, device, expiry and that the relay was NOT told. Exit 0 only once the delete and its log succeeded. Test:
test/app-session-forget-local.test.sh.hmd app: every paired phone comes back by itself after a restart, not only the first. A relay session binds one phone, so each phone has a client of its own and the
login of its session stored;hmd app connect(also its supervisor's respawn, and its look everyHMD_RESUME_RECONCILE_Sseconds, default 15) and the SessionStart pair
window now start oneheimdall-relay-client --resume-onlyfor every stored login that no live client serves, each resuming its own phone's session (its own seq blocks and
store lock, no re-pair, no nonce reuse) however long ago that phone's client was last alive (an operator-setHMD_SESSION_RESUME_Hbounds it, per phone). Phones added with
hmd app add-deviceare included.--resume-onlynever pairs: with no login left to claim it saysresume_noneand exits 0. Test:test/app-session-multi-resume.test.sh.hmd app disconnectand--forget-localcan no longer race a resume (security review of the multi-phone resume): a disconnect MARKS FIRST -- every stored login
goesrevokingin one hold of the store lock, before a window, the supervisor, a client or the relay is touched -- so no resume claims it afterwards (a periodic look,
a respawn, the pair window and a--resume-onlyclient all claim under that same lock and skip what is marked or gone); then the connect supervisor (its pid is
pid_watcherinconnect.json) is stopped and waited for, then the clients, and only then is the relay asked to revoke (or the login dropped), so nothing is
left running that could reattach, respawn or look again; a connect's periodic look also stops onceconnect.jsonis gone. A client that got through anyway ends
itself, revoking its session, withinHMD_SESSION_LOGIN_CHECK_S(2 s) of its login being marked or gone; a store it merely cannot read ends nothing. Tests:
test/app-session-revoke-race.test.sh.- A paired phone survives Claude Code ending, restarting, resuming or sitting idle — the repo's pair window belongs to the repo, not to a session, and a paired phone is never let go of by a session ending, a restart, a resume or idle time: the SessionEnd hook leaves the window, its supervisor and the phone's client running, the supervisor brings the client back if it dies, and the next session finds the same code and the same relay session. Each repo keeps a pairing record,
$HEIMDALL_HOME/app/repos/<sha256 of the repo's real path, first 16 hex>.json(0600: the repo's session code and the relay session and device ids of its phones; written when a phone binds, removed with the last stored login); while it exists every surface shows ITS code, so a regenerated machine seed cannot change the code of a repo with a paired phone. Onlyhmd app disconnect, a revoke from the phone or a login the relay refuses ends a pairing, and each stop of a window or a client leaves a line in$HEIMDALL_HOME/logs/app.logsaying what ended it and whether its session was kept or revoked.HMD_PAIR_WINDOW_IDLE_Hnow defaults to 0, no idle shutdown (the window used to stop after 4 hours); set above 0 it is an operator's bound that applies only while no phone is paired. Users must re-pair phones once after upgrading: a phone paired by an earlier release has no stored login to resume (its login lived only in the running client's memory) and its repo has no opt-in record, so runhmd app connectin the repo; from then on a restart resumes it. Tests:test/app-session-survives-cc-restart.test.sh,test/app-session-resume.test.sh. - The SessionStart pair window no longer sends your gh token on its own — it used to read
gh auth tokenand register the repo's code in every attended session of every repo whileghwas signed in, and again about every 5 minutes while no phone was paired. The token now goes out only for a repohmd app connectwas run for (the opt-in is a 0600 record under$HEIMDALL_HOME/app/pair-optin/, never inside the repo, so a checkout cannot opt itself in;hmd app disconnectandhmd app connect --no-coderemove it, a session ending never does) and only when pairing needs it: the window registers the code ONCE, with the token, while no phone is paired. The relay keeps a registration 360 s and nothing renews it (no timer, no restarted client, no later session start, which finds the window open and sends nothing); a lapsed code is offered again byhmd app connectorhmd app add-device, under the same code. A repo with neither the opt-in nor a paired phone starts nothing: noghcall, no token, no relay request. Resuming a paired phone needs no opt-in, noghand no token.HMD_PAIR_WINDOW_RENEW=1(exactly1) restores the old schedule: the code registered again about every 5 minutes, the token sent each time, until a phone binds orHMD_PAIR_WINDOW_IDLE_Hhours pass. Tests:test/pair-window-token-optin.test.sh,test/app-session-survives-cc-restart.test.sh. - The push gate runs each repo's own checks, not heimdall's — the quality gate used to run in the session's cwd for any push, so a push of the site (six gates of its own) was blocked for lacking heimdall's sweep receipt.
bin/lib/hmd_push_target.pynow names the repo each push goes to by asking git itself (-Cchains,--git-dir,GIT_DIR, subdirectories, linked worktrees;--work-treecannot act as a decoy) and its class: sweep (test/run-all.shnameslast-sweep.json: heimdall's own regime, the old gate unchanged and fail closed, now run inside that repo), repo-checks (a green, current receipt of its own checks) or unmanaged (one warning, the push goes on). The class is read from the COMMITS a push can carry (HEAD, its upstream,origin/HEAD,origin/<branch>, the refspecs named), never the work tree, and the strictest wins: an uncommitted marker edit changes nothing, and a pushed commit that drops a marker cannot downgrade a repo whose upstream still has it. The shell is read by a conservative model ($( ), backticks,eval,sh -c, here-strings and heredocs fed to shells,xargs,pushd/popd, chains ofcdas sets of possible states); any construct it does not model exactly (a dynamic or conditionalcd,cd -, a function,source,alias, a script piped to a shell,find -exec, an unknown git option or subcommand, an unmodelledGIT_*variable, a marker that cannot be inspected) makes the command uncertain, and then EVERY repo involved gets the strict sweep gate, never a warning; a repo that declares its own checks keeps owing the receipt under uncertainty (for commits nobody can list, so it is refused until the command is restated), and a strict gate that was never evaluated (heimdall-statehas no state there) blocks instead of allowing. Every error path is strict too: git exiting non-zero, timing out or printing something it does not print, a ref that cannot be placed, a marker that is a link, a submodule or a directory, a resolver that crashes, a corrupt or oddly typed receipt or declaration; the tests force each one (agiton PATH that fails, prints nonsense or hangs at each call the resolver makes). A real shell is the judge of the model: every command of a corpus is RUN by bash and zsh with a git that records where it was started, and the repositories the pushes really went to are compared with the resolver's answer. It found acdbehindenv/nice/xargs(a process runs it, not this shell), the last element of a pipeline (zsh runs it in the current shell),CDPATH=andcommand cd(bash runs the builtin, zsh does not); the same review madeenvwith any option and assignments toHOME,PWD,OLDPWD,BASH_ENVandENVuncertain. The secret, self-scan, oracle and corpus stages are not relaxed. Tests:test/heimdall-push-gate-per-repo.test.sh, andtest/heimdall-push-gate-target-repo.test.shfor the case that was reported (the session in heimdall, the push to another repo bycd, by-Cor from it, each graded on that repo's own record).
Fixed
-
Tests no longer fill the disk with leftover temp folders -- on 2026-10-13 the disk reached 0 bytes free mid-session.
bin/heimdall-agentsmade amktemp -dwork dir per call and removed it only on normal return, so the statusline's timeout kill stranded one each time (3,731hmd-agents.*dirs); it now needs no on-disk work dir.test/lib/dashboard_alerts_battery.pynever removed its dir (782hmd-alerts-*). On this macOS a template-lessmktempignoresTMPDIR, so tests that set a throwawayHOMEand ranuvleakedtmp.*dirs; the newtest/lib/private-tmp.shkeeps a test's temp files, mktemp calls and pip/uv caches in one private root removed on EXIT, INT, TERM and HUP, adopted byinstall-stranger,update-signed-applyandheimdall-deploy-maintainer. The sweep gives each suite its own TMPDIR and removes it, recordstmp_leaksandtmp_outsidein the receipt, fails a suite that leaves over 50 MB inside or outside its TMPDIR, refuses to start below 5 GB free (exit 5) and refuses socket paths without headroom (exit 6). Not heimdall's and not fixed here: thepip-unpack-*dirs (2.2 GB), which come from Claude Code's own~/.claude/security/agent-sdk-venvpip bootstrap. 499 of the 534 suites (thetest/*.test.shfiles that containmktempand notprivate-tmp, counted 2026-10-15) still use baremktempand can leak if killed. -
release/ship.sh --npm-onlyand--release-onlyno longer refuse the sweep receipt a full ship has just used, and they now ship the tagged commit's own files. The ship grades the
receipt BEFORE its version bump, so its ownchore(release): vX.Y.Zcommit (one no sweep ever saw) made that receipt look stale, and the npm-drift recovery died with "graded , HEAD is " although nothing had landed since. These two post-tag modes now grade the TAGGED commit, not HEAD: the tagv<version>names the release commit T,
and the receipt covers it when it graded T itself, or the commit T was bumped from (journal and ledger commits, the hook-owned paths, aside: the rule the ship applied to that commit)
while T does nothing but re-pin version files. That means every changed path is one the bump writes (RELEASE_BUMP_PATHS) orCHANGELOG.md, the edits are in place with file modes
untouched, each file differs from its parent only in version numbers and sha256 digests (CHANGELOG.mdonly in the release heading), the commit has exactly one parent, its subject is
exactlychore(release): v<version>and the manifest version really moved; T's own manifest must also say the version the tag is named for. Commits after the tag (journal or code) and
uncommitted edits are irrelevant, because nothing is read from them:--npm-onlyexportspackages/of T withgit archiveinto a temporary directory (removed on exit) and publishes
from there, so an uncommitted edit underpackages/can no longer ship without proof (the export is checked against T's blobs, and anexport-ignore,export-substoreolrule that
would change a file refuses instead);--release-onlysigns T'sinstall.sh(the bytes the tag serves, whichbin/heimdall-autoupdateverifies) instead of the working tree's, and
refuses arelease-manifest.jsonwhoseinstall_sha256is not T's. A--release-onlyrun that must create the tag has no tagged commit to grade and still needs the receipt to cover
HEAD, as a full ship does. A sweep recorded at a HEAD past the tag proves HEAD, not the tag, so it no longer covers the tag: the refusal names the tag's commit, the reason and the commit
to re-sweep. The release notes are not read from T (Release prose, editable afterwards). A new full ship (it grades the commit it is about to bump), the pre-push gate and
bin/heimdall-stateare unchanged. Test:test/ship-npm-only-after-bump.test.sh(the real full ship in a throwaway repo, then the real--npm-onlyand--release-onlywith nothing
landed in between, with a journal commit, a code commit and an uncommitted edit after the tag -- what is published and signed is compared with the tag's files -- and the shapes that must
still be refused). -
The per-repo push gate, last security review (2026-10-14) -- three findings against the gate described under Changed, each with its tests in
test/heimdall-push-gate-per-repo.test.sh
(test/heimdall-precheck-bash.test.shfilters two more trace lines of the old gate's call). (1) A resolver that cannot answer fell back to the WRONG repository. A timeout (HMD_PUSH_TARGET_TIMEOUT_S),
a crash or a missing resolver ran the strict gate on ONE directory (the hook's own cwd), so a push to another repository by-C/cd, or from the payload's cwd, went ungraded. Now the strict
gate runs against EVERY candidate -- the session's cwd, the payload's cwd and each-C/cd/--git-dir/--work-treedirectory the parser had reached (the resolver prints them itself when it times
out or fails, without asking git; the hook adds the two cwds when it gets no answer at all) -- and the push is blocked unless all of them pass. A push whose repository git names but whose work tree cannot
be confirmed from the other end (a dotfiles layout) is graded on those directories as well, not only on the cwd's; a gate directory the hook cannot enter blocks (it used to run the gate wherever the hook
was). Tests C50-C53. (2) Gate scope mismatch. One resolved (git directory, work tree) pair now feeds classification, refspec resolution, the receipt lookup and theheimdall-statecheck: each resolver
row carries it (class, work tree, extra, git directory), every git call about the pushed repository runs withGIT_DIRandGIT_WORK_TREEpinned to it,heimdall-repo-checks status --git-dirverifies the
pair from both ends and reads the receipt inside that work tree, and the hook runsheimdall-statein that work tree with the same pair exported. AGIT_*variable the resolver does not reproduce
(GIT_COMMON_DIR,GIT_OBJECT_DIRECTORY,GIT_NAMESPACE,GIT_INDEX_FILE, ...) gave the push another view of the refs than the one the gate classified; it now makes the command uncertain (transport and
identity variables such asGIT_SSH_COMMANDkeep the class). Tests B "pair" and B "GIT_*", C54-C59. (3) Self-audit. The receipt is read as ONE regular file inside the repository: a symlinked.heimdall,
.heimdall/receiptsor receipt is never followed (andrunwill not write through one), the file is opened once withO_NOFOLLOWand checked on that descriptor, at most 1 MiB is read (a committed
declaration over 1 MiB is refused too) and nesting deep enough to exhaust the parser is "not valid JSON", not a crash;statusnames HEAD once and reads the tree and the declaration of that commit, so a
HEAD that moves between its reads cannot mix two commits; one regex that backtracked quadratically on a long command (a signal cannot interrupt a regex, so the resolver's own timeout never fired) is two linear
searches. Tests A10, B "linear time", C60-C61. The unmanaged-repo warning, the secret and self-scan gates and every earlier class rule are unchanged. -
The push gate arms for more ways of typing a push — the Bash hook decides whether a command is a push with one matcher (
GP_REinbin/heimdall-precheck-bash), and a push it did not read as one walked past the quality, secret, self-scan, oracle and corpus stages. It now also reads as a push: a push in backticks; behind a wrapper with its options and their values (nice -n 5 git push,sudo -u user git push,timeout -k 5 30 git push,env -u NAME git push,ionice -c 2,stdbuf -oL,caffeinate,xargs -n 1; the wrappers are sudo, doas, command, env, exec, time, timeout, nohup, nice, ionice, setsid, stdbuf, caffeinate, builtin and xargs); with a quoted or backslash-escaped command word ("git" push,\git push,"/usr/bin/git" push); and after an assignment or option value that holds a space, an escape or a substitution (FOO=$(echo a b) git push,FOO=a\ b git push,git -c user.name="a b" push,git -C My\ Dir push). Chains (&&,;,||,|,&, a newline), subshells, substitutions,cd x &&,sh -candeval "..."strings,git -Cand a full-pathgitwere already read. Text that only mentions a push (echo "git push",git stash push) still does not arm the gate. Run against the previous matcher, 17 of the 47 one-line forms in the main list of section E oftest/heimdall-push-gate-per-repo.test.shwere not read as a push; all 47 are now. Still not armed: a push no pattern can see ($GIT push, unquotedeval git push, a here-string, a script piped into a shell, process substitution,bash --norc -c '...'); the native pre-push hook, where it is wired, is the only gate for those. -
Runtime state is no longer committed —
.gitignoreignored.heimdall/only at the repo root, so the runtime caches underskills/designmatch/.heimdall/(7 files) and a straypackages/runheimdall/heimdall-state.jsonwere tracked. The pattern is now**/.heimdall/*(the root exceptions are unchanged), those 8 files are untracked, andtest/heimdall-nested-state-gitignore.test.shfails if runtime state is tracked again. Two example files stay tracked on purpose at the root (.heimdall/cp-endpoint.json.example,.heimdall/issue-loop.config.json.example); the test allowlists exactly those. -
Plugin auto-installs are receipted and loud — every plugin, marketplace and skill install attempt is logged (name, source marketplace, version or pin, result) to
$HEIMDALL_HOME/receipts/installs-<run id>.ndjson, andsummary-card --receiptlists that run's lines. A failed install printsinstall failed, continuing without <name>on stderr and records the same line, instead of vanishing behind|| true;install.shno longer reports the plugin step as done regardless. The fallback to the unlistedwshobson/agentsmarketplace is removed: onlyclaude-plugins-officialandheimdall-marketplacecan be registered. The set of plugins attempted is unchanged. The Claude Code CLI has no plugin version flag, so onlyhmd@heimdallis pinned (by the installer's tag); other installs record the version they resolved. Five plugins the prompt can trigger (devops-toolkit,database-toolkit,seo,security-scanner,api-design) are not in the official catalog and now fail loudly when triggered. A consent model is planned for 3.1. Test:test/plugin-install-receipt.test.sh. -
The plugin and marketplace listings carry the proof positioning —
.claude-plugin/plugin.jsonandmarketplace.jsonno longer call hmd an "autonomous multi-agent orchestrator" that turns "one prompt into a finished project"; they lead with the proof line (work passes a test the agent didn't write, shown able to fail, before the push lands), the pre-push-hook gate and the phone headline, with keywords to match. -
The CI waste guard: agents can no longer pay CI minutes to re-roll a flaky result — the Bash hook denies
git commit --allow-empty,gh run rerun(any flags),gh workflow runand agh apiPOST to a re-run, re-request or dispatch endpoint (an hmd agent in a client repo had pushed an empty commit and re-run a workflow twice to get past a flaky test). It reads the command like a shell, so text that only mentions a form (echo, grep, a commit message) passes. A flaky test gets fixed or reported; the agent definitions and the definition of done say so. Operator overrideHMD_CI_RERUN_ALLOW=1in the environment Claude Code runs in; a cost guard, so it fails open. Proof:test/ci-waste-guard.test.sh. -
The relay session login no longer asks macOS for a keychain that is not there — under a throwaway HOME (
env -i HOME=..., as many test harnesses run) storing a login opened a "Keychain Not Found … Reset To Defaults" dialog whilesecurity -iwaited for a human. The store now askssecurity default-keychain(read-only) first and uses the Keychain only when it names a file under$HOME/Library/Keychains; otherwise it keeps the login in the 0600 file with one stderr notice, and an explicitHMD_SESSION_LOGIN_STORE=keychainis an error rather than a silent fallback. Everysecuritycall is killed afterHMD_SECURITY_TIMEOUT_Sseconds (default 20). Test:test/session-login-no-keychain.test.sh. -
heimdall-landmine-lintno longer flags a source from a tree that was deleted and then recreated (git clone,mkdir,mvinto the same path) before the source, the shape ofinstall.sh's clone fallback; a delete followed by a source with no recreate is still flagged. -
test/install-stranger.test.shno longer leaves a ~1 GB (growing to 4.5 GB) Headroom install behind: the installer's validation boots Claude Code, whose SessionStart hook started a detachedheimdall-modules add headroomin the throwaway HOME. The suite now runs withHEIMDALL_NO_AUTOUPDATE=1andHEIMDALL_NO_MODULES=1, asserts no module install starts, and stops anything still running in its root before removing it.
Not in this release
- The kill guard is not narrowed yet (the 2.4.7 notes said 2.4.8); it still denies kills by name or computed pid.
HMD_KILL_GUARD=offstill turns it off. release/ship.shdoes not yet commit, check and push the site's version pins, and a full ship still publishes npm from the working tree rather than the tag; both are planned for 2.4.9.- The push gate still allows a push, with a warning, when
heimdall-statecannot give a verdict in a heimdall checkout that was never initialised.
Full changelog: v2.4.7...v2.4.8
Install/upgrade:
curl -fsSL https://raw.githubusercontent.com/randomittin/heimdall/v2.4.8/install.sh | bashinstall.sh.minisig is the detached minisign signature of this release's install.sh; bin/heimdall-autoupdate verifies it against release/heimdall-signing.pub before applying.