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,259 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Auto-rebuild bundle watcher — dev-loop ergonomics for v0.9.17+.
4
+ *
5
+ * Watches `src/`, `index.bundle.ts` for changes and rebuilds
6
+ * `dist/index.mjs` after a 300ms debounce. Solves the "edit src/foo.ts,
7
+ * forget to rebuild, run stale bundle" race that motivated check:
8
+ * bundle-staleness as a CI gate.
9
+ *
10
+ * Usage:
11
+ * node scripts/watch-bundle.mjs # default: watch src/ + index.bundle.ts
12
+ * node scripts/watch-bundle.mjs --once # build once and exit (CI prep)
13
+ * node scripts/watch-bundle.mjs --debounce 500 # custom debounce ms
14
+ *
15
+ * Why native fs.watch (not chokidar):
16
+ * - Adds zero deps (chokidar would add ~50 transitive deps)
17
+ * - Node 22 + Linux/macOS support recursive watching (fs.watch recursive)
18
+ *
19
+ * Why not in-process (bundled into build:bundle):
20
+ * - Watch is a separate dev concern; doesn't belong in the build script
21
+ * - Keeps the build script one-shot (CI-friendly, deterministic)
22
+ */
23
+ import { spawn } from "node:child_process";
24
+ import { existsSync, statSync } from "node:fs";
25
+ import { readdir, watch } from "node:fs/promises";
26
+ import { fileURLToPath } from "node:url";
27
+ import path from "node:path";
28
+
29
+ const args = process.argv.slice(2);
30
+ let onceMode = false;
31
+ let debounceMs = 300;
32
+ for (let i = 0; i < args.length; i += 1) {
33
+ if (args[i] === "--once") {
34
+ onceMode = true;
35
+ } else if (args[i] === "--debounce" && args[i + 1]) {
36
+ debounceMs = Number(args[i + 1]);
37
+ i += 1;
38
+ }
39
+ }
40
+
41
+ const here = path.dirname(fileURLToPath(import.meta.url));
42
+ const root = path.resolve(here, "..");
43
+
44
+ const WATCH_PATHS = ["src", "index.bundle.ts"];
45
+ const EXTENSIONS_TO_WATCH = new Set([".ts", ".mts", ".cts", ".js", ".mjs"]);
46
+ const IGNORE_DIRS = new Set(["node_modules", "dist", ".git"]);
47
+
48
+ let pending = null;
49
+ let lastBuildReason = null;
50
+
51
+ function scheduleRebuild(reason, file) {
52
+ lastBuildReason = `${reason} ${path.relative(root, file ?? "")}`;
53
+ if (pending) clearTimeout(pending);
54
+ pending = setTimeout(() => {
55
+ pending = null;
56
+ runBuild().catch((err) => console.error("[watch-bundle] build crashed:", err));
57
+ }, debounceMs);
58
+ }
59
+
60
+ function runBuild() {
61
+ return new Promise((resolve, reject) => {
62
+ const startMs = Date.now();
63
+ const child = spawn(process.execPath, [path.join(root, "scripts/build-bundle.mjs")], {
64
+ stdio: ["ignore", "pipe", "pipe"],
65
+ cwd: root,
66
+ env: { ...process.env, FORCE_COLOR: "0" },
67
+ });
68
+ let stderr = "";
69
+ child.stdout.on("data", (chunk) => {
70
+ const text = String(chunk);
71
+ if (text.includes("[build-bundle] dist/index.mjs")) {
72
+ process.stdout.write(`[watch-bundle] ${text}`);
73
+ }
74
+ });
75
+ child.stderr.on("data", (chunk) => {
76
+ stderr += String(chunk);
77
+ process.stderr.write(`[watch-bundle] ${chunk}`);
78
+ });
79
+ child.on("exit", (code) => {
80
+ const ms = Date.now() - startMs;
81
+ if (code === 0) {
82
+ console.log(`[watch-bundle] ok in ${ms}ms (${lastBuildReason ?? "initial"})`);
83
+ resolve();
84
+ } else {
85
+ reject(new Error(`build exited with code ${code}: ${stderr.trim().split("\n").slice(-3).join(" / ")}`));
86
+ }
87
+ });
88
+ child.on("error", reject);
89
+ });
90
+ }
91
+
92
+ async function listFiles(dir) {
93
+ const out = [];
94
+ let entries;
95
+ try {
96
+ entries = await readdir(dir, { withFileTypes: true });
97
+ } catch {
98
+ return out;
99
+ }
100
+ for (const entry of entries) {
101
+ const full = path.join(dir, entry.name);
102
+ if (entry.isDirectory()) {
103
+ if (IGNORE_DIRS.has(entry.name)) continue;
104
+ out.push(...(await listFiles(full)));
105
+ } else if (entry.isFile()) {
106
+ const ext = path.extname(entry.name).toLowerCase();
107
+ if (EXTENSIONS_TO_WATCH.has(ext)) out.push(full);
108
+ }
109
+ }
110
+ return out;
111
+ }
112
+
113
+ async function watchDir(dir) {
114
+ // Linux inotify (used by fs.watch {recursive: true} on modern Node)
115
+ // sometimes misses events on deep trees. Walk the tree once and
116
+ // spawn a per-directory watcher for robustness. Cost: O(2N) FDs
117
+ // where N = number of source dirs (~5 for a typical pi-crew tree).
118
+ // Each watcher is independent and yields events as its dir changes.
119
+ const watcherJobs = [];
120
+ try {
121
+ // Watch the dir itself (catches direct children changes)
122
+ const rootWatcher = await watch(dir, { persistent: true });
123
+ watcherJobs.push(
124
+ (async () => {
125
+ for await (const e of rootWatcher) {
126
+ if (e.filename) handleEvent(dir, e.filename);
127
+ }
128
+ })(),
129
+ );
130
+ } catch (err) {
131
+ console.warn(`[watch-bundle] cannot watch ${dir}:`, err?.code ?? err?.message ?? err);
132
+ }
133
+ // Recurse into subdirectories (skip ignored) — each gets its own watcher
134
+ try {
135
+ const entries = await readdir(dir, { withFileTypes: true });
136
+ for (const entry of entries) {
137
+ if (!entry.isDirectory()) continue;
138
+ if (IGNORE_DIRS.has(entry.name)) continue;
139
+ const sub = path.join(dir, entry.name);
140
+ watcherJobs.push(
141
+ (async () => {
142
+ try {
143
+ const subWatcher = await watch(sub, { persistent: true });
144
+ for await (const e of subWatcher) {
145
+ if (e.filename) handleEvent(sub, e.filename);
146
+ }
147
+ } catch (err) {
148
+ console.warn(
149
+ `[watch-bundle] cannot watch ${sub}:`,
150
+ err?.code ?? err?.message ?? err,
151
+ );
152
+ }
153
+ })(),
154
+ );
155
+ }
156
+ } catch {
157
+ // ignore readdir errors
158
+ }
159
+ // Block until any watcher completes (they shouldn't, they're persistent)
160
+ await Promise.race(watcherJobs);
161
+ }
162
+
163
+ function handleEvent(watchedDir, filename) {
164
+ const fullPath = path.resolve(watchedDir, filename);
165
+ const ext = path.extname(fullPath).toLowerCase();
166
+ if (!EXTENSIONS_TO_WATCH.has(ext)) return;
167
+ if (fullPath.includes(`${path.sep}dist${path.sep}`)) return;
168
+ scheduleRebuild("change", fullPath);
169
+ }
170
+
171
+ async function watchFile(file) {
172
+ if (!existsSync(file)) return;
173
+ const dir = path.dirname(file);
174
+ const watcher = watch(dir, { persistent: true });
175
+ for await (const event of watcher) {
176
+ if (!event.filename) continue;
177
+ const changed = path.resolve(dir, event.filename);
178
+ if (changed === file) scheduleRebuild("change", changed);
179
+ }
180
+ }
181
+
182
+ async function main() {
183
+ const watchables = WATCH_PATHS.map((p) => path.join(root, p)).filter((p) => {
184
+ try {
185
+ return existsSync(p);
186
+ } catch {
187
+ return false;
188
+ }
189
+ });
190
+ if (watchables.length === 0) {
191
+ console.error("[watch-bundle] no watchable paths found (run from repo root)");
192
+ process.exit(1);
193
+ }
194
+
195
+ console.log("[watch-bundle] initial build...");
196
+ let initialOk = true;
197
+ try {
198
+ await runBuild();
199
+ } catch (err) {
200
+ console.error("[watch-bundle] initial build failed:", err.message);
201
+ initialOk = false;
202
+ }
203
+
204
+ if (onceMode) {
205
+ process.exit(initialOk ? 0 : 2);
206
+ }
207
+
208
+ if (!initialOk) {
209
+ console.warn("[watch-bundle] initial build failed; watching anyway");
210
+ }
211
+
212
+ let seedCount = 0;
213
+ for (const p of watchables) {
214
+ try {
215
+ const stat = statSync(p);
216
+ if (stat.isDirectory()) {
217
+ seedCount += (await listFiles(p)).length;
218
+ } else if (stat.isFile()) {
219
+ seedCount += 1;
220
+ }
221
+ } catch {
222
+ // skip
223
+ }
224
+ }
225
+ console.log(`[watch-bundle] seeded with ${seedCount} source files`);
226
+
227
+ const watchJobs = [];
228
+ for (const p of watchables) {
229
+ try {
230
+ const stat = statSync(p);
231
+ watchJobs.push(stat.isDirectory() ? watchDir(p) : watchFile(p));
232
+ } catch (err) {
233
+ console.warn(`[watch-bundle] cannot watch ${p}:`, err?.code ?? err?.message ?? err);
234
+ }
235
+ }
236
+
237
+ console.log(`[watch-bundle] watching ${watchables.length} paths; debounce=${debounceMs}ms; Ctrl+C to stop`);
238
+
239
+ let shutting = false;
240
+ const stop = () => {
241
+ if (shutting) return;
242
+ shutting = true;
243
+ console.log("\n[watch-bundle] shutting down...");
244
+ if (pending) {
245
+ clearTimeout(pending);
246
+ pending = null;
247
+ }
248
+ setTimeout(() => process.exit(0), 200);
249
+ };
250
+ process.on("SIGINT", stop);
251
+ process.on("SIGTERM", stop);
252
+
253
+ await Promise.race(watchJobs);
254
+ }
255
+
256
+ main().catch((err) => {
257
+ console.error("[watch-bundle] fatal:", err);
258
+ process.exit(1);
259
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Watchdog harness — a minimal script that mimics the background-runner's
3
+ * watchdog + keepAlive mechanism WITHOUT needing a real team/run manifest.
4
+ *
5
+ * This is used by test/integration/background-runner-watchdog-e2e.test.ts to
6
+ * verify the watchdog fires and force-exits a hung process.
7
+ *
8
+ * Usage:
9
+ * node --experimental-strip-types scripts/watchdog-harness.ts
10
+ *
11
+ * Env vars:
12
+ * PI_CREW_MAX_RUN_MS — watchdog timeout (ms). Default: 2h.
13
+ * PI_CREW_PARENT_PID — parent guard PID. If parent dies, exit 124.
14
+ * PI_CREW_HARD_KILL_GRACE_MS — grace period before force-exit. Default: 15000.
15
+ *
16
+ * The script:
17
+ * 1. Starts the parent guard (self-terminates if parent dies)
18
+ * 2. Starts a keepAlive interval (prevents event-loop exit)
19
+ * 3. Starts a watchdog timer (fires after MAX_RUN_MS, schedules force-exit)
20
+ * 4. NEVER resolves — simulating a hung team run
21
+ *
22
+ * Expected behaviour: process exits via watchdog force-exit within
23
+ * PI_CREW_MAX_RUN_MS + PI_CREW_HARD_KILL_GRACE_MS milliseconds.
24
+ */
25
+
26
+ // ─── Config (from env — mirrors background-runner.ts) ─────────────────────────
27
+ const MAX_BACKGROUND_RUN_MS = (() => {
28
+ const env = Number.parseInt(process.env.PI_CREW_MAX_RUN_MS ?? "", 10);
29
+ return Number.isFinite(env) && env > 0 ? env : 2 * 60 * 60 * 1000; // 2h default
30
+ })();
31
+
32
+ const HARD_KILL_GRACE_MS = Number.parseInt(process.env.PI_CREW_HARD_KILL_GRACE_MS ?? "", 10) || 15_000;
33
+
34
+ // ─── Global error handlers (mirror background-runner.ts patterns) ─────────────
35
+ // These prevent unhandled exceptions/rejections from crashing the process
36
+ // before the watchdog can fire. The harness deliberately hangs, so these are
37
+ // the only way to keep the process alive long enough for the watchdog to trigger.
38
+ process.on("uncaughtException", (err) => {
39
+ console.error(`[watchdog-harness] uncaughtException: ${err.message}`);
40
+ });
41
+
42
+ process.on("unhandledRejection", (reason) => {
43
+ const msg = reason instanceof Error ? reason.message : String(reason);
44
+ console.error(`[watchdog-harness] unhandledRejection: ${msg}`);
45
+ });
46
+
47
+ // ─── Parent Guard ──────────────────────────────────────────────────────────────
48
+ const parentPid = Number(process.env.PI_CREW_PARENT_PID);
49
+ if (parentPid > 0) {
50
+ const POLL_INTERVAL_MS = 500;
51
+
52
+ function isPidAlive(pid: number): boolean {
53
+ try {
54
+ process.kill(pid, 0);
55
+ return true;
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ const guard = setInterval(() => {
62
+ // Wrap in try/catch: if isPidAlive throws unexpectedly (e.g. EPERM on
63
+ // some platforms), we don't want the interval itself to crash and prevent
64
+ // the watchdog from ever firing.
65
+ try {
66
+ if (!isPidAlive(parentPid)) {
67
+ clearInterval(guard);
68
+ process.exit(124); // 124 = "parent died" exit code
69
+ }
70
+ } catch (err) {
71
+ // Unexpected error in parent guard — log and continue polling.
72
+ console.error(`[watchdog-harness] parent-guard error: ${err}`);
73
+ }
74
+ }, POLL_INTERVAL_MS);
75
+ // NOTE: intentionally NOT unref'd — must keep event loop alive while parent is alive.
76
+ }
77
+
78
+ // ─── KeepAlive (same interval as background-runner.ts) ─────────────────────────
79
+ const keepAlive = setInterval(() => {}, 5000);
80
+ // NOTE: intentionally NOT unref'd — must hold the event loop open while the
81
+ // process is alive. The event loop MUST stay alive so the forceExit setTimeout
82
+ // below can fire. If keepAlive is cleared too early, the event loop drains
83
+ // and all pending setTimeouts are silently cancelled before they fire.
84
+
85
+ // ─── Watchdog ─────────────────────────────────────────────────────────────────
86
+ // Track whether abort+force-exit has already been triggered (prevents double-fire).
87
+ let watchdogFired = false;
88
+
89
+ const watchdogTimer = setTimeout(() => {
90
+ if (watchdogFired) return;
91
+ watchdogFired = true;
92
+
93
+ console.error(
94
+ `[watchdog-harness] WATCHDOG: run exceeded ${MAX_BACKGROUND_RUN_MS}ms — scheduling force-exit in ${HARD_KILL_GRACE_MS}ms (zombie prevention)`,
95
+ );
96
+
97
+ // Hard-exit safety net: if the abort does not propagate within grace period,
98
+ // force-kill so the process cannot linger. Matches background-runner.ts logic.
99
+ // NOTE: keepAlive is NOT cleared here — it keeps the event loop alive so
100
+ // the forceExit setTimeout below can fire. If keepAlive is cleared, the
101
+ // event loop drains and forceExit is silently cancelled (observed: process
102
+ // exits via module-level finally/runCleanup with the last exit code instead).
103
+ const forceExit = setTimeout(() => {
104
+ // Clear keepAlive only at the very end, right before force-exit.
105
+ clearInterval(keepAlive);
106
+ clearTimeout(watchdogTimer);
107
+ process.exit(1);
108
+ }, HARD_KILL_GRACE_MS);
109
+ // NOTE: intentionally NOT unref'd — this timer MUST fire to force-exit.
110
+ }, MAX_BACKGROUND_RUN_MS);
111
+
112
+ // ─── Hung run simulation ──────────────────────────────────────────────────────
113
+ // NEVER resolve this promise — simulates a team run that hangs forever.
114
+ // The watchdog setTimeout above fires and force-exits the process first.
115
+ await new Promise<void>((resolve) => {
116
+ // Intentionally never resolved. The unhandledRejection handler above keeps
117
+ // the process alive until the watchdog fires and calls process.exit().
118
+ void resolve;
119
+ });
@@ -490,7 +490,12 @@ function applyAgentOverrides(agents: AgentConfig[], cwd: string, loadedConfig?:
490
490
  // SEC-005 Fix: Uses version-based cache for atomic invalidation.
491
491
  // ═══════════════════════════════════════════════════════════════════════════
492
492
 
493
- const DISCOVERY_CACHE_TTL_MS = 500;
493
+ // F15: raised from 500 ms -> 5 s. The cache-version stamp (SEC-005) plus
494
+ // explicit `invalidateAgentDiscoveryCache()` calls from management actions
495
+ // (create/update/delete) cover correctness on edit; 5 s TTL is plenty for the
496
+ // powerbar / scheduler hot path (~5 Hz when a run is active) and matches the
497
+ // TTL we use for workflow / team discovery caches.
498
+ const DISCOVERY_CACHE_TTL_MS = 5000;
494
499
  interface CachedDiscoveryEntry {
495
500
  result: AgentDiscoveryResult;
496
501
  expiresAt: number;
@@ -1,6 +1,11 @@
1
1
  export const DEFAULT_CHILD_PI: Readonly<{
2
2
  postExitStdioGuardMs: number;
3
3
  finalDrainMs: number;
4
+ /** F12: early-exit the drain if stdout goes silent for ≥ this many ms after
5
+ * the final assistant event AND the assistant already emitted
6
+ * message_end with stopReason=stop. Lets well-behaved workers finish in
7
+ * ~800 ms instead of the full 5 s ceiling. Set to >= finalDrainMs to disable. */
8
+ finalDrainQuietMs: number;
4
9
  hardKillMs: number;
5
10
  responseTimeoutMs: number;
6
11
  maxCaptureBytes: number;
@@ -11,6 +16,7 @@ export const DEFAULT_CHILD_PI: Readonly<{
11
16
  }> = {
12
17
  postExitStdioGuardMs: 3000,
13
18
  finalDrainMs: 5000,
19
+ finalDrainQuietMs: 800,
14
20
  hardKillMs: 3000,
15
21
  // Child workers can spend more than a few seconds in provider calls or long-running tools without emitting stdout.
16
22
  // Keep this as a coarse stuck-worker guard rather than a short per-message latency budget.
@@ -0,0 +1,18 @@
1
+ /**
2
+ * ANSI art cat animation frames extracted from runner-spritesheet.png.
3
+ * Used when PUA font glyphs are not available (web terminals like gotty).
4
+ * Each frame is an array of strings (one per line).
5
+ */
6
+ export const CAT_FRAMES: readonly string[][] = [
7
+ // Frame 0 — standing
8
+ ["▄▄ ███▄ ", "▀▀█████▄", " ██▀██ ", " ▀▀ ▀▀"],
9
+ // Frame 1 — step 1
10
+ [" ████ ", " █████▄", "▄██████▀", " ██ ▀█▄"],
11
+ // Frame 2 — step 2
12
+ [" ▄██████", " █████▀ ", "███████ ", "▀██████ "],
13
+ // Frame 3 — step 3
14
+ [" ████ ", "▄█████▄ ", "▀██████▄", "▄█▀ ██ "],
15
+ ] as const;
16
+
17
+ /** Number of animation frames. */
18
+ export const CAT_FRAME_COUNT = CAT_FRAMES.length;
@@ -0,0 +1,195 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { hasCrewFontFile, isWebTerminal } from "./font-detect.ts";
4
+
5
+ /**
6
+ * Self-contained config for the crew-vibes module (speed + capacity meters).
7
+ * Stored in its own JSON file so it never touches the strict typebox schema
8
+ * used by the rest of pi-crew.
9
+ */
10
+
11
+ export const SPEED_STATUS_ID = "pi-crew-speed";
12
+ export const CAPACITY_STATUS_ID = "pi-crew-bar";
13
+ export const PROVIDER_STATUS_ID = "pi-crew-bar";
14
+
15
+ function resolveHome(): string {
16
+ return process.env.PI_TEAMS_HOME?.trim() || process.env.HOME || process.env.USERPROFILE || "";
17
+ }
18
+
19
+ export function configPath(): string {
20
+ return join(resolveHome(), ".pi", "agent", "pi-crew-vibes.json");
21
+ }
22
+
23
+ export type TokenDisplay = "off" | "tokens" | "percentage";
24
+
25
+ export interface SpeedConfig {
26
+ enabled: boolean;
27
+ footer: boolean;
28
+ indicator: boolean;
29
+ label: string;
30
+ renderIntervalMs: number;
31
+ slidingWindowMs: number;
32
+ minReliableDurationMs: number;
33
+ maxDisplayTokS: number;
34
+ defaultIntervalMs: number;
35
+ minIntervalMs: number;
36
+ maxIntervalMs: number;
37
+ scale: number;
38
+ }
39
+
40
+ export interface CapacityConfig {
41
+ enabled: boolean;
42
+ tokenDisplay: TokenDisplay;
43
+ showLabel: boolean;
44
+ refreshIntervalMs: number;
45
+ labels: [string, string, string, string, string, string];
46
+ icons: [string, string, string, string, string, string];
47
+ providerUsage: boolean;
48
+ providerRefreshMs: number;
49
+ }
50
+
51
+ export interface CrewVibesConfig {
52
+ enabled: boolean;
53
+ speed: SpeedConfig;
54
+ capacity: CapacityConfig;
55
+ }
56
+
57
+ export const DEFAULT_CONFIG: CrewVibesConfig = {
58
+ enabled: true,
59
+ speed: {
60
+ enabled: true,
61
+ footer: false,
62
+ indicator: true,
63
+ label: "tok/s",
64
+ renderIntervalMs: 250,
65
+ slidingWindowMs: 1000,
66
+ minReliableDurationMs: 1000,
67
+ maxDisplayTokS: 500,
68
+ defaultIntervalMs: 167,
69
+ minIntervalMs: 50,
70
+ maxIntervalMs: 250,
71
+ scale: 6000,
72
+ },
73
+ capacity: {
74
+ enabled: true,
75
+ tokenDisplay: "tokens",
76
+ showLabel: true,
77
+ refreshIntervalMs: 2000,
78
+ labels: ["Orbit", "Cruise", "Warp", "Black Hole", "Supernova", "Big Bang"],
79
+ icons: ["", "", "", "", "", ""],
80
+ providerUsage: true,
81
+ providerRefreshMs: 300000,
82
+ },
83
+ };
84
+
85
+ // Fallback capacity icons using standard Unicode characters that render
86
+ // on any terminal without the crew-vibes PUA font.
87
+ const FALLBACK_CAPACITY_ICONS: [string, string, string, string, string, string] = [
88
+ "\u25CB ", // ○ empty circle (lean)
89
+ "\u25D4 ", // ◔ circle with dot (chonking)
90
+ "\u25D1 ", // ◑ circle half filled (chonky)
91
+ "\u25CF ", // ● filled circle (big chonk)
92
+ "\u2B24 ", // ⬤ large filled circle (mega chonk)
93
+ "\u2B22 ", // ⬢ filled hexagon (oh lawd)
94
+ ];
95
+
96
+ /** Return capacity icons: standard Unicode glyphs that render on any terminal.
97
+ * PUA glyphs (U+E710..U+E715) require crew-vibes.ttf AND terminal PUA
98
+ * support — many terminals cannot render them even with the font installed. */
99
+ export function capacityIcons(): [string, string, string, string, string, string] {
100
+ // Web terminals cannot render PUA glyphs — use fallback.
101
+ if (isWebTerminal()) return FALLBACK_CAPACITY_ICONS;
102
+ return hasCrewFontFile() ? DEFAULT_CONFIG.capacity.icons : FALLBACK_CAPACITY_ICONS;
103
+ }
104
+
105
+ function asRecord(value: unknown): Record<string, unknown> {
106
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
107
+ }
108
+
109
+ function boolFrom(raw: unknown, fallback: boolean): boolean {
110
+ return typeof raw === "boolean" ? raw : fallback;
111
+ }
112
+
113
+ function stringFrom(raw: unknown, fallback: string): string {
114
+ return typeof raw === "string" ? raw : fallback;
115
+ }
116
+
117
+ function positiveFrom(raw: unknown, fallback: number): number {
118
+ return typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? raw : fallback;
119
+ }
120
+
121
+ function sextet(
122
+ raw: unknown,
123
+ fallback: [string, string, string, string, string, string],
124
+ ): [string, string, string, string, string, string] {
125
+ if (Array.isArray(raw) && raw.length === 6 && raw.every((entry) => typeof entry === "string")) {
126
+ return raw as [string, string, string, string, string, string];
127
+ }
128
+ return fallback;
129
+ }
130
+
131
+ function tokenDisplayFrom(raw: unknown, fallback: TokenDisplay): TokenDisplay {
132
+ return raw === "off" || raw === "tokens" || raw === "percentage" ? raw : fallback;
133
+ }
134
+
135
+ function normalizeSpeed(raw: unknown): SpeedConfig {
136
+ const input = asRecord(raw);
137
+ const speed: SpeedConfig = {
138
+ enabled: boolFrom(input.enabled, DEFAULT_CONFIG.speed.enabled),
139
+ footer: boolFrom(input.footer, DEFAULT_CONFIG.speed.footer),
140
+ indicator: boolFrom(input.indicator, DEFAULT_CONFIG.speed.indicator),
141
+ label: stringFrom(input.label, DEFAULT_CONFIG.speed.label),
142
+ renderIntervalMs: positiveFrom(input.renderIntervalMs, DEFAULT_CONFIG.speed.renderIntervalMs),
143
+ slidingWindowMs: positiveFrom(input.slidingWindowMs, DEFAULT_CONFIG.speed.slidingWindowMs),
144
+ minReliableDurationMs: positiveFrom(input.minReliableDurationMs, DEFAULT_CONFIG.speed.minReliableDurationMs),
145
+ maxDisplayTokS: positiveFrom(input.maxDisplayTokS, DEFAULT_CONFIG.speed.maxDisplayTokS),
146
+ defaultIntervalMs: positiveFrom(input.defaultIntervalMs, DEFAULT_CONFIG.speed.defaultIntervalMs),
147
+ minIntervalMs: positiveFrom(input.minIntervalMs, DEFAULT_CONFIG.speed.minIntervalMs),
148
+ maxIntervalMs: positiveFrom(input.maxIntervalMs, DEFAULT_CONFIG.speed.maxIntervalMs),
149
+ scale: positiveFrom(input.scale, DEFAULT_CONFIG.speed.scale),
150
+ };
151
+ if (speed.minIntervalMs > speed.maxIntervalMs) {
152
+ speed.minIntervalMs = DEFAULT_CONFIG.speed.minIntervalMs;
153
+ speed.maxIntervalMs = DEFAULT_CONFIG.speed.maxIntervalMs;
154
+ }
155
+ return speed;
156
+ }
157
+
158
+ function normalizeCapacity(raw: unknown): CapacityConfig {
159
+ const input = asRecord(raw);
160
+ return {
161
+ enabled: boolFrom(input.enabled, DEFAULT_CONFIG.capacity.enabled),
162
+ tokenDisplay: tokenDisplayFrom(input.tokenDisplay, DEFAULT_CONFIG.capacity.tokenDisplay),
163
+ showLabel: boolFrom(input.showLabel, DEFAULT_CONFIG.capacity.showLabel),
164
+ refreshIntervalMs: positiveFrom(input.refreshIntervalMs, DEFAULT_CONFIG.capacity.refreshIntervalMs),
165
+ labels: sextet(input.labels, DEFAULT_CONFIG.capacity.labels),
166
+ icons: sextet(input.icons, DEFAULT_CONFIG.capacity.icons),
167
+ providerUsage: boolFrom(input.providerUsage, DEFAULT_CONFIG.capacity.providerUsage),
168
+ providerRefreshMs: positiveFrom(input.providerRefreshMs, DEFAULT_CONFIG.capacity.providerRefreshMs),
169
+ };
170
+ }
171
+
172
+ export function normalizeConfig(raw: unknown): CrewVibesConfig {
173
+ const input = asRecord(raw);
174
+ return {
175
+ enabled: boolFrom(input.enabled, DEFAULT_CONFIG.enabled),
176
+ speed: normalizeSpeed(input.speed),
177
+ capacity: normalizeCapacity(input.capacity),
178
+ };
179
+ }
180
+
181
+ export function loadConfig(): CrewVibesConfig {
182
+ try {
183
+ const path = configPath();
184
+ if (!existsSync(path)) return normalizeConfig(undefined);
185
+ return normalizeConfig(JSON.parse(readFileSync(path, "utf8")));
186
+ } catch {
187
+ return normalizeConfig(undefined);
188
+ }
189
+ }
190
+
191
+ export function saveConfig(config: CrewVibesConfig): void {
192
+ const path = configPath();
193
+ mkdirSync(dirname(path), { recursive: true });
194
+ writeFileSync(path, `${JSON.stringify(normalizeConfig(config), null, 2)}\n`);
195
+ }