@maxiaochao/pi-toolkit 0.10.1 → 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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## 2026-10-08 - v0.10.2
2
+
3
+ - bundle herdr-subagents v0.3.0: split execution and control tools and unify task runs
4
+ - Bundle the Herdr Subagents execution/control tool split and unified non-empty `tasks` input for single and multi-task runs.
5
+
1
6
  ## 2026-10-08 - v0.10.1
2
7
 
3
8
  - bundle herdr-subagents v0.2.1: add default thinking configuration and inline Herdr workflow video
package/global/AGENTS.md CHANGED
@@ -4,71 +4,54 @@ Behavioral guidelines to reduce common LLM coding mistakes. Project-specific ins
4
4
 
5
5
  ## 1. Think Before Coding
6
6
 
7
- **Surface tradeoffs. Name confusion instead of resolving it silently.**
7
+ - Surface material assumptions, interpretations, and tradeoffs.
8
+ - If unresolved ambiguity would materially change the result, ask. When no human is reachable, choose a defensible interpretation, state it, and proceed with verifiable criteria.
9
+ - Prefer the simpler approach. Name conflicting requirements rather than silently compromising between them.
8
10
 
9
- - State your material assumptions explicitly.
10
- - If multiple interpretations exist, present them rather than picking one silently.
11
- - If a simpler approach exists, say so. Push back when warranted.
12
- - If requirements conflict (durability vs latency, correctness vs speed), name the conflict explicitly. Never average conflicting requirements into something that satisfies neither.
13
- - When unresolved ambiguity would materially change the result, ask. When no human is reachable (headless, async, subagent), choose the interpretation you can defend, state it as an assumption, and proceed under Rule 4.
11
+ ## 2. Minimal Solution
14
12
 
15
- ## 2. Simplicity First
13
+ Understand the problem and relevant code flow before choosing the first rung that works:
16
14
 
17
- **YAGNI cuts what you don't need; KISS keeps what remains simple.**
15
+ 1. Skip functionality that is speculative or was not requested.
16
+ 2. Reuse an existing project abstraction when it encodes the required semantics.
17
+ 3. Otherwise use the standard library or a native platform feature.
18
+ 4. Otherwise use a direct dependency already declared by the project.
19
+ 5. Only then write the minimum custom code that works.
18
20
 
19
- - Build what was asked, for the callers that exist today.
20
- - Introduce an abstraction after repeated use reveals a stable concept.
21
+ - Introduce abstractions only after repeated use reveals a stable concept.
22
+ - Prefer boring, focused code; review long or deeply nested functions for splitting.
23
+ - Never simplify away explicit requirements, trust-boundary validation, data-loss protection, security, or accessibility.
21
24
  - For states excluded by enforced invariants, fail visibly instead of inventing fallback behavior.
22
- - Each function you write does one thing. If it runs past a couple of screens or nests past three levels, review whether splitting it improves clarity.
25
+ - State the ceiling and upgrade trigger of any deliberate simplification that cuts a real corner.
23
26
 
24
- ## 3. Surgical Changes
27
+ ## 3. Skill Boundaries
25
28
 
26
- **Touch only what you must. Clean up only your own mess.**
29
+ After loading a skill, treat its documented interface as sufficient for normal use. Do not inspect the skill's implementation, installed executable, or package source unless the user asks to review, debug, or modify it; its documented behavior conflicts with observed behavior; or required usage details are missing from the skill.
27
30
 
28
- The test: every changed line traces to the user's request, or to a defect that request reveals.
31
+ ## 4. Read, Then Change Surgically
29
32
 
30
- - Match existing style, even if you'd do it differently.
31
- - Remove imports, variables, and functions that YOUR changes made unused. Leave pre-existing dead code in place and mention it.
33
+ Before editing, read the affected public API and its immediate callers. For a bug, find every statically discoverable caller and relevant implementation.
32
34
 
33
- When fixing a bug, fix the root cause, not the symptom. Find where the defect actually lives:
34
- - Find every statically discoverable caller and relevant implementation; a report names a symptom, and patching only the path the ticket mentions leaves sibling callers still broken.
35
- - If the shared function is wrong, fix it once - do not scatter per-caller guards. Name the sibling callers the fix now affects.
36
- - If one caller is wrong, fix that caller - do not add defensive handling to a correct shared function.
35
+ Every changed line traces to the user's request or to a defect that request reveals.
37
36
 
38
- ## 4. Goal-Driven Execution
37
+ - Match existing style.
38
+ - Remove imports, variables, and functions that your changes make unused. Leave pre-existing dead code in place and mention it.
39
+ - Fix a faulty shared function once and name affected sibling callers; if only one caller is wrong, fix that caller instead of guarding a correct shared function.
39
40
 
40
- **Define success criteria. Loop until verified.**
41
+ ## 5. Goal-Driven Execution
41
42
 
42
- Transform tasks into verifiable goals:
43
- - "Add validation" -> "Write tests for invalid inputs, then make them pass"
44
- - "Fix the bug" -> "Write a test that reproduces it, then make it pass"
45
- - "Refactor X" -> "Ensure tests pass before and after"
46
- - "Make X fast" -> "Write and test a simple reference implementation, then optimize against its results"
43
+ - Turn work into checkable outcomes: reproduce bugs before fixing them, preserve behavior during refactors, and optimize against a tested reference implementation.
44
+ - For multi-step or materially ambiguous work, state a brief plan and give every step its own verification.
45
+ - Leave the smallest relevant runnable check for new or changed non-trivial logic. Do not introduce a test framework solely for that check.
47
46
 
48
- For multi-step or materially ambiguous tasks, state a brief plan where every step carries its own check:
49
- 1. [Step] -> verify: [check]
47
+ ## 6. Fail Visibly
50
48
 
51
- Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
52
-
53
- ## 5. Read Before You Write
54
-
55
- **Understand adjacent code before changing it. "Looks orthogonal" is a dangerous assumption.**
56
-
57
- Before changing a file:
58
- - Read the file's exports and public API.
59
- - Read the immediate callers of the function you're modifying.
60
-
61
- ## 6. Fail Visibly, Not Silently
62
-
63
- **Silent skips are failures.**
64
-
65
- - "Completed" is wrong if anything was silently skipped.
49
+ - "Completed" is wrong if anything required was silently skipped.
66
50
  - "Tests pass" is wrong if any required test was skipped or not run.
67
- - If a required input, file, or tool is missing (or a step is impossible under the stated constraints), do NOT fabricate it to make a check pass. Report what is missing and what you need.
51
+ - If a required input, file, or tool is missing, or a step is impossible under the stated constraints, report what is missing and what you need. Never fabricate it to make a check pass.
52
+
53
+ ## 7. Worktree Isolation
68
54
 
69
- ## 7. Reuse Before Writing
55
+ For a Git-repository task that coordinates substantial changes across multiple files or modules, or implements a feature intended for release, ask before editing whether to isolate it in a worktree. Skip the question when already in a linked worktree.
70
56
 
71
- **Before writing new code, check in order:**
72
- 1. Reuse an existing project abstraction when it encodes project semantics. (If it is itself the bug, fix it once - see Rule 3.)
73
- 2. Otherwise use the standard library or a direct dependency already declared by the project.
74
- 3. Only then write it yourself.
57
+ If the user agrees, read the `pi-worktree` skill and establish the worktree before changing code. When no human is reachable, create a worktree only when the task explicitly requests one.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-toolkit",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "description": "Personal pi coding agent toolkit: /todos, /btw, /cache_export and apply_patch extensions, browser automation, and bundled theme/skills.",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -1,3 +1,9 @@
1
+ ## 2026-10-08 - v0.3.0
2
+
3
+ - split execution and control tools and unify task runs
4
+ - Split execution and control into `herdr_subagents` and `herdr_subagents_control`; require a non-empty `tasks` array for new runs so one-item runs and batches share background and concurrency semantics.
5
+ - Normalize legacy top-level single-task prompts into one-item task arrays before schema validation.
6
+
1
7
  ## 2026-10-08 - v0.2.1
2
8
 
3
9
  - add default thinking configuration and inline Herdr workflow video
@@ -29,7 +29,7 @@ pi update npm:@maxiaochao/pi-herdr-subagents
29
29
 
30
30
  The full `@maxiaochao/pi-toolkit` package also bundles this extension. Install the full toolkit or this standalone child package, not both.
31
31
 
32
- Inside a Herdr workspace, the package registers `herdr_subagents` and `/herdr-subagents`. Outside Herdr (or without a workspace ID), the extension registers neither, so the tool is absent from the model context. Start Pi inside Herdr or reload the extension there to enable them.
32
+ Inside a Herdr workspace, the package registers `herdr_subagents`, `herdr_subagents_control`, and `/herdr-subagents`. Outside Herdr (or without a workspace ID), the extension registers none of them, so these capabilities are absent from the model context. Start Pi inside Herdr or reload the extension there to enable them.
33
33
 
34
34
  The design replaces hidden print-mode subprocesses with ordinary interactive Pi Agents managed through Herdr, so delegated work stays visible, inspectable, resumable, and durably recorded.
35
35
 
@@ -38,16 +38,17 @@ The design replaces hidden print-mode subprocesses with ordinary interactive Pi
38
38
  Discover the effective personas first:
39
39
 
40
40
  ```text
41
- herdr_subagents({ action: "list" })
41
+ herdr_subagents_control({ action: "list" })
42
42
  ```
43
43
 
44
44
  Then run one self-contained task:
45
45
 
46
46
  ```text
47
47
  herdr_subagents({
48
- agent: "worker",
49
48
  label: "auth-review",
50
- prompt: "Inspect the authentication flow and summarize the risks"
49
+ tasks: [
50
+ { name: "auth", agent: "worker", prompt: "Inspect the authentication flow and summarize the risks" }
51
+ ]
51
52
  })
52
53
  ```
53
54
 
@@ -64,21 +65,21 @@ herdr_subagents({
64
65
  })
65
66
  ```
66
67
 
67
- Set `background: true` to return a stable run ID immediately while the queue continues. One run is dispatched at a time by an extension instance; later runs remain queued and start FIFO. Each background run sends one compact parent notification only when the whole run completes, partially fails, fails, or blocks. Individual successful tasks do not wake the parent model.
68
+ `tasks` is always a non-empty array: one item is a single task and multiple items form a batch. Set `background: true` for either form to return a stable run ID immediately while the queue continues. One run is dispatched at a time by an extension instance; later runs remain queued and start FIFO. Each background run sends one compact parent notification only when the whole run completes, partially fails, fails, or blocks. Individual successful tasks do not wake the parent model.
68
69
 
69
- A child missing required information reports `needs-input` with an exact question. The run becomes blocked, keeps its Herdr tab, pane, partial report, and Pi session, and returns control to the parent. Answer through the same tool to continue the original session:
70
+ A child missing required information reports `needs-input` with an exact question. The run becomes blocked, keeps its Herdr tab, pane, partial report, and Pi session, and returns control to the parent. Answer through the control tool to continue the original session:
70
71
 
71
72
  ```text
72
- herdr_subagents({ action: "respond", runId: "<run-id>", answer: "Use the main branch." })
73
+ herdr_subagents_control({ action: "respond", runId: "<run-id>", answer: "Use the main branch." })
73
74
  ```
74
75
 
75
76
  While a batch is active, its task rows stay below the editor. With an empty editor, press `↓` to enter the list, `↑`/`↓` to choose a task, `Enter` to focus its exact pane, and `Esc` to return. `/herdr-subagents focus <task-number>` remains available as a fallback.
76
77
 
77
78
  ## History
78
79
 
79
- Run `/herdr-subagents history` or `herdr_subagents({ action: "history" })` to list compact project-local run and task reports after Herdr tabs close. Full results, described documents, artifacts, and persistent Pi sessions remain separate on disk and are not loaded into parent context.
80
+ Run `/herdr-subagents history` or `herdr_subagents_control({ action: "history" })` to list compact project-local run and task reports after Herdr tabs close. Full results, described documents, artifacts, and persistent Pi sessions remain separate on disk and are not loaded into parent context.
80
81
 
81
- Continue a saved conversation in a new Herdr tab with `herdr_subagents({ action: "resume", runId: "<run-id>", task: 1, prompt: "Follow up..." })`. Remove only a selected archive with `herdr_subagents({ action: "cleanup", runId: "<run-id>" })`. History has no automatic count-based eviction. The extension adds the runtime path to local Git excludes when available (and otherwise uses `.pi/.gitignore`) without overwriting unrelated rules.
82
+ Continue a saved conversation in a new Herdr tab with `herdr_subagents_control({ action: "resume", runId: "<run-id>", task: 1, prompt: "Follow up..." })`. Remove only a selected archive with `herdr_subagents_control({ action: "cleanup", runId: "<run-id>" })`. History has no automatic count-based eviction. The extension adds the runtime path to local Git excludes when available (and otherwise uses `.pi/.gitignore`) without overwriting unrelated rules.
82
83
 
83
84
  Read-only tasks start FIFO up to the configured concurrency limit. A pane is created only when its task starts, belongs exclusively to that task, and is never reused. Completed panes remain inspectable while another task in the batch is unsettled. Results remain in input order, and one failed task does not cancel its siblings. Any batch containing a write-capable persona is forced to concurrency one.
84
85
 
@@ -250,9 +251,9 @@ Override the model for one task:
250
251
 
251
252
  ```text
252
253
  herdr_subagents({
253
- agent: "reviewer",
254
- model: "anthropic/claude-opus-4-1:high",
255
- prompt: "Review the authorization boundary"
254
+ tasks: [
255
+ { name: "authorization", agent: "reviewer", model: "anthropic/claude-opus-4-1:high", prompt: "Review the authorization boundary" }
256
+ ]
256
257
  })
257
258
  ```
258
259
 
@@ -16,10 +16,10 @@
16
16
  - Force write-capable batches to serial execution while allowing bounded read-only parallelism.
17
17
  - Preserve compact reports, full results, Pi sessions, blocked questions, and historical follow-ups.
18
18
  - Recover queued/running work after interruption without retrying post-prompt side effects, including truncated reports and recovery errors.
19
- - Verify the flat public contract and injectable extension wiring, including conditional registration only inside Herdr, Fleet rendering, editor focus ownership, terminal-input passthrough, exact-pane navigation, and stable display while another background run queues.
19
+ - Verify the split public contracts (`herdr_subagents` with required `tasks`, plus `herdr_subagents_control` with required `action`) and injectable extension wiring, including conditional registration only inside Herdr, Fleet rendering, editor focus ownership, terminal-input passthrough, exact-pane navigation, and stable display while another background run queues.
20
20
  - Verify blocked-response live Fleet updates, authoritative stale-field replacement, parent-shutdown cancellation, cleanup notifications, atomic state replacement, non-retried uncertain startup operations, and stalled warnings.
21
21
  - Verify resumed batches retain newly allocated pane IDs and historical follow-ups exclude simultaneous session writers.
22
22
 
23
23
  ## Latest baseline
24
24
 
25
- All 136 deterministic scheduler, recovery, process-boundary, schema-validation, navigation, and persona cases must pass. Release validation also requires extension loading and a real Herdr read-only batch smoke with distinct task panes and final tab cleanup. The latest smoke completed two concurrent tasks, retained its focused run tab through settlement, and closed it automatically after focus moved away.
25
+ All 137 deterministic scheduler, recovery, process-boundary, schema-validation, navigation, and persona cases must pass. Release validation also requires extension loading and a real Herdr read-only batch smoke with distinct task panes and final tab cleanup. The latest smoke completed two concurrent tasks, retained its focused run tab through settlement, and closed it automatically after focus moved away.
@@ -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> {
@@ -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.1",
3
+ "version": "0.3.0",
4
4
  "description": "Run inspectable interactive Pi subagent batches through Herdr.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -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
  }
@@ -15,7 +15,7 @@ import { cancelActiveSubagentRuns, reconcileSubagentRuns } from "../src/reconcil
15
15
  import { continueQueuedRun } from "../src/continue-run.ts";
16
16
  import { CliHerdrAutomation } from "../src/herdr.ts";
17
17
  import { fleetEditorHasFocus, FleetSelection, fleetLines } from "../src/fleet.ts";
18
- import { HERDR_ACTIONS, validateToolParams } from "../src/validation.ts";
18
+ import { HERDR_ACTIONS, validateControlParams, validateRunParams } from "../src/validation.ts";
19
19
  import { resultPathForAttempt } from "../src/result-path.ts";
20
20
  import { isSettledRunStatus, readTaskRecord } from "../src/records.ts";
21
21
  import { RunPaneAllocator } from "../src/run-pane-allocator.ts";
@@ -136,22 +136,18 @@ function latestRunDirectory() {
136
136
  }
137
137
 
138
138
  try {
139
- check("flat mode validation accepts supported calls", [
139
+ check("run validation accepts one or more tasks", validateRunParams({ tasks: [{ name: "one", prompt: "do it" }], concurrency: 2, background: true }) === undefined);
140
+ check("control validation accepts supported calls", [
140
141
  { action: "list" }, { action: "history" }, { action: "cleanup", runId: "run" },
141
142
  { action: "respond", runId: "run", answer: "yes", task: 1 },
142
143
  { action: "resume", runId: "run", task: 1, prompt: "continue" },
143
- { prompt: "one task", agent: "worker", label: "one", model: "fake/model" },
144
- { tasks: [{ name: "one", prompt: "do it" }], concurrency: 2, background: true },
145
- ].every((params) => validateToolParams(params) === undefined));
144
+ ].every((params) => validateControlParams(params) === undefined));
146
145
  check("all supported actions have shared validation and rendering metadata", ["list", "history", "cleanup", "respond", "resume"].every((action) => action in HERDR_ACTIONS));
147
- check("flat mode validation rejects ambiguous and extraneous fields", [
148
- validateToolParams({ prompt: "one", tasks: [{ name: "two", prompt: "two" }] }),
149
- validateToolParams({ action: "list", prompt: "not allowed" }),
150
- validateToolParams({ action: "respond", runId: "run" }),
151
- validateToolParams({ tasks: [{ name: "one", prompt: "one" }], agent: "worker" }),
152
- validateToolParams({ prompt: "one", runId: "run" }),
153
- validateToolParams({ prompt: "one", task: 1 }),
154
- validateToolParams({ tasks: [{ name: "one", prompt: "one" }], answer: "extra" }),
146
+ check("split validation rejects missing tasks and invalid control fields", [
147
+ validateRunParams({ tasks: [] }),
148
+ validateControlParams({ action: "list", prompt: "not allowed" }),
149
+ validateControlParams({ action: "respond", runId: "run" }),
150
+ validateControlParams({ action: "resume", runId: "run", task: 1 }),
155
151
  ].every((message) => typeof message === "string" && message.length > 0));
156
152
  const fleet = new FleetSelection();
157
153
  const fleetBatch = { label: "reviews", activity: [
@@ -554,7 +550,7 @@ try {
554
550
  const historyHerdr = fakeHerdr();
555
551
  const historicalResume = await executeHerdrSubagents({ action: "resume", runId: backgroundId, task: 1, prompt: "follow up" }, undefined, undefined, { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: historyHerdr });
556
552
  const historyStartRequest = historyHerdr.calls.find(([name]) => name === "startAgent")[1];
557
- check("historical resume preserves the saved Pi session and persona prompt", !historicalResume.isError && historyHerdr.calls[0][0] === "createTab" && historyStartRequest.args.includes(stableHistory.tasks[0].sessionFile) && historyStartRequest.args.includes("fake/model") && historyStartRequest.args.some((value) => String(value).includes("subagent_report")) && existsSync(join(stableHistory.tasks[0].recordDirectory, "turns", "02.json")) && readFileSync(join(stableHistory.tasks[0].recordDirectory, "followup-system-prompt.md"), "utf8").includes("Investigate the assigned question without modifying files"));
553
+ check("historical resume preserves the saved Pi session and persona prompt", !historicalResume.isError && historyHerdr.calls[0][0] === "createTab" && historyStartRequest.args.includes(stableHistory.tasks[0].sessionFile) && historyStartRequest.args.includes(stableHistory.tasks[0].model) && historyStartRequest.args.some((value) => String(value).includes("subagent_report")) && existsSync(join(stableHistory.tasks[0].recordDirectory, "turns", "02.json")) && readFileSync(join(stableHistory.tasks[0].recordDirectory, "followup-system-prompt.md"), "utf8").includes("Investigate the assigned question without modifying files"));
558
554
  const historicalTaskRecord = JSON.parse(readFileSync(join(stableHistory.tasks[0].recordDirectory, "task.json"), "utf8"));
559
555
  const historicalTurnRecord = JSON.parse(readFileSync(join(stableHistory.tasks[0].recordDirectory, "turns", "02.json"), "utf8"));
560
556
  check("historical task and turn retain their tab identity", historicalTaskRecord.tabId === historicalResume.details.tabId && historicalTurnRecord.tabId === historicalResume.details.tabId);