pi-crew 0.9.41 → 0.9.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/CHANGELOG.md +253 -0
  2. package/README.md +14 -0
  3. package/dist/build-meta.json +6450 -12419
  4. package/dist/index.mjs +57998 -68417
  5. package/dist/index.mjs.map +4 -4
  6. package/docs/migration/atomic-write-v2-migration.md +2 -0
  7. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +2 -0
  8. package/package.json +4 -2
  9. package/scripts/README.md +6 -0
  10. package/scripts/build-bundle.mjs +2 -1
  11. package/scripts/check-all-skills.ts +3 -3
  12. package/scripts/dev-runner.mjs +106 -0
  13. package/scripts/postinstall.mjs +6 -2
  14. package/scripts/verify-skill.ts +2 -2
  15. package/src/agents/agent-config.ts +1 -1
  16. package/src/config/config.ts +20 -1
  17. package/src/config/drift-detector.ts +97 -15
  18. package/src/config/resilient-parser.ts +4 -4
  19. package/src/config/types.ts +0 -2
  20. package/src/extension/crew-cleanup.ts +17 -3
  21. package/src/extension/crew-vibes/config.ts +1 -1
  22. package/src/extension/crew-vibes/font-detect.ts +16 -3
  23. package/src/extension/crew-vibes/index.ts +1 -1
  24. package/src/extension/cross-extension-rpc.ts +26 -9
  25. package/src/extension/management.ts +6 -5
  26. package/src/extension/register.ts +58 -1573
  27. package/src/extension/registration/command-registration.ts +57 -0
  28. package/src/extension/registration/commands.ts +7 -2
  29. package/src/extension/registration/context-builder.ts +131 -0
  30. package/src/extension/registration/crash-recovery-cache.ts +54 -0
  31. package/src/extension/registration/foreground-run-controller.ts +238 -0
  32. package/src/extension/registration/hook-registration.ts +119 -0
  33. package/src/extension/registration/lazy-configurers.ts +124 -0
  34. package/src/extension/registration/lifecycle-handlers.ts +731 -0
  35. package/src/extension/registration/observability.ts +33 -4
  36. package/src/extension/registration/registration-types.ts +149 -0
  37. package/src/extension/registration/runtime-cleanup.ts +230 -0
  38. package/src/extension/registration/subagent-manager-setup.ts +211 -0
  39. package/src/extension/registration/tool-registration.ts +50 -0
  40. package/src/extension/registration/wire-cross-extension.ts +37 -0
  41. package/src/extension/rpc-hmac.ts +21 -1
  42. package/src/extension/run-import.ts +3 -3
  43. package/src/extension/team-tool/auto-summarize.ts +4 -4
  44. package/src/extension/team-tool/cancel.ts +5 -5
  45. package/src/extension/team-tool/chain-executor.ts +5 -4
  46. package/src/extension/team-tool/context.ts +11 -1
  47. package/src/extension/team-tool/doctor.ts +2 -1
  48. package/src/extension/team-tool/goal-wrap.ts +2 -2
  49. package/src/extension/team-tool/goal.ts +1 -1
  50. package/src/extension/team-tool/handle-schedule.ts +25 -36
  51. package/src/extension/team-tool/orchestrate.ts +0 -1
  52. package/src/extension/team-tool/parallel-dispatch.ts +5 -0
  53. package/src/extension/team-tool/run.ts +220 -214
  54. package/src/extension/team-tool/workflow-manage.ts +1 -1
  55. package/src/extension/team-tool.ts +69 -16
  56. package/src/hooks/registry.ts +7 -2
  57. package/src/observability/correlation.ts +4 -3
  58. package/src/observability/event-bus.ts +10 -1
  59. package/src/observability/event-to-metric.ts +8 -7
  60. package/src/observability/exporters/adapter.ts +6 -9
  61. package/src/observability/exporters/otlp-exporter.ts +81 -0
  62. package/src/observability/metric-retention.ts +4 -0
  63. package/src/observability/metric-sink.ts +14 -4
  64. package/src/prompt/prompt-runtime.ts +15 -0
  65. package/src/runtime/adaptive-plan.ts +1 -1
  66. package/src/runtime/async-runner.ts +13 -4
  67. package/src/runtime/background-runner.ts +32 -23
  68. package/src/runtime/capability-inventory.ts +1 -1
  69. package/src/runtime/chain-runner.ts +5 -5
  70. package/src/runtime/checkpoint.ts +6 -16
  71. package/src/runtime/child-pi-constants.ts +42 -0
  72. package/src/runtime/child-pi-kill.ts +180 -0
  73. package/src/runtime/child-pi-spawn.ts +234 -0
  74. package/src/runtime/child-pi-steering.ts +128 -0
  75. package/src/runtime/child-pi-streams.ts +296 -0
  76. package/src/runtime/child-pi-transcript.ts +169 -0
  77. package/src/runtime/child-pi.ts +80 -822
  78. package/src/runtime/coalesce-tasks.ts +1 -1
  79. package/src/runtime/compact-stages/tail-capture-stage.ts +1 -1
  80. package/src/runtime/crash-recovery.ts +63 -47
  81. package/src/runtime/delivery-coordinator.ts +1 -1
  82. package/src/runtime/deterministic-ast.ts +2 -2
  83. package/src/runtime/diagnostic-export.ts +2 -1
  84. package/src/runtime/dwf-state-store.ts +5 -0
  85. package/src/runtime/dynamic-workflow-context.ts +78 -18
  86. package/src/runtime/dynamic-workflow-runner.ts +18 -2
  87. package/src/runtime/foreground-control.ts +3 -2
  88. package/src/runtime/goal-evaluator.ts +59 -27
  89. package/src/runtime/goal-loop-runner.ts +46 -13
  90. package/src/runtime/goal-state-store.ts +1 -1
  91. package/src/runtime/handoff-manager.ts +0 -2
  92. package/src/runtime/intercom-bridge.ts +0 -2
  93. package/src/runtime/live-agent-manager.ts +30 -5
  94. package/src/runtime/live-extension-bridge.ts +0 -2
  95. package/src/runtime/live-session-runtime.ts +65 -57
  96. package/src/runtime/manifest-cache.ts +59 -0
  97. package/src/runtime/mcp-proxy.ts +1 -1
  98. package/src/runtime/orphan-worker-registry.ts +3 -3
  99. package/src/runtime/output-validator.ts +1 -1
  100. package/src/runtime/path-overlap.ts +1 -1
  101. package/src/runtime/pi-args.ts +3 -3
  102. package/src/runtime/pi-json-output.ts +4 -3
  103. package/src/runtime/pi-spawn.ts +7 -1
  104. package/src/runtime/pipeline-runner.ts +1 -4
  105. package/src/runtime/post-checks.ts +0 -1
  106. package/src/runtime/retry-runner.ts +1 -1
  107. package/src/runtime/run-coalesced-task-group.ts +18 -23
  108. package/src/runtime/scheduler.ts +2 -0
  109. package/src/runtime/session-resources.ts +1 -1
  110. package/src/runtime/settings-store.ts +31 -1
  111. package/src/runtime/skill-effectiveness.ts +2 -2
  112. package/src/runtime/stale-reconciler.ts +23 -53
  113. package/src/runtime/subagent-manager.ts +19 -3
  114. package/src/runtime/task-output-context.ts +2 -1
  115. package/src/runtime/task-runner/prompt-builder.ts +3 -3
  116. package/src/runtime/task-runner/run-projection.ts +3 -1
  117. package/src/runtime/task-runner/state-helpers.ts +13 -1
  118. package/src/runtime/task-runner.ts +31 -6
  119. package/src/runtime/team-runner.ts +8 -2
  120. package/src/runtime/verification-gates.ts +54 -5
  121. package/src/schema/config-schema.ts +19 -1
  122. package/src/schema/team-tool-schema.ts +2 -0
  123. package/src/skills/discover-skills.ts +25 -15
  124. package/src/state/active-run-registry.ts +0 -1
  125. package/src/state/atomic-write.ts +37 -11
  126. package/src/state/blob-store.ts +3 -9
  127. package/src/state/contracts.ts +5 -1
  128. package/src/state/crew-init.ts +3 -2
  129. package/src/state/decision-ledger.ts +2 -2
  130. package/src/state/event-log-rotation.ts +1 -1
  131. package/src/state/event-log.ts +4 -3
  132. package/src/state/gitignore-manager.ts +2 -1
  133. package/src/state/health-store.ts +5 -2
  134. package/src/state/instinct-store.ts +11 -2
  135. package/src/state/locks.ts +132 -49
  136. package/src/state/mailbox.ts +3 -4
  137. package/src/state/observation-store.ts +1 -13
  138. package/src/state/run-cache.ts +12 -12
  139. package/src/state/run-graph.ts +2 -1
  140. package/src/state/run-metrics.ts +1 -1
  141. package/src/state/schedule.ts +7 -1
  142. package/src/state/state-store.ts +9 -3
  143. package/src/state/tiered-eval.ts +1 -1
  144. package/src/state/worker-atomic-writer.ts +1 -0
  145. package/src/ui/dynamic-border.ts +13 -3
  146. package/src/ui/live-conversation-overlay.ts +7 -27
  147. package/src/ui/live-duration.ts +2 -2
  148. package/src/ui/live-run-sidebar.ts +56 -32
  149. package/src/ui/mascot.ts +19 -13
  150. package/src/ui/powerbar-publisher.ts +244 -178
  151. package/src/ui/render-coalescer.ts +44 -3
  152. package/src/ui/render-diff.ts +2 -2
  153. package/src/ui/run-dashboard.ts +122 -21
  154. package/src/ui/run-event-bus.ts +66 -0
  155. package/src/ui/run-snapshot-cache.ts +84 -81
  156. package/src/ui/settings-overlay.ts +3 -3
  157. package/src/ui/status-colors.ts +24 -0
  158. package/src/ui/tool-progress-formatter.ts +1 -1
  159. package/src/ui/tool-render.ts +3 -3
  160. package/src/ui/transcript-cache.ts +5 -10
  161. package/src/ui/transcript-entries.ts +68 -72
  162. package/src/ui/transcript-viewer.ts +54 -14
  163. package/src/ui/widget/index.ts +26 -15
  164. package/src/ui/widget/widget-model.ts +18 -9
  165. package/src/ui/widget/widget-renderer.ts +2 -5
  166. package/src/utils/fingerprint.ts +1 -1
  167. package/src/utils/paths.ts +1 -1
  168. package/src/utils/project-detector.ts +1 -1
  169. package/src/worktree/worktree-manager.ts +47 -19
  170. package/src/state/atomic-write-v2.ts +0 -108
@@ -557,8 +557,8 @@ class TextinputSubmenu {
557
557
  this.buffer = this.buffer.slice(0, -1);
558
558
  return;
559
559
  }
560
- // Printable character
561
- if (data.length === 1 && data >= " " && data <= "~") {
560
+ // Printable character (accept any non-control character, including Unicode)
561
+ if (data.length === 1 && data >= " ") {
562
562
  this.buffer += data;
563
563
  return;
564
564
  }
@@ -704,7 +704,7 @@ class AgentOverridesSubmenu {
704
704
  this.editBuffer = this.editBuffer.slice(0, -1);
705
705
  return;
706
706
  }
707
- if (data.length === 1 && data >= " " && data <= "~") {
707
+ if (data.length === 1 && data >= " ") {
708
708
  this.editBuffer += data;
709
709
  }
710
710
  }
@@ -106,6 +106,11 @@ function glyphColor(glyph: string): CrewThemeColor {
106
106
  }
107
107
  }
108
108
 
109
+ /**
110
+ * Status glyph characters used in the fast-path check.
111
+ */
112
+ const STATUS_GLYPH_CHARS = "\u2713\u2717\u25a0\u23f8\u25e6\u00b7\u25b6\u23f3\u26a0";
113
+
109
114
  /**
110
115
  * Colorize status glyphs embedded in a rendered line. Wraps every status glyph
111
116
  * (✓ ✗ ■ ⏸ ⏳ ⚠ ◦ · ▶ and braille spinner frames ⠁–⣿) in the appropriate theme
@@ -115,5 +120,24 @@ function glyphColor(glyph: string): CrewThemeColor {
115
120
  * and live-run-sidebar.ts with a single shared helper (F-1, F-2, V-3).
116
121
  */
117
122
  export function colorizeStatusGlyphs(line: string, theme: CrewTheme): string {
123
+ // Fast path: skip regex if line contains no status glyphs at all.
124
+ let hasGlyph = false;
125
+ for (let i = 0; i < STATUS_GLYPH_CHARS.length; i++) {
126
+ if (line.indexOf(STATUS_GLYPH_CHARS[i]) !== -1) {
127
+ hasGlyph = true;
128
+ break;
129
+ }
130
+ }
131
+ if (!hasGlyph) {
132
+ // Check for braille range (U+2800–U+28FF)
133
+ for (let i = 0; i < line.length; i++) {
134
+ const cp = line.codePointAt(i)!;
135
+ if (cp >= 0x2800 && cp <= 0x28ff) {
136
+ hasGlyph = true;
137
+ break;
138
+ }
139
+ }
140
+ }
141
+ if (!hasGlyph) return line;
118
142
  return line.replace(/[✓✗■⏸◦·▶⏳⚠]|[\u2800-\u28FF]/g, (glyph) => theme.fg(glyphColor(glyph), glyph));
119
143
  }
@@ -81,7 +81,7 @@ export function formatCompactToolProgress(input: ToolProgressInput): string {
81
81
  lines.push(` tool: ${active.progress.currentTool}${count}`);
82
82
  }
83
83
  const recent = active.progress?.recentOutput?.at(-1);
84
- if (recent && recent.trim()) lines.push(` ${trimLine(recent)}`);
84
+ if (recent?.trim()) lines.push(` ${trimLine(recent)}`);
85
85
  } else if (input.runId && !counts) {
86
86
  lines.push(` run=${input.runId} (starting)`);
87
87
  } else if (input.error) {
@@ -7,7 +7,7 @@
7
7
  * Ports logic from pi-subagent4 adapted for pi-crew's data model.
8
8
  * Uses @earendil-works/pi-tui Components (Container, Text, Spacer) directly.
9
9
  */
10
- import { Container, Spacer, Text, visibleWidth } from "@earendil-works/pi-tui";
10
+ import { Container, Spacer, Text } from "@earendil-works/pi-tui";
11
11
  import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
12
12
  import { truncateToWidth } from "../utils/visual.ts";
13
13
  import { replaceTabs } from "./render-diff.ts";
@@ -444,10 +444,10 @@ function parseArgs(argsStr: string | undefined): Record<string, unknown> {
444
444
  function computeDurationMs(startedAt: string, completedAt?: string): number {
445
445
  if (!startedAt) return 0;
446
446
  const start = new Date(startedAt).getTime();
447
- if (isNaN(start)) return 0;
447
+ if (Number.isNaN(start)) return 0;
448
448
  if (completedAt) {
449
449
  const end = new Date(completedAt).getTime();
450
- return isNaN(end) ? 0 : Math.max(0, end - start);
450
+ return Number.isNaN(end) ? 0 : Math.max(0, end - start);
451
451
  }
452
452
  return Math.max(0, Date.now() - start);
453
453
  }
@@ -112,17 +112,12 @@ export function readTranscriptLinesCached(
112
112
  truncated: read.truncated,
113
113
  };
114
114
  transcriptCache.set(key, entry);
115
- // Evict oldest entry if cache exceeds max size
115
+ // FIND-12: evict the oldest entry by Map insertion order. Map natively
116
+ // preserves insertion order, so the first key is the oldest — replaces
117
+ // the previous O(N) `parsedAt` min-scan that ran on every miss.
116
118
  if (transcriptCache.size > MAX_CACHE_SIZE) {
117
- let oldestKey: string | null = null;
118
- let oldestParsedAt = Infinity;
119
- for (const [k, v] of transcriptCache.entries()) {
120
- if (v.parsedAt < oldestParsedAt) {
121
- oldestParsedAt = v.parsedAt;
122
- oldestKey = k;
123
- }
124
- }
125
- if (oldestKey) transcriptCache.delete(oldestKey);
119
+ const oldestKey = transcriptCache.keys().next().value;
120
+ if (oldestKey !== undefined) transcriptCache.delete(oldestKey);
126
121
  }
127
122
  return lines;
128
123
  } catch {
@@ -97,71 +97,61 @@ export function parseTranscriptEntries(lines: string[]): TranscriptEntry[] {
97
97
  }
98
98
  }
99
99
 
100
- let i = 0;
101
- while (i < parsed.length) {
100
+ // FIND-18: single linear pass to match each tool_call with the next
101
+ // tool-flavored event after it. Replaces the previous O(n²) lookahead that
102
+ // re-scanned the suffix of the array for every tool_call. The pending slot
103
+ // holds the most recent tool_call awaiting its result. A new tool_call
104
+ // arriving before any result flushes the previous one as no-result (same
105
+ // semantic as the old lookahead hitting the next tool_call). Non-tool
106
+ // events are emitted in order and do not interrupt the pending slot,
107
+ // matching the old lookahead's behaviour of skipping over messages.
108
+ let pending: { obj: Record<string, unknown>; toolName: string; inputText: string; timestamp?: number } | null = null;
109
+
110
+ const flushPending = (isError: boolean, resultText: string, resultTimestamp: number | undefined): void => {
111
+ if (!pending) return;
112
+ const callSummary = summarize(
113
+ `🔧 ${pending.toolName}${pending.inputText ? `: ${summarize(pending.inputText, 60)}` : ""}`,
114
+ SUMMARY_MAX,
115
+ );
116
+ const callContent = [
117
+ `[Tool Call: ${pending.toolName}]`,
118
+ pending.inputText || "(no input)",
119
+ `[Result${isError ? " ✗" : " ✓"}]`,
120
+ resultText || "(no output)",
121
+ ].join("\n");
122
+ entries.push({
123
+ id: id++,
124
+ type: "tool_call",
125
+ toolName: pending.toolName,
126
+ summary: callSummary,
127
+ content: callContent,
128
+ expanded: false,
129
+ timestamp: resultTimestamp ?? pending.timestamp,
130
+ });
131
+ pending = null;
132
+ };
133
+
134
+ for (let i = 0; i < parsed.length; i++) {
102
135
  const { obj } = parsed[i]!;
103
136
  const type = typeof obj.type === "string" ? obj.type : "";
104
137
  const timestamp = typeof obj.timestamp === "number" ? obj.timestamp : undefined;
105
138
 
106
139
  if (isToolCallType(type)) {
107
- // Look ahead for matching tool_result
108
- const toolName = extractToolName(obj) ?? "unknown";
140
+ // A new tool_call interrupts any pending one (same as the old
141
+ // lookahead breaking on the next tool_call). Flush as no-result.
142
+ if (pending) flushPending(false, "", undefined);
109
143
  const inputText = typeof obj.input === "string" ? obj.input : obj.input !== undefined ? JSON.stringify(obj.input) : "";
110
- let resultText = "";
111
- let isError = false;
112
- let resultTimestamp = timestamp;
113
-
114
- // Consume consecutive tool_result lines
115
- let j = i + 1;
116
- while (j < parsed.length) {
117
- const nextTypeVal = parsed[j]!.obj.type;
118
- const nextType = typeof nextTypeVal === "string" ? nextTypeVal : "";
119
- if (isToolResultType(nextType) || /tool/i.test(nextType)) {
120
- const robj = parsed[j]!.obj;
121
- const rt =
122
- typeof robj.text === "string"
123
- ? robj.text
124
- : typeof robj.result === "string"
125
- ? robj.result
126
- : robj.result !== undefined
127
- ? JSON.stringify(robj.result)
128
- : "";
129
- resultText = rt;
130
- isError = robj.isError === true;
131
- resultTimestamp = typeof robj.timestamp === "number" ? robj.timestamp : resultTimestamp;
132
- j++;
133
- break;
134
- }
135
- if (isToolCallType(nextType)) break; // next tool call, stop looking
136
- j++;
137
- }
138
-
139
- const callSummary = summarize(`🔧 ${toolName}${inputText ? ": " + summarize(inputText, 60) : ""}`, SUMMARY_MAX);
140
- const callContent = [
141
- `[Tool Call: ${toolName}]`,
142
- inputText || "(no input)",
143
- `[Result${isError ? " ✗" : " ✓"}]`,
144
- resultText || "(no output)",
145
- ].join("\n");
146
-
147
- entries.push({
148
- id: id++,
149
- type: "tool_call",
150
- toolName,
151
- summary: callSummary,
152
- content: callContent,
153
- expanded: false,
154
- timestamp: resultTimestamp ?? timestamp,
155
- });
156
- i = j;
144
+ pending = {
145
+ obj,
146
+ toolName: extractToolName(obj) ?? "unknown",
147
+ inputText,
148
+ timestamp,
149
+ };
157
150
  continue;
158
151
  }
159
152
 
160
153
  if (isToolResultType(type) || /tool/i.test(type)) {
161
- // Standalone tool result (no preceding tool_call consumed it)
162
- const toolName = extractToolName(obj) ?? "unknown";
163
- const isError = obj.isError === true;
164
- const text =
154
+ const rt =
165
155
  typeof obj.text === "string"
166
156
  ? obj.text
167
157
  : typeof obj.result === "string"
@@ -169,20 +159,25 @@ export function parseTranscriptEntries(lines: string[]): TranscriptEntry[] {
169
159
  : obj.result !== undefined
170
160
  ? JSON.stringify(obj.result)
171
161
  : "";
172
-
173
- const summary = summarize(`${isError ? "✗" : "✓"} ${toolName}${text ? ": " + summarize(text, 60) : ""}`, SUMMARY_MAX);
174
- const content = [`[Tool Result: ${toolName}${isError ? " (error)" : ""}]`, text || "(no output)"].join("\n");
175
-
176
- entries.push({
177
- id: id++,
178
- type: "tool_result",
179
- toolName,
180
- summary,
181
- content,
182
- expanded: false,
183
- timestamp,
184
- });
185
- i++;
162
+ const isError = obj.isError === true;
163
+ const resultTimestamp = typeof obj.timestamp === "number" ? obj.timestamp : undefined;
164
+ if (pending) {
165
+ flushPending(isError, rt, resultTimestamp);
166
+ } else {
167
+ // Standalone tool result (no preceding tool_call consumed it)
168
+ const toolName = extractToolName(obj) ?? "unknown";
169
+ const summary = summarize(`${isError ? "✗" : "✓"} ${toolName}${rt ? `: ${summarize(rt, 60)}` : ""}`, SUMMARY_MAX);
170
+ const content = [`[Tool Result: ${toolName}${isError ? " (error)" : ""}]`, rt || "(no output)"].join("\n");
171
+ entries.push({
172
+ id: id++,
173
+ type: "tool_result",
174
+ toolName,
175
+ summary,
176
+ content,
177
+ expanded: false,
178
+ timestamp,
179
+ });
180
+ }
186
181
  continue;
187
182
  }
188
183
 
@@ -194,7 +189,7 @@ export function parseTranscriptEntries(lines: string[]): TranscriptEntry[] {
194
189
  const text = textFromContent(msg.content);
195
190
 
196
191
  const label = role === "assistant" ? "🤖" : role === "user" ? "👤" : "💬";
197
- const summary = summarize(`${label} ${role}${text ? ": " + summarize(text, 80) : ""}`, SUMMARY_MAX);
192
+ const summary = summarize(`${label} ${role}${text ? `: ${summarize(text, 80)}` : ""}`, SUMMARY_MAX);
198
193
  const content = `[${role.charAt(0).toUpperCase()}${role.slice(1)}]:\n${text || "(empty)"}`;
199
194
 
200
195
  entries.push({
@@ -206,7 +201,6 @@ export function parseTranscriptEntries(lines: string[]): TranscriptEntry[] {
206
201
  expanded: false,
207
202
  timestamp,
208
203
  });
209
- i++;
210
204
  continue;
211
205
  }
212
206
 
@@ -221,9 +215,11 @@ export function parseTranscriptEntries(lines: string[]): TranscriptEntry[] {
221
215
  expanded: false,
222
216
  timestamp,
223
217
  });
224
- i++;
225
218
  }
226
219
 
220
+ // End of input: any pending tool_call never received a result.
221
+ if (pending) flushPending(false, "", undefined);
222
+
227
223
  return entries;
228
224
  }
229
225
 
@@ -18,6 +18,38 @@ type Component = {
18
18
 
19
19
  type TranscriptTheme = CrewTheme;
20
20
 
21
+ interface ReadRunTranscriptResult {
22
+ title: string;
23
+ path: string;
24
+ lines: string[];
25
+ bytesRead: number;
26
+ size: number;
27
+ truncated: boolean;
28
+ }
29
+
30
+ // FIND-06: short-TTL cache keyed by (runId, taskId, readOptions) so the ~500ms
31
+ // render tick of `DurableTranscriptViewer` does not re-stat / re-read / re-parse
32
+ // the underlying transcript on every call. The deeper `transcriptCache` only
33
+ // reuses parsed lines when mtime/size are stable; during an actively-written
34
+ // run, the file's mtime changes on every tick and the inner cache is
35
+ // invalidated. This outer cache caps the per-tick re-parse cost at one parse
36
+ // per ~500ms even while the file is growing.
37
+ const READ_RUN_TRANSCRIPT_TTL_MS = 500;
38
+ const READ_RUN_TRANSCRIPT_CACHE_MAX = 32;
39
+ interface ReadRunTranscriptCacheEntry {
40
+ parsedAt: number;
41
+ result: ReadRunTranscriptResult;
42
+ }
43
+ const readRunTranscriptCache = new Map<string, ReadRunTranscriptCacheEntry>();
44
+
45
+ function readRunTranscriptCacheKey(
46
+ manifest: TeamRunManifest,
47
+ taskId: string | undefined,
48
+ readOptions: { full: boolean; maxTailBytes: number },
49
+ ): string {
50
+ return `${manifest.runId}|${taskId ?? ""}|${readOptions.full ? "full" : "tail"}|${readOptions.maxTailBytes}`;
51
+ }
52
+
21
53
  function asRecord(value: unknown): Record<string, unknown> | undefined {
22
54
  return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
23
55
  }
@@ -140,14 +172,17 @@ export function readRunTranscript(
140
172
  manifest: TeamRunManifest,
141
173
  taskId?: string,
142
174
  options: { full?: boolean; maxTailBytes?: number } = {},
143
- ): {
144
- title: string;
145
- path: string;
146
- lines: string[];
147
- bytesRead: number;
148
- size: number;
149
- truncated: boolean;
150
- } {
175
+ ): ReadRunTranscriptResult {
176
+ const now = Date.now();
177
+ const readOptions = {
178
+ full: options.full === true,
179
+ maxTailBytes: options.maxTailBytes ?? DEFAULT_TRANSCRIPT_TAIL_BYTES,
180
+ };
181
+ const cacheKey = readRunTranscriptCacheKey(manifest, taskId, readOptions);
182
+ const cached = readRunTranscriptCache.get(cacheKey);
183
+ if (cached && now - cached.parsedAt < READ_RUN_TRANSCRIPT_TTL_MS) {
184
+ return cached.result;
185
+ }
151
186
  const agents = readCrewAgents(manifest);
152
187
  const agent = taskId
153
188
  ? agents.find((item) => item.taskId === taskId || item.id === taskId)
@@ -172,13 +207,9 @@ export function readRunTranscript(
172
207
  // Ignore untrusted transcript paths from mutable agent state and fall back to durable agent output.
173
208
  }
174
209
  }
175
- const readOptions = {
176
- full: options.full === true,
177
- maxTailBytes: options.maxTailBytes ?? DEFAULT_TRANSCRIPT_TAIL_BYTES,
178
- };
179
- const lines = readTranscriptLinesCached(transcriptPath, (text) => formatTranscriptText(text), Date.now(), readOptions);
210
+ const lines = readTranscriptLinesCached(transcriptPath, (text) => formatTranscriptText(text), now, readOptions);
180
211
  const entry = getTranscriptCacheEntry(transcriptPath, readOptions);
181
- return {
212
+ const result: ReadRunTranscriptResult = {
182
213
  title: `${manifest.runId}:${selectedTaskId}`,
183
214
  path: transcriptPath,
184
215
  lines: lines.length ? lines : ["(no transcript content)"],
@@ -186,6 +217,15 @@ export function readRunTranscript(
186
217
  size: entry?.size ?? 0,
187
218
  truncated: entry?.truncated ?? false,
188
219
  };
220
+ // Map preserves insertion order; the first key is the oldest entry. Evict
221
+ // before insert so the cache stays at most READ_RUN_TRANSCRIPT_CACHE_MAX.
222
+ while (readRunTranscriptCache.size >= READ_RUN_TRANSCRIPT_CACHE_MAX) {
223
+ const oldest = readRunTranscriptCache.keys().next().value;
224
+ if (oldest === undefined) break;
225
+ readRunTranscriptCache.delete(oldest);
226
+ }
227
+ readRunTranscriptCache.set(cacheKey, { parsedAt: now, result });
228
+ return result;
189
229
  }
190
230
 
191
231
  interface ViewerState {
@@ -12,13 +12,14 @@ import type { ManifestCache } from "../../runtime/manifest-cache.ts";
12
12
  import type { TeamRunManifest } from "../../state/types.ts";
13
13
  import { truncate } from "../../utils/visual.ts";
14
14
  import { requestRender, requestRenderTarget, setExtensionWidget } from "../pi-ui-compat.ts";
15
- import { runEventBus } from "../run-event-bus.ts";
16
- import type { RunSnapshotCache, RunUiSnapshot } from "../snapshot-types.ts";
15
+ import { RenderScheduler } from "../render-scheduler.ts";
16
+ import { runEventBusAsRenderScheduler } from "../run-event-bus.ts";
17
+ import type { RunSnapshotCache } from "../snapshot-types.ts";
17
18
  import { spinnerBucket, spinnerFrame } from "../spinner.ts";
18
19
  import type { CrewTheme } from "../theme-adapter.ts";
19
20
  import { asCrewTheme, subscribeThemeChange } from "../theme-adapter.ts";
20
21
  import { activeWidgetRuns, statusSummary } from "./widget-model.ts";
21
- import { buildWidgetLines, colorWidgetLine, DEFAULT_WIDGET_WIDTH, renderLines, TASK_DESC_MAX } from "./widget-renderer.ts";
22
+ import { buildWidgetLines, colorWidgetLine, DEFAULT_WIDGET_WIDTH, renderLines } from "./widget-renderer.ts";
22
23
  import type { CrewWidgetModel, CrewWidgetState, WidgetRun } from "./widget-types.ts";
23
24
 
24
25
  export { activeWidgetRuns, statusSummary } from "./widget-model.ts";
@@ -109,7 +110,7 @@ class CrewWidgetComponent implements WidgetComponent {
109
110
  private cachedTheme: CrewTheme;
110
111
  private readonly tui: unknown;
111
112
  private readonly unsubscribeTheme: () => void;
112
- private readonly unsubscribeEventBus: () => void;
113
+ private readonly renderScheduler: RenderScheduler;
113
114
 
114
115
  constructor(model: CrewWidgetModel, themeLike: unknown, tui?: unknown) {
115
116
  this.model = model;
@@ -123,16 +124,21 @@ class CrewWidgetComponent implements WidgetComponent {
123
124
  activeResizeTarget = this;
124
125
  installResizeListener();
125
126
  this.unsubscribeTheme = subscribeThemeChange(themeLike, () => this.invalidate());
126
- this.unsubscribeEventBus = (() => {
127
- const unsub1 = runEventBus.onChannel("run:state", () => this.invalidate());
128
- const unsub2 = runEventBus.onChannel("worker:lifecycle", () => this.invalidate());
129
- const unsub3 = runEventBus.onChannel("ui:invalidate", () => this.invalidate());
130
- return () => {
131
- unsub1();
132
- unsub2();
133
- unsub3();
134
- };
135
- })();
127
+ // 1.10 (UI-P1-1): route run:state / worker:lifecycle / ui:invalidate
128
+ // through a RenderScheduler (debounce + fallback) instead of three
129
+ // direct runEventBus.onChannel subscriptions. With 3 overlays
130
+ // subscribing independently a single event triggered up to 9 callbacks
131
+ // and ~150 invalidates/sec under load. The scheduler collapses bursts
132
+ // into one debounced invalidate.
133
+ this.renderScheduler = new RenderScheduler(
134
+ runEventBusAsRenderScheduler(["run:state", "worker:lifecycle", "ui:invalidate"]),
135
+ () => this.invalidate(),
136
+ {
137
+ debounceMs: 75,
138
+ fallbackMs: 750,
139
+ events: ["run:state", "worker:lifecycle", "ui:invalidate"],
140
+ },
141
+ );
136
142
  }
137
143
 
138
144
  private buildSignature(runs: WidgetRun[]): string {
@@ -193,7 +199,7 @@ class CrewWidgetComponent implements WidgetComponent {
193
199
 
194
200
  dispose(): void {
195
201
  this.unsubscribeTheme();
196
- this.unsubscribeEventBus();
202
+ this.renderScheduler.dispose();
197
203
  if (activeResizeTarget === this) activeResizeTarget = undefined;
198
204
  }
199
205
 
@@ -222,6 +228,11 @@ class CrewWidgetComponent implements WidgetComponent {
222
228
 
223
229
  if (runs.length === 0) {
224
230
  this.invalidate();
231
+ // P0-6: render from snapshots only — never read disk on every render tick.
232
+ // When the snapshot cache is provided but hasn't populated yet, paint a
233
+ // single "(loading…)" line so the pre-load frame is well-formed instead
234
+ // of an empty panel. Without a cache (legacy/tests) keep the empty result.
235
+ if (this.model.snapshotCache) return ["(loading…)"];
225
236
  return [];
226
237
  }
227
238
 
@@ -8,7 +8,7 @@ import { listRecentRuns } from "../../extension/run-index.ts";
8
8
  import { reconcileAllStaleRuns } from "../../runtime/crash-recovery.ts";
9
9
  import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
10
10
  import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
11
- import { evictStaleLiveAgentHandles, listLiveAgents } from "../../runtime/live-agent-manager.ts";
11
+ import { evictStaleLiveAgentHandles } from "../../runtime/live-agent-manager.ts";
12
12
  import type { ManifestCache } from "../../runtime/manifest-cache.ts";
13
13
  import { isDisplayActiveRun } from "../../runtime/process-status.ts";
14
14
  import type { TeamRunManifest } from "../../state/types.ts";
@@ -57,18 +57,27 @@ export function activeWidgetRuns(
57
57
  .map((run) => {
58
58
  try {
59
59
  const snapshot = snapshotCache?.get(run.runId);
60
- return snapshot
61
- ? {
62
- run: snapshot.manifest,
63
- agents: snapshot.agents,
64
- snapshot,
65
- }
66
- : { run, agents: agentsFor(run) };
60
+ if (snapshot) {
61
+ return {
62
+ run: snapshot.manifest,
63
+ agents: snapshot.agents,
64
+ snapshot,
65
+ };
66
+ }
67
+ // P0-6: render from snapshots only — never read disk on every
68
+ // render tick. When the snapshot cache is provided but hasn't
69
+ // populated yet (first frame after `updateCrewWidget`), drop the
70
+ // run from the result so the widget paints "(loading…)" instead
71
+ // of calling `agentsFor(run) → readCrewAgents`. Legacy callers
72
+ // (no snapshotCache) keep the disk-read fallback for tests/dev.
73
+ if (snapshotCache) return null;
74
+ return { run, agents: agentsFor(run) };
67
75
  } catch {
76
+ if (snapshotCache) return null;
68
77
  return { run, agents: agentsFor(run) };
69
78
  }
70
79
  })
71
- .filter((item) => isDisplayActiveRun(item.run, item.agents));
80
+ .filter((item): item is WidgetRun => item !== null && isDisplayActiveRun(item.run, item.agents));
72
81
  }
73
82
 
74
83
  /**
@@ -5,16 +5,13 @@
5
5
  */
6
6
 
7
7
  import { listLiveAgents } from "../../runtime/live-agent-manager.ts";
8
- import { computePhaseProgress, formatPhaseProgressLine } from "../../runtime/phase-progress.ts";
9
8
  import { isFinishedRunStatus } from "../../runtime/process-status.ts";
10
- import { getTaskUsage } from "../../runtime/usage-tracker.ts";
11
9
  import { truncate } from "../../utils/visual.ts";
12
10
  import { Box, Text } from "../layout-primitives.ts";
13
- import { computeLiveDurationMs } from "../live-duration.ts";
14
11
  import { spinnerFrame } from "../spinner.ts";
15
12
  import { colorizeStatusGlyphs, iconForStatus } from "../status-colors.ts";
16
13
  import type { CrewTheme } from "../theme-adapter.ts";
17
- import { agentActivity, agentStats, elapsed, formatTokensCompact, notificationBadge } from "./widget-formatters.ts";
14
+ import { agentActivity, agentStats, notificationBadge } from "./widget-formatters.ts";
18
15
  import { activeWidgetRuns, shortRunLabel } from "./widget-model.ts";
19
16
  import type { WidgetRun } from "./widget-types.ts";
20
17
 
@@ -98,7 +95,7 @@ export function buildWidgetLines(
98
95
  // reached its terminal status). The status label is also surfaced
99
96
  // explicitly so the row cannot be misread as an active run.
100
97
  const agentCountText = `${completed}/${agents.length} agents`;
101
- const runEndMs = isTerminal ? new Date(run.updatedAt).getTime() : Date.now();
98
+ const runEndMs = isTerminal ? new Date(run.updatedAt).getTime() : now;
102
99
  const runElapsedMs = Math.max(0, Number.isFinite(runEndMs) ? runEndMs - new Date(run.createdAt).getTime() : 0);
103
100
  const runElapsedText = `${Math.floor(runElapsedMs / 1000)}s`;
104
101
  const statusLabel = isTerminal ? ` · ${run.status}` : "";
@@ -7,7 +7,7 @@
7
7
  * Only STRUCTURAL changes trigger re-analysis.
8
8
  */
9
9
 
10
- import { createHash, type Hash } from "node:crypto";
10
+ import { createHash } from "node:crypto";
11
11
  import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
12
12
  import { logInternalError } from "../utils/internal-error.ts";
13
13
 
@@ -44,7 +44,7 @@ export function userPiRoot(): string {
44
44
  // "undefined/.pi/agent" relative to cwd and silently create a junk "undefined/"
45
45
  // tree. Treat the literal "undefined" (and empty) as unset and fall back to
46
46
  // os.homedir().
47
- const rawHome = process.env.PI_TEAMS_HOME?.trim();
47
+ const rawHome = (process.env.PI_TEAMS_HOME ?? process.env.PI_CREW_HOME)?.trim();
48
48
  const home = rawHome && rawHome !== "undefined" ? rawHome : os.homedir();
49
49
  const resolved = path.join(home, ".pi", "agent");
50
50
 
@@ -113,7 +113,7 @@ export function detectProjectId(cwd: string): ProjectInfo {
113
113
 
114
114
  // Method 1: Check CLAUDE_PROJECT_DIR env var
115
115
  const claudeProjectDir = process.env.CLAUDE_PROJECT_DIR;
116
- if (claudeProjectDir && claudeProjectDir.trim()) {
116
+ if (claudeProjectDir?.trim()) {
117
117
  const resolvedPath = path.resolve(claudeProjectDir.trim());
118
118
  const projectName = path.basename(resolvedPath);
119
119
  const projectId = hashPath(resolvedPath);