pi-subagents 0.27.0 → 0.28.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.
@@ -121,6 +121,8 @@ export interface SubagentParamsLike {
121
121
  chain?: ChainStep[];
122
122
  tasks?: TaskParam[];
123
123
  concurrency?: number;
124
+ timeoutMs?: number;
125
+ maxRuntimeMs?: number;
124
126
  worktree?: boolean;
125
127
  context?: "fresh" | "fork";
126
128
  async?: boolean;
@@ -169,6 +171,7 @@ interface ExecutionContextData {
169
171
  artifactsDir: string;
170
172
  backgroundRequestedWhileClarifying: boolean;
171
173
  effectiveAsync: boolean;
174
+ foregroundTimeoutMs?: number;
172
175
  controlConfig: ResolvedControlConfig;
173
176
  intercomBridge: IntercomBridgeState;
174
177
  nestedRoute?: NestedRouteInfo;
@@ -250,7 +253,7 @@ function rememberForegroundRun(state: SubagentState, input: { runId: string; mod
250
253
  children: input.results.map((result, index) => ({
251
254
  agent: result.agent,
252
255
  index,
253
- status: resolveSubagentResultStatus({ exitCode: result.exitCode, interrupted: result.interrupted, detached: result.detached }),
256
+ status: resolveSubagentResultStatus({ exitCode: result.exitCode, interrupted: result.interrupted, detached: result.detached, timedOut: result.timedOut }),
254
257
  ...(result.sessionFile ? { sessionFile: result.sessionFile } : {}),
255
258
  })),
256
259
  });
@@ -716,6 +719,7 @@ async function emitForegroundResultIntercom(input: {
716
719
  exitCode: result.exitCode,
717
720
  interrupted: result.interrupted,
718
721
  detached: result.detached,
722
+ timedOut: result.timedOut,
719
723
  }),
720
724
  summary: resultSummaryForIntercom(result),
721
725
  index,
@@ -765,6 +769,21 @@ function validationErrorResult(mode: Details["mode"], text: string): AgentToolRe
765
769
  return { content: [{ type: "text", text }], isError: true, details: { mode, results: [] } };
766
770
  }
767
771
 
772
+ function resolveForegroundTimeoutMs(params: SubagentParamsLike): { timeoutMs?: number; error?: string } {
773
+ const rawTimeout = (params as { timeoutMs?: unknown }).timeoutMs;
774
+ const rawMaxRuntime = (params as { maxRuntimeMs?: unknown }).maxRuntimeMs;
775
+ for (const [name, value] of [["timeoutMs", rawTimeout], ["maxRuntimeMs", rawMaxRuntime]] as const) {
776
+ if (value !== undefined && (typeof value !== "number" || !Number.isInteger(value) || value < 1)) {
777
+ return { error: `${name} must be a positive integer.` };
778
+ }
779
+ }
780
+ if (rawTimeout !== undefined && rawMaxRuntime !== undefined && rawTimeout !== rawMaxRuntime) {
781
+ return { error: "timeoutMs and maxRuntimeMs are aliases; provide only one or use identical values." };
782
+ }
783
+ const timeoutMs = rawTimeout ?? rawMaxRuntime;
784
+ return timeoutMs === undefined ? {} : { timeoutMs };
785
+ }
786
+
768
787
  function validateAcceptanceForExecution(params: SubagentParamsLike): AgentToolResult<Details> | null {
769
788
  const topLevelErrors = validateAcceptanceInput(params.acceptance);
770
789
  if (topLevelErrors.length > 0) return validationErrorResult("single", topLevelErrors.join(" "));
@@ -815,6 +834,9 @@ function validateExecutionInput(
815
834
  };
816
835
  }
817
836
 
837
+ const timeoutResolution = resolveForegroundTimeoutMs(params);
838
+ if (timeoutResolution.error) return validationErrorResult(getRequestedModeLabel(params), timeoutResolution.error);
839
+
818
840
  if (hasSingle && params.agent && !agents.find((agent) => agent.name === params.agent)) {
819
841
  return {
820
842
  content: [{ type: "text", text: `Unknown agent: ${params.agent}` }],
@@ -1288,6 +1310,7 @@ async function runChainPath(data: ExecutionContextData, deps: ExecutorDeps): Pro
1288
1310
  onUpdate,
1289
1311
  onControlEvent,
1290
1312
  controlConfig,
1313
+ ...(data.foregroundTimeoutMs !== undefined ? { timeoutMs: data.foregroundTimeoutMs } : {}),
1291
1314
  childIntercomTarget: childIntercomTarget ? (agent, index) => childIntercomTarget(runId, agent, index) : undefined,
1292
1315
  orchestratorIntercomTarget: data.intercomBridge.active ? data.intercomBridge.orchestratorTarget : undefined,
1293
1316
  foregroundControl,
@@ -1344,7 +1367,7 @@ async function runChainPath(data: ExecutionContextData, deps: ExecutorDeps): Pro
1344
1367
  const chainDetails = chainResult.details ? compactForegroundDetails({ ...chainResult.details, runId }) : undefined;
1345
1368
  if (foregroundControl) updateForegroundNestedProjection(foregroundControl);
1346
1369
  if (chainDetails) rememberForegroundRun(deps.state, { runId, mode: "chain", cwd: effectiveCwd, results: chainDetails.results });
1347
- const intercomReceipt = chainDetails && !chainDetails.results.some((result) => result.interrupted || result.detached)
1370
+ const intercomReceipt = chainDetails && !chainDetails.results.some((result) => result.interrupted || result.detached || result.timedOut)
1348
1371
  ? await maybeBuildForegroundIntercomReceipt({
1349
1372
  pi: deps.pi,
1350
1373
  intercomBridge: data.intercomBridge,
@@ -1379,6 +1402,8 @@ interface ForegroundParallelRunInput {
1379
1402
  artifactConfig: ArtifactConfig;
1380
1403
  artifactsDir: string;
1381
1404
  maxOutput?: MaxOutputConfig;
1405
+ timeoutMs?: number;
1406
+ timeoutAt?: number;
1382
1407
  paramsCwd: string;
1383
1408
  maxSubagentDepths: number[];
1384
1409
  availableModels: ModelInfo[];
@@ -1531,6 +1556,7 @@ async function runForegroundParallelTasks(input: ForegroundParallelRunInput): Pr
1531
1556
  cwd: taskCwd,
1532
1557
  signal: input.signal,
1533
1558
  interruptSignal: interruptController.signal,
1559
+ ...(input.timeoutMs !== undefined && input.timeoutAt !== undefined ? { timeoutMs: input.timeoutMs, timeoutAt: input.timeoutAt } : {}),
1534
1560
  allowIntercomDetach: agentConfig?.systemPrompt?.includes(INTERCOM_BRIDGE_MARKER) === true,
1535
1561
  intercomEvents: input.intercomEvents,
1536
1562
  runId: input.runId,
@@ -1544,6 +1570,8 @@ async function runForegroundParallelTasks(input: ForegroundParallelRunInput): Pr
1544
1570
  outputPath,
1545
1571
  outputMode: behavior?.outputMode,
1546
1572
  maxSubagentDepth: input.maxSubagentDepths[index],
1573
+ maxExecutionTimeMs: agentConfig?.maxExecutionTimeMs,
1574
+ maxTokens: agentConfig?.maxTokens,
1547
1575
  controlConfig: input.controlConfig,
1548
1576
  onControlEvent: input.onControlEvent,
1549
1577
  intercomSessionName: input.childIntercomTarget?.(task.agent, index),
@@ -1811,6 +1839,7 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1811
1839
  }
1812
1840
  }
1813
1841
 
1842
+ const timeoutAt = data.foregroundTimeoutMs !== undefined ? Date.now() + data.foregroundTimeoutMs : undefined;
1814
1843
  const results = await runForegroundParallelTasks({
1815
1844
  tasks,
1816
1845
  taskTexts,
@@ -1825,6 +1854,7 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1825
1854
  artifactConfig,
1826
1855
  artifactsDir,
1827
1856
  maxOutput: params.maxOutput,
1857
+ ...(data.foregroundTimeoutMs !== undefined && timeoutAt !== undefined ? { timeoutMs: data.foregroundTimeoutMs, timeoutAt } : {}),
1828
1858
  paramsCwd: effectiveCwd,
1829
1859
  availableModels,
1830
1860
  modelOverrides,
@@ -1852,6 +1882,7 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1852
1882
  if (result.artifactPaths) allArtifactPaths.push(result.artifactPaths);
1853
1883
  }
1854
1884
 
1885
+ const timedOut = results.find((result) => result.timedOut);
1855
1886
  const interrupted = results.find((result) => result.interrupted);
1856
1887
  const details = compactForegroundDetails({
1857
1888
  mode: "parallel",
@@ -1861,6 +1892,13 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1861
1892
  artifacts: allArtifactPaths.length ? { dir: artifactsDir, files: allArtifactPaths } : undefined,
1862
1893
  });
1863
1894
  rememberForegroundRun(deps.state, { runId, mode: "parallel", cwd: effectiveCwd, results: details.results });
1895
+ if (timedOut) {
1896
+ return {
1897
+ content: [{ type: "text", text: `Parallel run timed out (${timedOut.agent}): ${timedOut.error ?? "timeout expired"}` }],
1898
+ details,
1899
+ isError: true,
1900
+ };
1901
+ }
1864
1902
  if (interrupted) {
1865
1903
  return {
1866
1904
  content: [{ type: "text", text: `Parallel run paused after interrupt (${interrupted.agent}). Waiting for explicit next action.` }],
@@ -2091,10 +2129,12 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
2091
2129
  }
2092
2130
  : undefined;
2093
2131
 
2132
+ const timeoutAt = data.foregroundTimeoutMs !== undefined ? Date.now() + data.foregroundTimeoutMs : undefined;
2094
2133
  const r = await runSync(ctx.cwd, agents, params.agent!, task, {
2095
2134
  cwd: effectiveCwd,
2096
2135
  signal,
2097
2136
  interruptSignal: interruptController.signal,
2137
+ ...(data.foregroundTimeoutMs !== undefined && timeoutAt !== undefined ? { timeoutMs: data.foregroundTimeoutMs, timeoutAt } : {}),
2098
2138
  allowIntercomDetach: agentConfig.systemPrompt?.includes(INTERCOM_BRIDGE_MARKER) === true,
2099
2139
  intercomEvents: deps.pi.events,
2100
2140
  runId,
@@ -2107,6 +2147,8 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
2107
2147
  outputPath,
2108
2148
  outputMode: effectiveOutputMode,
2109
2149
  maxSubagentDepth,
2150
+ maxExecutionTimeMs: agentConfig.maxExecutionTimeMs,
2151
+ maxTokens: agentConfig.maxTokens,
2110
2152
  onUpdate: forwardSingleUpdate,
2111
2153
  controlConfig,
2112
2154
  onControlEvent,
@@ -2159,7 +2201,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
2159
2201
  });
2160
2202
  rememberForegroundRun(deps.state, { runId, mode: "single", cwd: effectiveCwd, results: details.results });
2161
2203
 
2162
- if (!r.detached && !r.interrupted) {
2204
+ if (!r.detached && !r.interrupted && !r.timedOut) {
2163
2205
  if (foregroundControl) updateForegroundNestedProjection(foregroundControl);
2164
2206
  const intercomReceipt = await maybeBuildForegroundIntercomReceipt({
2165
2207
  pi: deps.pi,
@@ -2185,6 +2227,14 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
2185
2227
  };
2186
2228
  }
2187
2229
 
2230
+ if (r.timedOut) {
2231
+ return {
2232
+ content: [{ type: "text", text: `Run timed out (${params.agent}): ${r.error ?? "timeout expired"}` }],
2233
+ details,
2234
+ isError: true,
2235
+ };
2236
+ }
2237
+
2188
2238
  if (r.interrupted) {
2189
2239
  return {
2190
2240
  content: [{ type: "text", text: `Run paused after interrupt (${params.agent}). Waiting for explicit next action.` }],
@@ -2412,6 +2462,11 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
2412
2462
  const requestedAsync = effectiveParams.async ?? deps.asyncByDefault;
2413
2463
  const backgroundRequestedWhileClarifying = (hasChain || hasTasks) && requestedAsync && effectiveParams.clarify === true;
2414
2464
  const effectiveAsync = requestedAsync && effectiveParams.clarify !== true;
2465
+ const foregroundTimeout = resolveForegroundTimeoutMs(effectiveParams);
2466
+ if (foregroundTimeout.error) return buildRequestedModeError(effectiveParams, foregroundTimeout.error);
2467
+ if (effectiveAsync && foregroundTimeout.timeoutMs !== undefined) {
2468
+ return buildRequestedModeError(effectiveParams, "timeoutMs/maxRuntimeMs only applies to foreground subagent runs. Omit async:true or use action:'interrupt' for background runs.");
2469
+ }
2415
2470
  const controlConfig = resolveControlConfig(deps.config.control, effectiveParams.control);
2416
2471
 
2417
2472
  const artifactConfig: ArtifactConfig = {
@@ -2463,6 +2518,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
2463
2518
  artifactsDir,
2464
2519
  backgroundRequestedWhileClarifying,
2465
2520
  effectiveAsync,
2521
+ ...(foregroundTimeout.timeoutMs !== undefined ? { foregroundTimeoutMs: foregroundTimeout.timeoutMs } : {}),
2466
2522
  controlConfig,
2467
2523
  intercomBridge,
2468
2524
  nestedRoute,
@@ -34,6 +34,22 @@ const ACCEPTANCE_KEYS = new Set([
34
34
 
35
35
  const REMOVED_ACCEPTANCE_KEYS = new Set(["level", "finalization", "reason"]);
36
36
 
37
+ const EVIDENCE_REPORT_FIELDS: Record<AcceptanceEvidenceKind, string> = {
38
+ "changed-files": "changedFiles: array of changed file paths",
39
+ "tests-added": "testsAddedOrUpdated: array of test files, suites, or cases added/updated",
40
+ "commands-run": "commandsRun: array of commands with result passed/failed/not-run and a short summary",
41
+ "validation-output": "validationOutput: array of relevant validation output summaries",
42
+ "residual-risks": "residualRisks: array of remaining risks or blockers; use [] when none remain",
43
+ "no-staged-files": "noStagedFiles: boolean",
44
+ "diff-summary": "diffSummary: non-empty string summarizing changed behavior and important files",
45
+ "review-findings": "reviewFindings: array of reviewer findings; use [] when no findings remain",
46
+ "manual-notes": "manualNotes: string for manual notes or external evidence",
47
+ };
48
+
49
+ export function formatEvidenceReportFieldMapping(evidence: AcceptanceEvidenceKind[]): string[] {
50
+ return evidence.map((kind) => `- ${kind} -> ${EVIDENCE_REPORT_FIELDS[kind]}`);
51
+ }
52
+
37
53
  function hasArrayItems(value: unknown): boolean {
38
54
  return Array.isArray(value) && value.length > 0;
39
55
  }
@@ -260,6 +276,14 @@ export function formatAcceptancePrompt(acceptance: ResolvedAcceptanceConfig): st
260
276
  "",
261
277
  `Required evidence: ${acceptance.evidence.join(", ") || "none explicitly requested"}`,
262
278
  ];
279
+ if (acceptance.evidence.length > 0) {
280
+ lines.push(
281
+ "",
282
+ "Structured evidence must be present in the `acceptance-report` JSON fields. Markdown sections in your visible answer do not satisfy required evidence by themselves. If you already described evidence in prose, copy or summarize it into the matching JSON field.",
283
+ "Evidence field mapping:",
284
+ ...formatEvidenceReportFieldMapping(acceptance.evidence),
285
+ );
286
+ }
263
287
  if (acceptance.verify.length > 0) {
264
288
  lines.push("", "Runtime verification commands configured by parent:");
265
289
  for (const command of acceptance.verify) lines.push(`- ${command.id}: ${command.command}`);
@@ -283,6 +307,9 @@ export function formatAcceptancePrompt(acceptance: ResolvedAcceptanceConfig): st
283
307
  validationOutput: [],
284
308
  residualRisks: [],
285
309
  noStagedFiles: true,
310
+ diffSummary: "concise summary of changed behavior and important files",
311
+ reviewFindings: [],
312
+ manualNotes: "manual notes or external evidence, if any",
286
313
  notes: "anything else the parent should know",
287
314
  }, null, 2),
288
315
  "```",
@@ -4,6 +4,7 @@ import type {
4
4
  ResolvedAcceptanceConfig,
5
5
  } from "../../shared/types.ts";
6
6
  import { acceptanceFailureMessage } from "./acceptance-evaluation.ts";
7
+ import { formatEvidenceReportFieldMapping } from "./acceptance-contract.ts";
7
8
  import { stripAcceptanceReport } from "./acceptance-reports.ts";
8
9
 
9
10
  const INITIAL_OUTPUT_LIMIT = 8_000;
@@ -42,6 +43,14 @@ export function formatAcceptanceFinalizationPrompt(input: {
42
43
  "",
43
44
  `Required evidence: ${input.acceptance.evidence.join(", ") || "none explicitly requested"}`,
44
45
  ];
46
+ if (input.acceptance.evidence.length > 0) {
47
+ lines.push(
48
+ "",
49
+ "Structured evidence must be present in the final `acceptance-report` JSON fields. Markdown sections in the visible answer do not satisfy required evidence by themselves. If the previous visible output already included the evidence, copy or summarize it into the matching JSON field.",
50
+ "Evidence field mapping:",
51
+ ...formatEvidenceReportFieldMapping(input.acceptance.evidence),
52
+ );
53
+ }
45
54
  if (input.acceptance.verify.length > 0) {
46
55
  lines.push("", "Runtime verification commands that must pass:", ...input.acceptance.verify.map((command) => `- ${command.id}: ${command.command}`));
47
56
  }
@@ -74,6 +83,9 @@ export function formatAcceptanceFinalizationPrompt(input: {
74
83
  validationOutput: [],
75
84
  residualRisks: [],
76
85
  noStagedFiles: true,
86
+ diffSummary: "concise summary of changed behavior and important files",
87
+ reviewFindings: [],
88
+ manualNotes: "manual notes or external evidence, if any",
77
89
  notes: "final self-review summary",
78
90
  }, null, 2),
79
91
  "```",
@@ -25,6 +25,8 @@ export interface RunnerSubagentStep {
25
25
  outputMode?: "inline" | "file-only";
26
26
  sessionFile?: string;
27
27
  maxSubagentDepth?: number;
28
+ maxExecutionTimeMs?: number;
29
+ maxTokens?: number;
28
30
  structuredOutput?: {
29
31
  schema: JsonSchemaObject;
30
32
  schemaPath: string;
@@ -5,7 +5,7 @@ export interface WorkflowGraphBuildInput {
5
5
  runId: string;
6
6
  mode?: SubagentRunMode;
7
7
  steps: ChainStep[];
8
- results?: Array<Pick<SingleResult, "exitCode" | "detached" | "interrupted" | "error" | "acceptance">>;
8
+ results?: Array<Pick<SingleResult, "exitCode" | "detached" | "interrupted" | "timedOut" | "error" | "acceptance">>;
9
9
  currentFlatIndex?: number;
10
10
  currentStepIndex?: number;
11
11
  stepStatuses?: Array<{ status?: string; error?: string }>;
@@ -26,6 +26,8 @@ function normalizeStatus(status: string | undefined): WorkflowNodeStatus | undef
26
26
  return "paused";
27
27
  case "detached":
28
28
  return "detached";
29
+ case "timed-out":
30
+ return "timed-out";
29
31
  case "pending":
30
32
  return "pending";
31
33
  default:
@@ -33,9 +35,10 @@ function normalizeStatus(status: string | undefined): WorkflowNodeStatus | undef
33
35
  }
34
36
  }
35
37
 
36
- function resultStatus(result: Pick<SingleResult, "exitCode" | "detached" | "interrupted"> | undefined): WorkflowNodeStatus | undefined {
38
+ function resultStatus(result: Pick<SingleResult, "exitCode" | "detached" | "interrupted" | "timedOut"> | undefined): WorkflowNodeStatus | undefined {
37
39
  if (!result) return undefined;
38
40
  if (result.detached) return "detached";
41
+ if (result.timedOut) return "timed-out";
39
42
  if (result.interrupted) return "paused";
40
43
  return result.exitCode === 0 ? "completed" : "failed";
41
44
  }
@@ -63,6 +66,7 @@ function seqLabel(step: SequentialStep, stepIndex: number): string {
63
66
  function summarizeParallelStatuses(statuses: WorkflowNodeStatus[]): WorkflowNodeStatus {
64
67
  if (statuses.some((status) => status === "running")) return "running";
65
68
  if (statuses.some((status) => status === "failed")) return "failed";
69
+ if (statuses.some((status) => status === "timed-out")) return "timed-out";
66
70
  if (statuses.some((status) => status === "paused")) return "paused";
67
71
  if (statuses.some((status) => status === "detached")) return "detached";
68
72
  if (statuses.length > 0 && statuses.every((status) => status === "completed")) return "completed";
@@ -30,7 +30,7 @@ export interface ChainOutputMapEntry {
30
30
 
31
31
  export type ChainOutputMap = Record<string, ChainOutputMapEntry>;
32
32
 
33
- export type WorkflowNodeStatus = "pending" | "running" | "completed" | "failed" | "paused" | "detached";
33
+ export type WorkflowNodeStatus = "pending" | "running" | "completed" | "failed" | "paused" | "detached" | "timed-out";
34
34
 
35
35
  export interface WorkflowGraphNode {
36
36
  id: string;
@@ -142,7 +142,7 @@ export interface ControlEvent {
142
142
  recentFailureSummary?: string;
143
143
  }
144
144
 
145
- export type SubagentResultStatus = "completed" | "failed" | "paused" | "detached";
145
+ export type SubagentResultStatus = "completed" | "failed" | "paused" | "detached" | "timed-out";
146
146
  export type SubagentRunMode = "single" | "parallel" | "chain";
147
147
 
148
148
  export type PublicNestedStepSummary = Pick<
@@ -408,6 +408,13 @@ export interface AcceptanceLedger {
408
408
  };
409
409
  }
410
410
 
411
+ export interface ResourceLimitExceeded {
412
+ kind: "maxExecutionTimeMs" | "maxTokens";
413
+ limit: number;
414
+ observed?: number;
415
+ message: string;
416
+ }
417
+
411
418
  export interface SingleResult {
412
419
  agent: string;
413
420
  task: string;
@@ -415,6 +422,8 @@ export interface SingleResult {
415
422
  detached?: boolean;
416
423
  detachedReason?: string;
417
424
  interrupted?: boolean;
425
+ timedOut?: boolean;
426
+ resourceLimitExceeded?: ResourceLimitExceeded;
418
427
  messages?: Message[];
419
428
  usage: Usage;
420
429
  model?: string;
@@ -639,6 +648,7 @@ export interface AsyncStatus {
639
648
  structuredOutputPath?: string;
640
649
  structuredOutputSchemaPath?: string;
641
650
  acceptance?: AcceptanceLedger;
651
+ resourceLimitExceeded?: ResourceLimitExceeded;
642
652
  }>;
643
653
  sessionDir?: string;
644
654
  outputFile?: string;
@@ -780,6 +790,8 @@ export interface RunSyncOptions {
780
790
  cwd?: string;
781
791
  signal?: AbortSignal;
782
792
  interruptSignal?: AbortSignal;
793
+ timeoutMs?: number;
794
+ timeoutAt?: number;
783
795
  allowIntercomDetach?: boolean;
784
796
  intercomEvents?: IntercomEventBus;
785
797
  onUpdate?: (r: import("@earendil-works/pi-agent-core").AgentToolResult<Details>) => void;
@@ -798,6 +810,8 @@ export interface RunSyncOptions {
798
810
  outputPath?: string;
799
811
  outputMode?: OutputMode;
800
812
  maxSubagentDepth?: number;
813
+ maxExecutionTimeMs?: number;
814
+ maxTokens?: number;
801
815
  nestedRoute?: NestedRouteInfo;
802
816
  /** Override the agent's default model (format: "provider/id" or just "id") */
803
817
  modelOverride?: string;
@@ -204,6 +204,13 @@ export function getSingleResultOutput(result: Pick<SingleResult, "finalOutput" |
204
204
  return result.finalOutput ?? getFinalOutput(result.messages ?? []);
205
205
  }
206
206
 
207
+ export function formatResourceLimitExceeded(input: { agent: string; kind: "maxExecutionTimeMs" | "maxTokens"; limit: number; observed?: number }): string {
208
+ if (input.kind === "maxExecutionTimeMs") {
209
+ return `Resource limit exceeded for ${input.agent}: maxExecutionTimeMs ${input.limit}ms.`;
210
+ }
211
+ return `Resource limit exceeded for ${input.agent}: maxTokens ${input.limit}${input.observed !== undefined ? ` (observed ${input.observed})` : ""}.`;
212
+ }
213
+
207
214
  /**
208
215
  * Extract display items (text and tool calls) from messages
209
216
  */
package/src/tui/render.ts CHANGED
@@ -233,6 +233,7 @@ function formatAcceptanceStatus(result: Details["results"][number]): string | un
233
233
 
234
234
  function resultStatusLine(result: Details["results"][number], output: string): string {
235
235
  if (result.detached) return result.detachedReason ? `Detached: ${result.detachedReason}` : "Detached";
236
+ if (result.timedOut) return `Timed out${result.error ? `: ${result.error}` : ""}`;
236
237
  if (result.interrupted) return "Paused";
237
238
  if (result.exitCode !== 0) return `Error: ${result.error ?? (firstOutputLine(output) || `exit ${result.exitCode}`)}`;
238
239
  const acceptance = formatAcceptanceStatus(result);
@@ -244,6 +245,7 @@ function resultStatusLine(result: Details["results"][number], output: string): s
244
245
  function resultGlyph(result: Details["results"][number], output: string, theme: Theme, running = result.progress?.status === "running", seed = progressRunningSeed(result.progress ?? result.progressSummary)): string {
245
246
  if (running) return theme.fg("accent", runningGlyph(seed));
246
247
  if (result.detached) return theme.fg("warning", "■");
248
+ if (result.timedOut) return theme.fg("error", "✗");
247
249
  if (result.interrupted) return theme.fg("warning", "■");
248
250
  if (result.exitCode !== 0) return theme.fg("error", "✗");
249
251
  if (hasEmptyTextOutputWithoutOutputTarget(result.task, output)) return theme.fg("warning", "✓");
@@ -363,18 +365,19 @@ function widgetStatusGlyph(job: AsyncJobState, theme: Theme): string {
363
365
  return theme.fg("error", "✗");
364
366
  }
365
367
 
366
- function widgetStepGlyph(status: AsyncJobStep["status"], theme: Theme, seed?: number): string {
368
+ function widgetStepGlyph(status: AsyncJobStep["status"] | WorkflowNodeStatus, theme: Theme, seed?: number): string {
367
369
  if (status === "running") return theme.fg("accent", runningGlyph(seed));
368
370
  if (status === "complete" || status === "completed") return theme.fg("success", "✓");
369
- if (status === "failed") return theme.fg("error", "✗");
371
+ if (status === "failed" || status === "timed-out") return theme.fg("error", "✗");
370
372
  if (status === "paused") return theme.fg("warning", "■");
371
373
  return theme.fg("muted", "◦");
372
374
  }
373
375
 
374
- function widgetStepStatus(status: AsyncJobStep["status"], theme: Theme): string {
376
+ function widgetStepStatus(status: AsyncJobStep["status"] | WorkflowNodeStatus, theme: Theme): string {
375
377
  if (status === "running") return theme.fg("accent", "running");
376
378
  if (status === "complete" || status === "completed") return theme.fg("success", "complete");
377
379
  if (status === "failed") return theme.fg("error", "failed");
380
+ if (status === "timed-out") return theme.fg("error", "timed out");
378
381
  if (status === "paused") return theme.fg("warning", "paused");
379
382
  return theme.fg("dim", status);
380
383
  }
@@ -511,7 +514,7 @@ function isDoneResult(result: Details["results"][number]): boolean {
511
514
  const status = result.progress?.status;
512
515
  if (status === "completed") return true;
513
516
  if (status === "running" || status === "pending") return false;
514
- if (result.interrupted || result.detached) return false;
517
+ if (result.interrupted || result.detached || result.timedOut) return false;
515
518
  return result.exitCode === 0;
516
519
  }
517
520
 
@@ -583,7 +586,7 @@ function buildMultiProgressLabel(details: Pick<Details, "mode" | "results" | "pr
583
586
 
584
587
  if (details.mode === "parallel") {
585
588
  const totalCount = details.totalSteps ?? details.results.length;
586
- const statuses = new Array(totalCount).fill("pending") as Array<"pending" | "running" | "completed" | "failed" | "detached">;
589
+ const statuses = new Array(totalCount).fill("pending") as WorkflowNodeStatus[];
587
590
  for (const progress of details.progress ?? []) {
588
591
  if (progress.index >= 0 && progress.index < totalCount) statuses[progress.index] = progress.status;
589
592
  }
@@ -594,11 +597,13 @@ function buildMultiProgressLabel(details: Pick<Details, "mode" | "results" | "pr
594
597
  const index = result.progress?.index ?? progressFromArray?.index ?? i;
595
598
  if (index < 0 || index >= totalCount) continue;
596
599
  const status = result.progress?.status
597
- ?? (result.interrupted || result.detached
598
- ? "detached"
599
- : result.exitCode === 0
600
- ? "completed"
601
- : "failed");
600
+ ?? (result.timedOut
601
+ ? "timed-out"
602
+ : result.interrupted || result.detached
603
+ ? "detached"
604
+ : result.exitCode === 0
605
+ ? "completed"
606
+ : "failed");
602
607
  statuses[index] = status;
603
608
  }
604
609
  const running = statuses.filter((status) => status === "running").length;
@@ -1060,7 +1065,7 @@ function renderMultiCompact(d: Details, theme: Theme): Component {
1060
1065
  || d.results.some((r) => r.progress?.status === "running")
1061
1066
  || workflowGraphHasStatus(d, ["running"]);
1062
1067
  const failed = d.results.some((r) => r.exitCode !== 0 && r.progress?.status !== "running")
1063
- || workflowGraphHasStatus(d, ["failed"]);
1068
+ || workflowGraphHasStatus(d, ["failed", "timed-out"]);
1064
1069
  const paused = d.results.some((r) => (r.interrupted || r.detached) && r.progress?.status !== "running")
1065
1070
  || workflowGraphHasStatus(d, ["paused", "detached"]);
1066
1071
  let totalSummary = d.progressSummary;
@@ -1136,7 +1141,7 @@ function renderMultiCompact(d: Details, theme: Theme): Component {
1136
1141
  const activity = compactCurrentActivity(rProg);
1137
1142
  c.addChild(new Text(truncLine(theme.fg("dim", ` ⎿ ${activity}`), width), 0, 0));
1138
1143
  c.addChild(new Text(truncLine(theme.fg("accent", " Press Ctrl+O for live detail"), width), 0, 0));
1139
- } else if (!rPending && (r.exitCode !== 0 || r.interrupted || r.detached || hasEmptyTextOutputWithoutOutputTarget(r.task, output))) {
1144
+ } else if (!rPending && (r.exitCode !== 0 || r.interrupted || r.detached || r.timedOut || hasEmptyTextOutputWithoutOutputTarget(r.task, output))) {
1140
1145
  c.addChild(new Text(truncLine(theme.fg(r.exitCode !== 0 ? "error" : "dim", ` ⎿ ${resultStatusLine(r, output)}`), width), 0, 0));
1141
1146
  }
1142
1147
  const outputTarget = extractOutputTarget(r.task);
@@ -1273,7 +1278,7 @@ export function renderSubagentResult(
1273
1278
  && r.progress?.status !== "running"
1274
1279
  && hasEmptyTextOutputWithoutOutputTarget(r.task, getSingleResultOutput(r)),
1275
1280
  );
1276
- const hasWorkflowFailure = workflowGraphHasStatus(d, ["failed"]);
1281
+ const hasWorkflowFailure = workflowGraphHasStatus(d, ["failed", "timed-out"]);
1277
1282
  const hasWorkflowPause = workflowGraphHasStatus(d, ["paused", "detached"]);
1278
1283
  const icon = hasRunning
1279
1284
  ? theme.fg("warning", "running")