@dougbots/avenor-pi 0.16.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,6 +14,10 @@ which avenor
14
14
  export AVENOR_BIN=~/.botfiles/bin/avenor
15
15
  ```
16
16
 
17
+ You also need Pi 1.4.0 or newer (`pi --version`). Busy-session delivery depends on
18
+ the `agent_before_settle` hook, so a notice raised while you were busy needs that
19
+ version to reach you. A missing notification is not a lost run.
20
+
17
21
  ## Installation
18
22
 
19
23
  ### From npm (once published)
@@ -144,8 +148,9 @@ Each roster entry must contain `backend` and at least one of `agent` or `model`.
144
148
  Beyond the tools, the extension integrates with Pi's TUI and event system:
145
149
 
146
150
  - **Status widget** — persistent widget showing all active runs with status, phase, and permission state
147
- - **Footer status** — active runs and polling error counts shown in the Pi footer bar
148
- - **Polling diagnostics** — bounded polling errors are emitted on `avenor:poll:error` for companion extensions
151
+ - **Footer status** — the `avenor` status key carries active runs, the live agent-tree total, the unread notice count, and the current poll-failure count (see [Completion notices](#completion-notices))
152
+ - **Completion notices** — one notice per finished run, delivered immediately when your session is idle and batched into a single continuation when it was busy
153
+ - **Polling diagnostics** — bounded polling errors are emitted on `avenor:poll:error` for companion extensions; a completion-notice delivery that exhausts its retries is emitted separately on `avenor:delivery:error`
149
154
  - **Live progress** — blocking `avenor_spawn` calls stream progress updates via `onUpdate`
150
155
  - **Context enrichment** — active sub-agents are automatically surfaced in the system prompt via `before_agent_start`
151
156
  - **Custom rendering** — The Pi extension renders all Avenor tool calls and results as themed, bounded summaries. Results have collapsed and expanded views.
@@ -155,6 +160,56 @@ Beyond the tools, the extension integrates with Pi's TUI and event system:
155
160
  - `/avenor-watch <run_id>` — open a live event feed overlay
156
161
  - `/avenor-cancel <run_id>` — cancel a running sub-agent
157
162
 
163
+ ### Completion notices
164
+
165
+ A fire-and-forget spawn (`wait: false`) promises one thing: you hear about the
166
+ run when it ends. Each finished run produces one notice — label, terminal
167
+ status, bounded output preview — and where it lands depends on your session at
168
+ that moment.
169
+
170
+ - **Idle.** The notice is sent as a user message and starts its own turn. It
171
+ counts as unread until Pi confirms the message landed, and while compaction is
172
+ running the send waits for it to finish. A send that is accepted but never
173
+ confirmed is sent at most three times; once that budget is spent the notice
174
+ stays unread for the next settlement and the failure is emitted on
175
+ `avenor:delivery:error`.
176
+ - **Busy.** The notice is queued and the footer shows `unread:N`. The next
177
+ settlement that completes normally appends each queued notice as an
178
+ `avenor-completion` custom message under a single continuation, so three runs
179
+ finishing while you work mean three messages and one wake-up. A settlement
180
+ that ends in abort or error delivers nothing; the notices wait.
181
+
182
+ Every terminal status queues a notice — `done`, `failed`, `timeout`, `killed`.
183
+
184
+ `avenor_result` and a blocking `avenor_spawn` (`wait: true`) own delivery while
185
+ they run; `avenor_result` takes the label you spawned with, so you need not keep
186
+ the id. A `ready` result consumes the run: the queued notice is dropped,
187
+ `unread:N` falls by one, and nothing arrives for it later. A result that is not
188
+ `ready` — interrupted by a sibling's stopping point, timed out, or reporting a
189
+ pending permission — consumes nothing, and its notice arrives on the normal
190
+ schedule. A blocking spawn that reaches completion returns the result inline and
191
+ removes the notice; the footer can show a transient `unread:N` for that run while
192
+ the wait is in flight.
193
+
194
+ #### Footer status
195
+
196
+ One string under the status key `avenor`, in this order:
197
+
198
+ | Token | Meaning |
199
+ |---|---|
200
+ | `🟢 label — running [phase] 🔒 +2` | one per non-terminal run: status emoji, label, status, phase label, `🔒` for a pending permission, `+N` for that run's active descendants |
201
+ | `total:N` | direct active runs plus discovered active descendants; present only when active descendants exist |
202
+ | `unread:N` | notices not yet delivered: queued for a settlement, or in flight for an idle session |
203
+ | `errors:N` | recent poll failures; cleared by a cycle that adds no new error |
204
+
205
+ Missing `unread:N` or `errors:N` means zero. Without `total:N`, count the active run lines. `unread:N` counts notices,
206
+ not runs, and can stay set after the last run exits — an otherwise idle session
207
+ can still advertise `unread:1` until you consume or receive that notice.
208
+ `total:N` tracks live runs and disappears with them. `errors:N` outlives them
209
+ too: it clears when a later poll cycle adds no new error, so it counts recent
210
+ failures rather than a consecutive streak. The bounded history behind
211
+ `/avenor-errors` is longer than `errors:N`; running that command clears both.
212
+
158
213
  ### Tool-result channels
159
214
 
160
215
  Pi returns JSON model content in `content[0].text` and structured `details` for
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { InspectResult, MessageProvider, StatusResult, Supervisor, answerPermissionTool, attachOwner, brokerReceiveTool, dial, eventsTool, getSupervisorClient, inspectTool, killTool, messageTool, observeRun, resultTool, shutdownTool, spawnTool, statusTool, workflowCancelTool, workflowCompleteTool, workflowControllerStatusTool, workflowEventsTool, workflowGateTool, workflowInspectTool, workflowReadyTool, workflowStatusTool, workflowWaitTool } from "@dougbots/avenor-core";
1
+ import { InspectResult, MessageProvider, RunObserver, StatusResult, Supervisor, answerPermissionTool, attachOwner, brokerReceiveTool, dial, eventsTool, getSupervisorClient, inspectTool, killTool, messageTool, observeRun, resultTool, shutdownTool, spawnTool, statusTool, workflowCancelTool, workflowCompleteTool, workflowControllerStatusTool, workflowEventsTool, workflowGateTool, workflowInspectTool, workflowReadyTool, workflowStatusTool, workflowWaitTool } from "@dougbots/avenor-core";
2
2
  import { EventBus, ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
 
4
4
  //#region src/subagent-broker.d.ts
@@ -37,7 +37,11 @@ declare const CHANNEL_POLL_COMPLETED: "avenor:poll:completed";
37
37
  declare const CHANNEL_RUN_TERMINAL: "avenor:run:terminal";
38
38
  /** Emitted when a best-effort polling operation fails. */
39
39
  declare const CHANNEL_POLL_ERROR: "avenor:poll:error";
40
+ /** Emitted when a completion-notice delivery fails permanently. */
41
+ declare const CHANNEL_DELIVERY_ERROR: "avenor:delivery:error";
40
42
  type PollErrorSource = 'singleton-list' | 'run-status' | 'spawn-status';
43
+ /** Delivery paths whose retries can be exhausted. */
44
+ type DeliveryErrorSource = 'idle-send';
41
45
  interface PollErrorPayload {
42
46
  /** Polling operation that failed. */
43
47
  readonly source: PollErrorSource;
@@ -60,6 +64,31 @@ interface PollErrorPayloadInput {
60
64
  readonly count: number;
61
65
  readonly timestamp: number;
62
66
  }
67
+ interface DeliveryErrorPayload {
68
+ /** Delivery path that failed. */
69
+ readonly source: DeliveryErrorSource;
70
+ /** Run whose completion notice is stuck unread. */
71
+ readonly runId: string;
72
+ /** Label of the run, as shown in completion notices. */
73
+ readonly label: string;
74
+ /** Human-readable failure description. */
75
+ readonly message: string;
76
+ /** Bounded, sanitized error text suitable for debug consumers. */
77
+ readonly error: string;
78
+ /** Delivery attempts made for this notice. */
79
+ readonly attempts: number;
80
+ /** Wall-clock timestamp in epoch milliseconds. */
81
+ readonly timestamp: number;
82
+ }
83
+ interface DeliveryErrorPayloadInput {
84
+ readonly source: DeliveryErrorSource;
85
+ readonly runId: string;
86
+ readonly label: string;
87
+ readonly message: string;
88
+ readonly error: unknown;
89
+ readonly attempts: number;
90
+ readonly timestamp: number;
91
+ }
63
92
  type AvenorRunStatus = Readonly<Omit<RunStatusEntry, 'supervisorId' | 'pid' | 'permissionDescription'> & {
64
93
  supervisorKey: string;
65
94
  }>;
@@ -79,7 +108,7 @@ interface RunTerminalPayload {
79
108
  readonly label: string;
80
109
  readonly agent: string;
81
110
  readonly status: string;
82
- /** Number of active descendants when the run was removed, if available. */
111
+ /** Number of active descendants last seen for the run, if available. */
83
112
  readonly nestedCount?: number;
84
113
  readonly backend?: string;
85
114
  }
@@ -98,6 +127,7 @@ interface AvenorEventMap {
98
127
  [CHANNEL_POLL_COMPLETED]: PollCompletedPayload;
99
128
  [CHANNEL_RUN_TERMINAL]: RunTerminalPayload;
100
129
  [CHANNEL_POLL_ERROR]: PollErrorPayload;
130
+ [CHANNEL_DELIVERY_ERROR]: DeliveryErrorPayload;
101
131
  }
102
132
  type AvenorEventChannel = keyof AvenorEventMap;
103
133
  type AvenorEventBus = Pick<EventBus, 'emit' | 'on'>;
@@ -113,6 +143,8 @@ declare function onAvenorEvent<K extends AvenorEventChannel>(bus: Pick<EventBus,
113
143
  declare function createPollCompletedPayload(entries: readonly RunStatusEntry[], timestamp: number, generation: number): PollCompletedPayload;
114
144
  /** Copy and freeze a terminal payload before publishing it. */
115
145
  declare function createRunTerminalPayload(payload: RunTerminalPayloadInput): RunTerminalPayload;
146
+ /** Copy, bound, sanitize, and freeze a delivery failure before publishing it. */
147
+ declare function createDeliveryErrorPayload(payload: DeliveryErrorPayloadInput): DeliveryErrorPayload;
116
148
  /** Copy, bound, sanitize, and freeze a polling error before publishing it. */
117
149
  declare function createPollErrorPayload(payload: PollErrorPayloadInput): PollErrorPayload;
118
150
  //#endregion
@@ -141,7 +173,9 @@ interface ExtensionDeps {
141
173
  hostProviderFor?: (supervisorId: string | undefined) => Promise<MessageProvider>;
142
174
  /** Dial a supervisor for the default host provider; tests inject a fake client. */
143
175
  getSupervisorClient?: typeof getSupervisorClient;
144
- observeRun: typeof observeRun;
176
+ /** A watch transport may decline to observe (no runtime id yet), so a test
177
+ * double that only asserts the call can answer null. */
178
+ observeRun: (client: Parameters<typeof observeRun>[0], options?: Parameters<typeof observeRun>[1]) => RunObserver | null;
145
179
  dial: typeof dial;
146
180
  attachOwner: typeof attachOwner;
147
181
  Supervisor: typeof Supervisor;
@@ -167,10 +201,13 @@ declare function buildInspectPayload(result: InspectResult): Record<string, unkn
167
201
  interface ExtensionOptions {
168
202
  /** Polling cadence; injectable so tests can drive deterministic ticks. */
169
203
  pollIntervalMs?: number;
204
+ /** How long an unconfirmed idle completion send waits before re-arming;
205
+ * injectable so tests can exercise the timeout without fixed sleeps. */
206
+ idleSendTimeoutMs?: number;
170
207
  /** Overrides broker-credential autodetection for sub-agent broker mode (tests). */
171
208
  subAgentEnv?: SubAgentEnv | null;
172
209
  }
173
210
  declare function createExtension(deps?: ExtensionDeps, options?: ExtensionOptions): (pi: ExtensionAPI) => Promise<void>;
174
211
  declare const _default: (pi: ExtensionAPI) => Promise<void>;
175
212
  //#endregion
176
- export { type AvenorEventBus, type AvenorEventChannel, type AvenorEventMap, type AvenorRunStatus, CHANNEL_POLL_COMPLETED, CHANNEL_POLL_ERROR, CHANNEL_RUN_TERMINAL, ExtensionDeps, ExtensionOptions, type PollCompletedPayload, type PollErrorPayload, type PollErrorPayloadInput, type PollErrorSource, type RunTerminalPayload, type RunTerminalPayloadInput, buildCompletionText, buildInspectPayload, compactWhitespace, createExtension, createPollCompletedPayload, createPollErrorPayload, createRunTerminalPayload, _default as default, emitAvenorEvent, isTerminalStatus, onAvenorEvent, renderStatusLines, spawnIdentityMetadata, statusSupervisorId };
213
+ export { type AvenorEventBus, type AvenorEventChannel, type AvenorEventMap, type AvenorRunStatus, CHANNEL_DELIVERY_ERROR, CHANNEL_POLL_COMPLETED, CHANNEL_POLL_ERROR, CHANNEL_RUN_TERMINAL, type DeliveryErrorPayload, type DeliveryErrorPayloadInput, type DeliveryErrorSource, ExtensionDeps, ExtensionOptions, type PollCompletedPayload, type PollErrorPayload, type PollErrorPayloadInput, type PollErrorSource, type RunTerminalPayload, type RunTerminalPayloadInput, buildCompletionText, buildInspectPayload, compactWhitespace, createDeliveryErrorPayload, createExtension, createPollCompletedPayload, createPollErrorPayload, createRunTerminalPayload, _default as default, emitAvenorEvent, isTerminalStatus, onAvenorEvent, renderStatusLines, spawnIdentityMetadata, statusSupervisorId };
package/dist/index.js CHANGED
@@ -106,6 +106,14 @@ async function postBroker(env, path, body, fetchImpl = fetch, signal) {
106
106
  if (!res.ok) throw await codedError(path, res);
107
107
  return res.json();
108
108
  }
109
+ /**
110
+ * Publish the harness's own user-defined session name for this run, or clear it
111
+ * with an empty name. The broker stores it apart from any model-set rename and
112
+ * prefers it, so peers see the name the human gave the session.
113
+ */
114
+ async function publishHarnessName(env, name, fetchImpl = fetch) {
115
+ await postBroker(env, "/session_name", { name: name ?? "" }, fetchImpl);
116
+ }
109
117
  /** Authenticated GET; broker credentials travel in headers, never the URL. */
110
118
  async function getBroker(env, path, fetchImpl = fetch, signal) {
111
119
  const res = await fetchImpl(`${env.brokerUrl}${path}`, {
@@ -536,7 +544,13 @@ function createBrokerChildBroker(env, fetchImpl = fetch) {
536
544
  }
537
545
  },
538
546
  async peers() {
539
- return { peers: peerRecords(await getBroker(env, "/sessions", fetchImpl)) };
547
+ const body = await getBroker(env, "/sessions", fetchImpl);
548
+ const peers = peerRecords(body);
549
+ const detail = body !== null && typeof body === "object" ? body.peers_error : void 0;
550
+ return typeof detail === "string" && detail ? {
551
+ peers,
552
+ detail
553
+ } : { peers };
540
554
  },
541
555
  async rename(req) {
542
556
  try {
@@ -1136,21 +1150,6 @@ function formatRunLine(entry) {
1136
1150
  const phase = entry.phaseLabel ? ` (${entry.phaseLabel})` : "";
1137
1151
  return `${emoji} ${entry.label} (${entry.agent}) — ${entry.status}${phase}${perm}`;
1138
1152
  }
1139
- /**
1140
- * decideCompletion determines whether to skip or send the completion
1141
- * steering message for a tracked run that has reached terminal status.
1142
- *
1143
- * The message is delivered on the same terminal tick — never deferred to a
1144
- * later cycle — so a run that is the last live one still gets its completion.
1145
- * `blocking` suppresses it while avenor_result is awaiting (it will deliver
1146
- * the result itself and set `consumed`), and `consumed` suppresses it once
1147
- * avenor_result has already returned the result successfully.
1148
- */
1149
- function decideCompletion(run) {
1150
- if (run.blocking) return "skip";
1151
- if (run.consumed) return "skip";
1152
- return "send";
1153
- }
1154
1153
  //#endregion
1155
1154
  //#region src/nested-count.ts
1156
1155
  const NESTED_DIAL_TIMEOUT_MS = 2e3;
@@ -1303,6 +1302,8 @@ const CHANNEL_POLL_COMPLETED = "avenor:poll:completed";
1303
1302
  const CHANNEL_RUN_TERMINAL = "avenor:run:terminal";
1304
1303
  /** Emitted when a best-effort polling operation fails. */
1305
1304
  const CHANNEL_POLL_ERROR = "avenor:poll:error";
1305
+ /** Emitted when a completion-notice delivery fails permanently. */
1306
+ const CHANNEL_DELIVERY_ERROR = "avenor:delivery:error";
1306
1307
  /** Maximum sanitized error text exposed to companion extensions. */
1307
1308
  const POLL_ERROR_TEXT_CHARS = 600;
1308
1309
  function sanitizeAndBoundText(value) {
@@ -1360,6 +1361,18 @@ function createRunTerminalPayload(payload) {
1360
1361
  ...payload.backend !== void 0 && { backend: payload.backend }
1361
1362
  });
1362
1363
  }
1364
+ /** Copy, bound, sanitize, and freeze a delivery failure before publishing it. */
1365
+ function createDeliveryErrorPayload(payload) {
1366
+ return Object.freeze({
1367
+ source: payload.source,
1368
+ runId: payload.runId,
1369
+ label: payload.label,
1370
+ message: sanitizeAndBoundText(payload.message),
1371
+ error: sanitizeAndBoundText(payload.error),
1372
+ attempts: payload.attempts,
1373
+ timestamp: payload.timestamp
1374
+ });
1375
+ }
1363
1376
  /** Copy, bound, sanitize, and freeze a polling error before publishing it. */
1364
1377
  function createPollErrorPayload(payload) {
1365
1378
  return Object.freeze({
@@ -1920,6 +1933,8 @@ function asStatusResult(raw) {
1920
1933
  return raw;
1921
1934
  }
1922
1935
  const POLL_INTERVAL_MS = 3e3;
1936
+ const IDLE_SEND_TIMEOUT_MS = 1e4;
1937
+ const MAX_IDLE_SEND_ATTEMPTS = 3;
1923
1938
  const INSPECT_LIMIT = 128;
1924
1939
  const COMPLETION_OUTPUT_CHARS = 600;
1925
1940
  const INSPECT_JSON_STRING_CHARS = 600;
@@ -1972,6 +1987,18 @@ function spawnIdentityMetadata(params, status) {
1972
1987
  function sleep(ms) {
1973
1988
  return new Promise((resolve) => setTimeout(resolve, ms));
1974
1989
  }
1990
+ /** The text of a tool result's first text part; image parts carry none. */
1991
+ function firstTextPart(content) {
1992
+ return content?.find((part) => part.type === "text")?.text ?? "";
1993
+ }
1994
+ /** The status a tool result records; a stored transcript record may carry no details. */
1995
+ function resultStatus(details) {
1996
+ return details && typeof details === "object" && "status" in details ? String(details.status ?? "") : "";
1997
+ }
1998
+ /** Close an observer a concurrent watch assigned, ignoring a close failure. */
1999
+ async function closeObserverQuietly(observer) {
2000
+ await observer?.close().catch(() => {});
2001
+ }
1975
2002
  function normalizeMultilinePreview(text, limit = COMPLETION_OUTPUT_CHARS) {
1976
2003
  if (!text) return void 0;
1977
2004
  const clean = sanitizeText(text).trim();
@@ -2092,6 +2119,7 @@ function trackedRunKey(supervisorId, runId) {
2092
2119
  function createExtension(deps = defaultDeps, options = {}) {
2093
2120
  return async function(pi) {
2094
2121
  const pollIntervalMs = options.pollIntervalMs ?? POLL_INTERVAL_MS;
2122
+ const idleSendTimeoutMs = options.idleSendTimeoutMs ?? IDLE_SEND_TIMEOUT_MS;
2095
2123
  let telemetryGeneration = 0;
2096
2124
  const trackedRuns = /* @__PURE__ */ new Map();
2097
2125
  let pollingActive = false;
@@ -2101,6 +2129,8 @@ function createExtension(deps = defaultDeps, options = {}) {
2101
2129
  let pollingErrorCount = 0;
2102
2130
  const pollingErrors = [];
2103
2131
  let lastStatusEntries = [];
2132
+ let sessionGeneration = 0;
2133
+ let compacting = false;
2104
2134
  let lastTurnSocketClosed = false;
2105
2135
  const pendingWaits = /* @__PURE__ */ new Set();
2106
2136
  /** The agent-facing prose clause for a stopping point. A synthetic status
@@ -2174,12 +2204,16 @@ function createExtension(deps = defaultDeps, options = {}) {
2174
2204
  };
2175
2205
  }
2176
2206
  /** Build the early return for a wait interrupted by a sibling stopping
2177
- * point. When the awaited run itself has since reached a stopping point
2178
- * (possible when the caller omitted supervisor_id and the registry key
2179
- * diverged), deliver its real state instead of an interruption notice. */
2207
+ * point. When the awaited run itself has since reached a stopping point,
2208
+ * deliver its real state instead of an interruption notice. The probe and
2209
+ * redelivery use the caller's original run reference, matching the non-
2210
+ * interrupted path: a label may resolve canonically to a newer run than
2211
+ * the one this call first bound to, and the reconciliation below handles
2212
+ * that divergence the same way. A caller that omitted supervisor_id still
2213
+ * probes through the bound run's supervisor. */
2180
2214
  async function resultAfterInterrupt(params, stop, tracked) {
2181
- const runId = tracked?.runId ?? params.run_id;
2182
- const supervisorId = tracked?.supervisorId ?? params.supervisor_id;
2215
+ const runId = params.run_id;
2216
+ const supervisorId = params.supervisor_id ?? tracked?.supervisorId;
2183
2217
  let current;
2184
2218
  try {
2185
2219
  current = asStatusResult(await deps.statusTool({
@@ -2229,6 +2263,35 @@ function createExtension(deps = defaultDeps, options = {}) {
2229
2263
  subAgentPollTimer = null;
2230
2264
  }
2231
2265
  }
2266
+ let publishedSessionName = null;
2267
+ let publishChain = Promise.resolve();
2268
+ let refusedSessionName = null;
2269
+ function publishSessionName(ctx) {
2270
+ if (!subAgentEnv) return publishChain;
2271
+ const env = subAgentEnv;
2272
+ const manager = ctx.sessionManager;
2273
+ if (!manager || typeof manager.getSessionName !== "function") return publishChain;
2274
+ const name = manager.getSessionName();
2275
+ if (publishedSessionName && publishedSessionName.name === name) return publishChain;
2276
+ if (refusedSessionName === name) return publishChain;
2277
+ const attempt = { name };
2278
+ publishedSessionName = attempt;
2279
+ publishChain = publishChain.then(async () => {
2280
+ try {
2281
+ await publishHarnessName(env, name);
2282
+ refusedSessionName = null;
2283
+ } catch (error) {
2284
+ if (error instanceof BrokerHttpError && error.status === 400 && error.code === "invalid-name") {
2285
+ refusedSessionName = name ?? null;
2286
+ console.error("[avenor] session_name_set refused:", error.message);
2287
+ return;
2288
+ }
2289
+ if (publishedSessionName === attempt) publishedSessionName = null;
2290
+ console.error("[avenor] session_name_set failed:", error instanceof Error ? error.message : error);
2291
+ }
2292
+ });
2293
+ return publishChain;
2294
+ }
2232
2295
  async function startSubAgentPoll() {
2233
2296
  if (!subAgentEnv) return;
2234
2297
  stopSubAgentPoll();
@@ -2313,6 +2376,40 @@ function createExtension(deps = defaultDeps, options = {}) {
2313
2376
  if (pollingErrors.length > MAX_POLL_ERRORS) pollingErrors.shift();
2314
2377
  emitAvenorEvent(pi.events, CHANNEL_POLL_ERROR, payload);
2315
2378
  }
2379
+ /** A delivery that exhausted its retries is not a polling failure: emit
2380
+ * it on its own channel so `avenor:poll:error` consumers keep a pure
2381
+ * polling contract. The unread notice is the persistent user-facing
2382
+ * surface; this event is the companion-extension signal. */
2383
+ function recordDeliveryError(run, attempts) {
2384
+ emitAvenorEvent(pi.events, CHANNEL_DELIVERY_ERROR, createDeliveryErrorPayload({
2385
+ source: "idle-send",
2386
+ runId: run.runId,
2387
+ label: run.label,
2388
+ message: "idle completion send was never confirmed",
2389
+ error: /* @__PURE__ */ new Error(`no message_end after ${attempts} attempts`),
2390
+ attempts,
2391
+ timestamp: Date.now()
2392
+ }));
2393
+ console.error(`[avenor] idle completion send for run "${run.label}" was never confirmed after ${attempts} attempts; the notice stays unread`);
2394
+ }
2395
+ /** Latch the per-notice retry budget as spent and report the exhausted
2396
+ * delivery. Idempotent, so the timeout re-arm and the compaction
2397
+ * hand-back cannot double-report the same notice. */
2398
+ function capIdleSend(run) {
2399
+ if (!run.idleSend || run.idleSend.capped) return;
2400
+ run.idleSend.capped = true;
2401
+ recordDeliveryError(run, run.idleSend.attempts);
2402
+ }
2403
+ /** Hand an unconfirmed idle send back to the poll: clear the in-flight
2404
+ * mark and latch the budget when it is spent. Shared by the poll-tick
2405
+ * timeout and the compaction hand-back, which must stay in lockstep. */
2406
+ function rearmIdleSend(run) {
2407
+ const idle = run.idleSend;
2408
+ if (!idle) return;
2409
+ idle.inFlight = false;
2410
+ idle.sentAt = void 0;
2411
+ if (idle.attempts >= MAX_IDLE_SEND_ATTEMPTS) capIdleSend(run);
2412
+ }
2316
2413
  function clearPollingErrors() {
2317
2414
  pollingErrorCount = 0;
2318
2415
  pollingErrors.length = 0;
@@ -2321,38 +2418,75 @@ function createExtension(deps = defaultDeps, options = {}) {
2321
2418
  function upsertTrackedRun(run, status, blocking = false) {
2322
2419
  const key = trackedRunKey(run.supervisorId, run.runId);
2323
2420
  const current = trackedRuns.get(key);
2324
- trackedRuns.set(key, {
2421
+ const tracked = current ?? {
2325
2422
  runId: run.runId,
2326
- agent: run.agent ?? status?.agent ?? current?.agent ?? "unknown",
2327
- label: run.label ?? status?.label ?? current?.label ?? run.runId,
2328
- supervisorId: run.supervisorId ?? current?.supervisorId,
2329
- runtimeId: run.runtimeId ?? status?.runtime_id ?? current?.runtimeId,
2330
- startTime: current?.startTime ?? Date.now(),
2331
- lastStatus: status ?? current?.lastStatus,
2332
- blocking: current?.blocking ?? blocking,
2333
- permissionNotified: current?.permissionNotified,
2334
- consumed: current?.consumed
2335
- });
2423
+ startTime: Date.now(),
2424
+ agent: "unknown",
2425
+ label: run.label ?? run.runId
2426
+ };
2427
+ tracked.runId = run.runId;
2428
+ tracked.agent = run.agent ?? status?.agent ?? current?.agent ?? tracked.agent;
2429
+ tracked.label = run.label ?? status?.label ?? current?.label ?? tracked.label;
2430
+ tracked.supervisorId = run.supervisorId ?? current?.supervisorId;
2431
+ tracked.runtimeId = run.runtimeId ?? status?.runtime_id ?? current?.runtimeId;
2432
+ if (!current) tracked.startTime = Date.now();
2433
+ tracked.lastStatus = status ?? current?.lastStatus;
2434
+ tracked.blocking = current?.blocking ?? blocking;
2435
+ trackedRuns.set(key, tracked);
2436
+ }
2437
+ function findTrackedByLabel(label, supervisorId) {
2438
+ let match;
2439
+ for (const run of trackedRuns.values()) {
2440
+ if (supervisorId !== void 0 && run.supervisorId !== supervisorId) continue;
2441
+ if (run.label !== label) continue;
2442
+ if (match) return void 0;
2443
+ match = run;
2444
+ }
2445
+ return match;
2336
2446
  }
2337
2447
  /**
2338
- * Look a run up in the per-supervisor tracking namespace. When a
2339
- * supervisor is supplied, resolve exactly. Without one, prefer the
2448
+ * Resolve a canonical run id in the per-supervisor tracking namespace.
2449
+ * When a supervisor is supplied, resolve exactly. Without one, prefer the
2340
2450
  * singleton namespace and only fall back to a run id walk when it is
2341
2451
  * unambiguous across supervisors. Duplicate run ids across supervisors
2342
2452
  * without an explicit supervisor return undefined so callers never bind
2343
2453
  * flags or telemetry to the wrong supervisor's run.
2344
2454
  */
2345
- function getTrackedRun(runId, supervisorId) {
2346
- if (supervisorId !== void 0) return trackedRuns.get(trackedRunKey(supervisorId, runId));
2347
- const singleton = trackedRuns.get(trackedRunKey(void 0, runId));
2348
- if (singleton) return singleton;
2349
- let match;
2350
- for (const run of trackedRuns.values()) {
2351
- if (run.runId !== runId) continue;
2352
- if (match) return void 0;
2353
- match = run;
2455
+ function trackedRunsById(runId, supervisorId) {
2456
+ if (supervisorId !== void 0) {
2457
+ const exact = trackedRuns.get(trackedRunKey(supervisorId, runId));
2458
+ return exact ? [exact] : [];
2354
2459
  }
2355
- return match;
2460
+ const singleton = trackedRuns.get(trackedRunKey(void 0, runId));
2461
+ if (singleton) return [singleton];
2462
+ const matches = [];
2463
+ for (const run of trackedRuns.values()) if (run.runId === runId) matches.push(run);
2464
+ return matches;
2465
+ }
2466
+ function getTrackedRunById(runId, supervisorId) {
2467
+ const matches = trackedRunsById(runId, supervisorId);
2468
+ return matches.length === 1 ? matches[0] : void 0;
2469
+ }
2470
+ /**
2471
+ * Resolve a user-supplied run reference. A canonical identity always wins;
2472
+ * a label is only a fallback, and an ambiguous canonical id never falls
2473
+ * through to a label.
2474
+ */
2475
+ function getTrackedRun(runId, supervisorId) {
2476
+ const exact = getTrackedRunById(runId, supervisorId);
2477
+ if (exact) return exact;
2478
+ if (trackedRunsById(runId, supervisorId).length > 1) return void 0;
2479
+ return findTrackedByLabel(runId, supervisorId);
2480
+ }
2481
+ /** Resolve the tracked run a successful result belongs to from the
2482
+ * canonical run id the result tool reports, accepting a runtime-id alias
2483
+ * for the label-resolved run. */
2484
+ function reconcileResultRun(tracked, result, supervisorId) {
2485
+ const runId = result.run_id;
2486
+ if (!runId) return tracked;
2487
+ if (tracked && (tracked.runId === runId || tracked.runtimeId === runId)) return tracked;
2488
+ const namespace = tracked?.supervisorId ?? supervisorId;
2489
+ return trackedRuns.get(trackedRunKey(namespace, runId));
2356
2490
  }
2357
2491
  async function resolveFinalOutput(run, status) {
2358
2492
  const preview = finalOutputPreview(status);
@@ -2368,6 +2502,67 @@ function createExtension(deps = defaultDeps, options = {}) {
2368
2502
  return;
2369
2503
  }
2370
2504
  }
2505
+ /**
2506
+ * Fire-and-forget delivery. Pi's extension-facing `sendUserMessage` returns
2507
+ * void and the runtime swallows async rejections, so a send is only known to
2508
+ * have landed when the user message comes back through `message_end`. Only a
2509
+ * synchronous throw is observable here.
2510
+ */
2511
+ function sendUserMessageLogged(text, failureLog) {
2512
+ try {
2513
+ pi.sendUserMessage(text, { deliverAs: "followUp" });
2514
+ } catch (err) {
2515
+ console.error(failureLog, err);
2516
+ }
2517
+ }
2518
+ function clearPendingNotices() {
2519
+ for (const run of trackedRuns.values()) {
2520
+ run.pendingNotice = void 0;
2521
+ run.idleSend = void 0;
2522
+ }
2523
+ }
2524
+ /**
2525
+ * Emit the single terminal event for a tracked run. Every terminal path
2526
+ * (poll, blocking spawn, avenor_result) goes through here so the event-once
2527
+ * guard and payload shape stay identical. A tracked run emits at most once;
2528
+ * an untracked run emits with the canonical result identity, unguarded.
2529
+ *
2530
+ * @returns whether the event was emitted, so callers can gate one-time
2531
+ * side effects.
2532
+ */
2533
+ function emitRunTerminal(input) {
2534
+ const { run } = input;
2535
+ if (!run) {
2536
+ if (!input.runId) return false;
2537
+ emitAvenorEvent(pi.events, CHANNEL_RUN_TERMINAL, createRunTerminalPayload({
2538
+ runId: input.runId,
2539
+ supervisorId: input.supervisorId,
2540
+ runtimeId: input.runtimeId,
2541
+ label: input.label,
2542
+ agent: input.agent,
2543
+ status: input.status,
2544
+ nestedCount: input.nestedCount,
2545
+ backend: input.backend
2546
+ }));
2547
+ return true;
2548
+ }
2549
+ if (run.terminalEventEmitted) return false;
2550
+ run.terminalEventEmitted = true;
2551
+ emitAvenorEvent(pi.events, CHANNEL_RUN_TERMINAL, createRunTerminalPayload({
2552
+ runId: run.runId,
2553
+ supervisorId: run.supervisorId,
2554
+ runtimeId: input.runtimeId,
2555
+ label: input.label,
2556
+ agent: input.agent,
2557
+ status: input.status,
2558
+ nestedCount: input.nestedCount,
2559
+ backend: input.backend
2560
+ }));
2561
+ return true;
2562
+ }
2563
+ function sendCompletionNotice(notice) {
2564
+ sendUserMessageLogged(notice.text, "avenor tick: sendUserMessage failed");
2565
+ }
2371
2566
  async function pollRuns() {
2372
2567
  const errorCountAtPollStart = pollingErrorCount;
2373
2568
  const liveMap = /* @__PURE__ */ new Map();
@@ -2460,7 +2655,11 @@ function createExtension(deps = defaultDeps, options = {}) {
2460
2655
  if (nestedPids.length > 0) {
2461
2656
  const nestedCounts = await Promise.all(nestedPids.map((pid) => countNestedRuns(pid, deps.dial)));
2462
2657
  let idx = 0;
2463
- for (const entry of entries) if (entry.backend === "pi" && entry.pid && !isTerminalStatus(entry.status)) entry.nestedCount = nestedCounts[idx++] ?? 0;
2658
+ for (const entry of entries) if (entry.backend === "pi" && entry.pid && !isTerminalStatus(entry.status)) {
2659
+ entry.nestedCount = nestedCounts[idx++] ?? 0;
2660
+ const tracked = trackedRuns.get(trackedRunKey(entry.supervisorId, entry.runId));
2661
+ if (tracked) tracked.nestedCount = entry.nestedCount;
2662
+ }
2464
2663
  }
2465
2664
  if (pollingErrorCount === errorCountAtPollStart) pollingErrorCount = 0;
2466
2665
  return entries;
@@ -2469,17 +2668,24 @@ function createExtension(deps = defaultDeps, options = {}) {
2469
2668
  lastStatusEntries = entries;
2470
2669
  if (!sessionCtx) return;
2471
2670
  const lines = renderStatusLines(entries);
2671
+ let unread = 0;
2672
+ for (const run of trackedRuns.values()) if (run.pendingNotice) unread++;
2472
2673
  const errorSuffix = pollingErrorCount > 0 ? `errors:${pollingErrorCount}` : "";
2674
+ const unreadSuffix = unread > 0 ? `unread:${unread}` : "";
2473
2675
  if (lines.length > 0) {
2474
2676
  sessionCtx.ui.setWidget("avenor-status", lines);
2475
2677
  const statusStr = lines.map((line) => line.slice(0, 60)).join(" ");
2476
2678
  const nestedTotal = entries.filter((e) => !isTerminalStatus(e.status)).reduce((sum, e) => sum + (e.nestedCount ?? 0), 0);
2477
2679
  const total = lines.length + nestedTotal;
2478
- const statusWithTotal = [nestedTotal > 0 ? `${statusStr} total:${total}` : statusStr, errorSuffix].filter(Boolean).join(" ");
2680
+ const statusWithTotal = [
2681
+ nestedTotal > 0 ? `${statusStr} total:${total}` : statusStr,
2682
+ unreadSuffix,
2683
+ errorSuffix
2684
+ ].filter(Boolean).join(" ");
2479
2685
  sessionCtx.ui.setStatus("avenor", statusWithTotal);
2480
2686
  } else {
2481
2687
  sessionCtx.ui.setWidget("avenor-status", void 0);
2482
- sessionCtx.ui.setStatus("avenor", errorSuffix);
2688
+ sessionCtx.ui.setStatus("avenor", [unreadSuffix, errorSuffix].filter(Boolean).join(" "));
2483
2689
  }
2484
2690
  }
2485
2691
  function startPolling() {
@@ -2500,12 +2706,20 @@ function createExtension(deps = defaultDeps, options = {}) {
2500
2706
  });
2501
2707
  if (!pollingActive || generation !== pollingGeneration) return;
2502
2708
  updateWidget(entries);
2709
+ let widgetDirty = false;
2710
+ const sessionAtPollStart = sessionGeneration;
2503
2711
  emitAvenorEvent(pi.events, CHANNEL_POLL_COMPLETED, createPollCompletedPayload(entries, Date.now(), ++telemetryGeneration));
2504
2712
  for (const entry of entries) {
2505
2713
  const entryKey = trackedRunKey(entry.supervisorId, entry.runId);
2506
2714
  const run = trackedRuns.get(entryKey);
2507
2715
  if (!run) continue;
2508
2716
  const prevStatus = prevStatuses.get(entryKey);
2717
+ const idle = run.idleSend;
2718
+ if (idle?.inFlight && idle.sentAt !== void 0 && Date.now() - idle.sentAt >= idleSendTimeoutMs) {
2719
+ rearmIdleSend(run);
2720
+ widgetDirty = true;
2721
+ }
2722
+ if (run.idleSend?.inFlight && isTerminalStatus(entry.status)) continue;
2509
2723
  const wasStopped = prevStatus?.status !== void 0 && isTerminalStatus(prevStatus.status) || !!prevStatus?.pendingPermission;
2510
2724
  if ((entry.pendingPermission || isTerminalStatus(entry.status)) && !wasStopped) interruptPendingWaits({
2511
2725
  runId: entry.runId,
@@ -2513,45 +2727,61 @@ function createExtension(deps = defaultDeps, options = {}) {
2513
2727
  status: isTerminalStatus(entry.status) ? entry.status : "waiting"
2514
2728
  }, entryKey);
2515
2729
  if (isTerminalStatus(entry.status)) {
2516
- if (decideCompletion(run) === "skip") {
2517
- if (run.consumed) trackedRuns.delete(entryKey);
2518
- continue;
2519
- }
2520
- if (!(prevStatus?.status !== void 0 && isTerminalStatus(prevStatus.status))) sessionCtx?.ui.notify(`Sub-agent "${entry.label}" finished: ${entry.status}`, entry.status === "done" ? "info" : "warning");
2521
- const finalOutput = await resolveFinalOutput(run, run.lastStatus);
2522
- try {
2523
- pi.sendUserMessage(buildCompletionText({
2524
- runId: run.runId,
2525
- label: run.label
2526
- }, run.lastStatus, finalOutput), { deliverAs: "followUp" });
2527
- } catch (err) {
2528
- console.error("avenor tick: sendUserMessage failed", err);
2529
- }
2530
- emitAvenorEvent(pi.events, CHANNEL_RUN_TERMINAL, createRunTerminalPayload({
2531
- runId: entry.runId,
2532
- supervisorId: entry.supervisorId,
2730
+ const finalOutput = run.pendingNotice ? void 0 : await resolveFinalOutput(run, run.lastStatus);
2731
+ if (sessionAtPollStart !== sessionGeneration) continue;
2732
+ if (trackedRuns.get(entryKey) !== run) continue;
2733
+ const wasTerminalBefore = prevStatus?.status !== void 0 && isTerminalStatus(prevStatus.status);
2734
+ if (emitRunTerminal({
2735
+ run,
2533
2736
  runtimeId: entry.runtimeId,
2534
2737
  label: entry.label,
2535
2738
  agent: entry.agent,
2536
2739
  status: entry.status,
2537
- nestedCount: entry.nestedCount,
2740
+ nestedCount: entry.nestedCount ?? run.nestedCount,
2538
2741
  backend: entry.backend
2539
- }));
2540
- trackedRuns.delete(entryKey);
2541
- } else if (entry.pendingPermission && !run.blocking && !run.permissionNotified) {
2542
- run.permissionNotified = true;
2543
- if (run.lastStatus) try {
2544
- pi.sendUserMessage(buildWaitingText({
2742
+ })) {
2743
+ if (!wasTerminalBefore && !run.blocking) sessionCtx?.ui.notify(`Sub-agent "${entry.label}" finished: ${entry.status}`, entry.status === "done" ? "info" : "warning");
2744
+ }
2745
+ const notice = run.pendingNotice ?? {
2746
+ status: entry.status,
2747
+ text: buildCompletionText({
2545
2748
  runId: run.runId,
2546
- label: run.label,
2547
- agent: run.agent
2548
- }, run.lastStatus), { deliverAs: "followUp" });
2549
- } catch (err) {
2550
- console.error("avenor tick: sendUserMessage for permission failed", err);
2749
+ label: run.label
2750
+ }, run.lastStatus, finalOutput)
2751
+ };
2752
+ if (run.blocking) {
2753
+ if (!run.pendingNotice) {
2754
+ run.pendingNotice = notice;
2755
+ widgetDirty = true;
2756
+ }
2757
+ continue;
2551
2758
  }
2759
+ if (sessionCtx?.isIdle() === true && !compacting && !run.idleSend?.capped) {
2760
+ run.pendingNotice = notice;
2761
+ run.idleSend = {
2762
+ inFlight: true,
2763
+ sentAt: Date.now(),
2764
+ attempts: (run.idleSend?.attempts ?? 0) + 1,
2765
+ capped: false
2766
+ };
2767
+ widgetDirty = true;
2768
+ sendCompletionNotice(notice);
2769
+ continue;
2770
+ } else if (!run.pendingNotice) {
2771
+ run.pendingNotice = notice;
2772
+ widgetDirty = true;
2773
+ }
2774
+ } else if (entry.pendingPermission && !run.blocking && !run.permissionNotified) {
2775
+ run.permissionNotified = true;
2776
+ if (run.lastStatus) sendUserMessageLogged(buildWaitingText({
2777
+ runId: run.runId,
2778
+ label: run.label,
2779
+ agent: run.agent
2780
+ }, run.lastStatus), "avenor tick: sendUserMessage for permission failed");
2552
2781
  } else if (!entry.pendingPermission && run.permissionNotified) run.permissionNotified = false;
2553
2782
  }
2554
- if (entries.some((entry) => !isTerminalStatus(entry.status)) && pollingActive && generation === pollingGeneration) setTimeout(() => {
2783
+ if (widgetDirty) updateWidget(entries);
2784
+ if ((entries.some((entry) => !isTerminalStatus(entry.status)) || Array.from(trackedRuns.values()).some((run) => run.idleSend?.inFlight)) && pollingActive && generation === pollingGeneration) setTimeout(() => {
2555
2785
  tick().catch((err) => {
2556
2786
  pollingActive = false;
2557
2787
  console.error("avenor polling loop failed", err);
@@ -2693,7 +2923,7 @@ function createExtension(deps = defaultDeps, options = {}) {
2693
2923
  const outcome = abortPromise ? await Promise.race([waitPromise, abortPromise]) : await waitPromise;
2694
2924
  if (signal && abortListener) signal.removeEventListener("abort", abortListener);
2695
2925
  unsubscribe?.();
2696
- await observer?.close().catch(() => {});
2926
+ await closeObserverQuietly(observer);
2697
2927
  if (outcome === "aborted") return { aborted: true };
2698
2928
  try {
2699
2929
  return {
@@ -2846,10 +3076,16 @@ function createExtension(deps = defaultDeps, options = {}) {
2846
3076
  });
2847
3077
  pi.on("session_start", async (_event, ctx) => {
2848
3078
  sessionCtx = ctx;
3079
+ sessionGeneration++;
3080
+ compacting = false;
3081
+ clearPendingNotices();
3082
+ if (trackedRuns.size > 0) startPolling();
2849
3083
  lastStatusEntries = [];
2850
3084
  lastTurnSocketClosed = false;
2851
3085
  pollingErrorCount = 0;
2852
3086
  pollingErrors.length = 0;
3087
+ publishedSessionName = null;
3088
+ publishSessionName(ctx);
2853
3089
  if (!isSubAgentBrokerMode) startHostAskPoll();
2854
3090
  startSubAgentPoll();
2855
3091
  });
@@ -2859,6 +3095,8 @@ function createExtension(deps = defaultDeps, options = {}) {
2859
3095
  disposeHostProviders();
2860
3096
  hostPendingAsks.clear();
2861
3097
  subAgentPendingAsks.clear();
3098
+ sessionGeneration++;
3099
+ clearPendingNotices();
2862
3100
  await stopPolling();
2863
3101
  for (const wait of [...pendingWaits]) {
2864
3102
  pendingWaits.delete(wait);
@@ -2870,7 +3108,36 @@ function createExtension(deps = defaultDeps, options = {}) {
2870
3108
  }
2871
3109
  sessionCtx = null;
2872
3110
  });
2873
- pi.on("before_agent_start", async () => {
3111
+ pi.on("message_end", (event) => {
3112
+ if (event.message?.role !== "user") return;
3113
+ const text = extractTurnText([event.message]).text;
3114
+ if (text === "") return;
3115
+ const matches = [...trackedRuns].filter(([, run]) => run.pendingNotice?.text === text);
3116
+ let targets = matches.filter(([, run]) => run.idleSend?.inFlight);
3117
+ if (targets.length === 0) targets = matches.filter(([, run]) => run.idleSend !== void 0);
3118
+ for (const [key, run] of targets) {
3119
+ run.pendingNotice = void 0;
3120
+ run.idleSend = void 0;
3121
+ trackedRuns.delete(key);
3122
+ }
3123
+ if (targets.length > 0) updateWidget(lastStatusEntries);
3124
+ });
3125
+ pi.on("session_before_compact", () => {
3126
+ compacting = true;
3127
+ });
3128
+ const resumeAfterCompaction = () => {
3129
+ compacting = false;
3130
+ for (const run of trackedRuns.values()) {
3131
+ if (run.permissionNotified && run.lastStatus?.pending_permission) run.permissionNotified = false;
3132
+ if (!run.idleSend) continue;
3133
+ rearmIdleSend(run);
3134
+ }
3135
+ if (Array.from(trackedRuns.values()).some((run) => run.pendingNotice || !run.permissionNotified && !!run.lastStatus?.pending_permission)) startPolling();
3136
+ };
3137
+ pi.on("session_compact", resumeAfterCompaction);
3138
+ pi.on("before_agent_start", async (_event, ctx) => {
3139
+ publishSessionName(ctx);
3140
+ compacting = false;
2874
3141
  if (trackedRuns.size === 0) return;
2875
3142
  const active = (await pollRuns()).filter((entry) => !isTerminalStatus(entry.status));
2876
3143
  if (active.length === 0) return;
@@ -2884,6 +3151,38 @@ function createExtension(deps = defaultDeps, options = {}) {
2884
3151
  display: true
2885
3152
  } };
2886
3153
  });
3154
+ pi.on("agent_before_settle", async (event) => {
3155
+ if (event.outcome !== "completed") return;
3156
+ const deliverable = [];
3157
+ for (const run of trackedRuns.values()) {
3158
+ if (!run.pendingNotice) continue;
3159
+ if (run.idleSend?.inFlight) continue;
3160
+ deliverable.push({
3161
+ run,
3162
+ notice: run.pendingNotice
3163
+ });
3164
+ }
3165
+ if (deliverable.length === 0) return;
3166
+ for (const { run } of deliverable) {
3167
+ run.pendingNotice = void 0;
3168
+ trackedRuns.delete(trackedRunKey(run.supervisorId, run.runId));
3169
+ }
3170
+ updateWidget(lastStatusEntries);
3171
+ return {
3172
+ entries: [...event.entries, ...deliverable.map(({ run, notice }) => ({
3173
+ type: "custom_message",
3174
+ customType: "avenor-completion",
3175
+ content: notice.text,
3176
+ display: true,
3177
+ details: {
3178
+ run_id: run.runId,
3179
+ supervisor_id: run.supervisorId,
3180
+ status: notice.status
3181
+ }
3182
+ }))],
3183
+ continue: true
3184
+ };
3185
+ });
2887
3186
  pi.registerMessageRenderer("avenor-active-runs", (message, _options, theme) => {
2888
3187
  const lines = message.content.split("\n");
2889
3188
  let text = "";
@@ -2934,10 +3233,13 @@ function createExtension(deps = defaultDeps, options = {}) {
2934
3233
  wait: Type.Optional(Type.Boolean({ description: "Block until complete (default true)" }))
2935
3234
  }),
2936
3235
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
2937
- if (signal?.aborted) return { content: [{
2938
- type: "text",
2939
- text: "Cancelled"
2940
- }] };
3236
+ if (signal?.aborted) return {
3237
+ content: [{
3238
+ type: "text",
3239
+ text: "Cancelled"
3240
+ }],
3241
+ details: {}
3242
+ };
2941
3243
  validateSpawnSelection({
2942
3244
  agent: params.agent,
2943
3245
  model: params.model,
@@ -3016,13 +3318,14 @@ function createExtension(deps = defaultDeps, options = {}) {
3016
3318
  if (waitResult.aborted) {
3017
3319
  const stop = blockingWait.stopped;
3018
3320
  if (stop && !stop.synthetic) {
3019
- const probed = asStatusResult(await deps.statusTool({
3321
+ const raw = await deps.statusTool({
3020
3322
  runId: result.run_id,
3021
3323
  supervisorId
3022
- }).catch(() => void 0));
3324
+ }).catch(() => void 0);
3325
+ const probed = raw ? asStatusResult(raw) : void 0;
3023
3326
  if (probed && (isTerminalStatus(probed.status) || probed.status === "waiting")) waitStatus = probed;
3024
3327
  else {
3025
- const tracked = getTrackedRun(result.run_id, supervisorId);
3328
+ const tracked = getTrackedRunById(result.run_id, supervisorId);
3026
3329
  if (tracked) tracked.blocking = false;
3027
3330
  return {
3028
3331
  content: [{
@@ -3041,7 +3344,8 @@ function createExtension(deps = defaultDeps, options = {}) {
3041
3344
  };
3042
3345
  }
3043
3346
  } else {
3044
- trackedRuns.delete(trackedRunKey(supervisorId, result.run_id));
3347
+ const runKey = trackedRunKey(supervisorId, result.run_id);
3348
+ trackedRuns.delete(runKey);
3045
3349
  return {
3046
3350
  content: [{
3047
3351
  type: "text",
@@ -3066,10 +3370,13 @@ function createExtension(deps = defaultDeps, options = {}) {
3066
3370
  ...spawnIdentityMetadata(hostParams)
3067
3371
  }
3068
3372
  };
3069
- upsertTrackedRun(runRef, status, wait);
3373
+ if (trackedRuns.has(trackedRunKey(supervisorId, result.run_id))) upsertTrackedRun(runRef, status, wait);
3070
3374
  if (status.status === "waiting") {
3071
- const tracked = getTrackedRun(result.run_id, supervisorId);
3072
- if (tracked) tracked.permissionNotified = true;
3375
+ const tracked = getTrackedRunById(result.run_id, supervisorId);
3376
+ if (tracked) {
3377
+ tracked.permissionNotified = true;
3378
+ tracked.blocking = false;
3379
+ }
3073
3380
  return {
3074
3381
  content: [{
3075
3382
  type: "text",
@@ -3093,16 +3400,19 @@ function createExtension(deps = defaultDeps, options = {}) {
3093
3400
  runId: result.run_id,
3094
3401
  supervisorId
3095
3402
  }, status);
3096
- emitAvenorEvent(pi.events, CHANNEL_RUN_TERMINAL, createRunTerminalPayload({
3097
- runId: result.run_id,
3098
- supervisorId,
3403
+ const runKey = trackedRunKey(supervisorId, result.run_id);
3404
+ const tracked = trackedRuns.get(runKey);
3405
+ emitRunTerminal({
3406
+ run: tracked,
3099
3407
  runtimeId: status.runtime_id ?? runRef.runtimeId,
3100
3408
  label,
3101
- agent: params.agent,
3409
+ agent: tracked?.agent ?? params.agent ?? "unknown",
3102
3410
  status: status.status,
3411
+ nestedCount: tracked?.nestedCount,
3103
3412
  backend: status.backend
3104
- }));
3105
- trackedRuns.delete(trackedRunKey(supervisorId, result.run_id));
3413
+ });
3414
+ trackedRuns.delete(runKey);
3415
+ updateWidget(lastStatusEntries);
3106
3416
  return {
3107
3417
  content: [{
3108
3418
  type: "text",
@@ -3120,6 +3430,8 @@ function createExtension(deps = defaultDeps, options = {}) {
3120
3430
  }
3121
3431
  };
3122
3432
  }
3433
+ const fallthrough = getTrackedRunById(result.run_id, supervisorId);
3434
+ if (fallthrough) fallthrough.blocking = false;
3123
3435
  return {
3124
3436
  content: [{
3125
3437
  type: "text",
@@ -3140,17 +3452,11 @@ function createExtension(deps = defaultDeps, options = {}) {
3140
3452
  return new Text(agent + agentName + label + wait, 0, 0);
3141
3453
  },
3142
3454
  renderResult(result, { isPartial }, theme) {
3143
- if (isPartial) {
3144
- const status = result.details?.status ?? "running";
3145
- const emoji = statusEmoji(String(status));
3146
- return new Text(theme.fg("accent", `${emoji} ${result.content?.[0]?.text ?? "Running…"}`), 0, 0);
3147
- }
3148
- const status = String(result.details?.status ?? "");
3149
- if (status && isTerminalStatus(status)) {
3150
- const emoji = statusEmoji(status);
3151
- return new Text(`${theme.fg(status === "done" ? "success" : "error", `${emoji} ${status}`)}\n${(result.content?.[0]?.text ?? "").split("\n").map((line) => theme.fg("muted", ` ${line}`)).join("\n")}`, 0, 0);
3152
- }
3153
- return new Text((result.content?.[0]?.text ?? "").split("\n").map((line) => theme.fg("muted", line)).join("\n"), 0, 0);
3455
+ const status = resultStatus(result.details);
3456
+ const text = firstTextPart(result.content);
3457
+ if (isPartial) return new Text(theme.fg("accent", `${statusEmoji(status || "running")} ${text || "Running…"}`), 0, 0);
3458
+ if (status && isTerminalStatus(status)) return new Text(`${theme.fg(status === "done" ? "success" : "error", `${statusEmoji(status)} ${status}`)}\n${text.split("\n").map((line) => theme.fg("muted", ` ${line}`)).join("\n")}`, 0, 0);
3459
+ return new Text(text.split("\n").map((line) => theme.fg("muted", line)).join("\n"), 0, 0);
3154
3460
  }
3155
3461
  });
3156
3462
  pi.registerTool({
@@ -3205,6 +3511,7 @@ function createExtension(deps = defaultDeps, options = {}) {
3205
3511
  const previousBlocking = tracked?.blocking;
3206
3512
  if (tracked) tracked.blocking = true;
3207
3513
  let completed = false;
3514
+ let resultRun = tracked;
3208
3515
  const blockingWait = params.wait ?? true ? createInterruptibleWait(trackedRunKey(tracked?.supervisorId ?? params.supervisor_id, tracked?.runId ?? params.run_id), signal) : void 0;
3209
3516
  try {
3210
3517
  let result;
@@ -3221,18 +3528,30 @@ function createExtension(deps = defaultDeps, options = {}) {
3221
3528
  result = await resultAfterInterrupt(params, blockingWait.stopped, tracked);
3222
3529
  }
3223
3530
  completed = result.ready;
3531
+ resultRun = reconcileResultRun(tracked, result, params.supervisor_id);
3224
3532
  if (completed) {
3225
- if (tracked) tracked.consumed = true;
3226
- emitAvenorEvent(pi.events, CHANNEL_RUN_TERMINAL, createRunTerminalPayload({
3227
- runId: params.run_id,
3228
- supervisorId: tracked?.supervisorId ?? params.supervisor_id,
3229
- runtimeId: tracked?.runtimeId ?? result.runtime_id,
3230
- label: tracked?.label ?? result.label ?? params.run_id,
3231
- agent: tracked?.agent ?? "unknown",
3232
- status: result.status ?? "done"
3233
- }));
3234
- if (tracked) trackedRuns.delete(trackedRunKey(tracked.supervisorId, tracked.runId));
3235
- } else if (tracked && result.status === "waiting") tracked.permissionNotified = true;
3533
+ const idMatches = trackedRunsById(params.run_id, params.supervisor_id).length;
3534
+ const labelMatches = [...trackedRuns.values()].filter((run) => (params.supervisor_id === void 0 || run.supervisorId === params.supervisor_id) && run.label === params.run_id).length;
3535
+ emitRunTerminal({
3536
+ run: resultRun,
3537
+ runId: idMatches > 1 || idMatches === 0 && labelMatches > 1 ? void 0 : result.run_id ?? params.run_id,
3538
+ supervisorId: params.supervisor_id,
3539
+ runtimeId: resultRun?.runtimeId ?? result.runtime_id,
3540
+ label: resultRun?.label ?? result.label ?? params.run_id,
3541
+ agent: resultRun?.agent ?? "unknown",
3542
+ status: result.status ?? "done",
3543
+ nestedCount: resultRun?.nestedCount,
3544
+ backend: resultRun?.lastStatus?.backend
3545
+ });
3546
+ if (resultRun) {
3547
+ trackedRuns.delete(trackedRunKey(resultRun.supervisorId, resultRun.runId));
3548
+ updateWidget(lastStatusEntries);
3549
+ if (tracked && resultRun !== tracked) {
3550
+ const trackedKey = trackedRunKey(tracked.supervisorId, tracked.runId);
3551
+ if (trackedRuns.get(trackedKey) === tracked) tracked.blocking = previousBlocking ?? false;
3552
+ }
3553
+ } else if (tracked && trackedRuns.get(trackedRunKey(tracked.supervisorId, tracked.runId)) === tracked) tracked.blocking = previousBlocking ?? false;
3554
+ } else if (resultRun && result.status === "waiting") resultRun.permissionNotified = true;
3236
3555
  return {
3237
3556
  content: [{
3238
3557
  type: "text",
@@ -3242,7 +3561,7 @@ function createExtension(deps = defaultDeps, options = {}) {
3242
3561
  };
3243
3562
  } finally {
3244
3563
  blockingWait?.dispose();
3245
- if (!completed && tracked && getTrackedRun(params.run_id, params.supervisor_id) === tracked) tracked.blocking = previousBlocking ?? false;
3564
+ if (!completed && tracked && trackedRuns.get(trackedRunKey(tracked.supervisorId, tracked.runId)) === tracked) tracked.blocking = previousBlocking ?? false;
3246
3565
  }
3247
3566
  },
3248
3567
  renderCall(args, theme, context) {
@@ -3330,7 +3649,7 @@ function createExtension(deps = defaultDeps, options = {}) {
3330
3649
  pi.registerTool({
3331
3650
  name: "avenor_message",
3332
3651
  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.",
3652
+ 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 retained in the contract but no surface can deliver one.",
3334
3653
  parameters: Type.Object({
3335
3654
  action: Type.Union([
3336
3655
  Type.Literal("tell"),
@@ -3410,7 +3729,7 @@ function createExtension(deps = defaultDeps, options = {}) {
3410
3729
  workflow_id: params.workflow_id,
3411
3730
  actor: params.actor,
3412
3731
  reason: params.reason,
3413
- supervisor_id: params.supervisor_id
3732
+ supervisorId: params.supervisor_id
3414
3733
  });
3415
3734
  return {
3416
3735
  content: [{
@@ -3832,4 +4151,4 @@ function createExtension(deps = defaultDeps, options = {}) {
3832
4151
  }
3833
4152
  var src_default = createExtension();
3834
4153
  //#endregion
3835
- export { CHANNEL_POLL_COMPLETED, CHANNEL_POLL_ERROR, CHANNEL_RUN_TERMINAL, buildCompletionText, buildInspectPayload, compactWhitespace, createExtension, createPollCompletedPayload, createPollErrorPayload, createRunTerminalPayload, src_default as default, emitAvenorEvent, isTerminalStatus, onAvenorEvent, renderStatusLines, spawnIdentityMetadata, statusSupervisorId };
4154
+ export { CHANNEL_DELIVERY_ERROR, CHANNEL_POLL_COMPLETED, CHANNEL_POLL_ERROR, CHANNEL_RUN_TERMINAL, buildCompletionText, buildInspectPayload, compactWhitespace, createDeliveryErrorPayload, createExtension, createPollCompletedPayload, createPollErrorPayload, createRunTerminalPayload, src_default as default, emitAvenorEvent, isTerminalStatus, onAvenorEvent, renderStatusLines, spawnIdentityMetadata, statusSupervisorId };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dougbots/avenor-pi",
3
- "version": "0.16.1",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -31,19 +31,20 @@
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsdown",
34
+ "typecheck": "tsc --noEmit",
34
35
  "test": "bun test",
35
36
  "dev": "tsdown --watch"
36
37
  },
37
38
  "dependencies": {
38
- "@dougbots/avenor-core": "^0.16.1"
39
+ "@dougbots/avenor-core": "^0.18.0"
39
40
  },
40
41
  "peerDependencies": {
41
- "@earendil-works/pi-coding-agent": ">=0.74.0",
42
+ "@earendil-works/pi-coding-agent": ">=1.4.0",
42
43
  "@earendil-works/pi-tui": "*"
43
44
  },
44
45
  "devDependencies": {
45
- "@earendil-works/pi-coding-agent": "*",
46
- "@earendil-works/pi-tui": "*",
46
+ "@earendil-works/pi-coding-agent": "^1.4.0",
47
+ "@earendil-works/pi-tui": "^1.4.0",
47
48
  "@types/bun": "^1.3.12",
48
49
  "@types/node": "^25.8.0",
49
50
  "tsdown": "^0.22.0",