pi-crew 0.9.50 → 0.9.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ # C6 — mascot visibility gate: NOT WIRED (closed)
2
+
3
+ **Date:** 2026-07-26
4
+ **Status:** CLOSED — gate kept (tested), wiring intentionally NOT done
5
+ **Supersedes / closes:** finding C6 in `reports/ui-animation-audit-2026-07-24.md`
6
+
7
+ ## Context
8
+
9
+ UI-animation-audit finding **C6** (LOW): `src/ui/mascot.ts` `tick()` calls
10
+ `requestRender()` unconditionally, even when the mascot overlay is obscured by
11
+ another overlay on top. The audit suggested adding a visibility flag
12
+ (`setVisible()`), gated by a note: *"needs visibility flag infrastructure;
13
+ bounded by 7s auto-close so low priority."*
14
+
15
+ The `setVisible()` gate was implemented + unit-tested (`mascot.ts:440`,
16
+ `test/unit/mascot.test.ts` "AnimatedMascot does not requestRender when
17
+ invisible (C6)"). The remaining question was whether to **wire** it — i.e.,
18
+ have a caller invoke `setVisible(false)` when the mascot is obscured.
19
+
20
+ ## Investigation
21
+
22
+ Read the pi source (`earendil-works/pi` @ `cee5ff75`) to determine whether an
23
+ overlay component can know it is obscured:
24
+
25
+ - **`ctx.ui.custom()` exposes `onHandle?: (handle: OverlayHandle) => void`**
26
+ (`packages/coding-agent/.../interactive-mode.ts:2448`). The `OverlayHandle`
27
+ offers `isFocused()` — a usable *proxy* for "not obscured" (a capturing
28
+ overlay loses focus when another opens on top). So wiring IS technically
29
+ feasible (~5 lines: `onHandle` → `mascot.setOverlayHandle(h)`; `tick()` sets
30
+ `this.visible = !h || h.isFocused()`).
31
+ - **BUT pi composites ALL non-hidden overlays** (`packages/tui/src/tui.ts:1044`
32
+ filters `visibleEntries`, then `component.render()` at `:1054` for each).
33
+ `isOverlayVisible` checks only the `hidden` flag + a terminal-dimension
34
+ callback — **it does not account for obscuring.** So when the mascot is
35
+ obscured, the host STILL calls `mascot.render()` (the output is computed then
36
+ painted over by the overlay above).
37
+
38
+ ## Decision
39
+
40
+ **Do NOT wire C6.** Keep the tested `setVisible()` gate (harmless, ready if pi
41
+ ever exposes a true visibility signal), but do not add the `onHandle`/`isFocused`
42
+ wiring.
43
+
44
+ ## Rationale
45
+
46
+ C6 does not save the work it appears to target:
47
+
48
+ | Aspect | Saved by C6 wiring? |
49
+ |---|---|
50
+ | Mascot's `render()` (the actual paint work — ~31-col image) | **No** — host calls it during compositing regardless (`visibleEntries` includes the non-hidden mascot) |
51
+ | Mascot's animation state advancement in `tick()` | **No** — the gate is on `requestRender` only; the animation computation still runs |
52
+ | Mascot's `requestRender()` pokes | Yes — but these are **cheap + render-coalesced + redundant** whenever the obscuring overlay is itself active (dashboard/settings/mailbox all drive their own repaints) |
53
+
54
+ The only scenario where C6 saves a *real* repaint is: mascot obscured by a
55
+ **static, non-render-requesting** overlay. That is rare, bounded to the
56
+ mascot's **7 s auto-close** window, and even there the `render()` work still
57
+ runs. Net measurable benefit: near zero.
58
+
59
+ Contrast with the sibling findings that DID land: **C1** (30→10 fps) cut
60
+ repaint pressure 3× on *every* mascot frame and fixed Windows flicker; **C3**
61
+ (shared scheduler) cut 9→3 subscriptions/timers and per-event `schedule()` CPU
62
+ in active runs. C6 is an order of magnitude below either.
63
+
64
+ ## Consequences
65
+
66
+ - The `setVisible()` gate stays in `mascot.ts` (tested, documented here as
67
+ intentionally unwired). Future callers can use it if a true visibility signal
68
+ becomes available.
69
+ - The **correct** fix for "render the mascot when obscured" is a **pi-core
70
+ compositing optimization**: skip `render()` for overlays fully occluded by a
71
+ higher opaque overlay. That belongs upstream in `packages/tui/src/tui.ts`
72
+ (the `visibleEntries` compositing loop), not in pi-crew. Track as a pi
73
+ upstream suggestion, not a pi-crew task.
74
+ - `isFocused()` is an imperfect proxy anyway (a *non-capturing* overlay on top
75
+ would not steal focus, so the mascot would stay "focused" while painted over
76
+ — a false-negative). Wiring it would trade a rare wasted repaint for a
77
+ semantic mismatch; not worth it.
78
+
79
+ ## Verification
80
+
81
+ - `setVisible()` gate: `test/unit/mascot.test.ts` → 7/7 pass (incl. the C6
82
+ "does not requestRender when invisible" test).
83
+ - No production caller of `setVisible()` exists (grep confirms); the gate is
84
+ inert but correct.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.9.50",
3
+ "version": "0.9.51",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -9,6 +9,9 @@ triggers:
9
9
  - "how does this codebase do things"
10
10
  - "distill software expertise"
11
11
  - "distill [repo] conventions"
12
+ - "distill [source] to [target]"
13
+ - "port [source] features to [target]"
14
+ - "bring [source] capabilities to [target]"
12
15
  ---
13
16
 
14
17
  # distill-software
@@ -120,6 +123,11 @@ Ask (defaults provided; never block value):
120
123
  4. **Cost tier** (quote before Phase 1): quick (3 streams) / standard (6+extra) / deep (full archive). Codebase sweep scales with repo size.
121
124
  5. **Ethics** (engineer flavor, living non-public colleague): consent gate — require subject-provided material + consent flag (inherited M-F3).
122
125
  6. **Decomposition for large targets** (Core Principle #6 — decide HERE): if the repo is large (>200 files OR >5 subsystems/packages), decompose into sub-targets and distill each, then merge. Never ôm đồm (take it all at once). Decompose by **package/subsystem** (each gets its own coverage-manifest + 3-empty-rounds gate); then sweep the **cross-cutting** conventions (the ones spanning packages). Record the decomposition tree in `DISTILLATION-PROCESS-CHECKLIST.md`. Recursive if a sub-package is still large.
126
+ 7. **🔴 Transfer vs Audit mode** (learned from real runs: audit-only distillation produced 0 visible features for the target). Default = **Transfer** (user says "distill X to Y" → they want Y to GAIN capabilities from X). Two modes:
127
+ - **Transfer mode** ⭐ (default for "distill X to/about/into Y"): enumerate source **CAPABILITIES** (features, not files) → compare against target **CAPABILITY DEPTH** (not surface) → apply the gaps. Deliverable = visible/behavioral new features in target. "The user can SEE or EXPERIENCE the difference."
128
+ - **Audit mode**: enumerate source conventions → compare against target surface → apply pattern refactors/lint rules. Deliverable = cleaner code, invisible refactors.
129
+ - **Detection**: "distill X to/about/into Y" / "port X features" / "bring X to Y" → **Transfer**. "audit X conventions" / "how does X do Y" / "what can Y learn from X's conventions" → **Audit**. **When in doubt → Transfer** (audit is a subset — transfer includes convention adoption as a side effect).
130
+ - **Decomposition follows mode**: Transfer mode decomposes by **CAPABILITY BUCKET** (terminal/PTY, plugin system, markdown rendering, routing) NOT by directory (`components/`, `lib/`). Audit mode decomposes by package/subsystem (existing Principle #6).
123
131
 
124
132
  ## Phase 0.5 — ANALYZE TARGET
125
133
 
@@ -208,7 +216,8 @@ Mỗi verified model đi qua 3-chiều filter against the TARGET (KHÔNG phải
208
216
  ❌ KHÔNG → NECESSITY: target CẦN không?
209
217
  ❌ KHÔNG CẦN → SKIP (complexity vô ích).
210
218
  ✅ CẦN → ADOPT (thêm mới).
211
- ✅ CÓ → QUALITY COMPARISON:
219
+ ✅ CÓ → **🔴 CAPABILITY DEPTH check**: "target has a similar SURFACE" ≠ "target has the same CAPABILITY". Surface match (target has an extension-point system) can mask a capability gap (target lacks route-level extensibility — the distinctive feature). Before SKIP on PRESENCE=yes, verify the target has the FULL capability at the FEATURE level, not just a similarly-named file/module. If target has a SUBSET → the missing subset is an ADOPT candidate (transfer the gap), NOT a SKIP.
220
+ Then QUALITY COMPARISON:
212
221
  Source TỐT HƠN → IMPROVE (thay bằng cách source).
213
222
  Source BẰNG/TỆ → SKIP.
214
223
  COMPLEMENTARY → MERGE.
@@ -294,6 +303,12 @@ Mỗi TO-APPLY item → HOW to apply, then APPLY. Plan channels: AGENTS.md updat
294
303
  (e) Mỗi destructive action cần confirmation riêng.
295
304
  (f) Atomic writes (temp sibling + rename) — không ghi dở.
296
305
  - **APPLY-LOG.md**: every edit recorded (file:line + before→after + verification). ≥3 items required before "done". This is THE deliverable proof — NOT a SKILL.md.
306
+ - **🔴 Post-APPLY integration checks (learned from real runs: artifact-level gates were ALL-GREEN while code had 12 runtime bugs; multiple review rounds were needed to find them):**
307
+ (g) **WIRED** — is the applied code actually CALLED by existing code? `grep -rn '<exported-symbol>' target-src/` — if 0 hits outside the defining file, it's DEAD CODE. Wire it into a consumer or remove it. (Observed: a routing-merge function was exported but never called by the app shell; a component prop was added but no consumer passed it.)
308
+ (h) **VISIBLE DELTA** — can the user SEE or EXPERIENCE the difference? If the apply is a pure refactor (same behavior, different code structure), it is NOT a capability transfer — flag it. Transfer mode (Phase 0 #7) REQUIRES visible deltas; invisible refactors are Audit-mode artifacts.
309
+ (i) **RUNTIME** — does the feature WORK in the real system? Don't just run unit tests — run the BUILD (`tsc --noEmit`, `vite build`, `cargo build`) AND, if the feature spans runtime boundaries (server + client, gateway + web), test END-TO-END (start the server, hit the endpoint, verify the response). Unit tests passing ≠ feature working. (Observed: a component passed unit tests but crashed in a real browser — missing vendor API flags and browser-only globals that the test environment mocked away.)
310
+ (j) **API VERIFIED** — before USING any target API (component prop, function signature, config option), READ ITS SOURCE to confirm the API matches your usage. Do NOT assume props exist. (Observed: code used a component prop that doesn't exist in its interface; the TypeScript object-literal excess-property check was silently bypassed.) This is the APPLY-phase analog of Phase 1's "excavate, don't recall".
311
+ (k) **RESOURCE LIFECYCLE** — if the applied code spawns processes, opens connections, registers listeners, or creates timers: verify CLEANUP on ALL exit paths (close, dispose, kill, unsubscribe). A missing disconnect handler that kills child processes = orphan leak. (Observed: a server endpoint spawned shell commands but never killed them when the client disconnected — process leak.)
297
312
 
298
313
  ## Phase 4 — VERIFY TARGET IMPROVED (Fidelity + Darwin ratchet, inherited F2' + software edge)
299
314
 
@@ -343,6 +358,9 @@ Refuse to ship if ANY fails; iterate Phase 2→4 until green:
343
358
  - [ ] **Coverage manifest complete** (inherited exhaustive-sweep): every content-bearing part UNCOVERED→COVERED or `[UNREADABLE — reason]`; no dangling UNCOVERED rows at ship time (the codebase analog of persona's no-dangling-checklist rule).
344
359
  - [ ] **DISTILLATION-PROCESS-CHECKLIST.md present + every phase ✅** (no ⬜/⏳ dangling) — proves no phase skipped
345
360
  - [ ] **3-empty-rounds gate fired** for every sweep/synthesis phase (≥3 consecutive zero-new rounds recorded in the round log) — proves deep-dive wasn't cut short at 1-2 passes
361
+ - [ ] **🔴 Runtime verification**: full regression `npx vitest run` (or project test runner) passes with 0 failures — NOT just the new test file, the FULL suite. `npx tsc --noEmit` (or project typechecker) introduces 0 NEW errors. `vite build` / `cargo build` / `npm run build` succeeds.
362
+ - [ ] **🔴 Applied code is WIRED** — every new export/function/component is imported + called by ≥1 existing file (grep proof in APPLY-LOG). Dead code (exported but never imported) = failed apply.
363
+ - [ ] **🔴 VISIBLE DELTA** (Transfer mode only) — the user can see or experience the difference. Invisible refactors require Audit-mode justification.
346
364
 
347
365
  > **Tiered effort (#8)**: for a trivial distillation (single small file, <5 conventions) you may skip the costliest sub-step (the independent dual-agent fidelity re-score → self-score with the single-agent caveat) — but never skip the structural, V5-grep, coverage, or ungrep-ratio gates.
348
366
 
@@ -358,6 +376,11 @@ Refuse to ship if ANY fails; iterate Phase 2→4 until green:
358
376
  | 7 | Recency erasure (engineer reversed a stance) | Skill presents current-only view; hides evolution | preserve as temporal contradiction; "近期观点" prevails, old mentioned |
359
377
  | 8 | Orphaned operational scripts | Scripts exist but agent never invokes them → dead code | wire INTO Agentic Protocol Step 2 (F13) |
360
378
  | 9 | Single-source monoculture (domain skill) | One-source bias; no cross-verification | topic skills cite ≥3 independent sources |
379
+ | 10 | **Audit-only when user wanted Transfer** — "distill X to Y" produces invisible refactors (routing table, lint config) not visible features | User sees nothing new; 0 visible delta = failed transfer | Phase 0 #7 Transfer-vs-Audit routing (default Transfer); VISIBLE DELTA check in Phase 3 + ship-gate |
380
+ | 11 | **Surface-match SKIP masks capability gap** — target has an extension-point module → SKIP, but target lacks route-level extensibility (the distinctive capability) | Misses the highest-value transfers; filter is correct on paper, wrong in DEPTH | Phase 2.5 CAPABILITY DEPTH check: PRESENCE=yes → verify FULL capability at FEATURE level, not just surface |
381
+ | 12 | **Applied code is dead (never wired)** — a routing function is exported but never called; a component prop is added but no consumer passes it | APPLY-LOG says "applied" but target behavior unchanged; user sees nothing | Phase 3 (g) WIRED check (grep for usage); ship-gate WIRED assertion |
382
+ | 13 | **Unit tests pass but feature doesn't work** — tests mock the environment (jsdom lacks canvas/matchMedia/ResizeObserver) but the real browser needs them; a server endpoint has 0 integration test | Green CI, broken runtime; multiple review rounds needed to find bugs | Phase 3 (i) RUNTIME check (build + end-to-end); ship-gate runtime verification |
383
+ | 14 | **Missing resource cleanup** — spawned child processes, open WebSockets, registered listeners never cleaned up on disconnect/unmount | Orphaned processes accumulate; memory/connection leaks in production | Phase 3 (k) RESOURCE LIFECYCLE check (verify cleanup on ALL exit paths) |
361
384
 
362
385
  ## Self-containment
363
386
  This engine embeds its methodology inline (inherited from distill-persona). The methodology is self-contained (copy dir → runs); `language`+`distilled_against` make the extraction's staleness auditable.
@@ -8,8 +8,8 @@ import type { TeamTaskState } from "../state/types.ts";
8
8
  import { aggregateUsage, formatUsage } from "../state/usage.ts";
9
9
  import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
10
10
  import { pad, truncate } from "../utils/visual.ts";
11
- import { RenderScheduler } from "./render-scheduler.ts";
12
- import { runEventBusAsRenderScheduler } from "./run-event-bus.ts";
11
+ import type { OverlaySchedulerHandle } from "./shared-overlay-scheduler.ts";
12
+ import { registerOverlayScheduler } from "./shared-overlay-scheduler.ts";
13
13
  import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
14
14
  import { spinnerBucket, spinnerFrame } from "./spinner.ts";
15
15
  import { colorizeStatusGlyphs, iconForStatus } from "./status-colors.ts";
@@ -53,7 +53,7 @@ export class LiveRunSidebar {
53
53
  private readonly theme: CrewTheme;
54
54
  private readonly config: CrewUiConfig;
55
55
  private readonly unsubscribeTheme: () => void;
56
- private readonly renderScheduler: RenderScheduler;
56
+ private readonly schedulerHandle: OverlaySchedulerHandle;
57
57
  private readonly snapshotCache?: RunSnapshotCache;
58
58
  private cachedLines: string[] = [];
59
59
  private cachedWidth = 0;
@@ -82,15 +82,7 @@ export class LiveRunSidebar {
82
82
  // subscribing independently a single event triggered up to 9 callbacks
83
83
  // and ~150 invalidates/sec under load. The scheduler collapses bursts
84
84
  // into one debounced invalidate.
85
- this.renderScheduler = new RenderScheduler(
86
- runEventBusAsRenderScheduler(["run:state", "worker:lifecycle", "ui:invalidate"]),
87
- () => this.invalidate(),
88
- {
89
- debounceMs: 75,
90
- fallbackMs: 750,
91
- events: ["run:state", "worker:lifecycle", "ui:invalidate"],
92
- },
93
- );
85
+ this.schedulerHandle = registerOverlayScheduler(() => this.invalidate());
94
86
  }
95
87
 
96
88
  private buildSignature(
@@ -150,7 +142,7 @@ export class LiveRunSidebar {
150
142
  this.autoCloseTimeout = undefined;
151
143
  }
152
144
  this.unsubscribeTheme();
153
- this.renderScheduler.dispose();
145
+ this.schedulerHandle.dispose();
154
146
  }
155
147
 
156
148
  render(width: number): string[] {
package/src/ui/loaders.ts CHANGED
@@ -1,176 +1,6 @@
1
- import { pad, truncate } from "../utils/visual.ts";
2
- import { DynamicCrewBorder } from "./dynamic-border.ts";
3
- import type { CrewTheme } from "./theme-adapter.ts";
4
- import { asCrewTheme } from "./theme-adapter.ts";
5
-
6
- export interface BorderedLoaderOptions {
7
- message: string;
8
- cancellable?: boolean;
9
- frames?: string[];
10
- intervalMs?: number;
11
- minWidth?: number;
12
- onAbort?: () => void;
13
- }
14
-
15
- const DEFAULT_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
16
-
17
- export class CrewBorderedLoader {
18
- private readonly abortController = new AbortController();
19
- private readonly frameOptions: string[];
20
- private readonly intervalMs: number;
21
- private readonly minWidth: number;
22
- private readonly onAbort?: () => void;
23
- private theme: CrewTheme;
24
- private message: string;
25
- private lineCache = "";
26
- private width = 0;
27
- private startedAt = Date.now();
28
-
29
- constructor(_ui: unknown, themeLike: unknown, options: BorderedLoaderOptions) {
30
- const theme = asCrewTheme(themeLike);
31
- this.theme = theme;
32
- this.message = options.message;
33
- this.minWidth = Math.max(12, options.minWidth ?? 24);
34
- this.onAbort = options.onAbort;
35
- this.frameOptions = options.frames ?? DEFAULT_FRAMES;
36
- this.intervalMs = Math.max(40, options.intervalMs ?? 120);
37
- }
38
-
39
- private spinnerFrame(): string {
40
- if (this.frameOptions.length === 0) return "•";
41
- const elapsed = Date.now() - this.startedAt;
42
- const index = Math.floor(elapsed / this.intervalMs) % this.frameOptions.length;
43
- return this.frameOptions[Math.max(0, index)];
44
- }
45
-
46
- setMessage(message: string): void {
47
- this.message = message;
48
- }
49
-
50
- get signal(): AbortSignal {
51
- return this.abortController.signal;
52
- }
53
-
54
- handleInput(data: string): void {
55
- if (!this.onAbort || this.abortController.signal.aborted) return;
56
- if (data === "c" || data === "q" || data === "\u001b" || data === "\u0003") {
57
- this.abortController.abort();
58
- this.onAbort();
59
- }
60
- }
61
-
62
- render(width: number): string[] {
63
- if (width === this.width && this.lineCache) {
64
- return this.lineCache.split("\n");
65
- }
66
- const innerWidth = Math.max(this.minWidth - 4, 1);
67
- const contentWidth = Math.max(1, Math.min(width - 4, innerWidth));
68
- const frame = this.spinnerFrame();
69
- const loaderLine = ` ${frame} ${truncate(this.message, Math.max(1, contentWidth - 4))} `;
70
- const body = ` ${truncate(loaderLine, contentWidth - 2)} `;
71
- const inner = ` ${pad(body, contentWidth - 1)} `;
72
- const padWidth = Math.max(0, width - (contentWidth + 4));
73
- const leftRightPad = " ".repeat(Math.floor(padWidth / 2));
74
- const widthAwareInner = contentWidth + padWidth;
75
- const border = new DynamicCrewBorder(this.theme).render(widthAwareInner + 2)[0];
76
- const top = `${leftRightPad}${this.theme.fg("border", "┌")}${border}${this.theme.fg("border", "┐")}`;
77
- const line = `${leftRightPad}${this.theme.fg("border", "│")} ${truncate(inner, widthAwareInner)} ${this.theme.fg("border", "│")}`;
78
- const hint = `${leftRightPad}${this.theme.fg("border", "│")}${" ".repeat(widthAwareInner + 2)}${this.theme.fg("border", "│")}`;
79
- const bottom = `${leftRightPad}${this.theme.fg("border", "└")}${border}${this.theme.fg("border", "┘")}`;
80
- const lineWithHint = optionsHint(this.theme, this.message, widthAwareInner);
81
- this.width = width;
82
- const lines = [top, line, `${leftRightPad}│ ${pad(lineWithHint, widthAwareInner)} │`, hint, bottom];
83
- this.lineCache = lines.join("\n");
84
- return lines;
85
- }
86
-
87
- invalidate(): void {
88
- this.lineCache = "";
89
- this.width = 0;
90
- }
91
-
92
- dispose(): void {
93
- this.abortController.abort();
94
- }
95
- }
96
-
97
- export interface CountdownTimerOptions {
98
- timeoutMs: number;
99
- onTick: (seconds: number) => void;
100
- onExpire: () => void;
101
- }
102
-
103
- export class CountdownTimer {
104
- private readonly onExpire: () => void;
105
- private readonly onTick: (seconds: number) => void;
106
- private readonly startedAt: number;
107
- private readonly timeoutMs: number;
108
- private timer: ReturnType<typeof setTimeout> | undefined;
109
- private expired = false;
110
- private lastEmittedSeconds = -1;
111
-
112
- constructor(options: CountdownTimerOptions) {
113
- this.timeoutMs = Math.max(0, options.timeoutMs);
114
- this.onTick = options.onTick;
115
- this.onExpire = options.onExpire;
116
- this.startedAt = Date.now();
117
- this.lastEmittedSeconds = this.secondsLeft();
118
- this.onTick(this.lastEmittedSeconds);
119
- if (this.timeoutMs === 0) {
120
- this.emitExpire();
121
- return;
122
- }
123
- this.scheduleNextTick();
124
- }
125
-
126
- /**
127
- * Schedule the next tick via recursive setTimeout. Each tick re-emits the
128
- * current `secondsLeft()` only if it differs from the last emitted value
129
- * (lastEmittedSeconds guard). This makes the countdown correct even under
130
- * event-loop pressure: if the previous tick fired 1.2s late, the next
131
- * tick still emits the right value for the current second rather than
132
- * skipping it (the pre-fix `setInterval` could SKIP a second value when
133
- * the loop was busy, producing [3,2,0] instead of [3,2,1,0] in tests).
134
- */
135
- private scheduleNextTick(): void {
136
- this.timer = setTimeout(() => {
137
- const seconds = this.secondsLeft();
138
- if (seconds !== this.lastEmittedSeconds) {
139
- this.lastEmittedSeconds = seconds;
140
- this.onTick(seconds);
141
- }
142
- if (seconds <= 0) {
143
- this.emitExpire();
144
- return;
145
- }
146
- this.scheduleNextTick();
147
- }, 1000);
148
- // Defense-in-depth: never let the countdown timer keep the event loop
149
- // alive. If dispose() is missed (e.g. UI unmount race), the timer must
150
- // not block process exit.
151
- if (typeof this.timer.unref === "function") this.timer.unref();
152
- }
153
-
154
- private emitExpire(): void {
155
- if (this.expired) return;
156
- this.expired = true;
157
- this.dispose();
158
- this.onExpire();
159
- }
160
-
161
- private secondsLeft(): number {
162
- const remainingMs = this.startedAt + this.timeoutMs - Date.now();
163
- return Math.max(0, Math.ceil(remainingMs / 1000));
164
- }
165
-
166
- dispose(): void {
167
- if (this.timer === undefined) return;
168
- clearTimeout(this.timer);
169
- this.timer = undefined;
170
- }
171
- }
172
-
173
- function optionsHint(theme: CrewTheme, message: string, width: number): string {
174
- if (!message) return "";
175
- return truncate(theme.fg("muted", message), width);
176
- }
1
+ /**
2
+ * CrewBorderedLoader and CountdownTimer were removed (UI-animation-audit C9).
3
+ * They were dead production code — referenced only by test/unit/loaders.test.ts
4
+ * (now deleted) and never instantiated in src/ or re-exported from the public
5
+ * index. If a new loader component is needed, add it here.
6
+ */
package/src/ui/mascot.ts CHANGED
@@ -99,6 +99,7 @@ export class AnimatedMascot {
99
99
  private currentArminGrid: string[][];
100
100
  private effectState: EffectState = {};
101
101
  private effectDone = false;
102
+ private visible = true;
102
103
  private frame = 0;
103
104
  private effectPhase = 0;
104
105
  private gridVersion = 0;
@@ -199,7 +200,10 @@ export class AnimatedMascot {
199
200
  this.gridVersion++;
200
201
  }
201
202
  this.invalidate();
202
- this.requestRender?.();
203
+ // Only request a re-render when the mascot is visible (not obscured by
204
+ // another overlay such as the dashboard or live sidebar). This avoids
205
+ // pointless repaints on every animation frame while hidden.
206
+ if (this.visible) this.requestRender?.();
203
207
  }
204
208
 
205
209
  private tickArminEffect(): boolean {
@@ -428,6 +432,15 @@ export class AnimatedMascot {
428
432
  }
429
433
  }
430
434
 
435
+ /**
436
+ * Set whether the mascot is currently visible (not obscured by another
437
+ * overlay). When invisible, tick() skips requestRender so the animation
438
+ * does not trigger needless repaints while hidden.
439
+ */
440
+ setVisible(visible: boolean): void {
441
+ this.visible = visible;
442
+ }
443
+
431
444
  dispose(): void {
432
445
  this.doneGuard.called = true;
433
446
  if (this.interval) clearInterval(this.interval);
@@ -21,6 +21,13 @@ export interface RenderSchedulerOptions {
21
21
  * trigger a single cache invalidate instead of N. Set to 0 to disable.
22
22
  */
23
23
  invalidateCoalesceMs?: number;
24
+ /**
25
+ * R1: maximum number of consecutive catch-up renders the fallback loop
26
+ * emits while idle (no real external event) before it stops re-arming.
27
+ * Prevents a hung run from rendering forever. A real event resets the
28
+ * count and re-arms the loop. Defaults to 8.
29
+ */
30
+ maxIdleFallbackRenders?: number;
24
31
  }
25
32
 
26
33
  const DEFAULT_EVENTS = [
@@ -34,6 +41,9 @@ const DEFAULT_EVENTS = [
34
41
  "crew.mailbox.message",
35
42
  ];
36
43
 
44
+ /** R4: max exponent for flush re-entrancy backoff (delay = debounceMs * 2^exp). */
45
+ const CAP_BACKOFF_MAX_EXP = 5;
46
+
37
47
  /**
38
48
  * Coordinates UI renders with debounce + fallback polling.
39
49
  *
@@ -50,6 +60,7 @@ export class RenderScheduler {
50
60
  private readonly fallbackProvider: () => number;
51
61
  private readonly fallbackMs: number;
52
62
  private readonly invalidateCoalesceMs: number;
63
+ private readonly maxIdleFallbackRenders: number;
53
64
  private debounceTimer: ReturnType<typeof setTimeout> | undefined;
54
65
  private fallbackTimer: ReturnType<typeof setTimeout> | undefined;
55
66
  private invalidateTimer: ReturnType<typeof setTimeout> | undefined;
@@ -59,6 +70,10 @@ export class RenderScheduler {
59
70
  private lastEventAt = 0;
60
71
  private rendering = false;
61
72
  private pendingRender = false;
73
+ /** R1: consecutive idle fallback renders since the last real event. */
74
+ private idleFallbackRenders = 0;
75
+ /** R4: consecutive re-entrancy cap-hits — drives exponential backoff. */
76
+ private consecutiveCapHits = 0;
62
77
  private readonly unsubs: Array<() => void> = [];
63
78
 
64
79
  constructor(events: RenderSchedulerEventBus | undefined, render: () => void, options: RenderSchedulerOptions = {}) {
@@ -69,6 +84,7 @@ export class RenderScheduler {
69
84
  this.fallbackProvider = typeof fallback === "function" ? fallback : () => fallback;
70
85
  this.fallbackMs = typeof fallback === "number" ? fallback : 750;
71
86
  this.invalidateCoalesceMs = options.invalidateCoalesceMs ?? 50;
87
+ this.maxIdleFallbackRenders = options.maxIdleFallbackRenders ?? 8;
72
88
  for (const event of options.events ?? DEFAULT_EVENTS) this.subscribe(events, event);
73
89
  this.fallbackTimer = setTimeout(() => this.fallbackLoop(), this.currentFallbackMs());
74
90
  this.fallbackTimer.unref();
@@ -100,13 +116,25 @@ export class RenderScheduler {
100
116
  if (this.disposed) return;
101
117
  const fallbackMs = this.currentFallbackMs();
102
118
  if (Date.now() - this.lastEventAt < fallbackMs) {
103
- if (this.disposed) return;
119
+ // A real external event arrived recently — not idle. Keep polling.
120
+ this.idleFallbackRenders = 0;
104
121
  this.fallbackTimer = setTimeout(() => this.fallbackLoop(), fallbackMs);
105
122
  this.fallbackTimer.unref();
106
123
  return;
107
124
  }
108
- this.schedule();
109
- if (this.disposed) return;
125
+ // R1: truly idle (no real event for `fallbackMs`). `lastEventAt` is only
126
+ // advanced by real external events (schedule()), so this idle check stays
127
+ // honest — unlike the old code which called schedule() here and thereby
128
+ // reset lastEventAt, self-perpetuating a ~fallbackMs render loop forever.
129
+ if (this.idleFallbackRenders >= this.maxIdleFallbackRenders) {
130
+ // Sustained idleness — stop re-arming so a hung run does not render
131
+ // forever. A subsequent real event (schedule()) re-arms the loop.
132
+ this.fallbackTimer = undefined;
133
+ return;
134
+ }
135
+ // Emit one catch-up render WITHOUT advancing lastEventAt (no real event).
136
+ this.armDebouncedRender();
137
+ this.idleFallbackRenders += 1;
110
138
  this.fallbackTimer = setTimeout(() => this.fallbackLoop(), this.currentFallbackMs());
111
139
  this.fallbackTimer.unref();
112
140
  }
@@ -114,12 +142,30 @@ export class RenderScheduler {
114
142
  schedule(payload?: unknown): void {
115
143
  if (this.disposed) return;
116
144
  this.lastEventAt = Date.now();
145
+ this.idleFallbackRenders = 0;
146
+ this.consecutiveCapHits = 0;
117
147
  this.invalidate(payload);
148
+ this.armDebouncedRender();
149
+ // R1: if the fallback loop stopped itself after a sustained idle period,
150
+ // a real event means activity resumed — re-arm the safety-net loop.
151
+ if (!this.fallbackTimer) {
152
+ this.fallbackTimer = setTimeout(() => this.fallbackLoop(), this.currentFallbackMs());
153
+ this.fallbackTimer.unref();
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Arm (or re-arm) the debounce timer that triggers a single flush. Shared by
159
+ * real-event schedules, the fallback path, and the re-entrancy cap drain.
160
+ * `delay` lets the cap apply exponential backoff (R4) without touching
161
+ * `lastEventAt` or `onInvalidate` (those belong to real external events).
162
+ */
163
+ private armDebouncedRender(delay: number = this.debounceMs): void {
118
164
  if (this.debounceTimer) clearTimeout(this.debounceTimer);
119
165
  this.debounceTimer = setTimeout(() => {
120
166
  this.debounceTimer = undefined;
121
167
  this.flush();
122
- }, this.debounceMs);
168
+ }, delay);
123
169
  this.debounceTimer.unref();
124
170
  }
125
171
 
@@ -196,9 +242,17 @@ export class RenderScheduler {
196
242
  logInternalError("render-scheduler.render", error);
197
243
  } finally {
198
244
  this.rendering = false;
199
- // If we hit the iteration cap, schedule one more render to drain.
245
+ // R4: if we hit the re-entrancy cap with more work pending, drain it —
246
+ // but with exponential backoff on consecutive cap-hits so a pathological
247
+ // render() cannot sustain a tight debounceMs render loop. We re-arm via
248
+ // armDebouncedRender (not schedule()) so lastEventAt / onInvalidate are
249
+ // not perturbed by a pure re-entrancy drain.
200
250
  if (iterations >= 5 && this.pendingRender && !this.disposed) {
201
- this.schedule();
251
+ this.consecutiveCapHits += 1;
252
+ const exp = Math.min(this.consecutiveCapHits - 1, CAP_BACKOFF_MAX_EXP);
253
+ this.armDebouncedRender(this.debounceMs * 2 ** exp);
254
+ } else {
255
+ this.consecutiveCapHits = 0;
202
256
  }
203
257
  }
204
258
  }
@@ -20,8 +20,8 @@ import { renderTranscriptPane } from "./dashboard-panes/transcript-pane.ts";
20
20
  import { DynamicCrewBorder } from "./dynamic-border.ts";
21
21
  import { dashboardActionForKey } from "./keybinding-map.ts";
22
22
  import { HelpOverlay } from "./overlays/help-overlay.ts";
23
- import { RenderScheduler } from "./render-scheduler.ts";
24
- import { runEventBusAsRenderScheduler } from "./run-event-bus.ts";
23
+ import type { OverlaySchedulerHandle } from "./shared-overlay-scheduler.ts";
24
+ import { registerOverlayScheduler } from "./shared-overlay-scheduler.ts";
25
25
  import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
26
26
  import { spinnerBucket, spinnerFrame } from "./spinner.ts";
27
27
  import { applyStatusColor, colorizeStatusGlyphs, iconForStatus, type RunStatus } from "./status-colors.ts";
@@ -423,7 +423,7 @@ export class RunDashboard implements DashboardComponent {
423
423
  private cachedSignatureAt = 0;
424
424
  private cachedSignature = "";
425
425
  private readonly unsubscribeTheme: () => void;
426
- private readonly renderScheduler: RenderScheduler | undefined;
426
+ private readonly schedulerHandle: OverlaySchedulerHandle | undefined;
427
427
 
428
428
  constructor(
429
429
  runs: TeamRunManifest[],
@@ -466,21 +466,12 @@ export class RunDashboard implements DashboardComponent {
466
466
  const renderTick = (): void => {
467
467
  this.invalidateAndRender();
468
468
  };
469
- this.renderScheduler = new RenderScheduler(
470
- runEventBusAsRenderScheduler(["run:state", "worker:lifecycle", "ui:invalidate"]),
471
- renderTick,
472
- {
473
- debounceMs: 75,
474
- fallbackMs: 750,
475
- events: ["run:state", "worker:lifecycle", "ui:invalidate"],
476
- onInvalidate: () => {
477
- // Drop any cached signature — data underneath may have
478
- // changed so the next buildSignature() needs to recompute.
479
- this.cachedSignature = "";
480
- this.cachedSignatureAt = 0;
481
- },
482
- },
483
- );
469
+ this.schedulerHandle = registerOverlayScheduler(renderTick, () => {
470
+ // Drop any cached signature — data underneath may have
471
+ // changed so the next buildSignature() needs to recompute.
472
+ this.cachedSignature = "";
473
+ this.cachedSignatureAt = 0;
474
+ });
484
475
  // FIND-07: route theme changes through the scheduler so they coalesce
485
476
  // with run:state / worker:lifecycle / ui:invalidate bursts instead of
486
477
  // each firing their own immediate invalidate+requestRender.
@@ -498,8 +489,8 @@ export class RunDashboard implements DashboardComponent {
498
489
  * (defensive — currently always created in the constructor).
499
490
  */
500
491
  private scheduleRender(): void {
501
- if (this.renderScheduler) {
502
- this.renderScheduler.schedule();
492
+ if (this.schedulerHandle) {
493
+ this.schedulerHandle.schedule();
503
494
  return;
504
495
  }
505
496
  this.invalidateAndRender();
@@ -619,7 +610,7 @@ export class RunDashboard implements DashboardComponent {
619
610
 
620
611
  dispose(): void {
621
612
  this.unsubscribeTheme();
622
- this.renderScheduler?.dispose();
613
+ this.schedulerHandle?.dispose();
623
614
  }
624
615
 
625
616
  private selectedRunId(): string | undefined {