@hheei/pi-subagents 0.2.4 → 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.
Files changed (61) hide show
  1. package/README.md +35 -20
  2. package/dist/agent-resolver.d.ts +9 -0
  3. package/dist/agent-resolver.js +278 -51
  4. package/dist/builtin-agents.js +50 -37
  5. package/dist/child-bridge.d.ts +0 -8
  6. package/dist/child-bridge.js +60 -43
  7. package/dist/child-control.d.ts +6 -4
  8. package/dist/child-control.js +251 -47
  9. package/dist/child-process.d.ts +2 -0
  10. package/dist/child-process.js +24 -12
  11. package/dist/commands.d.ts +1 -4
  12. package/dist/commands.js +1 -52
  13. package/dist/domain.d.ts +27 -12
  14. package/dist/domain.js +2 -1
  15. package/dist/extension.d.ts +7 -6
  16. package/dist/extension.js +96 -112
  17. package/dist/host-adapter.d.ts +19 -0
  18. package/dist/host-adapter.js +140 -1
  19. package/dist/info.d.ts +9 -0
  20. package/dist/info.js +80 -0
  21. package/dist/launch-spec.d.ts +0 -1
  22. package/dist/launch-spec.js +38 -18
  23. package/dist/lease.d.ts +83 -0
  24. package/dist/lease.js +239 -0
  25. package/dist/mailbox.d.ts +46 -0
  26. package/dist/mailbox.js +368 -0
  27. package/dist/manager.d.ts +20 -20
  28. package/dist/manager.js +421 -398
  29. package/dist/process-lock.d.ts +11 -0
  30. package/dist/process-lock.js +55 -0
  31. package/dist/protocol.d.ts +2 -2
  32. package/dist/protocol.js +1 -0
  33. package/dist/registry.d.ts +1 -1
  34. package/dist/registry.js +99 -82
  35. package/dist/rpc-events.d.ts +1 -1
  36. package/dist/rpc-events.js +2 -1
  37. package/dist/runtime.d.ts +8 -21
  38. package/dist/runtime.js +61 -83
  39. package/dist/session-bootstrap.d.ts +2 -1
  40. package/dist/session-bootstrap.js +22 -14
  41. package/dist/state-db.d.ts +52 -0
  42. package/dist/state-db.js +209 -0
  43. package/dist/state.d.ts +25 -0
  44. package/dist/state.js +166 -37
  45. package/dist/status.d.ts +14 -0
  46. package/dist/status.js +36 -0
  47. package/dist/tool-activation.d.ts +3 -4
  48. package/dist/tool-activation.js +13 -215
  49. package/dist/tool-presentation.d.ts +14 -0
  50. package/dist/tool-presentation.js +193 -0
  51. package/dist/tools.d.ts +6 -1
  52. package/dist/tools.js +134 -180
  53. package/package.json +6 -6
  54. package/dist/bridge-client.d.ts +0 -55
  55. package/dist/bridge-client.js +0 -453
  56. package/dist/bridge-server.d.ts +0 -72
  57. package/dist/bridge-server.js +0 -490
  58. package/dist/session-fork.d.ts +0 -16
  59. package/dist/session-fork.js +0 -67
  60. package/dist/widget.d.ts +0 -12
  61. package/dist/widget.js +0 -149
@@ -1,6 +1,6 @@
1
- import type { ExtensionAPI, Skill } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionUIContext, Skill } from "@earendil-works/pi-coding-agent";
2
2
  import { type BackgroundDelivery } from "@hheei/pi-ext-core";
3
- import type { SkillCatalogEntry } from "./agent-resolver.js";
3
+ 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 {
@@ -10,12 +10,13 @@ export interface ParentChannelHandle extends ParentChannel {
10
10
  /**
11
11
  * How child reports reach the parent conversation.
12
12
  *
13
- * Busy parents receive a steer into the next model step. Idle parents wait until all registered
14
- * background work finishes; decision requests and blockers bypass that wait. The shared gate
15
- * 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.
16
16
  */
17
- export declare function createParentChannel(pi: Pick<ExtensionAPI, "sendMessage" | "on">, deps: {
17
+ export declare function createParentChannel(pi: Pick<ExtensionAPI, "sendMessage" | "on" | "appendEntry">, deps: {
18
18
  readonly isIdle: () => boolean;
19
+ readonly ui: Pick<ExtensionUIContext, "notify">;
19
20
  readonly delivery: BackgroundDelivery;
20
21
  readonly signal?: AbortSignal;
21
22
  }): ParentChannelHandle;
package/dist/extension.js CHANGED
@@ -1,18 +1,19 @@
1
- import { errorMessage, getBackgroundDelivery, registerExtensionLifecycle, TASK_REGISTRY_SERVICE_KEY, waitForService, } from "@hheei/pi-ext-core";
2
- import { ChildBridgeServer } from "./bridge-server.js";
1
+ import { errorMessage, getBackgroundDelivery, registerExtensionLifecycle, } from "@hheei/pi-ext-core";
2
+ import { discoverAgents } from "./agent-resolver.js";
3
3
  import { registerChildBridge } from "./child-bridge.js";
4
- import { bindParentStatus, registerParentCommands } from "./commands.js";
4
+ import { registerParentCommands } from "./commands.js";
5
5
  import { BRIDGE_ENVIRONMENT_KEYS, isThinkingLevel } from "./domain.js";
6
- import { createCmuxHostAdapter, createHerdrHostAdapter, selectHostAdapter, } from "./host-adapter.js";
6
+ import { createCmuxHostAdapter, createHerdrHostAdapter, listCmuxAttachments, listHerdrAttachments, rebindCmuxSurface, rebindHerdrTab, selectHostAdapter, systemHostCommandRunner, } from "./host-adapter.js";
7
+ import { appendSubagentInfo, displaySubagentText, registerSubagentInfo } from "./info.js";
8
+ import { createMailboxTransport } from "./mailbox.js";
7
9
  import { SubagentManager } from "./manager.js";
8
10
  import { transformSubagentsMarkdown } from "./markdown.js";
9
11
  import { createSubagentRegistry } from "./registry.js";
10
- import { createRuntimeTokenStore, launchChild, openChildPanel, parentBridgeEndpoint, prepareRuntimeDirectory, } from "./runtime.js";
12
+ import { createRuntimeTokenStore, launchChild, openChildPanel, prepareRuntimeDirectory, } from "./runtime.js";
11
13
  import { persistSubagentIntent, resolveSubagentLaunch } from "./session-bootstrap.js";
12
- import { resolveForkSessionReference } from "./session-fork.js";
14
+ import { createSubagentStatus } from "./status.js";
13
15
  import { registerInteractiveToolActivation } from "./tool-activation.js";
14
16
  import { registerParentTools } from "./tools.js";
15
- import { createSubagentWidget } from "./widget.js";
16
17
  const CHILD_ENV_KEYS = Object.values(BRIDGE_ENVIRONMENT_KEYS);
17
18
  /** How long startup waits for a child that survived a parent restart to reconnect its bridge. */
18
19
  const ADOPT_WINDOW_MS = 5_000;
@@ -44,26 +45,23 @@ function childIdentityFromEnv(env) {
44
45
  /**
45
46
  * How child reports reach the parent conversation.
46
47
  *
47
- * Busy parents receive a steer into the next model step. Idle parents wait until all registered
48
- * background work finishes; decision requests and blockers bypass that wait. The shared gate
49
- * 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.
50
51
  */
51
52
  export function createParentChannel(pi, deps) {
52
53
  const held = [];
53
54
  let disposed = false;
54
- const flush = (triggerTurn, urgentOnly = false) => {
55
+ const flush = (triggerTurn) => {
55
56
  if (held.length === 0)
56
57
  return;
57
- const reports = urgentOnly ? held.filter((report) => report.reason === "blocked") : [...held];
58
- if (reports.length === 0)
59
- return;
60
- for (const report of reports)
61
- held.splice(held.indexOf(report), 1);
58
+ const reports = [...held];
59
+ held.length = 0;
62
60
  try {
63
61
  pi.sendMessage({
64
62
  customType: "subagent-report",
65
63
  content: reports.map(reportText).join("\n\n"),
66
- display: true,
64
+ display: false,
67
65
  details: { reports },
68
66
  }, { triggerTurn, deliverAs: "steer" });
69
67
  }
@@ -71,6 +69,15 @@ export function createParentChannel(pi, deps) {
71
69
  held.unshift(...reports);
72
70
  throw error;
73
71
  }
72
+ for (const report of reports) {
73
+ try {
74
+ appendSubagentInfo(pi, report);
75
+ }
76
+ catch (error) {
77
+ // The model already received this batch; an entry failure must not resend it.
78
+ deps.ui.notify(displaySubagentText(`Could not display subagent report for ${report.childId}: ${errorMessage(error)}`), "error");
79
+ }
80
+ }
74
81
  };
75
82
  const registration = deps.delivery.registerChannel({
76
83
  isIdle: deps.isIdle,
@@ -84,7 +91,7 @@ export function createParentChannel(pi, deps) {
84
91
  if (disposed)
85
92
  throw new Error("Parent report channel is disposed");
86
93
  held.push(report);
87
- registration.request(report.reason === "blocked");
94
+ registration.request();
88
95
  },
89
96
  dispose() {
90
97
  disposed = true;
@@ -113,6 +120,7 @@ export default function piSubagentsExtension(pi) {
113
120
  registerChildBridge(pi, child);
114
121
  return;
115
122
  }
123
+ registerSubagentInfo(pi);
116
124
  registerExtensionLifecycle(pi, {
117
125
  key: "@hheei/pi-subagents",
118
126
  async start(runtime) {
@@ -134,47 +142,27 @@ export default function piSubagentsExtension(pi) {
134
142
  const delivery = getBackgroundDelivery(pi);
135
143
  const channel = createParentChannel(pi, {
136
144
  isIdle: () => context.isIdle(),
145
+ ui: context.ui,
137
146
  delivery,
138
147
  signal: runtime.signal,
139
148
  });
140
149
  runtime.resources.add("parent-channel", () => channel.dispose());
141
150
  let manager;
142
- // The parent listens; every child dials this socket, so the bridge connection is both the
143
- // control plane and the only honest answer to "is this child still running?".
144
- const server = new ChildBridgeServer({
145
- 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,
146
154
  parentSessionId,
147
- authorize: async (frame) => {
148
- const expected = tokens.get(frame.runtimeIdentity);
149
- if (expected === undefined) {
150
- return `no bridge token is recorded for runtime ${frame.runtimeIdentity}`;
151
- }
152
- if (expected !== frame.token)
153
- return "bridge token mismatch";
154
- // The token says which runtime is calling; the registry says which child that runtime
155
- // belongs to. Without this check a child could name another child's id and take over
156
- // its control channel, so a hello that does not match its record is refused.
157
- const record = await registry.get(frame.subagentId).catch(() => undefined);
158
- if (record === undefined)
159
- return `no recorded child ${frame.subagentId}`;
160
- if (record.runtime?.runtimeIdentity !== frame.runtimeIdentity) {
161
- return `runtime ${frame.runtimeIdentity} is not the recorded runtime of child ${frame.subagentId}`;
162
- }
163
- return undefined;
164
- },
165
155
  handleRequest: (request) => manager.handleChildRequest(request.childId, request.operation, request.payload),
166
156
  diagnose,
167
157
  });
168
158
  await prepareRuntimeDirectory(diagnose);
169
- await server.listen();
170
- // Closing is awaited: the endpoint is unlinked after the server is closed, so a reload that
171
- // started the next server first could have this close unlink the new socket path.
172
- runtime.resources.add("subagent-bridge", () => server.close());
159
+ runtime.resources.add("subagent-bridge", () => mailbox.close());
173
160
  // A definition names skills the way Pi lists them, so the parent's loaded skill set is
174
161
  // the only name → path catalog; Pi hands it over before every prompt of this session.
175
162
  let skillCatalog = [];
176
163
  const unsubscribeSkillCatalog = pi.on("before_agent_start", (event) => {
177
- skillCatalog = skillCatalogFromLoaded(event.systemPromptOptions.skills);
164
+ const skills = event.systemPromptOptions?.skills ?? [];
165
+ skillCatalog = skillCatalogFromLoaded(skills);
178
166
  });
179
167
  runtime.resources.add("subagent-skill-catalog", unsubscribeSkillCatalog);
180
168
  manager = new SubagentManager({
@@ -192,27 +180,6 @@ export default function piSubagentsExtension(pi) {
192
180
  }
193
181
  const records = await registry.list().catch(() => []);
194
182
  const existingSubagentIds = records.map((record) => record.subagentId);
195
- let referencedSessionPath;
196
- const forkTarget = input.forkFrom?.trim();
197
- if (forkTarget !== undefined && forkTarget !== "") {
198
- try {
199
- if (forkTarget === "current") {
200
- throw new Error("forkFrom cannot be 'current'. Use 'parent' to reference the parent session or specify a subagent id like 'agent-1'.");
201
- }
202
- const parentSessionFile = context.sessionManager.getSessionFile();
203
- referencedSessionPath = await resolveForkSessionReference({
204
- forkFrom: forkTarget,
205
- ...(parentSessionFile === undefined
206
- ? {}
207
- : { parentSessionPath: parentSessionFile }),
208
- registry,
209
- onWarning: (message) => context.ui.notify(message, "warning"),
210
- });
211
- }
212
- catch (error) {
213
- context.ui.notify(`Referencing session failed: ${errorMessage(error)}; starting fresh session without reference`, "warning");
214
- }
215
- }
216
183
  return resolveSubagentLaunch({
217
184
  input,
218
185
  cwd: context.cwd,
@@ -220,8 +187,6 @@ export default function piSubagentsExtension(pi) {
220
187
  skillCatalog,
221
188
  existingSubagentIds,
222
189
  enforceEnabled: true,
223
- onWarning: (message) => context.ui.notify(message, "warning"),
224
- ...(referencedSessionPath === undefined ? {} : { referencedSessionPath }),
225
190
  parent: {
226
191
  model: { provider: model.provider, id: model.id },
227
192
  thinking,
@@ -231,13 +196,13 @@ export default function piSubagentsExtension(pi) {
231
196
  bootstrap(input) {
232
197
  return persistSubagentIntent({ registry, ...input });
233
198
  },
234
- bridge: server,
199
+ bridge: mailbox,
235
200
  launch(record) {
236
201
  return launchChild({
237
202
  registry,
238
203
  record,
239
204
  tokens,
240
- waitForBridge: (childId, signal) => server.waitForConnection(childId, {
205
+ waitForBridge: (childId, signal) => mailbox.waitUntilSeen(childId, {
241
206
  timeoutMs: START_WINDOW_MS,
242
207
  ...(signal === undefined ? {} : { signal }),
243
208
  }),
@@ -254,7 +219,7 @@ export default function piSubagentsExtension(pi) {
254
219
  record,
255
220
  tokens,
256
221
  host: hostSelection.adapter,
257
- waitForBridge: (childId, signal) => server.waitForConnection(childId, {
222
+ waitForBridge: (childId, signal) => mailbox.waitUntilSeen(childId, {
258
223
  timeoutMs: START_WINDOW_MS,
259
224
  ...(signal === undefined ? {} : { signal }),
260
225
  }),
@@ -267,35 +232,64 @@ export default function piSubagentsExtension(pi) {
267
232
  // Adoption is a wait, never a spawn: a child that survived this parent reconnects on
268
233
  // its own, and one that did not stays recorded until a send starts it again.
269
234
  connect(record) {
270
- return server.waitForConnection(record.subagentId, {
235
+ return mailbox.waitUntilSeen(record.subagentId, {
271
236
  timeoutMs: ADOPT_WINDOW_MS,
272
237
  signal: runtime.signal,
273
238
  });
274
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
+ : {}),
275
278
  });
276
- const unregisterWork = delivery.registerSource({
277
- activeCount: () => manager.activeCount,
278
- onChange: (listener) => manager.onChange(listener),
279
- });
280
- runtime.resources.add("subagent-background-work", unregisterWork);
281
279
  // Recovery must never take the extension down with it: a registry this version cannot
282
280
  // read (for example records written by an older release) is reported in the session and
283
281
  // the user is told how to reset it.
284
- const recovery = await manager.recover().catch((error) => ({
285
- recovered: [],
286
- failures: [
287
- {
288
- childId: "registry",
289
- reason: `${errorMessage(error)}. Delete the stale records (or the whole registry file) to reset this parent session's subagent state.`,
290
- },
291
- ],
292
- }));
293
- if (recovery.failures.length > 0) {
294
- context.ui.notify(`Subagent recovery failed:\n${recovery.failures
295
- .map((failure) => `- ${failure.childId}: ${failure.reason}`)
296
- .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");
297
288
  }
298
- registerParentTools(pi, manager);
289
+ pi.on("before_agent_start", async () => {
290
+ await manager.ready();
291
+ });
292
+ registerParentTools(pi, manager, await discoverAgents(context.cwd));
299
293
  if (typeof pi.registerMarkdownTransformer === "function") {
300
294
  pi.registerMarkdownTransformer(transformSubagentsMarkdown);
301
295
  }
@@ -306,36 +300,26 @@ export default function piSubagentsExtension(pi) {
306
300
  });
307
301
  runtime.resources.add("interactive-tool-activation", () => activation.dispose());
308
302
  registerParentCommands(pi, manager);
309
- void waitForService(pi, TASK_REGISTRY_SERVICE_KEY, { signal: runtime.signal })
310
- .then((taskRegistry) => {
311
- if (runtime.signal.aborted)
312
- return;
313
- const unbind = manager.bindTaskRegistry(taskRegistry);
314
- runtime.resources.add("subagent-task-registry", unbind);
315
- })
316
- .catch((error) => {
317
- diagnose(`Task registry service unavailable for subagents: ${errorMessage(error)}`);
318
- });
319
- const widget = createSubagentWidget(pi, context, runtime.signal);
320
- const refreshWidget = () => {
303
+ const status = createSubagentStatus(context.ui, runtime.signal);
304
+ let refreshVersion = 0;
305
+ const refreshStatus = () => {
321
306
  if (runtime.signal.aborted)
322
307
  return;
308
+ const version = ++refreshVersion;
323
309
  void manager
324
310
  .list()
325
311
  .then((children) => {
326
- if (runtime.signal.aborted)
312
+ if (runtime.signal.aborted || version !== refreshVersion)
327
313
  return;
328
- widget?.refresh(children);
314
+ status.refresh(children);
329
315
  })
330
- .catch(() => { });
316
+ .catch((error) => diagnose(`Subagent status failed: ${errorMessage(error)}`));
331
317
  };
332
- const unsubscribe = manager.onChange(refreshWidget);
333
- const unbindStatus = bindParentStatus(pi, context, manager, runtime.signal);
334
- refreshWidget();
335
- runtime.resources.add("subagent-widget", () => {
318
+ const unsubscribe = manager.onChange(refreshStatus);
319
+ refreshStatus();
320
+ runtime.resources.add("subagent-status", () => {
336
321
  unsubscribe();
337
- unbindStatus();
338
- widget?.dispose();
322
+ status.dispose();
339
323
  });
340
324
  runtime.resources.add("subagent-manager", () => manager.closeLocalConnections());
341
325
  },
@@ -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
@@ -1,3 +1,4 @@
1
+ import { statSync } from "node:fs";
1
2
  import { errorMessage, runCommand, shellQuote } from "@hheei/pi-ext-core";
2
3
  const DEFAULT_HOST_ORDER = ["herdr", "cmux"];
3
4
  /** Selects a presentation host visibly; an explicit unavailable host never falls back. */
@@ -180,7 +181,7 @@ export function createCmuxHostAdapter(options) {
180
181
  noun: "surface",
181
182
  // TODO(cmux-focus): cmux does not report which surface a human is looking at, so
182
183
  // observation cannot answer `focused` and idle reclaim fails open here: a cmux child
183
- // is only ever closed by stop_agent.
184
+ // is only ever closed by agent_stop.
184
185
  reportsFocus: false,
185
186
  observe: async () => {
186
187
  const process = await runner.run("cmux", ["--json", "list-panels"], { timeoutMs });
@@ -302,6 +303,144 @@ export function herdrPanelFocused(tabList, workspaceList, tabId) {
302
303
  const workspaceFocused = workspace?.focused;
303
304
  return typeof workspaceFocused === "boolean" ? workspaceFocused : undefined;
304
305
  }
306
+ export async function listHerdrAttachments(runner, timeoutMs) {
307
+ const status = await runner.run("herdr", ["status", "server"], { timeoutMs });
308
+ if (status.timedOut || status.exitCode !== 0)
309
+ return { known: false };
310
+ const socket = statusField(status.stdout, "socket");
311
+ if (socket === undefined)
312
+ return { known: false };
313
+ let generation;
314
+ try {
315
+ const stat = statSync(socket);
316
+ generation = `${stat.dev}:${stat.ino}`;
317
+ }
318
+ catch {
319
+ return { known: false };
320
+ }
321
+ const tabs = await runner.run("herdr", ["tab", "list"], { timeoutMs });
322
+ if (tabs.timedOut || tabs.exitCode !== 0)
323
+ return { known: false };
324
+ const present = herdrTabIds(tabs.stdout);
325
+ if (present === undefined)
326
+ return { known: false };
327
+ return { known: true, generation, present };
328
+ }
329
+ export function herdrTabIds(output) {
330
+ const entries = herdrCollection(output, "tabs");
331
+ if (entries === undefined)
332
+ return undefined;
333
+ const ids = new Set();
334
+ for (const entry of entries) {
335
+ if (typeof entry !== "object" || entry === null)
336
+ continue;
337
+ const id = entry.tab_id;
338
+ if (typeof id === "string" && id !== "")
339
+ ids.add(id);
340
+ }
341
+ return ids;
342
+ }
343
+ export async function listCmuxAttachments(runner, timeoutMs) {
344
+ const panels = await runner.run("cmux", ["--json", "list-panels"], { timeoutMs });
345
+ if (panels.timedOut || panels.exitCode !== 0)
346
+ return { known: false };
347
+ const present = cmuxSurfaceIds(panels.stdout);
348
+ if (present === undefined)
349
+ return { known: false };
350
+ return { known: true, generation: "cmux", present };
351
+ }
352
+ export function cmuxSurfaceIds(output) {
353
+ let parsed;
354
+ try {
355
+ parsed = JSON.parse(output);
356
+ }
357
+ catch {
358
+ return undefined;
359
+ }
360
+ const panels = findNamedObject(parsed, "result")?.panels ?? findNamedObject(parsed, "panels");
361
+ const list = Array.isArray(panels) ? panels : Array.isArray(parsed) ? parsed : undefined;
362
+ if (list === undefined)
363
+ return undefined;
364
+ const ids = new Set();
365
+ for (const entry of list) {
366
+ if (typeof entry === "string" && entry !== "")
367
+ ids.add(entry);
368
+ else if (typeof entry === "object" && entry !== null) {
369
+ const id = entry.surface_id;
370
+ if (typeof id === "string" && id !== "")
371
+ ids.add(id);
372
+ }
373
+ }
374
+ return ids;
375
+ }
376
+ export function rebindHerdrTab(options) {
377
+ const runner = options.runner ?? systemHostCommandRunner;
378
+ const timeoutMs = options.timeoutMs ?? 10_000;
379
+ return hostAttachment({
380
+ identity: { host: "herdr", attachmentId: options.tabId, createdBy: options.ownerId },
381
+ launch: { stdout: "", stderr: "", exitCode: 0, timedOut: false },
382
+ noun: "tab",
383
+ reportsFocus: true,
384
+ observe: async () => {
385
+ const tabs = await runner.run("herdr", ["tab", "list"], { timeoutMs });
386
+ if (tabs.timedOut || tabs.exitCode !== 0)
387
+ return { process: tabs };
388
+ const ids = herdrTabIds(tabs.stdout);
389
+ if (ids === undefined)
390
+ return { process: tabs };
391
+ return { process: tabs, alive: ids.has(options.tabId) };
392
+ },
393
+ close: () => runner.run("herdr", ["tab", "close", options.tabId], { timeoutMs }),
394
+ ownsAttachment: options.ownsAttachment,
395
+ });
396
+ }
397
+ export function rebindCmuxSurface(options) {
398
+ const runner = options.runner ?? systemHostCommandRunner;
399
+ const timeoutMs = options.timeoutMs ?? 10_000;
400
+ return hostAttachment({
401
+ identity: { host: "cmux", attachmentId: options.surfaceId, createdBy: options.ownerId },
402
+ launch: { stdout: "", stderr: "", exitCode: 0, timedOut: false },
403
+ noun: "surface",
404
+ reportsFocus: false,
405
+ observe: async () => {
406
+ const process = await runner.run("cmux", ["--json", "list-panels"], { timeoutMs });
407
+ if (process.timedOut || process.exitCode !== 0)
408
+ return { process };
409
+ const ids = cmuxSurfaceIds(process.stdout);
410
+ if (ids === undefined)
411
+ return { process };
412
+ return { process, alive: ids.has(options.surfaceId) };
413
+ },
414
+ close: () => runner.run("cmux", ["--json", "close-surface", "--surface", options.surfaceId], {
415
+ timeoutMs,
416
+ }),
417
+ ownsAttachment: options.ownsAttachment,
418
+ });
419
+ }
420
+ function herdrCollection(output, collection) {
421
+ let parsed;
422
+ try {
423
+ parsed = JSON.parse(output);
424
+ }
425
+ catch {
426
+ return undefined;
427
+ }
428
+ const list = findNamedObject(parsed, "result")?.[collection];
429
+ return Array.isArray(list) ? list : undefined;
430
+ }
431
+ function statusField(output, name) {
432
+ for (const line of output.split("\n")) {
433
+ const separator = line.indexOf(":");
434
+ if (separator < 0)
435
+ continue;
436
+ if (line.slice(0, separator).trim() !== name)
437
+ continue;
438
+ const value = line.slice(separator + 1).trim();
439
+ if (value !== "")
440
+ return value;
441
+ }
442
+ return undefined;
443
+ }
305
444
  function herdrListEntry(output, collection, key, id) {
306
445
  let parsed;
307
446
  try {
package/dist/info.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { ParentChannelReport } from "./manager.js";
3
+ export declare const SUBAGENT_INFO_TYPE = "subagent-info";
4
+ /** Keep line breaks, expand tabs, and remove terminal control characters from child text. */
5
+ export declare function displaySubagentText(text: string): string;
6
+ export declare function renderSubagentInfo(data: unknown, expanded: boolean, theme: Pick<Theme, "fg">): string | undefined;
7
+ /** Native transcript entries survive resume and never enter the model context. */
8
+ export declare function registerSubagentInfo(pi: Pick<ExtensionAPI, "registerEntryRenderer">): void;
9
+ export declare function appendSubagentInfo(pi: Pick<ExtensionAPI, "appendEntry">, report: ParentChannelReport): void;
package/dist/info.js ADDED
@@ -0,0 +1,80 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { readMessageView } from "@hheei/pi-ext-core";
4
+ import { Type } from "typebox";
5
+ import { Value } from "typebox/value";
6
+ import { VISUAL_SUBAGENT_GLYPH, VISUAL_SUBAGENT_TONE } from "./domain.js";
7
+ import { formatSubagentId } from "./status.js";
8
+ export const SUBAGENT_INFO_TYPE = "subagent-info";
9
+ const infoSchema = Type.Object({
10
+ id: Type.String(),
11
+ agent: Type.String(),
12
+ status: Type.Union([
13
+ Type.Literal("running"),
14
+ Type.Literal("done"),
15
+ Type.Literal("blocked"),
16
+ Type.Literal("error"),
17
+ ]),
18
+ message: Type.String(),
19
+ });
20
+ /** Keep line breaks, expand tabs, and remove terminal control characters from child text. */
21
+ export function displaySubagentText(text) {
22
+ return stripVTControlCharacters(text)
23
+ .replaceAll("\t", " ")
24
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu, "");
25
+ }
26
+ function shownSubagent(data) {
27
+ if (!Value.Check(infoSchema, data))
28
+ return undefined;
29
+ return {
30
+ id: formatSubagentId(displaySubagentText(data.id).replace(/\s+/gu, " ")),
31
+ agent: displaySubagentText(data.agent).replace(/\s+/gu, " "),
32
+ status: data.status,
33
+ message: displaySubagentText(data.message),
34
+ };
35
+ }
36
+ export function renderSubagentInfo(data, expanded, theme) {
37
+ const shown = shownSubagent(data);
38
+ if (shown === undefined)
39
+ return undefined;
40
+ const heading = theme.fg("dim", "info · agents · ") +
41
+ theme.fg(VISUAL_SUBAGENT_TONE[shown.status], VISUAL_SUBAGENT_GLYPH[shown.status]) +
42
+ theme.fg("dim", ` ${shown.id} ${shown.agent} · ${shown.status}`);
43
+ return expanded ? `${heading}\n${shown.message}` : heading;
44
+ }
45
+ /** Native transcript entries survive resume and never enter the model context. */
46
+ export function registerSubagentInfo(pi) {
47
+ pi.registerEntryRenderer(SUBAGENT_INFO_TYPE, (entry, options, theme) => {
48
+ const create = readMessageView(pi);
49
+ const shown = create === undefined ? undefined : shownSubagent(entry.data);
50
+ if (create !== undefined && shown !== undefined) {
51
+ const message = shown.message.trim();
52
+ return create({
53
+ theme,
54
+ header: {
55
+ kind: "agents",
56
+ summary: `${VISUAL_SUBAGENT_GLYPH[shown.status]} ${shown.id} ${shown.agent} · ${shown.status}`,
57
+ tone: VISUAL_SUBAGENT_TONE[shown.status],
58
+ },
59
+ ...(message === "" ? {} : { body: shown.message }),
60
+ stateHolder: entry,
61
+ forcedExpanded: options.expanded,
62
+ });
63
+ }
64
+ const text = renderSubagentInfo(entry.data, options.expanded, theme);
65
+ return text === undefined ? undefined : new Text(text, 1, 0);
66
+ });
67
+ }
68
+ export function appendSubagentInfo(pi, report) {
69
+ const status = report.reason === "success"
70
+ ? "done"
71
+ : report.reason === "blocked" || report.reason === "error"
72
+ ? report.reason
73
+ : report.status;
74
+ pi.appendEntry(SUBAGENT_INFO_TYPE, {
75
+ id: report.childId,
76
+ agent: report.agent,
77
+ status,
78
+ message: report.message,
79
+ });
80
+ }
@@ -16,7 +16,6 @@ export interface LaunchSpec {
16
16
  readonly env: Readonly<Record<string, string>>;
17
17
  readonly config: EffectiveLaunchConfig;
18
18
  }
19
- export declare const HINDSIGHT_TOOLS: readonly ["hindsight_search_knowledge_pages", "hindsight_list_knowledge_pages", "hindsight_read_knowledge_page", "hindsight_reflect", "hindsight_capture_initiative", "hindsight_ingest_document", "hindsight_sync_status", "hindsight_diagnose"];
20
19
  export declare function stripHindsightContent(text: string): string;
21
20
  export interface BuildLaunchSpecOptions {
22
21
  readonly config: EffectiveLaunchConfig;