pi-crew 0.11.2 → 0.11.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +83 -1
  2. package/README.md +1 -1
  3. package/dist/index.mjs +492 -493
  4. package/docs/actions-reference.md +22 -0
  5. package/index.ts +9 -5
  6. package/package.json +12 -9
  7. package/scripts/analyze-run.mjs +6 -3
  8. package/skills/real-test-pi-crew/SKILL.md +20 -12
  9. package/src/extension/async-notifier.ts +32 -8
  10. package/src/extension/crew-input-router.ts +9 -2
  11. package/src/extension/plan-orchestrate.ts +19 -3
  12. package/src/extension/registration/commands/shared.ts +11 -10
  13. package/src/extension/registration/foreground-run-controller.ts +2 -2
  14. package/src/extension/registration/lazy-configurers.ts +4 -1
  15. package/src/extension/registration/lifecycle-handlers.ts +16 -10
  16. package/src/extension/registration/lifecycle.ts +2 -0
  17. package/src/extension/registration/observability.ts +27 -18
  18. package/src/extension/registration/team-tool.ts +9 -2
  19. package/src/extension/registration/wire-cross-extension.ts +2 -0
  20. package/src/extension/team-manager-command.ts +3 -2
  21. package/src/extension/team-tool/dispatch/index.ts +4 -3
  22. package/src/extension/team-tool/dispatch/status.ts +7 -3
  23. package/src/extension/team-tool/goal-wrap.ts +7 -0
  24. package/src/extension/team-tool/goal.ts +12 -0
  25. package/src/extension/team-tool/inspect.ts +90 -0
  26. package/src/extension/team-tool/parallel-dispatch.ts +16 -1
  27. package/src/extension/team-tool/run-intent.ts +1 -1
  28. package/src/extension/team-tool/run.ts +1 -1
  29. package/src/runtime/async-runner.ts +36 -0
  30. package/src/runtime/broker/crew-broker.ts +61 -62
  31. package/src/runtime/broker/delegate/delegate-event.ts +12 -8
  32. package/src/runtime/broker/protocol/connection-state.ts +10 -0
  33. package/src/runtime/broker/protocol/lock-busy.ts +76 -0
  34. package/src/runtime/delegate-spawn.ts +16 -2
  35. package/src/runtime/deterministic-ast.ts +14 -2
  36. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +78 -58
  37. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +14 -2
  38. package/src/runtime/goal-workflow/goal-loop-runner.ts +32 -0
  39. package/src/runtime/group-join.ts +11 -3
  40. package/src/runtime/live-session/live-session-runtime.ts +1 -1
  41. package/src/runtime/model/runtime-warmup.ts +2 -0
  42. package/src/runtime/output/output-validator.ts +10 -5
  43. package/src/runtime/plan-approval.ts +14 -1
  44. package/src/runtime/recovery/crash-recovery.ts +80 -52
  45. package/src/runtime/stale-reconciler.ts +8 -0
  46. package/src/runtime/subagent-manager.ts +43 -13
  47. package/src/runtime/task-output-context.ts +11 -1
  48. package/src/runtime/task-runner/child-executor.ts +17 -6
  49. package/src/runtime/task-runner/post-execution.ts +46 -14
  50. package/src/runtime/task-runner/pre-execution.ts +29 -21
  51. package/src/runtime/task-runner/prompt-builder.ts +8 -0
  52. package/src/runtime/task-runner/retrieval-orchestrator.ts +36 -14
  53. package/src/runtime/task-runner.ts +7 -0
  54. package/src/runtime/team-runner.ts +4 -1
  55. package/src/runtime/verification/verification-worktree.ts +3 -0
  56. package/src/schema/team-tool-schema.ts +2 -1
  57. package/src/state/coordination/mailbox.ts +7 -2
  58. package/src/state/stores/state-store.ts +8 -1
  59. package/src/ui/dashboard-panes/agents-pane.ts +11 -8
  60. package/src/ui/inline-panel/index.ts +1 -1
  61. package/src/ui/render-diff.ts +19 -6
  62. package/src/ui/run-dashboard.ts +11 -1
  63. package/src/ui/run-snapshot-cache.ts +9 -3
  64. package/src/ui/widget/task-list.ts +8 -5
  65. package/src/ui/widget/widget-model.ts +3 -1
  66. package/tsconfig.json +2 -0
  67. package/src/ui/card-colors.ts +0 -168
  68. package/src/utils/completion-dedupe.ts +0 -63
  69. package/src/utils/resolve-shell.ts +0 -36
@@ -71,6 +71,8 @@ export interface TaskExecutionContext {
71
71
  skillPaths: string[] | undefined;
72
72
  prompt: string;
73
73
  promptArtifact: ArtifactDescriptor;
74
+ /** G18: prompt-breakdown artifact descriptor (undefined when PI_CREW_PROMPT_BREAKDOWN off). */
75
+ breakdownArtifact?: ArtifactDescriptor;
74
76
  inputsArtifact: ArtifactDescriptor;
75
77
  skillArtifact: ArtifactDescriptor | undefined;
76
78
  coordinationArtifact: ArtifactDescriptor;
@@ -343,27 +345,32 @@ export async function prepareTaskExecutionContext(
343
345
  // SR-02 phase 1: per-section breakdown artifact (opt-in via
344
346
  // PI_CREW_PROMPT_BREAKDOWN=1). Adds the SYSTEM-side pieces the prompt
345
347
  // builder cannot see (agent definition, skills, pre-step output).
346
- if (promptBreakdownEnabled()) {
347
- const sections: Record<string, number> = {
348
- "system.agentDefinition": input.agent?.systemPrompt?.length ?? 0,
349
- ...(promptResult.sections ?? {}),
350
- "dynamic.preStepOutput": preStepOutput?.length ?? 0,
351
- };
352
- writeArtifact(manifest.artifactsRoot, {
353
- kind: "metadata",
354
- relativePath: `metadata/${task.id}.prompt-breakdown.json`,
355
- content: `${JSON.stringify(
356
- Object.fromEntries(
357
- Object.entries(sections)
358
- .filter(([, chars]) => chars > 0)
359
- .map(([name, chars]) => [name, { chars, estTokens: estimateTokens(chars) }]),
360
- ),
361
- null,
362
- 2,
363
- )}\n`,
364
- producer: "prompt-breakdown",
365
- });
366
- }
348
+ // G18 (SDD Buổi-1 WI-3 follow-up): capture the descriptor so it flows
349
+ // through ctx into the manifest artifacts index — otherwise the
350
+ // `breakdown` team-tool action cannot find it on real runs.
351
+ const breakdownArtifact: ArtifactDescriptor | undefined = promptBreakdownEnabled()
352
+ ? (() => {
353
+ const sections: Record<string, number> = {
354
+ "system.agentDefinition": input.agent?.systemPrompt?.length ?? 0,
355
+ ...(promptResult.sections ?? {}),
356
+ "dynamic.preStepOutput": preStepOutput?.length ?? 0,
357
+ };
358
+ return writeArtifact(manifest.artifactsRoot, {
359
+ kind: "metadata",
360
+ relativePath: `metadata/${task.id}.prompt-breakdown.json`,
361
+ content: `${JSON.stringify(
362
+ Object.fromEntries(
363
+ Object.entries(sections)
364
+ .filter(([, chars]) => chars > 0)
365
+ .map(([name, chars]) => [name, { chars, estTokens: estimateTokens(chars) }]),
366
+ ),
367
+ null,
368
+ 2,
369
+ )}\n`,
370
+ producer: "prompt-breakdown",
371
+ });
372
+ })()
373
+ : undefined;
367
374
 
368
375
  const collectedJsonEvents: Record<string, unknown>[] | undefined = collectYieldEvents ? [] : undefined;
369
376
 
@@ -415,6 +422,7 @@ export async function prepareTaskExecutionContext(
415
422
  skillPaths,
416
423
  prompt,
417
424
  promptArtifact,
425
+ breakdownArtifact,
418
426
  inputsArtifact,
419
427
  skillArtifact,
420
428
  coordinationArtifact,
@@ -248,6 +248,14 @@ export async function computeStablePrefixComponents(
248
248
 
249
249
  const components: StableComponents = { treeBlock, suggestedFilesBlock, knowledgeFragment };
250
250
  stableComponentCache.set(cacheKey, components);
251
+ // RR-021 WI-4.3f: same insertion-order eviction cap as the stableIOCache
252
+ // sibling below — stableComponentCache had NO cap (unbounded across a
253
+ // long session with many distinct runIds: the key includes runId).
254
+ while (stableComponentCache.size > 256) {
255
+ const oldest = stableComponentCache.keys().next().value;
256
+ if (oldest === undefined) break;
257
+ stableComponentCache.delete(oldest);
258
+ }
251
259
  // Populate the cross-run cache. Clamp size to avoid unbounded growth across
252
260
  // long sessions with many distinct (cwd, step) combos.
253
261
  stableIOCache.set(ioKey, { ...components, at: now });
@@ -160,6 +160,9 @@ interface RipgrepAvailable {
160
160
  }
161
161
 
162
162
  let cachedRgCheck: RipgrepAvailable | undefined;
163
+ /** RR-021 WI-1.3: memoize the in-flight detection so concurrent first calls
164
+ * spawn rg at most once instead of one process per caller. */
165
+ let inFlightRgCheck: Promise<RipgrepAvailable> | undefined;
163
166
 
164
167
  /**
165
168
  * PERF round 3: per-cwd cache of the rg discovery result (relative paths,
@@ -196,45 +199,64 @@ function storeDiscovered(cwd: string, files: string[]): void {
196
199
  * Detect ripgrep availability once per process. Uses `rg --version` and
197
200
  * catches ENOENT or non-zero exit. Cached so the cost (one spawn) is
198
201
  * paid only on the first retrieval cycle.
202
+ *
203
+ * RR-021 WI-1.3 hardening: a spawned-but-wedged rg no longer hangs detection
204
+ * forever — a 3s unref'd SIGKILL timer defaults to `{ available: false }`.
199
205
  */
200
206
  export async function detectRipgrep(): Promise<RipgrepAvailable> {
201
207
  if (cachedRgCheck !== undefined) return cachedRgCheck;
202
- return await new Promise<RipgrepAvailable>((resolve) => {
208
+ if (inFlightRgCheck !== undefined) return inFlightRgCheck;
209
+ inFlightRgCheck = new Promise<RipgrepAvailable>((resolve) => {
203
210
  let settled = false;
211
+ let killTimer: NodeJS.Timeout | undefined;
212
+ const settle = (result: RipgrepAvailable): void => {
213
+ if (settled) return;
214
+ settled = true;
215
+ if (killTimer !== undefined) clearTimeout(killTimer);
216
+ cachedRgCheck = result;
217
+ resolve(result);
218
+ };
204
219
  try {
205
220
  const child = spawn("rg", ["--version"], { stdio: ["ignore", "pipe", "pipe"] });
221
+ // 3s hard deadline. unref'd so the timer never holds the event loop open.
222
+ killTimer = setTimeout(() => {
223
+ try {
224
+ child.kill("SIGKILL");
225
+ } catch {
226
+ // already dead — the close handler settles the result
227
+ }
228
+ settle({ available: false });
229
+ }, 3000);
230
+ killTimer.unref();
206
231
  let stdout = "";
207
232
  child.stdout?.on("data", (chunk) => {
208
233
  stdout += chunk.toString("utf-8");
209
234
  });
210
235
  child.on("error", () => {
211
- if (settled) return;
212
- settled = true;
213
- cachedRgCheck = { available: false };
214
- resolve(cachedRgCheck);
236
+ settle({ available: false });
215
237
  });
216
238
  child.on("close", (code) => {
217
- if (settled) return;
218
- settled = true;
219
239
  if (code === 0) {
220
- cachedRgCheck = { available: true, version: stdout.split("\n")[0] ?? undefined };
240
+ settle({ available: true, version: stdout.split("\n")[0] ?? undefined });
221
241
  } else {
222
- cachedRgCheck = { available: false };
242
+ settle({ available: false });
223
243
  }
224
- resolve(cachedRgCheck);
225
244
  });
226
245
  } catch {
227
- if (settled) return;
228
- settled = true;
229
- cachedRgCheck = { available: false };
230
- resolve(cachedRgCheck);
246
+ settle({ available: false });
231
247
  }
232
248
  });
249
+ try {
250
+ return await inFlightRgCheck;
251
+ } finally {
252
+ inFlightRgCheck = undefined;
253
+ }
233
254
  }
234
255
 
235
256
  /** @internal Test-only: reset the ripgrep detection cache. */
236
257
  export function __test_resetRipgrepCache(): void {
237
258
  cachedRgCheck = undefined;
259
+ inFlightRgCheck = undefined;
238
260
  }
239
261
 
240
262
  /** @internal Test-only: reset the discovery cache. */
@@ -147,6 +147,11 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
147
147
  // branch. Leaving them undefined preserves those branches' behavior.
148
148
  let surfaceLost: TaskExecutionResult["surfaceLost"];
149
149
  let rawFinalText: string | undefined;
150
+ // Finding #2 (2026-09-29 battery): result-artifact provenance tag from the
151
+ // child-executor fallback chain — MUST be forwarded like surfaceLost/
152
+ // rawFinalText above, or finalizeTaskResult's deterministic
153
+ // stderr/none-source gate silently degrades to classifier-only.
154
+ let resultSource: TaskExecutionResult["resultSource"];
150
155
  if (runtimeKind === "child-process") {
151
156
  // CORE-5 extraction 4: the entire child-process branch (model routing +
152
157
  // model-fallback attempt loop, runWorker callbacks, R3 listener-leak
@@ -169,6 +174,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
169
174
  startupEvidence = child.startupEvidence;
170
175
  surfaceLost = child.surfaceLost;
171
176
  rawFinalText = child.rawFinalText;
177
+ resultSource = child.resultSource;
172
178
  } else if (runtimeKind === "live-session") {
173
179
  // LAZY: live-executor is only needed for live-session runtime branches.
174
180
  const { runLiveTask } = await import("./task-runner/live-executor.ts");
@@ -237,6 +243,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
237
243
  startupEvidence,
238
244
  surfaceLost,
239
245
  rawFinalText,
246
+ resultSource,
240
247
  };
241
248
  return await finalizeTaskResult(ctx, execResult);
242
249
  } finally {
@@ -1170,7 +1170,10 @@ async function executeTeamRunCore(
1170
1170
  // sync spin-lock (Atomics.wait) doesn't block the event loop every batch.
1171
1171
  // The terminal saveCrewAgents at closeout remains durable + flushes this.
1172
1172
  saveCrewAgentsCoalesced(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
1173
- const completedBatch = tasks.filter((t) => settledTaskIds.includes(t.id));
1173
+ // RR-021 WI-4.3a: Set lookup — settledTaskIds.includes() inside filter is
1174
+ // O(n*m) on every settled batch.
1175
+ const settledSet = new Set(settledTaskIds);
1176
+ const completedBatch = tasks.filter((t) => settledSet.has(t.id));
1174
1177
  const batchArtifact = writeArtifact(manifest.artifactsRoot, {
1175
1178
  kind: "summary",
1176
1179
  relativePath: `batches/${batchSummarySlug(settledTaskIds)}.md`,
@@ -66,6 +66,7 @@ export function checkWorktreeSandboxAvailable(cwd: string): { available: true; c
66
66
  cwd,
67
67
  stdio: ["ignore", "pipe", "pipe"],
68
68
  encoding: "utf-8",
69
+ timeout: 5000,
69
70
  }).trim();
70
71
  if (!toplevel)
71
72
  return {
@@ -77,6 +78,7 @@ export function checkWorktreeSandboxAvailable(cwd: string): { available: true; c
77
78
  cwd,
78
79
  stdio: ["ignore", "pipe", "pipe"],
79
80
  encoding: "utf-8",
81
+ timeout: 5000,
80
82
  }).trim();
81
83
  if (!commitSha)
82
84
  return {
@@ -92,6 +94,7 @@ export function checkWorktreeSandboxAvailable(cwd: string): { available: true; c
92
94
  cwd,
93
95
  stdio: ["ignore", "pipe", "pipe"],
94
96
  encoding: "utf-8",
97
+ timeout: 5000,
95
98
  }).trim();
96
99
  if (status.length > 0)
97
100
  return {
@@ -398,7 +398,7 @@ const sharedFields = {
398
398
  ),
399
399
  };
400
400
 
401
- // ─── Domain action unions (10+16+7+16+6 = 55 actions) ──────────────────────
401
+ // ─── Domain action unions (10+17+7+17+6 = 57 actions) ──────────────────────
402
402
 
403
403
  const ACTION_DESCRIPTION = "Team action. Defaults to 'list' when omitted.";
404
404
 
@@ -411,6 +411,7 @@ const STATUS_ACTIONS = [
411
411
  "get",
412
412
  "events",
413
413
  "artifacts",
414
+ "breakdown",
414
415
  "summary",
415
416
  "graph",
416
417
  "search",
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
4
  import { DEFAULT_MAILBOX } from "../../config/defaults.ts";
@@ -726,7 +727,9 @@ export function appendMailboxMessage(
726
727
  else ensureRunMailbox(manifest);
727
728
  const createdAt = new Date().toISOString();
728
729
  const complete: MailboxMessage = {
729
- id: message.id ?? `msg_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
730
+ // RR-021 WI-4.3j: randomUUID instead of Date.now()+Math.random() —
731
+ // collision-free under the msg_ prefix, no clock-ordering leakage.
732
+ id: message.id ?? `msg_${randomUUID()}`,
730
733
  runId: manifest.runId,
731
734
  direction: message.direction,
732
735
  from: message.from,
@@ -854,7 +857,9 @@ export async function appendMailboxMessageAsync(
854
857
  else ensureRunMailbox(manifest);
855
858
  const createdAt = new Date().toISOString();
856
859
  const complete: MailboxMessage = {
857
- id: message.id ?? `msg_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
860
+ // RR-021 WI-4.3j: randomUUID instead of Date.now()+Math.random() —
861
+ // collision-free under the msg_ prefix, no clock-ordering leakage.
862
+ id: message.id ?? `msg_${randomUUID()}`,
858
863
  runId: manifest.runId,
859
864
  direction: message.direction,
860
865
  from: message.from,
@@ -21,7 +21,7 @@ import {
21
21
  import { canTransitionRunStatus } from "../contracts.ts";
22
22
  import { withRunLock, withRunLockSync } from "../coordination/locks.ts";
23
23
  import { appendEvent } from "../event-log/event-log.ts";
24
- import type { TeamRunManifest, TeamTaskState } from "../types.ts";
24
+ import type { RunModelContext, TeamRunManifest, TeamTaskState } from "../types.ts";
25
25
  import { CURRENT_SCHEMA_VERSION } from "../types.ts";
26
26
  import { unregisterActiveRun } from "./active-run-registry.ts";
27
27
  import { extractTaskArray, loadTasksWithRecovery, loadTasksWithRecoveryAsync, quarantineCorruptFile } from "./manifest-io.ts";
@@ -438,6 +438,12 @@ export function createRunManifest(params: {
438
438
  * Defaults preserve current behavior (generated id, wall clock). */
439
439
  runId?: string;
440
440
  now?: () => Date;
441
+ /** Model routing snapshot (Finding #1, battery 2026-09-29): detached runs
442
+ * re-enter through background-runner with no ExtensionContext, so the
443
+ * caller's model override / inherited session model / auth-filtered
444
+ * catalogue must be persisted on the manifest at creation time.
445
+ * Omitted when undefined so old manifests stay byte-identical. */
446
+ modelContext?: RunModelContext;
441
447
  }): { manifest: TeamRunManifest; tasks: TeamTaskState[]; paths: RunPaths } {
442
448
  const paths = createRunPaths(params.cwd, params.runId);
443
449
  const now = (params.now ? params.now() : new Date()).toISOString();
@@ -462,6 +468,7 @@ export function createRunManifest(params: {
462
468
  ...(params.ownerSessionId ? { ownerSessionId: params.ownerSessionId } : {}),
463
469
  runKind: params.runKind ?? "team-run",
464
470
  ...(params.args !== undefined ? { args: params.args } : {}),
471
+ ...(params.modelContext ? { modelContext: params.modelContext } : {}),
465
472
  };
466
473
  fs.mkdirSync(paths.stateRoot, { recursive: true });
467
474
  fs.mkdirSync(paths.artifactsRoot, { recursive: true });
@@ -116,13 +116,16 @@ export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: R
116
116
  // One pinned clock (D6-T4) for the whole pane render.
117
117
  const nowMs = options.nowMs ?? Date.now();
118
118
 
119
- const realAgents = snapshot.agents.filter((a) =>
120
- isRealAgent(
121
- a,
122
- liveForRun.find((h) => h.taskId === a.taskId),
123
- nowMs,
124
- ),
125
- );
119
+ // RR-021 WI-4.3c: first-wins Map keyed by taskId — the two `.find` sites
120
+ // below (real-agent filter + per-agent handle) must keep seeing the SAME
121
+ // (first) matching handle, so the build guards with has() instead of set()
122
+ // overwriting later duplicates.
123
+ const liveByTaskId = new Map<string, (typeof liveForRun)[number]>();
124
+ for (const handle of liveForRun) {
125
+ if (!liveByTaskId.has(handle.taskId)) liveByTaskId.set(handle.taskId, handle);
126
+ }
127
+
128
+ const realAgents = snapshot.agents.filter((a) => isRealAgent(a, liveByTaskId.get(a.taskId), nowMs));
126
129
  const lineCount = Math.min(realAgents.length, 12);
127
130
  const label =
128
131
  realAgents.length !== snapshot.agents.length
@@ -135,7 +138,7 @@ export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: R
135
138
  lines.push(`${completed}/${total} tasks · ${label}`);
136
139
 
137
140
  for (const agent of realAgents.slice(0, 12)) {
138
- const liveHandle = liveForRun.find((h) => h.taskId === agent.taskId);
141
+ const liveHandle = liveByTaskId.get(agent.taskId);
139
142
  const icon = agentGlyph(agent.status, agent.taskId);
140
143
  const role = `${agent.role ?? "?"}`;
141
144
 
@@ -277,7 +277,7 @@ export function installInlinePanel(pi: ExtensionAPI, ctx: ExtensionContext, uiCo
277
277
  // (lazy import: viewers.ts pulls the state-store chain and must stay
278
278
  // off this module's startup path — AGENTS.md lazy boundary).
279
279
  onOpenBrowser: () => {
280
- void import("../../extension/registration/viewers.ts").then((m) => m.openAgentsJobsBrowser(ctx));
280
+ void import("../../extension/registration/viewers.ts").then((m) => m.openAgentsJobsBrowser(ctx)); // LAZY: defer agents-jobs browser until opened
281
281
  },
282
282
  });
283
283
  });
@@ -1,7 +1,20 @@
1
- import * as Diff from "diff";
1
+ import { createRequire } from "node:module";
2
+ import type { Change } from "diff";
2
3
  import type { CrewTheme } from "./theme-adapter.ts";
3
4
  import { asCrewTheme } from "./theme-adapter.ts";
4
5
 
6
+ // RR-021 WI-2.1: the `diff` package was eagerly imported module-scope with a
7
+ // wildcard (pulling the whole namespace into every UI load). Only diffWords is
8
+ // used at runtime — lazy sync accessor on first use (model: ui/syntax-highlight.ts).
9
+ // `Change` is a type-only import (erased at compile time, zero runtime cost).
10
+ const require = createRequire(import.meta.url);
11
+ type DiffModule = typeof import("diff");
12
+ let cachedDiff: DiffModule | undefined;
13
+ function diffModule(): DiffModule {
14
+ if (!cachedDiff) cachedDiff = require("diff") as DiffModule;
15
+ return cachedDiff;
16
+ }
17
+
5
18
  interface ParsedDiffLine {
6
19
  prefix: string;
7
20
  lineNum: string;
@@ -36,8 +49,8 @@ const WORD_DIFF_MIN_SIM = 0.15;
36
49
  * characters relative to the longer of the two strings, using word-level
37
50
  * diff to identify the common (unchanged) parts. Returns a value in [0, 1].
38
51
  */
39
- function computeSimilarity(oldContent: string, newContent: string, wordDiff?: Diff.Change[]): number {
40
- const parts = wordDiff ?? Diff.diffWords(oldContent, newContent);
52
+ function computeSimilarity(oldContent: string, newContent: string, wordDiff?: Change[]): number {
53
+ const parts = wordDiff ?? diffModule().diffWords(oldContent, newContent);
41
54
  let commonChars = 0;
42
55
  for (const part of parts) {
43
56
  if (!part.removed && !part.added) {
@@ -53,9 +66,9 @@ function renderIntraLineDiff(
53
66
  theme: CrewTheme,
54
67
  oldContent: string,
55
68
  newContent: string,
56
- wordDiff?: Diff.Change[],
69
+ wordDiff?: Change[],
57
70
  ): { removedLine: string; addedLine: string } {
58
- const parts = wordDiff ?? Diff.diffWords(oldContent, newContent);
71
+ const parts = wordDiff ?? diffModule().diffWords(oldContent, newContent);
59
72
  let removedLine = "";
60
73
  let addedLine = "";
61
74
  let isFirstRemoved = true;
@@ -137,7 +150,7 @@ export function renderDiff(diffText: string, options: RenderDiffOptions = {}): s
137
150
  const newContent = replaceTabs(addedLines[0]!.content);
138
151
  // PERF (2026-08-24): diffWords is the expensive call and was run
139
152
  // TWICE on identical inputs (similarity + render). One pass, shared.
140
- const wordDiff = Diff.diffWords(oldContent, newContent);
153
+ const wordDiff = diffModule().diffWords(oldContent, newContent);
141
154
  const similarity = computeSimilarity(oldContent, newContent, wordDiff);
142
155
  if (similarity >= WORD_DIFF_MIN_SIM) {
143
156
  const { removedLine, addedLine } = renderIntraLineDiff(theme, oldContent, newContent, wordDiff);
@@ -248,7 +248,17 @@ function readProgressPreview(run: TeamRunManifest, maxLines = 5, snapshotCache?:
248
248
  if (snapshotCache) return ["Progress: (loading…)"];
249
249
  // Legacy fallback: tests/dev paths without a snapshot cache still read the
250
250
  // progress artifact directly so existing assertions keep working.
251
- const progress = [...run.artifacts].reverse().find((artifact) => artifact.kind === "progress");
251
+ // RR-021 WI-4.3e: allocation-free reverse scan — the old
252
+ // [...run.artifacts].reverse().find() copied the whole array per render.
253
+ // (findLast needs an ES2023 lib bump, which breaks tsgo's RequestInfo
254
+ // ambient resolution — see WI-4.3 evidence note.)
255
+ let progress: (typeof run.artifacts)[number] | undefined;
256
+ for (let i = run.artifacts.length - 1; i >= 0; i--) {
257
+ if (run.artifacts[i]?.kind === "progress") {
258
+ progress = run.artifacts[i];
259
+ break;
260
+ }
261
+ }
252
262
  if (!progress) return ["Progress: (none)"];
253
263
  try {
254
264
  const progressPath = resolveRealContainedPath(run.artifactsRoot, progress.path);
@@ -692,9 +692,15 @@ async function mailboxFromAsync(manifest: TeamRunManifest, agents: CrewAgentReco
692
692
  }
693
693
 
694
694
  function cancellationReasonFromEvents(events: TeamEvent[]): string | undefined {
695
- return [...events].reverse().find((event) => event.type === "run.cancelled" && typeof event.data?.reason === "string")?.data?.reason as
696
- | string
697
- | undefined;
695
+ // RR-021 WI-4.3e: allocation-free reverse scan — the old
696
+ // [...events].reverse().find() copied the whole array on every call.
697
+ // (findLast needs an ES2023 lib bump, which breaks tsgo's RequestInfo
698
+ // ambient resolution — see WI-4.3 evidence note.)
699
+ for (let i = events.length - 1; i >= 0; i--) {
700
+ const event = events[i];
701
+ if (event?.type === "run.cancelled" && typeof event.data?.reason === "string") return event.data.reason;
702
+ }
703
+ return undefined;
698
704
  }
699
705
 
700
706
  type SliceSignatures = NonNullable<RunUiSnapshot["sliceSignatures"]>;
@@ -126,10 +126,12 @@ function runningSuffix(task: TeamTaskState): string {
126
126
 
127
127
  /** `› blocked by #2, #3` for queued tasks — open (non-completed)
128
128
  * dependencies only, named by task number (pi-tasks display rule). */
129
- function blockedSuffix(task: TeamTaskState, tasks: readonly TeamTaskState[], numberById: Map<string, number>): string {
129
+ function blockedSuffix(task: TeamTaskState, taskById: Map<string, TeamTaskState>, numberById: Map<string, number>): string {
130
130
  if (task.status !== "queued") return "";
131
+ // RR-021 WI-4.3b: Map lookup — tasks.find() per dependency was O(deps×tasks)
132
+ // on every queued row render.
131
133
  const openDeps = (task.dependsOn ?? [])
132
- .map((depId) => tasks.find((entry) => entry.id === depId))
134
+ .map((depId) => taskById.get(depId))
133
135
  .filter((dep): dep is TeamTaskState => dep !== undefined && dep.status !== "completed");
134
136
  if (openDeps.length === 0) return "";
135
137
  const labels = openDeps.map((dep) => `#${numberById.get(dep.id) ?? "?"}`);
@@ -139,9 +141,9 @@ function blockedSuffix(task: TeamTaskState, tasks: readonly TeamTaskState[], num
139
141
  /** One line per task: `#n title` behind its status glyph — pi-tasks style.
140
142
  * Completed titles are dimmed and struck through. Returns the ROW CONTENT:
141
143
  * the rail glyph (`┃ `) is owned by `railLine`. */
142
- function taskRow(task: TeamTaskState, taskNumber: number, tasks: readonly TeamTaskState[], numberById: Map<string, number>): string {
144
+ function taskRow(task: TeamTaskState, taskNumber: number, taskById: Map<string, TeamTaskState>, numberById: Map<string, number>): string {
143
145
  const title = taskTitle(task);
144
- const suffix = task.status === "running" ? runningSuffix(task) : blockedSuffix(task, tasks, numberById);
146
+ const suffix = task.status === "running" ? runningSuffix(task) : blockedSuffix(task, taskById, numberById);
145
147
  if (task.status === "completed") {
146
148
  const struck = `${STRIKE_ON}${DIM_ON}#${taskNumber} ${title}${DIM_OFF}${STRIKE_OFF}`;
147
149
  return `${taskStatusIcon(task)} ${struck}`;
@@ -182,6 +184,7 @@ export function buildTaskListLines(runs: readonly WidgetRun[], width: number, th
182
184
 
183
185
  const budget = Math.max(8, width - 2);
184
186
  const numberById = new Map(tasks.map((task, index) => [task.id, index + 1]));
187
+ const taskById = new Map(tasks.map((task) => [task.id, task]));
185
188
  const done = tasks.filter((task) => task.status === "completed").length;
186
189
  const dead = tasks.filter((task) => isDoneStatus(task.status) && task.status !== "completed").length;
187
190
  const inProgress = tasks.filter(
@@ -211,7 +214,7 @@ export function buildTaskListLines(runs: readonly WidgetRun[], width: number, th
211
214
 
212
215
  const visible = visibleTasks(tasks);
213
216
  for (const task of visible) {
214
- lines.push(railLine(RAIL.body, "border", taskRow(task, numberById.get(task.id) ?? 0, tasks, numberById), theme, budget));
217
+ lines.push(railLine(RAIL.body, "border", taskRow(task, numberById.get(task.id) ?? 0, taskById, numberById), theme, budget));
215
218
  }
216
219
  const hidden = tasks.length - visible.length;
217
220
  if (hidden > 0) {
@@ -46,7 +46,9 @@ export function activeWidgetRuns(
46
46
  if (now - lastStaleReconcileAt > STALE_RECONCILE_INTERVAL_MS && manifestCache) {
47
47
  lastStaleReconcileAt = now;
48
48
  try {
49
- reconcileAllStaleRuns(cwd, manifestCache, Date.now(), workspaceId);
49
+ void reconcileAllStaleRuns(cwd, manifestCache, Date.now(), workspaceId).catch(() => {
50
+ /* non-critical */
51
+ });
50
52
  } catch {
51
53
  /* non-critical */
52
54
  }
package/tsconfig.json CHANGED
@@ -9,6 +9,8 @@
9
9
  "skipLibCheck": true,
10
10
  "allowImportingTsExtensions": true,
11
11
  "noEmit": true,
12
+ "incremental": true,
13
+ "tsBuildInfoFile": "node_modules/.cache/pi-crew/tsconfig.tsbuildinfo",
12
14
  "types": ["node"],
13
15
  "verbatimModuleSyntax": true,
14
16
  "noUncheckedSideEffectImports": true