pi-crew 0.9.49 → 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.
Files changed (44) hide show
  1. package/CHANGELOG.md +324 -0
  2. package/dist/build-meta.json +84 -57
  3. package/dist/index.mjs +224 -96
  4. package/dist/index.mjs.map +4 -4
  5. package/docs/decisions/2026-07-26-c6-mascot-visibility-not-wired.md +84 -0
  6. package/package.json +1 -2
  7. package/skills/distill-persona/SKILL.md +83 -145
  8. package/skills/distill-persona/references/cross-skill-differentiation.md +12 -0
  9. package/skills/distill-persona/references/description-discipline.md +6 -0
  10. package/skills/distill-persona/references/diagnostic-path.md +25 -0
  11. package/skills/distill-persona/references/fidelity-rubric.md +19 -0
  12. package/skills/distill-persona/references/field-models.md +20 -0
  13. package/skills/distill-persona/references/optional-body-sections.md +9 -0
  14. package/skills/distill-persona/references/registry-routing.md +11 -0
  15. package/skills/distill-persona/references/self-upgrade-directive.md +20 -0
  16. package/skills/distill-persona/references/taste-principles.md +8 -0
  17. package/skills/distill-persona/references/topic-variant.md +13 -0
  18. package/skills/distill-persona/references/update-mode.md +7 -0
  19. package/skills/distill-persona/scripts/validate-run.mjs +297 -0
  20. package/skills/distill-software/SKILL.md +174 -90
  21. package/skills/research/SKILL.md +1 -1
  22. package/src/extension/crew-cleanup.ts +18 -1
  23. package/src/extension/crew-vibes/index.ts +11 -2
  24. package/src/extension/register.ts +1 -1
  25. package/src/extension/registration/command-registration.ts +1 -0
  26. package/src/extension/registration/commands.ts +7 -3
  27. package/src/extension/registration/lifecycle-handlers.ts +1 -3
  28. package/src/extension/registration/ui.ts +4 -0
  29. package/src/extension/registration/viewers.ts +3 -0
  30. package/src/extension/team-tool/run.ts +7 -6
  31. package/src/runtime/chain-runner.ts +3 -2
  32. package/src/runtime/pipeline-runner.ts +8 -7
  33. package/src/ui/live-run-sidebar.ts +7 -13
  34. package/src/ui/loaders.ts +6 -176
  35. package/src/ui/mascot.ts +25 -10
  36. package/src/ui/render-coalescer.ts +9 -0
  37. package/src/ui/render-scheduler.ts +60 -6
  38. package/src/ui/run-dashboard.ts +12 -21
  39. package/src/ui/run-snapshot-cache.ts +10 -11
  40. package/src/ui/shared-overlay-scheduler.ts +96 -0
  41. package/src/ui/terminal-status.ts +5 -0
  42. package/src/ui/widget/index.ts +48 -16
  43. package/src/ui/widget/widget-types.ts +0 -1
  44. package/assets/runner-spritesheet.png +0 -0
@@ -103,6 +103,9 @@ export async function openLiveConversation(
103
103
  invalidate() {
104
104
  /* overlay polls */
105
105
  },
106
+ dispose() {
107
+ overlay.dispose();
108
+ },
106
109
  };
107
110
  },
108
111
  {
@@ -15,6 +15,7 @@ import { assertCleanLeaderAsync, findGitRootAsync } from "../../worktree/worktre
15
15
  // eslint-disable-next-line @typescript-eslint/no-unused-vars -- type-only import for TS inference
16
16
  const _typeCheck: typeof ExecuteTeamRunFn = null as never as typeof ExecuteTeamRunFn;
17
17
 
18
+ import { errorMessage } from "../../utils/guards.ts";
18
19
  import { logInternalError } from "../../utils/internal-error.ts";
19
20
  import { resolveContainedPath, resolveRealContainedPath } from "../../utils/safe-paths.ts";
20
21
 
@@ -432,7 +433,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
432
433
  try {
433
434
  await assertCleanLeaderAsync(gitRoot);
434
435
  } catch (err) {
435
- const msg = err instanceof Error ? err.message : String(err);
436
+ const msg = errorMessage(err);
436
437
  return result(
437
438
  `${msg}\nCommit or stash changes before using worktree mode, or use workspaceMode: 'single'.`,
438
439
  { action: "run", status: "error" },
@@ -655,7 +656,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
655
656
  // Round-11 runtime fix: persist manifest with status=failed when runner throws
656
657
  // (e.g., script timeout, script syntax error, async failure). Previously the
657
658
  // manifest stayed at 'queued' indefinitely, leaving an orphan state file.
658
- const failureReason = runnerError instanceof Error ? runnerError.message : String(runnerError);
659
+ const failureReason = errorMessage(runnerError);
659
660
  const failedManifest = {
660
661
  ...dwfManifest,
661
662
  status: "failed" as const,
@@ -856,13 +857,13 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
856
857
  workspaceId: ctx.sessionId ?? ctx.cwd,
857
858
  });
858
859
  } catch (waitError: unknown) {
859
- const errorMessage = waitError instanceof Error ? waitError.message : String(waitError);
860
+ const waitErrMsg = errorMessage(waitError);
860
861
  return result(
861
862
  [
862
863
  `pi-crew run timed out or failed: ${updatedManifest.runId}`,
863
864
  `Team: ${team.name}`,
864
865
  `Workflow: ${workflow.name}`,
865
- `Error: ${errorMessage}`,
866
+ `Error: ${waitErrMsg}`,
866
867
  "",
867
868
  `Check status with: team status runId=${updatedManifest.runId}`,
868
869
  `State: ${updatedManifest.stateRoot}`,
@@ -970,13 +971,13 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
970
971
  workspaceId: ctx.sessionId ?? ctx.cwd,
971
972
  });
972
973
  } catch (waitError: unknown) {
973
- const errorMessage = waitError instanceof Error ? waitError.message : String(waitError);
974
+ const waitErrMsg = errorMessage(waitError);
974
975
  return result(
975
976
  [
976
977
  `pi-crew run timed out or failed: ${updatedManifest.runId}`,
977
978
  `Team: ${team.name}`,
978
979
  `Workflow: ${workflow.name}`,
979
- `Error: ${errorMessage}`,
980
+ `Error: ${waitErrMsg}`,
980
981
  "",
981
982
  `Check status with: team status runId=${updatedManifest.runId}`,
982
983
  `State: ${updatedManifest.stateRoot}`,
@@ -10,6 +10,7 @@
10
10
  * @see docs/pi-boomerang-integration-plan.md
11
11
  */
12
12
 
13
+ import { errorMessage } from "../utils/guards.ts";
13
14
  import type { ChainStep as DSLChainStep } from "./chain-parser.ts";
14
15
  import { parseChainDSL } from "./chain-parser.ts";
15
16
  import type { HandoffManager, HandoffSummary, TaskPacket, TaskResult } from "./handoff-manager.ts";
@@ -250,14 +251,14 @@ export class ChainRunner {
250
251
  });
251
252
  }
252
253
  } catch (error) {
253
- const errorMessage = error instanceof Error ? error.message : String(error);
254
+ const errMsg = errorMessage(error);
254
255
 
255
256
  stepResults.push({
256
257
  step: i + 1,
257
258
  name: step.name,
258
259
  outcome: "failure",
259
260
  duration: Date.now() - stepStart,
260
- error: errorMessage,
261
+ error: errMsg,
261
262
  });
262
263
 
263
264
  // Stop chain on failure unless configured to continue
@@ -1,5 +1,6 @@
1
1
  import { errors } from "../errors.ts";
2
2
  import { appendEventAsync, flushEventLogBuffer } from "../state/event-log.ts";
3
+ import { errorMessage } from "../utils/guards.ts";
3
4
  import type { WorkflowConfig } from "../workflows/workflow-config.ts";
4
5
  import { mapConcurrent } from "./parallel-utils.ts";
5
6
 
@@ -166,26 +167,26 @@ export class PipelineRunner {
166
167
  });
167
168
  } catch (error) {
168
169
  const duration = Date.now() - stageStartTime;
169
- const errorMessage = error instanceof Error ? error.message : String(error);
170
+ const errMsg = errorMessage(error);
170
171
 
171
172
  if (effectiveStopOnError) {
172
173
  stages.push({
173
174
  name: stage.name,
174
175
  status: "failed",
175
176
  results: [],
176
- error: errorMessage,
177
+ error: errMsg,
177
178
  duration,
178
179
  });
179
180
 
180
181
  await appendEventAsync(eventsPath, {
181
182
  type: "pipeline:stage_failed",
182
183
  runId,
183
- message: `Stage '${stage.name}' failed: ${errorMessage}`,
184
+ message: `Stage '${stage.name}' failed: ${errMsg}`,
184
185
  data: {
185
186
  stageIndex: i,
186
187
  stageName: stage.name,
187
188
  duration,
188
- error: errorMessage,
189
+ error: errMsg,
189
190
  },
190
191
  });
191
192
 
@@ -193,7 +194,7 @@ export class PipelineRunner {
193
194
  type: "pipeline:failed",
194
195
  runId,
195
196
  message: `Pipeline '${workflow.name}' failed at stage '${stage.name}'`,
196
- data: { failedStage: stage.name, error: errorMessage },
197
+ data: { failedStage: stage.name, error: errMsg },
197
198
  });
198
199
 
199
200
  return {
@@ -207,7 +208,7 @@ export class PipelineRunner {
207
208
  name: stage.name,
208
209
  status: "failed",
209
210
  results: [],
210
- error: errorMessage,
211
+ error: errMsg,
211
212
  duration,
212
213
  });
213
214
 
@@ -219,7 +220,7 @@ export class PipelineRunner {
219
220
  stageIndex: i,
220
221
  stageName: stage.name,
221
222
  duration,
222
- error: errorMessage,
223
+ error: errMsg,
223
224
  },
224
225
  });
225
226
  }
@@ -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[] {
@@ -270,10 +262,12 @@ export class LiveRunSidebar {
270
262
  if (isTerminal && !hasActiveAgents && !this.hasAutoClosed) {
271
263
  const autoCloseMs = this.config?.autoCloseDashboardMs ?? 3000;
272
264
  if (autoCloseMs > 0) {
265
+ if (this.autoCloseTimeout) clearTimeout(this.autoCloseTimeout);
273
266
  this.autoCloseTimeout = setTimeout(() => {
274
267
  this.hasAutoClosed = true;
275
268
  this.done(undefined);
276
269
  }, autoCloseMs);
270
+ this.autoCloseTimeout?.unref();
277
271
  lines.push(line(`auto-close in ${Math.round(autoCloseMs / 1000)}s…`, w));
278
272
  }
279
273
  }
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 {
@@ -226,7 +230,7 @@ export class AnimatedMascot {
226
230
  private tickTypewriter(): boolean {
227
231
  const state = this.effectState;
228
232
  if (state.pos === undefined) return true;
229
- for (let i = 0; i < 6; i++) {
233
+ for (let i = 0; i < 18; i++) {
230
234
  const row = Math.floor(state.pos / ARMIN_WIDTH);
231
235
  const x = state.pos % ARMIN_WIDTH;
232
236
  if (row >= ARMIN_DISPLAY_HEIGHT) return true;
@@ -239,9 +243,11 @@ export class AnimatedMascot {
239
243
  private tickScanline(): boolean {
240
244
  const state = this.effectState;
241
245
  if (state.row === undefined) return true;
242
- if (state.row >= ARMIN_DISPLAY_HEIGHT) return true;
243
- for (let x = 0; x < ARMIN_WIDTH; x++) this.currentArminGrid[state.row][x] = this.finalArminGrid[state.row][x];
244
- state.row++;
246
+ for (let step = 0; step < 3; step++) {
247
+ if (state.row >= ARMIN_DISPLAY_HEIGHT) return true;
248
+ for (let x = 0; x < ARMIN_WIDTH; x++) this.currentArminGrid[state.row][x] = this.finalArminGrid[state.row][x];
249
+ state.row++;
250
+ }
245
251
  return false;
246
252
  }
247
253
 
@@ -264,7 +270,7 @@ export class AnimatedMascot {
264
270
  break;
265
271
  }
266
272
  }
267
- drop.y++;
273
+ drop.y += 3;
268
274
  if (drop.y >= 0 && drop.y < ARMIN_DISPLAY_HEIGHT) {
269
275
  if (targetRow >= 0 && drop.y >= targetRow) {
270
276
  drop.settled = ARMIN_DISPLAY_HEIGHT - targetRow;
@@ -280,7 +286,7 @@ export class AnimatedMascot {
280
286
  private tickFade(): boolean {
281
287
  const state = this.effectState;
282
288
  if (!state.positions || state.idx === undefined) return true;
283
- for (let i = 0; i < 18; i++) {
289
+ for (let i = 0; i < 54; i++) {
284
290
  if (state.idx >= state.positions.length) return true;
285
291
  const [row, x] = state.positions[state.idx];
286
292
  this.currentArminGrid[row][x] = this.finalArminGrid[row][x];
@@ -299,7 +305,7 @@ export class AnimatedMascot {
299
305
  for (let row = Math.max(0, top); row <= Math.min(ARMIN_DISPLAY_HEIGHT - 1, bottom); row++) {
300
306
  for (let x = 0; x < ARMIN_WIDTH; x++) this.currentArminGrid[row][x] = this.finalArminGrid[row][x];
301
307
  }
302
- state.expansion++;
308
+ state.expansion += 3;
303
309
  return state.expansion > ARMIN_DISPLAY_HEIGHT;
304
310
  }
305
311
 
@@ -321,7 +327,7 @@ export class AnimatedMascot {
321
327
  }
322
328
  }
323
329
  }
324
- state.phase++;
330
+ state.phase += 3;
325
331
  return false;
326
332
  }
327
333
  // Restore final grid in-place
@@ -336,7 +342,7 @@ export class AnimatedMascot {
336
342
  private tickDissolve(): boolean {
337
343
  const state = this.effectState;
338
344
  if (!state.positions || state.idx === undefined) return true;
339
- for (let i = 0; i < 22; i++) {
345
+ for (let i = 0; i < 66; i++) {
340
346
  if (state.idx >= state.positions.length) return true;
341
347
  const [row, x] = state.positions[state.idx];
342
348
  this.currentArminGrid[row][x] = this.finalArminGrid[row][x];
@@ -426,6 +432,15 @@ export class AnimatedMascot {
426
432
  }
427
433
  }
428
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
+
429
444
  dispose(): void {
430
445
  this.doneGuard.called = true;
431
446
  if (this.interval) clearInterval(this.interval);
@@ -73,6 +73,15 @@ export class RenderCoalescer {
73
73
  this.#timerId = null;
74
74
  }
75
75
  this.#pending = false;
76
+ const dropped = this.#dropped;
77
+ this.#dropped = 0;
78
+ if (dropped > 0) {
79
+ try {
80
+ this.#onDrop(dropped);
81
+ } catch {
82
+ /* drop callback errors are non-fatal */
83
+ }
84
+ }
76
85
  this.#callback();
77
86
  }
78
87
 
@@ -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
  }