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 +30 -14
- package/package.json +4 -2
- package/pi-extension/subagents/completion.ts +65 -2
- package/pi-extension/subagents/herdr.ts +68 -0
- package/pi-extension/subagents/index.ts +227 -113
- package/pi-extension/subagents/lifecycle.ts +477 -0
- package/pi-extension/subagents/subagent-done.ts +11 -9
- package/pi-extension/subagents/terminal.ts +12 -0
- package/test/integration/subagent-lifecycle.test.ts +1 -1
- package/test/test.ts +652 -48
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
110
|
+
The widget projects each sub-agent from a **process + turn lifecycle**:
|
|
111
111
|
|
|
112
|
-
-
|
|
113
|
-
-
|
|
114
|
-
-
|
|
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
|
-
|
|
116
|
+
Projected labels include:
|
|
119
117
|
|
|
120
|
-
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
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
|
};
|