pi-subagents 0.34.0 → 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 +78 -9
  2. package/README.md +213 -32
  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 +91 -49
  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 +65 -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 +9 -1
  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 -127
  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 +34 -7
  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 +7 -4
  72. package/src/shared/status-format.ts +7 -1
  73. package/src/shared/types.ts +203 -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 -394
@@ -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 { resolveWaitToolConfig, 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,
@@ -266,8 +217,15 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
266
217
  baseCwd: "",
267
218
  currentSessionId: null,
268
219
  subagentInProgress: false,
269
- 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
+ },
270
227
  asyncJobs: new Map(),
228
+ fleetJobs: new Map(),
271
229
  foregroundRuns: new Map(),
272
230
  foregroundControls: new Map(),
273
231
  lastForegroundControlId: null,
@@ -285,6 +243,8 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
285
243
  };
286
244
 
287
245
  const supervisorChannel = createNativeSupervisorChannel(pi, state);
246
+ const mainWatchdog = registerMainWatchdog(pi);
247
+ let disposeSubagentNotify = () => {};
288
248
  const { startResultWatcher, primeExistingResults, stopResultWatcher } = createResultWatcher(
289
249
  pi,
290
250
  state,
@@ -295,6 +255,8 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
295
255
  primeExistingResults();
296
256
 
297
257
  const runtimeCleanup = () => {
258
+ disposeSubagentNotify();
259
+ mainWatchdog.dispose();
298
260
  stopResultWatcher();
299
261
  scheduledRunManager.stop();
300
262
  supervisorChannel.dispose();
@@ -306,7 +268,9 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
306
268
  };
307
269
  globalStore[runtimeCleanupStoreKey] = runtimeCleanup;
308
270
 
309
- 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
+ });
310
274
  let executorExecute: ((id: string, params: SubagentParamsLike, signal: AbortSignal, onUpdate: ((r: AgentToolResult<Details>) => void) | undefined, ctx: ExtensionContext) => Promise<AgentToolResult<Details>>) | undefined;
311
275
  const scheduledRunManager = createScheduledRunManager({
312
276
  config,
@@ -326,7 +290,9 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
326
290
  state,
327
291
  config,
328
292
  asyncByDefault,
293
+ waitToolEnabled: waitToolConfig.enabled,
329
294
  handleScheduledRunAction: (params, ctx) => scheduledRunManager.handleToolCall(params, ctx),
295
+ watchdog: mainWatchdog,
330
296
  tempArtifactsDir,
331
297
  getSubagentSessionRoot,
332
298
  expandTilde,
@@ -381,6 +347,12 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
381
347
  return new Text(text, 0, 0);
382
348
  });
383
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
+
384
356
  pi.registerMessageRenderer<SubagentControlMessageDetails>(SUBAGENT_CONTROL_MESSAGE_TYPE, (message, _options, theme) => {
385
357
  const details = message.details as SubagentControlMessageDetails | undefined;
386
358
  if (!details?.event) return undefined;
@@ -403,39 +375,8 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
403
375
  const promptTemplateBridge = registerPromptTemplateDelegationBridge({
404
376
  events: pi.events,
405
377
  getContext: () => state.lastUiContext,
406
- execute: async (requestId, request, signal, ctx, onUpdate) => {
407
- if (request.tasks && request.tasks.length > 0) {
408
- return executeSubagentCollapsed(
409
- requestId,
410
- {
411
- tasks: request.tasks,
412
- context: request.context,
413
- cwd: request.cwd,
414
- worktree: request.worktree,
415
- async: false,
416
- clarify: false,
417
- },
418
- signal,
419
- onUpdate,
420
- ctx,
421
- );
422
- }
423
- return executeSubagentCollapsed(
424
- requestId,
425
- {
426
- agent: request.agent,
427
- task: request.task,
428
- context: request.context,
429
- cwd: request.cwd,
430
- model: request.model,
431
- async: false,
432
- clarify: false,
433
- },
434
- signal,
435
- onUpdate,
436
- ctx,
437
- );
438
- },
378
+ execute: (requestId, params, signal, ctx, onUpdate) =>
379
+ executeSubagentCollapsed(requestId, params, signal, onUpdate, ctx),
439
380
  });
440
381
 
441
382
  const rpcBridge = registerSubagentRpcBridge({
@@ -458,6 +399,14 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
458
399
  description: buildSubagentToolDescription(config),
459
400
  parameters: SubagentParams,
460
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
+
461
410
  execute(id, params, signal, onUpdate, ctx) {
462
411
  return executeSubagentCollapsed(id, params, signal, onUpdate, ctx);
463
412
  },
@@ -493,38 +442,20 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
493
442
  },
494
443
 
495
444
  renderResult(result, options, theme, context) {
496
- if (subagentResultIsRunning(result)) {
497
- ensureSubagentResultAnimation(context);
498
- } else {
499
- clearLegacyResultAnimationTimer(context);
500
- }
501
- const frame = (context.state as { frame?: number } | undefined)?.frame ?? 0;
502
- return renderSubagentResult(result, options, theme, frame);
445
+ clearLegacyResultAnimationTimer(context);
446
+ return renderSubagentResult(result, options, theme);
503
447
  },
504
448
 
505
449
  };
506
450
 
507
451
  pi.registerTool(tool);
508
452
 
509
- const waitTool: ToolDefinition<typeof WaitParams, Details> = {
510
- name: "wait",
511
- label: "Wait",
512
- description: `Block until background (async) subagent runs started in this session finish, then return.
513
-
514
- 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.
515
-
516
- • { } — 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.
517
- • { all: true } — block until EVERY active run in this session is finished.
518
- • { id: "..." } — wait for one specific run (id or prefix) to finish.
519
- • { timeoutMs: 600000 } — stop waiting after N ms (the runs keep going regardless; default 30 min)
453
+ registerWaitTool(pi, state, waitToolConfig.enabled);
520
454
 
521
- 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.${waitToolConfig.enabled ? "" : "\n\nConfigured behavior: wait is disabled by config.waitTool or PI_SUBAGENT_WAIT_TOOL_ENABLED and returns immediately without blocking."}`,
522
- parameters: WaitParams,
523
- execute(_id, params, signal, _onUpdate, _ctx) {
524
- return waitForSubagents(params, signal, { state, events: pi.events, enabled: waitToolConfig.enabled });
525
- },
526
- };
527
- pi.registerTool(waitTool);
455
+ pi.on("agent_end", async (_event, ctx) => {
456
+ if (ctx.hasUI) return;
457
+ await drainOutstandingWork({ state, events: pi.events });
458
+ });
528
459
 
529
460
  registerSlashCommands(pi, state);
530
461
 
@@ -541,7 +472,7 @@ wait also returns when a run needs attention (a child that went idle or blocked
541
472
  }
542
473
  }
543
474
  }
544
- registerSubagentNotify(pi, state, { batchConfig: config.completionBatch });
475
+ disposeSubagentNotify = registerSubagentNotify(pi, state, { batchConfig: config.completionBatch });
545
476
 
546
477
  const existingVisibleControlNotices = globalStore[controlNoticeSeenStoreKey];
547
478
  const visibleControlNotices = existingVisibleControlNotices instanceof Set ? existingVisibleControlNotices as Set<string> : new Set<string>();
@@ -554,10 +485,14 @@ wait also returns when a run needs attention (a child that went idle or blocked
554
485
  details: payload as SubagentControlMessageDetails,
555
486
  });
556
487
  };
488
+ const steeringNoticeHandler = (payload: unknown) => {
489
+ handleSubagentSteeringNotice({ pi, state, details: payload as SubagentSteeringMessageDetails });
490
+ };
557
491
  const eventUnsubscribes = [
558
492
  pi.events.on(SUBAGENT_ASYNC_STARTED_EVENT, handleStarted),
559
493
  pi.events.on(SUBAGENT_ASYNC_COMPLETE_EVENT, handleComplete),
560
494
  pi.events.on(SUBAGENT_CONTROL_EVENT, controlEventHandler),
495
+ pi.events.on(SUBAGENT_STEERING_NOTICE_EVENT, steeringNoticeHandler),
561
496
  rpcBridge.dispose,
562
497
  ];
563
498
  globalStore[eventUnsubscribeStoreKey] = eventUnsubscribes;
@@ -567,8 +502,7 @@ wait also returns when a run needs attention (a child that went idle or blocked
567
502
  if (!ctx.hasUI) return;
568
503
  state.lastUiContext = ctx;
569
504
  if (state.asyncJobs.size > 0) {
570
- renderWidget(ctx, Array.from(state.asyncJobs.values()));
571
- ctx.ui.requestRender?.();
505
+ refreshWidget(ctx);
572
506
  ensurePoller();
573
507
  }
574
508
  });
@@ -587,7 +521,13 @@ wait also returns when a run needs attention (a child that went idle or blocked
587
521
  const resetSessionState = (ctx: ExtensionContext) => {
588
522
  state.baseCwd = ctx.cwd;
589
523
  state.currentSessionId = resolveCurrentSessionId(ctx.sessionManager);
590
- 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
+ };
591
531
  // Set PI_SUBAGENT_PARENT_SESSION for permission-system forwarding.
592
532
  // Only set in the root session (the interactive UI session), not in
593
533
  // child subagent processes — children inherit the parent's value
@@ -616,6 +556,7 @@ wait also returns when a run needs attention (a child that went idle or blocked
616
556
  });
617
557
 
618
558
  pi.on("session_shutdown", () => {
559
+ disposeSubagentNotify();
619
560
  delete process.env[SUBAGENT_PARENT_SESSION_ENV];
620
561
  for (const unsubscribe of eventUnsubscribes) {
621
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,