pi-herdr-subagents 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,14 +2,12 @@
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
- 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.
7
+ Call `subagent()` and it **returns immediately**. The sub-agent runs in its own terminal pane. A live widget above the input shows all tracked agents with their projected state — for example `starting`, `active`, `waiting`, `interrupted`, `stalled`, `running`, or `finalizing`. The header summarizes **active** (processing) vs **open** (not processing). When every tracked subagent is open, the border switches to amber. 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.
10
8
 
11
9
  ```
12
- ╭─ Subagents ──────────────────────────── 2 running ─╮
10
+ ╭─ Subagents ──────────────────── 1 active · 1 open ─╮
13
11
  │ 00:23 Scout: Auth (scout) active · bash 7m │
14
12
  │ 00:45 Scout: DB (scout) waiting 2m │
15
13
  ╰────────────────────────────────────────────────────╯
@@ -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.
@@ -95,31 +93,47 @@ Agent discovery follows priority: **project-local** (`.pi/agents/`) > **global**
95
93
  5. Main agent processes result → continues with new context
96
94
  ```
97
95
 
98
- Multiple subagents run concurrently — each steers its result back independently as it finishes. The live widget above the input tracks all running agents:
96
+ Multiple subagents run concurrently — each steers its result back independently as it finishes. The live widget above the input tracks every agent still in flight:
99
97
 
100
98
  ```
101
- ╭─ Subagents ───────────────────────────────── 3 running ─╮
99
+ ╭─ Subagents ──────────────────── 1 active · 2 open ─╮
102
100
  │ 01:23 Scout: Auth (scout) active · write 7m │
103
101
  │ 00:45 Researcher (researcher) stalled 4m │
104
102
  │ 00:12 Scout: DB (scout) starting… │
105
103
  ╰─────────────────────────────────────────────────────────╯
106
104
  ```
107
105
 
108
- Completion messages render with a colored background and are expandable with `Ctrl+O` to show the full summary and session file path.
106
+ Completion messages render with a colored background and are expandable with `Ctrl+O` to show the full summary and session file path. Completed rows are removed from the widget as soon as their result is delivered or suppressed.
109
107
 
110
108
  ### In-progress status updates
111
109
 
112
- The widget tracks each Pi-backed sub-agent from a child-written runtime snapshot and labels it with a coarse state:
110
+ The widget projects each sub-agent from a **process + turn lifecycle**:
111
+
112
+ - **Herdr pane inspection** is the coarse authority for whether the child process is present and whether Herdr reports it as idle, working, blocked, or done.
113
+ - **Child activity snapshots** enrich the label with Pi-only detail (tool name, streaming, etc.) when available.
114
+ - Session JSONL is still used for transcript, resume, lineage, and result extraction — not for liveness.
115
+
116
+ Projected labels include:
117
+
118
+ - `starting` — launched; pane/activity confirmation is still settling
119
+ - `active` — processing work (agent turn, provider request, streaming, or tool execution)
120
+ - `blocked` — Herdr reports the child as blocked
121
+ - `waiting` — turn finished; the process is intentionally open for more input or another stage
122
+ - `interrupted` — the current turn was cancelled (Escape / `subagent_interrupt`); the process stays open and is **not** treated as active processing
123
+ - `stalled` — pane inspection is unhealthy long enough that the parent can no longer trust the run
124
+ - `running` — fallback when only coarse process presence is known (e.g. non-Pi backends)
125
+ - `finalizing` — completion was observed and delivery is in progress; the process elapsed timer freezes here
126
+
127
+ The widget header counts **active** vs **open**:
128
+
129
+ - **active** — `active`, `starting`, `running`, or `blocked`
130
+ - **open** — everything else still tracked (`waiting`, `interrupted`, `stalled`, `finalizing`, …)
113
131
 
114
- - `starting` launched, but no valid child snapshot has been observed yet
115
- - `active` — the child is doing observed runtime work: agent turn, provider request, streaming, or tool execution
116
- - `waiting` — the child finished a turn and is intentionally open for more input or another stage
117
- - `stalled` — the parent has gone too long without a valid current child snapshot and can no longer trust the run is healthy
118
- - `running` — fallback for backends without child snapshots (e.g. Claude)
132
+ When `activeCount === 0` (every tracked row is open), the border uses an amber accent. Process elapsed time (`MM:SS` on the left) freezes when the process reaches finalizing/completed/failed. Interrupt does **not** freeze that process clock; the interrupted state shows its own duration on the right while the process remains open.
119
133
 
120
- These labels are no longer derived from session-file growth. Session JSONL is still used for transcript, resume, lineage, and result extraction, but Pi-backed liveness now comes from a small activity snapshot written by the child extension. A fixed internal watchdog marks a run as `stalled` when valid snapshots never appear, stop being readable, or stop matching the current child; valid long-running `active` or `waiting` states do not become `stalled` just because time passes. When a run enters `stalled` or recovers from it, the parent agent receives a steer message so it can react. All other status transitions stay in the widget only.
134
+ A fixed internal watchdog marks a run as `stalled` when pane inspection fails or the pane disappears without a completion sidecar; valid long-running `active` or `waiting` states do not become `stalled` just because time passes. When a run enters `stalled` or recovers from it, the parent agent receives a steer message so it can react. All other status transitions stay in the widget only.
121
135
 
122
- **Interactive subagents stay silent.** Long-running user-driven subagents (e.g. `planner`, or any `/iterate` fork) do not wake the parent session on `stalled`/`recovered` transitions — the user is working directly in the subagent's pane, and a steer message there would just burn an orchestrator turn on a no-op "still waiting" ping. The widget still updates normally, and child snapshots are still recorded/classified regardless of the `interactive` setting. By default, agents with `auto-exit: true` are treated as autonomous and get stall pings; agents without it are treated as interactive and stay quiet. Override per-agent with `interactive: true|false` in frontmatter, or per-spawn with `interactive: true|false` on the tool call.
136
+ **Interactive subagents stay silent.** Long-running user-driven subagents (e.g. `planner`, or any `/iterate` fork) do not wake the parent session on `stalled`/`recovered` transitions — the user is working directly in the subagent's pane, and a steer message there would just burn an orchestrator turn on a no-op "still waiting" ping. The widget still updates normally, and activity snapshots are still recorded/classified regardless of the `interactive` setting. By default, agents with `auto-exit: true` are treated as autonomous and get stall pings; agents without it are treated as interactive and stay quiet. Override per-agent with `interactive: true|false` in frontmatter, or per-spawn with `interactive: true|false` on the tool call.
123
137
 
124
138
  #### Configuration
125
139
 
@@ -184,7 +198,7 @@ subagent_interrupt({ id: "abcd1234" });
184
198
  subagent_interrupt({ name: "Scout" });
185
199
  ```
186
200
 
187
- This sends Escape to the child pane, cancelling the in-progress model turn. The subagent session stays alive — the pane, session file, and background polling all remain intact. After the interrupt, the widget immediately moves the child back to `waiting`, and stale pre-interrupt snapshots are ignored. If the child starts work later, newer snapshots return it to `active`; completion, failure, and `caller_ping` still flow through normally.
201
+ This sends Escape to the child pane, cancelling the in-progress model turn. The subagent session stays alive — the pane, session file, and background polling all remain intact. After the interrupt, the widget immediately labels the child as `interrupted` (counted as **open**, not active processing). Stale pre-interrupt activity snapshots are ignored so a lagging Herdr/`active` reading cannot overwrite the interrupt. The process elapsed timer keeps running because the pane is still open; only the interrupted-state duration freezes relative to the interrupt request. If the child starts work later, newer observations return it to `active`; completion, failure, and `caller_ping` still flow through normally.
188
202
 
189
203
  This is a turn-level interrupt, not a method for forcibly terminating a subagent session.
190
204
 
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.2",
4
4
  "description": "Interactive subagents for pi running in herdr",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -13,6 +13,7 @@
13
13
  },
14
14
  "type": "module",
15
15
  "scripts": {
16
+ "lint": "oxlint pi-extension test",
16
17
  "test": "node --test test/test.ts",
17
18
  "test:integration": "node --test --test-concurrency=1 test/integration/*.test.ts"
18
19
  },
@@ -29,6 +30,7 @@
29
30
  "devDependencies": {
30
31
  "@earendil-works/pi-coding-agent": "^0.80.6",
31
32
  "@earendil-works/pi-tui": "^0.80.6",
32
- "@sinclair/typebox": "^0.34.52"
33
+ "@sinclair/typebox": "^0.34.52",
34
+ "oxlint": "^1.73.0"
33
35
  }
34
36
  }
@@ -13,6 +13,13 @@ export interface CompletionResult {
13
13
  export interface CompletionOptions {
14
14
  intervalMs: number;
15
15
  readTerminalTail: () => Promise<string>;
16
+ inspectPane?: () => Promise<import("./lifecycle.ts").PaneInspection>;
17
+ /** Bounded artifact grace after explicit pane disappearance. Default: 500ms. */
18
+ paneDisappearanceGraceMs?: number;
19
+ onPaneInspection?: (
20
+ inspection: import("./lifecycle.ts").PaneInspection,
21
+ observedAt: number,
22
+ ) => void;
16
23
  sessionFile?: string;
17
24
  sentinelFile?: string;
18
25
  onTick?: (elapsedSeconds: number) => void;
@@ -45,7 +52,13 @@ export function interpretExitSidecar(data: unknown): CompletionResult {
45
52
  return { reason: "error", exitCode: 1, errorMessage };
46
53
  }
47
54
 
48
- return { reason: "done", exitCode: 0 };
55
+ if (payload?.type === "done") return { reason: "done", exitCode: 0 };
56
+
57
+ return {
58
+ reason: "error",
59
+ exitCode: 1,
60
+ errorMessage: "Invalid subagent completion sidecar: unsupported payload type.",
61
+ };
49
62
  }
50
63
 
51
64
  function consumeExitSidecar(sessionFile: string | undefined): CompletionResult | null {
@@ -69,6 +82,33 @@ function terminalExitCode(screen: string): number | null {
69
82
  return match ? Number.parseInt(match[1], 10) : null;
70
83
  }
71
84
 
85
+ function completionArtifact(options: CompletionOptions): CompletionResult | null {
86
+ const sidecar = consumeExitSidecar(options.sessionFile);
87
+ if (sidecar) return sidecar;
88
+ if (options.sentinelFile && existsSync(options.sentinelFile)) {
89
+ return { reason: "sentinel", exitCode: 0 };
90
+ }
91
+ return null;
92
+ }
93
+
94
+ async function waitForDisappearanceArtifacts(
95
+ signal: AbortSignal,
96
+ options: CompletionOptions,
97
+ ): Promise<CompletionResult | null> {
98
+ const immediate = completionArtifact(options);
99
+ if (immediate) return immediate;
100
+
101
+ const graceMs = Math.max(0, options.paneDisappearanceGraceMs ?? 500);
102
+ const deadline = Date.now() + graceMs;
103
+ while (Date.now() < deadline) {
104
+ const remaining = deadline - Date.now();
105
+ await abortableDelay(Math.min(25, remaining), signal);
106
+ const result = completionArtifact(options);
107
+ if (result) return result;
108
+ }
109
+ return null;
110
+ }
111
+
72
112
  function abortableDelay(milliseconds: number, signal: AbortSignal): Promise<void> {
73
113
  if (signal.aborted) return Promise.reject(new Error(ABORT_MESSAGE));
74
114
 
@@ -105,7 +145,30 @@ export async function waitForCompletion(
105
145
  const exitCode = terminalExitCode(await options.readTerminalTail());
106
146
  if (exitCode !== null) return { reason: "sentinel", exitCode };
107
147
  } catch {
108
- // Pane reads can fail transiently while herdr updates or closes a pane.
148
+ // Terminal reads are only sentinel/output probes; Herdr status is polled
149
+ // independently below, even when terminal reads succeed.
150
+ }
151
+
152
+ if (options.inspectPane) {
153
+ let inspection: import("./lifecycle.ts").PaneInspection;
154
+ try {
155
+ inspection = await options.inspectPane();
156
+ } catch {
157
+ inspection = { kind: "unavailable", error: "inspectPane threw" };
158
+ }
159
+ const observedAt = Date.now();
160
+ options.onPaneInspection?.(inspection, observedAt);
161
+ if (inspection.kind === "missing") {
162
+ // Pane closure and atomic artifact publication are separate operations.
163
+ // Allow a short bounded grace window before declaring evidence lost.
164
+ const racedCompletion = await waitForDisappearanceArtifacts(signal, options);
165
+ if (racedCompletion) return racedCompletion;
166
+ return {
167
+ reason: "error",
168
+ exitCode: 1,
169
+ errorMessage: "Subagent pane disappeared before completion evidence was recorded.",
170
+ };
171
+ }
109
172
  }
110
173
 
111
174
  options.onTick?.(Math.floor((Date.now() - startedAt) / 1000));
@@ -169,6 +169,72 @@ export async function readHerdrScreenAsync(surface: string, lines = 50): Promise
169
169
  return herdrExecAsync(["pane", "read", surface, "--source", "visible", "--lines", String(lines)]);
170
170
  }
171
171
 
172
+ export type { PaneInspection, HerdrAgentStatus } from "./lifecycle.ts";
173
+
174
+ type PaneInspectionResult =
175
+ | { kind: "present"; agent?: string; agentStatus: "idle" | "working" | "blocked" | "done" | "unknown" }
176
+ | { kind: "missing"; error?: string }
177
+ | { kind: "unavailable"; error: string };
178
+
179
+ function parsePaneGetOutput(output: string, surface: string): PaneInspectionResult {
180
+ const parsed = parseHerdrJson(output) as
181
+ | { result?: { pane?: unknown }; error?: { code?: unknown; message?: unknown } }
182
+ | null;
183
+ const errorObj = parsed?.error;
184
+ if (errorObj?.code === "pane_not_found" || errorObj?.code === "not_found") {
185
+ return { kind: "missing", error: typeof errorObj.message === "string" ? errorObj.message : "pane not found" };
186
+ }
187
+ const pane = parsed?.result?.pane;
188
+ if (!pane || typeof pane !== "object") return { kind: "unavailable", error: "pane get returned no pane record" };
189
+ const record = pane as { pane_id?: unknown; agent?: unknown; agent_status?: unknown };
190
+ if (record.pane_id !== surface) return { kind: "unavailable", error: "pane id mismatch" };
191
+ const agent = typeof record.agent === "string" ? record.agent : undefined;
192
+ const rawStatus = typeof record.agent_status === "string" ? record.agent_status : "unknown";
193
+ const agentStatus = rawStatus === "idle" ||
194
+ rawStatus === "working" ||
195
+ rawStatus === "blocked" ||
196
+ rawStatus === "done" ||
197
+ rawStatus === "unknown"
198
+ ? rawStatus
199
+ : "unknown";
200
+ return { kind: "present", ...(agent ? { agent } : {}), agentStatus };
201
+ }
202
+
203
+ function parsePaneGetError(error: any): PaneInspectionResult {
204
+ for (const raw of [error?.stderr, error?.stdout]) {
205
+ if (typeof raw !== "string" || !raw.trim()) continue;
206
+ try {
207
+ const parsed = parsePaneGetOutput(raw, "");
208
+ if (parsed.kind === "missing") return parsed;
209
+ } catch {
210
+ // A CLI may emit plain diagnostics on one stream and structured JSON on
211
+ // the other. Parse each stream independently before giving up.
212
+ }
213
+ // Older/alternate Herdr builds may print the stable error code as plain
214
+ // text rather than JSON. Only match explicit identifiers, not generic
215
+ // prose such as "pane unavailable".
216
+ if (/\b(?:pane_not_found|not_found)\b/.test(raw)) {
217
+ return { kind: "missing", error: raw.trim() };
218
+ }
219
+ }
220
+ const message = error?.message ? String(error.message) : "herdr pane get failed";
221
+ return { kind: "unavailable", error: message };
222
+ }
223
+
224
+ /**
225
+ * Structured pane query.
226
+ * - present: pane is reachable; agent/agentStatus may be present when detected
227
+ * - missing: server responded, pane is gone
228
+ * - unavailable: server command failed; caller should keep polling
229
+ */
230
+ export async function inspectHerdrPane(surface: string): Promise<PaneInspectionResult> {
231
+ try {
232
+ return parsePaneGetOutput(await herdrExecAsync(["pane", "get", surface]), surface);
233
+ } catch (error: any) {
234
+ return parsePaneGetError(error);
235
+ }
236
+ }
237
+
172
238
  export function sendHerdrCommand(surface: string, command: string): void {
173
239
  // pane run sends the text and Enter in a single socket request, avoiding
174
240
  // a race where Enter could arrive before the text is fully processed.
@@ -197,4 +263,6 @@ export const __herdrTest__ = {
197
263
  parseHerdrJson,
198
264
  extractHerdrPaneId,
199
265
  extractHerdrRootPaneId,
266
+ parsePaneGetOutput,
267
+ parsePaneGetError,
200
268
  };