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.
- package/CHANGELOG.md +78 -9
- package/README.md +213 -32
- 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 +91 -49
- 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 +65 -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 +9 -1
- 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 -127
- 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 +34 -7
- 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 +7 -4
- package/src/shared/status-format.ts +7 -1
- package/src/shared/types.ts +203 -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 -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
|
+
}
|
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 { resolveWaitToolConfig
|
|
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,
|
|
@@ -266,8 +217,15 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
|
|
|
266
217
|
baseCwd: "",
|
|
267
218
|
currentSessionId: null,
|
|
268
219
|
subagentInProgress: false,
|
|
269
|
-
subagentSpawns: {
|
|
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:
|
|
407
|
-
|
|
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
|
-
|
|
497
|
-
|
|
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
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
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 = {
|
|
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 {
|
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,
|