@hheei/pi-subagents 0.3.0 → 0.4.0
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 +5 -5
- package/dist/child-control.d.ts +1 -1
- package/dist/child-control.js +89 -26
- package/dist/child-process.d.ts +2 -0
- package/dist/child-process.js +24 -12
- package/dist/domain.d.ts +15 -0
- package/dist/extension.d.ts +3 -4
- package/dist/extension.js +66 -79
- package/dist/host-adapter.d.ts +19 -0
- package/dist/host-adapter.js +139 -0
- package/dist/info.js +32 -6
- package/dist/lease.d.ts +83 -0
- package/dist/lease.js +239 -0
- package/dist/mailbox.d.ts +46 -0
- package/dist/mailbox.js +368 -0
- package/dist/manager.d.ts +17 -19
- package/dist/manager.js +301 -427
- package/dist/process-lock.d.ts +11 -0
- package/dist/process-lock.js +55 -0
- package/dist/protocol.d.ts +2 -2
- package/dist/protocol.js +1 -0
- package/dist/registry.d.ts +1 -1
- package/dist/registry.js +80 -77
- package/dist/rpc-events.d.ts +1 -1
- package/dist/rpc-events.js +2 -0
- package/dist/runtime.d.ts +8 -21
- package/dist/runtime.js +61 -83
- package/dist/state-db.d.ts +52 -0
- package/dist/state-db.js +209 -0
- package/dist/state.d.ts +12 -0
- package/dist/state.js +107 -21
- package/dist/tool-presentation.d.ts +2 -5
- package/dist/tool-presentation.js +38 -5
- package/dist/tools.js +3 -2
- package/package.json +6 -6
- package/dist/bridge-client.d.ts +0 -60
- package/dist/bridge-client.js +0 -459
- package/dist/bridge-server.d.ts +0 -72
- package/dist/bridge-server.js +0 -490
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ children and `/subagents stop [id]` to stop one without conflicting with other e
|
|
|
16
16
|
| Tool | Purpose |
|
|
17
17
|
| --- | --- |
|
|
18
18
|
| `agent_spawn({ task, agent, cwd?, title? })` | Spawn an independent background subagent task. Runs in a new Herdr tab (or cmux surface) targeting the parent's workspace when a host is available, and headless in the background otherwise. **Prefer reusing existing subagents** via `agent_send` for related/follow-up work to maintain context and maximize prompt cache and memory efficiency. Tool description lists available agent names and roles, excluding disabled or hidden definitions. Deferred by default; discover via `tool_search({ query: "agent" })`. Do not poll for completion; results return automatically. |
|
|
19
|
-
| `agent_send({ id, message, mode? })` | Send `steer`, `follow_up`, or `auto` input to a specific child. **Primary tool for assigning follow-up or functionally related tasks** to existing subagents: idle, blocked, done, or errored children resume their existing session automatically with historical context intact. Deferred by default; discover via `tool_search({ query: "agent" })`. |
|
|
19
|
+
| `agent_send({ id, message, mode? })` | Send `steer`, `follow_up`, or `auto` input to a specific child. **Primary tool for assigning follow-up or functionally related tasks** to existing subagents: idle, blocked, done, or errored children resume their existing session automatically with historical context intact. A child whose runtime has ended is resumed the same way; spawn a new child only for new work. Stop first only when send says the previous process is still running. Deferred by default; discover via `tool_search({ query: "agent" })`. |
|
|
20
20
|
| `agent_get({ id, keys? })` | Inspect one subagent by id. `keys` is a comma-separated selection of `status` (default), `progress` (default), and `diagnostic`; unknown keys fail. The view never returns the child's task parameters or terminal result, which arrive automatically as reports. From the second successful inspection of the same child in this parent runtime, the result adds a short reminder that results return automatically and must not be polled. Deferred by default; discover via `tool_search({ query: "agent" })`. |
|
|
21
21
|
| `agent_stop({ id })` | Stop one subagent's runtime by id. The child keeps its session, so a later `agent_send` can resume it. Deferred by default; discover via `tool_search({ query: "agent" })`. |
|
|
22
22
|
|
|
@@ -28,16 +28,16 @@ assigned by Pi; its first write may still be pending. `agent_get` exposes the sa
|
|
|
28
28
|
sentence. Failures return only their reason. Operation results omit session IDs, presentation, and
|
|
29
29
|
state snapshots; use `agent_get` for runtime inspection.
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
SubagentManager and its SQLite ledger own child conversation state, runtime leases, recovery, and reports. No pi-ext-tools task service is required. Only delivery batching is shared through ext-core BackgroundDelivery. Child reports arrive automatically; do not poll status to detect completion. Bash uses separate `bg_bash_*` tools. Deferred subagent tools are loaded via `tool_search`; `before_agent_start` writes the `subagents` bullet into the structured prompt's `optional_tools` section. Both get tools return the same `status`, `progress`, and optional `diagnostic` envelope. Diagnostics describe blockers, failed turns, or uncertain runtime state.
|
|
32
32
|
|
|
33
33
|
## Child execution and reporting
|
|
34
34
|
|
|
35
|
-
Subagents communicate with the parent
|
|
35
|
+
Subagents communicate with the parent through the `commands` table in `state.sqlite`, with each child holding a SQLite process lock that proves its writer is alive:
|
|
36
36
|
|
|
37
37
|
- **Completion by Direct Output**: Subagents do **not** need to call any reporting tool upon successful completion. The subagent simply outputs its final answer as normal assistant text.
|
|
38
|
-
- **Settlement
|
|
38
|
+
- **Settlement Batching**: When a subagent settles, the harness captures the trailing assistant text and delivers it to the parent conversation as a `subagent-report`. Reports flush within 500ms when no other child is working, and are batched for up to 10 seconds while another child is still running.
|
|
39
39
|
- **Blockers & Decisions**: The child registers `contact_parent({ message, reason? })` strictly for reporting when it is blocked or urgently requires a parent decision midway (`reason` defaults to `'blocked'`). Blocked reports wake the parent immediately.
|
|
40
|
-
- **Automatic Retry**:
|
|
40
|
+
- **Automatic Retry**: Pi's own auto-retry is observed, not invented: reports are suppressed while a retry is in flight, and a retry that succeeds clears the previous error instead of reporting it.
|
|
41
41
|
- **Panel Failure Auto-Close**: When a subagent running in a panel encounters a fatal error or reports a blocker, the manager starts a 15-second countdown after notifying the parent, automatically closing the panel tab to avoid workspace clutter unless new instructions are dispatched.
|
|
42
42
|
## Compact status and reports
|
|
43
43
|
|
package/dist/child-control.d.ts
CHANGED
|
@@ -44,7 +44,7 @@ export declare function promptMessage(payload: unknown): string;
|
|
|
44
44
|
* Answers a parent request with this child's Pi state. Idleness is the child's own answer: the
|
|
45
45
|
* parent never infers it from how long ago it last heard something.
|
|
46
46
|
*/
|
|
47
|
-
export declare function childState(ctx: ExtensionContext): Record<string, unknown>;
|
|
47
|
+
export declare function childState(ctx: ExtensionContext, isCompacting?: boolean): Record<string, unknown>;
|
|
48
48
|
/**
|
|
49
49
|
* Compacts an advisory event to the telemetry the parent projects before it is serialized and
|
|
50
50
|
* pushed over the bridge. A streaming update carries the whole partial message — text, tool
|
package/dist/child-control.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { errorMessage, isRecord } from "@hheei/pi-ext-core";
|
|
2
|
-
import {
|
|
3
|
-
import { BridgeError } from "./protocol.js";
|
|
2
|
+
import { attachChildMailbox } from "./mailbox.js";
|
|
3
|
+
import { BridgeError, CHILD_INPUT_EVENT } from "./protocol.js";
|
|
4
4
|
import { FORWARDED_PI_EVENT_TYPES, shouldForwardPiEvent } from "./rpc-events.js";
|
|
5
|
-
import { assistantUpdatePhase } from "./state.js";
|
|
5
|
+
import { assistantUpdatePhase, transcriptFacts } from "./state.js";
|
|
6
6
|
const CHILD_INPUT_SOURCES = ["interactive", "extension", "rpc"];
|
|
7
7
|
/** The source of a Pi input event, when it is one the parent is allowed to hear about. */
|
|
8
8
|
export function childInputSource(event) {
|
|
@@ -23,11 +23,12 @@ export function promptMessage(payload) {
|
|
|
23
23
|
* Answers a parent request with this child's Pi state. Idleness is the child's own answer: the
|
|
24
24
|
* parent never infers it from how long ago it last heard something.
|
|
25
25
|
*/
|
|
26
|
-
export function childState(ctx) {
|
|
26
|
+
export function childState(ctx, isCompacting = false) {
|
|
27
27
|
const path = ctx.sessionManager.getSessionFile();
|
|
28
28
|
return {
|
|
29
29
|
idle: ctx.isIdle(),
|
|
30
30
|
pendingMessages: ctx.hasPendingMessages(),
|
|
31
|
+
...(isCompacting ? { compacting: true } : {}),
|
|
31
32
|
sessionId: ctx.sessionManager.getSessionId(),
|
|
32
33
|
...(path ? { path } : {}),
|
|
33
34
|
};
|
|
@@ -160,6 +161,13 @@ export function sanitizeEventForBridge(event) {
|
|
|
160
161
|
...(message === undefined ? {} : { message, messages: [message] }),
|
|
161
162
|
};
|
|
162
163
|
}
|
|
164
|
+
if (event.type === "compaction_start" || event.type === "compaction_end") {
|
|
165
|
+
return {
|
|
166
|
+
type: event.type,
|
|
167
|
+
...(typeof event.reason === "string" ? { reason: event.reason } : {}),
|
|
168
|
+
...(typeof event.aborted === "boolean" ? { aborted: event.aborted } : {}),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
163
171
|
return event;
|
|
164
172
|
}
|
|
165
173
|
/**
|
|
@@ -169,6 +177,21 @@ export function sanitizeEventForBridge(event) {
|
|
|
169
177
|
export function registerChildControl(pi, options) {
|
|
170
178
|
const isActive = options.isActive ?? (() => true);
|
|
171
179
|
let ctx;
|
|
180
|
+
let isCompacting = false;
|
|
181
|
+
const pendingCompactionDeliveries = [];
|
|
182
|
+
const flushCompactionQueue = () => {
|
|
183
|
+
if (pendingCompactionDeliveries.length === 0)
|
|
184
|
+
return;
|
|
185
|
+
const queued = pendingCompactionDeliveries.splice(0);
|
|
186
|
+
for (const item of queued) {
|
|
187
|
+
if (item.deliverAs !== undefined) {
|
|
188
|
+
pi.sendUserMessage(item.message, { deliverAs: item.deliverAs });
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
pi.sendUserMessage(item.message);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
};
|
|
172
195
|
const requireContext = () => {
|
|
173
196
|
if (ctx === undefined) {
|
|
174
197
|
throw new BridgeError("session_not_ready", "This child has no active Pi session yet");
|
|
@@ -183,12 +206,37 @@ export function registerChildControl(pi, options) {
|
|
|
183
206
|
if (path === undefined) {
|
|
184
207
|
throw new BridgeError("session_not_ready", "This child has no session file path");
|
|
185
208
|
}
|
|
209
|
+
if (isCompacting) {
|
|
210
|
+
pendingCompactionDeliveries.push({ message, deliverAs });
|
|
211
|
+
return { accepted: true, path, queuedForCompaction: true };
|
|
212
|
+
}
|
|
186
213
|
if (deliverAs !== undefined)
|
|
187
214
|
pi.sendUserMessage(message, { deliverAs });
|
|
188
215
|
else
|
|
189
216
|
pi.sendUserMessage(message);
|
|
190
217
|
return { accepted: true, path };
|
|
191
218
|
};
|
|
219
|
+
const currentEntries = () => {
|
|
220
|
+
const sm = requireContext().sessionManager;
|
|
221
|
+
const branch = typeof sm.getBranch === "function" ? sm.getBranch() : undefined;
|
|
222
|
+
return Array.isArray(branch) && branch.length > 0 ? branch : sm.getEntries();
|
|
223
|
+
};
|
|
224
|
+
let cachedFacts;
|
|
225
|
+
const sessionFacts = (expected) => {
|
|
226
|
+
const sm = requireContext().sessionManager;
|
|
227
|
+
const leaf = typeof sm.getLeafId === "function" ? sm.getLeafId() : undefined;
|
|
228
|
+
// Append-only: a new message, compaction, or branch moves the leaf. The same leaf is the
|
|
229
|
+
// same transcript, so turn_end and agent_end do not each copy and walk it.
|
|
230
|
+
if (typeof leaf === "string" && leaf.length > 0) {
|
|
231
|
+
const key = `${leaf}:${expected ?? ""}`;
|
|
232
|
+
if (cachedFacts?.key === key)
|
|
233
|
+
return cachedFacts.facts;
|
|
234
|
+
const facts = transcriptFacts(currentEntries(), expected);
|
|
235
|
+
cachedFacts = { key, facts };
|
|
236
|
+
return facts;
|
|
237
|
+
}
|
|
238
|
+
return transcriptFacts(currentEntries(), expected);
|
|
239
|
+
};
|
|
192
240
|
const handleRequest = async (request) => {
|
|
193
241
|
if (!isActive()) {
|
|
194
242
|
// The process serves another session now, so it is not this child's runtime: answering
|
|
@@ -203,12 +251,13 @@ export function registerChildControl(pi, options) {
|
|
|
203
251
|
case "follow_up":
|
|
204
252
|
return deliverPrompt(request.payload, "followUp");
|
|
205
253
|
case "get_state":
|
|
206
|
-
return childState(requireContext());
|
|
254
|
+
return childState(requireContext(), isCompacting);
|
|
207
255
|
case "get_entries": {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
256
|
+
return { entries: currentEntries() };
|
|
257
|
+
}
|
|
258
|
+
case "session_facts": {
|
|
259
|
+
const expected = isRecord(request.payload) ? request.payload.expectedInput : undefined;
|
|
260
|
+
return sessionFacts(typeof expected === "string" ? expected : undefined);
|
|
212
261
|
}
|
|
213
262
|
case "abort":
|
|
214
263
|
requireContext().abort();
|
|
@@ -223,7 +272,6 @@ export function registerChildControl(pi, options) {
|
|
|
223
272
|
const dispose = [];
|
|
224
273
|
let currentPhase;
|
|
225
274
|
/** The compact event carrying the current phase, resent when the parent reconnects. */
|
|
226
|
-
let lastPhaseEvent;
|
|
227
275
|
const phaseResetEvents = [
|
|
228
276
|
"agent_start",
|
|
229
277
|
"agent_end",
|
|
@@ -232,25 +280,22 @@ export function registerChildControl(pi, options) {
|
|
|
232
280
|
"agent_settled",
|
|
233
281
|
"tool_execution_start",
|
|
234
282
|
"tool_execution_end",
|
|
283
|
+
"compaction_start",
|
|
284
|
+
"compaction_end",
|
|
235
285
|
];
|
|
236
|
-
const client =
|
|
237
|
-
|
|
238
|
-
|
|
286
|
+
const client = attachChildMailbox({
|
|
287
|
+
filePath: options.endpoint,
|
|
288
|
+
parentSessionId: options.identity.parentSessionId,
|
|
289
|
+
subagentId: options.identity.subagentId,
|
|
239
290
|
handleRequest,
|
|
240
291
|
...(options.diagnose === undefined ? {} : { diagnose: options.diagnose }),
|
|
241
|
-
onConnect: () => {
|
|
242
|
-
// A reconnected parent knows nothing of the phase this child reached while the socket was
|
|
243
|
-
// down, so the current compact phase is resent before new deltas arrive.
|
|
244
|
-
if (lastPhaseEvent !== undefined)
|
|
245
|
-
client.sendEvent(lastPhaseEvent);
|
|
246
|
-
},
|
|
247
292
|
});
|
|
248
293
|
// `pi.on` is declared as literal-name overloads, so a loop over the forwarded event types cannot
|
|
249
294
|
// be typed through them; every name in FORWARDED_PI_EVENT_TYPES is a real Pi event.
|
|
250
295
|
const onPiEvent = pi.on;
|
|
251
296
|
for (const type of FORWARDED_PI_EVENT_TYPES) {
|
|
252
297
|
dispose.push(onPiEvent(type, (event) => {
|
|
253
|
-
if (!isActive())
|
|
298
|
+
if (!isActive() || !client.connected)
|
|
254
299
|
return;
|
|
255
300
|
if (!shouldForwardPiEvent(event))
|
|
256
301
|
return;
|
|
@@ -258,12 +303,10 @@ export function registerChildControl(pi, options) {
|
|
|
258
303
|
if (isRecord(event)) {
|
|
259
304
|
if (typeof event.type === "string" && phaseResetEvents.includes(event.type)) {
|
|
260
305
|
currentPhase = undefined;
|
|
261
|
-
lastPhaseEvent = undefined;
|
|
262
306
|
}
|
|
263
307
|
else if (event.type === "message_update") {
|
|
264
308
|
const phase = assistantPhaseKey(event);
|
|
265
309
|
if (phase !== undefined) {
|
|
266
|
-
lastPhaseEvent = compact;
|
|
267
310
|
// While the parent is listening, repetitive deltas in the same phase are dropped to
|
|
268
311
|
// prevent socket floods. A disconnected parent suppresses nothing: the phase is
|
|
269
312
|
// resent from `onConnect` instead, so a dropped delta cannot hide it.
|
|
@@ -277,17 +320,37 @@ export function registerChildControl(pi, options) {
|
|
|
277
320
|
}));
|
|
278
321
|
}
|
|
279
322
|
dispose.push(pi.on("input", (event) => {
|
|
280
|
-
if (!isActive())
|
|
323
|
+
if (!isActive() || !client.connected)
|
|
281
324
|
return;
|
|
282
325
|
const source = childInputSource(event);
|
|
283
|
-
if (source !== undefined)
|
|
284
|
-
client.
|
|
326
|
+
if (source !== undefined) {
|
|
327
|
+
client.sendEvent({
|
|
328
|
+
type: CHILD_INPUT_EVENT,
|
|
329
|
+
parentSessionId: options.identity.parentSessionId,
|
|
330
|
+
childId: options.identity.subagentId,
|
|
331
|
+
runtimeIdentity: options.identity.runtimeIdentity,
|
|
332
|
+
source,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
285
335
|
}));
|
|
286
336
|
dispose.push(pi.on("session_start", (_event, context) => {
|
|
287
337
|
ctx = context;
|
|
288
338
|
}));
|
|
339
|
+
dispose.push(onPiEvent("compaction_start", () => {
|
|
340
|
+
isCompacting = true;
|
|
341
|
+
}));
|
|
342
|
+
dispose.push(onPiEvent("compaction_end", () => {
|
|
343
|
+
isCompacting = false;
|
|
344
|
+
queueMicrotask(() => {
|
|
345
|
+
if (!isCompacting) {
|
|
346
|
+
flushCompactionQueue();
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
}));
|
|
289
350
|
dispose.push(pi.on("session_shutdown", () => {
|
|
290
351
|
ctx = undefined;
|
|
352
|
+
isCompacting = false;
|
|
353
|
+
pendingCompactionDeliveries.length = 0;
|
|
291
354
|
}));
|
|
292
355
|
let started = false;
|
|
293
356
|
return {
|
|
@@ -299,7 +362,7 @@ export function registerChildControl(pi, options) {
|
|
|
299
362
|
started = true;
|
|
300
363
|
client.start();
|
|
301
364
|
},
|
|
302
|
-
report: (operation, payload, signal) => client.
|
|
365
|
+
report: (operation, payload, signal) => client.report(operation, payload, signal),
|
|
303
366
|
sendEvent: (event) => client.sendEvent(event),
|
|
304
367
|
dispose: () => {
|
|
305
368
|
for (const off of dispose) {
|
package/dist/child-process.d.ts
CHANGED
|
@@ -19,3 +19,5 @@ export interface ChildRuntime {
|
|
|
19
19
|
* supervisor process, so nothing else can end a background child after its parent is gone.
|
|
20
20
|
*/
|
|
21
21
|
export declare function spawnChildRuntime(options: ChildProcessOptions): ChildRuntime;
|
|
22
|
+
/** The process-scoped stdin leash for a background child that survived an extension reload. */
|
|
23
|
+
export declare function attachRetainedChild(pid: number): ChildRuntime | undefined;
|
package/dist/child-process.js
CHANGED
|
@@ -18,6 +18,7 @@ function hasExited(child) {
|
|
|
18
18
|
* would send it an EOF and end a child that only its parent's exit is supposed to end.
|
|
19
19
|
*/
|
|
20
20
|
const retained = new Set();
|
|
21
|
+
const retainedRuntimes = new Map();
|
|
21
22
|
/**
|
|
22
23
|
* Starts a detached-from-the-terminal child Pi. The caller keeps the handle: this package has no
|
|
23
24
|
* supervisor process, so nothing else can end a background child after its parent is gone.
|
|
@@ -43,22 +44,14 @@ export function spawnChildRuntime(options) {
|
|
|
43
44
|
resolve(code);
|
|
44
45
|
});
|
|
45
46
|
});
|
|
46
|
-
|
|
47
|
+
const pid = child.pid;
|
|
48
|
+
if (pid === undefined) {
|
|
47
49
|
child.kill("SIGKILL");
|
|
48
50
|
throw new Error(`Child process ${options.command} did not start`);
|
|
49
51
|
}
|
|
50
52
|
retained.add(child);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
child.stdout.on("data", () => { });
|
|
54
|
-
child.stderr.on("data", (chunk) => {
|
|
55
|
-
const text = chunk.toString("utf8").trim();
|
|
56
|
-
if (text !== "")
|
|
57
|
-
diagnose(text);
|
|
58
|
-
});
|
|
59
|
-
child.stdin.on("error", () => { });
|
|
60
|
-
return {
|
|
61
|
-
pid: child.pid,
|
|
53
|
+
const runtime = {
|
|
54
|
+
pid,
|
|
62
55
|
get alive() {
|
|
63
56
|
return !hasExited(child);
|
|
64
57
|
},
|
|
@@ -76,6 +69,25 @@ export function spawnChildRuntime(options) {
|
|
|
76
69
|
}
|
|
77
70
|
},
|
|
78
71
|
};
|
|
72
|
+
retainedRuntimes.set(pid, runtime);
|
|
73
|
+
void exited.then(() => {
|
|
74
|
+
retainedRuntimes.delete(pid);
|
|
75
|
+
});
|
|
76
|
+
// stdout is the child's Pi protocol stream and stderr its diagnostics; neither is a control
|
|
77
|
+
// channel, so they are drained to keep the pipes from filling and reported only as diagnostics.
|
|
78
|
+
child.stdout.on("data", () => { });
|
|
79
|
+
child.stderr.on("data", (chunk) => {
|
|
80
|
+
const text = chunk.toString("utf8").trim();
|
|
81
|
+
if (text !== "")
|
|
82
|
+
diagnose(text);
|
|
83
|
+
});
|
|
84
|
+
child.stdin.on("error", () => { });
|
|
85
|
+
return runtime;
|
|
86
|
+
}
|
|
87
|
+
/** The process-scoped stdin leash for a background child that survived an extension reload. */
|
|
88
|
+
export function attachRetainedChild(pid) {
|
|
89
|
+
const runtime = retainedRuntimes.get(pid);
|
|
90
|
+
return runtime?.alive === true ? runtime : undefined;
|
|
79
91
|
}
|
|
80
92
|
/** True when `work` settled within the window; the child process outlives this wait either way. */
|
|
81
93
|
function settled(work, timeoutMs) {
|
package/dist/domain.d.ts
CHANGED
|
@@ -116,9 +116,24 @@ export interface ResolvedAgentPolicy {
|
|
|
116
116
|
* The runtime attachment a child was last launched with. It is evidence, never a liveness proof:
|
|
117
117
|
* whether that process is still serving this child is answered by the bridge connection.
|
|
118
118
|
*/
|
|
119
|
+
export type PanelHost = "herdr" | "cmux";
|
|
120
|
+
/**
|
|
121
|
+
* The surface that owns a child runtime. A panel id is a host tab or surface. A process id is a
|
|
122
|
+
* background child. Neither one is liveness by itself: the host list or a pid check says whether
|
|
123
|
+
* it is still there.
|
|
124
|
+
*/
|
|
125
|
+
export type RuntimeAttachment = {
|
|
126
|
+
readonly kind: "panel";
|
|
127
|
+
readonly host: PanelHost;
|
|
128
|
+
readonly id: string;
|
|
129
|
+
} | {
|
|
130
|
+
readonly kind: "process";
|
|
131
|
+
readonly pid: number;
|
|
132
|
+
};
|
|
119
133
|
export interface RuntimeMetadata {
|
|
120
134
|
readonly runtimeIdentity: string;
|
|
121
135
|
readonly endpoint: string;
|
|
136
|
+
readonly attachment?: RuntimeAttachment;
|
|
122
137
|
}
|
|
123
138
|
export interface UsageSummary {
|
|
124
139
|
readonly inputTokens: number;
|
package/dist/extension.d.ts
CHANGED
|
@@ -4,16 +4,15 @@ import { type SkillCatalogEntry } from "./agent-resolver.js";
|
|
|
4
4
|
import { type ParentChannel } from "./manager.js";
|
|
5
5
|
/** Parent channel plus the teardown its owner must call. */
|
|
6
6
|
export interface ParentChannelHandle extends ParentChannel {
|
|
7
|
-
dismiss(childId: string): void;
|
|
8
7
|
/** Unregisters delivery and appends anything still held without waking the parent. */
|
|
9
8
|
dispose(): void;
|
|
10
9
|
}
|
|
11
10
|
/**
|
|
12
11
|
* How child reports reach the parent conversation.
|
|
13
12
|
*
|
|
14
|
-
* Busy parents receive a steer into the next model step. Idle parents
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* Busy parents receive a steer into the next model step. Idle parents get one new turn per
|
|
14
|
+
* microtask batch; the shared coordinator makes this channel and the Bash one agree on which
|
|
15
|
+
* message requests it.
|
|
17
16
|
*/
|
|
18
17
|
export declare function createParentChannel(pi: Pick<ExtensionAPI, "sendMessage" | "on" | "appendEntry">, deps: {
|
|
19
18
|
readonly isIdle: () => boolean;
|
package/dist/extension.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import { errorMessage, getBackgroundDelivery, registerExtensionLifecycle,
|
|
1
|
+
import { errorMessage, getBackgroundDelivery, registerExtensionLifecycle, } from "@hheei/pi-ext-core";
|
|
2
2
|
import { discoverAgents } from "./agent-resolver.js";
|
|
3
|
-
import { ChildBridgeServer } from "./bridge-server.js";
|
|
4
3
|
import { registerChildBridge } from "./child-bridge.js";
|
|
5
4
|
import { registerParentCommands } from "./commands.js";
|
|
6
5
|
import { BRIDGE_ENVIRONMENT_KEYS, isThinkingLevel } from "./domain.js";
|
|
7
|
-
import { createCmuxHostAdapter, createHerdrHostAdapter, selectHostAdapter, } from "./host-adapter.js";
|
|
6
|
+
import { createCmuxHostAdapter, createHerdrHostAdapter, listCmuxAttachments, listHerdrAttachments, rebindCmuxSurface, rebindHerdrTab, selectHostAdapter, systemHostCommandRunner, } from "./host-adapter.js";
|
|
8
7
|
import { appendSubagentInfo, displaySubagentText, registerSubagentInfo } from "./info.js";
|
|
8
|
+
import { createMailboxTransport } from "./mailbox.js";
|
|
9
9
|
import { SubagentManager } from "./manager.js";
|
|
10
10
|
import { transformSubagentsMarkdown } from "./markdown.js";
|
|
11
11
|
import { createSubagentRegistry } from "./registry.js";
|
|
12
|
-
import { createRuntimeTokenStore, launchChild, openChildPanel,
|
|
12
|
+
import { createRuntimeTokenStore, launchChild, openChildPanel, prepareRuntimeDirectory, } from "./runtime.js";
|
|
13
13
|
import { persistSubagentIntent, resolveSubagentLaunch } from "./session-bootstrap.js";
|
|
14
14
|
import { createSubagentStatus } from "./status.js";
|
|
15
15
|
import { registerInteractiveToolActivation } from "./tool-activation.js";
|
|
@@ -45,23 +45,18 @@ function childIdentityFromEnv(env) {
|
|
|
45
45
|
/**
|
|
46
46
|
* How child reports reach the parent conversation.
|
|
47
47
|
*
|
|
48
|
-
* Busy parents receive a steer into the next model step. Idle parents
|
|
49
|
-
*
|
|
50
|
-
*
|
|
48
|
+
* Busy parents receive a steer into the next model step. Idle parents get one new turn per
|
|
49
|
+
* microtask batch; the shared coordinator makes this channel and the Bash one agree on which
|
|
50
|
+
* message requests it.
|
|
51
51
|
*/
|
|
52
52
|
export function createParentChannel(pi, deps) {
|
|
53
53
|
const held = [];
|
|
54
54
|
let disposed = false;
|
|
55
|
-
const flush = (triggerTurn
|
|
55
|
+
const flush = (triggerTurn) => {
|
|
56
56
|
if (held.length === 0)
|
|
57
57
|
return;
|
|
58
|
-
const reports =
|
|
59
|
-
|
|
60
|
-
: [...held];
|
|
61
|
-
if (reports.length === 0)
|
|
62
|
-
return;
|
|
63
|
-
for (const report of reports)
|
|
64
|
-
held.splice(held.indexOf(report), 1);
|
|
58
|
+
const reports = [...held];
|
|
59
|
+
held.length = 0;
|
|
65
60
|
try {
|
|
66
61
|
pi.sendMessage({
|
|
67
62
|
customType: "subagent-report",
|
|
@@ -96,12 +91,7 @@ export function createParentChannel(pi, deps) {
|
|
|
96
91
|
if (disposed)
|
|
97
92
|
throw new Error("Parent report channel is disposed");
|
|
98
93
|
held.push(report);
|
|
99
|
-
registration.request(
|
|
100
|
-
},
|
|
101
|
-
dismiss(childId) {
|
|
102
|
-
const index = held.findIndex((report) => report.childId === childId);
|
|
103
|
-
if (index !== -1)
|
|
104
|
-
held.splice(index, 1);
|
|
94
|
+
registration.request();
|
|
105
95
|
},
|
|
106
96
|
dispose() {
|
|
107
97
|
disposed = true;
|
|
@@ -158,37 +148,15 @@ export default function piSubagentsExtension(pi) {
|
|
|
158
148
|
});
|
|
159
149
|
runtime.resources.add("parent-channel", () => channel.dispose());
|
|
160
150
|
let manager;
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
endpoint: parentBridgeEndpoint(parentSessionId),
|
|
151
|
+
// One sqlite file is the ledger and the mailbox. Presence is not liveness.
|
|
152
|
+
const mailbox = createMailboxTransport({
|
|
153
|
+
filePath: registry.path,
|
|
165
154
|
parentSessionId,
|
|
166
|
-
authorize: async (frame) => {
|
|
167
|
-
const expected = tokens.get(frame.runtimeIdentity);
|
|
168
|
-
if (expected === undefined) {
|
|
169
|
-
return `no bridge token is recorded for runtime ${frame.runtimeIdentity}`;
|
|
170
|
-
}
|
|
171
|
-
if (expected !== frame.token)
|
|
172
|
-
return "bridge token mismatch";
|
|
173
|
-
// The token says which runtime is calling; the registry says which child that runtime
|
|
174
|
-
// belongs to. Without this check a child could name another child's id and take over
|
|
175
|
-
// its control channel, so a hello that does not match its record is refused.
|
|
176
|
-
const record = await registry.get(frame.subagentId).catch(() => undefined);
|
|
177
|
-
if (record === undefined)
|
|
178
|
-
return `no recorded child ${frame.subagentId}`;
|
|
179
|
-
if (record.runtime?.runtimeIdentity !== frame.runtimeIdentity) {
|
|
180
|
-
return `runtime ${frame.runtimeIdentity} is not the recorded runtime of child ${frame.subagentId}`;
|
|
181
|
-
}
|
|
182
|
-
return undefined;
|
|
183
|
-
},
|
|
184
155
|
handleRequest: (request) => manager.handleChildRequest(request.childId, request.operation, request.payload),
|
|
185
156
|
diagnose,
|
|
186
157
|
});
|
|
187
158
|
await prepareRuntimeDirectory(diagnose);
|
|
188
|
-
|
|
189
|
-
// Closing is awaited: the endpoint is unlinked after the server is closed, so a reload that
|
|
190
|
-
// started the next server first could have this close unlink the new socket path.
|
|
191
|
-
runtime.resources.add("subagent-bridge", () => server.close());
|
|
159
|
+
runtime.resources.add("subagent-bridge", () => mailbox.close());
|
|
192
160
|
// A definition names skills the way Pi lists them, so the parent's loaded skill set is
|
|
193
161
|
// the only name → path catalog; Pi hands it over before every prompt of this session.
|
|
194
162
|
let skillCatalog = [];
|
|
@@ -228,13 +196,13 @@ export default function piSubagentsExtension(pi) {
|
|
|
228
196
|
bootstrap(input) {
|
|
229
197
|
return persistSubagentIntent({ registry, ...input });
|
|
230
198
|
},
|
|
231
|
-
bridge:
|
|
199
|
+
bridge: mailbox,
|
|
232
200
|
launch(record) {
|
|
233
201
|
return launchChild({
|
|
234
202
|
registry,
|
|
235
203
|
record,
|
|
236
204
|
tokens,
|
|
237
|
-
waitForBridge: (childId, signal) =>
|
|
205
|
+
waitForBridge: (childId, signal) => mailbox.waitUntilSeen(childId, {
|
|
238
206
|
timeoutMs: START_WINDOW_MS,
|
|
239
207
|
...(signal === undefined ? {} : { signal }),
|
|
240
208
|
}),
|
|
@@ -251,7 +219,7 @@ export default function piSubagentsExtension(pi) {
|
|
|
251
219
|
record,
|
|
252
220
|
tokens,
|
|
253
221
|
host: hostSelection.adapter,
|
|
254
|
-
waitForBridge: (childId, signal) =>
|
|
222
|
+
waitForBridge: (childId, signal) => mailbox.waitUntilSeen(childId, {
|
|
255
223
|
timeoutMs: START_WINDOW_MS,
|
|
256
224
|
...(signal === undefined ? {} : { signal }),
|
|
257
225
|
}),
|
|
@@ -264,34 +232,63 @@ export default function piSubagentsExtension(pi) {
|
|
|
264
232
|
// Adoption is a wait, never a spawn: a child that survived this parent reconnects on
|
|
265
233
|
// its own, and one that did not stays recorded until a send starts it again.
|
|
266
234
|
connect(record) {
|
|
267
|
-
return
|
|
235
|
+
return mailbox.waitUntilSeen(record.subagentId, {
|
|
268
236
|
timeoutMs: ADOPT_WINDOW_MS,
|
|
269
237
|
signal: runtime.signal,
|
|
270
238
|
});
|
|
271
239
|
},
|
|
240
|
+
...(hostSelection.available
|
|
241
|
+
? {
|
|
242
|
+
directory: {
|
|
243
|
+
host: hostSelection.selectedHost,
|
|
244
|
+
list: () => hostSelection.selectedHost === "herdr"
|
|
245
|
+
? listHerdrAttachments(systemHostCommandRunner, 10_000)
|
|
246
|
+
: listCmuxAttachments(systemHostCommandRunner, 10_000),
|
|
247
|
+
open: async (record) => {
|
|
248
|
+
const launch = await openChildPanel({
|
|
249
|
+
registry,
|
|
250
|
+
record,
|
|
251
|
+
tokens,
|
|
252
|
+
host: hostSelection.adapter,
|
|
253
|
+
waitForBridge: (childId, signal) => mailbox.waitUntilSeen(childId, {
|
|
254
|
+
timeoutMs: START_WINDOW_MS,
|
|
255
|
+
...(signal === undefined ? {} : { signal }),
|
|
256
|
+
}),
|
|
257
|
+
signal: runtime.signal,
|
|
258
|
+
diagnose,
|
|
259
|
+
});
|
|
260
|
+
if (launch.handle !== undefined) {
|
|
261
|
+
manager.holdAttachment(record.subagentId, launch.handle);
|
|
262
|
+
}
|
|
263
|
+
if (launch.handle === undefined || launch.failure !== undefined) {
|
|
264
|
+
return { failure: launch.failure ?? "panel did not open" };
|
|
265
|
+
}
|
|
266
|
+
return { id: launch.handle.identity.attachmentId };
|
|
267
|
+
},
|
|
268
|
+
rebind: (attachment) => {
|
|
269
|
+
if (attachment.kind !== "panel")
|
|
270
|
+
return undefined;
|
|
271
|
+
return attachment.host === "herdr"
|
|
272
|
+
? rebindHerdrTab({ tabId: attachment.id, ownerId, ownsAttachment })
|
|
273
|
+
: rebindCmuxSurface({ surfaceId: attachment.id, ownerId, ownsAttachment });
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
}
|
|
277
|
+
: {}),
|
|
272
278
|
});
|
|
273
|
-
const unregisterWork = delivery.registerSource({
|
|
274
|
-
activeCount: () => manager.activeCount,
|
|
275
|
-
onChange: (listener) => manager.onChange(listener),
|
|
276
|
-
});
|
|
277
|
-
runtime.resources.add("subagent-background-work", unregisterWork);
|
|
278
279
|
// Recovery must never take the extension down with it: a registry this version cannot
|
|
279
280
|
// read (for example records written by an older release) is reported in the session and
|
|
280
281
|
// the user is told how to reset it.
|
|
281
|
-
const recovery = await manager.
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
},
|
|
288
|
-
],
|
|
289
|
-
}));
|
|
290
|
-
if (recovery.failures.length > 0) {
|
|
291
|
-
context.ui.notify(`Subagent recovery failed:\n${recovery.failures
|
|
292
|
-
.map((failure) => `- ${failure.childId}: ${failure.reason}`)
|
|
293
|
-
.join("\n")}`, "error");
|
|
282
|
+
const recovery = await manager.reconcile().catch((error) => {
|
|
283
|
+
context.ui.notify(`Subagent recovery failed: ${errorMessage(error)}. Delete the stale records (or the whole registry file) to reset this parent session's subagent state.`, "error");
|
|
284
|
+
return undefined;
|
|
285
|
+
});
|
|
286
|
+
if (recovery?.ok === false) {
|
|
287
|
+
context.ui.notify("Subagent panels could not be listed, so none were reopened. No second runtime was started.", "warning");
|
|
294
288
|
}
|
|
289
|
+
pi.on("before_agent_start", async () => {
|
|
290
|
+
await manager.ready();
|
|
291
|
+
});
|
|
295
292
|
registerParentTools(pi, manager, await discoverAgents(context.cwd));
|
|
296
293
|
if (typeof pi.registerMarkdownTransformer === "function") {
|
|
297
294
|
pi.registerMarkdownTransformer(transformSubagentsMarkdown);
|
|
@@ -303,16 +300,6 @@ export default function piSubagentsExtension(pi) {
|
|
|
303
300
|
});
|
|
304
301
|
runtime.resources.add("interactive-tool-activation", () => activation.dispose());
|
|
305
302
|
registerParentCommands(pi, manager);
|
|
306
|
-
void waitForService(pi, TASK_REGISTRY_SERVICE_KEY, { signal: runtime.signal })
|
|
307
|
-
.then((taskRegistry) => {
|
|
308
|
-
if (runtime.signal.aborted)
|
|
309
|
-
return;
|
|
310
|
-
const unbind = manager.bindTaskRegistry(taskRegistry);
|
|
311
|
-
runtime.resources.add("subagent-task-registry", unbind);
|
|
312
|
-
})
|
|
313
|
-
.catch((error) => {
|
|
314
|
-
diagnose(`Task registry service unavailable for subagents: ${errorMessage(error)}`);
|
|
315
|
-
});
|
|
316
303
|
const status = createSubagentStatus(context.ui, runtime.signal);
|
|
317
304
|
let refreshVersion = 0;
|
|
318
305
|
const refreshStatus = () => {
|
package/dist/host-adapter.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { LaunchSpec } from "./launch-spec.js";
|
|
2
|
+
import type { HostList } from "./lease.js";
|
|
2
3
|
export type HostKind = "herdr" | "cmux";
|
|
3
4
|
export interface HostCommandResult {
|
|
4
5
|
readonly stdout: string;
|
|
@@ -93,6 +94,24 @@ export declare function resolveHerdrProcessWorkspaceId(runner: HostCommandRunner
|
|
|
93
94
|
* on an unfocused answer.
|
|
94
95
|
*/
|
|
95
96
|
export declare function herdrPanelFocused(tabList: string, workspaceList: string, tabId: string): boolean | undefined;
|
|
97
|
+
export declare function listHerdrAttachments(runner: HostCommandRunner, timeoutMs: number): Promise<HostList>;
|
|
98
|
+
export declare function herdrTabIds(output: string): ReadonlySet<string> | undefined;
|
|
99
|
+
export declare function listCmuxAttachments(runner: HostCommandRunner, timeoutMs: number): Promise<HostList>;
|
|
100
|
+
export declare function cmuxSurfaceIds(output: string): ReadonlySet<string> | undefined;
|
|
101
|
+
export declare function rebindHerdrTab(options: {
|
|
102
|
+
readonly runner?: HostCommandRunner;
|
|
103
|
+
readonly tabId: string;
|
|
104
|
+
readonly ownerId: string;
|
|
105
|
+
readonly timeoutMs?: number;
|
|
106
|
+
readonly ownsAttachment: (identity: HostAttachmentIdentity) => boolean;
|
|
107
|
+
}): HostAttachment;
|
|
108
|
+
export declare function rebindCmuxSurface(options: {
|
|
109
|
+
readonly runner?: HostCommandRunner;
|
|
110
|
+
readonly surfaceId: string;
|
|
111
|
+
readonly ownerId: string;
|
|
112
|
+
readonly timeoutMs?: number;
|
|
113
|
+
readonly ownsAttachment: (identity: HostAttachmentIdentity) => boolean;
|
|
114
|
+
}): HostAttachment;
|
|
96
115
|
/**
|
|
97
116
|
* Reads the pane's process state. A pane that no longer exists is a definite "not alive" — that is
|
|
98
117
|
* how a closed panel looks — while a query that never ran or failed for any other reason is
|