pi-crew 0.8.12 → 0.8.14

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.
package/CHANGELOG.md CHANGED
@@ -2590,3 +2590,66 @@ correctness+error-handling, and performance+architecture audits across 77 source
2590
2590
  - TypeScript: 0 errors
2591
2591
  - Skills: 37/37 PASS
2592
2592
  - New modules: 11 files, 2,267 LOC
2593
+
2594
+ ## [0.8.13] — user-scope cleanup + install side-effects warning (Issue #35) (2026-06-18)
2595
+
2596
+ Follow-up to issue #35's latest comment ("pi-crew leaves behind user-level
2597
+ junk"). Two of the three points raised were valid; both addressed.
2598
+
2599
+ ### `team action=cleanup scope=user` — new user-level cleanup mode
2600
+ Removes pi-crew user-scope state that `pi uninstall npm:pi-crew` leaves behind:
2601
+ - `~/.pi/agent/extensions/pi-crew/` — pi-crew runtime state (artifacts, state,
2602
+ config.json). Regenerable, always removed.
2603
+ - `~/.pi/agent/agents/*.md.bak-<timestamp>-<hex>` — smoke-test backup junk
2604
+ pi-crew's own tests leave behind. NEVER touches real `*.md` agent files
2605
+ (pi-crew can't tell user-authored vs test-copied — only the timestamped
2606
+ `.bak-*` pattern is removed).
2607
+ - `~/.pi/agent/pi-crew.json` — global config. Gated on `force=true` (may hold
2608
+ your customized settings).
2609
+
2610
+ `dryRun=true` previews; safe by default. Routes via the new `scope=user` flag
2611
+ on `team action=cleanup`.
2612
+
2613
+ ### Install side-effects warning (install.mjs)
2614
+ The postinstall script now prints an explicit "What pi-crew writes (and how to
2615
+ undo it)" block: AGENTS.md injection (marker-delimited, on `init` only),
2616
+ `.crew/` runtime dir, the global config created at install, and the full
2617
+ uninstall command sequence (project + user + `pi uninstall`). Nothing is hidden
2618
+ behind install — be upfront about side effects.
2619
+
2620
+ ### README Uninstall section expanded
2621
+ Split into Project scope + User scope subsections, with the full 6-step
2622
+ uninstall flow and a note that authored agent files are never touched.
2623
+
2624
+ ### On the third claim (hijacks pi-intercom)
2625
+ Still not reproduced. Verified a third time: `grep -rni pi-intercom src/` → 0
2626
+ references. `crew-input-router.ts:11` passes slash commands through unchanged.
2627
+ The reply on the issue asks again for a concrete repro.
2628
+
2629
+ typecheck clean; +6 user-scope cleanup tests + 1 routing test update; full
2630
+ suite 2963/0.
2631
+
2632
+ ## [0.8.14] — stop injecting AGENTS.md on init (Issue #35, redundant) (2026-06-18)
2633
+
2634
+ `team action=init` no longer writes a guidance block into the project's
2635
+ `AGENTS.md`. AGENTS.md is the USER's project-instructions file (Pi loads it as
2636
+ project guidance), and the injected block was **redundant**: the `team` tool
2637
+ already self-describes via its tool registration (`description` + `promptSnippet`
2638
+ in `src/extension/registration/team-tool.ts:63-64`), which Pi injects into the
2639
+ agent's system prompt every session. So agents still learn pi-crew's commands —
2640
+ from the tool, not AGENTS.md.
2641
+
2642
+ - Removed `injectGuidance(AGENTS.md, ...)` call from `initializeProject()`
2643
+ (`src/extension/project-init.ts`).
2644
+ - Removed `guidancePath` / `guidanceModified` from `ProjectInitResult`.
2645
+ - Removed now-unused `getPackageVersion()` + the markers import.
2646
+ - `team action=cleanup` STILL removes any block injected by older versions
2647
+ (<0.8.14) — backward-compat preserved via `removeGuidance`.
2648
+ - README Uninstall section + install.mjs warning note the v0.8.14 behavior
2649
+ change.
2650
+
2651
+ Scope rationale: pi-crew is a sub-agent orchestration extension. Modifying a
2652
+ user's project-instructions file was out-of-scope and unnecessary.
2653
+
2654
+ +4 regression tests (init does NOT create/modify AGENTS.md; API fields removed).
2655
+ typecheck clean; full suite 2972/0.
package/README.md CHANGED
@@ -110,10 +110,16 @@ node ./pi-crew/install.mjs # from local clone
110
110
  ### Uninstall
111
111
 
112
112
  `pi uninstall npm:pi-crew` removes the package, but pi doesn't fire an
113
- extension uninstall hook, so two things `team action=init` created are left
114
- behind: the **marker-delimited guidance block in AGENTS.md** and the **`.crew/`
115
- runtime state directory** (run history, artifacts, worktrees). Reverse them
116
- explicitly:
113
+ extension uninstall hook, so several things pi-crew created are left behind.
114
+ Reverse them explicitly with `team action=cleanup`. There are **two scopes**:
115
+
116
+ > **v0.8.14+**: `team action=init` **no longer injects a guidance block into
117
+ > AGENTS.md** (it was redundant — the `team` tool self-describes via its tool
118
+ > registration, so the agent learns pi-crew's commands from there, not AGENTS.md).
119
+ > The cleanup steps below still work for removing blocks injected by **older
120
+ > versions** (<0.8.14).
121
+
122
+ #### Project scope (reverse `team action=init`)
117
123
 
118
124
  ```bash
119
125
  # 1. (Optional) Preview what would be removed, without writing:
@@ -124,17 +130,38 @@ team action=cleanup
124
130
 
125
131
  # 3. Remove BOTH the guidance block AND the .crew/ state directory (force):
126
132
  team action=cleanup force=true
127
-
128
- # 4. Finally, remove the package itself:
129
- pi uninstall npm:pi-crew
130
133
  ```
131
134
 
132
135
  The guidance block is wrapped in `<!-- PI-CREW:GUIDANCE:START -->` /
133
- `<!-- PI-CREW:GUIDANCE:END -->` markers, so `team action=cleanup` removes
134
- **only** that block — your own AGENTS.md content is never touched. The
135
- `.crew/` directory is removed **only** with `force=true` (it's irreversible).
136
- The user-scope dir (`~/.pi/agent/extensions/pi-crew/`) is owned by
137
- `pi uninstall` and is never touched by `team action=cleanup`.
136
+ `<!-- PI-CREW:GUIDANCE:END -->` markers, so cleanup removes **only** that
137
+ block — your own AGENTS.md content is never touched. The `.crew/` directory
138
+ is removed **only** with `force=true` (it's irreversible).
139
+
140
+ #### User scope (remove user-level state `pi uninstall` leaves behind)
141
+
142
+ ```bash
143
+ # 4. Preview + remove pi-crew user-scope junk:
144
+ team action=cleanup scope=user dryRun=true # preview
145
+ team action=cleanup scope=user # remove ~/.pi/agent/extensions/pi-crew/
146
+ # + pi-crew smoke-test *.bak files
147
+
148
+ # 5. (Optional) Also remove the global config (holds your settings):
149
+ team action=cleanup scope=user force=true # also removes ~/.pi/agent/pi-crew.json
150
+ ```
151
+
152
+ This removes the pi-crew state dir (`~/.pi/agent/extensions/pi-crew/`, which
153
+ holds run artifacts + state), the global config (with `force=true`), and the
154
+ `*.md.bak-<timestamp>` smoke-test backup files pi-crew's own tests may leave in
155
+ `~/.pi/agent/agents/`. **Your authored agent files (`*.md`) are never touched**
156
+ — pi-crew can't tell which were user-created vs test-copied, so only the
157
+ clearly-pi-crew `.bak-*` backups are removed.
158
+
159
+ #### Final step
160
+
161
+ ```bash
162
+ # 6. Remove the package itself:
163
+ pi uninstall npm:pi-crew
164
+ ```
138
165
 
139
166
 
140
167
  ---
package/install.mjs CHANGED
@@ -63,3 +63,25 @@ console.log("\nFor local development from a cloned repo:");
63
63
  console.log(" pi install .");
64
64
  console.log("\nChild workers are enabled by default. For dry runs, set runtime.mode=scaffold or executeWorkers=false.");
65
65
  console.log("To force-disable or force-enable workers in a shell, use PI_TEAMS_EXECUTE_WORKERS=0/1.");
66
+
67
+ // Side-effects warning (Issue #35): be upfront about what pi-crew writes and
68
+ // how to fully uninstall it. Nothing runs on install/registration itself; the
69
+ // writes below only happen when you explicitly invoke `team action=init`.
70
+ console.log("\n--- What pi-crew writes (and how to undo it) ---");
71
+ console.log("pi-crew itself writes nothing on install. The following only happens when you");
72
+ console.log("explicitly run `team action=init` in a project:");
73
+ console.log(" - A `.crew/` runtime state dir is created in the project (run history + artifacts).");
74
+ console.log(" - With --copy-builtins: bundled agents/teams/workflows are copied into the project.");
75
+ console.log("This install also created the global config above (`~/.pi/agent/pi-crew.json`).");
76
+ console.log("Note: pi-crew v0.8.14+ no longer injects a guidance block into AGENTS.md on init");
77
+ console.log(" (it was redundant — the `team` tool self-describes via tool registration).");
78
+ console.log(" Versions <0.8.14 did inject one; `team action=cleanup` removes it.");
79
+ console.log("\nFull uninstall (in order):");
80
+ console.log(" team action=cleanup dryRun=true # preview what would be removed (project)");
81
+ console.log(" team action=cleanup # remove the AGENTS.md guidance block");
82
+ console.log(" team action=cleanup force=true # also remove the .crew/ project state dir");
83
+ console.log(" team action=cleanup scope=user # remove pi-crew user-scope junk");
84
+ console.log(" # (~/.pi/agent/extensions/pi-crew/ + test .bak files)");
85
+ console.log(" team action=cleanup scope=user force=true # also remove ~/.pi/agent/pi-crew.json");
86
+ console.log(" pi uninstall npm:pi-crew # remove the package itself");
87
+ console.log("See the README 'Uninstall' section for details.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.8.12",
3
+ "version": "0.8.14",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -22,10 +22,10 @@ export function piTeamsHelp(): string {
22
22
  "- /team-mascot",
23
23
  "- /team-transcript <runId> [taskId]",
24
24
  "- /team-result <runId> [taskId]",
25
- "- /team-manager",
25
+ "- /team-manager — interactive menu (alias: /team-cleanup-menu)",
26
26
  "",
27
27
  "Maintenance:",
28
- "- /team-cleanup <runId> [--force]",
28
+ "- /team-cleanup <runId> [--force] (or scope=project/user for uninstall cleanup)",
29
29
  "- /team-forget <runId> --confirm [--force]",
30
30
  "- /team-prune --keep=20 --confirm",
31
31
  "",
@@ -2,7 +2,6 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { configPath as globalConfigPath } from "../config/config.ts";
4
4
  import { DEFAULT_UI } from "../config/defaults.ts";
5
- import { injectGuidance, standardGuidanceBlocks } from "../config/markers.ts";
6
5
  import { packageRoot, projectCrewRoot, projectPiRoot } from "../utils/paths.ts";
7
6
 
8
7
  export interface ProjectInitOptions {
@@ -22,8 +21,6 @@ export interface ProjectInitResult {
22
21
  configScope: "global" | "project" | "none";
23
22
  configCreated: boolean;
24
23
  configSkipped: boolean;
25
- guidancePath: string;
26
- guidanceModified: boolean;
27
24
  }
28
25
 
29
26
  function ensureDir(dir: string, createdDirs: string[]): void {
@@ -147,22 +144,12 @@ export function initializeProject(cwd: string, options: ProjectInitOptions = {})
147
144
  gitignoreUpdated = true;
148
145
  }
149
146
 
150
- // Inject guidance into project AGENTS.md (or similar) using marker-based injection.
151
- const guidancePath = path.join(cwd, "AGENTS.md");
152
- const version = getPackageVersion();
153
- const guidanceResult = injectGuidance(guidancePath, standardGuidanceBlocks(version));
147
+ // v0.8.14: pi-crew no longer injects a guidance block into AGENTS.md on init.
148
+ // AGENTS.md is the USER's project-instructions file (Pi loads it as project
149
+ // guidance) extensions modifying it was out-of-scope and redundant: the
150
+ // `team` tool already self-describes via its schema description, so the agent
151
+ // learns pi-crew's commands from tool registration, not AGENTS.md.
152
+ // `team action=cleanup` still removes any block injected by older versions.
154
153
 
155
- return { createdDirs, copiedFiles, skippedFiles, gitignorePath, gitignoreUpdated, configPath, configScope, configCreated, configSkipped, guidancePath, guidanceModified: guidanceResult.modified };
156
- }
157
-
158
- /** Read the current package version from the nearest package.json. */
159
- function getPackageVersion(): string {
160
- try {
161
- const pkgPath = path.join(packageRoot(), "package.json");
162
- const raw = fs.readFileSync(pkgPath, "utf-8");
163
- const parsed: { version?: string } = JSON.parse(raw) as { version?: string };
164
- return parsed.version ?? "0.0.0";
165
- } catch {
166
- return "0.0.0";
167
- }
154
+ return { createdDirs, copiedFiles, skippedFiles, gitignorePath, gitignoreUpdated, configPath, configScope, configCreated, configSkipped };
168
155
  }
@@ -123,6 +123,7 @@ import { registerContextStatusInjection } from "./context-status-injection.ts";
123
123
  import { registerTeamTool } from "./registration/team-tool.ts";
124
124
  import { handleTeamTool } from "./team-tool.ts";
125
125
  import { persistScheduledJobUpdate } from "./team-tool/handle-schedule.ts";
126
+ import { shouldBlockDestructiveTeamAction } from "./team-tool/destructive-gate.ts";
126
127
 
127
128
  let _cachedOTLPExporter: typeof OTLPExporterType | undefined;
128
129
  async function importOTLPExporter(): Promise<typeof OTLPExporterType> {
@@ -1992,13 +1993,11 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1992
1993
  const input = asRecord(rawInput);
1993
1994
  if (!input) return;
1994
1995
  const action = typeof input.action === "string" ? input.action : undefined;
1995
- const destructiveActions = new Set(["delete", "forget", "prune", "cleanup"]);
1996
- if (!action || !destructiveActions.has(action)) return;
1997
- const forceBypassesReferenceChecks = action === "delete" && input.force === true;
1998
- if (input.confirm === true || forceBypassesReferenceChecks) return;
1996
+ const reason = shouldBlockDestructiveTeamAction(action, input);
1997
+ if (!reason) return;
1999
1998
  return {
2000
- block: true,
2001
- reason: `Destructive action '${action}' requires confirm=true${action === "delete" ? " (or force=true to bypass reference checks)" : ""}.`,
1999
+ block: true as const,
2000
+ reason,
2002
2001
  };
2003
2002
  });
2004
2003
 
@@ -439,7 +439,11 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
439
439
  },
440
440
  })
441
441
 
442
- pi.registerCommand("team-cleanup", { description: "Open a simple pi-crew interactive manager", handler: handleTeamManagerCommand });
442
+ pi.registerCommand("team-manager", { description: "Open the pi-crew interactive menu (list/run/status/cleanup/manage resources/doctor)", handler: handleTeamManagerCommand });
443
+ // Backward-compat alias: this command was originally registered as "team-cleanup"
444
+ // (the interactive menu predates the runId-targeted cleanup action). Keep both so
445
+ // existing muscle memory + help docs both work.
446
+ pi.registerCommand("team-cleanup-menu", { description: "Alias for /team-manager (pi-crew interactive menu)", handler: handleTeamManagerCommand });
443
447
 
444
448
  pi.registerCommand("team-result", { description: "Open a pi-crew agent result viewer: <runId> [taskId]", getArgumentCompletions: async (argumentPrefix: string) => { const parts = argumentPrefix.trim().split(/\s+/); return parts.length <= 1 ? suggestRunIds(parts[0] ?? "") : suggestTaskIds(parts[0] ?? "", parts[1] ?? ""); }, handler: async (args: string, ctx: ExtensionCommandContext) => {
445
449
  const [runId, rawTaskId] = args.trim().split(/\s+/).filter(Boolean);
@@ -75,11 +75,13 @@ function loadRunSummaries(cwd: string, options: OnboardingOptions = {}): RunSumm
75
75
  }
76
76
 
77
77
  /**
78
- * Format duration in minutes.
78
+ * Format duration in minutes. Defensive against missing/invalid timestamps
79
+ * (e.g. legacy test runs) — returns "?" instead of "NaNm".
79
80
  */
80
- function formatDuration(createdAt: string, completedAt?: string): string {
81
+ export function formatDuration(createdAt: string, completedAt?: string): string {
81
82
  const start = new Date(createdAt).getTime();
82
83
  const end = completedAt ? new Date(completedAt).getTime() : Date.now();
84
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return "?";
83
85
  const minutes = Math.round((end - start) / 1000 / 60);
84
86
  if (minutes < 1) return "<1m";
85
87
  if (minutes >= 60) return `${Math.round(minutes / 60)}h`;
@@ -136,8 +138,10 @@ export function buildTeamOnboarding(team: string, cwd: string, options: Onboardi
136
138
  for (const run of runs) {
137
139
  const duration = formatDuration(run.createdAt, run.completedAt);
138
140
  const goalPreview = run.goal ? run.goal.slice(0, 40) : "N/A";
139
- const statusIcon = run.status === "completed" ? "✅" : run.status === "failed" ? "" : "⚠️";
140
- lines.push(`| \`${run.runId.slice(-8)}\` | ${goalPreview}${run.goal.length > 40 ? "..." : ""} | ${duration} | ${statusIcon} ${run.status} |`);
141
+ const goalSuffix = run.goal && run.goal.length > 40 ? "..." : "";
142
+ const status = run.status ?? "unknown";
143
+ const statusIcon = status === "completed" ? "✅" : status === "failed" ? "❌" : status === "cancelled" ? "⏹️" : "⚠️";
144
+ lines.push(`| \`${run.runId.slice(-8)}\` | ${goalPreview}${goalSuffix} | ${duration} | ${statusIcon} ${status} |`);
141
145
  }
142
146
  lines.push("");
143
147
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Permission gate logic for destructive team actions (delete/forget/prune/cleanup).
3
+ *
4
+ * Extracted from register.ts `pi.on("tool_call")` handler into a pure function
5
+ * so the gate logic is unit-testable in isolation (the handler itself is hard
6
+ * to test because it's an async event listener on the Pi extension API).
7
+ *
8
+ * Returns `undefined` when the action is ALLOWED, or a block `reason` string
9
+ * when it should be blocked. The handler wraps this and emits the `{block, reason}`
10
+ * shape Pi expects.
11
+ *
12
+ * Rules (in order):
13
+ * 1. Non-team / non-destructive actions → allowed (caller pre-filters, but safe).
14
+ * 2. `cleanup` with `dryRun=true` → ALWAYS allowed (a preview writes nothing,
15
+ * so gating it would block users from previewing what cleanup would do —
16
+ * this was a UX bug: team action=cleanup dryRun=true returned "requires
17
+ * confirm=true" even though it changed no files).
18
+ * 3. `confirm=true` on the input → allowed (explicit user intent).
19
+ * 4. `delete` with `force=true` → allowed (force bypasses reference checks).
20
+ * 5. Otherwise → blocked with a reason telling the user what to pass.
21
+ */
22
+
23
+ export const DESTRUCTIVE_TEAM_ACTIONS = new Set(["delete", "forget", "prune", "cleanup"]);
24
+
25
+ export interface TeamToolInputLike {
26
+ action?: unknown;
27
+ confirm?: unknown;
28
+ force?: unknown;
29
+ dryRun?: unknown;
30
+ }
31
+
32
+ /**
33
+ * Decide whether a destructive team action should be blocked.
34
+ * @returns block reason string, or `undefined` to allow.
35
+ */
36
+ export function shouldBlockDestructiveTeamAction(
37
+ action: string | undefined,
38
+ input: TeamToolInputLike,
39
+ ): string | undefined {
40
+ if (!action || !DESTRUCTIVE_TEAM_ACTIONS.has(action)) return undefined;
41
+ // dryRun cleanup is a PREVIEW (no writes) — never needs confirm.
42
+ if (action === "cleanup" && input.dryRun === true) return undefined;
43
+ if (input.confirm === true) return undefined;
44
+ const forceBypassesReferenceChecks = action === "delete" && input.force === true;
45
+ if (forceBypassesReferenceChecks) return undefined;
46
+ return `Destructive action '${action}' requires confirm=true${action === "delete" ? " (or force=true to bypass reference checks)" : ""}.`;
47
+ }
@@ -13,7 +13,7 @@ import { RUN_NOT_FOUND_HINT } from "./run-not-found.ts";
13
13
  import { enforceDestructiveIntent, intentFromConfig } from "./intent-policy.ts";
14
14
  import { executeHook, appendHookEvent } from "../../hooks/registry.ts";
15
15
  import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
16
- import { projectCrewRoot, userCrewRoot } from "../../utils/paths.ts";
16
+ import { projectCrewRoot, userCrewRoot, userPiRoot } from "../../utils/paths.ts";
17
17
  import { removeGuidance } from "../../config/markers.ts";
18
18
  import * as path from "node:path";
19
19
 
@@ -129,17 +129,22 @@ export async function handleCleanup(params: TeamToolParamsValue, ctx: TeamContex
129
129
  // can stay focused on their own logic.
130
130
  const intentError = enforceDestructiveIntent("cleanup", params, ctx.config);
131
131
  if (intentError) return intentError;
132
- // Two cleanup modes:
133
- // 1. WITH runId → per-run worktree cleanup (existing behavior).
134
- // 2. WITHOUT runId → PROJECT-LEVEL uninstall cleanup: removes the
135
- // AGENTS.md guidance block pi-crew injected (`team action=init`) and
136
- // optionally the `.crew/` state dir. Use this before/after
137
- // `pi uninstall npm:pi-crew` to leave the project pristine.
138
- // Issue #35: pi doesn't fire an uninstall hook for extensions, so this
139
- // mode is the documented way to reverse an init.
132
+ // Three cleanup modes:
133
+ // 1. WITH runId → per-run worktree cleanup (existing behavior).
134
+ // 2. WITHOUT runId, scope=project (default) → PROJECT-LEVEL uninstall:
135
+ // removes the AGENTS.md guidance block + optionally `.crew/`. Reverses
136
+ // `team action=init`.
137
+ // 3. WITHOUT runId, scope=user USER-LEVEL cleanup: removes pi-crew
138
+ // user-scope state that `pi uninstall` leaves behind (config.json,
139
+ // `~/.pi/agent/extensions/pi-crew/` state, junk `.bak` agent files
140
+ // from pi-crew smoke tests). Issue #35 comment: "pi-crew leaves behind
141
+ // user-level junk" — this closes that gap.
140
142
  if (params.runId) {
141
143
  return handleRunCleanup(params, ctx);
142
144
  }
145
+ if (params.scope === "user") {
146
+ return handleUserCleanup(params, ctx);
147
+ }
143
148
  return handleProjectCleanup(params, ctx);
144
149
  }
145
150
 
@@ -153,8 +158,8 @@ export async function handleCleanup(params: TeamToolParamsValue, ctx: TeamContex
153
158
  * - `removeGuidance` only touches content between the PI-CREW markers.
154
159
  * - `.crew/` removal requires explicit `force: true` (it holds run history,
155
160
  * artifacts, and worktrees — irreversible). Default is guidance-only.
156
- * - The user pi-crew user dir (`~/.pi/agent/extensions/pi-crew/`) is NEVER
157
- * touched here — `pi uninstall` owns that; we only touch project state.
161
+ * - User-scope cleanup (`scope=user`) is a SEPARATE handler — see
162
+ * `handleUserCleanup`.
158
163
  */
159
164
  function handleProjectCleanup(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
160
165
  const cwd = ctx.cwd;
@@ -242,6 +247,134 @@ function dryRunRemovedIds(guidancePath: string): string[] {
242
247
  }
243
248
  }
244
249
 
250
+ /**
251
+ * User-level cleanup (`scope=user`, no runId). Removes pi-crew user-scope
252
+ * state that `pi uninstall npm:pi-crew` leaves behind (Issue #35 comment:
253
+ * "pi-crew leaves behind user-level junk"). Targets ONLY pi-crew-owned paths:
254
+ *
255
+ * 1. `~/.pi/agent/extensions/pi-crew/` — pi-crew state dir (artifacts,
256
+ * state, config.json). Owned by pi-crew, safe to remove.
257
+ * 2. `~/.pi/agent/pi-crew.json` — pi-crew global config (only with force).
258
+ * 3. `~/.pi/agent/agents/*.bak-*` junk files from pi-crew smoke tests
259
+ * (pattern: `*.md.bak-<timestamp>-<hex>` — these are pi-crew test
260
+ * leftovers, never user data).
261
+ *
262
+ * Safety:
263
+ * - NEVER removes user-authored agent files (`~/.pi/agent/agents/*.md`)
264
+ * because pi-crew cannot tell which were user-created vs test-copied —
265
+ * only the timestamped `.bak-*` backups (clearly pi-crew test junk).
266
+ * - The state dir removal requires force=true by default is NOT required
267
+ * (it's pi-crew's own runtime cache, regenerable), but config.json removal
268
+ * IS gated on force=true (it may hold user-customized settings).
269
+ * - dryRun=true previews without writing.
270
+ */
271
+ function handleUserCleanup(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
272
+ const dryRun = params.dryRun === true;
273
+ const force = params.force === true;
274
+ const lines: string[] = ["User-scope cleanup for pi-crew:"];
275
+
276
+ // 1. pi-crew user state dir (~/.pi/agent/extensions/pi-crew/) — always safe
277
+ // to remove (regenerable runtime cache: artifacts + state). This is the
278
+ // bulk of the "user-level junk".
279
+ const crewStateDir = userCrewRoot();
280
+ lines.push(`pi-crew user state dir (${crewStateDir}):`);
281
+ let crewStateBytes = 0;
282
+ if (fs.existsSync(crewStateDir)) {
283
+ try {
284
+ crewStateBytes = dirSize(crewStateDir);
285
+ } catch { /* best-effort size */ }
286
+ if (!dryRun) {
287
+ try { fs.rmSync(crewStateDir, { recursive: true, force: true }); }
288
+ catch (e) { lines.push(` - ERROR removing: ${(e as Error).message}`); }
289
+ }
290
+ lines.push(` - ${dryRun ? "would remove" : "removed"}: ${crewStateDir} (${formatBytes(crewStateBytes)})`);
291
+ } else {
292
+ lines.push(" - (not present — nothing to do)");
293
+ }
294
+
295
+ // 2. pi-crew global config (~/.pi/agent/pi-crew.json) — gated on force=true
296
+ // because it may hold user-customized settings (autonomous profile, model
297
+ // overrides, etc.). Default preserves it.
298
+ const userConfigPath = path.join(userPiRoot(), "pi-crew.json");
299
+ lines.push("pi-crew global config:");
300
+ if (!fs.existsSync(userConfigPath)) {
301
+ lines.push(` - (not present at ${userConfigPath} — nothing to do)`);
302
+ } else if (!force) {
303
+ lines.push(` - present at ${userConfigPath} (preserved — use force: true to remove; may hold your customized settings)`);
304
+ } else {
305
+ if (!dryRun) {
306
+ try { fs.rmSync(userConfigPath, { force: true }); }
307
+ catch (e) { lines.push(` - ERROR removing: ${(e as Error).message}`); }
308
+ }
309
+ lines.push(` - ${dryRun ? "would remove" : "removed"}: ${userConfigPath}`);
310
+ }
311
+
312
+ // 3. pi-crew smoke-test `.bak-*` junk in ~/.pi/agent/agents/. These are
313
+ // leftover backups from pi-crew's own smoke tests (pattern:
314
+ // `<name>.md.bak-<timestamp>-<hex>`). NEVER touch real `.md` agent files
315
+ // (can't tell user-authored vs test-copied).
316
+ const agentsDir = path.join(userPiRoot(), "agents");
317
+ lines.push("pi-crew test junk in agents dir:");
318
+ const bakJunk: string[] = [];
319
+ if (fs.existsSync(agentsDir)) {
320
+ try {
321
+ for (const entry of fs.readdirSync(agentsDir)) {
322
+ // Only the pi-crew smoke-test backup pattern. Real agent files end in `.md`.
323
+ if (/^.*\.md\.bak-\d{17,}-[0-9a-f]+$/i.test(entry)) {
324
+ bakJunk.push(path.join(agentsDir, entry));
325
+ }
326
+ }
327
+ } catch { /* best-effort scan */ }
328
+ }
329
+ if (bakJunk.length === 0) {
330
+ lines.push(" - (no `*.md.bak-*` test backups found — nothing to do)");
331
+ } else {
332
+ for (const junk of bakJunk) {
333
+ if (!dryRun) {
334
+ try { fs.rmSync(junk, { force: true }); }
335
+ catch (e) { lines.push(` - ERROR removing ${path.basename(junk)}: ${(e as Error).message}`); }
336
+ }
337
+ }
338
+ lines.push(` - ${dryRun ? "would remove" : "removed"}: ${bakJunk.length} backup file(s) (pattern *.md.bak-<timestamp>-<hex>)`);
339
+ }
340
+
341
+ lines.push("");
342
+ lines.push(
343
+ dryRun
344
+ ? "(dry-run preview — no files were changed. Re-run without dryRun to apply.)"
345
+ : "Done. To fully remove pi-crew: also run `team action=cleanup force=true` (project .crew/) + `pi uninstall npm:pi-crew`.",
346
+ );
347
+ return result(lines.join("\n"), { action: "cleanup", status: "ok", scope: "user" }, false);
348
+ }
349
+
350
+ /** Recursively compute a directory's size in bytes (best-effort). */
351
+ function dirSize(dir: string): number {
352
+ let total = 0;
353
+ const stack = [dir];
354
+ while (stack.length > 0) {
355
+ const cur = stack.pop()!;
356
+ let entries: string[];
357
+ try { entries = fs.readdirSync(cur); }
358
+ catch { continue; }
359
+ for (const entry of entries) {
360
+ const full = path.join(cur, entry);
361
+ try {
362
+ const stat = fs.statSync(full);
363
+ if (stat.isDirectory()) stack.push(full);
364
+ else total += stat.size;
365
+ } catch { /* skip unreadable */ }
366
+ }
367
+ }
368
+ return total;
369
+ }
370
+
371
+ function formatBytes(n: number): string {
372
+ if (n < 1024) return `${n}B`;
373
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
374
+ if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)}MB`;
375
+ return `${(n / (1024 * 1024 * 1024)).toFixed(1)}GB`;
376
+ }
377
+
245
378
  /** Per-run worktree cleanup (existing behavior, preserved). */
246
379
  async function handleRunCleanup(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
247
380
  const loaded = loadRunManifestById(ctx.cwd, params.runId!); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
@@ -12,6 +12,7 @@ import { computePhaseProgress } from "../../runtime/phase-progress.ts";
12
12
  import { formatDuration } from "../../ui/tool-render.ts";
13
13
  import { verifyTaskCompletion } from "../../runtime/completion-guard.ts";
14
14
  import { evaluateRunEffectiveness } from "../../runtime/effectiveness.ts";
15
+ import { extractCommandTrace } from "../../runtime/command-trace.ts";
15
16
  import type { PiTeamsToolResult } from "../tool-result.ts";
16
17
  import { locateRunCwd } from "../team-tool.ts";
17
18
  import { result, type TeamContext } from "./context.ts";
@@ -88,7 +89,7 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
88
89
  "Task graph:",
89
90
  ...formatTaskGraphLines(tasks),
90
91
  "Tasks:",
91
- ...(tasks.length ? tasks.map((task) => `- ${task.id} [${task.status}] ${task.role} -> ${task.agent}${task.taskPacket ? ` scope=${task.taskPacket.scope}` : ""}${task.verification ? ` green=${task.verification.observedGreenLevel}/${task.verification.requiredGreenLevel}` : ""}${task.modelAttempts?.length ? ` attempts=${task.modelAttempts.length}` : ""}${task.modelRouting ? ` modelRouting=${task.modelRouting.requested ? `${task.modelRouting.requested}->` : ""}${task.modelRouting.resolved}${task.modelRouting.usedAttempt ? ` attempt=${task.modelRouting.usedAttempt + 1}` : ""}` : ""}${task.agentProgress?.activityState ? ` activityState=${task.agentProgress.activityState}` : ""}${attentionByTask.get(task.id)?.data?.reason ? ` attention=${String(attentionByTask.get(task.id)?.data?.reason)}` : ""}${task.jsonEvents !== undefined ? ` jsonEvents=${task.jsonEvents}` : ""}${task.usage ? ` usage=${JSON.stringify(task.usage)}` : ""}${task.resultArtifact ? ` result=${task.resultArtifact.path}` : ""}${task.transcriptArtifact ? ` transcript=${task.transcriptArtifact.path}` : ""}${task.worktree ? ` worktree=${task.worktree.path}` : ""}${task.error ? ` error=${task.error}` : ""}`) : ["- (none)"]),
92
+ ...(tasks.length ? tasks.map((task) => `- ${task.id} [${task.status}] ${task.role} -> ${task.agent}${task.taskPacket ? ` scope=${task.taskPacket.scope}` : ""}${task.verification ? ` green=${task.verification.observedGreenLevel}/${task.verification.requiredGreenLevel}` : ""}${task.modelAttempts?.length ? ` attempts=${task.modelAttempts.length}` : ""}${task.modelRouting ? ` modelRouting=${task.modelRouting.requested ? `${task.modelRouting.requested}->` : ""}${task.modelRouting.resolved}${task.modelRouting.usedAttempt ? ` attempt=${task.modelRouting.usedAttempt + 1}` : ""}` : ""}${task.agentProgress?.activityState ? ` activityState=${task.agentProgress.activityState}` : ""}${(() => { const t = extractCommandTrace(task.agentProgress?.recentTools); return t.summary ? ` ${t.summary}` : ""; })()}${attentionByTask.get(task.id)?.data?.reason ? ` attention=${String(attentionByTask.get(task.id)?.data?.reason)}` : ""}${task.jsonEvents !== undefined ? ` jsonEvents=${task.jsonEvents}` : ""}${task.usage ? ` usage=${JSON.stringify(task.usage)}` : ""}${task.resultArtifact ? ` result=${task.resultArtifact.path}` : ""}${task.transcriptArtifact ? ` transcript=${task.transcriptArtifact.path}` : ""}${task.worktree ? ` worktree=${task.worktree.path}` : ""}${task.error ? ` error=${task.error}` : ""}`) : ["- (none)"]),
92
93
  `Task counts: ${[...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none"}`,
93
94
  "Effectiveness:",
94
95
  `- observable=${effectiveness.observable}/${Math.max(1, effectiveness.completed)} completed tasks`,
@@ -0,0 +1,105 @@
1
+ /**
2
+ * T10 — Verbatim command-trace extraction from tool-call history.
3
+ *
4
+ * Ported from pi-agent-flow's `generateCommandsFromHistory` technique (see
5
+ * `research-findings/pi-ecosystem-distillation.md` T10): workers routinely
6
+ * PARAPHRASE commands in their self-reports ("I ran the tests" instead of the
7
+ * exact `npm test -- --grep foo`). The orchestrator/user wants the VERBATIM
8
+ * command trace. pi-crew already records each executed tool in
9
+ * `CrewAgentProgress.recentTools` (capped at 25, args previewed at ≤240 chars),
10
+ * so the factual trace is available — this module distills it.
11
+ *
12
+ * Design notes:
13
+ * - Reads only the recorded `{tool, args, endedAt}` tuples — never trusts any
14
+ * LLM-emitted command string. This is the whole point: mechanical, factual.
15
+ * - Recognizes bash/shell tools and extracts their `command` arg, sanitized to
16
+ * a single line (newlines → ⏎) so a multi-line script shows as one trace row.
17
+ * - Non-shell tools (write/edit/read) are counted but not inlined (their args
18
+ * are file paths, not commands; the task result already lists changed files).
19
+ * - Caps the returned command list to keep the status line / summary bounded.
20
+ */
21
+
22
+ export interface ToolCallRecord {
23
+ tool: string;
24
+ args?: string;
25
+ endedAt?: string;
26
+ }
27
+
28
+ export interface CommandTrace {
29
+ /** Total number of recorded tool calls (all tool kinds). */
30
+ totalTools: number;
31
+ /** Count of recognized command-executing tools (bash/shell). */
32
+ commandTools: number;
33
+ /** Verbatim command strings (sanitized to one line), most-recent-last. */
34
+ commands: string[];
35
+ /** One-line summary suitable for a status/dashboard row, e.g. "cmd=8 (3 bash)". */
36
+ summary: string;
37
+ }
38
+
39
+ const COMMAND_TOOL_NAMES = new Set(["bash", "shell", "execute_bash", "run_command", "terminal"]);
40
+ const MAX_COMMANDS = 12;
41
+ const MAX_COMMAND_LEN = 160;
42
+
43
+ /**
44
+ * Extract a verbatim command trace from recorded tool calls. Pure function —
45
+ * safe to call with any subset of recentTools. Returns an empty trace for
46
+ * empty/missing input.
47
+ */
48
+ export function extractCommandTrace(recentTools: readonly ToolCallRecord[] | undefined | null): CommandTrace {
49
+ const tools = Array.isArray(recentTools) ? recentTools : [];
50
+ // Only count well-formed records (string tool name) — malformed entries
51
+ // are skipped entirely so a corrupt/transient record can't inflate totals.
52
+ const valid = tools.filter((call) => call && typeof call.tool === "string");
53
+ const totalTools = valid.length;
54
+ const commands: string[] = [];
55
+ let commandTools = 0;
56
+ for (const call of valid) {
57
+ const toolName = call.tool.toLowerCase();
58
+ if (!COMMAND_TOOL_NAMES.has(toolName)) continue;
59
+ commandTools += 1;
60
+ const cmd = extractCommandArg(call.args);
61
+ if (cmd) commands.push(sanitizeCommand(cmd));
62
+ }
63
+ const trimmed = commands.slice(-MAX_COMMANDS);
64
+ return {
65
+ totalTools,
66
+ commandTools,
67
+ commands: trimmed,
68
+ summary: formatSummary(totalTools, commandTools),
69
+ };
70
+ }
71
+
72
+ /** Pull the `command` field out of an args preview (JSON string or raw). */
73
+ function extractCommandArg(args: string | undefined): string | undefined {
74
+ if (!args) return undefined;
75
+ const raw = args.trim();
76
+ if (!raw) return undefined;
77
+ // Args is a JSON-stringified object like {"command":"ls -la"} (previewArgs
78
+ // JSON.stringifies objects). Try to parse and read .command / .cmd.
79
+ if (raw.startsWith("{")) {
80
+ try {
81
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
82
+ const cmd = parsed.command ?? parsed.cmd ?? parsed.script ?? parsed.input;
83
+ if (typeof cmd === "string" && cmd.trim()) return cmd;
84
+ } catch {
85
+ // Not valid JSON — fall through to treat raw as the command.
86
+ }
87
+ }
88
+ // Otherwise treat the whole preview as the command text.
89
+ return raw;
90
+ }
91
+
92
+ /** Sanitize a command to a single bounded line for compact display. */
93
+ function sanitizeCommand(cmd: string): string {
94
+ const oneLine = cmd.replace(/\s*\r?\n\s*/g, " ⏎ ").trim();
95
+ if (oneLine.length <= MAX_COMMAND_LEN) return oneLine;
96
+ return `${oneLine.slice(0, MAX_COMMAND_LEN - 1)}…`;
97
+ }
98
+
99
+ function formatSummary(totalTools: number, commandTools: number): string {
100
+ if (totalTools === 0) return "";
101
+ // "cmd=8" always; append "(3 bash)" only when there's a mix worth showing.
102
+ if (commandTools === 0) return `cmd=${totalTools}`;
103
+ if (commandTools === totalTools) return `cmd=${totalTools}`;
104
+ return `cmd=${totalTools} (${commandTools} bash)`;
105
+ }
@@ -14,6 +14,10 @@ export interface ParsedPiJsonOutput {
14
14
  usage?: ParsedPiUsage;
15
15
  /** Unified patches extracted from tool_result events (edit tool patch field) */
16
16
  patches?: string[];
17
+ /** Model/provider error messages extracted from message_end events (e.g.
18
+ * "429 ... overloaded"). Used to detect runs that exited 0 but produced
19
+ * nothing because the model was rate-limited — see task-runner 429 fix. */
20
+ errorMessages?: string[];
17
21
  }
18
22
 
19
23
  function asRecord(value: unknown): Record<string, unknown> | undefined {
@@ -90,6 +94,7 @@ export function parsePiJsonOutput(stdout: string): ParsedPiJsonOutput {
90
94
  let jsonEvents = 0;
91
95
  const textEvents: string[] = [];
92
96
  const patches: string[] = [];
97
+ const errorMessages: string[] = [];
93
98
  let usage: ParsedPiUsage | undefined;
94
99
  for (const line of stdout.split("\n")) {
95
100
  const trimmed = line.trim();
@@ -104,6 +109,9 @@ export function parsePiJsonOutput(stdout: string): ParsedPiJsonOutput {
104
109
  textEvents.push(...extractText(event));
105
110
  // Extract unified patches from tool_result events
106
111
  extractPatch(event, patches);
112
+ // Extract provider/model error messages from message_end events (429 fix).
113
+ const errMsg = extractErrorMessage(event);
114
+ if (errMsg) errorMessages.push(errMsg);
107
115
  const eventUsage = extractUsage(event);
108
116
  if (eventUsage) usage = mergeUsage(usage ?? {}, eventUsage);
109
117
  }
@@ -113,9 +121,24 @@ export function parsePiJsonOutput(stdout: string): ParsedPiJsonOutput {
113
121
  finalText: textEvents.length > 0 ? textEvents[textEvents.length - 1] : undefined,
114
122
  usage,
115
123
  patches: patches.length > 0 ? patches : undefined,
124
+ errorMessages: errorMessages.length > 0 ? errorMessages : undefined,
116
125
  };
117
126
  }
118
127
 
128
+ /**
129
+ * Pull the provider/model error message out of a `message_end` event. The shape
130
+ * is `{type:"message_end", message:{role:"assistant", content:[], errorMessage:"429 ...", stopReason:"error"}}`.
131
+ * Returns undefined for events without an errorMessage.
132
+ */
133
+ function extractErrorMessage(event: unknown): string | undefined {
134
+ const obj = asRecord(event);
135
+ if (!obj) return undefined;
136
+ // message_end events carry the error on the nested message object.
137
+ const message = asRecord(obj.message) ?? obj;
138
+ const errorMessage = message.errorMessage;
139
+ return typeof errorMessage === "string" && errorMessage.trim() ? errorMessage.trim() : undefined;
140
+ }
141
+
119
142
  /**
120
143
  * Extract unified patches from a tool_result event.
121
144
  * pi's edit tool now includes a `patch` field (standard unified diff format).
@@ -333,6 +333,13 @@ export function renderSkillInstructions(
333
333
  ? `Description: ${description}${confidenceNote}`
334
334
  : undefined,
335
335
  `Source: ${source}`,
336
+ // Path: pointer to the skill directory so the agent can deterministically
337
+ // `ls <Path>/references/` and `read` a co-located reference corpus.
338
+ // Without this, skills that defer to a local corpus (the Agent Skills
339
+ // spec "small instruction + large local reference" pattern, e.g.
340
+ // effective-html's `references/html-effectiveness/`) leave the agent
341
+ // guessing the skill dir. No behavior change for corpus-less skills.
342
+ `Path: ${path.dirname(loaded.path)}`,
336
343
  ]
337
344
  .filter(Boolean)
338
345
  .join("\n");
@@ -83,6 +83,7 @@ import {
83
83
  progressEventSummary,
84
84
  shouldFlushProgressEvent,
85
85
  } from "./task-runner/progress.ts";
86
+ import { extractCommandTrace } from "./command-trace.ts";
86
87
  import {
87
88
  checkpointTask,
88
89
  persistSingleTaskUpdate,
@@ -729,6 +730,18 @@ export async function runTeamTask(
729
730
  stderr: childResult.stderr,
730
731
  }).message;
731
732
  }
733
+ // 429/rate-limit fix (PI_CREW_TOOLING_429_NOTE.md): a worker can exit
734
+ // code 0 with NO hard error, but the transcript is full of
735
+ // `message_end` events with `errorMessage: "429 ... overloaded"` and
736
+ // empty content. The model never produced a tool call, so the worker
737
+ // "completed" without doing anything. Detect this: if no error was set
738
+ // above AND the parsed output carries a retryable model-failure message
739
+ // AND there is no real output text, surface it as an error so the
740
+ // model-fallback chain can retry on another model.
741
+ if (!error && parsedOutput) {
742
+ const rateLimitErr = detectRetryableModelFailureFromOutput(parsedOutput);
743
+ if (rateLimitErr) error = rateLimitErr;
744
+ }
732
745
  persistHeartbeat(true);
733
746
  persistChildProgress({ type: "attempt_finished" }, true);
734
747
  const attempt: ModelAttemptSummary = {
@@ -1206,12 +1219,16 @@ export async function runTeamTask(
1206
1219
 
1207
1220
  // Emit task completion hooks (100% reliable, fire-and-forget)
1208
1221
  const hookType = task.status === "completed" ? "task_completed" : task.status === "failed" ? "task_failed" : "task_started";
1222
+ // T10: attach the VERBATIM command trace (mechanically derived from
1223
+ // recorded tool-call history, never from the worker's self-report) so
1224
+ // event viewers + the orchestrator see exactly which commands ran.
1225
+ const commandTrace = extractCommandTrace(task.agentProgress?.recentTools);
1209
1226
  crewHooks.emit({
1210
1227
  type: hookType,
1211
1228
  timestamp: task.finishedAt ?? new Date().toISOString(),
1212
1229
  runId: manifest.runId,
1213
1230
  taskId: task.id,
1214
- data: { status: task.status, role: task.role, error: task.error, exitCode: task.exitCode, usage: task.usage },
1231
+ data: { status: task.status, role: task.role, error: task.error, exitCode: task.exitCode, usage: task.usage, commandTrace },
1215
1232
  });
1216
1233
 
1217
1234
  const packetArtifact = writeArtifact(manifest.artifactsRoot, {
@@ -1332,3 +1349,36 @@ async function resolveTaskScopeModelsPatterns(cwd: string): Promise<string[]> {
1332
1349
  if (!scopeModels) return [];
1333
1350
  return readEnabledModelsPatterns(cwd);
1334
1351
  }
1352
+
1353
+ /**
1354
+ * 429/rate-limit detection (PI_CREW_TOOLING_429_NOTE.md).
1355
+ *
1356
+ * A worker can exit code 0 with no hard error, yet the transcript is full of
1357
+ * `message_end` events carrying `errorMessage: "429 ... overloaded"` (or any
1358
+ * retryable model-failure pattern) and empty content arrays. The model never
1359
+ * produced a tool call, so the worker "completed" without doing anything.
1360
+ *
1361
+ * This helper inspects a ParsedPiJsonOutput and, if the run produced only
1362
+ * retryable model-failure messages AND no real output text (no finalText, no
1363
+ * text events, no patches), returns a surfaced error string so the
1364
+ * model-fallback chain (isRetryableModelFailure) can retry on another model.
1365
+ * Returns undefined when the run has real output (the 429s were recovered from)
1366
+ * or when there are no retryable error messages.
1367
+ */
1368
+ export function detectRetryableModelFailureFromOutput(parsed: ParsedPiJsonOutput): string | undefined {
1369
+ const messages = parsed.errorMessages;
1370
+ if (!messages || messages.length === 0) return undefined;
1371
+ // Find the first retryable model-failure message (429 / rate-limit / overloaded / 5xx / ...).
1372
+ const retryable = messages.find((m) => isRetryableModelFailure(m));
1373
+ if (!retryable) return undefined;
1374
+ // Did the run actually produce real output despite the transient errors?
1375
+ // If finalText / textEvents / patches exist, the model recovered and we
1376
+ // should NOT mark the run as failed — only flag it when the worker yielded
1377
+ // nothing (the 429-only case from the bug report).
1378
+ const hasRealOutput =
1379
+ (parsed.finalText?.trim().length ?? 0) > 0 ||
1380
+ parsed.textEvents.some((t) => t.trim().length > 0) ||
1381
+ (parsed.patches?.length ?? 0) > 0;
1382
+ if (hasRealOutput) return undefined;
1383
+ return `Model returned only retryable errors and no output: ${retryable}`;
1384
+ }