pi-crew 0.5.2 → 0.5.5

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 (80) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/docs/bugs/cross-session-notification-leakage.md +82 -0
  3. package/docs/coding-agent-optimization.md +268 -0
  4. package/docs/deep-review-report.md +384 -0
  5. package/docs/distillation/cybersecurity-patterns.md +294 -0
  6. package/docs/migration-v0.4-v0.5.md +191 -0
  7. package/docs/optimization-plan.md +642 -0
  8. package/docs/pi-mono-opportunities.md +969 -0
  9. package/docs/pi-mono-review.md +291 -0
  10. package/docs/skills/REFERENCE.md +144 -0
  11. package/package.json +7 -6
  12. package/skills/artifact-analysis-loop/SKILL.md +302 -0
  13. package/skills/async-worker-recovery/SKILL.md +19 -1
  14. package/skills/child-pi-spawning/SKILL.md +19 -6
  15. package/skills/context-artifact-hygiene/SKILL.md +19 -2
  16. package/skills/delegation-patterns/SKILL.md +68 -3
  17. package/skills/detection-pipeline-design/SKILL.md +285 -0
  18. package/skills/event-log-tracing/SKILL.md +20 -6
  19. package/skills/git-master/SKILL.md +20 -6
  20. package/skills/hunting-investigation-loop/SKILL.md +401 -0
  21. package/skills/incident-playbook-construction/SKILL.md +383 -0
  22. package/skills/live-agent-lifecycle/SKILL.md +20 -6
  23. package/skills/mailbox-interactive/SKILL.md +19 -6
  24. package/skills/model-routing-context/SKILL.md +19 -1
  25. package/skills/multi-perspective-review/SKILL.md +19 -4
  26. package/skills/observability-reliability/SKILL.md +19 -2
  27. package/skills/orchestration/SKILL.md +20 -2
  28. package/skills/ownership-session-security/SKILL.md +20 -2
  29. package/skills/pi-extension-lifecycle/SKILL.md +20 -2
  30. package/skills/post-mortem/SKILL.md +7 -2
  31. package/skills/read-only-explorer/SKILL.md +20 -6
  32. package/skills/requirements-to-task-packet/SKILL.md +23 -3
  33. package/skills/resource-discovery-config/SKILL.md +20 -2
  34. package/skills/runtime-state-reader/SKILL.md +20 -2
  35. package/skills/safe-bash/SKILL.md +21 -6
  36. package/skills/scrutinize/SKILL.md +20 -2
  37. package/skills/secure-agent-orchestration-review/SKILL.md +29 -2
  38. package/skills/security-review/SKILL.md +560 -0
  39. package/skills/state-mutation-locking/SKILL.md +22 -2
  40. package/skills/systematic-debugging/SKILL.md +8 -6
  41. package/skills/threat-hypothesis-framework/SKILL.md +175 -0
  42. package/skills/ui-render-performance/SKILL.md +20 -2
  43. package/skills/verification-before-done/SKILL.md +17 -2
  44. package/skills/widget-rendering/SKILL.md +21 -6
  45. package/skills/workspace-isolation/SKILL.md +20 -6
  46. package/skills/worktree-isolation/SKILL.md +20 -6
  47. package/src/agents/agent-config.ts +40 -1
  48. package/src/config/config.ts +22 -5
  49. package/src/config/role-tools.ts +82 -0
  50. package/src/config/types.ts +4 -0
  51. package/src/extension/crew-cleanup.ts +114 -0
  52. package/src/extension/register.ts +15 -3
  53. package/src/extension/team-tool/run.ts +7 -7
  54. package/src/observability/event-bus.ts +60 -0
  55. package/src/runtime/background-runner.ts +8 -2
  56. package/src/runtime/child-pi.ts +122 -34
  57. package/src/runtime/crew-agent-runtime.ts +1 -0
  58. package/src/runtime/foreground-control.ts +87 -17
  59. package/src/runtime/pi-args.ts +11 -1
  60. package/src/runtime/pi-json-output.ts +31 -0
  61. package/src/runtime/progress-tracker.ts +124 -0
  62. package/src/runtime/skill-effectiveness.ts +473 -0
  63. package/src/runtime/skill-instructions.ts +37 -3
  64. package/src/runtime/task-runner.ts +91 -17
  65. package/src/runtime/team-runner.ts +11 -11
  66. package/src/runtime/tool-progress.ts +10 -3
  67. package/src/runtime/verification-gates.ts +367 -0
  68. package/src/schema/team-tool-schema.ts +7 -0
  69. package/src/state/decision-ledger.ts +92 -43
  70. package/src/state/event-log.ts +136 -10
  71. package/src/state/hook-instinct-bridge.ts +5 -5
  72. package/src/state/state-store.ts +3 -1
  73. package/src/state/types.ts +4 -0
  74. package/src/types/new-api-types.ts +34 -0
  75. package/src/ui/agent-management-overlay.ts +5 -1
  76. package/src/ui/crew-widget.ts +29 -15
  77. package/src/ui/powerbar-publisher.ts +100 -7
  78. package/src/ui/tool-render.ts +15 -15
  79. package/src/utils/session-utils.ts +52 -0
  80. package/src/worktree/worktree-manager.ts +32 -13
@@ -63,12 +63,17 @@ let appendCounter = 0;
63
63
 
64
64
  /** Simple cross-process lock for an eventsPath to prevent JSONL interleave on concurrent append.
65
65
  * Detects stale locks by checking the owner PID written inside the lock directory.
66
+ *
67
+ * @deprecated Prefer `appendEventAsync()` for callers in async contexts. The sync lock
68
+ * uses `sleepSync` which blocks the event loop and prevents AbortSignal handlers from firing.
66
69
  */
67
70
  export function withEventLogLockSync<T>(eventsPath: string, fn: () => T): T {
71
+ // Ensure parent directory exists before attempting lock
72
+ fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
68
73
  const lockDir = `${eventsPath}.lock`;
69
74
  const pidFile = path.join(lockDir, "pid");
70
75
  const start = Date.now();
71
- const timeout = 5000;
76
+ const timeout = 120000; // 120s timeout for slow CI environments
72
77
  const staleMs = 10000;
73
78
  let acquired = false;
74
79
  while (true) {
@@ -79,6 +84,8 @@ export function withEventLogLockSync<T>(eventsPath: string, fn: () => T): T {
79
84
  break;
80
85
  } catch {
81
86
  if (Date.now() - start > timeout) {
87
+ // Log error and continue without lock — lock is held by live process.
88
+ // Stale detection will clean up dead locks on next attempt.
82
89
  logInternalError("event-log.lock-timeout", new Error(`Event log lock timeout for ${eventsPath}`), `lockDir=${lockDir}`);
83
90
  break;
84
91
  }
@@ -112,9 +119,15 @@ export function withEventLogLockSync<T>(eventsPath: string, fn: () => T): T {
112
119
  }
113
120
  }
114
121
 
115
- function evictOldestSequenceCacheEntry(): void {
116
- const first = sequenceCache.keys().next().value;
117
- if (first !== undefined) sequenceCache.delete(first);
122
+ function evictOldestSequenceCacheEntries(): void {
123
+ // Batch evict oldest 50% of entries when cache is full
124
+ const toEvict = Math.ceil(MAX_SEQUENCE_CACHE_ENTRIES / 2);
125
+ let evicted = 0;
126
+ for (const key of sequenceCache.keys()) {
127
+ if (evicted >= toEvict) break;
128
+ sequenceCache.delete(key);
129
+ evicted++;
130
+ }
118
131
  }
119
132
 
120
133
  export function sequencePath(eventsPath: string): string {
@@ -174,10 +187,116 @@ export function computeEventFingerprint(event: Pick<TeamEvent, "type" | "runId"
174
187
  return createHash("sha256").update(JSON.stringify({ type: event.type, runId: event.runId, taskId: event.taskId, data: event.data ?? null })).digest("hex").slice(0, 16);
175
188
  }
176
189
 
190
+ /**
191
+ * @deprecated Prefer `appendEventAsync()` in async contexts. The sync lock uses
192
+ * `sleepSync` which blocks the Node.js event loop, preventing AbortSignal handlers
193
+ * from firing and degrading live-agent responsiveness.
194
+ */
177
195
  export function appendEvent(eventsPath: string, event: AppendTeamEvent): TeamEvent {
178
196
  return withEventLogLockSync(eventsPath, () => appendEventInsideLock(eventsPath, event));
179
197
  }
180
198
 
199
+ // --- Async write queue (non-blocking alternative to withEventLogLockSync) ---
200
+ const asyncQueues = new Map<string, Promise<unknown>>();
201
+
202
+ /**
203
+ * Append an event to the event log using non-blocking async I/O.
204
+ *
205
+ * Uses a per-eventsPath promise-chain queue to ensure sequential writes without
206
+ * blocking the Node.js event loop. This allows AbortSignal handlers and other
207
+ * async operations to proceed while events are being persisted.
208
+ *
209
+ * For callers that are already in an async context (team-runner, task-runner,
210
+ * foreground-control, etc.), prefer this over the sync `appendEvent()`.
211
+ */
212
+ export async function appendEventAsync(eventsPath: string, event: AppendTeamEvent): Promise<TeamEvent> {
213
+ const queueKey = eventsPath;
214
+ const prev = asyncQueues.get(queueKey) ?? Promise.resolve();
215
+ const next = prev.then(async (): Promise<TeamEvent> => {
216
+ // Ensure directory exists
217
+ await fs.promises.mkdir(path.dirname(eventsPath), { recursive: true });
218
+
219
+ // Build metadata (same logic as appendEventInsideLock)
220
+ const baseMetadata = event.metadata;
221
+ let metadata: TeamEventMetadata = {
222
+ seq: baseMetadata?.seq ?? nextSequence(eventsPath),
223
+ provenance: baseMetadata?.provenance ?? "team_runner",
224
+ ...(baseMetadata?.parentEventId ? { parentEventId: baseMetadata.parentEventId } : {}),
225
+ ...(baseMetadata?.attemptId ? { attemptId: baseMetadata.attemptId } : {}),
226
+ ...(baseMetadata?.branchId ? { branchId: baseMetadata.branchId } : {}),
227
+ ...(baseMetadata?.causationId ? { causationId: baseMetadata.causationId } : {}),
228
+ ...(baseMetadata?.correlationId ? { correlationId: baseMetadata.correlationId } : {}),
229
+ ...(baseMetadata?.sessionIdentity ? { sessionIdentity: baseMetadata.sessionIdentity } : {}),
230
+ ...(baseMetadata?.ownership ? { ownership: baseMetadata.ownership } : {}),
231
+ ...(baseMetadata?.nudgeId ? { nudgeId: baseMetadata.nudgeId } : {}),
232
+ ...(baseMetadata?.confidence ? { confidence: baseMetadata.confidence } : {}),
233
+ };
234
+ const fullEvent: TeamEvent = {
235
+ time: new Date().toISOString(),
236
+ ...event,
237
+ metadata,
238
+ };
239
+ if (baseMetadata?.fingerprint || TERMINAL_EVENT_TYPES.has(fullEvent.type)) {
240
+ metadata = { ...metadata, fingerprint: baseMetadata?.fingerprint ?? computeEventFingerprint(fullEvent) };
241
+ fullEvent.metadata = metadata;
242
+ }
243
+
244
+ // Overflow handling: same logic as sync path
245
+ const isTerminal = TERMINAL_EVENT_TYPES.has(fullEvent.type);
246
+ let skippedDueToSize = false;
247
+ if (!isTerminal && fs.existsSync(eventsPath)) {
248
+ const stat = fs.statSync(eventsPath);
249
+ if (stat.size > MAX_EVENTS_BYTES) {
250
+ try {
251
+ compactEventLog(eventsPath);
252
+ } catch (error) {
253
+ logInternalError("event-log.immediate-compact", error, `eventsPath=${eventsPath}`);
254
+ }
255
+ if (fs.existsSync(eventsPath)) {
256
+ const afterCompact = fs.statSync(eventsPath);
257
+ if (afterCompact.size > MAX_EVENTS_BYTES) {
258
+ rotateEventLog(eventsPath);
259
+ }
260
+ }
261
+ }
262
+ }
263
+ try {
264
+ if (fs.existsSync(eventsPath) && fs.statSync(eventsPath).size > MAX_EVENTS_BYTES) {
265
+ logInternalError("event-log.size-limit", new Error(`events file ${eventsPath} exceeds ${MAX_EVENTS_BYTES} bytes after compaction`), `eventsPath=${eventsPath}`);
266
+ skippedDueToSize = true;
267
+ }
268
+ } catch (error) {
269
+ logInternalError("event-log.size-check", error, `eventsPath=${eventsPath}`);
270
+ }
271
+
272
+ if (!skippedDueToSize) {
273
+ const line = JSON.stringify(redactSecrets(fullEvent)) + "\n";
274
+ await fs.promises.appendFile(eventsPath, line, { encoding: "utf-8" });
275
+ }
276
+
277
+ appendCounter++;
278
+ if (appendCounter % 100 === 0 && needsRotation(eventsPath)) {
279
+ try { compactEventLog(eventsPath); } catch (error) { logInternalError("event-log.rotation", error, `eventsPath=${eventsPath}`); }
280
+ }
281
+ try { emitFromTeamEvent(fullEvent); } catch (error) { logInternalError("event-log.emit", error); }
282
+
283
+ const seq = fullEvent.metadata?.seq ?? 0;
284
+ try {
285
+ const stat = fs.statSync(eventsPath);
286
+ if (sequenceCache.size >= MAX_SEQUENCE_CACHE_ENTRIES) {
287
+ evictOldestSequenceCacheEntries();
288
+ }
289
+ sequenceCache.set(eventsPath, { size: stat.size, mtimeMs: stat.mtimeMs, seq });
290
+ persistSequence(eventsPath, seq);
291
+ } catch (error) {
292
+ logInternalError("event-log.persist-sequence", error, `eventsPath=${eventsPath}`);
293
+ }
294
+ return fullEvent;
295
+ });
296
+ asyncQueues.set(queueKey, next.catch(() => {}));
297
+ return next;
298
+ }
299
+
181
300
  /**
182
301
  * Body of `appendEvent` assuming the caller already holds
183
302
  * `withEventLogLockSync` for `eventsPath`. Used by `appendEventBuffered` to
@@ -254,7 +373,7 @@ function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): Team
254
373
  try {
255
374
  const stat = fs.statSync(eventsPath);
256
375
  if (sequenceCache.size >= MAX_SEQUENCE_CACHE_ENTRIES) {
257
- evictOldestSequenceCacheEntry();
376
+ evictOldestSequenceCacheEntries();
258
377
  }
259
378
  sequenceCache.set(eventsPath, { size: stat.size, mtimeMs: stat.mtimeMs, seq });
260
379
  persistSequence(eventsPath, seq);
@@ -283,6 +402,12 @@ const bufferedTimers = new Map<string, ReturnType<typeof setTimeout>>();
283
402
  const DEFAULT_BUFFER_MS = 20;
284
403
 
285
404
  export function appendEventBuffered(eventsPath: string, event: AppendTeamEvent, bufferMs = DEFAULT_BUFFER_MS): Promise<TeamEvent> {
405
+ // FIX: Terminal events must bypass buffer to ensure they're written immediately.
406
+ // Previously, terminal events like task.failed could be lost on process crash.
407
+ if (TERMINAL_EVENT_TYPES.has(event.type)) {
408
+ // For terminal events, write synchronously to ensure durability
409
+ return Promise.resolve(appendEvent(eventsPath, event));
410
+ }
286
411
  return new Promise<TeamEvent>((resolve, reject) => {
287
412
  const queue = bufferedQueues.get(eventsPath) ?? [];
288
413
  queue.push({ event, resolve, reject });
@@ -325,12 +450,13 @@ export function flushEventLogBuffer(): void {
325
450
  }
326
451
 
327
452
  /**
328
- * 2.2 caller-migration helper schedule a buffered append but do not return
329
- * the resulting Promise. Use only for events whose return value is ignored
330
- * (high-frequency `task.progress`). Errors are logged via logInternalError.
453
+ * Schedule an async event append without waiting for the result.
454
+ * Uses the non-blocking async queue to avoid blocking the event loop.
455
+ * Use only for events whose return value is ignored (high-frequency `task.progress`).
456
+ * Errors are logged via logInternalError.
331
457
  */
332
- export function appendEventFireAndForget(eventsPath: string, event: AppendTeamEvent, bufferMs = DEFAULT_BUFFER_MS): void {
333
- appendEventBuffered(eventsPath, event, bufferMs).catch((error) => logInternalError("event-log.fire-and-forget", error, eventsPath));
458
+ export function appendEventFireAndForget(eventsPath: string, event: AppendTeamEvent, _bufferMs = DEFAULT_BUFFER_MS): void {
459
+ appendEventAsync(eventsPath, event).catch((error) => logInternalError("event-log.fire-and-forget", error, eventsPath));
334
460
  }
335
461
 
336
462
  // Auto-flush on process exit so buffered events do not silently leak.
@@ -6,13 +6,13 @@
6
6
  import { crewHooks } from "../runtime/crew-hooks.ts";
7
7
 
8
8
  // Lazy-initialized store and paths
9
- let storeInstance: import("./instinct-store").InstinctStore | null = null;
10
- let pathsInstance: typeof import("../utils/paths") | null = null;
9
+ let storeInstance: import("./instinct-store.js").InstinctStore | null = null;
10
+ let pathsInstance: typeof import("../utils/paths.js") | null = null;
11
11
 
12
12
  async function getStore() {
13
13
  if (!storeInstance) {
14
- const { InstinctStore } = await import("./instinct-store");
15
- const paths = await import("../utils/paths");
14
+ const { InstinctStore } = await import("./instinct-store.js");
15
+ const paths = await import("../utils/paths.js");
16
16
  storeInstance = new InstinctStore(paths.projectCrewRoot(process.cwd()));
17
17
  }
18
18
  return storeInstance;
@@ -20,7 +20,7 @@ async function getStore() {
20
20
 
21
21
  async function getPaths() {
22
22
  if (!pathsInstance) {
23
- pathsInstance = await import("../utils/paths");
23
+ pathsInstance = await import("../utils/paths.js");
24
24
  }
25
25
  return pathsInstance;
26
26
  }
@@ -12,6 +12,7 @@ import { assertSafePathId, resolveContainedRelativePath, resolveRealContainedPat
12
12
  import { withRunLock } from "./locks.ts";
13
13
  import type { TeamConfig } from "../teams/team-config.ts";
14
14
  import type { WorkflowConfig } from "../workflows/workflow-config.ts";
15
+ import { toPiSessionId } from "../utils/session-utils.ts";
15
16
 
16
17
  export interface RunPaths {
17
18
  runId: string;
@@ -32,7 +33,7 @@ interface ManifestCacheEntry {
32
33
  cachedAt?: number;
33
34
  }
34
35
 
35
- const MANIFEST_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
36
+ const MANIFEST_CACHE_TTL_MS = 30 * 1000; // 30 seconds (FIX: reduced from 5 minutes for faster state updates)
36
37
  const manifestCache = new Map<string, ManifestCacheEntry>();
37
38
 
38
39
  function setManifestCache(stateRoot: string, entry: ManifestCacheEntry): void {
@@ -148,6 +149,7 @@ export function createRunManifest(params: {
148
149
  const manifest: TeamRunManifest = {
149
150
  schemaVersion: 1,
150
151
  runId: paths.runId,
152
+ sessionId: toPiSessionId(paths.runId),
151
153
  team: params.team.name,
152
154
  workflow: params.workflow?.name,
153
155
  goal: params.goal,
@@ -4,6 +4,7 @@ import type { WorkerHeartbeatState } from "../runtime/worker-heartbeat.ts";
4
4
  import type { CrewAgentProgress } from "../runtime/crew-agent-runtime.ts";
5
5
  import type { RolloutEntry, CoherenceMark } from "./decision-ledger.ts";
6
6
  export type { RolloutEntry, CoherenceMark };
7
+ export type { CrewAgentProgress };
7
8
 
8
9
  export type { TeamRunStatus, TeamTaskStatus } from "./contracts.ts";
9
10
 
@@ -25,6 +26,7 @@ export interface VerificationCommandResult {
25
26
  cmd: string;
26
27
  status: "passed" | "failed" | "not_run";
27
28
  exitCode?: number | null;
29
+ durationMs?: number;
28
30
  outputArtifact?: ArtifactDescriptor;
29
31
  }
30
32
 
@@ -156,6 +158,8 @@ export interface CrewAttentionEventData {
156
158
  export interface TeamRunManifest {
157
159
  schemaVersion: 1;
158
160
  runId: string;
161
+ /** pi session ID aligned with run ID for cross-referencing (e.g., "crew-team20260528") */
162
+ sessionId?: string;
159
163
  team: string;
160
164
  workflow?: string;
161
165
  goal: string;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Type imports from pi v0.77.0
3
+ */
4
+ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
5
+
6
+ export type {
7
+ AgentSessionEvent,
8
+ } from "@earendil-works/pi-coding-agent";
9
+
10
+ // Note: AgentEvent is not exported by pi-coding-agent v0.77.0
11
+ // Using AgentEndEvent and AgentStartEvent instead
12
+
13
+ // Type guards for pi-crew usage
14
+ export function isToolEvent(event: AgentSessionEvent): boolean {
15
+ return event.type === "tool_execution_start" ||
16
+ event.type === "tool_execution_update" ||
17
+ event.type === "tool_execution_end";
18
+ }
19
+
20
+ export function isAgentLifecycleEvent(event: AgentSessionEvent): boolean {
21
+ return event.type === "agent_start" || event.type === "agent_end";
22
+ }
23
+
24
+ export function isCompactionEvent(event: AgentSessionEvent): boolean {
25
+ return event.type === "compaction_start" || event.type === "compaction_end";
26
+ }
27
+
28
+ export function isRetryEvent(event: AgentSessionEvent): boolean {
29
+ return event.type === "auto_retry_start" || event.type === "auto_retry_end";
30
+ }
31
+
32
+ export function isQueueEvent(event: AgentSessionEvent): boolean {
33
+ return event.type === "queue_update";
34
+ }
@@ -38,6 +38,8 @@ function sourceIcon(source: ResourceSource): string {
38
38
  case "user": return "👤";
39
39
  case "project": return "📂";
40
40
  case "git": return "🔗";
41
+ case "dynamic": return "⚡";
42
+ default: return "❓";
41
43
  }
42
44
  }
43
45
 
@@ -47,6 +49,8 @@ function sourceLabel(source: ResourceSource): string {
47
49
  case "user": return "user";
48
50
  case "project": return "project";
49
51
  case "git": return "git";
52
+ case "dynamic": return "dynamic";
53
+ default: return "unknown";
50
54
  }
51
55
  }
52
56
 
@@ -61,7 +65,7 @@ export interface AgentOverlayState {
61
65
  export function createAgentOverlayState(entries: AgentEntry[], maxVisible = 20): AgentOverlayState {
62
66
  return {
63
67
  entries: entries.sort((a, b) => {
64
- const order: Record<ResourceSource, number> = { project: 0, user: 1, git: 2, builtin: 3 };
68
+ const order: Record<ResourceSource, number> = { project: 0, user: 1, git: 2, builtin: 3, dynamic: 4 };
65
69
  const diff = (order[a.source] ?? 4) - (order[b.source] ?? 4);
66
70
  return diff !== 0 ? diff : a.name.localeCompare(b.name);
67
71
  }),
@@ -23,14 +23,25 @@ import { SUBAGENT_SPINNER_FRAMES, spinnerBucket, spinnerFrame } from "./spinner.
23
23
 
24
24
  const SPINNER = SUBAGENT_SPINNER_FRAMES;
25
25
  const TOOL_LABELS: Record<string, string> = {
26
- read: "reading",
26
+ head: "reading",
27
27
  bash: "running command",
28
28
  edit: "editing",
29
29
  write: "writing",
30
- grep: "searching",
30
+ grep: "searching",
31
31
  find: "finding files",
32
32
  ls: "listing",
33
33
  };
34
+
35
+ const TOOL_ICONS: Record<string, string> = {
36
+ read: "📖",
37
+ bash: ">",
38
+ edit: "✏",
39
+ write: "📝",
40
+ grep: "🔍",
41
+ find: "📁",
42
+ ls: "📋",
43
+ agent: "🤖",
44
+ };
34
45
  const LEGACY_WIDGET_KEY = "pi-crew";
35
46
  const WIDGET_KEY = "pi-crew-active";
36
47
  const STATUS_KEY = "pi-crew";
@@ -90,16 +101,16 @@ function describeLiveActivity(handle: LiveAgentHandle): string {
90
101
  if (act.activeTools.size > 0) {
91
102
  const groups = new Map<string, number>();
92
103
  for (const toolName of act.activeTools.values()) {
93
- const label = TOOL_LABELS[toolName] ?? toolName;
94
- groups.set(label, (groups.get(label) ?? 0) + 1);
104
+ groups.set(toolName, (groups.get(toolName) ?? 0) + 1);
95
105
  }
96
106
  const parts: string[] = [];
97
- for (const [label, count] of groups) {
107
+ for (const [toolName, count] of groups) {
108
+ const icon = TOOL_ICONS[toolName] ?? "?";
109
+ const label = TOOL_LABELS[toolName] ?? toolName;
98
110
  if (count > 1) {
99
- const noun = label === "searching" ? "patterns" : label === "listing" ? "entries" : "files";
100
- parts.push(`${label} ${count} ${noun}`);
111
+ parts.push(`${icon}${count} ${label}s`);
101
112
  } else {
102
- parts.push(label);
113
+ parts.push(`${icon} ${label}`);
103
114
  }
104
115
  }
105
116
  return parts.join(", ") + "…";
@@ -241,14 +252,17 @@ export function activeWidgetRuns(cwd: string, manifestCache?: ManifestCache, sna
241
252
  function statusSummary(runs: WidgetRun[]): string {
242
253
  const agents = runs.flatMap((item) => item.agents);
243
254
  const runningAgents = agents.filter((agent) => agent.status === "running").length;
244
- const queuedAgents = agents.filter((agent) => agent.status === "queued").length;
245
- const waitingAgents = agents.filter((agent) => agent.status === "waiting").length;
255
+ const queuedAgents = agents.filter((agent) => agent.status === "queued" || agent.status === "waiting").length;
246
256
  const completedAgents = agents.filter((agent) => agent.status === "completed").length;
247
- const parts = [`${runningAgents} running`];
248
- if (queuedAgents) parts.push(`${queuedAgents} queued`);
249
- if (waitingAgents) parts.push(`${waitingAgents} waiting`);
250
- if (completedAgents) parts.push(`${completedAgents}/${agents.length} done`);
251
- return `Crew: ${parts.join(", ")}`;
257
+ const totalAgents = agents.length;
258
+ const totalRuns = runs.length;
259
+ const model = agents.find((a) => a.model)?.model?.split("/").at(-1);
260
+ const parts = [`⚙ ${runningAgents}r`];
261
+ if (queuedAgents > 0) parts.push(`${queuedAgents}q`);
262
+ if (completedAgents > 0) parts.push(`${completedAgents}/${totalAgents}done`);
263
+ if (totalRuns > 1) parts.push(`${totalRuns}runs`);
264
+ if (model) parts.push(model);
265
+ return parts.join(" · ");
252
266
  }
253
267
 
254
268
  export function notificationBadge(count: number | undefined, env: NodeJS.ProcessEnv = process.env): string {
@@ -12,6 +12,8 @@ import type { ManifestCache } from "../runtime/manifest-cache.ts";
12
12
  import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
13
13
  import { notificationBadge } from "./crew-widget.ts";
14
14
  import { RenderCoalescer } from "./render-coalescer.ts";
15
+ import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
16
+ import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
15
17
 
16
18
  type EventBus = { emit?: (event: string, data: unknown) => void; listenerCount?: (event: string) => number } | undefined;
17
19
  type StatusContext = { hasUI?: boolean; ui?: { setStatus?: (key: string, text: string | undefined) => void } } | undefined;
@@ -63,6 +65,7 @@ export function registerPiCrewPowerbarSegments(events: EventBus, config?: CrewUi
63
65
  if (config?.powerbar === false) return;
64
66
  safeEmit(events, "powerbar:register-segment", { id: "pi-crew-active", label: "pi-crew active agents" });
65
67
  safeEmit(events, "powerbar:register-segment", { id: "pi-crew-progress", label: "pi-crew run progress" });
68
+ safeEmit(events, "powerbar:register-segment", { id: "pi-crew-steps", label: "pi-crew workflow steps" });
66
69
  }
67
70
 
68
71
  export function updatePiCrewPowerbar(events: EventBus, cwd: string, config?: CrewUiConfig, manifestCache?: ManifestCache, snapshotCache?: RunSnapshotCache, ctx?: StatusContext, notificationCount = 0, preloadedManifests?: TeamRunManifest[]): void {
@@ -90,9 +93,10 @@ export function updatePiCrewPowerbar(events: EventBus, cwd: string, config?: Cre
90
93
  if (!active.length) {
91
94
  lastActiveKey = undefined;
92
95
  lastProgressKey = undefined;
96
+ lastStepsKey = undefined;
93
97
  safeEmit(events, "powerbar:update", { id: "pi-crew-active" });
94
98
  safeEmit(events, "powerbar:update", { id: "pi-crew-progress" });
95
- if (useStatusFallback) setStatusFallback(ctx, undefined);
99
+ safeEmit(events, "powerbar:update", { id: "pi-crew-steps" });
96
100
  return;
97
101
  }
98
102
  const agents = active.flatMap((item) => item.agents);
@@ -108,13 +112,33 @@ export function updatePiCrewPowerbar(events: EventBus, cwd: string, config?: Cre
108
112
  const model = config?.showModel === false ? undefined : agents.find((agent) => agent.model)?.model?.split("/").at(-1);
109
113
  const tokenText = config?.showTokens === false || !tokenTotal ? undefined : compactTokens(tokenTotal);
110
114
  const liveRunning = listLiveAgents().filter((a) => a.status === "running").length;
111
- const activeText = `crew ${running}a/${waiting}w${liveRunning > 0 ? `/${liveRunning}live` : ""}${notificationBadge(notificationCount)}`;
112
- const activeSuffix = [model, tokenText].filter(Boolean).join(" · ") || undefined;
115
+ // Always show consistent status: running count + queued count from live tasks only
116
+ // Avoid snapshot cache for counts to prevent UI jumping
117
+ const runningCount = agents.filter((a) => a.status === "running").length;
118
+ // Count queued/waiting tasks directly from tasks array (not snapshot) for consistency
119
+ const queuedCount = active.reduce((sum, item) => sum + item.tasks.reduce((s, t) => s + (t.status === "queued" || t.status === "waiting" ? 1 : 0), 0), 0);
120
+ // Format: "1 running", "2 running · 1 queued", "3 queued", "idle"
121
+ const runningLabel = runningCount === 1 ? "1 running" : `${runningCount} running`;
122
+ const queuedLabel = queuedCount === 1 ? "1 queued" : `${queuedCount} queued`;
123
+ const crewStatus = runningCount > 0 && queuedCount > 0 ? `${runningLabel} · ${queuedLabel}` : runningCount > 0 ? runningLabel : queuedCount > 0 ? queuedLabel : "idle";
124
+ const liveSuffix = liveRunning > 0 ? ` (${liveRunning} live)` : "";
125
+ const notificationText = notificationBadge(notificationCount);
126
+ // Always show model + tokens as suffix when available (for activePayload consistency)
127
+ const suffixParts = [model, tokenText].filter(Boolean);
128
+ const activeSuffix = suffixParts.length > 0 ? suffixParts.join(" · ") : undefined;
129
+ // Progress always includes token count for consistency
113
130
  const progressSuffix = `${completed}/${total}${tokenText ? ` · ${tokenText}` : ""}`;
131
+ // Build complete, always-consistent fallback text AND event payload to prevent UI flickering
132
+ // Both fallback and events must use the SAME format - no conditional display
133
+ // Format: "⚙ 1 running · 1 queued · model · 30k · 0/1" (never changes based on availability)
134
+ const progressPart = `${completed}/${total}`;
135
+ const allParts = [`⚙ ${crewStatus}`, model ?? "", tokenText ?? "", progressPart].filter(Boolean);
136
+ const unifiedText = allParts.join(" · ");
137
+ // activePayload.text includes notification badge for event payload
114
138
  const activePayload = {
115
139
  id: "pi-crew-active",
116
140
  icon: "⚙",
117
- text: activeText,
141
+ text: `⚙ ${crewStatus}${liveSuffix}${notificationText}${activeSuffix ? ` · ${activeSuffix}` : ""}`,
118
142
  suffix: activeSuffix,
119
143
  color: running ? "accent" : "warning",
120
144
  } as const;
@@ -126,12 +150,15 @@ export function updatePiCrewPowerbar(events: EventBus, cwd: string, config?: Cre
126
150
  color: completed === total ? "success" : "accent",
127
151
  barSegments: 8,
128
152
  } as const;
153
+ // Build step progress: "explorer > planner > executor > verifier" with current step highlighted
154
+ const stepsPayload = buildStepsPayload(active, tasks);
129
155
  // 1.8: dedup per segment using a key over every visible field. Previously
130
156
  // the dedup string only carried text/suffix/running, so changes to `bar`
131
157
  // (progress %) or `color` could be swallowed and stale UI emitted again
132
158
  // later as a single noisy burst.
133
159
  const activeKey = powerbarKey(activePayload);
134
160
  const progressKey = powerbarKey(progressPayload);
161
+ const stepsKey = powerbarKey(stepsPayload);
135
162
  if (activeKey !== lastActiveKey) {
136
163
  lastActiveKey = activeKey;
137
164
  safeEmit(events, "powerbar:update", activePayload);
@@ -140,14 +167,21 @@ export function updatePiCrewPowerbar(events: EventBus, cwd: string, config?: Cre
140
167
  lastProgressKey = progressKey;
141
168
  safeEmit(events, "powerbar:update", progressPayload);
142
169
  }
143
- if (useStatusFallback) setStatusFallback(ctx, `${activeText}${activeSuffix ? ` · ${activeSuffix}` : ""} · ${progressSuffix}`);
170
+ if (stepsKey !== lastStepsKey) {
171
+ lastStepsKey = stepsKey;
172
+ safeEmit(events, "powerbar:update", stepsPayload);
173
+ }
174
+ // Never call setStatusFallback - crew-widget manages "pi-crew" status with its own widget format
175
+ // Powerbar only emits events; it does not set status directly
144
176
  }
145
177
 
146
178
  // --- Dedup state: skip emit if segment data unchanged ---
147
179
  let lastActiveKey: string | undefined;
148
180
  let lastProgressKey: string | undefined;
181
+ let lastStepsKey: string | undefined;
149
182
 
150
183
  interface PowerbarPayloadShape {
184
+ id?: string;
151
185
  text?: string;
152
186
  suffix?: string;
153
187
  bar?: number;
@@ -160,6 +194,63 @@ function powerbarKey(payload: PowerbarPayloadShape): string {
160
194
  return `${payload.text ?? ""}|${payload.suffix ?? ""}|${payload.bar ?? ""}|${payload.color ?? ""}|${payload.icon ?? ""}|${payload.barSegments ?? ""}`;
161
195
  }
162
196
 
197
+ interface ActiveItem {
198
+ run: TeamRunManifest;
199
+ agents: ReturnType<typeof readCrewAgents>;
200
+ tasks: TeamTaskState[];
201
+ snapshot?: RunUiSnapshot;
202
+ }
203
+
204
+ /**
205
+ * Build the workflow steps segment showing: ✓explore › →plan › ○execute › ○verify
206
+ * with the current/active step highlighted using → arrow.
207
+ */
208
+ function buildStepsPayload(active: ActiveItem[], allTasks: TeamTaskState[]): PowerbarPayloadShape {
209
+ if (!active.length) {
210
+ return { id: "pi-crew-steps" };
211
+ }
212
+ const run = active[0]!.run;
213
+ const workflowName = run.workflow ?? "default";
214
+ // Load workflow steps
215
+ const workflows = allWorkflows(discoverWorkflows(run.cwd));
216
+ const workflow = workflows.find((w) => w.name === workflowName);
217
+ if (!workflow || workflow.steps.length === 0) {
218
+ return { id: "pi-crew-steps", text: workflowName };
219
+ }
220
+ // Build step status map from tasks
221
+ const stepStatus = new Map<string, "completed" | "running" | "pending">();
222
+ for (const task of allTasks) {
223
+ if (!task.stepId) continue;
224
+ if (!stepStatus.has(task.stepId)) {
225
+ if (task.status === "completed") {
226
+ stepStatus.set(task.stepId, "completed");
227
+ } else if (task.status === "running" || task.status === "queued" || task.status === "waiting") {
228
+ stepStatus.set(task.stepId, "running");
229
+ }
230
+ }
231
+ }
232
+ // Format: "✓explore › →plan › ○execute › ○verify"
233
+ // ✓ = completed, → = running (current), ○ = pending
234
+ const stepParts: string[] = [];
235
+ for (const step of workflow.steps) {
236
+ const status = stepStatus.get(step.id) ?? "pending";
237
+ const icon = status === "completed" ? "✓" : status === "running" ? "→" : "○";
238
+ // Shorten long step names
239
+ const stepName = step.id.length > 10 ? step.id.slice(0, 9) + "…" : step.id;
240
+ stepParts.push(`${icon}${stepName}`);
241
+ }
242
+ const stepsText = stepParts.join(" › ");
243
+ // Color: accent if running step exists, success if all complete, dim otherwise
244
+ const hasRunningStep = [...stepStatus.values()].includes("running");
245
+ const allComplete = stepStatus.size === workflow.steps.length && ![...stepStatus.values()].includes("running");
246
+ const color = allComplete ? "success" : hasRunningStep ? "accent" : "dim";
247
+ return {
248
+ id: "pi-crew-steps",
249
+ text: stepsText,
250
+ color,
251
+ };
252
+ }
253
+
163
254
  // --- Coalesced powerbar update ---
164
255
 
165
256
  interface PowerbarUpdateArgs {
@@ -206,16 +297,18 @@ export function disposePowerbarCoalescer(): void {
206
297
  powerbarCoalescer.dispose();
207
298
  }
208
299
 
209
- export function clearPiCrewPowerbar(events: EventBus, ctx?: StatusContext): void {
300
+ export function clearPiCrewPowerbar(events: EventBus): void {
210
301
  lastActiveKey = undefined;
211
302
  lastProgressKey = undefined;
303
+ lastStepsKey = undefined;
212
304
  safeEmit(events, "powerbar:update", { id: "pi-crew-active" });
213
305
  safeEmit(events, "powerbar:update", { id: "pi-crew-progress" });
214
- setStatusFallback(ctx, undefined);
306
+ safeEmit(events, "powerbar:update", { id: "pi-crew-steps" });
215
307
  }
216
308
 
217
309
  /** Reset dedup state on session lifecycle events. */
218
310
  export function resetPowerbarDedupState(): void {
219
311
  lastActiveKey = undefined;
220
312
  lastProgressKey = undefined;
313
+ lastStepsKey = undefined;
221
314
  }