@maxiaochao/pi-toolkit 0.10.0 → 0.10.2

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.
@@ -9,8 +9,8 @@ import { RunDispatcher } from "../src/dispatcher.ts";
9
9
  import { historyText, listSubagentHistory } from "../src/history.ts";
10
10
  import { cancelQueuedRuns, loadQueuedRuns, persistQueuedRun, removeQueuedRun } from "../src/queued-runs.ts";
11
11
  import { cancelActiveSubagentRuns, reconcileSubagentRuns } from "../src/reconcile.ts";
12
- import { executeHerdrSubagents, type HerdrSubagentsBatchDetails, type HerdrSubagentsDetails, type HerdrSubagentsToolParams } from "../src/tool.ts";
13
- import { HerdrSubagentsParams } from "../src/parameters.ts";
12
+ import { executeHerdrSubagents, executeHerdrSubagentsControl, type HerdrSubagentsBatchDetails, type HerdrSubagentsControlToolParams, type HerdrSubagentsDetails, type HerdrSubagentsRunParams } from "../src/tool.ts";
13
+ import { HerdrSubagentsControlParams, HerdrSubagentsParams } from "../src/parameters.ts";
14
14
  import { HERDR_ACTIONS } from "../src/validation.ts";
15
15
  import { FleetController } from "../src/fleet-controller.ts";
16
16
  import { isSettledRunStatus } from "../src/records.ts";
@@ -25,18 +25,32 @@ function statusVisual(status: string): { color: "success" | "warning" | "error"
25
25
  return { color: "error", glyph: "✗" };
26
26
  }
27
27
 
28
- function renderToolCall(args: HerdrSubagentsToolParams, theme: Theme): Text {
29
- if (args.action) {
30
- const action = HERDR_ACTIONS[args.action];
31
- const runId = action.includeRunId ? ` ${theme.fg("muted", String(args.runId ?? ""))}` : "";
32
- return new Text(`${theme.fg("toolTitle", theme.bold(`herdr_subagents ${args.action}`))}${runId}`, 0, 0);
33
- }
34
- if (args.tasks) return new Text(`${theme.fg("toolTitle", theme.bold("herdr_subagents"))} ${theme.fg("accent", `${args.tasks.length} tasks`)}${theme.fg("muted", ` · concurrency ${args.concurrency ?? "default"}`)}`, 0, 0);
35
- const agent = args.agent || "worker";
36
- const label = args.label ? ` · ${String(args.label)}` : "";
37
- const prompt = args.prompt ? String(args.prompt).replace(/\s+/g, " ") : "...";
38
- const preview = prompt.length > 80 ? `${prompt.slice(0, 80)}...` : prompt;
39
- return new Text(`${theme.fg("toolTitle", theme.bold("herdr_subagents"))} ${theme.fg("accent", agent)}${theme.fg("muted", label)}\n ${theme.fg("dim", preview)}`, 0, 0);
28
+ function renderToolCall(args: HerdrSubagentsRunParams, theme: Theme): Text {
29
+ return new Text(`${theme.fg("toolTitle", theme.bold("herdr_subagents"))} ${theme.fg("accent", `${args.tasks.length} task${args.tasks.length === 1 ? "" : "s"}`)}${theme.fg("muted", ` · concurrency ${args.concurrency ?? "default"}`)}`, 0, 0);
30
+ }
31
+
32
+ function renderControlToolCall(args: HerdrSubagentsControlToolParams, theme: Theme): Text {
33
+ const action = HERDR_ACTIONS[args.action];
34
+ const runId = action.includeRunId ? ` ${theme.fg("muted", String(args.runId ?? ""))}` : "";
35
+ return new Text(`${theme.fg("toolTitle", theme.bold(`herdr_subagents_control ${args.action}`))}${runId}`, 0, 0);
36
+ }
37
+
38
+ export function normalizeHerdrSubagentsArguments(args: unknown): HerdrSubagentsRunParams {
39
+ if (!args || typeof args !== "object") return args as HerdrSubagentsRunParams;
40
+ const legacy = args as Record<string, unknown>;
41
+ if (Array.isArray(legacy.tasks) || typeof legacy.prompt !== "string" || legacy.action !== undefined) return args as HerdrSubagentsRunParams;
42
+ if (Object.keys(legacy).some((key) => !["prompt", "agent", "label", "model", "background"].includes(key))) return args as HerdrSubagentsRunParams;
43
+ const label = typeof legacy.label === "string" ? legacy.label : undefined;
44
+ return {
45
+ tasks: [{
46
+ name: label || "task",
47
+ prompt: legacy.prompt,
48
+ ...(typeof legacy.agent === "string" ? { agent: legacy.agent } : {}),
49
+ ...(typeof legacy.model === "string" ? { model: legacy.model } : {}),
50
+ }],
51
+ ...(label ? { label } : {}),
52
+ ...(typeof legacy.background === "boolean" ? { background: legacy.background } : {}),
53
+ };
40
54
  }
41
55
 
42
56
  function renderToolResult(result: AgentToolResult<unknown>, _options: ToolRenderResultOptions, theme: Theme): Container | Text {
@@ -49,20 +63,20 @@ function renderToolResult(result: AgentToolResult<unknown>, _options: ToolRender
49
63
  return container;
50
64
  }
51
65
  const details = result.details as HerdrSubagentsDetails | undefined;
52
- if (!details?.agent) {
53
- const content = result.content[0];
54
- return new Text(content?.type === "text" ? content.text : "(no output)", 0, 0);
55
- }
56
- const container = new Container();
57
- container.addChild(new Text(`${theme.fg("toolTitle", theme.bold(details.agent))} ${theme.fg(statusVisual(details.status).color, details.status)}`, 0, 0));
58
- container.addChild(new Text(details.summary, 0, 0));
59
- if (details.documents.length > 0) {
60
- container.addChild(new Spacer(1));
61
- for (const document of details.documents) container.addChild(new Text(theme.fg("muted", `${document.description}: ${document.path}`), 0, 0));
66
+ if (details?.agent) {
67
+ const container = new Container();
68
+ container.addChild(new Text(`${theme.fg("toolTitle", theme.bold(details.agent))} ${theme.fg(statusVisual(details.status).color, details.status)}`, 0, 0));
69
+ container.addChild(new Text(details.summary, 0, 0));
70
+ if (details.documents.length > 0) {
71
+ container.addChild(new Spacer(1));
72
+ for (const document of details.documents) container.addChild(new Text(theme.fg("muted", `${document.description}: ${document.path}`), 0, 0));
73
+ }
74
+ if (details.recordDirectory) container.addChild(new Text(theme.fg("dim", `Record: ${details.recordDirectory}`), 0, 0));
75
+ if (details.errorMessage) container.addChild(new Text(theme.fg("error", `Error: ${details.errorMessage}`), 0, 0));
76
+ return container;
62
77
  }
63
- if (details.recordDirectory) container.addChild(new Text(theme.fg("dim", `Record: ${details.recordDirectory}`), 0, 0));
64
- if (details.errorMessage) container.addChild(new Text(theme.fg("error", `Error: ${details.errorMessage}`), 0, 0));
65
- return container;
78
+ const content = result.content[0];
79
+ return new Text(content?.type === "text" ? content.text : "(no output)", 0, 0);
66
80
  }
67
81
 
68
82
  async function handleHerdrCommand(args: string, ctx: ExtensionCommandContext, fleet: FleetController): Promise<void> {
@@ -92,7 +106,7 @@ async function handleHerdrCommand(args: string, ctx: ExtensionCommandContext, fl
92
106
  }
93
107
  if (section === "settings") {
94
108
  const settings = configuration.settings;
95
- const values: Record<string, string | number | undefined> = { defaultConcurrency: settings.defaultConcurrency, maxConcurrency: settings.maxConcurrency, stalledWarningSeconds: settings.stalledWarningSeconds, defaultModel: settings.defaultModel };
109
+ const values: Record<string, string | number | undefined> = { defaultConcurrency: settings.defaultConcurrency, maxConcurrency: settings.maxConcurrency, stalledWarningSeconds: settings.stalledWarningSeconds, defaultModel: settings.defaultModel, defaultThinking: settings.defaultThinking };
96
110
  const lines = Object.entries(values).map(([key, value]) => `${key}=${String(value ?? "unset")} (${configuration.settingSources[key] ?? "built-in"})`);
97
111
  if (configuration.diagnostics.length) lines.push("Diagnostics:", ...configuration.diagnostics);
98
112
  ctx.ui.notify(lines.join("\n"), "info");
@@ -104,6 +118,7 @@ async function handleHerdrCommand(args: string, ctx: ExtensionCommandContext, fl
104
118
  export interface HerdrSubagentsExtensionDependencies {
105
119
  herdr?: HerdrAutomation;
106
120
  execute?: typeof executeHerdrSubagents;
121
+ executeControl?: typeof executeHerdrSubagentsControl;
107
122
  isEditor?: (value: unknown) => boolean;
108
123
  createFleetWidget?: (lines: string[]) => Container;
109
124
  }
@@ -139,6 +154,7 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
139
154
  if (process.env.PI_HERDR_SUBAGENTS_CHILD === "1" || process.env.HERDR_ENV !== "1" || !process.env.HERDR_WORKSPACE_ID) return;
140
155
  const herdr = dependencies.herdr ?? new CliHerdrAutomation();
141
156
  const execute = dependencies.execute ?? executeHerdrSubagents;
157
+ const executeControl = dependencies.executeControl ?? executeHerdrSubagentsControl;
142
158
  const isEditorComponent = dependencies.isEditor ?? ((value: unknown) => value instanceof Editor);
143
159
  const dispatcher = new RunDispatcher();
144
160
  const fleet = new FleetController(herdr, isEditorComponent, dependencies.createFleetWidget);
@@ -177,17 +193,17 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
177
193
  pi.registerTool({
178
194
  name: "herdr_subagents",
179
195
  label: "Herdr Subagents",
180
- description: "Run one focused task or an ordered bounded-concurrency batch in visible interactive Herdr Pi Agents. Foreground calls block while workers run concurrently; background batches return a run ID immediately and publish completion later. Complete results and Pi sessions stay in project-local records.",
196
+ description: "Run one or more ordered tasks in visible interactive Herdr Pi Agents. Use one tasks item for a single task. Independent read-only tasks may run concurrently; write-capable runs are serialized. Foreground calls block, while background calls return a run ID immediately. Complete results and Pi sessions stay in project-local records.",
181
197
  promptSnippet: "Run focused work in visible Herdr subagents",
182
- promptGuidelines: ["Call action=list once before the first execution. Submit one prompt or an ordered tasks list; use concurrency only for independent read-only tasks. Foreground calls block until the run settles; background calls return a run ID immediately."],
198
+ promptGuidelines: ["Call herdr_subagents_control with action=list once before the first execution. Submit an ordered tasks list; use one item for a single task and concurrency only for independent read-only tasks. Foreground calls block until the run settles; background calls return a run ID immediately."],
183
199
  parameters: HerdrSubagentsParams,
200
+ prepareArguments: normalizeHerdrSubagentsArguments,
184
201
 
185
- async execute(_toolCallId, params: HerdrSubagentsToolParams, signal, onUpdate, ctx) {
202
+ async execute(_toolCallId, params: HerdrSubagentsRunParams, signal, onUpdate, ctx) {
186
203
  const generation = sessionGeneration;
187
- const runId = params.tasks ? randomUUID() : undefined;
188
- const executionId = runId ?? params.runId ?? randomUUID();
204
+ const runId = randomUUID();
189
205
  const controller = new AbortController();
190
- runControllers.set(executionId, controller);
206
+ runControllers.set(runId, controller);
191
207
  const runSignal = AbortSignal.any([controller.signal, sessionController.signal, ...(!params.background && signal ? [signal] : [])]);
192
208
  const executeRun = async () => {
193
209
  const result = await execute(params, runSignal, (update) => {
@@ -201,21 +217,6 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
201
217
  fleet.sync(result.details);
202
218
  return result;
203
219
  };
204
- if (!params.tasks) {
205
- const promise = executeRun();
206
- inFlight.add(promise);
207
- const result = await promise.finally(() => { inFlight.delete(promise); runControllers.delete(executionId); });
208
- if (params.action === "respond") {
209
- const details = result.details as { runId?: string; status?: string };
210
- if (details.status === "blocked") fleet.sync(result.details);
211
- else if (isSettledRunStatus(details.status)) {
212
- fleet.clearRun(params.runId);
213
- if (params.runId) dispatcher.release(params.runId);
214
- }
215
- }
216
- return result;
217
- }
218
- if (!runId) throw new Error("Batch run ID was not allocated.");
219
220
  if (params.background) await persistQueuedRun(ctx.cwd, runId, params, { sessionId: ownerSessionId, processId: process.pid });
220
221
  const promise = dispatcher.submit(runId, async () => {
221
222
  const result = await executeRun().finally(async () => { if (params.background) await removeQueuedRun(ctx.cwd, runId); });
@@ -227,7 +228,7 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
227
228
  return result;
228
229
  });
229
230
  inFlight.add(promise);
230
- void promise.finally(() => { inFlight.delete(promise); runControllers.delete(executionId); }).catch(() => undefined);
231
+ void promise.finally(() => { inFlight.delete(promise); runControllers.delete(runId); }).catch(() => undefined);
231
232
  if (params.background) {
232
233
  void promise.then((result) => {
233
234
  if (generation !== sessionGeneration) return;
@@ -248,6 +249,44 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
248
249
  renderResult: renderToolResult,
249
250
  });
250
251
 
252
+ pi.registerTool({
253
+ name: "herdr_subagents_control",
254
+ label: "Herdr Subagents Control",
255
+ description: "Inspect Herdr personas and run history, answer a blocked task, resume a saved task, or remove a selected archived run. Use herdr_subagents to start new work.",
256
+ promptSnippet: "Inspect and control Herdr subagent runs",
257
+ parameters: HerdrSubagentsControlParams,
258
+
259
+ async execute(_toolCallId, params: HerdrSubagentsControlToolParams, signal, onUpdate, ctx) {
260
+ const executionId = params.runId ?? randomUUID();
261
+ const controller = new AbortController();
262
+ runControllers.set(executionId, controller);
263
+ const runSignal = AbortSignal.any([controller.signal, sessionController.signal, ...(signal ? [signal] : [])]);
264
+ const promise = executeControl(params, runSignal, (update) => {
265
+ const details = update.details as HerdrSubagentsBatchDetails | undefined;
266
+ if (details?.activity) fleet.sync(details);
267
+ const updateText = update.content.find((item) => item.type === "text")?.text;
268
+ const updateDetails = update.details as { warning?: string };
269
+ if (updateDetails.warning || updateText?.includes("cleanup failed")) ctx.ui.notify(updateDetails.warning ?? updateText ?? "Subagent warning", "warning");
270
+ onUpdate?.(update);
271
+ }, ctx, { agentsDirectory: packageAgentsDir, owner: { sessionId: ownerSessionId, processId: process.pid } });
272
+ inFlight.add(promise);
273
+ const result = await promise.finally(() => { inFlight.delete(promise); runControllers.delete(executionId); });
274
+ fleet.sync(result.details);
275
+ if (params.action === "respond") {
276
+ const details = result.details as { status?: string };
277
+ if (details.status === "blocked") fleet.sync(result.details);
278
+ else if (isSettledRunStatus(details.status)) {
279
+ fleet.clearRun(params.runId);
280
+ if (params.runId) dispatcher.release(params.runId);
281
+ }
282
+ }
283
+ return result;
284
+ },
285
+
286
+ renderCall: renderControlToolCall,
287
+ renderResult: renderToolResult,
288
+ });
289
+
251
290
  pi.registerCommand("herdr-subagents", {
252
291
  description: "Inspect effective subagent agents, models, and settings",
253
292
  getArgumentCompletions: (prefix) => ["active", "history", "agents", "models", "settings", "focus "].filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value })),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-herdr-subagents",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Run inspectable interactive Pi subagent batches through Herdr.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -10,6 +10,7 @@ export interface SubagentConfig {
10
10
  maxConcurrency: number;
11
11
  stalledWarningSeconds: number;
12
12
  defaultModel?: string;
13
+ defaultThinking?: string;
13
14
  personas: Record<string, PersonaConfig>;
14
15
  }
15
16
  export interface EffectivePersona extends AgentDefinition {
@@ -33,6 +34,7 @@ interface ConfigFile {
33
34
  maxConcurrency?: number;
34
35
  stalledWarningSeconds?: number;
35
36
  defaultModel?: string;
37
+ defaultThinking?: string;
36
38
  personas?: Record<string, PersonaConfig>;
37
39
  }
38
40
  export interface ConfigurationPaths {
@@ -64,9 +66,11 @@ function mergeConfig(base: SubagentConfig, input: ConfigFile, source: string, so
64
66
  result[key] = value as number;
65
67
  sources[key] = source;
66
68
  }
67
- if (input.defaultModel !== undefined) {
68
- if (typeof input.defaultModel === "string" && input.defaultModel.trim()) { result.defaultModel = input.defaultModel.trim(); sources.defaultModel = source; }
69
- else diagnostics.push(`${source} defaultModel must be a non-empty string`);
69
+ for (const key of ["defaultModel", "defaultThinking"] as const) {
70
+ const value = input[key];
71
+ if (value === undefined) continue;
72
+ if (typeof value === "string" && value.trim()) { result[key] = value.trim(); sources[key] = source; }
73
+ else diagnostics.push(`${source} ${key} must be a non-empty string`);
70
74
  }
71
75
  if (input.personas !== undefined) {
72
76
  if (!input.personas || typeof input.personas !== "object" || Array.isArray(input.personas)) diagnostics.push(`${source} personas must be an object`);
@@ -115,8 +119,8 @@ export function loadSubagentConfiguration(cwd: string, packageAgentsDirectory: s
115
119
  const projectPersona = projectInput.personas?.[definition.name] ?? {};
116
120
  const model = projectPersona.model ?? globalPersona.model ?? definition.model ?? settings.defaultModel ?? parentModel;
117
121
  const modelSource = projectPersona.model ? "project persona" : globalPersona.model ? "global persona" : definition.model ? `${source} persona` : settings.defaultModel ? settingSources.defaultModel : parentModel ? "parent session" : "unresolved";
118
- const thinking = projectPersona.thinking ?? globalPersona.thinking ?? definition.thinking;
119
- const thinkingSource = projectPersona.thinking ? "project persona" : globalPersona.thinking ? "global persona" : definition.thinking ? `${source} persona` : "default";
122
+ const thinking = projectPersona.thinking ?? globalPersona.thinking ?? definition.thinking ?? settings.defaultThinking;
123
+ const thinkingSource = projectPersona.thinking ? "project persona" : globalPersona.thinking ? "global persona" : definition.thinking ? `${source} persona` : settings.defaultThinking ? settingSources.defaultThinking : "default";
120
124
  const skills = projectPersona.skills ?? globalPersona.skills ?? definition.skills ?? [];
121
125
  const skillsSource = projectPersona.skills ? "project persona" : globalPersona.skills ? "global persona" : definition.skills ? `${source} persona` : "none";
122
126
  return { ...definition, source, access: definition.access ?? "write", model, modelSource, thinking, thinkingSource, skills, skillsSource };
@@ -1,21 +1,24 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import { Type } from "typebox";
3
3
 
4
+ const HerdrSubagentsTaskParams = Type.Object({
5
+ name: Type.String({ minLength: 1, maxLength: 48, description: "Short task name shown in the pane and task list." }),
6
+ prompt: Type.String({ minLength: 1, description: "Self-contained prompt for this task." }),
7
+ agent: Type.Optional(Type.String({ minLength: 1, description: "Persona for this task. Defaults to worker." })),
8
+ model: Type.Optional(Type.String({ minLength: 1, description: "Optional model override for this task." })),
9
+ }, { additionalProperties: false });
10
+
4
11
  export const HerdrSubagentsParams = Type.Object({
5
- action: Type.Optional(StringEnum(["list", "respond", "history", "resume", "cleanup"] as const, { description: "Operation mode. Omit it to execute one task or a batch." })),
12
+ tasks: Type.Array(HerdrSubagentsTaskParams, { minItems: 1, description: "Ordered tasks to execute. Use one item for a single task; independent read-only tasks may run concurrently." }),
13
+ label: Type.Optional(Type.String({ minLength: 1, maxLength: 48, description: "Short Herdr run-tab label." })),
14
+ concurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum simultaneously active Agents. Write-capable tasks force concurrency one." })),
15
+ background: Type.Optional(Type.Boolean({ description: "Return a run ID immediately while the run continues in this parent session." })),
16
+ }, { additionalProperties: false });
17
+
18
+ export const HerdrSubagentsControlParams = Type.Object({
19
+ action: StringEnum(["list", "respond", "history", "resume", "cleanup"] as const, { description: "Control operation to perform." }),
6
20
  runId: Type.Optional(Type.String({ minLength: 1, description: "Durable run ID used by respond, resume, or cleanup." })),
7
21
  answer: Type.Optional(Type.String({ minLength: 1, description: "Answer sent to a blocked task when action is respond." })),
8
22
  task: Type.Optional(Type.Integer({ minimum: 1, description: "One-based task number selected for respond or resume." })),
9
- prompt: Type.Optional(Type.String({ minLength: 1, description: "Task prompt for single execution, or follow-up prompt for resume." })),
10
- agent: Type.Optional(Type.String({ minLength: 1, description: "Persona for single execution. Defaults to worker." })),
11
- label: Type.Optional(Type.String({ minLength: 1, maxLength: 48, description: "Short Herdr run-tab label." })),
12
- model: Type.Optional(Type.String({ minLength: 1, description: "Optional Pi model override, for example provider/model:high." })),
13
- tasks: Type.Optional(Type.Array(Type.Object({
14
- name: Type.String({ minLength: 1, maxLength: 48, description: "Short task name shown in the pane and task list." }),
15
- prompt: Type.String({ minLength: 1, description: "Self-contained prompt for this task." }),
16
- agent: Type.Optional(Type.String({ minLength: 1, description: "Persona for this task. Defaults to worker." })),
17
- model: Type.Optional(Type.String({ minLength: 1, description: "Optional model override for this task." })),
18
- }), { minItems: 1, description: "Ordered tasks for bounded-concurrency batch execution." })),
19
- concurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum simultaneously active Agents in a batch." })),
20
- background: Type.Optional(Type.Boolean({ description: "Return a run ID immediately while the batch continues in this parent session." })),
23
+ prompt: Type.Optional(Type.String({ minLength: 1, description: "Follow-up prompt used by resume." })),
21
24
  }, { additionalProperties: false });
@@ -1,15 +1,15 @@
1
1
  import { existsSync, readdirSync } from "node:fs";
2
2
  import { mkdir, readFile, rm } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
- import type { HerdrSubagentsToolParams } from "./tool.ts";
4
+ import type { HerdrSubagentsRunParams } from "./tool.ts";
5
5
  import { isProcessAlive, ownerRecord, type OwnerIdentity } from "./ownership.ts";
6
6
  import { writeJsonAtomic } from "./state.ts";
7
7
  import { findRunDirectory } from "./run-locator.ts";
8
8
 
9
- export interface QueuedRunRecord { runId: string; cwd: string; ownerSessionId?: string; ownerProcessId?: number; params: HerdrSubagentsToolParams; queuedAt: string; }
9
+ export interface QueuedRunRecord { runId: string; cwd: string; ownerSessionId?: string; ownerProcessId?: number; params: HerdrSubagentsRunParams; queuedAt: string; }
10
10
  function queueDirectory(cwd: string): string { return join(cwd, ".pi", "herdr-subagents", "queue"); }
11
11
 
12
- export async function persistQueuedRun(cwd: string, runId: string, params: HerdrSubagentsToolParams, owner?: OwnerIdentity): Promise<void> {
12
+ export async function persistQueuedRun(cwd: string, runId: string, params: HerdrSubagentsRunParams, owner?: OwnerIdentity): Promise<void> {
13
13
  const directory = queueDirectory(cwd);
14
14
  await mkdir(directory, { recursive: true, mode: 0o700 });
15
15
  await writeJsonAtomic(join(directory, `${runId}.json`), { runId, cwd, ...ownerRecord(owner), params: { ...params, background: true }, queuedAt: new Date().toISOString() });
@@ -4,11 +4,27 @@ import { runHerdrSubagents, type HerdrSubagentsResult } from "./runner.ts";
4
4
  import { runHerdrSubagentsBatch, type BatchResult, type BatchTask } from "./batch-runner.ts";
5
5
  import { respondToBlockedTask } from "./blocking.ts";
6
6
  import { cleanSubagentRun, historyText, listSubagentHistory, resumeHistoricalTask } from "./history.ts";
7
- import { HERDR_ACTIONS, validateToolParams } from "./validation.ts";
7
+ import { HERDR_ACTIONS, validateControlParams, validateRunParams } from "./validation.ts";
8
8
  import type { OwnerIdentity } from "./ownership.ts";
9
9
 
10
10
  export interface HerdrSubagentsTaskParams { name: string; prompt: string; agent?: string; model?: string; }
11
11
 
12
+ export interface HerdrSubagentsRunParams {
13
+ tasks: HerdrSubagentsTaskParams[];
14
+ label?: string;
15
+ concurrency?: number;
16
+ background?: boolean;
17
+ }
18
+
19
+ export interface HerdrSubagentsControlToolParams {
20
+ action: "list" | "respond" | "history" | "resume" | "cleanup";
21
+ runId?: string;
22
+ answer?: string;
23
+ task?: number;
24
+ prompt?: string;
25
+ }
26
+
27
+ /** Internal superset used by shared execution helpers. */
12
28
  export interface HerdrSubagentsToolParams {
13
29
  action?: "list" | "respond" | "history" | "resume" | "cleanup";
14
30
  prompt?: string;
@@ -201,17 +217,34 @@ async function executeSingle({ params, signal, onUpdate, ctx, dependencies, disc
201
217
  }
202
218
 
203
219
  export async function executeHerdrSubagents(
204
- params: HerdrSubagentsToolParams,
220
+ params: HerdrSubagentsRunParams | HerdrSubagentsToolParams,
205
221
  signal: AbortSignal | undefined,
206
222
  onUpdate: ((response: ToolResponse) => void) | undefined,
207
223
  ctx: HerdrSubagentsToolContext,
208
224
  dependencies: HerdrSubagentsToolDependencies,
209
225
  ): Promise<ToolResponse> {
210
- const validationError = validateToolParams(params);
226
+ if (params.action) return executeHerdrSubagentsControl(params as HerdrSubagentsControlToolParams, signal, onUpdate, ctx, dependencies);
227
+ if (params.prompt && !params.tasks) {
228
+ const discovery = loadSubagentConfiguration(ctx.cwd, dependencies.agentsDirectory, modelId(ctx), dependencies.configurationPaths);
229
+ return executeSingle({ params, signal, onUpdate, ctx, dependencies, discovery });
230
+ }
231
+ const runParams = params as HerdrSubagentsRunParams;
232
+ const validationError = validateRunParams(runParams);
211
233
  if (validationError) return { content: [{ type: "text", text: validationError }], details: { errorMessage: validationError }, isError: true };
212
234
  const discovery = loadSubagentConfiguration(ctx.cwd, dependencies.agentsDirectory, modelId(ctx), dependencies.configurationPaths);
213
- const execution = { params, signal, onUpdate, ctx, dependencies, discovery };
214
- if (params.action) return actionHandlers[params.action](execution);
235
+ const execution = { params: runParams, signal, onUpdate, ctx, dependencies, discovery };
236
+ return executeBatch(execution);
237
+ }
215
238
 
216
- return params.tasks ? executeBatch(execution) : executeSingle(execution);
239
+ export async function executeHerdrSubagentsControl(
240
+ params: HerdrSubagentsControlToolParams,
241
+ signal: AbortSignal | undefined,
242
+ onUpdate: ((response: ToolResponse) => void) | undefined,
243
+ ctx: HerdrSubagentsToolContext,
244
+ dependencies: HerdrSubagentsToolDependencies,
245
+ ): Promise<ToolResponse> {
246
+ const validationError = validateControlParams(params);
247
+ if (validationError) return { content: [{ type: "text", text: validationError }], details: { errorMessage: validationError }, isError: true };
248
+ const discovery = loadSubagentConfiguration(ctx.cwd, dependencies.agentsDirectory, modelId(ctx), dependencies.configurationPaths);
249
+ return actionHandlers[params.action]({ params, signal, onUpdate, ctx, dependencies, discovery });
217
250
  }
@@ -1,6 +1,4 @@
1
- import type { HerdrSubagentsToolParams } from "./tool.ts";
2
-
3
- const executionFields = ["prompt", "agent", "label", "model", "tasks", "concurrency", "background"] as const;
1
+ import type { HerdrSubagentsControlToolParams, HerdrSubagentsRunParams } from "./tool.ts";
4
2
 
5
3
  export const HERDR_ACTIONS = {
6
4
  list: { allowed: ["action"], includeRunId: false },
@@ -8,31 +6,20 @@ export const HERDR_ACTIONS = {
8
6
  cleanup: { allowed: ["action", "runId"], includeRunId: true },
9
7
  respond: { allowed: ["action", "runId", "answer", "task"], includeRunId: true },
10
8
  resume: { allowed: ["action", "runId", "task", "prompt"], includeRunId: true },
11
- } as const satisfies Record<string, { allowed: readonly (keyof HerdrSubagentsToolParams)[]; includeRunId: boolean }>;
9
+ } as const satisfies Record<string, { allowed: readonly (keyof HerdrSubagentsControlToolParams)[]; includeRunId: boolean }>;
10
+
11
+ export function validateRunParams(params: HerdrSubagentsRunParams): string | undefined {
12
+ if (!params.tasks?.length) return "Provide a non-empty tasks list.";
13
+ return undefined;
14
+ }
12
15
 
13
- export function validateToolParams(params: HerdrSubagentsToolParams): string | undefined {
14
- const present = (key: keyof HerdrSubagentsToolParams) => params[key] !== undefined;
15
- const extraField = (allowed: readonly (keyof HerdrSubagentsToolParams)[]) => Object.keys(params).find((key) => !allowed.includes(key as keyof HerdrSubagentsToolParams));
16
- if (params.action) {
17
- const extra = extraField(HERDR_ACTIONS[params.action].allowed);
18
- if (extra) return `Field '${extra}' is not valid with action '${params.action}'.`;
19
- if (params.action === "cleanup" && !params.runId?.trim()) return "runId is required for action 'cleanup'.";
20
- if (params.action === "respond" && (!params.runId?.trim() || !params.answer?.trim())) return "runId and answer are required for action 'respond'.";
21
- if (params.action === "resume" && (!params.runId?.trim() || !params.task || !params.prompt?.trim())) return "runId, task, and prompt are required for action 'resume'.";
22
- return undefined;
23
- }
24
- if (params.prompt && params.tasks) return "Provide either prompt or tasks, not both.";
25
- if (params.prompt) {
26
- const extra = extraField(["prompt", "agent", "label", "model"]);
27
- if (extra) return `Field '${extra}' is not valid for single-task execution.`;
28
- return undefined;
29
- }
30
- if (params.tasks) {
31
- if (!params.tasks.length) return "tasks must contain at least one task.";
32
- const extra = extraField(["tasks", "label", "concurrency", "background"]);
33
- if (extra) return `Field '${extra}' is not valid for batch execution.`;
34
- return undefined;
35
- }
36
- const supplied = executionFields.find((key) => present(key));
37
- return supplied ? `Field '${supplied}' requires prompt or tasks.` : "Provide an action, prompt, or non-empty tasks list.";
16
+ export function validateControlParams(params: HerdrSubagentsControlToolParams): string | undefined {
17
+ const allowed = HERDR_ACTIONS[params.action]?.allowed;
18
+ if (!allowed) return "Provide a valid control action.";
19
+ const extra = Object.keys(params).find((key) => !allowed.includes(key as keyof HerdrSubagentsControlToolParams));
20
+ if (extra) return `Field '${extra}' is not valid with action '${params.action}'.`;
21
+ if (params.action === "cleanup" && !params.runId?.trim()) return "runId is required for action 'cleanup'.";
22
+ if (params.action === "respond" && (!params.runId?.trim() || !params.answer?.trim())) return "runId and answer are required for action 'respond'.";
23
+ if (params.action === "resume" && (!params.runId?.trim() || !params.task || !params.prompt?.trim())) return "runId, task, and prompt are required for action 'resume'.";
24
+ return undefined;
38
25
  }
@@ -9,6 +9,7 @@ function harness(execute: (...args: any[]) => Promise<any>) {
9
9
  const handlers = new Map<string, (...args: any[]) => any>();
10
10
  const commands: string[] = [];
11
11
  let tool: Record<string, any> | undefined;
12
+ let controlTool: Record<string, any> | undefined;
12
13
  let terminalInput: ((data: string) => { consume?: boolean } | undefined) | undefined;
13
14
  let widgetCalls = 0;
14
15
  let renderedLines: string[] = [];
@@ -26,13 +27,17 @@ function harness(execute: (...args: any[]) => Promise<any>) {
26
27
  };
27
28
  const pi = {
28
29
  on(name: string, handler: (...args: any[]) => any) { handlers.set(name, handler); },
29
- registerTool(definition: Record<string, any>) { tool = definition; },
30
+ registerTool(definition: Record<string, any>) {
31
+ if (definition.name === "herdr_subagents") tool = definition;
32
+ if (definition.name === "herdr_subagents_control") controlTool = definition;
33
+ },
30
34
  registerCommand(name: string) { commands.push(name); },
31
35
  sendMessage() {},
32
36
  };
33
37
  registerHerdrSubagents(pi as never, {
34
38
  herdr: { async focusPane(paneId: string) { focusedPanes.push(paneId); } } as never,
35
39
  execute: execute as never,
40
+ executeControl: execute as never,
36
41
  isEditor: (value) => value === editor,
37
42
  createFleetWidget(lines) { renderedLines = lines; return new Container(); },
38
43
  });
@@ -40,6 +45,7 @@ function harness(execute: (...args: any[]) => Promise<any>) {
40
45
  handlers,
41
46
  commands,
42
47
  get tool() { return tool; },
48
+ get controlTool() { return controlTool; },
43
49
  get terminalInput() { return terminalInput; },
44
50
  get widgetCalls() { return widgetCalls; },
45
51
  get renderedLines() { return renderedLines; },
@@ -73,7 +79,8 @@ async function verifyPublicUiWiring() {
73
79
  if (!responding.tool) throw new Error("herdr_subagents was not registered for UI wiring test");
74
80
  const renderedBlocked = responding.tool.renderResult({ content: [{ type: "text", text: "needs input" }], details: { agent: "explorer", status: "blocked", ok: false, summary: "Need input.", documents: [] } }, { isPartial: false }, { fg: (_color: string, text: string) => text, bold: (text: string) => text }).render(100).join("\n");
75
81
  if (!renderedBlocked.includes("blocked") || renderedBlocked.includes("failed")) throw new Error("single-task blocked result did not render its authoritative status");
76
- await responding.tool.execute("call", { action: "respond", runId: "blocked-run", answer: "main" }, undefined, undefined, { cwd, ui: responding.ui });
82
+ if (!responding.controlTool) throw new Error("herdr_subagents_control was not registered for UI wiring test");
83
+ await responding.controlTool.execute("call", { action: "respond", runId: "blocked-run", answer: "main" }, undefined, undefined, { cwd, ui: responding.ui });
77
84
  if (!responding.renderedLines.some((line) => line.includes("second") && line.includes("blocked"))) throw new Error("blocked response did not refresh Fleet rows");
78
85
  if (!responding.terminalInput?.("\u001b[B")?.consume || !responding.terminalInput?.("\u001b[B")?.consume || !responding.terminalInput?.("\r")?.consume) throw new Error("Fleet terminal input was not consumed while selecting");
79
86
  await new Promise((resolve) => setTimeout(resolve, 0));
@@ -167,7 +174,7 @@ try {
167
174
  if (inactiveHerdr.tool || inactiveHerdr.commands.length || inactiveHerdr.handlers.size) throw new Error("Subagents registered with HERDR_ENV=0");
168
175
  process.env.HERDR_ENV = "1";
169
176
  const inside = harness(async () => { throw new Error("unreachable"); });
170
- if (!inside.tool || !inside.commands.includes("herdr-subagents") || !inside.handlers.has("session_start")) throw new Error("Subagents did not register inside Herdr");
177
+ if (!inside.tool || !inside.controlTool || !inside.commands.includes("herdr-subagents") || !inside.handlers.has("session_start")) throw new Error("Subagents did not register both tools inside Herdr");
171
178
  process.env.PI_HERDR_SUBAGENTS_CHILD = "1";
172
179
  const child = harness(async () => { throw new Error("unreachable"); });
173
180
  if (child.tool || child.commands.length || child.handlers.size) throw new Error("Subagents registered inside a child Agent");
@@ -183,10 +190,10 @@ try {
183
190
  }
184
191
 
185
192
  export default function (_pi: ExtensionAPI) {
186
- let tool: Record<string, any> | undefined;
193
+ const tools = new Map<string, Record<string, any>>();
187
194
  const pi = {
188
195
  on() {},
189
- registerTool(definition: Record<string, any>) { tool = definition; },
196
+ registerTool(definition: Record<string, any>) { tools.set(definition.name, definition); },
190
197
  registerCommand() {},
191
198
  sendMessage() {},
192
199
  };
@@ -207,19 +214,30 @@ export default function (_pi: ExtensionAPI) {
207
214
  if (previousChild === undefined) delete process.env.PI_HERDR_SUBAGENTS_CHILD;
208
215
  else process.env.PI_HERDR_SUBAGENTS_CHILD = previousChild;
209
216
  }
210
- if (!tool) throw new Error("herdr_subagents was not registered");
217
+ const tool = tools.get("herdr_subagents");
218
+ const controlTool = tools.get("herdr_subagents_control");
219
+ if (!tool || !controlTool) throw new Error("Herdr execution and control tools were not both registered");
211
220
  const schema = tool.parameters as Record<string, any>;
212
221
  if (schema.type !== "object" || schema.anyOf || schema.oneOf) throw new Error("herdr_subagents parameters must be one flat object schema");
213
- const required = ["action", "runId", "answer", "task", "prompt", "agent", "label", "model", "tasks", "concurrency", "background"];
214
- for (const name of required) {
222
+ for (const name of ["tasks", "label", "concurrency", "background"]) {
215
223
  if (!schema.properties?.[name]) throw new Error(`schema is missing property '${name}'`);
216
224
  if (!schema.properties[name].description) throw new Error(`schema property '${name}' has no description`);
217
225
  }
226
+ if (!schema.required?.includes("tasks")) throw new Error("herdr_subagents tasks must be required");
227
+ const normalized = tool.prepareArguments?.({ prompt: "inspect", agent: "explorer", label: "probe", background: true });
228
+ if (normalized?.tasks?.length !== 1 || normalized.tasks[0].name !== "probe" || normalized.tasks[0].prompt !== "inspect" || normalized.tasks[0].agent !== "explorer" || normalized.background !== true) {
229
+ throw new Error("legacy single-task arguments were not normalized into a one-item background run");
230
+ }
218
231
  for (const name of ["name", "prompt", "agent", "model"]) {
219
232
  const property = schema.properties.tasks.items.properties[name];
220
233
  if (!property?.description) throw new Error(`nested task property '${name}' has no description`);
221
234
  }
222
- if (!/Foreground calls block/.test(tool.description) || !/background batches return a run ID immediately/.test(tool.description)) {
235
+ const controlSchema = controlTool.parameters as Record<string, any>;
236
+ for (const name of ["action", "runId", "answer", "task", "prompt"]) {
237
+ if (!controlSchema.properties?.[name]?.description) throw new Error(`control schema property '${name}' is missing or undocumented`);
238
+ }
239
+ if (!controlSchema.required?.includes("action")) throw new Error("herdr_subagents_control action must be required");
240
+ if (!/Foreground calls block/.test(tool.description) || !/background calls return a run ID immediately/.test(tool.description)) {
223
241
  throw new Error("tool description does not distinguish foreground and background execution");
224
242
  }
225
243
  }