pi-herdr-subagents 0.1.1 → 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
@@ -4,10 +4,10 @@ Async subagents for [pi](https://github.com/badlogic/pi-mono) running exclusivel
4
4
 
5
5
  ## How It Works
6
6
 
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.
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.
8
8
 
9
9
  ```
10
- ╭─ Subagents ──────────────────────────── 2 running ─╮
10
+ ╭─ Subagents ──────────────────── 1 active · 1 open ─╮
11
11
  │ 00:23 Scout: Auth (scout) active · bash 7m │
12
12
  │ 00:45 Scout: DB (scout) waiting 2m │
13
13
  ╰────────────────────────────────────────────────────╯
@@ -93,31 +93,47 @@ Agent discovery follows priority: **project-local** (`.pi/agents/`) > **global**
93
93
  5. Main agent processes result → continues with new context
94
94
  ```
95
95
 
96
- 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:
97
97
 
98
98
  ```
99
- ╭─ Subagents ───────────────────────────────── 3 running ─╮
99
+ ╭─ Subagents ──────────────────── 1 active · 2 open ─╮
100
100
  │ 01:23 Scout: Auth (scout) active · write 7m │
101
101
  │ 00:45 Researcher (researcher) stalled 4m │
102
102
  │ 00:12 Scout: DB (scout) starting… │
103
103
  ╰─────────────────────────────────────────────────────────╯
104
104
  ```
105
105
 
106
- 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.
107
107
 
108
108
  ### In-progress status updates
109
109
 
110
- 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
111
 
112
- - `starting` launched, but no valid child snapshot has been observed yet
113
- - `active` the child is doing observed runtime work: agent turn, provider request, streaming, or tool execution
114
- - `waiting` the child finished a turn and is intentionally open for more input or another stage
115
- - `stalled` — the parent has gone too long without a valid current child snapshot and can no longer trust the run is healthy
116
- - `running` — fallback for backends without child snapshots (e.g. Claude)
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.
117
115
 
118
- 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.
116
+ Projected labels include:
119
117
 
120
- **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.
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`, …)
131
+
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.
133
+
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.
135
+
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.
121
137
 
122
138
  #### Configuration
123
139
 
@@ -182,7 +198,7 @@ subagent_interrupt({ id: "abcd1234" });
182
198
  subagent_interrupt({ name: "Scout" });
183
199
  ```
184
200
 
185
- 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.
186
202
 
187
203
  This is a turn-level interrupt, not a method for forcibly terminating a subagent session.
188
204
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-herdr-subagents",
3
- "version": "0.1.1",
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
  };