@dougbots/avenor-pi 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,10 +1,12 @@
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, brokerReceiveTool, brokerReplyTool, cancelTool, dial, eventsTool, extractEventText, followUpTool, inspectTool, killTool, observeRun, peersTool, resultTool, shutdownTool, socketsRoot, spawnTool, statusTool, validateSpawnSelection, workflowCompleteTool, workflowControllerStatusTool, workflowEventsTool, workflowGateTool, workflowInspectTool, workflowReadyTool, workflowStatusTool, workflowWaitTool } from "@dougbots/avenor-core";
3
+ import { Supervisor, THINKING_LEVELS, answerPermissionTool, attachOwner, boundFinalOutput, brokerReceiveTool, createChildMessageProvider, createHostMessageProvider, dial, eventsTool, extractEventText, extractReplyMessage, extractTurnText, getSupervisorClient, inspectTool, installAskClaims, 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
+ /** Deadline for a cleanup call whose caller's own deadline has already expired. */
9
+ const ASK_CLEANUP_TIMEOUT_MS = 5e3;
8
10
  function readSubAgentEnv(env = process.env) {
9
11
  const brokerUrl = env.AVENOR_BROKER_URL ?? "";
10
12
  const runId = env.AVENOR_RUN_ID ?? "";
@@ -34,43 +36,540 @@ function parseControlMessage(msg) {
34
36
  return {
35
37
  from_run_id: fromRunId,
36
38
  message_id: messageId,
37
- message: p.message
39
+ message: p.message,
40
+ role: typeof p.role === "string" ? p.role : void 0
38
41
  };
39
42
  }
40
- function formatInboundAskBody(ask) {
41
- 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: "..." }).`;
52
+ }
53
+ /** A broker HTTP response error, so callers can classify by status instead of
54
+ * matching digits in the request text. */
55
+ var BrokerHttpError = class extends Error {
56
+ status;
57
+ code;
58
+ body;
59
+ constructor(path, status, body, code = "") {
60
+ super(`broker ${path}: ${status} ${body}`);
61
+ this.name = "BrokerHttpError";
62
+ this.status = status;
63
+ this.code = code;
64
+ this.body = body;
65
+ }
66
+ };
67
+ /** Build the `/send` body for one agent_message, as the broker child tools do. */
68
+ function buildAgentMessage(fields) {
69
+ return {
70
+ ...fields.generation !== void 0 ? { generation: fields.generation } : {},
71
+ from_run_id: fields.fromRunId,
72
+ to_run_id: fields.toRunId,
73
+ type: "agent_message",
74
+ payload: {
75
+ id: fields.messageId,
76
+ from: fields.fromRunId,
77
+ from_run_id: fields.fromRunId,
78
+ to_run_id: fields.toRunId,
79
+ message: fields.message,
80
+ role: fields.role,
81
+ ...fields.expectsReply ? { expects_reply: true } : {},
82
+ ...fields.replyTo !== void 0 ? { reply_to: fields.replyTo } : {}
83
+ }
84
+ };
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);
42
94
  }
43
- async function postBroker(env, path, body, fetchImpl = fetch) {
95
+ async function postBroker(env, path, body, fetchImpl = fetch, signal) {
44
96
  const res = await fetchImpl(`${env.brokerUrl}${path}`, {
45
97
  method: "POST",
46
98
  headers: { "Content-Type": "application/json" },
47
99
  body: JSON.stringify({
100
+ ...body,
48
101
  run_id: env.runId,
49
- token: env.token,
50
- ...body
51
- })
102
+ token: env.token
103
+ }),
104
+ ...signal ? { signal } : {}
52
105
  });
53
- if (!res.ok) throw new Error(`broker ${path}: ${res.status} ${(await res.text()).trim()}`);
106
+ if (!res.ok) throw await codedError(path, res);
54
107
  return res.json();
55
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);
120
+ return res.json();
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
+ }
127
+ /**
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.
132
+ */
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
+ };
145
+ }
146
+ function combineSignals(...signals) {
147
+ const active = signals.filter((signal) => signal !== void 0);
148
+ if (active.length === 0) return void 0;
149
+ if (active.length === 1) return active[0];
150
+ return AbortSignal.any(active);
151
+ }
56
152
  /**
57
- * Resolve which pending ask to answer. Prefers an explicit message id (and its
58
- * asker); otherwise the most recent pending ask matching fromRunId.
153
+ * Withdraw a pending ask through the broker as this run. The broker's own error
154
+ * propagates so a caller reporting to a model shows its exact text.
59
155
  */
60
- function resolveReplyTarget(pending, fromRunId, replyToMessageId) {
61
- if (replyToMessageId) {
62
- const hit = pending.get(replyToMessageId);
63
- if (!hit) return null;
64
- const from = fromRunId ?? hit.from_run_id;
65
- if (!from) return null;
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" };
163
+ }
164
+ /**
165
+ * Withdraw a pending ask and classify whether the edge may still exist on the
166
+ * broker: true means the cleanup could not be confirmed, false means it is gone.
167
+ */
168
+ async function withdrawAsk(env, messageId, generation, fetchImpl) {
169
+ try {
170
+ return {
171
+ pending: false,
172
+ outcome: (await withdrawPendingAsk(env, messageId, generation, fetchImpl)).outcome
173
+ };
174
+ } catch (error) {
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
+ }
66
189
  return {
67
- from_run_id: from,
68
- message_id: replyToMessageId
190
+ pending: true,
191
+ outcome: "possibly-pending"
69
192
  };
70
193
  }
71
- let match;
72
- for (const [, entry] of pending) if (!fromRunId || entry.from_run_id === fromRunId) match = entry;
73
- return match ?? null;
194
+ }
195
+ /** Fetch causes for a dial that never opened, so no request reached the broker. */
196
+ const NEVER_REACHED_CODES = new Set([
197
+ "ECONNREFUSED",
198
+ "ConnectionRefused",
199
+ "ENOTFOUND",
200
+ "EAI_AGAIN",
201
+ "EHOSTUNREACH",
202
+ "ENETUNREACH",
203
+ "UND_ERR_CONNECT_TIMEOUT",
204
+ "ERR_INVALID_URL"
205
+ ]);
206
+ /**
207
+ * Whether a fetch failure carries only dial-level causes, so no request was
208
+ * carried to the broker and no ask edge can exist. Undici aggregates one error
209
+ * per dialled address, so every attempt must have failed at the dial.
210
+ */
211
+ function dialNeverOpened(error, depth = 0) {
212
+ if (depth > 5 || error === null || error === void 0) return false;
213
+ const node = error;
214
+ if (typeof node.code === "string" && NEVER_REACHED_CODES.has(node.code)) return true;
215
+ if (Array.isArray(node.errors) && node.errors.length > 0) return node.errors.every((cause) => dialNeverOpened(cause, depth + 1));
216
+ return dialNeverOpened(node.cause, depth + 1);
217
+ }
218
+ /** Whether the failure came from a caller or timeout abort, not from the wire. */
219
+ function abortedBeforeResponse(error, depth = 0) {
220
+ if (depth > 5 || error === null || error === void 0) return false;
221
+ const node = error;
222
+ if (node.name === "AbortError" || node.name === "TimeoutError") return true;
223
+ if (Array.isArray(node.errors) && node.errors.some((cause) => abortedBeforeResponse(cause, depth + 1))) return true;
224
+ return abortedBeforeResponse(node.cause, depth + 1);
225
+ }
226
+ /** Deadline for the reachability probe, short enough to precede a cleanup. */
227
+ const BROKER_PROBE_TIMEOUT_MS = 250;
228
+ /**
229
+ * Whether the broker still answers HTTP at all. The endpoint is unauthenticated
230
+ * and any response counts, so a live broker that rejects the request is still
231
+ * reachable. Only a dial that never opened means the broker is gone: the
232
+ * probe's own deadline, a reset, and every other transport failure are
233
+ * inconclusive, so a slow broker that already processed the send still counts
234
+ * as reachable and the caller withdraws rather than calling the ask gone.
235
+ */
236
+ async function brokerReachable(env, fetchImpl) {
237
+ try {
238
+ await fetchImpl(`${env.brokerUrl}/health`, { signal: AbortSignal.timeout(BROKER_PROBE_TIMEOUT_MS) });
239
+ return true;
240
+ } catch (error) {
241
+ return !dialNeverOpened(error);
242
+ }
243
+ }
244
+ /**
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.
249
+ */
250
+ async function sendNeverReachedBroker(error, env, fetchImpl) {
251
+ if (error instanceof BrokerHttpError) return false;
252
+ if (dialNeverOpened(error)) return true;
253
+ if (abortedBeforeResponse(error)) return false;
254
+ return !await brokerReachable(env, fetchImpl);
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
+ }
260
+ /**
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.
265
+ */
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";
323
+ const timeoutMs = normalizeAskTimeoutMs(args.timeoutMs);
324
+ const signal = combineSignals(args.signal, timeoutMs !== void 0 ? AbortSignal.timeout(timeoutMs) : void 0);
325
+ let generation;
326
+ try {
327
+ generation = await pin();
328
+ } catch (error) {
329
+ return {
330
+ outcome: pinFailureOutcome(error),
331
+ messageId
332
+ };
333
+ }
334
+ try {
335
+ await postBroker(env, "/send", buildAgentMessage({
336
+ fromRunId: env.runId,
337
+ toRunId: args.toRunId,
338
+ messageId,
339
+ message: args.message,
340
+ role,
341
+ expectsReply: true,
342
+ generation
343
+ }), fetchImpl, signal);
344
+ } catch (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
+ };
358
+ }
359
+ let reply;
360
+ try {
361
+ reply = await postBroker(env, "/wait_reply", {
362
+ waiting_for: messageId,
363
+ generation
364
+ }, fetchImpl, signal);
365
+ } catch (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
+ };
385
+ }
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);
450
+ return {
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
+ }
572
+ };
74
573
  }
75
574
  //#endregion
76
575
  //#region src/watch.ts
@@ -401,7 +900,7 @@ var RunInspectorOverlay = class {
401
900
  }
402
901
  if (!isFollowUpable(this.state.status, this.state.snapshot)) return;
403
902
  this.runAction(async () => {
404
- const next = await this.deps.followUpRun(this.state.run, text);
903
+ const next = await this.deps.resumeRun(this.state.run, text);
405
904
  this.state = {
406
905
  ...this.state,
407
906
  run: {
@@ -1005,7 +1504,7 @@ function guidance(status, pending, runId) {
1005
1504
  const options = permissionOptions(pending);
1006
1505
  if (options.length > 0) return `Guidance: Call avenor_answer_permission with run_id "${runId}" and option_id "${selected(options[0], "option_id")}".`;
1007
1506
  if (pending) return `Guidance: Permission input is required, but no options are available; call avenor_inspect with run_id "${runId}".`;
1008
- 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}".`;
1009
1508
  }
1010
1509
  if (isTerminal(status)) return `Guidance: Call avenor_result or avenor_events with run_id "${runId}".`;
1011
1510
  return `Guidance: Call avenor_result with run_id "${runId}".`;
@@ -1101,18 +1600,33 @@ function permissionLines(details, args) {
1101
1600
  `Guidance: Call avenor_status with run_id "${runId}" to verify the permission state.`
1102
1601
  ];
1103
1602
  }
1104
- function followUpLines(details, args) {
1603
+ function messageLines(details, args) {
1105
1604
  const result = asRecord(details);
1106
- if (!result || !stringValue(result.run_id) || !stringValue(result.label)) return void 0;
1107
- const runId = selected(result, "run_id", args.run_id);
1108
- const label = selected(result, "label", args.label, runId);
1109
- return [
1110
- `Follow-up: ${label} — dispatched.`,
1111
- `Source run: ${scalar(args.run_id)}`,
1112
- `Run: ${runId}`,
1113
- `Label: ${label}`,
1114
- `Guidance: Call avenor_status with run_id "${runId}".`
1115
- ];
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;
1116
1630
  }
1117
1631
  function eventSequence(value) {
1118
1632
  if (typeof value === "number" && Number.isFinite(value)) return String(value);
@@ -1334,12 +1848,21 @@ function renderAnswerPermissionCall(args, theme) {
1334
1848
  if (supervisor) pieces.push(`supervisor_id ${supervisor}`);
1335
1849
  return linesText([pieces.join(" ")], theme);
1336
1850
  }
1337
- function renderFollowUpCall(args, theme) {
1338
- const pieces = [`avenor_follow_up run_id ${quoted(args.run_id)}`, `message ${quoted(args.message)}`];
1339
- const label = optionalQuoted(args.label);
1340
- const supervisor = optionalQuoted(args.supervisor_id);
1341
- if (label) pieces.push(`label ${label}`);
1342
- 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
+ }
1343
1866
  return linesText([pieces.join(" ")], theme);
1344
1867
  }
1345
1868
  function renderEventsCall(args, theme) {
@@ -1373,8 +1896,8 @@ function renderInspectResult(result, options, theme, args) {
1373
1896
  function renderAnswerPermissionResult(result, options, theme, args) {
1374
1897
  return render("answer_permission", result, options, theme, () => permissionLines(result.details, args));
1375
1898
  }
1376
- function renderFollowUpResult(result, options, theme, args) {
1377
- 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));
1378
1901
  }
1379
1902
  function renderEventsResult(result, options, theme, args) {
1380
1903
  return render("events", result, options, theme, () => eventsLines(result.details, args, options.expanded));
@@ -1409,11 +1932,10 @@ const defaultDeps = {
1409
1932
  statusTool,
1410
1933
  eventsTool,
1411
1934
  answerPermissionTool,
1412
- followUpTool,
1935
+ messageTool,
1413
1936
  inspectTool,
1414
1937
  resultTool,
1415
1938
  brokerReceiveTool,
1416
- brokerReplyTool,
1417
1939
  shutdownTool,
1418
1940
  killTool,
1419
1941
  workflowStatusTool,
@@ -1424,35 +1946,13 @@ const defaultDeps = {
1424
1946
  workflowGateTool,
1425
1947
  workflowControllerStatusTool,
1426
1948
  workflowReadyTool,
1949
+ workflowCancelTool,
1950
+ getSupervisorClient,
1427
1951
  observeRun,
1428
1952
  dial,
1953
+ attachOwner,
1429
1954
  Supervisor
1430
1955
  };
1431
- let hostPollTimer = null;
1432
- const hostPendingAsks = /* @__PURE__ */ new Map();
1433
- function stopHostAskPoll() {
1434
- if (hostPollTimer) {
1435
- clearInterval(hostPollTimer);
1436
- hostPollTimer = null;
1437
- }
1438
- }
1439
- async function startHostAskPoll(ctx) {
1440
- stopHostAskPoll();
1441
- hostPollTimer = setInterval(async () => {
1442
- try {
1443
- const { asks } = await deps.brokerReceiveTool({});
1444
- for (const ask of asks) {
1445
- hostPendingAsks.set(ask.message_id, {
1446
- from_run_id: ask.from_run_id,
1447
- message_id: ask.message_id
1448
- });
1449
- 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: "..." })`;
1450
- await ctx.sendUserMessage(body, { deliverAs: "steer" });
1451
- }
1452
- } catch (error) {}
1453
- }, 2e3);
1454
- hostPollTimer.unref?.();
1455
- }
1456
1956
  function statusSupervisorId(requestedSupervisorId, spawnedSupervisorId) {
1457
1957
  return requestedSupervisorId ? spawnedSupervisorId : void 0;
1458
1958
  }
@@ -1489,7 +1989,7 @@ function buildCompletionText(run, result, finalOutput) {
1489
1989
  if (output) lines.push("", "Final output:", ...quotedLines(output));
1490
1990
  if (result.stop_reason && result.stop_reason.toUpperCase() !== result.status.toUpperCase()) lines.push(`Stop reason: ${result.stop_reason}`);
1491
1991
  if (result.session_id) lines.push(`Session: \`${result.session_id}\``);
1492
- 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.`);
1493
1993
  return lines.join("\n");
1494
1994
  }
1495
1995
  function buildWaitingText(run, status) {
@@ -1498,7 +1998,7 @@ function buildWaitingText(run, status) {
1498
1998
  const lines = [`Sub-agent "${run.label}" (${agent}) is waiting for input.`];
1499
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"`);
1500
2000
  else if (status.phase_label) lines.push(`Question: ${status.phase_label}`);
1501
- 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.`);
1502
2002
  return lines.join("\n");
1503
2003
  }
1504
2004
  function isTerminalStatus(status) {
@@ -1602,6 +2102,16 @@ function createExtension(deps = defaultDeps, options = {}) {
1602
2102
  const pollingErrors = [];
1603
2103
  let lastStatusEntries = [];
1604
2104
  const pendingWaits = /* @__PURE__ */ new Set();
2105
+ /** The agent-facing prose clause for a stopping point. A synthetic status
2106
+ * such as `asking` has no run-status spelling, so it must not appear raw in
2107
+ * this prose. Structured payloads still carry the raw status (the
2108
+ * avenor_result JSON reports it as `interrupted_by.status`) so consumers can
2109
+ * check it. */
2110
+ function describeStoppingPoint(stop) {
2111
+ if (stop.status === "waiting") return "reached a permission request";
2112
+ if (stop.status === "asking") return "asked a question for you";
2113
+ return `reached ${stop.status}`;
2114
+ }
1605
2115
  function interruptPendingWaits(stop, excludeRunKey) {
1606
2116
  for (const wait of [...pendingWaits]) {
1607
2117
  if (wait.runKey === excludeRunKey) continue;
@@ -1617,6 +2127,29 @@ function createExtension(deps = defaultDeps, options = {}) {
1617
2127
  pendingWaits.add(wait);
1618
2128
  return wait;
1619
2129
  }
2130
+ /** An inbound ask is a stopping point: the asker cannot finish until it is
2131
+ * answered, so every blocking wait returns, the asker's own wait included. */
2132
+ function interruptWaitsForAsk(ask) {
2133
+ interruptPendingWaits({
2134
+ runId: ask.from_run_id,
2135
+ status: "asking"
2136
+ });
2137
+ }
2138
+ /** Surface one inbound ask: hand the body to the session, remember it so a
2139
+ * reply can reach the asker, then let every blocking wait return. Delivery
2140
+ * comes first so a throw leaves the waits waiting on an ask the agent never
2141
+ * saw, rather than reporting an ask it cannot answer. */
2142
+ function surfaceInboundAsk(pending, ask, heading) {
2143
+ pi.sendUserMessage(formatInboundAskBody(ask, {
2144
+ heading,
2145
+ role: ask.role
2146
+ }), { deliverAs: "steer" });
2147
+ pending.set(ask.message_id, {
2148
+ from_run_id: ask.from_run_id,
2149
+ message_id: ask.message_id
2150
+ });
2151
+ interruptWaitsForAsk(ask);
2152
+ }
1620
2153
  function createInterruptibleWait(runKey, signal) {
1621
2154
  const controller = new AbortController();
1622
2155
  let stopped;
@@ -1695,7 +2228,7 @@ function createExtension(deps = defaultDeps, options = {}) {
1695
2228
  subAgentPollTimer = null;
1696
2229
  }
1697
2230
  }
1698
- async function startSubAgentPoll(ctx) {
2231
+ async function startSubAgentPoll() {
1699
2232
  if (!subAgentEnv) return;
1700
2233
  stopSubAgentPoll();
1701
2234
  const generation = subAgentPollGeneration;
@@ -1704,16 +2237,13 @@ function createExtension(deps = defaultDeps, options = {}) {
1704
2237
  const tick = async () => {
1705
2238
  try {
1706
2239
  const msgs = await postBroker(subAgentEnv, "/poll-control", {}) ?? [];
2240
+ if (generation !== subAgentPollGeneration) return;
1707
2241
  if (consecutiveFailures >= 3) console.error("[avenor] sub-agent broker poll recovered");
1708
2242
  consecutiveFailures = 0;
1709
2243
  for (const msg of msgs) {
1710
2244
  const ask = parseControlMessage(msg);
1711
2245
  if (!ask) continue;
1712
- subAgentPendingAsks.set(ask.message_id, {
1713
- from_run_id: ask.from_run_id,
1714
- message_id: ask.message_id
1715
- });
1716
- await ctx.sendUserMessage(formatInboundAskBody(ask), { deliverAs: "steer" });
2246
+ surfaceInboundAsk(subAgentPendingAsks, ask, "Inbound ask");
1717
2247
  }
1718
2248
  } catch (error) {
1719
2249
  consecutiveFailures++;
@@ -1726,6 +2256,48 @@ function createExtension(deps = defaultDeps, options = {}) {
1726
2256
  subAgentPollTimer = setTimeout(() => void tick(), intervalMs);
1727
2257
  subAgentPollTimer.unref?.();
1728
2258
  }
2259
+ let hostPollTimer = null;
2260
+ let hostPollGeneration = 0;
2261
+ let hostReceiveGeneration;
2262
+ const hostPendingAsks = /* @__PURE__ */ new Map();
2263
+ function stopHostAskPoll() {
2264
+ hostPollGeneration++;
2265
+ if (hostPollTimer) {
2266
+ clearTimeout(hostPollTimer);
2267
+ hostPollTimer = null;
2268
+ }
2269
+ }
2270
+ function startHostAskPoll() {
2271
+ stopHostAskPoll();
2272
+ const generation = hostPollGeneration;
2273
+ const intervalMs = options.pollIntervalMs ?? 2e3;
2274
+ const tick = async () => {
2275
+ const startedAt = Date.now();
2276
+ try {
2277
+ const receive = () => hostReceiveGeneration ? deps.brokerReceiveTool({
2278
+ generation: hostReceiveGeneration,
2279
+ timeout_ms: intervalMs
2280
+ }) : deps.brokerReceiveTool({ timeout_ms: intervalMs });
2281
+ let received;
2282
+ try {
2283
+ received = await receive();
2284
+ } catch (error) {
2285
+ if (!hostReceiveGeneration || !isGenerationMismatchError(error)) throw error;
2286
+ hostReceiveGeneration = void 0;
2287
+ disposeHostProviders();
2288
+ received = await receive();
2289
+ }
2290
+ if (generation !== hostPollGeneration) return;
2291
+ if (received.generation) hostReceiveGeneration = received.generation;
2292
+ for (const ask of received.asks) surfaceInboundAsk(hostPendingAsks, ask, "Ask from sub-agent");
2293
+ } catch {}
2294
+ if (generation !== hostPollGeneration) return;
2295
+ hostPollTimer = setTimeout(() => void tick(), Math.max(0, intervalMs - (Date.now() - startedAt)));
2296
+ hostPollTimer.unref?.();
2297
+ };
2298
+ hostPollTimer = setTimeout(() => void tick(), intervalMs);
2299
+ hostPollTimer.unref?.();
2300
+ }
1729
2301
  function recordPollingError(source, message, error, runId) {
1730
2302
  pollingErrorCount++;
1731
2303
  const payload = createPollErrorPayload({
@@ -2010,7 +2582,7 @@ function createExtension(deps = defaultDeps, options = {}) {
2010
2582
  }
2011
2583
  async function withClient(run, work) {
2012
2584
  if (!run.supervisorId || deps.Supervisor.isCurrentInstance(run.supervisorId)) return work((await deps.Supervisor.get()).getClient());
2013
- const client = await deps.dial(run.supervisorId);
2585
+ const client = await deps.attachOwner(run.supervisorId);
2014
2586
  try {
2015
2587
  return await work(client);
2016
2588
  } finally {
@@ -2048,44 +2620,22 @@ function createExtension(deps = defaultDeps, options = {}) {
2048
2620
  await client.interruptAndPrompt(runtimeId, text);
2049
2621
  });
2050
2622
  },
2051
- followUpRun(run, text) {
2052
- return deps.followUpTool({
2053
- runId: run.runId,
2054
- message: text,
2055
- supervisorId: run.supervisorId
2056
- });
2623
+ resumeRun(run, text) {
2624
+ return resumeTrackedRun(run, text);
2057
2625
  }
2058
2626
  };
2059
2627
  }
2060
- async function followUpTrackedRun(run, text) {
2061
- const result = await deps.followUpTool({
2062
- runId: run.runId,
2063
- message: text,
2064
- supervisorId: run.supervisorId
2065
- });
2066
- try {
2067
- const status = asStatusResult(await deps.statusTool({
2068
- runId: result.run_id,
2069
- supervisorId: run.supervisorId
2070
- }));
2071
- upsertTrackedRun({
2072
- runId: result.run_id,
2073
- label: result.label,
2074
- agent: status?.agent ?? run.agent,
2075
- runtimeId: status?.runtime_id,
2076
- supervisorId: run.supervisorId
2077
- }, status, false);
2078
- startPolling();
2079
- } catch {
2080
- upsertTrackedRun({
2081
- runId: result.run_id,
2082
- label: result.label,
2083
- agent: run.agent,
2084
- supervisorId: run.supervisorId
2085
- }, void 0, false);
2086
- startPolling();
2087
- }
2088
- return result;
2628
+ async function resumeTrackedRun(run, text) {
2629
+ const provider = await hostMessageProvider(run.supervisorId);
2630
+ const result = await deps.messageTool({
2631
+ action: "resume",
2632
+ target: run.runId,
2633
+ message: text
2634
+ }, provider);
2635
+ return {
2636
+ run_id: result.runId ?? run.runId,
2637
+ label: result.label ?? run.runId
2638
+ };
2089
2639
  }
2090
2640
  async function waitForRunWithObserver(run, signal, onUpdate) {
2091
2641
  let observer = null;
@@ -2193,20 +2743,117 @@ function createExtension(deps = defaultDeps, options = {}) {
2193
2743
  if (observed.status || observed.aborted) return observed;
2194
2744
  return waitForRunByPolling(run, signal, onUpdate);
2195
2745
  }
2746
+ const hostProviders = /* @__PURE__ */ new Map();
2747
+ const hostAskClaims = /* @__PURE__ */ new Map();
2748
+ function disposeHostProvider(supervisorId) {
2749
+ const key = supervisorId ?? "";
2750
+ const binding = hostProviders.get(key);
2751
+ hostProviders.delete(key);
2752
+ hostAskClaims.delete(key);
2753
+ if (!binding || binding.isSingleton || !binding.client) return;
2754
+ try {
2755
+ binding.client.close();
2756
+ } catch {}
2757
+ }
2758
+ function disposeHostProviders() {
2759
+ for (const key of [...hostProviders.keys()]) disposeHostProvider(key);
2760
+ hostAskClaims.clear();
2761
+ }
2762
+ async function defaultHostProviderFor(supervisorId) {
2763
+ const key = supervisorId ?? "";
2764
+ const existing = hostProviders.get(key);
2765
+ if (existing) return existing.provider;
2766
+ const { client, isSingleton } = await (deps.getSupervisorClient ?? getSupervisorClient)(supervisorId, { owner: true });
2767
+ const preflight = await client.messagePreflight();
2768
+ const provider = createHostMessageProvider({
2769
+ surface: "pi-host",
2770
+ supervisorId: supervisorId ?? Supervisor.currentInstance()?.supervisorId ?? "",
2771
+ generation: preflight.generation,
2772
+ client,
2773
+ onResumed: (result) => trackResumed(supervisorId, result)
2774
+ });
2775
+ hostProviders.set(key, {
2776
+ provider,
2777
+ client,
2778
+ isSingleton
2779
+ });
2780
+ return provider;
2781
+ }
2782
+ async function trackResumed(supervisorId, result) {
2783
+ const run = {
2784
+ runId: result.runId ?? "",
2785
+ label: result.label ?? result.runId ?? "",
2786
+ supervisorId
2787
+ };
2788
+ try {
2789
+ const status = asStatusResult(await deps.statusTool({
2790
+ runId: run.runId,
2791
+ supervisorId
2792
+ }));
2793
+ upsertTrackedRun({
2794
+ runId: run.runId,
2795
+ label: run.label,
2796
+ agent: status?.agent,
2797
+ runtimeId: status?.runtime_id,
2798
+ supervisorId
2799
+ }, status, false);
2800
+ } catch {
2801
+ upsertTrackedRun(run, void 0, false);
2802
+ }
2803
+ startPolling();
2804
+ }
2805
+ const hostProviderFor = deps.hostProviderFor ?? defaultHostProviderFor;
2806
+ const childProvider = isSubAgentBrokerMode && subAgentEnv ? createChildMessageProvider({
2807
+ surface: "pi-child",
2808
+ runId: subAgentEnv.runId,
2809
+ broker: createBrokerChildBroker(subAgentEnv),
2810
+ pendingAsks: subAgentPendingAsks,
2811
+ pollGeneration: () => subAgentPollGeneration
2812
+ }) : null;
2813
+ async function hostMessageProvider(supervisorId) {
2814
+ const provider = await hostProviderFor(supervisorId);
2815
+ const key = supervisorId ?? "";
2816
+ let claims = hostAskClaims.get(key);
2817
+ if (!claims) {
2818
+ claims = /* @__PURE__ */ new Map();
2819
+ hostAskClaims.set(key, claims);
2820
+ }
2821
+ installAskClaims(provider, claims, hostPendingAsks, () => hostPollGeneration);
2822
+ if (!provider.onResumed) provider.onResumed = (result) => trackResumed(supervisorId, result);
2823
+ return provider;
2824
+ }
2825
+ async function messageProvider(supervisorId) {
2826
+ if (childProvider) return childProvider;
2827
+ return hostMessageProvider(supervisorId);
2828
+ }
2829
+ pi.on("turn_end", async (event) => {
2830
+ if (isSubAgentBrokerMode) return;
2831
+ if (!deps.Supervisor.currentInstance?.()) return;
2832
+ const turn = extractTurnText([event.message]);
2833
+ try {
2834
+ const provider = await hostProviderFor(void 0);
2835
+ if (!provider.recordLastTurn) return;
2836
+ const bounded = boundFinalOutput(turn.text);
2837
+ await provider.recordLastTurn(bounded, normalizeTurnStopReason(turn.stopReason), turn.text.length > bounded.length);
2838
+ } catch (error) {
2839
+ console.error("[avenor] session_last_set failed:", error instanceof Error ? error.message : error);
2840
+ }
2841
+ });
2196
2842
  pi.on("session_start", async (_event, ctx) => {
2197
2843
  sessionCtx = ctx;
2198
2844
  lastStatusEntries = [];
2199
2845
  pollingErrorCount = 0;
2200
2846
  pollingErrors.length = 0;
2201
- startHostAskPoll(ctx);
2202
- startSubAgentPoll(ctx);
2847
+ if (!isSubAgentBrokerMode) startHostAskPoll();
2848
+ startSubAgentPoll();
2203
2849
  });
2204
2850
  pi.on("session_shutdown", async () => {
2205
- await stopPolling();
2206
2851
  stopHostAskPoll();
2207
2852
  stopSubAgentPoll();
2853
+ disposeHostProviders();
2208
2854
  hostPendingAsks.clear();
2209
2855
  subAgentPendingAsks.clear();
2856
+ await stopPolling();
2210
2857
  for (const wait of [...pendingWaits]) {
2211
2858
  pendingWaits.delete(wait);
2212
2859
  wait.interrupt({
@@ -2243,15 +2890,15 @@ function createExtension(deps = defaultDeps, options = {}) {
2243
2890
  pi.registerTool({
2244
2891
  name: "avenor_spawn",
2245
2892
  label: "Avenor Spawn",
2246
- 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.`,
2893
+ 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.`,
2247
2894
  promptSnippet: "Dispatch sub-agent runs via avenor (spawn, status, result, inspect, events, follow-up, shutdown)",
2248
2895
  promptGuidelines: [
2249
2896
  "Use avenor_spawn to delegate well-defined tasks to sub-agents.",
2250
2897
  "Use avenor_status (view \"lifecycle\") only to check progress or pending permissions.",
2251
- "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.",
2898
+ "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.",
2252
2899
  "Use avenor_inspect to review a bounded transcript and tool activity from a run.",
2253
2900
  "Use avenor_events only when you specifically need raw event payloads.",
2254
- "Use avenor_follow_up to iterate on a completed run.",
2901
+ "Use avenor_message with action \"resume\" to iterate on a completed run.",
2255
2902
  "Use avenor_shutdown when done delegating work.",
2256
2903
  "When roster_entry is set, the roster selects agent, model, and backend; do not pass those fields alongside it.",
2257
2904
  "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."
@@ -2372,7 +3019,7 @@ function createExtension(deps = defaultDeps, options = {}) {
2372
3019
  return {
2373
3020
  content: [{
2374
3021
  type: "text",
2375
- 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.`
3022
+ 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.`
2376
3023
  }],
2377
3024
  details: {
2378
3025
  run_id: result.run_id,
@@ -2538,7 +3185,7 @@ function createExtension(deps = defaultDeps, options = {}) {
2538
3185
  pi.registerTool({
2539
3186
  name: "avenor_result",
2540
3187
  label: "Avenor Result",
2541
- 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.`,
3188
+ 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.`,
2542
3189
  parameters: Type.Object({
2543
3190
  run_id: Type.String({ description: "Run ID or label" }),
2544
3191
  wait: Type.Optional(Type.Boolean({ description: "Wait for a terminal result (default true); false retrieves the current state without blocking" })),
@@ -2673,183 +3320,97 @@ function createExtension(deps = defaultDeps, options = {}) {
2673
3320
  }
2674
3321
  });
2675
3322
  pi.registerTool({
2676
- name: "avenor_follow_up",
2677
- label: "Avenor Follow Up",
2678
- description: "Resume a completed run with a follow-up message.",
3323
+ name: "avenor_message",
3324
+ label: "Avenor Message",
3325
+ 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.",
2679
3326
  parameters: Type.Object({
2680
- run_id: Type.String({ description: "Completed run ID to resume" }),
2681
- message: Type.String({ description: "Follow-up message text" }),
2682
- label: Type.Optional(Type.String({ description: "Override label (defaults to <original>-followup)" })),
2683
- supervisor_id: Type.Optional(Type.String({ description: "Reuse an existing supervisor by socket path" }))
3327
+ action: Type.Union([
3328
+ Type.Literal("tell"),
3329
+ Type.Literal("ask"),
3330
+ Type.Literal("reply"),
3331
+ Type.Literal("resume"),
3332
+ Type.Literal("withdraw"),
3333
+ Type.Literal("control_reply")
3334
+ ], { description: "tell | ask | reply | resume | withdraw | control_reply" }),
3335
+ 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" })),
3336
+ message: Type.Optional(Type.String({ description: "Message text for tell/ask/reply/resume" })),
3337
+ reply_to: Type.Optional(Type.String({ description: "Inbound ask id being answered (reply)" })),
3338
+ message_id: Type.Optional(Type.String({ description: "Ask id being withdrawn (withdraw)" })),
3339
+ timeout_ms: Type.Optional(Type.Integer({ description: "Bound on the ask wait in milliseconds" })),
3340
+ label: Type.Optional(Type.String({ description: "Override label for the resumed run (resume)" })),
3341
+ idempotency_key: Type.Optional(Type.String({ description: "Forwarded verbatim to the resume authority (resume)" })),
3342
+ payload: Type.Optional(Type.Unknown({ description: "Control reply body (control_reply)" }))
2684
3343
  }),
2685
- async execute(_toolCallId, params) {
2686
- const result = await deps.followUpTool({
2687
- runId: params.run_id,
2688
- message: params.message,
2689
- label: params.label,
2690
- supervisorId: params.supervisor_id
2691
- });
2692
- try {
2693
- const status = asStatusResult(await deps.statusTool({
2694
- runId: result.run_id,
2695
- supervisorId: params.supervisor_id
2696
- }));
2697
- upsertTrackedRun({
2698
- runId: result.run_id,
2699
- label: result.label,
2700
- agent: status?.agent,
2701
- runtimeId: status?.runtime_id,
2702
- supervisorId: params.supervisor_id
2703
- }, status, false);
2704
- } catch {
2705
- upsertTrackedRun({
2706
- runId: result.run_id,
2707
- label: result.label,
2708
- supervisorId: params.supervisor_id
2709
- }, void 0, false);
2710
- }
2711
- startPolling();
3344
+ async execute(_toolCallId, params, signal) {
3345
+ const provider = await messageProvider(void 0);
3346
+ const result = await deps.messageTool(params, provider, { signal });
2712
3347
  return {
2713
3348
  content: [{
2714
3349
  type: "text",
2715
- text: JSON.stringify(result, null, 2)
3350
+ text: result.text ?? ""
2716
3351
  }],
2717
- details: result
3352
+ details: resultOutput(result)
2718
3353
  };
2719
3354
  },
2720
- renderCall(args, theme, context) {
2721
- return renderFollowUpCall(context.args, theme);
3355
+ renderCall(args, theme) {
3356
+ return renderMessageResumeCall(args, theme);
2722
3357
  },
2723
3358
  renderResult(result, { expanded, isPartial }, theme, context) {
2724
- return renderFollowUpResult(result, {
3359
+ return renderMessageResumeResult(result, {
2725
3360
  expanded,
2726
3361
  isPartial
2727
3362
  }, theme, context.args);
2728
3363
  }
2729
3364
  });
2730
3365
  pi.registerTool({
2731
- name: "avenor_ask",
2732
- label: "Avenor Ask",
2733
- 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.",
3366
+ name: "avenor_peers",
3367
+ label: "Avenor Peers",
3368
+ 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.",
2734
3369
  parameters: Type.Object({
2735
- to_run_id: Type.String({ description: "Target run ID" }),
2736
- message: Type.String({ description: "Question to ask the other agent" }),
2737
- timeout_ms: Type.Optional(Type.Integer({ description: "Optional timeout in milliseconds for the ask" })),
2738
- supervisor_id: Type.Optional(Type.String({ description: "Reuse an existing supervisor by socket path" }))
3370
+ action: Type.Optional(Type.Union([
3371
+ Type.Literal("list"),
3372
+ Type.Literal("rename"),
3373
+ Type.Literal("last")
3374
+ ], { description: "list (the default) | rename | last" })),
3375
+ name: Type.Optional(Type.String({ description: "New display name (rename)" })),
3376
+ session_id: Type.Optional(Type.String({ description: "Run to rename; omitted renames the caller (rename)" })),
3377
+ target: Type.Optional(Type.String({ description: "Peer to read: a run id, a label, or \"self\" (last)" }))
2739
3378
  }),
2740
3379
  async execute(_toolCallId, params) {
2741
- return askTool({
2742
- toRunId: params.to_run_id,
2743
- message: params.message,
2744
- timeoutMs: params.timeout_ms,
2745
- supervisorId: params.supervisor_id
2746
- });
3380
+ const result = await peersTool(params, await messageProvider(void 0));
3381
+ return {
3382
+ content: [{
3383
+ type: "text",
3384
+ text: result.action === "list" ? [result.text, renderPeerList(result.peers)].filter((part) => part !== "").join("\n\n") : result.text
3385
+ }],
3386
+ details: peersDetails(result)
3387
+ };
2747
3388
  }
2748
3389
  });
2749
3390
  pi.registerTool({
2750
- name: "avenor_reply",
2751
- label: "Avenor Reply (host)",
2752
- 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.",
3391
+ name: "avenor_workflow_cancel",
3392
+ label: "Avenor Workflow Cancel",
3393
+ description: "Cancel a workflow instance. The actor and reason are required.",
2753
3394
  parameters: Type.Object({
2754
- from_run_id: Type.Optional(Type.String({ description: "Sub-agent run id to reply to (the asker)" })),
2755
- reply_to_message_id: Type.Optional(Type.String({ description: "The ask's broker message id to answer specifically" })),
2756
- message: Type.String({ description: "Your answer" }),
2757
- supervisor_id: Type.Optional(Type.String({ description: "Reuse an existing supervisor by socket path" }))
3395
+ workflow_id: Type.String({ description: "Workflow ID" }),
3396
+ actor: Type.String({ description: "Who is cancelling" }),
3397
+ reason: Type.String({ description: "Why the workflow is being cancelled" }),
3398
+ supervisor_id: Type.Optional(Type.String({ description: "Supervisor ID for multi-supervisor mode" }))
2758
3399
  }),
2759
3400
  async execute(_toolCallId, params) {
2760
- if (isSubAgentBrokerMode && subAgentEnv) {
2761
- const target = resolveReplyTarget(subAgentPendingAsks, params.from_run_id, params.reply_to_message_id);
2762
- if (!target) return {
2763
- content: [{
2764
- type: "text",
2765
- text: "No pending ask found to reply to. Pass from_run_id and/or reply_to_message_id."
2766
- }],
2767
- details: { error: true }
2768
- };
2769
- subAgentPendingAsks.delete(target.message_id);
2770
- try {
2771
- await postBroker(subAgentEnv, "/send", {
2772
- from_run_id: subAgentEnv.runId,
2773
- to_run_id: target.from_run_id,
2774
- type: "agent_message",
2775
- payload: {
2776
- id: randomUUID(),
2777
- from_run_id: subAgentEnv.runId,
2778
- to_run_id: target.from_run_id,
2779
- message: params.message,
2780
- role: "agent",
2781
- reply_to: target.message_id
2782
- }
2783
- });
2784
- } catch (error) {
2785
- subAgentPendingAsks.set(target.message_id, target);
2786
- throw error;
2787
- }
2788
- return { content: [{
2789
- type: "text",
2790
- text: "Reply sent"
2791
- }] };
2792
- }
2793
- let fromRunId = params.from_run_id;
2794
- let messageId = params.reply_to_message_id;
2795
- if (!messageId) {
2796
- let match;
2797
- for (const [, pending] of hostPendingAsks) if (pending.from_run_id === fromRunId && (!match || pending.message_id > match.message_id)) match = pending;
2798
- if (match) messageId = match.message_id;
2799
- }
2800
- if (!fromRunId || !messageId) return {
3401
+ const result = await deps.workflowCancelTool({
3402
+ workflow_id: params.workflow_id,
3403
+ actor: params.actor,
3404
+ reason: params.reason,
3405
+ supervisor_id: params.supervisor_id
3406
+ });
3407
+ return {
2801
3408
  content: [{
2802
3409
  type: "text",
2803
- text: "No pending ask found to reply to. Pass from_run_id and/or reply_to_message_id."
3410
+ text: JSON.stringify(result, null, 2)
2804
3411
  }],
2805
- details: { error: true }
3412
+ details: result
2806
3413
  };
2807
- await deps.brokerReplyTool({
2808
- toRunId: fromRunId,
2809
- replyTo: messageId,
2810
- message: params.message,
2811
- supervisorId: params.supervisor_id
2812
- });
2813
- hostPendingAsks.delete(messageId);
2814
- return { content: [{
2815
- type: "text",
2816
- text: "Reply sent"
2817
- }] };
2818
- }
2819
- });
2820
- pi.registerTool({
2821
- name: "avenor_peers",
2822
- label: "Avenor Peers",
2823
- description: "List all connected agent sessions with their status and metadata.",
2824
- parameters: Type.Object({}),
2825
- async execute() {
2826
- const result = await peersTool({});
2827
- if (result.peers.length === 0) return { content: [{
2828
- type: "text",
2829
- text: "No other agent sessions connected."
2830
- }] };
2831
- return { content: [{
2832
- type: "text",
2833
- text: `**Active sessions:**\n${result.peers.map((p) => {
2834
- const parts = [p.label ? `${p.label} (${p.run_id})` : p.run_id];
2835
- const tags = [
2836
- p.status,
2837
- p.backend,
2838
- p.model
2839
- ].filter(Boolean);
2840
- if (tags.length) parts.push(`[${tags.join(", ")}]`);
2841
- return ` ${parts.join(" ")}`;
2842
- }).join("\n")}`
2843
- }] };
2844
- }
2845
- });
2846
- pi.registerTool({
2847
- name: "avenor_cancel",
2848
- label: "Avenor Cancel",
2849
- description: "Cancel a pending ask by its message ID.",
2850
- parameters: Type.Object({ message_id: Type.String({ description: "Message ID of the pending ask to cancel" }) }),
2851
- async execute(_toolCallId, params) {
2852
- return cancelTool({ messageId: params.message_id });
2853
3414
  }
2854
3415
  });
2855
3416
  pi.registerTool({
@@ -2914,6 +3475,8 @@ function createExtension(deps = defaultDeps, options = {}) {
2914
3475
  supervisorId: params.supervisor_id,
2915
3476
  force: params.force
2916
3477
  });
3478
+ disposeHostProvider(params.supervisor_id);
3479
+ if (targetIsSingleton && target !== void 0) disposeHostProvider(void 0);
2917
3480
  return {
2918
3481
  content: [{
2919
3482
  type: "text",
@@ -3206,7 +3769,7 @@ function createExtension(deps = defaultDeps, options = {}) {
3206
3769
  initialRunId: args?.trim() || void 0,
3207
3770
  deps: {
3208
3771
  ...createWatchDeps(),
3209
- followUpRun: (run, text) => followUpTrackedRun(run, text)
3772
+ resumeRun: (run, text) => resumeTrackedRun(run, text)
3210
3773
  },
3211
3774
  onTrackRun: (run, status) => {
3212
3775
  upsertTrackedRun(run, status, false);