pi-crew 0.1.41 → 0.1.43

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 (176) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +51 -0
  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/refactor-tasks-phase3.md +394 -394
  14. package/docs/refactor-tasks-phase4.md +564 -564
  15. package/docs/refactor-tasks-phase5.md +402 -402
  16. package/docs/refactor-tasks-phase6.md +662 -662
  17. package/docs/research-extension-examples.md +297 -297
  18. package/docs/research-extension-system.md +324 -324
  19. package/docs/research-optimization-plan.md +548 -548
  20. package/docs/research-pi-coding-agent.md +357 -357
  21. package/docs/research-source-pi-crew-reference.md +174 -174
  22. package/docs/runtime-flow.md +148 -148
  23. package/docs/source-runtime-refactor-map.md +83 -83
  24. package/index.ts +6 -6
  25. package/package.json +1 -1
  26. package/src/agents/agent-serializer.ts +34 -34
  27. package/src/agents/discover-agents.ts +4 -4
  28. package/src/config/config.ts +9 -4
  29. package/src/extension/cross-extension-rpc.ts +82 -82
  30. package/src/extension/management.ts +37 -8
  31. package/src/extension/notification-router.ts +2 -2
  32. package/src/extension/register.ts +3 -0
  33. package/src/extension/registration/commands.ts +11 -9
  34. package/src/extension/registration/compaction-guard.ts +125 -125
  35. package/src/extension/registration/subagent-tools.ts +28 -19
  36. package/src/extension/result-watcher.ts +4 -4
  37. package/src/extension/run-bundle-schema.ts +8 -4
  38. package/src/extension/run-import.ts +4 -0
  39. package/src/extension/run-index.ts +4 -1
  40. package/src/extension/run-maintenance.ts +43 -24
  41. package/src/extension/team-tool/api.ts +1 -1
  42. package/src/extension/team-tool/cancel.ts +31 -31
  43. package/src/extension/team-tool/doctor.ts +8 -1
  44. package/src/extension/team-tool/handle-settings.ts +188 -0
  45. package/src/extension/team-tool/inspect.ts +41 -41
  46. package/src/extension/team-tool/lifecycle-actions.ts +79 -79
  47. package/src/extension/team-tool/plan.ts +19 -19
  48. package/src/extension/team-tool/run.ts +4 -2
  49. package/src/extension/team-tool/status.ts +93 -93
  50. package/src/extension/team-tool.ts +3 -1
  51. package/src/i18n.ts +184 -0
  52. package/src/observability/correlation.ts +2 -2
  53. package/src/observability/event-to-metric.ts +4 -3
  54. package/src/observability/exporters/adapter.ts +7 -1
  55. package/src/observability/exporters/otlp-exporter.ts +14 -2
  56. package/src/observability/exporters/prometheus-exporter.ts +9 -2
  57. package/src/observability/metric-registry.ts +18 -3
  58. package/src/observability/metric-retention.ts +11 -3
  59. package/src/observability/metric-sink.ts +9 -4
  60. package/src/observability/metrics-primitives.ts +4 -3
  61. package/src/prompt/prompt-runtime.ts +72 -68
  62. package/src/runtime/agent-control.ts +63 -63
  63. package/src/runtime/agent-memory.ts +72 -72
  64. package/src/runtime/agent-observability.ts +114 -114
  65. package/src/runtime/async-marker.ts +26 -26
  66. package/src/runtime/attention-events.ts +28 -23
  67. package/src/runtime/background-runner.ts +53 -53
  68. package/src/runtime/child-pi.ts +4 -4
  69. package/src/runtime/completion-guard.ts +103 -99
  70. package/src/runtime/concurrency.ts +1 -1
  71. package/src/runtime/crash-recovery.ts +2 -1
  72. package/src/runtime/crew-agent-runtime.ts +58 -58
  73. package/src/runtime/deadletter.ts +14 -4
  74. package/src/runtime/direct-run.ts +35 -35
  75. package/src/runtime/foreground-control.ts +82 -82
  76. package/src/runtime/green-contract.ts +46 -46
  77. package/src/runtime/group-join.ts +106 -106
  78. package/src/runtime/heartbeat-gradient.ts +28 -28
  79. package/src/runtime/heartbeat-watcher.ts +48 -4
  80. package/src/runtime/live-agent-control.ts +87 -87
  81. package/src/runtime/live-agent-manager.ts +85 -85
  82. package/src/runtime/live-control-realtime.ts +36 -36
  83. package/src/runtime/live-session-runtime.ts +305 -305
  84. package/src/runtime/manifest-cache.ts +2 -2
  85. package/src/runtime/model-fallback.ts +269 -261
  86. package/src/runtime/parallel-research.ts +44 -44
  87. package/src/runtime/parallel-utils.ts +1 -1
  88. package/src/runtime/pi-json-output.ts +111 -111
  89. package/src/runtime/policy-engine.ts +79 -78
  90. package/src/runtime/post-exit-stdio-guard.ts +2 -2
  91. package/src/runtime/process-status.ts +56 -56
  92. package/src/runtime/progress-event-coalescer.ts +43 -43
  93. package/src/runtime/recovery-recipes.ts +74 -74
  94. package/src/runtime/retry-executor.ts +5 -0
  95. package/src/runtime/role-permission.ts +39 -39
  96. package/src/runtime/runtime-resolver.ts +1 -1
  97. package/src/runtime/session-usage.ts +79 -79
  98. package/src/runtime/sidechain-output.ts +29 -29
  99. package/src/runtime/subagent-manager.ts +3 -3
  100. package/src/runtime/task-display.ts +38 -38
  101. package/src/runtime/task-output-context.ts +127 -127
  102. package/src/runtime/task-runner/live-executor.ts +101 -101
  103. package/src/runtime/task-runner/progress.ts +119 -111
  104. package/src/runtime/task-runner/result-utils.ts +14 -14
  105. package/src/runtime/task-runner/state-helpers.ts +22 -22
  106. package/src/runtime/team-runner.ts +3 -6
  107. package/src/runtime/worker-heartbeat.ts +21 -21
  108. package/src/runtime/worker-startup.ts +57 -57
  109. package/src/schema/config-schema.ts +1 -1
  110. package/src/schema/team-tool-schema.ts +110 -109
  111. package/src/state/artifact-store.ts +4 -2
  112. package/src/state/atomic-write.ts +12 -4
  113. package/src/state/contracts.ts +105 -105
  114. package/src/state/event-log.ts +3 -4
  115. package/src/state/jsonl-writer.ts +4 -1
  116. package/src/state/locks.ts +9 -1
  117. package/src/state/task-claims.ts +44 -42
  118. package/src/state/usage.ts +29 -29
  119. package/src/subagents/async-entry.ts +1 -1
  120. package/src/subagents/index.ts +3 -3
  121. package/src/subagents/live/control.ts +1 -1
  122. package/src/subagents/live/manager.ts +1 -1
  123. package/src/subagents/live/realtime.ts +1 -1
  124. package/src/subagents/live/session-runtime.ts +1 -1
  125. package/src/subagents/manager.ts +1 -1
  126. package/src/subagents/spawn.ts +1 -1
  127. package/src/teams/discover-teams.ts +2 -2
  128. package/src/teams/team-serializer.ts +38 -38
  129. package/src/types/diff.d.ts +18 -18
  130. package/src/ui/crew-footer.ts +101 -101
  131. package/src/ui/crew-select-list.ts +111 -111
  132. package/src/ui/dashboard-panes/metrics-pane.ts +34 -34
  133. package/src/ui/dynamic-border.ts +25 -25
  134. package/src/ui/layout-primitives.ts +106 -106
  135. package/src/ui/live-run-sidebar.ts +1 -1
  136. package/src/ui/loaders.ts +158 -158
  137. package/src/ui/mascot.ts +3 -2
  138. package/src/ui/powerbar-publisher.ts +4 -3
  139. package/src/ui/render-diff.ts +119 -119
  140. package/src/ui/render-scheduler.ts +54 -14
  141. package/src/ui/run-dashboard.ts +39 -11
  142. package/src/ui/run-snapshot-cache.ts +63 -37
  143. package/src/ui/spinner.ts +17 -17
  144. package/src/ui/status-colors.ts +54 -54
  145. package/src/ui/syntax-highlight.ts +116 -116
  146. package/src/ui/theme-adapter.ts +1 -1
  147. package/src/ui/transcript-viewer.ts +7 -2
  148. package/src/utils/completion-dedupe.ts +63 -63
  149. package/src/utils/file-coalescer.ts +5 -3
  150. package/src/utils/frontmatter.ts +68 -36
  151. package/src/utils/git.ts +262 -262
  152. package/src/utils/ids.ts +12 -12
  153. package/src/utils/internal-error.ts +1 -1
  154. package/src/utils/names.ts +27 -26
  155. package/src/utils/paths.ts +1 -1
  156. package/src/utils/redaction.ts +44 -41
  157. package/src/utils/safe-paths.ts +47 -34
  158. package/src/utils/sleep.ts +2 -2
  159. package/src/utils/timings.ts +2 -0
  160. package/src/utils/visual.ts +9 -1
  161. package/src/workflows/discover-workflows.ts +4 -1
  162. package/src/workflows/validate-workflow.ts +40 -40
  163. package/src/worktree/branch-freshness.ts +45 -45
  164. package/src/worktree/worktree-manager.ts +6 -1
  165. package/teams/default.team.md +12 -12
  166. package/teams/fast-fix.team.md +11 -11
  167. package/teams/implementation.team.md +18 -18
  168. package/teams/parallel-research.team.md +14 -14
  169. package/teams/research.team.md +11 -11
  170. package/teams/review.team.md +12 -12
  171. package/workflows/default.workflow.md +29 -29
  172. package/workflows/fast-fix.workflow.md +22 -22
  173. package/workflows/implementation.workflow.md +38 -38
  174. package/workflows/parallel-research.workflow.md +46 -46
  175. package/workflows/research.workflow.md +22 -22
  176. package/workflows/review.workflow.md +30 -30
package/src/ui/mascot.ts CHANGED
@@ -136,9 +136,9 @@ export class AnimatedMascot {
136
136
  this.currentArminGrid = this.style === "armin" ? this.initialArminGrid() : emptyArminGrid();
137
137
  this.initEffect();
138
138
  this.interval = setInterval(() => this.tick(), this.frameIntervalMs);
139
- this.interval.unref?.();
139
+ this.interval.unref();
140
140
  this.timeout = this.autoCloseMs > 0 ? setTimeout(() => this.close(), this.autoCloseMs) : undefined;
141
- this.timeout?.unref?.();
141
+ this.timeout?.unref();
142
142
  }
143
143
 
144
144
  private resolveEffect(requested: MascotEffect | undefined): MascotEffect {
@@ -435,6 +435,7 @@ export class AnimatedMascot {
435
435
  }
436
436
 
437
437
  dispose(): void {
438
+ this.doneGuard.called = true;
438
439
  if (this.interval) clearInterval(this.interval);
439
440
  if (this.timeout) clearTimeout(this.timeout);
440
441
  }
@@ -92,12 +92,13 @@ export function updatePiCrewPowerbar(events: EventBus, cwd: string, config?: Cre
92
92
  const agents = active.flatMap((item) => item.agents);
93
93
  const tasks = active.flatMap((item) => item.tasks);
94
94
  const running = agents.filter((agent) => agent.status === "running").length;
95
- const waiting = active.reduce((sum, item) => sum + (item.snapshot?.progress.queued ?? item.tasks.filter((task) => task.status === "queued").length), 0);
96
- const completed = active.reduce((sum, item) => sum + (item.snapshot?.progress.completed ?? item.tasks.filter((task) => task.status === "completed").length), 0);
95
+ const waiting = active.reduce((sum, item) => sum + (item.snapshot?.progress.queued ?? item.tasks.reduce((s, t) => s + (t.status === "queued" ? 1 : 0), 0)), 0);
96
+ const completed = active.reduce((sum, item) => sum + (item.snapshot?.progress.completed ?? item.tasks.reduce((s, t) => s + (t.status === "completed" ? 1 : 0), 0)), 0);
97
97
  const total = Math.max(1, active.reduce((sum, item) => sum + (item.snapshot?.progress.total ?? item.tasks.length), 0) || agents.length);
98
98
  const usage = aggregateUsage(tasks);
99
99
  const snapshotTokens = active.reduce((sum, item) => sum + (item.snapshot ? item.snapshot.usage.tokensIn + item.snapshot.usage.tokensOut : 0), 0);
100
- const tokenTotal = usage ? (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0) : snapshotTokens;
100
+ const hasUsage = usage && ((usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0)) > 0;
101
+ const tokenTotal = hasUsage ? (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0) : snapshotTokens;
101
102
  const model = config?.showModel === false ? undefined : agents.find((agent) => agent.model)?.model?.split("/").at(-1);
102
103
  const tokenText = config?.showTokens === false || !tokenTotal ? undefined : compactTokens(tokenTotal);
103
104
  const activeText = `crew ${running}a/${waiting}w${notificationBadge(notificationCount)}`;
@@ -1,119 +1,119 @@
1
- import * as Diff from "diff";
2
- import type { CrewTheme } from "./theme-adapter.ts";
3
- import { asCrewTheme } from "./theme-adapter.ts";
4
-
5
- interface ParsedDiffLine {
6
- prefix: string;
7
- lineNum: string; content: string;
8
- }
9
-
10
- interface DiffLineContent {
11
- lineNum: string;
12
- content: string;
13
- }
14
-
15
- function parseDiffLine(line: string): ParsedDiffLine | null {
16
- const match = line.match(/^([+-\s])(\s*\d*)\s(.*)$/);
17
- if (!match) return null;
18
- return { prefix: match[1], lineNum: match[2], content: match[3] };
19
- }
20
-
21
- function replaceTabs(text: string): string {
22
- return text.replace(/\t/g, " ");
23
- }
24
-
25
- function renderIntraLineDiff(theme: CrewTheme, oldContent: string, newContent: string): { removedLine: string; addedLine: string } {
26
- const wordDiff = Diff.diffWords(oldContent, newContent);
27
- let removedLine = "";
28
- let addedLine = "";
29
- let isFirstRemoved = true;
30
- let isFirstAdded = true;
31
-
32
- for (const part of wordDiff) {
33
- if (part.removed) {
34
- let value = part.value;
35
- if (isFirstRemoved) {
36
- const leadingWs = value.match(/^(\s*)/)?.[1] ?? "";
37
- value = value.slice(leadingWs.length);
38
- removedLine += leadingWs;
39
- isFirstRemoved = false;
40
- }
41
- if (value) removedLine += theme.inverse?.(value) ?? value;
42
- } else if (part.added) {
43
- let value = part.value;
44
- if (isFirstAdded) {
45
- const leadingWs = value.match(/^(\s*)/)?.[1] ?? "";
46
- value = value.slice(leadingWs.length);
47
- addedLine += leadingWs;
48
- isFirstAdded = false;
49
- }
50
- if (value) addedLine += theme.inverse?.(value) ?? value;
51
- } else {
52
- removedLine += part.value;
53
- addedLine += part.value;
54
- }
55
- }
56
-
57
- return { removedLine, addedLine };
58
- }
59
-
60
- export interface RenderDiffOptions {
61
- filePath?: string;
62
- theme?: unknown;
63
- }
64
-
65
- export function renderDiff(diffText: string, options: RenderDiffOptions = {}): string {
66
- const theme = asCrewTheme(options.theme);
67
- const lines = diffText.split("\n");
68
- const result: string[] = [];
69
- let i = 0;
70
-
71
- while (i < lines.length) {
72
- const line = lines[i] ?? "";
73
- const parsed = parseDiffLine(line);
74
- if (!parsed) {
75
- result.push(theme.fg("toolDiffContext", line));
76
- i++;
77
- continue;
78
- }
79
-
80
- if (parsed.prefix === "-") {
81
- const removedLines: DiffLineContent[] = [];
82
- while (i < lines.length) {
83
- const nextParsed = parseDiffLine(lines[i] ?? "");
84
- if (!nextParsed || nextParsed.prefix !== "-") break;
85
- removedLines.push({ lineNum: nextParsed.lineNum, content: nextParsed.content });
86
- i++;
87
- }
88
-
89
- const addedLines: DiffLineContent[] = [];
90
- while (i < lines.length) {
91
- const nextParsed = parseDiffLine(lines[i] ?? "");
92
- if (!nextParsed || nextParsed.prefix !== "+") break;
93
- addedLines.push({ lineNum: nextParsed.lineNum, content: nextParsed.content });
94
- i++;
95
- }
96
-
97
- if (removedLines.length === 1 && addedLines.length === 1) {
98
- const { removedLine, addedLine } = renderIntraLineDiff(theme, replaceTabs(removedLines[0]!.content), replaceTabs(addedLines[0]!.content));
99
- result.push(theme.fg("toolDiffRemoved", `-${removedLines[0]!.lineNum} ${removedLine}`));
100
- result.push(theme.fg("toolDiffAdded", `+${addedLines[0]!.lineNum} ${addedLine}`));
101
- } else {
102
- for (const removed of removedLines) {
103
- result.push(theme.fg("toolDiffRemoved", `-${removed.lineNum} ${replaceTabs(removed.content)}`));
104
- }
105
- for (const added of addedLines) {
106
- result.push(theme.fg("toolDiffAdded", `+${added.lineNum} ${replaceTabs(added.content)}`));
107
- }
108
- }
109
- } else if (parsed.prefix === "+") {
110
- result.push(theme.fg("toolDiffAdded", `+${parsed.lineNum} ${replaceTabs(parsed.content)}`));
111
- i++;
112
- } else {
113
- result.push(theme.fg("toolDiffContext", ` ${parsed.lineNum} ${replaceTabs(parsed.content)}`));
114
- i++;
115
- }
116
- }
117
-
118
- return result.join("\n");
119
- }
1
+ import * as Diff from "diff";
2
+ import type { CrewTheme } from "./theme-adapter.ts";
3
+ import { asCrewTheme } from "./theme-adapter.ts";
4
+
5
+ interface ParsedDiffLine {
6
+ prefix: string;
7
+ lineNum: string; content: string;
8
+ }
9
+
10
+ interface DiffLineContent {
11
+ lineNum: string;
12
+ content: string;
13
+ }
14
+
15
+ function parseDiffLine(line: string): ParsedDiffLine | null {
16
+ const match = line.match(/^([+-\s])(\s*\d*)\s(.*)$/);
17
+ if (!match) return null;
18
+ return { prefix: match[1], lineNum: match[2], content: match[3] };
19
+ }
20
+
21
+ function replaceTabs(text: string): string {
22
+ return text.replace(/\t/g, " ");
23
+ }
24
+
25
+ function renderIntraLineDiff(theme: CrewTheme, oldContent: string, newContent: string): { removedLine: string; addedLine: string } {
26
+ const wordDiff = Diff.diffWords(oldContent, newContent);
27
+ let removedLine = "";
28
+ let addedLine = "";
29
+ let isFirstRemoved = true;
30
+ let isFirstAdded = true;
31
+
32
+ for (const part of wordDiff) {
33
+ if (part.removed) {
34
+ let value = part.value;
35
+ if (isFirstRemoved) {
36
+ const leadingWs = value.match(/^(\s*)/)?.[1] ?? "";
37
+ value = value.slice(leadingWs.length);
38
+ removedLine += leadingWs;
39
+ isFirstRemoved = false;
40
+ }
41
+ if (value) removedLine += theme.inverse?.(value) ?? value;
42
+ } else if (part.added) {
43
+ let value = part.value;
44
+ if (isFirstAdded) {
45
+ const leadingWs = value.match(/^(\s*)/)?.[1] ?? "";
46
+ value = value.slice(leadingWs.length);
47
+ addedLine += leadingWs;
48
+ isFirstAdded = false;
49
+ }
50
+ if (value) addedLine += theme.inverse?.(value) ?? value;
51
+ } else {
52
+ removedLine += part.value;
53
+ addedLine += part.value;
54
+ }
55
+ }
56
+
57
+ return { removedLine, addedLine };
58
+ }
59
+
60
+ export interface RenderDiffOptions {
61
+ filePath?: string;
62
+ theme?: unknown;
63
+ }
64
+
65
+ export function renderDiff(diffText: string, options: RenderDiffOptions = {}): string {
66
+ const theme = asCrewTheme(options.theme);
67
+ const lines = diffText.split("\n");
68
+ const result: string[] = [];
69
+ let i = 0;
70
+
71
+ while (i < lines.length) {
72
+ const line = lines[i] ?? "";
73
+ const parsed = parseDiffLine(line);
74
+ if (!parsed) {
75
+ result.push(theme.fg("toolDiffContext", line));
76
+ i++;
77
+ continue;
78
+ }
79
+
80
+ if (parsed.prefix === "-") {
81
+ const removedLines: DiffLineContent[] = [];
82
+ while (i < lines.length) {
83
+ const nextParsed = parseDiffLine(lines[i] ?? "");
84
+ if (!nextParsed || nextParsed.prefix !== "-") break;
85
+ removedLines.push({ lineNum: nextParsed.lineNum, content: nextParsed.content });
86
+ i++;
87
+ }
88
+
89
+ const addedLines: DiffLineContent[] = [];
90
+ while (i < lines.length) {
91
+ const nextParsed = parseDiffLine(lines[i] ?? "");
92
+ if (!nextParsed || nextParsed.prefix !== "+") break;
93
+ addedLines.push({ lineNum: nextParsed.lineNum, content: nextParsed.content });
94
+ i++;
95
+ }
96
+
97
+ if (removedLines.length === 1 && addedLines.length === 1) {
98
+ const { removedLine, addedLine } = renderIntraLineDiff(theme, replaceTabs(removedLines[0]!.content), replaceTabs(addedLines[0]!.content));
99
+ result.push(theme.fg("toolDiffRemoved", `-${removedLines[0]!.lineNum} ${removedLine}`));
100
+ result.push(theme.fg("toolDiffAdded", `+${addedLines[0]!.lineNum} ${addedLine}`));
101
+ } else {
102
+ for (const removed of removedLines) {
103
+ result.push(theme.fg("toolDiffRemoved", `-${removed.lineNum} ${replaceTabs(removed.content)}`));
104
+ }
105
+ for (const added of addedLines) {
106
+ result.push(theme.fg("toolDiffAdded", `+${added.lineNum} ${replaceTabs(added.content)}`));
107
+ }
108
+ }
109
+ } else if (parsed.prefix === "+") {
110
+ result.push(theme.fg("toolDiffAdded", `+${parsed.lineNum} ${replaceTabs(parsed.content)}`));
111
+ i++;
112
+ } else {
113
+ result.push(theme.fg("toolDiffContext", ` ${parsed.lineNum} ${replaceTabs(parsed.content)}`));
114
+ i++;
115
+ }
116
+ }
117
+
118
+ return result.join("\n");
119
+ }
@@ -22,15 +22,26 @@ const DEFAULT_EVENTS = [
22
22
  "crew.mailbox.message",
23
23
  ];
24
24
 
25
+ /**
26
+ * Coordinates UI renders with debounce + fallback polling.
27
+ *
28
+ * Critical: uses recursive setTimeout instead of setInterval + a rendering
29
+ * guard (`rendering` / `pendingRender`) so that when render() takes longer
30
+ * than the fallback interval, callbacks do NOT pile up and storm the event
31
+ * loop. Instead, overlapping schedules are collapsed into a single deferred
32
+ * re-render.
33
+ */
25
34
  export class RenderScheduler {
26
35
  private readonly render: () => void;
27
36
  private readonly onInvalidate?: (payload: unknown) => void;
28
37
  private readonly debounceMs: number;
29
38
  private readonly fallbackMs: number;
30
39
  private debounceTimer: ReturnType<typeof setTimeout> | undefined;
31
- private fallbackTimer: ReturnType<typeof setInterval> | undefined;
40
+ private fallbackTimer: ReturnType<typeof setTimeout> | undefined;
32
41
  private disposed = false;
33
42
  private lastEventAt = 0;
43
+ private rendering = false;
44
+ private pendingRender = false;
34
45
  private readonly unsubs: Array<() => void> = [];
35
46
 
36
47
  constructor(events: RenderSchedulerEventBus | undefined, render: () => void, options: RenderSchedulerOptions = {}) {
@@ -39,8 +50,8 @@ export class RenderScheduler {
39
50
  this.debounceMs = options.debounceMs ?? 75;
40
51
  this.fallbackMs = options.fallbackMs ?? 750;
41
52
  for (const event of options.events ?? DEFAULT_EVENTS) this.subscribe(events, event);
42
- this.fallbackTimer = setInterval(() => this.fallback(), this.fallbackMs);
43
- this.fallbackTimer.unref?.();
53
+ this.fallbackTimer = setTimeout(() => this.fallbackLoop(), this.fallbackMs);
54
+ this.fallbackTimer.unref();
44
55
  }
45
56
 
46
57
  private subscribe(events: RenderSchedulerEventBus | undefined, event: string): void {
@@ -54,10 +65,19 @@ export class RenderScheduler {
54
65
  }
55
66
  }
56
67
 
57
- private fallback(): void {
68
+ /** Recursive setTimeout — avoids setInterval timer storms. */
69
+ private fallbackLoop(): void {
58
70
  if (this.disposed) return;
59
- if (Date.now() - this.lastEventAt < this.fallbackMs) return;
60
- this.flush();
71
+ if (Date.now() - this.lastEventAt < this.fallbackMs) {
72
+ if (this.disposed) return;
73
+ this.fallbackTimer = setTimeout(() => this.fallbackLoop(), this.fallbackMs);
74
+ this.fallbackTimer.unref();
75
+ return;
76
+ }
77
+ this.schedule();
78
+ if (this.disposed) return;
79
+ this.fallbackTimer = setTimeout(() => this.fallbackLoop(), this.fallbackMs);
80
+ this.fallbackTimer.unref();
61
81
  }
62
82
 
63
83
  schedule(payload?: unknown): void {
@@ -73,15 +93,39 @@ export class RenderScheduler {
73
93
  this.debounceTimer = undefined;
74
94
  this.flush();
75
95
  }, this.debounceMs);
76
- this.debounceTimer.unref?.();
96
+ this.debounceTimer.unref();
77
97
  }
78
98
 
99
+ /**
100
+ * Flush a render. If a render is already in progress the request is
101
+ * collapsed: `pendingRender` is set and the caller that holds
102
+ * `rendering==true` will loop one more time after finishing.
103
+ */
79
104
  flush(): void {
80
105
  if (this.disposed) return;
106
+ if (this.rendering) {
107
+ this.pendingRender = true;
108
+ return;
109
+ }
110
+ this.rendering = true;
111
+ this.pendingRender = false;
112
+ let iterations = 0;
81
113
  try {
82
- this.render();
114
+ do {
115
+ this.pendingRender = false;
116
+ this.render();
117
+ iterations += 1;
118
+ // Safety valve: 5 re-renders max per flush to prevent infinite loops
119
+ // if render() itself calls flush() synchronously.
120
+ } while (this.pendingRender && !this.disposed && iterations < 5);
83
121
  } catch (error) {
84
122
  logInternalError("render-scheduler.render", error);
123
+ } finally {
124
+ this.rendering = false;
125
+ // If we hit the iteration cap, schedule one more render to drain.
126
+ if (iterations >= 5 && this.pendingRender && !this.disposed) {
127
+ this.schedule();
128
+ }
85
129
  }
86
130
  }
87
131
 
@@ -89,15 +133,11 @@ export class RenderScheduler {
89
133
  if (this.disposed) return;
90
134
  this.disposed = true;
91
135
  if (this.debounceTimer) clearTimeout(this.debounceTimer);
92
- if (this.fallbackTimer) clearInterval(this.fallbackTimer);
136
+ if (this.fallbackTimer) clearTimeout(this.fallbackTimer);
93
137
  this.debounceTimer = undefined;
94
138
  this.fallbackTimer = undefined;
95
139
  for (const unsub of this.unsubs.splice(0)) {
96
- try {
97
- unsub();
98
- } catch (error) {
99
- logInternalError("render-scheduler.unsubscribe", error);
100
- }
140
+ try { unsub(); } catch (error) { logInternalError("render-scheduler.unsubscribe", error); }
101
141
  }
102
142
  }
103
143
  }
@@ -12,6 +12,7 @@ import { Box, Text } from "./layout-primitives.ts";
12
12
  import { DynamicCrewBorder } from "./dynamic-border.ts";
13
13
  import { CrewFooter } from "./crew-footer.ts";
14
14
  import { aggregateUsage } from "../state/usage.ts";
15
+ import { logInternalError } from "../utils/internal-error.ts";
15
16
  import { renderAgentsPane } from "./dashboard-panes/agents-pane.ts";
16
17
  import { renderMailboxPane } from "./dashboard-panes/mailbox-pane.ts";
17
18
  import { renderProgressPane } from "./dashboard-panes/progress-pane.ts";
@@ -194,18 +195,38 @@ function runLabel(run: TeamRunManifest, selected: boolean, snapshotCache?: RunSn
194
195
  const stale = isLikelyOrphanedActiveRun(run, agents);
195
196
  const running = agents.find((agent) => agent.status === "running");
196
197
  const queued = agents.find((agent) => agent.status === "queued");
197
- const step = stale ? "orphaned queued run" : running ? `step ${running.taskId}` : queued ? `queued ${queued.taskId}` : `agents ${agents.length}`;
198
+ const step = stale ? "orphaned queued run" : running ? `step ${running.taskId.replace(/[\x00-\x1f\x7f-\x9f]/g, "")}` : queued ? `queued ${queued.taskId.replace(/[\x00-\x1f\x7f-\x9f]/g, "")}` : `agents ${agents.length}`;
198
199
  const status: RunStatus = stale ? "stale" : (run.status as RunStatus);
199
200
  const marker = selected ? "›" : " ";
200
201
  return `${marker} ${iconForStatus(status, { runningGlyph: spinnerFrame(run.runId) })} ${run.runId.slice(-8)} ${status} | ${run.team}/${run.workflow ?? "none"} | ${step} | ${run.goal}`;
201
202
  }
202
203
 
204
+ interface ResolvedRun {
205
+ manifest: TeamRunManifest;
206
+ snapshot: RunUiSnapshot | undefined;
207
+ agents: CrewAgentRecord[];
208
+ status: RunStatus;
209
+ }
210
+
211
+ function resolveRuns(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache): Map<string, ResolvedRun> {
212
+ const map = new Map<string, ResolvedRun>();
213
+ for (const run of runs) {
214
+ const snapshot = snapshotFor(run, snapshotCache);
215
+ const agents = snapshot?.agents ?? agentsFor(run, snapshotCache);
216
+ const displayRun = snapshot?.manifest ?? run;
217
+ const status: RunStatus = isLikelyOrphanedActiveRun(displayRun, agents) ? "stale" : (displayRun.status as RunStatus);
218
+ map.set(run.runId, { manifest: run, snapshot, agents, status });
219
+ }
220
+ return map;
221
+ }
222
+
203
223
  function groupedRuns(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache): Array<{ label: string; run?: TeamRunManifest }> {
204
- const active = runs.filter((run) => isDisplayActiveRun(snapshotFor(run, snapshotCache)?.manifest ?? run, agentsFor(run, snapshotCache)));
205
- const recent = runs.filter((run) => !isDisplayActiveRun(snapshotFor(run, snapshotCache)?.manifest ?? run, agentsFor(run, snapshotCache)));
224
+ const resolved = resolveRuns(runs, snapshotCache);
206
225
  const rows: Array<{ label: string; run?: TeamRunManifest }> = [];
226
+ const active = runs.filter((run) => isDisplayActiveRun(resolved.get(run.runId)?.snapshot?.manifest ?? run, resolved.get(run.runId)?.agents ?? []));
227
+ const rest = runs.filter((run) => !isDisplayActiveRun(resolved.get(run.runId)?.snapshot?.manifest ?? run, resolved.get(run.runId)?.agents ?? []));
207
228
  if (active.length) rows.push({ label: "Active" }, ...active.map((run) => ({ label: run.runId, run })));
208
- if (recent.length) rows.push({ label: "Recent" }, ...recent.map((run) => ({ label: run.runId, run })));
229
+ if (rest.length) rows.push({ label: "Recent" }, ...rest.map((run) => ({ label: run.runId, run })));
209
230
  return rows;
210
231
  }
211
232
 
@@ -214,13 +235,9 @@ function selectedRunFromGrouped(runs: TeamRunManifest[], selected: number, snaps
214
235
  }
215
236
 
216
237
  function countByStatus(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache): string {
238
+ const resolved = resolveRuns(runs, snapshotCache);
217
239
  const counts = new Map<RunStatus, number>();
218
- for (const run of runs) {
219
- const snapshot = snapshotFor(run, snapshotCache);
220
- const displayRun = snapshot?.manifest ?? run;
221
- const status: RunStatus = isLikelyOrphanedActiveRun(displayRun, snapshot?.agents ?? agentsFor(run, snapshotCache)) ? "stale" : (displayRun.status as RunStatus);
222
- counts.set(status, (counts.get(status) ?? 0) + 1);
223
- }
240
+ for (const r of resolved.values()) counts.set(r.status, (counts.get(r.status) ?? 0) + 1);
224
241
  return [...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none";
225
242
  }
226
243
 
@@ -253,10 +270,12 @@ export class RunDashboard implements DashboardComponent {
253
270
  private refreshRuns(): void {
254
271
  if (!this.options.runProvider) return;
255
272
  const selectedRunId = this.selectedRunId();
256
- this.runs = this.options.runProvider();
273
+ const next = this.options.runProvider();
274
+ this.runs = Array.isArray(next) ? next : this.runs;
257
275
  if (selectedRunId) {
258
276
  const nextIndex = groupedRuns(this.runs, this.options.snapshotCache).filter((row) => row.run).findIndex((row) => row.run?.runId === selectedRunId);
259
277
  if (nextIndex >= 0) this.selected = nextIndex;
278
+ else this.selected = 0;
260
279
  }
261
280
  }
262
281
 
@@ -289,6 +308,15 @@ export class RunDashboard implements DashboardComponent {
289
308
  }
290
309
 
291
310
  render(width: number): string[] {
311
+ try {
312
+ return this.renderUnsafe(width);
313
+ } catch (error) {
314
+ logInternalError("run-dashboard.render", error);
315
+ return renderLines(["Dashboard error — see logs for details."], width);
316
+ }
317
+ }
318
+
319
+ private renderUnsafe(width: number): string[] {
292
320
  this.refreshRuns();
293
321
  const signature = this.buildSignature();
294
322
  if (signature !== this.cachedVersion || this.cachedWidth !== width) {
@@ -4,17 +4,19 @@ import * as path from "node:path";
4
4
  import { readCrewAgents, agentsPath, agentOutputPath } from "../runtime/crew-agent-records.ts";
5
5
  import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
6
6
  import { isActiveRunStatus } from "../runtime/process-status.ts";
7
- import { readEvents, type TeamEvent } from "../state/event-log.ts";
7
+ import type { TeamEvent } from "../state/event-log.ts";
8
8
  import type { MailboxMessageStatus } from "../state/mailbox.ts";
9
9
  import { loadRunManifestById } from "../state/state-store.ts";
10
10
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
11
11
  import type { RunSnapshotCache, RunUiGroupJoin, RunUiMailbox, RunUiProgress, RunUiSnapshot, RunUiUsage } from "./snapshot-types.ts";
12
12
 
13
- const DEFAULT_TTL_MS = 250;
13
+ const DEFAULT_TTL_MS = 500;
14
14
  const DEFAULT_MAX_ENTRIES = 24;
15
15
  const DEFAULT_RECENT_EVENTS = 20;
16
16
  const DEFAULT_RECENT_OUTPUT_LINES = 20;
17
17
  const MAX_TAIL_BYTES = 32 * 1024;
18
+ /** Max JSONL lines to tail when reading growing files (events, mailbox). */
19
+ const MAX_TAIL_LINES = 500;
18
20
 
19
21
  interface FileStamp {
20
22
  mtimeMs: number;
@@ -116,14 +118,40 @@ function readTasks(tasksPath: string): TeamTaskState[] {
116
118
  }
117
119
  }
118
120
 
119
- function safeRecentEvents(eventsPath: string, limit: number): TeamEvent[] {
121
+ /** Tail-read JSONL lines from a file, returning parsed objects (limited). */
122
+ function tailJsonlLines<T>(filePath: string, limit: number, parse: (line: string) => T | undefined): T[] {
123
+ if (limit <= 0) return [];
120
124
  try {
121
- return readEvents(eventsPath).slice(-limit);
125
+ const stat = fs.statSync(filePath);
126
+ const bytesToRead = Math.min(stat.size, MAX_TAIL_BYTES);
127
+ const fd = fs.openSync(filePath, "r");
128
+ try {
129
+ const buffer = Buffer.alloc(bytesToRead);
130
+ fs.readSync(fd, buffer, 0, bytesToRead, stat.size - bytesToRead);
131
+ const lines = buffer.toString("utf-8").split(/\r?\n/).filter(Boolean);
132
+ return lines.flatMap((line) => {
133
+ const item = parse(line);
134
+ return item ? [item] : [];
135
+ }).slice(-limit);
136
+ } finally {
137
+ fs.closeSync(fd);
138
+ }
122
139
  } catch {
123
140
  return [];
124
141
  }
125
142
  }
126
143
 
144
+ function safeRecentEvents(eventsPath: string, limit: number): TeamEvent[] {
145
+ return tailJsonlLines(eventsPath, limit, (line) => {
146
+ try {
147
+ const parsed = JSON.parse(line) as unknown;
148
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as TeamEvent) : undefined;
149
+ } catch {
150
+ return undefined;
151
+ }
152
+ });
153
+ }
154
+
127
155
  function tailLines(filePath: string, limit: number): string[] {
128
156
  if (limit <= 0) return [];
129
157
  try {
@@ -196,40 +224,33 @@ function readDeliveryMessages(filePath: string): Record<string, MailboxMessageSt
196
224
  }
197
225
 
198
226
  function readGroupJoinMailbox(filePath: string, delivery: Record<string, MailboxMessageStatus>): RunUiGroupJoin[] {
199
- try {
200
- return fs.readFileSync(filePath, "utf-8").split(/\r?\n/).filter(Boolean).flatMap((line) => {
201
- try {
202
- const parsed = JSON.parse(line) as unknown;
203
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
204
- const message = parsed as { id?: unknown; data?: unknown };
205
- const data = message.data && typeof message.data === "object" && !Array.isArray(message.data) ? message.data as Record<string, unknown> : undefined;
206
- if (typeof message.id !== "string" || data?.kind !== "group_join" || typeof data.requestId !== "string") return [];
207
- return [{ requestId: data.requestId, messageId: message.id, partial: data.partial === true, ack: delivery[message.id] === "acknowledged" ? "acknowledged" as const : "pending" as const }];
208
- } catch {
209
- return [];
210
- }
211
- });
212
- } catch {
213
- return [];
214
- }
227
+ return tailJsonlLines(filePath, MAX_TAIL_LINES, (line) => {
228
+ try {
229
+ const parsed = JSON.parse(line) as unknown;
230
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
231
+ const message = parsed as { id?: unknown; data?: unknown };
232
+ const data = message.data && typeof message.data === "object" && !Array.isArray(message.data) ? message.data as Record<string, unknown> : undefined;
233
+ if (typeof message.id !== "string" || data?.kind !== "group_join" || typeof data.requestId !== "string") return undefined;
234
+ return { requestId: data.requestId, messageId: message.id, partial: data.partial === true, ack: delivery[message.id] === "acknowledged" ? "acknowledged" as const : "pending" as const };
235
+ } catch {
236
+ return undefined;
237
+ }
238
+ });
215
239
  }
216
240
 
217
241
  function readMailboxCounts(filePath: string, delivery: Record<string, MailboxMessageStatus>): number {
218
- try {
219
- return fs.readFileSync(filePath, "utf-8").split(/\r?\n/).filter(Boolean).reduce((count, line) => {
220
- try {
221
- const parsed = JSON.parse(line) as unknown;
222
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return count;
223
- const message = parsed as { id?: unknown; status?: unknown };
224
- if (typeof message.id !== "string" || !isMailboxStatus(message.status)) return count;
225
- return message.status !== "acknowledged" && delivery[message.id] !== "acknowledged" ? count + 1 : count;
226
- } catch {
227
- return count;
228
- }
229
- }, 0);
230
- } catch {
231
- return 0;
232
- }
242
+ const items = tailJsonlLines(filePath, MAX_TAIL_LINES, (line) => {
243
+ try {
244
+ const parsed = JSON.parse(line) as unknown;
245
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return 0;
246
+ const message = parsed as { id?: unknown; status?: unknown };
247
+ if (typeof message.id !== "string" || !isMailboxStatus(message.status)) return 0;
248
+ return message.status !== "acknowledged" && delivery[message.id] !== "acknowledged" ? 1 : 0;
249
+ } catch {
250
+ return 0;
251
+ }
252
+ }) as number[];
253
+ return items.reduce((sum, val) => sum + val, 0);
233
254
  }
234
255
 
235
256
  function groupJoinsFrom(manifest: TeamRunManifest): RunUiGroupJoin[] {
@@ -258,8 +279,9 @@ function mailboxFrom(manifest: TeamRunManifest, agents: CrewAgentRecord[]): RunU
258
279
  }
259
280
 
260
281
  function signatureFor(input: Omit<RunUiSnapshot, "signature" | "fetchedAt">, stamps: SnapshotStamps): string {
261
- const digest = createHash("sha256");
262
- digest.update(JSON.stringify({
282
+ try {
283
+ const digest = createHash("sha256");
284
+ digest.update(JSON.stringify({
263
285
  run: [input.manifest.runId, input.manifest.status, input.manifest.updatedAt, input.manifest.artifacts.length],
264
286
  tasks: input.tasks.map((task) => [task.id, task.status, task.startedAt, task.finishedAt, task.agentProgress, task.usage]),
265
287
  agents: input.agents.map((agent) => [agent.id, agent.status, agent.startedAt, agent.completedAt, agent.toolUses, agent.progress, agent.usage, agent.model]),
@@ -272,6 +294,10 @@ function signatureFor(input: Omit<RunUiSnapshot, "signature" | "fetchedAt">, sta
272
294
  stamps,
273
295
  }));
274
296
  return digest.digest("hex").slice(0, 16);
297
+ } catch {
298
+ // Circular reference or non-serializable data — fall back to timestamp.
299
+ return String(Date.now());
300
+ }
275
301
  }
276
302
 
277
303
  function stampsFor(manifest: TeamRunManifest, agents: CrewAgentRecord[]): SnapshotStamps {