pi-crew 0.9.8 → 0.9.10

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 (47) hide show
  1. package/CHANGELOG.md +311 -0
  2. package/README.md +2 -2
  3. package/docs/fixes/v0.9.10/locks-fix-verify.md +3 -0
  4. package/docs/fixes/v0.9.10/smoke-test.md +12 -0
  5. package/package.json +1 -1
  6. package/src/extension/register.ts +94 -21
  7. package/src/extension/registration/subagent-helpers.ts +1 -0
  8. package/src/extension/registration/subagent-tools.ts +9 -0
  9. package/src/extension/team-tool/doctor.ts +41 -18
  10. package/src/runtime/batch-barrier.ts +145 -0
  11. package/src/runtime/child-pi.ts +135 -22
  12. package/src/runtime/compact-pipeline.ts +56 -0
  13. package/src/runtime/compact-stages/ansi-strip-stage.ts +25 -0
  14. package/src/runtime/compact-stages/blank-collapse-stage.ts +31 -0
  15. package/src/runtime/compact-stages/deduplicate-stage.ts +34 -0
  16. package/src/runtime/compact-stages/head-snap-stage.ts +57 -0
  17. package/src/runtime/compact-stages/index.ts +13 -0
  18. package/src/runtime/compact-stages/tail-capture-stage.ts +72 -0
  19. package/src/runtime/compact-stages/truncation-stage.ts +71 -0
  20. package/src/runtime/crash-classification.ts +208 -0
  21. package/src/runtime/custom-tools/irc-tool.ts +47 -7
  22. package/src/runtime/handoff-manager.ts +10 -0
  23. package/src/runtime/important-line-classifier.ts +130 -0
  24. package/src/runtime/iteration-hooks.ts +7 -19
  25. package/src/runtime/live-agent-manager.ts +185 -0
  26. package/src/runtime/live-session-runtime.ts +50 -1
  27. package/src/runtime/model-fallback.ts +29 -1
  28. package/src/runtime/process-lifecycle.ts +481 -0
  29. package/src/runtime/role-permission.ts +2 -2
  30. package/src/runtime/stream-preview.ts +9 -2
  31. package/src/runtime/subagent-manager.ts +6 -0
  32. package/src/runtime/task-output-context.ts +209 -24
  33. package/src/runtime/task-runner.ts +76 -15
  34. package/src/runtime/tool-output-pruner.ts +334 -0
  35. package/src/state/locks.ts +16 -0
  36. package/src/state/state-store.ts +8 -2
  37. package/src/state/types.ts +5 -0
  38. package/src/ui/live-run-sidebar.ts +6 -1
  39. package/src/ui/loaders.ts +24 -4
  40. package/src/ui/run-dashboard.ts +6 -1
  41. package/src/ui/run-event-bus.ts +1 -1
  42. package/src/ui/run-snapshot-cache.ts +50 -16
  43. package/src/ui/widget/index.ts +27 -5
  44. package/src/ui/widget/widget-renderer.ts +43 -13
  45. package/src/utils/redaction.ts +17 -1
  46. package/src/utils/visual.ts +6 -0
  47. package/src/ui/crew-widget.ts +0 -544
@@ -4,6 +4,9 @@ import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../stat
4
4
  import { writeArtifact } from "../state/artifact-store.ts";
5
5
  import { resolveRealContainedPath } from "../utils/safe-paths.ts";
6
6
  import type { WorkflowStep } from "../workflows/workflow-config.ts";
7
+ import { pruneToolOutputs, type ToolResultEntry, type FileEditEvent, DEFAULT_PRUNE_CONFIG } from "./tool-output-pruner.ts";
8
+ import { applyCompactPipeline } from "./compact-pipeline.ts";
9
+ import { ANSI_STRIP_STAGE, BLANK_COLLAPSE_STAGE, TruncationStage } from "./compact-stages/index.ts";
7
10
 
8
11
  export interface DependencyContextEntry {
9
12
  taskId: string;
@@ -18,7 +21,14 @@ export interface DependencyContextEntry {
18
21
 
19
22
  export interface DependencyOutputContext {
20
23
  dependencies: DependencyContextEntry[];
21
- sharedReads: Array<{ name: string; path: string; content: string }>;
24
+ /**
25
+ * Each shared artifact read, truncated for inline injection. When truncation
26
+ * is materially lossy (file size > 2× MAX_RESULT_INLINE_BYTES) the FULL
27
+ * content is also teed to `${artifactsRoot}/tee/${taskId}-${name}.full.txt`
28
+ * and the path is exposed via `fullOutputPath` so the downstream worker
29
+ * can `read` it back if it needs the dropped middle.
30
+ */
31
+ sharedReads: Array<{ name: string; path: string; content: string; fullOutputPath?: string }>;
22
32
  }
23
33
 
24
34
  function containedExists(filePath: string, baseDir?: string): boolean {
@@ -38,35 +48,127 @@ function containedExists(filePath: string, baseDir?: string): boolean {
38
48
  * (24K/40K/80K) which truncated the same artifact differently depending on
39
49
  * which code path read it.
40
50
  */
41
- const MAX_RESULT_INLINE_BYTES = 32_000;
51
+ export const MAX_RESULT_INLINE_BYTES = 32_000;
42
52
 
43
- function readIfSmall(filePath: string, baseDir?: string): string | undefined {
44
- const maxBytes = MAX_RESULT_INLINE_BYTES;
53
+ /**
54
+ * Read a file and return its content, truncating to a head+tail slice if it
55
+ * exceeds {@link MAX_RESULT_INLINE_BYTES} characters. Multi-byte UTF-8
56
+ * sequences are preserved by reading the full file as a UTF-8 string and
57
+ * slicing by character count (not raw bytes).
58
+ */
59
+ export interface TeeRecoveryOptions {
60
+ /** Absolute path to write the full (non-truncated) content to. */
61
+ fullOutputPath: string;
62
+ }
63
+
64
+ export interface ReadIfSmallTeeResult {
65
+ /** Truncated content (or full content when no truncation). */
66
+ content: string;
67
+ /** Set only when tee was actually written (file size > 2× threshold + write succeeded). */
68
+ fullOutputPath?: string;
69
+ }
70
+
71
+ /**
72
+ * Sanitize a taskId / artifactName into a flat tee filename. Any character
73
+ * outside [A-Za-z0-9._-] is replaced with underscore so the resulting path
74
+ * is always single-segment and cannot escape the tee directory.
75
+ */
76
+ function safeTeeName(taskId: string, artifactName: string): string {
77
+ const safe = (s: string): string => s.replace(/[^A-Za-z0-9._-]/g, "_");
78
+ return `${safe(taskId)}-${safe(artifactName)}.full.txt`;
79
+ }
80
+
81
+ /**
82
+ * Canonical tee path for a shared artifact read.
83
+ *
84
+ * Format: `${artifactsRoot}/tee/${taskId}-${artifactName}.full.txt`
85
+ *
86
+ * The downstream worker prompt includes this path so the worker can `read`
87
+ * the full content when it needs the dropped middle.
88
+ */
89
+ export function teePathForArtifact(artifactsRoot: string, taskId: string, artifactName: string): string {
90
+ return path.join(artifactsRoot, "tee", safeTeeName(taskId, artifactName));
91
+ }
92
+
93
+ /**
94
+ * Best-effort tee write. Returns true on success, false on any error (write
95
+ * failures are silent — tee is enhancement, never a hard dependency). The
96
+ * truncated inline content is still returned by the caller either way.
97
+ */
98
+ function writeTeeFile(fullOutputPath: string, content: string): boolean {
45
99
  try {
46
- const safePath = baseDir ? resolveRealContainedPath(baseDir, filePath) : filePath;
47
- const stat = fs.statSync(safePath);
48
- if (stat.size > maxBytes) {
49
- // L4: head + tail instead of head-only. Keeps closing markdown
50
- // structure (code fences, headings) instead of leaving them truncated.
51
- const head = Math.floor(maxBytes * 0.75);
52
- const tail = maxBytes - head;
53
- const headBuf = Buffer.alloc(head);
54
- const tailBuf = Buffer.alloc(tail);
55
- const fd = fs.openSync(safePath, "r");
56
- try {
57
- fs.readSync(fd, headBuf, 0, head, 0);
58
- fs.readSync(fd, tailBuf, 0, tail, stat.size - tail);
59
- } finally {
60
- fs.closeSync(fd);
100
+ fs.mkdirSync(path.dirname(fullOutputPath), { recursive: true });
101
+ fs.writeFileSync(fullOutputPath, content, "utf-8");
102
+ return true;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Read a file with optional tee-recovery (P1-A). Returns the truncated
110
+ * content AND (when tee was actually written) the absolute path to the full
111
+ * file. Returns undefined if the file cannot be read at all.
112
+ *
113
+ * Tee threshold: only when content.length > 2 * MAX_RESULT_INLINE_BYTES
114
+ * (the head+tail is materially lossy — small over-threshold files are not
115
+ * teed because the inline content is mostly intact and the worker can live
116
+ * with the 75/25 split). File content is read once and reused for both the
117
+ * pipeline (truncation) and the tee write (full file).
118
+ *
119
+ * Truncation behavior is unchanged from the P0-A pipeline: ANSI strip +
120
+ * blank collapse BEFORE truncation, important-line preservation (P0-B)
121
+ * inside TruncationStage, marker wording matches the pre-P1-A `readIfSmall`
122
+ * output exactly (L4 backward-compat).
123
+ */
124
+ export function readIfSmallWithTee(
125
+ filePath: string,
126
+ opts: { baseDir?: string; tee?: TeeRecoveryOptions } = {},
127
+ ): ReadIfSmallTeeResult | undefined {
128
+ const maxChars = MAX_RESULT_INLINE_BYTES;
129
+ try {
130
+ const safePath = opts.baseDir ? resolveRealContainedPath(opts.baseDir, filePath) : filePath;
131
+ const content = fs.readFileSync(safePath, "utf-8");
132
+ if (content.length > maxChars) {
133
+ let fullOutputPath: string | undefined;
134
+ // Tee only when truncation is materially lossy (>2× threshold).
135
+ if (opts.tee && content.length > maxChars * 2) {
136
+ if (writeTeeFile(opts.tee.fullOutputPath, content)) {
137
+ fullOutputPath = opts.tee.fullOutputPath;
138
+ }
61
139
  }
62
- return `${headBuf.toString("utf-8")}\n\n...[pi-crew truncated ${stat.size - maxBytes} bytes, head+tail preserved]...\n${tailBuf.toString("utf-8")}`;
140
+ const result = applyCompactPipeline(content, [
141
+ ANSI_STRIP_STAGE,
142
+ BLANK_COLLAPSE_STAGE,
143
+ new TruncationStage(maxChars, {
144
+ preserveImportant: true,
145
+ marker: { verb: "truncated", unit: "chars", headSeparator: "\n\n", tailSeparator: "\n" },
146
+ }),
147
+ ]);
148
+ return fullOutputPath ? { content: result.text, fullOutputPath } : { content: result.text };
63
149
  }
64
- return fs.readFileSync(safePath, "utf-8");
150
+ return { content };
65
151
  } catch {
66
152
  return undefined;
67
153
  }
68
154
  }
69
155
 
156
+ /**
157
+ * Read a file and return its content, truncating to a head+tail slice if it
158
+ * exceeds {@link MAX_RESULT_INLINE_BYTES} characters. Multi-byte UTF-8
159
+ * sequences are preserved by reading the full file as a UTF-8 string and
160
+ * slicing by character count (not raw bytes).
161
+ *
162
+ * Thin wrapper around {@link readIfSmallWithTee} for backward compatibility
163
+ * — callers that do not need tee-recovery metadata get just the content
164
+ * string. New tee-recovery call sites should use {@link readIfSmallWithTee}
165
+ * directly so they can include the full output path in the worker prompt.
166
+ */
167
+ export function readIfSmall(filePath: string, baseDir?: string): string | undefined {
168
+ const result = readIfSmallWithTee(filePath, { baseDir });
169
+ return result?.content;
170
+ }
171
+
70
172
  function safeSharedName(name: string): string {
71
173
  const normalized = name.replaceAll("\\", "/").replace(/^\.\/+/, "");
72
174
  if (!normalized || normalized.split("/").some((segment) => segment === "..") || path.isAbsolute(normalized)) throw new Error(`Invalid shared artifact name: ${name}`);
@@ -111,6 +213,56 @@ function aggregateUsage(task: TeamTaskState): DependencyContextEntry["usage"] {
111
213
  return { inputTokens, outputTokens, durationMs };
112
214
  }
113
215
 
216
+ /**
217
+ * Apply staleness-aware pruning to shared reads before they are injected
218
+ * into a downstream worker's prompt. Converts shared reads to generic
219
+ * {@link ToolResultEntry}s (toolName="read") and file edits from dependency
220
+ * artifacts, then delegates to {@link pruneToolOutputs}. Superseded reads
221
+ * (same base file re-read, or file edited by a later dependency) are replaced
222
+ * with compact digest notices, reducing context bloat.
223
+ *
224
+ * OPT-IN: the default prune config protects recent results and only fires
225
+ * when minimum-savings hysteresis is met, so small/unique reads pass through
226
+ * unchanged.
227
+ */
228
+ function pruneSharedReads(
229
+ reads: Array<{ name: string; path: string; content: string }>,
230
+ dependencies: DependencyContextEntry[],
231
+ artifactsRoot: string,
232
+ ): Array<{ name: string; path: string; content: string }> {
233
+ if (reads.length === 0) return reads;
234
+ // Convert shared reads to tool result entries (ordered oldest → newest
235
+ // by position in the reads array — earlier entries are "older").
236
+ const entries: ToolResultEntry[] = reads.map((read, index) => ({
237
+ id: `shared-read-${index}`,
238
+ toolName: "read",
239
+ target: read.path,
240
+ content: read.content,
241
+ }));
242
+ // Collect file edit events from dependency artifacts produced to shared/.
243
+ // A dependency that wrote a shared file after an earlier read invalidates
244
+ // that read (the content is now stale relative to the latest version).
245
+ // Artifact entries from listTaskArtifacts() are already relative to
246
+ // artifactsRoot (e.g. "shared/foo.md"), so resolve directly against
247
+ // artifactsRoot — NOT against a "shared" subdirectory (which would
248
+ // double-prefix to <artifactsRoot>/shared/shared/foo.md).
249
+ const fileEdits: FileEditEvent[] = [];
250
+ for (let depIndex = 0; depIndex < dependencies.length; depIndex++) {
251
+ const dep = dependencies[depIndex]!;
252
+ const produced = dep.artifactsProduced ?? [];
253
+ for (const artifact of produced) {
254
+ if (typeof artifact !== "string") continue;
255
+ // Map artifact path (relative to artifactsRoot) to absolute and
256
+ // check against read targets.
257
+ fileEdits.push({ target: path.resolve(artifactsRoot, artifact), index: reads.length + depIndex });
258
+ }
259
+ }
260
+ const pruned = pruneToolOutputs(entries, DEFAULT_PRUNE_CONFIG);
261
+ if (pruned.prunedCount === 0) return reads;
262
+ // Map pruned entries back to the shared-read shape.
263
+ return pruned.results.map((entry, index) => ({ ...reads[index]!, content: entry.content }));
264
+ }
265
+
114
266
  export function collectDependencyOutputContext(manifest: TeamRunManifest, tasks: TeamTaskState[], task: TeamTaskState, step: WorkflowStep): DependencyOutputContext {
115
267
  const byStep = new Map(tasks.map((item) => [item.stepId, item]).filter((entry): entry is [string, TeamTaskState] => Boolean(entry[0])));
116
268
  const byId = new Map(tasks.map((item) => [item.id, item]));
@@ -127,10 +279,35 @@ export function collectDependencyOutputContext(manifest: TeamRunManifest, tasks:
127
279
  usage: aggregateUsage(item),
128
280
  };
129
281
  });
130
- const sharedReads = (step.reads === false ? [] : step.reads ?? []).map((name) => {
282
+ const rawSharedReads = (step.reads === false ? [] : step.reads ?? []).map((name) => {
131
283
  const filePath = sharedPath(manifest, name);
132
- return { name, path: filePath, content: readIfSmall(filePath, path.resolve(manifest.artifactsRoot, "shared")) ?? "" };
284
+ // P1-A tee-recovery: when the shared artifact is large enough that the
285
+ // 75/25 head+tail split is materially lossy (>2× MAX_RESULT_INLINE_BYTES),
286
+ // tee the full content to ${artifactsRoot}/tee/${taskId}-${name}.full.txt
287
+ // and expose the path so the downstream worker can `read` the full file
288
+ // if it needs the dropped middle. The truncated content is still
289
+ // included inline; tee is an enhancement, not a hard dependency. Tee
290
+ // write is best-effort (writeTeeFile swallows I/O errors and the result
291
+ // simply omits fullOutputPath in that case).
292
+ const teePath = teePathForArtifact(manifest.artifactsRoot, task.id, name);
293
+ const teeResult = readIfSmallWithTee(filePath, {
294
+ baseDir: path.resolve(manifest.artifactsRoot, "shared"),
295
+ tee: { fullOutputPath: teePath },
296
+ });
297
+ if (teeResult === undefined) return { name, path: filePath, content: "" };
298
+ const entry: { name: string; path: string; content: string; fullOutputPath?: string } = {
299
+ name,
300
+ path: filePath,
301
+ content: teeResult.content,
302
+ };
303
+ if (teeResult.fullOutputPath) entry.fullOutputPath = teeResult.fullOutputPath;
304
+ return entry;
133
305
  }).filter((item) => item.content.trim().length > 0);
306
+ // Apply staleness-aware pruning to shared reads: drops superseded reads
307
+ // (same file re-read with different selectors) and replaces stale large
308
+ // outputs with compact digest notices before injecting into the worker
309
+ // prompt. OPT-IN: default config protects recent results.
310
+ const sharedReads = pruneSharedReads(rawSharedReads, dependencies, manifest.artifactsRoot);
134
311
  return { dependencies, sharedReads };
135
312
  }
136
313
 
@@ -147,7 +324,15 @@ export function renderDependencyOutputContext(context: DependencyOutputContext):
147
324
  }
148
325
  if (context.sharedReads.length) {
149
326
  parts.push("# Shared Run Context Reads", "");
150
- for (const read of context.sharedReads) parts.push(`## shared/${read.name}`, `Path: ${read.path}`, "", read.content.trim(), "");
327
+ for (const read of context.sharedReads) {
328
+ parts.push(`## shared/${read.name}`, `Path: ${read.path}`);
329
+ // P1-A tee-recovery hint: when the file was materially truncated
330
+ // (>2× threshold) the full content was teed to fullOutputPath so the
331
+ // worker can read the dropped middle if needed. The path is inside
332
+ // artifactsRoot/tee/ and goes through the normal permission gate.
333
+ if (read.fullOutputPath) parts.push(`Full output (if you need the missing middle): ${read.fullOutputPath}`);
334
+ parts.push("", read.content.trim(), "");
335
+ }
151
336
  }
152
337
  return parts.join("\n").trim();
153
338
  }
@@ -763,7 +763,30 @@ export async function runTeamTask(
763
763
  "",
764
764
  );
765
765
  if (!error) break;
766
- const nextModel = attemptModels[i + 1];
766
+ let nextModel = attemptModels[i + 1];
767
+ // FIX 1 (task packet 01_01-agent): when the precomputed attempt
768
+ // chain is exhausted but the failure is retryable, do a one-shot
769
+ // re-resolve via buildConfiguredModelRouting with the failed
770
+ // model as parent. This finds alternative providers/models the
771
+ // original chain missed (e.g. a registry gained new fallbacks
772
+ // after the precompute, or the precompute ran before the parent
773
+ // model was known). If a different candidate is found, use it as
774
+ // nextModel; otherwise fall through to the existing break.
775
+ if (!nextModel && isRetryableModelFailure(error)) {
776
+ const reResolved = buildConfiguredModelRouting({
777
+ overrideModel: undefined,
778
+ stepModel: undefined,
779
+ teamRoleModel: undefined,
780
+ agentModel: undefined,
781
+ fallbackModels: undefined,
782
+ parentModel: attempt.model,
783
+ modelRegistry: input.modelRegistry,
784
+ cwd: task.cwd,
785
+ scopeModelsPatterns: await resolveTaskScopeModelsPatterns(task.cwd),
786
+ });
787
+ const alt = reResolved.candidates.find((c) => c !== attempt.model);
788
+ if (alt) nextModel = alt;
789
+ }
767
790
  if (!nextModel || !isRetryableModelFailure(error)) break;
768
791
  logs.push(formatModelAttemptNote(attempt, nextModel), "");
769
792
  }
@@ -1368,19 +1391,57 @@ async function resolveTaskScopeModelsPatterns(cwd: string): Promise<string[]> {
1368
1391
  * or when there are no retryable error messages.
1369
1392
  */
1370
1393
  export function detectRetryableModelFailureFromOutput(parsed: ParsedPiJsonOutput): string | undefined {
1394
+ // Primary signal: pre-extracted `errorMessages` (from pi-json-output parser).
1395
+ // The parser already filters to non-empty trimmed strings from message_end
1396
+ // events.
1371
1397
  const messages = parsed.errorMessages;
1372
- if (!messages || messages.length === 0) return undefined;
1373
- // Find the first retryable model-failure message (429 / rate-limit / overloaded / 5xx / ...).
1374
- const retryable = messages.find((m) => isRetryableModelFailure(m));
1375
- if (!retryable) return undefined;
1376
- // Did the run actually produce real output despite the transient errors?
1377
- // If finalText / textEvents / patches exist, the model recovered and we
1378
- // should NOT mark the run as failed only flag it when the worker yielded
1379
- // nothing (the 429-only case from the bug report).
1380
- const hasRealOutput =
1381
- (parsed.finalText?.trim().length ?? 0) > 0 ||
1382
- parsed.textEvents.some((t) => t.trim().length > 0) ||
1383
- (parsed.patches?.length ?? 0) > 0;
1384
- if (hasRealOutput) return undefined;
1385
- return `Model returned only retryable errors and no output: ${retryable}`;
1398
+ if (messages && messages.length > 0) {
1399
+ // Find the first retryable model-failure message
1400
+ // (429 / rate-limit / overloaded / 5xx / ...).
1401
+ const retryable = messages.find((m) => isRetryableModelFailure(m));
1402
+ if (retryable) {
1403
+ // Did the run actually produce real output despite the transient errors?
1404
+ // If finalText / textEvents / patches exist, the model recovered and we
1405
+ // should NOT mark the run as failed — only flag it when the worker
1406
+ // yielded nothing (the 429-only case from the bug report).
1407
+ const hasRealOutput =
1408
+ (parsed.finalText?.trim().length ?? 0) > 0 ||
1409
+ parsed.textEvents.some((t) => t.trim().length > 0) ||
1410
+ (parsed.patches?.length ?? 0) > 0;
1411
+ if (hasRealOutput) return undefined;
1412
+ return `Model returned only retryable errors and no output: ${retryable}`;
1413
+ }
1414
+ }
1415
+ // Secondary signal (FIX 3, task packet 01_01-agent): inspect a raw
1416
+ // `messageEndEvents` (or `transcript`) array on the parsed output. The
1417
+ // ParsedPiJsonOutput type does not currently declare this field, so we
1418
+ // read it through a local extension cast. Callers that pass it (tests, a
1419
+ // future parser that captures the full event stream) get a second chance
1420
+ // to surface retryable failures. Primary path still wins when it matches.
1421
+ const raw = parsed as ParsedPiJsonOutput & {
1422
+ messageEndEvents?: unknown;
1423
+ transcript?: unknown;
1424
+ };
1425
+ const eventSource = Array.isArray(raw.messageEndEvents)
1426
+ ? raw.messageEndEvents
1427
+ : Array.isArray(raw.transcript)
1428
+ ? raw.transcript
1429
+ : undefined;
1430
+ if (!eventSource || eventSource.length === 0) return undefined;
1431
+ for (const candidate of eventSource) {
1432
+ if (!candidate || typeof candidate !== "object") continue;
1433
+ const event = candidate as { stopReason?: unknown; errorMessage?: unknown };
1434
+ if (event.stopReason !== "error") continue;
1435
+ if (typeof event.errorMessage !== "string" || event.errorMessage.length === 0) continue;
1436
+ if (!isRetryableModelFailure(event.errorMessage)) continue;
1437
+ // Same real-output gate as the primary signal — don't flag runs that
1438
+ // recovered with real final text / patches.
1439
+ const hasRealOutput =
1440
+ (parsed.finalText?.trim().length ?? 0) > 0 ||
1441
+ parsed.textEvents.some((t) => t.trim().length > 0) ||
1442
+ (parsed.patches?.length ?? 0) > 0;
1443
+ if (hasRealOutput) return undefined;
1444
+ return `Model returned only retryable errors and no output: ${event.errorMessage}`;
1445
+ }
1446
+ return undefined;
1386
1447
  }