pi-herdr-subagents 0.1.1 → 0.1.3

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.3",
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));
@@ -114,18 +114,26 @@ function getHerdrCurrentPaneInfo(): {
114
114
  return { pane_id: paneId, tab_id: tabId, workspace_id: workspaceId };
115
115
  }
116
116
 
117
- export function createHerdrSurface(name: string): string {
118
- // Create a new tab per subagent so parallel spawns each get a full tab
119
- // instead of ever-narrower splits of the parent pane.
120
- const output = herdrExec([
117
+ function buildTabCreateArgs(name: string, cwd: string, workspaceId: string): string[] {
118
+ return [
121
119
  "tab",
122
120
  "create",
121
+ "--workspace",
122
+ workspaceId,
123
123
  "--label",
124
124
  name,
125
125
  "--cwd",
126
- process.cwd(),
126
+ cwd,
127
127
  "--no-focus",
128
- ]);
128
+ ];
129
+ }
130
+
131
+ export function createHerdrSurface(name: string): string {
132
+ // Create a new tab per subagent so parallel spawns each get a full tab
133
+ // instead of ever-narrower splits of the parent pane. Target the current
134
+ // workspace explicitly because Herdr's implicit default may be another space.
135
+ const { workspace_id: workspaceId } = getHerdrCurrentPaneInfo();
136
+ const output = herdrExec(buildTabCreateArgs(name, process.cwd(), workspaceId));
129
137
  const paneId = extractHerdrRootPaneId(output, "tab create");
130
138
  try {
131
139
  herdrExec(["pane", "rename", paneId, name]);
@@ -169,6 +177,72 @@ export async function readHerdrScreenAsync(surface: string, lines = 50): Promise
169
177
  return herdrExecAsync(["pane", "read", surface, "--source", "visible", "--lines", String(lines)]);
170
178
  }
171
179
 
180
+ export type { PaneInspection, HerdrAgentStatus } from "./lifecycle.ts";
181
+
182
+ type PaneInspectionResult =
183
+ | { kind: "present"; agent?: string; agentStatus: "idle" | "working" | "blocked" | "done" | "unknown" }
184
+ | { kind: "missing"; error?: string }
185
+ | { kind: "unavailable"; error: string };
186
+
187
+ function parsePaneGetOutput(output: string, surface: string): PaneInspectionResult {
188
+ const parsed = parseHerdrJson(output) as
189
+ | { result?: { pane?: unknown }; error?: { code?: unknown; message?: unknown } }
190
+ | null;
191
+ const errorObj = parsed?.error;
192
+ if (errorObj?.code === "pane_not_found" || errorObj?.code === "not_found") {
193
+ return { kind: "missing", error: typeof errorObj.message === "string" ? errorObj.message : "pane not found" };
194
+ }
195
+ const pane = parsed?.result?.pane;
196
+ if (!pane || typeof pane !== "object") return { kind: "unavailable", error: "pane get returned no pane record" };
197
+ const record = pane as { pane_id?: unknown; agent?: unknown; agent_status?: unknown };
198
+ if (record.pane_id !== surface) return { kind: "unavailable", error: "pane id mismatch" };
199
+ const agent = typeof record.agent === "string" ? record.agent : undefined;
200
+ const rawStatus = typeof record.agent_status === "string" ? record.agent_status : "unknown";
201
+ const agentStatus = rawStatus === "idle" ||
202
+ rawStatus === "working" ||
203
+ rawStatus === "blocked" ||
204
+ rawStatus === "done" ||
205
+ rawStatus === "unknown"
206
+ ? rawStatus
207
+ : "unknown";
208
+ return { kind: "present", ...(agent ? { agent } : {}), agentStatus };
209
+ }
210
+
211
+ function parsePaneGetError(error: any): PaneInspectionResult {
212
+ for (const raw of [error?.stderr, error?.stdout]) {
213
+ if (typeof raw !== "string" || !raw.trim()) continue;
214
+ try {
215
+ const parsed = parsePaneGetOutput(raw, "");
216
+ if (parsed.kind === "missing") return parsed;
217
+ } catch {
218
+ // A CLI may emit plain diagnostics on one stream and structured JSON on
219
+ // the other. Parse each stream independently before giving up.
220
+ }
221
+ // Older/alternate Herdr builds may print the stable error code as plain
222
+ // text rather than JSON. Only match explicit identifiers, not generic
223
+ // prose such as "pane unavailable".
224
+ if (/\b(?:pane_not_found|not_found)\b/.test(raw)) {
225
+ return { kind: "missing", error: raw.trim() };
226
+ }
227
+ }
228
+ const message = error?.message ? String(error.message) : "herdr pane get failed";
229
+ return { kind: "unavailable", error: message };
230
+ }
231
+
232
+ /**
233
+ * Structured pane query.
234
+ * - present: pane is reachable; agent/agentStatus may be present when detected
235
+ * - missing: server responded, pane is gone
236
+ * - unavailable: server command failed; caller should keep polling
237
+ */
238
+ export async function inspectHerdrPane(surface: string): Promise<PaneInspectionResult> {
239
+ try {
240
+ return parsePaneGetOutput(await herdrExecAsync(["pane", "get", surface]), surface);
241
+ } catch (error: any) {
242
+ return parsePaneGetError(error);
243
+ }
244
+ }
245
+
172
246
  export function sendHerdrCommand(surface: string, command: string): void {
173
247
  // pane run sends the text and Enter in a single socket request, avoiding
174
248
  // a race where Enter could arrive before the text is fully processed.
@@ -194,7 +268,10 @@ export function renameHerdrWorkspace(title: string): void {
194
268
  }
195
269
 
196
270
  export const __herdrTest__ = {
271
+ buildTabCreateArgs,
197
272
  parseHerdrJson,
198
273
  extractHerdrPaneId,
199
274
  extractHerdrRootPaneId,
275
+ parsePaneGetOutput,
276
+ parsePaneGetError,
200
277
  };