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 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 explicitly from this repository (replace the URL if you use a fork):
26
+ Install the package from npm:
29
27
 
30
28
  ```bash
31
- pi install git:github.com/0xRichardH/pi-herdr-subagents
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-herdr-subagents",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Interactive subagents for pi running in herdr",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -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: clear timers and abort poll loops from the previous module load.
61
- // /reload re-imports this file, giving fresh module-level state, but closures from
62
- // the old module keep running. These global symbols preserve reload compatibility.
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 POLL_ABORT_KEY = Symbol.for("pi-subagents/poll-abort-controller");
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
- /** All currently running subagents, keyed by id. */
519
- const runningSubagents = new Map<string, RunningSubagent>();
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
- // ── Widget management ──
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
- /** Latest ExtensionContext from session_start, used for widget updates. */
524
- let latestCtx: ExtensionContext | null = null;
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(AbortSignal.any([signal, getModuleAbortSignal()]), {
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
- // Capture the UI context for widget updates
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", (_event, _ctx) => {
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
- const moduleAbort = (globalThis as any)[POLL_ABORT_KEY] as AbortController | undefined;
1379
- if (moduleAbort) moduleAbort.abort();
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
- pi.sendMessage(
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
- pi.sendMessage(
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
- pi.sendMessage(
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
- pi.sendMessage(
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 {