@maxiaochao/pi-toolkit 0.10.3 → 0.10.5
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 +7 -0
- package/package.json +1 -1
- package/packages/herdr-subagents/CHANGELOG.md +12 -0
- package/packages/herdr-subagents/README.md +5 -3
- package/packages/herdr-subagents/TESTING.md +4 -4
- package/packages/herdr-subagents/extensions/herdr-subagents.ts +37 -4
- package/packages/herdr-subagents/extensions/subagent-report.ts +26 -0
- package/packages/herdr-subagents/package.json +1 -1
- package/packages/herdr-subagents/src/batch-runner.ts +2 -2
- package/packages/herdr-subagents/src/dispatcher.ts +8 -0
- package/packages/herdr-subagents/src/fleet-controller.ts +4 -1
- package/packages/herdr-subagents/src/history.ts +2 -2
- package/packages/herdr-subagents/src/live-agent.ts +11 -0
- package/packages/herdr-subagents/src/parameters.ts +2 -2
- package/packages/herdr-subagents/src/reconcile.ts +2 -1
- package/packages/herdr-subagents/src/run-pane-allocator.ts +2 -1
- package/packages/herdr-subagents/src/runner.ts +2 -8
- package/packages/herdr-subagents/src/tab-cleanup.ts +18 -2
- package/packages/herdr-subagents/src/tool.ts +29 -3
- package/packages/herdr-subagents/src/validation.ts +2 -1
- package/packages/herdr-subagents/tests/extension-contract.ts +16 -2
- package/packages/herdr-subagents/tests/run-tests.mjs +47 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## 2026-10-08 - v0.10.5
|
|
2
|
+
|
|
3
|
+
- bundle herdr-subagents v0.4.2: add Alt+P and /parent controls to return from child Agents
|
|
4
|
+
## 2026-10-08 - v0.10.4
|
|
5
|
+
|
|
6
|
+
- bundle herdr-subagents v0.4.1: targeted cancellation, live token polling, and idempotent tab cleanup
|
|
7
|
+
|
|
1
8
|
## 2026-10-08 - v0.10.3
|
|
2
9
|
|
|
3
10
|
- bundle herdr-subagents v0.4.0: add the live compact Fleet footer with authoritative metrics and refreshed media
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maxiaochao/pi-toolkit",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.5",
|
|
4
4
|
"description": "Personal pi coding agent toolkit: execution plans, /btw, /cache_export and apply_patch extensions, browser automation, and bundled theme/skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## 2026-10-08 - v0.4.2
|
|
2
|
+
|
|
3
|
+
- add Alt+P and /parent controls to return from child Agents
|
|
4
|
+
- Add `/parent` and `Alt+P` inside child Agents to return focus to the exact parent Agent pane, with a visible child footer hint.
|
|
5
|
+
|
|
6
|
+
## 2026-10-08 - v0.4.1
|
|
7
|
+
|
|
8
|
+
- targeted cancellation, live token polling, and idempotent tab cleanup
|
|
9
|
+
- Add `cancel` control for one active or queued run while leaving unrelated runs untouched.
|
|
10
|
+
- Refresh live Fleet token totals every second as well as on file events.
|
|
11
|
+
- Treat already-missing tabs as successful cleanup and emit one warning per real cleanup failure.
|
|
12
|
+
|
|
1
13
|
## 2026-10-08 - v0.4.0
|
|
2
14
|
|
|
3
15
|
- add the live compact Fleet footer with authoritative metrics and refreshed media
|
|
@@ -72,9 +72,11 @@ A child missing required information reports `needs-input` with an exact questio
|
|
|
72
72
|
herdr_subagents_control({ action: "respond", runId: "<run-id>", answer: "Use the main branch." })
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
Cancel one active or queued run with `herdr_subagents_control({ action: "cancel", runId: "<run-id>" })`. Cancellation aborts its live child prompts, archives active or queued tasks as cancelled, closes its run tab, and leaves unrelated runs alone.
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
While a batch is active, a compact Fleet monitor stays below the editor. Its divider/title row summarizes the run, and each task row shows a hollow or filled status circle, the real persona and task name, status, live active duration, and cumulative child Pi token usage in one compact shared metrics column after the longest row rather than at the terminal edge. Running work uses Pi's warning/yellow semantic color and completed work uses success/green. Token totals use the child session's persisted assistant, tool-result, standalone usage, compaction, and branch-summary usage; they are never estimated. Queued tasks show neither fabricated time nor tokens. The monitor follows Pi theme semantics, truncates by visible terminal columns, and refreshes session usage from both file events and the running one-second timer.
|
|
78
|
+
|
|
79
|
+
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. Inside every child Agent, press `Alt+P` or run `/parent` to return to the exact parent Agent pane; the shortcut is also shown in the child footer.
|
|
78
80
|
|
|
79
81
|
## History
|
|
80
82
|
|
|
@@ -84,7 +86,7 @@ Continue a saved conversation in a new Herdr tab with `herdr_subagents_control({
|
|
|
84
86
|
|
|
85
87
|
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.
|
|
86
88
|
|
|
87
|
-
The widget shows one row per queued, running, blocked, failed, or completed task. Answering a blocked task or resuming saved history refreshes authoritative persona, timing, session, status, and pane projections as work advances. Active duration accumulates only while the task is running, excluding time spent queued or waiting for an answer. Completed rows remain selectable until the whole batch settles. `/herdr-subagents active` shows the same compact state. Native labels stay short: `SA · <batch>` for the tab and `<order> · <task>` for each pane. When all tasks settle, the tab closes immediately if unfocused or after the user leaves it; a deferred close failure is persisted and shown as a warning.
|
|
89
|
+
The widget shows one row per queued, running, blocked, failed, or completed task. Answering a blocked task or resuming saved history refreshes authoritative persona, timing, session, status, and pane projections as work advances. Active duration accumulates only while the task is running, excluding time spent queued or waiting for an answer. Completed rows remain selectable until the whole batch settles. `/herdr-subagents active` shows the same compact state. Native labels stay short: `SA · <batch>` for the tab and `<order> · <task>` for each pane. When all tasks settle, the tab closes immediately if unfocused or after the user leaves it; a deferred close failure is persisted and shown as a warning. An already-missing tab is treated as successfully cleaned, so retries are idempotent.
|
|
88
90
|
|
|
89
91
|
Foreground calls block until all children report completion, failure, or a request for input. Every child is started with Herdr's Agent lifecycle as a normal Pi TUI and prompted with wait semantics; no print-mode command or completion marker is injected into the pane.
|
|
90
92
|
|
|
@@ -16,12 +16,12 @@
|
|
|
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 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.
|
|
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, child `/parent` and `Alt+P` return controls, and stable display while another background run queues.
|
|
20
20
|
- Verify Fleet divider/row formatting, warning/yellow running indicators, success/green completed indicators, persona and task projections, cumulative active duration across blocking/resume, exact Pi usage aggregation from assistant messages, tool results, standalone usage, compaction, and branch summaries, malformed/truncated JSONL tolerance, queued metric omission, and visible-column bounds at narrow widths.
|
|
21
|
-
- Verify live token updates are file-event driven, elapsed repainting exists only while work runs, and widget disposal closes watchers and timers.
|
|
22
|
-
- 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
|
+
- Verify live token updates are file-event driven and polled on each running one-second tick, elapsed repainting exists only while work runs, and widget disposal closes watchers and timers.
|
|
22
|
+
- Verify selected active/queued run cancellation, blocked-response live Fleet updates, authoritative stale-field replacement, parent-shutdown cancellation, idempotent missing-tab cleanup, cleanup notifications, atomic state replacement, non-retried uncertain startup operations, and stalled warnings.
|
|
23
23
|
- Verify resumed batches retain newly allocated pane IDs and historical follow-ups exclude simultaneous session writers.
|
|
24
24
|
|
|
25
25
|
## Latest baseline
|
|
26
26
|
|
|
27
|
-
All
|
|
27
|
+
All 169 deterministic scheduler, recovery, process-boundary, schema-validation, navigation, metrics, responsive-formatting, 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.
|
|
@@ -160,6 +160,7 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
|
|
|
160
160
|
const dispatcher = new RunDispatcher();
|
|
161
161
|
const fleet = new FleetController(herdr, isEditorComponent, dependencies.createFleetWidget, dependencies.fleetWidgetRuntime);
|
|
162
162
|
const runControllers = new Map<string, AbortController>();
|
|
163
|
+
const runPromises = new Map<string, Promise<unknown>>();
|
|
163
164
|
const inFlight = new Set<Promise<unknown>>();
|
|
164
165
|
let sessionController = new AbortController();
|
|
165
166
|
let ownerSessionId: string | undefined;
|
|
@@ -228,8 +229,9 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
|
|
|
228
229
|
}
|
|
229
230
|
return result;
|
|
230
231
|
});
|
|
232
|
+
runPromises.set(runId, promise);
|
|
231
233
|
inFlight.add(promise);
|
|
232
|
-
void promise.finally(() => { inFlight.delete(promise); runControllers.delete(runId); }).catch(() => undefined);
|
|
234
|
+
void promise.finally(() => { inFlight.delete(promise); runControllers.delete(runId); runPromises.delete(runId); }).catch(() => undefined);
|
|
233
235
|
if (params.background) {
|
|
234
236
|
void promise.then((result) => {
|
|
235
237
|
if (generation !== sessionGeneration) return;
|
|
@@ -237,6 +239,10 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
|
|
|
237
239
|
if (!fleet.active && !dispatcher.snapshot().activeRunId && dispatcher.snapshot().queuedRunIds.length === 0) fleet.render();
|
|
238
240
|
}).catch((error) => {
|
|
239
241
|
if (generation !== sessionGeneration) return;
|
|
242
|
+
if (controller.signal.aborted) {
|
|
243
|
+
pi.sendMessage({ customType: "herdr-subagents-run", content: `Subagent run ${runId} cancelled.`, display: true, details: { runId, status: "cancelled" } }, { triggerTurn: true, deliverAs: "followUp" });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
240
246
|
pi.sendMessage({ customType: "herdr-subagents-run", content: `Subagent run ${runId} failed: ${error instanceof Error ? error.message : String(error)}`, display: true, details: { runId, status: "failed" } }, { triggerTurn: true, deliverAs: "followUp" });
|
|
241
247
|
});
|
|
242
248
|
return { content: [{ type: "text" as const, text: `Subagent run queued: ${runId}` }], details: { runId, status: "queued", background: true } };
|
|
@@ -253,12 +259,12 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
|
|
|
253
259
|
pi.registerTool({
|
|
254
260
|
name: "herdr_subagents_control",
|
|
255
261
|
label: "Herdr Subagents Control",
|
|
256
|
-
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.",
|
|
262
|
+
description: "Inspect Herdr personas and run history, cancel active or queued work, answer a blocked task, resume a saved task, or remove a selected archived run. Use herdr_subagents to start new work.",
|
|
257
263
|
promptSnippet: "Inspect and control Herdr subagent runs",
|
|
258
264
|
parameters: HerdrSubagentsControlParams,
|
|
259
265
|
|
|
260
266
|
async execute(_toolCallId, params: HerdrSubagentsControlToolParams, signal, onUpdate, ctx) {
|
|
261
|
-
const executionId =
|
|
267
|
+
const executionId = randomUUID();
|
|
262
268
|
const controller = new AbortController();
|
|
263
269
|
runControllers.set(executionId, controller);
|
|
264
270
|
const runSignal = AbortSignal.any([controller.signal, sessionController.signal, ...(signal ? [signal] : [])]);
|
|
@@ -269,7 +275,34 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
|
|
|
269
275
|
const updateDetails = update.details as { warning?: string };
|
|
270
276
|
if (updateDetails.warning || updateText?.includes("cleanup failed")) ctx.ui.notify(updateDetails.warning ?? updateText ?? "Subagent warning", "warning");
|
|
271
277
|
onUpdate?.(update);
|
|
272
|
-
}, ctx, {
|
|
278
|
+
}, ctx, {
|
|
279
|
+
agentsDirectory: packageAgentsDir,
|
|
280
|
+
herdr,
|
|
281
|
+
owner: { sessionId: ownerSessionId, processId: process.pid },
|
|
282
|
+
cancelRun: async (runId) => {
|
|
283
|
+
const reason = new Error("Subagent run cancelled by user.");
|
|
284
|
+
const targetController = runControllers.get(runId);
|
|
285
|
+
targetController?.abort(reason);
|
|
286
|
+
if (dispatcher.cancelQueuedRun(runId, reason)) {
|
|
287
|
+
await cancelQueuedRuns(ctx.cwd, [runId], { reason: reason.message, owner: { sessionId: ownerSessionId, processId: process.pid } });
|
|
288
|
+
fleet.clearRun(runId);
|
|
289
|
+
return { cancelled: true, queued: true };
|
|
290
|
+
}
|
|
291
|
+
const activePromise = runPromises.get(runId);
|
|
292
|
+
if (activePromise) {
|
|
293
|
+
await activePromise.catch(() => undefined);
|
|
294
|
+
fleet.clearRun(runId);
|
|
295
|
+
dispatcher.release(runId);
|
|
296
|
+
return { cancelled: true };
|
|
297
|
+
}
|
|
298
|
+
const cancellation = await cancelActiveSubagentRuns(ctx.cwd, herdr, { reason: reason.message, owner: { sessionId: ownerSessionId, processId: process.pid }, runId });
|
|
299
|
+
if (cancellation.cancelledRuns) {
|
|
300
|
+
fleet.clearRun(runId);
|
|
301
|
+
dispatcher.release(runId);
|
|
302
|
+
}
|
|
303
|
+
return { cancelled: cancellation.cancelledRuns > 0, cleanupErrors: cancellation.cleanupErrors };
|
|
304
|
+
},
|
|
305
|
+
});
|
|
273
306
|
inFlight.add(promise);
|
|
274
307
|
const result = await promise.finally(() => { inFlight.delete(promise); runControllers.delete(executionId); });
|
|
275
308
|
fleet.sync(result.details);
|
|
@@ -4,6 +4,21 @@ import { StringEnum } from "@earendil-works/pi-ai";
|
|
|
4
4
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { Type } from "typebox";
|
|
6
6
|
import { resultPathForAttempt } from "../src/result-path.ts";
|
|
7
|
+
import { PARENT_PANE_ENV } from "../src/live-agent.ts";
|
|
8
|
+
|
|
9
|
+
const RETURN_SHORTCUT = "alt+p";
|
|
10
|
+
|
|
11
|
+
async function returnToParent(pi: ExtensionAPI, ctx: { ui: { notify(message: string, type?: "info" | "warning" | "error"): void } }): Promise<void> {
|
|
12
|
+
const parentPaneId = process.env[PARENT_PANE_ENV]?.trim();
|
|
13
|
+
if (!parentPaneId) {
|
|
14
|
+
ctx.ui.notify("Parent Agent pane is unavailable for this Subagent session.", "error");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const result = await pi.exec("herdr", ["agent", "focus", parentPaneId]);
|
|
18
|
+
if (result.code !== 0) {
|
|
19
|
+
ctx.ui.notify(`Could not return to the parent Agent: ${result.stderr.trim() || result.stdout.trim() || `Herdr exited ${result.code}`}`, "error");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
7
22
|
|
|
8
23
|
const ReportParams = Type.Object({
|
|
9
24
|
status: StringEnum(["completed", "needs-input", "failed"] as const, { description: "Current outcome for this task." }),
|
|
@@ -24,6 +39,17 @@ function writeAtomic(path: string, content: string): void {
|
|
|
24
39
|
}
|
|
25
40
|
|
|
26
41
|
export default function (pi: ExtensionAPI) {
|
|
42
|
+
pi.registerCommand("parent", {
|
|
43
|
+
description: "Return focus to the parent Agent pane",
|
|
44
|
+
async handler(_args, ctx) { await returnToParent(pi, ctx); },
|
|
45
|
+
});
|
|
46
|
+
pi.registerShortcut(RETURN_SHORTCUT, {
|
|
47
|
+
description: "Return to parent Agent",
|
|
48
|
+
async handler(ctx) { await returnToParent(pi, ctx); },
|
|
49
|
+
});
|
|
50
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
51
|
+
if (process.env[PARENT_PANE_ENV]?.trim()) ctx.ui.setStatus("herdr-subagents-parent", `${RETURN_SHORTCUT} parent`);
|
|
52
|
+
});
|
|
27
53
|
pi.registerTool({
|
|
28
54
|
name: "subagent_report",
|
|
29
55
|
label: "Subagent Report",
|
|
@@ -190,7 +190,7 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
|
|
|
190
190
|
const failed = settledResults.filter((result) => result.status === "failed").length;
|
|
191
191
|
const cancelled = options.signal?.aborted || activity.some((entry) => entry.status === "cancelled");
|
|
192
192
|
const status: BatchResult["status"] = cancelled ? "cancelled" : blocked || activity.some((entry) => entry.status === "queued") ? "blocked" : failed === 0 ? "completed" : failed === tasks.length ? "failed" : "partial";
|
|
193
|
-
const summary = status === "cancelled" ? "Subagent batch cancelled
|
|
193
|
+
const summary = status === "cancelled" ? "Subagent batch cancelled." : blocked ? `${blocked.name} needs input: ${blocked.question}` : failed ? `${tasks.length - failed}/${tasks.length} tasks completed; ${failed} failed.` : `${tasks.length}/${tasks.length} tasks completed.`;
|
|
194
194
|
applyRunStatus(runRecord, status, { question: blocked?.question });
|
|
195
195
|
await writeJsonAtomic(runFile, runRecord);
|
|
196
196
|
return { ok: status === "completed", status, runId, label: options.label, requestedConcurrency, effectiveConcurrency, tasks: settledResults, activity, summary, documents: settledResults.flatMap((result) => result.documents), recordDirectory: runDirectory, tabId };
|
|
@@ -205,7 +205,7 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
|
|
|
205
205
|
await cleanupRunTab({
|
|
206
206
|
herdr, tabId, runFile, runRecord, signal: options.signal, failureStatus: "failed",
|
|
207
207
|
errorPrefix: "Task results settled, but Herdr tab cleanup failed",
|
|
208
|
-
onError: (message) =>
|
|
208
|
+
onError: (message) => options.onCleanupError?.(message),
|
|
209
209
|
});
|
|
210
210
|
} catch (error) {
|
|
211
211
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -51,6 +51,14 @@ export class RunDispatcher {
|
|
|
51
51
|
this.changed();
|
|
52
52
|
return cancelled.map((run) => run.id);
|
|
53
53
|
}
|
|
54
|
+
cancelQueuedRun(id: string, reason: Error): boolean {
|
|
55
|
+
const index = this.queue.findIndex((run) => run.id === id);
|
|
56
|
+
if (index < 0) return false;
|
|
57
|
+
const [cancelled] = this.queue.splice(index, 1);
|
|
58
|
+
cancelled.reject(reason);
|
|
59
|
+
this.changed();
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
54
62
|
|
|
55
63
|
private changed(): void { this.onChange?.(this.snapshot()); }
|
|
56
64
|
|
|
@@ -103,7 +103,10 @@ export class FleetWidget implements Component {
|
|
|
103
103
|
} catch { /* The next durable activity update can retry the watch. */ }
|
|
104
104
|
}
|
|
105
105
|
const hasRunning = this.batch.activity.some((task) => task.status === "running");
|
|
106
|
-
if (hasRunning && !this.elapsedTimer) this.elapsedTimer = this.runtime.setInterval(() =>
|
|
106
|
+
if (hasRunning && !this.elapsedTimer) this.elapsedTimer = this.runtime.setInterval(() => {
|
|
107
|
+
this.tui.requestRender();
|
|
108
|
+
this.scheduleMetricsRefresh(0);
|
|
109
|
+
}, 1_000);
|
|
107
110
|
else if (!hasRunning && this.elapsedTimer) { this.runtime.clearInterval(this.elapsedTimer); this.elapsedTimer = undefined; }
|
|
108
111
|
this.scheduleMetricsRefresh(0);
|
|
109
112
|
}
|
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
|
2
2
|
import { appendFile, mkdir, readFile, rm, rmdir } from "node:fs/promises";
|
|
3
3
|
import { isAbsolute, join } from "node:path";
|
|
4
4
|
import { CliHerdrAutomation, type HerdrAutomation } from "./herdr.ts";
|
|
5
|
-
import { liveAgentName, promptLiveAgent } from "./live-agent.ts";
|
|
5
|
+
import { childEnvironment, liveAgentName, promptLiveAgent } from "./live-agent.ts";
|
|
6
6
|
import { writeJsonAtomic, writeTextAtomic } from "./state.ts";
|
|
7
7
|
import { cleanupRunTab } from "./tab-cleanup.ts";
|
|
8
8
|
import type { OwnerIdentity } from "./ownership.ts";
|
|
@@ -144,7 +144,7 @@ async function resumeHistoricalTaskLocked(cwd: string, runId: string, taskNumber
|
|
|
144
144
|
let report: PersistedReport;
|
|
145
145
|
let status: "blocked" | "completed" | "failed";
|
|
146
146
|
try {
|
|
147
|
-
const env =
|
|
147
|
+
const env = childEnvironment(historical.recordDirectory);
|
|
148
148
|
tab = await herdr.createTab({ workspaceId: process.env.HERDR_WORKSPACE_ID ?? "", cwd, label: `SA · ${run.label}`, env, focus: false, signal: options.signal });
|
|
149
149
|
const paneLabel = `${String(historical.order).padStart(2, "0")} · ${historical.name}`;
|
|
150
150
|
await herdr.renamePane(tab.paneId, paneLabel, options.signal);
|
|
@@ -17,6 +17,17 @@ export interface LiveTaskRecord {
|
|
|
17
17
|
skills?: string[];
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export const PARENT_PANE_ENV = "PI_HERDR_SUBAGENTS_PARENT_PANE_ID";
|
|
21
|
+
|
|
22
|
+
export function childEnvironment(taskDirectory: string, environment: NodeJS.ProcessEnv = process.env): Record<string, string> {
|
|
23
|
+
const parentPaneId = environment.HERDR_PANE_ID?.trim();
|
|
24
|
+
return {
|
|
25
|
+
PI_HERDR_SUBAGENTS_CHILD: "1",
|
|
26
|
+
PI_HERDR_SUBAGENTS_TASK_DIR: taskDirectory,
|
|
27
|
+
...(parentPaneId ? { [PARENT_PANE_ENV]: parentPaneId } : {}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
export function childPiArgs(task: LiveTaskRecord, systemPromptFile: string): string[] {
|
|
21
32
|
const args = [
|
|
22
33
|
"--approve",
|
|
@@ -16,8 +16,8 @@ export const HerdrSubagentsParams = Type.Object({
|
|
|
16
16
|
}, { additionalProperties: false });
|
|
17
17
|
|
|
18
18
|
export const HerdrSubagentsControlParams = Type.Object({
|
|
19
|
-
action: StringEnum(["list", "respond", "history", "resume", "cleanup"] as const, { description: "Control operation to perform." }),
|
|
20
|
-
runId: Type.Optional(Type.String({ minLength: 1, description: "Durable run ID used by respond, resume, or cleanup." })),
|
|
19
|
+
action: StringEnum(["list", "respond", "history", "resume", "cancel", "cleanup"] as const, { description: "Control operation to perform." }),
|
|
20
|
+
runId: Type.Optional(Type.String({ minLength: 1, description: "Durable run ID used by respond, resume, cancel, or cleanup." })),
|
|
21
21
|
answer: Type.Optional(Type.String({ minLength: 1, description: "Answer sent to a blocked task when action is respond." })),
|
|
22
22
|
task: Type.Optional(Type.Integer({ minimum: 1, description: "One-based task number selected for respond or resume." })),
|
|
23
23
|
prompt: Type.Optional(Type.String({ minLength: 1, description: "Follow-up prompt used by resume." })),
|
|
@@ -23,7 +23,7 @@ async function closeRunTab(run: RunRecord, runFile: string, herdr: HerdrAutomati
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
export interface CancelActiveRunsOptions { reason?: string; owner?: OwnerIdentity; }
|
|
26
|
+
export interface CancelActiveRunsOptions { reason?: string; owner?: OwnerIdentity; runId?: string; }
|
|
27
27
|
|
|
28
28
|
export async function cancelActiveSubagentRuns(cwd: string, herdr: HerdrAutomation, options: CancelActiveRunsOptions = {}): Promise<CancellationResult> {
|
|
29
29
|
const reason = options.reason ?? "Parent Pi session closed.";
|
|
@@ -39,6 +39,7 @@ export async function cancelActiveSubagentRuns(cwd: string, herdr: HerdrAutomati
|
|
|
39
39
|
result.cleanupErrors.push(`Could not inspect Subagent run ${runFile}: ${error instanceof Error ? error.message : String(error)}`);
|
|
40
40
|
continue;
|
|
41
41
|
}
|
|
42
|
+
if (options.runId && run.id !== options.runId) continue;
|
|
42
43
|
if (!isActiveStatus(run.status)) continue;
|
|
43
44
|
if (options.owner?.sessionId && run.ownerSessionId !== options.owner.sessionId) continue;
|
|
44
45
|
const now = new Date().toISOString();
|
|
@@ -3,6 +3,7 @@ import { writeJsonAtomic } from "./state.ts";
|
|
|
3
3
|
import type { RunRecord } from "./records.ts";
|
|
4
4
|
import { applyRunStatus } from "./run-status.ts";
|
|
5
5
|
import { cleanupRunTab } from "./tab-cleanup.ts";
|
|
6
|
+
import { childEnvironment } from "./live-agent.ts";
|
|
6
7
|
|
|
7
8
|
interface RunPaneAllocatorOptions {
|
|
8
9
|
herdr: HerdrAutomation;
|
|
@@ -31,7 +32,7 @@ export class RunPaneAllocator {
|
|
|
31
32
|
let paneId = "";
|
|
32
33
|
this.allocation = this.allocation.then(async () => {
|
|
33
34
|
const { herdr, run, runFile, signal } = this.options;
|
|
34
|
-
const env =
|
|
35
|
+
const env = childEnvironment(taskDirectory);
|
|
35
36
|
let anchorExists = Boolean(run.tabId && this.lastPaneId);
|
|
36
37
|
if (anchorExists && this.options.validateExistingPane) {
|
|
37
38
|
const survivingPaneIds: string[] = [];
|
|
@@ -3,7 +3,7 @@ import { mkdir, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { CliHerdrAutomation, type HerdrAutomation } from "./herdr.ts";
|
|
5
5
|
import { ensureRuntimeIgnored } from "./history.ts";
|
|
6
|
-
import { liveAgentName, promptLiveAgent } from "./live-agent.ts";
|
|
6
|
+
import { childEnvironment, liveAgentName, promptLiveAgent } from "./live-agent.ts";
|
|
7
7
|
import { writeJsonAtomic } from "./state.ts";
|
|
8
8
|
import { cleanupRunTab } from "./tab-cleanup.ts";
|
|
9
9
|
import type { OwnerIdentity } from "./ownership.ts";
|
|
@@ -13,9 +13,6 @@ import { reportProtocolPrompt } from "./protocol.ts";
|
|
|
13
13
|
import { applyRunStatus } from "./run-status.ts";
|
|
14
14
|
import { isAbortError } from "./errors.ts";
|
|
15
15
|
|
|
16
|
-
const CHILD_ENV = "PI_HERDR_SUBAGENTS_CHILD";
|
|
17
|
-
const TASK_DIRECTORY_ENV = "PI_HERDR_SUBAGENTS_TASK_DIR";
|
|
18
|
-
|
|
19
16
|
export type HerdrSubagentsDocument = PersistedDocument;
|
|
20
17
|
export type HerdrSubagentsReport = PersistedReport;
|
|
21
18
|
|
|
@@ -168,10 +165,7 @@ export async function runHerdrSubagents(prompt: string, options: HerdrSubagentsO
|
|
|
168
165
|
workspaceId: process.env.HERDR_WORKSPACE_ID,
|
|
169
166
|
cwd,
|
|
170
167
|
label: `SA · ${label}`,
|
|
171
|
-
env:
|
|
172
|
-
[CHILD_ENV]: "1",
|
|
173
|
-
[TASK_DIRECTORY_ENV]: taskDirectory,
|
|
174
|
-
},
|
|
168
|
+
env: childEnvironment(taskDirectory),
|
|
175
169
|
focus: false,
|
|
176
170
|
signal: options.signal,
|
|
177
171
|
});
|
|
@@ -2,6 +2,11 @@ import type { HerdrAutomation } from "./herdr.ts";
|
|
|
2
2
|
import { readRunRecord, type RunRecord } from "./records.ts";
|
|
3
3
|
import { writeJsonAtomic } from "./state.ts";
|
|
4
4
|
|
|
5
|
+
function isMissingTabError(error: unknown): boolean {
|
|
6
|
+
const detail = error instanceof Error ? `${error.message}\n${(error as { stderr?: unknown }).stderr ?? ""}` : String(error);
|
|
7
|
+
return /tab[_ -]?not[_ -]?found|unknown tab/i.test(detail);
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
export async function cleanupRunTab(options: {
|
|
6
11
|
herdr: HerdrAutomation;
|
|
7
12
|
tabId: string;
|
|
@@ -52,17 +57,28 @@ export async function cleanupRunTab(options: {
|
|
|
52
57
|
.then(() => options.herdr.closeTab(options.tabId, options.signal))
|
|
53
58
|
.then(() => finishClosed(true))
|
|
54
59
|
.catch(async (error) => {
|
|
60
|
+
if (isMissingTabError(error)) { await finishClosed(true); return; }
|
|
55
61
|
if (!options.signal?.aborted) { await reportFailure(error, true); return; }
|
|
56
62
|
try {
|
|
57
63
|
await options.herdr.closeTab(options.tabId);
|
|
58
64
|
await finishClosed(true);
|
|
59
|
-
} catch (closeError) {
|
|
65
|
+
} catch (closeError) {
|
|
66
|
+
if (isMissingTabError(closeError)) { await finishClosed(true); return; }
|
|
67
|
+
await reportFailure(closeError, true);
|
|
68
|
+
}
|
|
60
69
|
});
|
|
61
70
|
return { deferred: true };
|
|
62
71
|
}
|
|
63
72
|
await options.herdr.closeTab(options.tabId, options.signal);
|
|
64
73
|
await finishClosed();
|
|
65
74
|
}
|
|
66
|
-
catch (error) {
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (isMissingTabError(error)) {
|
|
77
|
+
await finishClosed();
|
|
78
|
+
return { deferred: false };
|
|
79
|
+
}
|
|
80
|
+
await reportFailure(error);
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
67
83
|
return { deferred: false };
|
|
68
84
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { CliHerdrAutomation, type HerdrAutomation } from "./herdr.ts";
|
|
2
2
|
import { loadSubagentConfiguration, type ConfigurationPaths, type SubagentConfiguration } from "./config.ts";
|
|
3
3
|
import { runHerdrSubagents, type HerdrSubagentsResult } from "./runner.ts";
|
|
4
4
|
import { runHerdrSubagentsBatch, type BatchResult, type BatchTask } from "./batch-runner.ts";
|
|
@@ -6,6 +6,8 @@ import { respondToBlockedTask } from "./blocking.ts";
|
|
|
6
6
|
import { cleanSubagentRun, historyText, listSubagentHistory, resumeHistoricalTask } from "./history.ts";
|
|
7
7
|
import { HERDR_ACTIONS, validateControlParams, validateRunParams } from "./validation.ts";
|
|
8
8
|
import type { OwnerIdentity } from "./ownership.ts";
|
|
9
|
+
import { cancelActiveSubagentRuns } from "./reconcile.ts";
|
|
10
|
+
import { cancelQueuedRuns, loadQueuedRuns } from "./queued-runs.ts";
|
|
9
11
|
|
|
10
12
|
export interface HerdrSubagentsTaskParams { name: string; prompt: string; agent?: string; model?: string; }
|
|
11
13
|
|
|
@@ -17,7 +19,7 @@ export interface HerdrSubagentsRunParams {
|
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export interface HerdrSubagentsControlToolParams {
|
|
20
|
-
action: "list" | "respond" | "history" | "resume" | "cleanup";
|
|
22
|
+
action: "list" | "respond" | "history" | "resume" | "cancel" | "cleanup";
|
|
21
23
|
runId?: string;
|
|
22
24
|
answer?: string;
|
|
23
25
|
task?: number;
|
|
@@ -26,7 +28,7 @@ export interface HerdrSubagentsControlToolParams {
|
|
|
26
28
|
|
|
27
29
|
/** Internal superset used by shared execution helpers. */
|
|
28
30
|
export interface HerdrSubagentsToolParams {
|
|
29
|
-
action?: "list" | "respond" | "history" | "resume" | "cleanup";
|
|
31
|
+
action?: "list" | "respond" | "history" | "resume" | "cancel" | "cleanup";
|
|
30
32
|
prompt?: string;
|
|
31
33
|
agent?: string;
|
|
32
34
|
label?: string;
|
|
@@ -51,6 +53,7 @@ export interface HerdrSubagentsToolDependencies {
|
|
|
51
53
|
configurationPaths?: ConfigurationPaths;
|
|
52
54
|
owner?: OwnerIdentity;
|
|
53
55
|
runId?: string;
|
|
56
|
+
cancelRun?: (runId: string) => Promise<{ cancelled: boolean; queued?: boolean; cleanupErrors?: string[] }>;
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
export type HerdrSubagentsDetails = HerdrSubagentsResult & {
|
|
@@ -125,6 +128,29 @@ const actionHandlers = {
|
|
|
125
128
|
return errorResponse(text, { action: "cleanup", runId: params.runId, cleaned: false });
|
|
126
129
|
}
|
|
127
130
|
},
|
|
131
|
+
async cancel({ params, ctx, dependencies }: ToolExecutionContext) {
|
|
132
|
+
const runId = (params.runId as string).trim();
|
|
133
|
+
try {
|
|
134
|
+
let result: { cancelled: boolean; queued?: boolean; cleanupErrors?: string[] };
|
|
135
|
+
if (dependencies.cancelRun) result = await dependencies.cancelRun(runId);
|
|
136
|
+
else {
|
|
137
|
+
const reason = "Subagent run cancelled by user.";
|
|
138
|
+
const queued = (await loadQueuedRuns(ctx.cwd)).some((run) => run.runId === runId);
|
|
139
|
+
if (queued) {
|
|
140
|
+
await cancelQueuedRuns(ctx.cwd, [runId], { reason, owner: dependencies.owner });
|
|
141
|
+
result = { cancelled: true, queued: true };
|
|
142
|
+
} else {
|
|
143
|
+
const cancellation = await cancelActiveSubagentRuns(ctx.cwd, dependencies.herdr ?? new CliHerdrAutomation(), { reason, owner: dependencies.owner, runId });
|
|
144
|
+
result = { cancelled: cancellation.cancelledRuns > 0, cleanupErrors: cancellation.cleanupErrors };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const suffix = result.cleanupErrors?.length ? ` Cleanup warnings: ${result.cleanupErrors.join("; ")}` : "";
|
|
148
|
+
const text = result.cancelled ? `Cancelled Subagent run ${runId}.${suffix}` : `Subagent run '${runId}' is not active or queued.`;
|
|
149
|
+
return { content: [{ type: "text", text }], details: { action: "cancel", runId, ...result }, ...(result.cancelled ? {} : { isError: true }) };
|
|
150
|
+
} catch (error) {
|
|
151
|
+
return errorResponse(error instanceof Error ? error.message : String(error), { action: "cancel", runId, cancelled: false });
|
|
152
|
+
}
|
|
153
|
+
},
|
|
128
154
|
async resume({ params, signal, onUpdate, ctx, dependencies, discovery }: ToolExecutionContext) {
|
|
129
155
|
try {
|
|
130
156
|
const result = await resumeHistoricalTask(ctx.cwd, params.runId as string, params.task as number, (params.prompt as string).trim(), {
|
|
@@ -3,6 +3,7 @@ import type { HerdrSubagentsControlToolParams, HerdrSubagentsRunParams } from ".
|
|
|
3
3
|
export const HERDR_ACTIONS = {
|
|
4
4
|
list: { allowed: ["action"], includeRunId: false },
|
|
5
5
|
history: { allowed: ["action"], includeRunId: false },
|
|
6
|
+
cancel: { allowed: ["action", "runId"], includeRunId: true },
|
|
6
7
|
cleanup: { allowed: ["action", "runId"], includeRunId: true },
|
|
7
8
|
respond: { allowed: ["action", "runId", "answer", "task"], includeRunId: true },
|
|
8
9
|
resume: { allowed: ["action", "runId", "task", "prompt"], includeRunId: true },
|
|
@@ -18,7 +19,7 @@ export function validateControlParams(params: HerdrSubagentsControlToolParams):
|
|
|
18
19
|
if (!allowed) return "Provide a valid control action.";
|
|
19
20
|
const extra = Object.keys(params).find((key) => !allowed.includes(key as keyof HerdrSubagentsControlToolParams));
|
|
20
21
|
if (extra) return `Field '${extra}' is not valid with action '${params.action}'.`;
|
|
21
|
-
if (params.action === "cleanup" && !params.runId?.trim()) return
|
|
22
|
+
if ((params.action === "cancel" || params.action === "cleanup") && !params.runId?.trim()) return `runId is required for action '${params.action}'.`;
|
|
22
23
|
if (params.action === "respond" && (!params.runId?.trim() || !params.answer?.trim())) return "runId and answer are required for action 'respond'.";
|
|
23
24
|
if (params.action === "resume" && (!params.runId?.trim() || !params.task || !params.prompt?.trim())) return "runId, task, and prompt are required for action 'resume'.";
|
|
24
25
|
return undefined;
|
|
@@ -103,9 +103,23 @@ async function verifyPublicUiWiring() {
|
|
|
103
103
|
await background.tool.execute("second", { background: true, tasks: [{ name: "queued task", prompt: "two" }] }, undefined, undefined, { cwd, ui: background.ui });
|
|
104
104
|
if (background.widgetCalls !== activeWidgetCalls || !background.renderedLines.some((line) => line.includes("active task"))) throw new Error("queued background run replaced the active Fleet widget");
|
|
105
105
|
releaseFirst?.({ content: [{ type: "text", text: "done" }], details: { ...runningDetails, status: "completed" } });
|
|
106
|
-
|
|
106
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
107
|
+
|
|
108
|
+
let cancelledSignal: AbortSignal | undefined;
|
|
109
|
+
const cancellable = harness(async (params, signal, _onUpdate, _ctx, dependencies) => {
|
|
110
|
+
if (params.action === "cancel") {
|
|
111
|
+
const result = await dependencies.cancelRun(params.runId);
|
|
112
|
+
return { content: [{ type: "text", text: "cancelled" }], details: { action: "cancel", runId: params.runId, ...result } };
|
|
113
|
+
}
|
|
114
|
+
cancelledSignal = signal;
|
|
115
|
+
return await new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(signal.reason), { once: true }));
|
|
116
|
+
});
|
|
117
|
+
await startHarness(cancellable, cwd);
|
|
118
|
+
const cancellableRun = await cancellable.tool?.execute("cancellable", { background: true, tasks: [{ name: "cancel me", prompt: "wait" }] }, undefined, undefined, { cwd, ui: cancellable.ui });
|
|
119
|
+
const cancelled = await cancellable.controlTool?.execute("cancel", { action: "cancel", runId: cancellableRun.details.runId }, undefined, undefined, { cwd, ui: cancellable.ui });
|
|
120
|
+
if (!cancelledSignal?.aborted || cancelled?.details?.cancelled !== true) throw new Error("cancel control did not abort the selected live background run");
|
|
107
121
|
|
|
108
|
-
|
|
122
|
+
const rejectedCwd = join(cwd, "rejected-background");
|
|
109
123
|
mkdirSync(rejectedCwd, { recursive: true });
|
|
110
124
|
const rejectedBackground = harness(async () => ({ content: [{ type: "text", text: "Unknown persona" }], details: { errorMessage: "Unknown persona" }, isError: true }));
|
|
111
125
|
await startHarness(rejectedBackground, rejectedCwd);
|
|
@@ -23,6 +23,7 @@ import { applyTaskStatus, isSettledRunStatus, projectTaskActivity, readTaskRecor
|
|
|
23
23
|
import { RunPaneAllocator } from "../src/run-pane-allocator.ts";
|
|
24
24
|
import { aggregateTaskStatus } from "../src/run-status.ts";
|
|
25
25
|
import { isAbortError } from "../src/errors.ts";
|
|
26
|
+
import registerSubagentReport from "../extensions/subagent-report.ts";
|
|
26
27
|
|
|
27
28
|
let passed = 0;
|
|
28
29
|
let failed = 0;
|
|
@@ -140,17 +141,20 @@ function latestRunDirectory() {
|
|
|
140
141
|
try {
|
|
141
142
|
check("run validation accepts one or more tasks", validateRunParams({ tasks: [{ name: "one", prompt: "do it" }], concurrency: 2, background: true }) === undefined);
|
|
142
143
|
check("control validation accepts supported calls", [
|
|
143
|
-
{ action: "list" }, { action: "history" }, { action: "cleanup", runId: "run" },
|
|
144
|
+
{ action: "list" }, { action: "history" }, { action: "cancel", runId: "run" }, { action: "cleanup", runId: "run" },
|
|
144
145
|
{ action: "respond", runId: "run", answer: "yes", task: 1 },
|
|
145
146
|
{ action: "resume", runId: "run", task: 1, prompt: "continue" },
|
|
146
147
|
].every((params) => validateControlParams(params) === undefined));
|
|
147
|
-
check("all supported actions have shared validation and rendering metadata", ["list", "history", "cleanup", "respond", "resume"].every((action) => action in HERDR_ACTIONS));
|
|
148
|
+
check("all supported actions have shared validation and rendering metadata", ["list", "history", "cancel", "cleanup", "respond", "resume"].every((action) => action in HERDR_ACTIONS));
|
|
148
149
|
check("split validation rejects missing tasks and invalid control fields", [
|
|
149
150
|
validateRunParams({ tasks: [] }),
|
|
150
151
|
validateControlParams({ action: "list", prompt: "not allowed" }),
|
|
151
152
|
validateControlParams({ action: "respond", runId: "run" }),
|
|
152
153
|
validateControlParams({ action: "resume", runId: "run", task: 1 }),
|
|
153
154
|
].every((message) => typeof message === "string" && message.length > 0));
|
|
155
|
+
let cancelledRunId;
|
|
156
|
+
const cancelControl = await executeHerdrSubagents({ action: "cancel", runId: "active-run" }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), cancelRun: async (runId) => { cancelledRunId = runId; return { cancelled: true }; } });
|
|
157
|
+
check("cancel control targets the selected active or queued run", !cancelControl.isError && cancelledRunId === "active-run" && cancelControl.details.cancelled === true);
|
|
154
158
|
const fleet = new FleetSelection();
|
|
155
159
|
const fleetBatch = { label: "reviews", activity: [
|
|
156
160
|
{ index: 0, name: "one", status: "completed", paneId: "p1" },
|
|
@@ -239,6 +243,12 @@ try {
|
|
|
239
243
|
runtimeTimeouts.clear();
|
|
240
244
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
241
245
|
check("Fleet widget refreshes token usage from file events", liveWidget.render(80)[1].includes("84 tok"));
|
|
246
|
+
liveTokens = 126;
|
|
247
|
+
for (const callback of [...runtimeIntervals.values()]) callback();
|
|
248
|
+
for (const callback of [...runtimeTimeouts.values()]) callback();
|
|
249
|
+
runtimeTimeouts.clear();
|
|
250
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
251
|
+
check("Fleet widget refreshes token usage on every running one-second tick", liveWidget.render(80)[1].includes("126 tok"));
|
|
242
252
|
liveWidget.update({ ...liveWidgetBatch, status: "blocked", activity: [{ ...liveWidgetBatch.activity[0], status: "blocked", updatedAt: "2026-10-08T07:00:05.000Z", activeStartedAt: undefined, elapsedMs: 5_000 }] });
|
|
243
253
|
check("Fleet widget stops elapsed repainting when work blocks", runtimeIntervals.size === 0 && clearedIntervals === 1);
|
|
244
254
|
liveWidget.dispose();
|
|
@@ -304,7 +314,25 @@ try {
|
|
|
304
314
|
const outside = await runHerdrSubagents("hello", { cwd: root, herdr: unavailableHerdr });
|
|
305
315
|
check("Herdr is required", !outside.ok && outside.errorMessage?.includes("inside Herdr") && unavailableHerdr.calls.length === 0);
|
|
306
316
|
|
|
307
|
-
setEnv({ HERDR_ENV: "1", HERDR_WORKSPACE_ID: "w1", PI_HERDR_SUBAGENTS_PI_BINARY: "/definitely/missing/pi" });
|
|
317
|
+
setEnv({ HERDR_ENV: "1", HERDR_WORKSPACE_ID: "w1", HERDR_PANE_ID: "w1:p-parent", PI_HERDR_SUBAGENTS_PI_BINARY: "/definitely/missing/pi" });
|
|
318
|
+
const childCommands = new Map();
|
|
319
|
+
const childShortcuts = new Map();
|
|
320
|
+
const childEvents = new Map();
|
|
321
|
+
const childExecCalls = [];
|
|
322
|
+
const childStatuses = [];
|
|
323
|
+
registerSubagentReport({
|
|
324
|
+
registerTool() {},
|
|
325
|
+
registerCommand(name, options) { childCommands.set(name, options); },
|
|
326
|
+
registerShortcut(key, options) { childShortcuts.set(key, options); },
|
|
327
|
+
on(name, handler) { childEvents.set(name, handler); },
|
|
328
|
+
async exec(command, args) { childExecCalls.push([command, args]); return { stdout: "", stderr: "", code: 0, killed: false }; },
|
|
329
|
+
});
|
|
330
|
+
process.env.PI_HERDR_SUBAGENTS_PARENT_PANE_ID = "w1:p-parent";
|
|
331
|
+
const childContext = { ui: { notify() {}, setStatus(key, value) { childStatuses.push([key, value]); } } };
|
|
332
|
+
await childEvents.get("session_start")?.({}, childContext);
|
|
333
|
+
await childCommands.get("parent")?.handler("", childContext);
|
|
334
|
+
await childShortcuts.get("alt+p")?.handler(childContext);
|
|
335
|
+
check("child command and shortcut return to the exact parent Agent pane", childExecCalls.length === 2 && childExecCalls.every(([command, args]) => command === "herdr" && args.join(",") === "agent,focus,w1:p-parent") && childStatuses.some(([, value]) => value.includes("alt+p")));
|
|
308
336
|
const herdr = fakeHerdr();
|
|
309
337
|
const success = await runHerdrSubagents("Review authentication", {
|
|
310
338
|
cwd: root,
|
|
@@ -321,7 +349,7 @@ try {
|
|
|
321
349
|
const startRequest = herdr.calls.find(([name]) => name === "startAgent")[1];
|
|
322
350
|
check("named unfocused tab in parent workspace", createRequest.workspaceId === "w1" && createRequest.cwd === root && createRequest.label === "SA · auth-review" && createRequest.focus === false);
|
|
323
351
|
check("ordinary execution never steals pane focus", !herdr.calls.some(([name]) => name === "focusPane"));
|
|
324
|
-
check("child environment points at durable task", createRequest.env.PI_HERDR_SUBAGENTS_CHILD === "1" && createRequest.env.PI_HERDR_SUBAGENTS_TASK_DIR.includes(join(".pi", "herdr-subagents", "runs")));
|
|
352
|
+
check("child environment points at durable task and parent pane", createRequest.env.PI_HERDR_SUBAGENTS_CHILD === "1" && createRequest.env.PI_HERDR_SUBAGENTS_TASK_DIR.includes(join(".pi", "herdr-subagents", "runs")) && createRequest.env.PI_HERDR_SUBAGENTS_PARENT_PANE_ID === "w1:p-parent");
|
|
325
353
|
check("persistent isolated child Pi", startRequest.kind === "pi" && startRequest.paneId === "w1:p2" && startRequest.args.includes("--session") && startRequest.args.includes("--no-extensions") && startRequest.args.includes("--extension") && startRequest.args.includes("--no-skills") && startRequest.args.includes("--model") && startRequest.args.includes("fake/model") && startRequest.args.includes("--tools") && startRequest.args.includes("read,grep,subagent_report"));
|
|
326
354
|
const promptRequest = herdr.calls.find(([name]) => name === "promptAgent")[1];
|
|
327
355
|
check("prompt submitted through Herdr", promptRequest.prompt === "Review authentication" && promptRequest.target === startRequest.name);
|
|
@@ -430,14 +458,20 @@ try {
|
|
|
430
458
|
check("batch is FIFO with a dedicated pane per task", batchPrompts.join(",") === "one slow,two,three FAIL,four" && new Set(batchStarts.map((request) => request.paneId)).size === 4);
|
|
431
459
|
check("failed sibling does not cancel batch", batch.isError && batchDetails.status === "partial" && batchDetails.tasks.map((task) => task.name).join(",") === "one,two,three,four" && batchDetails.tasks[2].status === "failed" && batchDetails.tasks[3].status === "completed");
|
|
432
460
|
const batchCleanupHerdr = fakeHerdr("close-failed");
|
|
433
|
-
const
|
|
461
|
+
const batchCleanupUpdates = [];
|
|
462
|
+
const batchCleanup = await executeHerdrSubagents({ tasks: [{ name: "cleanup", prompt: "cleanup", agent: "explorer" }] }, undefined, (update) => batchCleanupUpdates.push(update), { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: batchCleanupHerdr });
|
|
434
463
|
const batchCleanupRecord = JSON.parse(readFileSync(join(batchCleanup.details.recordDirectory, "run.json"), "utf8"));
|
|
435
|
-
check("batch cleanup failure is persisted and surfaced", batchCleanup.isError && batchCleanup.content[0].text.includes("cleanup failed") && batchCleanupRecord.status === "failed" && batchCleanupRecord.cleanupError.includes("tab still busy"));
|
|
464
|
+
check("batch cleanup failure is persisted and surfaced once", batchCleanup.isError && batchCleanup.content[0].text.includes("cleanup failed") && batchCleanupRecord.status === "failed" && batchCleanupRecord.cleanupError.includes("tab still busy") && batchCleanupUpdates.filter((update) => update.content[0]?.text.includes("cleanup failed")).length === 1);
|
|
436
465
|
const cleanupProbeHerdr = fakeHerdr();
|
|
437
466
|
cleanupProbeHerdr.isTabFocused = async () => { throw new Error("tab lookup failed"); };
|
|
438
467
|
const cleanupProbe = await executeHerdrSubagents({ tasks: [{ name: "cleanup-probe", prompt: "cleanup probe", agent: "explorer" }] }, undefined, undefined, { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: cleanupProbeHerdr });
|
|
439
468
|
const cleanupProbeRecord = JSON.parse(readFileSync(join(cleanupProbe.details.recordDirectory, "run.json"), "utf8"));
|
|
440
469
|
check("cleanup focus-probe failure is persisted and surfaced", cleanupProbe.isError && cleanupProbe.content[0].text.includes("tab lookup failed") && cleanupProbeRecord.status === "failed" && cleanupProbeRecord.cleanupError === "tab lookup failed");
|
|
470
|
+
const missingTabHerdr = fakeHerdr();
|
|
471
|
+
missingTabHerdr.isTabFocused = async () => { throw new Error('{"error":{"code":"tab_not_found","message":"tab w1:t2 not found"}}'); };
|
|
472
|
+
const missingTabCleanup = await executeHerdrSubagents({ tasks: [{ name: "already-closed", prompt: "already closed", agent: "explorer" }] }, undefined, undefined, { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: missingTabHerdr });
|
|
473
|
+
const missingTabRecord = JSON.parse(readFileSync(join(missingTabCleanup.details.recordDirectory, "run.json"), "utf8"));
|
|
474
|
+
check("already-missing tab is successful idempotent cleanup", !missingTabCleanup.isError && missingTabRecord.status === "completed" && !missingTabRecord.cleanupPendingTabIds && !missingTabRecord.cleanupError);
|
|
441
475
|
const activeUpdate = batchUpdates.find((update) => update.activity?.some((task) => task.status === "running"));
|
|
442
476
|
const counts = activityCounts(activeUpdate);
|
|
443
477
|
const focusHerdr = fakeHerdr();
|
|
@@ -551,6 +585,13 @@ try {
|
|
|
551
585
|
check("dispatcher starts next queued run automatically", starts.join(",") === "run-one,run-two" && dispatcher.snapshot().activeRunId === "run-two");
|
|
552
586
|
releaseSecond();
|
|
553
587
|
check("dispatcher settles queued run", await second === "two");
|
|
588
|
+
const cancellingDispatcher = new RunDispatcher();
|
|
589
|
+
cancellingDispatcher.pause();
|
|
590
|
+
const removedQueued = cancellingDispatcher.submit("remove-me", async () => "unexpected").catch((error) => error.message);
|
|
591
|
+
const keptQueued = cancellingDispatcher.submit("keep-me", async () => "kept");
|
|
592
|
+
check("dispatcher cancels only the selected queued run", cancellingDispatcher.cancelQueuedRun("remove-me", new Error("cancelled by user")) && await removedQueued === "cancelled by user" && cancellingDispatcher.snapshot().queuedRunIds.join(",") === "keep-me");
|
|
593
|
+
cancellingDispatcher.resume();
|
|
594
|
+
check("dispatcher still executes uncancelled queued siblings", await keptQueued === "kept");
|
|
554
595
|
const retainedDispatcher = new RunDispatcher();
|
|
555
596
|
const retainedStarts = [];
|
|
556
597
|
const retainedFirst = retainedDispatcher.submit("blocked-run", async () => { retainedStarts.push("blocked-run"); retainedDispatcher.retain("blocked-run"); return "blocked"; });
|