Skip to content

Release 8 — parity: optional channels, phone approval, sound, packaging - #1

Merged
jackiectl2 merged 37 commits into
mainfrom
v8-parity
Aug 20, 2026
Merged

jackiectl2 merged 37 commits into
mainfrom
v8-parity

Conversation

@jackiectl2

Copy link
Copy Markdown
Owner

Release 8 builds what comparable tools have and tailbell did not: optional
delivery channels, phone approval, sound, and one-line packaging. 37 commits.

The guarantee this release was not allowed to break

Release 0 owns the zero-dependency promise, so every addition here is optional
and off by default. That is asserted, not claimed:

  • A test drives tailbell-notify with no channels configured and asserts zero
    network calls
    .
  • The event record is byte-compared against main and is identical but for the
    new kind field.
  • tests/run-tests.sh still needs no network. Everything that reaches outside
    goes through an injectable command (TAILBELL_CURL, TAILBELL_OSASCRIPT,
    TAILBELL_AFPLAY, TAILBELL_SAY, TAILBELL_FOCUS_CMD) pointed at a recorder.
  • Conversation content still never leaves the machine. Stop.last_assistant_message
    and PermissionRequest.tool_input are both guarded by canary tests.

What is in it

  1. Optional channels as a first-class concept. ntfy, slack, discord,
    feishu (with signed-bot support), plus the existing desktop and file,
    behind one TAILBELL_CHANNELS list. A channel is a sink_<name> function
    plus a row in the doctor's table — not a branch in the event logic.
  2. Phone approval, terminal CLI only. PermissionRequest fires in the chat
    panel but a command hook's verdict there is ignored — measured, both
    directions, four output shapes. PreToolUse is the hook whose decision is
    applied, so that is where this lives.
  3. Sound and voice. A distinct sound per event kind, Focus/DND respected,
    voice off by default, one-line mute.
  4. Packaging. brew formula, npm wrapper, and a curl one-liner — each a
    wrapper over install.sh, none of them required.

Two features declined rather than deferred

Each has a page saying what was weighed and what would change the answer:
docs/two-way-control.md and docs/usage-quota.md.

Bugs found and fixed along the way

  • install.sh exited 1 on every Mac — it required flock, which macOS does
    not have. Caught by the new macOS CI job on its first run.
  • A notification storm. Rotating events.log by rewriting it replays the
    whole file: the workstation follows with tail -F, which tracks by name, so
    a replaced file is re-read from the start. Rotation now moves the old file
    aside, and the listener drops events older than TAILBELL_MAX_AGE.
  • The listener was silently dead on the cluster — text=True is Python 3.7+
    and the system Python is 3.6.8; the exception was swallowed by a bare except.
  • install.sh registered 5 of 7 events and never wrote cluster-id, so the
    listener skipped the machine entirely.

Verified, and not

Verified: the full cluster -> Mac chain, the macOS install, doctor --test, the
three per-kind sounds (audibly distinct), approval end to end against a real CLI
session in a pty, ntfy delivery from a login node and a Slurm compute node
through ARC's proxy. 128 tests, green on ubuntu-latest and macos-latest.

Not verified: a real phone subscribed to an ntfy topic, the Slack/Discord/Feishu
webhooks against real endpoints, the spoken alert, and two known renderer defects
(a truncated message line; stacked alerts overlapping). All are listed in
docs/STATUS.md and docs/verify-release-8.md.

jackiectl2 and others added 30 commits August 19, 2026 19:19
Written before any code, so the shape can be argued with rather than
reverse-engineered from the diff.

Two things were measured first, and one of them contradicts the doc:

- PermissionRequest is NOT dead in the VS Code chat panel. The probe left
  registered in ~/.claude/hooks logged 16 fires between 2026-07-30 and
  2026-08-12 — every one of them tool_name=AskUserQuestion, not one a tool
  permission prompt. architecture.md's "0 fires" line was true for its own
  window and is now wrong; the conclusion it supports survives. Notification
  stayed at 0 over the same two weeks.
- PermissionRequest can return allow/deny/ask/defer, per the hook reference
  bundled in 2.1.160. That is what the approval path hooks, rather than a
  PreToolUse matcher on * — which would spawn ~787 processes per session and
  fire for calls the allowlist had already approved.

It also records a second place a hook is handed conversation content:
PermissionRequest.tool_input is the full question text for AskUserQuestion.
Like last_assistant_message, it gets a canary test rather than a promise.

The two open questions the brief demanded be decided before building are
decided, with the reasoning attached rather than the verdict alone:

- two-way remote control: declined, and written up as a position. An attacker
  holding a notification channel learns project names; one holding an
  instruction channel owns an HPC account.
- usage/quota warnings: no credential file, no transcript parsing. The
  objection is to the source, not the feature.

Co-Authored-By: Claude <noreply@anthropic.com>
TAILBELL_TRANSPORT was one value with an if/else behind it; the ntfy half was
documented in a line, never covered by the doctor, and never exercised by a
test. This replaces it with TAILBELL_CHANNELS — a comma-separated list where
each name resolves to a sink_<name> function with one signature. Adding Teams
later is a row in the table, not a new branch in the event logic.

Release 0's path is unchanged, and that is the point: with TAILBELL_CHANNELS
unset the list resolves to TAILBELL_TRANSPORT (still "file") on the agent host
and to "desktop" on macOS, so nobody has to migrate and nothing new is spawned.
A test asserts the unconfigured hook makes zero network calls.

Two things found while writing the sinks:

- The old ntfy path put the title in an HTTP header. Headers are officially
  ISO-8859-1 and every title here is CJK — "✅ proj · 完成" was going out to be
  mangled. Sinks now use ntfy's JSON publishing API, where UTF-8 is defined.
- Network sinks are backgrounded. Three channels at an 8 s timeout is 24 s of
  stalled Stop hook. The children get their stdio closed first, or the hook
  appears to hang for as long as curl does — Claude Code is still reading that
  pipe. TAILBELL_SYNC=1 restores blocking for the doctor and the tests.

Testing them needed a seam, not a network: TAILBELL_CURL points at
tests/fake-curl, which records the request and reports a status you choose.
The suite still runs with the cable out, which is a release 8 guardrail.

33 new cases: every channel's URL and body shape, fan-out to three at once, a
500 and a dead connection each named in debug.log with their status, an
unconfigured channel skipping rather than posting nowhere, a misspelt channel
name not taking the others down, the proxy that makes sbatch work being passed
through, and the privacy canary extended from the log file to all four
channels — plus a second canary for PermissionRequest.tool_input, which is the
full question text and is now also never read.

24 -> 57 cases, all passing.

Co-Authored-By: Claude <noreply@anthropic.com>
The doctor checked the SSH path link by link and said nothing at all about
where else a notification was supposed to go. Now it walks the same channel
list tailbell-notify walks — resolved by the same rules, so the two cannot
disagree the way the SSH host list once did — and for each one reports whether
it is configured, whether the URL is the shape that provider uses, and whether
this machine can reach the host at all. That last check is the compute-node
case: a channel that works from a login node and fails under sbatch fails
because nothing set http_proxy, and now it says so.

--test drives a real notification through tailbell-notify with TAILBELL_SYNC=1
and quotes back what each channel answered. A doctor that builds its own
request tests the doctor, not the hook, so it does not.

Two supporting changes:

- A failed POST now carries the server's own reason, not just a status. "HTTP
  403" sends you reading docs; `invalid_token` or `sign match fail` does not.
- Webhook URLs are redacted to host plus first path segment wherever the doctor
  prints them. The URL *is* the credential, this runs on a shared login node,
  and doctor output is what people paste into issues.

Found while writing it, and fixed: install.sh carried its own copy of the
settings.json merge, and it had drifted. It registered five events where
tailbell-register registers seven, so StopFailure and Elicitation did nothing
for anyone who installed the way the README says. It also never wrote
~/.tailbell/cluster-id — and the listener skips any host without one, so the
documented install produced silence with every other check green. install.sh
now delegates to tailbell-register, which does both.

10 new cases: the doctor naming an unknown channel, an unconfigured one, a
revoked one with its status code, not leaking the webhook secret, and always
exiting 0 — plus the install path creating cluster-id, registering all seven
events, and not clobbering an existing config.

57 -> 67 cases, all passing.

Co-Authored-By: Claude <noreply@anthropic.com>
PermissionRequest runs before Claude Code shows the prompt and honours a
decision printed on stdout, so tailbell-approve holds the prompt for a moment,
pushes two buttons to a phone, and answers on your behalf. Terminal CLI only:
in the chat panel that hook fires for AskUserQuestion and nothing else, which
is measured, and the brief says not to spend effort there.

No inbound port, for the reason RemoteForward was rejected in release 0 — on a
shared login node the loopback interface belongs to every user of the node, so
a port is not yours alone. The hook POSTs out and then polls. Nothing binds.

What actually holds the security, since "it uses a secret URL" is not an answer:

- The approval topic is NOT the notification topic, and tailbell refuses to run
  if you set them equal. Whoever learns your day-to-day topic — from a
  screenshot, a config, this repo — reads project names and can approve nothing.
- Each request carries a fresh 128-bit token from /dev/urandom, not $RANDOM,
  which is 15 bits of a seeded PRNG and would be guessable.
- A late reply is refused against the deadline carried in the message's own
  timestamp, so it is rejected on evidence rather than because we stopped
  looking.
- Silence is never approval. Every refusal path prints nothing at all, so
  Claude Code falls through to its own prompt. There is no path from no answer
  to yes.

The push carries the tool NAME and never tool_input. Approving without seeing
the command is weaker than what the terminal gives you, and it is the trade:
this does not put your command lines on somebody else's server. A canary test
holds that line.

Registration is separate (tailbell-register --approve) and re-registering
without the flag removes it again, because a hook that sits in front of every
permission prompt should not need hand-edited JSON to remove. It carries an
explicit timeout of TTL+30 — Claude Code kills a hook at its timeout, and a
killed hook is a decision that never arrives.

Testing this offline needed a fake phone, not just a fake curl: when
tests/fake-curl sees a POST carrying ntfy action buttons it reads the request
id and token out of the button and writes back the reply pressing it would
send. Publish, tap, poll, verify — the real round trip, no network.

17 new cases, including every way a reply must be refused.

67 -> 84 cases, all passing.

Co-Authored-By: Claude <noreply@anthropic.com>
tailbell was silent, which on a busy screen is the difference between seeing an
alert and not. Four of the tools in docs/competitors.md have per-event sounds
and two speak the message.

One sound for everything would be barely better than none — the point is
telling "it finished" from "it needs you" without looking — so the five kinds
get Glass, Ping, Sosumi, Basso and Tink. All five ship in
/System/Library/Sounds, so this costs no dependency, each is overridable, and
TAILBELL_SOUND=0 silences all of them in one line.

Where the sound comes from matters more than it looks. The OS plays it as part
of the notification wherever the renderer lets it — osascript's `sound name`,
alerter's -sound — because that is the path macOS holds back under Focus.
afplay is used only on the Hammerspoon path, whose canvas is silent by
construction, and there it is gated on an explicit Focus check. Do Not Disturb
is a choice the user made; routing around it would make tailbell the one thing
on the machine that ignores it.

Voice is off by default and speaks only the project name and what happened,
never the message text — the same rule the webhooks follow. TAILBELL_VOICE
takes a list of kinds, and "question,permission" is the setting worth having:
speak when something is waiting on you, stay quiet when something merely
finished.

Focus detection is behind TAILBELL_FOCUS_CMD rather than hardcoded. Apple has
moved where that state lives more than once, and someone on a version we
guessed wrong about should be able to substitute a working command instead of
waiting for a patch.

The doctor now checks the sound files exist, warns when voice is on without a
voice that can read Chinese, and — since "distinguishable with the screen
turned away" is something you listen to rather than something a script can
assert — plays the three kinds in sequence under --test.

17 new cases, through stubbed osascript/afplay/say. They cover every decision
made before a Mac API is touched. What they cannot cover, and what stays
labelled untested: whether the sounds are actually distinguishable, and whether
the Focus detection reads the right plist on any given macOS.

84 -> 101 cases, all passing.

Co-Authored-By: Claude <noreply@anthropic.com>
bin/tailbell dispatches to everything else — install, doctor, register, deploy,
listen, notify, approve. Both packages point at it rather than reimplementing
anything, which is what keeps the packaged install honest: it runs exactly what
a git clone runs, so it cannot quietly acquire a dependency the clone does not
have.

Resolving $0 through symlinks by hand is not incidental. Homebrew symlinks the
dispatcher out of libexec and npm out of node_modules, so it is a link in both
packaged cases, and `readlink -f` is a GNU extension macOS does not ship. Get
that wrong and `brew install` produces a command that does nothing.

The formula is HEAD-only. A checksum is a fact about a file that exists, and
writing one for a tag that has not been cut would put a hash in the repository
that matches nothing — Homebrew's error for that names the hash, not the
mistake. packaging/release.sh cuts the tag, aligns the two version numbers,
refuses to proceed if the suite fails, and prints the command that produces the
real checksum.

Neither package registers hooks or starts anything. A package manager that
edits ~/.claude/settings.json on install is the surprise this project exists to
avoid; `tailbell install` stays a visible, separate step and the caveats say so.

packaging/get-tailbell.sh is the curl-pipe-bash one-liner every comparable tool
has. Its header says plainly that you are piping a script from the internet into
a shell and shows the two commands it runs, because the clone remains the
supported path and the one the tests exercise.

6 new cases, all of them things that fail silently at publish time rather than
at test time: dispatch through a symlink, the two version numbers agreeing,
npm's files list actually containing the executable, and no invented checksum.

101 -> 107 cases, all passing.

Co-Authored-By: Claude <noreply@anthropic.com>
The README now says both. New capabilities are documented at the point where
someone would turn them on, with the cost stated there rather than in a
footnote: a third party sees project names, the ntfy topic is the password, a
free account does not move the quota, you are approving a tool name rather than
a command, and the approval hook holds the prompt while it waits.

The two declined features get a section of their own instead of being absent.
"We evaluated this and decided not to" is worth more than a gap, and both pages
say what evidence would change the answer.

architecture.md gains a section for this release, including one negative result
worth recording. The ntfy sink moved to the JSON publishing API on the
assumption that a CJK title in an HTTP header would be mangled — headers are
US-ASCII by spec. Measured against ntfy.sh: it is not. `Title: 完成 ✅` comes back
byte-identical, because curl sends raw UTF-8 and ntfy decodes it as UTF-8. The
JSON API is still right — it does not depend on that leniency surviving a
proxy, and action buttons have no header form at all — but the reason in the
earlier commit message was wrong, and this file does not get to keep claims it
cannot back up.

competitors.md's gap table becomes a status table; roadmap.md marks release 8
code-complete with macOS unverified, and records that two of its rows are
positions rather than backlog.

STATUS.md now leads with what is *not* verified, because that is the thing a
fresh session most needs and the thing this branch cannot check for itself:
every macOS path, the real PermissionRequest event, and delivery to an actual
phone.

CLAUDE.md picks up the conventions that are now load-bearing: the suite never
touches the network and everything outbound goes through an injectable command;
a new channel is a sink function plus a doctor row, never a branch in the event
logic; nothing but the decision JSON may reach tailbell-approve's stdout.

Co-Authored-By: Claude <noreply@anthropic.com>
…out it

tailbell-listen passed `text=True` to subprocess.Popen. That keyword is 3.7+,
and the cluster's system Python is 3.6.8 — which this repo's conventions
already say to write for. It works on the Mac, where the listener normally
runs, so nobody had hit it.

The way it fails is the part worth fixing. watch() wrapped stream_once in
`except Exception: pass`, so the TypeError was swallowed, the thread retired,
and the host was simply never streamed — silence, with the doctor green on both
sides. That is the exact failure mode docs/architecture.md §6 exists to prevent,
reproduced in the one file that had no logging of its own. The handler now
writes to stderr, which launchd sends to /tmp/tailbell.err, which
tailbell-doctor already prints.

Found by writing the listener's first tests. It had none, because everything it
does goes through ssh — docs/MIGRATION.md flagged that gap and it stayed open.
tests/fake-ssh answers the three questions the listener asks (is the master
alive, which cluster is this host, give me the stream) from a script.

6 cases: two connections to one cluster producing one notification rather than
two — the bug that made connecting to gl1..gl6 mean six copies of everything —
plus kind reaching the renderer, high priority becoming urgent, a malformed line
not ending the stream, and a host without tailbell installed being skipped
instead of tailed.

107 -> 113 cases, all passing.

Co-Authored-By: Claude <noreply@anthropic.com>
…ment

troubleshooting.md gains the symptoms release 8 can produce: a channel that is
listed but unconfigured, a revoked webhook, a Feishu bot in signed mode, the
Allow/Deny buttons never arriving, a late tap being refused, and every event
sounding the same because the agent side is older than the workstation side.
Each row is a line you will actually see in debug.log or doctor output, not a
category.

It also corrects a claim that was true when written: PermissionRequest does not
fire "zero times" in the chat panel — it fires, only ever for AskUserQuestion.
The advice is unchanged, the reason is now accurate.

architecture.md records the compute-node measurement, which was the trap the
brief named: a channel that works on a login node and fails under sbatch. A job
on gl3009 published to ntfy through ARC's preset http_proxy and the message came
back off the topic intact. The first attempt failed for a reason worth keeping —
/tmp is node-local, so the config was simply not there, and the hook silently
fell back to defaults. $HOME is the shared mount; that is the same fact §3 rests
on, seen from the other side.

Co-Authored-By: Claude <noreply@anthropic.com>
Both were latent on the path a Mac-local Claude Code session uses, and both are
the same mistake: a GNU tool assumed to exist.

- `stat -c %s` is GNU. macOS spells it `stat -f %z`. The rotation guards tried
  only the GNU form and fell back to 0, so on a Mac neither events.log nor
  debug.log ever rotated — they just grew, on the machine least likely to
  notice. Same for `stat -c %y` in the doctor, which printed an empty
  last-written time.
- `flock` does not exist on macOS at all. sink_file piped through it
  unconditionally, so a Mac user who added `file` to their channel list would
  get "FAILED writing" and nothing else. It now falls back to a plain append and
  says so in the log; the shared-NFS case that actually needs the lock is the
  Linux one.

Then CI, with macos-latest in the matrix, because this repository has now
shipped five bugs of exactly this shape and docs/MIGRATION.md exists because of
the first three. A runner cannot see a banner or hear a sound, but it runs the
same scripts under the same /bin/bash 3.2 and the same BSD userland — which is
where two of the three original bugs actually lived.

Making the suite run there needed two portability fixes of its own: the channel
list is now pinned to `file` so cases mean the same thing on both platforms
(the release 0 case unsets it deliberately and asserts the platform-appropriate
default instead), and `timeout` — also GNU-only — is replaced by a
background-and-kill helper.

CI also checks the one thing a green local run cannot: that no test reaches a
real endpoint. A case that starts making live requests would pass on a laptop
and fail here.

113 cases, all passing.

Co-Authored-By: Claude <noreply@anthropic.com>
The previous CI step looked for real-looking endpoints in the test file, which
is brittle in both directions — it fired on the fake Slack webhook and would
have missed a live request written any other way.

Replaced with the property itself: put a curl on PATH that refuses to connect,
and run the whole suite again. Everything that reaches outside goes through an
injectable command, so nothing should notice. Verified locally: 113/113 with a
curl that exits 7 on every call.

Co-Authored-By: Claude <noreply@anthropic.com>
tailbell-register never read ~/.tailbell/config, so it always wrote a 120 s
timeout on the PermissionRequest hook. Setting TAILBELL_APPROVE_TTL=300 gave you
a hook that waits 300 s and a Claude Code that kills it at 120 — and a killed
hook is a decision that never arrives, which is indistinguishable from nobody
answering. It sources the config now, and a case asserts the timeout follows.

Also in tailbell-approve: a poll that cannot run at all — a 401 on a protected
topic, a dead proxy — returned the same empty string as nobody having answered
yet, and ended with the same "no decision" line. Those are the two things you
need to tell apart when working out why tapping Allow did nothing, so the
failure is now logged, once rather than once per tick.

113 -> 114 cases, all passing.

Co-Authored-By: Claude <noreply@anthropic.com>
get-tailbell.sh is the path most new users will take and it was the only entry
point with no coverage at all. The case clones from this working tree at this
commit, installs into a throwaway HOME, and then asserts the thing actually
being claimed — that what it installed emits a notification with nothing else
configured. No network involved.

It also needed a real fix to be testable: `git clone --branch` takes a branch or
a tag and nothing else, so pinning to a commit failed. That is what CI has when
it checks out a pull request, and it is also what you want when reproducing
somebody's setup. Clone, then check out, which accepts all three.

114 -> 116 cases, passing with and without a working curl on PATH.

Co-Authored-By: Claude <noreply@anthropic.com>
Kept as its own section rather than edited into the design, so the two can be
compared. It names the four bugs the tests turned up — none of them introduced
by this release — the three places the implementation departed from the plan,
and the one thing deliberately not built and why.

Co-Authored-By: Claude <noreply@anthropic.com>
The compute-node channel test was submitted from the repository root, and
sbatch writes slurm-<jobid>.out to the working directory unless told otherwise.
It got picked up by `git add -A` two commits later. Removed, and .gitignore now
covers the pattern — the same test will be run again on other machines.

Co-Authored-By: Claude <noreply@anthropic.com>
Both were found the same way — running the real thing instead of the fake — and
both had been shipped for a while.

1. Approval was on the hook whose decision is not applied.

tailbell-approve was registered on PermissionRequest. That hook is documented as
running "before permission prompt", the runtime carries strings about it allowing
and denying, and it fires with the real tool_name and tool_input. Its decision is
ignored.

Measured on 2.1.160 by driving a terminal CLI session inside a pty — headless
`claude -p` auto-approves and never prompts, which is why this went untested.
allow and deny, four output shapes (hookSpecificOutput with either hookEventName,
top-level decision:"approve", top-level permissionDecision): the prompt appeared
every time. PreToolUse with the matching hookEventName does apply the decision —
the write happened and no prompt was ever drawn. The round trip is now verified
against a real session, not only against the fake phone.

The cost drives two changes. PreToolUse runs before *every* matching tool call,
not only the ones that would have prompted, so TAILBELL_APPROVE_TOOLS is required
— tailbell-register refuses without it — and doubles as the hook's matcher. The
hook also returns immediately when permission_mode is already auto-approving.
hooks/hooks.json drops the entry entirely: a static file cannot know your
matcher, and a matcher-less PreToolUse hook would run before all ~787 tool calls.

2. Log rotation replayed every notification it had ever sent.

Rotation was `tail -n 200 log > tmp && mv tmp log`. The workstation follows that
file with `tail -F`, which tracks it BY NAME — so a replaced file is reopened and
read from the beginning, and all 200 retained lines arrive as fresh alerts. The
listener's duplicate suppression is a 120-second window and does not touch events
that old.

Found by doing it by hand on a live system: rewriting events.log replayed days of
history onto the user's screen, dozens of stacked alerts for turns that had
finished long ago. Reproduced deterministically afterwards.

Fixed in two places, because one of them is on the wrong machine to be trusted.
Rotation moves the old file to events.log.1 and leaves a fresh empty one — tail
still reopens and still reads from the start, and finds nothing. And the listener
now refuses any record older than TAILBELL_MAX_AGE (300 s) and says so on stderr,
because the sender is on another machine and may be an older build.

126 cases, all passing, with and without a working curl.

Co-Authored-By: Claude <noreply@anthropic.com>
Every claim in this release is covered by tests that run offline. What a Linux
login node cannot do is look at a screen, listen to a sound, or hold a phone,
and those gaps were scattered across STATUS.md as prose. This turns them into
something workable in one sitting: exact commands, what "good" looks like, and
what the failure means when it is not.

It also carries the two rendering problems seen on 2026-08-19 — a truncated
message line and alerts overlapping instead of stacking — with a snippet that
reproduces them, since neither has been diagnosed and both were observed by
accident rather than by testing.

Co-Authored-By: Claude <noreply@anthropic.com>
The snippet raises five real alerts on the user's screen, which is the point,
but it deserves saying out loud. And the obvious cleanup afterwards — editing
events.log to remove them — is exactly the thing that replays every notification
in the file. That mistake has now been made once on a live system; the guide
should not let a reader walk into it a second time.

Co-Authored-By: Claude <noreply@anthropic.com>
Kept on its own branch so the checklist's history is separate from the code it
verifies. v8-parity stays the integration branch.
Every published URL in this repository is built from the handle `jackiectl` —
fourteen of them, across README, package.json, the Homebrew formula, the plugin
manifest and packaging/. None of them has ever been checked.

Checked now, from a login node whose GitHub access demonstrably works
(api.github.com resolves `torvalds` and answers rate_limit with 200):
`jackiectl`, `JackyCTL` and the pre-rename `aevum-orrin` all 404, and so does a
lookup by the numeric id in the commit email, which would survive a rename.

Recorded rather than guessed at. Picking a spelling and editing fourteen URLs on
a hunch is how a repository ends up advertising a clone command that does not
work. It is one sed once the real handle is known.

Co-Authored-By: Claude <noreply@anthropic.com>
A release branch had been doubling as the workbench: release 8 put sixteen
commits straight onto v8-parity, and one of them ended up carrying two unrelated
fixes because everything landed in one line of history.

The convention now says what was actually done from the verification guide
onward — topic branch off the release branch, merge back with --no-ff — plus the
rule that catches the other case: a commit that turns out to carry two reasons
names both rather than letting one hide behind the other.

Co-Authored-By: Claude <noreply@anthropic.com>
The bulk-dismiss hotkey was written as a glyph and never spelled out, so the one
time it was needed — a screenful of alerts, in the middle of the incident that
caused them — it could not be acted on without asking which key ⌥ is.

Now says Option, says where the key is, says it is not Command or Control, says
it is global so no alert needs clicking first, and says the ✕ closes one. Also
notes that the hotkey only exists with the Hammerspoon renderer: the osascript
fallback fades on its own and has nothing to clear.

Verified against the binding rather than from memory:
hs.hotkey.bind({"alt"}, "escape", dismissAll) in mac/tailbell.lua.

Co-Authored-By: Claude <noreply@anthropic.com>
…rangers

An earlier commit recorded that this repository's fourteen GitHub URLs point at
a handle that could not be found. That was a wrong conclusion drawn from real
evidence, and the correction is worth more than the retraction.

The handle is `jackiectl`, and the authoritative check is SSH, not HTTP:
`ssh -T git@github.com` answers "Hi jackiectl! You've successfully
authenticated". The URLs are correct and unchanged.

The account is flagged, which makes it invisible to anonymous requests — profile
404, user API 404, lookup by numeric id 404, user search empty, all while
torvalds resolves fine from the same host. Every one of those is what a deleted
account looks like too, which is how the wrong conclusion got drawn. The `gh`
CLI is also unusable, and again not for the obvious reason: its token is fine
and the account's API quota is zero ("API rate limit already exceeded for user
ID 272573266"). Plain git is unaffected, which is why the account's other
repositories still push.

Written down so the next session does not spend twenty minutes rediscovering it,
and does not "fix" fourteen correct URLs.

Co-Authored-By: Claude <noreply@anthropic.com>
The original account is flagged. That did not stop git from working, but it
zeroes the API quota, hides the profile from anonymous visitors, and would have
attributed every pushed commit to an account nobody can look up.

Three changes, all of which had to happen together to be coherent:

- The sixteen published URLs — README, package.json, the Homebrew formula, the
  plugin manifest, packaging/ — now point at jackiectl2, as do the two author
  email fields.
- The whole history was rewritten onto that account's noreply address. GitHub
  attributes commits by email, so leaving 35 commits on the old one would have
  credited a flagged account on a repository owned by the new one. Nothing had
  ever been pushed, so this cost nothing; the originals are in refs/original/
  and every tree is byte-identical.
- user.email is set repo-locally, not globally. The account's other
  repositories are on the old identity and should keep it.

Not done here, because neither is possible from a login node: the public key has
to be added to the new account (GitHub allows a key on exactly one account, so
the existing one authenticates as jackiectl and always will — a second key pair
and an ssh config alias are in place), and the repository has to be created in a
browser, since creating one needs the API that the flag disabled.

Co-Authored-By: Claude <noreply@anthropic.com>
The first CI run failed on macos-latest and passed on ubuntu-latest — exactly
what that runner was added to catch. The failure could not be read: downloading
a job log needs admin rights on the repository, so an unauthenticated maintainer
sees a red X and nothing else.

The suite's output now also goes to the job summary, which is part of the check
run and readable by anyone who can see the repository. On failure it lists the
failing cases first and then forty lines of tail; on success just the count. It
also records the bash, python and jq actually in use, since macOS still ships
bash 3.2 and that is half of what these runs are for.

Co-Authored-By: Claude <noreply@anthropic.com>
…osed

The job summary shows in the web UI but comes back null on the check run's
`output.summary` through the API, so it did not solve the problem it was added
for. Annotations do come back, and without repository admin rights — verified by
reading this run's existing two.

Each failing case is now emitted as ::error::, so a red run explains itself to
anyone who can see the repository. The summary stays, because it is still the
nicer thing to read in a browser.

Co-Authored-By: Claude <noreply@anthropic.com>
jackiectl2 and others added 7 commits August 19, 2026 23:32
…st there

The dependency check listed flock as required. macOS has no flock(1) at all, so
`bash install.sh` exited 1 on every Mac — meaning the agent side could not be
installed on the machine release 4 exists to support, and the one-line installer
was broken there too.

The runtime half of this was already fixed earlier in the release: the file sink
falls back to a plain append when flock is missing. The install-time check was
not updated to match, which is the whole bug.

flock is now checked separately and reported as a warning that says what is
actually lost — serialising concurrent appends to one log on a shared NFS $HOME,
which is the cluster case and not the laptop case.

Found by CI on macos-latest, on the very first run after pushing. That runner
was added two commits earlier for exactly this class of bug and has now paid for
itself. The regression test hides flock behind a PATH that does not contain it,
so it fails on Linux too if this ever comes back.

126 -> 128 cases, all passing.

Co-Authored-By: Claude <noreply@anthropic.com>
… Mac

The case asserted that a freshly installed tailbell writes to events.log. On
macOS it does not, and should not: with no channel configured the list resolves
to `desktop` and the alert is drawn locally, because there is nothing to
transport it to. The assertion was Linux's answer applied everywhere.

Split by platform, the same way the release 0 guarantee case already is — on a
Mac it now checks the decision log says the alert was shown locally.

Caught by CI on macos-latest. Second real finding from that runner, and this one
is in the test rather than the code, which is its own kind of useful: the suite
was quietly claiming something untrue about half the platforms it supports.

Co-Authored-By: Claude <noreply@anthropic.com>
CI on macos-latest found two things on its first runs: install.sh refused to run
at all because it required flock, which macOS does not have, and a test case was
asserting Linux's answer on both platforms.
The per-kind sounds were the one claim that could not be checked from the
cluster at all -- a sound map is only worth having if the three are audibly
distinct, and that is a judgement no test can make. They are. Recorded against
the procedure so it can be re-run, rather than as a line in a changelog.

Also corrects two facts that went stale with yesterday's push: the suite is 128
cases and not 113, and the two things STATUS.md listed as impossible from here
(adding the key, creating the repo) are done.

Co-Authored-By: Claude <noreply@anthropic.com>
It told the Mac to scp the directory across, which was the only option when it
was written. There is a GitHub remote now, so the honest instruction is 'clone'
-- scp would lose the history and every branch, and drag untracked files along.

Five of its seven tasks are also done, and leaving them listed would send the
first Mac session off to redo work. What is left is exactly the two things that
need the hardware, which is the argument for moving in the first place.

Records one thing that was checked rather than assumed: no path in the repo
needs editing after the move. Every script resolves itself from $0 and all
state hangs off TAILBELL_HOME.

Co-Authored-By: Claude <noreply@anthropic.com>
An optional ntfy or Feishu channel is a third party, and a reader who turns one
on has every right to feel the headline oversold it. The claim is true of the
default path and the README already says so 150 lines later -- it just needed
saying where the claim is made.

Also drops the sounds from the untested list; they were verified on a Mac.

Co-Authored-By: Claude <noreply@anthropic.com>
@jackiectl2
jackiectl2 merged commit 9c04cb2 into main Aug 20, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant