pi-crew 0.9.26 → 0.9.27

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 (59) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/NOTICE.md +14 -0
  3. package/assets/crew-vibes.ttf +0 -0
  4. package/assets/runner-spritesheet.png +0 -0
  5. package/dist/build-meta.json +255 -40
  6. package/dist/index.mjs +1564 -241
  7. package/dist/index.mjs.map +4 -4
  8. package/docs/perf/optimization-plan-2026-07-verified.md +396 -0
  9. package/package.json +9 -2
  10. package/scripts/bench-check.mjs +96 -0
  11. package/scripts/bench-cold-start.mjs +216 -0
  12. package/scripts/build-bundle.mjs +84 -0
  13. package/scripts/build-crew-vibes-font.py +328 -0
  14. package/scripts/check-all-skills.ts +294 -0
  15. package/scripts/check-bundle-staleness.mjs +97 -0
  16. package/scripts/check-conflict-markers.mjs +91 -0
  17. package/scripts/check-lazy-imports.mjs +28 -0
  18. package/scripts/install-crew-vibes-font.mjs +91 -0
  19. package/scripts/postinstall.mjs +47 -0
  20. package/scripts/profile-startup.mjs +125 -0
  21. package/scripts/release-smoke.mjs +74 -0
  22. package/scripts/run-bench.mjs +47 -0
  23. package/scripts/run-real-chain.ts +41 -0
  24. package/scripts/test-issue-29-crash.ts +111 -0
  25. package/scripts/test-issue-29-e2e.ts +183 -0
  26. package/scripts/test-issue-29-real-runtime.ts +330 -0
  27. package/scripts/test-issue-29-real-tasks.ts +387 -0
  28. package/scripts/test-issue-29-team-tool.ts +105 -0
  29. package/scripts/test-runner.mjs +74 -0
  30. package/scripts/verify-flicker-fix.ts +109 -0
  31. package/scripts/verify-skill.ts +550 -0
  32. package/scripts/watch-bundle.mjs +259 -0
  33. package/scripts/watchdog-harness.ts +119 -0
  34. package/src/agents/discover-agents.ts +6 -1
  35. package/src/config/defaults.ts +6 -0
  36. package/src/extension/crew-vibes/cat-frames.ts +18 -0
  37. package/src/extension/crew-vibes/config.ts +195 -0
  38. package/src/extension/crew-vibes/figures.ts +94 -0
  39. package/src/extension/crew-vibes/font-detect.ts +58 -0
  40. package/src/extension/crew-vibes/index.ts +396 -0
  41. package/src/extension/crew-vibes/provider-usage.ts +330 -0
  42. package/src/extension/crew-vibes/render.ts +206 -0
  43. package/src/extension/crew-vibes/speed.ts +286 -0
  44. package/src/extension/knowledge-injection.ts +12 -3
  45. package/src/extension/management.ts +23 -3
  46. package/src/extension/register.ts +7 -0
  47. package/src/runtime/child-pi.ts +117 -10
  48. package/src/runtime/crew-agent-records.ts +10 -3
  49. package/src/runtime/retry-executor.ts +4 -1
  50. package/src/runtime/task-runner/state-helpers.ts +9 -30
  51. package/src/runtime/team-runner.ts +5 -3
  52. package/src/state/atomic-write.ts +153 -49
  53. package/src/state/event-log.ts +16 -10
  54. package/src/state/locks.ts +7 -8
  55. package/src/state/mailbox.ts +15 -4
  56. package/src/state/state-store.ts +39 -10
  57. package/src/teams/discover-teams.ts +56 -1
  58. package/src/utils/safe-paths.ts +2 -1
  59. package/src/workflows/discover-workflows.ts +72 -1
@@ -0,0 +1,94 @@
1
+ import type { SpeedConfig } from "./config.ts";
2
+ import { isWebTerminal } from "./font-detect.ts";
3
+
4
+ /**
5
+ * Crew figures replacing the original cat glyphs from pi-speeed / pi-chonk.
6
+ *
7
+ * The working indicator uses braille spinner characters by default — the
8
+ * same characters pi uses for its built-in loading animation (loaders.ts
9
+ * DEFAULT_FRAMES). These render on ANY terminal with basic Unicode support,
10
+ * no custom font required.
11
+ *
12
+ * PUA glyphs (U+E700..U+E70F) from the bundled crew-vibes.ttf font are
13
+ * available as an opt-in alternative (set speed.indicatorStyle = "pua" in
14
+ * config). Requires the font to be installed via `npm run install:crew-font`
15
+ * AND a terminal that renders Private Use Area codepoints.
16
+ */
17
+
18
+ // ── Braille spinner frames (web terminal fallback) ───────────────────
19
+ // Standard Unicode braille spinner — same characters pi uses for its
20
+ // built-in loading animation. Renders on ANY terminal.
21
+ const BRAILLE_FRAMES: readonly string[] = [
22
+ "\u280B ", // ⠋
23
+ "\u2819 ", // ⠙
24
+ "\u2839 ", // ⠹
25
+ "\u2838 ", // ⠸
26
+ "\u283C ", // ⠼
27
+ "\u2834 ", // ⠴
28
+ "\u2826 ", // ⠦
29
+ "\u2827 ", // ⠧
30
+ "\u2807 ", // ⠇
31
+ "\u280F ", // ⠏
32
+ ] as const;
33
+
34
+ // ── PUA runner frames (opt-in) ────────────────────────────────────────
35
+ // 16 runner poses from the bundled crew-vibes.ttf font. Only usable when
36
+ // the font is installed AND the terminal renders PUA codepoints correctly.
37
+ // Each frame is one glyph + trailing space for constant 2-cell width.
38
+ const PUA_CREW_FRAMES: readonly string[] = [
39
+ "\uE700 ",
40
+ "\uE701 ",
41
+ "\uE702 ",
42
+ "\uE703 ",
43
+ "\uE704 ",
44
+ "\uE705 ",
45
+ "\uE706 ",
46
+ "\uE707 ",
47
+ "\uE708 ",
48
+ "\uE709 ",
49
+ "\uE70A ",
50
+ "\uE70B ",
51
+ "\uE70C ",
52
+ "\uE70D ",
53
+ "\uE70E ",
54
+ "\uE70F ",
55
+ ] as const;
56
+
57
+ /**
58
+ * Return the indicator frames for the given style.
59
+ *
60
+ * - `"braille"` (default): standard Unicode braille spinner, works everywhere
61
+ * - `"pua"`: runner-pose glyphs from crew-vibes.ttf, requires font install
62
+ */
63
+ export function crewFrames(style: "braille" | "pua" = "pua"): readonly string[] {
64
+ // Web terminals (gotty, wetty) cannot render PUA glyphs — use braille.
65
+ if (style === "pua" && isWebTerminal()) return BRAILLE_FRAMES;
66
+ return style === "pua" ? PUA_CREW_FRAMES : BRAILLE_FRAMES;
67
+ }
68
+
69
+ // Re-export PUA frames for backward compatibility and direct consumers.
70
+ export const RUN_CREW_FRAMES = PUA_CREW_FRAMES;
71
+
72
+ /**
73
+ * Map a tok/s reading to a working-indicator frame interval in ms.
74
+ * Ported from pi-speeed's runcatInterval: interval = scale / speed, clamped
75
+ * to [min, max]. A null/zero speed falls back to the default (idle) cadence.
76
+ */
77
+ export function intervalForSpeed(config: SpeedConfig, speed: number | null): number {
78
+ if (speed === null || !Number.isFinite(speed) || speed <= 0) return config.defaultIntervalMs;
79
+ return Math.max(config.minIntervalMs, Math.min(config.maxIntervalMs, Math.round(config.scale / speed)));
80
+ }
81
+
82
+ /**
83
+ * Pick the capacity stage index (0..levels-1) for a context-fill percent.
84
+ * Ported from pi-chonk's chonkIndex.
85
+ */
86
+ export function capacityIndex(percent: number | null | undefined, levels = 6): number {
87
+ if (percent === null || percent === undefined || !Number.isFinite(percent)) return 0;
88
+ return Math.max(0, Math.min(levels - 1, Math.floor((Math.max(0, Math.min(100, percent)) / 100) * levels)));
89
+ }
90
+
91
+ /** The last two stages are "danger" stages and get the error color. */
92
+ export function isDangerStage(index: number, levels: number): boolean {
93
+ return index >= Math.max(0, levels - 2);
94
+ }
@@ -0,0 +1,58 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir, platform } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * Detect whether the crew-vibes PUA font (U+E700..U+E70F) file exists
7
+ * on disk. This is a *best-effort* heuristic — file existence does NOT
8
+ * guarantee the terminal can render PUA glyphs (many terminals have their
9
+ * own font stacks). For reliable animation, braille fallback frames are
10
+ * used by default; PUA frames are only activated when the user explicitly
11
+ * enables them via config (speed.indicatorStyle = "pua").
12
+ */
13
+
14
+ function fontPath(): string {
15
+ const os = platform();
16
+ const home = homedir();
17
+ if (os === "darwin") return join(home, "Library", "Fonts", "crew-vibes.ttf");
18
+ if (os === "linux") return join(home, ".local", "share", "fonts", "crew-vibes.ttf");
19
+ if (os === "win32") {
20
+ const local = process.env.LOCALAPPDATA ?? join(home, "AppData", "Local");
21
+ return join(local, "Microsoft", "Windows", "Fonts", "crew-vibes.ttf");
22
+ }
23
+ return "";
24
+ }
25
+
26
+ let _hasFontFile: boolean | null = null;
27
+
28
+ /** Returns true when crew-vibes.ttf exists in the platform font directory. */
29
+ export function hasCrewFontFile(): boolean {
30
+ if (_hasFontFile !== null) return _hasFontFile;
31
+ const p = fontPath();
32
+ _hasFontFile = p !== "" && existsSync(p);
33
+ return _hasFontFile;
34
+ }
35
+
36
+ /** Returns true when running in a web-based terminal (gotty, wetty, etc.)
37
+ * where system fonts are not available for PUA rendering. */
38
+ export function isWebTerminal(): boolean {
39
+ if (process.env.GOTTY || process.env.WEBTERM) return true;
40
+ if (process.env.TERM === "dumb") return true;
41
+ try {
42
+ // Check current process and ancestors — gotty is often the grandparent
43
+ // (gotty → tmux → pi), so /proc/self/cgroup may not contain it.
44
+ let pid = process.pid;
45
+ for (let i = 0; i < 6 && pid > 1; i++) {
46
+ const cgroup = readFileSync(`/proc/${pid}/cgroup`, "utf8");
47
+ if (cgroup.includes("gotty") || cgroup.includes("wetty")) return true;
48
+ const match = cgroup.match(/\d+:.*:(.*)/);
49
+ // Read PPid from status
50
+ const status = readFileSync(`/proc/${pid}/status`, "utf8");
51
+ const ppid = status.match(/^PPid:\s+(\d+)/m);
52
+ pid = ppid ? Number.parseInt(ppid[1], 10) : 1;
53
+ }
54
+ } catch {
55
+ // not Linux or /proc not available
56
+ }
57
+ return false;
58
+ }
@@ -0,0 +1,396 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { type CrewVibesConfig, loadConfig, PROVIDER_STATUS_ID, saveConfig } from "./config.ts";
3
+ import { isWebTerminal } from "./font-detect.ts";
4
+ import { fetchProviderUsage, clearProviderUsageCache } from "./provider-usage.ts";
5
+ import { intervalForSpeed } from "./figures.ts";
6
+ import {
7
+ asCrewTheme,
8
+ clearVibesStatus,
9
+ crewIndicatorFrames,
10
+ formatSpeed,
11
+ getCapacityUsage,
12
+ renderCapacity,
13
+ renderProviderUsage,
14
+ renderSpeedFooter,
15
+ renderWorkingMessage,
16
+ setCapacityStatus,
17
+ setProviderStatus,
18
+ setSpeedStatus,
19
+ } from "./render.ts";
20
+ import { SpeedAnimator, SpeedTracker } from "./speed.ts";
21
+ import { CAT_FRAMES } from "./cat-frames.ts";
22
+
23
+ export const CREW_VIBES_STATUS_KEY = "pi-crew-vibes";
24
+
25
+ function isAssistantMessage(message: unknown): boolean {
26
+ return typeof message === "object" && message !== null && (message as { role?: string }).role === "assistant";
27
+ }
28
+
29
+ function assistantUsageOutput(message: unknown): number | undefined {
30
+ const usage = (message as { usage?: { output?: unknown } }).usage;
31
+ const output = usage?.output;
32
+ return typeof output === "number" && Number.isFinite(output) ? output : undefined;
33
+ }
34
+
35
+ function assistantStopReason(message: unknown): string | undefined {
36
+ const reason = (message as { stopReason?: unknown }).stopReason;
37
+ return typeof reason === "string" ? reason : undefined;
38
+ }
39
+
40
+ function assistantEventType(event: unknown): string | undefined {
41
+ return typeof (event as { type?: string }).type === "string" ? (event as { type: string }).type : undefined;
42
+ }
43
+
44
+ export function registerCrewVibes(pi: ExtensionAPI): void {
45
+ let config: CrewVibesConfig = loadConfig();
46
+ let lastRenderedAt = 0;
47
+ let currentIntervalMs = 0;
48
+ const speedTracker = new SpeedTracker(config.speed);
49
+ const footerAnimator = new SpeedAnimator(config.speed.renderIntervalMs);
50
+ let liveTimer: ReturnType<typeof setInterval> | undefined;
51
+ let footerTimer: ReturnType<typeof setInterval> | undefined;
52
+ let capacityTimer: ReturnType<typeof setInterval> | undefined;
53
+ let providerTimer: ReturnType<typeof setInterval> | undefined;
54
+ let lastProviderText: string | undefined;
55
+ let catFrameIndex = 0;
56
+
57
+ /** Strip ANSI codes to measure visible width. */
58
+ function visibleLen(text: string): number {
59
+ return text.replace(/\x1b\[[0-9;]*m/g, "").length;
60
+ }
61
+
62
+ /** Left-align `left`, right-align `right` on the same line.
63
+ * Uses non-breaking space (U+00A0) for padding because pi's
64
+ * sanitizeStatusText collapses regular spaces: / +/g → " ". */
65
+ function spreadLine(left: string, right: string): string {
66
+ const cols = process.stdout.columns || 120;
67
+ const padding = Math.max(2, cols - visibleLen(left) - visibleLen(right));
68
+ return left + "\u00A0".repeat(padding) + right;
69
+ }
70
+
71
+ function themeOf(ctx: ExtensionContext) {
72
+ return asCrewTheme(ctx.hasUI ? ctx.ui.theme : undefined);
73
+ }
74
+
75
+ function publishCapacity(ctx: ExtensionContext): void {
76
+ if (!ctx?.hasUI) return;
77
+ if (!config.enabled || !config.capacity.enabled) {
78
+ setCapacityStatus(ctx, config, undefined);
79
+ return;
80
+ }
81
+ const capText = renderCapacity(themeOf(ctx), config.capacity, getCapacityUsage(ctx));
82
+ // Combine capacity + provider on one line with spacing
83
+ const combined = lastProviderText ? spreadLine(capText, lastProviderText) : capText;
84
+ setCapacityStatus(ctx, config, combined);
85
+ }
86
+
87
+ function publishSpeedFooter(ctx: ExtensionContext, speed = footerAnimator.value()): void {
88
+ if (!config.enabled || !config.speed.enabled || !config.speed.footer) {
89
+ setSpeedStatus(ctx, config, undefined);
90
+ return;
91
+ }
92
+ setSpeedStatus(ctx, config, renderSpeedFooter(themeOf(ctx), config.speed, speed));
93
+ }
94
+
95
+ function applyIndicator(ctx: ExtensionContext, speed: number | null, force = false): void {
96
+ if (!ctx.hasUI || !ctx.ui.setWorkingIndicator) return;
97
+ if (!config.enabled || !config.speed.enabled || !config.speed.indicator) {
98
+ ctx.ui.setWorkingIndicator();
99
+ return;
100
+ }
101
+ const next = intervalForSpeed(config.speed, speed);
102
+ if (!force && Math.abs(next - currentIntervalMs) < 10) return;
103
+ ctx.ui.setWorkingIndicator({
104
+ frames: crewIndicatorFrames(themeOf(ctx)),
105
+ intervalMs: next,
106
+ });
107
+ currentIntervalMs = next;
108
+ }
109
+
110
+ function renderWorking(ctx: ExtensionContext, speed: number | null): void {
111
+ if (!config.enabled || !config.speed.enabled || !ctx.hasUI) return;
112
+ ctx.ui.setWorkingMessage(renderWorkingMessage(themeOf(ctx), config.speed, speed));
113
+ }
114
+
115
+ function stopLiveTimer(): void {
116
+ if (!liveTimer) return;
117
+ clearInterval(liveTimer);
118
+ liveTimer = undefined;
119
+ }
120
+
121
+ function stopFooterTimer(): void {
122
+ if (!footerTimer) return;
123
+ clearInterval(footerTimer);
124
+ footerTimer = undefined;
125
+ }
126
+
127
+ function stopCapacityTimer(): void {
128
+ if (!capacityTimer) return;
129
+ clearInterval(capacityTimer);
130
+ capacityTimer = undefined;
131
+ }
132
+
133
+ function stopProviderTimer(): void {
134
+ if (!providerTimer) return;
135
+ clearInterval(providerTimer);
136
+ providerTimer = undefined;
137
+ }
138
+
139
+ function startLiveTimer(ctx: ExtensionContext): void {
140
+ if (liveTimer || !ctx.hasUI) return;
141
+ liveTimer = setInterval(() => {
142
+ if (!config.enabled || !config.speed.enabled || !speedTracker.isStreaming) {
143
+ stopLiveTimer();
144
+ return;
145
+ }
146
+ const speed = speedTracker.liveTokS();
147
+ applyIndicator(ctx, speed);
148
+ renderWorking(ctx, speed);
149
+ // Update cat animation frame in web terminals
150
+ if (isWebTerminal()) {
151
+ catFrameIndex = (catFrameIndex + 1) % CAT_FRAMES.length;
152
+ ctx.ui.setWidget("crew-vibes-cat", [...CAT_FRAMES[catFrameIndex]], { placement: "aboveEditor" });
153
+ }
154
+ }, config.speed.renderIntervalMs);
155
+ liveTimer.unref?.();
156
+ }
157
+
158
+ function startFooterTimer(ctx: ExtensionContext): void {
159
+ if (footerTimer || !ctx.hasUI) return;
160
+ footerTimer = setInterval(() => {
161
+ if (!config.enabled || !config.speed.enabled) {
162
+ stopFooterTimer();
163
+ return;
164
+ }
165
+ publishSpeedFooter(ctx);
166
+ if (!footerAnimator.isAnimating()) stopFooterTimer();
167
+ }, config.speed.renderIntervalMs);
168
+ footerTimer.unref?.();
169
+ }
170
+
171
+ function startCapacityTimer(ctx: ExtensionContext): void {
172
+ if (capacityTimer) return;
173
+ const interval = Math.max(250, config.capacity.refreshIntervalMs);
174
+ capacityTimer = setInterval(() => publishCapacity(ctx), interval);
175
+ capacityTimer.unref?.();
176
+ }
177
+
178
+ function startProviderTimer(ctx: ExtensionContext): void {
179
+ if (providerTimer) return;
180
+ if (!config.capacity.providerUsage) return;
181
+ const interval = Math.max(10000, config.capacity.providerRefreshMs);
182
+
183
+ async function tick(): Promise<void> {
184
+ if (!config.enabled || !config.capacity.providerUsage) {
185
+ stopProviderTimer();
186
+ return;
187
+ }
188
+ try {
189
+ const usage = await fetchProviderUsage(config.capacity.providerRefreshMs);
190
+ lastProviderText = renderProviderUsage(themeOf(ctx), usage);
191
+ // Re-render combined capacity + provider line
192
+ publishCapacity(ctx);
193
+ } catch {
194
+ // Never crash on provider fetch failure
195
+ lastProviderText = undefined;
196
+ publishCapacity(ctx);
197
+ }
198
+ }
199
+
200
+ tick(); // Fetch immediately on start
201
+ providerTimer = setInterval(tick, interval);
202
+ providerTimer.unref?.();
203
+ }
204
+
205
+ function resetWorking(ctx: ExtensionContext): void {
206
+ applyIndicator(ctx, null, true);
207
+ renderWorking(ctx, speedTracker.lastTokS);
208
+ }
209
+
210
+ function applyConfig(ctx: ExtensionContext): void {
211
+ saveConfig(config);
212
+ speedTracker.updateConfig(config.speed);
213
+ footerAnimator.updateDuration(config.speed.renderIntervalMs);
214
+ if (!config.enabled) {
215
+ stopLiveTimer();
216
+ stopFooterTimer();
217
+ stopCapacityTimer();
218
+ stopProviderTimer();
219
+ clearVibesStatus(ctx);
220
+ return;
221
+ }
222
+ publishCapacity(ctx);
223
+ publishSpeedFooter(ctx);
224
+ if (config.capacity.providerUsage) startProviderTimer(ctx);
225
+ }
226
+
227
+ pi.on("session_start", (_event, ctx) => {
228
+ stopLiveTimer();
229
+ stopFooterTimer();
230
+ stopCapacityTimer();
231
+ stopProviderTimer();
232
+ config = loadConfig();
233
+ speedTracker.updateConfig(config.speed);
234
+ footerAnimator.updateDuration(config.speed.renderIntervalMs);
235
+ speedTracker.resetSession();
236
+ footerAnimator.reset(null);
237
+ clearProviderUsageCache();
238
+ if (!config.enabled) {
239
+ clearVibesStatus(ctx);
240
+ return;
241
+ }
242
+ publishCapacity(ctx);
243
+ publishSpeedFooter(ctx);
244
+ startCapacityTimer(ctx);
245
+ startProviderTimer(ctx);
246
+ // Set the working indicator early (matches pi's official working-indicator
247
+ // example) so pi has the custom frames configured before streaming begins.
248
+ // Calls before the loading animation exists are ignored, so we re-apply on
249
+ // agent_start/message_update as well.
250
+ applyIndicator(ctx, null, true);
251
+ });
252
+
253
+ pi.on("agent_start", (_event, ctx) => {
254
+ if (!config.enabled) return;
255
+ resetWorking(ctx);
256
+ });
257
+
258
+ pi.on("turn_start", (_event, ctx) => {
259
+ if (!config.enabled) return;
260
+ resetWorking(ctx);
261
+ });
262
+
263
+ pi.on("message_start", (event, ctx) => {
264
+ if (!config.enabled || !config.speed.enabled || !isAssistantMessage(event.message)) return;
265
+ speedTracker.startMessage();
266
+ footerAnimator.reset(speedTracker.lastTokS);
267
+ startLiveTimer(ctx);
268
+ lastRenderedAt = 0;
269
+ // Show cat widget in web terminals
270
+ if (isWebTerminal() && ctx.hasUI) {
271
+ ctx.ui.setWidget("crew-vibes-cat", [...CAT_FRAMES[0]], { placement: "aboveEditor" });
272
+ }
273
+ });
274
+
275
+ pi.on("message_update", (event, ctx) => {
276
+ if (!config.enabled || !config.speed.enabled || !isAssistantMessage(event.message) || !speedTracker.isStreaming) return;
277
+
278
+ const ev = event.assistantMessageEvent;
279
+ const type = assistantEventType(ev);
280
+ if (type === "text_delta" || type === "thinking_delta") {
281
+ const delta = (ev as { delta?: string }).delta ?? "";
282
+ speedTracker.recordDelta(delta, assistantUsageOutput(event.message));
283
+ }
284
+ if (type === "start") resetWorking(ctx);
285
+
286
+ const now = Date.now();
287
+ if (now - lastRenderedAt < config.speed.renderIntervalMs && type !== "done") return;
288
+ lastRenderedAt = now;
289
+
290
+ const speed = speedTracker.liveTokS();
291
+ applyIndicator(ctx, speed);
292
+ renderWorking(ctx, speed);
293
+ });
294
+
295
+ pi.on("message_end", (event, ctx) => {
296
+ if (!isAssistantMessage(event.message)) return;
297
+ publishCapacity(ctx);
298
+ if (!config.enabled || !config.speed.enabled || !speedTracker.isStreaming) return;
299
+
300
+ const completed = speedTracker.finishMessage(assistantUsageOutput(event.message) ?? 0, assistantStopReason(event.message));
301
+ if (!completed) return;
302
+
303
+ footerAnimator.setTarget(speedTracker.sessionAvgTokS());
304
+ publishSpeedFooter(ctx);
305
+ startFooterTimer(ctx);
306
+ applyIndicator(ctx, speedTracker.lastTokS);
307
+ // Remove cat widget on message end
308
+ if (isWebTerminal() && ctx.hasUI) {
309
+ ctx.ui.setWidget("crew-vibes-cat", undefined);
310
+ }
311
+ });
312
+
313
+ pi.on("turn_end", () => {
314
+ speedTracker.stopMessage();
315
+ stopLiveTimer();
316
+ });
317
+
318
+ pi.on("agent_end", (_event, ctx) => {
319
+ speedTracker.stopMessage();
320
+ stopLiveTimer();
321
+ if (ctx && config.enabled && ctx.hasUI) {
322
+ applyIndicator(ctx, speedTracker.lastTokS);
323
+ ctx.ui.setWorkingMessage();
324
+ if (isWebTerminal()) ctx.ui.setWidget("crew-vibes-cat", undefined);
325
+ }
326
+ });
327
+
328
+ pi.on("model_select", (_event, ctx) => publishCapacity(ctx));
329
+ pi.on("session_compact", (_event, ctx) => publishCapacity(ctx));
330
+ pi.on("session_tree", (_event, ctx) => publishCapacity(ctx));
331
+
332
+ pi.on("session_shutdown", (_event, ctx) => {
333
+ stopLiveTimer();
334
+ stopFooterTimer();
335
+ stopCapacityTimer();
336
+ stopProviderTimer();
337
+ clearVibesStatus(ctx);
338
+ if (ctx.hasUI) ctx.ui.setWidget("crew-vibes-cat", undefined);
339
+ });
340
+
341
+ async function handleCommand(args: string, ctx: ExtensionCommandContext): Promise<void> {
342
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
343
+ const [first, second] = tokens;
344
+
345
+ const mutate = (next: CrewVibesConfig): void => {
346
+ config = next;
347
+ applyConfig(ctx);
348
+ };
349
+
350
+ if (!first) {
351
+ const speed = speedTracker.liveTokS();
352
+ const usage = getCapacityUsage(ctx);
353
+ const stage = config.capacity.icons.length
354
+ ? config.capacity.labels[
355
+ Math.max(
356
+ 0,
357
+ Math.min(
358
+ config.capacity.labels.length - 1,
359
+ Math.floor(((usage.percent ?? 0) / 100) * config.capacity.labels.length),
360
+ ),
361
+ )
362
+ ]
363
+ : "?";
364
+ ctx.ui.notify(
365
+ `crew-vibes: ${config.enabled ? "on" : "off"} · speed ${config.speed.enabled ? "on" : "off"} (${formatSpeed(config.speed, speed)}) · capacity ${config.capacity.enabled ? "on" : "off"} (${stage})`,
366
+ "info",
367
+ );
368
+ return;
369
+ }
370
+
371
+ if (first === "on" || first === "off") {
372
+ mutate({ ...config, enabled: first === "on" });
373
+ ctx.ui.notify(`crew-vibes ${first === "on" ? "enabled" : "disabled"}`, "info");
374
+ return;
375
+ }
376
+
377
+ if (first === "speed" && (second === "on" || second === "off")) {
378
+ mutate({ ...config, speed: { ...config.speed, enabled: second === "on" } });
379
+ ctx.ui.notify(`crew-vibes speed ${second === "on" ? "enabled" : "disabled"}`, "info");
380
+ return;
381
+ }
382
+
383
+ if (first === "capacity" && (second === "on" || second === "off")) {
384
+ mutate({ ...config, capacity: { ...config.capacity, enabled: second === "on" } });
385
+ ctx.ui.notify(`crew-vibes capacity ${second === "on" ? "enabled" : "disabled"}`, "info");
386
+ return;
387
+ }
388
+
389
+ ctx.ui.notify("Usage: /team-vibes [on|off|speed on|off|capacity on|off]", "error");
390
+ }
391
+
392
+ pi.registerCommand("team-vibes", {
393
+ description: "Toggle crew-vibes speed + context meters (on/off, speed, capacity)",
394
+ handler: handleCommand,
395
+ });
396
+ }