pi-crew 0.1.45 → 0.1.49

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 (178) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +5 -5
  3. package/agents/analyst.md +11 -11
  4. package/agents/critic.md +11 -11
  5. package/agents/executor.md +11 -11
  6. package/agents/explorer.md +11 -11
  7. package/agents/planner.md +11 -11
  8. package/agents/reviewer.md +11 -11
  9. package/agents/security-reviewer.md +11 -11
  10. package/agents/test-engineer.md +11 -11
  11. package/agents/verifier.md +11 -11
  12. package/agents/writer.md +11 -11
  13. package/docs/next-upgrade-roadmap.md +808 -0
  14. package/docs/research/AGENT-EXECUTION-ARCHITECTURE.md +261 -0
  15. package/docs/research/AGENT-LIFECYCLE-COMPARISON.md +111 -0
  16. package/docs/research/AUDIT_OH_MY_PI.md +261 -0
  17. package/docs/research/AUDIT_PI_CREW.md +457 -0
  18. package/docs/research/CAVEMAN-DEEP-RESEARCH.md +281 -0
  19. package/docs/research/COMPARISON_OH_MY_PI_VS_PI_CREW.md +264 -0
  20. package/docs/research/DEEP-RESEARCH-PI-POWERBAR.md +343 -0
  21. package/docs/research/DEEP_RESEARCH_SUBAGENT_ARCHITECTURE.md +480 -0
  22. package/docs/research/GAP_CLOSURE_IMPLEMENTATION_PLAN.md +354 -0
  23. package/docs/research/IMPLEMENTATION_PLAN.md +385 -0
  24. package/docs/research/LIVE-SESSION-PRODUCTION-READY-PLAN.md +502 -0
  25. package/docs/research/OH-MY-PI-DEEP-RESEARCH-v14.7.6.md +266 -0
  26. package/docs/research/REMAINING-GAPS-PLAN.md +363 -0
  27. package/docs/research/SESSION-SUMMARY-2026-05-08.md +146 -0
  28. package/docs/research/UI-RESPONSIVENESS-AUDIT.md +173 -0
  29. package/docs/research-awesome-agent-skills-distillation.md +100 -0
  30. package/docs/research-oh-my-pi-distillation.md +369 -0
  31. package/docs/source-runtime-refactor-map.md +24 -0
  32. package/docs/usage.md +3 -3
  33. package/install.mjs +52 -8
  34. package/package.json +99 -98
  35. package/schema.json +10 -1
  36. package/skills/async-worker-recovery/SKILL.md +42 -0
  37. package/skills/context-artifact-hygiene/SKILL.md +52 -0
  38. package/skills/delegation-patterns/SKILL.md +54 -0
  39. package/skills/mailbox-interactive/SKILL.md +40 -0
  40. package/skills/model-routing-context/SKILL.md +39 -0
  41. package/skills/multi-perspective-review/SKILL.md +58 -0
  42. package/skills/observability-reliability/SKILL.md +41 -0
  43. package/skills/orchestration/SKILL.md +157 -0
  44. package/skills/ownership-session-security/SKILL.md +41 -0
  45. package/skills/pi-extension-lifecycle/SKILL.md +39 -0
  46. package/skills/requirements-to-task-packet/SKILL.md +63 -0
  47. package/skills/resource-discovery-config/SKILL.md +41 -0
  48. package/skills/runtime-state-reader/SKILL.md +44 -0
  49. package/skills/secure-agent-orchestration-review/SKILL.md +45 -0
  50. package/skills/state-mutation-locking/SKILL.md +42 -0
  51. package/skills/systematic-debugging/SKILL.md +67 -0
  52. package/skills/ui-render-performance/SKILL.md +39 -0
  53. package/skills/verification-before-done/SKILL.md +57 -0
  54. package/skills/worktree-isolation/SKILL.md +39 -0
  55. package/src/agents/agent-config.ts +6 -0
  56. package/src/agents/agent-search.ts +98 -0
  57. package/src/agents/agent-serializer.ts +38 -34
  58. package/src/agents/discover-agents.ts +29 -15
  59. package/src/config/config.ts +72 -24
  60. package/src/config/defaults.ts +25 -0
  61. package/src/extension/autonomous-policy.ts +26 -33
  62. package/src/extension/help.ts +1 -0
  63. package/src/extension/management.ts +5 -0
  64. package/src/extension/project-init.ts +62 -2
  65. package/src/extension/register.ts +69 -22
  66. package/src/extension/registration/commands.ts +64 -25
  67. package/src/extension/registration/compaction-guard.ts +1 -1
  68. package/src/extension/registration/subagent-helpers.ts +8 -0
  69. package/src/extension/registration/subagent-tools.ts +149 -148
  70. package/src/extension/registration/team-tool.ts +14 -10
  71. package/src/extension/run-index.ts +35 -21
  72. package/src/extension/run-maintenance.ts +30 -5
  73. package/src/extension/team-tool/api.ts +47 -9
  74. package/src/extension/team-tool/cancel.ts +109 -5
  75. package/src/extension/team-tool/context.ts +8 -0
  76. package/src/extension/team-tool/intent-policy.ts +42 -0
  77. package/src/extension/team-tool/lifecycle-actions.ts +120 -79
  78. package/src/extension/team-tool/parallel-dispatch.ts +156 -0
  79. package/src/extension/team-tool/respond.ts +46 -18
  80. package/src/extension/team-tool/run.ts +55 -12
  81. package/src/extension/team-tool/status.ts +13 -2
  82. package/src/extension/team-tool-types.ts +3 -0
  83. package/src/extension/team-tool.ts +45 -14
  84. package/src/hooks/registry.ts +61 -0
  85. package/src/hooks/types.ts +41 -0
  86. package/src/observability/event-to-metric.ts +8 -1
  87. package/src/runtime/agent-control.ts +169 -63
  88. package/src/runtime/async-runner.ts +3 -1
  89. package/src/runtime/background-runner.ts +78 -53
  90. package/src/runtime/cancellation-token.ts +89 -0
  91. package/src/runtime/cancellation.ts +61 -0
  92. package/src/runtime/capability-inventory.ts +116 -0
  93. package/src/runtime/child-pi.ts +458 -444
  94. package/src/runtime/code-summary.ts +247 -0
  95. package/src/runtime/crash-recovery.ts +182 -0
  96. package/src/runtime/crew-agent-records.ts +70 -10
  97. package/src/runtime/crew-agent-runtime.ts +1 -0
  98. package/src/runtime/custom-tools/irc-tool.ts +201 -0
  99. package/src/runtime/custom-tools/submit-result-tool.ts +90 -0
  100. package/src/runtime/deadletter.ts +1 -0
  101. package/src/runtime/delivery-coordinator.ts +48 -25
  102. package/src/runtime/effectiveness.ts +81 -0
  103. package/src/runtime/event-stream-bridge.ts +90 -0
  104. package/src/runtime/live-agent-control.ts +2 -1
  105. package/src/runtime/live-agent-manager.ts +179 -85
  106. package/src/runtime/live-control-realtime.ts +1 -1
  107. package/src/runtime/live-extension-bridge.ts +150 -0
  108. package/src/runtime/live-irc.ts +92 -0
  109. package/src/runtime/live-session-health.ts +100 -0
  110. package/src/runtime/live-session-runtime.ts +599 -305
  111. package/src/runtime/manifest-cache.ts +17 -2
  112. package/src/runtime/mcp-proxy.ts +113 -0
  113. package/src/runtime/model-fallback.ts +6 -4
  114. package/src/runtime/notebook-helpers.ts +90 -0
  115. package/src/runtime/orphan-sentinel.ts +7 -0
  116. package/src/runtime/output-validator.ts +187 -0
  117. package/src/runtime/parallel-utils.ts +57 -0
  118. package/src/runtime/parent-guard.ts +80 -0
  119. package/src/runtime/pi-args.ts +18 -3
  120. package/src/runtime/process-status.ts +5 -1
  121. package/src/runtime/prose-compressor.ts +164 -0
  122. package/src/runtime/result-extractor.ts +121 -0
  123. package/src/runtime/retry-executor.ts +81 -64
  124. package/src/runtime/runtime-resolver.ts +23 -10
  125. package/src/runtime/semaphore.ts +131 -0
  126. package/src/runtime/sensitive-paths.ts +92 -0
  127. package/src/runtime/skill-instructions.ts +222 -0
  128. package/src/runtime/stale-reconciler.ts +4 -14
  129. package/src/runtime/stream-preview.ts +177 -0
  130. package/src/runtime/subagent-manager.ts +6 -2
  131. package/src/runtime/subprocess-tool-registry.ts +67 -0
  132. package/src/runtime/task-output-context.ts +177 -127
  133. package/src/runtime/task-runner/capabilities.ts +78 -0
  134. package/src/runtime/task-runner/live-executor.ts +107 -101
  135. package/src/runtime/task-runner/prompt-builder.ts +72 -8
  136. package/src/runtime/task-runner/prompt-pipeline.ts +64 -0
  137. package/src/runtime/task-runner/run-projection.ts +104 -0
  138. package/src/runtime/task-runner.ts +115 -5
  139. package/src/runtime/team-runner.ts +134 -19
  140. package/src/runtime/workspace-tree.ts +298 -0
  141. package/src/runtime/yield-handler.ts +189 -0
  142. package/src/schema/config-schema.ts +7 -0
  143. package/src/schema/team-tool-schema.ts +14 -4
  144. package/src/skills/discover-skills.ts +67 -0
  145. package/src/state/active-run-registry.ts +167 -0
  146. package/src/state/artifact-store.ts +4 -1
  147. package/src/state/atomic-write.ts +50 -1
  148. package/src/state/blob-store.ts +117 -0
  149. package/src/state/contracts.ts +2 -1
  150. package/src/state/event-log-rotation.ts +158 -0
  151. package/src/state/event-log.ts +52 -2
  152. package/src/state/mailbox.ts +129 -9
  153. package/src/state/state-store.ts +32 -5
  154. package/src/state/types.ts +64 -2
  155. package/src/teams/team-config.ts +1 -0
  156. package/src/ui/agent-management-overlay.ts +144 -0
  157. package/src/ui/crew-widget.ts +15 -5
  158. package/src/ui/dashboard-panes/cancellation-pane.ts +43 -0
  159. package/src/ui/dashboard-panes/capability-pane.ts +60 -0
  160. package/src/ui/dashboard-panes/mailbox-pane.ts +35 -11
  161. package/src/ui/dashboard-panes/progress-pane.ts +2 -0
  162. package/src/ui/live-run-sidebar.ts +4 -0
  163. package/src/ui/powerbar-publisher.ts +77 -15
  164. package/src/ui/render-coalescer.ts +51 -0
  165. package/src/ui/run-dashboard.ts +4 -0
  166. package/src/ui/run-event-bus.ts +209 -0
  167. package/src/ui/run-snapshot-cache.ts +78 -18
  168. package/src/ui/snapshot-types.ts +10 -0
  169. package/src/ui/transcript-entries.ts +258 -0
  170. package/src/utils/ids.ts +5 -0
  171. package/src/utils/incremental-reader.ts +104 -0
  172. package/src/utils/paths.ts +4 -2
  173. package/src/utils/scan-cache.ts +137 -0
  174. package/src/utils/sse-parser.ts +134 -0
  175. package/src/utils/task-name-generator.ts +337 -0
  176. package/src/utils/visual.ts +33 -2
  177. package/src/workflows/workflow-config.ts +1 -0
  178. package/src/worktree/cleanup.ts +2 -1
@@ -10,6 +10,7 @@ import { logInternalError } from "../utils/internal-error.ts";
10
10
  import type { ManifestCache } from "../runtime/manifest-cache.ts";
11
11
  import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
12
12
  import { notificationBadge } from "./crew-widget.ts";
13
+ import { RenderCoalescer } from "./render-coalescer.ts";
13
14
 
14
15
  type EventBus = { emit?: (event: string, data: unknown) => void; listenerCount?: (event: string) => number } | undefined;
15
16
  type StatusContext = { hasUI?: boolean; ui?: { setStatus?: (key: string, text: string | undefined) => void } } | undefined;
@@ -84,6 +85,8 @@ export function updatePiCrewPowerbar(events: EventBus, cwd: string, config?: Cre
84
85
  return { run, agents, tasks: readTasks(run.tasksPath), snapshot };
85
86
  }).filter((item) => isDisplayActiveRun(item.run, item.agents));
86
87
  if (!active.length) {
88
+ lastEmittedActive = undefined;
89
+ lastEmittedProgress = undefined;
87
90
  safeEmit(events, "powerbar:update", { id: "pi-crew-active" });
88
91
  safeEmit(events, "powerbar:update", { id: "pi-crew-progress" });
89
92
  if (useStatusFallback) setStatusFallback(ctx, undefined);
@@ -104,25 +107,84 @@ export function updatePiCrewPowerbar(events: EventBus, cwd: string, config?: Cre
104
107
  const activeText = `crew ${running}a/${waiting}w${notificationBadge(notificationCount)}`;
105
108
  const activeSuffix = [model, tokenText].filter(Boolean).join(" · ") || undefined;
106
109
  const progressSuffix = `${completed}/${total}${tokenText ? ` · ${tokenText}` : ""}`;
107
- safeEmit(events, "powerbar:update", {
108
- id: "pi-crew-active",
109
- icon: "⚙",
110
- text: activeText,
111
- suffix: activeSuffix,
112
- color: running ? "accent" : "warning",
113
- });
114
- safeEmit(events, "powerbar:update", {
115
- id: "pi-crew-progress",
116
- text: (active[0]?.run as TeamRunManifest)?.team ?? "crew",
117
- bar: Math.round((completed / total) * 100),
118
- suffix: progressSuffix,
119
- color: completed === total ? "success" : "accent",
120
- barSegments: 8,
121
- });
110
+ const activeKey = `${activeText}|${activeSuffix ?? ""}|${running}`;
111
+ const progressKey = `${(active[0]?.run as TeamRunManifest)?.team ?? "crew"}|${completed}/${total}|${tokenText ?? ""}`;
112
+ const changed = activeKey !== lastEmittedActive || progressKey !== lastEmittedProgress;
113
+ if (changed) {
114
+ lastEmittedActive = activeKey;
115
+ lastEmittedProgress = progressKey;
116
+ safeEmit(events, "powerbar:update", {
117
+ id: "pi-crew-active",
118
+ icon: "",
119
+ text: activeText,
120
+ suffix: activeSuffix,
121
+ color: running ? "accent" : "warning",
122
+ });
123
+ safeEmit(events, "powerbar:update", {
124
+ id: "pi-crew-progress",
125
+ text: (active[0]?.run as TeamRunManifest)?.team ?? "crew",
126
+ bar: Math.round((completed / total) * 100),
127
+ suffix: progressSuffix,
128
+ color: completed === total ? "success" : "accent",
129
+ barSegments: 8,
130
+ });
131
+ }
122
132
  if (useStatusFallback) setStatusFallback(ctx, `${activeText}${activeSuffix ? ` · ${activeSuffix}` : ""} · ${progressSuffix}`);
123
133
  }
124
134
 
135
+ // --- Dedup state: skip emit if segment data unchanged ---
136
+ let lastEmittedActive: string | undefined;
137
+ let lastEmittedProgress: string | undefined;
138
+
139
+ // --- Coalesced powerbar update ---
140
+
141
+ interface PowerbarUpdateArgs {
142
+ events: EventBus;
143
+ cwd: string;
144
+ config?: CrewUiConfig;
145
+ manifestCache?: ManifestCache;
146
+ snapshotCache?: RunSnapshotCache;
147
+ ctx?: StatusContext;
148
+ notificationCount: number;
149
+ preloadedManifests?: TeamRunManifest[];
150
+ }
151
+
152
+ let latestArgs: PowerbarUpdateArgs | null = null;
153
+
154
+ const powerbarCoalescer = new RenderCoalescer(() => {
155
+ if (!latestArgs) return;
156
+ const a = latestArgs;
157
+ latestArgs = null;
158
+ updatePiCrewPowerbar(a.events, a.cwd, a.config, a.manifestCache, a.snapshotCache, a.ctx, a.notificationCount, a.preloadedManifests);
159
+ }, 200);
160
+
161
+ /**
162
+ * Request a coalesced powerbar update. Multiple rapid calls are batched into a single
163
+ * render pass within 200ms, preventing UI flicker from event bursts.
164
+ */
165
+ export function requestPowerbarUpdate(
166
+ events: EventBus,
167
+ cwd: string,
168
+ config?: CrewUiConfig,
169
+ manifestCache?: ManifestCache,
170
+ snapshotCache?: RunSnapshotCache,
171
+ ctx?: StatusContext,
172
+ notificationCount = 0,
173
+ preloadedManifests?: TeamRunManifest[],
174
+ ): void {
175
+ if (config?.powerbar === false) return;
176
+ latestArgs = { events, cwd, config, manifestCache, snapshotCache, ctx, notificationCount, preloadedManifests };
177
+ powerbarCoalescer.request();
178
+ }
179
+
180
+ /** Dispose the powerbar coalescer. Call during extension cleanup. */
181
+ export function disposePowerbarCoalescer(): void {
182
+ powerbarCoalescer.dispose();
183
+ }
184
+
125
185
  export function clearPiCrewPowerbar(events: EventBus, ctx?: StatusContext): void {
186
+ lastEmittedActive = undefined;
187
+ lastEmittedProgress = undefined;
126
188
  safeEmit(events, "powerbar:update", { id: "pi-crew-active" });
127
189
  safeEmit(events, "powerbar:update", { id: "pi-crew-progress" });
128
190
  setStatusFallback(ctx, undefined);
@@ -0,0 +1,51 @@
1
+ /**
2
+ * RenderCoalescer — batches multiple render requests into single render passes.
3
+ * Prevents UI flicker when many events arrive in quick succession.
4
+ * Inspired by oh-my-pi's PROGRESS_COALESCE_MS (150ms) pattern.
5
+ */
6
+ export class RenderCoalescer {
7
+ #pending = false;
8
+ #timerId: ReturnType<typeof setTimeout> | null = null;
9
+ #callback: () => void;
10
+ #intervalMs: number;
11
+
12
+ constructor(callback: () => void, intervalMs = 32) {
13
+ this.#callback = callback;
14
+ this.#intervalMs = intervalMs;
15
+ }
16
+
17
+ /** Request a render. Will be coalesced with other requests within the interval. */
18
+ request(): void {
19
+ if (this.#pending) return;
20
+ this.#pending = true;
21
+ this.#timerId = setTimeout(() => {
22
+ this.#pending = false;
23
+ this.#timerId = null;
24
+ this.#callback();
25
+ }, this.#intervalMs);
26
+ }
27
+
28
+ /** Force an immediate render, bypassing coalescing. */
29
+ flush(): void {
30
+ if (this.#timerId !== null) {
31
+ clearTimeout(this.#timerId);
32
+ this.#timerId = null;
33
+ }
34
+ this.#pending = false;
35
+ this.#callback();
36
+ }
37
+
38
+ /** Check if a render is pending. */
39
+ get pending(): boolean {
40
+ return this.#pending;
41
+ }
42
+
43
+ /** Clean up timers. Call when the coalescer is no longer needed. */
44
+ dispose(): void {
45
+ if (this.#timerId !== null) {
46
+ clearTimeout(this.#timerId);
47
+ this.#timerId = null;
48
+ }
49
+ this.#pending = false;
50
+ }
51
+ }
@@ -24,6 +24,7 @@ import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
24
24
  import { spinnerBucket, spinnerFrame } from "./spinner.ts";
25
25
  import type { MetricRegistry } from "../observability/metric-registry.ts";
26
26
  import { resolveRealContainedPath } from "../utils/safe-paths.ts";
27
+ import { runEventBus } from "./run-event-bus.ts";
27
28
 
28
29
  interface DashboardComponent {
29
30
  invalidate(): void;
@@ -253,6 +254,7 @@ export class RunDashboard implements DashboardComponent {
253
254
  private cachedVersion = "";
254
255
  private cachedLines: string[] = [];
255
256
  private readonly unsubscribeTheme: () => void;
257
+ private readonly unsubscribeEventBus: () => void;
256
258
 
257
259
  constructor(
258
260
  runs: TeamRunManifest[],
@@ -265,6 +267,7 @@ export class RunDashboard implements DashboardComponent {
265
267
  this.theme = asCrewTheme(theme);
266
268
  this.options = options;
267
269
  this.unsubscribeTheme = subscribeThemeChange(theme, () => this.invalidate());
270
+ this.unsubscribeEventBus = runEventBus.onAny(() => this.invalidate());
268
271
  }
269
272
 
270
273
  private refreshRuns(): void {
@@ -301,6 +304,7 @@ export class RunDashboard implements DashboardComponent {
301
304
 
302
305
  dispose(): void {
303
306
  this.unsubscribeTheme();
307
+ this.unsubscribeEventBus();
304
308
  }
305
309
 
306
310
  private selectedRunId(): string | undefined {
@@ -0,0 +1,209 @@
1
+ import type { TeamEvent } from "../state/event-log.ts";
2
+
3
+ export type RunEventType =
4
+ | "task_started"
5
+ | "task_completed"
6
+ | "task_failed"
7
+ | "task_cancelled"
8
+ | "worker_status"
9
+ | "mailbox_updated"
10
+ | "effectiveness_changed"
11
+ | "run_started"
12
+ | "run_completed"
13
+ | "run_blocked"
14
+ | "run_cancelled";
15
+
16
+ /** Typed channel names for category-based event subscription. */
17
+ export type EventChannel =
18
+ | "worker:progress"
19
+ | "worker:lifecycle"
20
+ | "worker:stream"
21
+ | "run:state"
22
+ | "ui:invalidate";
23
+
24
+ /** Sets used by classifyEventChannel for O(1) lookup. */
25
+ const WORKER_PROGRESS_TYPES = new Set([
26
+ "tool_execution_start", "tool_result", "agent_progress", "worker_status",
27
+ ]);
28
+ const WORKER_LIFECYCLE_TYPES = new Set([
29
+ "task.started", "task.completed", "task.failed",
30
+ "task_started", "task_completed", "task_failed", "task_cancelled",
31
+ "run.started", "run.completed", "run.cancelled", "run.failed",
32
+ "run_started", "run_completed", "run_cancelled", "run_blocked",
33
+ ]);
34
+ const WORKER_STREAM_TYPES = new Set([
35
+ "stdout_chunk", "stderr_chunk", "stream",
36
+ ]);
37
+ const RUN_STATE_TYPES = new Set([
38
+ "manifest.saved", "task.claimed", "task.unclaimed", "mailbox_updated",
39
+ ]);
40
+ const UI_INVALIDATE_TYPES = new Set([
41
+ "effectiveness_changed", "snapshot_stale",
42
+ ]);
43
+
44
+ /** Classify an event type string into a typed channel. */
45
+ export function classifyEventChannel(type: string): EventChannel {
46
+ if (WORKER_PROGRESS_TYPES.has(type)) return "worker:progress";
47
+ if (WORKER_LIFECYCLE_TYPES.has(type)) return "worker:lifecycle";
48
+ if (WORKER_STREAM_TYPES.has(type)) return "worker:stream";
49
+ if (RUN_STATE_TYPES.has(type)) return "run:state";
50
+ if (UI_INVALIDATE_TYPES.has(type)) return "ui:invalidate";
51
+ return "worker:progress"; // default fallback
52
+ }
53
+
54
+ export interface RunEventPayload {
55
+ type: RunEventType;
56
+ runId: string;
57
+ taskId?: string;
58
+ timestamp?: string;
59
+ data?: unknown;
60
+ channel?: EventChannel;
61
+ }
62
+
63
+ export type RunEventCallback = (event: RunEventPayload) => void;
64
+
65
+ class RunEventBus {
66
+ #listeners = new Map<string, Set<RunEventCallback>>();
67
+ #globalListeners = new Set<RunEventCallback>();
68
+ #channelListeners = new Map<EventChannel, Set<RunEventCallback>>();
69
+ #channelRunListeners = new Map<string, Map<EventChannel, Set<RunEventCallback>>>();
70
+
71
+ on(runId: string, callback: RunEventCallback): () => void {
72
+ const listeners = this.#listeners.get(runId) ?? new Set();
73
+ listeners.add(callback);
74
+ this.#listeners.set(runId, listeners);
75
+ return () => { listeners.delete(callback); if (listeners.size === 0) this.#listeners.delete(runId); };
76
+ }
77
+
78
+ onAny(callback: RunEventCallback): () => void {
79
+ this.#globalListeners.add(callback);
80
+ return () => { this.#globalListeners.delete(callback); };
81
+ }
82
+
83
+ off(runId: string, callback: RunEventCallback): void {
84
+ const listeners = this.#listeners.get(runId);
85
+ if (listeners) {
86
+ listeners.delete(callback);
87
+ if (listeners.size === 0) this.#listeners.delete(runId);
88
+ }
89
+ }
90
+
91
+ /** Subscribe to all events on a specific channel. */
92
+ onChannel(channel: EventChannel, callback: RunEventCallback): () => void {
93
+ const listeners = this.#channelListeners.get(channel) ?? new Set();
94
+ listeners.add(callback);
95
+ this.#channelListeners.set(channel, listeners);
96
+ return () => {
97
+ listeners.delete(callback);
98
+ if (listeners.size === 0) this.#channelListeners.delete(channel);
99
+ };
100
+ }
101
+
102
+ /** Subscribe to events on a specific channel for a given runId. */
103
+ onChannelForRun(channel: EventChannel, runId: string, callback: RunEventCallback): () => void {
104
+ const runKey = `${channel}::${runId}`;
105
+ const runMap = this.#channelRunListeners.get(runKey) ?? new Map();
106
+ const listeners = runMap.get(channel) ?? new Set();
107
+ listeners.add(callback);
108
+ runMap.set(channel, listeners);
109
+ this.#channelRunListeners.set(runKey, runMap);
110
+ return () => {
111
+ listeners.delete(callback);
112
+ if (listeners.size === 0) runMap.delete(channel);
113
+ if (runMap.size === 0) this.#channelRunListeners.delete(runKey);
114
+ };
115
+ }
116
+
117
+ emit(event: RunEventPayload): void {
118
+ // Auto-classify channel if not already set.
119
+ // M2: Use local variable for routing, but also set on event
120
+ // for subscriber API contract (listeners read event.channel).
121
+ if (!event.channel) {
122
+ (event as { channel?: EventChannel }).channel = classifyEventChannel(event.type);
123
+ }
124
+ const channel = event.channel!;
125
+
126
+ // Existing: runId-specific listeners
127
+ const listeners = this.#listeners.get(event.runId);
128
+ if (listeners) {
129
+ for (const cb of listeners) {
130
+ try { cb(event); } catch { /* subscriber errors are non-fatal */ }
131
+ }
132
+ }
133
+
134
+ // Existing: global listeners
135
+ for (const cb of this.#globalListeners) {
136
+ try { cb(event); } catch { /* subscriber errors are non-fatal */ }
137
+ }
138
+
139
+ // New: channel listeners
140
+ const channelListeners = this.#channelListeners.get(channel);
141
+ if (channelListeners) {
142
+ for (const cb of channelListeners) {
143
+ try { cb(event); } catch { /* subscriber errors are non-fatal */ }
144
+ }
145
+ }
146
+
147
+ // New: channel+runId listeners
148
+ const runKey = `${channel}::${event.runId}`;
149
+ const runMap = this.#channelRunListeners.get(runKey);
150
+ if (runMap) {
151
+ const runChannelListeners = runMap.get(channel);
152
+ if (runChannelListeners) {
153
+ for (const cb of runChannelListeners) {
154
+ try { cb(event); } catch { /* subscriber errors are non-fatal */ }
155
+ }
156
+ }
157
+ }
158
+ }
159
+
160
+ /** Dispose all subscriptions including channel-based ones. */
161
+ dispose(): void {
162
+ this.#listeners.clear();
163
+ this.#globalListeners.clear();
164
+ this.#channelListeners.clear();
165
+ this.#channelRunListeners.clear();
166
+ }
167
+
168
+ listenerCount(runId?: string): number {
169
+ if (runId) return this.#listeners.get(runId)?.size ?? 0;
170
+ let total = this.#globalListeners.size;
171
+ for (const set of this.#listeners.values()) total += set.size;
172
+ for (const set of this.#channelListeners.values()) total += set.size;
173
+ for (const runMap of this.#channelRunListeners.values()) {
174
+ for (const set of runMap.values()) total += set.size;
175
+ }
176
+ return total;
177
+ }
178
+ }
179
+
180
+ /** Global singleton run event bus for UI-first event delivery. */
181
+ export const runEventBus = new RunEventBus();
182
+
183
+ /** Derive a RunEventType from a TeamEvent. */
184
+ export function teamEventToRunEventType(event: TeamEvent): RunEventType | undefined {
185
+ const type = event.type;
186
+ if (type === "task.started") return "task_started";
187
+ if (type === "task.completed") return "task_completed";
188
+ if (type === "task.failed") return "task_failed";
189
+ if (type === "run.completed") return "run_completed";
190
+ if (type === "run.blocked") return "run_blocked";
191
+ if (type === "run.running") return "run_started";
192
+ if (type === "run.cancelled") return "run_cancelled";
193
+ if (type === "task.progress" || type === "mailbox.message_queued" || type === "mailbox.message_delivered") return "mailbox_updated";
194
+ if (type === "run.effectiveness" || type === "task.attention") return "effectiveness_changed";
195
+ return undefined;
196
+ }
197
+
198
+ /** Emit a run event from a TeamEvent. */
199
+ export function emitFromTeamEvent(event: TeamEvent): void {
200
+ const type = teamEventToRunEventType(event);
201
+ if (!type) return;
202
+ runEventBus.emit({
203
+ type,
204
+ runId: event.runId,
205
+ taskId: event.taskId,
206
+ timestamp: event.time,
207
+ data: event.data,
208
+ });
209
+ }
@@ -9,6 +9,7 @@ import type { MailboxMessageStatus } from "../state/mailbox.ts";
9
9
  import { loadRunManifestById, loadRunManifestByIdAsync } from "../state/state-store.ts";
10
10
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
11
11
  import type { RunSnapshotCache as RunSnapshotCacheBase, RunUiGroupJoin, RunUiMailbox, RunUiProgress, RunUiSnapshot, RunUiUsage } from "./snapshot-types.ts";
12
+ import { runEventBus } from "./run-event-bus.ts";
12
13
 
13
14
  export interface RunSnapshotCache extends RunSnapshotCacheBase {
14
15
  preloadStale(runId: string): Promise<RunUiSnapshot | undefined>;
@@ -378,6 +379,13 @@ interface MailboxCount {
378
379
  approximate: boolean;
379
380
  }
380
381
 
382
+ interface MailboxKindCount extends MailboxCount {
383
+ steer: number;
384
+ followUp: number;
385
+ response: number;
386
+ message: number;
387
+ }
388
+
381
389
  function tailApproximate(filePath: string): boolean {
382
390
  try {
383
391
  return fs.statSync(filePath).size > MAX_TAIL_BYTES;
@@ -394,34 +402,54 @@ async function tailApproximateAsync(filePath: string): Promise<boolean> {
394
402
  }
395
403
  }
396
404
 
397
- function readMailboxCounts(filePath: string, delivery: Record<string, MailboxMessageStatus>): MailboxCount {
405
+ function readMailboxCounts(filePath: string, delivery: Record<string, MailboxMessageStatus>): MailboxKindCount {
406
+ const kindCounts = { steer: 0, followUp: 0, response: 0, message: 0 };
398
407
  const items = tailJsonlLines(filePath, MAX_TAIL_LINES, (line) => {
399
408
  try {
400
409
  const parsed = JSON.parse(line) as unknown;
401
410
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return 0;
402
- const message = parsed as { id?: unknown; status?: unknown };
403
- if (typeof message.id !== "string" || !isMailboxStatus(message.status)) return 0;
404
- return message.status !== "acknowledged" && delivery[message.id] !== "acknowledged" ? 1 : 0;
411
+ const msg = parsed as { id?: unknown; status?: unknown; kind?: unknown; data?: unknown };
412
+ if (typeof msg.id !== "string" || !isMailboxStatus(msg.status)) return 0;
413
+ if (msg.status !== "acknowledged" && delivery[msg.id] !== "acknowledged") {
414
+ const kind = typeof msg.kind === "string" ? msg.kind : typeof (msg.data as Record<string, unknown>)?.kind === "string" ? (msg.data as Record<string, unknown>).kind as string : undefined;
415
+ if (kind === "steer") kindCounts.steer++;
416
+ else if (kind === "follow-up") kindCounts.followUp++;
417
+ else if (kind === "response") kindCounts.response++;
418
+ else kindCounts.message++;
419
+ return 1;
420
+ }
421
+ return 0;
405
422
  } catch {
406
423
  return 0;
407
424
  }
408
425
  }) as number[];
409
- return { count: items.reduce((sum, val) => sum + val, 0), approximate: tailApproximate(filePath) };
426
+ const count = items.reduce((sum, val) => sum + val, 0);
427
+ return { count, approximate: tailApproximate(filePath), steer: kindCounts.steer, followUp: kindCounts.followUp, response: kindCounts.response, message: kindCounts.message };
410
428
  }
411
429
 
412
- async function readMailboxCountsAsync(filePath: string, delivery: Record<string, MailboxMessageStatus>): Promise<MailboxCount> {
430
+ async function readMailboxCountsAsync(filePath: string, delivery: Record<string, MailboxMessageStatus>): Promise<MailboxKindCount> {
431
+ const kindCounts = { steer: 0, followUp: 0, response: 0, message: 0 };
413
432
  const items = await tailJsonlLinesAsync(filePath, MAX_TAIL_LINES, (line) => {
414
433
  try {
415
434
  const parsed = JSON.parse(line) as unknown;
416
435
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return 0;
417
- const message = parsed as { id?: unknown; status?: unknown };
418
- if (typeof message.id !== "string" || !isMailboxStatus(message.status)) return 0;
419
- return message.status !== "acknowledged" && delivery[message.id] !== "acknowledged" ? 1 : 0;
436
+ const msg = parsed as { id?: unknown; status?: unknown; kind?: unknown; data?: unknown };
437
+ if (typeof msg.id !== "string" || !isMailboxStatus(msg.status)) return 0;
438
+ if (msg.status !== "acknowledged" && delivery[msg.id] !== "acknowledged") {
439
+ const kind = typeof msg.kind === "string" ? msg.kind : typeof (msg.data as Record<string, unknown>)?.kind === "string" ? (msg.data as Record<string, unknown>).kind as string : undefined;
440
+ if (kind === "steer") kindCounts.steer++;
441
+ else if (kind === "follow-up") kindCounts.followUp++;
442
+ else if (kind === "response") kindCounts.response++;
443
+ else kindCounts.message++;
444
+ return 1;
445
+ }
446
+ return 0;
420
447
  } catch {
421
448
  return 0;
422
449
  }
423
450
  }) as number[];
424
- return { count: items.reduce((sum, val) => sum + val, 0), approximate: await tailApproximateAsync(filePath) };
451
+ const count = items.reduce((sum, val) => sum + val, 0);
452
+ return { count, approximate: await tailApproximateAsync(filePath), steer: kindCounts.steer, followUp: kindCounts.followUp, response: kindCounts.response, message: kindCounts.message };
425
453
  }
426
454
 
427
455
  function groupJoinsFrom(manifest: TeamRunManifest): RunUiGroupJoin[] {
@@ -436,6 +464,17 @@ async function groupJoinsFromAsync(manifest: TeamRunManifest): Promise<RunUiGrou
436
464
  return (await readGroupJoinMailboxAsync(path.join(root, "outbox.jsonl"), delivery)).slice(-5);
437
465
  }
438
466
 
467
+ function mergeKindCounts(a: MailboxKindCount, b: MailboxKindCount): MailboxKindCount {
468
+ return {
469
+ count: a.count + b.count,
470
+ approximate: a.approximate || b.approximate,
471
+ steer: a.steer + b.steer,
472
+ followUp: a.followUp + b.followUp,
473
+ response: a.response + b.response,
474
+ message: a.message + b.message,
475
+ };
476
+ }
477
+
439
478
  function mailboxFrom(manifest: TeamRunManifest, agents: CrewAgentRecord[]): RunUiMailbox {
440
479
  const root = path.join(manifest.stateRoot, "mailbox");
441
480
  const delivery = readDeliveryMessages(path.join(root, "delivery.json"));
@@ -447,14 +486,17 @@ function mailboxFrom(manifest: TeamRunManifest, agents: CrewAgentRecord[]): RunU
447
486
  if (!entry.isDirectory()) continue;
448
487
  const taskInbox = readMailboxCounts(path.join(tasksRoot, entry.name, "inbox.jsonl"), delivery);
449
488
  const taskOutbox = readMailboxCounts(path.join(tasksRoot, entry.name, "outbox.jsonl"), delivery);
450
- inbox = { count: inbox.count + taskInbox.count, approximate: inbox.approximate || taskInbox.approximate };
451
- outbox = { count: outbox.count + taskOutbox.count, approximate: outbox.approximate || taskOutbox.approximate };
489
+ inbox = mergeKindCounts(inbox, taskInbox);
490
+ outbox = mergeKindCounts(outbox, taskOutbox);
452
491
  }
453
492
  } catch {
454
493
  // No task mailboxes yet.
455
494
  }
456
495
  const attentionAgents = agents.filter((agent) => agent.progress?.activityState === "needs_attention").length;
457
- return { inboxUnread: inbox.count, outboxPending: outbox.count, needsAttention: inbox.count + attentionAgents, approximate: inbox.approximate || outbox.approximate };
496
+ return {
497
+ inboxUnread: inbox.count, outboxPending: outbox.count, needsAttention: inbox.count + attentionAgents, approximate: inbox.approximate || outbox.approximate,
498
+ steerUnread: inbox.steer + outbox.steer, followUpUnread: inbox.followUp + outbox.followUp, responseUnread: inbox.response + outbox.response, messageUnread: inbox.message + outbox.message,
499
+ };
458
500
  }
459
501
 
460
502
  async function mailboxFromAsync(manifest: TeamRunManifest, agents: CrewAgentRecord[]): Promise<RunUiMailbox> {
@@ -468,14 +510,21 @@ async function mailboxFromAsync(manifest: TeamRunManifest, agents: CrewAgentReco
468
510
  if (!entry.isDirectory()) continue;
469
511
  const taskInbox = await readMailboxCountsAsync(path.join(tasksRoot, entry.name, "inbox.jsonl"), delivery);
470
512
  const taskOutbox = await readMailboxCountsAsync(path.join(tasksRoot, entry.name, "outbox.jsonl"), delivery);
471
- inbox = { count: inbox.count + taskInbox.count, approximate: inbox.approximate || taskInbox.approximate };
472
- outbox = { count: outbox.count + taskOutbox.count, approximate: outbox.approximate || taskOutbox.approximate };
513
+ inbox = mergeKindCounts(inbox, taskInbox);
514
+ outbox = mergeKindCounts(outbox, taskOutbox);
473
515
  }
474
516
  } catch {
475
517
  // No task mailboxes yet.
476
518
  }
477
519
  const attentionAgents = agents.filter((agent) => agent.progress?.activityState === "needs_attention").length;
478
- return { inboxUnread: inbox.count, outboxPending: outbox.count, needsAttention: inbox.count + attentionAgents, approximate: inbox.approximate || outbox.approximate };
520
+ return {
521
+ inboxUnread: inbox.count, outboxPending: outbox.count, needsAttention: inbox.count + attentionAgents, approximate: inbox.approximate || outbox.approximate,
522
+ steerUnread: inbox.steer + outbox.steer, followUpUnread: inbox.followUp + outbox.followUp, responseUnread: inbox.response + outbox.response, messageUnread: inbox.message + outbox.message,
523
+ };
524
+ }
525
+
526
+ function cancellationReasonFromEvents(events: TeamEvent[]): string | undefined {
527
+ return [...events].reverse().find((event) => event.type === "run.cancelled" && typeof event.data?.reason === "string")?.data?.reason as string | undefined;
479
528
  }
480
529
 
481
530
  function signatureFor(input: Omit<RunUiSnapshot, "signature" | "fetchedAt">, stamps: SnapshotStamps): string {
@@ -489,7 +538,8 @@ function signatureFor(input: Omit<RunUiSnapshot, "signature" | "fetchedAt">, sta
489
538
  usage: input.usage,
490
539
  mailbox: input.mailbox,
491
540
  groupJoins: input.groupJoins,
492
- events: input.recentEvents.map((event) => [event.metadata?.seq, event.time, event.type, event.taskId, event.message]),
541
+ events: input.recentEvents.map((event) => [event.metadata?.seq, event.time, event.type, event.taskId, event.message, event.data?.reason]),
542
+ cancellationReason: input.cancellationReason,
493
543
  output: input.recentOutputLines,
494
544
  stamps,
495
545
  }));
@@ -571,6 +621,7 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
571
621
  }
572
622
  const mailbox = mailboxFrom(loaded.manifest, agents);
573
623
  const groupJoins = groupJoinsFrom(loaded.manifest);
624
+ const recentEvents = safeRecentEvents(loaded.manifest.eventsPath, recentEventsLimit);
574
625
  const base = {
575
626
  runId: loaded.manifest.runId,
576
627
  cwd: loaded.manifest.cwd,
@@ -581,7 +632,8 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
581
632
  usage: usageFrom(tasks, agents),
582
633
  mailbox,
583
634
  groupJoins,
584
- recentEvents: safeRecentEvents(loaded.manifest.eventsPath, recentEventsLimit),
635
+ cancellationReason: cancellationReasonFromEvents(recentEvents),
636
+ recentEvents,
585
637
  recentOutputLines: recentOutputLines(loaded.manifest, agents, recentOutputLimit),
586
638
  };
587
639
  const stamps = stampsFor(loaded.manifest, agents);
@@ -626,6 +678,7 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
626
678
  usage: usageFrom(tasks, agents),
627
679
  mailbox,
628
680
  groupJoins,
681
+ cancellationReason: cancellationReasonFromEvents(recentEvents),
629
682
  recentEvents,
630
683
  recentOutputLines: recentOutput,
631
684
  };
@@ -680,6 +733,12 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
680
733
  }
681
734
  }
682
735
 
736
+ const unsubscribe = runEventBus.onAny((event) => {
737
+ if (entries.has(event.runId)) {
738
+ entries.delete(event.runId);
739
+ }
740
+ });
741
+
683
742
  return {
684
743
  get(runId: string): RunUiSnapshot | undefined {
685
744
  const entry = entries.get(runId);
@@ -711,6 +770,7 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
711
770
  return new Map([...entries.entries()].map(([key, entry]) => [key, entry.snapshot]));
712
771
  },
713
772
  dispose(): void {
773
+ unsubscribe();
714
774
  entries.clear();
715
775
  },
716
776
  };
@@ -23,6 +23,14 @@ export interface RunUiMailbox {
23
23
  inboxUnread: number;
24
24
  outboxPending: number;
25
25
  needsAttention: number;
26
+ /** Urgent steering messages count. Default 0. */
27
+ steerUnread?: number;
28
+ /** Follow-up / continuation messages count. Default 0. */
29
+ followUpUnread?: number;
30
+ /** Response / reply messages count. Default 0. */
31
+ responseUnread?: number;
32
+ /** Generic messages count. Default 0. */
33
+ messageUnread?: number;
26
34
  /** True when counts come from bounded tail reads and older messages may be omitted. */
27
35
  approximate?: boolean;
28
36
  }
@@ -46,6 +54,8 @@ export interface RunUiSnapshot {
46
54
  usage: RunUiUsage;
47
55
  mailbox: RunUiMailbox;
48
56
  groupJoins?: RunUiGroupJoin[];
57
+ /** Structured cancellation reason from run.cancelled event data, when available. */
58
+ cancellationReason?: string;
49
59
  recentEvents: TeamEvent[];
50
60
  recentOutputLines: string[];
51
61
  }