talon-agent 3.28.1 → 3.29.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
@@ -297,6 +297,8 @@ GitHub API access via the official GitHub MCP server. Gives the agent access to
297
297
 
298
298
  The token is optional --- defaults to the output of `gh auth token` if the GitHub CLI is authenticated.
299
299
 
300
+ The server image is pinned to a known-good tag and pulled in the background at boot when absent (docker still pulls on first use as the fallback). Override with `"imageTag"` (`"latest"` opts out of pinning); `"autoProvision": false` disables the pre-pull.
301
+
300
302
  ### Long-term Memory
301
303
 
302
304
  Talon supports two long-term memory backends, selected via the unified `memory` section:
@@ -316,14 +318,9 @@ Set `"backend"` to `"mempalace"` (local, vector search + knowledge graph) or `"m
316
318
 
317
319
  Structured long-term memory with vector search. The agent can store, search, and retrieve memories semantically. Integrates with Dream mode for automatic memory consolidation and personal diary entries.
318
320
 
319
- **Requirements:** Python 3.10+ with the `mempalace` package.
321
+ **Requirements:** Python 3.10+ on PATH. Nothing else — Talon provisions its own environment.
320
322
 
321
- ```bash
322
- # Set up a Python environment
323
- python -m venv ~/.talon/mempalace-venv
324
- ~/.talon/mempalace-venv/bin/pip install mempalace # Unix
325
- # or: ~/.talon/mempalace-venv/Scripts/pip install mempalace # Windows
326
- ```
323
+ On first boot Talon creates a venv at `~/.talon/mempalace-venv` and installs the pinned `mempalace` version into it. From then on the venv is **self-maintaining**: version drift against the pin reconciles automatically in the background, a broken install (half-written site-packages, a gutted venv) self-heals at the next start, and one-time palace data migrations (e.g. the ≥3.4 wing-name normalization) are applied exactly once, safely and idempotently. Failed upgrades never take the working install down — the current version keeps serving and the retry backs off.
327
324
 
328
325
  ```json
329
326
  {
@@ -332,13 +329,17 @@ python -m venv ~/.talon/mempalace-venv
332
329
  "backend": "mempalace",
333
330
  "mempalace": {
334
331
  "palacePath": "~/.talon/workspace/palace",
335
- "pythonPath": "~/.talon/mempalace-venv/bin/python"
332
+ "version": "3.8.0",
333
+ "autoUpdate": true,
334
+ "autoProvision": true
336
335
  }
337
336
  }
338
337
  }
339
338
  ```
340
339
 
341
- Both paths are optional --- defaults to `~/.talon/workspace/palace/` and the venv Python respectively.
340
+ Everything is optional --- `palacePath` defaults to `~/.talon/workspace/palace/`, `version` defaults to the built-in pin, and both `auto*` flags default to `true`. Leave `pythonPath` unset to use the managed venv --- its interpreter is `~/.talon/mempalace-venv/bin/python` on Linux/macOS and `~/.talon/mempalace-venv/Scripts/python.exe` on Windows; any other value is treated as operator-managed (see below). `autoProvision` governs creating and healing the venv; `autoUpdate` governs reconciling a working venv to the pin --- they are independent.
341
+
342
+ **Bring your own environment:** point `pythonPath` at any interpreter — a `uv tool` install, pipx, conda, or your own venv — and Talon treats it as operator-managed: it is probed and reported on (`talon doctor` shows the exact upgrade command for your install flavor) but never mutated.
342
343
 
343
344
  #### mem0 backend
344
345
 
@@ -379,6 +380,8 @@ Headless browser automation via the Playwright MCP server. The agent can browse
379
380
 
380
381
  Supported browsers: `chromium` (default), `chrome`, `firefox`, `webkit`, `msedge`.
381
382
 
383
+ For Playwright-managed engines (`chromium`, `firefox`, `webkit`) the browser build is downloaded automatically at boot when missing — version-matched to the bundled `@playwright/mcp`. System channels (`chrome`, `msedge`) and endpoint mode are never touched. `"autoProvision": false` disables the download.
384
+
382
385
  ### Brave Search
383
386
 
384
387
  Web search via the Brave Search MCP server. Replaces the built-in WebSearch/WebFetch tools with higher-quality search results.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "3.28.1",
3
+ "version": "3.29.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
package/src/bootstrap.ts CHANGED
@@ -479,11 +479,9 @@ export async function initBackendAndDispatcher(
479
479
  if (config.mempalace?.enabled) {
480
480
  const { getPlugin } = await import("./core/plugin/index.js");
481
481
  if (getPlugin("mempalace")) {
482
- const { dirs, files: pathFiles } = await import("./util/paths.js");
483
- mempalaceCfg = {
484
- pythonPath: config.mempalace.pythonPath ?? pathFiles.mempalacePython,
485
- palacePath: config.mempalace.palacePath ?? dirs.palace,
486
- };
482
+ const { resolveMempalacePaths } =
483
+ await import("./plugins/mempalace/provision.js");
484
+ mempalaceCfg = resolveMempalacePaths(config.mempalace);
487
485
  } else {
488
486
  log(
489
487
  "mempalace",
@@ -529,5 +527,24 @@ export async function initBackendAndDispatcher(
529
527
  mempalace: Boolean(mempalaceCfg),
530
528
  });
531
529
 
530
+ // Post-/update provisioning report — if the previous process armed one
531
+ // before respawning, tell the chat that asked for the update what the
532
+ // provisioners changed during this boot. Fire-and-forget; the delivery
533
+ // helper retries while frontends finish registering on the cross-send
534
+ // broker and gives up quietly.
535
+ {
536
+ const { deliverPendingProvisionReport } =
537
+ await import("./core/plugin/provision-journal.js");
538
+ const { crossSendHandlers } =
539
+ await import("./core/engine/gateway-actions/cross-send.js");
540
+ void deliverPendingProvisionReport(async (frontend, target, text) => {
541
+ const result = await crossSendHandlers.send_via(
542
+ { frontend, target, text },
543
+ 0,
544
+ );
545
+ return Boolean(result && (result as { ok?: unknown }).ok === true);
546
+ });
547
+ }
548
+
532
549
  return { backend };
533
550
  }
@@ -78,6 +78,18 @@ export interface DoctorConfigSlice {
78
78
  codexApiKey?: string;
79
79
  openaiApiKey?: string;
80
80
  openaiBaseUrl?: string;
81
+ mempalace?: {
82
+ enabled?: boolean;
83
+ pythonPath?: string;
84
+ version?: string;
85
+ };
86
+ playwright?: {
87
+ enabled?: boolean;
88
+ browser?: string;
89
+ endpoint?: string;
90
+ endpointFile?: string;
91
+ };
92
+ github?: { enabled?: boolean; imageTag?: string };
81
93
  }
82
94
 
83
95
  function errorNote(err: unknown): string {
@@ -297,6 +309,34 @@ async function checkClaudeConfiguredModels(
297
309
  return checks;
298
310
  }
299
311
 
312
+ /**
313
+ * Native plugin runtimes (MemPalace's venv, Playwright's browser build,
314
+ * GitHub's Docker image) — the artifacts the provisioners own. The
315
+ * runtime list and each inspection live in the native-runtimes registry
316
+ * (src/core/plugin/native-runtimes.ts); a new native plugin shows up
317
+ * here by registering there. Doctor reads, never mutates: a drifted or
318
+ * missing runtime reports what will fix it (usually "next talon start").
319
+ */
320
+ async function checkPluginRuntimes(
321
+ config: DoctorConfigSlice | undefined,
322
+ ): Promise<DoctorCheck[]> {
323
+ const checks: DoctorCheck[] = [];
324
+ const { NATIVE_RUNTIMES } = await import("./plugin/native-runtimes.js");
325
+ for (const runtime of NATIVE_RUNTIMES) {
326
+ if (!runtime.enabled(config)) continue;
327
+ try {
328
+ checks.push(...(await runtime.inspect(config!)));
329
+ } catch (err) {
330
+ checks.push({
331
+ label: `${runtime.id} runtime check errored`,
332
+ status: "warn",
333
+ detail: errorNote(err),
334
+ });
335
+ }
336
+ }
337
+ return checks;
338
+ }
339
+
300
340
  /** Every backend id doctor knows how to inspect. */
301
341
  const KNOWN_BACKENDS = [
302
342
  "claude",
@@ -632,6 +672,7 @@ export async function collectDoctorReport(opts: {
632
672
  }
633
673
 
634
674
  const native = await checkNativeModules();
675
+ checks.push(...(await checkPluginRuntimes(opts.config)));
635
676
  checks.push(...(await checkBackend(opts.config)));
636
677
 
637
678
  const issues =
@@ -3,26 +3,101 @@
3
3
  * path that re-reads config, tears down, and re-loads everything.
4
4
  */
5
5
 
6
- import { log, logError } from "../../util/log.js";
6
+ import { log, logError, logWarn } from "../../util/log.js";
7
7
  import type { TalonConfig } from "../../util/config.js";
8
8
  import { registry, reloadState } from "./registry.js";
9
+ import type { ProvisionOutcome } from "./provision.js";
10
+ import { NATIVE_RUNTIMES, type NativePluginId } from "./native-runtimes.js";
11
+ import {
12
+ recordProvisionEvents,
13
+ trackBackgroundProvision,
14
+ } from "./provision-journal.js";
9
15
  import {
10
16
  initPluginWithTimeout,
11
17
  loadPlugins,
12
18
  registerPlugin,
13
19
  } from "./loader.js";
14
20
 
21
+ /**
22
+ * Surface a provisioning outcome in the logs and the journal, and fire
23
+ * its background reconcile task (fire-and-forget: the plugin is already
24
+ * serving on whatever the outcome declared usable).
25
+ */
26
+ function reportProvision(
27
+ pluginName: NativePluginId,
28
+ outcome: ProvisionOutcome,
29
+ ): void {
30
+ recordProvisionEvents(pluginName, outcome.actions);
31
+ for (const action of outcome.actions) {
32
+ log(pluginName, `provision: ${action}`);
33
+ }
34
+ for (const warning of outcome.warnings) {
35
+ logWarn(pluginName, `provision: ${warning}`);
36
+ }
37
+ if (outcome.status === "failed" && outcome.error) {
38
+ logError(pluginName, `provision failed: ${outcome.error}`);
39
+ }
40
+ const background = outcome.background;
41
+ if (background) {
42
+ // Tracked so the post-update report waits for it to settle (its
43
+ // actions are the changes worth reporting).
44
+ void trackBackgroundProvision(
45
+ background()
46
+ .then((result) =>
47
+ reportProvision(pluginName, { ...result, background: undefined }),
48
+ )
49
+ .catch((err) =>
50
+ logError(
51
+ pluginName,
52
+ `background provision: ${err instanceof Error ? err.message : err}`,
53
+ ),
54
+ ),
55
+ );
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Provision every enabled native runtime (see native-runtimes.ts) and
61
+ * return the outcomes so plugin construction can read what it got
62
+ * (e.g. the installed version). A provisioner that throws is treated
63
+ * as a failed pass, never a failed boot.
64
+ */
65
+ async function provisionNativeRuntimes(
66
+ config: TalonConfig,
67
+ ): Promise<Map<NativePluginId, ProvisionOutcome>> {
68
+ const outcomes = new Map<NativePluginId, ProvisionOutcome>();
69
+ for (const runtime of NATIVE_RUNTIMES) {
70
+ if (!runtime.enabled(config)) continue;
71
+ try {
72
+ const outcome = await runtime.provision(config);
73
+ outcomes.set(runtime.id, outcome);
74
+ reportProvision(runtime.id, outcome);
75
+ } catch (err) {
76
+ logError(
77
+ runtime.id,
78
+ `provision: ${err instanceof Error ? err.message : err}`,
79
+ );
80
+ }
81
+ }
82
+ return outcomes;
83
+ }
84
+
15
85
  /**
16
86
  * Load built-in plugins (GitHub, MemPalace, mem0, Playwright) based on config flags.
17
87
  * Shared by both bootstrap and hot-reload to avoid duplication.
18
88
  */
19
89
  export async function loadBuiltinPlugins(config: TalonConfig): Promise<void> {
90
+ const provisioned = await provisionNativeRuntimes(config);
91
+
20
92
  const github = config.github;
21
93
  if (github?.enabled) {
22
94
  try {
23
95
  const { createGitHubPlugin } =
24
96
  await import("../../plugins/github/index.js");
25
- const gh = createGitHubPlugin({ token: github.token });
97
+ const gh = createGitHubPlugin({
98
+ token: github.token,
99
+ imageTag: github.imageTag,
100
+ });
26
101
  const ghConfig = github as unknown as Record<string, unknown>;
27
102
  const loaded = registerPlugin(gh, ghConfig);
28
103
  if (loaded) {
@@ -47,14 +122,15 @@ export async function loadBuiltinPlugins(config: TalonConfig): Promise<void> {
47
122
  try {
48
123
  const { createMempalacePlugin } =
49
124
  await import("../../plugins/mempalace/index.js");
50
- const { dirs, files: pf } = await import("../../util/paths.js");
51
- const pythonPath = mempalace.pythonPath ?? pf.mempalacePython;
52
- const palacePath = mempalace.palacePath ?? dirs.palace;
125
+ const { resolveMempalacePaths } =
126
+ await import("../../plugins/mempalace/provision.js");
127
+ const { pythonPath, palacePath } = resolveMempalacePaths(mempalace);
53
128
  const mp = createMempalacePlugin({
54
129
  pythonPath,
55
130
  palacePath,
56
131
  entityLanguages: mempalace.entityLanguages,
57
132
  verbose: mempalace.verbose,
133
+ installedVersion: provisioned.get("mempalace")?.version,
58
134
  });
59
135
  const mpConfig = mempalace as unknown as Record<string, unknown>;
60
136
  const loaded = registerPlugin(mp, mpConfig);
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Single source of truth for native plugin runtimes — the built-in
3
+ * plugins whose MCP servers depend on an artifact Talon doesn't ship
4
+ * (MemPalace's Python venv, Playwright's browser build, GitHub's Docker
5
+ * image).
6
+ *
7
+ * Everything that iterates "the native plugins" — boot provisioning
8
+ * (builtins.ts), doctor inspection (doctor.ts), the provisioning CI —
9
+ * walks this list. Adding a native runtime is one descriptor here plus
10
+ * its provision module; no other call site changes.
11
+ *
12
+ * Plugin modules load lazily inside each method so this module stays
13
+ * import-cheap for consumers that only need the id list.
14
+ */
15
+
16
+ import type { DoctorCheck } from "../doctor.js";
17
+ import type { ProvisionOutcome } from "./provision.js";
18
+ import type { MempalaceSection } from "../../plugins/mempalace/provision.js";
19
+ import type { PlaywrightSection } from "../../plugins/playwright/provision.js";
20
+ import type { GithubSection } from "../../plugins/github/provision.js";
21
+
22
+ const NATIVE_PLUGIN_IDS = ["mempalace", "playwright", "github"] as const;
23
+ export type NativePluginId = (typeof NATIVE_PLUGIN_IDS)[number];
24
+
25
+ /**
26
+ * The config slice native runtimes read. Both TalonConfig and doctor's
27
+ * DoctorConfigSlice satisfy it structurally.
28
+ */
29
+ interface NativeRuntimeConfig {
30
+ mempalace?: ({ enabled?: boolean } & MempalaceSection) | undefined;
31
+ playwright?: ({ enabled?: boolean } & PlaywrightSection) | undefined;
32
+ github?: ({ enabled?: boolean } & GithubSection) | undefined;
33
+ }
34
+
35
+ export interface NativeRuntime {
36
+ id: NativePluginId;
37
+ enabled(config: NativeRuntimeConfig | undefined): boolean;
38
+ /** Install/heal/reconcile the runtime. Only called when enabled. */
39
+ provision(config: NativeRuntimeConfig): Promise<ProvisionOutcome>;
40
+ /** Read-only health report for doctor. Only called when enabled. */
41
+ inspect(config: NativeRuntimeConfig): Promise<DoctorCheck[]>;
42
+ }
43
+
44
+ export const NATIVE_RUNTIMES: readonly NativeRuntime[] = [
45
+ {
46
+ id: "mempalace",
47
+ enabled: (config) => config?.mempalace?.enabled === true,
48
+ provision: async (config) => {
49
+ const { provisionMempalace } =
50
+ await import("../../plugins/mempalace/provision.js");
51
+ return provisionMempalace(config.mempalace ?? {});
52
+ },
53
+ inspect: async (config) => {
54
+ const { inspectMempalace } =
55
+ await import("../../plugins/mempalace/provision.js");
56
+ return inspectMempalace(config.mempalace ?? {});
57
+ },
58
+ },
59
+ {
60
+ id: "playwright",
61
+ enabled: (config) => config?.playwright?.enabled === true,
62
+ provision: async (config) => {
63
+ const { provisionPlaywright } =
64
+ await import("../../plugins/playwright/provision.js");
65
+ return provisionPlaywright(config.playwright ?? {});
66
+ },
67
+ inspect: async (config) => {
68
+ const { inspectPlaywright } =
69
+ await import("../../plugins/playwright/provision.js");
70
+ return inspectPlaywright(config.playwright ?? {});
71
+ },
72
+ },
73
+ {
74
+ id: "github",
75
+ enabled: (config) => config?.github?.enabled === true,
76
+ provision: async (config) => {
77
+ const { provisionGithubMcp } =
78
+ await import("../../plugins/github/provision.js");
79
+ return provisionGithubMcp(config.github ?? {});
80
+ },
81
+ inspect: async (config) => {
82
+ const { inspectGithub } =
83
+ await import("../../plugins/github/provision.js");
84
+ return inspectGithub(config.github ?? {});
85
+ },
86
+ },
87
+ ];
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Provision journal — what the provisioners changed, and the post-restart
3
+ * report that documents it.
4
+ *
5
+ * Two small persisted pieces under ~/.talon/data/:
6
+ *
7
+ * - provision-events.json — a capped rolling log of provisioning
8
+ * mutations ("upgraded mempalace 3.3.5 → 3.8.0", "palace wing-name
9
+ * migration: Migrated 3 drawer(s)."). Every action a provisioner
10
+ * reports lands here, whichever boot or background pass produced it.
11
+ *
12
+ * - provision-report-pending.json — armed by a frontend's /update
13
+ * command just before the respawn. The successor process, once its
14
+ * frontends are serving, reads the marker and messages the chat that
15
+ * asked for the update with whatever provisioning changed during the
16
+ * new boot — closing the loop that the pre-restart reply can't see.
17
+ */
18
+
19
+ import { readFileSync, rmSync, writeFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { dirs } from "../../util/paths.js";
22
+ import { log, logWarn } from "../../util/log.js";
23
+ import type { NativePluginId } from "./native-runtimes.js";
24
+
25
+ interface ProvisionEvent {
26
+ at: string;
27
+ plugin: NativePluginId;
28
+ action: string;
29
+ }
30
+
31
+ interface PendingReport {
32
+ frontend: string;
33
+ target: string;
34
+ since: string;
35
+ }
36
+
37
+ const EVENT_CAP = 50;
38
+ const eventsPath = (): string => join(dirs.data, "provision-events.json");
39
+ const pendingPath = (): string =>
40
+ join(dirs.data, "provision-report-pending.json");
41
+
42
+ function readJson<T>(path: string): T | undefined {
43
+ try {
44
+ return JSON.parse(readFileSync(path, "utf-8")) as T;
45
+ } catch {
46
+ return undefined;
47
+ }
48
+ }
49
+
50
+ /** Append provisioning mutations to the rolling event log. */
51
+ export function recordProvisionEvents(
52
+ plugin: NativePluginId,
53
+ actions: readonly string[],
54
+ ): void {
55
+ if (actions.length === 0) return;
56
+ const existing = readJson<ProvisionEvent[]>(eventsPath());
57
+ const at = new Date().toISOString();
58
+ const events = [
59
+ ...(Array.isArray(existing) ? existing : []),
60
+ ...actions.map((action) => ({ at, plugin, action })),
61
+ ].slice(-EVENT_CAP);
62
+ try {
63
+ writeFileSync(eventsPath(), JSON.stringify(events, null, 2));
64
+ } catch {
65
+ /* the journal is reporting, never load-bearing */
66
+ }
67
+ }
68
+
69
+ /** Events recorded at or after the given ISO timestamp. */
70
+ function provisionEventsSince(sinceIso: string): ProvisionEvent[] {
71
+ const events = readJson<ProvisionEvent[]>(eventsPath());
72
+ if (!Array.isArray(events)) return [];
73
+ return events.filter((e) => e.at >= sinceIso);
74
+ }
75
+
76
+ /**
77
+ * Arm the post-restart report. Called by a frontend's /update handler
78
+ * right before the respawn, with the chat that asked for the update.
79
+ */
80
+ export function armProvisionReport(frontend: string, target: string): void {
81
+ try {
82
+ writeFileSync(
83
+ pendingPath(),
84
+ JSON.stringify({
85
+ frontend,
86
+ target,
87
+ since: new Date().toISOString(),
88
+ } satisfies PendingReport),
89
+ );
90
+ } catch {
91
+ /* losing the report loses a courtesy message, nothing more */
92
+ }
93
+ }
94
+
95
+ const DELIVER_ATTEMPTS = 60;
96
+ const DELIVER_DELAY_MS = 15_000;
97
+
98
+ /**
99
+ * Background reconcile tasks still running (a version upgrade, a docker
100
+ * pull). The post-update report waits for them: their actions are the
101
+ * changes worth reporting, and they settle minutes after boot.
102
+ */
103
+ let backgroundInFlight = 0;
104
+
105
+ /** Track a background provisioning task until it settles (including its journaling). */
106
+ export function trackBackgroundProvision<T>(task: Promise<T>): Promise<T> {
107
+ backgroundInFlight++;
108
+ return task.finally(() => {
109
+ backgroundInFlight--;
110
+ });
111
+ }
112
+
113
+ export function backgroundProvisionInFlight(): number {
114
+ return backgroundInFlight;
115
+ }
116
+
117
+ /**
118
+ * Deliver the pending report, if one is armed and this boot's
119
+ * provisioning changed something. Waits for the boot's background
120
+ * reconcile tasks to settle before reading the journal, so the report
121
+ * covers what they did rather than a snapshot taken while pip was still
122
+ * running, and retries the send because it races frontend registration
123
+ * on the cross-send broker. Bounded (~15 minutes, matching the longest
124
+ * provisioner timeout), then gives up quietly — this is a courtesy
125
+ * message, not state. Returns immediately when nothing is pending and
126
+ * nothing changed.
127
+ */
128
+ export async function deliverPendingProvisionReport(
129
+ send: (frontend: string, target: string, text: string) => Promise<boolean>,
130
+ sleep: (ms: number) => Promise<void> = (ms) =>
131
+ new Promise((r) => setTimeout(r, ms).unref?.()),
132
+ ): Promise<void> {
133
+ const pending = readJson<PendingReport>(pendingPath());
134
+ if (!pending?.frontend || !pending.target || !pending.since) return;
135
+ // Consume the marker up front so a crashy boot can't replay a stale
136
+ // report later; the wait loop below keeps it alive in memory.
137
+ try {
138
+ rmSync(pendingPath(), { force: true });
139
+ } catch {
140
+ /* already gone is fine */
141
+ }
142
+
143
+ for (let attempt = 1; attempt <= DELIVER_ATTEMPTS; attempt++) {
144
+ // Report only once the background work is done — or, on the last
145
+ // attempt, whatever has landed so far rather than nothing.
146
+ const settled = backgroundInFlight === 0 || attempt === DELIVER_ATTEMPTS;
147
+ if (settled) {
148
+ const events = provisionEventsSince(pending.since);
149
+ if (events.length === 0 && backgroundInFlight === 0) return;
150
+ if (events.length > 0) {
151
+ const lines = events.map((e) => `• ${e.plugin}: ${e.action}`);
152
+ const text = `♻️ Back online. Provisioning changes during the update:\n${lines.join("\n")}`;
153
+ try {
154
+ if (await send(pending.frontend, pending.target, text)) {
155
+ log(
156
+ "plugin",
157
+ `Post-update provision report delivered (${events.length} change${events.length === 1 ? "" : "s"})`,
158
+ );
159
+ return;
160
+ }
161
+ } catch {
162
+ /* fall through to retry */
163
+ }
164
+ }
165
+ }
166
+ if (attempt < DELIVER_ATTEMPTS) await sleep(DELIVER_DELAY_MS);
167
+ }
168
+ logWarn(
169
+ "plugin",
170
+ `Post-update provision report not delivered (${pending.frontend} unavailable or provisioning still running)`,
171
+ );
172
+ }