pi-crew 0.5.2 → 0.5.6

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 (137) hide show
  1. package/CHANGELOG.md +183 -0
  2. package/README.md +17 -1
  3. package/docs/architecture.md +2 -0
  4. package/docs/bugs/cross-session-notification-leakage.md +82 -0
  5. package/docs/coding-agent-optimization.md +268 -0
  6. package/docs/deep-review-report.md +384 -0
  7. package/docs/distillation/cybersecurity-patterns.md +294 -0
  8. package/docs/migration-v0.4-v0.5.md +208 -0
  9. package/docs/optimization-plan.md +642 -0
  10. package/docs/pi-crew-v0.5.5-audit-fix-plan.md +133 -0
  11. package/docs/pi-mono-opportunities.md +969 -0
  12. package/docs/pi-mono-review.md +291 -0
  13. package/docs/skills/REFERENCE.md +144 -0
  14. package/package.json +12 -9
  15. package/skills/artifact-analysis-loop/SKILL.md +302 -0
  16. package/skills/async-worker-recovery/SKILL.md +19 -1
  17. package/skills/child-pi-spawning/SKILL.md +19 -6
  18. package/skills/context-artifact-hygiene/SKILL.md +19 -2
  19. package/skills/delegation-patterns/SKILL.md +68 -3
  20. package/skills/detection-pipeline-design/SKILL.md +285 -0
  21. package/skills/event-log-tracing/SKILL.md +20 -6
  22. package/skills/git-master/SKILL.md +20 -6
  23. package/skills/hunting-investigation-loop/SKILL.md +401 -0
  24. package/skills/incident-playbook-construction/SKILL.md +383 -0
  25. package/skills/live-agent-lifecycle/SKILL.md +20 -6
  26. package/skills/mailbox-interactive/SKILL.md +19 -6
  27. package/skills/model-routing-context/SKILL.md +19 -1
  28. package/skills/multi-perspective-review/SKILL.md +19 -4
  29. package/skills/observability-reliability/SKILL.md +19 -2
  30. package/skills/orchestration/SKILL.md +20 -2
  31. package/skills/ownership-session-security/SKILL.md +20 -2
  32. package/skills/pi-extension-lifecycle/SKILL.md +20 -2
  33. package/skills/post-mortem/SKILL.md +7 -2
  34. package/skills/read-only-explorer/SKILL.md +20 -6
  35. package/skills/requirements-to-task-packet/SKILL.md +23 -3
  36. package/skills/resource-discovery-config/SKILL.md +20 -2
  37. package/skills/runtime-state-reader/SKILL.md +20 -2
  38. package/skills/safe-bash/SKILL.md +21 -6
  39. package/skills/scrutinize/SKILL.md +20 -2
  40. package/skills/secure-agent-orchestration-review/SKILL.md +29 -2
  41. package/skills/security-review/SKILL.md +560 -0
  42. package/skills/state-mutation-locking/SKILL.md +22 -2
  43. package/skills/systematic-debugging/SKILL.md +8 -6
  44. package/skills/threat-hypothesis-framework/SKILL.md +175 -0
  45. package/skills/ui-render-performance/SKILL.md +20 -2
  46. package/skills/verification-before-done/SKILL.md +17 -2
  47. package/skills/widget-rendering/SKILL.md +21 -6
  48. package/skills/workspace-isolation/SKILL.md +20 -6
  49. package/skills/worktree-isolation/SKILL.md +20 -6
  50. package/src/agents/agent-config.ts +40 -1
  51. package/src/benchmark/benchmark-runner.ts +45 -0
  52. package/src/benchmark/feedback-loop.ts +5 -0
  53. package/src/config/config.ts +32 -5
  54. package/src/config/role-tools.ts +82 -0
  55. package/src/config/suggestions.ts +8 -0
  56. package/src/config/types.ts +4 -0
  57. package/src/extension/async-notifier.ts +10 -1
  58. package/src/extension/crew-cleanup.ts +114 -0
  59. package/src/extension/cross-extension-rpc.ts +1 -1
  60. package/src/extension/notification-router.ts +18 -0
  61. package/src/extension/register.ts +27 -19
  62. package/src/extension/registration/subagent-tools.ts +1 -1
  63. package/src/extension/team-tool/anchor.ts +201 -0
  64. package/src/extension/team-tool/api.ts +2 -1
  65. package/src/extension/team-tool/auto-summarize.ts +154 -0
  66. package/src/extension/team-tool/run.ts +42 -7
  67. package/src/extension/team-tool.ts +44 -2
  68. package/src/hooks/registry.ts +1 -3
  69. package/src/observability/event-bus.ts +69 -0
  70. package/src/observability/event-to-metric.ts +0 -2
  71. package/src/runtime/anchor-manager.ts +473 -0
  72. package/src/runtime/async-runner.ts +8 -4
  73. package/src/runtime/auto-summarize.ts +350 -0
  74. package/src/runtime/background-runner.ts +10 -3
  75. package/src/runtime/budget-tracker.ts +354 -0
  76. package/src/runtime/chain-runner.ts +507 -0
  77. package/src/runtime/child-pi.ts +123 -35
  78. package/src/runtime/crash-recovery.ts +5 -4
  79. package/src/runtime/crew-agent-runtime.ts +1 -0
  80. package/src/runtime/custom-tools/irc-tool.ts +13 -0
  81. package/src/runtime/custom-tools/submit-result-tool.ts +3 -2
  82. package/src/runtime/delivery-coordinator.ts +10 -3
  83. package/src/runtime/dynamic-script-runner.ts +482 -0
  84. package/src/runtime/foreground-control.ts +87 -17
  85. package/src/runtime/handoff-manager.ts +589 -0
  86. package/src/runtime/hidden-handoff.ts +424 -0
  87. package/src/runtime/live-agent-manager.ts +20 -4
  88. package/src/runtime/live-session-runtime.ts +39 -4
  89. package/src/runtime/manifest-cache.ts +2 -1
  90. package/src/runtime/model-resolver.ts +16 -4
  91. package/src/runtime/phase-tracker.ts +373 -0
  92. package/src/runtime/pi-args.ts +11 -1
  93. package/src/runtime/pi-json-output.ts +31 -0
  94. package/src/runtime/pipeline-runner.ts +514 -0
  95. package/src/runtime/progress-tracker.ts +124 -0
  96. package/src/runtime/retry-runner.ts +354 -0
  97. package/src/runtime/sandbox.ts +252 -0
  98. package/src/runtime/scheduler.ts +7 -2
  99. package/src/runtime/skill-effectiveness.ts +473 -0
  100. package/src/runtime/skill-instructions.ts +37 -3
  101. package/src/runtime/subagent-manager.ts +1 -1
  102. package/src/runtime/task-graph.ts +11 -1
  103. package/src/runtime/task-runner.ts +92 -18
  104. package/src/runtime/team-runner.ts +13 -12
  105. package/src/runtime/tool-progress.ts +10 -3
  106. package/src/runtime/verification-gates.ts +367 -0
  107. package/src/schema/team-tool-schema.ts +37 -0
  108. package/src/skills/discover-skills.ts +5 -0
  109. package/src/state/active-run-registry.ts +9 -2
  110. package/src/state/contracts.ts +9 -0
  111. package/src/state/crew-init.ts +3 -3
  112. package/src/state/decision-ledger.ts +98 -55
  113. package/src/state/event-log-rotation.ts +2 -2
  114. package/src/state/event-log.ts +144 -10
  115. package/src/state/hook-instinct-bridge.ts +5 -5
  116. package/src/state/mailbox.ts +10 -0
  117. package/src/state/run-cache.ts +18 -8
  118. package/src/state/state-store.ts +3 -1
  119. package/src/state/types.ts +4 -0
  120. package/src/tools/safe-bash-extension.ts +1 -0
  121. package/src/tools/safe-bash.ts +152 -20
  122. package/src/types/new-api-types.ts +34 -0
  123. package/src/ui/agent-management-overlay.ts +5 -1
  124. package/src/ui/crew-widget.ts +29 -15
  125. package/src/ui/overlays/mailbox-detail-overlay.ts +13 -2
  126. package/src/ui/powerbar-publisher.ts +101 -7
  127. package/src/ui/tool-render.ts +15 -15
  128. package/src/ui/transcript-cache.ts +13 -0
  129. package/src/utils/bm25-search.ts +16 -8
  130. package/src/utils/env-filter.ts +8 -5
  131. package/src/utils/redaction.ts +169 -15
  132. package/src/utils/session-utils.ts +52 -0
  133. package/src/utils/sse-parser.ts +10 -1
  134. package/src/worktree/cleanup.ts +6 -1
  135. package/src/worktree/worktree-manager.ts +32 -13
  136. package/workflows/chain.workflow.md +252 -0
  137. package/workflows/pipeline.workflow.md +27 -0
@@ -1,50 +1,144 @@
1
1
  /**
2
2
  * Safe Bash Tool for pi-crew
3
3
  * Wraps bash with dangerous command blocking
4
+ * Uses linear-time scanning to prevent ReDoS attacks
4
5
  */
5
6
 
6
7
  import { Type } from "@sinclair/typebox";
7
8
 
8
- // Dangerous command patterns to block
9
+ // Backward-compatible pattern array (kept for getPatterns API)
10
+ // IMPORTANT: Line 8 (rm pattern with nested quantifiers) has been replaced
11
+ // with linear-time checking in isDangerous() to prevent ReDoS attacks.
9
12
  const DANGEROUS_PATTERNS = [
10
- // rm -rf / or rm -rf ~ (catastrophic root/home deletion)
11
- /\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s*)+(\/|~)(\s*$)/,
12
- /\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s*)+(\/|~)($|\s)/,
13
- // Privilege escalation
13
+ // NOTE: rm patterns handled by matchesDangerousRm() for linear-time safety
14
14
  /\bsudo\b/,
15
15
  /\bsu\s+root\b/,
16
- // Filesystem destruction
17
16
  /\bmkfs\b/,
18
17
  /\bdd\s+if=/,
19
- // Fork bomb
20
18
  /^:\s*\(\s*\)\s*\{.*\|.*&.*\}\s*;.*$/,
21
- // Device writing
22
19
  />\s*\/dev\/[sh]d[a-z]/,
23
20
  /\bchmod\s+(-[a-zA-Z]+\s+)?777\s+\//,
24
21
  /\bchown\s+(-[a-zA-Z]+\s+)?root/,
25
- // Pipe to shell (download and execute)
26
22
  /\bcurl\s.*\|\s*(ba)?sh/i,
27
23
  /\bwget\s.*\|\s*(ba)?sh/i,
28
- // System shutdown/reboot
29
24
  /\bshutdown\b/,
30
25
  /\breboot\b/,
31
26
  /\binit\s+0\b/,
32
- // Kill critical processes
33
27
  /\bkill\s+-9\s+1\b/,
34
28
  /\bkillall\b/,
35
- // Encoded commands
36
29
  /\|\s*base64\s+-d/,
37
30
  /\|\s*python.*-c/,
38
31
  /\|\s*perl.*-e/,
39
32
  /\|\s*ruby.*-e/,
40
- // Network to shell
41
- /\bbash\s+-i\s+>\s*\&/,
33
+ /\bbash\s+-i\s*>\s*\&/,
42
34
  /\bexec\s+.*bash/,
43
- // /etc/passwd manipulation
44
35
  /\becho\s+.*>\s*\/etc\/passwd/,
45
36
  /\bcat\s+.*>\s*\/etc\/passwd/,
46
37
  ];
47
38
 
39
+ /**
40
+ * Linear-time check if command contains a dangerous rm pattern like "rm -rf /" or "rm -rf ~"
41
+ * Replaces O(n²) regex backtracking with O(n) string scanning
42
+ */
43
+ function matchesDangerousRm(command: string): boolean {
44
+ let pos = 0;
45
+ const len = command.length;
46
+ // Find "rm" at word boundary
47
+ while (pos < len) {
48
+ const rmIdx = command.indexOf("rm", pos);
49
+ if (rmIdx === -1) return false;
50
+ // Check word boundary before "rm"
51
+ if (rmIdx > 0 && /\w/.test(command[rmIdx - 1])) {
52
+ pos = rmIdx + 1;
53
+ continue;
54
+ }
55
+ // Must be followed by whitespace
56
+ const afterRm = rmIdx + 2;
57
+ if (afterRm >= len || /\s/.test(command[afterRm])) {
58
+ // Found "rm " - now check for -rf flags followed by / or ~
59
+ let p = afterRm + 1;
60
+ while (p < len) {
61
+ // Skip whitespace
62
+ while (p < len && /\s/.test(command[p])) p++;
63
+ if (p >= len) break;
64
+ // Check for flag
65
+ if (command[p] !== "-") break;
66
+ p++;
67
+ let hasR = false, hasF = false;
68
+ while (p < len && /[a-zA-Z]/.test(command[p])) {
69
+ if (command[p] === "r" || command[p] === "R") hasR = true;
70
+ if (command[p] === "f" || command[p] === "F") hasF = true;
71
+ p++;
72
+ }
73
+ if (!hasR && !hasF) break; // Flag must have r or f
74
+ // Skip whitespace after flag
75
+ while (p < len && /\s/.test(command[p])) p++;
76
+ }
77
+ // Now check if followed by / or ~ (end or whitespace)
78
+ if (p < len && (command[p] === "/" || command[p] === "~")) {
79
+ const afterSlash = p + 1;
80
+ if (afterSlash >= len || /\s/.test(command[afterSlash]) || command[afterSlash] === ";") {
81
+ return true; // Dangerous!
82
+ }
83
+ }
84
+ }
85
+ pos = rmIdx + 1;
86
+ }
87
+ return false;
88
+ }
89
+
90
+ /**
91
+ * Linear-time check for fork bomb pattern: :() { ... | ... & ... } ; ...
92
+ */
93
+ function matchesForkBomb(command: string): boolean {
94
+ // Must start with :
95
+ const trimmed = command.trimStart();
96
+ if (!trimmed.startsWith(":")) return false;
97
+ // Find () after :
98
+ const parenIdx = trimmed.indexOf("()");
99
+ if (parenIdx === -1 || parenIdx > 10) return false; // : must be close to ()
100
+ // Find { after ()
101
+ const braceIdx = trimmed.indexOf("{", parenIdx);
102
+ if (braceIdx === -1 || braceIdx > parenIdx + 5) return false;
103
+ // Find } closing brace
104
+ const closeBrace = trimmed.indexOf("}", braceIdx);
105
+ if (closeBrace === -1) return false;
106
+ // Check content between braces for | and &
107
+ const content = trimmed.slice(braceIdx + 1, closeBrace);
108
+ if (content.includes("|") && content.includes("&")) return true;
109
+ return false;
110
+ }
111
+
112
+ /**
113
+ * Check for encoded command patterns (pipe to shell)
114
+ */
115
+ function matchesEncodedPipe(command: string): boolean {
116
+ const lower = command.toLowerCase();
117
+ const pipeIdx = lower.indexOf("|");
118
+ if (pipeIdx === -1) return false;
119
+ const afterPipe = lower.slice(pipeIdx + 1).trimStart();
120
+ if (afterPipe.startsWith("base64") || afterPipe.startsWith("python") || afterPipe.startsWith("perl") || afterPipe.startsWith("ruby")) {
121
+ // Check if followed by -d or -c or -e
122
+ const rest = afterPipe.slice(6).trimStart();
123
+ if (rest.startsWith("-d") || rest.startsWith("-c") || rest.startsWith("-e")) return true;
124
+ }
125
+ return false;
126
+ }
127
+
128
+ /**
129
+ * Check if command contains a specific dangerous substring
130
+ */
131
+ function containsDangerous(command: string, pattern: string): boolean {
132
+ return command.indexOf(pattern) !== -1;
133
+ }
134
+
135
+ /**
136
+ * Check if command starts with dangerous prefix
137
+ */
138
+ function startsWithDangerous(command: string, pattern: string): boolean {
139
+ return command.trimStart().startsWith(pattern);
140
+ }
141
+
48
142
  export interface SafeBashOptions {
49
143
  /** Enable/disable safe mode. Default: true */
50
144
  enabled?: boolean;
@@ -75,9 +169,47 @@ export function isDangerous(command: string, options: SafeBashOptions = {}): str
75
169
  }
76
170
  }
77
171
 
78
- // Check dangerous patterns
79
- const allPatterns = [...DANGEROUS_PATTERNS, ...additionalPatterns];
80
- for (const pattern of allPatterns) {
172
+ // Use linear-time scanning functions for critical patterns
173
+ if (matchesDangerousRm(normalized)) {
174
+ return "Command blocked by safe_bash: dangerous rm pattern detected";
175
+ }
176
+ if (matchesForkBomb(normalized)) {
177
+ return "Command blocked by safe_bash: fork bomb pattern detected";
178
+ }
179
+ if (matchesEncodedPipe(normalized)) {
180
+ return "Command blocked by safe_bash: encoded pipe to shell detected";
181
+ }
182
+
183
+ // Check remaining patterns using regex (these are safe from ReDoS)
184
+ for (const pattern of DANGEROUS_PATTERNS) {
185
+ if (pattern.test(normalized)) {
186
+ return `Command blocked by safe_bash: matches dangerous pattern \`${pattern}\``;
187
+ }
188
+ }
189
+
190
+ // Additional shell injection checks using regex for non-critical patterns
191
+ // Block command substitution $(...)
192
+ if (/\$\([^)]*\)/.test(command)) {
193
+ return "Command blocked by safe_bash: command substitution $(...) is not allowed";
194
+ }
195
+ // Block backtick substitution
196
+ const backtickRe = /`[^`]*`/;
197
+ if (backtickRe.test(command)) {
198
+ return "Command blocked by safe_bash: backtick substitution is not allowed";
199
+ }
200
+ // Block here-docs <<
201
+ if (/<<\s*['"]?[\w-]+['"]?/.test(command) || /\$<<\s*['"]?[\w-]+['"]?/.test(command)) {
202
+ return "Command blocked by safe_bash: here-doc is not allowed";
203
+ }
204
+ // Block ${...} variable expansion containing shell metacharacters (pipes, redirects, &&/||)
205
+ const varExpRe = /\$\{([^}]*)\}/;
206
+ const varMatch = command.match(varExpRe);
207
+ if (varMatch && /[|&;<>]/.test(varMatch[1])) {
208
+ return "Command blocked by safe_bash: variable expansion with shell metacharacters is not allowed";
209
+ }
210
+
211
+ // Check additional patterns (user-provided regex)
212
+ for (const pattern of additionalPatterns) {
81
213
  if (pattern.test(normalized)) {
82
214
  return `Command blocked by safe_bash: matches dangerous pattern \`${pattern}\``;
83
215
  }
@@ -142,8 +274,8 @@ export function createSafeBash(options: SafeBashOptions = {}) {
142
274
  * These can be used in allowPatterns for specific use cases
143
275
  */
144
276
  export const COMMON_SAFE_PATTERNS = {
145
- // Safe rm with specific paths
146
- safeRm: /\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?((?![\/~])\/)?(tmp|cache|node_modules|dist|build)\//,
277
+ // Safe rm with specific paths - uses simple contains check
278
+ safeRm: /rm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?((?![\/~])\/)?(tmp|cache|node_modules|dist|build)\//,
147
279
  // Safe git operations
148
280
  safeGit: /\bgit\s+(clone|pull|push|commit|add|status|diff|log|branch|checkout|merge|rebase)/,
149
281
  // Safe npm/yarn/pnpm
@@ -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 {
@@ -20,6 +20,8 @@ export class MailboxDetailOverlay {
20
20
  private side: "inbox" | "outbox" = "inbox";
21
21
  private selected = 0;
22
22
  private expanded = false;
23
+ private lastRefreshedTaskCount = 0;
24
+ private needsRefresh = true;
23
25
 
24
26
  constructor(opts: { runId: string; cwd: string; done: (action: MailboxAction | undefined) => void; theme?: unknown }) {
25
27
  this.runId = opts.runId;
@@ -32,6 +34,12 @@ export class MailboxDetailOverlay {
32
34
  private refresh(): void {
33
35
  const loaded = loadRunManifestById(this.cwd, this.runId);
34
36
  if (!loaded) return;
37
+ // Track task count changes to trigger re-render
38
+ const taskCount = loaded.tasks.length;
39
+ if (taskCount !== this.lastRefreshedTaskCount) {
40
+ this.lastRefreshedTaskCount = taskCount;
41
+ this.needsRefresh = true;
42
+ }
35
43
  const delivery = readDeliveryState(loaded.manifest).messages;
36
44
  const applyDelivery = (message: MailboxMessage): MailboxMessage => ({ ...message, status: delivery[message.id] ?? message.status });
37
45
  const taskIds = loaded.tasks.map((task) => task.id);
@@ -49,11 +57,14 @@ export class MailboxDetailOverlay {
49
57
  }
50
58
 
51
59
  invalidate(): void {
52
- this.refresh();
60
+ this.needsRefresh = true;
53
61
  }
54
62
 
55
63
  render(width: number): string[] {
56
- this.refresh();
64
+ if (this.needsRefresh) {
65
+ this.refresh();
66
+ this.needsRefresh = false;
67
+ }
57
68
  const inner = Math.max(40, width - 4);
58
69
  const col = Math.max(18, Math.floor((inner - 3) / 2));
59
70
  const lines = [
@@ -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 {
@@ -203,19 +294,22 @@ export function requestPowerbarUpdate(
203
294
 
204
295
  /** Dispose the powerbar coalescer. Call during extension cleanup. */
205
296
  export function disposePowerbarCoalescer(): void {
297
+ powerbarCoalescer.flush();
206
298
  powerbarCoalescer.dispose();
207
299
  }
208
300
 
209
- export function clearPiCrewPowerbar(events: EventBus, ctx?: StatusContext): void {
301
+ export function clearPiCrewPowerbar(events: EventBus): void {
210
302
  lastActiveKey = undefined;
211
303
  lastProgressKey = undefined;
304
+ lastStepsKey = undefined;
212
305
  safeEmit(events, "powerbar:update", { id: "pi-crew-active" });
213
306
  safeEmit(events, "powerbar:update", { id: "pi-crew-progress" });
214
- setStatusFallback(ctx, undefined);
307
+ safeEmit(events, "powerbar:update", { id: "pi-crew-steps" });
215
308
  }
216
309
 
217
310
  /** Reset dedup state on session lifecycle events. */
218
311
  export function resetPowerbarDedupState(): void {
219
312
  lastActiveKey = undefined;
220
313
  lastProgressKey = undefined;
314
+ lastStepsKey = undefined;
221
315
  }