Repository navigation
Release 8 — parity: optional channels, phone approval, sound, packaging - #1
Merged
Merged
Conversation
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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
tailbell-notifywith no channels configured and asserts zeronetwork calls.
mainand is identical but for thenew
kindfield.tests/run-tests.shstill needs no network. Everything that reaches outsidegoes through an injectable command (
TAILBELL_CURL,TAILBELL_OSASCRIPT,TAILBELL_AFPLAY,TAILBELL_SAY,TAILBELL_FOCUS_CMD) pointed at a recorder.Stop.last_assistant_messageand
PermissionRequest.tool_inputare both guarded by canary tests.What is in it
ntfy,slack,discord,feishu(with signed-bot support), plus the existingdesktopandfile,behind one
TAILBELL_CHANNELSlist. A channel is asink_<name>functionplus a row in the doctor's table — not a branch in the event logic.
PermissionRequestfires in the chatpanel but a command hook's verdict there is ignored — measured, both
directions, four output shapes.
PreToolUseis the hook whose decision isapplied, so that is where this lives.
voice off by default, one-line mute.
curlone-liner — each awrapper 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.mdanddocs/usage-quota.md.Bugs found and fixed along the way
install.shexited 1 on every Mac — it requiredflock, which macOS doesnot have. Caught by the new macOS CI job on its first run.
events.logby rewriting it replays thewhole file: the workstation follows with
tail -F, which tracks by name, soa 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.text=Trueis Python 3.7+and the system Python is 3.6.8; the exception was swallowed by a bare
except.install.shregistered 5 of 7 events and never wrotecluster-id, so thelistener skipped the machine entirely.
Verified, and not
Verified: the full cluster -> Mac chain, the macOS install,
doctor --test, thethree 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-latestandmacos-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.mdanddocs/verify-release-8.md.