pi-crew 0.9.41 → 0.9.44

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 (170) hide show
  1. package/CHANGELOG.md +253 -0
  2. package/README.md +14 -0
  3. package/dist/build-meta.json +6450 -12419
  4. package/dist/index.mjs +57998 -68417
  5. package/dist/index.mjs.map +4 -4
  6. package/docs/migration/atomic-write-v2-migration.md +2 -0
  7. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +2 -0
  8. package/package.json +4 -2
  9. package/scripts/README.md +6 -0
  10. package/scripts/build-bundle.mjs +2 -1
  11. package/scripts/check-all-skills.ts +3 -3
  12. package/scripts/dev-runner.mjs +106 -0
  13. package/scripts/postinstall.mjs +6 -2
  14. package/scripts/verify-skill.ts +2 -2
  15. package/src/agents/agent-config.ts +1 -1
  16. package/src/config/config.ts +20 -1
  17. package/src/config/drift-detector.ts +97 -15
  18. package/src/config/resilient-parser.ts +4 -4
  19. package/src/config/types.ts +0 -2
  20. package/src/extension/crew-cleanup.ts +17 -3
  21. package/src/extension/crew-vibes/config.ts +1 -1
  22. package/src/extension/crew-vibes/font-detect.ts +16 -3
  23. package/src/extension/crew-vibes/index.ts +1 -1
  24. package/src/extension/cross-extension-rpc.ts +26 -9
  25. package/src/extension/management.ts +6 -5
  26. package/src/extension/register.ts +58 -1573
  27. package/src/extension/registration/command-registration.ts +57 -0
  28. package/src/extension/registration/commands.ts +7 -2
  29. package/src/extension/registration/context-builder.ts +131 -0
  30. package/src/extension/registration/crash-recovery-cache.ts +54 -0
  31. package/src/extension/registration/foreground-run-controller.ts +238 -0
  32. package/src/extension/registration/hook-registration.ts +119 -0
  33. package/src/extension/registration/lazy-configurers.ts +124 -0
  34. package/src/extension/registration/lifecycle-handlers.ts +731 -0
  35. package/src/extension/registration/observability.ts +33 -4
  36. package/src/extension/registration/registration-types.ts +149 -0
  37. package/src/extension/registration/runtime-cleanup.ts +230 -0
  38. package/src/extension/registration/subagent-manager-setup.ts +211 -0
  39. package/src/extension/registration/tool-registration.ts +50 -0
  40. package/src/extension/registration/wire-cross-extension.ts +37 -0
  41. package/src/extension/rpc-hmac.ts +21 -1
  42. package/src/extension/run-import.ts +3 -3
  43. package/src/extension/team-tool/auto-summarize.ts +4 -4
  44. package/src/extension/team-tool/cancel.ts +5 -5
  45. package/src/extension/team-tool/chain-executor.ts +5 -4
  46. package/src/extension/team-tool/context.ts +11 -1
  47. package/src/extension/team-tool/doctor.ts +2 -1
  48. package/src/extension/team-tool/goal-wrap.ts +2 -2
  49. package/src/extension/team-tool/goal.ts +1 -1
  50. package/src/extension/team-tool/handle-schedule.ts +25 -36
  51. package/src/extension/team-tool/orchestrate.ts +0 -1
  52. package/src/extension/team-tool/parallel-dispatch.ts +5 -0
  53. package/src/extension/team-tool/run.ts +220 -214
  54. package/src/extension/team-tool/workflow-manage.ts +1 -1
  55. package/src/extension/team-tool.ts +69 -16
  56. package/src/hooks/registry.ts +7 -2
  57. package/src/observability/correlation.ts +4 -3
  58. package/src/observability/event-bus.ts +10 -1
  59. package/src/observability/event-to-metric.ts +8 -7
  60. package/src/observability/exporters/adapter.ts +6 -9
  61. package/src/observability/exporters/otlp-exporter.ts +81 -0
  62. package/src/observability/metric-retention.ts +4 -0
  63. package/src/observability/metric-sink.ts +14 -4
  64. package/src/prompt/prompt-runtime.ts +15 -0
  65. package/src/runtime/adaptive-plan.ts +1 -1
  66. package/src/runtime/async-runner.ts +13 -4
  67. package/src/runtime/background-runner.ts +32 -23
  68. package/src/runtime/capability-inventory.ts +1 -1
  69. package/src/runtime/chain-runner.ts +5 -5
  70. package/src/runtime/checkpoint.ts +6 -16
  71. package/src/runtime/child-pi-constants.ts +42 -0
  72. package/src/runtime/child-pi-kill.ts +180 -0
  73. package/src/runtime/child-pi-spawn.ts +234 -0
  74. package/src/runtime/child-pi-steering.ts +128 -0
  75. package/src/runtime/child-pi-streams.ts +296 -0
  76. package/src/runtime/child-pi-transcript.ts +169 -0
  77. package/src/runtime/child-pi.ts +80 -822
  78. package/src/runtime/coalesce-tasks.ts +1 -1
  79. package/src/runtime/compact-stages/tail-capture-stage.ts +1 -1
  80. package/src/runtime/crash-recovery.ts +63 -47
  81. package/src/runtime/delivery-coordinator.ts +1 -1
  82. package/src/runtime/deterministic-ast.ts +2 -2
  83. package/src/runtime/diagnostic-export.ts +2 -1
  84. package/src/runtime/dwf-state-store.ts +5 -0
  85. package/src/runtime/dynamic-workflow-context.ts +78 -18
  86. package/src/runtime/dynamic-workflow-runner.ts +18 -2
  87. package/src/runtime/foreground-control.ts +3 -2
  88. package/src/runtime/goal-evaluator.ts +59 -27
  89. package/src/runtime/goal-loop-runner.ts +46 -13
  90. package/src/runtime/goal-state-store.ts +1 -1
  91. package/src/runtime/handoff-manager.ts +0 -2
  92. package/src/runtime/intercom-bridge.ts +0 -2
  93. package/src/runtime/live-agent-manager.ts +30 -5
  94. package/src/runtime/live-extension-bridge.ts +0 -2
  95. package/src/runtime/live-session-runtime.ts +65 -57
  96. package/src/runtime/manifest-cache.ts +59 -0
  97. package/src/runtime/mcp-proxy.ts +1 -1
  98. package/src/runtime/orphan-worker-registry.ts +3 -3
  99. package/src/runtime/output-validator.ts +1 -1
  100. package/src/runtime/path-overlap.ts +1 -1
  101. package/src/runtime/pi-args.ts +3 -3
  102. package/src/runtime/pi-json-output.ts +4 -3
  103. package/src/runtime/pi-spawn.ts +7 -1
  104. package/src/runtime/pipeline-runner.ts +1 -4
  105. package/src/runtime/post-checks.ts +0 -1
  106. package/src/runtime/retry-runner.ts +1 -1
  107. package/src/runtime/run-coalesced-task-group.ts +18 -23
  108. package/src/runtime/scheduler.ts +2 -0
  109. package/src/runtime/session-resources.ts +1 -1
  110. package/src/runtime/settings-store.ts +31 -1
  111. package/src/runtime/skill-effectiveness.ts +2 -2
  112. package/src/runtime/stale-reconciler.ts +23 -53
  113. package/src/runtime/subagent-manager.ts +19 -3
  114. package/src/runtime/task-output-context.ts +2 -1
  115. package/src/runtime/task-runner/prompt-builder.ts +3 -3
  116. package/src/runtime/task-runner/run-projection.ts +3 -1
  117. package/src/runtime/task-runner/state-helpers.ts +13 -1
  118. package/src/runtime/task-runner.ts +31 -6
  119. package/src/runtime/team-runner.ts +8 -2
  120. package/src/runtime/verification-gates.ts +54 -5
  121. package/src/schema/config-schema.ts +19 -1
  122. package/src/schema/team-tool-schema.ts +2 -0
  123. package/src/skills/discover-skills.ts +25 -15
  124. package/src/state/active-run-registry.ts +0 -1
  125. package/src/state/atomic-write.ts +37 -11
  126. package/src/state/blob-store.ts +3 -9
  127. package/src/state/contracts.ts +5 -1
  128. package/src/state/crew-init.ts +3 -2
  129. package/src/state/decision-ledger.ts +2 -2
  130. package/src/state/event-log-rotation.ts +1 -1
  131. package/src/state/event-log.ts +4 -3
  132. package/src/state/gitignore-manager.ts +2 -1
  133. package/src/state/health-store.ts +5 -2
  134. package/src/state/instinct-store.ts +11 -2
  135. package/src/state/locks.ts +132 -49
  136. package/src/state/mailbox.ts +3 -4
  137. package/src/state/observation-store.ts +1 -13
  138. package/src/state/run-cache.ts +12 -12
  139. package/src/state/run-graph.ts +2 -1
  140. package/src/state/run-metrics.ts +1 -1
  141. package/src/state/schedule.ts +7 -1
  142. package/src/state/state-store.ts +9 -3
  143. package/src/state/tiered-eval.ts +1 -1
  144. package/src/state/worker-atomic-writer.ts +1 -0
  145. package/src/ui/dynamic-border.ts +13 -3
  146. package/src/ui/live-conversation-overlay.ts +7 -27
  147. package/src/ui/live-duration.ts +2 -2
  148. package/src/ui/live-run-sidebar.ts +56 -32
  149. package/src/ui/mascot.ts +19 -13
  150. package/src/ui/powerbar-publisher.ts +244 -178
  151. package/src/ui/render-coalescer.ts +44 -3
  152. package/src/ui/render-diff.ts +2 -2
  153. package/src/ui/run-dashboard.ts +122 -21
  154. package/src/ui/run-event-bus.ts +66 -0
  155. package/src/ui/run-snapshot-cache.ts +84 -81
  156. package/src/ui/settings-overlay.ts +3 -3
  157. package/src/ui/status-colors.ts +24 -0
  158. package/src/ui/tool-progress-formatter.ts +1 -1
  159. package/src/ui/tool-render.ts +3 -3
  160. package/src/ui/transcript-cache.ts +5 -10
  161. package/src/ui/transcript-entries.ts +68 -72
  162. package/src/ui/transcript-viewer.ts +54 -14
  163. package/src/ui/widget/index.ts +26 -15
  164. package/src/ui/widget/widget-model.ts +18 -9
  165. package/src/ui/widget/widget-renderer.ts +2 -5
  166. package/src/utils/fingerprint.ts +1 -1
  167. package/src/utils/paths.ts +1 -1
  168. package/src/utils/project-detector.ts +1 -1
  169. package/src/worktree/worktree-manager.ts +47 -19
  170. package/src/state/atomic-write-v2.ts +0 -108
@@ -9,7 +9,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
11
  import { Box, Text } from "./layout-primitives.ts";
12
- import { runEventBus } from "./run-event-bus.ts";
12
+ import { RenderScheduler } from "./render-scheduler.ts";
13
+ import { runEventBusAsRenderScheduler } from "./run-event-bus.ts";
13
14
  import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
14
15
  import { spinnerBucket, spinnerFrame } from "./spinner.ts";
15
16
  import { colorizeStatusGlyphs, iconForStatus } from "./status-colors.ts";
@@ -60,7 +61,7 @@ export class LiveRunSidebar {
60
61
  private readonly theme: CrewTheme;
61
62
  private readonly config: CrewUiConfig;
62
63
  private readonly unsubscribeTheme: () => void;
63
- private readonly unsubscribeEventBus: () => void;
64
+ private readonly renderScheduler: RenderScheduler;
64
65
  private readonly snapshotCache?: RunSnapshotCache;
65
66
  private cachedLines: string[] = [];
66
67
  private cachedWidth = 0;
@@ -83,16 +84,21 @@ export class LiveRunSidebar {
83
84
  this.config = input.config ?? {};
84
85
  this.snapshotCache = input.snapshotCache;
85
86
  this.unsubscribeTheme = subscribeThemeChange(input.theme, () => this.invalidate());
86
- this.unsubscribeEventBus = (() => {
87
- const unsub1 = runEventBus.onChannel("run:state", () => this.invalidate());
88
- const unsub2 = runEventBus.onChannel("worker:lifecycle", () => this.invalidate());
89
- const unsub3 = runEventBus.onChannel("ui:invalidate", () => this.invalidate());
90
- return () => {
91
- unsub1();
92
- unsub2();
93
- unsub3();
94
- };
95
- })();
87
+ // 1.10 (UI-P1-1): route run:state / worker:lifecycle / ui:invalidate
88
+ // through a RenderScheduler (debounce + fallback) instead of three
89
+ // direct runEventBus.onChannel subscriptions. With 3 overlays
90
+ // subscribing independently a single event triggered up to 9 callbacks
91
+ // and ~150 invalidates/sec under load. The scheduler collapses bursts
92
+ // into one debounced invalidate.
93
+ this.renderScheduler = new RenderScheduler(
94
+ runEventBusAsRenderScheduler(["run:state", "worker:lifecycle", "ui:invalidate"]),
95
+ () => this.invalidate(),
96
+ {
97
+ debounceMs: 75,
98
+ fallbackMs: 750,
99
+ events: ["run:state", "worker:lifecycle", "ui:invalidate"],
100
+ },
101
+ );
96
102
  }
97
103
 
98
104
  private buildSignature(
@@ -152,34 +158,52 @@ export class LiveRunSidebar {
152
158
  this.autoCloseTimeout = undefined;
153
159
  }
154
160
  this.unsubscribeTheme();
155
- this.unsubscribeEventBus();
161
+ this.renderScheduler.dispose();
156
162
  }
157
163
 
158
164
  render(width: number): string[] {
159
165
  const w = Math.max(36, width);
160
- const loaded = loadRunManifestById(this.cwd, this.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
161
- if (!loaded) {
162
- return renderLines(
163
- [
164
- border("╭", "─", "╮", w),
165
- line(`${this.theme.fg("accent", "▐")} ${this.theme.bold("pi-crew live sidebar")}`, w),
166
- line("run not found", w),
167
- border("╰", "─", "╯", w),
168
- ],
169
- w,
170
- );
171
- }
172
166
 
167
+ // P0-6: render from snapshots only — never read disk on every render tick.
168
+ // Production wires a snapshotCache (extension/registration/ui.ts). When
169
+ // the cache hasn't populated yet we paint a single "(loading…)" line so
170
+ // the pre-load frame is well-formed instead of an empty panel. When the
171
+ // cache is undefined (tests/dev) we fall back to direct disk reads so
172
+ // existing unit tests keep working.
173
+ let run: import("../state/types.ts").TeamRunManifest;
174
+ let tasks: TeamTaskState[];
175
+ let rawAgents: ReturnType<typeof readCrewAgents>;
173
176
  let snapshot: RunUiSnapshot | undefined;
174
- try {
175
- snapshot = this.snapshotCache?.refreshIfStale(this.runId);
176
- } catch {
177
- snapshot = undefined;
177
+ if (this.snapshotCache) {
178
+ try {
179
+ snapshot = this.snapshotCache.refreshIfStale(this.runId);
180
+ } catch {
181
+ snapshot = undefined;
182
+ }
183
+ if (!snapshot) {
184
+ return ["(loading…)"];
185
+ }
186
+ run = snapshot.manifest;
187
+ tasks = snapshot.tasks;
188
+ rawAgents = snapshot.agents;
189
+ } else {
190
+ const loaded = loadRunManifestById(this.cwd, this.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
191
+ if (!loaded) {
192
+ return renderLines(
193
+ [
194
+ border("╭", "─", "╮", w),
195
+ line(`${this.theme.fg("accent", "▐")} ${this.theme.bold("pi-crew live sidebar")}`, w),
196
+ line("run not found", w),
197
+ border("╰", "─", "╯", w),
198
+ ],
199
+ w,
200
+ );
201
+ }
202
+ run = loaded.manifest;
203
+ tasks = readTasks(run.tasksPath);
204
+ rawAgents = readCrewAgents(run);
178
205
  }
179
- const run = snapshot?.manifest ?? loaded.manifest;
180
- const tasks = snapshot?.tasks ?? readTasks(run.tasksPath);
181
206
  const controlConfig = resolveCrewControlConfig({ ui: this.config });
182
- const rawAgents = snapshot?.agents ?? readCrewAgents(run);
183
207
  const agents = rawAgents.map((agent) => applyAttentionState(run, agent, controlConfig));
184
208
  const active = agents.filter((agent) => agent.status === "running");
185
209
  const completed = agents.filter((agent) => agent.status !== "running").slice(-5);
package/src/ui/mascot.ts CHANGED
@@ -307,23 +307,29 @@ export class AnimatedMascot {
307
307
  const state = this.effectState;
308
308
  if (state.phase === undefined || state.glitchFrames === undefined) return true;
309
309
  if (state.phase < state.glitchFrames) {
310
- this.currentArminGrid = this.finalArminGrid.map((row) => {
311
- const offset = Math.floor(Math.random() * 7) - 3;
312
- const glitchRow = [...row];
313
- if (Math.random() < 0.3) {
314
- const shifted = glitchRow.slice(offset).concat(glitchRow.slice(0, offset));
315
- return shifted.slice(0, ARMIN_WIDTH);
310
+ const offset = Math.floor(Math.random() * 7) - 3;
311
+ const swapRow = Math.floor(Math.random() * ARMIN_DISPLAY_HEIGHT);
312
+ const doShift = Math.random() < 0.3;
313
+ const doSwap = !doShift && Math.random() < 0.2;
314
+ for (let row = 0; row < ARMIN_DISPLAY_HEIGHT; row++) {
315
+ const src = doSwap ? this.finalArminGrid[swapRow] : this.finalArminGrid[row];
316
+ for (let x = 0; x < ARMIN_WIDTH; x++) {
317
+ if (doShift) {
318
+ this.currentArminGrid[row][x] = src[(x + offset + ARMIN_WIDTH) % ARMIN_WIDTH];
319
+ } else {
320
+ this.currentArminGrid[row][x] = src[x];
321
+ }
316
322
  }
317
- if (Math.random() < 0.2) {
318
- const swapRow = Math.floor(Math.random() * ARMIN_DISPLAY_HEIGHT);
319
- return [...this.finalArminGrid[swapRow]];
320
- }
321
- return glitchRow;
322
- });
323
+ }
323
324
  state.phase++;
324
325
  return false;
325
326
  }
326
- this.currentArminGrid = this.finalArminGrid.map((row) => [...row]);
327
+ // Restore final grid in-place
328
+ for (let row = 0; row < ARMIN_DISPLAY_HEIGHT; row++) {
329
+ for (let x = 0; x < ARMIN_WIDTH; x++) {
330
+ this.currentArminGrid[row][x] = this.finalArminGrid[row][x];
331
+ }
332
+ }
327
333
  return true;
328
334
  }
329
335
 
@@ -10,7 +10,6 @@ import { aggregateUsage } from "../state/usage.ts";
10
10
  import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
11
11
  import { logInternalError } from "../utils/internal-error.ts";
12
12
  import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
13
- import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
14
13
  import { RenderCoalescer } from "./render-coalescer.ts";
15
14
  import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
16
15
  import { notificationBadge } from "./widget/widget-formatters.ts";
@@ -99,155 +98,16 @@ export function updatePiCrewPowerbar(
99
98
  notificationCount = 0,
100
99
  preloadedManifests?: TeamRunManifest[],
101
100
  ): void {
102
- if (config?.powerbar === false) return;
103
- const useStatusFallback = !hasPowerbarConsumer(events);
104
- const runs = preloadedManifests ?? (manifestCache ? manifestCache.list(20) : listRecentRuns(cwd, 20));
105
- const active = runs
106
- .map((run) => {
107
- let snapshot: RunUiSnapshot | undefined;
108
- try {
109
- // 1.2: render path is read-only. Use cache.get() only; the background
110
- // preload loop in register.ts populates entries on its own cadence.
111
- snapshot = snapshotCache?.get(run.runId);
112
- } catch (error) {
113
- logInternalError("powerbar.snapshot", error, run.runId);
114
- }
115
- if (snapshot)
116
- return {
117
- run: snapshot.manifest,
118
- agents: snapshot.agents,
119
- tasks: snapshot.tasks,
120
- snapshot,
121
- };
122
- let agents: ReturnType<typeof readCrewAgents> = [];
123
- try {
124
- agents = readCrewAgents(run);
125
- } catch (error) {
126
- logInternalError("powerbar.readCrewAgents", error, run.runId);
127
- }
128
- return { run, agents, tasks: readTasks(run.tasksPath), snapshot };
129
- })
130
- .filter((item) => isDisplayActiveRun(item.run, item.agents));
131
- if (!active.length) {
132
- lastActiveKey = undefined;
133
- lastProgressKey = undefined;
134
- lastStepsKey = undefined;
135
- safeEmit(events, "powerbar:update", { id: "pi-crew-active" });
136
- safeEmit(events, "powerbar:update", { id: "pi-crew-progress" });
137
- safeEmit(events, "powerbar:update", { id: "pi-crew-steps" });
138
- return;
139
- }
140
- const agents = active.flatMap((item) => item.agents);
141
- const tasks = active.flatMap((item) => item.tasks);
142
- const running = agents.filter((agent) => agent.status === "running").length;
143
- const waiting = active.reduce(
144
- (sum, item) =>
145
- sum +
146
- (item.snapshot
147
- ? item.snapshot.progress.queued + (item.snapshot.progress.waiting ?? 0)
148
- : item.tasks.reduce((s, t) => s + (t.status === "queued" || t.status === "waiting" ? 1 : 0), 0)),
149
- 0,
150
- );
151
- const completed = active.reduce(
152
- (sum, item) => sum + (item.snapshot?.progress.completed ?? item.tasks.reduce((s, t) => s + (t.status === "completed" ? 1 : 0), 0)),
153
- 0,
154
- );
155
- const total = Math.max(1, active.reduce((sum, item) => sum + (item.snapshot?.progress.total ?? item.tasks.length), 0) || agents.length);
156
- const usage = aggregateUsage(tasks);
157
- const snapshotTokens = active.reduce(
158
- (sum, item) => sum + (item.snapshot ? item.snapshot.usage.tokensIn + item.snapshot.usage.tokensOut : 0),
159
- 0,
160
- );
161
- const hasUsage = usage && (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0) > 0;
162
- const tokenTotal = hasUsage
163
- ? (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0)
164
- : snapshotTokens;
165
- const model =
166
- config?.showModel === false
167
- ? undefined
168
- : agents
169
- .find((agent) => agent.model)
170
- ?.model?.split("/")
171
- .at(-1);
172
- const tokenText = config?.showTokens === false || !tokenTotal ? undefined : compactTokens(tokenTotal);
173
- const liveRunning = listLiveAgents().filter((a) => a.status === "running").length;
174
- // Always show consistent status: running count + queued count from live tasks only
175
- // Avoid snapshot cache for counts to prevent UI jumping
176
- const runningCount = agents.filter((a) => a.status === "running").length;
177
- // Count queued/waiting tasks directly from tasks array (not snapshot) for consistency
178
- const queuedCount = active.reduce(
179
- (sum, item) => sum + item.tasks.reduce((s, t) => s + (t.status === "queued" || t.status === "waiting" ? 1 : 0), 0),
180
- 0,
181
- );
182
- // Format: "1 running", "2 running · 1 queued", "3 queued", "idle"
183
- const runningLabel = runningCount === 1 ? "1 running" : `${runningCount} running`;
184
- const queuedLabel = queuedCount === 1 ? "1 queued" : `${queuedCount} queued`;
185
- const crewStatus =
186
- runningCount > 0 && queuedCount > 0
187
- ? `${runningLabel} · ${queuedLabel}`
188
- : runningCount > 0
189
- ? runningLabel
190
- : queuedCount > 0
191
- ? queuedLabel
192
- : "idle";
193
- const liveSuffix = liveRunning > 0 ? ` (${liveRunning} live)` : "";
194
- const notificationText = notificationBadge(notificationCount);
195
- // Always show model + tokens as suffix when available (for activePayload consistency)
196
- const suffixParts = [model, tokenText].filter(Boolean);
197
- const activeSuffix = suffixParts.length > 0 ? suffixParts.join(" · ") : undefined;
198
- // Progress always includes token count for consistency
199
- const progressSuffix = `${completed}/${total}${tokenText ? ` · ${tokenText}` : ""}`;
200
- // Build complete, always-consistent fallback text AND event payload to prevent UI flickering
201
- // Both fallback and events must use the SAME format - no conditional display
202
- // Format: "⚙ 1 running · 1 queued · model · 30k · 0/1" (never changes based on availability)
203
- const progressPart = `${completed}/${total}`;
204
- const allParts = [`⚙ ${crewStatus}`, model ?? "", tokenText ?? "", progressPart].filter(Boolean);
205
- const unifiedText = allParts.join(" · ");
206
- // activePayload.text includes notification badge for event payload
207
- const activePayload = {
208
- id: "pi-crew-active",
209
- icon: "⚙",
210
- text: `⚙ ${crewStatus}${liveSuffix}${notificationText}${activeSuffix ? ` · ${activeSuffix}` : ""}`,
211
- suffix: activeSuffix,
212
- color: running ? "accent" : "warning",
213
- } as const;
214
- const progressPayload = {
215
- id: "pi-crew-progress",
216
- text: (active[0]?.run as TeamRunManifest)?.team ?? "crew",
217
- bar: Math.round((completed / total) * 100),
218
- suffix: progressSuffix,
219
- color: completed === total ? "success" : "accent",
220
- barSegments: 8,
221
- } as const;
222
- // Build step progress: "explorer > planner > executor > verifier" with current step highlighted
223
- const stepsPayload = buildStepsPayload(active, tasks);
224
- // 1.8: dedup per segment using a key over every visible field. Previously
225
- // the dedup string only carried text/suffix/running, so changes to `bar`
226
- // (progress %) or `color` could be swallowed and stale UI emitted again
227
- // later as a single noisy burst.
228
- const activeKey = powerbarKey(activePayload);
229
- const progressKey = powerbarKey(progressPayload);
230
- const stepsKey = powerbarKey(stepsPayload);
231
- if (activeKey !== lastActiveKey) {
232
- lastActiveKey = activeKey;
233
- safeEmit(events, "powerbar:update", activePayload);
234
- }
235
- if (progressKey !== lastProgressKey) {
236
- lastProgressKey = progressKey;
237
- safeEmit(events, "powerbar:update", progressPayload);
238
- }
239
- if (stepsKey !== lastStepsKey) {
240
- lastStepsKey = stepsKey;
241
- safeEmit(events, "powerbar:update", stepsPayload);
242
- }
243
- // Never call setStatusFallback - crew-widget manages "pi-crew" status with its own widget format
244
- // Powerbar only emits events; it does not set status directly
101
+ powerbarPublisher.update(events, cwd, config, manifestCache, snapshotCache, ctx, notificationCount, preloadedManifests);
245
102
  }
246
103
 
247
- // --- Dedup state: skip emit if segment data unchanged ---
248
- let lastActiveKey: string | undefined;
249
- let lastProgressKey: string | undefined;
250
- let lastStepsKey: string | undefined;
104
+ // --- Dedup state + coalescer live on a class instance so they survive hot-reload
105
+ // cleanly: previously these were module-level globals (`lastActiveKey`,
106
+ // `latestArgs`, `powerbarCoalescer`) that survived across reloads and could
107
+ // leak between independent sessions. Encapsulating the state on an instance
108
+ // lets `dispose()` tear it down and a fresh `new PowerbarPublisher()` start
109
+ // from clean slate. The module-level functions remain for back-compat and
110
+ // delegate to a process-wide singleton.
251
111
 
252
112
  interface PowerbarPayloadShape {
253
113
  id?: string;
@@ -333,14 +193,239 @@ interface PowerbarUpdateArgs {
333
193
  preloadedManifests?: TeamRunManifest[];
334
194
  }
335
195
 
336
- let latestArgs: PowerbarUpdateArgs | null = null;
196
+ /**
197
+ * Owns the per-segment dedup keys and the coalescer. Module-level functions
198
+ * (`updatePiCrewPowerbar`, `requestPowerbarUpdate`, etc.) are thin facades
199
+ * over a process-wide singleton so existing call sites and tests keep
200
+ * working without code changes.
201
+ */
202
+ class PowerbarPublisher {
203
+ #lastActiveKey: string | undefined;
204
+ #lastProgressKey: string | undefined;
205
+ #lastStepsKey: string | undefined;
206
+ #latestArgs: PowerbarUpdateArgs | null = null;
207
+ readonly #coalescer: RenderCoalescer;
208
+
209
+ constructor() {
210
+ this.#coalescer = new RenderCoalescer(() => {
211
+ const a = this.#latestArgs;
212
+ this.#latestArgs = null;
213
+ if (!a) return;
214
+ this.update(a.events, a.cwd, a.config, a.manifestCache, a.snapshotCache, a.ctx, a.notificationCount, a.preloadedManifests);
215
+ }, 200);
216
+ }
217
+
218
+ update(
219
+ events: EventBus,
220
+ cwd: string,
221
+ config?: CrewUiConfig,
222
+ manifestCache?: ManifestCache,
223
+ snapshotCache?: RunSnapshotCache,
224
+ ctx?: StatusContext,
225
+ notificationCount = 0,
226
+ preloadedManifests?: TeamRunManifest[],
227
+ ): void {
228
+ if (config?.powerbar === false) return;
229
+ const useStatusFallback = !hasPowerbarConsumer(events);
230
+ const runs = preloadedManifests ?? (manifestCache ? manifestCache.list(20) : listRecentRuns(cwd, 20));
231
+ const active = runs
232
+ .map((run) => {
233
+ let snapshot: RunUiSnapshot | undefined;
234
+ try {
235
+ // 1.2: render path is read-only. Use cache.get() only; the background
236
+ // preload loop in register.ts populates entries on its own cadence.
237
+ snapshot = snapshotCache?.get(run.runId);
238
+ } catch (error) {
239
+ logInternalError("powerbar.snapshot", error, run.runId);
240
+ }
241
+ if (snapshot)
242
+ return {
243
+ run: snapshot.manifest,
244
+ agents: snapshot.agents,
245
+ tasks: snapshot.tasks,
246
+ snapshot,
247
+ };
248
+ let agents: ReturnType<typeof readCrewAgents> = [];
249
+ try {
250
+ agents = readCrewAgents(run);
251
+ } catch (error) {
252
+ logInternalError("powerbar.readCrewAgents", error, run.runId);
253
+ }
254
+ return { run, agents, tasks: readTasks(run.tasksPath), snapshot };
255
+ })
256
+ .filter((item) => isDisplayActiveRun(item.run, item.agents));
257
+ if (!active.length) {
258
+ this.#lastActiveKey = undefined;
259
+ this.#lastProgressKey = undefined;
260
+ this.#lastStepsKey = undefined;
261
+ safeEmit(events, "powerbar:update", { id: "pi-crew-active" });
262
+ safeEmit(events, "powerbar:update", { id: "pi-crew-progress" });
263
+ safeEmit(events, "powerbar:update", { id: "pi-crew-steps" });
264
+ return;
265
+ }
266
+ const agents = active.flatMap((item) => item.agents);
267
+ const tasks = active.flatMap((item) => item.tasks);
268
+ const running = agents.filter((agent) => agent.status === "running").length;
269
+ const waiting = active.reduce(
270
+ (sum, item) =>
271
+ sum +
272
+ (item.snapshot
273
+ ? item.snapshot.progress.queued + (item.snapshot.progress.waiting ?? 0)
274
+ : item.tasks.reduce((s, t) => s + (t.status === "queued" || t.status === "waiting" ? 1 : 0), 0)),
275
+ 0,
276
+ );
277
+ const completed = active.reduce(
278
+ (sum, item) =>
279
+ sum + (item.snapshot?.progress.completed ?? item.tasks.reduce((s, t) => s + (t.status === "completed" ? 1 : 0), 0)),
280
+ 0,
281
+ );
282
+ const total = Math.max(
283
+ 1,
284
+ active.reduce((sum, item) => sum + (item.snapshot?.progress.total ?? item.tasks.length), 0) || agents.length,
285
+ );
286
+ const usage = aggregateUsage(tasks);
287
+ const snapshotTokens = active.reduce(
288
+ (sum, item) => sum + (item.snapshot ? item.snapshot.usage.tokensIn + item.snapshot.usage.tokensOut : 0),
289
+ 0,
290
+ );
291
+ const hasUsage = usage && (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0) > 0;
292
+ const tokenTotal = hasUsage
293
+ ? (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0)
294
+ : snapshotTokens;
295
+ const model =
296
+ config?.showModel === false
297
+ ? undefined
298
+ : agents
299
+ .find((agent) => agent.model)
300
+ ?.model?.split("/")
301
+ .at(-1);
302
+ const tokenText = config?.showTokens === false || !tokenTotal ? undefined : compactTokens(tokenTotal);
303
+ const liveRunning = listLiveAgents().filter((a) => a.status === "running").length;
304
+ // Always show consistent status: running count + queued count from live tasks only
305
+ // Avoid snapshot cache for counts to prevent UI jumping
306
+ const runningCount = agents.filter((a) => a.status === "running").length;
307
+ // Count queued/waiting tasks directly from tasks array (not snapshot) for consistency
308
+ const queuedCount = active.reduce(
309
+ (sum, item) => sum + item.tasks.reduce((s, t) => s + (t.status === "queued" || t.status === "waiting" ? 1 : 0), 0),
310
+ 0,
311
+ );
312
+ // Format: "1 running", "2 running · 1 queued", "3 queued", "idle"
313
+ const runningLabel = runningCount === 1 ? "1 running" : `${runningCount} running`;
314
+ const queuedLabel = queuedCount === 1 ? "1 queued" : `${queuedCount} queued`;
315
+ const crewStatus =
316
+ runningCount > 0 && queuedCount > 0
317
+ ? `${runningLabel} · ${queuedLabel}`
318
+ : runningCount > 0
319
+ ? runningLabel
320
+ : queuedCount > 0
321
+ ? queuedLabel
322
+ : "idle";
323
+ const liveSuffix = liveRunning > 0 ? ` (${liveRunning} live)` : "";
324
+ const notificationText = notificationBadge(notificationCount);
325
+ // Always show model + tokens as suffix when available (for activePayload consistency)
326
+ const suffixParts = [model, tokenText].filter(Boolean);
327
+ const activeSuffix = suffixParts.length > 0 ? suffixParts.join(" · ") : undefined;
328
+ // Progress always includes token count for consistency
329
+ const progressSuffix = `${completed}/${total}${tokenText ? ` · ${tokenText}` : ""}`;
330
+ // Build complete, always-consistent fallback text AND event payload to prevent UI flickering
331
+ // Both fallback and events must use the SAME format - no conditional display
332
+ // Format: "⚙ 1 running · 1 queued · model · 30k · 0/1" (never changes based on availability)
333
+ const progressPart = `${completed}/${total}`;
334
+ const allParts = [`⚙ ${crewStatus}`, model ?? "", tokenText ?? "", progressPart].filter(Boolean);
335
+ const unifiedText = allParts.join(" · ");
336
+ // activePayload.text includes notification badge for event payload
337
+ const activePayload = {
338
+ id: "pi-crew-active",
339
+ icon: "⚙",
340
+ text: `⚙ ${crewStatus}${liveSuffix}${notificationText}${activeSuffix ? ` · ${activeSuffix}` : ""}`,
341
+ suffix: activeSuffix,
342
+ color: running ? "accent" : "warning",
343
+ } as const;
344
+ const progressPayload = {
345
+ id: "pi-crew-progress",
346
+ text: (active[0]?.run as TeamRunManifest)?.team ?? "crew",
347
+ bar: Math.round((completed / total) * 100),
348
+ suffix: progressSuffix,
349
+ color: completed === total ? "success" : "accent",
350
+ barSegments: 8,
351
+ } as const;
352
+ // Build step progress: "explorer > planner > executor > verifier" with current step highlighted
353
+ const stepsPayload = buildStepsPayload(active, tasks);
354
+ // 1.8: dedup per segment using a key over every visible field. Previously
355
+ // the dedup string only carried text/suffix/running, so changes to `bar`
356
+ // (progress %) or `color` could be swallowed and stale UI emitted again
357
+ // later as a single noisy burst.
358
+ const activeKey = powerbarKey(activePayload);
359
+ const progressKey = powerbarKey(progressPayload);
360
+ const stepsKey = powerbarKey(stepsPayload);
361
+ if (activeKey !== this.#lastActiveKey) {
362
+ this.#lastActiveKey = activeKey;
363
+ safeEmit(events, "powerbar:update", activePayload);
364
+ }
365
+ if (progressKey !== this.#lastProgressKey) {
366
+ this.#lastProgressKey = progressKey;
367
+ safeEmit(events, "powerbar:update", progressPayload);
368
+ }
369
+ if (stepsKey !== this.#lastStepsKey) {
370
+ this.#lastStepsKey = stepsKey;
371
+ safeEmit(events, "powerbar:update", stepsPayload);
372
+ }
373
+ // Never call setStatusFallback - crew-widget manages "pi-crew" status with its own widget format
374
+ // Powerbar only emits events; it does not set status directly
375
+ }
376
+
377
+ /**
378
+ * Request a coalesced powerbar update. Multiple rapid calls are batched
379
+ * into a single render pass within 200ms, preventing UI flicker from
380
+ * event bursts.
381
+ */
382
+ request(
383
+ events: EventBus,
384
+ cwd: string,
385
+ config?: CrewUiConfig,
386
+ manifestCache?: ManifestCache,
387
+ snapshotCache?: RunSnapshotCache,
388
+ ctx?: StatusContext,
389
+ notificationCount = 0,
390
+ preloadedManifests?: TeamRunManifest[],
391
+ ): void {
392
+ if (config?.powerbar === false) return;
393
+ this.#latestArgs = {
394
+ events,
395
+ cwd,
396
+ config,
397
+ manifestCache,
398
+ snapshotCache,
399
+ ctx,
400
+ notificationCount,
401
+ preloadedManifests,
402
+ };
403
+ this.#coalescer.request();
404
+ }
337
405
 
338
- const powerbarCoalescer = new RenderCoalescer(() => {
339
- if (!latestArgs) return;
340
- const a = latestArgs;
341
- latestArgs = null;
342
- updatePiCrewPowerbar(a.events, a.cwd, a.config, a.manifestCache, a.snapshotCache, a.ctx, a.notificationCount, a.preloadedManifests);
343
- }, 200);
406
+ clear(events: EventBus): void {
407
+ this.#lastActiveKey = undefined;
408
+ this.#lastProgressKey = undefined;
409
+ this.#lastStepsKey = undefined;
410
+ safeEmit(events, "powerbar:update", { id: "pi-crew-active" });
411
+ safeEmit(events, "powerbar:update", { id: "pi-crew-progress" });
412
+ safeEmit(events, "powerbar:update", { id: "pi-crew-steps" });
413
+ }
414
+
415
+ /** Reset dedup state on session lifecycle events. */
416
+ resetDedupState(): void {
417
+ this.#lastActiveKey = undefined;
418
+ this.#lastProgressKey = undefined;
419
+ this.#lastStepsKey = undefined;
420
+ }
421
+
422
+ dispose(): void {
423
+ this.#coalescer.flush();
424
+ this.#coalescer.dispose();
425
+ }
426
+ }
427
+
428
+ const powerbarPublisher = new PowerbarPublisher();
344
429
 
345
430
  /**
346
431
  * Request a coalesced powerbar update. Multiple rapid calls are batched into a single
@@ -356,38 +441,19 @@ export function requestPowerbarUpdate(
356
441
  notificationCount = 0,
357
442
  preloadedManifests?: TeamRunManifest[],
358
443
  ): void {
359
- if (config?.powerbar === false) return;
360
- latestArgs = {
361
- events,
362
- cwd,
363
- config,
364
- manifestCache,
365
- snapshotCache,
366
- ctx,
367
- notificationCount,
368
- preloadedManifests,
369
- };
370
- powerbarCoalescer.request();
444
+ powerbarPublisher.request(events, cwd, config, manifestCache, snapshotCache, ctx, notificationCount, preloadedManifests);
371
445
  }
372
446
 
373
447
  /** Dispose the powerbar coalescer. Call during extension cleanup. */
374
448
  export function disposePowerbarCoalescer(): void {
375
- powerbarCoalescer.flush();
376
- powerbarCoalescer.dispose();
449
+ powerbarPublisher.dispose();
377
450
  }
378
451
 
379
452
  export function clearPiCrewPowerbar(events: EventBus): void {
380
- lastActiveKey = undefined;
381
- lastProgressKey = undefined;
382
- lastStepsKey = undefined;
383
- safeEmit(events, "powerbar:update", { id: "pi-crew-active" });
384
- safeEmit(events, "powerbar:update", { id: "pi-crew-progress" });
385
- safeEmit(events, "powerbar:update", { id: "pi-crew-steps" });
453
+ powerbarPublisher.clear(events);
386
454
  }
387
455
 
388
456
  /** Reset dedup state on session lifecycle events. */
389
457
  export function resetPowerbarDedupState(): void {
390
- lastActiveKey = undefined;
391
- lastProgressKey = undefined;
392
- lastStepsKey = undefined;
458
+ powerbarPublisher.resetDedupState();
393
459
  }