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.
- package/CHANGELOG.md +84 -4
- package/README.md +220 -31
- package/index.ts +1 -0
- package/install.mjs +1 -1
- package/package.json +23 -8
- package/prompts/review-loop.md +3 -1
- package/skills/pi-subagents/SKILL.md +87 -25
- package/src/agents/agent-management.ts +82 -15
- package/src/agents/agent-serializer.ts +19 -0
- package/src/agents/agents.ts +94 -52
- package/src/agents/frontmatter.ts +67 -13
- package/src/agents/skills.ts +25 -12
- package/src/api/background-work.ts +197 -0
- package/src/api/delegation.ts +158 -0
- package/src/extension/chain-validation.ts +165 -0
- package/src/extension/doctor.ts +15 -0
- package/src/extension/fanout-child.ts +3 -1
- package/src/extension/index.ts +66 -124
- package/src/extension/rpc.ts +10 -2
- package/src/extension/schemas.ts +18 -14
- package/src/extension/steering-notices.ts +35 -0
- package/src/extension/tool-description.ts +20 -9
- package/src/intercom/intercom-bridge.ts +3 -2
- package/src/intercom/native-supervisor-channel.ts +184 -27
- package/src/intercom/result-intercom.ts +4 -0
- package/src/runs/background/async-execution.ts +293 -44
- package/src/runs/background/async-job-tracker.ts +56 -9
- package/src/runs/background/async-resume.ts +159 -52
- package/src/runs/background/async-status.ts +25 -18
- package/src/runs/background/auto-drain.ts +67 -0
- package/src/runs/background/chain-root-attachment.ts +16 -8
- package/src/runs/background/control-channel.ts +260 -13
- package/src/runs/background/fleet-view.ts +23 -2
- package/src/runs/background/notify.ts +79 -10
- package/src/runs/background/result-watcher.ts +12 -9
- package/src/runs/background/run-id-resolver.ts +14 -2
- package/src/runs/background/run-status.ts +23 -15
- package/src/runs/background/scheduled-runs.ts +3 -0
- package/src/runs/background/stale-run-reconciler.ts +32 -10
- package/src/runs/background/steering.ts +237 -0
- package/src/runs/background/subagent-runner.ts +898 -236
- package/src/runs/background/subagent-wait.ts +484 -0
- package/src/runs/background/top-level-async.ts +2 -1
- package/src/runs/background/wait-config.ts +36 -0
- package/src/runs/background/wait-tool.ts +26 -0
- package/src/runs/foreground/async-steering-action.ts +230 -0
- package/src/runs/foreground/chain-clarify.ts +22 -6
- package/src/runs/foreground/chain-execution.ts +50 -32
- package/src/runs/foreground/execution.ts +308 -94
- package/src/runs/foreground/subagent-executor.ts +592 -268
- package/src/runs/shared/acceptance.ts +355 -97
- package/src/runs/shared/child-protocol.ts +121 -0
- package/src/runs/shared/completion-guard.ts +8 -91
- package/src/runs/shared/dynamic-fanout.ts +6 -4
- package/src/runs/shared/model-fallback.ts +36 -0
- package/src/runs/shared/nested-events.ts +9 -4
- package/src/runs/shared/nested-render.ts +4 -1
- package/src/runs/shared/parallel-utils.ts +7 -0
- package/src/runs/shared/pi-args.ts +36 -9
- package/src/runs/shared/pi-spawn.ts +18 -12
- package/src/runs/shared/session-lease.ts +279 -0
- package/src/runs/shared/single-output.ts +61 -6
- package/src/runs/shared/spawn-budget.ts +128 -0
- package/src/runs/shared/subagent-control.ts +10 -6
- package/src/runs/shared/subagent-prompt-runtime.ts +127 -26
- package/src/runs/shared/task-intent.ts +176 -0
- package/src/runs/shared/tool-availability.ts +65 -0
- package/src/runs/shared/turn-budget.ts +49 -4
- package/src/shared/atomic-json.ts +4 -1
- package/src/shared/fork-context.ts +28 -3
- package/src/shared/model-info.ts +8 -5
- package/src/shared/status-format.ts +7 -1
- package/src/shared/types.ts +210 -25
- package/src/shared/utils.ts +35 -7
- package/src/slash/delegation-adapters.ts +457 -0
- package/src/slash/delegation-request.ts +103 -0
- package/src/slash/prompt-template-bridge.ts +167 -344
- package/src/slash/slash-commands.ts +239 -6
- package/src/slash/subagents-admin.ts +428 -0
- package/src/slash/subagents-editor.ts +86 -0
- package/src/tui/fleet.ts +405 -0
- package/src/tui/render.ts +90 -16
- package/src/watchdog/change-signature.ts +127 -0
- package/src/watchdog/child-status.ts +205 -0
- package/src/watchdog/emission-guard.ts +123 -0
- package/src/watchdog/lsp-diagnostics.ts +532 -0
- package/src/watchdog/model-selection.ts +167 -0
- package/src/watchdog/register-child.ts +117 -0
- package/src/watchdog/register-main.ts +433 -0
- package/src/watchdog/render.ts +54 -0
- package/src/watchdog/review.ts +293 -0
- package/src/watchdog/runtime.ts +712 -0
- package/src/watchdog/settings.ts +528 -0
- package/src/watchdog/tool-actions.ts +155 -0
- package/src/watchdog/turn-delta.ts +161 -0
- package/src/watchdog/types.ts +188 -0
- package/src/watchdog/warning-format.ts +73 -0
- 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
|
+
}
|
package/src/extension/doctor.ts
CHANGED
|
@@ -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
|
-
"
|
|
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) {
|
package/src/extension/index.ts
CHANGED
|
@@ -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,
|
|
27
|
-
import { SubagentParams
|
|
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 {
|
|
40
|
-
import
|
|
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: {
|
|
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:
|
|
406
|
-
|
|
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
|
-
|
|
496
|
-
|
|
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
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
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 = {
|
|
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 {
|
package/src/extension/rpc.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
258
|
+
deliverStopRequest({
|
|
251
259
|
asyncDir: location.asyncDir,
|
|
252
260
|
pid: status.pid,
|
|
253
261
|
kill: options.kill,
|