@maxiaochao/pi-toolkit 0.11.6 → 0.11.8
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 +11 -0
- package/README.md +2 -1
- package/package.json +5 -3
- package/packages/herdr-subagents/ARCHITECTURE.md +82 -378
- package/packages/herdr-subagents/CHANGELOG.md +4 -0
- package/packages/herdr-subagents/README.md +52 -321
- package/packages/herdr-subagents/TESTING.md +36 -53
- package/packages/herdr-subagents/extensions/herdr-subagents.ts +24 -158
- package/packages/herdr-subagents/extensions/subagent-report.ts +21 -7
- package/packages/herdr-subagents/package.json +4 -3
- package/packages/herdr-subagents/src/batch-runner.ts +5 -3
- package/packages/herdr-subagents/src/herdr.ts +11 -0
- package/packages/herdr-subagents/src/live-agent.ts +49 -114
- package/packages/herdr-subagents/src/parameters.ts +2 -6
- package/packages/herdr-subagents/src/protocol.ts +4 -5
- package/packages/herdr-subagents/src/records.ts +2 -0
- package/packages/herdr-subagents/src/tool.ts +9 -51
- package/packages/herdr-subagents/src/validation.ts +0 -4
- package/packages/herdr-subagents/tests/core-state-machine.mjs +178 -0
- package/packages/herdr-subagents/tests/extension-sources-load.mjs +7 -3
- package/packages/openai-account-pool/CHANGELOG.md +11 -0
- package/packages/openai-account-pool/README.md +67 -0
- package/packages/openai-account-pool/TESTING.md +28 -0
- package/packages/openai-account-pool/docs/demo.gif +0 -0
- package/packages/openai-account-pool/docs/screenshot.png +0 -0
- package/packages/openai-account-pool/extensions/openai-account-pool.ts +368 -0
- package/packages/openai-account-pool/package.json +45 -0
- package/packages/openai-account-pool/src/auth.ts +418 -0
- package/packages/openai-account-pool/src/availability.ts +230 -0
- package/packages/openai-account-pool/src/command-completions.ts +18 -0
- package/packages/openai-account-pool/src/enrollment.ts +339 -0
- package/packages/openai-account-pool/src/lease.ts +128 -0
- package/packages/openai-account-pool/src/lock.ts +171 -0
- package/packages/openai-account-pool/src/login-slot.ts +158 -0
- package/packages/openai-account-pool/src/policy.ts +176 -0
- package/packages/openai-account-pool/src/provider.ts +511 -0
- package/packages/openai-account-pool/src/status-widget.ts +62 -0
- package/packages/openai-account-pool/src/store.ts +259 -0
- package/packages/openai-account-pool/src/usage.ts +291 -0
- package/packages/openai-account-pool/tests/command-completions.mjs +10 -0
- package/packages/openai-account-pool/tests/enrollment.mjs +322 -0
- package/packages/openai-account-pool/tests/failover-stream.mjs +554 -0
- package/packages/openai-account-pool/tests/helpers/fixtures.mjs +40 -0
- package/packages/openai-account-pool/tests/helpers/native-credential-store.mjs +58 -0
- package/packages/openai-account-pool/tests/helpers/pi-runtime.mjs +24 -0
- package/packages/openai-account-pool/tests/helpers/refresh-child.mjs +29 -0
- package/packages/openai-account-pool/tests/integration-login-ownership.mjs +299 -0
- package/packages/openai-account-pool/tests/login-slot.mjs +131 -0
- package/packages/openai-account-pool/tests/multiprocess.mjs +73 -0
- package/packages/openai-account-pool/tests/selection.mjs +286 -0
- package/packages/openai-account-pool/tests/store-ownership.mjs +369 -0
- package/packages/openai-account-pool/tests/usage.mjs +428 -0
|
@@ -5,15 +5,13 @@ import { Container, Editor, Text } from "@earendil-works/pi-tui";
|
|
|
5
5
|
import { loadSubagentConfiguration } from "../src/config.ts";
|
|
6
6
|
import { CliHerdrAutomation, type HerdrAutomation } from "../src/herdr.ts";
|
|
7
7
|
import { activeRunText } from "../src/monitor.ts";
|
|
8
|
-
import { RunDispatcher } from "../src/dispatcher.ts";
|
|
9
8
|
import { historyText, listSubagentHistory } from "../src/history.ts";
|
|
10
|
-
import { cancelQueuedRuns, loadQueuedRuns
|
|
9
|
+
import { cancelQueuedRuns, loadQueuedRuns } from "../src/queued-runs.ts";
|
|
11
10
|
import { cancelActiveSubagentRuns, reconcileSubagentRuns } from "../src/reconcile.ts";
|
|
12
11
|
import { executeHerdrSubagents, executeHerdrSubagentsControl, type HerdrSubagentsBatchDetails, type HerdrSubagentsControlToolParams, type HerdrSubagentsRunParams } from "../src/tool.ts";
|
|
13
12
|
import { HerdrSubagentsControlParams, HerdrSubagentsParams } from "../src/parameters.ts";
|
|
14
13
|
import { HERDR_ACTIONS } from "../src/validation.ts";
|
|
15
14
|
import { FleetController, type FleetWidgetRuntime } from "../src/fleet-controller.ts";
|
|
16
|
-
import { isSettledRunStatus } from "../src/records.ts";
|
|
17
15
|
|
|
18
16
|
const packageAgentsDir = fileURLToPath(new URL("../agents/", import.meta.url));
|
|
19
17
|
|
|
@@ -39,7 +37,7 @@ export function normalizeHerdrSubagentsArguments(args: unknown): HerdrSubagentsR
|
|
|
39
37
|
if (!args || typeof args !== "object") return args as HerdrSubagentsRunParams;
|
|
40
38
|
const legacy = args as Record<string, unknown>;
|
|
41
39
|
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"
|
|
40
|
+
if (Object.keys(legacy).some((key) => !["prompt", "agent", "label", "model"].includes(key))) return args as HerdrSubagentsRunParams;
|
|
43
41
|
const label = typeof legacy.label === "string" ? legacy.label : undefined;
|
|
44
42
|
return {
|
|
45
43
|
tasks: [{
|
|
@@ -49,7 +47,6 @@ export function normalizeHerdrSubagentsArguments(args: unknown): HerdrSubagentsR
|
|
|
49
47
|
...(typeof legacy.model === "string" ? { model: legacy.model } : {}),
|
|
50
48
|
}],
|
|
51
49
|
...(label ? { label } : {}),
|
|
52
|
-
...(typeof legacy.background === "boolean" ? { background: legacy.background } : {}),
|
|
53
50
|
};
|
|
54
51
|
}
|
|
55
52
|
|
|
@@ -124,17 +121,12 @@ async function recoverParentSession(cwd: string, herdr: HerdrAutomation, owner:
|
|
|
124
121
|
catch (error) { notify(`Subagent recovery could not inspect active runs: ${error instanceof Error ? error.message : String(error)}`); }
|
|
125
122
|
}
|
|
126
123
|
|
|
127
|
-
async function shutdownParentSession(options: { cwd: string; herdr: HerdrAutomation; owner: { sessionId?: string; processId: number };
|
|
128
|
-
options.dispatcher.pause();
|
|
124
|
+
async function shutdownParentSession(options: { cwd: string; herdr: HerdrAutomation; owner: { sessionId?: string; processId: number }; fleet: FleetController; sessionController: AbortController; runControllers: Map<string, AbortController>; inFlight: Set<Promise<unknown>>; notify: (message: string) => void }): Promise<void> {
|
|
129
125
|
options.sessionController.abort(new Error("Parent Pi session closed."));
|
|
130
|
-
const queuedRunIds = options.dispatcher.cancelQueued(new Error("Parent Pi session closed."));
|
|
131
|
-
await cancelQueuedRuns(options.cwd, queuedRunIds, { reason: "Parent Pi session closed.", owner: options.owner });
|
|
132
126
|
for (const controller of options.runControllers.values()) controller.abort(new Error("Parent Pi session closed."));
|
|
133
127
|
options.fleet.shutdown();
|
|
134
128
|
await Promise.allSettled([...options.inFlight]);
|
|
135
129
|
const cancellation = await cancelActiveSubagentRuns(options.cwd, options.herdr, { reason: "Parent Pi session closed.", owner: options.owner });
|
|
136
|
-
const retainedRunId = options.dispatcher.snapshot().activeRunId;
|
|
137
|
-
if (retainedRunId) options.dispatcher.release(retainedRunId);
|
|
138
130
|
for (const message of cancellation.cleanupErrors) options.notify(message);
|
|
139
131
|
}
|
|
140
132
|
|
|
@@ -144,168 +136,58 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
|
|
|
144
136
|
const execute = dependencies.execute ?? executeHerdrSubagents;
|
|
145
137
|
const executeControl = dependencies.executeControl ?? executeHerdrSubagentsControl;
|
|
146
138
|
const isEditorComponent = dependencies.isEditor ?? ((value: unknown) => value instanceof Editor);
|
|
147
|
-
const dispatcher = new RunDispatcher();
|
|
148
139
|
const fleet = new FleetController(herdr, isEditorComponent, dependencies.createFleetWidget, dependencies.fleetWidgetRuntime);
|
|
149
140
|
const runControllers = new Map<string, AbortController>();
|
|
150
141
|
const runPromises = new Map<string, Promise<unknown>>();
|
|
151
142
|
const inFlight = new Set<Promise<unknown>>();
|
|
152
143
|
let sessionController = new AbortController();
|
|
153
144
|
let ownerSessionId: string | undefined;
|
|
154
|
-
let sessionGeneration = 0;
|
|
155
|
-
const notifyRun = (runId: string, result: { details: unknown; content?: Array<{ type: string; text?: string }> }) => {
|
|
156
|
-
const details = result.details as Partial<HerdrSubagentsBatchDetails> & { errorMessage?: string };
|
|
157
|
-
if (!details.tasks || !details.documents || !details.status) {
|
|
158
|
-
const error = details.errorMessage ?? result.content?.find((item) => item.type === "text")?.text ?? "Unknown Subagent failure.";
|
|
159
|
-
pi.sendMessage({ customType: "herdr-subagents-run", content: `Subagent run ${runId} failed: ${error}`, display: true, details: { runId, status: "failed" } }, { triggerTurn: true, deliverAs: "followUp" });
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
const lines = [`Subagent run ${runId} ${details.status}: ${details.summary}`, ...details.tasks.map((task) => `- ${task.name}: ${task.summary}`)];
|
|
163
|
-
if (details.documents.length) lines.push("Documents:", ...details.documents.map((document) => `- ${document.description}: ${document.path}`));
|
|
164
|
-
pi.sendMessage({ customType: "herdr-subagents-run", content: lines.join("\n"), display: true, details: { runId, status: details.status } }, { triggerTurn: true, deliverAs: "followUp" });
|
|
165
|
-
};
|
|
166
145
|
|
|
167
146
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
168
|
-
|
|
169
|
-
await shutdownParentSession({ cwd: ctx.cwd, herdr, owner: { sessionId: ownerSessionId, processId: process.pid }, dispatcher, fleet, sessionController, runControllers, inFlight, notify: (message) => ctx.ui.notify(message, "error") });
|
|
147
|
+
await shutdownParentSession({ cwd: ctx.cwd, herdr, owner: { sessionId: ownerSessionId, processId: process.pid }, fleet, sessionController, runControllers, inFlight, notify: (message) => ctx.ui.notify(message, "error") });
|
|
170
148
|
});
|
|
171
149
|
pi.on("session_start", async (_event, ctx) => {
|
|
172
|
-
sessionGeneration += 1;
|
|
173
150
|
sessionController.abort(new Error("Parent Pi session changed."));
|
|
174
151
|
sessionController = new AbortController();
|
|
175
152
|
ownerSessionId = `${ctx.sessionManager.getSessionId()}:${randomUUID()}`;
|
|
176
153
|
fleet.bind(ctx.ui, ctx.mode === "tui");
|
|
177
|
-
|
|
178
|
-
try { await recoverParentSession(ctx.cwd, herdr, { sessionId: ownerSessionId, processId: process.pid }, (message) => ctx.ui.notify(message, "error")); }
|
|
179
|
-
finally { dispatcher.resume(); }
|
|
154
|
+
await recoverParentSession(ctx.cwd, herdr, { sessionId: ownerSessionId, processId: process.pid }, (message) => ctx.ui.notify(message, "error"));
|
|
180
155
|
});
|
|
181
156
|
|
|
182
157
|
pi.registerTool({
|
|
183
158
|
name: "herdr_subagents",
|
|
184
159
|
label: "Herdr Subagents",
|
|
185
|
-
description: "Run one or more ordered tasks in visible
|
|
160
|
+
description: "Run one or more ordered tasks in visible, focusable Herdr Pi panes. concurrency 1 is strict serial execution; higher values form a bounded pool for read-only tasks. Write-capable tasks require concurrency 1. The call returns only after every task has one terminal completed, failed, or cancelled result. Complete results and Pi sessions stay in project-local records.",
|
|
186
161
|
promptSnippet: "Run focused work in visible Herdr subagents",
|
|
187
162
|
parameters: HerdrSubagentsParams,
|
|
188
163
|
prepareArguments: normalizeHerdrSubagentsArguments,
|
|
189
164
|
|
|
190
165
|
async execute(_toolCallId, params: HerdrSubagentsRunParams, signal, onUpdate, ctx) {
|
|
191
|
-
const blockedForegroundResponse = (blockingRunId: string) => {
|
|
192
|
-
const text = `Cannot start a foreground Subagent run while blocked run ${blockingRunId} is waiting for input. Respond to or cancel that run first, or submit this run with background: true.`;
|
|
193
|
-
return { content: [{ type: "text" as const, text }], details: { errorMessage: text, blockingRunId }, isError: true };
|
|
194
|
-
};
|
|
195
|
-
const blockingRunId = dispatcher.snapshot().retainedRunId;
|
|
196
|
-
if (!params.background && blockingRunId) return blockedForegroundResponse(blockingRunId);
|
|
197
|
-
const generation = sessionGeneration;
|
|
198
166
|
const runId = randomUUID();
|
|
199
167
|
const controller = new AbortController();
|
|
200
|
-
const
|
|
201
|
-
let foregroundAttached = !params.background;
|
|
202
|
-
const abortForeground = () => {
|
|
203
|
-
if (foregroundAttached && signal?.aborted) foregroundController.abort(signal.reason);
|
|
204
|
-
};
|
|
205
|
-
if (foregroundAttached) {
|
|
206
|
-
if (signal?.aborted) abortForeground();
|
|
207
|
-
else signal?.addEventListener("abort", abortForeground, { once: true });
|
|
208
|
-
}
|
|
168
|
+
const runSignal = AbortSignal.any([controller.signal, sessionController.signal, ...(signal ? [signal] : [])]);
|
|
209
169
|
runControllers.set(runId, controller);
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if (!params.background && !failureObserved && details?.effectiveConcurrency > 1 && hasUnfinishedSibling && details.tasks?.some((task) => task.status === "failed")) {
|
|
221
|
-
failureObserved = true;
|
|
222
|
-
foregroundAttached = false;
|
|
223
|
-
signal?.removeEventListener("abort", abortForeground);
|
|
224
|
-
resolveEarlyFailure?.(details);
|
|
225
|
-
}
|
|
226
|
-
const updateText = update.content.find((item) => item.type === "text")?.text;
|
|
227
|
-
const updateDetails = update.details as { warning?: string };
|
|
228
|
-
if (updateDetails.warning || updateText?.includes("cleanup failed") || (!foregroundAttached && updateText?.includes("appears stalled"))) ctx.ui.notify(updateDetails.warning ?? updateText ?? "Subagent warning", "warning");
|
|
229
|
-
if (foregroundAttached) onUpdate?.(update);
|
|
230
|
-
}, ctx, { agentsDirectory: packageAgentsDir, owner: { sessionId: ownerSessionId, processId: process.pid }, runId });
|
|
231
|
-
fleet.sync(result.details);
|
|
232
|
-
return result;
|
|
233
|
-
};
|
|
234
|
-
if (params.background) await persistQueuedRun(ctx.cwd, runId, params, { sessionId: ownerSessionId, processId: process.pid });
|
|
235
|
-
const promise = dispatcher.submit(runId, async () => {
|
|
236
|
-
const result = await executeRun();
|
|
237
|
-
const details = result.details as HerdrSubagentsBatchDetails;
|
|
238
|
-
const ownsDispatcher = dispatcher.snapshot().activeRunId === runId;
|
|
239
|
-
if (ownsDispatcher) fleet.setActive(details.status === "blocked" ? details : undefined);
|
|
240
|
-
else if (details.status === "blocked") fleet.clearRun(runId);
|
|
241
|
-
if (ownsDispatcher && fleet.active) {
|
|
242
|
-
dispatcher.retain(runId);
|
|
243
|
-
}
|
|
244
|
-
return result;
|
|
245
|
-
}, params.background ? undefined : { ...(signal ? { signal } : {}), onRetained: blockedForegroundResponse });
|
|
170
|
+
const promise = execute(params, runSignal, (update) => {
|
|
171
|
+
const details = update.details as HerdrSubagentsBatchDetails | undefined;
|
|
172
|
+
if (details?.activity) fleet.sync(details);
|
|
173
|
+
onUpdate?.(update);
|
|
174
|
+
}, ctx, {
|
|
175
|
+
agentsDirectory: packageAgentsDir,
|
|
176
|
+
herdr,
|
|
177
|
+
owner: { sessionId: ownerSessionId, processId: process.pid },
|
|
178
|
+
runId,
|
|
179
|
+
});
|
|
246
180
|
runPromises.set(runId, promise);
|
|
247
181
|
inFlight.add(promise);
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
182
|
+
try {
|
|
183
|
+
const result = await promise;
|
|
184
|
+
fleet.sync(result.details);
|
|
185
|
+
return result;
|
|
186
|
+
} finally {
|
|
252
187
|
inFlight.delete(promise);
|
|
253
|
-
runControllers.delete(runId);
|
|
254
188
|
runPromises.delete(runId);
|
|
255
|
-
|
|
256
|
-
let backgroundLifecycleStarted = false;
|
|
257
|
-
const startBackgroundLifecycle = () => {
|
|
258
|
-
if (backgroundLifecycleStarted) return;
|
|
259
|
-
backgroundLifecycleStarted = true;
|
|
260
|
-
const lifecycle = promise.then((result) => {
|
|
261
|
-
if (generation !== sessionGeneration) return;
|
|
262
|
-
notifyRun(runId, result);
|
|
263
|
-
if (!fleet.active && !dispatcher.snapshot().activeRunId && dispatcher.snapshot().queuedRunIds.length === 0) fleet.render();
|
|
264
|
-
}).catch((error) => {
|
|
265
|
-
if (generation !== sessionGeneration) return;
|
|
266
|
-
if (controller.signal.aborted) {
|
|
267
|
-
pi.sendMessage({ customType: "herdr-subagents-run", content: `Subagent run ${runId} cancelled.`, display: true, details: { runId, status: "cancelled" } }, { triggerTurn: true, deliverAs: "followUp" });
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
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" });
|
|
271
|
-
}).finally(() => removeQueuedRun(ctx.cwd, runId));
|
|
272
|
-
inFlight.add(lifecycle);
|
|
273
|
-
void lifecycle.finally(() => inFlight.delete(lifecycle)).catch(() => undefined);
|
|
274
|
-
};
|
|
275
|
-
if (params.background) {
|
|
276
|
-
startBackgroundLifecycle();
|
|
277
|
-
return { content: [{ type: "text" as const, text: `Subagent run queued: ${runId}` }], details: { runId, status: "queued", background: true } };
|
|
278
|
-
}
|
|
279
|
-
const foregroundOutcome = await Promise.race([
|
|
280
|
-
promise.then((result) => ({ kind: "settled" as const, result })),
|
|
281
|
-
earlyFailure.then((details) => ({ kind: "failed" as const, details })),
|
|
282
|
-
]);
|
|
283
|
-
if (foregroundOutcome.kind === "failed") {
|
|
284
|
-
try {
|
|
285
|
-
await persistQueuedRun(ctx.cwd, runId, params, { sessionId: ownerSessionId, processId: process.pid });
|
|
286
|
-
} catch (error) {
|
|
287
|
-
controller.abort(new Error("Could not persist failed foreground run for background continuation."));
|
|
288
|
-
await promise.catch(() => undefined);
|
|
289
|
-
await removeQueuedRun(ctx.cwd, runId).catch(() => undefined);
|
|
290
|
-
throw error;
|
|
291
|
-
}
|
|
292
|
-
if (runSettled) {
|
|
293
|
-
await removeQueuedRun(ctx.cwd, runId);
|
|
294
|
-
return promise;
|
|
295
|
-
}
|
|
296
|
-
dispatcher.detach(runId);
|
|
297
|
-
startBackgroundLifecycle();
|
|
298
|
-
const failedTask = foregroundOutcome.details.tasks.find((task) => task.status === "failed");
|
|
299
|
-
const failure = failedTask?.error ?? failedTask?.summary ?? "Unknown task failure.";
|
|
300
|
-
return {
|
|
301
|
-
content: [{ type: "text" as const, text: `Subagent run ${runId} continues in the background after ${failedTask?.name ?? "a task"} failed: ${failure}` }],
|
|
302
|
-
details: { ...foregroundOutcome.details, runId, background: true, earlyReturn: true, failedTask: failedTask ? { index: failedTask.index, name: failedTask.name, summary: failedTask.summary, ...(failedTask.error ? { error: failedTask.error } : {}) } : undefined },
|
|
303
|
-
isError: true,
|
|
304
|
-
};
|
|
189
|
+
runControllers.delete(runId);
|
|
305
190
|
}
|
|
306
|
-
const result = foregroundOutcome.result;
|
|
307
|
-
if (!fleet.active && !dispatcher.snapshot().activeRunId && dispatcher.snapshot().queuedRunIds.length === 0) fleet.resetSelection();
|
|
308
|
-
return result;
|
|
309
191
|
},
|
|
310
192
|
|
|
311
193
|
renderCall: renderToolCall,
|
|
@@ -315,7 +197,7 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
|
|
|
315
197
|
pi.registerTool({
|
|
316
198
|
name: "herdr_subagents_control",
|
|
317
199
|
label: "Herdr Subagents Control",
|
|
318
|
-
description: "Inspect Herdr personas, query one run by ID, list run history, cancel active
|
|
200
|
+
description: "Inspect Herdr personas, query one run by ID, list run history, cancel active work, or remove an archived run. Use herdr_subagents to start new work.",
|
|
319
201
|
parameters: HerdrSubagentsControlParams,
|
|
320
202
|
|
|
321
203
|
async execute(_toolCallId, params: HerdrSubagentsControlToolParams, signal, onUpdate, ctx) {
|
|
@@ -338,22 +220,15 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
|
|
|
338
220
|
const reason = new Error("Subagent run cancelled by user.");
|
|
339
221
|
const targetController = runControllers.get(runId);
|
|
340
222
|
targetController?.abort(reason);
|
|
341
|
-
if (dispatcher.cancelQueuedRun(runId, reason)) {
|
|
342
|
-
await cancelQueuedRuns(ctx.cwd, [runId], { reason: reason.message, owner: { sessionId: ownerSessionId, processId: process.pid } });
|
|
343
|
-
fleet.clearRun(runId);
|
|
344
|
-
return { cancelled: true, queued: true };
|
|
345
|
-
}
|
|
346
223
|
const activePromise = runPromises.get(runId);
|
|
347
224
|
if (activePromise) {
|
|
348
225
|
await activePromise.catch(() => undefined);
|
|
349
226
|
fleet.clearRun(runId);
|
|
350
|
-
dispatcher.release(runId);
|
|
351
227
|
return { cancelled: true };
|
|
352
228
|
}
|
|
353
229
|
const cancellation = await cancelActiveSubagentRuns(ctx.cwd, herdr, { reason: reason.message, owner: { sessionId: ownerSessionId, processId: process.pid }, runId });
|
|
354
230
|
if (cancellation.cancelledRuns) {
|
|
355
231
|
fleet.clearRun(runId);
|
|
356
|
-
dispatcher.release(runId);
|
|
357
232
|
}
|
|
358
233
|
return { cancelled: cancellation.cancelledRuns > 0, cleanupErrors: cancellation.cleanupErrors };
|
|
359
234
|
},
|
|
@@ -361,15 +236,6 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
|
|
|
361
236
|
inFlight.add(promise);
|
|
362
237
|
const result = await promise.finally(() => { inFlight.delete(promise); runControllers.delete(executionId); });
|
|
363
238
|
fleet.sync(result.details);
|
|
364
|
-
if (params.action === "respond") {
|
|
365
|
-
const details = result.details as { runId?: string; status?: string };
|
|
366
|
-
if (details.status === "blocked") fleet.sync(result.details);
|
|
367
|
-
else if (isSettledRunStatus(details.status)) {
|
|
368
|
-
const canonicalRunId = details.runId ?? params.runId;
|
|
369
|
-
fleet.clearRun(canonicalRunId);
|
|
370
|
-
if (canonicalRunId) dispatcher.release(canonicalRunId);
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
239
|
return result;
|
|
374
240
|
},
|
|
375
241
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
2
|
import net from "node:net";
|
|
3
3
|
import { dirname, resolve } from "node:path";
|
|
4
4
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
@@ -23,7 +23,7 @@ async function returnToParent(pi: ExtensionAPI, ctx: { ui: { notify(message: str
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
const ReportParams = Type.Object({
|
|
26
|
-
status: StringEnum(["completed", "
|
|
26
|
+
status: StringEnum(["completed", "failed"] as const, { description: "Terminal task outcome. completed: the task is finished. failed: the task cannot be completed." }),
|
|
27
27
|
summary: Type.String({ minLength: 1, description: "One concise paragraph for the parent agent." }),
|
|
28
28
|
result: Type.String({ description: "Complete final result. Stored on disk and excluded from the parent context." }),
|
|
29
29
|
documents: Type.Optional(Type.Array(Type.Object({
|
|
@@ -31,7 +31,6 @@ const ReportParams = Type.Object({
|
|
|
31
31
|
description: Type.String({ minLength: 1, description: "Short description of the document." }),
|
|
32
32
|
}))),
|
|
33
33
|
error: Type.Optional(Type.String({ minLength: 1, description: "Failure detail when status is failed." })),
|
|
34
|
-
question: Type.Optional(Type.String({ minLength: 1, description: "Exact question for the parent agent when status is blocked." })),
|
|
35
34
|
});
|
|
36
35
|
|
|
37
36
|
function writeAtomic(path: string, content: string): void {
|
|
@@ -74,7 +73,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
74
73
|
pi.on("agent_start", () => reportHerdrState("working"));
|
|
75
74
|
pi.on("agent_settled", (_event, ctx) => {
|
|
76
75
|
const taskDirectory = process.env.PI_HERDR_SUBAGENTS_TASK_DIR?.trim();
|
|
77
|
-
if (taskDirectory && activeAttemptId)
|
|
76
|
+
if (taskDirectory && activeAttemptId) {
|
|
77
|
+
writeAttemptState(taskDirectory, activeAttemptId, "settled");
|
|
78
|
+
const reported = existsSync(attemptReportPath(taskDirectory, activeAttemptId));
|
|
79
|
+
// agent_settled is Pi's final automatic-work boundary. Ending the child here
|
|
80
|
+
// gives the orchestrator an authoritative no-report process-exit signal while
|
|
81
|
+
// still allowing all provider auto-retries to finish first.
|
|
82
|
+
setTimeout(() => process.exit(reported ? 0 : 2), 25);
|
|
83
|
+
}
|
|
78
84
|
if (ctx?.isIdle?.() === true) reportHerdrState("idle");
|
|
79
85
|
});
|
|
80
86
|
pi.events.on("herdr:blocked", (data: { active?: boolean; label?: string }) => {
|
|
@@ -103,7 +109,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
103
109
|
const attemptId = activeAttemptId ?? currentAttemptId(taskDirectory);
|
|
104
110
|
mkdirSync(taskDirectory, { recursive: true, mode: 0o700 });
|
|
105
111
|
const resultPath = resultPathForAttemptId(taskDirectory, attemptId);
|
|
106
|
-
|
|
112
|
+
const reportPath = attemptReportPath(taskDirectory, attemptId);
|
|
107
113
|
const documents = (params.documents ?? []).map((document) => ({
|
|
108
114
|
path: resolve(process.cwd(), document.path),
|
|
109
115
|
description: document.description,
|
|
@@ -117,10 +123,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
117
123
|
summary: params.summary,
|
|
118
124
|
documents,
|
|
119
125
|
...(params.error ? { error: params.error } : {}),
|
|
120
|
-
...(params.question ? { question: params.question } : {}),
|
|
121
126
|
reportedAt: new Date().toISOString(),
|
|
122
127
|
};
|
|
123
|
-
|
|
128
|
+
if (existsSync(reportPath)) {
|
|
129
|
+
const existing = JSON.parse(readFileSync(reportPath, "utf8")) as Record<string, unknown>;
|
|
130
|
+
const comparable = ({ reportedAt: _reportedAt, ...value }: Record<string, unknown>) => value;
|
|
131
|
+
const sameReport = JSON.stringify(comparable(existing)) === JSON.stringify(comparable(report));
|
|
132
|
+
const sameResult = existsSync(resultPath) && readFileSync(resultPath, "utf8") === params.result;
|
|
133
|
+
if (!sameReport || !sameResult) throw new Error("Conflicting subagent_report already exists for this task.");
|
|
134
|
+
} else {
|
|
135
|
+
writeAtomic(resultPath, params.result);
|
|
136
|
+
writeAtomic(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
137
|
+
}
|
|
124
138
|
return {
|
|
125
139
|
content: [{ type: "text" as const, text: `${params.status}: ${params.summary}` }],
|
|
126
140
|
details: report,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maxiaochao/pi-herdr-subagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Run inspectable interactive Pi subagent batches through Herdr.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -37,9 +37,10 @@
|
|
|
37
37
|
"typebox": "*"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
|
-
"test": "node --experimental-strip-types tests/
|
|
40
|
+
"test": "node --experimental-strip-types tests/core-state-machine.mjs",
|
|
41
41
|
"test:real-herdr": "node --experimental-strip-types tests/real-herdr-smoke.mjs",
|
|
42
|
-
"load-test": "env -u PI_HERDR_SUBAGENTS_CHILD \"${PI_TEST_BINARY:-pi}\" -ne -ns --no-session -e ./tests/extension-contract.ts && env -u PI_HERDR_SUBAGENTS_CHILD \"${PI_TEST_BINARY:-pi}\" -ne -ns --no-session -e ./extensions/herdr-subagents.ts && node --experimental-strip-types tests/extension-sources-load.mjs"
|
|
42
|
+
"load-test": "env -u PI_HERDR_SUBAGENTS_CHILD \"${PI_TEST_BINARY:-pi}\" -ne -ns --no-session -e ./tests/extension-contract.ts && env -u PI_HERDR_SUBAGENTS_CHILD \"${PI_TEST_BINARY:-pi}\" -ne -ns --no-session -e ./extensions/herdr-subagents.ts && node --experimental-strip-types tests/extension-sources-load.mjs",
|
|
43
|
+
"test:legacy": "node --experimental-strip-types tests/run-tests.mjs && node --experimental-strip-types tests/cleanup-timing.mjs"
|
|
43
44
|
},
|
|
44
45
|
"publishConfig": {
|
|
45
46
|
"access": "public"
|
|
@@ -91,7 +91,10 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
|
|
|
91
91
|
if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_WORKSPACE_ID) throw new Error("Herdr Subagents must run from a Pi session inside Herdr.");
|
|
92
92
|
const runId = options.runId ?? randomUUID();
|
|
93
93
|
const requestedConcurrency = Math.max(1, Math.floor(options.concurrency));
|
|
94
|
-
|
|
94
|
+
if (requestedConcurrency > 1 && tasks.some((task) => task.access === "write")) {
|
|
95
|
+
throw new Error("Write-capable tasks require concurrency 1; parallel writes are not safe.");
|
|
96
|
+
}
|
|
97
|
+
const effectiveConcurrency = Math.min(requestedConcurrency, options.maxConcurrency, tasks.length);
|
|
95
98
|
const runDirectory = join(options.cwd, ".pi", "herdr-subagents", "runs", `${timestamp().replace(/[-:.]/g, "").replace("Z", "Z-")}${runId.slice(0, 8)}`);
|
|
96
99
|
const runFile = join(runDirectory, "run.json");
|
|
97
100
|
await ensureRuntimeIgnored(options.cwd);
|
|
@@ -113,7 +116,7 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
|
|
|
113
116
|
return { taskId, taskDirectory, sessionFile, reportFile: join(taskDirectory, "report.json"), taskFile, systemPromptFile, taskRecord };
|
|
114
117
|
}));
|
|
115
118
|
const runRecord: RunRecord = {
|
|
116
|
-
id: runId, label: options.label, status: "
|
|
119
|
+
id: runId, label: options.label, status: "running", cwd: options.cwd, ...ownerRecord(options.owner), requestedConcurrency,
|
|
117
120
|
effectiveConcurrency, stalledWarningSeconds: options.stalledWarningSeconds, startedAt: timestamp(), taskIds: contexts.map((context) => context.taskId), paneIds: [],
|
|
118
121
|
};
|
|
119
122
|
await writeJsonAtomic(runFile, runRecord);
|
|
@@ -205,7 +208,6 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
|
|
|
205
208
|
const index = nextIndex++;
|
|
206
209
|
if (index >= tasks.length) return;
|
|
207
210
|
await runOne(index);
|
|
208
|
-
if (results[index]?.status === "blocked") return;
|
|
209
211
|
}
|
|
210
212
|
};
|
|
211
213
|
const workers = await Promise.allSettled(Array.from({ length: effectiveConcurrency }, worker));
|
|
@@ -39,6 +39,7 @@ export interface HerdrAutomation {
|
|
|
39
39
|
startAgent(request: StartAgentRequest): Promise<void>;
|
|
40
40
|
promptAgent(request: PromptAgentRequest): Promise<{ status?: string }>;
|
|
41
41
|
getAgentState(target: string, signal?: AbortSignal): Promise<HerdrAgentState>;
|
|
42
|
+
waitForAgentChange?(target: string, signal?: AbortSignal): Promise<void>;
|
|
42
43
|
splitPane(request: SplitPaneRequest): Promise<{ paneId: string }>;
|
|
43
44
|
renamePane(paneId: string, label: string, signal?: AbortSignal): Promise<void>;
|
|
44
45
|
renameTab(tabId: string, label: string, signal?: AbortSignal): Promise<void>;
|
|
@@ -165,6 +166,16 @@ export class CliHerdrAutomation implements HerdrAutomation {
|
|
|
165
166
|
return state === "working" || state === "blocked" || state === "idle" || state === "done" ? state : "unknown";
|
|
166
167
|
}
|
|
167
168
|
|
|
169
|
+
async waitForAgentChange(target: string, signal?: AbortSignal): Promise<void> {
|
|
170
|
+
try {
|
|
171
|
+
await runCommand(this.binary, ["agent", "wait", target, "--until", "idle", "--until", "blocked", "--until", "done", "--until", "unknown", "--timeout", "500"], signal);
|
|
172
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 100));
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (error instanceof HerdrCommandError && /timed?\s*out|timeout/i.test(`${error.message}\n${error.stderr}`)) return;
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
168
179
|
async splitPane(request: SplitPaneRequest): Promise<{ paneId: string }> {
|
|
169
180
|
const args = ["pane", "split", request.paneId, "--direction", request.direction, "--cwd", request.cwd, request.focus ? "--focus" : "--no-focus"];
|
|
170
181
|
for (const [key, value] of Object.entries(request.env ?? {}).sort(([left], [right]) => left.localeCompare(right))) args.push("--env", `${key}=${value}`);
|