pi-subagents 0.37.2 → 0.38.0
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 +18 -0
- package/LICENSE +21 -0
- package/README.md +16 -4
- package/package.json +1 -1
- package/src/agents/agents.ts +58 -8
- package/src/extension/index.ts +3 -0
- package/src/extension/rpc.ts +221 -4
- package/src/extension/schemas.ts +13 -1
- package/src/intercom/intercom-bridge.ts +4 -2
- package/src/runs/background/control-channel.ts +8 -1
- package/src/runs/background/subagent-runner.ts +71 -11
- package/src/runs/foreground/chain-clarify.ts +3 -3
- package/src/runs/foreground/execution.ts +149 -51
- package/src/runs/foreground/foreground-control.ts +12 -0
- package/src/runs/foreground/subagent-executor.ts +1 -0
- package/src/runs/shared/acceptance.ts +15 -7
- package/src/runs/shared/pi-args.ts +4 -0
- package/src/runs/shared/subagent-startup-retry.ts +101 -0
- package/src/shared/types.ts +16 -0
- package/src/shared/utils.ts +43 -0
- package/src/slash/slash-commands.ts +3 -3
- package/src/tui/fleet-status.ts +13 -6
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Usage } from "../../shared/types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Retry delays for child processes that exit before any model or tool activity.
|
|
5
|
+
* The schedule is intentionally short and bounded to avoid amplifying persistent
|
|
6
|
+
* launch failures while allowing concurrent Pi startup locks to clear.
|
|
7
|
+
*/
|
|
8
|
+
export const SUBAGENT_STARTUP_RETRY_DELAYS_MS = [250, 750, 1500] as const;
|
|
9
|
+
|
|
10
|
+
/** A genuine startup race should fail well before a model request can complete. */
|
|
11
|
+
export const MAX_SUBAGENT_STARTUP_FAILURE_DURATION_MS = 2000;
|
|
12
|
+
|
|
13
|
+
export interface SubagentStartupFailureEvidence {
|
|
14
|
+
exitCode: number | null | undefined;
|
|
15
|
+
error?: string;
|
|
16
|
+
finalOutput?: string;
|
|
17
|
+
messageCount: number;
|
|
18
|
+
toolCount: number;
|
|
19
|
+
usage: Usage;
|
|
20
|
+
durationMs: number;
|
|
21
|
+
protocolError?: unknown;
|
|
22
|
+
processSignal?: string | null;
|
|
23
|
+
observedMutationAttempt?: boolean;
|
|
24
|
+
detached?: boolean;
|
|
25
|
+
interrupted?: boolean;
|
|
26
|
+
timedOut?: boolean;
|
|
27
|
+
stopped?: boolean;
|
|
28
|
+
turnBudgetExceeded?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function hasUsage(usage: Usage): boolean {
|
|
32
|
+
return usage.input !== 0
|
|
33
|
+
|| usage.output !== 0
|
|
34
|
+
|| usage.cacheRead !== 0
|
|
35
|
+
|| usage.cacheWrite !== 0
|
|
36
|
+
|| usage.cost !== 0
|
|
37
|
+
|| usage.turns !== 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Classify only unexplained, zero-activity child exits as startup failures.
|
|
42
|
+
* This intentionally fails closed: any model output, tool activity, usage,
|
|
43
|
+
* mutation evidence, diagnostic, or lifecycle interruption prevents a retry.
|
|
44
|
+
*/
|
|
45
|
+
export function isRetryableSubagentStartupFailure(evidence: SubagentStartupFailureEvidence): boolean {
|
|
46
|
+
return evidence.exitCode !== undefined
|
|
47
|
+
&& evidence.exitCode !== null
|
|
48
|
+
&& evidence.exitCode !== 0
|
|
49
|
+
&& !evidence.error?.trim()
|
|
50
|
+
&& !evidence.finalOutput?.trim()
|
|
51
|
+
&& evidence.messageCount === 0
|
|
52
|
+
&& evidence.toolCount === 0
|
|
53
|
+
&& !hasUsage(evidence.usage)
|
|
54
|
+
&& evidence.durationMs >= 0
|
|
55
|
+
&& evidence.durationMs <= MAX_SUBAGENT_STARTUP_FAILURE_DURATION_MS
|
|
56
|
+
&& evidence.protocolError === undefined
|
|
57
|
+
&& (evidence.processSignal === undefined || evidence.processSignal === null)
|
|
58
|
+
&& evidence.observedMutationAttempt !== true
|
|
59
|
+
&& evidence.detached !== true
|
|
60
|
+
&& evidence.interrupted !== true
|
|
61
|
+
&& evidence.timedOut !== true
|
|
62
|
+
&& evidence.stopped !== true
|
|
63
|
+
&& evidence.turnBudgetExceeded !== true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function formatSubagentStartupRetryNote(input: {
|
|
67
|
+
model: string;
|
|
68
|
+
attempt: number;
|
|
69
|
+
maxAttempts: number;
|
|
70
|
+
delayMs: number;
|
|
71
|
+
}): string {
|
|
72
|
+
return `[startup-retry] ${input.model} exited before model or tool activity (attempt ${input.attempt}/${input.maxAttempts}). Retrying the same model in ${input.delayMs}ms.`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function formatSubagentStartupRetryExhaustedError(input: {
|
|
76
|
+
model: string;
|
|
77
|
+
attempts: number;
|
|
78
|
+
}): string {
|
|
79
|
+
return `Subagent failed to start after ${input.attempts} attempts on ${input.model}; no model, tool, output, usage, or diagnostic activity was observed. This may be a concurrent Pi startup race. Retry the run or temporarily lower subagent concurrency.`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Wait for a retry delay, returning false when any lifecycle signal aborts it. */
|
|
83
|
+
export async function waitForSubagentStartupRetry(delayMs: number, signals: Array<AbortSignal | undefined>): Promise<boolean> {
|
|
84
|
+
const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal));
|
|
85
|
+
if (activeSignals.some((signal) => signal.aborted)) return false;
|
|
86
|
+
if (delayMs <= 0) return true;
|
|
87
|
+
|
|
88
|
+
return await new Promise<boolean>((resolve) => {
|
|
89
|
+
let settled = false;
|
|
90
|
+
const finish = (shouldRetry: boolean): void => {
|
|
91
|
+
if (settled) return;
|
|
92
|
+
settled = true;
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
for (const signal of activeSignals) signal.removeEventListener("abort", abort);
|
|
95
|
+
resolve(shouldRetry);
|
|
96
|
+
};
|
|
97
|
+
const abort = (): void => finish(false);
|
|
98
|
+
const timer = setTimeout(() => finish(true), delayMs);
|
|
99
|
+
for (const signal of activeSignals) signal.addEventListener("abort", abort, { once: true });
|
|
100
|
+
});
|
|
101
|
+
}
|
package/src/shared/types.ts
CHANGED
|
@@ -566,6 +566,11 @@ export interface AgentProgress {
|
|
|
566
566
|
toolCount: number;
|
|
567
567
|
turnCount?: number;
|
|
568
568
|
tokens: number;
|
|
569
|
+
/** Resolved launch model/effort and split usage for public live projections. */
|
|
570
|
+
model?: string;
|
|
571
|
+
thinking?: string;
|
|
572
|
+
inputTokens?: number;
|
|
573
|
+
outputTokens?: number;
|
|
569
574
|
durationMs: number;
|
|
570
575
|
error?: string;
|
|
571
576
|
failedTool?: string;
|
|
@@ -762,6 +767,7 @@ export interface SingleResult {
|
|
|
762
767
|
/** Resolved launch context for this child. */
|
|
763
768
|
context?: "fresh" | "fork";
|
|
764
769
|
exitCode: number;
|
|
770
|
+
processSignal?: string | null;
|
|
765
771
|
detached?: boolean;
|
|
766
772
|
detachedReason?: string;
|
|
767
773
|
interrupted?: boolean;
|
|
@@ -1259,12 +1265,18 @@ export interface ForegroundChildControl {
|
|
|
1259
1265
|
currentPath?: string;
|
|
1260
1266
|
turnCount?: number;
|
|
1261
1267
|
tokens?: number;
|
|
1268
|
+
inputTokens?: number;
|
|
1269
|
+
outputTokens?: number;
|
|
1270
|
+
model?: string;
|
|
1271
|
+
thinking?: string;
|
|
1262
1272
|
toolCount?: number;
|
|
1263
1273
|
interrupt?: () => boolean;
|
|
1264
1274
|
}
|
|
1265
1275
|
|
|
1266
1276
|
export interface ForegroundRunControl {
|
|
1267
1277
|
runId: string;
|
|
1278
|
+
/** Originating parent session; required for public fleet projection. */
|
|
1279
|
+
sessionId?: string;
|
|
1268
1280
|
mode: SubagentRunMode;
|
|
1269
1281
|
startedAt: number;
|
|
1270
1282
|
updatedAt: number;
|
|
@@ -1281,6 +1293,10 @@ export interface ForegroundRunControl {
|
|
|
1281
1293
|
currentPath?: string;
|
|
1282
1294
|
turnCount?: number;
|
|
1283
1295
|
tokens?: number;
|
|
1296
|
+
inputTokens?: number;
|
|
1297
|
+
outputTokens?: number;
|
|
1298
|
+
model?: string;
|
|
1299
|
+
thinking?: string;
|
|
1284
1300
|
toolCount?: number;
|
|
1285
1301
|
/** Independently tracked children for foreground parallel work and fleet inspection. */
|
|
1286
1302
|
activeChildren?: Map<number, ForegroundChildControl>;
|
package/src/shared/utils.ts
CHANGED
|
@@ -412,6 +412,49 @@ export function compactForegroundDetails(details: Details): Details {
|
|
|
412
412
|
};
|
|
413
413
|
}
|
|
414
414
|
|
|
415
|
+
/**
|
|
416
|
+
* Streaming counterparts to compactForegroundResult / compactCompletedProgress.
|
|
417
|
+
*
|
|
418
|
+
* The completed-compaction helpers above bail out while a child is still
|
|
419
|
+
* `running`, so a long or deeply nested fan-out streams full, unbounded progress on
|
|
420
|
+
* every tick. Pi serializes each streamed `tool_execution_update` as a single
|
|
421
|
+
* child-stdout line, which the parent reads under `MAX_CHILD_PENDING_LINE_BYTES`;
|
|
422
|
+
* an unbounded running snapshot can cross that cap and kill the child with
|
|
423
|
+
* `protocol_output_limit`.
|
|
424
|
+
*
|
|
425
|
+
* These bound the STREAMED snapshot only. The final returned result keeps the full
|
|
426
|
+
* live progress and message transcript, and every live-display consumer already
|
|
427
|
+
* reads just the last few entries (`recentTools.slice(-3)`, `recentOutput.slice(-5)`).
|
|
428
|
+
*/
|
|
429
|
+
export const MAX_STREAMED_RECENT_TOOLS = 32;
|
|
430
|
+
export const MAX_STREAMED_TOOL_CALLS = 64;
|
|
431
|
+
export const MAX_STREAMED_OUTPUT_LINE_CHARS = 2000;
|
|
432
|
+
|
|
433
|
+
/** Keep only the most recent tool-history entries in a streamed snapshot. */
|
|
434
|
+
export function boundStreamedRecentTools(recentTools: AgentProgress["recentTools"]): AgentProgress["recentTools"] {
|
|
435
|
+
return recentTools.slice(-MAX_STREAMED_RECENT_TOOLS).map((tool) => ({ ...tool }));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Cap per-line length of recent output so one long line can't inflate a snapshot. */
|
|
439
|
+
export function boundStreamedRecentOutput(recentOutput: string[]): string[] {
|
|
440
|
+
return recentOutput.map((line) =>
|
|
441
|
+
line.length > MAX_STREAMED_OUTPUT_LINE_CHARS
|
|
442
|
+
? `${line.slice(0, MAX_STREAMED_OUTPUT_LINE_CHARS)}… [truncated]`
|
|
443
|
+
: line,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Compact tool-call summaries for a streamed snapshot, standing in for the
|
|
449
|
+
* unbounded `messages` transcript. Prefers an existing `toolCalls` summary, else
|
|
450
|
+
* derives one from `messages`; bounded to the most recent calls.
|
|
451
|
+
*/
|
|
452
|
+
export function boundStreamedToolCalls(result: Pick<SingleResult, "toolCalls" | "messages">): ToolCallSummary[] | undefined {
|
|
453
|
+
const summaries = result.toolCalls?.length ? result.toolCalls : extractToolCallSummaries(result.messages);
|
|
454
|
+
if (!summaries.length) return undefined;
|
|
455
|
+
return summaries.slice(-MAX_STREAMED_TOOL_CALLS).map((summary) => ({ ...summary }));
|
|
456
|
+
}
|
|
457
|
+
|
|
415
458
|
export function hasEmptyTerminalAssistantResponse(messages: Message[]): boolean {
|
|
416
459
|
const lastAssistant = messages.findLast((message) => message.role === "assistant");
|
|
417
460
|
return lastAssistant?.role === "assistant"
|
|
@@ -336,12 +336,12 @@ class SubagentsStopSelector implements Component {
|
|
|
336
336
|
}
|
|
337
337
|
return;
|
|
338
338
|
}
|
|
339
|
-
if (matchesKey(data, "up")) {
|
|
339
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
340
340
|
this.selected = Math.max(0, this.selected - 1);
|
|
341
341
|
this.tui.requestRender();
|
|
342
342
|
return;
|
|
343
343
|
}
|
|
344
|
-
if (matchesKey(data, "down")) {
|
|
344
|
+
if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
345
345
|
this.selected = Math.min(this.targets.length - 1, this.selected + 1);
|
|
346
346
|
this.tui.requestRender();
|
|
347
347
|
return;
|
|
@@ -375,7 +375,7 @@ class SubagentsStopSelector implements Component {
|
|
|
375
375
|
if (target.kind === "async") lines.push(this.theme.fg("dim", "Stop ends this run; use interrupt for a resumable pause."));
|
|
376
376
|
lines.push(this.theme.fg("dim", "Enter/Y confirms · N returns · Esc cancels"));
|
|
377
377
|
} else {
|
|
378
|
-
lines.push(this.theme.fg("dim", "
|
|
378
|
+
lines.push(this.theme.fg("dim", "↑↓/jk select · Enter confirm · Esc cancel"));
|
|
379
379
|
}
|
|
380
380
|
return lines;
|
|
381
381
|
}
|
package/src/tui/fleet-status.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import { type EditorComponent, isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
3
|
import type { AsyncJobStep, FleetViewPlacement, SubagentState } from "../shared/types.ts";
|
|
4
4
|
|
|
5
5
|
export const FLEET_STATUS_WIDGET_KEY = "subagent-fleet-status";
|
|
@@ -252,12 +252,12 @@ export class SubagentFleetStatus {
|
|
|
252
252
|
|
|
253
253
|
const roster = this.rosterKeys();
|
|
254
254
|
const selectedIndex = Math.max(0, roster.indexOf(this.selectedKey));
|
|
255
|
-
if (matchesKey(data, "down")) {
|
|
255
|
+
if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
256
256
|
this.selectedKey = roster[Math.min(roster.length - 1, selectedIndex + 1)] ?? "main";
|
|
257
257
|
this.refresh();
|
|
258
258
|
return { consume: true };
|
|
259
259
|
}
|
|
260
|
-
if (matchesKey(data, "up")) {
|
|
260
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
261
261
|
if (selectedIndex === 0) {
|
|
262
262
|
this.deactivate();
|
|
263
263
|
return { consume: true };
|
|
@@ -297,7 +297,7 @@ export class SubagentFleetStatus {
|
|
|
297
297
|
const roster = this.rosterKeys();
|
|
298
298
|
const selectedIndex = Math.max(0, roster.indexOf(this.selectedKey));
|
|
299
299
|
const hint = this.active
|
|
300
|
-
? "
|
|
300
|
+
? "↑↓/jk select · enter inspect · esc back"
|
|
301
301
|
: "esc to interrupt · ← for agents · ↓ to manage";
|
|
302
302
|
const lines = [truncateToWidth(` ${theme.fg("dim", hint)}`, width), ""];
|
|
303
303
|
lines.push(truncateToWidth(` ${this.bullet(0, selectedIndex, theme)} main`, width));
|
|
@@ -341,9 +341,16 @@ export class SubagentFleetStatus {
|
|
|
341
341
|
}
|
|
342
342
|
|
|
343
343
|
private editorHasFocus(): boolean {
|
|
344
|
-
// pi-tui exposes focus mutation but no focus getter
|
|
344
|
+
// pi-tui exposes focus mutation but no focus getter, so inspect the focused
|
|
345
|
+
// component structurally. instanceof is unreliable across jiti module boundaries.
|
|
345
346
|
const focused = (this.tui as unknown as { focusedComponent?: unknown } | undefined)?.focusedComponent;
|
|
346
|
-
|
|
347
|
+
if (!focused || typeof focused !== "object") return false;
|
|
348
|
+
const candidate = focused as Partial<EditorComponent>;
|
|
349
|
+
return typeof candidate.render === "function"
|
|
350
|
+
&& typeof candidate.invalidate === "function"
|
|
351
|
+
&& typeof candidate.handleInput === "function"
|
|
352
|
+
&& typeof candidate.getText === "function"
|
|
353
|
+
&& typeof candidate.setText === "function";
|
|
347
354
|
}
|
|
348
355
|
|
|
349
356
|
private getRenderKey(): string {
|