pi-tui-footer 1.2.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.
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Rounded tool frames (merged port) — rounded frames for tool calls and results.
3
+ *
4
+ * Merged 2026-08 from the standalone local extension rounded-tools.ts (a
5
+ * localized copy of npm:pi-rounded-tools@0.1.3, MIT, by OrionPax,
6
+ * https://github.com/orionpax1997/pi-rounded-tools), integrated into pi-tui
7
+ * (based on https://github.com/OldSuns/pi-open-tui v0.3.7, MIT, by
8
+ * OldSun) and gated behind `roundedTools` in pi-tui config
9
+ * (~/.pi/agent/pi-tui.json, default on).
10
+ *
11
+ * Re-registers the built-in tools (read, write, edit, bash, grep, find, ls)
12
+ * with `renderShell: "self"` and wraps each tool call / result in a frame
13
+ * drawn with Unicode rounded-corner characters (╭ ╮ ╰ ╯ ─ │).
14
+ *
15
+ * No left color bar, no theme matching — just the corners.
16
+ * Border color follows `theme.fg("border", …)` so it adapts to your theme.
17
+ *
18
+ * Implementation note: we don't reimplement the inner rendering — we just
19
+ * call the built-in `renderCall` / `renderResult` and wrap the returned
20
+ * component in a `RoundedFrame`. That way bash's preview, read's syntax
21
+ * highlighting, edit's diff stats, etc. all stay exactly as pi ships them.
22
+ *
23
+ * Disabling registers the plain built-in definitions again, restoring stock
24
+ * rendering in the current session without a /reload.
25
+ */
26
+
27
+ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
28
+ import {
29
+ createBashToolDefinition,
30
+ createEditToolDefinition,
31
+ createFindToolDefinition,
32
+ createGrepToolDefinition,
33
+ createLsToolDefinition,
34
+ createReadToolDefinition,
35
+ createWriteToolDefinition,
36
+ } from "@earendil-works/pi-coding-agent";
37
+ import type { Component } from "@earendil-works/pi-tui";
38
+ import { visibleWidth } from "@earendil-works/pi-tui";
39
+
40
+ // ─── Rounded-corner frame ────────────────────────────────────────────────
41
+ //
42
+ // Three modes let call + result stack into one continuous frame:
43
+ // - "closed" → ╭ ── ╮ / │ x │ / ╰ ── ╯ (standalone box)
44
+ // - "open-bottom" → ╭ ── ╮ / │ x │ (top of a stacked frame)
45
+ // - "open-top" → │ x │ / ╰ ── ╯ (bottom of a stacked frame)
46
+
47
+ type FrameMode = "closed" | "open-bottom" | "open-top";
48
+
49
+ class RoundedFrame implements Component {
50
+ constructor(
51
+ private readonly inner: Component,
52
+ private readonly border: (text: string) => string,
53
+ private readonly mode: FrameMode = "closed",
54
+ ) {}
55
+
56
+ invalidate(): void {
57
+ this.inner.invalidate?.();
58
+ this.cachedWidth = undefined;
59
+ this.cachedInnerLines = undefined;
60
+ this.cachedLines = undefined;
61
+ }
62
+
63
+ // pi's built-in tool renderers cache the previously-returned component
64
+ // via `context.lastComponent` and call mutating methods on it
65
+ // (`Text.setText`, `Container.clear`/`addChild`, `invalidate`) so they
66
+ // can re-render in place during streaming. Once we wrap the inner in a
67
+ // RoundedFrame, pi hands our wrapper back to the inner renderer on the
68
+ // next render — but pi's try/catch around `callRenderer(...)` falls
69
+ // back to a plain `createCallFallback()` text node if the inner
70
+ // renderer throws, which would make the border vanish on every
71
+ // Ctrl+O toggle. Forward these methods to the inner so the original
72
+ // renderers work unchanged; calls on a wrapper whose inner lacks the
73
+ // method are no-ops thanks to optional chaining.
74
+ setText(text: string): void {
75
+ (this.inner as any).setText?.(text);
76
+ }
77
+
78
+ clear(): void {
79
+ (this.inner as any).clear?.();
80
+ }
81
+
82
+ addChild(child: any): void {
83
+ (this.inner as any).addChild?.(child);
84
+ }
85
+
86
+ render(width: number): string[] {
87
+ if (width < 4) {
88
+ return this.inner.render(width);
89
+ }
90
+
91
+ const innerWidth = Math.max(1, width - 4); // │ + space + content + space + │
92
+ const innerLines = this.inner.render(innerWidth);
93
+
94
+ // pi's TUI re-renders every transcript component on every render
95
+ // cycle (each keystroke), but built-in components below us (e.g.
96
+ // `Text`, `Box`) cache their rendered line arrays and return the
97
+ // same array reference when content and width haven't changed.
98
+ // Without a cache here, every keystroke re-runs `visibleWidth()`
99
+ // (ANSI parsing) and string building for every line of every
100
+ // framed tool entry — O(N_session) per keypress, visible as input
101
+ // lag on long sessions.
102
+ //
103
+ // Cache key: `width` plus the array reference returned by
104
+ // `inner.render()`. Built-in Text/Box return the same reference on
105
+ // cache hits and a fresh array whenever their content changes
106
+ // (e.g. Ctrl+O expand), so reference equality is a reliable change
107
+ // signal. `invalidate()` (theme changes etc.) clears the cache
108
+ // explicitly, and so does an actual `width` change.
109
+ if (
110
+ this.cachedLines &&
111
+ this.cachedWidth === width &&
112
+ this.cachedInnerLines === innerLines
113
+ ) {
114
+ return this.cachedLines;
115
+ }
116
+
117
+ const horizontal = "─".repeat(Math.max(0, width - 2));
118
+ const side = this.border("│");
119
+
120
+ const out: string[] = [];
121
+
122
+ if (this.mode !== "open-top") {
123
+ out.push(this.border("╭" + horizontal + "╮"));
124
+ }
125
+
126
+ if (innerLines.length > 0) {
127
+ for (const line of innerLines) {
128
+ const vis = visibleWidth(line);
129
+ const pad = " ".repeat(Math.max(0, innerWidth - vis));
130
+ out.push(side + " " + line + pad + " " + side);
131
+ }
132
+ } else if (this.mode === "closed") {
133
+ // Standalone closed frame with no content: draw a placeholder row
134
+ // so the box still has visual presence. Skipped for open-top /
135
+ // open-bottom since they pair with another half-frame and a fake
136
+ // empty row would show up as a stray blank line in the middle.
137
+ out.push(side + " ".repeat(width - 2) + side);
138
+ }
139
+
140
+ if (this.mode !== "open-bottom") {
141
+ out.push(this.border("╰" + horizontal + "╯"));
142
+ }
143
+ this.cachedWidth = width;
144
+ this.cachedInnerLines = innerLines;
145
+ this.cachedLines = out;
146
+ return out;
147
+ }
148
+
149
+ private cachedWidth: number | undefined;
150
+ private cachedInnerLines: string[] | undefined;
151
+ private cachedLines: string[] | undefined;
152
+ }
153
+
154
+ const frame = (
155
+ inner: Component,
156
+ theme: { fg: (color: string, text: string) => string },
157
+ mode: FrameMode = "closed",
158
+ colorKey: string = "border",
159
+ ): Component => new RoundedFrame(inner, (t) => theme.fg(colorKey, t), mode);
160
+
161
+ /**
162
+ * Pick a border color key based on the tool's runtime state.
163
+ *
164
+ * - still running (`context.isPartial`) → warning (yellow)
165
+ * - finished with an error (`context.isError`) → error (red)
166
+ * - finished successfully → border (theme default)
167
+ *
168
+ * Both `renderCall` and `renderResult` go through this picker, so when
169
+ * they stack into one continuous frame, the borders match. pi re-renders
170
+ * the whole component on every state transition (args streaming → done,
171
+ * partial → final), and `context.isPartial` flips accordingly, so call
172
+ * and result always pick the same color at any given moment.
173
+ *
174
+ * Mirrors pi's own 3-state scheme (`toolPendingBg` / `toolSuccessBg` /
175
+ * `toolErrorBg`) — see `theme.js`.
176
+ */
177
+ function borderColorFor(
178
+ context: { isPartial?: boolean; isError?: boolean } | undefined,
179
+ ): string {
180
+ if (context?.isPartial) return "warning";
181
+ if (context?.isError) return "error";
182
+ return "border";
183
+ }
184
+
185
+ // ─── Helpers ─────────────────────────────────────────────────────────────
186
+
187
+ type ToolDef = ToolDefinition<any, any, any>;
188
+
189
+ const EMPTY_COMPONENT: Component = { render: () => [], invalidate: () => {} };
190
+
191
+ /**
192
+ * Wrap a built-in tool definition in rounded frames.
193
+ *
194
+ * We spread the original definition first so we preserve every metadata
195
+ * field (promptSnippet / promptGuidelines / prepareArguments /
196
+ * constrainedSampling / executionMode / …) — only the renderers and
197
+ * `renderShell` are overwritten. This keeps the tool's LLM-facing prompt
198
+ * data intact, edit's argument pre-processing intact, etc.
199
+ */
200
+ function wrapBuiltin(def: ToolDef) {
201
+ return {
202
+ ...def,
203
+ renderShell: "self" as const,
204
+ renderCall: (args: any, theme: any, context: any) => {
205
+ // Hide our (or any) previous wrapper from the inner renderer so it
206
+ // doesn't treat a RoundedFrame as its own lastComponent — that
207
+ // would either throw (`Text.setText` is missing) or nest frames
208
+ // (`inner = previous RoundedFrame` → we re-wrap it). Forcing the
209
+ // inner to allocate fresh costs one Text/Container per render but
210
+ // keeps every render independent and CC-protect against future
211
+ // tool changes.
212
+ const inner: Component = def.renderCall
213
+ ? def.renderCall(args, theme, { ...context, lastComponent: undefined })
214
+ : EMPTY_COMPONENT;
215
+ // During execution pi re-renders every tick (e.g. bash's elapsed
216
+ // timer). Skip the frame then — keeping its borders stable avoids
217
+ // redraw flicker — and only wrap it once the tool settles.
218
+ if (context?.isPartial) return inner;
219
+ return frame(inner, theme, "open-bottom", borderColorFor(context));
220
+ },
221
+ renderResult: (result: any, options: any, theme: any, context: any) => {
222
+ const inner: Component = def.renderResult
223
+ ? def.renderResult(result, options, theme, { ...context, lastComponent: undefined })
224
+ : EMPTY_COMPONENT;
225
+ if (context?.isPartial) return inner;
226
+ return frame(inner, theme, "open-top", borderColorFor(context));
227
+ },
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Register the 7 built-in file/bash tools, either with rounded frames
233
+ * (`enabled`) or with their plain stock definitions (`!enabled`, restores
234
+ * original rendering). Registration refreshes the active tool set in the
235
+ * current session, so toggling the setting takes effect immediately.
236
+ */
237
+ export function registerRoundedTools(pi: ExtensionAPI, enabled: boolean, cwd: string): void {
238
+ const tools: ToolDef[] = [
239
+ createReadToolDefinition(cwd),
240
+ createWriteToolDefinition(cwd),
241
+ createEditToolDefinition(cwd),
242
+ createBashToolDefinition(cwd),
243
+ createGrepToolDefinition(cwd),
244
+ createFindToolDefinition(cwd),
245
+ createLsToolDefinition(cwd),
246
+ ];
247
+
248
+ for (const def of tools) {
249
+ pi.registerTool(enabled ? wrapBuiltin(def) : def);
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Manager class encapsulating rounded tools registration and lifecycle updates.
255
+ */
256
+ export class RoundedToolsManager {
257
+ constructor(private readonly pi: ExtensionAPI) {}
258
+
259
+ /** Register initial tools before session_start with fallback cwd. */
260
+ init(enabled: boolean, cwd = process.cwd()): void {
261
+ registerRoundedTools(this.pi, enabled, cwd);
262
+ }
263
+
264
+ /** Apply tools with current session cwd. */
265
+ apply(enabled: boolean, cwd: string): void {
266
+ registerRoundedTools(this.pi, enabled, cwd);
267
+ }
268
+ }
269
+
@@ -0,0 +1,174 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync, readdirSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { promisify } from "node:util";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+ const VERSION_TIMEOUT_MS = 2500;
8
+
9
+ export interface RuntimeInfo {
10
+ name: string;
11
+ version?: string;
12
+ }
13
+
14
+ interface RuntimeDef {
15
+ name: string;
16
+ files: readonly string[];
17
+ folders?: readonly string[];
18
+ extensions?: readonly string[];
19
+ env?: string;
20
+ versionCommand?: { cmd: string; args?: string[]; pattern?: RegExp };
21
+ }
22
+
23
+ const RUNTIMES: readonly RuntimeDef[] = [
24
+ { name: "bun", files: ["bun.lock", "bun.lockb"], versionCommand: { cmd: "bun", args: ["--version"], pattern: /(\d+\.\d+\.\d+)/ } },
25
+ { name: "nodejs", files: ["package.json", ".nvmrc", ".node-version"], versionCommand: { cmd: "node", args: ["--version"], pattern: /v(\d+\.\d+\.\d+)/ } },
26
+ { name: "rust", files: ["Cargo.toml"], versionCommand: { cmd: "rustc", args: ["--version"], pattern: /rustc\s+(\d+\.\d+\.\d+)/ } },
27
+ { name: "go", files: ["go.mod"], versionCommand: { cmd: "go", args: ["version"], pattern: /go(\d+\.\d+\.\d+)/ } },
28
+ { name: "python", files: ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile", ".python-version"], versionCommand: { cmd: "python3", args: ["--version"], pattern: /Python\s+(\d+\.\d+\.\d+)/ } },
29
+ { name: "ruby", files: ["Gemfile", ".ruby-version"], versionCommand: { cmd: "ruby", args: ["--version"], pattern: /ruby\s+(\d+\.\d+\.\d+)/ } },
30
+ { name: "java", files: ["pom.xml", "build.gradle", "build.gradle.kts", ".java-version"], versionCommand: { cmd: "java", args: ["-version"], pattern: /version\s+"(\d+\.\d+[\.\d]*)"/ } },
31
+ { name: "swift", files: ["Package.swift"], versionCommand: { cmd: "swift", args: ["--version"], pattern: /Swift\s+(\d+\.\d+)/ } },
32
+ { name: "kotlin", files: ["build.gradle.kts", "settings.gradle.kts"] },
33
+ { name: "cpp", files: ["CMakeLists.txt", "Makefile"] },
34
+ { name: "c", files: ["Makefile", "CMakeLists.txt"] },
35
+ { name: "deno", files: ["deno.json", "deno.jsonc", "deno.lock"], versionCommand: { cmd: "deno", args: ["--version"], pattern: /deno\s+(\d+\.\d+\.\d+)/ } },
36
+ { name: "php", files: ["composer.json"], versionCommand: { cmd: "php", args: ["--version"], pattern: /PHP\s+(\d+\.\d+\.\d+)/ } },
37
+ { name: "haskell", files: ["stack.yaml", "cabal.project", ".cabal"], versionCommand: { cmd: "ghc", args: ["--version"], pattern: /(\d+\.\d+\.\d+)/ } },
38
+ { name: "julia", files: ["Project.toml", "Manifest.toml"], versionCommand: { cmd: "julia", args: ["--version"], pattern: /julia\s+(\d+\.\d+\.\d+)/ } },
39
+ { name: "lua", files: ["stylua.toml", ".luarc.json"], versionCommand: { cmd: "lua", args: ["-v"], pattern: /Lua\s+(\d+\.\d+)/ } },
40
+ { name: "elixir", files: ["mix.exs"], versionCommand: { cmd: "elixir", args: ["--version"], pattern: /Elixir\s+(\d+\.\d+\.\d+)/ } },
41
+ { name: "erlang", files: ["rebar.config", "erlang.mk"] },
42
+ { name: "gleam", files: ["gleam.toml"], versionCommand: { cmd: "gleam", args: ["--version"], pattern: /gleam\s+(\d+\.\d+\.\d+)/ } },
43
+ { name: "crystal", files: ["shard.yml"], versionCommand: { cmd: "crystal", args: ["--version"], pattern: /Crystal\s+(\d+\.\d+\.\d+)/ } },
44
+ { name: "dart", files: ["pubspec.yaml"], versionCommand: { cmd: "dart", args: ["--version"], pattern: /Dart\s+SDK\s+version:\s+(\d+\.\d+\.\d+)/ } },
45
+ { name: "nim", files: ["nim.cfg", ".nimble"] },
46
+ { name: "zig", files: ["build.zig"], versionCommand: { cmd: "zig", args: ["version"], pattern: /(\d+\.\d+\.\d+)/ } },
47
+ { name: "ocaml", files: [".opam", "dune", "dune-project"] },
48
+ { name: "clojure", files: ["project.clj", "deps.edn"] },
49
+ { name: "scala", files: ["build.sbt", ".scala", ".metals"] },
50
+ { name: "perl", files: ["Makefile.PL", "cpanfile"] },
51
+ { name: "r", files: [".Rproj", "DESCRIPTION"] },
52
+ { name: "elm", files: ["elm.json"] },
53
+ { name: "haxe", files: ["haxelib.json", ".haxerc"] },
54
+ { name: "vagrant", files: ["Vagrantfile"] },
55
+ { name: "terraform", files: ["main.tf", "variables.tf"], folders: [".terraform"] },
56
+ { name: "helm", files: ["Chart.yaml", "helmfile.yaml"] },
57
+ { name: "solidity", files: [], extensions: [".sol"] },
58
+ { name: "fortran", files: ["fpm.toml"], extensions: [".f", ".f90", ".f95"] },
59
+ { name: "mojo", files: [], extensions: [".mojo"] },
60
+ { name: "red", files: [], extensions: [".red", ".reds"] },
61
+ { name: "raku", files: ["META6.json"], extensions: [".raku", ".rakumod"] },
62
+ { name: "purescript", files: ["spago.dhall", "spago.yaml"] },
63
+ { name: "fennel", files: [], extensions: [".fnl"] },
64
+ { name: "odin", files: [], extensions: [".odin"] },
65
+ { name: "v", files: ["v.mod", "vpkg.json"], extensions: [".v"] },
66
+ { name: "xmake", files: ["xmake.lua"] },
67
+ { name: "gradle", files: ["build.gradle", "build.gradle.kts"], folders: ["gradle"] },
68
+ { name: "maven", files: ["pom.xml"] },
69
+ { name: "cmake", files: ["CMakeLists.txt", "CMakeCache.txt"] },
70
+ { name: "meson", files: ["meson.build"], env: "MESON_DEVENV" },
71
+ { name: "nix", files: ["flake.nix", "shell.nix"], env: "IN_NIX_SHELL" },
72
+ { name: "guix", files: [], env: "GUIX_ENVIRONMENT" },
73
+ { name: "conda", files: [], env: "CONDA_DEFAULT_ENV" },
74
+ { name: "pixi", files: ["pixi.toml", "pixi.lock"], env: "PIXI_ENVIRONMENT_NAME" },
75
+ { name: "spack", files: [], env: "SPACK_ENV" },
76
+ { name: "pulumi", files: ["Pulumi.yaml", "Pulumi.yml"] },
77
+ { name: "typst", files: ["template.typ"], extensions: [".typ"] },
78
+ { name: "buf", files: ["buf.yaml", "buf.gen.yaml", "buf.work.yaml"] },
79
+ { name: "dotnet", files: [".csproj", ".fsproj", "global.json", "Directory.Build.props"] },
80
+ { name: "cobol", files: [], extensions: [".cbl", ".cob"] },
81
+ ];
82
+
83
+ interface CacheEntry {
84
+ fingerprint: string;
85
+ runtime: RuntimeInfo | null;
86
+ }
87
+
88
+ const cache = new Map<string, CacheEntry>();
89
+ const CACHE_MAX = 32;
90
+
91
+ function fingerprint(cwd: string, def: RuntimeDef): string {
92
+ const parts: string[] = [];
93
+ for (const f of def.files) {
94
+ try {
95
+ const stat = statSync(join(cwd, f));
96
+ parts.push(`${f}:${stat.mtimeMs}`);
97
+ } catch { /* ignore */ }
98
+ }
99
+ if (def.extensions || def.folders) {
100
+ try {
101
+ const entries = readdirSync(cwd);
102
+ parts.push(...entries.slice().sort());
103
+ } catch { /* ignore */ }
104
+ }
105
+ if (def.env && process.env[def.env]) {
106
+ parts.push(`${def.env}=${process.env[def.env]}`);
107
+ }
108
+ return parts.join("\0");
109
+ }
110
+
111
+ function matchesDef(cwd: string, def: RuntimeDef): boolean {
112
+ if (def.env && process.env[def.env]) return true;
113
+ if (def.files.some((f) => existsSync(join(cwd, f)))) return true;
114
+ if (def.folders?.some((f) => existsSync(join(cwd, f)))) return true;
115
+ if (def.extensions) {
116
+ try {
117
+ const entries = readdirSync(cwd);
118
+ if (entries.some((e) => def.extensions!.some((ext) => e.endsWith(ext)))) return true;
119
+ } catch { /* ignore */ }
120
+ }
121
+ return false;
122
+ }
123
+
124
+ async function fetchVersion(def: RuntimeDef, cwd: string): Promise<string | undefined> {
125
+ if (!def.versionCommand) return undefined;
126
+ try {
127
+ const { stdout } = await execFileAsync(def.versionCommand.cmd, def.versionCommand.args ?? [], {
128
+ cwd,
129
+ timeout: VERSION_TIMEOUT_MS,
130
+ maxBuffer: 64 * 1024,
131
+ });
132
+ if (def.versionCommand.pattern) {
133
+ const match = stdout.match(def.versionCommand.pattern);
134
+ return match?.[1];
135
+ }
136
+ return stdout.trim() || undefined;
137
+ } catch {
138
+ return undefined;
139
+ }
140
+ }
141
+
142
+ export async function readRuntimeInfo(cwd: string): Promise<RuntimeInfo | null> {
143
+ for (const def of RUNTIMES) {
144
+ if (!matchesDef(cwd, def)) continue;
145
+ const fp = fingerprint(cwd, def);
146
+ const cacheKey = `${cwd}\0${def.name}`;
147
+ const cached = cache.get(cacheKey);
148
+ if (cached && cached.fingerprint === fp) {
149
+ return cached.runtime;
150
+ }
151
+
152
+ for (const key of cache.keys()) {
153
+ if (key === cacheKey || key.startsWith(`${cwd}\0`)) cache.delete(key);
154
+ }
155
+
156
+ const version = await fetchVersion(def, cwd);
157
+ const info: RuntimeInfo = {
158
+ name: def.name,
159
+ version,
160
+ };
161
+ cache.set(cacheKey, { fingerprint: fp, runtime: info });
162
+ while (cache.size > CACHE_MAX) {
163
+ const oldest = cache.keys().next().value;
164
+ if (oldest === undefined) break;
165
+ cache.delete(oldest);
166
+ }
167
+ return info;
168
+ }
169
+ return null;
170
+ }
171
+
172
+ export function clearRuntimeCache(): void {
173
+ cache.clear();
174
+ }
@@ -0,0 +1,43 @@
1
+ export class SessionLifecycle {
2
+ private current = 0;
3
+ private deferred: Array<() => void> = [];
4
+ private shutDown = false;
5
+
6
+ start(): void {
7
+ this.current++;
8
+ this.shutDown = false;
9
+ this.flushDeferred();
10
+ }
11
+
12
+ shutdown(): void {
13
+ this.shutDown = true;
14
+ this.deferred = [];
15
+ }
16
+
17
+ isCurrent(generation?: number): boolean {
18
+ if (this.shutDown) return false;
19
+ if (generation === undefined) return true;
20
+ return generation === this.current;
21
+ }
22
+
23
+ currentGeneration(): number {
24
+ return this.current;
25
+ }
26
+
27
+ defer(fn: () => void): void {
28
+ if (this.shutDown) return;
29
+ this.deferred.push(fn);
30
+ }
31
+
32
+ private flushDeferred(): void {
33
+ const pending = this.deferred;
34
+ this.deferred = [];
35
+ for (const fn of pending) {
36
+ try {
37
+ fn();
38
+ } catch {
39
+ // ponytail: silent fallback — deferred tasks are best-effort
40
+ }
41
+ }
42
+ }
43
+ }