Skip to content

v2.4.7

Latest

Choose a tag to compare

@randomittin randomittin released this 13 Oct 09:24

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-device opens 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_DEVICES lowers that, and 1 refuses add-device), and --confirm puts the new phone through the SAS compare. The default is unchanged: one phone, and the code closes once the first has paired. hmd app status lists each phone, and hmd app disconnect --device <label|id> revokes just that one (hmd app disconnect still 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.consumer is one of waiting, tmux, wake or none, and now says tmux when the armed wake is the keystroke wake of hmd app connect --tmux and the session's own pane is one that connect.json's tmux list names; every other armed wake stays wake. A delivered message's via is one of stop, prompt, tool, tmux or cli: tmux only when the submitted prompt is the wake's fixed prompt and a queued record carries a fresh mark that the wake typed for it, prompt otherwise. The wake records the record ids it typed for in inbox-wake.json, written before the keys and taken back if they fail. All of it is read through hmd_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-push lints the diff against the push base (@{u}, else origin/<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 records mode, base_sha, base_ref and scope, and --list and --upstream-only print the scope. Until now stop-lint linted only the edits edit-tracker had seen, so work merged from sub-agent worktrees had no lint evidence and the strict push gate needed an HMD_LINT_WAIVER. Now heimdall-state check-quality-gates fails a receipt that does not cover the scope (uncovered, naming the file and the command), and heimdall-precheck-bash runs 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's tmp.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-green re-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 changed bin/ script or module (the suite and its test/lib helpers 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, with escalation_reason in 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-run prints which suites would run (with --since-green, the selection and its reason) and runs nothing: no lock, no receipt. Receipts carry kind (full or incremental); a green, clean FULL run is also kept as receipts/last-full-green.json, the anchor of the chain, and bin/heimdall-state accepts a green incremental receipt for HEAD only while it chains to that anchor and the selection rules of bin/lib/sweep-chain.sh (shared with the writer, re-derived from git with the gate's own limits) hold. A --filter run writes receipts/last-filtered.json and never reaches the gate or the chain, and release/ship.sh needs 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 an flock on receipts/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.md and agents/heimdall.md restate 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-conformance now 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 .planning or .heimdall commits do not count) and sweeps-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.sh sections 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_clean as it was, so a stale true (from an earlier run, or from heimdall-state mark-clean, which set it with nothing run) survived and the push gate passed on old evidence. Every heimdall-stop-lint run that reaches no verdict (tool error, timeout, exhausted budget) now sets lint_clean false; its receipt gains reason (clean, findings, tool-error or incomplete) and paths (the files it covers), and its at is the start of the run, so an edit made mid-run reads as newer. heimdall-state check-quality-gates counts lint_clean only behind a lint_last_run that 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, naming no-evidence, tool-error, findings, incomplete, no-coverage or stale. mark-clean sets lint_clean only 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_state with the wall clock while the monitors it drives run on an injected clock at T0 + 6000 (01:40 UTC), so before about 01:27 UTC the report read as older than foreground_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 — _finish published the row (done or failed) 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 (the audit-ids-only check 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-honesty C3 no longer races the operator's real ~/.heimdall — C3 snapshotted the real ~/.heimdall around 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 the notif-index directory's mtime, written by bin/heimdall-agents for an unrelated session, beside a routine's metrics.jsonl); an exclusion list for machine-wide writers can only grow. The dry-run now runs with HOME and HEIMDALL_HOME pointed 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 ~/.heimdall for 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-client ended the live phone relay clients, and a client revokes its pairing on any SIGTERM, so their phones were unpaired. bin/heimdall-precheck-bash now reads the command like a shell (bin/lib/hmd_kill_guard.py) and denies pkill, killall and skill with any pattern; kill of anything but a literal pid (a variable, a substitution, an xargs feed, a glob, a process group, 0, -1); find -exec kill; fuser -k; and those verbs run through sh -c, eval or a heredoc, generated by awk or python and piped into a shell, or typed in another letter case (PKILL on 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 mentions kill or fuser and 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 answers ok with exit 0, so a crash, no output or a missing python3 or 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.sh pins 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 the hmd_ui_procs orphan 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 (also 0, false or no, 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 of bin/heimdall-precheck-bash still run. The deny now says how to turn it off, also when the guard itself failed. It is an environment switch and not heimdall-hooks disable <id> because the guard sits inside bash-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 (every pkill and killall, 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, false and no in any case allow; unset, empty, on and 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 | bash

install.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.