From 5e175de2089829a7af302ed5a7b0aaf8353d4872 Mon Sep 17 00:00:00 2001 From: Ramiro Rivera Date: Tue, 28 Jul 2026 04:54:20 +0200 Subject: [PATCH 1/2] fix(statusline): expire the achievement banner instead of latching it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trophy banner rendered unconditionally from status.json with no TTL, while reactions expire via _sweep_expired_reactions. status.json is shared across sessions (reactions are per-SID), so a single unlock pinned every session's bubble to that achievement until some path happened to call writeStatusState with no achievement — observed as a "Diplomat" banner stuck for ~5h after conflict_resolver unlocked. writeStatusState now stamps achievementAt, and the statusline expires the banner on the same reactionTTL clock as a reaction. An explicit 0 never renders; a legacy status.json with no field is treated as fresh and self-heals on the next server write. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01H2GXsqpMvUhxBSNabSqZdk --- server/state.ts | 5 +++ statusline/buddy-status.sh | 28 +++++++++++++-- statusline/buddy-status.test.ts | 60 +++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 3 deletions(-) diff --git a/server/state.ts b/server/state.ts index 8eabff3..875b248 100644 --- a/server/state.ts +++ b/server/state.ts @@ -454,6 +454,10 @@ export interface StatusState { reaction: string; muted: boolean; achievement: string; + /** Epoch ms the achievement was awarded; 0 when none. The statusline expires + * the trophy banner on this, otherwise it latches into the shared + * status.json and pins every session's bubble to the last unlock. */ + achievementAt: number; frames: string[]; compactFrames: string[]; minimalFrames: string[]; @@ -543,6 +547,7 @@ export function writeStatusState( reaction: "", muted: muted ?? false, achievement: achievement ?? "", + achievementAt: achievement ? Date.now() : 0, frames, compactFrames, minimalFrames, diff --git a/statusline/buddy-status.sh b/statusline/buddy-status.sh index 62404a8..b37e4a3 100755 --- a/statusline/buddy-status.sh +++ b/statusline/buddy-status.sh @@ -58,6 +58,9 @@ STARS=$(jq -r '.stars // ""' "$STATE" 2>/dev/null) SHINY=$(jq -r '.shiny // false' "$STATE" 2>/dev/null) REACTION_FILE="$BUDDY_STATE_DIR/reaction.$SID.json" ACHIEVEMENT=$(jq -r '.achievement // ""' "$STATE" 2>/dev/null) +# "absent" distinguishes a legacy status.json (no field at all) from an explicit +# 0, which means "no achievement pending" and must not render. +ACHIEVEMENT_AT=$(jq -r 'if has("achievementAt") then (.achievementAt // 0) else "absent" end' "$STATE" 2>/dev/null) LEVEL=$(jq -r '.level // 1' "$STATE" 2>/dev/null) MOOD=$(jq -r '.mood // "focused"' "$STATE" 2>/dev/null) @@ -229,10 +232,10 @@ DETECTED_COLS="$COLS" DETECTED_ROWS="$ROWS" # ─── Reaction bubble (with TTL check) ──────────────────────────────────────── +# The achievement banner is resolved further down, once REACTION_TTL is known — +# it expires on the same clock as a reaction. Without that it latches into the +# shared status.json and pins every session's bubble to the last trophy. BUBBLE="" -if [ -n "$ACHIEVEMENT" ] && [ "$ACHIEVEMENT" != "null" ] && [ "$ACHIEVEMENT" != "" ]; then - BUBBLE=$'\xf0\x9f\x8f\x86'" $ACHIEVEMENT" -fi REACTION_TTL=900 INNER_W=44 MARGIN=8 @@ -307,6 +310,25 @@ _sweep_expired_reactions() { } _sweep_expired_reactions + +# Achievement banner: shown only while fresh. ACHIEVEMENT_AT is epoch ms, written +# alongside the name by writeStatusState. A legacy status.json without the field +# (pre-upgrade, or a snapshot fixture) is treated as fresh — the next write from +# the server backfills it. +if [ -n "$ACHIEVEMENT" ] && [ "$ACHIEVEMENT" != "null" ]; then + ACH_FRESH=1 + if [ "$REACTION_TTL" -gt 0 ] 2>/dev/null && [ "$ACHIEVEMENT_AT" != "absent" ]; then + case "$ACHIEVEMENT_AT" in + ''|*[!0-9]*) ACH_FRESH=0 ;; + *) + ACH_AGE=$(( ($(date +%s) * 1000 - ACHIEVEMENT_AT) / 1000 )) + [ "$ACH_AGE" -ge "$REACTION_TTL" ] && ACH_FRESH=0 + ;; + esac + fi + [ "$ACH_FRESH" -eq 1 ] && BUBBLE=$'\xf0\x9f\x8f\x86'" $ACHIEVEMENT" +fi + REACTION=$(jq -r '.reaction // ""' "$REACTION_FILE" 2>/dev/null) if [ -n "$REACTION" ] && [ "$REACTION" != "null" ] && [ "$REACTION" != "" ]; then FRESH=0 diff --git a/statusline/buddy-status.test.ts b/statusline/buddy-status.test.ts index 5ed3576..8897f00 100644 --- a/statusline/buddy-status.test.ts +++ b/statusline/buddy-status.test.ts @@ -584,3 +584,63 @@ describe("statusline density", () => { expect(narrow.stdout.toString()).not.toContain("long-reaction-text"); }); }); + +describe("achievement banner expiry", () => { + function writeStatus(stateDir: string, extra: Record) { + writeFileSync(join(stateDir, "status.json"), JSON.stringify({ + name: "Nimbus", + rarity: "common", + stars: "★", + shiny: false, + reaction: "", + level: 1, + mood: "focused", + frames: [" art"], + frameSequence: [0], + ...extra, + })); + } + + test("shows a freshly awarded achievement", () => { + const { configDir, stateDir } = createStatuslineFixture({ reactionTTL: 900 }); + writeStatus(stateDir, { achievement: "🕊️ Diplomat", achievementAt: Date.now() }); + + const result = runStatusline(configDir); + + expect(result.status).toBe(0); + expect(result.stdout.toString()).toContain("Diplomat"); + }); + + test("drops an achievement older than the reaction TTL", () => { + const { configDir, stateDir } = createStatuslineFixture({ reactionTTL: 900 }); + writeStatus(stateDir, { + achievement: "🕊️ Diplomat", + achievementAt: Date.now() - 901_000, + }); + + const result = runStatusline(configDir); + + expect(result.status).toBe(0); + expect(result.stdout.toString()).not.toContain("Diplomat"); + }); + + test("does not render a banner when achievementAt is zeroed", () => { + const { configDir, stateDir } = createStatuslineFixture({ reactionTTL: 900 }); + writeStatus(stateDir, { achievement: "🕊️ Diplomat", achievementAt: 0 }); + + const result = runStatusline(configDir); + + expect(result.status).toBe(0); + expect(result.stdout.toString()).not.toContain("Diplomat"); + }); + + test("treats a legacy status.json without achievementAt as fresh", () => { + const { configDir, stateDir } = createStatuslineFixture({ reactionTTL: 900 }); + writeStatus(stateDir, { achievement: "🕊️ Diplomat" }); + + const result = runStatusline(configDir); + + expect(result.status).toBe(0); + expect(result.stdout.toString()).toContain("Diplomat"); + }); +}); From ee7ad8c7ba02730bd95983c9afc9b0e12341f649 Mon Sep 17 00:00:00 2001 From: Ramiro Rivera Date: Tue, 28 Jul 2026 05:09:13 +0200 Subject: [PATCH 2/2] fix(statusline): never render a zeroed achievementAt under reactionTTL=0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review catch (Copilot, Devin): the freshness gate was skipped wholesale when reactionTTL=0, so an explicit achievementAt of 0 still rendered — contradicting the field's documented "nothing pending" meaning and re-enabling cross-session latching for TTL=0 users. Validity and age are now separate gates. A zeroed or malformed achievementAt never renders regardless of TTL; reactionTTL=0 disables expiry only, so a validly stamped achievement still persists as it does for reactions. Both cases are covered by tests. The zeroed-under-TTL=0 case was confirmed failing against the previous commit's script before the fix. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01H2GXsqpMvUhxBSNabSqZdk --- statusline/buddy-status.sh | 14 ++++++++++---- statusline/buddy-status.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/statusline/buddy-status.sh b/statusline/buddy-status.sh index b37e4a3..b8d1c15 100755 --- a/statusline/buddy-status.sh +++ b/statusline/buddy-status.sh @@ -315,14 +315,20 @@ _sweep_expired_reactions # alongside the name by writeStatusState. A legacy status.json without the field # (pre-upgrade, or a snapshot fixture) is treated as fresh — the next write from # the server backfills it. +# +# Validity and age are separate gates on purpose. A zeroed or malformed +# achievementAt means "nothing pending" and must never render, including under +# reactionTTL=0 — that opt-out disables *expiry*, not the field's meaning. if [ -n "$ACHIEVEMENT" ] && [ "$ACHIEVEMENT" != "null" ]; then ACH_FRESH=1 - if [ "$REACTION_TTL" -gt 0 ] 2>/dev/null && [ "$ACHIEVEMENT_AT" != "absent" ]; then + if [ "$ACHIEVEMENT_AT" != "absent" ]; then case "$ACHIEVEMENT_AT" in - ''|*[!0-9]*) ACH_FRESH=0 ;; + ''|0|*[!0-9]*) ACH_FRESH=0 ;; *) - ACH_AGE=$(( ($(date +%s) * 1000 - ACHIEVEMENT_AT) / 1000 )) - [ "$ACH_AGE" -ge "$REACTION_TTL" ] && ACH_FRESH=0 + if [ "$REACTION_TTL" -gt 0 ] 2>/dev/null; then + ACH_AGE=$(( ($(date +%s) * 1000 - ACHIEVEMENT_AT) / 1000 )) + [ "$ACH_AGE" -ge "$REACTION_TTL" ] && ACH_FRESH=0 + fi ;; esac fi diff --git a/statusline/buddy-status.test.ts b/statusline/buddy-status.test.ts index 8897f00..3c840d9 100644 --- a/statusline/buddy-status.test.ts +++ b/statusline/buddy-status.test.ts @@ -634,6 +634,29 @@ describe("achievement banner expiry", () => { expect(result.stdout.toString()).not.toContain("Diplomat"); }); + test("never renders a zeroed achievementAt even when reactionTTL disables expiry", () => { + const { configDir, stateDir } = createStatuslineFixture({ reactionTTL: 0 }); + writeStatus(stateDir, { achievement: "\u{1F54A}\uFE0F Diplomat", achievementAt: 0 }); + + const result = runStatusline(configDir); + + expect(result.status).toBe(0); + expect(result.stdout.toString()).not.toContain("Diplomat"); + }); + + test("reactionTTL=0 keeps a validly stamped achievement from expiring", () => { + const { configDir, stateDir } = createStatuslineFixture({ reactionTTL: 0 }); + writeStatus(stateDir, { + achievement: "\u{1F54A}\uFE0F Diplomat", + achievementAt: Date.now() - 86_400_000, + }); + + const result = runStatusline(configDir); + + expect(result.status).toBe(0); + expect(result.stdout.toString()).toContain("Diplomat"); + }); + test("treats a legacy status.json without achievementAt as fresh", () => { const { configDir, stateDir } = createStatuslineFixture({ reactionTTL: 900 }); writeStatus(stateDir, { achievement: "🕊️ Diplomat" });