@dougbots/avenor-pi 0.15.0 → 0.16.1

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/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { Input, Key, Text, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
2
  import { Type } from "typebox";
3
- import { createHash, randomUUID } from "node:crypto";
4
- import { Supervisor, THINKING_LEVELS, answerPermissionTool, askTool, attachOwner, brokerReceiveTool, brokerReplyTool, cancelTool, dial, eventsTool, extractEventText, extractReplyMessage, followUpTool, formatAskFailure, inspectTool, killTool, normalizeAskTimeoutMs, observeRun, peersTool, resultTool, shutdownTool, socketsRoot, spawnTool, statusTool, validateSpawnSelection, workflowCompleteTool, workflowControllerStatusTool, workflowEventsTool, workflowGateTool, workflowInspectTool, workflowReadyTool, workflowStatusTool, workflowWaitTool } from "@dougbots/avenor-core";
3
+ import { SUPERVISOR_RUN_ID, Supervisor, THINKING_LEVELS, answerPermissionTool, attachOwner, boundFinalOutput, brokerReceiveTool, createChildMessageProvider, createHostMessageProvider, dial, eventsTool, extractEventText, extractReplyMessage, extractTurnText, getSupervisorClient, inspectTool, installAskClaims, isClosedSocketError, isGenerationMismatchError, killTool, messageTool, normalizeAskTimeoutMs, normalizeTurnStopReason, observeRun, peersDetails, peersTool, renderPeerList, resultOutput, resultTool, shutdownTool, socketsRoot, spawnTool, statusTool, validateSpawnSelection, workflowCancelTool, workflowCompleteTool, workflowControllerStatusTool, workflowEventsTool, workflowGateTool, workflowInspectTool, workflowReadyTool, workflowStatusTool, workflowWaitTool } from "@dougbots/avenor-core";
5
4
  import * as fs from "node:fs";
6
5
  import * as path from "node:path";
6
+ import { createHash } from "node:crypto";
7
7
  //#region src/subagent-broker.ts
8
8
  /** Deadline for a cleanup call whose caller's own deadline has already expired. */
9
9
  const ASK_CLEANUP_TIMEOUT_MS = 5e3;
@@ -36,25 +36,38 @@ function parseControlMessage(msg) {
36
36
  return {
37
37
  from_run_id: fromRunId,
38
38
  message_id: messageId,
39
- message: p.message
39
+ message: p.message,
40
+ role: typeof p.role === "string" ? p.role : void 0
40
41
  };
41
42
  }
42
- function formatInboundAskBody(ask) {
43
- return `**📨 Inbound ask**${ask.from_run_id ? ` from \`${ask.from_run_id}\`` : ""}\n\n${ask.message}\n\nTo answer, use avenor_reply({ reply_to_message_id: ${JSON.stringify(ask.message_id)}, message: "..." }).`;
43
+ /**
44
+ * Format an inbound ask as a steer body. Both modes share the formatter; only
45
+ * the heading and the optional role metadata differ.
46
+ */
47
+ function formatInboundAskBody(ask, options) {
48
+ const sender = ask.from_run_id;
49
+ const role = options.role ?? ask.role;
50
+ const from = sender ? role && role !== "agent" ? ` from \`${sender}\` (${role})` : ` from \`${sender}\`` : "";
51
+ return `**📨 ${options.heading}**${from}\n\n${ask.message}\n\nTo answer, use avenor_message({ action: "reply", reply_to: ${JSON.stringify(ask.message_id)}, message: "..." }).`;
44
52
  }
45
53
  /** A broker HTTP response error, so callers can classify by status instead of
46
54
  * matching digits in the request text. */
47
55
  var BrokerHttpError = class extends Error {
48
56
  status;
49
- constructor(path, status, body) {
57
+ code;
58
+ body;
59
+ constructor(path, status, body, code = "") {
50
60
  super(`broker ${path}: ${status} ${body}`);
51
61
  this.name = "BrokerHttpError";
52
62
  this.status = status;
63
+ this.code = code;
64
+ this.body = body;
53
65
  }
54
66
  };
55
67
  /** Build the `/send` body for one agent_message, as the broker child tools do. */
56
68
  function buildAgentMessage(fields) {
57
69
  return {
70
+ ...fields.generation !== void 0 ? { generation: fields.generation } : {},
58
71
  from_run_id: fields.fromRunId,
59
72
  to_run_id: fields.toRunId,
60
73
  type: "agent_message",
@@ -70,6 +83,15 @@ function buildAgentMessage(fields) {
70
83
  }
71
84
  };
72
85
  }
86
+ async function codedError(path, response) {
87
+ const body = (await response.text()).trim();
88
+ let code = "";
89
+ try {
90
+ const parsed = JSON.parse(body);
91
+ if (typeof parsed.code === "string") code = parsed.code;
92
+ } catch {}
93
+ return new BrokerHttpError(path, response.status, body, code);
94
+ }
73
95
  async function postBroker(env, path, body, fetchImpl = fetch, signal) {
74
96
  const res = await fetchImpl(`${env.brokerUrl}${path}`, {
75
97
  method: "POST",
@@ -81,27 +103,45 @@ async function postBroker(env, path, body, fetchImpl = fetch, signal) {
81
103
  }),
82
104
  ...signal ? { signal } : {}
83
105
  });
84
- if (!res.ok) throw new BrokerHttpError(path, res.status, (await res.text()).trim());
106
+ if (!res.ok) throw await codedError(path, res);
107
+ return res.json();
108
+ }
109
+ /** Authenticated GET; broker credentials travel in headers, never the URL. */
110
+ async function getBroker(env, path, fetchImpl = fetch, signal) {
111
+ const res = await fetchImpl(`${env.brokerUrl}${path}`, {
112
+ method: "GET",
113
+ headers: {
114
+ "X-Avenor-Run-Id": env.runId,
115
+ "X-Avenor-Run-Token": env.token
116
+ },
117
+ ...signal ? { signal } : {}
118
+ });
119
+ if (!res.ok) throw await codedError(path, res);
85
120
  return res.json();
86
121
  }
122
+ /** Read the ask generation `/sessions` advertises, or empty for none. */
123
+ async function readGeneration(env, fetchImpl) {
124
+ const body = await getBroker(env, "/sessions", fetchImpl);
125
+ return typeof body?.generation === "string" ? body.generation : "";
126
+ }
87
127
  /**
88
- * Resolve which pending ask to answer. Prefers an explicit message id (and its
89
- * asker); otherwise the most recent pending ask matching fromRunId.
128
+ * Pin the generation on first use and reuse it, so one child binds to one broker
129
+ * generation. The broker rotates it when its ask store resets; a pinned child is
130
+ * then refused instead of being retargeted. A broker that advertises no
131
+ * generation is not pinned, so the next call reads it again.
90
132
  */
91
- function resolveReplyTarget(pending, fromRunId, replyToMessageId) {
92
- if (replyToMessageId) {
93
- const hit = pending.get(replyToMessageId);
94
- if (!hit) return null;
95
- const from = fromRunId ?? hit.from_run_id;
96
- if (!from) return null;
97
- return {
98
- from_run_id: from,
99
- message_id: replyToMessageId
100
- };
101
- }
102
- let match;
103
- for (const [, entry] of pending) if (!fromRunId || entry.from_run_id === fromRunId) match = entry;
104
- return match ?? null;
133
+ function pinGeneration(env, fetchImpl) {
134
+ let pinned;
135
+ return () => {
136
+ pinned ??= readGeneration(env, fetchImpl).then((generation) => {
137
+ if (!generation) pinned = void 0;
138
+ return generation;
139
+ }, (error) => {
140
+ pinned = void 0;
141
+ throw error;
142
+ });
143
+ return pinned;
144
+ };
105
145
  }
106
146
  function combineSignals(...signals) {
107
147
  const active = signals.filter((signal) => signal !== void 0);
@@ -109,32 +149,47 @@ function combineSignals(...signals) {
109
149
  if (active.length === 1) return active[0];
110
150
  return AbortSignal.any(active);
111
151
  }
112
- function describeFailure(error) {
113
- if (error instanceof Error) {
114
- if (error.name === "TimeoutError") return "timed out";
115
- if (error.name === "AbortError") return "cancelled";
116
- return error.message || error.name;
117
- }
118
- return String(error);
119
- }
120
152
  /**
121
153
  * Withdraw a pending ask through the broker as this run. The broker's own error
122
154
  * propagates so a caller reporting to a model shows its exact text.
123
155
  */
124
- async function withdrawPendingAsk(env, messageId, fetchImpl = fetch) {
125
- await postBroker(env, "/cancel_message", { cancel_message_id: messageId }, fetchImpl, AbortSignal.timeout(ASK_CLEANUP_TIMEOUT_MS));
156
+ async function withdrawPendingAsk(env, messageId, generation, fetchImpl = fetch) {
157
+ const body = await postBroker(env, "/cancel_message", {
158
+ cancel_message_id: messageId,
159
+ generation
160
+ }, fetchImpl, AbortSignal.timeout(ASK_CLEANUP_TIMEOUT_MS));
161
+ if (typeof body?.outcome === "string" && body.outcome) return { outcome: body.outcome };
162
+ return { outcome: "withdrawn" };
126
163
  }
127
164
  /**
128
165
  * Withdraw a pending ask and classify whether the edge may still exist on the
129
166
  * broker: true means the cleanup could not be confirmed, false means it is gone.
130
167
  */
131
- async function withdrawAsk(env, messageId, fetchImpl) {
168
+ async function withdrawAsk(env, messageId, generation, fetchImpl) {
132
169
  try {
133
- await withdrawPendingAsk(env, messageId, fetchImpl);
134
- return false;
170
+ return {
171
+ pending: false,
172
+ outcome: (await withdrawPendingAsk(env, messageId, generation, fetchImpl)).outcome
173
+ };
135
174
  } catch (error) {
136
- if (error instanceof BrokerHttpError) return error.status !== 404;
137
- return true;
175
+ if (error instanceof BrokerHttpError) {
176
+ if (error.code === "fence-capacity") return {
177
+ pending: true,
178
+ outcome: "possibly-pending"
179
+ };
180
+ if (error.status === 404) return {
181
+ pending: false,
182
+ outcome: "edge-absent"
183
+ };
184
+ return {
185
+ pending: true,
186
+ outcome: "possibly-pending"
187
+ };
188
+ }
189
+ return {
190
+ pending: true,
191
+ outcome: "possibly-pending"
192
+ };
138
193
  }
139
194
  }
140
195
  /** Fetch causes for a dial that never opened, so no request reached the broker. */
@@ -187,12 +242,10 @@ async function brokerReachable(env, fetchImpl) {
187
242
  }
188
243
  }
189
244
  /**
190
- * Whether a failed send provably never reached the broker, mirroring the host's
191
- * rule: a dial that never opened or an unusable broker address carries no
192
- * request, and a send that drew no HTTP response while the broker no longer
193
- * accepts a dial cannot have left its edge in a broker that is gone. An HTTP
194
- * response, a reset, or an abort against a broker that answers at all may have
195
- * been processed and still needs a withdraw.
245
+ * Whether a failed send provably never reached the broker: a dial that never
246
+ * opened or an unusable broker address carries no request, and a send that drew
247
+ * no HTTP response while the broker no longer accepts a dial cannot have left
248
+ * its edge in a broker that is gone.
196
249
  */
197
250
  async function sendNeverReachedBroker(error, env, fetchImpl) {
198
251
  if (error instanceof BrokerHttpError) return false;
@@ -200,21 +253,84 @@ async function sendNeverReachedBroker(error, env, fetchImpl) {
200
253
  if (abortedBeforeResponse(error)) return false;
201
254
  return !await brokerReachable(env, fetchImpl);
202
255
  }
256
+ function brokerOutcome(body) {
257
+ const record = body !== null && typeof body === "object" ? body : {};
258
+ return typeof record.outcome === "string" && record.outcome ? record.outcome : "sent";
259
+ }
203
260
  /**
204
- * Ask another run through the inherited broker as this run, then wait for the
205
- * correlated reply. A failed send, wait, timeout, or abort withdraws the ask
206
- * and reports its message id plus whether it is still pending, matching the
207
- * host-side ask tool's contract.
261
+ * The outcome the wire contract documents for a known code, or undefined for a
262
+ * code outside it. This mirrors the Go transport's wireErrorClasses table: a
263
+ * code whose documented outcome is a credential or edge refusal keeps it, and
264
+ * every other code names a request that was never admitted.
208
265
  */
209
- async function askViaBroker(env, args, fetchImpl = fetch) {
210
- const messageId = randomUUID();
211
- const role = args.role ?? "agent";
266
+ function knownCodeOutcome(code) {
267
+ switch (code) {
268
+ case "auth-rejected":
269
+ case "sender-mismatch": return "auth-rejected";
270
+ case "not-ask-sender":
271
+ case "not-ask-target": return "edge-absent";
272
+ case "invalid-request":
273
+ case "generation-mismatch":
274
+ case "unsupported-protocol":
275
+ case "malformed-message-id":
276
+ case "message-id-expired":
277
+ case "message-id-horizon":
278
+ case "duplicate-message-id":
279
+ case "mutual-ask":
280
+ case "fence-capacity":
281
+ case "target-unavailable":
282
+ case "transport-closed":
283
+ case "unsupported-operation": return "not-sent";
284
+ default: return;
285
+ }
286
+ }
287
+ /**
288
+ * The outcome of a response the messaging envelope does not describe, as the Go
289
+ * local transport classifies it: only a 401 there is a credential rejection, a
290
+ * code-less 403 is the router's not-ask-target refusal, and every other status
291
+ * leaves transmission uncertain.
292
+ */
293
+ function unenvelopedOutcome(status) {
294
+ if (status === 401) return "auth-rejected";
295
+ if (status === 403) return "edge-absent";
296
+ return "possibly-pending";
297
+ }
298
+ /**
299
+ * Classify a broker HTTP rejection the way the Go local transport does: the
300
+ * body's outcome wins, then a known wire code, then the status. A rejection that
301
+ * names neither leaves transmission uncertain, so it is never reported
302
+ * `not-sent`; only a send that provably never left the surface is.
303
+ */
304
+ function brokerErrorOutcome(error) {
305
+ if (!(error instanceof BrokerHttpError)) return void 0;
306
+ try {
307
+ const parsed = JSON.parse(error.body);
308
+ if (parsed && typeof parsed.outcome === "string" && parsed.outcome) return parsed.outcome;
309
+ } catch {}
310
+ if (error.code) return knownCodeOutcome(error.code) ?? "possibly-pending";
311
+ return unenvelopedOutcome(error.status);
312
+ }
313
+ /**
314
+ * The outcome of a generation pin that failed before any message was sent: no
315
+ * request reached the send route, so only a credential refusal keeps its own
316
+ * outcome and everything else is a message that was never sent.
317
+ */
318
+ function pinFailureOutcome(error) {
319
+ return brokerErrorOutcome(error) === "auth-rejected" ? "auth-rejected" : "not-sent";
320
+ }
321
+ async function brokerAsk(env, args, messageId, fetchImpl, pin) {
322
+ const role = "agent";
212
323
  const timeoutMs = normalizeAskTimeoutMs(args.timeoutMs);
213
324
  const signal = combineSignals(args.signal, timeoutMs !== void 0 ? AbortSignal.timeout(timeoutMs) : void 0);
214
- const fail = async (detail) => {
215
- const pending = await withdrawAsk(env, messageId, fetchImpl);
216
- throw new Error(formatAskFailure(messageId, pending, detail));
217
- };
325
+ let generation;
326
+ try {
327
+ generation = await pin();
328
+ } catch (error) {
329
+ return {
330
+ outcome: pinFailureOutcome(error),
331
+ messageId
332
+ };
333
+ }
218
334
  try {
219
335
  await postBroker(env, "/send", buildAgentMessage({
220
336
  fromRunId: env.runId,
@@ -222,22 +338,237 @@ async function askViaBroker(env, args, fetchImpl = fetch) {
222
338
  messageId,
223
339
  message: args.message,
224
340
  role,
225
- expectsReply: true
341
+ expectsReply: true,
342
+ generation
226
343
  }), fetchImpl, signal);
227
344
  } catch (error) {
228
- if (await sendNeverReachedBroker(error, env, fetchImpl)) throw new Error(formatAskFailure(messageId, false, `send ask: ${describeFailure(error)}`));
229
- return fail(`send ask: ${describeFailure(error)}`);
345
+ if (await sendNeverReachedBroker(error, env, fetchImpl)) return {
346
+ outcome: "not-sent",
347
+ messageId
348
+ };
349
+ const rejected = brokerErrorOutcome(error);
350
+ if (rejected) return {
351
+ outcome: rejected,
352
+ messageId
353
+ };
354
+ return {
355
+ outcome: (await withdrawAsk(env, messageId, generation, fetchImpl)).outcome,
356
+ messageId
357
+ };
230
358
  }
231
359
  let reply;
232
360
  try {
233
- reply = await postBroker(env, "/wait_reply", { waiting_for: messageId }, fetchImpl, signal);
361
+ reply = await postBroker(env, "/wait_reply", {
362
+ waiting_for: messageId,
363
+ generation
364
+ }, fetchImpl, signal);
234
365
  } catch (error) {
235
- return fail(`wait for reply: ${describeFailure(error)}`);
366
+ const timedOut = error instanceof Error && error.name === "TimeoutError" || brokerErrorOutcome(error) === "timed-out";
367
+ const cleaned = await withdrawAsk(env, messageId, generation, fetchImpl);
368
+ if (timedOut) return {
369
+ outcome: "timed-out",
370
+ messageId
371
+ };
372
+ const outcome = brokerErrorOutcome(error);
373
+ if (outcome) return {
374
+ outcome,
375
+ messageId
376
+ };
377
+ if (cleaned.pending) return {
378
+ outcome: "possibly-pending",
379
+ messageId
380
+ };
381
+ return {
382
+ outcome: cleaned.outcome,
383
+ messageId
384
+ };
236
385
  }
237
- const record = reply && typeof reply === "object" ? reply : {};
386
+ const record = reply !== null && typeof reply === "object" ? reply : {};
387
+ const outcome = brokerOutcome(record);
388
+ const result = {
389
+ outcome,
390
+ messageId
391
+ };
392
+ if (outcome === "replied") result.reply = extractReplyMessage(record);
393
+ return result;
394
+ }
395
+ function peerRecords(body) {
396
+ const sessions = body !== null && typeof body === "object" ? body.sessions ?? [] : [];
397
+ if (!Array.isArray(sessions)) return [];
398
+ return sessions.map((raw) => {
399
+ const session = raw !== null && typeof raw === "object" ? raw : {};
400
+ const record = { run_id: typeof session.run_id === "string" ? session.run_id : "" };
401
+ for (const key of [
402
+ "label",
403
+ "kind",
404
+ "status",
405
+ "backend",
406
+ "model",
407
+ "dir"
408
+ ]) {
409
+ const value = session[key];
410
+ if (typeof value === "string" && value) record[key] = value;
411
+ }
412
+ return record;
413
+ }).filter((record) => record.run_id !== "");
414
+ }
415
+ function wireCode(code) {
416
+ switch (code) {
417
+ case "not-permitted": return "not_permitted";
418
+ case "invalid-name": return "invalid_name";
419
+ case "unknown-target": return "unknown_target";
420
+ case "ambiguous-name": return "ambiguous_name";
421
+ default: return;
422
+ }
423
+ }
424
+ /** A broker refusal as the router's typed tool error. */
425
+ function brokerToolError(error) {
426
+ if (error instanceof BrokerHttpError) {
427
+ const code = wireCode(error.code) ?? (error.status === 403 ? "not_permitted" : error.status === 404 ? "unknown_target" : error.status === 409 ? "ambiguous_name" : "refused");
428
+ const text = (() => {
429
+ try {
430
+ const parsed = JSON.parse(error.body);
431
+ if (typeof parsed.error === "string") return parsed.error;
432
+ } catch {}
433
+ return error.body || error.message;
434
+ })();
435
+ const typed = new Error(text);
436
+ typed.name = "ToolError";
437
+ typed.code = code;
438
+ typed.facts = {};
439
+ return typed;
440
+ }
441
+ return error instanceof Error ? error : new Error(String(error));
442
+ }
443
+ /**
444
+ * The inherited-broker implementation of the core ChildBroker contract. Every
445
+ * request authenticates as this run; a transport failure keeps its own text and
446
+ * becomes a refusal at the router boundary.
447
+ */
448
+ function createBrokerChildBroker(env, fetchImpl = fetch) {
449
+ const pin = pinGeneration(env, fetchImpl);
238
450
  return {
239
- reply: extractReplyMessage(record),
240
- from_run_id: record.from_run_id ?? ""
451
+ async tell(targetRunId, message, messageId) {
452
+ let generation;
453
+ try {
454
+ generation = await pin();
455
+ } catch (error) {
456
+ return {
457
+ outcome: pinFailureOutcome(error),
458
+ messageId
459
+ };
460
+ }
461
+ let body;
462
+ try {
463
+ body = await postBroker(env, "/send", buildAgentMessage({
464
+ fromRunId: env.runId,
465
+ toRunId: targetRunId,
466
+ messageId,
467
+ message,
468
+ role: "agent",
469
+ generation
470
+ }), fetchImpl);
471
+ } catch (error) {
472
+ return {
473
+ outcome: brokerErrorOutcome(error) ?? (await sendNeverReachedBroker(error, env, fetchImpl) ? "not-sent" : "possibly-pending"),
474
+ messageId
475
+ };
476
+ }
477
+ return {
478
+ outcome: brokerOutcome(body),
479
+ messageId: typeof body?.message_id === "string" && body.message_id ? body.message_id : messageId
480
+ };
481
+ },
482
+ ask(targetRunId, message, messageId, timeoutMs, signal) {
483
+ return brokerAsk(env, {
484
+ toRunId: targetRunId,
485
+ message,
486
+ timeoutMs,
487
+ signal
488
+ }, messageId, fetchImpl, pin);
489
+ },
490
+ async reply(askerRunId, inboundAskId, message) {
491
+ let body;
492
+ try {
493
+ body = await postBroker(env, "/send", buildAgentMessage({
494
+ fromRunId: env.runId,
495
+ toRunId: askerRunId,
496
+ messageId: `${inboundAskId}.reply`,
497
+ message,
498
+ role: "agent",
499
+ replyTo: inboundAskId
500
+ }), fetchImpl);
501
+ } catch (error) {
502
+ return {
503
+ outcome: brokerErrorOutcome(error) ?? (await sendNeverReachedBroker(error, env, fetchImpl) ? "not-sent" : "possibly-pending"),
504
+ messageId: inboundAskId
505
+ };
506
+ }
507
+ return {
508
+ outcome: brokerOutcome(body),
509
+ messageId: inboundAskId
510
+ };
511
+ },
512
+ async withdraw(messageId) {
513
+ let generation;
514
+ try {
515
+ generation = await pin();
516
+ } catch (error) {
517
+ throw brokerToolError(error);
518
+ }
519
+ try {
520
+ return { outcome: brokerOutcome(await postBroker(env, "/cancel_message", {
521
+ cancel_message_id: messageId,
522
+ generation
523
+ }, fetchImpl)) };
524
+ } catch (error) {
525
+ if (error instanceof BrokerHttpError) {
526
+ if (error.code === "fence-capacity") return { outcome: "possibly-pending" };
527
+ if (error.status === 404) {
528
+ try {
529
+ const parsed = JSON.parse(error.body);
530
+ if (typeof parsed.outcome === "string" && parsed.outcome) return { outcome: parsed.outcome };
531
+ } catch {}
532
+ return { outcome: "edge-absent" };
533
+ }
534
+ }
535
+ throw brokerToolError(error);
536
+ }
537
+ },
538
+ async peers() {
539
+ return { peers: peerRecords(await getBroker(env, "/sessions", fetchImpl)) };
540
+ },
541
+ async rename(req) {
542
+ try {
543
+ const body = await postBroker(env, "/rename", {
544
+ session_id: req.sessionId ?? "",
545
+ name: req.name
546
+ }, fetchImpl);
547
+ return {
548
+ outcome: "renamed",
549
+ runId: typeof body?.run_id === "string" ? body.run_id : "",
550
+ label: typeof body?.label === "string" ? body.label : "",
551
+ previousLabel: typeof body?.previous_label === "string" ? body.previous_label : ""
552
+ };
553
+ } catch (error) {
554
+ throw brokerToolError(error);
555
+ }
556
+ },
557
+ async last(runId) {
558
+ try {
559
+ const body = await getBroker(env, `/last_message?target=${encodeURIComponent(runId)}`, fetchImpl);
560
+ return {
561
+ outcome: "sent",
562
+ label: typeof body?.label === "string" ? body.label : void 0,
563
+ hasRecord: body?.has_record === true,
564
+ finalOutput: typeof body?.final_output === "string" ? body.final_output : "",
565
+ stopReason: typeof body?.stop_reason === "string" ? body.stop_reason : void 0,
566
+ truncated: body?.final_output_truncated === true
567
+ };
568
+ } catch (error) {
569
+ throw brokerToolError(error);
570
+ }
571
+ }
241
572
  };
242
573
  }
243
574
  //#endregion
@@ -569,7 +900,7 @@ var RunInspectorOverlay = class {
569
900
  }
570
901
  if (!isFollowUpable(this.state.status, this.state.snapshot)) return;
571
902
  this.runAction(async () => {
572
- const next = await this.deps.followUpRun(this.state.run, text);
903
+ const next = await this.deps.resumeRun(this.state.run, text);
573
904
  this.state = {
574
905
  ...this.state,
575
906
  run: {
@@ -1173,7 +1504,7 @@ function guidance(status, pending, runId) {
1173
1504
  const options = permissionOptions(pending);
1174
1505
  if (options.length > 0) return `Guidance: Call avenor_answer_permission with run_id "${runId}" and option_id "${selected(options[0], "option_id")}".`;
1175
1506
  if (pending) return `Guidance: Permission input is required, but no options are available; call avenor_inspect with run_id "${runId}".`;
1176
- return `Guidance: Call avenor_follow_up or avenor_status with run_id "${runId}".`;
1507
+ return `Guidance: Call avenor_message with action "resume" or avenor_status with run_id "${runId}".`;
1177
1508
  }
1178
1509
  if (isTerminal(status)) return `Guidance: Call avenor_result or avenor_events with run_id "${runId}".`;
1179
1510
  return `Guidance: Call avenor_result with run_id "${runId}".`;
@@ -1269,18 +1600,33 @@ function permissionLines(details, args) {
1269
1600
  `Guidance: Call avenor_status with run_id "${runId}" to verify the permission state.`
1270
1601
  ];
1271
1602
  }
1272
- function followUpLines(details, args) {
1603
+ function messageLines(details, args) {
1273
1604
  const result = asRecord(details);
1274
- if (!result || !stringValue(result.run_id) || !stringValue(result.label)) return void 0;
1275
- const runId = selected(result, "run_id", args.run_id);
1276
- const label = selected(result, "label", args.label, runId);
1277
- return [
1278
- `Follow-up: ${label} — dispatched.`,
1279
- `Source run: ${scalar(args.run_id)}`,
1280
- `Run: ${runId}`,
1281
- `Label: ${label}`,
1282
- `Guidance: Call avenor_status with run_id "${runId}".`
1283
- ];
1605
+ if (!result) return void 0;
1606
+ const action = selectedOptional(result, "action") ?? stringValue(args.action) ?? "tell";
1607
+ if (action === "resume") {
1608
+ const runId = selected(result, "run_id", args.target);
1609
+ const label = selected(result, "label", args.label, runId);
1610
+ if (!stringValue(runId) || !stringValue(label)) return void 0;
1611
+ return [
1612
+ `Resume: ${label} — dispatched.`,
1613
+ `Source run: ${scalar(args.target)}`,
1614
+ `Run: ${runId}`,
1615
+ `Label: ${label}`,
1616
+ `Guidance: Call avenor_status with run_id "${runId}".`
1617
+ ];
1618
+ }
1619
+ const outcome = selectedOptional(result, "outcome") ?? "done";
1620
+ const lines = [`${action.charAt(0).toUpperCase() + action.slice(1)}: ${outcome}.`];
1621
+ const text = selectedOptional(result, "text");
1622
+ if (text) lines.push(scalar(text));
1623
+ const messageId = selectedOptional(result, "message_id") ?? stringValue(args.reply_to) ?? stringValue(args.message_id);
1624
+ if (messageId) lines.push(`Message: ${scalar(messageId)}`);
1625
+ const reply = selectedOptional(result, "reply");
1626
+ if (action === "ask" && reply) lines.push(`Reply: ${scalar(reply)}`);
1627
+ const target = selectedOptional(result, "label") ?? (action === "reply" ? void 0 : stringValue(args.target));
1628
+ if (target) lines.push(`Target: ${scalar(target)}`);
1629
+ return lines;
1284
1630
  }
1285
1631
  function eventSequence(value) {
1286
1632
  if (typeof value === "number" && Number.isFinite(value)) return String(value);
@@ -1502,12 +1848,21 @@ function renderAnswerPermissionCall(args, theme) {
1502
1848
  if (supervisor) pieces.push(`supervisor_id ${supervisor}`);
1503
1849
  return linesText([pieces.join(" ")], theme);
1504
1850
  }
1505
- function renderFollowUpCall(args, theme) {
1506
- const pieces = [`avenor_follow_up run_id ${quoted(args.run_id)}`, `message ${quoted(args.message)}`];
1507
- const label = optionalQuoted(args.label);
1508
- const supervisor = optionalQuoted(args.supervisor_id);
1509
- if (label) pieces.push(`label ${label}`);
1510
- if (supervisor) pieces.push(`supervisor_id ${supervisor}`);
1851
+ function renderMessageResumeCall(args, theme) {
1852
+ const action = stringValue(args.action) || "tell";
1853
+ const pieces = [`avenor_message action ${quoted(action)}`];
1854
+ if (action === "reply") pieces.push(`reply_to ${quoted(args.reply_to)}`, `message ${quoted(args.message)}`);
1855
+ else if (action === "withdraw") pieces.push(`message_id ${quoted(args.message_id)}`);
1856
+ else if (action === "control_reply") {
1857
+ pieces.push(`target ${quoted(args.target)}`);
1858
+ if (args.payload !== void 0) pieces.push(`payload ${quoted(JSON.stringify(args.payload))}`);
1859
+ } else {
1860
+ pieces.push(`target ${quoted(args.target)}`, `message ${quoted(args.message)}`);
1861
+ if (action === "resume") {
1862
+ const label = optionalQuoted(args.label);
1863
+ if (label) pieces.push(`label ${label}`);
1864
+ }
1865
+ }
1511
1866
  return linesText([pieces.join(" ")], theme);
1512
1867
  }
1513
1868
  function renderEventsCall(args, theme) {
@@ -1541,8 +1896,8 @@ function renderInspectResult(result, options, theme, args) {
1541
1896
  function renderAnswerPermissionResult(result, options, theme, args) {
1542
1897
  return render("answer_permission", result, options, theme, () => permissionLines(result.details, args));
1543
1898
  }
1544
- function renderFollowUpResult(result, options, theme, args) {
1545
- return render("follow_up", result, options, theme, () => followUpLines(result.details, args));
1899
+ function renderMessageResumeResult(result, options, theme, args) {
1900
+ return render("message", result, options, theme, () => messageLines(result.details, args));
1546
1901
  }
1547
1902
  function renderEventsResult(result, options, theme, args) {
1548
1903
  return render("events", result, options, theme, () => eventsLines(result.details, args, options.expanded));
@@ -1577,11 +1932,10 @@ const defaultDeps = {
1577
1932
  statusTool,
1578
1933
  eventsTool,
1579
1934
  answerPermissionTool,
1580
- followUpTool,
1935
+ messageTool,
1581
1936
  inspectTool,
1582
1937
  resultTool,
1583
1938
  brokerReceiveTool,
1584
- brokerReplyTool,
1585
1939
  shutdownTool,
1586
1940
  killTool,
1587
1941
  workflowStatusTool,
@@ -1592,6 +1946,8 @@ const defaultDeps = {
1592
1946
  workflowGateTool,
1593
1947
  workflowControllerStatusTool,
1594
1948
  workflowReadyTool,
1949
+ workflowCancelTool,
1950
+ getSupervisorClient,
1595
1951
  observeRun,
1596
1952
  dial,
1597
1953
  attachOwner,
@@ -1633,7 +1989,7 @@ function buildCompletionText(run, result, finalOutput) {
1633
1989
  if (output) lines.push("", "Final output:", ...quotedLines(output));
1634
1990
  if (result.stop_reason && result.stop_reason.toUpperCase() !== result.status.toUpperCase()) lines.push(`Stop reason: ${result.stop_reason}`);
1635
1991
  if (result.session_id) lines.push(`Session: \`${result.session_id}\``);
1636
- lines.push("", `Call \`avenor_inspect\` with \`run_id: "${run.runId}"\` for the bounded transcript/snapshot, or \`avenor_follow_up\` to iterate.`);
1992
+ lines.push("", `Call \`avenor_inspect\` with \`run_id: "${run.runId}"\` for the bounded transcript/snapshot, or \`avenor_message\` with \`action: "resume"\` to iterate.`);
1637
1993
  return lines.join("\n");
1638
1994
  }
1639
1995
  function buildWaitingText(run, status) {
@@ -1642,7 +1998,7 @@ function buildWaitingText(run, status) {
1642
1998
  const lines = [`Sub-agent "${run.label}" (${agent}) is waiting for input.`];
1643
1999
  if (perm) lines.push(`Permission request: ${perm.description}`, `To answer: call \`avenor_answer_permission\` with run_id "${run.runId}", option_id "allow_once" | "allow_always" | "deny"`);
1644
2000
  else if (status.phase_label) lines.push(`Question: ${status.phase_label}`);
1645
- if (!perm) lines.push(`Use \`avenor_follow_up\` with run_id "${run.runId}" to respond, or \`avenor_inspect\` to review context first.`);
2001
+ if (!perm) lines.push(`Use \`avenor_message\` with \`action: "resume"\` and target "${run.runId}" to respond, or \`avenor_inspect\` to review context first.`);
1646
2002
  return lines.join("\n");
1647
2003
  }
1648
2004
  function isTerminalStatus(status) {
@@ -1745,7 +2101,18 @@ function createExtension(deps = defaultDeps, options = {}) {
1745
2101
  let pollingErrorCount = 0;
1746
2102
  const pollingErrors = [];
1747
2103
  let lastStatusEntries = [];
2104
+ let lastTurnSocketClosed = false;
1748
2105
  const pendingWaits = /* @__PURE__ */ new Set();
2106
+ /** The agent-facing prose clause for a stopping point. A synthetic status
2107
+ * such as `asking` has no run-status spelling, so it must not appear raw in
2108
+ * this prose. Structured payloads still carry the raw status (the
2109
+ * avenor_result JSON reports it as `interrupted_by.status`) so consumers can
2110
+ * check it. */
2111
+ function describeStoppingPoint(stop) {
2112
+ if (stop.status === "waiting") return "reached a permission request";
2113
+ if (stop.status === "asking") return "asked a question for you";
2114
+ return `reached ${stop.status}`;
2115
+ }
1749
2116
  function interruptPendingWaits(stop, excludeRunKey) {
1750
2117
  for (const wait of [...pendingWaits]) {
1751
2118
  if (wait.runKey === excludeRunKey) continue;
@@ -1761,6 +2128,29 @@ function createExtension(deps = defaultDeps, options = {}) {
1761
2128
  pendingWaits.add(wait);
1762
2129
  return wait;
1763
2130
  }
2131
+ /** An inbound ask is a stopping point: the asker cannot finish until it is
2132
+ * answered, so every blocking wait returns, the asker's own wait included. */
2133
+ function interruptWaitsForAsk(ask) {
2134
+ interruptPendingWaits({
2135
+ runId: ask.from_run_id,
2136
+ status: "asking"
2137
+ });
2138
+ }
2139
+ /** Surface one inbound ask: hand the body to the session, remember it so a
2140
+ * reply can reach the asker, then let every blocking wait return. Delivery
2141
+ * comes first so a throw leaves the waits waiting on an ask the agent never
2142
+ * saw, rather than reporting an ask it cannot answer. */
2143
+ function surfaceInboundAsk(pending, ask, heading) {
2144
+ pi.sendUserMessage(formatInboundAskBody(ask, {
2145
+ heading,
2146
+ role: ask.role
2147
+ }), { deliverAs: "steer" });
2148
+ pending.set(ask.message_id, {
2149
+ from_run_id: ask.from_run_id,
2150
+ message_id: ask.message_id
2151
+ });
2152
+ interruptWaitsForAsk(ask);
2153
+ }
1764
2154
  function createInterruptibleWait(runKey, signal) {
1765
2155
  const controller = new AbortController();
1766
2156
  let stopped;
@@ -1854,11 +2244,7 @@ function createExtension(deps = defaultDeps, options = {}) {
1854
2244
  for (const msg of msgs) {
1855
2245
  const ask = parseControlMessage(msg);
1856
2246
  if (!ask) continue;
1857
- subAgentPendingAsks.set(ask.message_id, {
1858
- from_run_id: ask.from_run_id,
1859
- message_id: ask.message_id
1860
- });
1861
- pi.sendUserMessage(formatInboundAskBody(ask), { deliverAs: "steer" });
2247
+ surfaceInboundAsk(subAgentPendingAsks, ask, "Inbound ask");
1862
2248
  }
1863
2249
  } catch (error) {
1864
2250
  consecutiveFailures++;
@@ -1873,6 +2259,7 @@ function createExtension(deps = defaultDeps, options = {}) {
1873
2259
  }
1874
2260
  let hostPollTimer = null;
1875
2261
  let hostPollGeneration = 0;
2262
+ let hostReceiveGeneration;
1876
2263
  const hostPendingAsks = /* @__PURE__ */ new Map();
1877
2264
  function stopHostAskPoll() {
1878
2265
  hostPollGeneration++;
@@ -1886,20 +2273,27 @@ function createExtension(deps = defaultDeps, options = {}) {
1886
2273
  const generation = hostPollGeneration;
1887
2274
  const intervalMs = options.pollIntervalMs ?? 2e3;
1888
2275
  const tick = async () => {
2276
+ const startedAt = Date.now();
1889
2277
  try {
1890
- const { asks } = await deps.brokerReceiveTool({});
1891
- if (generation !== hostPollGeneration) return;
1892
- for (const ask of asks) {
1893
- hostPendingAsks.set(ask.message_id, {
1894
- from_run_id: ask.from_run_id,
1895
- message_id: ask.message_id
1896
- });
1897
- const body = `**📨 Ask from sub-agent** (\`${ask.role && ask.role !== "agent" ? `${ask.from_run_id} (${ask.role})` : ask.from_run_id}\`)\n\n${ask.message}\n\nTo answer, use the intercom tool: avenor_reply({ from_run_id: ${JSON.stringify(ask.from_run_id)}, reply_to_message_id: ${JSON.stringify(ask.message_id)}, message: "..." })`;
1898
- pi.sendUserMessage(body, { deliverAs: "steer" });
2278
+ const receive = () => hostReceiveGeneration ? deps.brokerReceiveTool({
2279
+ generation: hostReceiveGeneration,
2280
+ timeout_ms: intervalMs
2281
+ }) : deps.brokerReceiveTool({ timeout_ms: intervalMs });
2282
+ let received;
2283
+ try {
2284
+ received = await receive();
2285
+ } catch (error) {
2286
+ if (!hostReceiveGeneration || !isGenerationMismatchError(error)) throw error;
2287
+ hostReceiveGeneration = void 0;
2288
+ disposeHostProviders();
2289
+ received = await receive();
1899
2290
  }
2291
+ if (generation !== hostPollGeneration) return;
2292
+ if (received.generation) hostReceiveGeneration = received.generation;
2293
+ for (const ask of received.asks) surfaceInboundAsk(hostPendingAsks, ask, "Ask from sub-agent");
1900
2294
  } catch {}
1901
2295
  if (generation !== hostPollGeneration) return;
1902
- hostPollTimer = setTimeout(() => void tick(), intervalMs);
2296
+ hostPollTimer = setTimeout(() => void tick(), Math.max(0, intervalMs - (Date.now() - startedAt)));
1903
2297
  hostPollTimer.unref?.();
1904
2298
  };
1905
2299
  hostPollTimer = setTimeout(() => void tick(), intervalMs);
@@ -2227,44 +2621,22 @@ function createExtension(deps = defaultDeps, options = {}) {
2227
2621
  await client.interruptAndPrompt(runtimeId, text);
2228
2622
  });
2229
2623
  },
2230
- followUpRun(run, text) {
2231
- return deps.followUpTool({
2232
- runId: run.runId,
2233
- message: text,
2234
- supervisorId: run.supervisorId
2235
- });
2624
+ resumeRun(run, text) {
2625
+ return resumeTrackedRun(run, text);
2236
2626
  }
2237
2627
  };
2238
2628
  }
2239
- async function followUpTrackedRun(run, text) {
2240
- const result = await deps.followUpTool({
2241
- runId: run.runId,
2242
- message: text,
2243
- supervisorId: run.supervisorId
2244
- });
2245
- try {
2246
- const status = asStatusResult(await deps.statusTool({
2247
- runId: result.run_id,
2248
- supervisorId: run.supervisorId
2249
- }));
2250
- upsertTrackedRun({
2251
- runId: result.run_id,
2252
- label: result.label,
2253
- agent: status?.agent ?? run.agent,
2254
- runtimeId: status?.runtime_id,
2255
- supervisorId: run.supervisorId
2256
- }, status, false);
2257
- startPolling();
2258
- } catch {
2259
- upsertTrackedRun({
2260
- runId: result.run_id,
2261
- label: result.label,
2262
- agent: run.agent,
2263
- supervisorId: run.supervisorId
2264
- }, void 0, false);
2265
- startPolling();
2266
- }
2267
- return result;
2629
+ async function resumeTrackedRun(run, text) {
2630
+ const provider = await hostMessageProvider(run.supervisorId);
2631
+ const result = await deps.messageTool({
2632
+ action: "resume",
2633
+ target: run.runId,
2634
+ message: text
2635
+ }, provider);
2636
+ return {
2637
+ run_id: result.runId ?? run.runId,
2638
+ label: result.label ?? run.runId
2639
+ };
2268
2640
  }
2269
2641
  async function waitForRunWithObserver(run, signal, onUpdate) {
2270
2642
  let observer = null;
@@ -2372,9 +2744,110 @@ function createExtension(deps = defaultDeps, options = {}) {
2372
2744
  if (observed.status || observed.aborted) return observed;
2373
2745
  return waitForRunByPolling(run, signal, onUpdate);
2374
2746
  }
2747
+ const hostProviders = /* @__PURE__ */ new Map();
2748
+ const hostAskClaims = /* @__PURE__ */ new Map();
2749
+ function disposeHostProvider(supervisorId) {
2750
+ const key = supervisorId ?? "";
2751
+ const binding = hostProviders.get(key);
2752
+ hostProviders.delete(key);
2753
+ hostAskClaims.delete(key);
2754
+ if (key === "") lastTurnSocketClosed = false;
2755
+ if (!binding || binding.isSingleton || !binding.client) return;
2756
+ try {
2757
+ binding.client.close();
2758
+ } catch {}
2759
+ }
2760
+ function disposeHostProviders() {
2761
+ for (const key of [...hostProviders.keys()]) disposeHostProvider(key);
2762
+ hostAskClaims.clear();
2763
+ lastTurnSocketClosed = false;
2764
+ }
2765
+ async function defaultHostProviderFor(supervisorId) {
2766
+ const key = supervisorId ?? "";
2767
+ const existing = hostProviders.get(key);
2768
+ if (existing) return existing.provider;
2769
+ const { client, isSingleton } = await (deps.getSupervisorClient ?? getSupervisorClient)(supervisorId, { owner: true });
2770
+ const preflight = await client.messagePreflight();
2771
+ const provider = createHostMessageProvider({
2772
+ surface: "pi-host",
2773
+ supervisorId: supervisorId ?? Supervisor.currentInstance()?.supervisorId ?? "",
2774
+ generation: preflight.generation,
2775
+ client,
2776
+ onResumed: (result) => trackResumed(supervisorId, result)
2777
+ });
2778
+ hostProviders.set(key, {
2779
+ provider,
2780
+ client,
2781
+ isSingleton
2782
+ });
2783
+ return provider;
2784
+ }
2785
+ async function trackResumed(supervisorId, result) {
2786
+ const run = {
2787
+ runId: result.runId ?? "",
2788
+ label: result.label ?? result.runId ?? "",
2789
+ supervisorId
2790
+ };
2791
+ try {
2792
+ const status = asStatusResult(await deps.statusTool({
2793
+ runId: run.runId,
2794
+ supervisorId
2795
+ }));
2796
+ upsertTrackedRun({
2797
+ runId: run.runId,
2798
+ label: run.label,
2799
+ agent: status?.agent,
2800
+ runtimeId: status?.runtime_id,
2801
+ supervisorId
2802
+ }, status, false);
2803
+ } catch {
2804
+ upsertTrackedRun(run, void 0, false);
2805
+ }
2806
+ startPolling();
2807
+ }
2808
+ const hostProviderFor = deps.hostProviderFor ?? defaultHostProviderFor;
2809
+ const childProvider = isSubAgentBrokerMode && subAgentEnv ? createChildMessageProvider({
2810
+ surface: "pi-child",
2811
+ runId: subAgentEnv.runId,
2812
+ broker: createBrokerChildBroker(subAgentEnv),
2813
+ pendingAsks: subAgentPendingAsks,
2814
+ pollGeneration: () => subAgentPollGeneration
2815
+ }) : null;
2816
+ async function hostMessageProvider(supervisorId) {
2817
+ const provider = await hostProviderFor(supervisorId);
2818
+ const key = supervisorId ?? "";
2819
+ let claims = hostAskClaims.get(key);
2820
+ if (!claims) {
2821
+ claims = /* @__PURE__ */ new Map();
2822
+ hostAskClaims.set(key, claims);
2823
+ }
2824
+ installAskClaims(provider, claims, hostPendingAsks, () => hostPollGeneration);
2825
+ if (!provider.onResumed) provider.onResumed = (result) => trackResumed(supervisorId, result);
2826
+ return provider;
2827
+ }
2828
+ async function messageProvider(supervisorId) {
2829
+ if (childProvider) return childProvider;
2830
+ return hostMessageProvider(supervisorId);
2831
+ }
2832
+ pi.on("turn_end", async (event) => {
2833
+ if (isSubAgentBrokerMode) return;
2834
+ if (lastTurnSocketClosed) return;
2835
+ if (!deps.Supervisor.currentInstance?.()) return;
2836
+ const turn = extractTurnText([event.message]);
2837
+ try {
2838
+ const provider = await hostProviderFor(void 0);
2839
+ if (!provider.recordLastTurn) return;
2840
+ const bounded = boundFinalOutput(turn.text);
2841
+ await provider.recordLastTurn(bounded, normalizeTurnStopReason(turn.stopReason), turn.text.length > bounded.length);
2842
+ } catch (error) {
2843
+ if (isClosedSocketError(error)) lastTurnSocketClosed = true;
2844
+ console.error("[avenor] session_last_set failed:", error instanceof Error ? error.message : error);
2845
+ }
2846
+ });
2375
2847
  pi.on("session_start", async (_event, ctx) => {
2376
2848
  sessionCtx = ctx;
2377
2849
  lastStatusEntries = [];
2850
+ lastTurnSocketClosed = false;
2378
2851
  pollingErrorCount = 0;
2379
2852
  pollingErrors.length = 0;
2380
2853
  if (!isSubAgentBrokerMode) startHostAskPoll();
@@ -2383,6 +2856,7 @@ function createExtension(deps = defaultDeps, options = {}) {
2383
2856
  pi.on("session_shutdown", async () => {
2384
2857
  stopHostAskPoll();
2385
2858
  stopSubAgentPoll();
2859
+ disposeHostProviders();
2386
2860
  hostPendingAsks.clear();
2387
2861
  subAgentPendingAsks.clear();
2388
2862
  await stopPolling();
@@ -2422,15 +2896,15 @@ function createExtension(deps = defaultDeps, options = {}) {
2422
2896
  pi.registerTool({
2423
2897
  name: "avenor_spawn",
2424
2898
  label: "Avenor Spawn",
2425
- description: `Dispatch an agent run via avenor. Optional thinking accepts ${[...THINKING_LEVELS].slice(0, -1).join(", ")}, or ${THINKING_LEVELS[THINKING_LEVELS.length - 1]}; unsupported backends reject explicit values. Blocks by default, showing live progress. Set wait=false for fire-and-forget. With several blocking spawns in parallel, the first stopping point (permission or completion) interrupts the other waits.`,
2899
+ description: `Dispatch an agent run via avenor. Optional thinking accepts ${[...THINKING_LEVELS].slice(0, -1).join(", ")}, or ${THINKING_LEVELS[THINKING_LEVELS.length - 1]}; unsupported backends reject explicit values. Blocks by default, showing live progress. Set wait=false for fire-and-forget. With several blocking spawns in parallel, the first stopping point (a permission request, an inbound ask, or completion) interrupts the other waits.`,
2426
2900
  promptSnippet: "Dispatch sub-agent runs via avenor (spawn, status, result, inspect, events, follow-up, shutdown)",
2427
2901
  promptGuidelines: [
2428
2902
  "Use avenor_spawn to delegate well-defined tasks to sub-agents.",
2429
2903
  "Use avenor_status (view \"lifecycle\") only to check progress or pending permissions.",
2430
- "Use avenor_result to retrieve the final result of a sub-agent; it waits by default, but set wait=false for runs that already completed. When awaiting multiple runs, the first stopping point (permission or completion) interrupts the other waits.",
2904
+ "Use avenor_result to retrieve the final result of a sub-agent; it waits by default, but set wait=false for runs that already completed. When awaiting multiple runs, the first stopping point (a permission request, an inbound ask, or completion) interrupts the other waits.",
2431
2905
  "Use avenor_inspect to review a bounded transcript and tool activity from a run.",
2432
2906
  "Use avenor_events only when you specifically need raw event payloads.",
2433
- "Use avenor_follow_up to iterate on a completed run.",
2907
+ "Use avenor_message with action \"resume\" to iterate on a completed run.",
2434
2908
  "Use avenor_shutdown when done delegating work.",
2435
2909
  "When roster_entry is set, the roster selects agent, model, and backend; do not pass those fields alongside it.",
2436
2910
  "If a roster spawn fails validation or resolution, do not retry the roster: respawn without roster_file/roster_entry. Direct selectors (agent, model, backend) are optional; omission uses runtime defaults."
@@ -2482,6 +2956,7 @@ function createExtension(deps = defaultDeps, options = {}) {
2482
2956
  ...params,
2483
2957
  backend
2484
2958
  };
2959
+ const parentRunId = isSubAgentBrokerMode ? void 0 : !params.supervisor_id || Boolean(deps.Supervisor.isCurrentInstance?.(params.supervisor_id)) ? SUPERVISOR_RUN_ID : void 0;
2485
2960
  const result = await deps.spawnTool({
2486
2961
  ...params.agent !== void 0 ? { agent: params.agent } : {},
2487
2962
  ...params.prompt !== void 0 ? { prompt: params.prompt } : {},
@@ -2496,7 +2971,8 @@ function createExtension(deps = defaultDeps, options = {}) {
2496
2971
  ...params.roster_entry !== void 0 ? { rosterEntry: params.roster_entry } : {},
2497
2972
  ...agentProfile !== void 0 ? { agentProfile } : {},
2498
2973
  ...params.server_url !== void 0 ? { serverUrl: params.server_url } : {},
2499
- ...params.supervisor_id !== void 0 ? { supervisorId: params.supervisor_id } : {}
2974
+ ...params.supervisor_id !== void 0 ? { supervisorId: params.supervisor_id } : {},
2975
+ ...parentRunId !== void 0 ? { parent_run_id: parentRunId } : {}
2500
2976
  });
2501
2977
  const supervisorId = statusSupervisorId(params.supervisor_id, result.supervisor_id);
2502
2978
  const runRef = {
@@ -2551,7 +3027,7 @@ function createExtension(deps = defaultDeps, options = {}) {
2551
3027
  return {
2552
3028
  content: [{
2553
3029
  type: "text",
2554
- text: `Monitoring of "${label}" (run_id: ${result.run_id}) was interrupted: "${stop.label ?? stop.runId}" reached ${stop.status === "waiting" ? "a permission request" : stop.status}. Handle that first; this run's final result is delivered automatically.`
3030
+ text: `Monitoring of "${label}" (run_id: ${result.run_id}) was interrupted: "${stop.label ?? stop.runId}" ${describeStoppingPoint(stop)}. Handle that first; this run's final result is delivered automatically.`
2555
3031
  }],
2556
3032
  details: {
2557
3033
  run_id: result.run_id,
@@ -2717,7 +3193,7 @@ function createExtension(deps = defaultDeps, options = {}) {
2717
3193
  pi.registerTool({
2718
3194
  name: "avenor_result",
2719
3195
  label: "Avenor Result",
2720
- description: `Retrieve the complete final output of a run without transcript or event details. Waits by default; set wait=false to retrieve an already-completed run without blocking. When several runs are pending, the first stopping point (a permission request or completion on any run) interrupts other blocking waits so they can be handled; re-issue the interrupted wait afterwards.`,
3196
+ description: `Retrieve the complete final output of a run without transcript or event details. Waits by default; set wait=false to retrieve an already-completed run without blocking. When several runs are pending, the first stopping point (a permission request, an inbound ask, or completion on any run) interrupts other blocking waits so they can be handled; re-issue the interrupted wait afterwards.`,
2721
3197
  parameters: Type.Object({
2722
3198
  run_id: Type.String({ description: "Run ID or label" }),
2723
3199
  wait: Type.Optional(Type.Boolean({ description: "Wait for a terminal result (default true); false retrieves the current state without blocking" })),
@@ -2852,205 +3328,94 @@ function createExtension(deps = defaultDeps, options = {}) {
2852
3328
  }
2853
3329
  });
2854
3330
  pi.registerTool({
2855
- name: "avenor_follow_up",
2856
- label: "Avenor Follow Up",
2857
- description: "Resume a completed run with a follow-up message.",
3331
+ name: "avenor_message",
3332
+ label: "Avenor Message",
3333
+ description: "Send a message to another agent run, answer an inbound ask, withdraw a pending ask, or resume a completed run. Every action is explicit and the result states whether a failed ask can still be withdrawn. `control_reply` is available only on the claude-channel surface.",
2858
3334
  parameters: Type.Object({
2859
- run_id: Type.String({ description: "Completed run ID to resume" }),
2860
- message: Type.String({ description: "Follow-up message text" }),
2861
- label: Type.Optional(Type.String({ description: "Override label (defaults to <original>-followup)" })),
2862
- supervisor_id: Type.Optional(Type.String({ description: "Reuse an existing supervisor by socket path" }))
3335
+ action: Type.Union([
3336
+ Type.Literal("tell"),
3337
+ Type.Literal("ask"),
3338
+ Type.Literal("reply"),
3339
+ Type.Literal("resume"),
3340
+ Type.Literal("withdraw"),
3341
+ Type.Literal("control_reply")
3342
+ ], { description: "tell | ask | reply | resume | withdraw | control_reply" }),
3343
+ target: Type.Optional(Type.String({ description: "Recipient for tell/ask/resume (a run id, a peer label, or \"self\"), or the opaque control message id for control_reply" })),
3344
+ message: Type.Optional(Type.String({ description: "Message text for tell/ask/reply/resume" })),
3345
+ reply_to: Type.Optional(Type.String({ description: "Inbound ask id being answered (reply)" })),
3346
+ message_id: Type.Optional(Type.String({ description: "Ask id being withdrawn (withdraw)" })),
3347
+ timeout_ms: Type.Optional(Type.Integer({ description: "Bound on the ask wait in milliseconds" })),
3348
+ label: Type.Optional(Type.String({ description: "Override label for the resumed run (resume)" })),
3349
+ idempotency_key: Type.Optional(Type.String({ description: "Forwarded verbatim to the resume authority (resume)" })),
3350
+ payload: Type.Optional(Type.Unknown({ description: "Control reply body (control_reply)" }))
2863
3351
  }),
2864
- async execute(_toolCallId, params) {
2865
- const result = await deps.followUpTool({
2866
- runId: params.run_id,
2867
- message: params.message,
2868
- label: params.label,
2869
- supervisorId: params.supervisor_id
2870
- });
2871
- try {
2872
- const status = asStatusResult(await deps.statusTool({
2873
- runId: result.run_id,
2874
- supervisorId: params.supervisor_id
2875
- }));
2876
- upsertTrackedRun({
2877
- runId: result.run_id,
2878
- label: result.label,
2879
- agent: status?.agent,
2880
- runtimeId: status?.runtime_id,
2881
- supervisorId: params.supervisor_id
2882
- }, status, false);
2883
- } catch {
2884
- upsertTrackedRun({
2885
- runId: result.run_id,
2886
- label: result.label,
2887
- supervisorId: params.supervisor_id
2888
- }, void 0, false);
2889
- }
2890
- startPolling();
3352
+ async execute(_toolCallId, params, signal) {
3353
+ const provider = await messageProvider(void 0);
3354
+ const result = await deps.messageTool(params, provider, { signal });
2891
3355
  return {
2892
3356
  content: [{
2893
3357
  type: "text",
2894
- text: JSON.stringify(result, null, 2)
3358
+ text: result.text ?? ""
2895
3359
  }],
2896
- details: result
3360
+ details: resultOutput(result)
2897
3361
  };
2898
3362
  },
2899
- renderCall(args, theme, context) {
2900
- return renderFollowUpCall(context.args, theme);
3363
+ renderCall(args, theme) {
3364
+ return renderMessageResumeCall(args, theme);
2901
3365
  },
2902
3366
  renderResult(result, { expanded, isPartial }, theme, context) {
2903
- return renderFollowUpResult(result, {
3367
+ return renderMessageResumeResult(result, {
2904
3368
  expanded,
2905
3369
  isPartial
2906
3370
  }, theme, context.args);
2907
3371
  }
2908
3372
  });
2909
3373
  pi.registerTool({
2910
- name: "avenor_ask",
2911
- label: "Avenor Ask",
2912
- description: "Ask another agent run a question and block until it replies. An optional timeout_ms bounds the wait; a failed ask reports its message_id and whether it is still pending (withdrawable via avenor_cancel) or already withdrawn.",
3374
+ name: "avenor_peers",
3375
+ label: "Avenor Peers",
3376
+ description: "List the visible agent sessions, rename a run, or read a run's last agent message. `last` returns the target's final text with its stop reason, or an empty result when the target has no completed turn yet.",
2913
3377
  parameters: Type.Object({
2914
- to_run_id: Type.String({ description: "Target run ID" }),
2915
- message: Type.String({ description: "Question to ask the other agent" }),
2916
- timeout_ms: Type.Optional(Type.Integer({ description: "Optional timeout in milliseconds for the ask" })),
2917
- supervisor_id: Type.Optional(Type.String({ description: "Reuse an existing supervisor by socket path (ignored in a Pi sub-agent, which asks through its inherited broker)" }))
3378
+ action: Type.Optional(Type.Union([
3379
+ Type.Literal("list"),
3380
+ Type.Literal("rename"),
3381
+ Type.Literal("last")
3382
+ ], { description: "list (the default) | rename | last" })),
3383
+ name: Type.Optional(Type.String({ description: "New display name (rename)" })),
3384
+ session_id: Type.Optional(Type.String({ description: "Run to rename; omitted renames the caller (rename)" })),
3385
+ target: Type.Optional(Type.String({ description: "Peer to read: a run id, a label, or \"self\" (last)" }))
2918
3386
  }),
2919
- async execute(_toolCallId, params, signal) {
2920
- const result = isSubAgentBrokerMode && subAgentEnv ? await askViaBroker(subAgentEnv, {
2921
- toRunId: params.to_run_id,
2922
- message: params.message,
2923
- timeoutMs: params.timeout_ms,
2924
- signal
2925
- }) : await askTool({
2926
- toRunId: params.to_run_id,
2927
- message: params.message,
2928
- timeoutMs: params.timeout_ms,
2929
- supervisorId: params.supervisor_id
2930
- });
3387
+ async execute(_toolCallId, params) {
3388
+ const result = await peersTool(params, await messageProvider(void 0));
2931
3389
  return {
2932
3390
  content: [{
2933
3391
  type: "text",
2934
- text: `Reply from ${result.from_run_id || params.to_run_id}:\n${result.reply}`
3392
+ text: result.action === "list" ? [result.text, renderPeerList(result.peers)].filter((part) => part !== "").join("\n\n") : result.text
2935
3393
  }],
2936
- details: result
3394
+ details: peersDetails(result)
2937
3395
  };
2938
3396
  }
2939
3397
  });
2940
3398
  pi.registerTool({
2941
- name: "avenor_reply",
2942
- label: "Avenor Reply (host)",
2943
- description: "Reply to an inbound ask received from a sub-agent. Answer the most recent ask from a given sub-agent, or reply to a specific message_id from avenor_ask.",
3399
+ name: "avenor_workflow_cancel",
3400
+ label: "Avenor Workflow Cancel",
3401
+ description: "Cancel a workflow instance. The actor and reason are required.",
2944
3402
  parameters: Type.Object({
2945
- from_run_id: Type.Optional(Type.String({ description: "Sub-agent run id to reply to (the asker)" })),
2946
- reply_to_message_id: Type.Optional(Type.String({ description: "The ask's broker message id to answer specifically" })),
2947
- message: Type.String({ description: "Your answer" }),
2948
- supervisor_id: Type.Optional(Type.String({ description: "Reuse an existing supervisor by socket path" }))
3403
+ workflow_id: Type.String({ description: "Workflow ID" }),
3404
+ actor: Type.String({ description: "Who is cancelling" }),
3405
+ reason: Type.String({ description: "Why the workflow is being cancelled" }),
3406
+ supervisor_id: Type.Optional(Type.String({ description: "Supervisor ID for multi-supervisor mode" }))
2949
3407
  }),
2950
3408
  async execute(_toolCallId, params) {
2951
- if (isSubAgentBrokerMode && subAgentEnv) {
2952
- const target = resolveReplyTarget(subAgentPendingAsks, params.from_run_id, params.reply_to_message_id);
2953
- if (!target) return {
2954
- content: [{
2955
- type: "text",
2956
- text: "No pending ask found to reply to. Pass from_run_id and/or reply_to_message_id."
2957
- }],
2958
- details: { error: true }
2959
- };
2960
- const generation = subAgentPollGeneration;
2961
- subAgentPendingAsks.delete(target.message_id);
2962
- try {
2963
- await postBroker(subAgentEnv, "/send", buildAgentMessage({
2964
- fromRunId: subAgentEnv.runId,
2965
- toRunId: target.from_run_id,
2966
- messageId: randomUUID(),
2967
- message: params.message,
2968
- role: "agent",
2969
- replyTo: target.message_id
2970
- }));
2971
- } catch (error) {
2972
- if (generation === subAgentPollGeneration) subAgentPendingAsks.set(target.message_id, target);
2973
- throw error;
2974
- }
2975
- return { content: [{
2976
- type: "text",
2977
- text: "Reply sent"
2978
- }] };
2979
- }
2980
- const target = resolveReplyTarget(hostPendingAsks, params.from_run_id, params.reply_to_message_id);
2981
- if (!target) return {
2982
- content: [{
2983
- type: "text",
2984
- text: "No pending ask found to reply to. Pass from_run_id and/or reply_to_message_id."
2985
- }],
2986
- details: { error: true }
2987
- };
2988
- const generation = hostPollGeneration;
2989
- hostPendingAsks.delete(target.message_id);
2990
- try {
2991
- await deps.brokerReplyTool({
2992
- toRunId: target.from_run_id,
2993
- replyTo: target.message_id,
2994
- message: params.message,
2995
- supervisorId: params.supervisor_id
2996
- });
2997
- } catch (error) {
2998
- if (generation === hostPollGeneration) hostPendingAsks.set(target.message_id, target);
2999
- throw error;
3000
- }
3001
- return { content: [{
3002
- type: "text",
3003
- text: "Reply sent"
3004
- }] };
3005
- }
3006
- });
3007
- pi.registerTool({
3008
- name: "avenor_peers",
3009
- label: "Avenor Peers",
3010
- description: "List all connected agent sessions with their status and metadata.",
3011
- parameters: Type.Object({}),
3012
- async execute() {
3013
- const result = await peersTool({});
3014
- if (result.peers.length === 0) return { content: [{
3015
- type: "text",
3016
- text: "No other agent sessions connected."
3017
- }] };
3018
- return { content: [{
3019
- type: "text",
3020
- text: `**Active sessions:**\n${result.peers.map((p) => {
3021
- const parts = [p.label ? `${p.label} (${p.run_id})` : p.run_id];
3022
- const tags = [
3023
- p.status,
3024
- p.backend,
3025
- p.model
3026
- ].filter(Boolean);
3027
- if (tags.length) parts.push(`[${tags.join(", ")}]`);
3028
- return ` ${parts.join(" ")}`;
3029
- }).join("\n")}`
3030
- }] };
3031
- }
3032
- });
3033
- pi.registerTool({
3034
- name: "avenor_cancel",
3035
- label: "Avenor Cancel",
3036
- description: "Cancel a pending ask by its message ID.",
3037
- parameters: Type.Object({ message_id: Type.String({ description: "Message ID of the pending ask to cancel" }) }),
3038
- async execute(_toolCallId, params) {
3039
- if (isSubAgentBrokerMode && subAgentEnv) {
3040
- await withdrawPendingAsk(subAgentEnv, params.message_id);
3041
- return {
3042
- content: [{
3043
- type: "text",
3044
- text: `Cancelled ${params.message_id}`
3045
- }],
3046
- details: { cancelled: true }
3047
- };
3048
- }
3049
- const result = await cancelTool({ messageId: params.message_id });
3409
+ const result = await deps.workflowCancelTool({
3410
+ workflow_id: params.workflow_id,
3411
+ actor: params.actor,
3412
+ reason: params.reason,
3413
+ supervisor_id: params.supervisor_id
3414
+ });
3050
3415
  return {
3051
3416
  content: [{
3052
3417
  type: "text",
3053
- text: `Cancelled ${params.message_id}`
3418
+ text: JSON.stringify(result, null, 2)
3054
3419
  }],
3055
3420
  details: result
3056
3421
  };
@@ -3118,6 +3483,8 @@ function createExtension(deps = defaultDeps, options = {}) {
3118
3483
  supervisorId: params.supervisor_id,
3119
3484
  force: params.force
3120
3485
  });
3486
+ disposeHostProvider(params.supervisor_id);
3487
+ if (targetIsSingleton && target !== void 0) disposeHostProvider(void 0);
3121
3488
  return {
3122
3489
  content: [{
3123
3490
  type: "text",
@@ -3410,7 +3777,7 @@ function createExtension(deps = defaultDeps, options = {}) {
3410
3777
  initialRunId: args?.trim() || void 0,
3411
3778
  deps: {
3412
3779
  ...createWatchDeps(),
3413
- followUpRun: (run, text) => followUpTrackedRun(run, text)
3780
+ resumeRun: (run, text) => resumeTrackedRun(run, text)
3414
3781
  },
3415
3782
  onTrackRun: (run, status) => {
3416
3783
  upsertTrackedRun(run, status, false);