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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.28.0] - 2026-06-03
6
+
7
+ ### Added
8
+ - Added foreground-only `timeoutMs`/`maxRuntimeMs` for single, parallel, and chain subagent runs. Timed-out children are soft-interrupted, keep completed sibling/prior results, and return `timedOut: true` with a stable timeout message.
9
+ - Added per-agent `maxExecutionTimeMs` and `maxTokens` resource limits. Foreground and async children stop with a clear `resourceLimitExceeded` result when the configured runtime or observed token budget is reached.
10
+
11
+ ### Changed
12
+ - Strengthened tool and skill guidance so writer subagents launched from plans, specs, issues, or broad fixes proactively use structured `acceptance` instead of burying validation requirements only in task prose.
13
+
14
+ ### Fixed
15
+ - Removed a provider-unfriendly required-only subschema from the public `acceptance` tool schema so Kimi models served through OpenCode Go can load the `subagent` tool, while keeping runtime validation for empty acceptance contracts.
16
+ - Clarified acceptance-report prompts so required evidence like `diff-summary` must be copied into structured JSON fields such as `diffSummary`, not only described in visible prose.
17
+
5
18
  ## [0.27.0] - 2026-05-30
6
19
 
7
20
  ### Changed
package/README.md CHANGED
@@ -145,7 +145,7 @@ Use `~/.pi/agent/settings.json` for a user override or `.pi/settings.json` for a
145
145
 
146
146
  ## Where running subagents show up
147
147
 
148
- Foreground runs stream progress in the conversation while they run.
148
+ Foreground runs stream progress in the conversation while they run. Use `timeoutMs` or its alias `maxRuntimeMs` when a foreground run must return within a wall-clock budget. When the timeout expires, running children are soft-interrupted, completed children stay in the result, and timed-out children return `timedOut: true` with a stable timeout message.
149
149
 
150
150
  Background runs keep working after control returns to you. Inspect active runs with `subagent({ action: "status" })`, or a specific run with `subagent({ action: "status", id: "..." })`.
151
151
 
@@ -436,6 +436,8 @@ defaultProgress: true
436
436
  completionGuard: false
437
437
  interactive: true
438
438
  maxSubagentDepth: 1
439
+ maxExecutionTimeMs: 600000
440
+ maxTokens: 50000
439
441
  ---
440
442
 
441
443
  Your system prompt goes here.
@@ -462,6 +464,8 @@ Important fields:
462
464
  | `completionGuard` | Set `false` only for non-implementation agents that may mention implementation words while using mutation-capable tools such as `bash`. |
463
465
  | `interactive` | Parsed for compatibility but not enforced in v1. |
464
466
  | `maxSubagentDepth` | Tightens nested delegation for this agent’s children. |
467
+ | `maxExecutionTimeMs` | Stops each foreground or async child run for this agent after the given number of milliseconds. |
468
+ | `maxTokens` | Stops each foreground or async child run for this agent when observed input plus output tokens reach the limit. Token enforcement is best-effort because usage is reported after model events arrive. |
465
469
 
466
470
  ### Tool and extension selection
467
471
 
@@ -793,6 +797,7 @@ Agent definitions are not loaded into context by default. Management actions let
793
797
  | `model` | string | agent default | Override model. |
794
798
  | `tasks` | array | - | Top-level parallel tasks. Supports `agent`, `task`, `cwd`, `count`, `output`, `outputMode`, `reads`, `progress`, `skill`, `model`, and `acceptance`. |
795
799
  | `concurrency` | number | config or `4` | Top-level parallel concurrency. |
800
+ | `timeoutMs` / `maxRuntimeMs` | number | - | Foreground wall-clock timeout for single, parallel, and chain runs. Timed-out children return `timedOut: true`; async/background runs reject it. |
796
801
  | `worktree` | boolean | false | Create isolated git worktrees for parallel tasks. |
797
802
  | `chain` | array | - | Sequential, static parallel, and dynamic fanout chain steps. Sequential steps and parallel child tasks support `phase`, `label`, `as`, `outputSchema`, and `acceptance` in addition to the usual execution fields. Dynamic fanout uses `expand`, one child `parallel` template, and `collect`; group-level acceptance is not supported because there is no child session to finalize. |
798
803
  | `context` | `fresh \| fork` | agent default or `fresh` | `fork` creates real branched sessions from the parent leaf. Packaged `planner`, `worker`, and `oracle` default to `fork`. |
@@ -911,6 +916,19 @@ Session directory precedence is: `params.sessionDir`, then `config.defaultSessio
911
916
 
912
917
  Controls nested delegation when no inherited `PI_SUBAGENT_MAX_DEPTH` is already in effect. Per-agent `maxSubagentDepth` can tighten the limit for that agent’s child runs, but cannot relax an inherited stricter limit. This applies even to children that explicitly declare `tools: subagent`; at the cap, execution fanout is blocked instead of silently hiding nested work.
913
918
 
919
+ ### Agent resource limits
920
+
921
+ Set `maxExecutionTimeMs` and `maxTokens` in agent frontmatter or through `subagent({ action: "create" | "update", config })` to bound a specific agent across foreground and async runs.
922
+
923
+ ```yaml
924
+ maxExecutionTimeMs: 600000
925
+ maxTokens: 50000
926
+ ```
927
+
928
+ When a limit is reached, the child receives a soft interrupt, the run fails with a clear `Resource limit exceeded...` error, and the result includes `resourceLimitExceeded` with the limit kind, configured limit, and observed token count when available. Resource-limit failures do not trigger fallback model retries. `maxTokens` is best-effort because providers report usage after message events; a child may exceed the exact limit before the runtime can stop it.
929
+
930
+ Spawn-count and per-agent child-concurrency quotas are not part of this release; use `maxSubagentDepth` and parallel `concurrency` for those boundaries today.
931
+
914
932
  ### `intercomBridge`
915
933
 
916
934
  ```json
@@ -969,7 +987,7 @@ Debug artifacts live under `{sessionDir}/subagent-artifacts/` or a user-scoped t
969
987
  - `{runId}_{agent}.jsonl`
970
988
  - `{runId}_{agent}_meta.json`
971
989
 
972
- Metadata records timing, usage, exit code, final model, attempted models, and fallback attempt outcomes.
990
+ Metadata records timing, usage, exit code, final model, attempted models, fallback attempt outcomes, and any resource-limit termination reason.
973
991
 
974
992
  Session files are stored under a per-run session directory. With `context: "fork"`, each child starts with `--session <branched-session-file>` produced from the parent’s current leaf. That is a real session fork, not an injected summary.
975
993
 
@@ -1018,6 +1036,32 @@ Public acceptance config is evidence-driven. There is no public `level` field an
1018
1036
 
1019
1037
  Self-review finalization never counts as `reviewed`, and it never counts as `verified` unless configured runtime verification commands actually pass. The visible child output remains the initial answer; finalization reports and residual risks are stored in the acceptance ledger and async/status details.
1020
1038
 
1039
+ When delegating implementation from a plan or spec, keep the task focused on what to implement and put the definition of done in `acceptance` so the runtime can finalize and evaluate it:
1040
+
1041
+ ```ts
1042
+ subagent({
1043
+ agent: "worker",
1044
+ async: true,
1045
+ task: "Implement the plan at /Users/me/docs/mcp-alignment-plan.md. Use scout artifacts in ./handoff/ as context. Do not commit the scout artifacts.",
1046
+ acceptance: {
1047
+ criteria: [
1048
+ "Implementation follows /Users/me/docs/mcp-alignment-plan.md",
1049
+ "Plan acceptance checks are addressed",
1050
+ "Scout handoff artifacts are not committed",
1051
+ "Focused validation for changed behavior passes",
1052
+ "Residual risks or skipped checks are reported"
1053
+ ],
1054
+ evidence: ["changed-files", "commands-run", "validation-output", "residual-risks"],
1055
+ verify: [{ id: "focused", command: "npm test -- --runInBand" }],
1056
+ stopRules: [
1057
+ "Do not edit unrelated files",
1058
+ "Stop and report if the plan requires an unapproved product decision"
1059
+ ],
1060
+ maxFinalizationTurns: 3
1061
+ }
1062
+ })
1063
+ ```
1064
+
1021
1065
  ## Live progress
1022
1066
 
1023
1067
  Foreground runs show compact live progress for single, chain, and parallel modes: current tool, recent output, token counts, duration, activity freshness, current-tool duration, and chain graph metadata when available.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Pi extension for delegating tasks to subagents with chains, parallel execution, and TUI clarification",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
@@ -134,7 +134,7 @@ subagent({
134
134
  { agent: "reviewer", phase: "Planning", label: "Scheduler contract", as: "schedulerPlan", task: "Plan fixes for scheduler contract. Inspect the current diff. Do not modify project/source files; returning findings via the configured output artifact is allowed.", output: "plans/scheduler.md", outputMode: "file-only" },
135
135
  { agent: "reviewer", phase: "Planning", label: "Sandbox/security", as: "sandboxPlan", task: "Plan fixes for sandbox/security. Inspect the current diff. Do not modify project/source files; returning findings via the configured output artifact is allowed.", output: "plans/sandbox.md", outputMode: "file-only" }
136
136
  ], concurrency: 3 },
137
- { agent: "worker", phase: "Implementation", label: "Apply accepted fixes", as: "workerResult", task: "Apply only the accepted fixes from these planning summaries. You are the sole writer for the active worktree. Run focused validation and report changed files, commands, failures, and remaining issues.\n\nDeploy plan:\n{outputs.deployPlan}\n\nScheduler plan:\n{outputs.schedulerPlan}\n\nSandbox plan:\n{outputs.sandboxPlan}", output: "worker/fixes.md", outputMode: "file-only", progress: true },
137
+ { agent: "worker", phase: "Implementation", label: "Apply accepted fixes", as: "workerResult", task: "Apply only the accepted fixes from these planning summaries. You are the sole writer for the active worktree.\n\nDeploy plan:\n{outputs.deployPlan}\n\nScheduler plan:\n{outputs.schedulerPlan}\n\nSandbox plan:\n{outputs.sandboxPlan}", acceptance: { criteria: ["Accepted fixes from each planning summary are applied", "Focused validation for changed behavior passes", "Changed files, validation commands, failures, and residual risks are reported"], evidence: ["changed-files", "commands-run", "validation-output", "residual-risks"], stopRules: ["Do not expand product scope beyond accepted fixes", "Stop and report if a fix requires an unapproved decision"], maxFinalizationTurns: 3 }, output: "worker/fixes.md", outputMode: "file-only", progress: true },
138
138
  { parallel: [
139
139
  { agent: "reviewer", phase: "Validation", label: "Deploy/scheduler validation", task: "Validate the post-worker diff for deploy and scheduler fixes. Start from the worker result: {outputs.workerResult}. Do not modify project/source files; returning findings via the configured output artifact is allowed.", output: "validation/deploy-scheduler.md", outputMode: "file-only" },
140
140
  { agent: "reviewer", phase: "Validation", label: "Sandbox validation", task: "Validate the post-worker diff for sandbox/security fixes. Start from the worker result: {outputs.workerResult}. Do not modify project/source files; returning findings via the configured output artifact is allowed.", output: "validation/sandbox.md", outputMode: "file-only" }
@@ -696,6 +696,34 @@ Use the structured `acceptance` field when the run should carry an explicit acce
696
696
 
697
697
  Goal-style requests map to `acceptance`. If the user says `/goal`, “goal”, “active goal”, “continue until evidence says done”, or “verify against a goal” for a subagent run, create an explicit run-scoped acceptance contract: `criteria` for the target, `evidence` and `verify` for proof, `stopRules` for constraints, and `maxFinalizationTurns` for the bounded loop budget.
698
698
 
699
+ When launching a writer/worker from a plan, PRD, spec, issue, or broad fix, set structured `acceptance` proactively. Put implementation instructions, plan paths, and handoff artifacts in `task`; put the definition of done in `acceptance.criteria`, proof requirements in `acceptance.evidence` and `acceptance.verify`, constraints in `acceptance.stopRules`, and usually set `maxFinalizationTurns: 3`. Do not bury all validation requirements only in the task prompt.
700
+
701
+ Example writer handoff:
702
+
703
+ ```typescript
704
+ subagent({
705
+ agent: "worker",
706
+ async: true,
707
+ task: "Implement the plan at /Users/me/docs/mcp-alignment-plan.md. Use scout artifacts in ./handoff/ as context. Do not commit the scout artifacts.",
708
+ acceptance: {
709
+ criteria: [
710
+ "Implementation follows /Users/me/docs/mcp-alignment-plan.md",
711
+ "Plan acceptance checks are addressed",
712
+ "Scout handoff artifacts are not committed",
713
+ "Focused validation for changed behavior passes",
714
+ "Residual risks or skipped checks are reported"
715
+ ],
716
+ evidence: ["changed-files", "commands-run", "validation-output", "residual-risks"],
717
+ verify: [{ id: "focused", command: "npm test -- --runInBand" }],
718
+ stopRules: [
719
+ "Do not edit unrelated files",
720
+ "Stop and report if the plan requires an unapproved product decision"
721
+ ],
722
+ maxFinalizationTurns: 3
723
+ }
724
+ })
725
+ ```
726
+
699
727
  The first `worker` implements the approved plan. The parent continues with independent inspection or validation prep while it runs, not parallel edits to the same worktree. When the async worker completes, treat its handoff as the transition into review, not as final completion, unless the user explicitly asked for worker-only work, review-only output, or to stop after implementation. Parallel reviewers inspect the resulting diff from fresh context. Validators check behavior with the best available evidence: commands, tests, browser/CLI interaction, screenshots, logs, or manual reproduction notes. The final `worker` applies synthesized review fixes in forked context, then the parent looks over the final diff before completing. The parent may launch these steps as an initial async chain when the workflow is already clear, or as follow-up subagent runs after each async completion. Initial chains should pass `async: true` so the main chat is unblocked; avoid `clarify: true` unless the user asked for foreground clarification. Do not stop after parallel review unless the user explicitly asked for review-only output or the review surfaced a decision that needs approval first.
700
728
 
701
729
  For complex work, risky changes, broad refactors, or many changed lines, increase review and validation fanout rather than trusting one reviewer. Use distinct angles such as correctness/regressions, tests/validation, simplicity/maintainability, security/privacy, performance, docs/API contracts, and user-flow behavior. When reviewers find non-trivial issues or the fix worker touches many lines, run another focused review round before final validation.
@@ -313,6 +313,18 @@ function applyAgentConfig(target: AgentConfig, cfg: Record<string, unknown>): st
313
313
  target.maxSubagentDepth = cfg.maxSubagentDepth;
314
314
  } else return "config.maxSubagentDepth must be an integer >= 0 or false when provided.";
315
315
  }
316
+ if (hasKey(cfg, "maxExecutionTimeMs")) {
317
+ if (cfg.maxExecutionTimeMs === false || cfg.maxExecutionTimeMs === "") target.maxExecutionTimeMs = undefined;
318
+ else if (typeof cfg.maxExecutionTimeMs === "number" && Number.isInteger(cfg.maxExecutionTimeMs) && cfg.maxExecutionTimeMs >= 1) {
319
+ target.maxExecutionTimeMs = cfg.maxExecutionTimeMs;
320
+ } else return "config.maxExecutionTimeMs must be an integer >= 1 or false when provided.";
321
+ }
322
+ if (hasKey(cfg, "maxTokens")) {
323
+ if (cfg.maxTokens === false || cfg.maxTokens === "") target.maxTokens = undefined;
324
+ else if (typeof cfg.maxTokens === "number" && Number.isInteger(cfg.maxTokens) && cfg.maxTokens >= 1) {
325
+ target.maxTokens = cfg.maxTokens;
326
+ } else return "config.maxTokens must be an integer >= 1 or false when provided.";
327
+ }
316
328
  if (hasKey(cfg, "completionGuard")) {
317
329
  if (typeof cfg.completionGuard !== "boolean") return "config.completionGuard must be a boolean when provided.";
318
330
  target.completionGuard = cfg.completionGuard;
@@ -386,6 +398,8 @@ function formatAgentDetail(agent: AgentConfig): string {
386
398
  if (agent.defaultReads?.length) lines.push(`Reads: ${agent.defaultReads.join(", ")}`);
387
399
  if (agent.defaultProgress) lines.push("Progress: true");
388
400
  if (agent.maxSubagentDepth !== undefined) lines.push(`Max subagent depth: ${agent.maxSubagentDepth}`);
401
+ if (agent.maxExecutionTimeMs !== undefined) lines.push(`Max execution time: ${agent.maxExecutionTimeMs}ms`);
402
+ if (agent.maxTokens !== undefined) lines.push(`Max tokens: ${agent.maxTokens}`);
389
403
  if (agent.completionGuard === false) lines.push("Completion guard: false");
390
404
  if (agent.systemPrompt.trim()) lines.push("", "System Prompt:", agent.systemPrompt);
391
405
  return lines.join("\n");
@@ -21,6 +21,8 @@ export const KNOWN_FIELDS = new Set([
21
21
  "defaultProgress",
22
22
  "interactive",
23
23
  "maxSubagentDepth",
24
+ "maxExecutionTimeMs",
25
+ "maxTokens",
24
26
  "completionGuard",
25
27
  ]);
26
28
 
@@ -71,6 +73,14 @@ export function serializeAgent(config: AgentConfig): string {
71
73
  if (typeof maxSubagentDepth === "number" && Number.isInteger(maxSubagentDepth) && maxSubagentDepth >= 0) {
72
74
  lines.push(`maxSubagentDepth: ${maxSubagentDepth}`);
73
75
  }
76
+ const maxExecutionTimeMs = config.maxExecutionTimeMs;
77
+ if (typeof maxExecutionTimeMs === "number" && Number.isInteger(maxExecutionTimeMs) && maxExecutionTimeMs >= 1) {
78
+ lines.push(`maxExecutionTimeMs: ${maxExecutionTimeMs}`);
79
+ }
80
+ const maxTokens = config.maxTokens;
81
+ if (typeof maxTokens === "number" && Number.isInteger(maxTokens) && maxTokens >= 1) {
82
+ lines.push(`maxTokens: ${maxTokens}`);
83
+ }
74
84
  if (config.completionGuard === false) lines.push("completionGuard: false");
75
85
 
76
86
  if (config.extraFields) {
@@ -46,6 +46,8 @@ export interface BuiltinAgentOverrideBase {
46
46
  skills?: string[];
47
47
  tools?: string[];
48
48
  mcpDirectTools?: string[];
49
+ maxExecutionTimeMs?: number;
50
+ maxTokens?: number;
49
51
  completionGuard?: boolean;
50
52
  }
51
53
 
@@ -61,6 +63,8 @@ interface BuiltinAgentOverrideConfig {
61
63
  systemPrompt?: string;
62
64
  skills?: string[] | false;
63
65
  tools?: string[] | false;
66
+ maxExecutionTimeMs?: number | false;
67
+ maxTokens?: number | false;
64
68
  completionGuard?: boolean;
65
69
  }
66
70
 
@@ -94,6 +98,8 @@ export interface AgentConfig {
94
98
  defaultProgress?: boolean;
95
99
  interactive?: boolean;
96
100
  maxSubagentDepth?: number;
101
+ maxExecutionTimeMs?: number;
102
+ maxTokens?: number;
97
103
  completionGuard?: boolean;
98
104
  disabled?: boolean;
99
105
  extraFields?: Record<string, string>;
@@ -203,6 +209,8 @@ function cloneOverrideBase(agent: AgentConfig): BuiltinAgentOverrideBase {
203
209
  skills: agent.skills ? [...agent.skills] : undefined,
204
210
  tools: agent.tools ? [...agent.tools] : undefined,
205
211
  mcpDirectTools: agent.mcpDirectTools ? [...agent.mcpDirectTools] : undefined,
212
+ maxExecutionTimeMs: agent.maxExecutionTimeMs,
213
+ maxTokens: agent.maxTokens,
206
214
  completionGuard: agent.completionGuard,
207
215
  };
208
216
  }
@@ -222,6 +230,8 @@ function cloneOverrideValue(override: BuiltinAgentOverrideConfig): BuiltinAgentO
222
230
  ...(override.systemPrompt !== undefined ? { systemPrompt: override.systemPrompt } : {}),
223
231
  ...(override.skills !== undefined ? { skills: override.skills === false ? false : [...override.skills] } : {}),
224
232
  ...(override.tools !== undefined ? { tools: override.tools === false ? false : [...override.tools] } : {}),
233
+ ...(override.maxExecutionTimeMs !== undefined ? { maxExecutionTimeMs: override.maxExecutionTimeMs } : {}),
234
+ ...(override.maxTokens !== undefined ? { maxTokens: override.maxTokens } : {}),
225
235
  ...(override.completionGuard !== undefined ? { completionGuard: override.completionGuard } : {}),
226
236
  };
227
237
  }
@@ -359,6 +369,22 @@ function parseBuiltinOverrideEntry(
359
369
  }
360
370
  }
361
371
 
372
+ if ("maxExecutionTimeMs" in input) {
373
+ if (input.maxExecutionTimeMs === false || (typeof input.maxExecutionTimeMs === "number" && Number.isInteger(input.maxExecutionTimeMs) && input.maxExecutionTimeMs >= 1)) {
374
+ override.maxExecutionTimeMs = input.maxExecutionTimeMs;
375
+ } else {
376
+ throw new Error(`Builtin override '${name}' in '${filePath}' has invalid 'maxExecutionTimeMs'; expected an integer >= 1 or false.`);
377
+ }
378
+ }
379
+
380
+ if ("maxTokens" in input) {
381
+ if (input.maxTokens === false || (typeof input.maxTokens === "number" && Number.isInteger(input.maxTokens) && input.maxTokens >= 1)) {
382
+ override.maxTokens = input.maxTokens;
383
+ } else {
384
+ throw new Error(`Builtin override '${name}' in '${filePath}' has invalid 'maxTokens'; expected an integer >= 1 or false.`);
385
+ }
386
+ }
387
+
362
388
  if ("completionGuard" in input) {
363
389
  if (typeof input.completionGuard === "boolean") {
364
390
  override.completionGuard = input.completionGuard;
@@ -439,6 +465,8 @@ function applyBuiltinOverride(
439
465
  next.tools = tools;
440
466
  next.mcpDirectTools = mcpDirectTools;
441
467
  }
468
+ if (override.maxExecutionTimeMs !== undefined) next.maxExecutionTimeMs = override.maxExecutionTimeMs === false ? undefined : override.maxExecutionTimeMs;
469
+ if (override.maxTokens !== undefined) next.maxTokens = override.maxTokens === false ? undefined : override.maxTokens;
442
470
  if (override.completionGuard !== undefined) next.completionGuard = override.completionGuard;
443
471
 
444
472
  return next;
@@ -479,7 +507,7 @@ function applyBuiltinOverrides(
479
507
 
480
508
  export function buildBuiltinOverrideConfig(
481
509
  base: BuiltinAgentOverrideBase,
482
- draft: Pick<AgentConfig, "model" | "fallbackModels" | "thinking" | "systemPromptMode" | "inheritProjectContext" | "inheritSkills" | "defaultContext" | "disabled" | "systemPrompt" | "skills" | "tools" | "mcpDirectTools" | "completionGuard">,
510
+ draft: Pick<AgentConfig, "model" | "fallbackModels" | "thinking" | "systemPromptMode" | "inheritProjectContext" | "inheritSkills" | "defaultContext" | "disabled" | "systemPrompt" | "skills" | "tools" | "mcpDirectTools" | "maxExecutionTimeMs" | "maxTokens" | "completionGuard">,
483
511
  ): BuiltinAgentOverrideConfig | undefined {
484
512
  const override: BuiltinAgentOverrideConfig = {};
485
513
 
@@ -497,6 +525,8 @@ export function buildBuiltinOverrideConfig(
497
525
  const baseTools = joinToolList(base);
498
526
  const draftTools = joinToolList(draft);
499
527
  if (!arraysEqual(draftTools, baseTools)) override.tools = draftTools ? [...draftTools] : false;
528
+ if (draft.maxExecutionTimeMs !== base.maxExecutionTimeMs) override.maxExecutionTimeMs = draft.maxExecutionTimeMs ?? false;
529
+ if (draft.maxTokens !== base.maxTokens) override.maxTokens = draft.maxTokens ?? false;
500
530
  if ((draft.completionGuard !== false) !== (base.completionGuard !== false)) {
501
531
  override.completionGuard = draft.completionGuard !== false;
502
532
  }
@@ -665,6 +695,8 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
665
695
  }
666
696
 
667
697
  const parsedMaxSubagentDepth = Number(frontmatter.maxSubagentDepth);
698
+ const parsedMaxExecutionTimeMs = Number(frontmatter.maxExecutionTimeMs);
699
+ const parsedMaxTokens = Number(frontmatter.maxTokens);
668
700
  const completionGuard = frontmatter.completionGuard === "false"
669
701
  ? false
670
702
  : frontmatter.completionGuard === "true"
@@ -698,6 +730,14 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
698
730
  Number.isInteger(parsedMaxSubagentDepth) && parsedMaxSubagentDepth >= 0
699
731
  ? parsedMaxSubagentDepth
700
732
  : undefined,
733
+ maxExecutionTimeMs:
734
+ Number.isInteger(parsedMaxExecutionTimeMs) && parsedMaxExecutionTimeMs >= 1
735
+ ? parsedMaxExecutionTimeMs
736
+ : undefined,
737
+ maxTokens:
738
+ Number.isInteger(parsedMaxTokens) && parsedMaxTokens >= 1
739
+ ? parsedMaxTokens
740
+ : undefined,
701
741
  completionGuard,
702
742
  extraFields: Object.keys(extraFields).length > 0 ? extraFields : undefined,
703
743
  });
@@ -157,6 +157,7 @@ export default function registerFanoutChildSubagentExtension(pi: ExtensionAPI):
157
157
  description: [
158
158
  "Delegate to subagents from child-safe fanout mode.",
159
159
  "For goal-style requests such as /goal, goal, active goal, or work until evidence says done, use explicit acceptance on the delegated run: criteria for the target, evidence/verify for proof, stopRules for constraints, and maxFinalizationTurns for the bounded loop.",
160
+ "For implementation handoffs from a plan, PRD, spec, issue, or broad fix, put implementation instructions and plan paths in task, and put the definition of done, evidence, verification commands, constraints, and loop cap in acceptance.",
160
161
  "Allowed management/control actions: list, get, status, interrupt, resume, doctor.",
161
162
  "Agent config mutation actions create, update, and delete are blocked in this mode.",
162
163
  ].join("\n"),
@@ -394,8 +394,10 @@ EXECUTION (use exactly ONE mode):
394
394
  • SINGLE: { agent, task? } - one task; omit task for self-contained agents
395
395
  • CHAIN: { chain: [{agent:"agent-a"}, {parallel:[{agent:"agent-b",count:3}]}] } - sequential pipeline with optional parallel fan-out
396
396
  • PARALLEL: { tasks: [{agent,task,count?,output?,reads?,progress?}, ...], concurrency?: number, worktree?: true } - concurrent execution (worktree: isolate each task in a git worktree)
397
+ • Foreground timeout: { timeoutMs } or { maxRuntimeMs } - wall-clock limit for foreground single, parallel, and chain runs. Timed-out children return timedOut:true with completed sibling/prior results preserved. Not for async/background runs.
397
398
  • Optional context: { context: "fresh" | "fork" } (default: if any requested agent has defaultContext: "fork", the whole invocation uses fork; otherwise "fresh"; inspect agent defaults via { action: "list" })
398
399
  • Goal-style requests: when the user says “/goal”, “goal”, “active goal”, “work until evidence says done”, or “verify against a goal”, model that as explicit acceptance. Use acceptance.criteria for the target, acceptance.evidence/verify for proof, acceptance.stopRules for constraints, and acceptance.maxFinalizationTurns for the bounded loop.
400
+ • Plan/spec implementation handoffs: when delegating a plan, PRD, spec, issue, or broad fix to an editing-capable child, prefer structured acceptance instead of burying validation requirements in task prose. Put the implementation instructions and plan paths in task; put the definition of done, evidence, verification commands, constraints, and loop cap in acceptance.
399
401
 
400
402
  CHAIN TEMPLATE VARIABLES (use in task strings):
401
403
  • {task} - The original task/request from the user
@@ -407,8 +409,8 @@ Example: { chain: [{agent:"agent-a", task:"Analyze {task}"}, {agent:"agent-b", t
407
409
  MANAGEMENT (use action field, omit agent/task/chain/tasks):
408
410
  • { action: "list" } - discover executable agents/chains
409
411
  • { action: "get", agent: "name" } - full detail; packaged agents use dotted runtime names like "package.agent"
410
- • { action: "create", config: { name: "custom-agent", package: "code-analysis", systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, defaultContext, ... } }
411
- • { action: "update", agent: "code-analysis.custom-agent", config: { package: "analysis", ... } } - merge
412
+ • { action: "create", config: { name: "custom-agent", package: "code-analysis", systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, defaultContext, maxExecutionTimeMs, maxTokens, ... } }
413
+ • { action: "update", agent: "code-analysis.custom-agent", config: { package: "analysis", maxExecutionTimeMs, maxTokens, ... } } - merge
412
414
  • { action: "delete", agent: "code-analysis.custom-agent" }
413
415
  • Use chainName for chain operations; packaged chains also use dotted runtime names
414
416
 
@@ -96,16 +96,7 @@ const AcceptanceOverride = Type.Unsafe({
96
96
  maxFinalizationTurns: { type: "integer", minimum: 1, maximum: 10 },
97
97
  },
98
98
  additionalProperties: false,
99
- allOf: [{
100
- anyOf: [
101
- { required: ["criteria"] },
102
- { required: ["evidence"] },
103
- { required: ["verify"] },
104
- { required: ["review"] },
105
- { required: ["stopRules"] },
106
- ],
107
- }],
108
- description: "Optional acceptance contract. Use this for goal-style requests such as /goal, goal, active goal, or work until evidence says done: criteria define the target, evidence/verify define proof, stopRules define constraints, and maxFinalizationTurns defines the bounded loop. When present, the child must complete a same-session self-review/repair loop before acceptance is evaluated.",
99
+ description: "Optional acceptance contract. Use this for goal-style requests and for implementation handoffs from plans, PRDs, specs, issues, or broad fixes. Put implementation instructions and plan paths in task; put the definition of done in criteria, proof in evidence/verify, constraints in stopRules, and the bounded loop budget in maxFinalizationTurns. Runtime validation still requires at least one of criteria, evidence, verify, review, or stopRules. When present, the child must complete a same-session self-review/repair loop before acceptance is evaluated.",
109
100
  });
110
101
 
111
102
  const TaskItem = Type.Object({
@@ -259,10 +250,12 @@ export const SubagentParams = Type.Object({
259
250
  { type: "object", additionalProperties: true },
260
251
  { type: "string" },
261
252
  ],
262
- description: "Agent or chain config for create/update. Agent: name, package (optional namespace; runtime name becomes package.name), description, scope ('user'|'project', default 'user'), systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, defaultContext ('fresh'|'fork'), model, tools (comma-separated), extensions (comma-separated), skills (comma-separated), thinking, output, reads, progress, maxSubagentDepth. Chain: name, package, description, scope, steps (array of {agent, task?, output?, outputMode?, reads?, model?, skill?, progress?}). Presence of 'steps' creates a chain instead of an agent. String values must be valid JSON."
253
+ description: "Agent or chain config for create/update. Agent: name, package (optional namespace; runtime name becomes package.name), description, scope ('user'|'project', default 'user'), systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, defaultContext ('fresh'|'fork'), model, tools (comma-separated), extensions (comma-separated), skills (comma-separated), thinking, output, reads, progress, maxSubagentDepth, maxExecutionTimeMs, maxTokens. Chain: name, package, description, scope, steps (array of {agent, task?, output?, outputMode?, reads?, model?, skill?, progress?}). Presence of 'steps' creates a chain instead of an agent. String values must be valid JSON."
263
254
  })),
264
255
  tasks: Type.Optional(Type.Array(TaskItem, { description: "PARALLEL mode: [{agent, task, count?, output?, outputMode?, reads?, progress?}, ...]" })),
265
256
  concurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Top-level PARALLEL mode only: max concurrent tasks. Defaults to config.parallel.concurrency or 4." })),
257
+ timeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "Foreground execution wall-clock timeout in milliseconds. When it expires, running children are soft-interrupted and timed-out results are returned. Foreground only; async/background runs ignore this field." })),
258
+ maxRuntimeMs: Type.Optional(Type.Integer({ minimum: 1, description: "Alias for timeoutMs. Use only one unless both values are identical." })),
266
259
  worktree: Type.Optional(Type.Boolean({
267
260
  description: "Create isolated git worktrees for each parallel task. " +
268
261
  "Prevents filesystem conflicts. Requires clean git state. " +
@@ -20,8 +20,10 @@ export function resolveSubagentResultStatus(input: {
20
20
  state?: string;
21
21
  interrupted?: boolean;
22
22
  detached?: boolean;
23
+ timedOut?: boolean;
23
24
  }): SubagentResultStatus {
24
25
  if (input.detached) return "detached";
26
+ if (input.timedOut || input.state === "timed-out") return "timed-out";
25
27
  if (input.interrupted || input.state === "paused") return "paused";
26
28
  if (typeof input.success === "boolean") return input.success ? "completed" : "failed";
27
29
  if (input.state === "complete") return "completed";
@@ -36,6 +38,7 @@ function countStatuses(children: SubagentResultIntercomChild[]): Record<Subagent
36
38
  failed: 0,
37
39
  paused: 0,
38
40
  detached: 0,
41
+ "timed-out": 0,
39
42
  };
40
43
  for (const child of children) {
41
44
  counts[child.status] += 1;
@@ -49,6 +52,7 @@ function formatStatusCounts(counts: Record<SubagentResultStatus, number>): strin
49
52
  counts.failed ? `${counts.failed} failed` : undefined,
50
53
  counts.paused ? `${counts.paused} paused` : undefined,
51
54
  counts.detached ? `${counts.detached} detached` : undefined,
55
+ counts["timed-out"] ? `${counts["timed-out"]} timed out` : undefined,
52
56
  ].filter((part): part is string => Boolean(part));
53
57
  return parts.length ? parts.join(", ") : "0 results";
54
58
  }
@@ -56,6 +60,7 @@ function formatStatusCounts(counts: Record<SubagentResultStatus, number>): strin
56
60
  function resolveGroupedStatus(children: SubagentResultIntercomChild[]): SubagentResultStatus {
57
61
  const counts = countStatuses(children);
58
62
  if (counts.failed > 0) return "failed";
63
+ if (counts["timed-out"] > 0) return "timed-out";
59
64
  if (counts.paused > 0) return "paused";
60
65
  if (counts.completed > 0) return "completed";
61
66
  if (counts.detached > 0) return "detached";
@@ -370,6 +370,8 @@ export function executeAsyncChain(
370
370
  outputMode: behavior.outputMode,
371
371
  sessionFile,
372
372
  maxSubagentDepth: resolveChildMaxSubagentDepth(maxSubagentDepth, a.maxSubagentDepth),
373
+ maxExecutionTimeMs: a.maxExecutionTimeMs,
374
+ maxTokens: a.maxTokens,
373
375
  effectiveAcceptance: resolveEffectiveAcceptance({
374
376
  explicit: s.acceptance,
375
377
  agentName: s.agent,
@@ -689,6 +691,8 @@ export function executeAsyncSingle(
689
691
  outputMode,
690
692
  sessionFile,
691
693
  maxSubagentDepth: resolveChildMaxSubagentDepth(maxSubagentDepth, agentConfig.maxSubagentDepth),
694
+ maxExecutionTimeMs: agentConfig.maxExecutionTimeMs,
695
+ maxTokens: agentConfig.maxTokens,
692
696
  effectiveAcceptance: resolveEffectiveAcceptance({
693
697
  explicit: params.acceptance,
694
698
  agentName: agent,