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.
@@ -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
+ }
@@ -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>;
@@ -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", "↑/↓ select · Enter confirm · Esc cancel"));
378
+ lines.push(this.theme.fg("dim", "↑↓/jk select · Enter confirm · Esc cancel"));
379
379
  }
380
380
  return lines;
381
381
  }
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
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
- ? "↑↓ select · enter inspect · esc back"
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; fail closed if this compatibility seam disappears.
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
- return focused instanceof Editor;
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 {