pi-subagents 0.33.1 → 0.35.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.
Files changed (98) hide show
  1. package/CHANGELOG.md +84 -4
  2. package/README.md +220 -31
  3. package/index.ts +1 -0
  4. package/install.mjs +1 -1
  5. package/package.json +23 -8
  6. package/prompts/review-loop.md +3 -1
  7. package/skills/pi-subagents/SKILL.md +87 -25
  8. package/src/agents/agent-management.ts +82 -15
  9. package/src/agents/agent-serializer.ts +19 -0
  10. package/src/agents/agents.ts +94 -52
  11. package/src/agents/frontmatter.ts +67 -13
  12. package/src/agents/skills.ts +25 -12
  13. package/src/api/background-work.ts +197 -0
  14. package/src/api/delegation.ts +158 -0
  15. package/src/extension/chain-validation.ts +165 -0
  16. package/src/extension/doctor.ts +15 -0
  17. package/src/extension/fanout-child.ts +3 -1
  18. package/src/extension/index.ts +66 -124
  19. package/src/extension/rpc.ts +10 -2
  20. package/src/extension/schemas.ts +18 -14
  21. package/src/extension/steering-notices.ts +35 -0
  22. package/src/extension/tool-description.ts +20 -9
  23. package/src/intercom/intercom-bridge.ts +3 -2
  24. package/src/intercom/native-supervisor-channel.ts +184 -27
  25. package/src/intercom/result-intercom.ts +4 -0
  26. package/src/runs/background/async-execution.ts +293 -44
  27. package/src/runs/background/async-job-tracker.ts +56 -9
  28. package/src/runs/background/async-resume.ts +159 -52
  29. package/src/runs/background/async-status.ts +25 -18
  30. package/src/runs/background/auto-drain.ts +67 -0
  31. package/src/runs/background/chain-root-attachment.ts +16 -8
  32. package/src/runs/background/control-channel.ts +260 -13
  33. package/src/runs/background/fleet-view.ts +23 -2
  34. package/src/runs/background/notify.ts +79 -10
  35. package/src/runs/background/result-watcher.ts +12 -9
  36. package/src/runs/background/run-id-resolver.ts +14 -2
  37. package/src/runs/background/run-status.ts +23 -15
  38. package/src/runs/background/scheduled-runs.ts +3 -0
  39. package/src/runs/background/stale-run-reconciler.ts +32 -10
  40. package/src/runs/background/steering.ts +237 -0
  41. package/src/runs/background/subagent-runner.ts +898 -236
  42. package/src/runs/background/subagent-wait.ts +484 -0
  43. package/src/runs/background/top-level-async.ts +2 -1
  44. package/src/runs/background/wait-config.ts +36 -0
  45. package/src/runs/background/wait-tool.ts +26 -0
  46. package/src/runs/foreground/async-steering-action.ts +230 -0
  47. package/src/runs/foreground/chain-clarify.ts +22 -6
  48. package/src/runs/foreground/chain-execution.ts +50 -32
  49. package/src/runs/foreground/execution.ts +308 -94
  50. package/src/runs/foreground/subagent-executor.ts +592 -268
  51. package/src/runs/shared/acceptance.ts +355 -97
  52. package/src/runs/shared/child-protocol.ts +121 -0
  53. package/src/runs/shared/completion-guard.ts +8 -91
  54. package/src/runs/shared/dynamic-fanout.ts +6 -4
  55. package/src/runs/shared/model-fallback.ts +36 -0
  56. package/src/runs/shared/nested-events.ts +9 -4
  57. package/src/runs/shared/nested-render.ts +4 -1
  58. package/src/runs/shared/parallel-utils.ts +7 -0
  59. package/src/runs/shared/pi-args.ts +36 -9
  60. package/src/runs/shared/pi-spawn.ts +18 -12
  61. package/src/runs/shared/session-lease.ts +279 -0
  62. package/src/runs/shared/single-output.ts +61 -6
  63. package/src/runs/shared/spawn-budget.ts +128 -0
  64. package/src/runs/shared/subagent-control.ts +10 -6
  65. package/src/runs/shared/subagent-prompt-runtime.ts +127 -26
  66. package/src/runs/shared/task-intent.ts +176 -0
  67. package/src/runs/shared/tool-availability.ts +65 -0
  68. package/src/runs/shared/turn-budget.ts +49 -4
  69. package/src/shared/atomic-json.ts +4 -1
  70. package/src/shared/fork-context.ts +28 -3
  71. package/src/shared/model-info.ts +8 -5
  72. package/src/shared/status-format.ts +7 -1
  73. package/src/shared/types.ts +210 -25
  74. package/src/shared/utils.ts +35 -7
  75. package/src/slash/delegation-adapters.ts +457 -0
  76. package/src/slash/delegation-request.ts +103 -0
  77. package/src/slash/prompt-template-bridge.ts +167 -344
  78. package/src/slash/slash-commands.ts +239 -6
  79. package/src/slash/subagents-admin.ts +428 -0
  80. package/src/slash/subagents-editor.ts +86 -0
  81. package/src/tui/fleet.ts +405 -0
  82. package/src/tui/render.ts +90 -16
  83. package/src/watchdog/change-signature.ts +127 -0
  84. package/src/watchdog/child-status.ts +205 -0
  85. package/src/watchdog/emission-guard.ts +123 -0
  86. package/src/watchdog/lsp-diagnostics.ts +532 -0
  87. package/src/watchdog/model-selection.ts +167 -0
  88. package/src/watchdog/register-child.ts +117 -0
  89. package/src/watchdog/register-main.ts +433 -0
  90. package/src/watchdog/render.ts +54 -0
  91. package/src/watchdog/review.ts +293 -0
  92. package/src/watchdog/runtime.ts +712 -0
  93. package/src/watchdog/settings.ts +528 -0
  94. package/src/watchdog/tool-actions.ts +155 -0
  95. package/src/watchdog/turn-delta.ts +161 -0
  96. package/src/watchdog/types.ts +188 -0
  97. package/src/watchdog/warning-format.ts +73 -0
  98. package/src/runs/background/wait.ts +0 -353
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Friendly chain parameter validation.
3
+ *
4
+ * The runtime (pi-ai) validates tool arguments against the TypeBox schema before
5
+ * the tool's `execute` runs. When a chain step has an extra property, the model
6
+ * receives a raw TypeBox message such as `chain.1: must not have additional
7
+ * properties` — which does not name the offending property, list the allowed
8
+ * ones, or show the expected shape. Agents then iteratively guess the format and
9
+ * waste several turns before getting it right.
10
+ *
11
+ * `validateChainInput` runs from the tool's `prepareArguments` shim (and the RPC
12
+ * bridge) *before* schema validation. It throws a rich, actionable error for the
13
+ * common chain-shape failures, so the model sees which property is disallowed,
14
+ * what is allowed, and a valid example — instead of a raw TypeBox diagnostic.
15
+ *
16
+ * Allowed property names and additional-property strictness are derived directly
17
+ * from the schema objects in `schemas.ts` so this stays aligned with TypeBox.
18
+ */
19
+
20
+ import {
21
+ ChainItem,
22
+ ParallelTaskSchema,
23
+ DynamicParallelTemplateSchema,
24
+ DynamicExpandSchema,
25
+ DynamicCollectSchema,
26
+ } from "./schemas.ts";
27
+
28
+ type ObjectSchema = {
29
+ properties?: Record<string, unknown>;
30
+ additionalProperties?: unknown;
31
+ };
32
+
33
+ function allowedKeysOf(schema: ObjectSchema | undefined): string[] {
34
+ return schema?.properties ? Object.keys(schema.properties) : [];
35
+ }
36
+
37
+ const ExpandFromSchema = (DynamicExpandSchema.properties?.from ?? {}) as ObjectSchema;
38
+
39
+ export const CHAIN_STEP_KEYS = allowedKeysOf(ChainItem);
40
+ export const PARALLEL_TASK_KEYS = allowedKeysOf(ParallelTaskSchema);
41
+ export const DYNAMIC_TEMPLATE_KEYS = allowedKeysOf(DynamicParallelTemplateSchema);
42
+ export const EXPAND_KEYS = allowedKeysOf(DynamicExpandSchema);
43
+ export const EXPAND_FROM_KEYS = allowedKeysOf(ExpandFromSchema);
44
+ export const COLLECT_KEYS = allowedKeysOf(DynamicCollectSchema);
45
+
46
+ const CHAIN_STEP_EXAMPLE = '{"agent": "worker", "task": "do X"}';
47
+ const PARALLEL_TASK_EXAMPLE = '{"agent": "worker", "task": "do X"}';
48
+ const DYNAMIC_TEMPLATE_EXAMPLE = '{"agent": "worker", "task": "Review {item.path}"}';
49
+ const EXPAND_EXAMPLE = '{"from": {"output": "targets", "path": "/items"}, "maxItems": 4}';
50
+ const EXPAND_FROM_EXAMPLE = '{"output": "targets", "path": "/items"}';
51
+ const COLLECT_EXAMPLE = '{"as": "reviews"}';
52
+
53
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
54
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
55
+ }
56
+
57
+ function disallowedKeys(value: Record<string, unknown>, allowed: readonly string[]): string[] {
58
+ return Object.keys(value).filter((key) => !allowed.includes(key));
59
+ }
60
+
61
+ function typeName(value: unknown): string {
62
+ if (Array.isArray(value)) return "array";
63
+ return typeof value;
64
+ }
65
+
66
+ function quoteList(keys: string[]): string {
67
+ return keys.map((key) => `"${key}"`).join(", ");
68
+ }
69
+
70
+ function disallowedMessage(
71
+ path: string,
72
+ keys: string[],
73
+ allowed: readonly string[],
74
+ example?: string,
75
+ ): string {
76
+ const subject = keys.length === 1
77
+ ? `property "${keys[0]}" is not allowed`
78
+ : `properties ${quoteList(keys)} are not allowed`;
79
+ const examplePart = example ? `\n\nExample: ${example}` : "";
80
+ return `subagent ${path}: ${subject}.\nAllowed properties: ${allowed.join(", ")}.${examplePart}`;
81
+ }
82
+
83
+ function expectObject(value: unknown, path: string, allowed: readonly string[], example?: string): asserts value is Record<string, unknown> {
84
+ if (!isPlainObject(value)) {
85
+ const examplePart = example ? `\n\nExample: ${example}` : "";
86
+ throw new Error(
87
+ `subagent ${path}: expected an object, received ${typeName(value)}.\nAllowed properties: ${allowed.join(", ")}.${examplePart}`,
88
+ );
89
+ }
90
+ }
91
+
92
+ function checkNoExtraKeys(
93
+ value: Record<string, unknown>,
94
+ path: string,
95
+ schema: ObjectSchema,
96
+ allowed: readonly string[],
97
+ example?: string,
98
+ ): void {
99
+ if (schema.additionalProperties !== false) return;
100
+ const extra = disallowedKeys(value, allowed);
101
+ if (extra.length > 0) {
102
+ throw new Error(disallowedMessage(path, extra, allowed, example));
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Validates the `chain` array on a raw subagent tool-call argument object and
108
+ * throws a friendly, actionable error when a chain step (or a nested
109
+ * `parallel`/`expand`/`collect` object) has a disallowed property or wrong shape.
110
+ *
111
+ * No-op when `chain` is absent or not an array, so management/control/single/
112
+ * parallel calls are unaffected.
113
+ */
114
+ export function validateChainInput(args: unknown): void {
115
+ if (!isPlainObject(args)) return;
116
+ const chain = (args as Record<string, unknown>).chain;
117
+ if (!Array.isArray(chain)) return;
118
+
119
+ chain.forEach((step, index) => {
120
+ const stepPath = `chain step validation failed at chain[${index}]`;
121
+ expectObject(step, stepPath, CHAIN_STEP_KEYS, CHAIN_STEP_EXAMPLE);
122
+ checkNoExtraKeys(step, stepPath, ChainItem, CHAIN_STEP_KEYS, CHAIN_STEP_EXAMPLE);
123
+
124
+ if (step.expand !== undefined) {
125
+ const expandPath = `${stepPath}.expand`;
126
+ expectObject(step.expand, expandPath, EXPAND_KEYS, EXPAND_EXAMPLE);
127
+ checkNoExtraKeys(step.expand, expandPath, DynamicExpandSchema, EXPAND_KEYS, EXPAND_EXAMPLE);
128
+
129
+ const from = (step.expand as Record<string, unknown>).from;
130
+ if (from !== undefined) {
131
+ const fromPath = `${expandPath}.from`;
132
+ expectObject(from, fromPath, EXPAND_FROM_KEYS, EXPAND_FROM_EXAMPLE);
133
+ checkNoExtraKeys(from, fromPath, ExpandFromSchema, EXPAND_FROM_KEYS, EXPAND_FROM_EXAMPLE);
134
+ }
135
+ }
136
+
137
+ if (step.collect !== undefined) {
138
+ const collectPath = `${stepPath}.collect`;
139
+ expectObject(step.collect, collectPath, COLLECT_KEYS, COLLECT_EXAMPLE);
140
+ checkNoExtraKeys(step.collect, collectPath, DynamicCollectSchema, COLLECT_KEYS, COLLECT_EXAMPLE);
141
+ }
142
+
143
+ const parallel = step.parallel;
144
+ if (parallel === undefined) return;
145
+
146
+ if (Array.isArray(parallel)) {
147
+ parallel.forEach((task, pIndex) => {
148
+ const taskPath = `${stepPath}.parallel[${pIndex}]`;
149
+ expectObject(task, taskPath, PARALLEL_TASK_KEYS, PARALLEL_TASK_EXAMPLE);
150
+ checkNoExtraKeys(task, taskPath, ParallelTaskSchema, PARALLEL_TASK_KEYS, PARALLEL_TASK_EXAMPLE);
151
+ });
152
+ return;
153
+ }
154
+
155
+ if (isPlainObject(parallel)) {
156
+ const templatePath = `${stepPath}.parallel (dynamic fanout template)`;
157
+ checkNoExtraKeys(parallel, templatePath, DynamicParallelTemplateSchema, DYNAMIC_TEMPLATE_KEYS, DYNAMIC_TEMPLATE_EXAMPLE);
158
+ return;
159
+ }
160
+
161
+ throw new Error(
162
+ `subagent ${stepPath}.parallel: expected an array of task objects or a dynamic fanout template object, received ${typeName(parallel)}.\n\nExample static: [${PARALLEL_TASK_EXAMPLE}]\nExample dynamic: ${DYNAMIC_TEMPLATE_EXAMPLE}`,
163
+ );
164
+ });
165
+ }
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { discoverAgentsAll, type AgentSource } from "../agents/agents.ts";
4
4
  import { isAsyncAvailable } from "../runs/background/async-execution.ts";
5
+ import { formatSpawnBudgetSummary, getSpawnBudgetSnapshot } from "../runs/shared/spawn-budget.ts";
5
6
  import { diagnoseIntercomBridge, type IntercomBridgeDiagnostic } from "../intercom/intercom-bridge.ts";
6
7
  import { discoverAvailableSkills, type SkillSource } from "../agents/skills.ts";
7
8
  import {
@@ -162,6 +163,17 @@ function formatIntercomDiagnostic(diagnostic: IntercomBridgeDiagnostic, context:
162
163
  return lines;
163
164
  }
164
165
 
166
+ function formatSpawnBudgetSection(input: DoctorReportInput): string[] {
167
+ const snapshot = getSpawnBudgetSnapshot(input.state, input.config, input.currentSessionId ?? input.state.currentSessionId);
168
+ return [
169
+ `- usage: ${formatSpawnBudgetSummary(snapshot)}`,
170
+ `- recent grants: ${snapshot.grantHistory.length === 0
171
+ ? "none"
172
+ : snapshot.grantHistory.map((grant) => `+${grant.amount} at ${new Date(grant.grantedAt).toISOString()} (${grant.previousLimit} → ${grant.limit})`).join("; ")}`,
173
+ "- reset boundary: a new parent session resets usage and grants; compaction does not",
174
+ ];
175
+ }
176
+
165
177
  function formatPermissionSystemSection(): string[] {
166
178
  const lines: string[] = [];
167
179
  const parentSession = process.env["PI_SUBAGENT_PARENT_SESSION"] ?? "";
@@ -199,6 +211,9 @@ export function buildDoctorReport(input: DoctorReportInput): string {
199
211
  "Discovery",
200
212
  ...formatDiscovery(input, deps),
201
213
  "",
214
+ "Spawn budget",
215
+ ...formatSpawnBudgetSection(input),
216
+ "",
202
217
  "Permission system",
203
218
  ...formatPermissionSystemSection(),
204
219
  "",
@@ -5,6 +5,7 @@ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-age
5
5
  import { discoverAgents } from "../agents/agents.ts";
6
6
  import { getArtifactsDir } from "../shared/artifacts.ts";
7
7
  import { createSubagentExecutor, type SubagentParamsLike } from "../runs/foreground/subagent-executor.ts";
8
+ import { resolveWaitToolConfig } from "../runs/background/wait-config.ts";
8
9
  import { SUBAGENT_CHILD_ENV, SUBAGENT_FANOUT_CHILD_ENV } from "../runs/shared/pi-args.ts";
9
10
  import { readNestedControlRequests, resolveNestedRouteFromEnv, writeNestedControlResult } from "../runs/shared/nested-events.ts";
10
11
  import { deliverSubagentIntercomMessageEvent } from "../intercom/result-intercom.ts";
@@ -146,6 +147,7 @@ export default function registerFanoutChildSubagentExtension(pi: ExtensionAPI):
146
147
  state,
147
148
  config,
148
149
  asyncByDefault: config.asyncByDefault === true,
150
+ waitToolEnabled: resolveWaitToolConfig(config.waitTool).enabled,
149
151
  tempArtifactsDir: getArtifactsDir(null),
150
152
  getSubagentSessionRoot,
151
153
  expandTilde,
@@ -159,7 +161,7 @@ export default function registerFanoutChildSubagentExtension(pi: ExtensionAPI):
159
161
  description: [
160
162
  "Delegate to subagents from child-safe fanout mode.",
161
163
  "Allowed management/control actions: list, get, status, interrupt, resume, steer, append-step, doctor.",
162
- "Agent config mutation actions (create, update, delete, eject, disable, enable, reset) are blocked in this mode.",
164
+ "Mutating management actions (create, update, delete, eject, disable, enable, reset, grant-spawn-budget) are blocked in this mode.",
163
165
  ].join("\n"),
164
166
  parameters: SubagentParams,
165
167
  execute(id, params, signal, onUpdate, ctx) {
@@ -23,21 +23,26 @@ import { discoverAgents } from "../agents/agents.ts";
23
23
  import { cleanupAllArtifactDirs, cleanupOldArtifacts, getArtifactsDir } from "../shared/artifacts.ts";
24
24
  import { resolveCurrentSessionId } from "../shared/session-identity.ts";
25
25
  import { cleanupOldChainDirs } from "../shared/settings.ts";
26
- import { clearLegacyResultAnimationTimer, renderWidget, renderSubagentResult } from "../tui/render.ts";
27
- import { SubagentParams, WaitParams } from "./schemas.ts";
26
+ import { clearLegacyResultAnimationTimer, renderSubagentResult } from "../tui/render.ts";
27
+ import { SubagentParams } from "./schemas.ts";
28
+ import { validateChainInput } from "./chain-validation.ts";
28
29
  import { createSubagentExecutor, type SubagentParamsLike } from "../runs/foreground/subagent-executor.ts";
29
30
  import { createAsyncJobTracker } from "../runs/background/async-job-tracker.ts";
30
31
  import { createResultWatcher } from "../runs/background/result-watcher.ts";
31
32
  import { createScheduledRunManager } from "../runs/background/scheduled-runs.ts";
32
33
  import { registerSlashCommands } from "../slash/slash-commands.ts";
33
34
  import { registerPromptTemplateDelegationBridge } from "../slash/prompt-template-bridge.ts";
35
+ import { registerMainWatchdog } from "../watchdog/register-main.ts";
34
36
  import { registerSlashSubagentBridge } from "../slash/slash-bridge.ts";
35
37
  import { createNativeSupervisorChannel } from "../intercom/native-supervisor-channel.ts";
36
38
  import { registerSubagentRpcBridge } from "./rpc.ts";
37
39
  import { clearSlashSnapshots, getSlashRenderableSnapshot, resolveSlashMessageDetails, restoreSlashFinalSnapshots, type SlashMessageDetails } from "../slash/slash-live-state.ts";
38
40
  import { inspectSubagentStatus } from "../runs/background/run-status.ts";
39
- import { waitForSubagents } from "../runs/background/wait.ts";
40
- import registerSubagentNotify, { type SubagentNotifyDetails } from "../runs/background/notify.ts";
41
+ import { resolveWaitToolConfig } from "../runs/background/subagent-wait.ts";
42
+ import { registerWaitTool } from "../runs/background/wait-tool.ts";
43
+ import { drainOutstandingWork } from "../runs/background/auto-drain.ts";
44
+ import registerSubagentNotify, { parseSubagentNotifyContent, type SubagentNotifyDetails } from "../runs/background/notify.ts";
45
+ import { formatSteeringNotice, handleSubagentSteeringNotice, SUBAGENT_STEERING_MESSAGE_TYPE, type SubagentSteeringMessageDetails } from "./steering-notices.ts";
41
46
  import { SUBAGENT_CHILD_ENV, SUBAGENT_PARENT_SESSION_ENV } from "../runs/shared/pi-args.ts";
42
47
  import { formatDuration, shortenPath } from "../shared/formatters.ts";
43
48
  import { loadConfig } from "./config.ts";
@@ -53,7 +58,9 @@ import {
53
58
  SUBAGENT_ASYNC_COMPLETE_EVENT,
54
59
  SUBAGENT_ASYNC_STARTED_EVENT,
55
60
  SUBAGENT_CONTROL_EVENT,
61
+ SUBAGENT_STEERING_NOTICE_EVENT,
56
62
  WIDGET_KEY,
63
+ resolveMaxSubagentSpawnsPerSession,
57
64
  } from "../shared/types.ts";
58
65
  import {
59
66
  clearPendingForegroundControlNotices,
@@ -113,30 +120,6 @@ function isSlashResultRunning(result: { details?: Details }): boolean {
113
120
  || false;
114
121
  }
115
122
 
116
- // Drives the inline running-indicator braille animation for foreground subagent
117
- // results. Foreground runs receive progress only on child events, so the glyph
118
- // (derived from progress fields) would freeze between events. While a result is
119
- // running we tick a frame counter + invalidate() every 80ms so renderSubagentResult
120
- // can blend the frame into runningGlyph and produce a smooth spinner.
121
- function subagentResultIsRunning(result: { details?: Details }): boolean {
122
- return result.details?.progress?.some((entry) => entry.status === "running")
123
- || result.details?.results.some((entry) => entry.progress?.status === "running")
124
- || false;
125
- }
126
-
127
- function ensureSubagentResultAnimation(context: { state: Record<string, unknown>; invalidate?: () => void }): void {
128
- const state = context.state as { subagentResultAnimationTimer?: ReturnType<typeof setInterval>; frame?: number };
129
- if (state.subagentResultAnimationTimer) return;
130
- if (typeof context.invalidate !== "function") return;
131
- if (state.frame === undefined) state.frame = 0;
132
- state.subagentResultAnimationTimer = setInterval(() => {
133
- state.frame = ((state.frame ?? 0) + 1) % 10;
134
- try {
135
- context.invalidate();
136
- } catch {}
137
- }, 80);
138
- }
139
-
140
123
  function isSlashResultError(result: { details?: Details }): boolean {
141
124
  return result.details?.results.some((entry) => entry.exitCode !== 0 && entry.progress?.status !== "running") || false;
142
125
  }
@@ -177,38 +160,6 @@ function createSlashResultComponent(
177
160
  return container;
178
161
  }
179
162
 
180
- function parseSubagentNotifyContent(content: string): SubagentNotifyDetails | undefined {
181
- const lines = content.split("\n");
182
- const header = lines[0] ?? "";
183
- const match = header.match(/^Background task (completed|failed|paused): \*\*(.+?)\*\*(?:\s+(\([^)]*\)))?$/);
184
- if (!match) return undefined;
185
- const body = lines.slice(2);
186
- let sessionIndex = -1;
187
- for (let i = body.length - 1; i >= 1; i--) {
188
- if (body[i - 1]?.trim() === "" && /^(Session|Session file|Session share error):\s+/.test(body[i]!)) {
189
- sessionIndex = i;
190
- break;
191
- }
192
- }
193
- const sessionLine = sessionIndex >= 0 ? body[sessionIndex] : undefined;
194
- const resultLines = sessionIndex >= 0 ? body.slice(0, sessionIndex) : body;
195
- const resultPreview = resultLines.join("\n").trim() || "(no output)";
196
- let sessionLabel: string | undefined;
197
- let sessionValue: string | undefined;
198
- if (sessionLine) {
199
- const separator = sessionLine.indexOf(":");
200
- sessionLabel = sessionLine.slice(0, separator).toLowerCase();
201
- sessionValue = sessionLine.slice(separator + 1).trim();
202
- }
203
- return {
204
- agent: match[2]!,
205
- status: match[1] as SubagentNotifyDetails["status"],
206
- ...(match[3] ? { taskInfo: match[3] } : {}),
207
- resultPreview,
208
- ...(sessionLabel && sessionValue ? { sessionLabel, sessionValue } : {}),
209
- };
210
- }
211
-
212
163
  class SubagentControlNoticeComponent implements Component {
213
164
  constructor(
214
165
  private readonly details: SubagentControlMessageDetails,
@@ -257,6 +208,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
257
208
  cleanupOldChainDirs();
258
209
 
259
210
  const config = loadConfig();
211
+ const waitToolConfig = resolveWaitToolConfig(config.waitTool);
260
212
  const asyncByDefault = config.asyncByDefault === true;
261
213
  const tempArtifactsDir = getArtifactsDir(null);
262
214
  cleanupAllArtifactDirs(DEFAULT_ARTIFACT_CONFIG.cleanupDays);
@@ -265,8 +217,15 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
265
217
  baseCwd: "",
266
218
  currentSessionId: null,
267
219
  subagentInProgress: false,
268
- subagentSpawns: { sessionId: null, count: 0 },
220
+ subagentSpawns: {
221
+ sessionId: null,
222
+ count: 0,
223
+ configuredLimit: resolveMaxSubagentSpawnsPerSession(config.maxSubagentSpawnsPerSession) ?? null,
224
+ granted: 0,
225
+ grantHistory: [],
226
+ },
269
227
  asyncJobs: new Map(),
228
+ fleetJobs: new Map(),
270
229
  foregroundRuns: new Map(),
271
230
  foregroundControls: new Map(),
272
231
  lastForegroundControlId: null,
@@ -284,6 +243,8 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
284
243
  };
285
244
 
286
245
  const supervisorChannel = createNativeSupervisorChannel(pi, state);
246
+ const mainWatchdog = registerMainWatchdog(pi);
247
+ let disposeSubagentNotify = () => {};
287
248
  const { startResultWatcher, primeExistingResults, stopResultWatcher } = createResultWatcher(
288
249
  pi,
289
250
  state,
@@ -294,6 +255,8 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
294
255
  primeExistingResults();
295
256
 
296
257
  const runtimeCleanup = () => {
258
+ disposeSubagentNotify();
259
+ mainWatchdog.dispose();
297
260
  stopResultWatcher();
298
261
  scheduledRunManager.stop();
299
262
  supervisorChannel.dispose();
@@ -305,7 +268,9 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
305
268
  };
306
269
  globalStore[runtimeCleanupStoreKey] = runtimeCleanup;
307
270
 
308
- const { ensurePoller, handleStarted, handleComplete, resetJobs, restoreActiveJobs } = createAsyncJobTracker(pi, state, ASYNC_DIR);
271
+ const { ensurePoller, refreshWidget, handleStarted, handleComplete, resetJobs, restoreActiveJobs } = createAsyncJobTracker(pi, state, ASYNC_DIR, {
272
+ widgetEnabled: config.asyncWidget !== false,
273
+ });
309
274
  let executorExecute: ((id: string, params: SubagentParamsLike, signal: AbortSignal, onUpdate: ((r: AgentToolResult<Details>) => void) | undefined, ctx: ExtensionContext) => Promise<AgentToolResult<Details>>) | undefined;
310
275
  const scheduledRunManager = createScheduledRunManager({
311
276
  config,
@@ -325,7 +290,9 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
325
290
  state,
326
291
  config,
327
292
  asyncByDefault,
293
+ waitToolEnabled: waitToolConfig.enabled,
328
294
  handleScheduledRunAction: (params, ctx) => scheduledRunManager.handleToolCall(params, ctx),
295
+ watchdog: mainWatchdog,
329
296
  tempArtifactsDir,
330
297
  getSubagentSessionRoot,
331
298
  expandTilde,
@@ -380,6 +347,12 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
380
347
  return new Text(text, 0, 0);
381
348
  });
382
349
 
350
+ pi.registerMessageRenderer<SubagentSteeringMessageDetails>(SUBAGENT_STEERING_MESSAGE_TYPE, (message, _options, theme) => {
351
+ const details = message.details as SubagentSteeringMessageDetails | undefined;
352
+ if (!details) return undefined;
353
+ return new Text(theme.fg(details.state === "recovered" ? "warning" : "error", formatSteeringNotice(details)), 0, 0);
354
+ });
355
+
383
356
  pi.registerMessageRenderer<SubagentControlMessageDetails>(SUBAGENT_CONTROL_MESSAGE_TYPE, (message, _options, theme) => {
384
357
  const details = message.details as SubagentControlMessageDetails | undefined;
385
358
  if (!details?.event) return undefined;
@@ -402,39 +375,8 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
402
375
  const promptTemplateBridge = registerPromptTemplateDelegationBridge({
403
376
  events: pi.events,
404
377
  getContext: () => state.lastUiContext,
405
- execute: async (requestId, request, signal, ctx, onUpdate) => {
406
- if (request.tasks && request.tasks.length > 0) {
407
- return executeSubagentCollapsed(
408
- requestId,
409
- {
410
- tasks: request.tasks,
411
- context: request.context,
412
- cwd: request.cwd,
413
- worktree: request.worktree,
414
- async: false,
415
- clarify: false,
416
- },
417
- signal,
418
- onUpdate,
419
- ctx,
420
- );
421
- }
422
- return executeSubagentCollapsed(
423
- requestId,
424
- {
425
- agent: request.agent,
426
- task: request.task,
427
- context: request.context,
428
- cwd: request.cwd,
429
- model: request.model,
430
- async: false,
431
- clarify: false,
432
- },
433
- signal,
434
- onUpdate,
435
- ctx,
436
- );
437
- },
378
+ execute: (requestId, params, signal, ctx, onUpdate) =>
379
+ executeSubagentCollapsed(requestId, params, signal, onUpdate, ctx),
438
380
  });
439
381
 
440
382
  const rpcBridge = registerSubagentRpcBridge({
@@ -457,6 +399,14 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
457
399
  description: buildSubagentToolDescription(config),
458
400
  parameters: SubagentParams,
459
401
 
402
+ prepareArguments(args) {
403
+ // Run friendly chain validation before pi-ai's raw TypeBox schema check
404
+ // so the model sees which property is disallowed, what is allowed, and a
405
+ // valid example instead of `chain.N: must not have additional properties`.
406
+ validateChainInput(args);
407
+ return args as never;
408
+ },
409
+
460
410
  execute(id, params, signal, onUpdate, ctx) {
461
411
  return executeSubagentCollapsed(id, params, signal, onUpdate, ctx);
462
412
  },
@@ -492,38 +442,20 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
492
442
  },
493
443
 
494
444
  renderResult(result, options, theme, context) {
495
- if (subagentResultIsRunning(result)) {
496
- ensureSubagentResultAnimation(context);
497
- } else {
498
- clearLegacyResultAnimationTimer(context);
499
- }
500
- const frame = (context.state as { frame?: number } | undefined)?.frame ?? 0;
501
- return renderSubagentResult(result, options, theme, frame);
445
+ clearLegacyResultAnimationTimer(context);
446
+ return renderSubagentResult(result, options, theme);
502
447
  },
503
448
 
504
449
  };
505
450
 
506
451
  pi.registerTool(tool);
507
452
 
508
- const waitTool: ToolDefinition<typeof WaitParams, Details> = {
509
- name: "wait",
510
- label: "Wait",
511
- description: `Block until background (async) subagent runs started in this session finish, then return.
512
-
513
- Use this after launching async subagents when you have no independent work left and must not end your turn — for example inside a skill that has to run to completion, or any non-interactive run (\`pi -p ...\`) where the whole task is a single turn and ending it would abandon the still-running children.
514
-
515
- • { } — return as soon as the FIRST active run finishes (default). Ideal for a rolling fleet: launch N, wait, spawn a replacement for the one that finished, wait again — keeping N in flight.
516
- • { all: true } — block until EVERY active run in this session is finished.
517
- • { id: "..." } — wait for one specific run (id or prefix) to finish.
518
- • { timeoutMs: 600000 } — stop waiting after N ms (the runs keep going regardless; default 30 min)
453
+ registerWaitTool(pi, state, waitToolConfig.enabled);
519
454
 
520
- wait also returns when a run needs attention (a child that went idle or blocked for a decision), not only on completion — so a stuck child never stalls the loop; the summary names the run(s) to inspect/nudge/resume/interrupt. It wakes the instant a completion or control event arrives (subscribed to Pi's event bus, with a poll fallback that reconciles crashed runners), keeps the turn alive for normal notification delivery, and resolves early if the turn is aborted.`,
521
- parameters: WaitParams,
522
- execute(_id, params, signal, _onUpdate, _ctx) {
523
- return waitForSubagents(params, signal, { state, events: pi.events });
524
- },
525
- };
526
- pi.registerTool(waitTool);
455
+ pi.on("agent_end", async (_event, ctx) => {
456
+ if (ctx.hasUI) return;
457
+ await drainOutstandingWork({ state, events: pi.events });
458
+ });
527
459
 
528
460
  registerSlashCommands(pi, state);
529
461
 
@@ -540,7 +472,7 @@ wait also returns when a run needs attention (a child that went idle or blocked
540
472
  }
541
473
  }
542
474
  }
543
- registerSubagentNotify(pi, state, { batchConfig: config.completionBatch });
475
+ disposeSubagentNotify = registerSubagentNotify(pi, state, { batchConfig: config.completionBatch });
544
476
 
545
477
  const existingVisibleControlNotices = globalStore[controlNoticeSeenStoreKey];
546
478
  const visibleControlNotices = existingVisibleControlNotices instanceof Set ? existingVisibleControlNotices as Set<string> : new Set<string>();
@@ -553,10 +485,14 @@ wait also returns when a run needs attention (a child that went idle or blocked
553
485
  details: payload as SubagentControlMessageDetails,
554
486
  });
555
487
  };
488
+ const steeringNoticeHandler = (payload: unknown) => {
489
+ handleSubagentSteeringNotice({ pi, state, details: payload as SubagentSteeringMessageDetails });
490
+ };
556
491
  const eventUnsubscribes = [
557
492
  pi.events.on(SUBAGENT_ASYNC_STARTED_EVENT, handleStarted),
558
493
  pi.events.on(SUBAGENT_ASYNC_COMPLETE_EVENT, handleComplete),
559
494
  pi.events.on(SUBAGENT_CONTROL_EVENT, controlEventHandler),
495
+ pi.events.on(SUBAGENT_STEERING_NOTICE_EVENT, steeringNoticeHandler),
560
496
  rpcBridge.dispose,
561
497
  ];
562
498
  globalStore[eventUnsubscribeStoreKey] = eventUnsubscribes;
@@ -566,8 +502,7 @@ wait also returns when a run needs attention (a child that went idle or blocked
566
502
  if (!ctx.hasUI) return;
567
503
  state.lastUiContext = ctx;
568
504
  if (state.asyncJobs.size > 0) {
569
- renderWidget(ctx, Array.from(state.asyncJobs.values()));
570
- ctx.ui.requestRender?.();
505
+ refreshWidget(ctx);
571
506
  ensurePoller();
572
507
  }
573
508
  });
@@ -586,7 +521,13 @@ wait also returns when a run needs attention (a child that went idle or blocked
586
521
  const resetSessionState = (ctx: ExtensionContext) => {
587
522
  state.baseCwd = ctx.cwd;
588
523
  state.currentSessionId = resolveCurrentSessionId(ctx.sessionManager);
589
- state.subagentSpawns = { sessionId: state.currentSessionId, count: 0 };
524
+ state.subagentSpawns = {
525
+ sessionId: state.currentSessionId,
526
+ count: 0,
527
+ configuredLimit: resolveMaxSubagentSpawnsPerSession(config.maxSubagentSpawnsPerSession) ?? null,
528
+ granted: 0,
529
+ grantHistory: [],
530
+ };
590
531
  // Set PI_SUBAGENT_PARENT_SESSION for permission-system forwarding.
591
532
  // Only set in the root session (the interactive UI session), not in
592
533
  // child subagent processes — children inherit the parent's value
@@ -615,6 +556,7 @@ wait also returns when a run needs attention (a child that went idle or blocked
615
556
  });
616
557
 
617
558
  pi.on("session_shutdown", () => {
559
+ disposeSubagentNotify();
618
560
  delete process.env[SUBAGENT_PARENT_SESSION_ENV];
619
561
  for (const unsubscribe of eventUnsubscribes) {
620
562
  try {
@@ -3,12 +3,13 @@ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
3
3
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { Compile } from "typebox/compile";
5
5
  import { resolveAsyncRunLocation } from "../runs/background/async-resume.ts";
6
- import { deliverTimeoutRequest } from "../runs/background/control-channel.ts";
6
+ import { deliverStopRequest } from "../runs/background/control-channel.ts";
7
7
  import { reconcileAsyncRun } from "../runs/background/stale-run-reconciler.ts";
8
8
  import type { SubagentParamsLike } from "../runs/foreground/subagent-executor.ts";
9
9
  import { type Details, ASYNC_DIR, RESULTS_DIR } from "../shared/types.ts";
10
10
  import { readStatus } from "../shared/utils.ts";
11
11
  import { SubagentParams } from "./schemas.ts";
12
+ import { validateChainInput } from "./chain-validation.ts";
12
13
 
13
14
  export const SUBAGENT_RPC_PROTOCOL_VERSION = 1;
14
15
  export const SUBAGENT_RPC_REQUEST_EVENT = "subagents:rpc:v1:request";
@@ -111,6 +112,13 @@ function assertRecordParams(params: unknown, method: SubagentRpcMethod): Record<
111
112
  }
112
113
 
113
114
  function assertSubagentParams(params: SubagentParamsLike, label: string): void {
115
+ // Friendly chain validation first: name the disallowed property, list allowed
116
+ // ones, and show a valid example instead of raw TypeBox diagnostics.
117
+ try {
118
+ validateChainInput(params);
119
+ } catch (error) {
120
+ throw new SubagentRpcError("invalid_params", `${label}: ${error instanceof Error ? error.message : String(error)}`);
121
+ }
114
122
  if (subagentParamsValidator.Check(params)) return;
115
123
  const messages = [...subagentParamsValidator.Errors(params)]
116
124
  .slice(0, 4)
@@ -247,7 +255,7 @@ function stopAsyncRun(
247
255
  }
248
256
 
249
257
  try {
250
- deliverTimeoutRequest({
258
+ deliverStopRequest({
251
259
  asyncDir: location.asyncDir,
252
260
  pid: status.pid,
253
261
  kill: options.kill,