Repository navigation
The second-phone release. A repo can now have more than one phone paired: hmd app add-device opens one extra pairing window under the same code, hmd app status lists each phone and hmd app disconnect --device revokes one, and the default stays one phone. The push gate no longer passes on lint evidence that is stale or missing, and heimdall-stop-lint --since-push produces that evidence for everything changed since the last push. Sweeps can be incremental, chained to the last full green run, and a guard denies kills by name after an agent's pkill unpaired live phones.
Added
- A second phone —
hmd app add-deviceopens one extra pairing window under the same code: 10 minutes by default (HMD_ADD_DEVICE_WINDOW_S, never above an hour), registered before the code is shown, and each use admits one phone. At most 4 phones are paired to a repo at once (HMD_PAIR_MAX_DEVICESlowers that, and 1 refusesadd-device), and--confirmputs the new phone through the SAS compare. The default is unchanged: one phone, and the code closes once the first has paired.hmd app statuslists each phone, andhmd app disconnect --device <label|id>revokes just that one (hmd app disconnectstill ends them all): its push tokens are removed first, a revoke is final (legacy records are tombstoned), and a revoke the relay cannot confirm is reported loudly. Every phone that pairs leaves a notice in the laptop's Claude session; the name in it is the phone's own claim, so it is sanitised and quoted. A phone paired under 2.4.6 carries over into the per-phone device records (bin/lib/hmd_devices.py) on upgrade, without re-pairing and without a restart of its relay client. Tests:test/app-pair-confirm.test.sh,test/app-upgrade-paired.test.sh. - Delivery receipts show tmux —
inbox.consumeris one ofwaiting,tmux,wakeornone, and now saystmuxwhen the armed wake is the keystroke wake ofhmd app connect --tmuxand the session's own pane is one thatconnect.json's tmux list names; every other armed wake stayswake. A delivered message'sviais one ofstop,prompt,tool,tmuxorcli:tmuxonly when the submitted prompt is the wake's fixed prompt and a queued record carries a fresh mark that the wake typed for it,promptotherwise. The wake records the record ids it typed for ininbox-wake.json, written before the keys and taken back if they fail. All of it is read throughhmd_private_state(link-safe, 0600 ledger). Tests:test/heimdall-inbox-wake.test.sh,test/heimdall-ui-inbox.test.sh. - Lint covers everything changed since the last push —
heimdall-stop-lint --since-pushlints the diff against the push base (@{u}, elseorigin/<branch>, else the last full green, else every tracked file) plus uncommitted work, with the Stop path's tool selection and exclusions and the same budget (a skipped file makes the run incomplete). Its receipt recordsmode,base_sha,base_refandscope, and--listand--upstream-onlyprint the scope. Until now stop-lint linted only the editsedit-trackerhad seen, so work merged from sub-agent worktrees had no lint evidence and the strict push gate needed anHMD_LINT_WAIVER. Nowheimdall-state check-quality-gatesfails a receipt that does not cover the scope (uncovered, naming the file and the command), andheimdall-precheck-bashruns the mode, bounded, when the evidence is missing, stale or uncovered, naming the exact command in its deny. The same change fixes an extensionless bash or python tool being skipped whenever a directory in its path had a dot (.claude/worktrees,mktemp'stmp.XXXX): the extension test now looks at the file name only. Test:test/heimdall-stop-lint.test.sh. - Incremental sweeps, chained to the last full green run —
test/run-all.sh --since-greenre-runs only the suites a diff since the last green FULL sweep can affect: a suite naming a changed path or the basename of a changedbin/script or module (the suite and itstest/libhelpers are searched, widened through scripts that call a changed script), a changed test file, and any suite that was red in the previous run. Names match only as whole tokens and a comment-only line is not a call edge, so a script that merely mentions a changed one in a comment does not pull its suites in. The run escalates to a full sweep (ESCALATED TO FULL SWEEP, withescalation_reasonin the receipt) when the diff touches test or gate infrastructure, changes more than 40 files, would select more than 20% of the suites, widens past 200 names (a hub, which selects every suite), or has a base that is older than 7 days or 200 commits, not an ancestor of HEAD, or missing; a dirty tree is refused (exit 2).--dry-runprints which suites would run (with--since-green, the selection and its reason) and runs nothing: no lock, no receipt. Receipts carrykind(fullorincremental); a green, clean FULL run is also kept asreceipts/last-full-green.json, the anchor of the chain, andbin/heimdall-stateaccepts a green incremental receipt for HEAD only while it chains to that anchor and the selection rules ofbin/lib/sweep-chain.sh(shared with the writer, re-derived from git with the gate's own limits) hold. A--filterrun writesreceipts/last-filtered.jsonand never reaches the gate or the chain, andrelease/ship.shneeds a green FULL receipt at HEAD before a tag, a Release or an npm publish, checked before anything is bumped. One sweep runs at a time: it holds anflockonreceipts/sweep.lock, a second run exits 4 naming the running pid, and the kernel frees the lock if the holder dies. A suite that is red under load and green solo is reported as FLAKY-UNDER-LOAD: it counts green, because the solo re-run was always the verdict, but it is never hidden. The receipt keeps both exit codes and the parallel run's failing lines, and the gate names it when it passes. Tests:test/run-all-incremental.test.sh,test/ship-guard-order.test.sh. - Batch before you sweep — the harness rule is in
CLAUDE.md("When the full gate runs";AGENTS.md,skills/heimdall/SKILL.md,skills/heimdall/references/planning-pipeline.mdandagents/heimdall.mdrestate it in a line and point back): merge the agents' fixes first, then run one sweep on the batch. It comes from 2026-10-11, when about nine full sweeps of about 70 minutes each ran in one day: agent fixes were merged one at a time and each merge staled the last green receipt, two sweeps overlapped, one wait sat on an absolute load threshold, and whole sweeps were re-run to tell load flakes from real failures.bin/heimdall-conformancenow checks it:sweep-staled-by-merge(a green sweep, then a non-hook-owned merge or commit before the next push, then another sweep; red sweeps, pushes, real edits and.planningor.heimdallcommits do not count) andsweeps-overlap(two sweeps running at once, each interval read from the transcript; a stopped run ends its interval, and each lost run is reported once). Tests:test/protocol-conformance.test.shsections N and O plus mutants M14-M22.
Changed
- The push gate needs real lint evidence — in 2.4.6 a linter that failed to run, or a run that linted nothing, left
lint_cleanas it was, so a stale true (from an earlier run, or fromheimdall-state mark-clean, which set it with nothing run) survived and the push gate passed on old evidence. Everyheimdall-stop-lintrun that reaches no verdict (tool error, timeout, exhausted budget) now setslint_cleanfalse; its receipt gainsreason(clean,findings,tool-errororincomplete) andpaths(the files it covers), and itsatis the start of the run, so an edit made mid-run reads as newer.heimdall-state check-quality-gatescountslint_cleanonly behind alint_last_runthat is complete, has no findings and no tool errors, lists the files it covers and is not older than a modification of any of them; otherwise it blocks, namingno-evidence,tool-error,findings,incomplete,no-coverageorstale.mark-cleansetslint_cleanonly against a passing receipt and otherwise drops it to false and says why.HMD_LINT_WAIVER=<reason>records an explicit waiver (lint_waiver,lint_waiver_log), which the gate honours loudly and the next lint run voids. The trade-off is deliberate: a broken linter now blocks the push until it is fixed or waived, and the fixtures that need a green gate without being about lint waive it explicitly. Tests:test/heimdall-stop-lint.test.sh,test/sweep-receipt-gate.test.sh.
Fixed
- The dashboard-alerts foreground test no longer fails for the first 87 minutes of every UTC day — its foreground case stamped
app_statewith the wall clock while the monitors it drives run on an injected clock atT0 + 6000(01:40 UTC), so before about 01:27 UTC the report read as older thanforeground_ttl_s(900 s) and the suppression assertion failed. The record is still written and sealed by the store itself; only its timestamp moves onto the injected clock. Test:test/lib/dashboard_alerts_battery.py. - A quick ask is audited before its row shows as ended —
_finishpublished the row (doneorfailed) under the lock and only then appended the answer line to the audit, so a reader that saw the ask end could read the audit in the window where it had no answer line yet (theaudit-ids-onlycheck failed under load with the dispatcher's queued line as the only record; no content was exposed, those records hold ids and fixed tokens only). The answer line is now appended first and the row published after, so whoever sees an ended ask finds its audit line already written, still exactly one answer line per_finish. Test:test/quick-ask.test.sh. Landed before the 2.4.6 tag (committed 2026-10-11) but is missing from its notes. demo-wall-honestyC3 no longer races the operator's real~/.heimdall— C3 snapshotted the real~/.heimdallaround the seeder's dry-run, so any other live session or scheduled routine writing there failed it (it failed the 2.4.6 sweep on thenotif-indexdirectory's mtime, written bybin/heimdall-agentsfor an unrelated session, beside a routine'smetrics.jsonl); an exclusion list for machine-wide writers can only grow. The dry-run now runs withHOMEandHEIMDALL_HOMEpointed at a private sentinel tree seeded with canaries, and a full-tree snapshot with no exclusions (mtime, size, checksum) must be identical afterwards. C3a proves the dry-run completed there (3 rows), C3c proves the snapshot sees a same-size in-place overwrite and a new file, and C4 still greps the real~/.heimdallfor the demo project id. Test:test/demo-wall-honesty.test.sh. Landed before the 2.4.6 tag (committed 2026-10-11) but is missing from its notes.
Security
- Kills by name or by a computed pid are denied — on 2026-10-12 a sub-agent's ad hoc
pkill -f heimdall-relay-clientended the live phone relay clients, and a client revokes its pairing on any SIGTERM, so their phones were unpaired.bin/heimdall-precheck-bashnow reads the command like a shell (bin/lib/hmd_kill_guard.py) and deniespkill,killallandskillwith any pattern;killof anything but a literal pid (a variable, a substitution, anxargsfeed, a glob, a process group,0,-1);find -exec kill;fuser -k; and those verbs run throughsh -c,evalor a heredoc, generated byawkorpythonand piped into a shell, or typed in another letter case (PKILLon a case-insensitive filesystem).kill <literal pid>,kill $!,kill -0, reads and text that only mentions a verb stay allowed. It fails closed: a command that mentionskillorfuserand that the guard cannot fully read (an unbalanced quote or substitution, an unterminated or expanded heredoc, a verb or shell string held in a variable, nesting past 8 levels, an exception, a timeout) is denied, and the hook denies unless the guard answersokwith exit 0, so a crash, no output or a missingpython3or helper all deny. It is best-effort defense in depth, not a security boundary: a text guard on shell is never complete. What would not depend on reading shell is a relay client that a stray signal cannot unpair, a client-side design that is still open. Test:test/hmd-server-kill-guard.test.shpins every bypass, the allow list and the fail-closed paths of guard and hook, and checks that no committed script pattern-kills an hmd server and that thehmd_ui_procsorphan reaper selects only orphans whose repo is gone or a temp dir. - The kill guard can be turned off, and it will be narrowed in 2.4.8 —
HMD_KILL_GUARD=off(also0,falseorno, in any case) in the environment Claude Code runs in turns the guard off for that operator: none of it runs and nothing is printed. Unset, empty or any other value leaves it on, so a typo never removes the guard, and it switches off only the kill guard: the commit secret gate and the push gates ofbin/heimdall-precheck-bashstill run. The deny now says how to turn it off, also when the guard itself failed. It is an environment switch and notheimdall-hooks disable <id>because the guard sits insidebash-gate-chain, a locked group that also carries the secret and push gates, so there is no hooks.json group of its own for a metadata entry to name. The block is broad on purpose for now (everypkillandkillall, because a stray SIGTERM still unpairs a phone); it will be narrowed to commands that could hit hmd's own processes once 2.4.8 stops a stray signal from unpairing a phone. Test:test/hmd-server-kill-guard.test.sh(off,0,falseandnoin any case allow; unset, empty,onand any other value deny; the deny names the switch; off runs no helper and opens no other gate).
Full changelog: v2.4.6...v2.4.7
Install/upgrade:
curl -fsSL https://raw.githubusercontent.com/randomittin/heimdall/v2.4.7/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.