Skip to content

feat!: one delivery rule for notifications — blocking work only, on a surface you can act from - #70

Merged
y49 merged 32 commits into
mainfrom
feat/notification-policy
Aug 4, 2026
Merged

feat!: one delivery rule for notifications — blocking work only, on a surface you can act from#70
y49 merged 32 commits into
mainfrom
feat/notification-policy

Conversation

@y49

@y49 y49 commented Aug 4, 2026

Copy link
Copy Markdown
Owner

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.

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.

What changes

Desktop — one aggregated toast, driven by a new WaitingBoard that is the single source of truth for "what is waiting". The three-term nothingWaiting() predicate and its duplicated passthruWaiting Set 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 a kill -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 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.

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.

Testspnpm test no longer fires real desktop notifications carrying fixture session names onto the developer's screen.

Breaking

  • 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 produced total silence for a blocking prompt. 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.
  • An idle session no longer pops its own self-expiring banner; it joins the single aggregated toast, which stays until the work is answered.

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:

new work silent update clear
linux fresh notification (guaranteed banner) --replace-id gdbus CloseNotification
win32 History.Remove then Show SuppressPopup = $true History.Remove
darwin display notification nothing no-op (platform)

macOS 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), tsc and build clean 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:

  1. Dropping the transient hint does not merely keep the toast out of the tray; it makes the notification resident, and --replace-id then 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.
  2. "No expiry" is not "stays on screen" — shells show the banner briefly and keep the notification in the notification centre, where it counts toward the app badge until answered. The docs now say so rather than promising something the platform does not do.

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

  • Branch is rebased onto main at 3.0.0. The two breaking commits make the next release 4.0.0.
  • docs/changelog-archive.md and CHANGELOG.md are untouched — the latter is machine-owned.

y49 added 30 commits August 3, 2026 17:20
…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.
y49 added 2 commits August 4, 2026 09:34
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
y49 merged commit 0329b22 into main Aug 4, 2026
4 checks passed
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.
@y49
y49 deleted the feat/notification-policy branch August 4, 2026 02:52
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