pi-herdr-subagents 0.1.0 → 0.1.1
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/README.md +2 -4
- package/package.json +1 -1
- package/pi-extension/subagents/index.ts +75 -32
- package/test/test.ts +43 -0
package/README.md
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
Async subagents for [pi](https://github.com/badlogic/pi-mono) running exclusively in [herdr](https://herdr.dev). Spawn, orchestrate, and manage sub-agent sessions in dedicated herdr tabs or panes. **Fully non-blocking** — the main agent keeps working while subagents run in the background.
|
|
4
4
|
|
|
5
|
-
https://github.com/user-attachments/assets/30adb156-cfb4-4c47-84ca-dd4aa80cba9f
|
|
6
|
-
|
|
7
5
|
## How It Works
|
|
8
6
|
|
|
9
7
|
Call `subagent()` and it **returns immediately**. The sub-agent runs in its own terminal pane. A live widget above the input shows all running agents with their current state — `starting`, `active`, `waiting`, `stalled`, or `running`. When a sub-agent finishes, its result is **steered back** into the main session as an async notification — triggering a new turn so the agent can process it.
|
|
@@ -25,10 +23,10 @@ subagent({ name: "Scout: DB", agent: "scout", task: "Map database schema" });
|
|
|
25
23
|
|
|
26
24
|
## Install
|
|
27
25
|
|
|
28
|
-
Install the package
|
|
26
|
+
Install the package from npm:
|
|
29
27
|
|
|
30
28
|
```bash
|
|
31
|
-
pi install
|
|
29
|
+
pi install npm:pi-herdr-subagents
|
|
32
30
|
```
|
|
33
31
|
|
|
34
32
|
This project does not install or load `HazAT/pi-interactive-subagents` automatically.
|
package/package.json
CHANGED
|
@@ -57,12 +57,12 @@ import {
|
|
|
57
57
|
/** Absolute path to `pi-extension/subagents`. https://github.com/nodejs/node/issues/37845 */
|
|
58
58
|
const SUBAGENTS_DIR = dirname(fileURLToPath(import.meta.url));
|
|
59
59
|
|
|
60
|
-
// Survive /reload:
|
|
61
|
-
//
|
|
62
|
-
// the
|
|
60
|
+
// Survive /reload: replace presentation timers while keeping active completion
|
|
61
|
+
// watchers and their registry alive. Old module closures continue watching the
|
|
62
|
+
// children; the reloaded module adopts the shared registry for status/interrupts.
|
|
63
63
|
const WIDGET_INTERVAL_KEY = Symbol.for("pi-subagents/widget-interval");
|
|
64
64
|
const STATUS_INTERVAL_KEY = Symbol.for("pi-subagents/status-interval");
|
|
65
|
-
const
|
|
65
|
+
const RUNTIME_KEY = Symbol.for("pi-subagents/runtime");
|
|
66
66
|
|
|
67
67
|
{
|
|
68
68
|
const prevInterval = (globalThis as any)[WIDGET_INTERVAL_KEY];
|
|
@@ -75,13 +75,6 @@ const POLL_ABORT_KEY = Symbol.for("pi-subagents/poll-abort-controller");
|
|
|
75
75
|
clearInterval(prevStatusInterval);
|
|
76
76
|
(globalThis as any)[STATUS_INTERVAL_KEY] = null;
|
|
77
77
|
}
|
|
78
|
-
const prevAbort = (globalThis as any)[POLL_ABORT_KEY] as AbortController | undefined;
|
|
79
|
-
if (prevAbort) prevAbort.abort();
|
|
80
|
-
(globalThis as any)[POLL_ABORT_KEY] = new AbortController();
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function getModuleAbortSignal(): AbortSignal {
|
|
84
|
-
return ((globalThis as any)[POLL_ABORT_KEY] as AbortController).signal;
|
|
85
78
|
}
|
|
86
79
|
|
|
87
80
|
const SubagentParams = Type.Object({
|
|
@@ -503,6 +496,8 @@ interface RunningSubagent {
|
|
|
503
496
|
error?: string;
|
|
504
497
|
};
|
|
505
498
|
abortController?: AbortController;
|
|
499
|
+
/** False after terminal parent shutdown; watcher results must not be delivered. */
|
|
500
|
+
completionDeliveryEnabled?: boolean;
|
|
506
501
|
cli?: string;
|
|
507
502
|
sentinelFile?: string;
|
|
508
503
|
statusState: SubagentStatusState;
|
|
@@ -515,13 +510,50 @@ interface RunningSubagent {
|
|
|
515
510
|
interactive: boolean;
|
|
516
511
|
}
|
|
517
512
|
|
|
518
|
-
|
|
519
|
-
|
|
513
|
+
interface SubagentRuntime {
|
|
514
|
+
runningSubagents: Map<string, RunningSubagent>;
|
|
515
|
+
pi?: ExtensionAPI;
|
|
516
|
+
latestCtx?: ExtensionContext;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function createSubagentRuntime(): SubagentRuntime {
|
|
520
|
+
return { runningSubagents: new Map<string, RunningSubagent>() };
|
|
521
|
+
}
|
|
520
522
|
|
|
521
|
-
|
|
523
|
+
/** Runtime state preserved across /reload. */
|
|
524
|
+
const runtime: SubagentRuntime =
|
|
525
|
+
(globalThis as any)[RUNTIME_KEY] ??
|
|
526
|
+
((globalThis as any)[RUNTIME_KEY] = createSubagentRuntime());
|
|
527
|
+
const runningSubagents = runtime.runningSubagents;
|
|
528
|
+
|
|
529
|
+
export function shouldPreserveSubagentsOnShutdown(reason: unknown): boolean {
|
|
530
|
+
return reason === "reload";
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export function cleanupSubagentsForShutdown(
|
|
534
|
+
reason: unknown,
|
|
535
|
+
agents: Map<string, Pick<RunningSubagent, "abortController" | "completionDeliveryEnabled">>,
|
|
536
|
+
): void {
|
|
537
|
+
if (shouldPreserveSubagentsOnShutdown(reason)) return;
|
|
538
|
+
|
|
539
|
+
for (const agent of agents.values()) {
|
|
540
|
+
agent.completionDeliveryEnabled = false;
|
|
541
|
+
agent.abortController?.abort();
|
|
542
|
+
}
|
|
543
|
+
agents.clear();
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export function shouldDeliverSubagentCompletion(
|
|
547
|
+
running: Pick<RunningSubagent, "completionDeliveryEnabled">,
|
|
548
|
+
): boolean {
|
|
549
|
+
return running.completionDeliveryEnabled !== false;
|
|
550
|
+
}
|
|
522
551
|
|
|
523
|
-
|
|
524
|
-
|
|
552
|
+
export function selectCompletionApi<T>(previous: T, current: T | undefined): T {
|
|
553
|
+
return current ?? previous;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ── Widget management ──
|
|
525
557
|
|
|
526
558
|
/** Interval timer for widget re-renders. */
|
|
527
559
|
let widgetInterval: ReturnType<typeof setInterval> | null = null;
|
|
@@ -622,6 +654,7 @@ function renderSubagentWidgetLines(agents: RunningSubagent[], width: number): st
|
|
|
622
654
|
}
|
|
623
655
|
|
|
624
656
|
function updateWidget() {
|
|
657
|
+
const latestCtx = runtime.latestCtx;
|
|
625
658
|
if (!latestCtx?.hasUI) return;
|
|
626
659
|
|
|
627
660
|
if (runningSubagents.size === 0) {
|
|
@@ -1249,7 +1282,7 @@ async function watchSubagent(
|
|
|
1249
1282
|
const { name, task, surface, startTime, sessionFile } = running;
|
|
1250
1283
|
|
|
1251
1284
|
try {
|
|
1252
|
-
const result = await waitForCompletion(
|
|
1285
|
+
const result = await waitForCompletion(signal, {
|
|
1253
1286
|
intervalMs: 1000,
|
|
1254
1287
|
sessionFile,
|
|
1255
1288
|
sentinelFile: running.sentinelFile,
|
|
@@ -1358,13 +1391,21 @@ async function watchSubagent(
|
|
|
1358
1391
|
}
|
|
1359
1392
|
|
|
1360
1393
|
export default function subagentsExtension(pi: ExtensionAPI) {
|
|
1361
|
-
|
|
1394
|
+
runtime.pi = pi;
|
|
1395
|
+
|
|
1396
|
+
// Capture the UI context for widget updates and restore presentation for
|
|
1397
|
+
// subagents whose watchers survived a reload.
|
|
1362
1398
|
pi.on("session_start", (_event, ctx) => {
|
|
1363
|
-
latestCtx = ctx;
|
|
1399
|
+
runtime.latestCtx = ctx;
|
|
1400
|
+
if (runningSubagents.size > 0) {
|
|
1401
|
+
startWidgetRefresh();
|
|
1402
|
+
startStatusRefresh(pi);
|
|
1403
|
+
updateWidget();
|
|
1404
|
+
}
|
|
1364
1405
|
});
|
|
1365
1406
|
|
|
1366
1407
|
// Clean up on session shutdown
|
|
1367
|
-
pi.on("session_shutdown", (
|
|
1408
|
+
pi.on("session_shutdown", (event, _ctx) => {
|
|
1368
1409
|
if (widgetInterval) {
|
|
1369
1410
|
clearInterval(widgetInterval);
|
|
1370
1411
|
widgetInterval = null;
|
|
@@ -1375,12 +1416,8 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1375
1416
|
statusInterval = null;
|
|
1376
1417
|
(globalThis as any)[STATUS_INTERVAL_KEY] = null;
|
|
1377
1418
|
}
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
for (const [_id, agent] of runningSubagents) {
|
|
1381
|
-
agent.abortController?.abort();
|
|
1382
|
-
}
|
|
1383
|
-
runningSubagents.clear();
|
|
1419
|
+
|
|
1420
|
+
cleanupSubagentsForShutdown((event as any).reason, runningSubagents);
|
|
1384
1421
|
});
|
|
1385
1422
|
|
|
1386
1423
|
// Tools denied via PI_DENY_TOOLS env var (set by parent agent based on frontmatter)
|
|
@@ -1461,12 +1498,14 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1461
1498
|
// Fire-and-forget: start watching in background
|
|
1462
1499
|
watchSubagent(running, watcherAbort.signal)
|
|
1463
1500
|
.then((result) => {
|
|
1501
|
+
if (!shouldDeliverSubagentCompletion(running)) return;
|
|
1464
1502
|
updateWidget(); // reflect removal from Map immediately
|
|
1503
|
+
const completionApi = selectCompletionApi(pi, runtime.pi);
|
|
1465
1504
|
|
|
1466
1505
|
if (result.ping) {
|
|
1467
1506
|
// Subagent is requesting help — steer a ping message with session path for resume
|
|
1468
1507
|
const sessionRef = `\n\nSession: ${result.sessionFile}\nResume: pi --session ${result.sessionFile}`;
|
|
1469
|
-
|
|
1508
|
+
completionApi.sendMessage(
|
|
1470
1509
|
{
|
|
1471
1510
|
customType: "subagent_ping",
|
|
1472
1511
|
content: `Sub-agent "${result.ping.name}" needs help (${formatElapsed(result.elapsed)}):\n\n${result.ping.message}${sessionRef}`,
|
|
@@ -1485,7 +1524,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1485
1524
|
|
|
1486
1525
|
const presentation = resolveResultPresentation(result, running.name);
|
|
1487
1526
|
|
|
1488
|
-
|
|
1527
|
+
completionApi.sendMessage(
|
|
1489
1528
|
{
|
|
1490
1529
|
customType: "subagent_result",
|
|
1491
1530
|
content: presentation,
|
|
@@ -1505,8 +1544,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1505
1544
|
);
|
|
1506
1545
|
})
|
|
1507
1546
|
.catch((err) => {
|
|
1547
|
+
if (!shouldDeliverSubagentCompletion(running)) return;
|
|
1508
1548
|
updateWidget();
|
|
1509
|
-
pi.sendMessage(
|
|
1549
|
+
selectCompletionApi(pi, runtime.pi).sendMessage(
|
|
1510
1550
|
{
|
|
1511
1551
|
customType: "subagent_result",
|
|
1512
1552
|
content: `Sub-agent "${running.name}" error: ${err?.message ?? String(err)}`,
|
|
@@ -1888,11 +1928,13 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1888
1928
|
|
|
1889
1929
|
watchSubagent(running, watcherAbort.signal)
|
|
1890
1930
|
.then((result) => {
|
|
1931
|
+
if (!shouldDeliverSubagentCompletion(running)) return;
|
|
1891
1932
|
updateWidget();
|
|
1933
|
+
const completionApi = selectCompletionApi(pi, runtime.pi);
|
|
1892
1934
|
|
|
1893
1935
|
if (result.ping) {
|
|
1894
1936
|
const sessionRef = `\n\nSession: ${params.sessionPath}\nResume: pi --session ${params.sessionPath}`;
|
|
1895
|
-
|
|
1937
|
+
completionApi.sendMessage(
|
|
1896
1938
|
{
|
|
1897
1939
|
customType: "subagent_ping",
|
|
1898
1940
|
content: `Sub-agent "${result.ping.name}" needs help (${formatElapsed(result.elapsed)}):\n\n${result.ping.message}${sessionRef}`,
|
|
@@ -1920,7 +1962,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1920
1962
|
name,
|
|
1921
1963
|
);
|
|
1922
1964
|
|
|
1923
|
-
|
|
1965
|
+
completionApi.sendMessage(
|
|
1924
1966
|
{
|
|
1925
1967
|
customType: "subagent_result",
|
|
1926
1968
|
content: presentation,
|
|
@@ -1938,8 +1980,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1938
1980
|
);
|
|
1939
1981
|
})
|
|
1940
1982
|
.catch((err) => {
|
|
1983
|
+
if (!shouldDeliverSubagentCompletion(running)) return;
|
|
1941
1984
|
updateWidget();
|
|
1942
|
-
pi.sendMessage(
|
|
1985
|
+
selectCompletionApi(pi, runtime.pi).sendMessage(
|
|
1943
1986
|
{
|
|
1944
1987
|
customType: "subagent_result",
|
|
1945
1988
|
content: `Resume error: ${err?.message ?? String(err)}`,
|
package/test/test.ts
CHANGED
|
@@ -6,6 +6,12 @@ import { tmpdir } from "node:os";
|
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
8
8
|
import * as subagentsModule from "../pi-extension/subagents/index.ts";
|
|
9
|
+
import {
|
|
10
|
+
cleanupSubagentsForShutdown,
|
|
11
|
+
selectCompletionApi,
|
|
12
|
+
shouldDeliverSubagentCompletion,
|
|
13
|
+
shouldPreserveSubagentsOnShutdown,
|
|
14
|
+
} from "../pi-extension/subagents/index.ts";
|
|
9
15
|
|
|
10
16
|
import {
|
|
11
17
|
getLeafId,
|
|
@@ -1479,6 +1485,43 @@ describe("tool registration", () => {
|
|
|
1479
1485
|
});
|
|
1480
1486
|
});
|
|
1481
1487
|
|
|
1488
|
+
describe("subagent parent lifecycle", () => {
|
|
1489
|
+
it("preserves active subagents during extension reload", () => {
|
|
1490
|
+
const abortController = new AbortController();
|
|
1491
|
+
const agents = new Map([["child", { abortController }]]);
|
|
1492
|
+
|
|
1493
|
+
cleanupSubagentsForShutdown("reload", agents);
|
|
1494
|
+
|
|
1495
|
+
assert.equal(shouldPreserveSubagentsOnShutdown("reload"), true);
|
|
1496
|
+
assert.equal(abortController.signal.aborted, false);
|
|
1497
|
+
assert.equal(shouldDeliverSubagentCompletion(agents.get("child")!), true);
|
|
1498
|
+
assert.equal(agents.size, 1);
|
|
1499
|
+
});
|
|
1500
|
+
|
|
1501
|
+
it("aborts and clears active subagents during final shutdown", () => {
|
|
1502
|
+
for (const reason of ["quit", "new", "resume", "fork", undefined]) {
|
|
1503
|
+
const abortController = new AbortController();
|
|
1504
|
+
const running = { abortController };
|
|
1505
|
+
const agents = new Map([["child", running]]);
|
|
1506
|
+
|
|
1507
|
+
cleanupSubagentsForShutdown(reason, agents);
|
|
1508
|
+
|
|
1509
|
+
assert.equal(shouldPreserveSubagentsOnShutdown(reason), false);
|
|
1510
|
+
assert.equal(abortController.signal.aborted, true);
|
|
1511
|
+
assert.equal(shouldDeliverSubagentCompletion(running), false);
|
|
1512
|
+
assert.equal(agents.size, 0);
|
|
1513
|
+
}
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
it("delivers completion through the reloaded extension API", () => {
|
|
1517
|
+
const previous = { id: "previous" };
|
|
1518
|
+
const current = { id: "current" };
|
|
1519
|
+
|
|
1520
|
+
assert.equal(selectCompletionApi(previous, current), current);
|
|
1521
|
+
assert.equal(selectCompletionApi(previous, undefined), previous);
|
|
1522
|
+
});
|
|
1523
|
+
});
|
|
1524
|
+
|
|
1482
1525
|
describe("subagent activity snapshots", () => {
|
|
1483
1526
|
function validActivity(overrides: Record<string, unknown> = {}) {
|
|
1484
1527
|
return {
|