Effective Finish — Quiet Chronology, Phased
Plan of record for honest “agent finished” attention — kaval owns resize-aware meaningful-output chronology; activity cutover (EF1) and pure-fold finishedIds via a second createActivityTracker (EF2) shipped. Phased EF0–EF3 (EF0–EF2 done) after closing the
An agent (Claude Code is the motivating case) can mark its turn waiting while
background sub-agents still emit bytes. Raw waiting in finishedIds is then a
premature finish — chime and host-tab mark before the work actually stopped.
The fix is not a second byte-tap state machine in padi. It is:
- kaval reports meaningful PTY output (resize repaints excluded at the source),
- padi + client cut over live-dots onto that fact in one PR (no dual path) — done,
- padi pure-folds finish from the same edge via a second
createActivityTrackerat a multi-second window (EF2) — done.
Work items use the EF track prefix (new; no Atlas collisions). #1938
closed without merge — design kept, big-bang delivery discarded. This note is the plan
of record (#1944). EF1 shipped (#1945); EF2 shipped
(#1947): finishedIds is effective quiet, not raw waiting.
The problem
| Symptom | Cause |
|---|---|
| Premature “finished” | finishedIds = raw agent waiting packages/padi/src/activity/urgency.ts |
| Visit finished tile → re-chime | First #1938 design made finish byte-reactive; client resize mute did not travel to padi taps |
| Sticky “all terminals live” | #1938 Phase D stored a Solid reconcile proxy as prev; removals never applied |
Attention already has one model (docs/atlas/src/content/atlas/agent-attention.mdx / #1931). This note only makes the finished half of that model honest — and, as a prerequisite, makes activity a single source of truth.
End state
Meaningful output (EF1 — shipped)
| Fact | Writer |
|---|---|
Meaningful-output edge { id } |
kaval onData (resize-muted bursts excluded) |
Consumers stamp arrival on their clock (recency-free — nothing cross-host compares two hosts’ clocks). Never ship kaval absolute timestamps for finish.
Two windows, one tracker type (EF1 + EF2)
EF1 extracted packages/padi/src/activity/terminalActivityTracker.ts and
wired it for live-dots. Its header already reserves isLive / forget for
the finish consumer. EF2 does not re-derive stamps + a hand deadline — it
builds a second createActivityTracker(EFFECTIVE_FINISH_QUIET_MS) instance.
| Consumer | Window | Read | Lifetime |
|---|---|---|---|
Live dots (liveActivity + client) |
TERMINAL_IDLE_AFTER_MS (~1s) |
snapshot() → stream |
Per activity-stream subscriber |
| Effective finish | EFFECTIVE_FINISH_QUIET_MS (~5s) |
isLive(id) |
Daemon-lifetime (urgency honest with zero watchers) |
Pure finish fold (mental model ≡ episode map + tracker)
episode: Map<id, "debouncing" | "finished">
finished(id) =
active ∧ agentBucket = waiting
∧ episode.get(id) === "finished"
// events (not reads):
enter-waiting → episode.set(id, "debouncing"); tracker.noteOutput(id)
quiet-exit → episode.set(id, "finished") // tracker.onChange
leave-waiting → episode.delete(id); tracker.forget(id)
boot seed → episode.set(id, "finished") // discovery
edge / restamp → tracker.noteOutput only if phase === "debouncing"
The first quiet-crossing is identical to the stamp form
now − max(enteredWaitingAt, lastArrival ?? enteredWaitingAt) ≥ QUIET
iff the tracker is fed for the first-finish debounce. Promotion to
finished is an event (quiet exit), never a side-effect inside the
predicate. Mid-waiting TUI noise does not re-arm a finished episode’s timer
(and so does not dual-edge-recompute forever).
ASKING (awaiting_user) stays ungated — never gated on quiet.
EFFECTIVE_FINISH_QUIET_MS (~5s): long enough to ride gaps between background
sub-agent output bursts; short enough the finished nudge isn’t noticeably late.
Only the lesser finish path is debounced — never asking.
Default-excluded and recycle blindness
- Default-excluded means: until we have affirmatively started a quiet window
(enter-waiting
noteOutput/ restamp), the terminal is not finished. - A lost-edge gap (kaval recycle,
resubscribeStreamdelay) can age the tracker to!isLivewhile the PTY is still noisy → early finish, the opposite of “late only delays.” - Required: on every successful (re)subscribe establishment of the
standing kaval activity sub, restamp all currently waiting ids
(
noteOutputeach). Blindness becomes delay again.
Graph wiring (urgency)
Today urgency is only terminals-driven:
// servePadi.ts today
urgency: derived.cell(($) => recomputeUrgency($.terminals())),
Quiet expiry is a non-event on terminals. EF2 must:
- Keep a daemon-lifetime finish tracker + standing kaval
resubscribeStream(not nested insideliveActivity’s per-subscriber source). - Publish the quiet/settled fact through a reactor
sourcewhoseinstallisfinishTracker.onChange(same pattern ashostInventorypoll sources). - Fold both edges:
urgency: derived.cell(($) =>
recomputeUrgency($.terminals(), finishQuietFact()),
),
so timer expiry re-folds finishedIds without an agent-state change.
Date.now() stays inside the tracker; recomputeUrgency stays pure over
graph inputs.
Optional shared fan-out of the kaval activity stream (one sub, many consumers) can wait for EF3; the standing finish sub is EF2-required.
Sticky-per-episode (product decision — field-corrected)
Client attention treats “left both awaitingIds and finishedIds” as episode
end and clears the fire-once latch
(packages/client/src/attention/attentionTransitions.ts /
packages/client/src/attention/attentionCore.ts). So if the server
un-finishes while the agent stays waiting, the latch clears and a later
re-quiet re-chimes.
Chosen semantics: sticky-per-episode. Once a waiting id crosses quiet and
enters finishedIds, it stays finished until it leaves the waiting bucket
(agent runs again / asks / leaves pool). Mid-waiting edges (including idle TUI
statusline/clock/context ticks that pass kaval’s resize-only mute) do not
un-finish.
Earlier plan chose re-chime-per-quiet-crossing under the assumption that idle
terminals are byte-quiet. Field falsified that — every idle agent TUI that
repaints on an interval > ~5s flapped finishedIds forever on every host.
Sticky kills the flap for every noise source (including resubscribe restamp after
the first quiet-crossing). The first-finish ~5s debounce is untouched.
Boot seed: already-waiting ids on first syncWaiting go straight to sticky
finished (discovery is not a transition — matches the client baseline and
pre-EF2 master; avoids mass chime on padi restart under a connected client).
Pin in unit tests:
- mid-waiting edge after finish does not un-finish (fold / tracker);
- boot seed already-waiting → immediately finished (no debounce window);
- steady finishedIds while waiting → no re-deliver; leave-both-sets re-arms
(attention engine
observe); - resize-class edge does not start an episode (kaval mute; EF1).
Live activity (EF1 — shipped)
- Short window:
TERMINAL_IDLE_AFTER_MS(~1s). - padi
activitystream = live id set; client mirrors it for dock/title dots. - Client must copy frames to plain arrays before set-diff (
prev = [...frame]), never hold the store proxy fromreconcile. - No dual path: no padi byte taps for activity, no client
noteOutput/ resize-suppress hack.
What kaval does not own
Quiet windows stay consumer policy. Kaval owns which bursts count (resize-aware filter + throttle), not a single live/quiet boolean with one internal timer.
Why not “edge only, consumers later”?
Shipping a kaval stream with nothing product-facing on it leaves the old mechanism in place. EF1 cut activity over completely. Finish is a different axis (attention policy) and stays EF2.
Phase tree
Strict dependency: EF0 → EF1 → EF2 → EF3. Each remaining box is one PR, green alone, revertible alone. EF0–EF2 are complete.
EF0 — close / capture
- Close #1938 without merge (done) — do not resurrect that branch wholesale.
- This note is the plan of record (#1944, merged).
EF1 — activity cutover (kaval → padi → client)
Shipped in #1945 — one volatility, full stack:
| Layer | Shipped | Deleted |
|---|---|---|
| kaval | Host-global activity edges ({ id }); emit from onData; resize mute before proc.resize; throttle; pure shouldEmitActivityEdge + tests; contract 5.3 |
— |
| padi | liveActivity folds the edge through the short idle tracker |
Per-terminal activity byte taps / dirty reconcile for taps |
| client | useAttentionFacts mirrors per-host streams.activity; plain array copies for frame/prev |
noteOutput, suppress, RESIZE_ACTIVITY_SUPPRESS_MS, attach-sink lighting |
Finish policy (finishedIds) was deliberately not changed here — that is EF2.
EF2 — pure finish fold (finish honesty)
Shipped in #1947 — tracker realization, not stamps + deadline; product corrected to sticky-per-episode after field flap:
| Piece | What |
|---|---|
| Tracker | createActivityTracker(EFFECTIVE_FINISH_QUIET_MS) (~5s) — second instance of the EF1 leaf packages/padi/src/activity/terminalActivityTracker.ts via packages/padi/src/activity/finishQuiet.ts |
| Feed | noteOutput on enter-waiting (awaiting→waiting, re-entry); every kaval edge (pre-finish debounce); resubscribe restamp of not-yet-sticky waiting ids |
| Boot | already-waiting on first sync → sticky finished immediately (discovery) |
| Evict | forget + sticky-clear on leave-waiting / leave-pool |
| Fold | finishedIds = active waiting ∧ isEpisodeFinished(id) (sticky memoize) — packages/padi/src/activity/urgency.ts; asking ungated |
| Graph | Daemon-lifetime standing kaval resubscribeStream + reactor push source on onChange; dual-edge derived.cell in packages/padi/src/servePadi.ts |
| Product | Sticky-per-episode — once quiet-crossed, stay finished until leave waiting |
Tests shipped
| Case | Layer |
|---|---|
Enter waiting + quiet ≥ QUIET → in finishedIds |
fold / tracker |
| Edge mid-window resets; not finished until QUIET after last edge | fold / tracker |
| Boot seed: already-waiting → immediately sticky-finished | fold / tracker |
| Mid-waiting edge after finish does not un-finish | fold / tracker |
| Resubscribe restamp pre-finish delays; post-sticky does not un-finish | fold / tracker |
| Leave waiting / leave pool clears sticky; re-entry earns a fresh window | fold / tracker |
awaiting_user → waiting demotion starts window |
fold / tracker |
| Quiet timer expiry re-folds without a terminals write | dual-edge integration |
| Steady finishedIds while waiting → no re-deliver; leave-both re-arms | attention engine (observe) |
Resize-class mute remains kaval EF1 (shouldEmitActivityEdge); no second mute in the finish fold.
EF3 — harden / e2e / docs
- Shared padi fan-out of kaval
activityif finish + liveActivity still double-subscribe. - e2e or scripted: agent finish → chime after quiet; mid-waiting TUI noise → no second chime; leave-waiting → work → finish again → one chime; visit → no chime from resize; padi restart under connected client → no mass chime for already-waiting.
- Product docs + changelog once the user story is true end-to-end.
- Point agent attention at shipped behaviour.
Anti-patterns
| Do not | Why |
|---|---|
| Dead kaval wire + old padi/client paths left in place | Half-baked dual mechanism; untestable; second PR tax |
| One PR = activity cutover and finish fold | Two volatilities; #1938 failure mode (bugs mask each other) |
| Quiet boolean with one window in kaval | Complects producer with consumer policy |
| Absolute kaval time for the fold | Breaks recency-free / invites cross-host compare |
| Hand-rolled stamp maps + deadline instead of second ActivityTracker | Re-derives shipped EF1 machinery |
Standing finish sub only while someone watches activity |
Urgency lies with zero stream subscribers |
| Assume “late only delays” without resubscribe restamp | Lost edges during debounce → early finish |
| Assume idle terminals are byte-quiet (re-chime-per-quiet) | Idle TUI statuslines flap finishedIds forever — field bug; sticky-per-episode is the plan of record |
| Strengthen kaval “meaningful” to kill idle ticks | Heuristic whack-a-mole at the wrong layer; quiet policy is consumer-side |
| Delete client activity tests in the mirror cutover | Sticky-live shipped silent |
| Call the pure fold a “gate v2” and claim deletion | Reviewers cannot tell the machine is gone |
Checklist per PR
| Phase | Must have |
|---|---|
| EF0 | ✓ done — #1938 closed; plan #1944 |
| EF1 | ✓ done — #1945 activity cutover (kaval + padi + client; no dual path) |
| EF2 | ✓ done — #1947 second tracker @ EFFECTIVE_FINISH_QUIET_MS; sticky-per-episode; boot-seed sticky; dual-edge urgency; fold + attention tests |
| EF3 | shared fan-out and/or e2e; docs polish |
Relation to attention
Agent Attention owns the rules (fire once, seen,
host-tab, dock). This note owns (1) one activity fact end-to-end and (2) the
truth of finished before those rules run. After EF1, live dots tell the truth
about output; after EF2, finishedIds means effective quiet sticky for the
waiting episode — first quiet-crossing sticks until leave-waiting.