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.
- package/CHANGELOG.md +15 -0
- package/dist/build-meta.json +164 -115
- package/dist/index.mjs +986 -555
- package/dist/index.mjs.map +4 -4
- package/docs/REVIEW-FINDINGS-2026-07-CORE.md +139 -0
- package/docs/REVIEW-FINDINGS-2026-07.md +125 -0
- package/docs/bugs/bug-quota-display-truncation.md +223 -0
- package/docs/stories/README.md +3 -1
- package/docs/stories/US-DEPS-major-upgrade.md +62 -0
- package/package.json +4 -4
- package/src/agents/agent-config.ts +2 -0
- package/src/agents/discover-agents.ts +4 -0
- package/src/config/config.ts +4 -0
- package/src/extension/crew-vibes/config.ts +1 -1
- package/src/extension/crew-vibes/figures.ts +1 -1
- package/src/extension/crew-vibes/footer.ts +292 -0
- package/src/extension/crew-vibes/index.ts +74 -70
- package/src/extension/crew-vibes/provider-usage.ts +119 -53
- package/src/extension/team-tool.ts +11 -0
- package/src/prompt/prompt-runtime.ts +65 -0
- package/src/runtime/child-pi.ts +58 -43
- package/src/runtime/cross-extension-rpc.ts +1 -1
- package/src/runtime/pi-args.ts +2 -0
- package/src/runtime/post-exit-stdio-guard.ts +22 -4
- package/src/runtime/stale-reconciler.ts +9 -4
- package/src/runtime/task-runner.ts +1 -0
- package/src/runtime/team-runner.ts +11 -16
- package/src/state/atomic-write.ts +46 -19
- package/src/state/event-log.ts +77 -24
- package/src/state/mailbox.ts +6 -3
- package/src/ui/pi-ui-compat.ts +11 -0
- package/src/utils/conflict-detect.ts +9 -3
- package/src/utils/paths.ts +7 -1
- package/src/worktree/cleanup.ts +19 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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);
|
package/src/runtime/child-pi.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1127
|
-
//
|
|
1128
|
-
//
|
|
1129
|
-
//
|
|
1130
|
-
//
|
|
1131
|
-
//
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
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-
|
|
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 {
|
|
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;
|
package/src/runtime/pi-args.ts
CHANGED
|
@@ -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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
761
|
-
//
|
|
762
|
-
//
|
|
763
|
-
//
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
//
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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"
|