/** * Idle badge — WEBGPT-IDLE-BADGE-v1 (verdict #26). * * A lightweight per-session, always-on footer line showing the three facts a * user actually scans for before trusting a handoff: browser running, which * ChatGPT conversation this pi thread is bound to, and whether that page is * currently open. It is a BADGE, not a monitoring subsystem: * * - no timers / heartbeats / polling — refreshed only on lifecycle events * (session_start, turn_start) and after BRB operations that change state; * - two layers only: IDLE (the badge) and TRANSIENT (task progress text). * A transient status is never overwritten by a badge refresh; when the task * ends the badge is restored instead of blanking the line; * - page-not-open is a recoverable condition (the bridge reopens the durable * conversation), shown as "页签—", never as an error; * - "浏览器×" means the automation browser was observed not running; * "状态?" means the observation itself failed — never claim a health we did * not measure. */ export interface IdleBadgeFacts { /** Observed automation-browser state (false: observed down). */ browserRunning: boolean; /** Bound conversation id (short form, caller decides the prefix length). */ conversationId: string | null; /** * Whether the bound conversation's tab is currently open. * null = not observable (browser down / query failed). */ pageOpen: boolean | null; /** * The binding exists but has no durable conversation id yet (auto-created, waiting for * the first message to establish one). * * BUG-061 ruling: without this the badge rendered that state as 未绑定, contradicting * `/brb status`, which says PROVISIONAL. A provisional binding is a real binding, not * the absence of one -- and during the BUG-060 incident the badge said "not bound" for * a whole thread whose every operation was failing on that very binding. */ provisional?: boolean; } /** * The badge text for a set of observed facts. * * Cases (frozen): * healthy BRB · 浏览器✓ · · 页签✓ * tab absent BRB · 浏览器✓ · · 页签— * browser down BRB · 浏览器× · (page unobservable) * unbound BRB · 浏览器✓ · 未绑定 * observation BRB · 状态? · (fails closed, never a fake health) * observation + BRB · 状态? (nothing measured at all) * * Appended by ruling (BUG-061), not a rewrite of the above: * provisional BRB · 浏览器✓ · 准备中 (binding exists, no durable id yet) */ export function formatIdleStatus(facts: IdleBadgeFacts): string { if (!facts.browserRunning && facts.pageOpen === null && facts.conversationId === null) { // Observation failed outright — we cannot even say the browser is down. return "BRB · 状态?"; } if (!facts.browserRunning) { return `BRB · 浏览器×${facts.conversationId ? ` · ${facts.conversationId}` : ""}`; } if (!facts.conversationId) { // Branch priority is unchanged: browser-down and observation-failure are already // handled above, so this only decides the wording of a live, id-less binding. return facts.provisional ? "BRB · 浏览器✓ · 准备中" : "BRB · 浏览器✓ · 未绑定"; } if (facts.pageOpen === null) { return `BRB · 状态? · ${facts.conversationId}`; } return `BRB · 浏览器✓ · ${facts.conversationId} · 页签${facts.pageOpen ? "✓" : "—"}`; } /** * Two-layer per-session status machine. * * `setStatus` is injected so the module stays pure (testable without pi); the * host wires it to `ctx.ui.setStatus?.("brb", text)`. The registry keys by * session id so parallel pi threads never share a badge state. */ export interface IdleBadge { /** * Cache new idle text and publish it ONLY when no transient status is on * screen (a refresh during a task must never clobber "等待 ChatGPT 方案…"). */ refresh(idleText: string): void; /** Show task progress; the badge stays cached underneath. */ transient(text: string): void; /** Task finished/errored: restore the last cached badge (or blank if none). */ restore(): void; /** Session is over: forget state and blank the line for good. */ clear(): void; } export function createIdleBadge(setStatus: (text: string | undefined) => void): IdleBadge { let idleText: string | null = null; let transient = false; return { refresh(idleTextNext: string): void { idleText = idleTextNext; if (!transient) setStatus(idleText); }, transient(text: string): void { transient = true; setStatus(text); }, restore(): void { transient = false; setStatus(idleText ?? undefined); }, clear(): void { transient = false; idleText = null; setStatus(undefined); }, }; } export function createIdleBadgeRegistry( setStatus: (sessionId: string, text: string | undefined) => void, ): { badgeFor(sessionId: string): IdleBadge; drop(sessionId: string): void; } { const badges = new Map(); return { badgeFor(sessionId: string): IdleBadge { let badge = badges.get(sessionId); if (!badge) { badge = createIdleBadge((text) => setStatus(sessionId, text)); badges.set(sessionId, badge); } return badge; }, drop(sessionId: string): void { badges.get(sessionId)?.clear(); badges.delete(sessionId); }, }; }