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.
- package/CHANGELOG.md +324 -0
- package/dist/build-meta.json +84 -57
- package/dist/index.mjs +224 -96
- package/dist/index.mjs.map +4 -4
- package/docs/decisions/2026-07-26-c6-mascot-visibility-not-wired.md +84 -0
- package/package.json +1 -2
- package/skills/distill-persona/SKILL.md +83 -145
- package/skills/distill-persona/references/cross-skill-differentiation.md +12 -0
- package/skills/distill-persona/references/description-discipline.md +6 -0
- package/skills/distill-persona/references/diagnostic-path.md +25 -0
- package/skills/distill-persona/references/fidelity-rubric.md +19 -0
- package/skills/distill-persona/references/field-models.md +20 -0
- package/skills/distill-persona/references/optional-body-sections.md +9 -0
- package/skills/distill-persona/references/registry-routing.md +11 -0
- package/skills/distill-persona/references/self-upgrade-directive.md +20 -0
- package/skills/distill-persona/references/taste-principles.md +8 -0
- package/skills/distill-persona/references/topic-variant.md +13 -0
- package/skills/distill-persona/references/update-mode.md +7 -0
- package/skills/distill-persona/scripts/validate-run.mjs +297 -0
- package/skills/distill-software/SKILL.md +174 -90
- package/skills/research/SKILL.md +1 -1
- package/src/extension/crew-cleanup.ts +18 -1
- package/src/extension/crew-vibes/index.ts +11 -2
- package/src/extension/register.ts +1 -1
- package/src/extension/registration/command-registration.ts +1 -0
- package/src/extension/registration/commands.ts +7 -3
- package/src/extension/registration/lifecycle-handlers.ts +1 -3
- package/src/extension/registration/ui.ts +4 -0
- package/src/extension/registration/viewers.ts +3 -0
- package/src/extension/team-tool/run.ts +7 -6
- package/src/runtime/chain-runner.ts +3 -2
- package/src/runtime/pipeline-runner.ts +8 -7
- package/src/ui/live-run-sidebar.ts +7 -13
- package/src/ui/loaders.ts +6 -176
- package/src/ui/mascot.ts +25 -10
- package/src/ui/render-coalescer.ts +9 -0
- package/src/ui/render-scheduler.ts +60 -6
- package/src/ui/run-dashboard.ts +12 -21
- package/src/ui/run-snapshot-cache.ts +10 -11
- package/src/ui/shared-overlay-scheduler.ts +96 -0
- package/src/ui/terminal-status.ts +5 -0
- package/src/ui/widget/index.ts +48 -16
- package/src/ui/widget/widget-types.ts +0 -1
- package/assets/runner-spritesheet.png +0 -0
|
@@ -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 =
|
|
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 =
|
|
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
|
|
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: ${
|
|
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
|
|
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: ${
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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: ${
|
|
184
|
+
message: `Stage '${stage.name}' failed: ${errMsg}`,
|
|
184
185
|
data: {
|
|
185
186
|
stageIndex: i,
|
|
186
187
|
stageName: stage.name,
|
|
187
188
|
duration,
|
|
188
|
-
error:
|
|
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:
|
|
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:
|
|
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:
|
|
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 {
|
|
12
|
-
import {
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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 <
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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 <
|
|
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 <
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
},
|
|
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
|
-
//
|
|
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.
|
|
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
|
}
|