@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 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
- Subagent execution tasks register with ext-core's `TaskRegistry` when pi-ext-tools provides it, alongside background Bash jobs. 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`; activation supplies `subagent_guidance`. Both get tools return the same `status`, `progress`, and optional `diagnostic` envelope. Diagnostics describe blockers, failed turns, or uncertain runtime state.
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 over a dedicated Unix domain socket bridge:
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 Debounce**: When a subagent finishes a turn and remains idle for 5 seconds (to prevent false idles), the harness automatically captures the trailing assistant text and delivers it to the parent conversation dialog as a completed `subagent-report`.
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**: If a subagent encounters a transient error, the harness allows one automatic retry before marking the task failed or delivering a blocked notification.
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
 
@@ -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
@@ -1,8 +1,8 @@
1
1
  import { errorMessage, isRecord } from "@hheei/pi-ext-core";
2
- import { ChildBridgeClient } from "./bridge-client.js";
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
- const sm = requireContext().sessionManager;
209
- const branch = typeof sm.getBranch === "function" ? sm.getBranch() : undefined;
210
- const entries = Array.isArray(branch) && branch.length > 0 ? branch : sm.getEntries();
211
- return { entries };
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 = new ChildBridgeClient({
237
- endpoint: options.endpoint,
238
- identity: options.identity,
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.sendChildInput(source);
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.sendReport(operation, payload, signal),
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) {
@@ -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;
@@ -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
- if (child.pid === undefined) {
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
- // stdout is the child's Pi protocol stream and stderr its diagnostics; neither is a control
52
- // channel, so they are drained to keep the pipes from filling and reported only as diagnostics.
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;
@@ -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 wait until all registered
15
- * background work finishes; decision requests and blockers bypass that wait. The shared gate
16
- * coordinates this channel with Task/Bash results so only one message requests a new idle turn.
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, TASK_REGISTRY_SERVICE_KEY, waitForService, } from "@hheei/pi-ext-core";
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, parentBridgeEndpoint, prepareRuntimeDirectory, } from "./runtime.js";
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 wait until all registered
49
- * background work finishes; decision requests and blockers bypass that wait. The shared gate
50
- * coordinates this channel with Task/Bash results so only one message requests a new idle turn.
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, urgentOnly = false) => {
55
+ const flush = (triggerTurn) => {
56
56
  if (held.length === 0)
57
57
  return;
58
- const reports = urgentOnly
59
- ? held.filter((report) => report.reason === "blocked" || report.reason === "error")
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(report.reason === "blocked" || report.reason === "error");
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
- // The parent listens; every child dials this socket, so the bridge connection is both the
162
- // control plane and the only honest answer to "is this child still running?".
163
- const server = new ChildBridgeServer({
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
- await server.listen();
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: server,
199
+ bridge: mailbox,
232
200
  launch(record) {
233
201
  return launchChild({
234
202
  registry,
235
203
  record,
236
204
  tokens,
237
- waitForBridge: (childId, signal) => server.waitForConnection(childId, {
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) => server.waitForConnection(childId, {
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 server.waitForConnection(record.subagentId, {
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.recover().catch((error) => ({
282
- recovered: [],
283
- failures: [
284
- {
285
- childId: "registry",
286
- reason: `${errorMessage(error)}. Delete the stale records (or the whole registry file) to reset this parent session's subagent state.`,
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 = () => {
@@ -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