feat!: one delivery rule for notifications — blocking work only, on a surface you can act from - #70
Merged
Merged
Conversation
…asts under vitest
…e copies of one predicate
…ktop-off test The startup clear() claimed to retract a toast stranded by a killed predecessor; across restarts it could not, because the notification id was process-local. Say what it actually does. The desktop-off test's clear assertion also passed regardless of the code under test, because the startup clear had already run — reset the counter so it proves what it claims.
…l closable A resident toast has no expiry to fall back on, and clear() keyed off process-local state, so a daemon killed with a toast up stranded it permanently. The id now survives in the daemon state file and seeds the next process, which makes the startup clear() real rather than a no-op.
clear() wrote toastId:null before the gdbus close was issued, not after. A kill -9 racing the in-flight close left state.json saying null while the toast was still on screen and unclosed — the next daemon's startup clear() would then see no id and do nothing, stranding the toast forever. Moving the write to after the close resolves means a mid-clear crash instead leaves the stale (still-valid) id on disk, and a redundant close on an already-closed notification is a harmless no-op.
The one-shot info banner never replaced and never recycled, so idle sessions stacked on the desktop and stayed there. Idle is a blocking state like any other, so it now lives on the WaitingBoard and shares the single resident toast. BREAKING CHANGE: an idle session no longer pops its own self-expiring desktop banner. It joins the single aggregated toast, which stays until the work is answered instead of vanishing after 15 seconds.
In notify mode the desktop toast is now the only push signal for a terminal-native dialog, so `tlive desktop off` produced total silence for a blocking prompt. Every legitimate use is served elsewhere: no notify-send already no-ops, the OS's Do Not Disturb silences temporarily, and `tlive mode off` stops tlive entirely. The posture ladder stays the single knob for how much tlive intervenes. BREAKING CHANGE: `tlive desktop on|off` and `approvals.desktopNotify` are removed. A leftover config key is ignored, not an error.
In notify mode tlive is not holding the request, so the IM message said 'answer in the terminal' to someone holding a phone. Every push channel now carries only what its reader can act on. A one-time card per chat explains the silence and offers the switch to full, so quiet is not mistaken for broken.
…/mute doc The one-time explanation was the only IM push in bootstrap.ts with no mute check, contradicting /mute's promise to suppress outbound IM; its own "silence reads as breakage" rationale doesn't apply to someone who muted on purpose. Gate it like every other IM push, and mark a chat as explained only on the branch that actually sends, so a muted chat doesn't burn its one lifetime card before it can genuinely use it. Also fix docs/commands.md's IM command table, which had /mute's on/off sense inverted relative to its own CLI table and the code.
…lways recycle
A sub-agent pass-through creates no router pending (requestPermission
returns {decision:'defer'} immediately for it), so unlike a `held` entry
it has no cancel, no timeout, and no onResolved. Its ONLY retirement
path was a PostToolUse matching (agentId, toolName) exactly — deny the
dialog at the terminal, Esc it, or let the sub-agent abort, and no
PostToolUse ever arrives. The board entry became permanent, and since
board.isEmpty() drives the ONLY clear() path left, that one denied
sub-agent tool call disabled toast recycling for every other session too.
Add WaitingBoard.removeWhere(pred) for retirement signals that know
only an entry's visible fields (session key, kind, what), not the
opaque id retirePassthruNotice needs to build. Wire it into the three
signals that ARE available: permission-denied (matches by key+toolName;
CC's PermissionDenied hook never carries an agentId), prompt and
session-end (retire every subagent entry for the key outright — the
session moved on or ended, so any outstanding notice for it is stale).
Over-retiring only ever drops a reminder, never a decision, matching
the idle reminder's existing eager retirement.
Also make clearLocalPrompt's board/registry cleanup unconditional,
matching onResolved: gating it on localPrompts.clear()'s own return
value stranded the local:<key> board entry when a dialog was noted
under one sessionId and its retirement arrived under a different one
(a resume in the same cwd after the old session was killed).
…s the socket The startup desktop.clear() used to run ~650 lines before startIpcServer's bind attempt. daemon/main.ts retries bootstrapDaemon a second time when that bind throws AlreadyRunningError, so the LOSING attempt would still reach the clear() call before its own bind failed — retracting the SURVIVING daemon's toast on its way to exiting, at the exact moment something is still genuinely waiting, and leaving the survivor rendering against a now-closed id via --replace-id. Move the call to right after the IPC bind succeeds, so only the attempt that actually owns the socket may retract what a predecessor left behind. Also add a regression test for the shutdown clear(): deleting it broke nothing before, despite being half of the resident toast's safety story.
writeState was a bare writeFileSync (O_TRUNC then write), and it runs on every render — i.e. precisely whenever a toast is up. A kill between the truncate and the write left a 0-byte state.json; readState degrades that exactly like a missing file, silently dropping toastId along with it and stranding a live toast (previously miscategorized as costing "at most one re-sent explanation card" — that was true before toastId shared the file). Write to a temp file in the same directory and renameSync over the target instead: rename is atomic within one filesystem, so a reader always sees either the complete old file or the complete new one. Clean up the temp file on failure. The read-modify-write callers (markNotifyExplained / writeToastId) stay fully synchronous, so this doesn't introduce a window for two writers to interleave and lose a field. Also correct the doc comments' remaining false claim: a ~4ms window between notify-send rendering a toast and its id reaching writeToastId is irreducible (desktop-notify.ts persists only after the spawned process's first line resolves) — you cannot atomically persist an id you do not have yet. Stated honestly instead of implying it's closed.
markNotifyExplained runs synchronously before the one-time explain card is sent, so a delivery failure right there (e.g. a Telegram 5xx) burned the chat's only card and delivered nothing — the bare .catch(() => undefined) discarded the failure entirely, leaving the user in the exact "IM never fires, it must be broken" state the card exists to prevent, with no trace in the log. Keep mark-before-send (moving the mark after the send would let two concurrent dialogs both pass wasNotifyExplained and both send, producing duplicate cards) but replace the silent catch with a logged outcome, following the sibling permission.passthrough.undelivered convention already in this file.
…a dead code
The vitest backstop test only asserted that render()/clear() resolve to
undefined. If the VITEST guard regressed, createDesktopNotifier would
fall through to the real linux notifier (platform:'linux', hasCmd:()=>true)
and fire a genuine notify-send on the developer's desktop — while
render()/clear() would still resolve to undefined either way, so the test
would keep passing right through the regression it exists to catch. Pass
a hasCmd spy and assert it was never called instead: the no-op path
returns before any platform branching, so hasCmd is provably never
consulted (same pattern as the sibling "silent no-op" test).
Also clean up two pieces of dead code this branch created when it
collapsed ping/info into the single toast: PingProc is a misnomer in a
file with no ping concept left (renamed to NotifySendProc), and
win32ToastScript's third `tag` parameter is unreachable now that its only
other caller ('tlive-info') is gone.
… toast rewrite This branch collapsed ping/info into a single resident toast (WaitingBoard + render/clear), but a few comments and test names still described the retired ping/sessionTag shape: - bootstrap.ts: "before the desktop ping below renders a sessionTag(key) label" (onPending) and the equivalent for onPassthrough — the code below renders a board entry via sessionLabel(key), not sessionTag. - bootstrap.ts: "pending approvals (ping)" in the continue-request comment, and "toast pinged at onPending" in the permission_prompt branch. - bootstrap.test.ts: five test names/comments still said "desktop ping". No behavior change — comments and test titles only.
…hat" noun
DesktopNotifier.clear()/render() cannot close or replace a macOS
Notification Center entry (osascript's display notification has no
scriptable equivalent), so every board change appends a fresh entry
there instead of recycling one toast, unlike Linux/Windows. The docs
(both plugin SKILL.md files, docs/commands.md, and the /help card body)
said desktop notifications "disappear once answered" with no platform
caveat — add a brief, honest one. macOS itself is not fixable here; the
platform genuinely cannot do this.
Also fix README_CN.md and docs/getting-started-cn.md, which said 每个会话
("per session") for the once-per-chat notify-mode explain card — the
flag is keyed <channel>:<chatId> (config/state.ts), i.e. per IM chat, not
per Claude Code session (会话 means the latter consistently elsewhere in
this repo). The English docs already say "once ever per chat" correctly.
Bumps the bundled plugin version + content lock (2.5.7 -> 2.5.8) for the
SKILL.md edits, per plugin-consistency.test.ts.
…p toggle KERNEL.md was in no task's doc list, so two statements outlived the code: notify mode no longer pushes the terminal-only-dialog IM text (it is dead mail the reader cannot act on), and `desktop on|off` was removed with its switch.
On a server advertising the persistence capability, --replace-id updates the panel entry silently, so a resident toast alerted once and then went quiet forever — measured live, not inferred. A board entry that was not there before now posts a fresh notification; shrinking or re-wording still replaces in place, so answering one of several never re-pops.
Two coverage gaps on invariants Task 9 exists to protect: nothing reached an empty board (the UUID-keyed test can never catch a deleted reset — ids never repeat), and nothing asserted the alert path's idStore write in combination. Add an idle-reminder regression (deterministic board id, reused after a full empty) verified to fail with the reset removed, and an alert+idStore test asserting the old id closes and the new one persists.
The desktop toast is the only push signal for a terminal-only dialog in notify mode and left no trace at all — diagnosing a real 'toasts don't fire' report needed a probe fired at the user's screen because the log could not say whether one had been attempted. One line at startup says whether the channel is live and why not; each projection records alert/count/kinds, so a run of alert:false after an alert:true identifies a silently-updated toast from the log alone.
…x Minor test Round-1 review fixes for the desktop observability logging: - Restore the vitest-no-spawner backstop to run before any platform/hasCmd branching (the reorder had no production effect and cost the structural "hasCmd never consulted" guarantee the existing test relies on); injecting a spawner in the affected tests reaches the platform branch instead. - darwin/win32 missing their own binary now log reason 'no-backend', not the linux-only 'no-notify-send'. - desktop.clear only logs on the board's real empty transition, not on every no-op refresh after an already-empty board (clearLocalPrompt's unconditional refresh runs on every main-session activity/prompt event). - The same-id re-render test now genuinely varies the rendered text (via a changed session label) instead of two byte-identical calls, so it actually protects against alert becoming text-sensitive.
Retiring one of several waiting things fired refreshDesktop twice in the same tick and logged two identical render lines, because clearLocalPrompt and the idle removal each refresh unconditionally. A view identical to the last one rendered, with no newly-arrived entry, now returns early: one less notify-send spawn and one line per real change. The alert half of the condition is required — two different entry sets can render the same text, and a new arrival must still raise a banner.
…unit-tested function refreshDesktop decided inline whether a projection could be skipped, and an ablation check showed the alert half of that decision and the empty-board lastView reset were each individually unprotected by the integration tests — dropping either one left all three passing. canSkipProjection(lastView, view, alert) moves the decision to waiting-board.ts beside renderBoard, where it is pure and directly testable; refreshDesktop now just calls it. One unit test per clause pins each one: dropping any single clause now turns exactly one test red. The empty-board lastView reset stays, with a comment explaining it is provably redundant given how alert is currently computed and is kept as defence in depth rather than a fact worth re-deriving.
The board passes through an empty state in this scenario, and the lastBoardIds reset there forces alert:true by a different mechanism, so the test stays green with the clause removed. canSkipProjection's unit tests are what pin it.
'No expiry' is not 'stays on screen': most shells show the banner briefly and then keep the notification in the notification centre until it is answered. That is where you find it after stepping away, and it is what every other app on the desktop does. Also records that a NEW waiting thing raises a fresh banner rather than silently updating the existing entry.
…cation Only blocking work reaches the desktop; a finished turn goes to IM only. But an IM client running on the same machine turns every message tlive sends into a second desktop notification, so a 'Turn finished' toast looks like the rule is broken when it is the IM client relaying. tlive cannot know where that client runs, so say where the fix belongs.
Both backends took the alert flag and ignored it, so answering one of several waiting things posted a fresh notification instead of updating quietly — the inverse of the Linux defect already fixed here. macOS can neither replace nor close an entry, so a silent update now posts nothing rather than a misleading banner. Windows states its intent explicitly: History.Remove then Show for a new arrival, SuppressPopup for a silent update, rather than depending on how Tag replacement happens to behave.
Round 1 review of the alert-contract fix found two gaps. First, the win32 SuppressPopup assertion checked presence but not position, so a reordering that moved it after Show() (a real bug — SuppressPopup must be set before Show is called) would have shipped green; added the same before-.Show( check already used for History.Remove, and confirmed by ablation that it now catches the reordering. Second, DesktopNotifier.render's alert flag was optional, so a future call site could omit it — and on darwin, omitted alert means posting nothing at all, the most concealed of the three backends' failure modes, invisible in CI. Made opts.alert required so every caller states its intent, updated the one production call site's neighbors that needed it, and deleted the test for the now-unrepresentable omitted case.
y49
added a commit
that referenced
this pull request
Aug 4, 2026
… surface you can act from (#70) Five notification surfaces had grown independently, each with its own gate and its own recycling rule. This adopts one rule: a notification is delivered only to a surface where the reader can act on it, and it fires at all only when something is blocking. New work alerts; finishing work does not. Desktop is now one aggregated toast driven by a new WaitingBoard, the single source of truth for what is waiting. The three-term nothingWaiting predicate and its duplicated passthruWaiting set are gone, so a new waiting state cannot be added without appearing in the predicate. The toast has no expiry and is actively closed once the work is answered, and its notification id is persisted so a killed daemon's toast is still closable by the next one. IM carries only traffic the reader can act on. In notify mode the "answer in the terminal" text is gone — it reached a phone that cannot reach a terminal. A one-time card per chat explains the silence and offers the full rung, gated on /mute and without spending the flag when suppressed. All three backends now honour one type-enforced alert contract: linux replaces in place for a silent update, win32 suppresses the popup, and darwin posts nothing at all because it can neither replace nor close an entry. The desktop channel also gained the logging it never had — its liveness once at startup, and each projection with its alert flag, count and kinds — so a silent channel can be diagnosed from the log instead of a probe fired at a real screen. BREAKING CHANGE: `tlive desktop on|off` and `approvals.desktopNotify` are removed. In notify mode the toast is the only push signal for a terminal-native dialog, so turning it off silenced a blocking prompt entirely. Use your OS's Do Not Disturb to silence temporarily, or `tlive mode off` to stop tlive entirely. A leftover config key is ignored, not an error. BREAKING CHANGE: an idle session no longer pops its own self-expiring desktop banner. It joins the single aggregated toast, which stays until the work is answered instead of vanishing after 15 seconds.
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.
Five notification surfaces had grown independently, each with its own gate and its own recycling rule. This adopts one governing rule and rebuilds the desktop channel around it.
What changes
Desktop — one aggregated toast, driven by a new
WaitingBoardthat is the single source of truth for "what is waiting". The three-termnothingWaiting()predicate and its duplicatedpassthruWaitingSet are gone;board.isEmpty()replaces them, so a new waiting state cannot be added without appearing in the predicate. The toast carries no expiry and is actively closed when the work is answered, and its notification id is persisted so akill -9'd daemon's toast is still closable by the next one. A new waiting thing raises a fresh banner; shrinking or re-wording updates in place.IM — only traffic the reader can act on. In
notifymode the "answer in the terminal" text is gone: it reached a phone that cannot reach a terminal. A one-time card per chat explains the silence and offers thefullrung, gated on/muteand without spending the flag when suppressed.Observability — the desktop channel used to leave zero trace in a multi-MB daemon log. It now logs its liveness once (
active,platform,reason) and each projection (alert,count,kinds), only on real changes.Tests —
pnpm testno longer fires real desktop notifications carrying fixture session names onto the developer's screen.Breaking
tlive desktop on|offandapprovals.desktopNotifyare removed. Innotifymode the toast is the only push signal for a terminal-native dialog, so turning it off produced total silence for a blocking prompt. Use your OS's Do Not Disturb to silence temporarily, ortlive mode offto stop tlive entirely. A leftover config key is ignored, not an error.One contract, three backends
render(title, body, { alert })is now type-enforced — the flag is required, so a call site cannot silently inherit a default:--replace-idgdbus CloseNotificationHistory.RemovethenShowSuppressPopup = $trueHistory.Removedisplay notificationmacOS can neither replace nor close a Notification Center entry, so a silent update is unrepresentable there and posting a fresh banner for it would be strictly wrong — it posts nothing and leaves the previous entry as a timestamped record. Windows states its intent explicitly rather than depending on how Tag replacement happens to behave.
Verification
793 tests (from 717),
tscandbuildclean on every commit.Verified on real hardware: Linux only. Two sessions blocking produce two banners, answering one does not re-alert, and the entry stays in the notification centre until answered. win32 and darwin are logic-verified via injected spawners — their generated argument lists and PowerShell are asserted, including ordering — but neither has been exercised on a real machine. That was already true before this PR and is unchanged by it.
Two defects were found on real hardware after all static review had passed, and neither was a code error — both were wrong assumptions about freedesktop semantics that static review has no way to falsify:
transienthint does not merely keep the toast out of the tray; it makes the notification resident, and--replace-idthen updates a resident entry silently. The channel alerted once and went quiet forever. Measured with a probe on the maintainer's screen (a fresh notification pops; a replace of it does not), then fixed by posting a fresh notification whenever a new entry reaches the board.The docs also name a trap that looks exactly like a bug: an IM client running on the same machine turns every message tlive sends to IM into a second desktop notification, so a "Turn finished" toast appears even though tlive never sends completion to the desktop.
Notes
mainat 3.0.0. The two breaking commits make the next release 4.0.0.docs/changelog-archive.mdandCHANGELOG.mdare untouched — the latter is machine-owned.