pi-crew 0.9.27 → 0.9.29

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 (35) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/build-meta.json +164 -115
  3. package/dist/index.mjs +986 -555
  4. package/dist/index.mjs.map +4 -4
  5. package/docs/REVIEW-FINDINGS-2026-07-CORE.md +139 -0
  6. package/docs/REVIEW-FINDINGS-2026-07.md +125 -0
  7. package/docs/bugs/bug-quota-display-truncation.md +223 -0
  8. package/docs/stories/README.md +3 -1
  9. package/docs/stories/US-DEPS-major-upgrade.md +62 -0
  10. package/package.json +4 -4
  11. package/src/agents/agent-config.ts +2 -0
  12. package/src/agents/discover-agents.ts +4 -0
  13. package/src/config/config.ts +4 -0
  14. package/src/extension/crew-vibes/config.ts +1 -1
  15. package/src/extension/crew-vibes/figures.ts +1 -1
  16. package/src/extension/crew-vibes/footer.ts +292 -0
  17. package/src/extension/crew-vibes/index.ts +74 -70
  18. package/src/extension/crew-vibes/provider-usage.ts +119 -53
  19. package/src/extension/team-tool.ts +11 -0
  20. package/src/prompt/prompt-runtime.ts +65 -0
  21. package/src/runtime/child-pi.ts +58 -43
  22. package/src/runtime/cross-extension-rpc.ts +1 -1
  23. package/src/runtime/pi-args.ts +2 -0
  24. package/src/runtime/post-exit-stdio-guard.ts +22 -4
  25. package/src/runtime/stale-reconciler.ts +9 -4
  26. package/src/runtime/task-runner.ts +1 -0
  27. package/src/runtime/team-runner.ts +11 -16
  28. package/src/state/atomic-write.ts +46 -19
  29. package/src/state/event-log.ts +77 -24
  30. package/src/state/mailbox.ts +6 -3
  31. package/src/ui/pi-ui-compat.ts +11 -0
  32. package/src/utils/conflict-detect.ts +9 -3
  33. package/src/utils/paths.ts +7 -1
  34. package/src/worktree/cleanup.ts +19 -0
  35. package/src/worktree/worktree-manager.ts +145 -34
@@ -68,6 +68,21 @@ export function loadZaiToken(): string | undefined {
68
68
  }
69
69
  }
70
70
 
71
+ /** Load the Minimax API key from env or auth.json. */
72
+ export function loadMinimaxToken(): string | undefined {
73
+ const envKey = process.env.MINIMAX_API_KEY?.trim();
74
+ if (envKey) return envKey;
75
+ try {
76
+ const data = JSON.parse(readFileSync(piAuthPath(), "utf8")) as {
77
+ minimax?: { key?: string };
78
+ };
79
+ const key = data.minimax?.key;
80
+ return typeof key === "string" && key.length > 0 ? key : undefined;
81
+ } catch {
82
+ return undefined;
83
+ }
84
+ }
85
+
71
86
  /** Copilot host entry keys used by the legacy GitHub Copilot CLI. */
72
87
  type CopilotHostEntry = {
73
88
  oauth_token?: string;
@@ -262,69 +277,120 @@ export function clearProviderUsageCache(): void {
262
277
  cachedAt = 0;
263
278
  }
264
279
 
280
+ /** Minimax token plan remains response shape. */
281
+ type MinimaxModelRemain = {
282
+ model_name?: string;
283
+ current_interval_remaining_percent?: number;
284
+ current_weekly_remaining_percent?: number;
285
+ end_time?: number;
286
+ weekly_end_time?: number;
287
+ };
288
+ type MinimaxUsageResponse = {
289
+ model_remains?: MinimaxModelRemain[];
290
+ base_resp?: { status_code?: number; status_msg?: string };
291
+ };
292
+
293
+ async function fetchMinimaxUsage(token: string): Promise<ProviderUsage> {
294
+ const data = await withTimeout(10000, async (signal) => {
295
+ const res = await fetch("https://www.minimax.io/v1/token_plan/remains", {
296
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
297
+ signal,
298
+ });
299
+ if (!res.ok) throw new Error(`minimax usage HTTP ${res.status}`);
300
+ return (await res.json()) as MinimaxUsageResponse;
301
+ });
302
+ if (data.base_resp?.status_code !== 0) throw new Error(data.base_resp?.status_msg || "minimax API error");
303
+
304
+ // Find the "general" model (text/chat). Fall back to first model.
305
+ const models = data.model_remains ?? [];
306
+ const general = models.find((m) => m.model_name === "general") ?? models[0];
307
+ if (!general) throw new Error("minimax: no model data");
308
+
309
+ // remaining_percent → used percent
310
+ const intervalUsed = 100 - (general.current_interval_remaining_percent ?? 100);
311
+ const weeklyUsed = 100 - (general.current_weekly_remaining_percent ?? 100);
312
+
313
+ const intervalReset = typeof general.end_time === "number" ? new Date(general.end_time).toISOString() : null;
314
+ const weeklyReset = typeof general.weekly_end_time === "number" ? new Date(general.weekly_end_time).toISOString() : null;
315
+
316
+ return {
317
+ providerName: "Minimax",
318
+ fiveHourPercent: intervalUsed,
319
+ fiveHourResetAt: intervalReset,
320
+ weeklyPercent: weeklyUsed,
321
+ weeklyResetAt: weeklyReset,
322
+ };
323
+ }
324
+
325
+ /** Providers that have a quota API. */
326
+ const QUOTA_PROVIDERS = new Set(["anthropic", "minimax", "minimax-cn", "zai", "github-copilot"]);
327
+
328
+ /** Check if a provider supports quota checking. */
329
+ export function providerSupportsQuota(provider: string): boolean {
330
+ return QUOTA_PROVIDERS.has(provider);
331
+ }
332
+
333
+ /** Fetch usage for a SPECIFIC provider. Returns null if not supported. */
334
+ async function fetchForProvider(provider: string): Promise<ProviderUsage | null> {
335
+ switch (provider) {
336
+ case "anthropic": {
337
+ const token = loadAnthropicToken();
338
+ if (!token) return null;
339
+ const base = await fetchAnthropicUsage(token);
340
+ return { providerName: "Claude", ...base };
341
+ }
342
+ case "minimax":
343
+ case "minimax-cn": {
344
+ const token = loadMinimaxToken();
345
+ if (!token) return null;
346
+ return await fetchMinimaxUsage(token);
347
+ }
348
+ case "zai": {
349
+ const token = loadZaiToken();
350
+ if (!token) return null;
351
+ const usage = await fetchZaiUsage(token);
352
+ usage.providerName = "z.ai";
353
+ return usage;
354
+ }
355
+ case "github-copilot": {
356
+ const token = loadCopilotToken();
357
+ if (!token) return null;
358
+ const pct = await fetchCopilotMonthlyPercent(token);
359
+ if (pct === undefined) return null;
360
+ return {
361
+ providerName: "Copilot",
362
+ fiveHourPercent: 0,
363
+ fiveHourResetAt: null,
364
+ weeklyPercent: pct,
365
+ weeklyResetAt: null,
366
+ copilotMonthlyPercent: pct,
367
+ };
368
+ }
369
+ default:
370
+ return null;
371
+ }
372
+ }
373
+
265
374
  /**
266
- * Fetch provider rate-limit usage, caching the result for `maxAgeMs`.
267
- *
268
- * Tries providers in order: Anthropic → z.ai → Copilot.
269
- * Returns the first one that has credentials + responds successfully.
270
- * Returns `null` when no credentials exist or all fetches fail — never throws.
375
+ * Fetch provider rate-limit usage for the current model's provider.
376
+ * Returns `null` when provider has no quota API or no credentials.
271
377
  */
272
- export async function fetchProviderUsage(maxAgeMs = 300000): Promise<ProviderUsage | null> {
273
- // Serve fresh-enough cache without hitting the network.
378
+ export async function fetchProviderUsage(maxAgeMs = 300000, provider?: string): Promise<ProviderUsage | null> {
379
+ if (!provider || !QUOTA_PROVIDERS.has(provider)) {
380
+ cachedUsage = null;
381
+ return null;
382
+ }
274
383
  if (cachedUsage !== null && Date.now() - cachedAt < maxAgeMs) {
275
384
  return cachedUsage;
276
385
  }
277
-
278
386
  try {
279
- // Try Anthropic first
280
- const anthropicToken = loadAnthropicToken();
281
- if (anthropicToken) {
282
- const base = await fetchAnthropicUsage(anthropicToken);
283
- const usage: ProviderUsage = {
284
- providerName: "Claude",
285
- fiveHourPercent: base.fiveHourPercent,
286
- fiveHourResetAt: base.fiveHourResetAt,
287
- weeklyPercent: base.weeklyPercent,
288
- weeklyResetAt: base.weeklyResetAt,
289
- };
387
+ const usage = await fetchForProvider(provider);
388
+ if (usage) {
290
389
  cachedUsage = usage;
291
390
  cachedAt = Date.now();
292
- return usage;
293
391
  }
294
-
295
- // Try z.ai
296
- const zaiToken = loadZaiToken();
297
- if (zaiToken) {
298
- const usage = await fetchZaiUsage(zaiToken);
299
- usage.providerName = "z.ai";
300
- cachedUsage = usage;
301
- cachedAt = Date.now();
302
- return usage;
303
- }
304
-
305
- // Try Copilot
306
- const copilotToken = loadCopilotToken();
307
- if (copilotToken) {
308
- const monthlyPercent = await fetchCopilotMonthlyPercent(copilotToken);
309
- if (monthlyPercent !== undefined) {
310
- const usage: ProviderUsage = {
311
- providerName: "Copilot",
312
- fiveHourPercent: 0,
313
- fiveHourResetAt: null,
314
- weeklyPercent: monthlyPercent,
315
- weeklyResetAt: null,
316
- copilotMonthlyPercent: monthlyPercent,
317
- };
318
- cachedUsage = usage;
319
- cachedAt = Date.now();
320
- return usage;
321
- }
322
- }
323
-
324
- // No credentials for any provider
325
- return null;
392
+ return usage;
326
393
  } catch {
327
- // Network error / timeout / parse error — fail gracefully.
328
394
  return null;
329
395
  }
330
396
  }
@@ -498,6 +498,17 @@ export function handleSteer(params: TeamToolParamsValue, ctx: TeamContext): PiTe
498
498
  }
499
499
  task.pendingSteers.push(message);
500
500
  saveRunTasks(loaded.manifest, loaded.tasks);
501
+ // Real-time steer delivery: write to steering file so child can read immediately
502
+ try {
503
+ const steeringDir = `${loaded.manifest.artifactsRoot}/steering`;
504
+ fs.mkdirSync(steeringDir, { recursive: true });
505
+ fs.appendFileSync(
506
+ `${steeringDir}/${taskId}.jsonl`,
507
+ JSON.stringify({ type: "steer", message, ts: new Date().toISOString() }) + "\n",
508
+ );
509
+ } catch {
510
+ // Best-effort: file write failure doesn't block the steer from pending array
511
+ }
501
512
  appendEvent(loaded.manifest.eventsPath, {
502
513
  type: "task.steer_queued",
503
514
  runId,
@@ -1,9 +1,12 @@
1
+ import * as fs from "node:fs";
1
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
3
 
3
4
  export const PI_TEAMS_INHERIT_PROJECT_CONTEXT_ENV = "PI_TEAMS_INHERIT_PROJECT_CONTEXT";
4
5
  export const PI_TEAMS_INHERIT_SKILLS_ENV = "PI_TEAMS_INHERIT_SKILLS";
5
6
  export const PI_CREW_INHERIT_PROJECT_CONTEXT_ENV = "PI_CREW_INHERIT_PROJECT_CONTEXT";
6
7
  export const PI_CREW_INHERIT_SKILLS_ENV = "PI_CREW_INHERIT_SKILLS";
8
+ const PI_CREW_MAX_OUTPUT_ENV = "PI_CREW_MAX_OUTPUT";
9
+ const PI_CREW_STEERING_FILE_ENV = "PI_CREW_STEERING_FILE";
7
10
 
8
11
  const PROJECT_CONTEXT_HEADER = "\n\n# Project Context\n\nProject-specific instructions and guidelines:\n\n";
9
12
  const SKILLS_HEADER = "\n\nThe following skills provide specialized instructions for specific tasks.";
@@ -58,6 +61,68 @@ export function rewriteTeamWorkerPrompt(prompt: string, options: { inheritProjec
58
61
  }
59
62
 
60
63
  export default function registerPiTeamsPromptRuntime(pi: ExtensionAPI): void {
64
+ // ── Feature 1: maxTokens cap ──────────────────────────────────────────
65
+ // Cap output tokens per API call for background workers. Reads
66
+ // PI_CREW_MAX_OUTPUT_TOKENS env (set by pi-args.ts from agent.maxTokens).
67
+ const maxTokensEnv = process.env[PI_CREW_MAX_OUTPUT_ENV];
68
+ const maxTokensCap = maxTokensEnv ? Number.parseInt(maxTokensEnv, 10) : undefined;
69
+ if (maxTokensCap && maxTokensCap > 0) {
70
+ pi.on("before_provider_request", (event) => {
71
+ const payload = event.payload as Record<string, unknown> | undefined;
72
+ if (!payload || typeof payload !== "object") return;
73
+ // Cap both OpenAI-style max_tokens and Anthropic-style max_tokens
74
+ if (typeof payload.max_tokens === "number" && payload.max_tokens > maxTokensCap) {
75
+ payload.max_tokens = maxTokensCap;
76
+ }
77
+ });
78
+ }
79
+
80
+ // ── Feature 2: real-time steering ──────────────────────────────────────
81
+ // Poll the steering JSONL file for new steer messages. The parent (team
82
+ // tool) writes steers here in real-time; this reader injects them into
83
+ // the active session via pi.sendMessage with deliverAs:"steer".
84
+ const steeringFile = process.env[PI_CREW_STEERING_FILE_ENV];
85
+ if (steeringFile) {
86
+ let lastOffset = 0;
87
+ const pollSteering = (): void => {
88
+ try {
89
+ const stat = fs.statSync(steeringFile, { throwIfNoEntry: false });
90
+ if (!stat || stat.size <= lastOffset) return;
91
+ const fd = fs.openSync(steeringFile, "r");
92
+ try {
93
+ const buf = Buffer.alloc(stat.size - lastOffset);
94
+ fs.readSync(fd, buf, 0, buf.length, lastOffset);
95
+ lastOffset = stat.size;
96
+ const lines = buf.toString("utf8").split("\n").filter(Boolean);
97
+ for (const line of lines) {
98
+ try {
99
+ const entry = JSON.parse(line) as { type?: string; message?: string };
100
+ if (entry.type === "steer" && entry.message) {
101
+ pi.sendMessage(
102
+ { customType: "crew-steer", content: entry.message, display: false },
103
+ { deliverAs: "steer" },
104
+ );
105
+ }
106
+ } catch {
107
+ // Malformed line — skip
108
+ }
109
+ }
110
+ } finally {
111
+ try {
112
+ fs.closeSync(fd);
113
+ } catch {
114
+ /* already closed */
115
+ }
116
+ }
117
+ } catch {
118
+ // File doesn't exist yet or read error — will retry next tick
119
+ }
120
+ };
121
+ const timer = setInterval(pollSteering, 500);
122
+ timer.unref?.();
123
+ }
124
+
125
+ // ── Prompt rewriting (existing) ────────────────────────────────────────
61
126
  pi.on("before_agent_start", (event) => {
62
127
  const inheritProjectContext = readBooleanEnvAny(PI_CREW_INHERIT_PROJECT_CONTEXT_ENV, PI_TEAMS_INHERIT_PROJECT_CONTEXT_ENV);
63
128
  const inheritSkills = readBooleanEnvAny(PI_CREW_INHERIT_SKILLS_ENV, PI_TEAMS_INHERIT_SKILLS_ENV);
@@ -80,6 +80,22 @@ function clearHardKillTimer(pid: number | undefined): void {
80
80
  childHardKillTimers.delete(pid);
81
81
  }
82
82
 
83
+ /**
84
+ * B6: spawn taskkill and attach an 'error' listener. spawn() emits ENOENT/EACCES
85
+ * asynchronously via the 'error' event (not as a throw), so an unlistened spawn
86
+ * can crash the parent as an uncaught exception. taskkill is a standard Windows
87
+ * binary so this is defensive, but the listener keeps failures bounded.
88
+ */
89
+ function spawnTaskkillSafe(pid: number): void {
90
+ const taskkillChild = spawn("taskkill", ["/pid", String(pid), "/t", "/f"], {
91
+ stdio: "ignore",
92
+ windowsHide: true,
93
+ });
94
+ taskkillChild.on("error", (err) => {
95
+ logInternalError("child-pi.taskkill-spawn-error", err instanceof Error ? err : new Error(String(err)), `pid=${pid}`);
96
+ });
97
+ }
98
+
83
99
  export function killProcessPid(pid: number): void {
84
100
  if (!Number.isInteger(pid) || pid <= 0) return;
85
101
  try {
@@ -87,10 +103,7 @@ export function killProcessPid(pid: number): void {
87
103
  // 3.8: Windows path uses taskkill /T /F (force kill the entire tree).
88
104
  // taskkill itself can silently fail (PID gone, permission denied, etc.)
89
105
  // so verify after 2s and log a warning if the process is still alive.
90
- spawn("taskkill", ["/pid", String(pid), "/t", "/f"], {
91
- stdio: "ignore",
92
- windowsHide: true,
93
- });
106
+ spawnTaskkillSafe(pid);
94
107
  const verifyTimer = setTimeout(() => {
95
108
  try {
96
109
  process.kill(pid, 0); // throws ESRCH when dead
@@ -101,10 +114,7 @@ export function killProcessPid(pid: number): void {
101
114
  `pid=${pid}`,
102
115
  );
103
116
  try {
104
- spawn("taskkill", ["/pid", String(pid), "/t", "/f"], {
105
- stdio: "ignore",
106
- windowsHide: true,
107
- });
117
+ spawnTaskkillSafe(pid);
108
118
  } catch {
109
119
  /* best-effort */
110
120
  }
@@ -238,6 +248,8 @@ export interface ChildPiRunInput {
238
248
  excludeContextBash?: boolean;
239
249
  /** pi session ID for session naming (aligns with pi-crew run ID) */
240
250
  sessionId?: string;
251
+ /** Path to steering JSONL file for real-time steer injection. */
252
+ steeringFile?: string;
241
253
  /** Run ID for cleanup tracking */
242
254
  runId?: string;
243
255
  /** Agent ID for cleanup tracking */
@@ -317,6 +329,8 @@ const BASE_ALLOWLIST: string[] = [
317
329
  "PI_TEAMS_PI_BIN",
318
330
  "PI_TEAMS_MOCK_CHILD_PI",
319
331
  "PI_CREW_ALLOW_MOCK",
332
+ "PI_CREW_MAX_OUTPUT",
333
+ "PI_CREW_STEERING_FILE",
320
334
  ];
321
335
 
322
336
  export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv, model?: string): SpawnOptions {
@@ -888,6 +902,22 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
888
902
  skillPaths: input.skillPaths,
889
903
  role: input.role,
890
904
  });
905
+ // Pass steering file path to child for real-time steer injection
906
+ if (input.steeringFile) built.env.PI_CREW_STEERING_FILE = input.steeringFile;
907
+ // B5: if the parent already aborted before we spawn, do not start the child
908
+ // at all. Spawning a doomed process wastes resources, and the abort listener
909
+ // registered below will not re-fire for an already-aborted signal (so the
910
+ // child would only be killed later by the response-timeout path). Return a
911
+ // cancelled-style result immediately.
912
+ if (input.signal?.aborted) {
913
+ return {
914
+ exitCode: null,
915
+ stdout: "",
916
+ stderr: "",
917
+ error: "Aborted before spawn (parent AbortSignal already aborted)",
918
+ aborted: true,
919
+ };
920
+ }
891
921
  const spawnSpec = getPiSpawnCommand(built.args);
892
922
  try {
893
923
  return await new Promise<ChildPiRunResult>((resolve) => {
@@ -1123,45 +1153,30 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
1123
1153
  turnCount += 1;
1124
1154
  if (maxTurns !== undefined && !softLimitReached && turnCount >= maxTurns) {
1125
1155
  softLimitReached = true;
1126
- // Inject steer via stdin to tell child to wrap up.
1127
- // Steer injection is ADVISORY: it asks the worker to wrap up. The real
1128
- // enforcement is the hard-abort at maxTurns + graceTurns (below). So a
1129
- // failed/non-writable stdin must NOT kill the worker that destroys a
1130
- // valid answer already in stdout (Phase-0 root cause of the
1131
- // disableTools/maxTurns:1 exit-null bug). Just log + let the hard-abort
1132
- // path handle a genuinely runaway worker.
1133
- if (child.stdin?.writable) {
1134
- const steerPayload =
1135
- JSON.stringify({
1136
- type: "steer",
1137
- message:
1138
- "You have reached your turn limit. Wrap up immediately — provide your final answer now.",
1139
- }) + "\n";
1140
- const writeSucceeded = child.stdin.write(steerPayload);
1141
- if (!writeSucceeded) {
1142
- // Normal Node backpressure: the payload is buffered and will flush on
1143
- // 'drain'. NOT a failure — do NOT kill the worker. The steer is
1144
- // advisory; if the worker ignores it and runs past maxTurns +
1145
- // graceTurns, the hard-abort below terminates it.
1156
+ // C8: deliver the "wrap up" advisory by appending to the steering JSONL
1157
+ // file the child polls (PI_CREW_STEERING_FILE). The child is spawned with
1158
+ // stdio:["ignore",...], so child.stdin is null and the old stdin branch was
1159
+ // dead code that only spammed logs on every soft-limit hit. Advisory only —
1160
+ // the hard-abort below at maxTurns + graceTurns is the real enforcement, so
1161
+ // a failed write must NOT kill the worker.
1162
+ if (input.steeringFile) {
1163
+ try {
1164
+ fs.appendFileSync(
1165
+ input.steeringFile,
1166
+ JSON.stringify({
1167
+ type: "steer",
1168
+ message:
1169
+ "You have reached your turn limit. Wrap up immediately — provide your final answer now.",
1170
+ }) + "\n",
1171
+ "utf-8",
1172
+ );
1173
+ } catch (err) {
1146
1174
  logInternalError(
1147
- "child-pi.steer-backpressure",
1148
- new Error(
1149
- "stdin write returned false (normal backpressure); steer buffered, worker NOT killed",
1150
- ),
1175
+ "child-pi.steer-write-failed",
1176
+ err instanceof Error ? err : new Error(String(err)),
1151
1177
  `pid=${child.pid}`,
1152
1178
  );
1153
1179
  }
1154
- } else {
1155
- // stdin closed (worker already finished) or otherwise unwritable.
1156
- // Also advisory — the worker is done or nearly done; let it exit
1157
- // naturally. Hard-abort remains the safety net for true runaways.
1158
- logInternalError(
1159
- "child-pi.steer-not-writable",
1160
- new Error(
1161
- "stdin not writable when attempting steer injection (worker may be done); worker NOT killed",
1162
- ),
1163
- `pid=${child.pid}`,
1164
- );
1165
1180
  }
1166
1181
  } else if (maxTurns !== undefined && softLimitReached && turnCount >= maxTurns + (graceTurns ?? 5)) {
1167
1182
  // Hard abort — terminate after grace turns
@@ -1,5 +1,5 @@
1
1
  import * as crypto from "node:crypto";
2
- import { isHmacEnabled, extractSignaturePayload, verifyRpcSignature } from "../extension/rpc-hmac.ts";
2
+ import { extractSignaturePayload, isHmacEnabled, verifyRpcSignature } from "../extension/rpc-hmac.ts";
3
3
 
4
4
  export interface EventBus {
5
5
  on(event: string, handler: (data: unknown) => void): () => void;
@@ -360,6 +360,8 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
360
360
  PI_TEAMS_DEPTH: String(parentDepth + 1),
361
361
  PI_TEAMS_MAX_DEPTH: String(maxDepth),
362
362
  PI_TEAMS_ROLE: input.agent.name,
363
+ // maxTokens cap for background workers — prompt-runtime reads this to cap API output
364
+ ...(input.agent.maxTokens ? { PI_CREW_MAX_OUTPUT: String(input.agent.maxTokens) } : {}),
363
365
  },
364
366
  tempDir,
365
367
  };
@@ -9,6 +9,9 @@ export interface ChildWithPipedStdio {
9
9
  stdout: ChildProcess["stdout"];
10
10
  stderr: ChildProcess["stderr"];
11
11
  on: ChildProcess["on"];
12
+ /** Set by Node after the child exits. Used to detect a post-exit attach. */
13
+ exitCode?: number | null;
14
+ signalCode?: NodeJS.Signals | null;
12
15
  }
13
16
 
14
17
  export interface ChildWithKill {
@@ -72,13 +75,28 @@ export function attachPostExitStdioGuard(child: ChildWithPipedStdio, options: Po
72
75
  stderrEnded = true;
73
76
  if (stdoutEnded && stderrEnded) clearTimers();
74
77
  });
75
- child.on("exit", () => {
76
- exited = true;
77
- armIdleTimer();
78
+
79
+ const armHardTimer = (): void => {
78
80
  if (hardTimer) return;
79
81
  hardTimer = setTimeout(destroyUnendedStdio, hardMs);
80
82
  hardTimer.unref();
81
- });
83
+ };
84
+ const onExit = (): void => {
85
+ exited = true;
86
+ armIdleTimer();
87
+ armHardTimer();
88
+ };
89
+ // The guard is normally attached from INSIDE the child's 'exit' handler
90
+ // (child-pi.ts), i.e. AFTER the child has already exited. A listener
91
+ // registered with child.on('exit') during the in-flight 'exit' dispatch
92
+ // will NOT fire (Node clones the listener array before iterating), so
93
+ // relying on it alone makes this guard a no-op and can hang the run if a
94
+ // descendant process holds the stdio pipes open. When a prior exit is
95
+ // detectable (exitCode/signalCode already set by Node), arm immediately.
96
+ if (child.exitCode != null || child.signalCode != null) {
97
+ onExit();
98
+ }
99
+ child.on("exit", onExit);
82
100
  child.on("close", clearTimers);
83
101
  child.on("error", clearTimers);
84
102
 
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { errors } from "../errors.ts";
5
+ import { atomicWriteJson } from "../state/atomic-write.ts";
5
6
  import { saveRunManifest } from "../state/state-store.ts";
6
7
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
7
8
  import { recordFromTask, upsertCrewAgent } from "./crew-agent-records.ts";
@@ -67,8 +68,12 @@ function checkResultFile(manifest: TeamRunManifest, tasks: TeamTaskState[]): { f
67
68
  );
68
69
  if (allTerminal) {
69
70
  // All tasks are terminal but manifest status was not updated — repair it.
71
+ // Derive the run status from task outcomes instead of blindly marking
72
+ // "completed": a run where every task failed/cancelled is NOT a success.
73
+ const hasFailed = tasks.some((t) => t.status === "failed");
74
+ const onlyCancelledOrSkipped = tasks.every((t) => t.status === "cancelled" || t.status === "skipped");
75
+ manifest.status = hasFailed ? "failed" : onlyCancelledOrSkipped ? "cancelled" : "completed";
70
76
  // Persist manifest status change immediately to make checkResultFile self-contained.
71
- manifest.status = "completed";
72
77
  saveRunManifest(manifest);
73
78
  // Sync agent records even when tasks are already terminal
74
79
  // (e.g., a previous reconcile fixed tasks but crashed before updating agents)
@@ -492,8 +497,8 @@ export function reconcileOrphanedTempWorkspaces(
492
497
  const tasks: TeamTaskState[] = JSON.parse(fs.readFileSync(tasksPath, "utf-8"));
493
498
  const result = reconcileStaleRun(manifest, tasks, now);
494
499
  if (result.repaired && result.repairedTasks) {
495
- // Persist repaired tasks
496
- fs.writeFileSync(tasksPath, JSON.stringify(result.repairedTasks, null, 2));
500
+ // Persist repaired tasks (atomic — temp+rename to survive mid-write crash)
501
+ atomicWriteJson(tasksPath, result.repairedTasks);
497
502
  // Update manifest status
498
503
  const updated = {
499
504
  ...manifest,
@@ -501,7 +506,7 @@ export function reconcileOrphanedTempWorkspaces(
501
506
  updatedAt: new Date(now).toISOString(),
502
507
  summary: `Stale run reconciled: ${result.detail}`,
503
508
  };
504
- fs.writeFileSync(manifestPath, JSON.stringify(updated, null, 2));
509
+ atomicWriteJson(manifestPath, updated);
505
510
  // Update agent records
506
511
  for (const task of result.repairedTasks) {
507
512
  try {
@@ -449,6 +449,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
449
449
  runId: manifest.runId,
450
450
  agentId: task.id,
451
451
  artifactsRoot: manifest.artifactsRoot,
452
+ steeringFile: `${manifest.artifactsRoot}/steering/${task.id}.jsonl`,
452
453
  onSpawn: (pid) => {
453
454
  try {
454
455
  ({ task, tasks } = checkpointTask(manifest, tasks, task, "child-spawned", pid));
@@ -757,22 +757,17 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
757
757
  stopTeamHeartbeat();
758
758
  // P1: Catch unhandled errors — ensure manifest/tasks/agents are terminal so they don't stay "running" forever.
759
759
  const message = error instanceof Error ? error.message : String(error);
760
- // FIX (2026-07-02): drop withRunLock here entirely.
761
- // Previously this catch path acquired withRunLock to reload manifest+tasks
762
- // from disk (best-effort with in-memory fallback). But the closeout path at
763
- // line ~1596 ALSO holds the same run lock for its final save — when a late
764
- // failure fires during closeout (e.g. async hook error after run.completed),
765
- // this catch path can hit `Run 'run.lock' is locked by another operation.`
766
- // and the error propagates as "Unhandled error in team runner" after the
767
- // run has actually completed. Lock acquisition here is unnecessary
768
- // the closeout writes through to disk before this catch fires (or after —
769
- // either is fine), and the in-memory state already contains the latest
770
- // post-completion manifest/tasks. We use in-memory state unconditionally.
771
- // The stale-data concern is mitigated by the dispatcher having re-read disk
772
- // state under lock at line ~1364 for each iteration; the manifest+task
773
- // objects in the calling scope are already post-merge.
774
- const freshManifest = manifest;
775
- const freshTasks = refreshTaskGraphQueues(input.tasks);
760
+ // Re-read the latest persisted state from disk instead of trusting
761
+ // input.tasks (the ORIGINAL start snapshot, still all "queued" — it is never
762
+ // mutated by executeTeamRunCore). A late failure during closeout would
763
+ // otherwise map every task to "failed", overwriting tasks that already
764
+ // completed during the run. loadRunManifestById is the established
765
+ // fresh-read pattern in this file (see ~line 1269); it is best-effort with
766
+ // no lock, consistent with the lock-drop decision below. If the disk read
767
+ // fails, fall back to input.tasks so the run is still marked terminal.
768
+ const fresh = loadRunManifestById(manifest.cwd, manifest.runId);
769
+ const freshManifest = fresh?.manifest ?? manifest;
770
+ const freshTasks = refreshTaskGraphQueues(fresh?.tasks ?? input.tasks);
776
771
  const failedAt = new Date().toISOString();
777
772
  const tasks = freshTasks.map((task) =>
778
773
  task.status === "running" || task.status === "queued" || task.status === "waiting"